본문 바로가기

전략·템플릿 콜백

변하는 실행 조각을 전략과 람다 콜백으로 전달하고 싱글톤 템플릿의 스레드 안전성·예외·반환 타입을 설계합니다.

템플릿 메서드에서는 상위 클래스가 하위 클래스의 메서드를 호출했습니다. 전략 패턴(strategy pattern)에서는 컨텍스트(context)가 자신이 가진 협력 객체를 호출합니다. 알고리즘의 흐름과 달라지는 동작을 상속이 아닌 객체 조합(composition)으로 연결하므로 단일 상속의 제약을 피할 수 있습니다.

달라지는 부분이 메서드 하나라면 콜백(callback) 람다로 더 작게 표현할 수 있습니다. 이렇게 흐름을 가진 템플릿에 실행할 조각을 인자로 넘기는 방식을 템플릿 콜백 패턴이라고 하며, Spring의 JdbcTemplate과 TransactionTemplate도 같은 생각으로 만들어졌습니다.

전략·템플릿 콜백

기존 TX 없음과 같은 매니저의 REQUIRED 참여를 두 독립 가정으로 표시합니다. trace·템플릿·물리 TX의 고정 역할에서 callback 완료 뒤 새 TX는 trace 안에서 물리 완료하지만 참여 TX는 trace 종료 뒤 바깥에서 완료하는 실제 차이를 추적하며 trace finished를 최종 commit 성공으로 합치지 않습니다.

TEMPLATE CALLBACK · WRAPPER NESTING

새 물리 트랜잭션을 트레이스로 감싸면 커밋까지 관찰한다

변하는 operation은 전략/콜백으로 전달합니다. 싱글톤 컨텍스트는 흐름만 소유하고 시작 시각·결과는 각 호출의 로컬 변수에 둡니다.

물리 트랜잭션의 소유 위치에 따른 커밋 관찰 범위trace범위안에서TransactionTemplate이시작또는참여상태를확보하고콜백을실행한다. 기존같은관리자의물리TX에REQUIRED로참여하면work실행뒤안쪽범위가끝나도물리커밋은나중바깥에서일어나며그점선경로는trace밖이다. 기존TX가없어새물리TX를시작했다면그commit또는rollback처리가trace안이어서그완료실패를관찰할수있다. tracefinished가항상최종DB커밋성공이라는뜻은아니다. 두가지가정은명령형일반전파모델이며sink실패와다른관리자/리액티브전파는별도다.TraceContext 범위started · 작업 시작TransactionTemplate시작/참여 상태 확보work.call()참여 범위 정상 종료물리 커밋은 아직 없음work.call()새 물리 TX 완료commit / rollback정상/실패 관찰결과·예외 전달이후 바깥 물리 TXcommit / rollback기존 물리 TX에참여하는가?예아니오이후 바깥 TX점선 뒤 커밋은이 trace의 관찰 밖
물리 트랜잭션의 소유 위치에 따른 커밋 관찰 범위trace범위안에서TransactionTemplate이시작또는참여상태를확보하고콜백을실행한다. 기존같은관리자의물리TX에REQUIRED로참여하면work실행뒤안쪽범위가끝나도물리커밋은나중바깥에서일어나며그점선경로는trace밖이다. 기존TX가없어새물리TX를시작했다면그commit또는rollback처리가trace안이어서그완료실패를관찰할수있다. tracefinished가항상최종DB커밋성공이라는뜻은아니다. 두가지가정은명령형일반전파모델이며sink실패와다른관리자/리액티브전파는별도다.TraceContext 범위started작업 시작TX 템플릿시작/참여 상태 확보work.call()참여 범위 종료물리 커밋은아직 없음work.call()새 물리 TX 완료commit /rollbacktrace 정상/실패결과·예외 전달바깥 물리 TX이후 commit또는 rollback기존 물리TX에참여하는가?예아니오바깥 TX점선 뒤 커밋은이 trace의 관찰 밖
trace가 transaction callback과 완료를 감싸는 중첩 구조외부 TraceContext 범위는 시작 기록 뒤 TransactionTemplate을 실행한다. 내부 범위는 operation 콜백을 실행하고 commit 또는 rollback을 완료한다. 그 이후 외부 trace가 정상 또는 실패를 기록하므로 커밋 실패도 관찰할 수 있다.바깥 · TraceContext.execute시작 기록 · 호출 로컬 start안쪽 · TransactionTemplate.executeoperation.call()불변 입력 · 한 작업TX 완료commit / rollback완료 뒤 정상/실패 기록 → 결과 또는 예외 전달트레이스 기록기가 실패하지 않을 때 예제의 원본 계약 유지
  1. 바깥 TraceContext · 시작 기록

    operation은 callback으로 받고 start/outcome은 호출 로컬 변수로 둡니다.

  2. 안쪽 TransactionTemplate · 대상 + 완료

    콜백에 도달하면 재시도 없이 작업을 호출하고 트랜잭션을 commit/rollback합니다.

  3. 바깥 trace · 완료 뒤 관찰

    트랜잭션 완료가 끝난 뒤 정상/실패를 기록하므로 커밋 실패도 포함됩니다. 기록기가 실패하지 않을 때 원본 결과·업무 예외가 유지됩니다.

