본문 바로가기

Study

[내일배움캠프 TIL] Testcontainers 볼륨 자동 정리 (Ryuk) & EmbeddedKafka 통합 테스트

728x90
반응형

배경

통합 테스트를 반복 실행하다 보니 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 통합 테스트 가능
반응형