Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save YangSiJun528/84f6b453b6687491a2b1756498f5ed5f to your computer and use it in GitHub Desktop.

Select an option

Save YangSiJun528/84f6b453b6687491a2b1756498f5ed5f to your computer and use it in GitHub Desktop.
템플릿/콜백 패턴 정리

AI 글

템플릿/콜백 패턴의 핵심 가치: 경계(boundary) 제어

템플릿 패턴의 본질은 실수 방지를 넘어서 자원의 생명주기를 프레임워크가 관리하는 데 있다.


0. 왜 템플릿 패턴이 필요한가?

안티패턴: 수동 자원 관리

// ❌ 위험한 코드
public void transferMoney(Account from, Account to, BigDecimal amount) {
    Connection conn = dataSource.getConnection();
    conn.setAutoCommit(false);

    try {
        accountRepository.withdraw(from, amount, conn);
        accountRepository.deposit(to, amount, conn);
        conn.commit();
    } catch (Exception e) {
        conn.rollback();  // 예외 발생 시 롤백을 잊으면?
        throw e;
    }
    conn.close();  // finally 없으면 리소스 누수!
}

문제점:

  1. 리소스 해제를 잊을 수 있음
  2. 중첩 호출 시 Connection을 파라미터로 계속 전달해야 함
  3. 트랜잭션 전파(propagation) 로직을 매번 작성해야 함

템플릿 패턴으로 해결

// ✅ 안전한 코드 - 템플릿/콜백 패턴
transactionTemplate.execute(status -> {
    accountRepository.withdraw(from, amount);  // Connection 자동 공유
    accountRepository.deposit(to, amount);     // 프레임워크가 관리
    return null;
});

1. 자원 생명주기를 프레임워크가 관리

핵심 구조

// 실제 코드를 단순화한 개념적 구조
public class Template {

    public <T> T execute(Callback<T> action) {
        Resource resource = acquireResource();  // 1. 자원 획득
        try {
            return action.doInResource(resource);  // 2. 사용자 로직 실행
        } catch (Exception ex) {
            handleException(resource, ex);  // 3-a. 예외 처리
            throw ex;
        } finally {
            releaseResource(resource);  // 3-b. 자원 해제 (항상)
        }
    }
}

핵심 흐름:

  1. execute() 호출 시 자원 획득
  2. 콜백 실행 중 예외 발생 → 예외 처리 후 재throw
  3. 정상 종료 → 최종 처리
  4. 항상 자원 해제 (finally)

프레임워크가 "열고 닫는 시점"을 결정하고, 사용자는 비즈니스 로직만 작성한다.


2. 컨텍스트 전파 문제 해결

문제: 중첩 호출에서 같은 자원 공유

template.execute(status -> {
    userRepository.save(user);           // Resource 필요
    orderService.createOrder(order);     // 이 안에서도 같은 Resource 필요 (아직 해제 안됨)
    return result;
});

파라미터로 Resource를 계속 넘기는 건 비현실적이다.

해결: 컨텍스트를 통한 암묵적 전파

방법 1: ThreadLocal (동기 환경)

// 핵심 개념을 보여주는 단순화된 구조
public class ResourceManager {

    private static final ThreadLocal<Resource> currentResource = new ThreadLocal<>();

    public static Resource getResource() {
        return currentResource.get();
    }

    public static void bindResource(Resource resource) {
        currentResource.set(resource);
    }

    public static void unbindResource() {
        currentResource.remove();
    }
}

생명주기:

1. 요청 시작 → 스레드 할당
2. Template → bindResource(resource)
3. 비즈니스 로직 실행 → getResource()로 획득
4. Template 종료 → unbindResource()
5. 요청 종료 → 스레드 반환

핵심: ThreadLocal은 스레드당 격리된 저장소를 제공하므로, 같은 스레드 내에서는 자동으로 컨텍스트 전파됨.

방법 2: Reactor Context (비동기 환경)

// 핵심 개념을 보여주는 단순화된 구조
public class ReactiveTemplate {

    public <T> Mono<T> execute(Function<Resource, Mono<T>> action) {
        return Mono.usingWhen(
            // 자원 획득
            acquireResource(),
            // 정상 실행
            action,
            // 정상 종료 시 해제
            this::releaseResource,
            // 에러 발생 시 해제
            (resource, ex) -> releaseResource(resource),
            // 취소 시 해제
            this::releaseResource
        ).contextWrite(ctx -> ctx.put(RESOURCE_KEY, new ResourceContext()));
    }
}

