Testcontainers - 테스트를 위한 Docker 컨테이너 자동화
테스트에 진짜 DB가 필요한 이유
단위 테스트에서 Repository를 Mocking하면 쿼리 자체는 검증되지 않는다. jOOQ의 타입 안전한 쿼리든, MyBatis의 XML 쿼리든, 실제 DB에 날려봐야 제대로 동작하는지 알 수 있다.
그래서 통합 테스트에는 진짜 DB가 필요하다. 문제는 그 “진짜 DB”를 어떻게 준비하느냐다.
흔한 방식들과 한계
H2 인메모리 DB
spring:
datasource:
url: jdbc:h2:mem:testdb
가장 쉽다. 하지만 PostgreSQL에서 되는 문법이 H2에서 안 되는 경우가 많다. ON CONFLICT, RETURNING, Window Function 등 DB 고유 문법을 쓰면 H2로는 테스트할 수 없다. “테스트는 통과했는데 운영에서 쿼리 에러”가 발생하는 원인이 된다.
로컬에 DB 직접 설치
spring:
datasource:
url: jdbc:postgresql://localhost:5432/downtime
username: postgres
password: postgres
운영과 같은 DB를 쓰니 쿼리 호환성 문제는 없다. 하지만:
- 새 팀원이 합류할 때마다 DB 설치/설정 필요
- CI 서버에도 DB 설치 및 관리 필요
- 로컬 DB 상태가 테스트에 영향을 줌 (이전에 넣은 데이터가 남아있다거나)
- 여러 모듈이 같은 DB를 쓰면 병렬 테스트 시 race condition
Testcontainers
@Container
@ServiceConnection
val postgres = PostgreSQLContainer("postgres:17")
테스트 코드가 Docker로 PostgreSQL을 직접 띄운다. 테스트 끝나면 자동으로 삭제된다. Docker만 설치되어 있으면 된다.
Testcontainers 동작 구조
전체 흐름
┌─────────────────────────────────────────────────────┐
│ JVM (테스트 실행) │
│ │
│ @Test │
│ fun `다운타임 등록`() { │
│ service.register(dto) ──── JDBC ────┐ │
│ } │ │
│ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ Testcontainers 라이브러리 │ │
│ │ - Docker API로 컨테이너 생성/삭제 │ │
│ │ - 랜덤 포트 할당 │ │
│ │ - spring.datasource.url 자동 주입 │ │
│ └──────────┬───────────────────────────────┘ │
└─────────────┼───────────────────────────────────────┘
│ Docker API
▼
┌─────────────────────────────────────────────────────┐
│ Docker │
│ │
│ ┌─────────────────────┐ │
│ │ PostgreSQL:17 │ ← 테스트 시작 시 자동 생성 │
│ │ port: 32789 (랜덤) │ ← 테스트 끝나면 자동 삭제 │
│ │ db: test │ │
│ └─────────────────────┘ │
└─────────────────────────────────────────────────────┘
Testcontainers 라이브러리가 내부적으로 Docker Engine API를 호출한다. 개발자가 Docker 명령어를 직접 쓸 필요가 없다.
라이프사이클
테스트 시작
│
▼
① Docker에 PostgreSQL 컨테이너 생성 (이미지 없으면 pull)
│
▼
② 컨테이너 준비 완료 대기 (health check)
│
▼
③ 랜덤 포트 확인 (예: localhost:32789)
│
▼
④ Spring에 접속 정보 자동 주입
spring.datasource.url = jdbc:postgresql://localhost:32789/test
spring.datasource.username = test
spring.datasource.password = test
│
▼
⑤ 테스트 실행 (평소처럼 jOOQ/JPA 사용)
│
▼
⑥ 테스트 종료 → 컨테이너 자동 삭제
랜덤 포트가 핵심이다. 로컬의 5432 포트를 쓰는 게 아니라 매번 빈 포트를 할당하기 때문에, 로컬에서 돌리는 PostgreSQL과 절대 충돌하지 않는다.
의존성 설정
// build.gradle.kts
dependencies {
testImplementation("org.springframework.boot:spring-boot-testcontainers")
testImplementation("org.testcontainers:postgresql")
}
Spring Boot 4 BOM이 Testcontainers 버전을 관리하므로 버전 명시가 필요 없다. spring-boot-testcontainers가 @ServiceConnection 등 Spring Boot 통합 기능을 제공한다.
적용 방식
방식 1: @DynamicPropertySource — 수동 연결
Testcontainers 초기부터 쓰던 방식이다. 컨테이너의 접속 정보를 개발자가 직접 Spring에 전달한다.
@SpringBootTest
@Testcontainers
@ActiveProfiles("test")
class DowntimeApiServiceTests {
companion object {
@Container
@JvmStatic
val postgres = PostgreSQLContainer("postgres:17")
.withDatabaseName("downtime")
@DynamicPropertySource
@JvmStatic
fun properties(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl)
registry.add("spring.datasource.username", postgres::getUsername)
registry.add("spring.datasource.password", postgres::getPassword)
}
}
@Test
fun `다운타임 등록`() {
// 테스트 코드
}
}
장점
- Spring Boot 버전에 의존하지 않는다. Spring Boot 2.x 시절부터 사용 가능
- 어떤 속성이 주입되는지 코드에 명시적으로 드러난다
@ServiceConnection이 지원하지 않는 커스텀 속성도 자유롭게 주입할 수 있다
// 예: jOOQ 전용 datasource 속성처럼 비표준 속성도 가능
registry.add("app.reporting-db.url", postgres::getJdbcUrl)
단점
- 보일러플레이트가 많다.
url,username,password3줄을 매번 반복 - 테스트 클래스마다 같은 코드를 복사해야 한다
- 속성 이름을 오타 내면 런타임에서야 발견된다
방식 2: @ServiceConnection — Spring Boot 4 권장
@SpringBootTest
@Testcontainers
@ActiveProfiles("test")
class DowntimeApiServiceTests {
companion object {
@Container
@ServiceConnection // 이것만 붙이면 끝
@JvmStatic
val postgres = PostgreSQLContainer("postgres:17")
}
@Test
fun `다운타임 등록`() {
// 테스트 코드
}
}
@ServiceConnection은 컨테이너 타입을 보고 알아서 판단한다.
| 컨테이너 타입 | 자동 설정하는 속성 |
|---|---|
PostgreSQLContainer |
spring.datasource.url, username, password |
MySQLContainer |
spring.datasource.url, username, password |
RedisContainer |
spring.data.redis.host, port |
KafkaContainer |
spring.kafka.bootstrap-servers |
MongoDBContainer |
spring.data.mongodb.uri |
DB뿐 아니라 Redis, Kafka, MongoDB 등도 동일한 패턴으로 사용할 수 있다.
장점
- 코드가 가장 짧다.
@ServiceConnection한 줄이면 끝 - 속성 이름 오타 위험이 없다. 컨테이너 타입 기반으로 자동 매핑
- Spring Boot가 공식 지원하는 방식이라 문서/예제가 풍부하다
단점
- Spring Boot 3.1+ 에서만 사용 가능 (이전 버전에서는 방식 1을 써야 한다)
@ServiceConnection이 지원하는 컨테이너 타입만 자동 매핑된다. 지원 목록에 없는 컨테이너는 방식 1로 fallback 해야 한다- 내부에서 어떤 속성이 주입되는지 코드만 봐서는 알 수 없다 (매직처럼 느껴질 수 있음)
방식 3: @TestConfiguration — 공통화
여러 테스트 클래스에서 같은 컨테이너 설정을 공유하고 싶을 때.
@TestConfiguration(proxyBeanMethods = false)
class TestcontainersConfig {
@Bean
@ServiceConnection
fun postgres(): PostgreSQLContainer<*> {
return PostgreSQLContainer("postgres:17")
.withDatabaseName("downtime")
}
}
테스트 클래스에서 Import:
@SpringBootTest
@Import(TestcontainersConfig::class)
@ActiveProfiles("test")
class DowntimeApiServiceTests {
@Test
fun `다운타임 등록`() {
// 컨테이너가 자동으로 시작되어 있다
}
}
이 방식이면 @Testcontainers와 @Container가 필요 없다. Spring이 빈 라이프사이클로 컨테이너를 관리한다.
장점
- 컨테이너 설정이 한 곳에 모인다. 10개 테스트 클래스가 있어도
TestcontainersConfig하나만 관리 - 컨테이너 옵션 변경(이미지 버전, init script 등)이 한 곳에서 끝난다
@Import만 추가하면 되므로 테스트 클래스가 깔끔하다- Spring 빈으로 관리되어 ApplicationContext 캐싱 혜택을 받는다. 같은 Config를 Import하는 테스트 클래스들은 컨테이너를 공유한다
단점
- 설정 파일이 하나 더 생긴다 (
TestcontainersConfig.kt) - 테스트 클래스마다
@Import(TestcontainersConfig::class)를 붙여야 한다. 빼먹으면 DB 연결 실패 - 특정 테스트만 다른 DB 설정이 필요한 경우 Config 분리가 필요하다
방식 비교 요약
방식 1: @DynamicPropertySource |
방식 2: @ServiceConnection |
방식 3: @TestConfiguration |
|
|---|---|---|---|
| 코드량 | 많음 | 적음 | 중간 (Config 클래스 분리) |
| Spring Boot 최소 버전 | 2.x | 3.1+ | 3.1+ (@ServiceConnection 사용 시) |
| 커스텀 속성 주입 | 자유롭게 가능 | 지원 목록만 가능 | @ServiceConnection + 추가 @Bean 조합 |
| 설정 공유 | 상속 또는 복사 | 상속 또는 복사 | @Import로 공유 |
| 권장 상황 | 비표준 속성이 필요할 때 | 단일 테스트 클래스 | 여러 테스트 클래스가 같은 인프라를 공유할 때 |
실무에서는 방식 3(
@TestConfiguration) + 방식 2(@ServiceConnection) 조합이 가장 많이 쓰인다. Config 클래스 안에서@ServiceConnection을 붙인 빈을 정의하면 보일러플레이트도 적고, 설정도 한 곳에서 관리된다.
컨테이너 범위 (Scope)
테스트 클래스 단위 (기본)
companion object {
@Container
@ServiceConnection
@JvmStatic
val postgres = PostgreSQLContainer("postgres:17")
}
companion object에 선언하면 클래스 내 모든 테스트가 하나의 컨테이너를 공유한다.
DowntimeApiServiceTests 시작
└─ PostgreSQL 컨테이너 생성
└─ @Test 다운타임_등록 ← 같은 컨테이너
└─ @Test 다운타임_삭제 ← 같은 컨테이너
└─ @Test 다운타임_검색 ← 같은 컨테이너
└─ PostgreSQL 컨테이너 삭제
DowntimeValidatorTests 시작
└─ PostgreSQL 컨테이너 생성 (새로)
└─ ...
테스트 메서드 단위
// companion object가 아닌 인스턴스 필드로 선언
@Container
@ServiceConnection
val postgres = PostgreSQLContainer("postgres:17")
메서드마다 새 컨테이너가 뜬다. 완전한 격리가 되지만 느리다. 일반적으로 권장하지 않는다.
싱글톤 패턴 — 전체 테스트 스위트에서 하나만
abstract class IntegrationTestBase {
companion object {
@JvmStatic
val postgres: PostgreSQLContainer<*> = PostgreSQLContainer("postgres:17")
.withDatabaseName("downtime")
.apply { start() } // 수동으로 start, JVM 종료 시 자동 정리
@DynamicPropertySource
@JvmStatic
fun properties(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl)
registry.add("spring.datasource.username", postgres::getUsername)
registry.add("spring.datasource.password", postgres::getPassword)
}
}
}
@SpringBootTest
@ActiveProfiles("test")
class DowntimeApiServiceTests : IntegrationTestBase() {
// 모든 테스트 클래스가 같은 PostgreSQL 컨테이너를 공유
}
컨테이너를 한 번만 띄우고 모든 테스트에서 재사용한다. Gradle 빌드 기준으로 JVM이 종료될 때 Ryuk(Testcontainers의 가비지 컬렉터)이 컨테이너를 자동 삭제한다.
Gradle 테스트 실행
└─ PostgreSQL 컨테이너 생성 (한 번)
└─ DowntimeApiServiceTests ← 같은 컨테이너
└─ DowntimeValidatorTests ← 같은 컨테이너
└─ 모든 테스트 완료
└─ JVM 종료 → Ryuk이 컨테이너 삭제
가장 실용적인 방식이다. 테스트 간 데이터 격리는 @Transactional 롤백으로 처리한다.
초기 스키마와 테스트 데이터
컨테이너가 뜨면 빈 DB다. 테이블을 만들어야 한다.
Spring SQL Init 활용
# application-test.yml
spring:
sql:
init:
mode: always
schema-locations: classpath:database-downtime/schema.sql
data-locations: classpath:database-downtime/data.sql
Spring Boot가 컨테이너 시작 후 자동으로 schema.sql → data.sql 순서로 실행한다.
Flyway/Liquibase 활용
프로젝트에서 마이그레이션 도구를 쓰고 있다면 별도 설정 없이 자동으로 적용된다.
# application-test.yml
spring:
flyway:
locations: classpath:db/migration
Testcontainers가 빈 DB를 만들고 → Flyway가 마이그레이션을 실행하고 → 테스트가 시작된다. 운영과 동일한 스키마가 보장된다.
init script 직접 지정
val postgres = PostgreSQLContainer("postgres:17")
.withDatabaseName("downtime")
.withInitScript("database-downtime/schema.sql")
withInitScript로 컨테이너 시작 시 SQL 파일을 직접 실행할 수도 있다. Spring에 의존하지 않는 방식이다.
로컬 개발 환경 — spring-boot-docker-compose
Testcontainers는 테스트 실행 시에만 컨테이너를 띄운다. 로컬 개발(애플리케이션 실행) 시에도 같은 방식을 쓰고 싶다면 두 가지 선택지가 있다.
TestApplication 방식
// src/test/kotlin/com/example/TestApplication.kt
fun main(args: Array<String>) {
fromApplication<Application>()
.with(TestcontainersConfig::class.java)
.run(*args)
}
IDE에서 TestApplication을 실행하면 Testcontainers가 DB를 띄우고 애플리케이션이 시작된다. 별도 Docker Compose 파일이 필요 없다.
spring-boot-docker-compose 방식
// build.gradle.kts
dependencies {
developmentOnly("org.springframework.boot:spring-boot-docker-compose")
}
# compose.yml
services:
postgres:
image: postgres:17
ports:
- "5432:5432"
environment:
POSTGRES_DB: downtime
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
./gradlew bootRun 하면 Spring Boot가 자동으로 compose.yml의 컨테이너를 띄운다. 애플리케이션 종료 시 컨테이너도 정리된다.
속도 최적화
첫 실행 vs 이후 실행
첫 실행: 이미지 pull (한 번만) → 30초~1분
두 번째: 이미지 캐시, 컨테이너만 생성 → 2~3초
테스트: 일반 JDBC 연결과 동일 → 속도 차이 없음
Docker 이미지는 로컬에 캐시되므로 첫 실행만 느리다.
컨테이너 재사용 (reuse)
val postgres = PostgreSQLContainer("postgres:17")
.withReuse(true)
# ~/.testcontainers.properties (사용자 홈 디렉토리)
testcontainers.reuse.enable=true
withReuse(true)를 설정하면 테스트가 끝나도 컨테이너를 삭제하지 않는다. 다음 테스트 실행 시 기존 컨테이너를 재사용한다. 로컬 개발 시 반복 실행 속도가 크게 향상된다.
reuse 미사용: 컨테이너 생성(3초) → 테스트 → 컨테이너 삭제 (매번 반복)
reuse 사용: 컨테이너 이미 있음 → 테스트 (생성/삭제 생략)
CI 환경에서는
reuse를 쓰지 않는 게 좋다. 이전 테스트의 데이터가 남아있을 수 있기 때문이다.
싱글톤 패턴 + @Transactional 조합
가장 실용적인 최적화다.
싱글톤: 전체 테스트 스위트에서 컨테이너 1개만 생성
@Transactional: 각 테스트가 끝나면 롤백 → DB 상태 자동 원복
컨테이너 생성 비용은 1번만 지불하고, 테스트 격리는 트랜잭션 롤백으로 해결한다.
다양한 컨테이너 지원
Testcontainers는 DB만 지원하는 게 아니다. Docker 이미지가 있는 모든 것을 테스트에 사용할 수 있다.
// PostgreSQL
val postgres = PostgreSQLContainer("postgres:17")
// MySQL
val mysql = MySQLContainer("mysql:8.4")
// Redis
val redis = GenericContainer("redis:7").withExposedPorts(6379)
// Kafka
val kafka = KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.8.0"))
// LocalStack (AWS S3, SQS 등)
val localstack = LocalStackContainer(DockerImageName.parse("localstack/localstack:4"))
.withServices(LocalStackContainer.Service.S3, LocalStackContainer.Service.SQS)
// Elasticsearch
val elasticsearch = ElasticsearchContainer("elasticsearch:8.17.0")
모두 @ServiceConnection으로 Spring Boot 자동 설정이 가능하다.
여러 컨테이너 동시 사용
@TestConfiguration(proxyBeanMethods = false)
class TestcontainersConfig {
@Bean
@ServiceConnection
fun postgres(): PostgreSQLContainer<*> {
return PostgreSQLContainer("postgres:17")
}
@Bean
@ServiceConnection
fun redis(): GenericContainer<*> {
return GenericContainer("redis:7").withExposedPorts(6379)
}
}
DB + Redis 조합처럼 여러 인프라를 동시에 컨테이너로 띄울 수 있다.
Ryuk — 가비지 컬렉터
Testcontainers를 쓰면 Docker에 testcontainers-ryuk이라는 컨테이너가 하나 더 뜬다.
$ docker ps
CONTAINER ID IMAGE PORTS
a1b2c3d4 postgres:17 0.0.0.0:32789->5432/tcp
e5f6g7h8 testcontainers/ryuk:0.11 0.0.0.0:32790->8080/tcp
Ryuk의 역할은 고아 컨테이너 정리다. 테스트가 비정상 종료(kill, OutOfMemoryError 등)되어 컨테이너 정리 코드가 실행되지 못해도, Ryuk이 JVM과의 연결이 끊어진 것을 감지하고 관련 컨테이너를 삭제한다.
정상 종료: 테스트 완료 → 컨테이너 정리 코드 실행 → 삭제
비정상 종료: 테스트 kill → 정리 코드 실행 안 됨 → Ryuk이 감지 → 삭제
Docker에 좀비 컨테이너가 쌓이는 것을 방지한다.
정리
| 항목 | H2 인메모리 | 로컬 DB 직접 설치 | Testcontainers |
|---|---|---|---|
| 설치 | 없음 | DB 수동 설치 | Docker만 필요 |
| 쿼리 호환성 | DB마다 다름 | 운영과 동일 | 운영과 동일 |
| 환경 일관성 | 높음 | 낮음 (개인별 다름) | 높음 |
| CI/CD | 쉬움 | DB 설치 필요 | Docker만 있으면 됨 |
| 테스트 격리 | 자동 (인메모리) | 수동 관리 필요 | 컨테이너 단위 격리 |
| 속도 | 가장 빠름 | 빠름 | 초기 약간 느림 |
| PostgreSQL 고유 기능 | 사용 불가 | 사용 가능 | 사용 가능 |
운영 DB와 다른 DB로 테스트하는 건 “연습은 맨손으로 하고 시합은 글러브 끼고 하는 것”과 같다. PostgreSQL을 쓴다면 테스트도 PostgreSQL에서 돌려야 한다. Testcontainers는 그 비용을 최소화해주는 도구다.
댓글