콜백 계약을 명시

Supplier는 검사 예외를 선언하지 않습니다. checked/nullable/rollback 접근을 콜백 API에 맞게 정하고 기술 실패 번역은 어댑터가 소유합니다.

싱글톤과 공유 수집 대상

현재 작업·결과·시각을 필드에 두지 않습니다. 공유 events/sink는 스레드 안전해야 합니다. 지연 실행에는 불변 snapshot과 명시적 executor가 필요합니다.

새 물리 트랜잭션의 완료를 trace 밖에서 처리하면 커밋 실패를 놓칠 수 있습니다. 기존 바깥 경계에 참여하는 안쪽 템플릿을 감싼 경우도 나중의 물리 커밋까지 포함하지 않습니다. 래퍼 순서와 실제 전파 조건을 함께 정합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • trace 범위 안에서 TransactionTemplate이 시작 또는 참여 상태를 확보하고 콜백을 실행한다. 기존의 같은 관리자가 소유한 물리 TX에 REQUIRED로 참여하면 work 실행 뒤 안쪽 범위가 끝나도 물리 커밋은 나중에 바깥에서 일어나며 그 점선 경로는 trace 밖이다. 기존 TX가 없어 새 물리 TX를 시작했다면 그 commit 또는 rollback 처리가 trace 안이어서 그 완료 실패를 관찰할 수 있다. trace finished가 항상 최종 DB 커밋 성공이라는 뜻은 아니다. 두 가지 가정은 명령형 일반 전파 모델이며 sink 실패와 다른 관리자/리액티브 전파는 별도다.
  • 외부 TraceContext 범위는 시작 기록 뒤 TransactionTemplate을 실행한다. 내부 범위는 operation 콜백을 실행하고 commit 또는 rollback을 완료한다. 그 이후 외부 trace가 정상 또는 실패를 기록하므로 커밋 실패도 관찰할 수 있다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1전략·템플릿 콜백
6trace 범위 안에서 TransactionTemplate이 시작 또는 참여 상태를 확보하고 콜백을 실행한다. 기존의 같은 관리자가 소유한 물리 TX에 REQUIRED로 참여하면 work 실행 뒤 안쪽 범위가 끝나도 물리 커밋은 나중에 바깥에서 일어나며 그 점선 경로는 trace 밖이다. 기존 TX가 없어 새 물리 TX를 시작했다면 그 commit 또는 rollback 처리가 trace 안이어서 그 완료 실패를 관찰할 수 있다. trace finished가 항상 최종 DB 커밋 성공이라는 뜻은 아니다. 두 가지 가정은 명령형 일반 전파 모델이며 sink 실패와 다른 관리자/리액티브 전파는 별도다.
22아니요
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
독립 TX 가정

trace 범위가 시작되고 기존 TX 없음과 같은 매니저의 기존 TX 참여를 분리합니다.

새 TX 사례: 기존 TX 없음 · 참여 사례: 기존 물리 TX 있음 · 모델: 명령형 REQUIRED완료 소유자가 다른 두 가정을 고정합니다.
템플릿과 callback

