본문 바로가기

트랜잭션 관리자

트랜잭션 관리자가 JDBC 연결을 실행 문맥에 묶는 과정을 확인하고 롤백·시간 제한·관리자 선택을 검증합니다.

수동 트랜잭션 코드의 핵심 패턴은 리소스 획득, 트랜잭션 시작, 업무 콜백, 커밋 또는 롤백, 정리입니다.

Spring의 PlatformTransactionManager는 이 생명 주기를 공통 인터페이스로 표현하고 JDBC 구현은 DataSource 연결을 현재 실행 컨텍스트에 연결합니다.

애플리케이션은 커밋 문법이 아니라 어디까지 하나의 업무 단위인지에 집중할 수 있습니다.

트랜잭션 관리자는 한 연결을 실행 문맥에 묶고 완료 뒤 반드시 정리한다

manager 획득·연결 바인딩·같은 DataSource 재사용·완료·unbind/반환의 자원 변화가 있다.

TransactionTemplate 실행, 매니저의 트랜잭션 획득, 같은 DataSource의 연결 바인딩, DataSourceUtils의 연결 재사용, commit 또는 rollback, 바인딩 해제와 연결 반환 순서이다. quota 거절은 별도의 fake 포트 테스트이고 timeout과 readOnly 검사는 설정값 확인이다.

CONTRACT / IMPLEMENTATION / REVIEW SCOPE

같은 DataSource에 묶인 연결로 콜백을 실행하고 완료 뒤 정리한다

TransactionTemplate은 업무 콜백의 경계를 맡고, DataSourceUtils는 같은 DataSource 인스턴스에 바인딩된 연결을 재사용합니다. 예제는 실제 H2 INSERT가 런타임 실패로 롤백되고, 경계 밖에서는 바인딩이 사라지는지 확인합니다.

같은 DataSource에 묶인 연결로 콜백을 실행하고 완료 뒤 정리한다TransactionTemplate 실행, 매니저의 트랜잭션 획득, 같은 DataSource의 연결 바인딩, DataSourceUtils의 연결 재사용, commit 또는 rollback, 바인딩 해제와 연결 반환 순서이다. quota 거절은 별도의 fake 포트 테스트이고 timeout과 readOnly 검사는 설정값 확인이다.
Template.execute콜백 경계
연결 관계Template.execute · 콜백 경계 → getTransaction · 매니저 선택
getTransaction매니저 선택
연결 관계getTransaction · 매니저 선택 → 연결 바인딩 · 같은 DataSource
연결 바인딩같은 DataSource
연결 관계연결 바인딩 · 같은 DataSource → DataSourceUtils · 연결 재사용
DataSourceUtils연결 재사용
연결 관계DataSourceUtils · 연결 재사용 → 완료 규칙 · commit / rollback
완료 규칙commit / rollback
연결 관계완료 규칙 · commit / rollback → 바인딩 해제 · 연결 반환
바인딩 해제연결 반환
같은 DataSource의 연결을 재사용한 뒤 완료한다원desktop여섯노드다섯간선 유지. Template실행→getTransaction→같은DataSource연결바인딩→DataSourceUtils재사용→commit/rollback→바인딩해제와반환이다. 같은URL의별도DataSource를같은자원으로묶는그림이아니다.
Template.execute콜백 경계
연결 관계Template.execute · 콜백 경계 → getTransaction · 매니저 선택
getTransaction매니저 선택
연결 관계getTransaction · 매니저 선택 → 연결 바인딩 · 같은 DataSource
연결 바인딩같은 DataSource
연결 관계연결 바인딩 · 같은 DataSource → DataSourceUtils · 연결 재사용
DataSourceUtils연결 재사용
연결 관계DataSourceUtils · 연결 재사용 → 완료 규칙 · commit / rollback
완료 규칙commit / rollback
연결 관계완료 규칙 · commit / rollback → 바인딩 해제 · 연결 반환
바인딩 해제연결 반환

RESOURCE / SAME INSTANCE

같은 URL만으로 같은 경계가 되지 않는다

트랜잭션 매니저와 repository는 동일한 DataSource 인스턴스를 사용합니다. DataSourceUtils.releaseConnection은 경계에 묶인 연결을 중간에 닫지 않고, 매니저가 완료 뒤 반환합니다.

