H2로 테스트하면 통과하는데, 실제 MySQL에서는 실패하는 경험을 해보셨나요?

임베디드 DB는 빠르고 편하지만, 운영 환경과 동일한 DB로 테스트하지 않으면 "테스트는 통과했는데 배포 후 장애"가 발생할 수 있습니다. Testcontainers는 Docker로 실제 인프라를 테스트 환경에서 자동으로 띄워주어 이 문제를 해결합니다.

Testcontainers란

Testcontainers는 Docker 컨테이너를 테스트 코드에서 프로그래밍 방식으로 관리하는 라이브러리입니다.

  • 테스트 시작 시 Docker 컨테이너 자동 시작
  • 테스트 종료 시 ** 자동 정리**
  • 실제 MySQL, PostgreSQL, Redis, Kafka 등을 사용
  • ** 랜덤 포트** 매핑으로 테스트 간 충돌 방지

의존성 추가

XML
<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 테스트

JAVA
@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 메서드가 정상 동작하는지 검증합니다.

JAVA
    @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 컨테이너 테스트

JAVA
@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 프로퍼티에 주입한 뒤 테스트를 실행합니다.

JAVA
    @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 컨테이너 테스트

JAVA
@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 컨테이너에서 메시지를 발행하고, 컨슈머가 수신했는지 비동기로 검증합니다.

JAVA
    @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 필드로 클래스 단위 공유

JAVA
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");
// → 클래스 내 모든 테스트 메서드가 같은 컨테이너를 공유

static이 아니면 매 테스트 메서드마다 컨테이너가 시작/종료되어 매우 느려집니다.

추상 클래스로 여러 테스트 클래스에서 공유

JAVA
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에 주입합니다.

JAVA
    @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 옵션을 사용합니다.

JAVA
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
        .withReuse(true);  // 컨테이너 재사용

~/.testcontainers.properties 파일에 활성화 설정이 필요합니다.

PROPERTIES
testcontainers.reuse.enable=true

reusable 컨테이너는 테스트 종료 후에도 Docker 컨테이너가 유지됩니다. 다음 테스트 실행 시 같은 컨테이너를 재사용하여 시작 시간을 절약합니다.

Spring Boot 3.1+ ServiceConnection

Spring Boot 3.1부터는 @ServiceConnection으로 더 간편하게 설정할 수 있습니다.

JAVA
@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 벤더 고유 동작 을 정확히 테스트할 수 있습니다
댓글 로딩 중...