H2로 테스트하면 안 되는 이유

통합 테스트를 H2 인메모리 DB로 돌리고 있었다. 로컬에서는 전부 통과하는데, 운영(PostgreSQL)에서만 터지는 버그가 분기에 2-3번은 나왔다. '테스트가 무슨 의미가 있나'라는 회의감이 들 때쯤, Testcontainers를 알게 됐다. 실제 PostgreSQL 컨테이너로 테스트를 돌리니까, 운영 버그가 사라졌다.

저희 팀에서도 H2에서는 통과했던 테스트가 운영 PostgreSQL에서 실패하는 사고를 여러 번 경험한 후, Testcontainers로 전면 전환했습니다.

Testcontainers 설정

// build.gradle
dependencies {
    testImplementation 'org.springframework.boot:spring-boot-testcontainers'
    testImplementation 'org.testcontainers:junit-jupiter'
    testImplementation 'org.testcontainers:postgresql'
    testImplementation 'org.testcontainers:kafka'
}

Spring Boot 3.1+ ServiceConnection 활용

Spring Boot 3.1부터 @ServiceConnection 어노테이션으로 컨테이너 연결 설정을 자동화할 수 있습니다.

@TestConfiguration(proxyBeanMethods = false)
public class TestContainerConfig {

    @Bean
    @ServiceConnection
    PostgreSQLContainer<?> postgresContainer() {
        return new PostgreSQLContainer<>("postgres:16-alpine")
                .withDatabaseName("testdb")
                .withUsername("test")
                .withPassword("test")
                .withInitScript("schema.sql");
    }

    @Bean
    @ServiceConnection
    GenericContainer<?> redisContainer() {
        return new GenericContainer<>("redis:7-alpine")
                .withExposedPorts(6379);
    }
}

통합 테스트 작성 패턴

Repository 테스트

@DataJpaTest
@Import(TestContainerConfig.class)
@AutoConfigureTestDatabase(replace = Replace.NONE)
class OrderRepositoryTest {

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void PostgreSQL_네이티브쿼리가_정상_동작한다() {
        // given
        Order order = Order.builder()
                .userId(1L)
                .metadata(Map.of("coupon", "SUMMER2025"))
                .build();
        orderRepository.save(order);

        // when - PostgreSQL JSON 연산자 사용
        List<Order> results = orderRepository
                .findByMetadataKey("coupon");

        // then
        assertThat(results).hasSize(1);
        assertThat(results.get(0).getMetadata())
                .containsEntry("coupon", "SUMMER2025");
    }

    @Test
    void 대량_데이터_페이징이_정상_동작한다() {
        // given
        List<Order> orders = IntStream.range(0, 100)
                .mapToObj(i -> Order.builder()
                        .userId((long) i % 10)
                        .amount(BigDecimal.valueOf(i * 1000))
                        .build())
                .collect(Collectors.toList());
        orderRepository.saveAll(orders);

        // when
        Page<Order> page = orderRepository.findByUserId(
                1L, PageRequest.of(0, 5, Sort.by("amount").descending()));

        // then
        assertThat(page.getTotalElements()).isEqualTo(10);
        assertThat(page.getContent()).hasSize(5);
        assertThat(page.getContent().get(0).getAmount())
                .isGreaterThan(page.getContent().get(1).getAmount());
    }
}

API 통합 테스트

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Import(TestContainerConfig.class)
class OrderApiIntegrationTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void 주문_생성부터_조회까지_전체_흐름() {
        // 1. 주문 생성
        var createRequest = new CreateOrderRequest(1L, 2, "PRODUCT_A");
        var createResponse = restTemplate.postForEntity(
                "/api/orders", createRequest, OrderResponse.class);

        assertThat(createResponse.getStatusCode())
                .isEqualTo(HttpStatus.CREATED);

        Long orderId = createResponse.getBody().getId();

        // 2. 주문 조회
        var getResponse = restTemplate.getForEntity(
                "/api/orders/" + orderId, OrderResponse.class);

        assertThat(getResponse.getBody().getProductCode())
                .isEqualTo("PRODUCT_A");
        assertThat(getResponse.getBody().getQuantity())
                .isEqualTo(2);
    }
}

테스트 성능 최적화

컨테이너 재사용으로 속도 향상

테스트마다 컨테이너를 새로 시작하면 시간이 오래 걸립니다. Singleton 패턴을 활용하여 전체 테스트 스위트에서 컨테이너를 공유합니다.

public abstract class AbstractIntegrationTest {

    static final PostgreSQLContainer<?> POSTGRES;
    static final GenericContainer<?> REDIS;

    static {
        POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine")
                .withReuse(true);
        REDIS = new GenericContainer<>("redis:7-alpine")
                .withExposedPorts(6379)
                .withReuse(true);

        POSTGRES.start();
        REDIS.start();
    }

    @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);
        registry.add("spring.data.redis.host", REDIS::getHost);
        registry.add("spring.data.redis.port",
            () -> REDIS.getMappedPort(6379));
    }
}

testcontainers.properties 설정

# ~/.testcontainers.properties
testcontainers.reuse.enable=true

이 설정으로 로컬에서 컨테이너를 재사용하면 테스트 실행 시간이 50% 이상 단축됩니다.

CI/CD 파이프라인에서의 활용

GitHub Actions에서 Testcontainers를 사용하려면 Docker-in-Docker 또는 서비스 컨테이너 설정이 필요합니다. GitHub Actions는 기본적으로 Docker를 지원하므로 별도의 설정 없이 바로 사용할 수 있습니다.

마무리

Testcontainers는 통합 테스트의 신뢰성을 극적으로 높여줍니다. 초기 설정 비용이 있지만, 운영 환경에서 발생할 수 있는 DB 호환성 이슈를 개발 단계에서 잡아낼 수 있어 충분히 투자할 가치가 있습니다. 특히 Spring Boot 3.1+의 ServiceConnection 기능으로 설정이 훨씬 간편해졌으니, 아직 도입하지 않았다면 적극 고려해보세요.

← 목록으로 다음 글 →
Jaeseong
Jaeseong

10년차 풀스택 개발자. Spring Boot, Flutter, AI 등 실무 경험을 기록합니다.

GitHub →

💬 댓글