Spring 테스트 — Testcontainers로 실제 DB 테스트 완전 정복

Testcontainers로 PostgreSQL·MySQL·Redis·Kafka 컨테이너를 테스트에서 제어하는 방법, @DynamicPropertySource 연동, 컨테이너 재사용(withReuse) 전략, Spring Boot 3.1+ 서비스 커넥션 자동 구성, Flyway 마이그레이션 통합까지 실전 예제로 정리합니다.

· 6 min read · PALDYN Team

지난 글에서 MockMvc로 Controller 레이어를 세밀하게 검증하는 방법을 알아봤습니다. Controller는 MockMvc로 충분히 검증할 수 있지만, Repository 레이어는 H2 인메모리 DB와 실제 PostgreSQL이 미묘하게 다를 수 있습니다. Testcontainers는 테스트 실행 시 실제 Docker 컨테이너를 제어해 이 간격을 없애줍니다.

Testcontainers란?

Testcontainers는 JUnit 5와 통합되어 테스트 생명주기에 맞게 Docker 컨테이너를 시작·종료합니다. PostgreSQL, MySQL, Redis, Kafka, Elasticsearch 등 수십 종의 컨테이너 모듈을 제공하며, 임의 이미지(GenericContainer)도 사용할 수 있습니다.

<!-- Maven 의존성 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <scope>test</scope>
</dependency>

Spring Boot 3.1+ 이상은 spring-boot-testcontainers가 BOM에 포함되어 버전 관리를 자동 처리합니다.

기본 사용법

@DataJpaTest + PostgreSQL 컨테이너

@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)  // H2 자동 교체 비활성화
@Testcontainers
class UserRepositoryTest {

    @Container
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test");

    @DynamicPropertySource
    static void registerProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired
    UserRepository userRepository;

    @Test
    void JSON_타입_컬럼_쿼리() {
        // PostgreSQL 전용 JSON 연산자 테스트
        User user = new User("홍길동", Map.of("theme", "dark", "lang", "ko"));
        userRepository.save(user);

        List<User> found = userRepository.findByPreference("theme", "dark");
        assertThat(found).hasSize(1);
    }
}

@Container static으로 선언하면 테스트 클래스 전체에서 컨테이너를 공유합니다. static 없이 인스턴스 필드로 선언하면 테스트 메서드마다 재생성되어 느려집니다.

Testcontainers 동작 구조

Testcontainers 동작 구조와 컨테이너 모듈

Spring Boot 3.1+ @ServiceConnection

Spring Boot 3.1부터 @ServiceConnection을 사용하면 @DynamicPropertySource 없이도 자동으로 DataSource 설정이 연결됩니다.

@SpringBootTest
@Testcontainers
class ServiceConnectionTest {

    @Container
    @ServiceConnection  // 자동으로 spring.datasource.* 설정
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16");

    @Container
    @ServiceConnection  // 자동으로 spring.data.redis.* 설정
    static GenericContainer<?> redis =
        new GenericContainer<>("redis:7")
            .withExposedPorts(6379);

    @Autowired
    UserRepository userRepository;

    @Autowired
    StringRedisTemplate redisTemplate;
}

@ServiceConnection은 컨테이너 타입을 인식해 적절한 프로퍼티 키를 자동 매핑합니다. PostgreSQLContainerspring.datasource.*, RedisContainerspring.data.redis.* 등.

컨테이너 재사용 전략

withReuse(true) — JVM 수준 재사용

@Container
static PostgreSQLContainer<?> postgres =
    new PostgreSQLContainer<>("postgres:16")
        .withReuse(true);  // 컨테이너를 JVM 종료까지 유지

withReuse(true)를 사용하려면 ~/.testcontainers.properties에 다음을 추가해야 합니다.

testcontainers.reuse.enable=true

컨테이너가 이미 실행 중이면 새로 시작하지 않아 전체 테스트 스위트 실행 시간이 대폭 줄어듭니다.

공유 기반 클래스 — 여러 테스트에서 재사용

@SpringBootTest
@Testcontainers
@ActiveProfiles("integration")
abstract class IntegrationTestBase {

    @Container
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16")
            .withReuse(true);

