--- title: "Logback list appender captures empty logs in shared-JVM test execution due to log level pollution" source_type: error-note status: raw tags: [logback, junit, spring-boot, test-pollution, logging, TDD] created: 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`에는 다음과 같이 전역 로깅 레벨이 정의되어 있다. ```yaml 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`로 설정하여 상속받은 전역 로그 레벨 환경에 관계없이 항상 로그가 발행되도록 보장하고, 테스트가 끝난 시점에 원래 레벨로 복구하여 다른 테스트에 영향을 주지 않도록 한다. ```java @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 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` 성공.