트랜잭션 관리자
DataSourceTransactionManager와 TransactionTemplate이 Connection을 스레드에 바인딩하는 과정을 확인하고 회원 게시판 명령의 롤백, 타임아웃과 매니저 선택을 실제 테스트로 검증합니다.
수동 트랜잭션 코드의 핵심 패턴은 리소스 획득, 트랜잭션 시작, 업무 콜백, 커밋 또는 롤백, 정리입니다.
Spring의 PlatformTransactionManager는 이 생명주기를 공통 인터페이스로 표현하고 JDBC 구현은 DataSource 연결을 현재 실행 컨텍스트에 연결합니다.
애플리케이션은 커밋 문법이 아니라 어디까지 하나의 업무 단위인지에 집중할 수 있습니다.
JDBC 트랜잭션 매니저
Spring 프레임워크 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.support.TransactionTemplate;
@Configuration
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;
}
}enforceReadOnly의 실제 SQL과 지원 범위는 데이터베이스에 따라 확인합니다.
JDBC setReadOnly(true)가 단순 힌트인 드라이버도 있습니다.
보안 권한처럼 믿지 않고 읽기 복제본 라우팅과 DB 역할을 별도로 설계합니다.
매니저 빈이 여러 개면 @Primary, 한정자, @Transactional(transactionManager="reportingTransactionManager")로 어느 리소스를 관리하는지 명시합니다.
이름 우연에 기대면 다른 데이터베이스에 트랜잭션을 열 수 있습니다.
TransactionTemplate 완료 규칙
프로그래밍 방식 트랜잭션은 분기와 재시도를 코드에서 명확히 제어할 때 유용합니다. 콜백 안에서 비검사 예외가 나면 롤백되고 정상 반환이면 커밋됩니다. 검사 예외는 콜백 인터페이스 제약과 감싸기 정책을 확인합니다.
package board.application;
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;
public TemplatePostService(
TransactionTemplate transactions,
DailyPostRepository daily,
PostRepository posts
) {
this.transactions = transactions;
this.daily = daily;
this.posts = posts;
}
public long register(CreatePostCommand command) {
Long result = transactions.execute(status -> {
boolean accepted = daily.addWithinLimit(
command.authorId(),
command.createdOn(),
command.content().length());
if (!accepted) {
throw new DailyContentLimitExceededException(10_000);
}
return posts.insert(command);
});
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 javax.sql.DataSource;
import org.springframework.jdbc.datasource.DataSourceUtils;
public final class TransactionAwarePostInsert {
private final DataSource dataSource;
public TransactionAwarePostInsert(DataSource dataSource) {
this.dataSource = dataSource;
}
public long insert(NewPost post) {
var connection = DataSourceUtils.getConnection(dataSource);
try (var statement = connection.prepareStatement(
"""
insert into posts(
author_id, title, content, created_on, version)
values (?, ?, ?, ?, 0)
""",
java.sql.Statement.RETURN_GENERATED_KEYS)) {
statement.setLong(1, post.authorId());
statement.setString(2, post.title());
statement.setString(3, post.content());
statement.setDate(4, Date.valueOf(post.createdOn()));
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에서 다룹니다.
콜백 예외 롤백
package board.application;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
class TransactionTemplateRollbackTest {
@Test
void callback_runtime_exception은_앞선_JDBC_변경도_rollback한다() {
var dataSource = new JdbcDataSource();
dataSource.setURL("jdbc:h2:mem:txtemplate;DB_CLOSE_DELAY=-1");
dataSource.setUser("sa");
var jdbc = new JdbcTemplate(dataSource);
jdbc.execute("create table change_log(id int primary key)");
var transactions = new TransactionTemplate(
new DataSourceTransactionManager(dataSource));
assertThatThrownBy(() -> transactions.execute(status -> {
jdbc.update("insert into change_log values(?)", 1);
throw new IllegalStateException("second operation failed");
})).isInstanceOf(IllegalStateException.class)
.hasMessage("second operation failed");
Integer count = jdbc.queryForObject(
"select count(*) from change_log", Integer.class);
assertThat(count).isZero();
}
}transaction active inside callback = true
first insert = executed
runtime exception = second operation failed
rollback = executed by manager
rows after callback = 0
connection released = true이 테스트에서 JdbcTemplate과 매니저가 같은 DataSource 인스턴스를 사용해야 합니다. URL만 같은 별도 DataSource는 리소스 동일성이 달라 트랜잭션 바인딩에 참여하지 않을 수 있습니다. 구성에서 빈 동일성을 재사용합니다.
트랜잭션 전파 규칙
REQUIRED는 기존 트랜잭션이 있으면 참여하고 없으면 새로 만듭니다.
REQUIRES_NEW는 바깥 트랜잭션을 중단하고 새 트랜잭션·연결을 요구하므로 풀 고갈과 부분 커밋 의미가 생깁니다.
NESTED는 저장점 지원에 의존하며 독립 커밋이 아닙니다.
| 전파 | 기존 트랜잭션 | 대표 사용 주의 |
|---|---|---|
| 필수 | 참여 | 내부 롤백 전용 상태가 전체에 영향 |
| REQUIRES_NEW | 중단 후 새 트랜잭션 | 추가 연결·부분 커밋 |
| NESTED | 저장점 | JDBC/저장점 지원 |
| NOT_SUPPORTED | 트랜잭션 없이 실행 | 일관성 요구 없음 확인 |
감사 로그를 반드시 남기려고 REQUIRES_NEW를 무조건 쓰면 메인 트랜잭션 롤백 뒤에도 성공 로그가 남아 실제 상태와 어긋날 수 있습니다.
관찰 로그는 외부 수집 대상, 업무 감사는 아웃박스·이벤트와 일관성 모델을 명시합니다.
연습 문제
TransactionTemplate을 읽기 전용 조회용과 3초 명령용 두 개로 구성하세요.
같은 매니저를 사용하되 옵션이 섞이지 않게 한정자를 붙이고, 조회 템플릿 안에서 갱신이 실제 운영 DB에서 거부되는지 통합 테스트로 확인합니다.
해설 보기
템플릿은 싱글톤으로 재사용 가능하지만 실행 중 옵션을 바꾸지 않습니다. 빈 생성 시 고정한 두 인스턴스를 주입합니다.
package board.config;
public record TransactionPolicies(
org.springframework.transaction.support.TransactionTemplate query,
org.springframework.transaction.support.TransactionTemplate command
) {
public TransactionPolicies {
if (query == null || command == null) {
throw new IllegalArgumentException("both policies required");
}
}
}H2의 읽기 전용 강제 방식이 운영 DB와 다를 수 있으므로 PostgreSQL 통합 테스트를 별도로 둡니다. 읽기 전용은 최적화 힌트와 실수 방지층이지 인가 대체가 아닙니다.
다음 문서에서는 선언적 @Transactional이 프록시와 TransactionSynchronizationManager를 통해 같은 매니저 생명주기를 적용하는 방식을 살펴봅니다.