TransactionTemplate이 새 TX를 시작하거나 기존 TX에 참여한 뒤 work callback을 실행합니다.

trace: 활성 · work: 실행 · 물리 TX: 시작 또는 참여두 사례 모두 callback 실행에 들어갑니다.
안쪽 execute 종료

새 TX는 trace 안에서 물리 commit·rollback을 처리하고 참여는 안쪽 scope만 끝냅니다.

새 TX: 물리 완료를 trace 안에서 처리 · 참여: 물리 TX 아직 바깥 소유같은 callback 완료에도 물리 완료 상태가 달라집니다.
trace 종료와 바깥 완료

trace 종료 뒤 참여 사례의 물리 완료는 바깥 경계에서 나중에 일어납니다.

새 TX 완료 실패: trace에서 관찰 가능 · 참여 최종 완료: trace 밖 · trace finished: 항상 commit 성공은 아님trace의 관찰 범위와 최종 DB 완료를 구분합니다.

전략·템플릿 콜백

기존 TX 없음과 같은 매니저의 REQUIRED 참여를 두 독립 가정으로 표시합니다. trace·템플릿·물리 TX의 고정 역할에서 callback 완료 뒤 새 TX는 trace 안에서 물리 완료하지만 참여 TX는 trace 종료 뒤 바깥에서 완료하는 실제 차이를 추적하며 trace finished를 최종 commit 성공으로 합치지 않습니다.

TEMPLATE CALLBACK · WRAPPER NESTING

새 물리 트랜잭션을 트레이스로 감싸면 커밋까지 관찰한다

변하는 operation은 전략/콜백으로 전달합니다. 싱글톤 컨텍스트는 흐름만 소유하고 시작 시각·결과는 각 호출의 로컬 변수에 둡니다.

물리 트랜잭션의 소유 위치에 따른 커밋 관찰 범위trace범위안에서TransactionTemplate이시작또는참여상태를확보하고콜백을실행한다. 기존같은관리자의물리TX에REQUIRED로참여하면work실행뒤안쪽범위가끝나도물리커밋은나중바깥에서일어나며그점선경로는trace밖이다. 기존TX가없어새물리TX를시작했다면그commit또는rollback처리가trace안이어서그완료실패를관찰할수있다. tracefinished가항상최종DB커밋성공이라는뜻은아니다. 두가지가정은명령형일반전파모델이며sink실패와다른관리자/리액티브전파는별도다.TraceContext 범위started · 작업 시작TransactionTemplate시작/참여 상태 확보work.call()참여 범위 정상 종료물리 커밋은 아직 없음work.call()새 물리 TX 완료commit / rollback정상/실패 관찰결과·예외 전달이후 바깥 물리 TXcommit / rollback기존 물리 TX에참여하는가?예아니오이후 바깥 TX점선 뒤 커밋은이 trace의 관찰 밖
물리 트랜잭션의 소유 위치에 따른 커밋 관찰 범위trace범위안에서TransactionTemplate이시작또는참여상태를확보하고콜백을실행한다. 기존같은관리자의물리TX에REQUIRED로참여하면work실행뒤안쪽범위가끝나도물리커밋은나중바깥에서일어나며그점선경로는trace밖이다. 기존TX가없어새물리TX를시작했다면그commit또는rollback처리가trace안이어서그완료실패를관찰할수있다. tracefinished가항상최종DB커밋성공이라는뜻은아니다. 두가지가정은명령형일반전파모델이며sink실패와다른관리자/리액티브전파는별도다.TraceContext 범위started작업 시작TX 템플릿시작/참여 상태 확보work.call()참여 범위 종료물리 커밋은아직 없음work.call()새 물리 TX 완료commit /rollbacktrace 정상/실패결과·예외 전달바깥 물리 TX이후 commit또는 rollback기존 물리TX에참여하는가?예아니오바깥 TX점선 뒤 커밋은이 trace의 관찰 밖
trace가 transaction callback과 완료를 감싸는 중첩 구조외부 TraceContext 범위는 시작 기록 뒤 TransactionTemplate을 실행한다. 내부 범위는 operation 콜백을 실행하고 commit 또는 rollback을 완료한다. 그 이후 외부 trace가 정상 또는 실패를 기록하므로 커밋 실패도 관찰할 수 있다.바깥 · TraceContext.execute시작 기록 · 호출 로컬 start안쪽 · TransactionTemplate.executeoperation.call()불변 입력 · 한 작업TX 완료commit / rollback완료 뒤 정상/실패 기록 → 결과 또는 예외 전달트레이스 기록기가 실패하지 않을 때 예제의 원본 계약 유지
  1. 바깥 TraceContext · 시작 기록

    operation은 callback으로 받고 start/outcome은 호출 로컬 변수로 둡니다.

  2. 안쪽 TransactionTemplate · 대상 + 완료

    콜백에 도달하면 재시도 없이 작업을 호출하고 트랜잭션을 commit/rollback합니다.

  3. 바깥 trace · 완료 뒤 관찰

    트랜잭션 완료가 끝난 뒤 정상/실패를 기록하므로 커밋 실패도 포함됩니다. 기록기가 실패하지 않을 때 원본 결과·업무 예외가 유지됩니다.

