Testcontainers란?
Testcontainers는 테스트 실행 시 Docker 컨테이너로 실제 인프라를 띄워주는 라이브러리다. H2 같은 인메모리 DB 대신 실제 MySQL, PostgreSQL, Redis, Kafka를 컨테이너로 구동해서 테스트한다. “로컬에서 되는데 서버에서 안 돼요” 문제를 근본적으로 해결한다.
Spring Boot 3.1부터 Testcontainers를 1급 시민(First-class citizen)으로 지원한다. @ServiceConnection 어노테이션 하나로 컨테이너와 Spring 프로퍼티를 자동 연결하고, @TestConfiguration으로 로컬 개발 환경까지 재사용할 수 있다.
의존성 설정
// build.gradle.kts
dependencies {
// Testcontainers BOM
testImplementation(platform("org.testcontainers:testcontainers-bom:1.20.4"))
testImplementation("org.testcontainers:junit-jupiter")
testImplementation("org.testcontainers:mysql")
testImplementation("org.testcontainers:postgresql")
// Spring Boot Testcontainers 통합
testImplementation("org.springframework.boot:spring-boot-testcontainers")
testImplementation("org.springframework.boot:spring-boot-starter-test")
// Redis, Kafka 등 추가 모듈
testImplementation("org.testcontainers:kafka")
testImplementation("com.redis:testcontainers-redis:2.2.2")
}
@ServiceConnection: 자동 프로퍼티 바인딩
Spring Boot 3.1 이전에는 @DynamicPropertySource로 컨테이너의 호스트/포트를 수동 매핑했다. @ServiceConnection은 이를 자동화한다.
Before: @DynamicPropertySource (수동)
@Testcontainers
@SpringBootTest
class OrderRepositoryTest {
companion object {
@Container
val mysql = MySQLContainer("mysql:8.0")
.withDatabaseName("testdb")
@JvmStatic
@DynamicPropertySource
fun properties(registry: DynamicPropertyRegistry) {
// 수동으로 매핑해야 함
registry.add("spring.datasource.url") { mysql.jdbcUrl }
registry.add("spring.datasource.username") { mysql.username }
registry.add("spring.datasource.password") { mysql.password }
}
}
}
After: @ServiceConnection (자동)
@Testcontainers
@SpringBootTest
class OrderRepositoryTest {
companion object {
@Container
@ServiceConnection // URL, username, password 자동 바인딩!
val mysql = MySQLContainer("mysql:8.0")
.withDatabaseName("testdb")
.withReuse(true) // 컨테이너 재사용으로 속도 향상
}
@Autowired
lateinit var orderRepository: OrderRepository
@Test
fun `주문 저장 후 조회`() {
val order = Order(product = "MacBook", amount = 2_500_000)
orderRepository.save(order)
val found = orderRepository.findById(order.id!!)
assertThat(found).isPresent
assertThat(found.get().product).isEqualTo("MacBook")
}
}
@ServiceConnection이 지원하는 컨테이너 타입:
| 컨테이너 | 자동 설정되는 프로퍼티 |
|---|---|
| MySQLContainer | spring.datasource.url/username/password |
| PostgreSQLContainer | spring.datasource.url/username/password |
| RedisContainer | spring.data.redis.host/port |
| KafkaContainer | spring.kafka.bootstrap-servers |
| MongoDBContainer | spring.data.mongodb.uri |
| RabbitMQContainer | spring.rabbitmq.host/port/username/password |
@TestConfiguration으로 로컬 개발 환경 구성
테스트용 컨테이너를 로컬 개발 시에도 재사용할 수 있다. spring-boot-devtools와 결합하면 별도 Docker Compose 없이 개발 환경을 구성할 수 있다.
// src/test/java 아래에 위치
@TestConfiguration(proxyBeanMethods = false)
class TestcontainersConfig {
@Bean
@ServiceConnection
fun mysqlContainer(): MySQLContainer<*> {
return MySQLContainer("mysql:8.0")
.withDatabaseName("devdb")
.withReuse(true) // 앱 재시작해도 컨테이너 유지
}
@Bean
@ServiceConnection
fun redisContainer(): GenericContainer<*> {
return GenericContainer("redis:7-alpine")
.withExposedPorts(6379)
.withReuse(true)
}
@Bean
@ServiceConnection
fun kafkaContainer(): KafkaContainer {
return KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0"))
.withReuse(true)
}
}
로컬 개발 시 실행
// TestApplication.kt — 로컬 개발용 엔트리포인트
fun main(args: Array<String>) {
fromApplication<Application>()
.with(TestcontainersConfig::class.java) // 테스트 설정 포함
.run(*args)
}
// 실행: ./gradlew bootTestRun
// 또는 IDE에서 TestApplication.kt 직접 실행
멀티 컨테이너 통합 테스트
실전에서는 DB + Redis + Kafka를 동시에 사용하는 경우가 많다. 여러 컨테이너를 조합한 통합 테스트 구성이다.
@Testcontainers
@SpringBootTest
@ActiveProfiles("test")
class OrderIntegrationTest {
companion object {
@Container
@ServiceConnection
val postgres = PostgreSQLContainer("postgres:16-alpine")
.withDatabaseName("orderdb")
.withInitScript("schema.sql") // 초기 스키마 로드
@Container
@ServiceConnection
val redis = GenericContainer("redis:7-alpine")
.withExposedPorts(6379)
@Container
@ServiceConnection
val kafka = KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.6.0")
)
}
@Autowired lateinit var orderService: OrderService
@Autowired lateinit var redisTemplate: StringRedisTemplate
@Autowired lateinit var kafkaTemplate: KafkaTemplate<String, String>
@Test
fun `주문 생성 시 캐시 저장 + 이벤트 발행`() {
// Given
val request = CreateOrderRequest("MacBook", 1, 2_500_000)
// When
val order = orderService.createOrder(request)
// Then - DB 저장 확인
val saved = orderService.getOrder(order.id)
assertThat(saved.product).isEqualTo("MacBook")
// Then - Redis 캐시 확인
val cached = redisTemplate.opsForValue()
.get("order:${order.id}")
assertThat(cached).isNotNull()
// Then - Kafka 이벤트 발행 확인
await().atMost(5, TimeUnit.SECONDS).untilAsserted {
// Consumer에서 이벤트 수신 확인
verify(orderEventListener).onOrderCreated(any())
}
}
}
컨테이너 재사용: 테스트 속도 최적화
Testcontainers의 가장 큰 단점은 컨테이너 시작 시간이다. MySQL은 약 10초, Kafka는 15초 이상 걸린다. withReuse(true)와 싱글톤 패턴으로 이를 최적화한다.
전략 1: withReuse(true)
// ~/.testcontainers.properties 파일에 추가 (필수!)
testcontainers.reuse.enable=true
// 컨테이너에 withReuse(true) 설정
val mysql = MySQLContainer("mysql:8.0")
.withReuse(true) // 테스트 종료 후에도 컨테이너 유지
// 다음 테스트 실행 시 기존 컨테이너 재사용
전략 2: 싱글톤 컨테이너 패턴
// 모든 테스트가 공유하는 추상 클래스
abstract class IntegrationTestBase {
companion object {
// static 블록에서 한 번만 시작
val postgres: PostgreSQLContainer<*> = PostgreSQLContainer("postgres:16-alpine")
.withDatabaseName("testdb")
.apply { start() } // 즉시 시작
val redis: GenericContainer<*> = GenericContainer("redis:7-alpine")
.withExposedPorts(6379)
.apply { start() }
@JvmStatic
@DynamicPropertySource
fun configureProperties(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url") { postgres.jdbcUrl }
registry.add("spring.datasource.username") { postgres.username }
registry.add("spring.datasource.password") { postgres.password }
registry.add("spring.data.redis.host") { redis.host }
registry.add("spring.data.redis.port") { redis.firstMappedPort }
}
}
}
// 개별 테스트에서 상속
@SpringBootTest
class UserServiceTest : IntegrationTestBase() {
// postgres, redis 컨테이너가 이미 실행 중
}
@SpringBootTest
class OrderServiceTest : IntegrationTestBase() {
// 같은 컨테이너 재사용 → 시작 시간 0초
}
전략 3: @TestConfiguration + @Bean (Spring Boot 3.1+ 권장)
@TestConfiguration(proxyBeanMethods = false)
class SharedContainerConfig {
@Bean
@ServiceConnection
@RestartScope // DevTools 재시작 시에도 컨테이너 유지
fun postgres(): PostgreSQLContainer<*> {
return PostgreSQLContainer("postgres:16-alpine")
.withReuse(true)
}
}
// @Import로 가져다 쓰기
@SpringBootTest
@Import(SharedContainerConfig::class)
class PaymentServiceTest { ... }
테스트 데이터 격리 전략
컨테이너를 공유하면 테스트 간 데이터 오염 문제가 발생한다. 3가지 격리 전략을 비교한다.
전략 1: @Transactional 롤백 (권장)
@SpringBootTest
@Transactional // 각 테스트 후 자동 롤백
class OrderRepositoryTest : IntegrationTestBase() {
@Test
fun `주문 저장`() {
// 테스트 데이터는 테스트 끝나면 롤백됨
orderRepository.save(Order("test", 1000))
}
}
전략 2: @Sql로 매 테스트 초기화
@SpringBootTest
@Sql(
statements = ["DELETE FROM orders", "DELETE FROM users"],
executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD
)
class OrderServiceTest : IntegrationTestBase() { ... }
전략 3: TestExecutionListener로 테이블 초기화
class DatabaseCleanupListener : TestExecutionListener {
override fun beforeTestMethod(testContext: TestContext) {
val dataSource = testContext.applicationContext
.getBean(DataSource::class.java)
JdbcTemplate(dataSource).execute("""
SET REFERENTIAL_INTEGRITY FALSE;
TRUNCATE TABLE orders;
TRUNCATE TABLE users;
SET REFERENTIAL_INTEGRITY TRUE;
""")
}
}
@SpringBootTest
@TestExecutionListeners(
listeners = [DatabaseCleanupListener::class],
mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS
)
class CleanTest : IntegrationTestBase() { ... }
CI/CD 환경 설정: GitHub Actions
# .github/workflows/test.yml
name: Integration Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
# Docker가 이미 설치되어 있으므로 바로 실행 가능
- name: Run Tests
run: ./gradlew test
env:
TESTCONTAINERS_RYUK_DISABLED: true # CI에서 Ryuk 비활성화
DOCKER_HOST: unix:///var/run/docker.sock
커스텀 컨테이너: GenericContainer 활용
// 공식 모듈이 없는 서비스도 GenericContainer로 구성
@Container
val elasticsearch = GenericContainer("elasticsearch:8.12.0")
.withExposedPorts(9200)
.withEnv("discovery.type", "single-node")
.withEnv("xpack.security.enabled", "false")
.waitingFor(Wait.forHttp("/").forStatusCode(200))
// LocalStack으로 AWS 서비스 테스트
@Container
val localstack = GenericContainer("localstack/localstack:3.0")
.withExposedPorts(4566)
.withEnv("SERVICES", "s3,sqs,dynamodb")
.waitingFor(Wait.forLogMessage(".*Ready.*", 1))
Testcontainers는 “테스트 환경과 운영 환경의 차이”라는 근본 문제를 해결한다. H2로 테스트하고 MySQL로 배포하는 시대는 끝났다. 실제 DB에서 테스트하고, 실제 메시지 브로커로 검증하라. @ServiceConnection과 withReuse(true)를 조합하면 개발 생산성을 잃지 않으면서도 테스트 신뢰도를 극적으로 높일 수 있다.