코드 한 줄의 기록

Java 코드 커버리지 메트릭, 진짜 알고 사용하고 있나요? 본문

JAVA

Java 코드 커버리지 메트릭, 진짜 알고 사용하고 있나요?

CodeByJin 2025. 12. 21. 16:11
반응형

지난해 팀 프로젝트를 맡게 되면서 처음으로 코드 커버리지(Code Coverage)라는 개념을 제대로 마주쳤다. SonarQube 대시보드에 커다란 빨간 숫자 35%가 표시되어 있었고, 리더는 "최소한 80%까지는 올려야 한다"고 말했다. 나는 자연스럽게 이것이 진리인 줄 알고 테스트를 쓰기 시작했다. 하지만 일 년이 지난 지금, 나는 깨달았다. 커버리지 숫자를 맹목적으로 따라가는 것만큼 위험한 일이 없다는 것을 말이다.

Java 개발자라면 누구나 JaCoCo(Java Code Coverage)를 들어봤을 것이다. 프로젝트에 플러그인 몇 줄을 추가하면 어느 코드가 테스트되고 어느 코드가 테스트되지 않았는지를 색깔로 표현해주는 마법 같은 도구다. 그런데 정말 그 숫자가 우리 코드의 품질을 보장할까? 80% 커버리지로 정말 안전한 코드일까? 아니면 우리는 가짜 안심 속에서 취약한 코드를 배포하고 있는 건 아닐까?

이 글은 단순히 "커버리지 도구 사용법"을 설명하는 글이 아니다. 내가 실제로 경험한 사례들과 함께, 코드 커버리지의 본질적인 한계를 직시하고, 어떻게 올바르게 활용해야 하는지를 깊이 있게 다룰 예정이다.

코드 커버리지란 정확히 무엇인가?

먼저 용어를 명확히 하자. 개발자들은 종종 코드 커버리지와 테스트 커버리지를 같은 것으로 생각하지만, 이 둘은 완전히 다른 개념이다.

코드 커버리지(Code Coverage)는 말 그대로 얼마나 많은 코드가 테스트에 의해 실행되었는가를 측정하는 메트릭이다. "테스트가 실행되었는가"에만 초점을 맞춘다. 반면 테스트 커버리지(Test Coverage)는 비즈니스 요구사항, 기능 요구사항, 사용자 시나리오가 얼마나 잘 테스트되었는지를 측정한다. "테스트가 제대로 검증했는가"에 초점을 맞춘다.

Java 생태계에서 코드 커버리지를 측정할 때 우리가 얘기하는 것은 대부분 이 네 가지다.

Statement Coverage (라인 커버리지)

가장 기본적인 메트릭이다. 소스 코드의 각 라인이 최소 한 번은 실행되었는가를 측정한다.

public int calculateDiscount(int amount, boolean isPremium) {
    if (isPremium) {
        return amount * 10 / 100;  // 라인 1

    } else {
        return amount * 5 / 100;   // 라인 2
    }
}

이 메서드의 statement coverage는 isPremium이 true인 경우와 false인 경우 모두 테스트해야 100%가 된다. 하나만 테스트하면 50%다.


Branch Coverage (분기 커버리지)

모든 조건문의 결과(true/false)가 최소 한 번은 실행되었는가를 측정한다. statement coverage와 겹치는 부분이 있지만, 더 정교하다.

public String validateUser(String username, String password, boolean isAdmin) {
    if (username != null && password.length() > 8) {  // 분기 1, 2
        if (isAdmin) {  // 분기 3
            return "Admin logged in";

        } else {
            return "User logged in";
        }

    } else {
        return "Invalid credentials";
    }
}

이 메서드의 모든 분기를 커버하려면 username이 null일 때, null이 아닐 때, password가 8자 이상일 때, 이하일 때, isAdmin이 true일 때, false일 때를 모두 테스트해야 한다.

Method Coverage (메서드 커버리지)

프로젝트의 모든 메서드 중 몇 개가 최소 한 번은 호출되었는가를 측정한다. 메서드 단위로 빠르게 파악할 때 유용하다.

