4.4 KiB
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 |
|
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
원인
- 테스트 간 JVM 프로세스 공유: Gradle의
testtask는 동일 JVM 내에서 여러 테스트를 구동한다. - Spring Context의 로깅 시스템 전역 초기화:
OperationalContractRuntimeTest등@SpringBootTest또는@WebMvcTest기반 슬라이스 테스트가 실행될 때, Spring Boot는 테스트 프로퍼티 파일(application-test.yml)을 바탕으로 로깅 시스템을 전역 설정한다. - 로깅 레벨 오염:
application-test.yml에는 다음과 같이 전역 로깅 레벨이 정의되어 있다.이로 인해logging: level: root: WARN dev.caskeleton: WARNdev.caskeleton패키지의 로그 레벨이 전역적으로WARN으로 설정된다. - 순수 JUnit 테스트에서의 로그 누락: 이후 동일 JVM에서 순수 JUnit 테스트인
PiiTokenBodyForbiddenContractTest가 돌 때,PiiTokenBodyForbiddenContractTest.classLogger의 유효 로깅 레벨(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)
- 순수 JUnit 단위 테스트에서 Logback
ListAppender등을 이용하여 로그 발생을 단언할 때는, 테스트 생명주기 안에서 대상 Logger의 레벨을 명시적으로 제어해야 한다. - Spring Boot의 LoggingSystem은 JVM 전역 상태(Logback LoggerContext)를 변경하므로, 순수 단위 테스트들이 그 뒤에 실행되면 환경 전염(Context Pollution/Level Leak)을 겪게 된다.
- 사용이 끝난 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성공.