테스트용 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 테스트하기