본문으로 건너뛰기

안동민 개발노트

본문 시작

선언적 트랜잭션

선언적 트랜잭션의 프록시·리소스 연결·커밋 후 콜백을 실행하고 내부 호출과 롤백 규칙을 검증합니다.

@Transactional은 메서드 안에 커밋 코드를 삽입하는 애노테이션이 아닙니다.

Spring이 빈 앞에 프록시를 만들고 외부 호출을 가로채 트랜잭션 매니저를 실행합니다.

매니저는 TransactionSynchronizationManager에 리소스와 동기화 상태를 현재 실행 컨텍스트에 연결하고 메서드가 끝나면 커밋·롤백과 콜백을 정리합니다.

프록시 경계를 모르면 애노테이션이 있어도 트랜잭션이 시작되지 않는 경우를 만들 수 있습니다.

선언적 트랜잭션은 호출이 프록시를 통과할 때만 경계를 적용한다

PROXY BOUNDARY · SELF INVOCATION · OBSERVED STATE

선언적 트랜잭션은 호출이 프록시를 통과할 때만 경계를 적용한다

같은 @Transactional 메서드라도 다른 빈에서 들어온 호출은 인터셉터가 트랜잭션을 열지만, this.method() 내부 호출은 프록시를 다시 거치지 않아 애노테이션을 평가하지 않는다.

외부 프록시 호출과 같은 인스턴스 내부 호출의 트랜잭션 상태 비교 위쪽 외부 호출은 Spring 프록시와 트랜잭션 인터셉터를 지나 target 메서드에서 실제 트랜잭션과 동기화가 활성화된다. 아래쪽 같은 인스턴스 내부 호출은 this 참조가 프록시를 우회하므로 target 메서드의 애노테이션이 평가되지 않고 트랜잭션과 동기화가 모두 비활성이다. A · EXTERNAL BEAN CALL · PROXY INTERCEPTED CALL SITE ImportCoordinator 다른 빈 참조 FIRST DIVERGENCE Spring proxy 호출 가로채기 POLICY Tx interceptor REQUIRES_NEW · begin TARGET OBSERVATION ACTIVE transaction=true · sync=true B · SAME INSTANCE CALL · PROXY BYPASSED CALL SITE 같은 target 프록시 안쪽 코드 FIRST DIVERGENCE this.method() 프록시 재진입 없음 ANNOTATION @Transactional NOT REACHED by interceptor TARGET OBSERVATION NO TX transaction=false · sync=false BOUNDARY CONSEQUENCES 롤백 규칙 RuntimeException 기본 롤백 · 검사 예외는 rollbackFor 완료 콜백 afterCommit 실패는 이미 커밋된 행을 되돌리지 못함 신뢰할 수 있는 이벤트 게시글과 outbox를 같은 DB 트랜잭션에 저장 DESIGN RULE 자기 주입보다 트랜잭션 사용 사례를 별도 빈의 공개 메서드로 분리한다

A · EXTERNAL CALL

다른 빈 → 프록시 → 인터셉터 → target

  1. 프록시 진입

    Spring이 관리하는 다른 빈의 참조로 공개 메서드를 호출합니다.

  2. 정책 평가

    인터셉터가 REQUIRES_NEW, timeout, rollback 규칙을 읽고 트랜잭션을 엽니다.

  3. 관찰 결과

    transaction=true, synchronization=true입니다.

B · SELF INVOCATION

같은 target → this.method() → target

  1. 프록시 우회

    이미 target 안에 있으므로 외부 프록시를 다시 통과하지 않습니다.

  2. 정책 미평가

    메서드의 @Transactional은 인터셉터에 도달하지 않습니다.

  3. 관찰 결과

    transaction=false, synchronization=false입니다.

애노테이션 존재가 아니라 실제 호출 경로를 테스트한다. 프록시 빈을 주입받아 활성 상태, DB 행 롤백, afterCommit, 아웃박스 커밋을 결과로 검증한다.


프록시와 트랜잭션 인터셉터

애플리케이션 서비스의 공개 사용 사례 메서드에 트랜잭션을 둡니다.

컨트롤러나 비공개 도우미에 흩뿌리지 않고 애그리거트 변경 범위를 드러내는 이름을 사용합니다.

src/main/java/board/application/PostEventOutbox.java
package board.application;

public interface PostEventOutbox {
    void append(PostCreatedEvent event);
}
src/main/java/board/application/DeclarativePostService.java
package board.application;

import java.time.Clock;
import java.time.Instant;

import board.application.postcreation.CreatePostUseCase.CreatePostCommand;
import board.application.postcreation.DailyLimitExceededException;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class DeclarativePostService {
    private final DailyPostRepository daily;
    private final PostRepository posts;
    private final PostEventOutbox outbox;
    private final Clock clock;

    public DeclarativePostService(
            DailyPostRepository daily,
            PostRepository posts,
            PostEventOutbox outbox,
            Clock clock
    ) {
        this.daily = daily;
        this.posts = posts;
        this.outbox = outbox;
        this.clock = clock;
    }

    @Transactional(timeout = 5)
    public long register(CreatePostCommand command) {
        var reservation = daily.reserve(
                command.memberId(),
                command.publishedOn(),
                command.content().length());
        if (!reservation.accepted()) {
            throw new DailyLimitExceededException(
                    reservation.limitCharacters(),
                    reservation.usedCharacters(),
                    reservation.attemptedCharacters());
        }
        Instant createdAt = clock.instant();
        long postId = posts.insert(command, createdAt);
        outbox.append(new PostCreatedEvent(
                postId,
                command.memberId(),
                createdAt,
                command.clientRequestId()));
        return postId;
    }

    @Transactional(readOnly = true)
    public PostSummary summary(long memberId) {
        return posts.summary(memberId);
    }
}

게시글과 아웃박스 삽입이 같은 DataSource와 매니저를 사용하면 함께 커밋됩니다.

실제 메시지 전송은 트랜잭션 밖 워커가 수행합니다.

afterCommit에서 바로 원격 호출을 하면 실패 재시도와 프로세스 장애 사이 이벤트를 잃을 수 있어 영속적 아웃박스가 더 안전합니다.

읽기 전용 메서드는 의도를 표현하고 ORM 플러시 최적화나 DB 읽기 전용 설정에 도움을 줄 수 있지만 변경 보안을 보장하지 않습니다.

DB 권한과 통합 테스트로 실제 행동을 확인합니다.


내부 호출 우회

this.register()처럼 빈 내부 메서드가 다른 @Transactional 메서드를 호출하면 외부 프록시를 다시 통과하지 않습니다.

private 메서드 애노테이션도 프록시 방식에서는 적용되지 않습니다.

트랜잭션 경계 메서드를 다른 서비스 빈으로 분리하거나 바깥 공개 사용 사례 하나에 둡니다.

src/main/java/board/application/ImportCoordinator.java
package board.application;

import java.util.List;

import board.application.postcreation.CreatePostUseCase.CreatePostCommand;

import org.springframework.stereotype.Service;

@Service
public class ImportCoordinator {
    private final ImportOnePost importOne;

    public ImportCoordinator(ImportOnePost importOne) {
        this.importOne = importOne;
    }

    public ImportReport importAll(List<CreatePostCommand> commands) {
        int succeeded = 0;
        for (CreatePostCommand command : commands) {
            importOne.inOwnTransaction(command);
            succeeded += 1;
        }
        return new ImportReport(commands.size(), succeeded);
    }

    public record ImportReport(int attempted, int succeeded) {
        public ImportReport {
            if (attempted < 0 || succeeded < 0 || succeeded > attempted) {
                throw new IllegalArgumentException("invalid import report");
            }
        }
    }
}
src/main/java/board/application/ImportOnePost.java
package board.application;

import java.time.Clock;

import board.application.postcreation.CreatePostUseCase.CreatePostCommand;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;

@Service
public class ImportOnePost {
    private final PostRepository posts;
    private final Clock clock;

    public ImportOnePost(PostRepository posts, Clock clock) {
        this.posts = posts;
        this.clock = clock;
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public TransactionObservation inOwnTransaction(
            CreatePostCommand command
    ) {
        var observation = observation("inOwnTransaction");
        posts.insert(command, clock.instant());
        return observation;
    }

    public TransactionObservation throughSelfInvocation(
            CreatePostCommand command
    ) {
        return inOwnTransaction(command);
    }

    private TransactionObservation observation(String method) {
        return new TransactionObservation(
                method,
                TransactionSynchronizationManager
                        .isActualTransactionActive(),
                TransactionSynchronizationManager
                        .isSynchronizationActive());
    }
}

이 예제는 별도 빈이라 프록시를 통과하지만 REQUIRES_NEW가 배치 요구에 맞는지는 별도 판단입니다.

한 행 실패 시 전체 중단인지 계속 진행인지, 새 연결이 풀에 몇 개 필요한지, 이미 커밋된 앞 행을 사용자에게 어떻게 보고할지 정합니다.

프록시 문제를 고치려고 전파를 바꾸지는 않습니다.


트랜잭션 동기화 상태

애플리케이션 업무 코드가 스레드-로컬 리소스를 직접 조작하지는 않지만 진단과 프레임워크 어댑터에서 트랜잭션 활성 여부와 동기화를 사용할 수 있습니다.

콜백 등록은 트랜잭션이 실제 활성일 때만 해야 합니다.

src/main/java/board/application/AfterCommitNotifier.java
package board.application;

import java.util.Objects;
import java.util.function.LongConsumer;

import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;

public final class AfterCommitNotifier {
    private final LongConsumer notifier;

    public AfterCommitNotifier(LongConsumer notifier) {
        this.notifier = Objects.requireNonNull(notifier, "notifier");
    }

    public void notifyAfterCommit(long postId) {
        if (postId <= 0) {
            throw new IllegalArgumentException("postId must be positive");
        }
        if (!TransactionSynchronizationManager.isActualTransactionActive()
                || !TransactionSynchronizationManager
                        .isSynchronizationActive()) {
            throw new IllegalStateException(
                    "active transaction synchronization required");
        }
        TransactionSynchronizationManager.registerSynchronization(
                new TransactionSynchronization() {
                    @Override
                    public void afterCommit() {
                        notifier.accept(postId);
                    }
                });
    }
}

afterCommit은 DB 커밋 뒤 호출되므로 여기서 예외가 나도 이미 커밋된 행을 롤백할 수 없습니다.

짧고 실패를 허용할 수 있는 캐시 무효화에 적합하지만 신뢰할 수 있는 알림에는 아웃박스를 사용합니다.

방식커밋과의 관계실패 복구적합한 용도
afterCommit 콜백DB 커밋 뒤 실행호출자가 직접 보상·재시도짧은 캐시 무효화
영속적 아웃박스업무 행과 같은 트랜잭션에 저장워커가 재시도유실하면 안 되는 알림·이벤트

콜백이 같은 스레드에서 오래 걸리면 연결 정리와 요청 완료를 늦출 수 있습니다.

리액티브 트랜잭션은 스레드-로컬과 다른 컨텍스트 전파를 사용합니다.

JDBC 트랜잭션 서비스에서 가상 스레드나 비동기 실행자로 작업을 넘기면 현재 리소스가 자동 공유된다고 가정하지 않습니다.

트랜잭션 범위 안에서는 같은 실행 흐름을 유지합니다.


검사 예외 롤백 규칙

기본 선언적 트랜잭션은 RuntimeExceptionError에서 롤백하고 검사 예외는 커밋할 수 있습니다.

업무 실패를 검사 예외로 모델링했다면 rollbackFor를 지정하거나 런타임 도메인 예외로 일관되게 사용합니다.

예외를 예외 처리하고 로그만 한 뒤 정상 반환하면 매니저는 커밋합니다.

메서드 종료기본 규칙명시적 선택
정상 반환커밋필요하면 setRollbackOnly()
RuntimeException·Error롤백noRollbackFor는 매우 제한적으로 사용
검사 예외커밋rollbackFor로 롤백 대상 선언
예외를 잡고 정상 반환커밋다시 던지거나 롤백 전용 상태로 표시
src/main/java/board/application/CsvRow.java
package board.application;

import java.time.LocalDate;

import board.application.postcreation.CreatePostUseCase.CreatePostCommand;

public record CsvRow(
        long memberId,
        String title,
        String content,
        LocalDate publishedOn,
        String clientRequestId
) {
    public CsvRow {
        new CreatePostCommand(
                memberId,
                title,
                content,
                publishedOn,
                clientRequestId);
    }

    public CreatePostCommand toCommand() {
        return new CreatePostCommand(
                memberId,
                title,
                content,
                publishedOn,
                clientRequestId);
    }
}
src/main/java/board/application/CsvReader.java
package board.application;

import java.io.IOException;
import java.nio.file.Path;

@FunctionalInterface
public interface CsvReader {
    void read(Path path, RowConsumer consumer) throws IOException;

    @FunctionalInterface
    interface RowConsumer {
        void accept(CsvRow row);
    }
}
src/main/java/board/application/ImportRepository.java
package board.application;

import java.time.Instant;

import board.application.postcreation.CreatePostUseCase.CreatePostCommand;

public interface ImportRepository {
    void insert(CreatePostCommand command, Instant createdAt);
}
src/main/java/board/application/CsvImportService.java
package board.application;

import java.io.IOException;
import java.nio.file.Path;
import java.time.Clock;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CsvImportService {
    private final ImportRepository imports;
    private final CsvReader csv;
    private final Clock clock;

    public CsvImportService(
            ImportRepository imports,
            CsvReader csv,
            Clock clock
    ) {
        this.imports = imports;
        this.csv = csv;
        this.clock = clock;
    }

    @Transactional(rollbackFor = IOException.class)
    public int importFile(Path path) throws IOException {
        int[] saved = {0};
        csv.read(path, row -> {
            imports.insert(row.toCommand(), clock.instant());
            saved[0] += 1;
        });
        return saved[0];
    }
}

I/O를 DB 트랜잭션 안에서 오래 수행하면 연결을 점유합니다.

실제로는 파일을 먼저 안전한 명령 목록이나 스테이징 테이블로 파싱한 뒤 짧은 트랜잭션에서 반영하는 편이 낫습니다.

예제의 rollbackFor는 검사 예외 규칙을 보여 주며 아키텍처 결정과 별개입니다.


트랜잭션 이벤트 검증

src/test/java/board/application/DeclarativeTransactionTest.java
package board.application;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.io.IOException;
import java.nio.file.Path;
import java.sql.Date;
import java.sql.Statement;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import javax.sql.DataSource;
import board.application.postcreation.CreatePostUseCase.CreatePostCommand;
import board.application.postcreation.DailyLimitExceededException;
import board.config.JdbcTransactionConfiguration;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.transaction.support.TransactionTemplate;
class DeclarativeTransactionTest {
    private static final Instant NOW = Instant.parse(
            "2026-08-29T03:00:00Z");
    @Test
    void 외부_proxy_호출은_게시글_사용량_outbox를_함께_commit한다() {
        try (var context = context()) {
            long postId = context.getBean(DeclarativePostService.class)
                    .register(command("declarative-success-0001", "본문"));
            var jdbc = context.getBean(JdbcTemplate.class);
            assertThat(postId).isPositive();
            assertThat(count(jdbc, "posts")).isEqualTo(1);
            assertThat(count(jdbc, "daily_post_stats")).isEqualTo(1);
            assertThat(count(jdbc, "post_event_outbox")).isEqualTo(1);
            assertThat(jdbc.queryForObject(
                    "select member_id from posts where id = ?",
                    Long.class,
                    postId)).isEqualTo(41L);
            assertThat(jdbc.queryForObject(
                    "select published_on from posts where id = ?",
                    LocalDate.class,
                    postId)).isEqualTo(LocalDate.of(2026, 8, 29));
            assertThat(jdbc.queryForObject(
                    "select version from posts where id = ?",
                    Long.class,
                    postId)).isZero();
            assertThat(jdbc.queryForObject(
                    "select title from posts where id = ?",
                    String.class,
                    postId)).isEqualTo("선언적 트랜잭션");
            assertThat(jdbc.queryForObject(
                    "select content from posts where id = ?",
                    String.class,
                    postId)).isEqualTo("본문");
            assertThat(jdbc.queryForObject(
                    "select client_request_id from posts where id = ?",
                    String.class,
                    postId)).isEqualTo("declarative-success-0001");
            assertThat(jdbc.queryForObject(
                    "select created_at from posts where id = ?",
                    OffsetDateTime.class,
                    postId).toInstant()).isEqualTo(NOW);
            assertThat(jdbc.queryForObject(
                    """
                    select total_characters from daily_post_stats
                    where member_id = 41 and published_on = ?
                    """,
                    Integer.class,
                    LocalDate.of(2026, 8, 29))).isEqualTo(2);
            assertThat(jdbc.queryForObject(
                    "select client_request_id from post_event_outbox",
                    String.class)).isEqualTo("declarative-success-0001");
            assertThat(jdbc.queryForObject(
                    "select post_id from post_event_outbox",
                    Long.class)).isEqualTo(postId);
            assertThat(jdbc.queryForObject(
                    "select member_id from post_event_outbox",
                    Long.class)).isEqualTo(41L);
            assertThat(jdbc.queryForObject(
                    "select occurred_at from post_event_outbox",
                    OffsetDateTime.class).toInstant()).isEqualTo(NOW);
        }
    }
    @Test
    void runtime_exception은_세_테이블의_변경을_모두_rollback한다() {
        try (var context = context()) {
            context.getBean(ToggleOutbox.class).failAfterInsert = true;
            assertThatThrownBy(() -> context
                    .getBean(DeclarativePostService.class)
                    .register(command("declarative-rollback-0001", "본문")))
                    .isInstanceOf(IllegalStateException.class)
                    .hasMessage("outbox append failed");
            var jdbc = context.getBean(JdbcTemplate.class);
            assertThat(count(jdbc, "posts")).isZero();
            assertThat(count(jdbc, "daily_post_stats")).isZero();
            assertThat(count(jdbc, "post_event_outbox")).isZero();
        }
    }
    @Test
    void 게시일별_한도_실패는_upstream_exception과_무변경을_보존한다() {
        try (var context = context()) {
            var jdbc = context.getBean(JdbcTemplate.class);
            jdbc.update(
                    "update members set daily_character_limit = 3 where id = 41");
            assertThatThrownBy(() -> context
                    .getBean(DeclarativePostService.class)
                    .register(command("declarative-limit-0001", "1234")))
                    .isInstanceOfSatisfying(
                            DailyLimitExceededException.class,
                            exception -> {
                                assertThat(exception.limitCharacters())
                                        .isEqualTo(3);
                                assertThat(exception.usedCharacters()).isZero();
                                assertThat(exception.attemptedCharacters())
                                        .isEqualTo(4);
                            });
            assertThat(count(jdbc, "posts")).isZero();
            assertThat(count(jdbc, "daily_post_stats")).isZero();
            assertThat(count(jdbc, "post_event_outbox")).isZero();
        }
    }
    @Test
    void REQUIRES_NEW는_outer_rollback과_독립적으로_commit한다() {
        try (var context = context()) {
            var jdbc = context.getBean(JdbcTemplate.class);
            var reports = new ArrayList<ImportCoordinator.ImportReport>();
            context.getBean(TransactionTemplate.class)
                    .executeWithoutResult(outer -> {
                        reports.add(context.getBean(ImportCoordinator.class)
                                .importAll(List.of(command(
                                        "proxy-inner-commit-0001",
                                        "내부 커밋"))));
                        context.getBean(PostRepository.class).insert(
                                command(
                                        "proxy-outer-rollback-0001",
                                        "바깥 롤백"),
                                NOW);
                        assertThat(count(jdbc, "posts")).isEqualTo(2);
                        outer.setRollbackOnly();
                    });
            assertThat(reports).singleElement().satisfies(report -> {
                assertThat(report.attempted()).isEqualTo(1);
                assertThat(report.succeeded()).isEqualTo(1);
            });
            assertThat(jdbc.queryForList(
                    "select client_request_id from posts",
                    String.class)).containsExactly(
                            "proxy-inner-commit-0001");
        }
    }
    @Test
    void 같은_instance의_내부_호출은_transactional_method를_우회한다() {
        try (var context = context()) {
            var observation = context.getBean(ImportOnePost.class)
                    .throughSelfInvocation(
                            command("proxy-self-call-0001", "본문"));
            assertThat(observation.active()).isFalse();
            assertThat(observation.synchronizationActive()).isFalse();
            assertThat(count(context.getBean(JdbcTemplate.class), "posts"))
                    .isEqualTo(1);
        }
    }
    @Test
    void rollbackFor는_일부_row_처리_뒤의_검사_IO예외도_rollback한다() {
        try (var context = context()) {
            context.getBean(MutableCsvReader.class).script = consumer -> {
                consumer.accept(row("csv-checked-0001", "첫 행"));
                throw new IOException("second row is truncated");
            };
            assertThatThrownBy(() -> context.getBean(CsvImportService.class)
                    .importFile(Path.of("posts.csv")))
                    .isInstanceOf(IOException.class)
                    .hasMessage("second row is truncated");
            assertThat(count(context.getBean(JdbcTemplate.class), "posts"))
                    .isZero();
        }
    }
    @Test
    void afterCommit은_commit에서만_실행되고_실패해도_commit을_되돌리지_못한다() {
        try (var context = context()) {
            var transactions = context.getBean(TransactionTemplate.class);
            var committed = new ArrayList<Long>();
            var notifier = new AfterCommitNotifier(committed::add);
            transactions.executeWithoutResult(status ->
                    notifier.notifyAfterCommit(41L));
            assertThat(committed).containsExactly(41L);
            assertThatThrownBy(() -> transactions.executeWithoutResult(status -> {
                notifier.notifyAfterCommit(42L);
                throw new IllegalStateException("force rollback");
            })).isInstanceOf(IllegalStateException.class);
            assertThat(committed).containsExactly(41L);
            assertThatThrownBy(() -> notifier.notifyAfterCommit(43L))
                    .isInstanceOf(IllegalStateException.class)
                    .hasMessage("active transaction synchronization required");
            var failing = new AfterCommitNotifier(postId -> {
                throw new IllegalStateException("delivery failed");
            });
            assertThatThrownBy(() -> transactions.executeWithoutResult(status -> {
                long postId = context.getBean(PostRepository.class).insert(
                        command("after-commit-fail-0001", "본문"), NOW);
                failing.notifyAfterCommit(postId);
            })).isInstanceOf(IllegalStateException.class)
                    .hasMessage("delivery failed");
            assertThat(count(context.getBean(JdbcTemplate.class), "posts"))
                    .isEqualTo(1);
        }
    }
    private AnnotationConfigApplicationContext context() {
        DataSource dataSource = initializedDataSource();
        var jdbc = new JdbcTemplate(dataSource);
        insertMember(jdbc);
        var clock = Clock.fixed(NOW, ZoneOffset.UTC);
        var posts = new JdbcPostRepository(jdbc);
        var context = new AnnotationConfigApplicationContext();
        context.registerBean(DataSource.class, () -> dataSource);
        context.registerBean(JdbcTemplate.class, () -> jdbc);
        context.registerBean(Clock.class, () -> clock);
        context.registerBean(JdbcDailyPostRepository.class,
                () -> new JdbcDailyPostRepository(jdbc));
        context.registerBean(JdbcPostRepository.class, () -> posts);
        context.registerBean(ToggleOutbox.class,
                () -> new ToggleOutbox(jdbc));
        context.registerBean(MutableCsvReader.class, MutableCsvReader::new);
        context.registerBean(JdbcImportRepository.class,
                () -> new JdbcImportRepository(posts));
        context.register(JdbcTransactionConfiguration.class);
        context.registerBean(DeclarativePostService.class);
        context.registerBean(ImportOnePost.class);
        context.registerBean(ImportCoordinator.class);
        context.registerBean(CsvImportService.class);
        context.refresh();
        return context;
    }
    private DataSource initializedDataSource() {
        var dataSource = new JdbcDataSource();
        dataSource.setURL("jdbc:h2:mem:declarative_"
                + UUID.randomUUID().toString().replace("-", "")
                + ";MODE=PostgreSQL;DB_CLOSE_DELAY=-1");
        dataSource.setUser("sa");
        new ResourceDatabasePopulator(new ClassPathResource("schema.sql"))
                .execute(dataSource);
        return dataSource;
    }
    private void insertMember(JdbcTemplate jdbc) {
        jdbc.update(
                """
                insert into members(
                    id, email, password_hash, name,
                    active, daily_character_limit)
                values (41, 'member@example.com', 'hash', '회원', true, 720)
                """);
    }
    private int count(JdbcTemplate jdbc, String table) {
        return switch (table) {
            case "posts" -> jdbc.queryForObject(
                    "select count(*) from posts", Integer.class);
            case "daily_post_stats" -> jdbc.queryForObject(
                    "select count(*) from daily_post_stats", Integer.class);
            case "post_event_outbox" -> jdbc.queryForObject(
                    "select count(*) from post_event_outbox", Integer.class);
            default -> throw new IllegalArgumentException("unknown table");
        };
    }
    private CreatePostCommand command(String requestId, String content) {
        return row(requestId, content).toCommand();
    }
    private CsvRow row(String requestId, String content) {
        return new CsvRow(
                41L,
                "선언적 트랜잭션",
                content,
                LocalDate.of(2026, 8, 29),
                requestId);
    }
    static final class JdbcDailyPostRepository
            implements DailyPostRepository {
        private final JdbcTemplate jdbc;
        JdbcDailyPostRepository(JdbcTemplate jdbc) {
            this.jdbc = jdbc;
        }
        @Override
        public Reservation reserve(
                long memberId,
                LocalDate publishedOn,
                int attemptedCharacters
        ) {
            int limit = jdbc.queryForObject(
                    "select daily_character_limit from members where id = ?",
                    Integer.class,
                    memberId);
            int used = jdbc.queryForObject(
                    """
                    select coalesce(max(total_characters), 0)
                    from daily_post_stats
                    where member_id = ? and published_on = ?
                    """,
                    Integer.class,
                    memberId,
                    publishedOn);
            boolean accepted = (long) used + attemptedCharacters <= limit;
            if (accepted) {
                int updated = jdbc.update(
                        """
                        update daily_post_stats
                        set total_characters = total_characters + ?
                        where member_id = ? and published_on = ?
                        """,
                        attemptedCharacters,
                        memberId,
                        publishedOn);
                if (updated == 0) {
                    jdbc.update(
                            """
                            insert into daily_post_stats(
                                member_id, published_on, total_characters)
                            values (?, ?, ?)
                            """,
                            memberId,
                            publishedOn,
                            attemptedCharacters);
                }
            }
            return new Reservation(
                    limit, used, attemptedCharacters, accepted);
        }
    }
    static final class JdbcPostRepository implements PostRepository {
        private final JdbcTemplate jdbc;
        JdbcPostRepository(JdbcTemplate jdbc) {
            this.jdbc = jdbc;
        }
        @Override
        public long insert(CreatePostCommand command, Instant createdAt) {
            var keys = new GeneratedKeyHolder();
            jdbc.update(connection -> {
                var statement = connection.prepareStatement(
                        """
                        insert into posts(
                            member_id, title, content, published_on,
                            client_request_id, created_at, version)
                        values (?, ?, ?, ?, ?, ?, 0)
                        """,
                        Statement.RETURN_GENERATED_KEYS);
                statement.setLong(1, command.memberId());
                statement.setString(2, command.title());
                statement.setString(3, command.content());
                statement.setDate(4, Date.valueOf(command.publishedOn()));
                statement.setString(5, command.clientRequestId());
                statement.setObject(6, OffsetDateTime.ofInstant(
                        createdAt, ZoneOffset.UTC));
                return statement;
            }, keys);
            Number key = keys.getKey();
            if (key == null) {
                throw new IllegalStateException("generated key missing");
            }
            return key.longValue();
        }
        @Override
        public PostSummary summary(long memberId) {
            return jdbc.queryForObject(
                    """
                    select count(*), coalesce(sum(length(content)), 0)
                    from posts where member_id = ?
                    """,
                    (result, rowNumber) -> new PostSummary(
                            memberId,
                            result.getLong(1),
                            result.getLong(2)),
                    memberId);
        }
    }
    static final class JdbcImportRepository implements ImportRepository {
        private final JdbcPostRepository posts;
        JdbcImportRepository(JdbcPostRepository posts) {
            this.posts = posts;
        }
        @Override
        public void insert(CreatePostCommand command, Instant createdAt) {
            posts.insert(command, createdAt);
        }
    }
    static final class ToggleOutbox implements PostEventOutbox {
        private final JdbcTemplate jdbc;
        boolean failAfterInsert;
        ToggleOutbox(JdbcTemplate jdbc) {
            this.jdbc = jdbc;
        }
        @Override
        public void append(PostCreatedEvent event) {
            jdbc.update(
                    """
                    insert into post_event_outbox(
                        post_id, member_id, occurred_at,
                        client_request_id, published_at)
                    values (?, ?, ?, ?, null)
                    """,
                    event.postId(),
                    event.memberId(),
                    OffsetDateTime.ofInstant(
                            event.occurredAt(), ZoneOffset.UTC),
                    event.clientRequestId());
            if (failAfterInsert) {
                throw new IllegalStateException("outbox append failed");
            }
        }
    }
    static final class MutableCsvReader implements CsvReader {
        ReadScript script = consumer -> {
        };
        @Override
        public void read(Path path, RowConsumer consumer) throws IOException {
            script.read(consumer);
        }
        @FunctionalInterface
        interface ReadScript {
            void read(RowConsumer consumer) throws IOException;
        }
    }
}

테스트는 공유 schema.sql을 H2 PostgreSQL 모드에 적용하고 실제 DataSourceTransactionManager와 Spring 프록시를 구성합니다.

전체 board.application을 스캔하지 않고, 테스트가 검증할 서비스와 JDBC fixture adapter를 명시적으로 등록해 각 포트의 소유 경계를 드러냅니다.

TransactionTemplate의 바깥 트랜잭션에서 프록시 ImportCoordinator를 거쳐 ImportOnePost를 호출한 뒤 바깥 상태만 롤백 전용으로 표시합니다. REQUIRES_NEW로 저장한 내부 행 하나만 남아야 하므로 전파를 REQUIRED로 바꾸면 이 oracle은 0행으로 실패합니다.

성공 시 게시글·게시일 사용량·아웃박스가 함께 커밋되고, 런타임 예외와 rollbackFor 검사 예외에서는 실제 행이 남지 않습니다.

선언적 서비스를 단순히 new 하면 프록시가 없어 @Transactional 동작을 검증할 수 없습니다.

애노테이션 경계는 Spring 컨텍스트에서 빈을 주입받아 테스트합니다.


트랜잭션 경계 설정

컨트롤러 트랜잭션은 뷰 렌더링과 원격 호출까지 연결을 오래 잡을 수 있습니다.

리포지토리마다 트랜잭션을 두면 여러 변경을 하나로 묶지 못합니다.

애플리케이션 서비스의 명령 메서드가 보통 적절한 경계입니다.

트랜잭션 안에서 지연 로딩이 필요한 쿼리를 모두 실행하고 DTO로 반환합니다.

영속성 컨텍스트를 뷰 렌더링까지 열어 두는 OSIV는 편리하지만 쿼리 발생 위치와 연결 수명을 웹 계층까지 늘릴 수 있으므로 장단점을 확인하고 선택합니다.

여러 매니저나 리소스를 하나의 애노테이션으로 원자화할 수 없습니다.

데이터베이스와 브로커는 아웃박스, 사가, 멱등 소비자 같은 일관성 패턴을 사용합니다.

XA가 필요한 경우 운영 복잡도와 지원 범위를 별도로 평가합니다.


연습 문제

@Transactional이 붙은 메서드를 같은 클래스의 공개 메서드에서 호출해 실제 트랜잭션이 비활성인 실패 테스트를 먼저 작성하세요.

메서드를 별도 빈으로 이동한 뒤 활성 상태, 롤백, 연결 반환이 모두 고쳐지는지 Spring 컨텍스트 테스트로 확인합니다.

해설 보기

대상 클래스를 직접 new 하지 않고 애플리케이션 컨텍스트의 빈으로 호출해야 프록시 차이를 볼 수 있습니다.

내부 메서드에서 TransactionSynchronizationManager.isActualTransactionActive()를 기록하는 점검 리포지토리를 사용합니다.

src/main/java/board/application/TransactionObservation.java
package board.application;

public record TransactionObservation(
        String method,
        boolean active,
        boolean synchronizationActive
) {
    public TransactionObservation {
        if (method == null || method.isBlank()) {
            throw new IllegalArgumentException("method required");
        }
    }
}

자기 주입이나 AopContext.currentProxy()로 우회하기보다 사용 사례 경계가 자연스러운 두 빈으로 나눕니다.

테스트는 빈 클래스가 프록시인지 자체를 고정하기보다 관찰된 트랜잭션 동작을 검증합니다.

이 장에서 드라이버 선택부터 선언적 트랜잭션까지 JDBC 실행 경계를 연결했습니다.

다음 장은 동일 리포지토리 규칙을 JdbcTemplate, MyBatis, JPA, Spring Data JPA와 Querydsl로 구현해 추상화의 이익과 비용을 비교합니다.