컨텍스트 매체 비교

특성 ThreadLocal Reactor Context
환경 동기 (Thread-per-request) 비동기 (Event Loop)
전파 자동 (같은 스레드) 명시적 (contextWrite)
저장 단위 스레드당 Subscriber당
주의사항 스레드 풀 재사용 시 정리 필수 체인 중간에 컨텍스트 유실 가능

3. 재진입(Reentrant) 처리

문제 상황

template.execute(outerCallback -> {
    // 이미 자원을 획득한 상태
    template.execute(innerCallback -> {
        // 새 자원? 기존 자원 재사용? → 프레임워크가 판단
        return innerResult;
    });
    return outerResult;
});

해결: 컨텍스트 확인

// 핵심 개념을 보여주는 단순화된 구조
public class Template {

    public <T> T execute(Callback<T> action) {
        Resource resource = ResourceManager.getResource();

        // 이미 자원이 있는지 확인
        if (resource != null) {
            return handleExisting(resource, action);  // 기존 자원 활용
        }

        // 새 자원 시작
        resource = acquireResource();
        ResourceManager.bindResource(resource);
        try {
            return action.doInResource(resource);
        } finally {
            ResourceManager.unbindResource();
            releaseResource(resource);
        }
    }

    private <T> T handleExisting(Resource resource, Callback<T> action) {
        // 전파 정책에 따라 다르게 처리
        // - REQUIRED: 기존 자원 재사용
        // - REQUIRES_NEW: 기존 중단 후 새 자원 생성
        // - NESTED: 중첩 자원 생성 (Savepoint 등)
    }
}

핵심: 컨텍스트가 있어야 "이미 자원을 획득한 상태인지" 판단 가능하다.


4. 추상화 구조

Template (인터페이스)
    ↓ execute(Callback)
ConcreteTemplate (구현)
    ↓ 내부에서 사용
ResourceManager
    ↓ 자원 바인딩
Context (ThreadLocal or Reactor Context)

결국 "콜백의 바깥에서 안까지 관통하는 암묵적 상태 공유"가 필요하고, 실행 모델에 따라 적절한 컨텍스트 매체를 선택하는 것이다.


핵심 정리

1. 자원 생명주기 관리

템플릿이 획득(acquire)과 해제(release) 를 책임지고, 사용자는 비즈니스 로직만 작성.

2. 컨텍스트 전파

ThreadLocal(동기), Reactor Context(비동기) 등 실행 모델에 맞는 매체를 통해 암묵적으로 자원 공유.

3. 재진입 처리

프레임워크가 "이미 자원이 있는가?"를 판단하여, 전파 정책에 따라 적절히 처리.

AI 글

Spring 트랜잭션: 템플릿/콜백 패턴의 실전 구현

Spring Framework의 트랜잭션 관리가 템플릿/콜백 패턴을 어떻게 구현하고, 어떤 전파 옵션을 제공하는지 살펴본다.


1. TransactionTemplate 구현

Spring의 트랜잭션 템플릿

// 실제 코드를 단순화한 개념적 구조
public class TransactionTemplate {

    public <T> T execute(TransactionCallback<T> action) {
        TransactionStatus status = transactionManager.getTransaction(this);
        T result;
        try {
            result = action.doInTransaction(status);
        } catch (RuntimeException | Error ex) {
            rollbackOnException(status, ex);
            throw ex;
        }
        transactionManager.commit(status);
        return result;
    }

    private void rollbackOnException(TransactionStatus status, Throwable ex) {
        try {
            transactionManager.rollback(status);
        } catch (TransactionSystemException ex2) {
            ex2.initApplicationException(ex);
            throw ex2;
        }
    }
}

실제 소스코드 참고

핵심 흐름:

  1. execute() 호출 시 트랜잭션 시작 (getTransaction)
  2. 콜백 실행 중 예외 발생 → rollback 후 예외 재throw
  3. 정상 종료 → commit

2. 컨텍스트 전파: Spring MVC vs WebFlux

Spring MVC: ThreadLocal 기반

// 핵심 개념을 보여주는 단순화된 구조
public abstract class TransactionSynchronizationManager {

    private static final ThreadLocal<Map<Object, Object>> resources =
            new ThreadLocal<>();

    public static Object getResource(Object key) {
        Map<Object, Object> map = resources.get();
        if (map == null) {
            return null;
        }
        return map.get(key);
    }

