선언적 트랜잭션
@Transactional 프록시, TransactionSynchronizationManager의 리소스 바인딩·afterCommit 콜백, 내부 호출과 롤백 규칙을 회원 게시판 서비스와 Spring 통합 테스트로 확인합니다.
@Transactional은 메서드 안에 커밋 코드를 삽입하는 애노테이션이 아닙니다.
Spring이 빈 앞에 프록시를 만들고 외부 호출을 가로채 트랜잭션 매니저를 실행합니다.
매니저는 TransactionSynchronizationManager에 리소스와 동기화 상태를 현재 실행 컨텍스트에 연결하고 메서드가 끝나면 커밋·롤백과 콜백을 정리합니다.
프록시 경계를 모르면 애노테이션이 있어도 트랜잭션이 시작되지 않는 경우를 만들 수 있습니다.
프록시와 트랜잭션 인터셉터
애플리케이션 서비스의 공개 사용 사례 메서드에 트랜잭션을 둡니다. 컨트롤러나 비공개 도우미에 흩뿌리지 않고 애그리거트 변경 범위를 드러내는 이름을 사용합니다.
package board.application;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public final class DeclarativePostService {
private final DailyPostRepository daily;
private final PostRepository posts;
private final PostEventOutbox outbox;
public DeclarativePostService(
DailyPostRepository daily,
PostRepository posts,
PostEventOutbox outbox
) {
this.daily = daily;
this.posts = posts;
this.outbox = outbox;
}
@Transactional(timeout = 5)
public long register(CreatePostCommand command) {
if (!daily.addWithinLimit(
command.authorId(),
command.createdOn(),
command.content().length())) {
throw new DailyContentLimitExceededException(10_000);
}
long postId = posts.insert(command);
outbox.append(new PostCreatedEvent(
postId,
command.authorId(),
command.clientRequestId()));
return postId;
}
@Transactional(readOnly = true)
public PostSummary summary(long authorId) {
return posts.summary(authorId);
}
}게시글과 아웃박스 삽입이 같은 DataSource와 매니저를 사용하면 함께 커밋됩니다.
실제 메시지 전송은 트랜잭션 밖 워커가 수행합니다.
afterCommit에서 바로 원격 호출을 하면 실패 재시도와 프로세스 장애 사이 이벤트를 잃을 수 있어 영속적 아웃박스가 더 안전합니다.
읽기 전용 메서드는 의도를 표현하고 ORM 플러시 최적화나 DB 읽기 전용 설정에 도움을 줄 수 있지만 변경 보안을 보장하지 않습니다. DB 권한과 통합 테스트로 실제 행동을 확인합니다.
내부 호출 우회
this.register()처럼 빈 내부 메서드가 다른 @Transactional 메서드를 호출하면 외부 프록시를 다시 통과하지 않습니다.
private 메서드 애노테이션도 프록시 방식에서는 적용되지 않습니다.
트랜잭션 경계 메서드를 다른 서비스 빈으로 분리하거나 바깥 공개 사용 사례 하나에 둡니다.
package board.application;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Service
public final class ImportCoordinator {
private final ImportOnePost importOne;
public ImportCoordinator(ImportOnePost importOne) {
this.importOne = importOne;
}
public ImportReport importAll(java.util.List<ImportRow> rows) {
int succeeded = 0;
for (ImportRow row : rows) {
importOne.inOwnTransaction(row);
succeeded += 1;
}
return new ImportReport(rows.size(), succeeded);
}
public record ImportRow(String title, String content) {
}
public record ImportReport(int attempted, int succeeded) {
}
}
@Service
final class ImportOnePost {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void inOwnTransaction(ImportCoordinator.ImportRow row) {
if (row.content() == null || row.content().isBlank()
|| row.content().length() > 5_000) {
throw new IllegalArgumentException("invalid content");
}
}
}이 예제는 별도 빈이라 프록시를 통과하지만 REQUIRES_NEW가 배치 요구에 맞는지는 별도 판단입니다.
한 행 실패 시 전체 중단인지 계속 진행인지, 새 연결이 풀에 몇 개 필요한지, 이미 커밋된 앞 행을 사용자에게 어떻게 보고할지 정합니다.
프록시 문제를 고치려고 전파를 바꾸지는 않습니다.
트랜잭션 동기화 상태
애플리케이션 업무 코드가 스레드-로컬 리소스를 직접 조작하지는 않지만 진단과 프레임워크 어댑터에서 트랜잭션 활성 여부와 동기화를 사용할 수 있습니다. 콜백 등록은 트랜잭션이 실제 활성일 때만 해야 합니다.
package board.application;
import java.util.ArrayList;
import java.util.List;
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 = notifier;
}
public void notifyAfterCommit(long postId) {
if (!TransactionSynchronizationManager
.isSynchronizationActive()) {
throw new IllegalStateException(
"active transaction synchronization required");
}
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
notifier.accept(postId);
}
});
}
}afterCommit은 DB 커밋 뒤 호출되므로 여기서 예외가 나도 이미 커밋된 행을 롤백할 수 없습니다.
짧고 실패를 허용할 수 있는 캐시 무효화에 적합하지만 신뢰할 수 있는 알림에는 아웃박스를 사용합니다.
콜백이 같은 스레드에서 오래 걸리면 연결 정리와 요청 완료를 늦출 수 있습니다.
리액티브 트랜잭션은 스레드-로컬과 다른 컨텍스트 전파를 사용합니다. JDBC 트랜잭션 서비스에서 가상 스레드나 비동기 실행자로 작업을 넘기면 현재 리소스가 자동 공유된다고 가정하지 않습니다. 트랜잭션 범위 안에서는 같은 실행 흐름을 유지합니다.
검사 예외 롤백 규칙
기본 선언적 트랜잭션은 RuntimeException과 Error에서 롤백하고 검사 예외는 커밋할 수 있습니다.
업무 실패를 검사 예외로 모델링했다면 rollbackFor를 지정하거나 런타임 도메인 예외로 일관되게 사용합니다.
예외를 예외 처리하고 로그만 한 뒤 정상 반환하면 매니저는 커밋합니다.
package board.application;
import java.io.IOException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public final class CsvImportService {
private final ImportRepository imports;
private final CsvReader csv;
public CsvImportService(ImportRepository imports, CsvReader csv) {
this.imports = imports;
this.csv = csv;
}
@Transactional(rollbackFor = IOException.class)
public int importFile(java.nio.file.Path path) throws IOException {
int saved = 0;
for (CsvRow row : csv.read(path)) {
imports.insert(row);
saved += 1;
}
return saved;
}
}I/O를 DB 트랜잭션 안에서 오래 수행하면 연결을 점유합니다.
실제로는 파일을 먼저 안전한 명령 목록이나 스테이징 테이블로 파싱한 뒤 짧은 트랜잭션에서 반영하는 편이 낫습니다.
예제의 rollbackFor는 검사 예외 규칙을 보여 주며 아키텍처 결정과 별개입니다.
트랜잭션 이벤트 검증
package board.application;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.ArrayList;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionTemplate;
class DeclarativeTransactionTest {
@Test
void afterCommit은_commit에서만_호출되고_rollback에서는_호출되지_않는다() {
var committed = new ArrayList<Long>();
var notifier = new AfterCommitNotifier(committed::add);
TransactionTemplate transactions = TestTransactions.template();
transactions.executeWithoutResult(status -> {
assertThat(TransactionSynchronizationManager
.isActualTransactionActive()).isTrue();
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);
}
static final class TestTransactions {
static TransactionTemplate template() {
var dataSource = new JdbcDataSource();
dataSource.setURL(
"jdbc:h2:mem:synchronization;DB_CLOSE_DELAY=-1");
dataSource.setUser("sa");
return new TransactionTemplate(
new DataSourceTransactionManager(dataSource));
}
}
}transaction active in callback = true
after commit post 41 = invoked
rollback post 42 = not invoked
committed notifications = [41]
synchronization cleared after completion = true테스트 도우미는 H2 DataSource와 DataSourceTransactionManager를 직접 구성합니다.
선언적 서비스를 단순히 new 하면 프록시가 없어 @Transactional 동작을 검증할 수 없습니다.
애노테이션 경계는 Spring 컨텍스트에서 빈을 주입받아 테스트합니다.
트랜잭션 경계 설정
컨트롤러 트랜잭션은 뷰 렌더링과 원격 호출까지 연결을 오래 잡을 수 있습니다. 리포지토리마다 트랜잭션을 두면 여러 변경을 하나로 묶지 못합니다. 애플리케이션 서비스의 명령 메서드가 보통 적절한 경계입니다.
트랜잭션 안에서 지연 로딩이 필요한 쿼리를 모두 실행하고 DTO로 반환합니다. 영속성 컨텍스트를 뷰 렌더링까지 열어 두는 OSIV는 편리하지만 쿼리 발생 위치와 연결 수명을 웹 계층까지 늘릴 수 있으므로 장단점을 확인하고 선택합니다.
여러 매니저나 리소스를 하나의 애노테이션으로 원자화할 수 없습니다. 데이터베이스와 브로커는 아웃박스, 사가, 멱등 소비자 같은 일관성 패턴을 사용합니다. XA가 필요한 경우 운영 복잡도와 지원 범위를 별도로 평가합니다.
연습 문제
@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()로 우회하기보다 사용 사례 경계가 자연스러운 두 빈으로 나눕니다.
테스트는 빈 클래스가 프록시인지 자체를 고정하기보다 관찰된 트랜잭션 동작을 검증합니다.
이 장에서 드라이버 선택부터 선언적 트랜잭션까지 JDBC 실행 경계를 연결했습니다. 다음 장은 동일 리포지토리 규칙을 JdbcTemplate, MyBatis, JPA, Spring Data JPA와 Querydsl로 구현해 추상화의 이익과 비용을 비교합니다.