콜백 계약을 명시

Supplier는 검사 예외를 선언하지 않습니다. checked/nullable/rollback 접근을 콜백 API에 맞게 정하고 기술 실패 번역은 어댑터가 소유합니다.

싱글톤과 공유 수집 대상

현재 작업·결과·시각을 필드에 두지 않습니다. 공유 events/sink는 스레드 안전해야 합니다. 지연 실행에는 불변 snapshot과 명시적 executor가 필요합니다.

새 물리 트랜잭션의 완료를 trace 밖에서 처리하면 커밋 실패를 놓칠 수 있습니다. 기존 바깥 경계에 참여하는 안쪽 템플릿을 감싼 경우도 나중의 물리 커밋까지 포함하지 않습니다. 래퍼 순서와 실제 전파 조건을 함께 정합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • trace 범위 안에서 TransactionTemplate이 시작 또는 참여 상태를 확보하고 콜백을 실행한다. 기존의 같은 관리자가 소유한 물리 TX에 REQUIRED로 참여하면 work 실행 뒤 안쪽 범위가 끝나도 물리 커밋은 나중에 바깥에서 일어나며 그 점선 경로는 trace 밖이다. 기존 TX가 없어 새 물리 TX를 시작했다면 그 commit 또는 rollback 처리가 trace 안이어서 그 완료 실패를 관찰할 수 있다. trace finished가 항상 최종 DB 커밋 성공이라는 뜻은 아니다. 두 가지 가정은 명령형 일반 전파 모델이며 sink 실패와 다른 관리자/리액티브 전파는 별도다.
  • 외부 TraceContext 범위는 시작 기록 뒤 TransactionTemplate을 실행한다. 내부 범위는 operation 콜백을 실행하고 commit 또는 rollback을 완료한다. 그 이후 외부 trace가 정상 또는 실패를 기록하므로 커밋 실패도 관찰할 수 있다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1전략·템플릿 콜백
6trace 범위 안에서 TransactionTemplate이 시작 또는 참여 상태를 확보하고 콜백을 실행한다. 기존의 같은 관리자가 소유한 물리 TX에 REQUIRED로 참여하면 work 실행 뒤 안쪽 범위가 끝나도 물리 커밋은 나중에 바깥에서 일어나며 그 점선 경로는 trace 밖이다. 기존 TX가 없어 새 물리 TX를 시작했다면 그 commit 또는 rollback 처리가 trace 안이어서 그 완료 실패를 관찰할 수 있다. trace finished가 항상 최종 DB 커밋 성공이라는 뜻은 아니다. 두 가지 가정은 명령형 일반 전파 모델이며 sink 실패와 다른 관리자/리액티브 전파는 별도다.
22아니요
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
독립 TX 가정

trace 범위가 시작되고 기존 TX 없음과 같은 매니저의 기존 TX 참여를 분리합니다.

새 TX 사례: 기존 TX 없음 · 참여 사례: 기존 물리 TX 있음 · 모델: 명령형 REQUIRED완료 소유자가 다른 두 가정을 고정합니다.
템플릿과 callback

TransactionTemplate이 새 TX를 시작하거나 기존 TX에 참여한 뒤 work callback을 실행합니다.

