← 목록으로 돌아가기

Spring 트랜잭션 전파와 격리 수준 함정: @Transactional이 예상과 다르게 동작하는 6가지 상황

Backend

Spring transactional propagation isolation pitfalls

@Transactional은 왜 예상대로 작동하지 않는가

Spring을 처음 배울 때 @Transactional은 마법처럼 보입니다. 메서드 위에 어노테이션 하나를 붙이면 DB 작업이 원자적으로 묶인다고 배우고, 실제로 기본 케이스에서는 그렇게 동작합니다. 하지만 시스템이 커지고 서비스 계층이 복잡해질수록, 이 마법이 특정 조건에서 아무런 경고 없이 조용히 무효화된다는 사실을 맞닥뜨리게 됩니다.

우리 팀은 2026년 초 포인트 적립과 결제 이력을 함께 저장하는 서비스에서 정확히 이 문제를 겪었습니다. REQUIRES_NEW로 선언한 감사 로그 메서드를 같은 서비스 클래스 안에서 호출했는데, 예외 발생 시 감사 로그가 포인트 적립과 함께 롤백되는 현상이 반복됐습니다. 원인을 찾기까지 이틀이 걸렸고, 문제는 AOP 프록시 구조와 self-invocation이 맞물린 데 있었습니다.

이 글에서는 @Transactional이 현장에서 예상과 다르게 동작하는 6가지 핵심 상황을 구조적으로 파악합니다. 단순한 속성 나열이 아니라, 왜 그렇게 동작하는지를 AOP 프록시 메커니즘부터 데이터베이스 격리 수준까지 연결해 설명합니다.


1. @Transactional은 어떻게 동작하는가: AOP 프록시의 한계

Spring의 @Transactional은 AOP(Aspect-Oriented Programming) 프록시를 통해 구현됩니다. Spring 컨텍스트에서 빈을 주입받으면 실제 서비스 객체가 아니라, 그 객체를 감싼 프록시 객체를 받게 됩니다. 외부에서 @Transactional 메서드를 호출하면 프록시가 먼저 가로채고, 트랜잭션을 시작한 뒤 실제 메서드를 실행하고, 결과에 따라 커밋 또는 롤백을 수행합니다.

Spring은 기본적으로 JDK 다이나믹 프록시 또는 CGLIB 프록시 두 가지 방식을 사용합니다. 인터페이스가 있는 경우 JDK 프록시가 기본이었으나, Spring Boot 2.x 이후부터는 CGLIB이 기본입니다. 어느 방식이든 핵심 구조는 동일합니다. 프록시가 호출을 가로채야만 트랜잭션 어드바이스가 적용됩니다.

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final AuditService auditService;

    public OrderService(OrderRepository orderRepository, AuditService auditService) {
        this.orderRepository = orderRepository;
        this.auditService = auditService;
    }

    @Transactional
    public void placeOrder(OrderRequest request) {
        Order order = orderRepository.save(new Order(request));
        // auditService는 외부 주입된 프록시 → 트랜잭션 어드바이스 적용됨
        auditService.recordOrderEvent(order.getId(), "PLACED");
    }
}

위 코드에서 auditService.recordOrderEvent()는 외부 빈에 대한 호출이므로 프록시를 통해 처리됩니다. AuditService@Transactional(propagation = Propagation.REQUIRES_NEW)가 붙어 있다면 새로운 트랜잭션이 열립니다. 하지만 이 recordOrderEventOrderService 내부에서 this.recordOrderEvent()로 호출하면 프록시를 거치지 않습니다. 이것이 self-invocation 문제의 출발점입니다.

Spring 공식 트랜잭션 문서는 이 한계를 명확히 서술합니다. "In proxy mode (which is the default), only external method calls coming in through the proxy are intercepted."


2. 전파(Propagation) 속성 7가지 정리와 디폴트의 함정

Spring의 @Transactional에는 propagation 속성이 있습니다. 이 속성은 메서드가 호출될 때 기존 트랜잭션이 있는 경우 어떻게 처리할지를 정의합니다. 공식 API 문서에 정의된 7가지를 정리합니다.