ACTUAL H2 / ROLLBACK

경계 안 INSERT 1행 → 경계 밖 0행

실제 DataSource와 매니저를 사용합니다. 경계 안에서는 resource=true와 방금 삽입한 게시글을 확인한 뒤 IllegalStateException을 던집니다. 경계 밖에서는 posts=0, resource=false를 확인합니다.

ACTUAL FAKE PORT / REJECTION

한도720 · 사용701 · 시도20을 보존

별도 서비스 테스트의 daily 포트는 거절 결과를 반환하는 fake입니다. DailyLimitExceededException의 세 값을 확인하고, posts 포트가 호출되면 실패시킵니다. 실제 quota SQL이나 경쟁 갱신을 검증하는 테스트는 아닙니다.

ACTUAL CONFIG / NOT ELAPSED TIME

조회5초 · 명령3초는 별도 템플릿

설정 테스트는 매니저 타입, 기본 템플릿 timeout=5, query readOnly=true/5초, command readOnly=false/3초를 확인합니다. SQL의 실제 시간 제한이나 운영 DB 쓰기 거절은 추가 검증이 필요합니다.

CALLBACK / COMPLETION

예외를 삼키면 성공으로 끝날 수 있다

콜백의 RuntimeException과 Error는 롤백 경로로 전파합니다. 실패를 잡고 정상 반환할 때는 setRollbackOnly로 의도를 알릴 수 있습니다. 이 JDBC adapter는 SQLException을 런타임 저장 예외로 감쌉니다.

PROPAGATION / ADDITIONAL

독립 커밋과 같은 업무 단위를 구분한다

REQUIRED는 기존 경계에 참여하고 REQUIRES_NEW는 별도 경계를 엽니다. savepoint를 쓰는 NESTED와 독립 커밋은 다릅니다. 여러 매니저 선택, 운영 DB readOnly, outbox 발행은 이 세 테스트의 검증 범위 밖입니다.

TransactionTemplate 실행, 매니저의 트랜잭션 획득, 같은 DataSource의 연결 바인딩, DataSourceUtils의 연결 재사용, commit 또는 rollback, 바인딩 해제와 연결 반환 순서이다. quota 거절은 별도의 fake 포트 테스트이고 timeout과 readOnly 검사는 설정값 확인이다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • TransactionTemplate 실행, 매니저의 트랜잭션 획득, 같은 DataSource의 연결 바인딩, DataSourceUtils의 연결 재사용, commit 또는 rollback, 바인딩 해제와 연결 반환 순서이다. quota 거절은 별도의 fake 포트 테스트이고 timeout과 readOnly 검사는 설정값 확인이다.
  • 원래 가로 도표의 여섯 노드와 다섯 간선을 유지한다. Template 실행→getTransaction→같은 DataSource의 연결 바인딩→DataSourceUtils 재사용→commit/rollback→바인딩 해제와 반환이다. 같은 URL의 별도 DataSource를 같은 자원으로 묶는 그림이 아니다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1트랜잭션 관리자는 한 연결을 실행 문맥에 묶고 완료 뒤 반드시 정리한다
19원래 가로 도표의 여섯 노드와 다섯 간선을 유지한다. Template 실행→getTransaction→같은 DataSource의 연결 바인딩→DataSourceUtils 재사용→commit/rollback→바인딩 해제와 반환이다. 같은 URL의 별도 DataSource를 같은 자원으로 묶는 그림이 아니다.
27한도 720 · 사용 701 · 시도 20을 보존
30조회 5초 · 명령 3초는 별도 템플릿
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
매니저 transaction 획득

TransactionTemplate이 매니저에게 transaction을 요청합니다.

시작: TransactionTemplate · 관리자: transaction 획득관리된 transaction 범위가 시작됩니다.
같은 DataSource 바인딩

매니저가 연결을 바인딩하고 DataSourceUtils가 같은 연결을 재사용합니다.

DataSource: 동일 · 연결: 바인딩·재사용업무 SQL이 관리된 연결에 합류합니다.
완료와 해제

commit 또는 rollback 뒤 바인딩을 해제하고 연결을 반환합니다.

완료: commit 또는 rollback · 바인딩: 해제 · 연결: 반환transaction의 연결 소유 범위가 정리됩니다.

