테스트용 H2를 실제 데이터베이스로 교체하기

테스트용 H2를 실제 데이터베이스로 교체하기

H2 인메모리 테스트 데이터베이스를 실제 PostgreSQL 인스턴스로 교체해요. Testcontainers 특수 JDBC URL을 사용하면 한 줄만 바꾸면 된답니다.

출처: 문서

본문

이 가이드를 통해 다음 내용을 배울 수 있어요.

  • 테스트에 H2 인메모리 데이터베이스를 쓰는 단점 이해하기
  • Testcontainers 특수 JDBC URL로 H2를 실제 PostgreSQL 데이터베이스로 교체하기
  • 컨테이너를 더 세밀하게 제어하려면 Testcontainers JUnit 5 확장 사용하기
  • Spring Data JPA와 JdbcTemplate 기반 리포지토리 모두 테스트하기

사전 준비 (Prerequisites)

  • Java 17 이상
  • Maven 또는 Gradle
  • Testcontainers가 지원하는 Docker 환경

참고: Testcontainers가 처음이라면 Testcontainers 개요를 방문해 알아보는 걸 권장해요.

테스트에 H2를 쓰는 문제점

프로덕션에서 PostgreSQL, MySQL, Oracle을 쓰면서 테스트에는 H2나 HSQL 같은 가벼운 데이터베이스를 인메모리로 쓰는 건 흔한 관행이에요. 하지만 이 접근에는 상당한 단점이 있어요.

  • 테스트 데이터베이스가 프로덕션 데이터베이스의 모든 기능을 지원하지 않을 수 있어요.
  • H2와 프로덕션 데이터베이스 사이에 SQL 문법이 호환되지 않을 수 있어요.
  • H2로 통과한 테스트가 프로덕션에서도 동작한다는 보장이 없어요.

PostgreSQL 전용 문법 예시

"upsert"(제품이 이미 존재하지 않을 때만 삽입)를 구현한다고 해볼게요. PostgreSQL에서는 다음을 사용할 수 있어요.

INSERT INTO products (id, code, name)
VALUES (?, ?, ?)
ON CONFLICT DO NOTHING;

이 쿼리는 기본적으로 H2에서 동작하지 않아요.

Caused by: org.h2.jdbc.JdbcSQLException: Syntax error in SQL statement \"INSERT INTO products (id, code, name) VALUES (?, ?, ?) ON[*] CONFLICT DO NOTHING\";

H2를 PostgreSQL 호환 모드로 실행할 수도 있지만, 모든 기능이 지원되는 건 아니에요. 그 반대도 마찬가지예요. H2는 PostgreSQL에 없는 ROWNUM()을 지원해요.

프로덕션과 다른 데이터베이스로 테스트하면 테스트 결과를 신뢰할 수 없고, 배포 후에 다시 검증해야 하므로 자동화된 테스트의 목적이 무너져요.

H2를 사용하는 Spring Boot 테스트

전형적인 H2 기반 테스트는 이렇게 생겼어요.

@DataJpaTest
class ProductRepositoryTest {
    @Autowired
    ProductRepository productRepository;

    @Test
    @Sql("classpath:/sql/seed-data.sql")
    void shouldGetAllProducts() {
        List<Product> products = productRepository.findAll();
        assertEquals(2, products.size());
    }
}

H2가 classpath에 있으면 Spring Boot는 H2를 자동으로 사용해요. 테스트는 통과하지만 PostgreSQL 전용 문제를 잡아내지는 못해요.

H2를 Testcontainers JDBC URL로 교체하기

H2를 실제 PostgreSQL 데이터베이스로 교체하려면 테스트 속성 두 개가 필요해요.

@DataJpaTest
@TestPropertySource(properties = {
    "spring.test.database.replace=none",
    "spring.datasource.url=jdbc:tc:postgresql:16-alpine:///db"
})
class ProductRepositoryWithJdbcUrlTest {
    @Autowired
    ProductRepository productRepository;

    @Test
    @Sql("classpath:/sql/seed-data.sql")
    void shouldGetAllProducts() {
        List<Product> products = productRepository.findAll();
        assertEquals(2, products.size());
    }
}

그게 전부예요. 속성 두 개만 바꾸면 테스트가 실제 PostgreSQL 데이터베이스에 대해 실행돼요.

특수 JDBC URL이 동작하는 방식

표준 PostgreSQL JDBC URL은 이렇게 생겼어요.

jdbc:postgresql://localhost:5432/postgres

Testcontainers 특수 JDBC URL은 jdbc: 뒤에 tc:를 넣어요.