    @Container
    static GenericContainer<?> redis =
        new GenericContainer<>("redis:7")
            .withExposedPorts(6379)
            .withReuse(true);

    @DynamicPropertySource
    static void registerProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
        registry.add("spring.data.redis.host", redis::getHost);
        registry.add("spring.data.redis.port",
            () -> redis.getMappedPort(6379));
    }
}

이 기반 클래스를 상속하는 모든 테스트가 같은 컨테이너와 Spring ApplicationContext를 재사용합니다. @MockBean이 없으면 캐시 키가 동일해 컨텍스트도 재사용됩니다.

Testcontainers 실전 코드 패턴

Kafka 통합 테스트

@SpringBootTest
@Testcontainers
class OrderEventTest extends IntegrationTestBase {

    @Container
    static KafkaContainer kafka =
        new KafkaContainer(DockerImageName.parse(
            "confluentinc/cp-kafka:7.6.0"));

    @DynamicPropertySource
    static void kafkaProps(DynamicPropertyRegistry registry) {
        registry.add("spring.kafka.bootstrap-servers",
            kafka::getBootstrapServers);
    }

    @Autowired
    KafkaTemplate<String, OrderEvent> kafkaTemplate;

    @Autowired
    OrderEventConsumer consumer;

    @Test
    void 주문_이벤트_발행_소비() throws Exception {
        kafkaTemplate.send("orders",
            new OrderEvent("ORD-001", OrderStatus.PLACED));

        // 컨슈머가 메시지를 처리할 때까지 대기
        assertThat(consumer.getLatch().await(10, TimeUnit.SECONDS))
            .isTrue();
        assertThat(consumer.getLastEvent().getOrderId())
            .isEqualTo("ORD-001");
    }
}

Flyway와 함께 사용

Testcontainers + Flyway 조합은 마이그레이션 스크립트가 실제 DB에서 올바르게 동작하는지 검증하는 강력한 방법입니다.

@SpringBootTest
@Testcontainers
class FlywayMigrationTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16");

    @Autowired
    Flyway flyway;

    @Test
    void 마이그레이션_실행_성공() {
        MigrationInfoService info = flyway.info();

        assertThat(info.pending()).isEmpty();
        assertThat(info.applied()).isNotEmpty();
        assertThat(info.current().getVersion().toString())
            .isNotEmpty();
    }
}

테스트 데이터 격리

Testcontainers 환경에서는 컨테이너가 공유되므로 테스트 간 데이터 격리가 중요합니다.

abstract class IntegrationTestBase {
    // ... 컨테이너 설정

    @BeforeEach
    void cleanDatabase(@Autowired DataSource dataSource) throws Exception {
        try (Connection conn = dataSource.getConnection();
             Statement stmt = conn.createStatement()) {
            // 모든 테이블 트런케이트
            stmt.execute("TRUNCATE TABLE users, orders RESTART IDENTITY CASCADE");
        }
    }
}

또는 @Sql로 각 테스트마다 초기 데이터를 넣고 정리합니다.

@Test
@Sql("/sql/insert-users.sql")
@Sql(scripts = "/sql/cleanup.sql",
     executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
void 사용자_이메일_검색() {
    List<User> found = userRepository.findByEmailDomain("example.com");
    assertThat(found).hasSize(2);
}

Testcontainers Desktop

로컬 개발 시 Testcontainers Desktop 앱을 설치하면 컨테이너 상태를 GUI로 확인하고, 고정 포트로 컨테이너를 노출해 디버깅할 수 있습니다. 컨테이너가 예상대로 시작되지 않을 때 특히 유용합니다.

성능 최적화 체크리스트

항목설명
static @Container클래스당 1개 컨테이너 공유
withReuse(true)JVM 수준 재사용으로 시작 오버헤드 제거
공유 기반 클래스Spring Context 캐시 키 통일 → 재사용
@MockBean 최소화Context 캐시 무효화 방지
병렬 테스트 실행JUnit 5 parallel execution 활성화

H2로 빠른 단위 수준 Repository 테스트를 하고, Testcontainers로 통합 테스트를 분리하는 전략이 실무에서 가장 효율적입니다.


지난 글: Spring 테스트 — MockMvc 심화 완전 정복


읽어주셔서 감사합니다. 😊