트랜잭션 관리자는 한 연결을 실행 문맥에 묶고 완료 뒤 반드시 정리한다

manager 획득·연결 바인딩·같은 DataSource 재사용·완료·unbind/반환의 자원 변화가 있다.

TransactionTemplate 실행, 매니저의 트랜잭션 획득, 같은 DataSource의 연결 바인딩, DataSourceUtils의 연결 재사용, commit 또는 rollback, 바인딩 해제와 연결 반환 순서이다. quota 거절은 별도의 fake 포트 테스트이고 timeout과 readOnly 검사는 설정값 확인이다.

CONTRACT / IMPLEMENTATION / REVIEW SCOPE

같은 DataSource에 묶인 연결로 콜백을 실행하고 완료 뒤 정리한다

TransactionTemplate은 업무 콜백의 경계를 맡고, DataSourceUtils는 같은 DataSource 인스턴스에 바인딩된 연결을 재사용합니다. 예제는 실제 H2 INSERT가 런타임 실패로 롤백되고, 경계 밖에서는 바인딩이 사라지는지 확인합니다.

같은 DataSource에 묶인 연결로 콜백을 실행하고 완료 뒤 정리한다TransactionTemplate 실행, 매니저의 트랜잭션 획득, 같은 DataSource의 연결 바인딩, DataSourceUtils의 연결 재사용, commit 또는 rollback, 바인딩 해제와 연결 반환 순서이다. quota 거절은 별도의 fake 포트 테스트이고 timeout과 readOnly 검사는 설정값 확인이다.
Template.execute콜백 경계
연결 관계Template.execute · 콜백 경계 → getTransaction · 매니저 선택
getTransaction매니저 선택
연결 관계getTransaction · 매니저 선택 → 연결 바인딩 · 같은 DataSource
연결 바인딩같은 DataSource
연결 관계연결 바인딩 · 같은 DataSource → DataSourceUtils · 연결 재사용
DataSourceUtils연결 재사용
연결 관계DataSourceUtils · 연결 재사용 → 완료 규칙 · commit / rollback
완료 규칙commit / rollback
연결 관계완료 규칙 · commit / rollback → 바인딩 해제 · 연결 반환
바인딩 해제연결 반환
같은 DataSource의 연결을 재사용한 뒤 완료한다원desktop여섯노드다섯간선 유지. Template실행→getTransaction→같은DataSource연결바인딩→DataSourceUtils재사용→commit/rollback→바인딩해제와반환이다. 같은URL의별도DataSource를같은자원으로묶는그림이아니다.
Template.execute콜백 경계
연결 관계Template.execute · 콜백 경계 → getTransaction · 매니저 선택
getTransaction매니저 선택
연결 관계getTransaction · 매니저 선택 → 연결 바인딩 · 같은 DataSource
연결 바인딩같은 DataSource
연결 관계연결 바인딩 · 같은 DataSource → DataSourceUtils · 연결 재사용
DataSourceUtils연결 재사용
연결 관계DataSourceUtils · 연결 재사용 → 완료 규칙 · commit / rollback
완료 규칙commit / rollback
연결 관계완료 규칙 · commit / rollback → 바인딩 해제 · 연결 반환
바인딩 해제연결 반환

RESOURCE / SAME INSTANCE

같은 URL만으로 같은 경계가 되지 않는다

트랜잭션 매니저와 repository는 동일한 DataSource 인스턴스를 사용합니다. DataSourceUtils.releaseConnection은 경계에 묶인 연결을 중간에 닫지 않고, 매니저가 완료 뒤 반환합니다.

ACTUAL H2 / ROLLBACK

경계 안 INSERT 1행 → 경계 밖 0행

실제 DataSource와 매니저를 사용합니다. 경계 안에서는 resource=true와 방금 삽입한 게시글을 확인한 뒤 IllegalStateException을 던집니다. 경계 밖에서는 posts=0, resource=false를 확인합니다.

ACTUAL FAKE PORT / REJECTION

한도720 · 사용701 · 시도20을 보존

별도 서비스 테스트의 daily 포트는 거절 결과를 반환하는 fake입니다. DailyLimitExceededException의 세 값을 확인하고, posts 포트가 호출되면 실패시킵니다. 실제 quota SQL이나 경쟁 갱신을 검증하는 테스트는 아닙니다.

