Testcontainers Spring Boot 통합 테스트 가이드

이번 포스팅에서는 Testcontainers Spring Boot 통합 테스트에 대해서 정리하고자 한다. Mock이나 H2 같은 인메모리 DB 대신 실제 PostgreSQL, Kafka, Redis를 Docker 컨테이너로 띄워서 테스트하는 방법을 단계별로 살펴본다. Spring Boot 3.1에서 도입된 @ServiceConnection부터 컨테이너 재사용 전략까지, 실무에 바로 적용할 수 있는 내용을 담았다.

Testcontainers 공식 로고
Testcontainers 공식 로고 (출처: GitHub testcontainers/testcontainers-java)

Mock 테스트의 함정, 왜 Testcontainers인가

“테스트에서는 통과했는데 운영에서 터졌다.” 백엔드 개발자라면 한 번쯤 겪어 봤을 상황이다. H2 인메모리 데이터베이스로 테스트를 작성하면 PostgreSQL 전용 문법(jsonb, ON CONFLICT, Window Function 등)을 검증할 수 없다. Mockito로 Repository를 Mocking하면 쿼리 자체가 실행되지 않으니 SQL 오류를 잡을 방법이 없다.

Testcontainers가 이 문제를 풀어 준다. 테스트가 시작되면 Docker 컨테이너로 실제 PostgreSQL, Kafka, Redis를 띄우고, 테스트가 끝나면 컨테이너를 자동으로 정리한다. GitHub 스타 8,600개, 2026년 3월 기준 최신 버전 2.0.4. Spring Boot 공식 문서에서도 Testcontainers를 권장 통합 테스트 도구로 안내하고 있다.

Testcontainers Spring Boot 조합이 주는 것은 세 가지이다.

  • 운영 환경과 동일한 DB 엔진으로 테스트하므로 SQL 방언 차이에 의한 버그가 사라진다
  • 컨테이너 생명주기가 JUnit과 연동되어 테스트 코드 안에서 인프라를 선언적으로 관리할 수 있다
  • Spring Boot 3.1부터 @ServiceConnection 어노테이션으로 설정 코드가 절반 이하로 줄었다

프로젝트 세팅 — 의존성 한 방에 잡기

Testcontainers Spring Boot 프로젝트를 시작하려면 spring-boot-testcontainers 모듈과 사용할 DB 모듈을 추가해야 한다. Spring Boot 3.2 이상에서는 BOM(Bill of Materials)이 Testcontainers 버전을 관리해 주므로 버전을 직접 명시할 필요가 없다.

Gradle (Kotlin DSL)

dependencies {
    // 운영 의존성
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.springframework.boot:spring-boot-starter-web")
    runtimeOnly("org.postgresql:postgresql")

    // 테스트 의존성 — Testcontainers Spring Boot 통합
    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("org.springframework.boot:spring-boot-testcontainers") // @ServiceConnection 지원
    testImplementation("org.testcontainers:junit-jupiter") // JUnit 5 확장
    testImplementation("org.testcontainers:postgresql") // PostgreSQL 컨테이너 모듈
}
Kotlin

위 설정에서 spring-boot-testcontainers@ServiceConnection 어노테이션을 사용하기 위해 반드시 필요한 모듈이다. testcontainers:junit-jupiter@Testcontainers@Container 어노테이션을 통한 컨테이너 생명주기 자동 관리를 제공한다.

Maven

<dependencies>
    <!-- 운영 의존성 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <scope>runtime</scope>
    </dependency>

    <!-- 테스트 의존성 — Testcontainers Spring Boot 통합 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-testcontainers</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>postgresql</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>
XML

Maven 사용 시에도 Spring Boot BOM이 Testcontainers 버전을 관리하므로 <version> 태그를 생략할 수 있다. spring-boot-starter-parent를 상속받고 있다면 별도 BOM 설정이 불필요하다.

첫 번째 Testcontainers Spring Boot 테스트 작성하기

가장 기본적인 패턴부터 시작한다. PostgreSQL 컨테이너를 띄우고, JPA 엔티티의 CRUD를 실제 DB에서 검증하는 통합 테스트이다.