전파 속성기존 트랜잭션 있을 때기존 트랜잭션 없을 때주요 용도
REQUIRED (기본값)기존 트랜잭션에 참여새 트랜잭션 생성일반 비즈니스 로직
REQUIRES_NEW기존 트랜잭션 일시 중단, 새 트랜잭션 생성새 트랜잭션 생성감사 로그, 독립 커밋 필요 작업
SUPPORTS기존 트랜잭션에 참여트랜잭션 없이 실행읽기 전용 보조 작업
NOT_SUPPORTED기존 트랜잭션 일시 중단, 트랜잭션 없이 실행트랜잭션 없이 실행트랜잭션이 오히려 방해되는 작업
MANDATORY기존 트랜잭션에 참여예외 발생반드시 트랜잭션 안에서만 사용해야 하는 작업
NEVER예외 발생트랜잭션 없이 실행트랜잭션이 절대 없어야 하는 작업
NESTED중첩 트랜잭션(savepoint) 생성새 트랜잭션 생성부분 롤백이 필요한 작업

디폴트인 REQUIRED는 대부분의 상황에 적합하지만, 함정이 있습니다. 호출자와 피호출자가 같은 트랜잭션에 묶인다는 뜻은, 피호출자 메서드에서 예외가 발생하면 전체 트랜잭션이 롤백 대상으로 표시된다는 의미입니다. 피호출자가 내부적으로 예외를 catch해서 처리했더라도, TransactionAspectSupport가 이미 트랜잭션에 rollback-only 플래그를 세웠다면 호출자가 커밋하려는 순간 UnexpectedRollbackException이 발생합니다.

NESTED는 JDBC savepoint를 사용해 부분 롤백을 지원하지만, 모든 데이터베이스 드라이버와 Spring 트랜잭션 매니저가 이를 지원하지는 않습니다. JPA와 함께 사용할 때는 특히 주의가 필요합니다.


3. REQUIRED vs REQUIRES_NEW: 같은 클래스에서 호출하면 깨지는 이유

REQUIRES_NEW가 제대로 작동하려면 두 가지 조건이 동시에 충족되어야 합니다. 첫째, 호출이 프록시를 통해 이루어져야 합니다. 둘째, 물리적으로 별도의 DB 커넥션을 사용해야 합니다.

아래 코드는 많은 개발자가 작성하는 패턴이지만, REQUIRES_NEW가 전혀 적용되지 않습니다.

@Service
public class PaymentService {

    @Transactional
    public void processPayment(Long orderId, BigDecimal amount) {
        // 결제 처리 로직
        deductBalance(orderId, amount);
        // self-invocation: this를 통한 호출, 프록시 우회
        this.saveAuditLog(orderId, "PAYMENT_PROCESSED");
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveAuditLog(Long orderId, String event) {
        // 이 메서드는 독립 트랜잭션으로 실행되어야 한다고 기대하지만
        // processPayment와 동일한 트랜잭션 안에서 실행된다
        auditRepository.save(new AuditLog(orderId, event));
    }
}

processPayment가 롤백되면 saveAuditLog의 결과도 함께 롤백됩니다. 감사 로그는 설령 결제가 실패해도 남아야 하는데, 의도와 정반대의 결과가 나옵니다.

올바른 해결책은 saveAuditLog를 별도 빈으로 분리해 외부 주입을 받는 것입니다.

@Service
public class PaymentService {

    private final AuditService auditService; // 별도 빈, 외부 주입

    public PaymentService(AuditService auditService) {
        this.auditService = auditService;
    }

    @Transactional
    public void processPayment(Long orderId, BigDecimal amount) {
        deductBalance(orderId, amount);
        // 프록시를 통한 호출 → REQUIRES_NEW 적용됨
        auditService.saveAuditLog(orderId, "PAYMENT_PROCESSED");
    }
}

@Service
public class AuditService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveAuditLog(Long orderId, String event) {
        auditRepository.save(new AuditLog(orderId, event));
        // processPayment가 롤백되어도 이 커밋은 독립적으로 유지된다
    }
}

이 구조에서 processPayment 트랜잭션이 롤백되더라도 saveAuditLog는 이미 독립된 커넥션으로 커밋됐기 때문에 남습니다. 단, REQUIRES_NEW는 두 개의 DB 커넥션을 동시에 열어둔다는 점에서 커넥션 풀에 주의해야 합니다.