ACTUAL CONFIG / NOT ELAPSED TIME

조회5초 · 명령3초는 별도 템플릿

설정 테스트는 매니저 타입, 기본 템플릿 timeout=5, query readOnly=true/5초, command readOnly=false/3초를 확인합니다. SQL의 실제 시간 제한이나 운영 DB 쓰기 거절은 추가 검증이 필요합니다.

CALLBACK / COMPLETION

예외를 삼키면 성공으로 끝날 수 있다

콜백의 RuntimeException과 Error는 롤백 경로로 전파합니다. 실패를 잡고 정상 반환할 때는 setRollbackOnly로 의도를 알릴 수 있습니다. 이 JDBC adapter는 SQLException을 런타임 저장 예외로 감쌉니다.

PROPAGATION / ADDITIONAL

독립 커밋과 같은 업무 단위를 구분한다

REQUIRED는 기존 경계에 참여하고 REQUIRES_NEW는 별도 경계를 엽니다. savepoint를 쓰는 NESTED와 독립 커밋은 다릅니다. 여러 매니저 선택, 운영 DB readOnly, outbox 발행은 이 세 테스트의 검증 범위 밖입니다.

TransactionTemplate 실행, 매니저의 트랜잭션 획득, 같은 DataSource의 연결 바인딩, DataSourceUtils의 연결 재사용, commit 또는 rollback, 바인딩 해제와 연결 반환 순서이다. quota 거절은 별도의 fake 포트 테스트이고 timeout과 readOnly 검사는 설정값 확인이다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • TransactionTemplate 실행, 매니저의 트랜잭션 획득, 같은 DataSource의 연결 바인딩, DataSourceUtils의 연결 재사용, commit 또는 rollback, 바인딩 해제와 연결 반환 순서이다. quota 거절은 별도의 fake 포트 테스트이고 timeout과 readOnly 검사는 설정값 확인이다.
  • 원래 가로 도표의 여섯 노드와 다섯 간선을 유지한다. Template 실행→getTransaction→같은 DataSource의 연결 바인딩→DataSourceUtils 재사용→commit/rollback→바인딩 해제와 반환이다. 같은 URL의 별도 DataSource를 같은 자원으로 묶는 그림이 아니다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1트랜잭션 관리자는 한 연결을 실행 문맥에 묶고 완료 뒤 반드시 정리한다
19원래 가로 도표의 여섯 노드와 다섯 간선을 유지한다. Template 실행→getTransaction→같은 DataSource의 연결 바인딩→DataSourceUtils 재사용→commit/rollback→바인딩 해제와 반환이다. 같은 URL의 별도 DataSource를 같은 자원으로 묶는 그림이 아니다.
27한도 720 · 사용 701 · 시도 20을 보존
30조회 5초 · 명령 3초는 별도 템플릿
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
매니저 transaction 획득

TransactionTemplate이 매니저에게 transaction을 요청합니다.

시작: TransactionTemplate · 관리자: transaction 획득관리된 transaction 범위가 시작됩니다.
같은 DataSource 바인딩

매니저가 연결을 바인딩하고 DataSourceUtils가 같은 연결을 재사용합니다.

DataSource: 동일 · 연결: 바인딩·재사용업무 SQL이 관리된 연결에 합류합니다.
완료와 해제

commit 또는 rollback 뒤 바인딩을 해제하고 연결을 반환합니다.

완료: commit 또는 rollback · 바인딩: 해제 · 연결: 반환transaction의 연결 소유 범위가 정리됩니다.

JDBC 트랜잭션 매니저

Spring 프레임워크 7에서 JDBC DataSource에는 DataSourceTransactionManager 또는 JDBC 예외 변환을 강화한 관련 매니저를 구성합니다.

하나의 매니저가 임의의 두 데이터베이스와 메시지 브로커를 자동으로 원자화하지 않습니다.

src/main/java/board/config/JdbcTransactionConfiguration.java
package board.config;

import javax.sql.DataSource;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.transaction.support.TransactionTemplate;

