배경
통합 테스트를 반복 실행하다 보니 docker volume ls에 이름 없는 익명 볼륨이 계속 쌓이는 현상을 발견했다. PostgreSQL 같은 컨테이너는 실행될 때마다 데이터 저장용 익명 볼륨을 자동 생성하는데, 테스트가 끝나도 이 볼륨이 그대로 남아 디스크 용량을 계속 잡아먹었다. Testcontainers와 EmbeddedKafka를 함께 사용하면서 이 문제가 어떻게 해결되는지, 그리고 각 테스트 패턴을 어떻게 구성하는지 정리한다.
1. Testcontainers — 테스트 종료 시 볼륨/컨테이너 자동 삭제
Ryuk이란?
Testcontainers는 테스트를 실행할 때 내부적으로 Ryuk(testcontainers/ryuk)라는 사이드카 컨테이너를 먼저 띄운다. Ryuk는 테스트 JVM과 TCP 소켓으로 연결을 유지하며, 테스트 중 생성된 모든 Docker 리소스(컨테이너, 네트워크, 볼륨)를 추적한다.
[Test JVM] ←── TCP heartbeat ──→ [Ryuk container]
↓
JVM 종료 감지 시
↓
test-container + volume + network 전부 삭제
왜 볼륨까지 삭제되나?
PostgreSQL 같은 컨테이너는 데이터 영속성을 위해 Docker 볼륨을 자동 생성한다. Testcontainers가 컨테이너를 중지/삭제할 때 --volumes 옵션도 함께 전달하기 때문에, **익명 볼륨(anonymous volume)**도 함께 삭제된다.
이 덕분에 테스트가 끝나도 docker volume ls에 고아 볼륨이 남지 않는다.
@Testcontainers vs 수동 시작 방식 비교
방식 컨테이너 생명주기 정리 주체
| @Testcontainers • @Container static | 클래스 당 1회 start/stop | JUnit + Ryuk |
| static { container.start(); } | JVM 시작 시 start, 종료 시 Ryuk가 정리 | Ryuk (JVM shutdown hook) |
두 방식 모두 결국 Ryuk가 cleanup을 담당하므로, 수동 시작 방식도 볼륨 정리가 보장된다.
testcontainers.properties
프로젝트 루트에 testcontainers.properties를 두면 전역 설정이 가능하다.
# 컨테이너 재사용 (CI 환경에서 속도 향상, 단 수동 cleanup 필요)
testcontainers.reuse.enable=true
# Ryuk 비활성화 (컨테이너 정리를 직접 관리할 때)
testcontainers.ryuk.disabled=true
2. Poison Pill 처리: DLT + Manual Ack
역직렬화에 실패한 메시지(포이즌 필)를 재시도하면 컨슈머가 영원히 블로킹된다. 이를 방지하기 위해 두 가지를 함께 적용했다.
DefaultErrorHandler + DeadLetterPublishingRecoverer
@Bean
public CommonErrorHandler kafkaCommonErrorHandler(KafkaTemplate<String, String> kafkaTemplate) {
DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer(
kafkaTemplate,
(record, ex) -> new TopicPartition(resolveDltTopic(record), record.partition())
);
DefaultErrorHandler errorHandler = new DefaultErrorHandler(
recoverer,
new FixedBackOff(1000L, 2) // 1초 간격 최대 2회 재시도
);
// IllegalStateException (역직렬화 실패) → 재시도 없이 즉시 DLT
errorHandler.addNotRetryableExceptions(IllegalStateException.class);
errorHandler.setCommitRecovered(true); // DLT 이관 후 offset 커밋
return errorHandler;
}
MANUAL_IMMEDIATE ack
enable-auto-commit: false + ack-mode: manual_immediate 설정으로, consumer가 acknowledgment.acknowledge()를 호출한 시점에 즉시 offset이 커밋된다. 비즈니스 로직 실행 전에 장애가 발생하면 offset이 커밋되지 않아 재처리가 보장된다.
3. EmbeddedKafka 통합 테스트 패턴
Testcontainers 기반 Kafka 대신 @EmbeddedKafka를 사용하면 Docker 없이도 Kafka consumer 통합 테스트가 가능하다.
@SpringBootTest
@EmbeddedKafka(
partitions = 1,
topics = {"payment.completed", "payment.completed.DLT"},
bootstrapServersProperty = "spring.kafka.bootstrap-servers"
)
@TestPropertySource(properties = {
"spring.kafka.consumer.auto-offset-reset=earliest",
"spring.kafka.consumer.value-deserializer=org.apache.kafka.common.serialization.StringDeserializer"
})
class CouponKafkaIntegrationTest {
@Autowired KafkaTemplate<String, String> kafkaTemplate;
@Test
void paymentCompleted_confirmsUserCoupon() {
kafkaTemplate.send("payment.completed",
"{\"eventId\":\"evt-1\",\"orderId\":\"..\"}");
await().atMost(5, SECONDS).untilAsserted(() ->
assertThat(userCouponRepository.findById(id).get().getStatus())
.isEqualTo(UserCouponStatus.USED)
);
}
}
핵심 포인트
- bootstrapServersProperty로 EmbeddedKafka의 주소를 Spring 설정에 주입
- auto-offset-reset=earliest로 테스트 시작 전 발행된 메시지도 수신
- Awaitility.await()로 비동기 consumer 처리 완료까지 폴링
- DLT 토픽도 topics에 미리 등록해야 DeadLetterPublishingRecoverer가 발행 가능
요약
주제 핵심 내용
| Testcontainers Ryuk | JVM 종료 시 컨테이너 + 볼륨 + 네트워크 자동 삭제 |
| 익명 볼륨 삭제 | --volumes 옵션으로 컨테이너와 함께 삭제됨 |
| Poison pill 처리 | DefaultErrorHandler + DLT + NotRetryable |
| Manual ack | MANUAL_IMMEDIATE로 비즈니스 완료 후 offset 커밋 |
| EmbeddedKafka | Docker 없이 Kafka consumer 통합 테스트 가능 |
'Study' 카테고리의 다른 글
| [내일배움캠프 TIL] ghcr.io 이미지 태그는 소문자만 허용된다 (0) | 2026.06.29 |
|---|---|
| [내일배움캠프 TIL] Spring Cloud Gateway Rate Limiting에 Redis가 필요한 이유 (0) | 2026.06.26 |
| [내일배움캠프 TIL] Redis 재고 차감 후 확인하는 이유 & Lua 스크립트 안 쓰는 이유 (0) | 2026.06.23 |
| [내일배움캠프 TIL] Terraform으로 AWS EC2 배포 자동화 완성 (0) | 2026.06.22 |
| [내일배움캠프 TIL] Testcontainers와 Karate DSL로 구축하는 MSA 테스트 환경 (통합 테스트 vs E2E 테스트) (0) | 2026.06.19 |