@Async에서 예외 나도 @Transactional은 롤백되지 않는다

Dev Smile·2026년 2월 15일
post-thumbnail

Spring 프레임워크에서 @Transactional은 개발자에게 “트랜잭션을 선언하면 끝”이라는 강력한 편의성을 제공합니다. 예외가 발생하면 자동으로 롤백되고, 데이터베이스 작업은 원자적으로 처리됩니다.

하지만 시스템이 커지고, 비동기 처리(@Async)나 외부 API 연동이 늘어나는 순간 이 편리함은 종종 걸림돌으로 변하게 될 수 있습니다. 예를 들어, "롤백이 됐다고 믿었는데” 실제로는 이미 커밋된 변경이 남아 데이터 정합성을 깨뜨리는 사고로 이어지기도 합니다.

이 글에서는 다음의 내용을 정리합니다.

  • 비동기 환경에서 왜 메인 트랜잭션이 자동 롤백되지 않는지
  • @Transactional의 범위를 어떻게 설계해야 커넥션과 성능이 무너지지 않는지
  • 자동 롤백이 불가능한 영역을 공학적으로 푸는 방법(보상 트랜잭션, Outbox Pattern)
  • 구현할 때 자주 밟는 함정(프록시, 내부 호출, 예외 관찰)

1. 왜 비동기에서 발생한 예외는 메인 트랜잭션을 롤백시키지 못할까

핵심은 Spring 트랜잭션 컨텍스트가 ThreadLocal 기반으로 관리된다는 점입니다.

1.1 ThreadLocal은 “스레드 전용 사물함”

Spring은 트랜잭션 상태(커넥션, 동기화 정보 등)를 ThreadLocal에 저장합니다. ThreadLocal은 특정 스레드 내부에서만 접근 가능한 저장소이기 때문에, 스레드가 바뀌면 동일한 트랜잭션 컨텍스트를 공유할 수 없습니다.

여기서 @Async가 등장합니다.

  • @Async는 보통 새로운 스레드를 직접 만드는 것이 아니라 TaskExecutor(스레드 풀)에 위임해 다른 스레드에서 실행합니다.
  • 결과적으로 실행 스레드가 달라지고, 메인 스레드의 ThreadLocal에 들어 있던 트랜잭션 컨텍스트는 전파되지 않습니다.

즉 흐름은 이렇게 됩니다.

  1. 메인 스레드에서 트랜잭션을 시작하고 DB 작업을 수행합니다
  2. @Async 메서드는 다른 스레드에서 실행되며 메인 트랜잭션을 모릅니다
  3. 메인 스레드는 로직이 끝나면 커밋할 수 있고, 그 이후 비동기 쪽에서 예외가 나도 이미 커밋된 메인 트랜잭션을 자동으로 되돌릴 방법이 없습니다

시퀀스 다이어그램으로 확인해보면 아래와 같습니다.

여기서 기억할 문장 하나면 충분합니다.

비동기에서 예외가 나도 메인 트랜잭션을 “자동으로” 롤백시키지 못한다

비동기 메서드에 @Transactional을 따로 걸어도, 그건 새로운 별도 트랜잭션일 뿐 메인 트랜잭션과 동일해지지 않습니다.


2. @Transactional을 무심코 넓히면 생기는 또 다른 문제

비동기의 문제를 이해했다면, 이제 @Transactional을 “너무 쉽게” 붙였을 때 생기는 실전 문제를 봐야 합니다.

2.1 DB 커넥션 점유 시간이 길어진다

@Transactional이 메서드 전체를 감싸면, 해당 메서드가 끝날 때까지 DB 커넥션이 점유됩니다. 여기서 메서드 중간에 외부 API 호출, 파일 업로드, 긴 연산이 들어가면 어떤 일이 생길까요.

  • 애플리케이션은 DB 작업을 하지 않으면서도 커넥션을 붙잡고 있게 됩니다
  • 트래픽이 몰리면 커넥션 풀이 고갈되고, 시스템 전반이 지연 또는 장애로 이어집니다

비동기 이슈가 “정합성”의 문제라면, 트랜잭션 범위 남용은 “성능과 안정성”의 문제를 일으킵니다.


3. 해결 전략 1: 트랜잭션 범위를 정밀하게 줄이기 (TransactionTemplate)

모든 로직을 트랜잭션으로 감싸는 대신, 실제로 DB 쓰기가 필요한 구간만 트랜잭션을 적용하는 것이 출발점입니다. 이때 유용한 도구가 TransactionTemplate입니다.