Condition Coverage (조건 커버리지)

각 boolean 조건이 true와 false 모두로 평가되었는가를 측정한다. branch coverage와 유사하지만 더 세부적이다.

Java에서 이 메트릭들을 측정하는 주요 도구는 JaCoCo(Java Code Coverage)다. Cobertura나 EMMA 같은 레거시 도구들이 있지만, 현대적인 Java 프로젝트는 거의 모두 JaCoCo를 사용한다. JaCoCo는 바이트코드를 실시간으로 계측(instrumentation)하는 방식으로 작동하므로, 소스 코드 수정 없이 커버리지를 측정할 수 있다.

왜 우리는 80% 커버리지를 신봉하는가?

업계에서 널리 퍼진 "80% 커버리지 마법의 숫자"가 있다. 어디서 비롯된 것일까?

내가 조사한 결과, 놀랍게도 이 80% 수치에 대한 과학적 근거는 거의 없다. 이것은 경험주의(empiricism)에서 비롯된 업계 관례일 뿐이다. 많은 회사들이 "일반적으로 80% 정도면 충분하다"고 말하고, 그것을 따라 다른 회사들도 80%를 목표로 삼기 시작했다. 마치 새로운 시작 직원에게 "우리 팀의 코딩 스타일은 이거야"라고 알려주는 것처럼 말이다.

하지만 실제로는

  • 60%는 "허용 가능함"
  • 75%는 "칭찬할 만함"
  • 90% 이상은 "모범적임"

이렇게 더 세분화된 기준이 있었다. 그런데 어느 순간부터 80%가 표준이 되었고, 이제는 이것을 최소 기준으로 강제하는 조직들이 많다.

문제는 이 기준이 모든 코드에 균등하게 적용된다는 것이다. 신용카드 검증 로직과 다크 모드 토글 기능이 같은 80% 기준을 적용받는다. 금융, 의료, 항공 같은 안전이 중요한 시스템에서는 90% 이상, 심지어 100% 커버리지가 필요할 수도 있다. 하지만 대부분의 웹 애플리케이션에서는 그럴 필요가 없다.

이것이 나의 첫 번째 통찰이었다: 커버리지 목표는 절대적이지 않으며, 코드의 중요도에 따라 달라져야 한다.

높은 커버리지의 함정: 보안의 착각

이제 핵심으로 들어가자. 100% 커버리지를 달성했다고 해서 정말 안전한 코드일까?

절대 그렇지 않다.

경계값 테스트의 미명에서의 실패

public void validateAge(int age) {
    if (age >= 18) {
        System.out.println("성인입니다");

    } else {
        System.out.println("미성년자입니다");
    }
}

이 메서드로 100% statement coverage를 달성하려면?

@Test
public void testAdult() {
    validateAge(19);  // ✓ if 블록 실행됨
}

@Test
public void testMinor() {
    validateAge(17);  // ✓ else 블록 실행됨
}

완벽하게 보인다. 하지만 우리는 경계값 18을 테스트하지 않았다. age == 18일 때를 시뮬레이션하지 않은 것이다. 만약 개발자가 부주의로 >= 대신 >를 사용했다면? 테스트는 여전히 통과하고, 18세인 사람이 미성년자로 분류되는 버그가 프로덕션으로 들어간다.


이것이 바로 100% coverage 앞에서도 거짓 안심이 생기는 이유다. coverage 수치는 "코드가 실행되었는가"만 보여줄 뿐, "코드가 올바르게 작동했는가"는 보여주지 않는다.

Assertion 없는 테스트의 진짜 공포

더 악랄한 시나리오가 있다.

@Test
public void testCalculation() {
    Calculator calc = new Calculator();
    calc.add(5, 3);  // 메서드만 호출, 반환값 검증 없음
}

이 테스트는 add() 메서드의 모든 코드 라인을 실행하고, 따라서 100% statement coverage를 달성한다. 하지만 정말로 검증한 것이 없다. 메서드가 올바른 값을 반환하는지 확인하지 않은 것이다.