trace: 활성 · work: 실행 · 물리 TX: 시작 또는 참여두 사례 모두 callback 실행에 들어갑니다.
안쪽 execute 종료

새 TX는 trace 안에서 물리 commit·rollback을 처리하고 참여는 안쪽 scope만 끝냅니다.

새 TX: 물리 완료를 trace 안에서 처리 · 참여: 물리 TX 아직 바깥 소유같은 callback 완료에도 물리 완료 상태가 달라집니다.
trace 종료와 바깥 완료

trace 종료 뒤 참여 사례의 물리 완료는 바깥 경계에서 나중에 일어납니다.

새 TX 완료 실패: trace에서 관찰 가능 · 참여 최종 완료: trace 밖 · trace finished: 항상 commit 성공은 아님trace의 관찰 범위와 최종 DB 완료를 구분합니다.

그림은 TraceContext 범위 안에서 TransactionTemplate이 트랜잭션을 시작하거나 기존 트랜잭션에 참여한 뒤 work 콜백을 실행하는 흐름입니다. 기존 트랜잭션이 없어 새 물리 트랜잭션을 시작했다면 그 커밋이나 롤백이 트레이스 범위 안에서 끝나므로 완료 단계의 실패도 트레이스가 봅니다. 같은 트랜잭션 관리자의 기존 물리 트랜잭션에 REQUIRED로 참여했다면 안쪽 범위만 끝나고, 실제 물리 커밋은 나중에 바깥에서 일어나 점선처럼 트레이스 밖에 놓입니다. 그래서 트레이스의 finished가 언제나 최종 DB 커밋 성공을 뜻하지는 않습니다.


컨텍스트와 전략의 역할

컨텍스트는 트레이스의 시작, 종료, 실패 기록을 맡고, 전략은 게시글 조회나 등록 같은 작업만 구현합니다. 전략을 생성자에서 받아 고정하면 재사용할 수 있는 객체 조합이 되고, 실행할 때 파라미터로 받으면 호출마다 다른 콜백을 쓸 수 있습니다. 아래 TraceContext는 후자를 택했습니다.

src/main/java/board/strategy/TraceContext.java
package board.strategy;

import java.util.Objects;

public final class TraceContext {
    private final TraceEvents events;

    public TraceContext(TraceEvents events) {
        this.events = Objects.requireNonNull(events);
    }

    public <T> T execute(String operation, Strategy<T> strategy) {
        long start = System.nanoTime();
        events.started(operation);
        try {
            T value = strategy.call();
            events.finished(operation, System.nanoTime() - start);
            return value;
        } catch (RuntimeException | Error failure) {
            events.failed(operation, failure, System.nanoTime() - start);
            throw failure;
        }
    }

    @FunctionalInterface
    public interface Strategy<T> {
        T call();
    }

    public interface TraceEvents {
        void started(String operation);
        void finished(String operation, long nanos);
        void failed(String operation, Throwable failure, long nanos);
    }
}

이 컨텍스트는 현재 작업을 필드에 저장하지 않으므로, 싱글톤으로 공유해도 호출마다 지역 변수 start와 value가 따로 생깁니다. 다만 주입받은 TraceEvents 구현도 스레드 안전해야 하며, 요청 상태가 필요하면 ThreadLocal 수명 주기에서 만든 ThreadLocal 범위에서 읽거나 인자로 받습니다.


람다 콜백

람다는 익명 클래스보다 짧고, 실행할 코드가 리포지토리 메서드 가까이에 보입니다. 람다가 캡처하는 지역 변수는 사실상 final(effectively final)이어야 하며, 변경 가능한 컬렉션을 캡처해 여러 스레드가 함께 쓰면 별도의 동시성 문제가 생깁니다.

src/main/java/board/strategy/CallbackDemo.java
package board.strategy;

public final class CallbackDemo {
    public static void main(String[] args) {
        var context = new TraceContext(new ConsoleEvents());
        long authorId = 41L;
        String result = context.execute(
                "weekly summary",
                () -> "member=" + authorId + " postCount=12");
        System.out.println(result);
    }

    static final class ConsoleEvents implements TraceContext.TraceEvents {
        @Override
        public void started(String operation) {
            System.out.println("start=" + operation);
        }

