데이터베이스 테스트
운영 스키마를 재사용하는 H2 테스트를 롤백·명시적 커밋 경계로 분리하고 실제 DB 호환성 검증의 범위를 구분합니다.
리포지토리 테스트는 SQL을 실제 데이터베이스에 보내 열 이름, 제약 조건, 생성 키와 트랜잭션 경계를 확인합니다.
다만 한 테스트 방식이 모든 현상을 보여 주지는 않습니다. 순수 값 테스트, 자동 롤백 H2 테스트, 명시적 커밋 테스트, 운영 DB 호환성 테스트는 관찰하는 사실이 서로 다릅니다.
검증할 주장에 맞는 관찰을 고르기
① 검색 값의 %가 리터럴로 이스케이프되는지, ② 같은 연결의 INSERT를 조회할 수 있는지, ③ 명시적 커밋 뒤 새 트랜잭션에서 행을 읽는지, ④ PostgreSQL의 특정 잠금 경쟁이 어떻게 끝나는지를 각각 어느 범위에서 확인해야 할까요? ②가 통과했다는 사실만으로 ③·④도 증명됐다고 답하지 말고 관찰 시점과 엔진을 적어 보세요.
값·현재 경계·다음 경계·실제 엔진의 풀이
①은 우선 순수 패턴 함수에서 변환 값을 확인합니다. DB가 그 패턴을 같은 의미로 해석하는지까지 주장하려면 SQL 실행 테스트도 필요합니다. ②는 H2 트랜잭션 내부 조회로 관찰할 수 있지만 그 테스트가 끝나며 자동 롤백하면 커밋 뒤 가시성은 보지 못합니다. ③은 명시적 커밋을 완료하고 새 트랜잭션에서 읽는 별도 경계가 필요합니다. ④는 실제 PostgreSQL과 명시한 격리·동시 호출 조건을 준비해야 합니다.
새 트랜잭션의 조회 성공도 전원 장애 뒤 디스크 복구나 after-commit 콜백을 직접 검증한 것은 아닙니다. 필요한 주장이 더 있다면 그 사건을 발생시키고 결과를 관찰하는 검사를 별도로 고릅니다. 아래 출력은 원 예제의 기대 관찰이며 이 문서 검토에서 새로 실행한 로그가 아닙니다.
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·실행 계획의 증거로 확대하지 않습니다.
운영 스키마를 읽는 클래스별 데이터베이스
테스트도 앞 장에서 만든 공유 스키마인 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",
"");
}
}테스트 구성은 데이터소스와 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 리스너나 지연 제약 조건까지 실행했다고 말하지 않습니다.
명시적 커밋과 실패 뒤 정리
커밋 뒤 현상은 실제로 커밋하는 별도 테스트에서 봅니다. 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 안에서만 실행되며, 운영 데이터소스를 가리킬 가능성이 있는 구성에서는 허용하지 않습니다.
H2와 실제 엔진이 나누어 증명하는 것
H2 PostgreSQL 모드는 기본 CRUD, 이름 기반 매핑, 제약 조건과 트랜잭션 참여를 빠르게 검증합니다. 다음 사실은 실제 PostgreSQL을 명시적으로 준비한 테스트가 없으면 주장하지 않습니다.
- SQLState와 제약 조건 이름 추출의 공급자별 세부
- MVCC 잠금, 교착 상태, 직렬화 가능 격리 중단
- JSONB, 배열, 부분 인덱스와 실행 계획
- 정렬 규칙과 보충 문자를 포함한 Unicode 길이 동등성
returning,on conflict같은 PostgreSQL 전용 구문
선택적 실제 엔진 계약 테스트는 같은 PostRepository와 PostQuery 계약을 실행하되, 공급자 메시지가 아니라 공개 예외 타입, 원인 보존, 기존 행 유지와 롤백 결과를 비교합니다.
연습 문제
H2와 명시적으로 프로비저닝한 PostgreSQL에서 (member_id, client_request_id) 중복 생성을 실행하세요.
두 구현 모두 DuplicatePostRequestException을 던지고 원인을 보존하며, 기존 게시글 한 행만 남고 실패한 트랜잭션의 다른 DML은 롤백되어야 합니다. PostgreSQL을 준비하지 않았다면 H2 결과를 PostgreSQL 호환성 증거로 기록하지 마세요.
해설 보기
공통 계약 테스트에 DataSource 팩토리와 스키마 초기화 훅을 주입하고 H2와 PostgreSQL 구현이 같은 테스트 메서드를 상속하게 합니다.
H2 구현은 위 DatabaseProfile.classUniqueH2(...)를 사용합니다. PostgreSQL 구현은 테스트가 소유한 데이터베이스나 스키마만 가리키며 실패해도 정리되는 생명 주기를 먼저 고정합니다.
공개 계약은 예외 메시지나 공급자 SQLState를 비교하지 않습니다. 공급자 진단이 필요하면 별도의 엔진 전용 테스트에서만 SQLState를 고정합니다.
다음 문서에서는 같은 애플리케이션 포트를 MyBatis mapper와 단일 XML 리소스로 구현하고 Spring 트랜잭션에 참여시키는 방법을 살펴봅니다.