한 팀이 coverage 목표 달성을 위해 의식적으로 또는 무의식적으로 이런 식의 테스트를 작성한다면? 코드는 "테스트됨"으로 표시되지만, 실제로는 시간 폭탄을 안은 것이나 다름없다.

이것이 Martin Fowler가 10년 이상 전에 경고했던 문제다. 그리고 지금도 여전히 일어나고 있다.

데드코드의 소리 없는 위험

public String processPayment(double amount) {
    if (amount <= 0) {
        return "Invalid amount";
    }

    // 레거시 코드 - 절대 실행되지 않음
    if (amount > 1000000) {
        // 누군가 이전에 작성했지만 지금은 deprecated된 로직
        return "Contact support for large transactions";
    }

    return processNormal(amount);
}

이 코드가 프로덕션에서 실행되지 않는다면? 커버리지는 그 부분을 빨간색으로 표시할 것이다. 하지만 많은 개발자들이 "음, 어쨌든 커버리지가 낮으니까 이건 중요하지 않을 거야"라고 생각하고 넘어간다.

실제로는 이 데드코드가 버그를 숨기고 있을 수도 있다. 혹은 다른 개발자가 나중에 이 코드를 리팩토링하면서 실수로 되살려낼 수도 있다.

뮤테이션 테스팅: 커버리지의 진정한 대안

이 지점에서 나는 "그러면 우리는 뭘 해야 하나?"라는 질문을 던지게 됐다.

나의 답은 뮤테이션 테스팅(Mutation Testing)이다.
뮤테이션 테스팅의 개념은 간단하지만 강력하다: 코드를 일부러 변조(mutation)시킨 후, 테스트 스위트가 그 변조를 감지할 수 있는지를 본다.

예를 들어, 다음 코드가 있다고 하자.

public int calculateTax(int salary, boolean isResident) {
    int tax = 0;
    if (isResident) {
        tax = salary * 20 / 100;

    } else {
        tax = salary * 35 / 100;
    }

    return tax;
}

뮤테이션 테스팅 도구(Java에서는 주로 PIT - Pitest를 사용)는 이 코드를 다음과 같이 변조한다.

// Mutation 1: 20을 19로 변경
tax = salary * 19 / 100;

// Mutation 2: >=를 >로 변경
if (isResident) { ... }

// Mutation 3: 반환값을 0으로 변경
return 0;

// ... 더 많은 변조들

각 변조에 대해 테스트 스위트를 실행한다. 만약 테스트가 "Mutation 1: 20을 19로 변경"을 감지했다면(즉, 어떤 테스트가 실패했다면), 그 뮤턴트는 "killed"되었다고 말한다. 만약 감지하지 못했다면, "survived"했다고 말한다.

뮤테이션 점수는 다음과 같이 계산된다.

뮤테이션 점수 = (Killed Mutants / Total Mutants) × 100

이것은 statement coverage보다 훨씬 더 정교한 메트릭이다. 왜냐하면 이것은 "당신의 테스트가 정말 버그를 찾을 수 있는가"를 측정하기 때문이다.

Maven 프로젝트에서 PIT를 사용하는 방법은 간단하다.

<plugin>
    <groupId>org.pitest</groupId>
    <artifactId>pitest-maven</artifactId>
    <version>1.14.2</version>
</plugin>

그리고 실행

mvn org.pitest:pitest-maven:mutationCoverage

생성된 HTML 보고서에서 녹색(killed mutants)과 빨간색(survived mutants) 라인을 볼 수 있다.

PIT의 장점과 단점

장점

  • 테스트 스위트의 실질적 품질을 측정한다
  • 100% statement coverage도 놓치는 버그들을 감지할 수 있다
  • 테스트를 작성하는 개발자에게 더 깊은 사고를 강요한다

단점

  • 매우 느리다. 큰 프로젝트에서는 뮤테이션 생성, 컴파일, 테스트 실행 등이 엄청난 시간이 걸린다
  • 거짓 양성(false positive)이 발생할 수 있다. 예를 들어 H2 인메모리 데이터베이스의 설정값을 뮤테이트하면 테스트가 실패하지만, 이것은 실제 버그와 무관하다
  • 레거시 코드나 통합 테스트가 많은 프로젝트에서는 설정이 복잡하다