public void placeOrder(OrderRequest request) {
    // 1) 트랜잭션 밖: 무거운 검증/외부 조회 (커넥션 미점유)
    validateOrder(request);

    // 2) 트랜잭션 안: 실제 DB 저장만 정밀하게 감싸기
    transactionTemplate.execute(status -> {
        return orderRepository.save(new Order(request));
    });

    // 3) 트랜잭션 밖: 사후 처리(알림 등)
    notificationService.send(request);
}

이 방식의 장점은 명확합니다.

  • DB 커넥션을 붙잡는 시간이 줄어든다
  • 예외 발생 시 영향 범위가 좁아진다
  • 트랜잭션이 필요한 곳과 아닌 곳이 코드 레벨에서 분리된다

하지만 트랜잭션 범위를 줄이면, 다음 질문이 따라옵니다.

DB 저장은 성공했는데, 그 직후 서버가 죽어서 외부 작업(알림/이벤트 발행)을 못 하면 어떡하지

이 질문에 답하는 것이 다음 전략입니다.


4. 해결 전략 2: 자동 롤백이 불가능한 영역을 설계로 메운다

비동기와 외부 연동이 있는 순간, 데이터 정합성은 “자동 롤백”이 아니라 “설계”의 문제입니다. 대표적인 해결책은 두 가지입니다.

  • 보상 트랜잭션(Compensating Transaction)
  • Transactional Outbox Pattern

각각이 어떤 문제를 풀어주는지, 언제 쓰면 좋은지 이어서 보겠습니다.


5. 보상 트랜잭션: 이미 커밋된 것을 논리적으로 되돌리기

비동기 작업이 실패했는데 메인 트랜잭션이 이미 커밋됐다면, 시스템이 자동으로 되돌려주지 않습니다. 그러면 우리가 반대 방향의 상태 전이를 설계해야 합니다.

예를 들어

  • 주문을 COMPLETED로 변경한 뒤
  • 외부 쿠폰 발급이 실패했다면
  • 주문을 CANCELLED로 되돌리는 로직이 보상 트랜잭션이 될 수 있습니다

5.1 보상 로직은 멱등해야 한다

보상 트랜잭션은 반드시 멱등성(idempotency) 을 가져야 합니다.

  • 이미 CANCELLED라면 아무 동작을 하지 않고 유지
  • 중복 호출되어도 결과가 안정적으로 유지

외부 시스템은 “부분 성공”이 가능하다는 점도 반드시 고려해야 합니다.

  • 외부 API는 성공했는데 응답을 받기 전에 타임아웃이 발생
  • 쿠폰은 발급됐지만 로컬 저장이 실패

그래서 보상 로직은 단순히 “실패 시 되돌리기”가 아니라 부분 성공 가능성까지 감안한 정합성 규칙이 필요합니다.


6. 보상 트랜잭션의 실패 격리: Propagation.REQUIRES_NEW

보상 로직은 보통 “실패한 흐름”과 분리되어야 합니다. 그래야 보상 작업 자체가 메인 흐름에 휘말려 같이 롤백되는 상황을 피할 수 있습니다.

@Transactional(propagation = Propagation.REQUIRES_NEW)
  • REQUIRES_NEW는 기존 트랜잭션 유무와 관계없이 항상 새로운 트랜잭션을 시작합니다
  • 보상 로직을 메인 흐름과 실패 격리(failure isolation) 시킵니다

다만 REQUIRES_NEW를 사용한다고 바로 해결이 되는 것은 아닙니다.

  • 보상 로직 자체도 실패할 수 있습니다
  • 실패 시 재시도 정책, 실패 기록(로그/DB/큐), 운영 관제가 함께 필요합니다

7. Transactional Outbox Pattern: “DB 저장”과 “외부 작업”의 간극 메우기

트랜잭션 범위를 줄였을 때 가장 무서운 시나리오는 이것입니다.

  • DB는 커밋됐는데
  • 이벤트 발행/외부 API 호출을 하기 전에 서버가 죽었다

이 문제를 푸는 대표 패턴이 Transactional Outbox입니다.

7.1 작동 원리

  1. 메인 비즈니스 변경(주문 저장 등)과 함께 “해야 할 작업(이벤트)”을 같은 트랜잭션으로 Outbox 테이블에 기록
  2. 트랜잭션 커밋 이후, 별도 프로세스(스케줄러/CDC/워커)가 Outbox를 읽어 실제 외부 작업을 수행
  3. 실패하더라도 Outbox 기록이 남아 재시도 가능

Outbox는 “외부 작업을 트랜잭션에 포함시키지 않으면서도 유실을 막는 방법”입니다.

보상 트랜잭션이 “되돌리는 설계”라면, Outbox는 “확실히 실행되게 만드는 설계”에 가깝습니다.


8. 실패를 우아하게 다루는 법: 회복 탄력성(Resilience)

비동기, 워커, Outbox를 도입하는 순간 우리는 재시도를 전제로 설계해야 합니다.

