RicoCheese기술 뉴스와 기록
← 목록으로
개발2026-10-1515분

[코드 품질] JaCoCo란 무엇인가 — 테스트가 지나간 길에 형광펜 긋기 (1)

커버리지 80%가 '테스트가 잘 짜여 있다'는 뜻은 아니다. 시험 범위에 형광펜을 긋는 비유로 JaCoCo의 동작 원리, 리포트 읽는 법, Gradle 설정, 흔한 함정까지 정리한다.

#jacoco#java#testing#gradle#code-coverage
Colorful source code displayed on a computer screen

시험 범위 중 몇 페이지를 읽었을까 #

시험 전날, 교재가 300페이지다. 공부를 끝내고 형광펜으로 읽은 페이지를 칠했더니 240페이지가 칠해졌다. "80% 공부했네"라고 말할 수 있다. 그런데 이 80%가 말해주는 건 "240페이지를 눈으로 지나갔다"까지다. 내용을 이해했는지, 문제를 풀 수 있는지는 형광펜 색깔로 알 수 없다.

코드 커버리지가 정확히 이 형광펜이다. 테스트를 돌리는 동안 코드의 어느 줄이 실행됐는지 칠해주는 도구이고, 자바에서 가장 널리 쓰이는 게 JaCoCo다. 이 글은 [코드 품질] 시리즈의 1편이다. JaCoCo가 무엇을 칠해주고 무엇은 못 칠해주는지를 정리한다. 2편은 SonarQube, 3편은 둘을 같이 쓰는 방법이다.

커버리지가 말해주는 것, 말해주지 않는 것 #

먼저 반대부터 보자. 아래 테스트는 커버리지 100%를 만든다.

src/test/java/example/PriceTest.java
java
@Test
void discountRuns() {
    discount(100, true);   // assert가 하나도 없다
}

discount가 실행만 되면 모든 줄이 초록색이 된다. 결과가 맞는지는 아무도 확인하지 않는데도 그렇다. 형광펜은 "읽었다"만 기록하지 "이해했다"는 기록하지 않는다.

커버리지가 알려주는 것커버리지가 모르는 것
이 줄이 테스트 중 한 번이라도 실행됐다실행 결과를 assert로 검증했다
이 분기의 한쪽 경로가 걸렸다경계값과 예외 입력을 시도했다
아예 손대지 않은 코드가 어디다테스트 자체가 의미 있는 시나리오인가

그래서 커버리지는 "잘 테스트했다"의 증거가 아니라 "여기는 확실히 테스트 안 했다"의 증거로 쓰는 게 맞다. 낮은 숫자는 정보가 되고, 높은 숫자는 정보가 되지 못한다.

JaCoCo는 어떻게 줄을 칠하는가 #

JaCoCo는 Java Code Coverage의 줄임말이다. 건물 순찰에 빗대면 이해가 쉽다. 경비원이 순찰을 돌 때 건물 곳곳에 체크 포인트(NFC 태그)가 붙어 있고, 지나가면서 하나씩 찍는다. 순찰이 끝나면 찍힌 태그 목록과 건물 도면을 대조해서 "3층 복도는 안 돌았네"를 알아낸다.

JaCoCo에서 태그가 probe, 경비원은 테스트, 도면은 컴파일된 .class 파일이다. 흐름은 아래와 같다.

JaCoCo 에이전트가 JVM 시작 시 붙어 클래스 로딩 때 probe를 삽입하고, 테스트가 지나간 probe를 jacoco.exec에 저장한 뒤 리포트를 만드는 5단계 흐름

핵심은 Java agent다. JVM을 띄울 때 -javaagent:jacocoagent.jar 옵션을 붙이면, 클래스가 로딩되는 순간 JaCoCo가 바이트코드를 가로채서 중간중간 probe를 끼워 넣는다. 소스를 고치는 것도, 미리 컴파일 결과물을 바꾸는 것도 아니고 메모리에 올라가는 시점에 바꾼다. 그래서 on-the-fly instrumentation이라고 부른다. probe는 클래스마다 boolean 배열 하나에 담기는 단순한 플래그이고, 해당 위치를 지나가면 true로 바뀐다.

테스트가 끝나 JVM이 종료되면 이 플래그들이 jacoco.exec이라는 바이너리 파일로 저장된다. Gradle 기본 경로는 build/jacoco/test.exec이다. 이 파일에는 "어떤 클래스의 어느 probe가 밟혔다"만 있고 소스 정보는 없다. 리포트를 만들 때 exec와 .class 파일(필요하면 소스)을 대조해서 줄 단위로 색을 입힌다.

에이전트를 못 붙이는 환경을 위한 대안도 있다. offline instrumentation은 테스트 전에 .class 파일 자체에 probe를 미리 심어 두는 방식이다. 커스텀 클래스로더가 바이트코드를 변조하거나 JVM 옵션을 줄 수 없는 환경에서 쓰는 선택지다. 일반적인 스프링 프로젝트라면 agent 방식으로 충분하다.