@Configuration(proxyBeanMethods = false)
@EnableTransactionManagement
public class JdbcTransactionConfiguration {
    @Bean
    PlatformTransactionManager transactionManager(DataSource dataSource) {
        var manager = new DataSourceTransactionManager(dataSource);
        manager.setEnforceReadOnly(true);
        return manager;
    }

    @Bean
    TransactionTemplate transactionTemplate(
            PlatformTransactionManager transactionManager
    ) {
        var template = new TransactionTemplate(transactionManager);
        template.setTimeout(5);
        return template;
    }

    @Bean
    TransactionPolicies transactionPolicies(
            PlatformTransactionManager transactionManager
    ) {
        return TransactionPolicies.from(transactionManager);
    }
}

enforceReadOnly의 실제 SQL과 지원 범위는 데이터베이스에 따라 확인합니다.

JDBC setReadOnly(true)가 단순 힌트인 드라이버도 있습니다.

보안 권한처럼 믿지 않고 읽기 복제본 라우팅과 DB 역할을 별도로 설계합니다.

매니저 빈이 여러 개면 @Primary, 한정자, @Transactional(transactionManager="reportingTransactionManager")로 어느 리소스를 관리하는지 명시합니다.

빈 이름이 우연히 일치하는 데 기대면 다른 데이터베이스에 트랜잭션을 열 수 있습니다.

업무 경계선택할 관리자검증할 사실
기본 JDBC 명령기본 DataSourceTransactionManager리포지토리와 같은 DataSource 인스턴스
보고용 별도 DBreportingTransactionManager한정자와 실제 연결 URL
DB 변경과 브로커 발행단일 로컬 관리자로 원자화 불가아웃박스·멱등 소비 같은 일관성 모델

TransactionTemplate 완료 규칙

프로그래밍 방식 트랜잭션은 분기와 재시도를 코드에서 명확히 제어할 때 유용합니다.

콜백 안에서 비검사 예외가 나면 롤백되고 정상 반환이면 커밋됩니다.

검사 예외는 콜백 인터페이스 제약과 감싸기 정책을 확인합니다.

애플리케이션 포트는 JDBC 타입을 노출하지 않고 게시일별 사용량 예약과 게시글 저장의 업무 의미만 표현합니다.

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

import java.time.LocalDate;

public interface DailyPostRepository {
    Reservation reserve(
            long memberId,
            LocalDate publishedOn,
            int attemptedCharacters
    );

    record Reservation(
            int limitCharacters,
            int usedCharacters,
            int attemptedCharacters,
            boolean accepted
    ) {
        public Reservation {
            if (limitCharacters < 1
                    || usedCharacters < 0
                    || attemptedCharacters < 1) {
                throw new IllegalArgumentException("invalid reservation");
            }
            boolean withinLimit = (long) usedCharacters
                    + attemptedCharacters <= limitCharacters;
            if (accepted != withinLimit) {
                throw new IllegalArgumentException(
                        "accepted must match the character limit");
            }
        }
    }
}
src/main/java/board/application/PostRepository.java
package board.application;

import java.time.Instant;

import board.application.postcreation.CreatePostUseCase.CreatePostCommand;

public interface PostRepository {
    long insert(CreatePostCommand command, Instant createdAt);

    PostSummary summary(long memberId);
}
src/main/java/board/application/PostSummary.java
package board.application;

public record PostSummary(
        long memberId,
        long postCount,
        long totalCharacters
) {
    public PostSummary {
        if (memberId <= 0 || postCount < 0 || totalCharacters < 0) {
            throw new IllegalArgumentException("invalid post summary");
        }
    }
}
src/main/java/board/application/TemplatePostService.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.support.TransactionTemplate;

@Service
public final class TemplatePostService {
    private final TransactionTemplate transactions;
    private final DailyPostRepository daily;
    private final PostRepository posts;
    private final Clock clock;

    public TemplatePostService(
            TransactionTemplate transactions,
            DailyPostRepository daily,
            PostRepository posts,
            Clock clock
    ) {
        this.transactions = transactions;
        this.daily = daily;
        this.posts = posts;
        this.clock = clock;
    }

    public long register(CreatePostCommand command) {
        Long result = transactions.execute(status -> {
            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();
            return posts.insert(command, createdAt);
        });
        if (result == null) {
            throw new IllegalStateException(
                    "transaction callback returned null");
        }
        return result;
    }
}