8.1 멱등성(Idempotency)

동일한 요청이 두 번 전달되어도 결과가 한 번만 반영되도록 해야 합니다.

  • 예: 주문 번호를 유니크 키로 사용해 중복 결제 방지
  • 예: 이벤트 ID를 기준으로 중복 처리 방지

8.2 지수 백오프와 DLQ

  • 지수 백오프(Exponential Backoff): 실패 시 1초, 2초, 4초… 간격을 늘리며 재시도해 대상 시스템 부하를 조절
  • DLQ(Dead Letter Queue): 일정 횟수 이상 실패한 작업은 별도로 보관하고 알림을 통해 수동 처리 가능하게 함

9. 마지막 함정: 프록시와 내부 호출(Self-Invocation)

“다 적용한 것 같은데 REQUIRES_NEW가 먹지 않는다”는 경우, 의외로 원인은 단순합니다. 대부분 프록시를 우회한 내부 호출입니다.

Spring의 @Transactional은 보통 AOP 프록시 기반으로 동작합니다.

  • 외부에서 빈을 호출할 때 프록시가 가로채며 트랜잭션을 시작합니다
  • 같은 클래스 내부에서 this.someMethod()로 호출하면 프록시를 거치지 않아 트랜잭션이 적용되지 않습니다

그래서 보상 로직은 별도 서비스로 분리하는 것이 가장 안전합니다.


10. 예시 코드: 비동기 실패 시 보상 트랜잭션 실행

아래 예시는 “비동기 작업(외부 쿠폰 발급)이 실패하면 주문 상태를 취소로 되돌린다”는 단순화된 시나리오입니다.

// 1) 보상 로직을 별도 서비스로 분리
@Service
public class OrderCompensator {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void cancelOrder(Long orderId) {
        // 멱등성 고려: 이미 CANCELLED면 no-op
        // 주문 상태를 'CANCELLED'로 변경
    }
}

// 2) 메인 서비스에서 비동기 작업 수행
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderCompensator compensator;
    private final CouponService couponService;

    @Async
    public void issueCouponAsync(Long orderId) {
        try {
            couponService.issue(orderId);
        } catch (Exception e) {
            // 비동기 실패 감지 -> 프록시를 거쳐 보상 트랜잭션 실행
            compensator.cancelOrder(orderId);

            // 실무: 실패 기록, 재시도 큐 적재, 알림 등을 함께 고려
        }
    }
}

10.1 예외 관찰 포인트

반환 타입에 따라 예외를 다루는 방식이 달라집니다.

  • void 반환이면 AsyncUncaughtExceptionHandler로 예외를 수집할 수 있습니다
  • Future/CompletableFuture면 호출 측에서 예외를 관찰하고 후속 처리를 설계할 수 있습니다

여기서는 “실패를 즉시 감지하고 보상을 실행하는 패턴”에 초점을 맞췄습니다.


11. 실전 체크리스트

비동기 + 트랜잭션 조합에서 정합성을 높이려면 아래를 점검하세요.

  • 실패를 어디에서 관찰할 것인가

    • try-catch, handler, future 등
  • 보상 로직은 멱등한가

    • 중복 호출 방어, 상태 전이 규칙 명확화
  • 보상 로직은 실패 격리되어 있는가

    • REQUIRES_NEW 또는 별도 처리 흐름
  • 보상 실패 시 복구 전략이 있는가

    • 재시도 정책, 실패 기록, 운영 관제
  • 외부 시스템의 부분 성공 가능성을 고려했는가

    • 타임아웃, 네트워크 오류, 중복 요청
  • 트랜잭션 범위가 불필요하게 넓지 않은가

    • 외부 호출, 긴 연산이 트랜잭션 안에 있는지 점검
  • 유실을 막아야 하는가

    • 필요하다면 Outbox Pattern 도입 검토

결론

Spring의 @Transactional은 강력한 도구이지만, 분산 환경과 비동기 처리가 포함된 구조에서는 한계가 명확합니다.

  • 단순한 내부 DB 로직은 @Transactional로 생산성을 높이기
  • 외부 연동과 성능 민감 구간은 TransactionTemplate로 범위를 좁히기
  • 분산 환경에서 유실이 치명적이면 Outbox Pattern으로 실행을 보장하기
  • 이미 커밋된 변경을 되돌려야 하면 보상 트랜잭션을 설계하기

비동기 환경에서 데이터 정합성을 유지하는 핵심은 결국 “자동 롤백”이 아니라 “정확한 이해 위에 세운 설계”입니다. ThreadLocal, 프록시, 커넥션 풀 같은 Spring의 실제 동작을 이해하고, 그 위에 보상/Outbox/회복탄력성을 얹을 때 시스템은 견고해집니다.

0개의 댓글