Testcontainers로 통합테스트 DB 격리하기 — 테스트가 서로 깨질 때

통합테스트를 몇 개 만들고 나면 반드시 겪는 일이 있습니다. 하나씩 돌리면 다 통과하는데 전체를 돌리면 깨지는 상황입니다. 원인은 대부분 테스트끼리 같은 데이터베이스를 공유하기 때문입니다. Testcontainers는 테스트마다 실제 DB를 컨테이너로 띄워 이 문제를 구조적으로 없앱니다. 여기서는 H2로 버티면 안 되는 이유부터 설정, 데이터 격리 전략, 그리고 느려졌을 때의 대처까지 정리합니다.

H2로 하면 안 되는 이유

통합테스트에 H2 인메모리 DB를 쓰는 구성이 흔합니다. 빠르고 설정이 간단하기 때문인데, 운영 DB와 다르게 동작하는 지점에서 테스트가 무력해집니다.

영역 H2 MySQL / PostgreSQL
함수 GROUP_CONCAT, JSON_EXTRACT 등 상당수 미지원·상이 벤더별 고유 문법
예약어·식별자 대소문자 처리가 다름 설정에 따라 다름
격리 수준 기본 READ COMMITTED MySQL 기본 REPEATABLE READ
락·데드락 재현되지 않음 실제로 발생
실행 계획 확인 불가 EXPLAIN으로 확인

특히 마지막 두 줄이 중요합니다. 데드락이나 격리 수준에 얽힌 버그는 H2에서 절대 재현되지 않습니다. 테스트는 초록불인데 운영에서 터지는 전형적인 경로입니다.

Testcontainers는 테스트 실행 시점에 운영과 같은 버전의 DB를 도커 컨테이너로 띄웁니다. 벤더 차이로 인한 간극이 사라집니다.

기본 설정

의존성은 세 개면 충분합니다.

// build.gradle
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:mysql'      // 또는 :postgresql

가장 단순한 형태는 이렇습니다.

@SpringBootTest
@Testcontainers
class OrderServiceTest {

    @Container
    static MySQLContainer<?> mysql =
        new MySQLContainer<>("mysql:8.0.36")      // 운영과 같은 버전으로 고정
            .withDatabaseName("testdb");

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

    @Autowired OrderService orderService;

    @Test
    void 주문을_저장한다() { /* ... */ }
}

두 가지가 핵심입니다. static 필드로 선언해야 클래스 전체에서 컨테이너 하나를 공유하고, @DynamicPropertySource로 실행 시점에 정해지는 포트를 스프링에 넘겨 줘야 합니다. 컨테이너는 매번 임의 포트에 뜨기 때문에 application.yml에 고정 URL을 적을 수 없습니다.

이미지 태그는 반드시 고정하십시오. mysql:latest로 두면 어느 날 이미지가 바뀌면서 테스트가 깨집니다. 운영에서 쓰는 버전과 같은 태그를 적는 것이 원칙입니다.

느려지는 문제 — 컨테이너를 재사용한다

위 구성의 문제는 테스트 클래스마다 컨테이너를 새로 띄운다는 점입니다. MySQL 컨테이너 기동에 5~10초가 걸리니, 클래스가 20개면 그것만으로 3분이 넘습니다.

해법은 컨테이너를 하나만 띄우고 전체 테스트가 공유하는 것입니다. 추상 클래스에 static 블록으로 한 번만 시작하면 됩니다.

public abstract class IntegrationTestSupport {

    static final MySQLContainer<?> MYSQL =
        new MySQLContainer<>("mysql:8.0.36").withDatabaseName("testdb");

    static {
        MYSQL.start();          // JVM 당 한 번만 실행된다 (종료는 JVM 종료 시 자동)
    }

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

이제 테스트 클래스들은 이걸 상속하기만 하면 됩니다.

@SpringBootTest
class OrderServiceTest extends IntegrationTestSupport { /* ... */ }

@Container 애너테이션을 쓰지 않은 것이 의도적입니다. 그 애너테이션은 클래스가 끝날 때 컨테이너를 종료시키기 때문입니다. static 블록에서 직접 start()하면 JVM이 살아 있는 동안 유지됩니다.

로컬에서 테스트를 자주 돌린다면 한 단계 더 갈 수 있습니다. withReuse(true)와 ~/.testcontainers.properties의 testcontainers.reuse.enable=true를 함께 쓰면 테스트 실행이 끝나도 컨테이너가 살아 있어 다음 실행에서 기동 시간이 0이 됩니다. 다만 CI에서는 매번 깨끗한 환경이 나아서 보통 끕니다.

진짜 문제 — 테스트 간 데이터 격리

컨테이너를 공유하기 시작하면 원래의 문제가 돌아옵니다. 앞 테스트가 넣은 데이터가 뒤 테스트에 보입니다. 이걸 푸는 방법이 셋 있습니다.

방법 1 — @Transactional 롤백 (가장 간단)

@SpringBootTest
@Transactional        // 각 테스트 후 자동 롤백
class OrderServiceTest extends IntegrationTestSupport { }

테스트 메서드마다 트랜잭션을 열고 끝나면 롤백합니다. 설정이 한 줄이라 가장 먼저 시도할 방법입니다.

대신 한계가 분명합니다. 테스트와 대상 코드가 같은 트랜잭션에 묶여 실제 커밋 동작을 검증하지 못합니다. @Transactional(propagation = REQUIRES_NEW)나 @TransactionalEventListener(AFTER_COMMIT)처럼 커밋 시점에 동작하는 코드는 이 방식으로 테스트할 수 없습니다. @SpringBootTest(webEnvironment = RANDOM_PORT)로 실제 HTTP 요청을 보내는 테스트에서도 스레드가 달라 롤백이 안 됩니다.

방법 2 — 테스트 후 테이블 비우기

커밋 동작까지 검증해야 한다면 실제로 커밋하고, 매 테스트 뒤에 정리합니다.

@Component
public class DatabaseCleaner {