        @Override
        public void finished(String operation, long nanos) {
            System.out.println("outcome=success");
        }

        @Override
        public void failed(String operation, Throwable failure, long nanos) {
            System.out.println("outcome=" + failure.getClass().getSimpleName());
        }
    }
}

컨텍스트가 시작을 기록하고, 콜백이 정상 반환한 뒤 성공을 기록하며, 마지막에 main이 결과를 출력합니다. 람다는 execute 안에서 strategy.call()이 불릴 때 실행되므로, 결과 문자열은 람다를 넘긴 시점이 아니라 두 번째 줄이 찍히기 직전에 만들어집니다. 람다가 캡처한 authorId는 다시 대입하지 않는 지역 변수이므로 컴파일러가 사실상 final로 받아들입니다.

CallbackDemo 실행
start=weekly summary
outcome=success
member=41 postCount=12

콜백이 너무 길어지면 이름을 붙인 private 메서드나 별도의 전략 클래스로 추출합니다. 람다 안에 트랜잭션, 재시도, 검증을 모두 넣으면 템플릿으로 드러내려던 흐름이 다시 숨겨집니다.


콜백의 검사 예외

JDK의 Supplier<T>는 검사 예외를 선언할 수 없습니다. 원시 JDBC 콜백처럼 검사 예외 타입이 실제 확장 규칙의 일부라면, 자체 인터페이스에 throws E를 둘 수 있습니다. 그러나 호출자 전체로 예외 타입 파라미터가 퍼지는 비용도 함께 따져야 합니다.

Spring의 템플릿은 저수준 검사 예외를 콜백 안에서 받아 공통 예외 계층으로 번역합니다. 애플리케이션의 트레이스 컨텍스트가 기술 예외의 번역까지 맡으면 관심사가 섞이므로, 대상 어댑터가 먼저 런타임 예외 규칙을 제공하게 합니다. 콜백에서 예외를 감쌀 때는 원인을 보존하고, 컨텍스트는 자신이 받은 인스턴스를 그대로 다시 던지는 편이 좋습니다. 트레이싱 계층이 예외 분류를 바꾸면 롤백 규칙도 예상과 달라질 수 있습니다.


TransactionTemplate 콜백

프록시를 거치지 않는 자기 호출 문제를 피하거나, 작은 블록만 트랜잭션으로 묶어야 할 때 TransactionTemplate.execute()가 쓸모 있습니다. 콜백은 TransactionStatus를 받아 롤백 전용(rollback-only) 상태를 표시할 수 있습니다. null을 반환해도 되는지와 예외가 어떻게 전파되는지는 API 문서에서 확인합니다.

프로그래밍 방식은 전파 속성과 타임아웃이 구성 객체에 그대로 보이지만, 애노테이션보다 반복 코드가 늘어납니다. 실행 중에 트랜잭션 옵션을 동적으로 바꾸는 코드는 검토하기 어려우므로, 미리 구성한 템플릿 빈을 목적별로 둡니다.

템플릿 콜백이 끝나는 시점과 물리 트랜잭션이 끝나는 시점은 전파 조건에 따라 다릅니다. Spring의 REQUIRED 전파 규칙에 따르면 기존 물리 트랜잭션이 있을 때 그 트랜잭션에 참여합니다. 기존 트랜잭션이 없어 새 물리 트랜잭션을 만들었다면 execute 안에서 그 완료를 처리하지만, 바깥 트랜잭션에 참여했다면 최종 물리 커밋은 바깥에서 나중에 일어납니다. 프로그래밍 방식 트랜잭션 관리의 설정과 콜백 규칙을 중첩 위치와 함께 확인합니다.

리액티브의 TransactionalOperator는 발행자(publisher) 콜백과 Reactor 컨텍스트를 사용합니다. 스레드에 묶인 명령형 트랜잭션 모델과 같은 것으로 취급하지 않습니다.

더 깊이: execute 안에서 예외는 어떻게 처리될까

