시리즈

- JMH 마이크로벤치마크

- 02 TestContainers로 실제 인프라 테스트

- 대량 데이터 시딩과 벤치마크 설계

- 결과 분석과 병목 진단

H2로 테스트를 돌리면 통과하는데 PostgreSQL에 배포하면 터지는 경험은 누구나 한 번씩 한다. JSONB 연산, 윈도우 함수, GIN 인덱스처럼 특정 DB에 의존하는 기능은 실제 DB에서 테스트해야만 의미가 있다. TestContainers는 Docker 컨테이너를 테스트 코드 안에서 프로그래밍적으로 관리하는 라이브러리다.

TestContainers가 해결하는 문제

전통적인 테스트 환경에는 세 가지 선택지가 있다.

  • 인메모리 DB (H2) — 빠르지만 SQL 방언 차이로 프로덕션 버그를 놓친다
  • 공유 개발 DB — 다른 사람의 테스트가 내 데이터를 오염시킨다
  • 로컬 Docker 수동 실행docker-compose up 후 테스트, 끝나면 down. 자동화가 안 된다

TestContainers는 세 번째 방식을 테스트 코드 안에서 자동화한다. 테스트 시작 시 컨테이너를 띄우고, 끝나면 자동으로 제거한다. CI에서도 Docker만 있으면 동일하게 동작한다.

핵심 개념

컨테이너 생명주기

TestContainers의 컨테이너는 Java 객체다. 생성하고, start()로 시작하고, stop()으로 정리한다.

PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16-alpine");
postgres.start();

String jdbcUrl = postgres.getJdbcUrl();
String username = postgres.getUsername();
String password = postgres.getPassword();

// 테스트 실행...

postgres.stop();

start()를 호출하면 내부적으로 이런 과정이 일어난다.

sequenceDiagram autonumber participant Test as 테스트 코드 participant TC as TestContainers participant Docker as Docker 데몬 Test->>TC: container.start() rect rgb(232, 248, 232) Note over TC, Docker: 컨테이너 준비 TC->>Docker: 이미지 pull (없으면) Docker-->>TC: 이미지 준비 완료 TC->>Docker: 컨테이너 생성 + 랜덤 포트 매핑 Docker-->>TC: 컨테이너 ID TC->>Docker: 컨테이너 시작 end rect rgb(255, 243, 224) Note over TC, Docker: 준비 확인 TC->>Docker: wait strategy 실행 Note right of Docker: 포트 리스닝 확인
또는 로그 패턴 매칭 Docker-->>TC: 준비 완료 end TC-->>Test: start() 반환 Note right of Test: getJdbcUrl() 등으로
동적 접속 정보 획득

2번 단계에서 랜덤 포트를 매핑한다는 점이 중요하다. 호스트의 5432 포트가 이미 사용 중이어도 충돌하지 않는다. 대신 접속 정보를 하드코딩할 수 없고, 반드시 getJdbcUrl() 같은 메서드로 동적으로 가져와야 한다.

Wait Strategy

컨테이너가 시작됐다고 해서 서비스가 바로 준비된 것은 아니다. PostgreSQL이 부팅 중일 때 접속하면 커넥션 에러가 발생한다. TestContainers는 Wait Strategy로 서비스가 실제로 요청을 받을 준비가 됐는지 확인한다.

  • PortWaitStrategy (기본) — 지정된 포트가 리스닝 상태가 될 때까지 대기
  • LogWaitStrategy — 로그에 특정 패턴이 출력될 때까지 대기
  • HttpWaitStrategy — HTTP 엔드포인트가 200을 반환할 때까지 대기

PostgreSQL이나 Elasticsearch 전용 컨테이너는 적절한 Wait Strategy가 이미 내장되어 있어서 직접 설정할 필요가 없다.

PostgreSQL 컨테이너

기본 사용법

PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16-alpine")
                .withDatabaseName("testdb")
                .withUsername("test")
                .withPassword("test");

postgres:16-alpine은 경량 이미지다. 프로덕션에서 사용하는 PostgreSQL 버전과 맞추는 것이 원칙이다. 15를 쓰고 있다면 postgres:15-alpine으로 지정한다.

