Testcontainers — 실제 DB, Redis, Kafka를 테스트에서 띄우는 방법
H2로 테스트하면 통과하는데, 실제 MySQL에서는 실패하는 경험을 해보셨나요?
임베디드 DB는 빠르고 편하지만, 운영 환경과 동일한 DB로 테스트하지 않으면 "테스트는 통과했는데 배포 후 장애"가 발생할 수 있습니다. Testcontainers는 Docker로 실제 인프라를 테스트 환경에서 자동으로 띄워주어 이 문제를 해결합니다.
Testcontainers란
Testcontainers는 Docker 컨테이너를 테스트 코드에서 프로그래밍 방식으로 관리하는 라이브러리입니다.
- 테스트 시작 시 Docker 컨테이너 자동 시작
- 테스트 종료 시 ** 자동 정리**
- 실제 MySQL, PostgreSQL, Redis, Kafka 등을 사용
- ** 랜덤 포트** 매핑으로 테스트 간 충돌 방지
의존성 추가
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
MySQL 컨테이너로 Repository 테스트
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class ProductRepositoryTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop");
}
컨테이너가 실행된 후 실제 MySQL에서 Repository 메서드가 정상 동작하는지 검증합니다.
@Autowired
private ProductRepository productRepository;
@Test
void 실제_MySQL에서_상품_저장_조회() {
Product product = Product.builder()
.name("실제 DB 테스트")
.price(10000)
.build();
Product saved = productRepository.save(product);
Product found = productRepository.findById(saved.getId()).orElseThrow();
assertThat(found.getName()).isEqualTo("실제 DB 테스트");
}
}
@DynamicPropertySource가 필요한 이유
Testcontainers는 랜덤 포트를 사용합니다. mysql:8.0 컨테이너가 매번 다른 포트에 바인딩되므로, application.yml에 고정 URL을 넣을 수 없습니다. @DynamicPropertySource로 ** 컨테이너가 실행된 후** 실제 URL을 Spring 프로퍼티에 주입합니다.
Redis 컨테이너 테스트
@SpringBootTest
@Testcontainers
class RedisCacheTest {
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@DynamicPropertySource
static void configureRedis(DynamicPropertyRegistry registry) {
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
}
@DynamicPropertySource로 컨테이너의 랜덤 포트를 Spring 프로퍼티에 주입한 뒤 테스트를 실행합니다.
@Autowired
private StringRedisTemplate redisTemplate;
@Test
void Redis에_값_저장_조회() {
redisTemplate.opsForValue().set("test-key", "hello");
String value = redisTemplate.opsForValue().get("test-key");
assertThat(value).isEqualTo("hello");
}
}
Kafka 컨테이너 테스트
@SpringBootTest
@Testcontainers
class KafkaIntegrationTest {
@Container
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.5.0"));
@DynamicPropertySource
static void configureKafka(DynamicPropertyRegistry registry) {
registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
}
실제 Kafka 컨테이너에서 메시지를 발행하고, 컨슈머가 수신했는지 비동기로 검증합니다.
@Autowired
private KafkaTemplate<String, String> kafkaTemplate;
@Autowired
private KafkaConsumer testConsumer; // 테스트용 컨슈머
@Test
void 메시지_발행_소비() throws Exception {
kafkaTemplate.send("test-topic", "hello kafka").get();
// 컨슈머가 메시지를 받을 때까지 대기
await().atMost(Duration.ofSeconds(10))
.untilAsserted(() ->
assertThat(testConsumer.getReceivedMessages())
.contains("hello kafka")
);
}
}
컨테이너 공유 — 테스트 속도 최적화
static 필드로 클래스 단위 공유
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");
// → 클래스 내 모든 테스트 메서드가 같은 컨테이너를 공유
static이 아니면 매 테스트 메서드마다 컨테이너가 시작/종료되어 매우 느려집니다.
추상 클래스로 여러 테스트 클래스에서 공유
public abstract class IntegrationTestBase {
static final MySQLContainer<?> MYSQL;
static final GenericContainer<?> REDIS;
static {
MYSQL = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
MYSQL.start();
REDIS = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
REDIS.start();
}
static 블록에서 시작한 컨테이너의 접속 정보를 @DynamicPropertySource로 Spring에 주입합니다.
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", MYSQL::getJdbcUrl);
registry.add("spring.datasource.username", MYSQL::getUsername);
registry.add("spring.datasource.password", MYSQL::getPassword);
registry.add("spring.data.redis.host", REDIS::getHost);
registry.add("spring.data.redis.port", () -> REDIS.getMappedPort(6379));
}
}
// 개별 테스트 클래스에서 상속
@SpringBootTest
class OrderServiceTest extends IntegrationTestBase {
// MySQL + Redis 컨테이너가 이미 실행 중
}
Reusable Containers
테스트 실행 사이에도 컨테이너를 유지하고 싶다면 reusable 옵션을 사용합니다.
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withReuse(true); // 컨테이너 재사용
~/.testcontainers.properties 파일에 활성화 설정이 필요합니다.
testcontainers.reuse.enable=true
reusable 컨테이너는 테스트 종료 후에도 Docker 컨테이너가 유지됩니다. 다음 테스트 실행 시 같은 컨테이너를 재사용하여 시작 시간을 절약합니다.
Spring Boot 3.1+ ServiceConnection
Spring Boot 3.1부터는 @ServiceConnection으로 더 간편하게 설정할 수 있습니다.
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class ProductRepositoryTest {
@Container
@ServiceConnection // @DynamicPropertySource 대신 자동 설정
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");
@Autowired
private ProductRepository productRepository;
@Test
void 상품_저장() {
// @DynamicPropertySource 없이도 자동으로 DataSource가 설정됨
Product saved = productRepository.save(
Product.builder().name("테스트").price(1000).build());
assertThat(saved.getId()).isNotNull();
}
}
@ServiceConnection은 컨테이너 타입을 보고 자동으로 적절한 Spring 프로퍼티를 설정합니다. @DynamicPropertySource 보일러플레이트를 제거할 수 있습니다.
실무 팁
- Docker Desktop 이 실행 중이어야 Testcontainers가 동작합니다
- CI 환경 에서는 Docker-in-Docker 또는 DinD 지원이 필요합니다 (GitHub Actions는 기본 지원)
- 컨테이너 이미지를 명시적 태그 로 지정하세요 (
mysql:8.0,redis:7-alpine) - 첫 실행 시 이미지 다운로드 시간이 걸립니다. CI에서는 이미지 캐싱 을 설정하세요
- 테스트 데이터 격리를 위해
@Transactional롤백 또는@Sql로 데이터를 관리하세요
주의할 점
1. Docker Desktop이 실행 중이지 않으면 테스트가 즉시 실패한다
Testcontainers는 Docker 위에서 동작하므로 Docker 데몬이 실행 중이어야 합니다. CI 환경에서 Docker-in-Docker가 설정되어 있지 않거나, 로컬에서 Docker Desktop이 꺼져 있으면 테스트 자체가 시작되지 않습니다. CI 파이프라인에서 Docker 지원 여부를 미리 확인하세요.
2. 이미지 다운로드 시간 때문에 첫 실행이 매우 느릴 수 있다
MySQL, Kafka 등의 Docker 이미지가 로컬에 없으면 수백 MB의 이미지를 다운로드해야 합니다. CI에서 캐싱을 설정하지 않으면 매 빌드마다 이미지를 받아 빌드 시간이 크게 늘어납니다. CI에서는 Docker 레이어 캐싱을 반드시 설정하세요.
3. non-static으로 컨테이너를 선언하면 매 테스트 메서드마다 컨테이너가 재생성된다
@Container 필드를 static으로 선언하지 않으면 각 테스트 메서드 실행 시마다 컨테이너가 시작/종료됩니다. MySQL 컨테이너 하나 시작에 수 초가 걸리므로, 테스트 메서드가 10개면 수십 초가 컨테이너 시작에만 소비됩니다. 반드시 static으로 선언하여 클래스 단위로 공유하세요.
정리
- Testcontainers는 Docker로 실제 인프라를 테스트에서 자동 구동/정리 합니다
@DynamicPropertySource로 랜덤 포트 정보를 Spring에 주입하고, Spring Boot 3.1+에서는@ServiceConnection으로 더 간편합니다- static 필드 + 추상 클래스 패턴으로 컨테이너 시작 오버헤드를 최소화하세요
- H2로는 검증할 수 없는 DB 벤더 고유 동작 을 정확히 테스트할 수 있습니다