Spring Framework 7.0.10의 TransactionTemplate.execute는 짧은 템플릿 콜백 구현 그 자체입니다. 관리자에서 트랜잭션을 얻은 뒤 콜백을 부르고, RuntimeException이나 Error가 나오면 롤백한 다음 같은 예외를 다시 던집니다. 콜백이 정상적으로 돌아오면 커밋하고 결과를 돌려줍니다. @Transactional과 달리 이 경로에는 rollbackFor 같은 규칙을 볼 곳이 없으므로, 콜백 밖으로 나온 예외는 종류와 상관없이 모두 롤백으로 이어집니다.

TransactionCallback.doInTransaction은 검사 예외를 선언하지 않지만, Kotlin 코드나 예외를 몰래 던지는 기법으로 검사 예외가 빠져나올 수는 있습니다. 이때 템플릿은 역시 롤백한 뒤 원래 예외를 UndeclaredThrowableException으로 감싸 던지므로, 호출한 쪽에서 원래 타입을 잡으려면 원인을 꺼내야 합니다. 또 롤백 자체가 실패하면 롤백의 예외가 바깥으로 나가고, 원래의 업무 예외는 오류 로그에 남습니다. 롤백의 예외가 TransactionSystemException일 때만 원래 예외가 그 안에 함께 담깁니다. 이 클래스는 DefaultTransactionDefinition을 상속해 전파와 격리 수준, 타임아웃을 자기 필드로 가집니다. Spring 레퍼런스가 대화 상태가 없어 여러 클래스가 공유해도 안전하지만, 설정이 다르면 인스턴스를 따로 만들라고 하는 이유가 이것입니다.


싱글톤 템플릿의 상태

작업 이름, 시작 시간, 결과를 컨텍스트의 필드에 두면, 전략을 적용했더라도 동시 요청과 공유 상태에서 본 경쟁이 그대로 돌아옵니다. 이런 값은 execute()의 지역 변수와 불변 객체에 보관합니다. 공유하는 수집 대상이 버퍼를 가진다면 동시성 컬렉션이나 추가 전용 백엔드를 씁니다.

콜백을 저장해 두었다가 나중에 실행하면, 캡처한 엔티티와 요청 컨텍스트의 수명이 실행 시점과 어긋납니다. 즉시 실행하는 템플릿인지 지연된 명령인지를 인터페이스 이름에서 구분하고, 지연된 작업에는 불변 입력 스냅샷과 명시적인 실행자를 씁니다.


패턴을 고르는 기준

조건템플릿 메서드템플릿 콜백
단계별 훅이 많음읽기 쉬움콜백 타입이 늘어남
작업 하나만 달라짐하위 클래스가 무거움람다가 적합
런타임 조합제한적자유로움
상속 계층필요불필요
상태 보관부모 인스턴스 필드에 주의컨텍스트의 지역 변수 권장

표의 어느 칸을 고르든 목표는 패턴 이름을 적용하는 데 있지 않습니다. 여러 곳에 복사된 제어 흐름을 한곳으로 모아, 그 흐름을 고칠 때 한 클래스만 보면 되게 만드는 것이 목표입니다.


정리

  • 전략 패턴은 흐름을 가진 컨텍스트가 달라지는 동작을 협력 객체로 받아 호출하므로 상속이 필요 없습니다.
  • 달라지는 부분이 메서드 하나라면 템플릿 콜백 패턴으로 람다를 넘기며, JdbcTemplate과 TransactionTemplate도 같은 구조입니다.
  • 싱글톤 컨텍스트는 호출별 값을 필드가 아닌 지역 변수에 두고, 주입받은 수집 대상도 스레드 안전하게 만듭니다.
  • 트레이스와 TransactionTemplate을 중첩할 때 트레이스가 커밋 실패까지 보려면, 새 물리 트랜잭션의 완료를 트레이스 범위 안에 두어야 합니다.

연습 문제

1. 트랜잭션과 트레이스의 중첩 순서

TransactionTemplate과 트레이스 콜백을 중첩해 게시글 등록을 실행하세요. 템플릿이 새 물리 트랜잭션을 시작하는 조건에서, 커밋 실패까지 트레이스에 포함되려면 어느 래퍼가 바깥에 있어야 하는지 정하세요. 런타임 예외의 동일성과 롤백된 행도 검증하세요. 기록기는 실패하지 않는다고 가정합니다.

해설 보기

