
Spring 프레임워크에서 @Transactional은 개발자에게 “트랜잭션을 선언하면 끝”이라는 강력한 편의성을 제공합니다. 예외가 발생하면 자동으로 롤백되고, 데이터베이스 작업은 원자적으로 처리됩니다.
하지만 시스템이 커지고, 비동기 처리(@Async)나 외부 API 연동이 늘어나는 순간 이 편리함은 종종 걸림돌으로 변하게 될 수 있습니다. 예를 들어, "롤백이 됐다고 믿었는데” 실제로는 이미 커밋된 변경이 남아 데이터 정합성을 깨뜨리는 사고로 이어지기도 합니다.
이 글에서는 다음의 내용을 정리합니다.
@Transactional의 범위를 어떻게 설계해야 커넥션과 성능이 무너지지 않는지핵심은 Spring 트랜잭션 컨텍스트가 ThreadLocal 기반으로 관리된다는 점입니다.
Spring은 트랜잭션 상태(커넥션, 동기화 정보 등)를 ThreadLocal에 저장합니다. ThreadLocal은 특정 스레드 내부에서만 접근 가능한 저장소이기 때문에, 스레드가 바뀌면 동일한 트랜잭션 컨텍스트를 공유할 수 없습니다.
여기서 @Async가 등장합니다.
@Async는 보통 새로운 스레드를 직접 만드는 것이 아니라 TaskExecutor(스레드 풀)에 위임해 다른 스레드에서 실행합니다.ThreadLocal에 들어 있던 트랜잭션 컨텍스트는 전파되지 않습니다.즉 흐름은 이렇게 됩니다.
@Async 메서드는 다른 스레드에서 실행되며 메인 트랜잭션을 모릅니다시퀀스 다이어그램으로 확인해보면 아래와 같습니다.

여기서 기억할 문장 하나면 충분합니다.
비동기에서 예외가 나도 메인 트랜잭션을 “자동으로” 롤백시키지 못한다
비동기 메서드에 @Transactional을 따로 걸어도, 그건 새로운 별도 트랜잭션일 뿐 메인 트랜잭션과 동일해지지 않습니다.
@Transactional을 무심코 넓히면 생기는 또 다른 문제비동기의 문제를 이해했다면, 이제 @Transactional을 “너무 쉽게” 붙였을 때 생기는 실전 문제를 봐야 합니다.
@Transactional이 메서드 전체를 감싸면, 해당 메서드가 끝날 때까지 DB 커넥션이 점유됩니다. 여기서 메서드 중간에 외부 API 호출, 파일 업로드, 긴 연산이 들어가면 어떤 일이 생길까요.
비동기 이슈가 “정합성”의 문제라면, 트랜잭션 범위 남용은 “성능과 안정성”의 문제를 일으킵니다.
모든 로직을 트랜잭션으로 감싸는 대신, 실제로 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 저장은 성공했는데, 그 직후 서버가 죽어서 외부 작업(알림/이벤트 발행)을 못 하면 어떡하지
이 질문에 답하는 것이 다음 전략입니다.
비동기와 외부 연동이 있는 순간, 데이터 정합성은 “자동 롤백”이 아니라 “설계”의 문제입니다. 대표적인 해결책은 두 가지입니다.
각각이 어떤 문제를 풀어주는지, 언제 쓰면 좋은지 이어서 보겠습니다.
비동기 작업이 실패했는데 메인 트랜잭션이 이미 커밋됐다면, 시스템이 자동으로 되돌려주지 않습니다. 그러면 우리가 반대 방향의 상태 전이를 설계해야 합니다.
예를 들어
COMPLETED로 변경한 뒤CANCELLED로 되돌리는 로직이 보상 트랜잭션이 될 수 있습니다보상 트랜잭션은 반드시 멱등성(idempotency) 을 가져야 합니다.
CANCELLED라면 아무 동작을 하지 않고 유지외부 시스템은 “부분 성공”이 가능하다는 점도 반드시 고려해야 합니다.
그래서 보상 로직은 단순히 “실패 시 되돌리기”가 아니라 부분 성공 가능성까지 감안한 정합성 규칙이 필요합니다.
보상 로직은 보통 “실패한 흐름”과 분리되어야 합니다. 그래야 보상 작업 자체가 메인 흐름에 휘말려 같이 롤백되는 상황을 피할 수 있습니다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
REQUIRES_NEW는 기존 트랜잭션 유무와 관계없이 항상 새로운 트랜잭션을 시작합니다다만 REQUIRES_NEW를 사용한다고 바로 해결이 되는 것은 아닙니다.
트랜잭션 범위를 줄였을 때 가장 무서운 시나리오는 이것입니다.
이 문제를 푸는 대표 패턴이 Transactional Outbox입니다.
Outbox는 “외부 작업을 트랜잭션에 포함시키지 않으면서도 유실을 막는 방법”입니다.
보상 트랜잭션이 “되돌리는 설계”라면, Outbox는 “확실히 실행되게 만드는 설계”에 가깝습니다.

비동기, 워커, Outbox를 도입하는 순간 우리는 재시도를 전제로 설계해야 합니다.
동일한 요청이 두 번 전달되어도 결과가 한 번만 반영되도록 해야 합니다.
“다 적용한 것 같은데 REQUIRES_NEW가 먹지 않는다”는 경우, 의외로 원인은 단순합니다. 대부분 프록시를 우회한 내부 호출입니다.
Spring의 @Transactional은 보통 AOP 프록시 기반으로 동작합니다.
this.someMethod()로 호출하면 프록시를 거치지 않아 트랜잭션이 적용되지 않습니다그래서 보상 로직은 별도 서비스로 분리하는 것이 가장 안전합니다.
아래 예시는 “비동기 작업(외부 쿠폰 발급)이 실패하면 주문 상태를 취소로 되돌린다”는 단순화된 시나리오입니다.
// 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);
// 실무: 실패 기록, 재시도 큐 적재, 알림 등을 함께 고려
}
}
}
반환 타입에 따라 예외를 다루는 방식이 달라집니다.
void 반환이면 AsyncUncaughtExceptionHandler로 예외를 수집할 수 있습니다Future/CompletableFuture면 호출 측에서 예외를 관찰하고 후속 처리를 설계할 수 있습니다여기서는 “실패를 즉시 감지하고 보상을 실행하는 패턴”에 초점을 맞췄습니다.
비동기 + 트랜잭션 조합에서 정합성을 높이려면 아래를 점검하세요.
실패를 어디에서 관찰할 것인가
보상 로직은 멱등한가
보상 로직은 실패 격리되어 있는가
REQUIRES_NEW 또는 별도 처리 흐름보상 실패 시 복구 전략이 있는가
외부 시스템의 부분 성공 가능성을 고려했는가
트랜잭션 범위가 불필요하게 넓지 않은가
유실을 막아야 하는가
Spring의 @Transactional은 강력한 도구이지만, 분산 환경과 비동기 처리가 포함된 구조에서는 한계가 명확합니다.
@Transactional로 생산성을 높이기TransactionTemplate로 범위를 좁히기비동기 환경에서 데이터 정합성을 유지하는 핵심은 결국 “자동 롤백”이 아니라 “정확한 이해 위에 세운 설계”입니다. ThreadLocal, 프록시, 커넥션 풀 같은 Spring의 실제 동작을 이해하고, 그 위에 보상/Outbox/회복탄력성을 얹을 때 시스템은 견고해집니다.