    public static void bindResource(Object key, Object value) {
        Map<Object, Object> map = resources.get();
        if (map == null) {
            map = new HashMap<>();
            resources.set(map);
        }
        map.put(key, value);
    }

    public static void unbindResource(Object key) {
        Map<Object, Object> map = resources.get();
        if (map != null) {
            map.remove(key);
            if (map.isEmpty()) {
                resources.remove();
            }
        }
    }
}

실제 소스코드 참고

생명주기:

1. 요청 시작 → 스레드 풀에서 스레드 할당
2. TransactionInterceptor → bindResource(dataSource, connection)
3. 비즈니스 로직 실행 → getResource(dataSource)로 Connection 획득
4. 트랜잭션 종료 → unbindResource(dataSource)
5. 요청 종료 → 스레드 풀로 반환

Spring WebFlux: Reactor Context 기반

// 핵심 개념을 보여주는 단순화된 구조
public class TransactionalOperator {

    public <T> Mono<T> transactional(Mono<T> mono) {
        return Mono.usingWhen(
            // 자원 획득
            transactionManager.getReactiveTransaction(definition),
            // 정상 실행
            (status) -> mono,
            // 정상 종료 시 커밋
            transactionManager::commit,
            // 에러 발생 시 롤백
            (status, ex) -> transactionManager.rollback(status),
            // 취소 시 롤백
            transactionManager::rollback
        ).contextWrite(ctx -> ctx.put(TX_KEY, new TransactionContext()));
    }
}

실제 소스코드 참고

Kotlin Coroutine

@Service
class UserService(
    private val userRepository: R2dbcUserRepository,
    private val transactionalOperator: TransactionalOperator
) {

    // Coroutine Context를 통한 트랜잭션 전파
    suspend fun createUserWithAudit(user: User): User {
        return transactionalOperator.executeAndAwait {
            val savedUser = userRepository.save(user).awaitSingle()
            auditRepository.save(AuditLog(userId = savedUser.id)).awaitSingle()
            savedUser
        }
    }
}

Coroutine Context 전파:

// CoroutineContext는 Job 계층을 통해 전파됨
launch(Dispatchers.IO + TransactionContext()) {  // Parent
    userRepository.save(user)                     // 컨텍스트 상속

    launch {                                      // Child
        auditRepository.save(audit)               // 부모의 컨텍스트 상속
    }
}

3. 재진입 처리와 전파 레벨

AbstractPlatformTransactionManager의 전파 처리

// 핵심 개념을 보여주는 단순화된 구조
public abstract class AbstractPlatformTransactionManager {

    public final TransactionStatus getTransaction(TransactionDefinition definition) {
        Object transaction = doGetTransaction();

        // 이미 트랜잭션이 존재하는지 확인
        if (isExistingTransaction(transaction)) {
            return handleExistingTransaction(definition, transaction);
        }

        // 새 트랜잭션 시작
        if (definition.getPropagationBehavior() == PROPAGATION_REQUIRED) {
            doBegin(transaction, definition);
            return prepareTransactionStatus(definition, transaction, true);
        }

        // 기타 전파 레벨 처리...
    }

    private TransactionStatus handleExistingTransaction(
            TransactionDefinition definition, Object transaction) {

        // PROPAGATION_REQUIRES_NEW: 기존 중단, 새로 시작
        if (definition.getPropagationBehavior() == PROPAGATION_REQUIRES_NEW) {
            SuspendedResourcesHolder suspended = suspend(transaction);
            return startTransaction(definition, transaction, suspended);
        }

        // PROPAGATION_REQUIRED: 기존에 참여
        if (definition.getPropagationBehavior() == PROPAGATION_REQUIRED) {
            return prepareTransactionStatus(definition, transaction, false);
        }

        // PROPAGATION_NESTED: Savepoint 생성
        if (definition.getPropagationBehavior() == PROPAGATION_NESTED) {
            DefaultTransactionStatus status =
                prepareTransactionStatus(definition, transaction, false);
            status.createAndHoldSavepoint();
            return status;
        }
    }
}

실제 소스코드 참고


4. 전파 레벨(Propagation) 상세

전파 레벨 요약

전파 레벨 트랜잭션 없을 때 트랜잭션 있을 때 핵심 사용처
REQUIRED (기본값) 새로 시작 기존 참여 일반적인 비즈니스 로직
REQUIRES_NEW 새로 시작 기존 중단 후 새로 시작 독립적인 로그/알림
NESTED 새로 시작 Savepoint 생성 부분 롤백 가능한 작업
SUPPORTS 트랜잭션 없이 기존 참여 읽기 전용 조회
NOT_SUPPORTED 트랜잭션 없이 기존 중단 외부 API 호출
MANDATORY 예외 발생 기존 참여 반드시 트랜잭션 필요
NEVER 트랜잭션 없이 예외 발생 트랜잭션 금지