숫자는 여섯 가지다 #

리포트에는 퍼센트가 여러 개 뜬다. 각각 "무엇을 한 칸으로 세는가"가 다르다.

카운터세는 단위비고
Instructions바이트코드 명령어흔히 C0 (구문 커버리지)에 가깝다고 설명한다. JaCoCo의 가장 기본 단위
Branchesif, switch 같은 분기의 갈래C1 (분기 커버리지). 갈래마다 한 칸
Lines소스 줄한 줄에서 명령어가 하나라도 실행되면 실행된 줄로 센다
Methods메서드명령어가 하나라도 실행되면 실행된 메서드
Classes클래스메서드가 하나라도 실행되면 실행된 클래스
Cyclomatic Complexity독립 경로 수복잡도 자체를 세고, 못 밟은 경로 수를 Missed로 보여준다

여기서 헷갈리는 게 Lines와 Branches의 차이다. 예시로 보자.

src/main/java/example/PriceService.java
java
int discount(int price, boolean vip) {
    int d = 0;
    if (vip) d = 10;
    return price - d;
}

테스트가 discount(100, true) 하나뿐이라고 하자. 어떻게 보일까.

vip가 true인 테스트 하나만 돌렸을 때 모든 줄은 초록으로 칠해져 라인 커버리지 100퍼센트이지만, if 줄에는 노란 마름모가 떠서 브랜치 커버리지는 50퍼센트로 나온다

네 줄 전부 실행됐으니 Lines는 100%다. 그런데 if (vip)의 false 쪽은 한 번도 안 지나갔으니 Branches는 1/2, 50%다. 줄 커버리지만 보면 "다 테스트했다"고 착각하기 딱 좋은 코드다. vip가 false일 때 할인이 0원이어야 한다는 검증은 존재하지 않는다. 숫자를 하나만 본다면 Branches를 보는 쪽이 낫다.

HTML 리포트 읽는 법 #

jacocoTestReport를 돌리면 build/reports/jacoco/test/html/index.html이 생긴다. 열면 패키지, 클래스 순으로 표가 나오고, 클래스를 클릭하면 소스가 칠해져서 나온다.

JaCoCo HTML 리포트 모형. 클래스별 Instructions 막대와 Branches 비율 표, 그리고 초록, 노랑, 빨강으로 칠해진 소스 줄과 분기 마름모의 의미 범례

색 규칙은 단순하다.

  • 초록: 그 줄의 명령어가 전부 실행됐다
  • 노랑: 일부만 실행됐다. 분기 한쪽만 걸린 경우가 대표적이다
  • 빨강: 한 번도 실행되지 않았다
  • 마름모: 분기 지점. 마우스를 올리면 "1 of 2 branches missed" 같은 문구가 뜬다

표의 막대도 같은 색이다. 빨간 부분이 길수록 안 밟은 코드가 많다는 뜻이다. 리포트를 열면 전체 퍼센트보다 노란 줄과 빨간 줄이 어디에 몰려 있는지를 먼저 보는 게 좋다. 핵심 비즈니스 로직에 빨강이 있으면 문제고, getter에 빨강이 있으면 사실 상관없다.

Gradle에 붙이기 #

여기서부터는 설정이다. Kotlin DSL 기준이다.

build.gradle.kts
kotlin
plugins {
    java
    jacoco
}

jacoco {
    // toolVersion = "0.8.x"  // JDK 버전에 맞는 JaCoCo가 필요할 때만 명시
}

tasks.test {
    useJUnitPlatform()
    finalizedBy(tasks.jacocoTestReport)   // 테스트 후 리포트 자동 생성
}

tasks.jacocoTestReport {
    dependsOn(tasks.test)
    reports {
        html.required.set(true)
        xml.required.set(true)   // SonarQube가 읽는 형식, 3편에서 쓴다
    }
}

jacoco 플러그인을 적용하면 테스트 태스크에 agent가 자동으로 붙는다. JVM 옵션을 직접 만질 일은 없다. xml을 켜 둔 건 SonarQube 때문이다.

기준 미달이면 빌드 실패시키기 #

jacocoTestCoverageVerification은 리포트가 아니라 검문소다. 규칙을 못 맞추면 빌드가 실패한다.

build.gradle.kts
kotlin
tasks.jacocoTestCoverageVerification {
    violationRules {
        rule {
            limit {
                counter = "LINE"
                value = "COVEREDRATIO"
                minimum = "0.80".toBigDecimal()
            }
            limit {
                counter = "BRANCH"
                value = "COVEREDRATIO"
                minimum = "0.70".toBigDecimal()
            }
        }
    }
}

tasks.check {
    dependsOn(tasks.jacocoTestCoverageVerification)
}

counter에는 앞서 본 INSTRUCTION, BRANCH, LINE, COMPLEXITY, METHOD, CLASS가 들어가고, value는 COVEREDRATIO, MISSEDCOUNT 등이다. element를 CLASS나 PACKAGE로 바꾸면 전체 합계 대신 클래스별로 기준을 건다. 다만 클래스 단위로 80%를 강제하면 작은 클래스 하나에 빌드가 막히기 쉬우니 처음에는 BUNDLE(기본값) 단위로 시작하길 권한다.