실제 코드에서 마주친 경험들

100% 커버리지, 5개의 버그

한 팀원이 자랑하며 새로운 결제 모듈을 보여줬다. 커버리지는 100%였다. 스크린샷을 찍어서 슬랙에 올렸을 정도였다.

코드 리뷰를 시작했을 때, 첫 번째 문제를 발견했다.

public BigDecimal processPayment(BigDecimal amount, String currency) {
    if (currency.equals("USD")) {
        return amount.multiply(new BigDecimal("0.85"));

    } else if (currency.equals("EUR")) {
        return amount.multiply(new BigDecimal("1.0"));

    } else {
        return amount;
    }
}

테스트는 USD와 EUR만 테스트했다. null currency, 소문자 "usd", 빈 문자열 "" 같은 엣지 케이스를 테스트하지 않았다. 만약 API에서 소문자 값을 보낸다면? 예상치 못한 버그가 터진다.

커버리지는 100%였지만, 테스트 커버리지는 훨씬 낮았던 것이다.

과도한 목(Mock)의 함정

@Test
public void testUserRepository() {
    UserRepository repo = new UserRepository(mockDatabase);
    User user = repo.findById(1);
    assertEquals(user.getName(), "John");  // 단순한 주입만 검증
}

이 테스트는 커버리지를 만족하지만, 실제로 데이터베이스 쿼리가 올바르게 구성되었는지는 검증하지 않는다. mockDatabase가 항상 user.getName() = "John"을 반환하도록 설정되어 있기 때문이다.

누군가의 의도적인 커버리지 게임

@Test
public void testAll() {
    service.validateEmail("valid@example.com");
    service.validateEmail("invalid.email");
    service.validateEmail("");
    service.validateEmail(null);

    // assertion 없음
}

이것은 진짜로 본 코드다. 함수만 호출하고 반환값이나 예외를 검증하지 않았다. 커버리지 수치를 올리기 위해 순전히 호출만 하는 테스트를 여러 개 작성한 것이다.

올바른 커버리지 전략: 피라미드 구조

그렇다면 우리는 어떻게 해야 할까?
나는 Mike Cohn의 테스트 피라미드 개념을 다시 생각해봤다.

        /\
       /UI         End-to-End 테스트
      /             적음
     /-------\
    /         \
   /Integration    통합 테스트
  /                 중간
 /---------------\
/                 \
/     Unit Tests      단위 테스트
/                     많음
/---------------------

이 구조는 테스트의 개수와 속도 간의 밸런스를 맞춘 것이다.

Unit Tests (단위 테스트): 비즈니스 로직의 핵심 부분을 깊이 있게 테스트한다. 이곳에서 코드 커버리지와 뮤테이션 스코어가 높아야 한다.

// 단위 테스트 - 한 가지 책임만
@Test
public void calculateDiscountFor10PremiumUsers() {
    DiscountCalculator calc = new DiscountCalculator();
    assertEquals(15, calc.calculate(100, 10, true));
}

@Test
public void calculateDiscountFor5NonPremiumUsers() {
    DiscountCalculator calc = new DiscountCalculator();
    assertEquals(5, calc.calculate(100, 5, false));
}

@Test
public void discountDoesNotExceed50Percent() {
    DiscountCalculator calc = new DiscountCalculator();
    assertEquals(50, calc.calculate(100, 1000, true));
}

Integration Tests (통합 테스트): 여러 컴포넌트가 함께 작동하는지를 테스트한다. 커버리지는 낮아도 괜찮다.

E2E Tests (엔드-투-엔드 테스트): 사용자 관점에서 시스템이 제대로 작동하는지를 테스트한다. 커버리지는 거의 신경 쓰지 않는다.

각 계층에서의 커버리지 목표를 다르게 설정하는 것이 합리적이다.

  • Unit 테스트: 80-90% (중요도 높은 부분은 90% 이상)
  • Integration 테스트: 40-60%
  • E2E 테스트: 명시적 커버리지 목표 없음