status.setRollbackOnly() 후 정상 값을 반환하는 방식도 가능하지만 호출자가 성공으로 오해할 수 있습니다.

실패는 예외로 표현하고 롤백 전용 상태는 참여 중인 내부 트랜잭션이 더 이상 커밋할 수 없음을 표시할 때 제한적으로 씁니다.

롤백 전용 트랜잭션을 바깥이 커밋하려 하면 UnexpectedRollbackException이 날 수 있습니다.


트랜잭션 인식 연결 획득

매니저가 스레드에 연결을 바인딩해도 리포지토리가 원시 dataSource.getConnection()으로 새 연결을 얻으면 같은 트랜잭션에 참여하지 않을 수 있습니다.

JdbcTemplate은 DataSourceUtils를 사용해 현재 트랜잭션 연결을 재사용합니다.

원시 JDBC라면 직접 DataSourceUtils.getConnection과 릴리스 규칙을 따라야 합니다.

src/main/java/board/jdbc/TransactionAwarePostInsert.java
package board.jdbc;

import java.sql.Date;
import java.sql.SQLException;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Objects;

import javax.sql.DataSource;

import board.application.postcreation.CreatePostUseCase.CreatePostCommand;

import org.springframework.jdbc.datasource.DataSourceUtils;

public final class TransactionAwarePostInsert {
    private final DataSource dataSource;

    public TransactionAwarePostInsert(DataSource dataSource) {
        this.dataSource = Objects.requireNonNull(dataSource, "dataSource");
    }

    public long insert(CreatePostCommand command, Instant createdAt) {
        Objects.requireNonNull(command, "command");
        Objects.requireNonNull(createdAt, "createdAt");
        var connection = DataSourceUtils.getConnection(dataSource);
        try (var statement = connection.prepareStatement(
                """
                insert into posts(
                    member_id, title, content, published_on,
                    client_request_id, created_at, version)
                values (?, ?, ?, ?, ?, ?, 0)
                """,
                java.sql.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));
            statement.executeUpdate();
            try (var keys = statement.getGeneratedKeys()) {
                if (!keys.next()) {
                    throw new SQLException("generated key missing");
                }
                return keys.getLong(1);
            }
        } catch (SQLException exception) {
            throw new PostPersistenceException(exception);
        } finally {
            DataSourceUtils.releaseConnection(connection, dataSource);
        }
    }

    public static final class PostPersistenceException
            extends RuntimeException {
        public PostPersistenceException(SQLException cause) {
            super("failed to insert post", cause);
        }
    }
}

트랜잭션 중 releaseConnection()은 연결을 실제로 닫지 않고 매니저가 완료할 때까지 보존하며, 트랜잭션 밖에서는 반환합니다.

직접 Connection.close()를 호출하는 방식과 섞지 않습니다.

Spring JDBC의 JdbcTemplate을 사용하면 이 반복과 예외 변환까지 줄어들며 ch10에서 다룹니다.


콜백 예외 롤백

src/test/java/board/application/TransactionTemplateRollbackTest.java
package board.application;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;

import javax.sql.DataSource;

import board.application.postcreation.CreatePostUseCase.CreatePostCommand;
import board.application.postcreation.DailyLimitExceededException;
import board.config.JdbcTransactionConfiguration;
import board.config.TransactionPolicies;
import board.jdbc.TransactionAwarePostInsert;

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.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionTemplate;

class TransactionTemplateRollbackTest {
    @Test
    void 구성은_하나의_manager와_고정된_template_정책을_제공한다() {
        try (var context = context(dataSource("manager"))) {
            assertThat(context.getBean(PlatformTransactionManager.class))
                    .isInstanceOf(DataSourceTransactionManager.class);
            assertThat(context.getBean(TransactionTemplate.class).getTimeout())
                    .isEqualTo(5);

            var policies = context.getBean(TransactionPolicies.class);
            assertThat(policies.query().isReadOnly()).isTrue();
            assertThat(policies.query().getTimeout()).isEqualTo(5);
            assertThat(policies.command().isReadOnly()).isFalse();
            assertThat(policies.command().getTimeout()).isEqualTo(3);
        }
    }

