Files
llm-wiki/raw/errors/logback-shared-jvm-test-leak-pii-contract-2026-06-23.md

4.4 KiB

title, source_type, status, tags, created
title source_type status tags created
Logback list appender captures empty logs in shared-JVM test execution due to log level pollution error-note raw
logback
junit
spring-boot
test-pollution
logging
TDD
2026-06-23

Logback List Appender empty logs in shared-JVM test execution

Parent

raw/branch-notes/feature-build-release-supply-chain-contract

현상

PiiTokenBodyForbiddenContractTest 클래스는 Spring context를 부트하지 않는 순수 JUnit 테스트 클래스이며, 내부의 captured_log_line_carries_no_unmasked_secret() 메서드는 ListAppender를 Logback Logger에 부착하여 PII 마스킹 규칙을 검증한다.

로컬에서 개별 테스트로 구동 시에는 항상 통과하나, 전체 테스트 슈트(./gradlew test) 실행 시 해당 테스트가 실패한다:

PiiTokenBodyForbiddenContractTest > captured_log_line_carries_no_unmasked_secret() FAILED
    java.lang.AssertionError: 
    Expectation: the log event was captured (size: 1) but was size: 0

원인

  1. 테스트 간 JVM 프로세스 공유: Gradle의 test task는 동일 JVM 내에서 여러 테스트를 구동한다.
  2. Spring Context의 로깅 시스템 전역 초기화: OperationalContractRuntimeTest@SpringBootTest 또는 @WebMvcTest 기반 슬라이스 테스트가 실행될 때, Spring Boot는 테스트 프로퍼티 파일(application-test.yml)을 바탕으로 로깅 시스템을 전역 설정한다.
  3. 로깅 레벨 오염: application-test.yml에는 다음과 같이 전역 로깅 레벨이 정의되어 있다.
    logging:
      level:
        root: WARN
        dev.caskeleton: WARN
    
    이로 인해 dev.caskeleton 패키지의 로그 레벨이 전역적으로 WARN으로 설정된다.
  4. 순수 JUnit 테스트에서의 로그 누락: 이후 동일 JVM에서 순수 JUnit 테스트인 PiiTokenBodyForbiddenContractTest가 돌 때, PiiTokenBodyForbiddenContractTest.class Logger의 유효 로깅 레벨(Effective Level)은 이전 Spring Context가 오염시킨 WARN 레벨을 그대로 상속받고 있다. 따라서 logger.info(...) 메서드 호출이 무시되고 ListAppender에 아무 이벤트도 쌓이지 않아 테스트 검증에 실패하게 된다.

해결

테스트 수행 전에 테스트 대상 Logger의 레벨을 명시적으로 INFO로 설정하여 상속받은 전역 로그 레벨 환경에 관계없이 항상 로그가 발행되도록 보장하고, 테스트가 끝난 시점에 원래 레벨로 복구하여 다른 테스트에 영향을 주지 않도록 한다.

  @Test
  void captured_log_line_carries_no_unmasked_secret() {
    Logger logger = (Logger) LoggerFactory.getLogger(PiiTokenBodyForbiddenContractTest.class);
    ch.qos.logback.classic.Level originalLevel = logger.getLevel();
    logger.setLevel(ch.qos.logback.classic.Level.INFO); // INFO 레벨 발행 보장
    ListAppender<ILoggingEvent> appender = new ListAppender<>();
    appender.start();
    logger.addAppender(appender);
    try {
      logger.info(
          "outbound call failed with token={} and authorization: Bearer {}",
          "leaked-token-abcdef123456",
          "eyJhbGciOiJIUzI1NiInPayload");
    } finally {
      logger.detachAppender(appender);
      logger.setLevel(originalLevel); // 원래 레벨로 복원 (Test Isolation)
    }

    assertThat(appender.list).as("the log event was captured").hasSize(1);
    // ...
  }

정리 (Lessons)

  1. 순수 JUnit 단위 테스트에서 Logback ListAppender 등을 이용하여 로그 발생을 단언할 때는, 테스트 생명주기 안에서 대상 Logger의 레벨을 명시적으로 제어해야 한다.
  2. Spring Boot의 LoggingSystem은 JVM 전역 상태(Logback LoggerContext)를 변경하므로, 순수 단위 테스트들이 그 뒤에 실행되면 환경 전염(Context Pollution/Level Leak)을 겪게 된다.
  3. 사용이 끝난 Logger 레벨은 원래대로 복구하는 것이 좋은 테스트 격리(Test Isolation) 습관이다.

재현 환경

  • Spring Boot 3.4.x, Java 21, Gradle 9.0
  • ./gradlew test (전체 실행 시 무조건 1건 실패)
  • 해결 후: 전체 테스트 통과 (BUILD SUCCESSFUL)

Evidence

  • actually-implemented: src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/PiiTokenBodyForbiddenContractTest.java 수정 적용.
  • locally-verified: cd src && ./gradlew test 성공.