엔티티와 Repository 준비

package com.example.demo.domain;

import jakarta.persistence.*;

@Entity
@Table(name = "customers")
public class Customer {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false, unique = true)
    private String email;

    protected Customer() {} // JPA 기본 생성자

    public Customer(String name, String email) {
        this.name = name;
        this.email = email;
    }

    // getter 생략
    public Long getId() { return id; }
    public String getName() { return name; }
    public String getEmail() { return email; }
}
Java

위 엔티티는 customers 테이블에 매핑되며, email 컬럼에 unique 제약 조건이 걸려 있다. H2에서는 통과하지만 PostgreSQL에서 실패하는 시나리오(예: ON CONFLICT 쿼리)를 검증하려면 반드시 실제 PostgreSQL이 필요하다.

package com.example.demo.domain;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;

public interface CustomerRepository extends JpaRepository<Customer, Long> {

    // PostgreSQL 전용 문법 — H2에서는 동작하지 않는다
    @Query(value = "SELECT * FROM customers WHERE name ILIKE CONCAT('%', :keyword, '%')", nativeQuery = true)
    List<Customer> searchByNameIgnoreCase(@Param("keyword") String keyword);
}
Java

ILIKE는 PostgreSQL 전용 대소문자 무시 검색 연산자이다. H2에서는 ILIKE를 지원하지 않으므로 이 쿼리를 Mock이나 H2로는 제대로 검증할 수 없다. 이것이 Testcontainers Spring Boot 통합 테스트가 필요한 이유이다.

@ServiceConnection을 사용한 테스트

package com.example.demo;

import com.example.demo.domain.Customer;
import com.example.demo.domain.CustomerRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest // 전체 애플리케이션 컨텍스트를 로드한다
@Testcontainers // Testcontainers JUnit 5 확장을 활성화한다
class CustomerRepositoryTest {

    @Container // 이 필드를 컨테이너로 관리하겠다는 선언
    @ServiceConnection // Spring Boot가 자동으로 DataSource 연결 정보를 주입한다
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired
    private CustomerRepository customerRepository;

    @BeforeEach
    void setUp() {
        customerRepository.deleteAll(); // 테스트 간 데이터 격리
    }

    @Test
    void PostgreSQL_ILIKE_검색이_정상_동작한다() {
        // given — 테스트 데이터 삽입
        customerRepository.save(new Customer("Kim Younghee", "younghee@test.com"));
        customerRepository.save(new Customer("Park Minsoo", "minsoo@test.com"));

        // when — 대소문자 무시 검색
        List<Customer> result = customerRepository.searchByNameIgnoreCase("kim");

        // then — PostgreSQL ILIKE가 정상 동작하는지 검증
        assertThat(result).hasSize(1);
        assertThat(result.get(0).getName()).isEqualTo("Kim Younghee");
    }

    @Test
    void 이메일_중복_저장_시_예외가_발생한다() {
        // given
        customerRepository.save(new Customer("User1", "duplicate@test.com"));

        // when & then — unique 제약 조건 위반
        org.junit.jupiter.api.Assertions.assertThrows(
            org.springframework.dao.DataIntegrityViolationException.class,
            () -> customerRepository.save(new Customer("User2", "duplicate@test.com"))
        );
    }
}
Java

여기서 눈여겨볼 부분은 @ServiceConnection이다. 이 어노테이션 하나가 spring.datasource.url, spring.datasource.username, spring.datasource.password를 컨테이너 정보에서 뽑아서 Spring에 주입한다. 과거 @DynamicPropertySource 방식에서 반복하던 프로퍼티 등록 코드가 사라진다.

@ServiceConnection vs @DynamicPropertySource — 무엇을 쓸까

Spring Boot 3.1 이전에는 Testcontainers Spring Boot 연동 시 @DynamicPropertySource를 사용해 컨테이너의 접속 정보를 수동으로 등록해야 했다.

과거 방식 — @DynamicPropertySource

@SpringBootTest
@Testcontainers
class LegacyStyleTest {

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