Spring Boot 연동 — @DynamicPropertySource

컨테이너의 랜덤 포트를 Spring의 프로퍼티에 주입하는 표준 방법이다.

@SpringBootTest
@Testcontainers
class FeedRepositoryTest {

    @Container
    static PostgreSQLContainer<?> postgres =
            new PostgreSQLContainer<>("postgres:16-alpine")
                    .withDatabaseName("testdb");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired
    private FeedRepository feedRepository;

    @Test
    void cursorQueryTest() {
        // 실제 PostgreSQL에서 JSONB 쿼리 테스트
    }
}

@Container@Testcontainers는 JUnit 5 확장이다. static 필드에 @Container를 붙이면 테스트 클래스 전체에서 하나의 컨테이너를 공유한다. 인스턴스 필드에 붙이면 테스트 메서드마다 새 컨테이너를 만든다.

JMH에서의 연동 방식

JMH는 JUnit이 아니므로 @DynamicPropertySource를 사용할 수 없다. 대신 System.setProperty()로 환경 변수를 설정한 뒤 SpringApplicationBuilder로 컨텍스트를 직접 부트스트랩한다. 이 방식은 01 벤치마크 환경 구성에서 다루고 있다.

Elasticsearch 컨테이너

기본 사용법

ElasticsearchContainer elastic =
        new ElasticsearchContainer(
                "docker.elastic.co/elasticsearch/elasticsearch:8.17.0")
                .withEnv("discovery.type", "single-node")
                .withEnv("xpack.security.enabled", "false")
                .withEnv("ES_JAVA_OPTS", "-Xms512m -Xmx512m");

ES 8.x부터 보안이 기본 활성화되어 있다. 테스트 환경에서는 xpack.security.enabled=false로 끄는 것이 편하다. ES_JAVA_OPTS로 힙 사이즈를 제한하지 않으면 호스트 메모리를 과도하게 잡아먹는다.

접속 정보 획득

elastic.start();

String httpHost = elastic.getHttpHostAddress();
// "localhost:32789" 같은 형태

Spring Data Elasticsearch와 연동할 때는 이 주소를 spring.elasticsearch.uris에 주입한다.

@DynamicPropertySource
static void configureElastic(DynamicPropertyRegistry registry) {
    registry.add("spring.elasticsearch.uris",
            elastic::getHttpHostAddress);
}

Nori 한글 분석기 플러그인

한글 검색을 테스트하려면 Nori 플러그인이 설치된 이미지가 필요하다. 공식 이미지에는 포함되어 있지 않으므로 커스텀 Dockerfile을 만들거나, 컨테이너 시작 후 플러그인을 설치한다.

ElasticsearchContainer elastic =
        new ElasticsearchContainer(
                "docker.elastic.co/elasticsearch/elasticsearch:8.17.0")
                .withEnv("discovery.type", "single-node")
                .withEnv("xpack.security.enabled", "false");

// 컨테이너 시작 전에 커맨드 실행은 불가능하므로,
// 커스텀 이미지를 빌드하는 방식을 사용한다

가장 깔끔한 방법은 프로젝트에 Dockerfile을 만들어두는 것이다.

FROM docker.elastic.co/elasticsearch/elasticsearch:8.17.0
RUN bin/elasticsearch-plugin install analysis-nori
ElasticsearchContainer elastic = new ElasticsearchContainer(
        new ImageFromDockerfile()
                .withDockerfileFromBuilder(builder ->
                        builder.from("docker.elastic.co/elasticsearch/elasticsearch:8.17.0")
                                .run("bin/elasticsearch-plugin install analysis-nori")
                                .build()))
        .withEnv("discovery.type", "single-node")
        .withEnv("xpack.security.enabled", "false");

ImageFromDockerfile을 사용하면 Dockerfile 없이 코드로 이미지를 빌드할 수 있다. 첫 실행 시 이미지 빌드에 시간이 걸리지만, Docker 이미지 캐시 덕분에 두 번째부터는 빠르다.

컨테이너 재사용 전략

테스트마다 새 컨테이너 vs 공유 컨테이너

