본문으로 건너뛰기

안동민 개발노트

본문 시작

데이터베이스 테스트

운영 스키마를 재사용하는 H2 테스트를 롤백·명시적 커밋 경계로 분리하고 실제 DB 호환성 검증의 범위를 구분합니다.

리포지토리 테스트는 SQL을 실제 데이터베이스에 보내 열 이름, 제약 조건, 생성 키와 트랜잭션 경계를 확인합니다.

다만 한 테스트 방식이 모든 현상을 보여 주지는 않습니다. 순수 값 테스트, 자동 롤백 H2 테스트, 명시적 커밋 테스트, 운영 DB 호환성 테스트는 관찰하는 사실이 서로 다릅니다.

데이터베이스 테스트는 관찰할 현상에 맞춰 네 층으로 나눈다

DATABASE TEST · ISOLATION · ENGINE FIDELITY

데이터베이스 테스트는 관찰할 현상에 맞춰 네 층으로 나눈다

빠른 순수 테스트에서 실제 PostgreSQL 호환성 테스트로 올라갈수록 관찰하는 현상과 실행 비용이 커진다. 각 층은 전체 schema.sql을 공유하지만 트랜잭션과 fixture 격리 규칙은 목적에 맞게 분리한다.

데이터베이스 테스트의 네 계층과 각 격리 규칙 순수 값과 빌더 테스트, H2 자동 롤백 테스트, 명시적 커밋 테스트, 실제 PostgreSQL 호환성 테스트가 아래에서 위로 현실성을 높인다. 모든 데이터베이스 테스트는 운영 schema.sql을 쓰며, 클래스별 데이터베이스나 스키마로 fixture를 격리한다. REALISM 실제 엔진 현상 L4 · REAL ENGINE PostgreSQL 호환성 SQLState · MVCC · dialect · collation을 실제로 실행 ISOLATION 테스트 소유 database/schema · 실패 뒤 폐기 L3 · EXPLICIT COMMIT 커밋 뒤 경계 새 transaction의 가시성 · after-commit 현상 · 정리 commit ISOLATION 클래스 고유 H2 · @AfterEach에서 항상 정리 L2 · AUTOMATIC ROLLBACK H2 repository 계약 실제 SQL · 전체 row mapping · constraint · transaction 참여 ISOLATION 클래스 고유 H2 · method DML 자동 rollback L1 · PURE VALUES 명령·검색·fixture builder 범위 검증 · literal pattern · cursor 구성 · DB 접근 없음 ISOLATION 각 test가 필요한 최소 불변 값만 생성 SHARED FLOOR schema.sql version 1 · member_id · published_on · client_request_id · version BOUNDARY H2 PASS는 PostgreSQL lock·SQLState·실행 계획의 증거가 아니다
  1. PURE VALUES

    명령·검색·fixture builder를 DB 없이 검증한다

    범위, 리터럴 패턴과 cursor 구성을 빠르게 확인하고 각 테스트에 필요한 최소 값만 만듭니다.

  2. H2 ROLLBACK

    전체 schema.sql에서 SQL과 mapping을 실행한다

    클래스 고유 H2 이름을 사용하고 메서드의 DML은 Spring Test가 자동 롤백합니다.

  3. EXPLICIT COMMIT

    새 트랜잭션에서 커밋된 결과를 관찰한다

    커밋 테스트는 별도 데이터베이스를 쓰며 검증 실패 뒤에도 정리를 커밋합니다.

  4. REAL ENGINE

    PostgreSQL에서만 공급자 현상을 주장한다

    SQLState, MVCC, 방언과 정렬은 테스트가 소유한 실제 database/schema에서 실행한 결과로만 기록합니다.

한 계층의 PASS를 위 계층의 증거로 확대하지 않는다. 공통 포트와 스키마는 같게 유지하되, 관찰하려는 현상에 맞는 트랜잭션과 격리 수단을 선택한다.


운영 스키마를 읽는 클래스별 데이터베이스

테스트도 B90의 src/main/resources/schema.sql을 그대로 실행합니다. 축약된 posts 테이블을 다시 정의하면 운영 제약 조건과 테스트 제약 조건이 갈라집니다.

DB_CLOSE_DELAY=-1은 마지막 연결이 닫혀도 메모리 데이터베이스를 유지하므로 모든 테스트 클래스가 같은 이름을 쓰면 커밋된 픽스처가 새 문맥으로 새어 나갈 수 있습니다. 각 클래스는 고유한 이름을 명시하고 H2 PostgreSQL 모드를 유지합니다.

src/test/java/board/jdbc/DatabaseProfile.java
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가 클래스 경로의 전체 스키마를 한 번 초기화합니다.

src/test/java/board/jdbc/DatabaseTestConfiguration.java
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는 테스트 메서드 전에 트랜잭션을 열고 정상 종료나 실패 뒤 기본 롤백합니다. 테스트 안에서는 방금 쓴 행을 읽을 수 있지만, 메서드 뒤에는 회원과 게시글이 모두 사라져야 합니다.

다음 테스트는 Author A의 CreatePostCommandPostSnapshot을 그대로 사용하고 B90 열 이름으로만 DML을 실행합니다.

src/test/java/board/jdbc/RollbackIsolatedRepositoryTest.java
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 이름을 사용합니다.

src/test/java/board/jdbc/CommitBoundaryTest.java
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");
    }
}
database test 경계 결과
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 전용 구문

선택적 실제 엔진 계약 테스트는 같은 PostRepositoryPostQuery 계약을 실행하되, 공급자 메시지가 아니라 공개 예외 타입, 원인 보존, 기존 행 유지와 롤백 결과를 비교합니다.


연습 문제

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 트랜잭션에 참여시키는 방법을 살펴봅니다.