Spring 트랜잭션 전파 모델과 실무 패턴

터프라이즈 애플리케이션에서 데이터 무결성(Integrity)은 타협할 수 없는 가치입니다. Spring Framework는 @Transactional이라는 선언적 트랜잭션 관리 도구를 제공하여, 개발자가 비즈니스 로직과 트랜잭션 관리 코드를 분리할 수 있게 돕습니다. 그러나 이 추상화 계층은 강력한 만큼 오남용의 위험이 큽니다. 특히 MSA(Microservices Architecture)나 모듈러 모놀리스 환경에서 서비스 간 호출이 빈번해질 때, 트랜잭션의 범위(Scope)와 전파(Propagation) 방식을 정확히 이해하지 못하면 데이터 불일치(Inconsistency)나 데드락(Deadlock), 커넥션 풀 고갈과 같은 심각한 장애를 초래합니다. 본 글에서는 Spring 트랜잭션의 기반인 AOP 프록시 메커니즘을 분석하고, 실무에서 마주하는 전파 속성의 트레이드오프를 논합니다.

1. AOP 프록시와 트랜잭션 추상화

Spring의 선언적 트랜잭션은 AOP(Aspect Oriented Programming)를 기반으로 동작합니다. @Transactional이 선언된 빈(Bean)은 애플리케이션 로딩 시점에 실제 객체가 아닌 프록시(Proxy) 객체로 래핑되어 컨테이너에 등록됩니다. 클라이언트가 메소드를 호출하면, 프록시가 먼저 요청을 가로채 트랜잭션을 시작(Do Begin)하고, 실제 대상 객체(Target)의 메소드를 실행한 뒤, 결과에 따라 커밋(Do Commit)하거나 롤백(Do Rollback)합니다.

Architecture Note: Spring Boot 2.0 이상에서는 CGLIB 프록시가 기본값입니다. 이는 인터페이스가 없는 클래스에 대해서도 상속을 통해 프록시를 생성할 수 있게 하며, JDK Dynamic Proxy보다 성능상 이점이 있습니다.

Self-Invocation 이슈와 한계

프록시 패턴의 구조적 한계로 인해, 동일한 클래스 내부의 메소드 호출(Self-Invocation) 시에는 트랜잭션이 적용되지 않습니다. this.method() 호출은 프록시를 거치지 않고 직접 내부 메소드를 실행하기 때문입니다.


@Service
public class OrderService {

    public void createOrder(OrderDto dto) {
        // 이 메소드는 트랜잭션 없이 실행됨
        processPayment(dto); 
    }

    @Transactional // 적용되지 않음 (Self-Invocation)
    public void processPayment(OrderDto dto) {
        // payment logic
    }
}

이 문제를 해결하기 위해서는 해당 메소드를 별도의 서비스(Bean)로 분리하거나, 자기 자신을 주입받아 호출하는 방식(Self-Injection)을 사용해야 합니다.

2. 전파 속성(Propagation) 분석과 전략

트랜잭션 전파는 "이미 트랜잭션이 진행 중일 때, 추가로 수행되는 트랜잭션 작업을 어떻게 처리할 것인가"를 정의합니다. 단순히 REQUIRED만 사용하는 것을 넘어, 각 속성이 데이터베이스 커넥션과 잠금(Lock)에 미치는 영향을 파악해야 합니다.

REQUIRED vs REQUIRES_NEW

가장 많이 비교되는 두 속성의 차이점은 물리적 트랜잭션의 분리 여부입니다.

속성 동작 방식 롤백 영향도 Connection 사용
REQUIRED (Default) 기존 트랜잭션에 참여. 없으면 생성. 내부/외부 중 하나라도 실패 시 전체 롤백 (논리적 결합) 1개 공유
REQUIRES_NEW 항상 새로운 물리적 트랜잭션 생성. 기존 트랜잭션은 일시 중단(Suspend). 상호 독립적. 내부 실패가 외부에 전파되지 않음 (try-catch 필요) 2개 점유 (Active + Suspended)
Performance Risk: REQUIRES_NEW는 단일 요청 처리 중에 데이터베이스 커넥션을 2개 점유합니다. 트래픽이 몰릴 경우 커넥션 풀(HikariCP 등)이 빠르게 고갈되어 시스템 전체의 처리량을 저하시킬 수 있습니다.

NESTED: Savepoint를 이용한 부분 롤백

NESTED는 물리적으로는 하나의 트랜잭션(커넥션)을 사용하지만, JDBC의 Savepoint 기능을 이용해 논리적인 중첩 구조를 만듭니다.


@Transactional(propagation = Propagation.REQUIRED)
public void outer() {
    try {
        innerService.nestedMethod(); // NESTED
    } catch (Exception e) {
        // nestedMethod의 변경사항만 롤백되고, outer는 계속 진행 가능
        log.warn("Inner transaction rolled back to savepoint");
        alternativeLogic(); 
    }
}

이는 REQUIRES_NEW와 달리 커넥션을 추가로 점유하지 않으면서도 부분적인 복구 로직을 구현할 수 있다는 장점이 있습니다. 단, JPA(Hibernate) 구현체에 따라 지원 여부가 다르므로 사전 검증이 필요합니다.

3. 실무 최적화 및 주의사항

안정적인 트랜잭션 설계를 위해 고려해야 할 엔지니어링 포인트입니다.

ReadOnly 속성의 활용

단순 조회용 메소드에는 반드시 @Transactional(readOnly = true)를 명시해야 합니다.

  • Hibernate 최적화: 스냅샷 저장 및 Dirty Checking(변경 감지) 과정을 생략하여 메모리 사용량과 CPU 연산을 줄입니다.
  • DB 부하 분산: Replication 환경에서 Slave(Read Replica) DB로 쿼리를 라우팅하도록 드라이버 레벨에서 설정할 수 있습니다.

예외 처리와 롤백 정책

Spring은 기본적으로 Unchecked Exception (RuntimeException, Error) 발생 시에만 롤백을 수행합니다. Checked Exception(예: IOException)이 발생해도 데이터는 커밋됩니다. 비즈니스적으로 중요한 예외라면 반드시 rollbackFor 옵션을 지정해야 합니다.


// Checked Exception 발생 시에도 강제 롤백
@Transactional(rollbackFor = {SQLException.class, IOException.class})
public void updateFileAndDb() throws IOException {
    // ...
}
Anti-Pattern: 트랜잭션 메소드 내부에서 예외를 catch 하고 아무런 조치 없이 로그만 남기면, 프록시는 정상 종료로 인식하여 커밋을 수행합니다. 예외를 잡았다면 반드시 throw 하거나 TransactionAspectSupport.setRollbackOnly()를 호출해야 합니다.

결론

Spring의 트랜잭션 관리는 개발 생산성을 높여주는 핵심 기능이지만, 내부 동작 원리인 프록시 패턴과 전파 속성의 물리적 비용을 이해하지 못하면 시스템의 병목 구간이 됩니다. REQUIRES_NEW 사용 시 커넥션 풀 용량을 고려하고, 조회 쿼리에는 readOnly를 적극 활용하며, 프록시 경계를 명확히 인식하여 Self-Invocation 문제를 방지하는 것이 안정적인 백엔드 시스템을 구축하는 엔지니어링의 기본입니다.

Recommendation: 비즈니스 로직의 복잡도가 높다면 트랜잭션 템플릿(TransactionTemplate)을 사용하여 프로그래밍 방식으로 트랜잭션 범위를 명시적으로 제어하는 것도 좋은 대안이 됩니다.

Post a Comment