전략격리성속도사용 시점
메서드마다 새 컨테이너완벽매우 느림거의 쓰지 않음
클래스마다 새 컨테이너높음느림데이터 격리가 중요할 때
싱글톤 컨테이너낮음빠름대부분의 경우

싱글톤 컨테이너는 모든 테스트 클래스가 하나의 컨테이너를 공유한다. 추상 클래스에 static 블록으로 컨테이너를 시작하면 JVM 종료 시까지 살아 있다.

public abstract class IntegrationTestBase {

    static final PostgreSQLContainer<?> POSTGRES;

    static {
        POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine")
                .withDatabaseName("testdb");
        POSTGRES.start();
    }

    @DynamicPropertySource
    static void configure(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
        registry.add("spring.datasource.username", POSTGRES::getUsername);
        registry.add("spring.datasource.password", POSTGRES::getPassword);
    }
}

stop()을 호출하지 않는다. TestContainers의 Ryuk 컨테이너가 JVM 종료를 감지하면 자동으로 모든 컨테이너를 정리한다.

싱글톤 컨테이너의 데이터 오염

테스트 A가 넣은 데이터가 테스트 B에 영향을 줄 수 있다. @Transactional로 각 테스트를 롤백하거나, @BeforeEach에서 테이블을 truncate하는 방식으로 격리한다. 벤치마크에서는 데이터를 한 번 시딩하고 읽기만 하므로 이 문제가 없다.

reuse 모드

~/.testcontainers.propertiestestcontainers.reuse.enable=true를 설정하고, 컨테이너에 .withReuse(true)를 붙이면 테스트가 끝나도 컨테이너를 제거하지 않는다. 다음 테스트 실행 시 같은 컨테이너를 재사용한다.

PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16-alpine")
                .withReuse(true);

로컬 개발 중 반복 실행 속도를 높이는 데 유용하다. 단, CI에서는 매번 깨끗한 상태로 시작하는 것이 원칙이므로 reuse를 끄는 것이 좋다.

Gradle 의존성 정리

dependencies {
    // 통합 테스트용
    testImplementation 'org.testcontainers:testcontainers:1.21.1'
    testImplementation 'org.testcontainers:junit-jupiter:1.21.1'
    testImplementation 'org.testcontainers:postgresql:1.21.1'
    testImplementation 'org.testcontainers:elasticsearch:1.21.1'

    // JMH 벤치마크용 (src/jmh에서 사용)
    jmhImplementation 'org.testcontainers:testcontainers:1.21.1'
    jmhImplementation 'org.testcontainers:postgresql:1.21.1'
    jmhImplementation 'org.testcontainers:elasticsearch:1.21.1'
}

testImplementationsrc/test에서, jmhImplementationsrc/jmh에서 사용한다. JUnit 확장(junit-jupiter 모듈)은 JMH에서는 필요 없다.

BOM으로 버전 통일

모듈마다 버전을 지정하는 대신 BOM을 사용할 수 있다.

```groovy

implementation platform('org.testcontainers:testcontainers-bom:1.21.1')

testImplementation 'org.testcontainers:postgresql' // 버전 생략

```

자주 하는 실수

Docker Desktop 미실행

TestContainers는 Docker 데몬이 실행 중이어야 한다. Windows에서는 Docker Desktop을 먼저 시작해야 한다. CI에서는 Docker-in-Docker 또는 Docker 소켓 마운트 설정이 필요하다.

[!WARNING] 포트 하드코딩

spring.datasource.url=jdbc:postgresql://localhost:5432/testdb처럼 포트를 고정하면, TestContainers의 랜덤 포트 매핑과 맞지 않아 접속에 실패한다. 반드시 getJdbcUrl()로 동적 URL을 받아야 한다.

[!WARNING] 이미지 태그 latest 사용

new PostgreSQLContainer<>("postgres:latest")는 빌드 시점에 따라 다른 버전이 내려온다. 어제까지 통과하던 테스트가 PostgreSQL 메이저 업데이트 때문에 갑자기 실패할 수 있다. 프로덕션과 동일한 버전을 명시한다.