트랜잭션 관리자
트랜잭션 관리자가 JDBC 연결을 실행 문맥에 묶는 과정을 확인하고 롤백·시간 제한·관리자 선택을 검증합니다.
수동 트랜잭션 코드의 핵심 패턴은 자원을 얻고, 트랜잭션을 시작하고, 업무 콜백을 실행하고, 커밋이나 롤백한 뒤 정리하는 것입니다. Spring의 PlatformTransactionManager는 이 생명 주기를 공통 인터페이스로 나타내고, JDBC 구현은 DataSource의 연결을 현재 실행 문맥에 묶습니다. 덕분에 애플리케이션은 커밋하는 문법이 아니라 어디까지가 하나의 업무 단위인지에 집중할 수 있습니다.
트랜잭션 관리자는 한 연결을 실행 문맥에 묶고 완료 뒤 반드시 정리한다
manager 획득·연결 바인딩·같은 DataSource 재사용·완료·unbind/반환의 자원 변화가 있다.
CONTRACT / IMPLEMENTATION / REVIEW SCOPE
같은 DataSource에 묶인 연결로 콜백을 실행하고 완료 뒤 정리한다
TransactionTemplate은 업무 콜백의 경계를 맡고, DataSourceUtils는 같은 DataSource 인스턴스에 바인딩된 연결을 재사용합니다. 예제는 실제 H2 INSERT가 런타임 실패로 롤백되고, 경계 밖에서는 바인딩이 사라지는지 확인합니다.
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/반환의 자원 변화가 있다.
CONTRACT / IMPLEMENTATION / REVIEW SCOPE
같은 DataSource에 묶인 연결로 콜백을 실행하고 완료 뒤 정리한다
TransactionTemplate은 업무 콜백의 경계를 맡고, DataSourceUtils는 같은 DataSource 인스턴스에 바인딩된 연결을 재사용합니다. 예제는 실제 H2 INSERT가 런타임 실패로 롤백되고, 경계 밖에서는 바인딩이 사라지는지 확인합니다.
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의 연결 소유 범위가 정리됩니다. |
그림은 TransactionTemplate이 실행되면 관리자가 트랜잭션을 얻어 같은 DataSource의 연결을 문맥에 묶고, DataSourceUtils가 그 연결을 다시 쓰며, 커밋이나 롤백으로 끝난 뒤 묶음을 풀고 연결을 돌려주는 여섯 단계를 보여 줍니다. URL만 같은 별도의 DataSource를 같은 자원으로 묶는 그림이 아닙니다. 아래 칸들은 실제 H2의 롤백 테스트와 가짜 포트를 쓰는 한도 거부 테스트, 설정 값만 확인하는 타임아웃과 읽기 전용 테스트를 나눠 설명합니다.
JDBC 트랜잭션 관리자
Spring Framework 7에서 JDBC의 DataSource에는 DataSourceTransactionManager나, JDBC 예외 변환을 더한 관련 관리자를 구성합니다. 관리자 하나가 서로 다른 두 데이터베이스와 메시지 브로커를 저절로 원자적으로 묶어 주지는 않습니다.
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);
}
}구성이 만드는 TransactionPolicies는 조회용과 명령용 템플릿 두 개를 담는 레코드이며, 파일 전체는 이 글의 연습 문제 1 해설에 있습니다. 이 구성과 아래 테스트를 컴파일하려면 해설의 src/main/java/board/config/TransactionPolicies.java를 먼저 만들어 둡니다.
enforceReadOnly가 실제로 보내는 SQL과 지원 범위는 데이터베이스마다 확인합니다. JDBC의 setReadOnly(true)가 단순한 힌트일 뿐인 드라이버도 있으므로, 보안 권한처럼 믿지 않고 읽기 복제본 라우팅과 DB 역할을 따로 설계합니다.
관리자 빈이 여럿이면 @Primary나 한정자, @Transactional(transactionManager="reportingTransactionManager")로 어느 자원을 관리하는지 명시합니다. 빈 이름이 우연히 맞는 데 기대면 다른 데이터베이스에 트랜잭션을 열 수 있습니다.
| 업무 단위 | 고를 관리자 | 확인할 사실 |
|---|---|---|
| 기본 JDBC 명령 | 기본 DataSourceTransactionManager | 저장소와 같은 DataSource 인스턴스 |
| 보고용 별도 DB | reportingTransactionManager | 한정자와 실제 연결 URL |
| DB 변경과 브로커 발행 | 로컬 관리자 하나로는 원자적으로 묶을 수 없음 | 아웃박스와 멱등한 소비 같은 일관성 모델 |
더 깊이: Boot가 만들어 주는 관리자와 직접 만든 관리자의 차이
관리자 빈을 직접 정의하지 않으면 Spring Boot 4.1.2의 DataSourceTransactionManagerAutoConfiguration이 하나를 만듭니다. 이때 만드는 것은 DataSourceTransactionManager가 아니라 그 하위 클래스인 JdbcTransactionManager이며, spring.dao.exceptiontranslation.enabled를 false로 바꾼 경우에만 상위 클래스를 씁니다. JdbcTransactionManager의 API 문서에 따르면 이 클래스는 커밋과 롤백 단계의 SQLException에도 JdbcTemplate과 같은 예외 변환을 적용합니다. 예를 들어 PostgreSQL의 직렬화 가능 트랜잭션처럼 커밋 시점에 동시성 실패가 늦게 드러나는 경우, 호출한 쪽은 TransactionSystemException 대신 ConcurrencyFailureException 같은 데이터 접근 예외를 받습니다.
이 글의 구성처럼 PlatformTransactionManager 빈을 직접 정의하면, 자동 구성은 @ConditionalOnMissingBean에 따라 물러납니다. 그러면 예외 변환이 없는 DataSourceTransactionManager가 쓰일 뿐 아니라, Boot가 자기 관리자에만 적용하는 사용자 정의 단계도 함께 빠집니다. 그 단계에서 적용되는 spring.transaction.default-timeout과 spring.transaction.rollback-on-commit-failure는 직접 만든 관리자에는 반영되지 않으므로, 필요하다면 빈을 만드는 코드에서 해당 세터를 직접 호출해야 합니다.
TransactionTemplate의 완료 규칙
프로그래밍 방식의 트랜잭션은 분기와 재시도를 코드에서 분명히 제어하고 싶을 때 유용합니다. 콜백 안에서 비검사 예외가 나면 롤백되고, 정상적으로 반환하면 커밋됩니다. 검사 예외는 콜백 인터페이스의 제약과 감싸는 정책을 확인합니다. 애플리케이션 포트는 JDBC 타입을 드러내지 않고, 게시일별 사용량의 예약과 게시글 저장이라는 업무의 의미만 나타냅니다. 게시글 저장 포트의 이름을 PostStore로 둔 것은, 같은 프로젝트에서 이어지는 10장이 board.application.PostRepository라는 다른 명령 포트를 만들기 때문입니다.
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");
}
}
}
}package board.application;
import java.time.Instant;
import board.application.postcreation.CreatePostUseCase.CreatePostCommand;
public interface PostStore {
long insert(CreatePostCommand command, Instant createdAt);
PostSummary summary(long memberId);
}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");
}
}
}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 PostStore posts;
private final Clock clock;
public TemplatePostService(
TransactionTemplate transactions,
DailyPostRepository daily,
PostStore 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과 반환 규칙을 직접 따라야 합니다.
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을 쓰면 이 반복과 예외 변환까지 줄어드는데, 이는 10장에서 다룹니다.
콜백 예외의 롤백
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);
PostStore posts = new PostStore() {
@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는 자원으로서는 다른 것이어서 트랜잭션의 묶음에 참여하지 않을 수 있으므로, 구성에서 같은 빈을 다시 씁니다.
트랜잭션 전파 규칙
REQUIRED는 기존 트랜잭션이 있으면 참여하고, 없으면 새로 만듭니다. REQUIRES_NEW는 바깥 트랜잭션을 잠시 멈추고 새 트랜잭션과 새 연결을 요구하므로, 풀이 바닥날 위험과 일부만 커밋되는 의미가 생깁니다. NESTED는 저장점의 지원에 기대며, 따로 커밋되는 것이 아닙니다.
| 전파 | 기존 트랜잭션이 있을 때 | 쓸 때 주의할 점 |
|---|---|---|
REQUIRED | 참여 | 안쪽의 롤백 전용 상태가 전체에 영향 |
REQUIRES_NEW | 멈추고 새 트랜잭션 | 연결이 하나 더 필요하고 일부만 커밋됨 |
NESTED | 저장점 | JDBC와 저장점의 지원 필요 |
NOT_SUPPORTED | 트랜잭션 없이 실행 | 일관성이 필요 없는지 확인 |
감사 로그를 반드시 남기려고 REQUIRES_NEW를 무조건 쓰면, 메인 트랜잭션이 롤백된 뒤에도 성공 로그가 남아 실제 상태와 어긋날 수 있습니다. 관찰용 로그는 외부 수집 대상으로 두고, 업무의 감사 기록은 아웃박스나 이벤트와 함께 일관성 모델을 명시합니다.
정리
PlatformTransactionManager는 연결 획득과 시작, 커밋이나 롤백, 정리를 공통 인터페이스로 맡고, JDBC 구현은 연결을 현재 문맥에 묶습니다.- 관리자가 여럿이면 한정자로 어느 자원을 관리하는지 명시하며, 관리자 하나가 여러 자원을 원자적으로 묶어 주지는 않습니다.
TransactionTemplate의 콜백은 비검사 예외면 롤백, 정상 반환이면 커밋되며, 실패는 롤백 전용 표시보다 예외로 나타냅니다.- 저장소는
DataSourceUtils나JdbcTemplate으로 연결을 얻어야 같은 트랜잭션에 참여하며, 관리자와 같은DataSource인스턴스를 씁니다. REQUIRES_NEW는 연결을 하나 더 쓰고 일부만 커밋하므로, 감사 로그처럼 무조건 쓰지 않고 일관성 모델을 먼저 정합니다.
연습 문제
1. 조회용과 명령용 템플릿
TransactionTemplate을 읽기 전용 조회용과 3초 제한의 명령용으로 두 개 구성하려고 합니다. 같은 관리자를 쓰되 옵션이 섞이지 않게 한정자를 붙이고, 조회 템플릿 안의 갱신이 실제 운영 DB에서 거부되는지 통합 테스트로 확인해야 합니다. 어떻게 구성하나요?
해설 보기
템플릿은 싱글톤으로 다시 쓸 수 있지만 실행 중에 옵션을 바꾸지 않으므로, 빈을 만들 때 고정한 두 인스턴스를 주입합니다.
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 통합 테스트를 따로 둡니다. 읽기 전용은 최적화 힌트이자 실수를 막는 층일 뿐, 인가를 대신하지 않습니다.
2. 같은 URL과 같은 묶음 구별하기
예제의 트랜잭션 관리자와 저장소는 DataSource A 하나를 함께 씁니다. 같은 JDBC URL로 별도의 DriverManagerDataSource B를 만들고 저장소만 B를 주입받게 바꿨다고 합시다. A의 트랜잭션 안에서 DataSourceUtils.getConnection(B)를 호출하면, URL이 같다는 이유로 A에 묶인 연결을 다시 쓴다고 답할 수 있나요?
해설 보기
B는 A와 자원의 키가 다르므로, URL 문자열이 같다는 것만으로 A의 묶음을 다시 쓰지 않습니다. A의 트랜잭션이 활성이라는 것과, 저장소가 실제로 A의 연결로 SQL을 실행했다는 것은 다른 사실입니다. 이 예제의 해결 방법은 관리자와 저장소, DataSourceUtils에 같은 DataSource 빈 A를 주입하는 것입니다.
B에서 새로 얻은 연결의 자동 커밋과 정리, 완료는 실제로 연결을 얻은 경로의 설정을 확인해야 합니다. 바깥의 A를 롤백했다고 B가 실행한 SQL까지 함께 취소되었다고 판단하지 않습니다. 같은 자원을 프록시로 노출하는 별도의 구성이나 여러 자원을 조정하는 관리자는, 이렇게 독립된 두 DataSource와 구별해서 생각합니다.
다음 글 선언적 트랜잭션에서는 선언적인 @Transactional이 프록시와 TransactionSynchronizationManager를 통해 같은 관리자의 생명 주기를 어떻게 적용하는지 살펴봅니다.