    // 접속 정보를 하나하나 수동 등록해야 한다
    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    // 테스트 코드 ...
}
Java

위 코드에서 @DynamicPropertySource 메서드는 컨테이너가 시작된 후 호출되어 동적으로 프로퍼티를 주입한다. 동작에는 문제가 없지만 컨테이너를 추가할 때마다 프로퍼티 등록 코드가 반복된다.

현재 권장 방식 — @ServiceConnection

@SpringBootTest
@Testcontainers
class ModernStyleTest {

    @Container
    @ServiceConnection // 이 한 줄이 위의 @DynamicPropertySource 메서드 전체를 대체한다
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    // 테스트 코드 ...
}
Java

@ServiceConnection은 컨테이너 타입(PostgreSQLContainer, KafkaContainer 등)을 자동으로 인식하고, 해당 타입에 맞는 ConnectionDetails Bean을 생성한다. Spring Boot가 지원하는 Testcontainers Spring Boot 서비스 연결 목록은 다음과 같다.

컨테이너생성되는 ConnectionDetails
PostgreSQLContainerJdbcConnectionDetails, R2dbcConnectionDetails
KafkaContainerKafkaConnectionDetails
RedisContainerDataRedisConnectionDetails
MongoDBContainerMongoConnectionDetails
RabbitMQContainerRabbitConnectionDetails
ElasticsearchContainerElasticsearchConnectionDetails
CassandraContainerCassandraConnectionDetails

@DynamicPropertySource@ServiceConnection이 지원하지 않는 커스텀 컨테이너를 사용할 때만 필요하다. 대부분의 경우 @ServiceConnection을 선택하면 된다.

REST API 통합 테스트 — 컨트롤러부터 DB까지 한 번에

실무에서는 Repository 단위 테스트뿐 아니라 HTTP 요청부터 DB 저장까지 전체 흐름을 검증하는 경우가 많다. Testcontainers Spring Boot 조합으로 RestAssured를 활용한 API 통합 테스트를 작성하는 방법이다.

package com.example.demo;

import com.example.demo.domain.Customer;
import com.example.demo.domain.CustomerRepository;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasSize;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class CustomerApiIntegrationTest {

    @LocalServerPort
    private int port; // 랜덤 포트를 주입받아 테스트 간 포트 충돌을 방지한다

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

    @Autowired
    private CustomerRepository customerRepository;

    @BeforeEach
    void setUp() {
        RestAssured.port = port;
        customerRepository.deleteAll(); // 매 테스트마다 데이터를 초기화한다
    }

    @Test
    void 고객_목록_조회_API가_정상_동작한다() {
        // given — DB에 직접 데이터 삽입
        customerRepository.save(new Customer("Kim", "kim@test.com"));
        customerRepository.save(new Customer("Lee", "lee@test.com"));

        // when & then — HTTP GET 요청 후 응답 검증
        given()
            .contentType(ContentType.JSON)
        .when()
            .get("/api/customers")
        .then()
            .statusCode(200)
            .body(".", hasSize(2))
            .body("[0].name", equalTo("Kim"));
    }

    @Test
    void 고객_등록_API가_정상_동작한다() {
        // when & then — HTTP POST 요청
        given()
            .contentType(ContentType.JSON)
            .body("""
                {
                    "name": "Park",
                    "email": "park@test.com"
                }
                """)
        .when()
            .post("/api/customers")
        .then()
            .statusCode(201)
            .body("name", equalTo("Park"));
    }
}
Java

RANDOM_PORT로 실제 서블릿 컨테이너를 띄우고, RestAssured로 HTTP 요청을 보낸다. DB는 Testcontainers의 PostgreSQL 컨테이너가 담당한다. 컨트롤러 → 서비스 → 리포지토리 → DB 전체 계층이 한 번에 검증된다. @WebMvcTest 슬라이스 테스트보다 느린 건 사실이다. 하지만 직렬화 오류, 트랜잭션 경계 문제, 제약 조건 위반처럼 계층 간 연결에서만 드러나는 버그를 이 테스트가 아니면 잡기 어렵다.

멀티 컨테이너 — Kafka와 Redis를 함께 띄우기

