본문으로 건너뛰기

안동민 개발노트

본문 시작

트랜잭션 관리자

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

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

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

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

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

TRANSACTION MANAGER · RESOURCE BINDING · COMPLETION

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

TransactionTemplate은 업무 콜백을 감싸고, JDBC 매니저는 같은 DataSource의 연결을 현재 실행 문맥에 바인딩한다. 정상 반환은 커밋, 예외는 롤백으로 끝나지만 두 경로 모두 연결 해제와 문맥 정리를 수행한다.

TransactionTemplate과 JDBC 트랜잭션 관리자의 연결 바인딩 시퀀스 호출자가 TransactionTemplate을 실행하면 트랜잭션 관리자가 연결을 얻어 실행 문맥에 바인딩한다. 리포지토리는 DataSourceUtils를 통해 같은 연결을 재사용하고 SQL을 실행한다. 콜백이 정상 반환하면 커밋하고 예외를 던지면 롤백하며, 두 경우 모두 연결을 해제하고 이전 문맥을 복원한다. ONE TRANSACTION CONTEXT CALLER Use case BOUNDARY Transaction Template LIFECYCLE TxManager EXECUTION CONTEXT ConnectionHolder ADAPTER Repository RESOURCE Database execute(callback) getTransaction 연결 획득 · bindResource 업무 콜백 · repository.insert DataSourceUtils.getConnection 바인딩된 동일 연결 SQL · member_id · published_on 결과 또는 SQLException 정상: commit · 예외: rollback unbind · release · 문맥 정리 값 반환 또는 예외 전파 INVARIANT 같은 DataSource 인스턴스 · 같은 실행 흐름 · 완료 뒤 리소스 0개
  1. BEGIN

    TransactionTemplate이 업무 콜백의 경계를 연다

    고정된 timeout과 readOnly 정책을 실행 중에 바꾸지 않습니다.

  2. BIND

    매니저가 DataSource 연결을 실행 문맥에 바인딩한다

    트랜잭션 관리자와 리포지토리는 정확히 같은 DataSource 인스턴스를 사용합니다.

  3. REUSE

    리포지토리가 DataSourceUtils로 바인딩된 연결을 재사용한다

    원시 getConnection()으로 별도 연결을 만들면 같은 트랜잭션을 벗어날 수 있습니다.

  4. WORK

    게시일별 사용량과 게시글 저장이 한 업무 단위에서 실행된다

    memberId, publishedOn, createdAt, version 계약을 그대로 저장합니다.

  5. COMPLETE

    정상 반환은 커밋, 예외는 롤백을 선택한다

    예외를 정상 값으로 삼키면 매니저는 성공으로 해석할 수 있습니다.

  6. CLEANUP

    두 종료 경로 모두 연결을 해제하고 문맥을 정리한다

    다음 요청이 이전 연결이나 트랜잭션 상태를 관찰하지 않게 합니다.

매니저 선택은 리소스 선택이다. 여러 DataSource가 있으면 이름 우연이 아니라 한정자와 명시적 transactionManager로 어느 연결을 묶는지 고정한다.


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()으로 새 연결을 얻으면 같은 트랜잭션에 참여하지 않을 수 있습니다.

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

원시 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, 'member@example.com', '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는 저장점 지원에 의존하며 독립 커밋이 아닙니다.

전파기존 트랜잭션대표 사용 주의
필수참여내부 롤백 전용 상태가 전체에 영향
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를 통해 같은 매니저 생명주기를 적용하는 방식을 살펴봅니다.