데이터베이스 테스트
Spring Test 트랜잭션과 H2 픽스처를 사용하고 운영 DB 호환성 테스트의 경계를 구분합니다.
리포지토리 테스트는 SQL을 실제 데이터베이스에서 실행해야 열 이름, 제약 조건, 트랜잭션을 확인할 수 있습니다.
매 테스트를 트랜잭션으로 감싸 롤백하면 빠르게 격리할 수 있지만 커밋 뒤 콜백, 트리거, 지연된 제약 조건은 보지 못합니다.
H2 내장 DB는 빠르지만 운영 환경 데이터베이스의 방언과 격리를 대신하지 않습니다.
목적별 테스트 층을 나눕니다.
운영과 같은 테스트 구성
작은 구성은 H2 DataSource, JdbcTemplate, 트랜잭션 매니저를 같은 빈으로 연결합니다.
리포지토리가 별도의 DataSource를 만들지 않게 합니다.
package board.jdbc;
import javax.sql.DataSource;
import org.h2.jdbcx.JdbcDataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public class DatabaseTestConfiguration {
@Bean
DataSource dataSource() {
var dataSource = new JdbcDataSource();
dataSource.setURL(
"jdbc:h2:mem:repository-test;DB_CLOSE_DELAY=-1");
dataSource.setUser("sa");
return dataSource;
}
@Bean
JdbcTemplate jdbcTemplate(DataSource dataSource) {
return new JdbcTemplate(dataSource);
}
@Bean
PlatformTransactionManager transactionManager(
DataSource dataSource
) {
return new DataSourceTransactionManager(dataSource);
}
}스키마는 마이그레이션을 그대로 실행하는 것이 이상적입니다.
테스트 전용 축약 스키마를 별도 유지하면 운영 마이그레이션과 차이가 생깁니다.
내장 DB가 운영 DDL을 읽지 못한다면 그 자체가 H2 테스트 범위가 제한됐다는 신호이므로 운영 DB 컨테이너 테스트를 둡니다.
@Transactional 기본 롤백
Spring Test가 테스트 메서드 전에 트랜잭션을 시작하고 완료 뒤 기본 롤백합니다.
테스트 내부에서 리포지토리가 커밋했다고 생각해도 실제 서비스 트랜잭션은 바깥 테스트 트랜잭션에 참여할 수 있습니다.
따라서 행 격리에는 좋지만 커밋 이벤트 검증에는 주의합니다.
package board.jdbc;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.test.context.transaction.AfterTransaction;
import org.springframework.transaction.annotation.Transactional;
@SpringJUnitConfig(DatabaseTestConfiguration.class)
@Transactional
@Sql(statements = """
create table if not exists posts(
id bigint generated by default as identity primary key,
title varchar(80) not null)
""")
class RollbackIsolatedRepositoryTest {
@Autowired
JdbcTemplate jdbc;
@Test
void test_안에서는_insert_row를_읽을_수_있다() {
jdbc.update(
"insert into posts(title) values(?)",
"Rollback");
Integer count = jdbc.queryForObject(
"select count(*) from posts",
Integer.class);
assertThat(count).isEqualTo(1);
}
@AfterTransaction
void rollback_뒤에는_test_data가_남지_않는다() {
Integer count = jdbc.queryForObject(
"select count(*) from posts",
Integer.class);
assertThat(count).isZero();
}
}DDL의 트랜잭션 동작은 DB마다 다릅니다.
스키마 설정은 테스트 트랜잭션 밖에서 테스트 모음을 초기화하거나 마이그레이션을 실행해 준비하고, 메서드 트랜잭션에는 DML 픽스처만 넣는 편이 안전합니다.
예제의 @Sql 실행 단계도 구성에 따라 트랜잭션과 관계가 달라질 수 있어 실제 출력을 확인합니다.
커밋 후 동작 검증
@TransactionalEventListener(AFTER_COMMIT), 동기화 콜백, 지연된 제약 조건은 롤백 전용 테스트에서 실행되지 않습니다.
TestTransaction으로 현재 테스트 트랜잭션을 커밋한 뒤 새 트랜잭션을 시작할 수 있습니다.
커밋된 데이터는 테스트가 직접 정리하거나 격리된 데이터베이스/스키마를 사용합니다.
package board.jdbc;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.test.context.transaction.TestTransaction;
import org.springframework.transaction.annotation.Transactional;
@SpringJUnitConfig(DatabaseTestConfiguration.class)
@Transactional
@Sql(statements = """
create table if not exists posts(
id bigint generated by default as identity primary key,
title varchar(80) not null)
""")
class CommitBoundaryTest {
@Autowired
JdbcTemplate jdbc;
@Test
void 명시적으로_commit한_row는_새_transaction에서_보인다() {
jdbc.update(
"insert into posts(title) values(?)",
"Committed");
TestTransaction.flagForCommit();
TestTransaction.end();
TestTransaction.start();
Integer count = jdbc.queryForObject(
"select count(*) from posts where title=?",
Integer.class,
"Committed");
assertThat(count).isEqualTo(1);
jdbc.update(
"delete from posts where title=?",
"Committed");
TestTransaction.flagForCommit();
TestTransaction.end();
}
}rollback-isolated row inside test = 1
row after automatic rollback = 0
explicit commit row in new transaction = 1
afterCommit boundary exercised = true
cleanup committed = true커밋 테스트가 중간 검증에서 실패하면 정리까지 가지 못할 수 있습니다.
테스트별 임시 스키마·데이터베이스, 정리 확장, 컨테이너 재생성 전략으로 오염을 방지합니다.
공유 영속 DB에서 전체 삭제를 무심코 실행하지 않습니다.
내장 DB의 한계
H2 PostgreSQL 모드에서도 다음은 실제 PostgreSQL과 같지 않을 수 있습니다.
- 동일성·순서와 생성된 키 세부
- JSONB, 배열, 열거형, 시간대 타입
on conflict,returning, 부분 인덱스- 제약 조건 SQLState와 이름 추출
- MVCC 잠금, 교착 상태, 직렬화 가능 격리 중단
- 실행 계획과 정렬 규칙
리포지토리의 기본 CRUD와 매핑은 H2로 빠르게 실행하고, 공급자 기능과 동시성은 Testcontainers 같은 실제 엔진에서 검사합니다.
무거운 통합 테스트를 CI에서 매번 실행할지 풀 리퀘스트·야간 작업으로 나눌지는 위험도에 따라 결정합니다.
가독성 높은 픽스처
각 테스트가 거대한 공통 SQL 덤프에 의존하면 어떤 행이 결과를 만드는지 알기 어렵습니다.
필요한 회원과 게시글만 빌더나 SQL로 생성하고 ID를 하드 코딩하기보다 반환 키를 사용합니다.
시계와 시간대도 고정합니다.
데이터 빌더가 운영 환경 불변식을 우회하지 않게 정상 빌더와 손상 데이터 픽스처를 나눕니다.
제약 조건 실패 테스트는 의도적으로 원시 SQL을 사용할 수 있지만 이름에서 그 목적을 드러냅니다.
병렬 테스트가 같은 메모리 기반 DB 이름을 공유하면 행과 DDL이 충돌합니다.
테스트 클래스별 UUID 데이터베이스 이름 또는 컨텍스트 격리를 사용합니다.
DB_CLOSE_DELAY=-1은 연결이 닫혀도 DB를 유지하므로 컨텍스트 생명주기와 정리를 이해합니다.
연습 문제
고유 제약 조건 변환을 H2와 운영 환경 PostgreSQL 두 환경에서 같은 리포지토리 계약 테스트로 실행하세요.
기술 예외의 SQLState는 다를 수 있지만 최종 DuplicatePostException과 롤백 후 행 개수는 같아야 합니다.
해설 보기
추상 규칙은 DataSource 팩토리와 스키마 마이그레이션 훅을 받습니다.
H2 하위 클래스와 PostgreSQL 컨테이너 하위 클래스가 동일 테스트 메서드를 상속합니다.
package board.jdbc;
public record DatabaseProfile(
String product,
String jdbcUrl,
String username,
String password
) {
public DatabaseProfile {
if (product == null || product.isBlank()
|| jdbcUrl == null || !jdbcUrl.startsWith("jdbc:")) {
throw new IllegalArgumentException("invalid database profile");
}
}
}규칙 검증은 공급자 메시지가 아니라 도메인 예외 타입, 원인 존재, 기존 행 유지, 트랜잭션 롤백을 봅니다.
공급자-구체적인 진단 테스트만 SQLState를 별도로 고정합니다.
다음 문서에서는 SQL을 XML이나 애노테이션 매퍼에 두는 MyBatis가 동적 쿼리와 명시적 매핑을 어떻게 제공하는지 검토합니다.