85 lines
4.4 KiB
Markdown
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` 성공.
|