DTO, 설정, 생성 코드는 빼자 #

단순 데이터 클래스와 설정 클래스는 테스트할 로직이 없다. 분모에 들어가면 숫자만 왜곡된다. QueryDSL이 만든 Q 클래스는 더 그렇다.

build.gradle.kts
kotlin
val coverageExcludes = listOf(
    "**/dto/**",
    "**/config/**",
    "**/*Application.class",
    "**/Q*.class"          // QueryDSL 생성 코드
)

tasks.jacocoTestReport {
    classDirectories.setFrom(
        files(classDirectories.files.map {
            fileTree(it) { exclude(coverageExcludes) }
        })
    )
}

tasks.jacocoTestCoverageVerification {
    classDirectories.setFrom(
        files(classDirectories.files.map {
            fileTree(it) { exclude(coverageExcludes) }
        })
    )
}

리포트와 검증 양쪽 모두에 적용해야 한다. 한쪽만 걸면 리포트는 깨끗한데 검증에서 떨어지는 어색한 상황이 생긴다. **/Q*.class는 이름이 Q로 시작하는 다른 클래스도 삼키니, 프로젝트에 맞춰 패키지 경로를 좁히는 편이 안전하다.

Maven이라면 #

같은 일을 jacoco-maven-plugin이 한다. 골은 세 개만 기억하면 된다.

pom.xml
xml
<plugin>
  <groupId>org.jacoco</groupId>
  <artifactId>jacoco-maven-plugin</artifactId>
  <executions>
    <execution>
      <goals><goal>prepare-agent</goal></goals>   <!-- 테스트 JVM에 agent 장착 -->
    </execution>
    <execution>
      <id>report</id>
      <phase>test</phase>
      <goals><goal>report</goal></goals>          <!-- 리포트 생성 -->
    </execution>
    <execution>
      <id>check</id>
      <goals><goal>check</goal></goals>           <!-- 기준 검증 (rules 설정) -->
    </execution>
  </executions>
</plugin>

prepare-agent가 Gradle 플러그인의 "agent 자동 장착"에 해당한다. 규칙은 check 실행에 configuration/rules로 지정한다.

흔한 함정 #

1. 100%에 집착하기. 마지막 10%는 getter, 예외 처리, 도달 불가능한 방어 코드다. 이걸 채우려고 assert 없는 테스트를 쓰기 시작하면 앞서 본 "읽기만 한 형광펜"이 된다. 숫자가 목표가 되는 순간 지표로서 쓸모가 없어진다. 신규 코드에만 기준을 걸거나 80% 근처를 하한선으로 두는 쪽이 현실적이다. 이 수치는 팀마다 다르고 정답이 있는 영역도 아니다.

2. Lombok이 만든 코드가 빨갛게 나온다. @Getter, @Builder 등이 만든 메서드는 소스에 없는데 바이트코드에는 있어서, 안 밟은 코드로 잡힌다. 프로젝트 루트의 lombok.config에 한 줄을 넣으면 해결된다.

lombok.config
properties
config.stopBubbling = true
lombok.addLombokGeneratedAnnotation = true

Lombok이 생성 코드에 @Generated를 붙여주고, JaCoCo(0.8.0 이상)는 이 어노테이션이 붙은 코드를 집계에서 자동 제외한다.

3. 멀티 모듈에서 모듈별 숫자만 나온다. 모듈마다 exec와 리포트가 따로 생기니 전체 숫자가 안 보인다. Gradle에서는 jacoco-report-aggregation 플러그인(Gradle 7.4+)이나 모든 모듈의 exec를 모으는 JacocoReport 태스크를 직접 만들어 합친다. Maven은 report-aggregate 골이 있다. 이 부분은 3편에서 실제 구성으로 다시 다룬다.

4. 테스트를 안 돌리고 리포트만 본다. jacocoTestReport는 exec가 없으면 리포트를 못 만들거나 오래된 exec를 쓴다. dependsOn(tasks.test)를 빼먹지 말자.

정리하면 #

JaCoCo는 테스트가 지나간 길에 형광펜을 긋는 도구다. agent가 클래스 로딩 때 probe를 심으면 테스트가 밟은 자리가 jacoco.exec에 기록되고, 리포트가 그걸 줄과 분기에 칠해준다. 퍼센트 하나가 아니라 Branches와 빨간 줄의 위치를 보는 게 요점이다.

그런데 JaCoCo는 "얼마나 실행했나"만 답한다. 코드가 읽기 어렵게 짜였는지, 중복이 많은지, 보안 취약점이 있는지는 모른다. 그 빈자리를 채우는 도구가 SonarQube다. 다음 편에서는 SonarQube가 무엇을 보고, JaCoCo와 어떻게 역할이 갈리는지 이어서 정리한다.

댓글GitHub Discussions