[코드 품질] JaCoCo란 무엇인가 — 테스트가 지나간 길에 형광펜 긋기 (1)
커버리지 80%가 '테스트가 잘 짜여 있다'는 뜻은 아니다. 시험 범위에 형광펜을 긋는 비유로 JaCoCo의 동작 원리, 리포트 읽는 법, Gradle 설정, 흔한 함정까지 정리한다.

시험 범위 중 몇 페이지를 읽었을까 #
시험 전날, 교재가 300페이지다. 공부를 끝내고 형광펜으로 읽은 페이지를 칠했더니 240페이지가 칠해졌다. "80% 공부했네"라고 말할 수 있다. 그런데 이 80%가 말해주는 건 "240페이지를 눈으로 지나갔다"까지다. 내용을 이해했는지, 문제를 풀 수 있는지는 형광펜 색깔로 알 수 없다.
코드 커버리지가 정확히 이 형광펜이다. 테스트를 돌리는 동안 코드의 어느 줄이 실행됐는지 칠해주는 도구이고, 자바에서 가장 널리 쓰이는 게 JaCoCo다. 이 글은 [코드 품질] 시리즈의 1편이다. JaCoCo가 무엇을 칠해주고 무엇은 못 칠해주는지를 정리한다. 2편은 SonarQube, 3편은 둘을 같이 쓰는 방법이다.
커버리지가 말해주는 것, 말해주지 않는 것 #
먼저 반대부터 보자. 아래 테스트는 커버리지 100%를 만든다.
@Test
void discountRuns() {
discount(100, true); // assert가 하나도 없다
}discount가 실행만 되면 모든 줄이 초록색이 된다. 결과가 맞는지는 아무도 확인하지 않는데도 그렇다. 형광펜은 "읽었다"만 기록하지 "이해했다"는 기록하지 않는다.
| 커버리지가 알려주는 것 | 커버리지가 모르는 것 |
|---|---|
| 이 줄이 테스트 중 한 번이라도 실행됐다 | 실행 결과를 assert로 검증했다 |
| 이 분기의 한쪽 경로가 걸렸다 | 경계값과 예외 입력을 시도했다 |
| 아예 손대지 않은 코드가 어디다 | 테스트 자체가 의미 있는 시나리오인가 |
그래서 커버리지는 "잘 테스트했다"의 증거가 아니라 "여기는 확실히 테스트 안 했다"의 증거로 쓰는 게 맞다. 낮은 숫자는 정보가 되고, 높은 숫자는 정보가 되지 못한다.
JaCoCo는 어떻게 줄을 칠하는가 #
JaCoCo는 Java Code Coverage의 줄임말이다. 건물 순찰에 빗대면 이해가 쉽다. 경비원이 순찰을 돌 때 건물 곳곳에 체크 포인트(NFC 태그)가 붙어 있고, 지나가면서 하나씩 찍는다. 순찰이 끝나면 찍힌 태그 목록과 건물 도면을 대조해서 "3층 복도는 안 돌았네"를 알아낸다.
JaCoCo에서 태그가 probe, 경비원은 테스트, 도면은 컴파일된 .class 파일이다. 흐름은 아래와 같다.
핵심은 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의 가장 기본 단위 |
| Branches | if, switch 같은 분기의 갈래 | C1 (분기 커버리지). 갈래마다 한 칸 |
| Lines | 소스 줄 | 한 줄에서 명령어가 하나라도 실행되면 실행된 줄로 센다 |
| Methods | 메서드 | 명령어가 하나라도 실행되면 실행된 메서드 |
| Classes | 클래스 | 메서드가 하나라도 실행되면 실행된 클래스 |
| Cyclomatic Complexity | 독립 경로 수 | 복잡도 자체를 세고, 못 밟은 경로 수를 Missed로 보여준다 |
여기서 헷갈리는 게 Lines와 Branches의 차이다. 예시로 보자.
int discount(int price, boolean vip) {
int d = 0;
if (vip) d = 10;
return price - d;
}테스트가 discount(100, true) 하나뿐이라고 하자. 어떻게 보일까.
네 줄 전부 실행됐으니 Lines는 100%다. 그런데 if (vip)의 false 쪽은 한 번도 안 지나갔으니 Branches는 1/2, 50%다. 줄 커버리지만 보면 "다 테스트했다"고 착각하기 딱 좋은 코드다. vip가 false일 때 할인이 0원이어야 한다는 검증은 존재하지 않는다. 숫자를 하나만 본다면 Branches를 보는 쪽이 낫다.
HTML 리포트 읽는 법 #
jacocoTestReport를 돌리면 build/reports/jacoco/test/html/index.html이 생긴다. 열면 패키지, 클래스 순으로 표가 나오고, 클래스를 클릭하면 소스가 칠해져서 나온다.
색 규칙은 단순하다.
- 초록: 그 줄의 명령어가 전부 실행됐다
- 노랑: 일부만 실행됐다. 분기 한쪽만 걸린 경우가 대표적이다
- 빨강: 한 번도 실행되지 않았다
- 마름모: 분기 지점. 마우스를 올리면 "1 of 2 branches missed" 같은 문구가 뜬다
표의 막대도 같은 색이다. 빨간 부분이 길수록 안 밟은 코드가 많다는 뜻이다. 리포트를 열면 전체 퍼센트보다 노란 줄과 빨간 줄이 어디에 몰려 있는지를 먼저 보는 게 좋다. 핵심 비즈니스 로직에 빨강이 있으면 문제고, getter에 빨강이 있으면 사실 상관없다.
Gradle에 붙이기 #
여기서부터는 설정이다. Kotlin DSL 기준이다.
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은 리포트가 아니라 검문소다. 규칙을 못 맞추면 빌드가 실패한다.
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 클래스는 더 그렇다.
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이 한다. 골은 세 개만 기억하면 된다.
<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에 한 줄을 넣으면 해결된다.
config.stopBubbling = true
lombok.addLombokGeneratedAnnotation = trueLombok이 생성 코드에 @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와 어떻게 역할이 갈리는지 이어서 정리한다.