jdbc:tc:postgresql:///db

호스트네임, 포트, 데이터베이스 이름은 무시되고 Testcontainers가 자동으로 관리해요. 데이터베이스 이름 뒤에 Docker 이미지 태그를 지정할 수 있어요.

jdbc:tc:postgresql:16-alpine:///db

이렇게 하면 postgres:16-alpine 이미지에서 컨테이너가 만들어져요.

스크립트로 데이터베이스 초기화하기

TC_INITSCRIPT를 전달하면 컨테이너가 시작될 때 SQL 스크립트를 실행해요.

jdbc:tc:postgresql:16-alpine:///db?TC_INITSCRIPT=sql/init-db.sql

Testcontainers가 스크립트를 자동으로 실행해요. 프로덕션 애플리케이션에서는 대신 Flyway나 Liquibase 같은 데이터베이스 마이그레이션 도구를 쓰는 게 좋아요.

이 특수 JDBC URL은 MySQL, MariaDB, PostGIS, YugabyteDB, CockroachDB 등 Testcontainers JDBC 지원이 있는 다른 데이터베이스에서도 동작해요.

JdbcTemplate 기반 리포지토리 테스트하기

같은 접근이 JdbcTemplate 기반 리포지토리에서도 동작해요. @DataJpaTest 대신 @JdbcTest를 사용해요.

@JdbcTest
@TestPropertySource(properties = {
    "spring.test.database.replace=none",
    "spring.datasource.url=jdbc:tc:postgresql:16-alpine:///db?TC_INITSCRIPT=sql/init-db.sql"
})
class JdbcProductRepositoryTest {
    @Autowired
    private JdbcTemplate jdbcTemplate;

    private JdbcProductRepository productRepository;

    @BeforeEach
    void setUp() {
        productRepository = new JdbcProductRepository(jdbcTemplate);
    }

    @Test
    @Sql("/sql/seed-data.sql")
    void shouldGetAllProducts() {
        List<Product> products = productRepository.getAllProducts();
        assertEquals(2, products.size());
    }
}

더 세밀한 제어를 위해 JUnit 5 확장 사용하기

특수 JDBC URL이 요구사항을 충족하지 못하거나, 컨테이너 생성에 더 세밀한 제어가 필요하면(예: 초기화 스크립트 복사), Testcontainers JUnit 5 확장을 사용해요.

@DataJpaTest
@TestPropertySource(properties = {
    "spring.test.database.replace=none"
})
@Testcontainers
class ProductRepositoryTest {
    @Container
    static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:16-alpine")
        .withCopyFileToContainer(
            MountableFile.forClasspathResource("sql/init-db.sql"),
            "/docker-entrypoint-initdb.d/init-db.sql"
        );

    @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);
    }

    @Autowired
    ProductRepository productRepository;

    @Test
    @Sql("/sql/seed-data.sql")
    void shouldGetAllProducts() {
        List<Product> products = productRepository.findAll();
        assertEquals(2, products.size());
    }

    @Test
    @Sql("/sql/seed-data.sql")
    void shouldNotCreateAProductWithDuplicateCode() {
        Product product = new Product(3L, "p101", "Test Product");
        productRepository.createProductIfNotExists(product);

        Optional<Product> optionalProduct = productRepository.findById(product.getId());
        assertThat(optionalProduct).isEmpty();
    }
}

이 접근은,

  • @Testcontainers와 @Container로 컨테이너 수명주기를 관리해요.
  • init-db.sql을 컨테이너의 init 디렉터리에 복사해서 PostgreSQL이 시작 시 실행하게 해요.
  • @DynamicPropertySource로 컨테이너의 연결 정보를 Spring Boot에 등록해요.
  • H2에서는 동작하지 않을 ON CONFLICT DO NOTHING 같은 PostgreSQL 전용 기능을 테스트해요.

요약 (Summary)

  • H2에서 실제 데이터베이스로 전환하는 가장 빠른 방법인 특수 JDBC URL(jdbc:tc:postgresql:...)을 사용해요. 속성 하나만 바꾸면 돼요.
  • 컨테이너에 대한 더 세밀한 제어가 필요할 때(커스텀 init 스크립트, 환경 변수 등) JUnit 5 확장을 사용해요.
  • 두 접근 모두 Spring Data JPA(@DataJpaTest)와 JdbcTemplate(@JdbcTest) 테스트에서 동작해요.

더 읽어보기 (Further reading)

  • Testcontainers Postgres 모듈
  • Testcontainers JDBC 지원
  • Testcontainers로 Spring Boot REST API 테스트하기

더 알아보기 (Learn more)