데이터베이스 테스트
운영 스키마를 재사용하는 H2 테스트를 롤백·명시적 커밋 경계로 분리하고 실제 DB 호환성 검증의 범위를 구분합니다.
저장소 테스트는 SQL을 실제 데이터베이스에 보내 열 이름과 제약 조건, 생성 키, 트랜잭션의 범위를 확인합니다. 다만 테스트 방식 하나가 모든 현상을 보여 주지는 않습니다. 순수한 값 테스트와 자동 롤백되는 H2 테스트, 명시적으로 커밋하는 테스트, 운영 DB의 호환성 테스트는 각각 확인하는 사실이 다릅니다.
DATABASE TEST · OBSERVATION + ISOLATION
검증할 현상에 맞춰 DB 테스트를 고른다
빠른 값 검증부터 실제 엔진 동작까지 네 범위를 나눕니다. DB 테스트는 운영 schema.sql을 공유하고 픽스처는 격리합니다.
1 · 순수 값 / 빌더
관찰: 입력 범위·리터럴 패턴·커서 구성
격리: 각 테스트가 최소 불변 값을 만듭니다. DB에는 접근하지 않습니다.
2 · H2 자동 롤백
관찰: 실제 SQL·전체 행 매핑·제약·트랜잭션 참여
격리: 클래스 고유 H2 이름을 쓰고 메서드의 DML을 기본 롤백합니다. DDL 초기화는 밖에서 끝냅니다.
3 · 명시적 커밋
관찰: 새 트랜잭션에서 커밋된 행의 가시성
격리: 롤백 테스트와 별도 DB를 쓰고 실패 뒤에도 정리를 커밋합니다. after-commit 현상은 필요한 전용 검증을 추가합니다.
4 · 실제 PostgreSQL
관찰: SQLState·MVCC·방언·정렬·실행 계획
격리: 테스트가 소유한 DB 또는 스키마에서 실행하고 종료·실패 뒤 폐기합니다. 실제 엔진을 준비한 경우에만 증거로 기록합니다.
H2 통과 결과를 PostgreSQL 잠금·SQLState·실행 계획의 증거로 확대하지 않습니다.
그림은 하나의 확인 목적에서 순수 값과 빌더, H2 롤백, 명시적 커밋, 실제 PostgreSQL의 네 가지 검사로 향하는 관계를 보여 줍니다. 네 검사는 차례로 거쳐야 하는 단계도, 하나만 고르는 분기도 아니며, 확인하려는 주장에 따라 여러 검사를 함께 씁니다. 아래 칸들은 각 검사가 확인하는 것과 픽스처를 격리하는 방법을 정리하며, H2를 통과했다고 PostgreSQL의 잠금이나 SQLState, 실행 계획까지 확인했다고 넓혀 보지 않습니다.
운영 스키마를 읽는 클래스별 데이터베이스
테스트도 앞 장에서 만든 공유 스키마인 src/main/resources/schema.sql을 그대로 실행합니다. 줄인 posts 테이블을 다시 정의하면 운영의 제약 조건과 테스트의 제약 조건이 갈라집니다.
DB_CLOSE_DELAY=-1은 마지막 연결이 닫혀도 메모리 데이터베이스를 유지하므로, 모든 테스트 클래스가 같은 이름을 쓰면 커밋된 픽스처가 다른 테스트 문맥으로 새어 나갈 수 있습니다. 클래스마다 고유한 이름을 명시하고, H2의 PostgreSQL 모드는 그대로 둡니다.
package board.jdbc;
import java.util.Objects;
import java.util.regex.Pattern;
public record DatabaseProfile(
String product,
String jdbcUrl,
String username,
String password
) {
private static final Pattern DATABASE_NAME =
Pattern.compile("[a-z][a-z0-9_]{7,79}");
public DatabaseProfile {
if (product == null || product.isBlank()
|| jdbcUrl == null || !jdbcUrl.startsWith("jdbc:")
|| username == null || username.isBlank()
|| password == null) {
throw new IllegalArgumentException("invalid database profile");
}
}
public static DatabaseProfile classUniqueH2(String databaseName) {
Objects.requireNonNull(databaseName, "databaseName");
if (!DATABASE_NAME.matcher(databaseName).matches()) {
throw new IllegalArgumentException("invalid test database name");
}
return new DatabaseProfile(
"H2",
"jdbc:h2:mem:" + databaseName
+ ";MODE=PostgreSQL;DB_CLOSE_DELAY=-1",
"sa",
"");
}
}테스트 구성은 DataSource와 JdbcTemplate만 만들고, 트랜잭션 관리자는 앞 장의 JdbcTransactionConfiguration을 그대로 가져옵니다. ResourceDatabasePopulator가 클래스 경로의 전체 스키마를 한 번 초기화합니다.
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.context.annotation.Import;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.env.Environment;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
import board.config.JdbcTransactionConfiguration;
@Configuration(proxyBeanMethods = false)
@Import(JdbcTransactionConfiguration.class)
public class DatabaseTestConfiguration {
@Bean
DatabaseProfile databaseProfile(Environment environment) {
return DatabaseProfile.classUniqueH2(
environment.getRequiredProperty("board.test.database"));
}
@Bean
DataSource dataSource(DatabaseProfile profile) {
var dataSource = new JdbcDataSource();
dataSource.setURL(profile.jdbcUrl());
dataSource.setUser(profile.username());
dataSource.setPassword(profile.password());
var schema = new ResourceDatabasePopulator(
new ClassPathResource("schema.sql"));
schema.execute(dataSource);
return dataSource;
}
@Bean
JdbcTemplate jdbcTemplate(DataSource dataSource) {
return new JdbcTemplate(dataSource);
}
}스키마 초기화는 테스트 메서드의 트랜잭션 밖에서 끝내고, 메서드 안에는 DML 픽스처만 둡니다. DDL이 트랜잭션에서 어떻게 동작하는지는 DB마다 다르므로, 자동 롤백에 포함된다고 가정하지 않습니다.
@Transactional 자동 롤백
Spring Test는 테스트 메서드 전에 트랜잭션을 열고, 정상적으로 끝나거나 실패하면 기본적으로 롤백합니다. 테스트 안에서는 방금 쓴 행을 읽을 수 있지만, 메서드가 끝나면 회원과 게시글이 모두 사라져야 합니다. 다음 테스트는 앞 장부터 써 온 CreatePostCommand와 이 장 첫 글의 PostSnapshot을 그대로 쓰고, 공유 스키마의 열 이름으로만 DML을 실행합니다.
package board.jdbc;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Instant;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import javax.sql.DataSource;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.transaction.AfterTransaction;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.Transactional;
import board.application.PostSnapshot;
import board.application.postcreation.CreatePostUseCase.CreatePostCommand;
@SpringJUnitConfig(DatabaseTestConfiguration.class)
@TestPropertySource(properties =
"board.test.database=rollback_isolated_repository_test")
@Transactional
@Execution(ExecutionMode.SAME_THREAD)
class RollbackIsolatedRepositoryTest {
@Autowired
JdbcTemplate jdbc;
@Autowired
DataSource dataSource;
@Autowired
PlatformTransactionManager transactionManager;
@Test
void DML은_test_transaction_안에서_보인다() {
var command = new CreatePostCommand(
41L,
"Rollback boundary",
"자동 롤백으로 격리되는 게시글",
LocalDate.parse("2026-08-29"),
"rollback-0001");
Instant createdAt = Instant.parse("2026-08-29T01:00:00Z");
insertMember(command.memberId());
int changed = jdbc.update("""
insert into posts(
member_id, title, content, published_on,
client_request_id, created_at, version)
values (?, ?, ?, ?, ?, ?, 0)
""",
command.memberId(),
command.title(),
command.content(),
command.publishedOn(),
command.clientRequestId(),
OffsetDateTime.ofInstant(createdAt, ZoneOffset.UTC));
PostSnapshot snapshot = jdbc.queryForObject("""
select id, member_id, title, content, published_on,
client_request_id, created_at, version
from posts
where member_id = ? and client_request_id = ?
""",
(resultSet, rowNumber) -> new PostSnapshot(
resultSet.getLong("id"),
resultSet.getLong("member_id"),
resultSet.getString("title"),
resultSet.getString("content"),
resultSet.getObject("published_on", LocalDate.class),
resultSet.getString("client_request_id"),
resultSet.getObject(
"created_at", OffsetDateTime.class).toInstant(),
resultSet.getLong("version")),
command.memberId(),
command.clientRequestId());
assertThat(changed).isEqualTo(1);
assertThat(snapshot.memberId()).isEqualTo(command.memberId());
assertThat(snapshot.title()).isEqualTo(command.title());
assertThat(snapshot.content()).isEqualTo(command.content());
assertThat(snapshot.publishedOn()).isEqualTo(command.publishedOn());
assertThat(snapshot.clientRequestId())
.isEqualTo(command.clientRequestId());
assertThat(snapshot.createdAt()).isEqualTo(createdAt);
assertThat(snapshot.version()).isZero();
}
@Test
void JDBC_component는_같은_DataSource_instance를_사용한다() {
assertThat(jdbc.getDataSource()).isSameAs(dataSource);
assertThat(transactionManager)
.isInstanceOf(DataSourceTransactionManager.class);
var jdbcManager =
(DataSourceTransactionManager) transactionManager;
assertThat(jdbcManager.getDataSource()).isSameAs(dataSource);
}
@AfterTransaction
void rollback_뒤에는_DML_fixture가_남지_않는다() {
Long posts = jdbc.queryForObject(
"select count(*) from posts", Long.class);
Long members = jdbc.queryForObject(
"select count(*) from members", Long.class);
assertThat(posts).isZero();
assertThat(members).isZero();
}
private void insertMember(long memberId) {
jdbc.update("""
insert into members(
id, email, password_hash, name,
active, daily_character_limit)
values (?, ?, ?, ?, true, 10000)
""",
memberId,
"rollback-" + memberId + "@example.com",
"{noop}password",
"Rollback member");
}
}이 테스트가 보여 주는 것은 한 테스트 트랜잭션 안의 SQL과 매핑, 제약 조건, 그리고 끝난 뒤의 격리입니다. AFTER_COMMIT 리스너나 지연 제약 조건까지 실행했다고 말하지 않습니다.
더 깊이: 자동 롤백에서 빠져나가는 변경
테스트 트랜잭션이 모든 변경을 되돌린다고 믿기 쉽지만, Spring 테스트 레퍼런스는 그 범위를 분명히 제한합니다. Spring의 테스트 지원은 테스트 메서드를 부르기 전에 트랜잭션 상태를 ThreadLocal로 현재 스레드에 묶습니다. 그래서 다른 스레드에서 실행된 작업은 테스트 트랜잭션에 참여하지 않고, 테스트가 롤백되어도 그 작업의 변경은 커밋된 채 남습니다. 레퍼런스는 그런 예로 JUnit Jupiter의 assertTimeoutPreemptively(...)를 듭니다. 이 단언은 시간 제한을 걸려고 테스트 코드를 새 스레드에서 실행하므로, 그 안에서 저장소를 호출하면 클래스별 DB에 행이 남습니다.
전파 속성도 같은 문제를 만듭니다. 레퍼런스는 테스트 대상 코드가 REQUIRED나 SUPPORTS가 아닌 전파를 쓸 때 주의하라고 하는데, 예를 들어 REQUIRES_NEW로 연 안쪽 트랜잭션은 테스트 트랜잭션과 따로 커밋되므로 테스트가 끝난 뒤에도 남습니다. 한 테스트 안에서 커밋 뒤의 상태를 보고 싶다면 별도 클래스 대신 TestTransaction을 쓸 수도 있습니다. TestTransaction.flagForCommit()과 end()로 지금까지의 변경을 커밋하고, start()로 새 테스트 트랜잭션을 열면 그 뒤의 변경은 다시 자동으로 롤백됩니다. 다만 이렇게 커밋한 행은 이 글의 명시적 커밋 테스트처럼 직접 정리해야 합니다.
명시적 커밋과 실패한 뒤의 정리
커밋한 뒤의 현상은 실제로 커밋하는 별도의 테스트에서 봅니다. TransactionTemplate의 첫 실행이 데이터를 커밋하고, 두 번째 실행이 새 트랜잭션에서 그것을 읽습니다. 커밋된 픽스처는 자동으로 롤백되지 않으므로, @AfterEach가 검증이 실패한 뒤에도 정리를 커밋합니다. 이 클래스는 롤백 테스트와 다른 H2 이름을 씁니다.
package board.jdbc;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Instant;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Objects;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.test.context.TestPropertySource;
import org.springframework.transaction.support.TransactionTemplate;
import board.application.PostSnapshot;
import board.application.postcreation.CreatePostUseCase.CreatePostCommand;
@SpringJUnitConfig(DatabaseTestConfiguration.class)
@TestPropertySource(properties =
"board.test.database=explicit_commit_boundary_test")
@Execution(ExecutionMode.SAME_THREAD)
class CommitBoundaryTest {
@Autowired
JdbcTemplate jdbc;
@Autowired
TransactionTemplate transactions;
@Test
void commit한_row는_새_transaction에서_보인다() {
var command = new CreatePostCommand(
51L,
"Commit boundary",
"명시적으로 커밋한 게시글",
LocalDate.parse("2026-08-29"),
"commit-00001");
Instant createdAt = Instant.parse("2026-08-29T02:00:00Z");
transactions.executeWithoutResult(status -> {
insertMember(command.memberId());
jdbc.update("""
insert into posts(
member_id, title, content, published_on,
client_request_id, created_at, version)
values (?, ?, ?, ?, ?, ?, 0)
""",
command.memberId(),
command.title(),
command.content(),
command.publishedOn(),
command.clientRequestId(),
OffsetDateTime.ofInstant(createdAt, ZoneOffset.UTC));
});
PostSnapshot committed = Objects.requireNonNull(
transactions.execute(status -> jdbc.queryForObject("""
select id, member_id, title, content, published_on,
client_request_id, created_at, version
from posts
where member_id = ? and client_request_id = ?
""",
(resultSet, rowNumber) -> new PostSnapshot(
resultSet.getLong("id"),
resultSet.getLong("member_id"),
resultSet.getString("title"),
resultSet.getString("content"),
resultSet.getObject(
"published_on", LocalDate.class),
resultSet.getString("client_request_id"),
resultSet.getObject(
"created_at",
OffsetDateTime.class).toInstant(),
resultSet.getLong("version")),
command.memberId(),
command.clientRequestId())));
assertThat(committed.memberId()).isEqualTo(command.memberId());
assertThat(committed.clientRequestId())
.isEqualTo(command.clientRequestId());
assertThat(committed.createdAt()).isEqualTo(createdAt);
assertThat(committed.version()).isZero();
}
@AfterEach
void committed_fixture를_항상_정리한다() {
transactions.executeWithoutResult(status -> {
jdbc.update("delete from post_event_outbox");
jdbc.update("delete from daily_post_stats");
jdbc.update("delete from posts");
jdbc.update("delete from members");
});
Long posts = jdbc.queryForObject(
"select count(*) from posts", Long.class);
Long members = jdbc.queryForObject(
"select count(*) from members", Long.class);
assertThat(posts).isZero();
assertThat(members).isZero();
}
private void insertMember(long memberId) {
jdbc.update("""
insert into members(
id, email, password_hash, name,
active, daily_character_limit)
values (?, ?, ?, ?, true, 10000)
""",
memberId,
"commit-" + memberId + "@example.com",
"{noop}password",
"Commit member");
}
}full schema version = 1
rollback snapshot visible inside transaction = true
rows after automatic rollback = 0
explicit commit visible from new transaction = true
committed fixture after cleanup = 0
transaction manager and JdbcTemplate DataSource identity = same공유되는 영속 DB에서 포괄적인 delete를 정리 방법으로 쓰지 않습니다. 위의 삭제는 클래스 고유의 메모리 DB 안에서만 실행되며, 운영 DataSource를 가리킬 가능성이 있는 구성에서는 허용하지 않습니다.
H2와 실제 엔진이 나누어 확인하는 것
H2의 PostgreSQL 모드는 기본 CRUD와 이름 기반 매핑, 제약 조건, 트랜잭션 참여를 빠르게 검증합니다. 다음 사실은 실제 PostgreSQL을 명시적으로 준비한 테스트가 없다면 주장하지 않습니다.
- SQLState와 제약 조건 이름을 꺼내는 공급자별 세부 동작
- MVCC 잠금과 교착 상태, 직렬화 가능 격리에서의 중단
- JSONB와 배열, 부분 인덱스, 실행 계획
- 정렬 규칙과, 보충 문자를 포함한 유니코드 길이의 동등성
returning이나on conflict같은 PostgreSQL 전용 구문
실제 엔진으로 하는 선택적 테스트는 같은 PostRepository와 PostQuery의 공통 테스트를 실행하되, 공급자의 메시지가 아니라 공개 예외 타입과 원인의 보존, 기존 행의 유지, 롤백 결과를 비교합니다.
정리
- 순수 값 테스트와 H2 롤백 테스트, 명시적 커밋 테스트, 실제 엔진 테스트는 확인하는 사실이 다르므로 주장에 맞게 고르고 함께 씁니다.
- 테스트도 운영의
schema.sql을 그대로 쓰고, 메모리 DB 이름은 클래스마다 달리해 커밋된 픽스처가 새어 나가지 않게 합니다. - 자동 롤백 테스트는 트랜잭션 안의 SQL과 매핑만 확인하며, 커밋 뒤의 가시성은 명시적으로 커밋하는 별도 테스트에서 봅니다.
- 커밋한 픽스처는 실패해도
@AfterEach에서 정리하되, 포괄적인 삭제는 테스트 전용 DB에서만 허용합니다. - SQLState와 잠금, 방언, 실행 계획은 실제 엔진을 준비한 테스트에서만 확인했다고 기록합니다.
연습 문제
1. 확인할 주장에 맞는 검사 고르기
① 검색 값의 %가 글자 그대로 이스케이프되는지, ② 같은 연결에서 INSERT한 행을 조회할 수 있는지, ③ 명시적으로 커밋한 뒤 새 트랜잭션에서 행을 읽는지, ④ PostgreSQL에서 특정한 잠금 경쟁이 어떻게 끝나는지를 각각 어느 범위에서 확인해야 할까요? ②가 통과했다고 ③과 ④도 확인되었다고 답하지 말고, 확인하는 시점과 엔진을 적어 보세요.
해설 보기
①은 먼저 순수한 패턴 함수에서 변환된 값을 확인합니다. DB가 그 패턴을 같은 의미로 해석하는지까지 주장하려면 SQL을 실행하는 테스트도 필요합니다. ②는 H2 트랜잭션 안의 조회로 확인할 수 있지만, 그 테스트가 끝나며 자동 롤백되므로 커밋 뒤의 가시성은 보지 못합니다. ③은 커밋을 완료하고 새 트랜잭션에서 읽는 별도의 테스트가 필요합니다. ④는 실제 PostgreSQL과, 정한 격리 수준과 동시 호출의 조건을 준비해야 합니다.
새 트랜잭션에서의 조회가 성공해도, 전원 장애 뒤의 디스크 복구나 커밋 뒤 콜백을 직접 검증한 것은 아닙니다. 더 확인할 주장이 있다면 그 사건을 실제로 일으키고 결과를 보는 검사를 따로 고릅니다.
2. H2와 PostgreSQL에서 중복 생성하기
H2와, 명시적으로 준비한 PostgreSQL에서 (member_id, client_request_id)가 같은 생성을 두 번 실행하려고 합니다. 두 구현 모두 DuplicatePostRequestException을 던지고 원인을 보존하며, 기존 게시글 한 행만 남고 실패한 트랜잭션의 다른 DML은 롤백되어야 합니다. PostgreSQL을 준비하지 않았다면 H2의 결과를 PostgreSQL 호환성을 확인한 결과로 쓰면 안 됩니다. 테스트를 어떻게 구성하나요?
해설 보기
공통 테스트에 DataSource 팩토리와 스키마 초기화 훅을 주입하고, H2와 PostgreSQL의 구체 테스트가 같은 테스트 메서드를 상속하게 합니다. H2 쪽은 DatabaseProfile.classUniqueH2(...)를 쓰고, PostgreSQL 쪽은 테스트가 가진 데이터베이스나 스키마만 가리키며, 실패해도 정리되는 수명 주기를 먼저 정해 둡니다.
공개된 결과는 예외 메시지나 공급자의 SQLState로 비교하지 않습니다. 공급자별 진단이 필요하다면 엔진 전용 테스트에서만 SQLState를 고정합니다. PostgreSQL 테스트를 실행하지 못한 환경에서는 그 사실을 결과에 남기고, H2의 통과만으로 호환성을 확인했다고 적지 않습니다.
다음 글 MyBatis SQL 매핑에서는 같은 애플리케이션 포트를 MyBatis 매퍼와 XML 리소스 하나로 구현하고, Spring 트랜잭션에 참여시키는 방법을 살펴봅니다.