4. self-invocation: this 호출이 트랜잭션을 무효화하는 메커니즘

self-invocation은 Spring AOP의 구조적 한계입니다. 앞서 설명한 프록시 구조에서 this를 통한 호출은 Spring이 관리하는 프록시 객체를 거치지 않고 실제 대상 객체의 메서드를 직접 호출합니다. 결과적으로 트랜잭션, 캐시(@Cacheable), 보안(@PreAuthorize) 등 모든 AOP 기반 어드바이스가 무력화됩니다.

self-invocation을 우회하는 방법에는 여러 가지가 있습니다.

방법 1: 별도 빈으로 분리 (권장)
앞에서 보인 것처럼 트랜잭션 경계를 넘어야 하는 로직을 다른 서비스 클래스로 옮기는 것이 가장 명확하고 유지보수하기 좋습니다.

방법 2: ApplicationContext 또는 자기 자신 주입
@Autowired로 자기 자신을 주입받으면 프록시를 통해 자기 메서드를 호출할 수 있습니다. 하지만 순환 참조가 발생할 수 있고, 코드의 의도가 불분명해집니다.

@Service
public class ReportService {

    @Autowired
    private ReportService self; // 자기 자신을 프록시로 주입

    @Transactional
    public void generateReport(Long reportId) {
        // self를 통해 호출하면 프록시를 거친다
        self.saveReportChunk(reportId, "chunk_1");
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveReportChunk(Long reportId, String chunk) {
        chunkRepository.save(new ReportChunk(reportId, chunk));
    }
}

방법 3: AspectJ 위빙(Weaving)
컴파일 타임 또는 로드 타임 위빙을 사용하면 프록시 방식의 한계를 넘어 this 호출도 어드바이스가 적용됩니다. 그러나 빌드 설정이 복잡해지고 Spring 생태계의 표준 경로에서 벗어나므로, 이 방식을 선택하는 팀은 소수입니다.

self-invocation 문제를 예방하는 가장 실효성 있는 방법은 코드 리뷰 단계에서 체크리스트를 두는 것입니다.


5. 격리 수준과 팬텀 리드: PostgreSQL/MySQL 기본값 차이

트랜잭션 격리 수준(Isolation Level)은 동시에 실행되는 트랜잭션이 서로의 중간 상태를 얼마나 볼 수 있는지를 정의합니다. SQL 표준은 네 가지 격리 수준을 정의합니다.