    @Test
    void transaction_aware_insert는_같은_connection에_참여하고_rollback한다() {
        DataSource dataSource = initializedDataSource("aware_insert");
        var jdbc = new JdbcTemplate(dataSource);
        insertMember(jdbc);
        var transactions = new TransactionTemplate(
                new DataSourceTransactionManager(dataSource));
        var insert = new TransactionAwarePostInsert(dataSource);
        var command = command("transaction-aware-0001", "본문");

        assertThatThrownBy(() -> transactions.execute(status -> {
            assertThat(TransactionSynchronizationManager.hasResource(dataSource))
                    .isTrue();
            long postId = insert.insert(
                    command, Instant.parse("2026-08-29T03:00:00Z"));
            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 client_request_id from posts where id = ?",
                    String.class,
                    postId)).isEqualTo("transaction-aware-0001");
            assertThat(jdbc.queryForObject(
                    "select created_at from posts where id = ?",
                    OffsetDateTime.class,
                    postId).toInstant()).isEqualTo(
                            Instant.parse("2026-08-29T03:00:00Z"));
            assertThat(jdbc.queryForObject(
                    "select version from posts where id = ?",
                    Long.class,
                    postId)).isZero();
            throw new IllegalStateException("second operation failed");
        })).isInstanceOf(IllegalStateException.class)
                .hasMessage("second operation failed");

        Integer count = jdbc.queryForObject(
                "select count(*) from posts", Integer.class);
        assertThat(count).isZero();
        assertThat(TransactionSynchronizationManager.hasResource(dataSource))
                .isFalse();
    }

    @Test
    void 게시일별_한도_거부는_upstream_exception의_정확한_수치를_보존한다() {
        DataSource dataSource = dataSource("limit_rejected");
        var transactions = new TransactionTemplate(
                new DataSourceTransactionManager(dataSource));
        DailyPostRepository daily = (memberId, publishedOn, attempted) ->
                new DailyPostRepository.Reservation(
                        720, 701, attempted, false);
        PostRepository posts = new PostRepository() {
            @Override
            public long insert(CreatePostCommand command, Instant createdAt) {
                throw new AssertionError("insert must not be called");
            }

            @Override
            public PostSummary summary(long memberId) {
                throw new UnsupportedOperationException();
            }
        };
        var service = new TemplatePostService(
                transactions,
                daily,
                posts,
                Clock.fixed(
                        Instant.parse("2026-08-29T03:00:00Z"),
                        ZoneOffset.UTC));

        assertThatThrownBy(() -> service.register(command(
                "transaction-limit-0001", "12345678901234567890")))
                .isInstanceOfSatisfying(
                        DailyLimitExceededException.class,
                        exception -> {
                            assertThat(exception.limitCharacters())
                                    .isEqualTo(720);
                            assertThat(exception.usedCharacters())
                                    .isEqualTo(701);
                            assertThat(exception.attemptedCharacters())
                                    .isEqualTo(20);
                        });
    }

    private AnnotationConfigApplicationContext context(DataSource dataSource) {
        var context = new AnnotationConfigApplicationContext();
        context.registerBean(DataSource.class, () -> dataSource);
        context.register(JdbcTransactionConfiguration.class);
        context.refresh();
        return context;
    }

    private DataSource initializedDataSource(String name) {
        DataSource dataSource = dataSource(name);
        new ResourceDatabasePopulator(new ClassPathResource("schema.sql"))
                .execute(dataSource);
        return dataSource;
    }

    private DataSource dataSource(String name) {
        var dataSource = new JdbcDataSource();
        dataSource.setURL("jdbc:h2:mem:" + name
                + ";MODE=PostgreSQL;DB_CLOSE_DELAY=-1");
        dataSource.setUser("sa");
        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 CreatePostCommand command(String requestId, String content) {
        return new CreatePostCommand(
                41L,
                "트랜잭션 경계",
                content,
                LocalDate.of(2026, 8, 29),
                requestId);
    }
}

세 테스트는 구성 옵션, 바인딩된 연결의 롤백·정리, 게시일별 한도 예외의 정확한 수치를 실행으로 확인합니다.

JdbcTemplate과 매니저는 같은 DataSource 인스턴스를 사용해야 합니다.

URL만 같은 별도 DataSource는 리소스 동일성이 달라 트랜잭션 바인딩에 참여하지 않을 수 있습니다.