주요 전파 레벨 동작

PROPAGATION_REQUIRED (기본값)

기존 트랜잭션에 참여하거나 새로 시작.

transactionTemplate.execute(outerStatus -> {
    userRepository.save(user);

    transactionTemplate.execute(innerStatus -> {
        orderRepository.save(order);  // 같은 트랜잭션
        return order;
    });  // 아직 커밋 안 됨

    return user;
});  // 여기서 전체 커밋

커밋/롤백: Inner 예외 발생 시 전체 롤백

PROPAGATION_REQUIRES_NEW

항상 독립적인 새 트랜잭션 시작.

@Transactional
public void createOrder(Order order) {
    orderRepository.save(order);  // TX1

    try {
        paymentService.processPayment(order);  // TX2 (REQUIRES_NEW)
    } catch (PaymentException e) {
        // 결제 실패해도 주문은 저장됨
        order.setStatus(OrderStatus.PAYMENT_FAILED);
    }
}

커밋/롤백: Inner와 Outer 독립적으로 커밋/롤백

PROPAGATION_NESTED

하이버네이트(JPA)에선 지원하지 않고, DBMS에 따라 JDBC에서도 지원하지 않을 수 있다.
이 경우 사용하면 에러가 난다.

Savepoint를 사용한 중첩 트랜잭션.

transactionTemplate.execute(status -> {
    userRepository.save(user);

    for (Item item : items) {
        try {
            nestedTemplate.execute(nestedStatus -> {
                itemRepository.save(item);  // Savepoint
                return null;
            });
        } catch (Exception e) {
            // 이 아이템만 롤백, 나머지는 계속
        }
    }
    return user;
});

커밋/롤백: Inner만 Savepoint로 롤백 가능, Outer는 영향 없음

기타 전파 레벨

레벨 동작 사용 예
SUPPORTS 트랜잭션 있으면 참여 @Transactional(propagation = SUPPORTS, readOnly = true)
NOT_SUPPORTED 트랜잭션 중단 외부 API 호출 (이메일, SMS 등),
MANDATORY 트랜잭션 없으면 예외 내부 헬퍼 메서드
NEVER 트랜잭션 있으면 예외 긴 배치 작업

5. 추상화 구조

TransactionOperations (인터페이스)
    ↓ execute(TransactionCallback)
TransactionTemplate (MVC용 구현)
    ↓ 내부에서 사용
PlatformTransactionManager
    ↓ 자원 바인딩
TransactionSynchronizationManager (ThreadLocal)

ReactiveTransactionOperations (인터페이스)
    ↓ transactional(Mono/Flux)
TransactionalOperator (WebFlux용 구현)
    ↓ 내부에서 사용
ReactiveTransactionManager
    ↓ 컨텍스트 전파
Reactor Context

CoroutineContext (Kotlin)
    ↓ withContext { }
TransactionalOperator + Coroutine Adapter
    ↓ 내부에서 사용
R2dbcTransactionManager
    ↓ 컨텍스트 전파
CoroutineContext (Job hierarchy)

핵심 정리

1. 트랜잭션 생명주기

  • TransactionTemplate: begin → execute → commit/rollback
  • 컨텍스트 매체: ThreadLocal(MVC), Reactor Context(WebFlux), CoroutineContext(Coroutine)

2. 전파 레벨 선택 가이드

목적 전파 레벨
일반적인 비즈니스 로직 REQUIRED (기본값)
독립적인 로그/알림 (실패해도 무관) REQUIRES_NEW
부분 롤백 가능한 작업 NESTED
읽기 전용 조회 SUPPORTS + readOnly=true
외부 API 호출 (롤백 불가) NOT_SUPPORTED
내부 헬퍼 메서드 MANDATORY
긴 배치 작업 NEVER

3. 주의사항

  • REQUIRES_NEW: 데드락 주의 (동일 자원에 대한 중첩 락)
  • NESTED: 모든 DB가 Savepoint 지원하는 것은 아님 (MySQL InnoDB는 지원), 하이버네이트(JPA) 환경 지원 안함.
  • ThreadLocal: 비동기 환경에서 사용 불가
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment