선언적 트랜잭션
선언적 트랜잭션의 프록시·리소스 연결·커밋 후 콜백을 실행하고 내부 호출과 롤백 규칙을 검증합니다.
@Transactional은 메서드 안에 커밋 코드를 끼워 넣는 애노테이션이 아닙니다. Spring이 빈 앞에 프록시를 만들고, 바깥에서 들어오는 호출을 가로채 트랜잭션 관리자를 실행합니다. 관리자는 TransactionSynchronizationManager를 통해 자원과 동기화 상태를 현재 실행 문맥에 묶고, 메서드가 끝나면 커밋이나 롤백과 콜백을 정리합니다. 프록시가 어디서 동작하는지 모르면, 애노테이션이 있는데도 트랜잭션이 시작되지 않는 경우를 만들 수 있습니다.
CONTRACT / IMPLEMENTATION / REVIEW SCOPE
프록시를 통과한 호출에 트랜잭션 정책을 적용한다
다른 빈을 통한 호출은 인터셉터에 도달하지만, 같은 target의 this 호출은 새 정책을 평가하지 않습니다. 아래 false/false 결과는 트랜잭션 없이 진입한 예제의 관찰값입니다. 이미 열린 트랜잭션이 있다면 내부 호출도 그 실행 문맥에서 동작할 수 있습니다.
ACTUAL PROXY / OBSERVED STATE
자기 호출에는 새 경계가 생기지 않는다
Spring 빈의 throughSelfInvocation에서 같은 인스턴스 메서드를 호출합니다. active=false, synchronization=false이고 INSERT 자체는 1행 남습니다. 이 관찰을 모든 자기 호출에 트랜잭션이 없다는 규칙으로 확대하지 않습니다.
ACTUAL H2 / THREE TABLES
게시글 · 일일 사용량 · outbox를 함께 완료
정상 등록은 세 테이블 각 1행, 사용량2와 정확한 키·시각을 확인합니다. outbox INSERT 뒤 런타임 실패하면 세 테이블 모두 0행입니다. limit3/used0/attempt4 거절도 세 테이블을 바꾸지 않습니다.
ACTUAL H2 / REQUIRES_NEW
바깥 롤백 뒤 안쪽 게시글 1행 유지
바깥 TransactionTemplate에서 다른 빈의 REQUIRES_NEW를 호출하고 바깥 게시글도 저장합니다. 바깥을 rollback-only로 끝내면 안쪽 key만 남습니다. importAll은 실패를 잡고 계속하는 배치 처리기가 아닙니다.
ACTUAL CHECKED EXCEPTION / ROLLBACK
첫 CSV 행 저장 뒤 IOException → 0행
CsvImportService의 rollbackFor=IOException이 적용됩니다. fake CSV reader가 첫 행을 넘긴 뒤 IOException을 던지면 실제 H2 게시글은 0행입니다. 기본 규칙과 별도 rollbackFor, 전역 롤백 설정을 구분합니다.
ACTUAL AFTER COMMIT / IRREVERSIBLE
알림 실패는 이미 커밋한 행을 되돌리지 못한다
콜백은 커밋에서만 실행되고 롤백에서는 실행되지 않습니다. 경계 없이 등록하면 거절합니다. delivery failed를 호출자에게 전파해도 이미 커밋한 게시글 1행은 남습니다.
OUTBOX / ADDITIONAL
저장 원자성과 전달 신뢰성을 구분한다
outbox 행은 게시글과 같은 DB 경계에 저장하지만 재시도 worker는 구현하지 않았습니다. quota adapter는 이 순차 fixture용이며 동시 한도 경쟁을 검증하지 않습니다. afterCommit과 비동기 호출을 내구성 있는 전달 보장으로 해석하지 않습니다.
다른 빈의 ImportCoordinator가 프록시를 통과하면 REQUIRES_NEW 정책이 적용되고 실제 트랜잭션과 동기화가 활성화된다. 트랜잭션 없는 throughSelfInvocation 진입에서 this.inOwnTransaction 호출은 프록시를 우회해 두 상태가 false이다. 실제 H2 테스트는 세 테이블 원자성, 독립 커밋, 검사 예외 롤백과 afterCommit의 한계를 확인한다.
그림의 A는 다른 빈의 ImportCoordinator가 빈 참조를 통해 호출하므로 프록시와 인터셉터를 지나 REQUIRES_NEW가 적용되고, 실제 트랜잭션과 동기화가 모두 활성인 흐름입니다. B는 트랜잭션 없이 진입한 같은 대상 객체에서 this.inOwnTransaction을 부르므로 정책이 다시 평가되지 않고, 두 상태가 모두 거짓인 흐름입니다. 두 흐름은 하나의 순서로 이어진 것이 아닙니다. B의 거짓·거짓은 트랜잭션 없이 진입한 예제에서 본 결과일 뿐이며, 이미 열린 트랜잭션이 있다면 자기 호출도 그 문맥에서 동작할 수 있습니다.
프록시와 트랜잭션 인터셉터
트랜잭션은 애플리케이션 서비스의 공개 사용 사례 메서드에 둡니다. 컨트롤러나 비공개 도우미에 흩어 두지 않고, 애그리거트를 바꾸는 범위가 드러나는 이름을 씁니다.
package board.application;
public interface PostEventOutbox {
void append(PostCreatedEvent event);
}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 PostStore posts;
private final PostEventOutbox outbox;
private final Clock clock;
public DeclarativePostService(
DailyPostRepository daily,
PostStore 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);
}
}아웃박스에 넘기는 PostCreatedEvent는 DataSource·커넥션 풀의 연습 문제 1 해설에서 만든 src/main/java/board/application/PostCreatedEvent.java를 그대로 씁니다. 게시글과 아웃박스의 삽입이 같은 DataSource와 관리자를 쓰면 함께 커밋됩니다. 실제 메시지 전송은 트랜잭션 밖의 워커가 합니다. afterCommit에서 바로 원격 호출을 하면 실패한 뒤의 재시도나 프로세스 장애 사이에 이벤트를 잃을 수 있으므로, 영속적인 아웃박스가 더 안전합니다.
읽기 전용 메서드는 의도를 드러내고 ORM의 플러시 최적화나 DB의 읽기 전용 설정에 도움을 줄 수 있지만, 변경을 막는 보안을 보장하지는 않습니다. 실제 동작은 DB 권한과 통합 테스트로 확인합니다.
내부 호출은 프록시를 우회합니다
this.register()처럼 빈 안의 메서드가 다른 @Transactional 메서드를 호출하면, 바깥의 프록시를 다시 지나지 않습니다. 이렇게 같은 객체 안에서 자기 메서드를 부르는 내부 호출을 자기 호출(self-invocation)이라고 합니다. 프록시 방식에서는 private 메서드에 붙인 애노테이션도 적용되지 않습니다. 트랜잭션을 여는 메서드는 다른 서비스 빈으로 나누거나, 바깥의 공개 사용 사례 하나에 둡니다.
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");
}
}
}
}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 PostStore posts;
private final Clock clock;
public ImportOnePost(PostStore 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());
}
}inOwnTransaction이 돌려주는 TransactionObservation은 메서드 이름과, 실제 트랜잭션과 동기화가 활성인지를 담는 레코드입니다. 파일은 이 글의 연습 문제 1 해설에 있으므로, 이 코드를 컴파일하기 전에 해설의 src/main/java/board/application/TransactionObservation.java를 먼저 만들어 둡니다.
이 예제는 별도의 빈이라 프록시를 지나지만, REQUIRES_NEW가 배치의 요구에 맞는지는 따로 판단해야 합니다. 한 행이 실패하면 전체를 멈출지 계속할지, 새 연결이 풀에 몇 개 필요한지, 이미 커밋된 앞의 행을 사용자에게 어떻게 알릴지 정합니다. 프록시 문제를 고치려고 전파 방식을 바꾸지는 않습니다.
트랜잭션 동기화 상태
애플리케이션의 업무 코드가 스레드 로컬 자원을 직접 다루지는 않지만, 진단이나 프레임워크 어댑터에서는 트랜잭션이 활성인지와 동기화 상태를 쓸 수 있습니다. 콜백은 트랜잭션이 실제로 활성일 때만 등록해야 합니다.
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 트랜잭션 서비스에서 가상 스레드나 비동기 실행기로 작업을 넘기면 현재 자원이 저절로 공유된다고 가정하지 않고, 트랜잭션 범위 안에서는 같은 실행 흐름을 유지합니다.
검사 예외의 롤백 규칙
기본 선언적 트랜잭션은 RuntimeException과 Error에서 롤백하고, 검사 예외에서는 커밋할 수 있습니다. 업무 실패를 검사 예외로 모델링했다면 rollbackFor를 지정하거나, 런타임 도메인 예외로 일관되게 씁니다. 예외를 잡아 로그만 남기고 정상적으로 반환하면 관리자는 커밋합니다.
| 메서드가 끝나는 방식 | 기본 규칙 | 명시적인 선택 |
|---|---|---|
| 정상 반환 | 커밋 | 필요하면 setRollbackOnly() |
RuntimeException이나 Error | 롤백 | noRollbackFor는 아주 제한적으로 사용 |
| 검사 예외 | 커밋 | rollbackFor로 롤백 대상을 선언 |
| 예외를 잡고 정상 반환 | 커밋 | 다시 던지거나 롤백 전용 상태로 표시 |
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);
}
}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);
}
}package board.application;
import java.time.Instant;
import board.application.postcreation.CreatePostUseCase.CreatePostCommand;
public interface ImportRepository {
void insert(CreatePostCommand command, Instant createdAt);
}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];
}
}DB 트랜잭션 안에서 I/O를 오래 하면 연결을 쥐고 있게 됩니다. 실제로는 파일을 먼저 안전한 명령 목록이나 스테이징 테이블로 파싱한 뒤, 짧은 트랜잭션에서 반영하는 편이 낫습니다. 예제의 rollbackFor는 검사 예외의 규칙을 보여 주기 위한 것이며, 아키텍처의 결정과는 별개입니다.
트랜잭션 동작 검증
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(PostStore.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(PostStore.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, '[email protected]', '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 PostStore {
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 예제 어댑터만 명시적으로 등록해 각 포트를 누가 맡는지 드러냅니다.
TransactionTemplate의 바깥 트랜잭션에서 ImportCoordinator가 프록시인 ImportOnePost를 호출하게 한 뒤, 바깥 상태만 롤백 전용으로 표시합니다. ImportCoordinator에는 @Transactional 메서드가 없으므로 트랜잭션 프록시로 감싸지지 않고, REQUIRES_NEW는 ImportOnePost의 프록시에서 적용됩니다. REQUIRES_NEW로 저장한 안쪽 행 하나만 남아야 하므로, 전파를 REQUIRED로 바꾸면 기대한 결과가 0행이 되어 테스트가 실패합니다. 성공하면 게시글과 게시일 사용량, 아웃박스가 함께 커밋되고, 런타임 예외와 rollbackFor로 지정한 검사 예외에서는 실제 행이 남지 않습니다.
선언적 서비스를 그냥 new로 만들면 프록시가 없어 @Transactional의 동작을 검증할 수 없으므로, Spring 컨텍스트에서 빈을 주입받아 테스트합니다.
이 글의 한도 예제는 차례로 호출했을 때의 거부와 세 테이블의 완료를 확인할 뿐입니다. 앞의 JDBC 글에서 두 연결로 경쟁시킨 조건부 한도 UPDATE와 달리, 이 예제의 조회 후 갱신만으로 동시 요청의 한도를 지킨다고 넓혀 보지 않습니다. 동시성까지 필요하다면 같은 한도 행의 잠금이나 조건부 갱신, 실패 뒤의 전체 롤백을 따로 검증해야 합니다.
트랜잭션의 범위 정하기
컨트롤러에 트랜잭션을 두면 뷰 렌더링과 원격 호출까지 연결을 오래 쥘 수 있고, 저장소마다 트랜잭션을 두면 여러 변경을 하나로 묶지 못합니다. 보통은 애플리케이션 서비스의 명령 메서드가 알맞은 범위입니다. 지연 로딩이 필요한 쿼리는 트랜잭션 안에서 모두 실행하고 DTO로 돌려줍니다. 영속성 컨텍스트를 뷰 렌더링까지 열어 두는 OSIV(Open Session in View)는 편리하지만, 쿼리가 실행되는 위치와 연결의 수명을 웹 계층까지 늘릴 수 있으므로 장단점을 확인하고 고릅니다.
여러 관리자나 자원을 애노테이션 하나로 원자적으로 묶을 수는 없습니다. 데이터베이스와 브로커에는 아웃박스나 사가, 멱등한 소비자 같은 일관성 패턴을 쓰고, XA가 필요하다면 운영의 복잡도와 지원 범위를 따로 평가합니다.
더 깊이: Spring Boot에서 OSIV는 기본으로 켜져 있습니다
OSIV는 고를 수 있는 선택처럼 보이지만, Spring Boot에서는 JPA를 쓰는 서블릿 MVC 애플리케이션이면 이미 켜져 있습니다. Spring Boot 4.1.2의 JpaBaseConfiguration은 spring.jpa.open-in-view 속성이 없을 때도 조건을 참으로 보고 OpenEntityManagerInViewInterceptor를 MVC 인터셉터로 등록합니다. 그리고 이 속성을 명시하지 않았다면, 뷰를 렌더링하는 동안 DB 쿼리가 실행될 수 있다는 경고를 시작 로그에 남깁니다. 시작 로그의 이 경고는 프로젝트가 OSIV를 고른 적이 없는데도 쓰고 있다는 뜻입니다.
인터셉터의 API 문서에 따르면 이 인터셉터는 요청을 처리하는 동안 JPA EntityManager를 스레드에 묶어, 서비스의 트랜잭션이 끝난 뒤에도 뷰에서 지연 로딩을 할 수 있게 합니다. 트랜잭션 관리자는 스레드에 묶인 이 EntityManager를 찾아 그대로 쓰므로, 서비스 계층의 트랜잭션 범위는 바뀌지 않습니다. 대신 트랜잭션 밖의 지연 로딩이 조용히 성공하게 되어, 서비스가 DTO에 필요한 값을 모두 읽었는지 테스트가 알아채기 어려워집니다. 범위를 서비스로 고정하려면 spring.jpa.open-in-view=false를 명시하고, 지연 로딩이 서비스 밖에서 일어나는 곳을 실패로 드러나게 합니다.
정리
@Transactional은 프록시가 바깥 호출을 가로챌 때만 적용되며, 같은 객체 안의this호출과private메서드에는 새 정책이 적용되지 않습니다.- 자기 호출 문제는 전파를 바꾸지 말고, 트랜잭션을 여는 메서드를 다른 빈으로 나눠 해결합니다.
afterCommit은 커밋 뒤에 실행되어 실패해도 커밋을 되돌리지 못하므로, 잃으면 안 되는 알림은 아웃박스로 같은 트랜잭션에 남깁니다.- 기본 규칙은 런타임 예외와
Error만 롤백하므로, 검사 예외는rollbackFor로 지정하고, 예외를 잡고 정상 반환하면 커밋된다는 점을 기억합니다. - 트랜잭션의 범위는 보통 애플리케이션 서비스의 명령 메서드이며, 여러 자원은 애노테이션 하나로 원자적으로 묶을 수 없습니다.
연습 문제
1. 자기 호출의 실패 테스트부터
@Transactional이 붙은 메서드를 같은 클래스의 공개 메서드에서 호출해, 실제로 트랜잭션이 비활성인 실패 테스트를 먼저 작성하려고 합니다. 그다음 메서드를 별도의 빈으로 옮긴 뒤 활성 상태와 롤백, 연결 반환이 모두 고쳐지는지 Spring 컨텍스트 테스트로 확인합니다. 어떻게 관찰하나요?
해설 보기
대상 클래스를 직접 new로 만들지 않고 애플리케이션 컨텍스트의 빈으로 호출해야 프록시의 차이를 볼 수 있습니다. 안쪽 메서드에서 TransactionSynchronizationManager.isActualTransactionActive()를 기록하는 점검용 저장소를 씁니다.
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()로 우회하기보다, 사용 사례의 범위에 맞게 두 빈으로 나눕니다. 테스트는 빈 클래스가 프록시인지 자체를 고정하기보다, 관찰한 트랜잭션의 동작을 검증합니다.
2. 이미 열린 트랜잭션에서의 자기 호출
이번에는 바깥의 TransactionTemplate이 이미 JDBC 트랜잭션을 시작했고, 같은 대상 객체 안에서 this.inOwnTransaction을 호출한다고 가정합니다. 안쪽 메서드에는 REQUIRES_NEW가 선언되어 있지만 호출은 프록시를 지나지 않습니다. 안쪽에서 트랜잭션이 활성인지, 새로운 독립 트랜잭션이 만들어졌는지, 바깥의 롤백이 이 연결로 실행한 안쪽 INSERT에도 영향을 주는지 각각 답하세요. 트랜잭션 없이 진입한 거짓·거짓의 경우와도 비교합니다.
해설 보기
안쪽 코드는 이미 열린 바깥 트랜잭션의 문맥에서 실행되므로 활성 상태일 수 있습니다. 하지만 자기 호출은 인터셉터에 다시 닿지 않으므로 REQUIRES_NEW 정책이 새로 적용되지 않습니다. 같은 DataSource의 바깥 연결로 실행한 INSERT라면 바깥 롤백의 대상이 되며, 다른 빈의 프록시를 통해 실제로 새 트랜잭션을 열어 따로 커밋한 경우와 다릅니다.
거짓·거짓은 트랜잭션 없이 진입한 조건에서 본 결과입니다. 자기 호출이면 언제나 비활성이라는 규칙으로 외우지 말고, 진입하기 전의 문맥과 실제로 쓴 관리자와 연결부터 확인합니다. 이 비교는 정한 호출과 자원의 조건에서 판단한 것이며, Spring의 프록시 호출 규칙을 함께 읽습니다.
이것으로 9장을 마칩니다. 9장에서는 JDBC 드라이버의 선택과 자원의 수명 주기에서 시작해, 변경 결과의 해석과 연결 풀, ACID와 수동 트랜잭션, 트랜잭션 관리자를 거쳐 선언적 트랜잭션까지, JDBC가 실행되는 범위를 하나로 이었습니다. 다음 장의 리포지토리와 저장 코드 분리부터는 같은 저장소의 규칙을 JdbcTemplate과 MyBatis, JPA, Spring Data JPA, Querydsl로 구현하며 추상화의 이득과 비용을 비교합니다.