    @PersistenceContext EntityManager em;

    @Transactional
    public void clean() {
        em.createNativeQuery("SET FOREIGN_KEY_CHECKS = 0").executeUpdate();
        for (String table : tableNames()) {              // 메타데이터에서 조회
            em.createNativeQuery("TRUNCATE TABLE " + table).executeUpdate();
        }
        em.createNativeQuery("SET FOREIGN_KEY_CHECKS = 1").executeUpdate();
    }
}
@AfterEach
void tearDown() {
    databaseCleaner.clean();
}

외래키 제약을 잠시 끄는 이유는 테이블 삭제 순서에 신경 쓰지 않기 위해서입니다. TRUNCATE는 DELETE보다 빠르고 AUTO_INCREMENT도 초기화해 주어, ID를 단정하는 테스트에 유리합니다.

방법 3 — 테스트마다 다른 데이터를 쓴다

가장 견고하지만 습관이 필요한 방법입니다. 고정값 대신 테스트마다 고유한 값을 만들어 씁니다.

// ✗ 다른 테스트와 충돌한다
Member m = new Member("[email protected]");

// ✓ 충돌하지 않는다
Member m = new Member(UUID.randomUUID() + "@example.com");

정리 로직 없이도 테스트가 서로 독립적이 되고, 병렬 실행까지 가능해집니다. 실무에서는 방법 1을 기본으로 쓰고, 커밋 검증이 필요한 테스트만 방법 2로 두는 조합이 무난합니다.

스키마는 어떻게 만들 것인가

컨테이너는 빈 DB로 뜨므로 테이블을 만들어 줘야 합니다. 선택지는 둘입니다.

방식 장점 단점
ddl-auto: create 설정 한 줄, 빠름 운영 스키마와 달라질 수 있음. 인덱스·제약이 반영되지 않음
마이그레이션 도구 실행 운영과 완전히 같은 스키마 기동이 조금 느림

통합테스트의 목적이 “운영과 같은 환경에서 검증”이라면 두 번째가 일관됩니다. Flyway를 쓴다면 테스트에서도 그대로 돌리면 되고, 마이그레이션 스크립트 자체가 검증되는 부수 효과도 있습니다. 운영 DB에 마이그레이션을 적용할 때 주의할 점은 별도 글에서 다루겠습니다.

자주 묻는 질문

Q1. CI에서 도커를 못 쓰면 어떻게 하나요?

GitHub Actions의 ubuntu-latest 러너는 도커가 기본 제공되어 그대로 동작합니다. 도커를 쓸 수 없는 환경이라면 Testcontainers는 선택지가 아니므로, 그때는 외부에 공용 테스트 DB를 두고 스키마를 분리하는 방식으로 우회합니다. 다만 테스트가 서로 간섭할 위험이 다시 생기므로, 앞의 “방법 3″처럼 데이터 자체를 고유하게 만드는 규칙이 필수가 됩니다.

Q2. 단위 테스트까지 전부 Testcontainers로 바꿔야 하나요?

아닙니다. 오히려 반대입니다. 비즈니스 로직 검증은 DB 없이 도는 단위 테스트가 압도적으로 빠르고, 실패했을 때 원인도 좁습니다. Testcontainers는 쿼리·트랜잭션·락처럼 DB가 실제로 개입해야 의미 있는 검증에만 쓰는 것이 맞습니다. 둘의 경계를 어떻게 나눌지는 Spring Boot 테스트 전략: 단위와 통합 나누기에서 정리했습니다.

Q3. 테스트가 여전히 느립니다.

컨테이너 재사용까지 적용했는데 느리다면 원인은 대개 컨테이너가 아니라 스프링 컨텍스트 재기동입니다. @MockBean이나 @TestPropertySource를 클래스마다 다르게 쓰면 스프링이 컨텍스트를 새로 만듭니다. 설정 조합을 최대한 통일해 컨텍스트 캐시가 재사용되도록 맞추는 것이 가장 효과가 큽니다.

Q4. 운영 DB 버전을 올릴 때도 도움이 되나요?

이 구성의 가장 큰 실익 중 하나입니다. 이미지 태그 한 줄만 바꿔 전체 테스트를 돌리면 새 버전에서 깨지는 쿼리가 있는지 미리 확인할 수 있습니다. 버전 업그레이드 검증을 실제 트래픽 없이 할 수 있다는 점에서, 도입 비용을 여기서 회수하는 경우가 많습니다.

마무리

Testcontainers의 가치는 “도커로 DB를 띄운다”가 아니라 테스트 환경과 운영 환경의 간극을 없앤다는 데 있습니다. H2에서 통과하던 테스트가 운영에서 깨지는 일이 사라집니다.

도입 순서는 이렇습니다. 먼저 컨테이너 하나를 공유하도록 추상 클래스를 만들고, @Transactional 롤백으로 격리를 시작하고, 커밋 동작을 검증해야 하는 테스트만 정리 방식으로 빼냅니다. 모든 테스트를 한 번에 바꾸려 하지 말고, 새로 쓰는 통합테스트부터 이 구조에 얹는 편이 현실적입니다.

함께 보면 좋은 글