트레이스 컨텍스트가 새 물리 트랜잭션의 시작과 완료를 수행하는 템플릿의 바깥을 감싸면, 대상 실행뿐 아니라 커밋 단계에서 난 예외도 트레이스가 봅니다.

package board.strategy;

import org.springframework.transaction.support.TransactionTemplate;

public final class TracedTransaction {
    private final TraceContext trace;
    private final TransactionTemplate transactions;

    public TracedTransaction(TraceContext trace, TransactionTemplate transactions) {
        this.trace = trace;
        this.transactions = transactions;
    }

    public <T> T execute(String name, TraceContext.Strategy<T> work) {
        return trace.execute(name, () -> transactions.execute(status -> work.call()));
    }
}

반환값이 null일 수 없는 사용 사례라면 콜백의 결과를 검사해 규칙 위반으로 드러냅니다. 다만 기존 물리 트랜잭션에 참여하는 경우에는 이 중첩만으로 나중에 일어나는 바깥 커밋까지 보지 못하며, 이 차이는 연습 2에서 계산합니다.

2. 커밋 실패가 보이는 위치

연습 1의 TracedTransaction에서 트레이스 기록기는 실패하지 않고 work는 정상 반환합니다. 첫 번째 경우에는 기존 트랜잭션이 없어 안쪽 템플릿이 새 물리 트랜잭션을 시작했고, 그 커밋에서 런타임 예외 C가 납니다. 두 번째 경우에는 같은 관리자의 바깥 트랜잭션에 REQUIRED로 참여해 안쪽 템플릿은 정상 반환했지만, 트레이스가 끝난 뒤 바깥 커밋에서 런타임 예외 C가 납니다. 각 경우에 트레이스가 finished와 failed 가운데 무엇을 기록하는지, C를 어디에서 볼 수 있는지 구하세요.

해설 보기

첫 번째 경우에는 transactions.execute가 C를 던지므로 트레이스의 failed가 C를 받고, 같은 C를 다시 던집니다. finished에는 도달하지 않습니다.

두 번째 경우에는 안쪽 템플릿이 반환한 뒤 트레이스가 finished를 기록합니다. 물리 커밋의 실패 C는 이 TraceContext 밖에서 나므로, 바깥 트랜잭션을 연 호출 쪽이 기록해야 합니다. 이때의 finished를 최종 DB 커밋 성공으로 해석하면 안 됩니다. 최종 커밋까지 보려면 트레이스가 실제 물리 트랜잭션을 감싸도록 위치를 정해야 합니다. 예외 동일성은 기록기와 트랜잭션 완료 처리가 원래 실패를 다른 예외로 바꾸지 않는다는 조건에서 성립하며, 실제 DB의 롤백 행은 따로 실행해 검증할 대상입니다.

3. 캡처한 변수를 바꾸면

CallbackDemo에서 context.execute(...) 호출 다음 줄에 authorId = 42L;을 추가하면 어떻게 되나요? 같은 목적을 위해 long[] holder = {41L}을 캡처하고 람다 밖에서 holder[0]을 바꾸는 방식은 무엇이 다른지도 설명하세요.

해설 보기

람다가 캡처한 지역 변수는 사실상 final이어야 하는데, 뒤에서 다시 대입하면 authorId가 더 이상 그 조건을 만족하지 않으므로 컴파일 오류가 납니다. 대입이 람다 뒤에 있어도 마찬가지입니다.

배열을 캡처하면 변수 holder 자체는 다시 대입하지 않으므로 컴파일은 됩니다. 하지만 람다와 바깥 코드가 같은 배열 칸을 공유하게 되어, 콜백을 다른 스레드에서 실행하거나 나중에 실행하면 어느 값을 읽을지가 실행 순서에 달립니다. 컴파일러의 제한을 피했을 뿐 공유 상태 문제는 그대로이므로, 콜백에 필요한 값은 실행 전에 불변 값으로 확정해 넘깁니다.

다음 글 프록시·데코레이터에서는 대상 코드를 고치지 않고도 같은 인터페이스를 구현한 대리 객체를 앞에 세워, 트레이스 같은 부가 기능을 호출 사이에 끼워 넣는 방법을 다룹니다.