실제 프로젝트에서는 DB 외에도 메시지 브로커나 캐시가 필요한 경우가 많다. Testcontainers Spring Boot는 여러 컨테이너를 동시에 관리할 수 있다.

Kafka 컨테이너 추가

// build.gradle.kts에 Kafka 의존성 추가
testImplementation("org.testcontainers:kafka")
testImplementation("org.springframework.kafka:spring-kafka-test")
Kotlin
package com.example.demo;

import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.test.utils.KafkaTestUtils;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.kafka.ConfluentKafkaContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import java.time.Duration;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
@Testcontainers
class KafkaIntegrationTest {

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

    @Container
    @ServiceConnection // KafkaConnectionDetails를 자동 생성한다
    static ConfluentKafkaContainer kafka = new ConfluentKafkaContainer("confluentinc/cp-kafka:7.6.1");

    @Autowired
    private KafkaTemplate<String, String> kafkaTemplate;

    @Test
    void Kafka_메시지_발행이_정상_동작한다() throws Exception {
        // when — 메시지 발행 후 전송 결과를 동기적으로 확인한다
        var result = kafkaTemplate.send("order-events", "order-123",
            "{\"orderId\": 123, \"status\": \"CREATED\"}").get(10, java.util.concurrent.TimeUnit.SECONDS);

        // then — 메시지가 브로커에 정상 전달되었는지 검증
        assertThat(result.getRecordMetadata().topic()).isEqualTo("order-events");
        assertThat(result.getRecordMetadata().offset()).isGreaterThanOrEqualTo(0);
    }
}
Java

ConfluentKafkaContainer@ServiceConnection을 붙이면 spring.kafka.bootstrap-servers 프로퍼티가 자동으로 주입된다. 별도의 @DynamicPropertySource 없이 Kafka 연결이 완료된다.

Redis 컨테이너 추가

// build.gradle.kts에 Redis 의존성 추가
testImplementation("com.redis:testcontainers-redis:2.2.4") // Spring Boot BOM 관리 범위 밖이므로 버전 명시 필요
Kotlin
package com.example.demo;

import com.redis.testcontainers.RedisContainer;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
@Testcontainers
class RedisIntegrationTest {

    @Container
    @ServiceConnection // DataRedisConnectionDetails를 자동 생성한다
    static RedisContainer redis = new RedisContainer("redis:7.2-alpine");

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Test
    void Redis_캐시_저장_및_조회가_정상_동작한다() {
        // given
        String key = "user:session:abc123";
        String value = "{\"userId\": 1, \"role\": \"ADMIN\"}";

        // when — Redis에 데이터 저장
        redisTemplate.opsForValue().set(key, value);

        // then — 저장된 데이터 조회
        String cached = redisTemplate.opsForValue().get(key);
        assertThat(cached).isEqualTo(value);
    }
}
Java

RedisContainer를 사용하면 spring.data.redis.hostspring.data.redis.port가 자동 설정된다. 운영 환경과 동일한 Redis 7.2를 사용하므로 Lua 스크립트나 Stream 기능까지 검증할 수 있다.

테스트 속도 올리기 — 컨테이너 재사용 전략

Testcontainers Spring Boot 통합 테스트의 가장 큰 불만은 “느리다”는 것이다. PostgreSQL 컨테이너 하나 띄우는 데 3~5초, Kafka까지 올리면 10초가 넘는다. 아래 세 가지 전략으로 이 시간을 줄일 수 있다.

전략 1: Singleton 컨테이너 패턴

여러 테스트 클래스에서 같은 컨테이너를 공유하면 컨테이너를 한 번만 시작할 수 있다.

package com.example.demo.support;

import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.containers.PostgreSQLContainer;

/**
 * 모든 통합 테스트가 상속받는 추상 클래스.
 * static 컨테이너를 선언하여 JVM 프로세스 내에서 한 번만 시작한다.
 * @Testcontainers/@Container를 사용하지 않고 직접 생명주기를 관리한다.
 */
public abstract class IntegrationTestSupport {