  • READ UNCOMMITTED: Dirty Read, Non-Repeatable Read, Phantom Read 모두 가능
  • READ COMMITTED: Dirty Read 방지. Non-Repeatable Read, Phantom Read는 가능
  • REPEATABLE READ: Dirty Read, Non-Repeatable Read 방지. Phantom Read는 가능(표준상)
  • SERIALIZABLE: 모든 이상 현상 방지. 가장 강력하지만 성능 비용 큼

주의해야 할 차이가 있습니다. MySQL InnoDB는 REPEATABLE READ가 기본값이며, 갭 락(Gap Lock)을 사용해 팬텀 리드를 사실상 방지합니다. 반면 PostgreSQL은 READ COMMITTED가 기본값이고(공식 문서), REPEATABLE READ 이상에서만 팬텀 리드가 방지됩니다.

@Transactional(isolation = Isolation.REPEATABLE_READ)
public void decreaseStock(Long productId, int quantity) {
    Product product = productRepository.findByIdWithLock(productId)
        .orElseThrow(() -> new EntityNotFoundException("Product not found: " + productId));

    if (product.getStock() < quantity) {
        throw new InsufficientStockException("재고 부족: 현재 재고 " + product.getStock());
    }

    product.decreaseStock(quantity);
    productRepository.save(product);
}

SERIALIZABLE 수준은 PostgreSQL에서 SSI(Serializable Snapshot Isolation)를 사용하기 때문에 쓰기 충돌 감지 비용이 있습니다. 동시 쓰기가 많은 핫 테이블에 SERIALIZABLE을 무분별하게 적용하면 재시도 증가와 처리량 저하가 발생할 수 있습니다.


6. LazyInitializationException의 근본 원인과 해결 패턴

JPA를 사용하는 Spring 애플리케이션에서 가장 흔히 마주치는 예외 중 하나가 LazyInitializationException입니다.

원인은 명확합니다. JPA의 지연 로딩(Lazy Loading)은 활성화된 영속성 컨텍스트(Persistence Context), 즉 열린 트랜잭션 안에서만 동작합니다. @Transactional 메서드가 반환되면 트랜잭션이 종료되고 영속성 컨텍스트가 닫힙니다. 이 시점 이후에 연관 엔티티에 접근하면 프록시 객체가 실제 데이터를 로드하려 하지만 Session이 없어 예외가 발생합니다.

해결 방법은 크게 세 가지입니다.

방법 1: Fetch Join 또는 EntityGraph 사용 (권장)

@Query("SELECT o FROM Order o JOIN FETCH o.orderItems WHERE o.id = :id")
Optional<Order> findByIdWithItems(@Param("id") Long id);

@EntityGraph(attributePaths = {"orderItems", "orderItems.product"})
Optional<Order> findById(Long id);

방법 2: DTO 프로젝션
엔티티 대신 필요한 필드만 담은 DTO를 반환하면 지연 로딩 문제 자체를 우회합니다.

방법 3: spring.jpa.open-in-view=true (비권장)
Spring Boot의 기본 설정인 OSIV(Open Session In View)는 HTTP 요청 전체를 트랜잭션 범위로 확장합니다. 뷰 레이어에서 지연 로딩이 가능해지지만, DB 커넥션이 요청 처리 내내 점유되어 높은 동시성 환경에서 커넥션 풀 고갈을 초래할 수 있습니다.


7. readOnly=true가 성능에 미치는 영향

@Transactional(readOnly = true)는 단순한 의미론적 힌트가 아닙니다.

첫째, JPA의 변경 감지(Dirty Checking)가 비활성화됩니다. Hibernate는 영속성 컨텍스트에 로드된 엔티티의 스냅샷을 유지하다가, 트랜잭션 커밋 시점에 변경된 필드를 감지해 UPDATE 쿼리를 생성합니다. readOnly = true이면 이 스냅샷 자체를 생성하지 않으므로 메모리 사용량이 줄고 flush 단계가 생략됩니다.

둘째, 데이터베이스 드라이버 또는 커넥션 풀 수준에서 읽기 전용 힌트가 전달될 수 있습니다. Amazon Aurora, MariaDB 등의 Read Replica 라우팅을 지원하는 커넥션 풀과 함께 쓰면 읽기 요청을 자동으로 Read Replica로 분산시킬 수 있습니다.

셋째, readOnly = true인 트랜잭션에서 엔티티를 수정하고 flush가 일어나면 어떻게 되는지는 JPA 구현체와 설정에 따라 다릅니다. Hibernate 기본 설정에서는 FlushMode를 NEVER로 세팅해 실제 쓰기 쿼리가 발생하지 않습니다.


8. 분산 트랜잭션 없이 결과적 일관성 유지하기

마이크로서비스 환경이나 멀티 데이터소스 환경에서는 단일 트랜잭션으로 여러 저장소의 일관성을 보장하기 어렵습니다. 2PC(Two-Phase Commit)는 XA 트랜잭션을 통해 분산 트랜잭션을 지원하지만, 모든 참여자가 XA를 지원해야 하고 코디네이터 장애 시 인-더블트(in-doubt) 상태가 발생할 수 있으며, 처리량이 크게 저하됩니다.

실용적인 대안은 결과적 일관성(Eventual Consistency)과 아웃박스 패턴(Transactional Outbox Pattern)입니다. Martin Fowler의 Unit of Work 패턴을 바탕으로 이해하면, 하나의 작업 단위 내에서 변경 사항을 추적하고 커밋 시점에 한 번에 반영하는 원칙을 따릅니다.

@Service
public class OrderPlacementService {

    private final OrderRepository orderRepository;
    private final OutboxEventRepository outboxEventRepository;