구성에서 빈 동일성을 재사용합니다.

같은 URL과 같은 바인딩을 구별하기 예시의 트랜잭션 매니저와 repository는 DataSource A 하나를 공유합니다. 같은 JDBC URL로 별도의 DriverManagerDataSource B를 만들고 repository만 B를 주입받게 바꿨다고 합시다. A의 경계에서 DataSourceUtils.getConnection(B)를 호출하면 URL이 같다는 이유로 A의 바인딩된 연결을 재사용한다고 답할 수 있나요?

DB 주소 대신 자원 키의 풀이

별도 B는 A의 자원 키와 같지 않으므로 URL 문자열이 같다는 사실만으로 A의 바인딩을 재사용하지 않습니다. A의 트랜잭션이 활성이라는 관찰과 repository가 실제 A의 연결로 SQL을 실행했다는 증거도 다릅니다. 이 예제의 해결은 매니저·repository·DataSourceUtils에 같은 DataSource 빈 A를 주입하는 것입니다.

새 B 연결의 자동 커밋과 정리·완료는 실제 획득 경로의 설정을 확인해야 합니다. 바깥 A를 롤백했다는 사실만으로 B가 실행한 SQL도 함께 취소됐다고 판정하지 않습니다. 프록시로 같은 자원을 노출하는 별도 구성과 여러 자원을 조정하는 매니저는 이 두 독립 DataSource 조건과 구별합니다. 이는 자원 동일성 판정 연습이며 Spring/H2를 실행한 로그가 아닙니다.


트랜잭션 전파 규칙

REQUIRED는 기존 트랜잭션이 있으면 참여하고 없으면 새로 만듭니다.

REQUIRES_NEW는 바깥 트랜잭션을 중단하고 새 트랜잭션·연결을 요구하므로 풀 고갈과 부분 커밋 의미가 생깁니다.

NESTED는 저장점 지원에 의존하며 독립 커밋이 아닙니다.

전파기존 트랜잭션대표 사용 주의
필수참여내부 롤백 전용 상태가 전체에 영향
REQUIRES_NEW중단 후 새 트랜잭션추가 연결·부분 커밋
NESTED저장점JDBC/저장점 지원
NOT_SUPPORTED트랜잭션 없이 실행일관성 요구 없음 확인

감사 로그를 반드시 남기려고 REQUIRES_NEW를 무조건 쓰면 메인 트랜잭션 롤백 뒤에도 성공 로그가 남아 실제 상태와 어긋날 수 있습니다.

관찰 로그는 외부 수집 대상, 업무 감사는 아웃박스·이벤트와 일관성 모델을 명시합니다.


연습 문제

TransactionTemplate을 읽기 전용 조회용과 3초 명령용 두 개로 구성하세요.

같은 매니저를 사용하되 옵션이 섞이지 않게 한정자를 붙이고, 조회 템플릿 안에서 갱신이 실제 운영 DB에서 거부되는지 통합 테스트로 확인합니다.

해설 보기

템플릿은 싱글톤으로 재사용 가능하지만 실행 중 옵션을 바꾸지 않습니다.

빈 생성 시 고정한 두 인스턴스를 주입합니다.

src/main/java/board/config/TransactionPolicies.java
package board.config;

import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;

public record TransactionPolicies(
        TransactionTemplate query,
        TransactionTemplate command
) {
    public TransactionPolicies {
        if (query == null || command == null) {
            throw new IllegalArgumentException("both policies required");
        }
    }

    public static TransactionPolicies from(
            PlatformTransactionManager transactionManager
    ) {
        var query = new TransactionTemplate(transactionManager);
        query.setReadOnly(true);
        query.setTimeout(5);

        var command = new TransactionTemplate(transactionManager);
        command.setTimeout(3);
        return new TransactionPolicies(query, command);
    }
}

H2의 읽기 전용 강제 방식이 운영 DB와 다를 수 있으므로 PostgreSQL 통합 테스트를 별도로 둡니다.

읽기 전용은 최적화 힌트와 실수 방지층이지 인가 대체가 아닙니다.

다음 문서에서는 선언적 @Transactional이 프록시와 TransactionSynchronizationManager를 통해 같은 매니저 생명 주기를 적용하는 방식을 살펴봅니다.