    @ServiceConnection // Spring Boot가 연결 정보를 자동 주입한다
    protected static final PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16-alpine")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test");

    // static 블록에서 컨테이너를 시작한다 — 클래스 로딩 시 1회만 실행
    static {
        postgres.start();
    }
}
Java

위 패턴에서 static 블록으로 컨테이너를 시작하면 JVM이 종료될 때까지 컨테이너가 유지된다. 테스트 클래스가 10개든 100개든 PostgreSQL 컨테이너는 1번만 시작되므로 전체 테스트 실행 시간이 크게 단축된다.

// 개별 테스트 클래스에서 상속만 하면 된다 — @Testcontainers 불필요
@SpringBootTest
class OrderServiceTest extends IntegrationTestSupport {

    @Autowired
    private OrderService orderService;

    @Test
    void 주문_생성이_정상_동작한다() {
        // 이미 PostgreSQL 컨테이너가 실행 중이다
        // ...
    }
}
Java

이 패턴에서 주의할 점은 @BeforeEach에서 반드시 테스트 데이터를 초기화해야 한다는 것이다. 컨테이너를 공유하면 이전 테스트의 데이터가 남아 있을 수 있기 때문이다.

전략 2: 로컬 개발 시 컨테이너 재사용

~/.testcontainers.properties 파일에 재사용 옵션을 설정하면 테스트를 다시 실행해도 이전에 띄운 컨테이너를 그대로 사용한다.

# ~/.testcontainers.properties
testcontainers.reuse.enable=true
ShellScript
// 컨테이너 선언 시 withReuse(true) 추가
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
    .withReuse(true); // 테스트 종료 후에도 컨테이너를 유지한다
Java

이 설정을 적용하면 두 번째 테스트 실행부터 컨테이너 시작 시간이 0초에 가까워진다. 다만 CI 환경에서는 매번 깨끗한 상태에서 시작해야 하므로 withReuse(true)를 사용하지 않는 것이 권장된다. 로컬 개발 환경에서만 활용하는 것이 안전하다.

전략 3: 병렬 테스트 실행

JUnit 5의 병렬 실행과 Testcontainers를 결합하면 테스트 스위트 전체 실행 시간을 단축할 수 있다.

# src/test/resources/junit-platform.properties
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.classes.default=concurrent
junit.jupiter.execution.parallel.config.fixed.parallelism=4

병렬 실행 시 주의할 점은 데이터 격리이다. 같은 컨테이너(DB)를 공유하면서 여러 테스트가 동시에 데이터를 삽입/삭제하면 테스트가 간헐적으로 실패한다. @Transactional을 붙여 각 테스트를 트랜잭션으로 감싸거나, 테스트마다 고유한 데이터를 사용하는 방식으로 격리해야 한다.

로컬 개발 환경에서 Testcontainers 활용하기

Testcontainers Spring Boot 3.1부터는 테스트뿐 아니라 로컬 개발 환경에서도 Testcontainers를 활용할 수 있다. @TestConfiguration으로 컨테이너를 Bean으로 등록하고, main 메서드에서 이를 로드하는 방식이다.

package com.example.demo;

import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.PostgreSQLContainer;

@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfig {

    @Bean
    @ServiceConnection
    PostgreSQLContainer<?> postgresContainer() {
        return new PostgreSQLContainer<>("postgres:16-alpine")
            .withReuse(true); // 애플리케이션 재시작 시에도 컨테이너 유지
    }
}
Java
package com.example.demo;

import org.springframework.boot.SpringApplication;

public class TestDemoApplication {

    public static void main(String[] args) {
        // TestcontainersConfig를 추가로 로드하여 로컬 개발 시 Docker DB를 자동으로 띄운다
        SpringApplication.from(DemoApplication::main)
            .with(TestcontainersConfig.class)
            .run(args);
    }
}
Java

위 코드를 src/test/java 경로에 두고 TestDemoApplication을 실행하면 로컬에 PostgreSQL을 설치하지 않아도 애플리케이션이 뜬다. Docker Desktop만 돌아가고 있으면 된다. 이렇게 하면 docker-compose.yml을 따로 관리할 필요가 없다. 새로 합류한 팀원이 git clone 하고 IDE에서 Run 한 번 누르면 끝이다.

