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()를 호출하면 내부적으로 이런 과정이 일어난다.
또는 로그 패턴 매칭 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는 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.properties에 testcontainers.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'
}
testImplementation은 src/test에서, jmhImplementation은 src/jmh에서 사용한다. JUnit 확장(junit-jupiter 모듈)은 JMH에서는 필요 없다.
모듈마다 버전을 지정하는 대신 BOM을 사용할 수 있다.
```groovy
implementation platform('org.testcontainers:testcontainers-bom:1.21.1')
testImplementation 'org.testcontainers:postgresql' // 버전 생략
```
자주 하는 실수
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 메이저 업데이트 때문에 갑자기 실패할 수 있다. 프로덕션과 동일한 버전을 명시한다.