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

85 lines
4.4 KiB
Markdown

---
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<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` 성공.