실전 팁 — Flyway 마이그레이션과 함께 쓰기

프로덕션에서 Flyway나 Liquibase로 스키마를 관리하고 있다면, Testcontainers Spring Boot 테스트에서도 동일한 마이그레이션 스크립트가 적용된다. 별도 설정 없이 src/main/resources/db/migration/ 경로의 SQL 파일이 컨테이너 DB에 자동 실행된다.

-- src/main/resources/db/migration/V1__create_customers.sql
CREATE TABLE customers (
    id BIGSERIAL PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    email VARCHAR(255) NOT NULL UNIQUE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- PostgreSQL 전용 인덱스 — H2에서는 사용할 수 없다
CREATE INDEX idx_customers_email_lower ON customers (LOWER(email));
SQL

위 마이그레이션 스크립트는 LOWER() 함수 기반 인덱스를 포함하고 있다. 이런 PostgreSQL 전용 기능은 H2에서 검증이 불가능하지만, Testcontainers로 실제 PostgreSQL을 사용하면 마이그레이션 스크립트의 문법 오류까지 테스트 단계에서 잡아낼 수 있다.

# application.yml — Flyway 설정
spring:
  flyway:
    enabled: true
    locations: classpath:db/migration
  jpa:
    hibernate:
      ddl-auto: validate  # Flyway가 스키마를 관리하므로 Hibernate는 검증만 수행한다
YAML

ddl-auto: validate로 설정하면 Flyway 마이그레이션 결과와 JPA 엔티티 매핑이 일치하는지 자동 검증된다. 컬럼 타입 불일치, 누락된 컬럼 같은 문제를 테스트 시작 시점에 즉시 발견할 수 있다.

자주 하는 실수와 트러블슈팅

Testcontainers를 처음 도입하면 거의 반드시 한 번씩 만나는 문제들이 있다.

Docker가 실행되지 않은 경우

org.testcontainers.containers.ContainerLaunchException:
  Container startup failed for image postgres:16-alpine
Plaintext

Testcontainers는 Docker 데몬이 실행 중이어야 동작한다. macOS에서는 Docker Desktop, Linux에서는 systemctl start docker로 Docker를 실행한다. CI 환경에서는 Docker-in-Docker 또는 원격 Docker 호스트를 사용해야 한다.

컨테이너 포트 충돌

Testcontainers는 기본적으로 랜덤 포트를 할당하므로 포트 충돌이 발생하지 않는다. withExposedPorts(5432)를 호출해도 호스트 포트는 매번 다르게 매핑된다. 고정 포트를 사용하고 싶다면 .withFixedExposedPort()를 쓸 수 있지만, 병렬 실행 시 충돌이 발생하므로 권장하지 않는다.

테스트 간 데이터 오염

컨테이너를 재사용하면 이전 테스트의 데이터가 남아 있을 수 있다. @BeforeEach에서 deleteAll()을 호출하거나, 각 테스트에 @Transactional을 붙여 자동 롤백하는 것이 안전하다. @Sql 어노테이션으로 테스트 전후에 SQL을 실행하는 방법도 있다.

지금까지 Testcontainers Spring Boot 통합 테스트에 대해서 정리해 보았다. 처음에는 컨테이너 시작 시간이 부담스럽게 느껴질 수 있지만, Singleton 패턴과 재사용 옵션을 적용하면 체감 시간이 크게 줄어든다. 운영에서 H2와 PostgreSQL의 미묘한 차이 때문에 장애를 겪은 적이 있다면, Testcontainers 도입을 적극 고려해 볼 만하다. 개인적으로 팀에 Testcontainers를 도입한 이후 “로컬에서는 되는데 스테이징에서 안 돼요” 같은 이슈가 눈에 띄게 줄었던 경험이 있다. H2를 쓰던 기존 테스트를 한꺼번에 전환하기보다, 새로 작성하는 테스트부터 Testcontainers를 적용하는 점진적 마이그레이션 전략을 추천한다.

함께 읽으면 좋은 글

더 알아보기