실무에서 적용할 수 있는 체크리스트

중요도별 분류 (Risk-Based Testing)

  • 금융, 보안, 의료 로직: 90% 이상 커버리지 + 높은 뮤테이션 스코어
  • 일반 비즈니스 로직: 70-80% 커버리지
  • UI/UX 로직: 낮은 커버리지 허용

경계값 테스트 (Boundary Value Testing)

  • age >= 18이면 18, 17을 반드시 테스트
  • 배열 크기가 0, 1, max인 경우를 테스트
  • null, 빈 문자열, 너무 긴 문자열을 테스트

Assertion 강제화

// 좋은 예
@Test
public void processPaymentReturnsConfirmationNumber() {
    PaymentService service = new PaymentService();
    String confirmation = service.process(100.0);
    assertNotNull(confirmation);
    assertTrue(confirmation.matches("[A-Z0-9]{10}"));
}

정기적인 뮤테이션 테스트

  • 적어도 월 1회는 중요한 모듈에 대해 PIT를 실행
  • survived mutants를 분석하고 테스트를 개선

CI/CD 파이프라인 설정

# GitHub Actions 예
- name: Check Code Coverage
  run: |
    mvn jacoco:report
    COVERAGE=$(grep -oP 'instruction-ratio="K[^"]*' target/site/jacoco/index.html)
    if (( $(echo "$COVERAGE < 0.80" | bc -l) )); then
      echo "Coverage below 80%"
      exit 1
    fi

Dead Code 정기적으로 정리

  • 적어도 분기마다 한 번은 커버되지 않은 코드를 검토
  • 진짜 불필요한 코드인지, 아니면 테스트해야 하는 코드인지 판단

숫자에 속지 말고 품질을 먼저 생각하자

나는 이 여정을 통해 깨달았다. 코드 커버리지는 확실히 유용한 도구지만, 그것을 목표로 삼아서는 절대 안 된다는 것을 말이다.

높은 커버리지가 높은 품질을 보장하지 않는다. 이것은 이제 거의 상식이 되어가고 있지만, 여전히 많은 조직들이 80% 커버리지를 강제하며 개발자들을 괴롭히고 있다.

대신 우리는 다음을 명심해야 한다.

  1. 테스트는 속도와 품질의 균형을 맞춰야 한다. 100% 커버리지를 위해 무의미한 테스트 100개를 작성하는 것보다, 의미 있는 테스트 20개를 작성하는 것이 낫다.
  2. 커버리지는 진단 도구이지, 처방약이 아니다. 낮은 커버리지는 "더 많은 테스트를 작성해야 한다"는 신호다. 하지만 높은 커버리지는 "코드가 안전하다"는 신호가 아니다.
  3. 경계값, null, 에러 상황을 테스트하자. 이것이 실제 버그를 잡는다.
  4. 정기적으로 뮤테이션 테스트를 실행하자. Statement coverage의 한계를 보완할 수 있는 가장 강력한 도구다.
  5. 코드의 중요도에 따라 커버리지 목표를 다르게 설정하자. 신용카드 검증과 다크 모드 토글이 같은 기준을 적용받을 이유는 없다.

내가 지금 진행 중인 프로젝트에서는 이제 더 이상 "전체 커버리지 80%"를 목표로 삼지 않는다. 대신 "비즈니스 로직 90% + 높은 뮤테이션 스코어"를 목표로 삼는다. 그리고 그 결과는 훨씬 더 건강한 코드베이스와 더 자신감 있는 배포로 이어졌다.

코드 커버리지를 도구로 사용하되, 그것에 지배당하지 말자. 그것이 전문가다운 개발자의 마음가짐이라고 본다.

Java 객체 생명주기와 Escape 분석, 박싱 비용 완벽 이해하기

최근에 Java 애플리케이션을 성능 측정 도구로 모니터링하면서 너무 많은 객체가 생성되고 빠르게 GC의 대상이 되는 현상을 봤습니다. 특히 Integer나 Long 같은 래퍼 클래스를 많이 사용하는 부분에

byteandbit.tistory.com

반응형