    @Transactional
    public void placeOrder(OrderRequest request) {
        // 도메인 객체 저장
        Order order = orderRepository.save(Order.from(request));

        // 같은 트랜잭션 안에서 아웃박스 이벤트 기록
        OutboxEvent event = OutboxEvent.builder()
            .aggregateType("Order")
            .aggregateId(order.getId().toString())
            .eventType("OrderPlaced")
            .payload(toJson(order))
            .createdAt(Instant.now())
            .build();
        outboxEventRepository.save(event);

        // 두 저장이 같은 트랜잭션 → 원자적으로 커밋되거나 롤백된다
    }
}

자세한 내용은 Transactional Outbox와 멱등성 패턴 에서 다룹니다.


9. 테스트 코드에서 @Transactional 남용이 만드는 거짓 안전감

통합 테스트에서 @Transactional을 사용하면 각 테스트가 끝난 후 자동으로 롤백됩니다. DB를 원상복구하는 편리한 방법이지만, 이것이 심각한 맹점을 만들 수 있습니다.

가장 큰 문제는 테스트가 운영 코드의 실제 트랜잭션 동작을 검증하지 못한다는 점입니다. self-invocation 문제를 예로 들겠습니다. PaymentService.processPayment()를 테스트 클래스에서 호출하면, 테스트 자체가 이미 트랜잭션 안에 있으므로 processPayment 내의 REQUIRES_NEW 분리가 테스트 환경에서는 의도한 대로 동작하지 않을 수 있습니다.

또한 @Transactional 테스트는 LazyInitializationException을 숨깁니다. 테스트 트랜잭션이 메서드 호출 이후에도 열려 있기 때문에, 운영 환경에서는 트랜잭션 종료 후 지연 로딩 시 발생할 예외가 테스트에서는 정상 동작으로 보입니다.

권장 접근법은 세 가지입니다.

  • 통합 테스트 격리는 @Sql 또는 @BeforeEach/@AfterEach Truncate로 처리: @Transactional 롤백에 의존하지 않고, 명시적으로 데이터를 제거합니다.
  • 트랜잭션 경계를 검증하는 테스트는 @Transactional 없이 작성: 실제 커밋이 일어나야만 확인 가능한 동작은 @Transactional 없는 테스트로 검증합니다.
  • Testcontainers 사용: 실제 DB 엔진 위에서 통합 테스트를 돌려야 격리 수준, 락 동작, 엔진 특성이 정확히 반영됩니다.

10. 운영 체크리스트

Spring 트랜잭션을 운영 환경에서 안전하게 사용하기 위한 실무 점검 항목입니다.

  • 프록시 우회 여부 확인: 같은 클래스 안에서 this를 통해 @Transactional 메서드를 호출하는 코드가 없는지 PR 리뷰 시 체크합니다.
  • REQUIRES_NEW 커넥션 풀 용량 검토: REQUIRES_NEW를 사용하는 메서드가 얼마나 동시에 호출되는지 파악하고, 최대 동시 커넥션 수가 커넥션 풀 한계를 초과하지 않는지 확인합니다.
  • 격리 수준과 DB 엔진 기본값 일치 여부 확인: 코드에서 격리 수준을 명시하지 않은 경우, 연결된 DB 엔진의 기본 격리 수준이 무엇인지 파악합니다. 관련 쿼리 튜닝 내용은 PostgreSQL EXPLAIN ANALYZE 실전 쿼리 튜닝에서 확인하세요.
  • OSIV 비활성화 검토: spring.jpa.open-in-view=false 설정 후 LazyInitializationException이 발생하지 않는지 확인합니다.
  • 통합 테스트에서 @Transactional 롤백 의존도 점검: 트랜잭션 경계가 핵심인 기능은 @Transactional 없는 통합 테스트로 검증합니다.

결론

@Transactional은 Spring 데이터 계층의 핵심 추상화지만, 추상화 뒤에 감춰진 AOP 프록시 구조와 DB 엔진 특성을 이해하지 않으면 조용한 버그의 원천이 됩니다. self-invocation 하나로 감사 로그 전체가 롤백되고, 격리 수준 기본값의 차이 하나로 재고가 음수가 됩니다.

좋은 트랜잭션 관리는 어노테이션을 많이 붙이는 것이 아닙니다. 어디서 트랜잭션이 시작되고 끝나는지, 각 메서드 호출이 프록시를 통해 이루어지는지, 사용하는 DB의 기본 격리 수준이 비즈니스 정합성 요구를 만족하는지를 명확히 이해하고 설계하는 것입니다.