레이블이 트랜잭션인 게시물을 표시합니다. 모든 게시물 표시
레이블이 트랜잭션인 게시물을 표시합니다. 모든 게시물 표시

금요일

Spring @Transactional self-invocation 완벽 해결 — 내부 메서드 호출 시 트랜잭션이 무시되는 이유

🔍 검색 키워드: Spring Transactional self-invocation, 스프링 자기 호출 트랜잭션 문제, @Transactional 내부 메서드 호출 무시, Spring AOP self-call, 스프링 트랜잭션 프록시 우회

증상: @Transactional인데 트랜잭션이 적용 안 된다

같은 클래스 내부에서 다른 메서드를 호출할 때 @Transactional이 무시되고, DB 에러가 발생해도 롤백이 되지 않는 현상이 발생한다.

@Service
public class OrderService {

    public void processOrder(Long orderId) {
        saveOrder(orderId);   // 트랜잭션이 적용되지 않음!
    }

    @Transactional
    public void saveOrder(Long orderId) {
        orderRepository.save(new Order(orderId));
        throw new RuntimeException("에러 발생");  // 롤백 안 됨!
    }
}

원인: Spring AOP 프록시 메커니즘

Spring의 @Transactional은 AOP 프록시 기반으로 동작한다. 외부에서 빈을 호출하면 프록시 객체를 거쳐 트랜잭션 처리가 이루어지지만, 같은 클래스 내에서 this.saveOrder()로 직접 호출하면 프록시를 우회하여 실제 객체를 직접 호출하게 된다. 결과적으로 트랜잭션이 시작되지 않는다.

// 외부 호출: 프록시 → 실제 메서드 (트랜잭션 O)
orderService.saveOrder(id);

// 내부 self-invocation: 실제 객체.saveOrder() (트랜잭션 X)
this.saveOrder(id);

해결 방법 1 (권장): 클래스 분리

가장 깔끔한 해결책이다. 트랜잭션이 필요한 메서드를 별도 클래스로 분리하면 외부 호출이 되어 프록시가 정상 동작한다.

@Service
public class OrderService {
    private final OrderSaveService orderSaveService;

    public OrderService(OrderSaveService orderSaveService) {
        this.orderSaveService = orderSaveService;
    }

    public void processOrder(Long orderId) {
        orderSaveService.saveOrder(orderId);  // 프록시 통과 → 트랜잭션 O
    }
}

@Service
public class OrderSaveService {
    @Transactional
    public void saveOrder(Long orderId) {
        orderRepository.save(new Order(orderId));
    }
}

해결 방법 2: ApplicationContext에서 자기 자신 빈 주입

@Service
public class OrderService {
    @Autowired
    private ApplicationContext applicationContext;

    public void processOrder(Long orderId) {
        OrderService self = applicationContext.getBean(OrderService.class);
        self.saveOrder(orderId);  // 프록시 통과
    }

    @Transactional
    public void saveOrder(Long orderId) {
        orderRepository.save(new Order(orderId));
    }
}

해결 방법 3: @Lazy Self-injection

@Service
public class OrderService {
    @Autowired
    @Lazy
    private OrderService self;

    public void processOrder(Long orderId) {
        self.saveOrder(orderId);  // 프록시 통과
    }

    @Transactional
    public void saveOrder(Long orderId) {
        orderRepository.save(new Order(orderId));
    }
}

Spring Boot 2.6 이상에서는 순환 참조가 기본으로 금지되므로 spring.main.allow-circular-references=true 설정이 필요하다. 권장하지 않는다.

해결 방법 비교표

방법권장도비고
클래스 분리★★★★★가장 깔끔, 단일 책임 원칙 준수
ApplicationContext 주입★★★코드는 다소 복잡해짐
@Lazy Self-injection★★순환 참조 이슈, 비권장
AspectJ 컴파일 타임 위빙★★★빌드 설정 복잡

Spring @Transactional self-invocation 문제는 AOP 프록시 원리를 이해하면 자연스럽게 해결 방향이 보인다. 가장 좋은 방법은 처음부터 단일 책임 원칙을 지켜 클래스를 적절히 분리하는 것이다. 이 문제는 Spring을 사용하는 개발자라면 반드시 숙지해야 할 AOP의 핵심 동작 방식이다.

목요일

Spring Boot UnexpectedRollbackException 완전 해결 가이드

🔍 검색 키워드: Spring Boot UnexpectedRollbackException, 트랜잭션 롤백 에러, Spring @Transactional 에러, rollback-only 에러, Spring 트랜잭션 전파

이 에러, 왜 뜨는 건가

운영 중에 갑자기 이런 에러를 마주하는 경우가 있다.

org.springframework.transaction.UnexpectedRollbackException:
Transaction silently rolled back because it has been marked as rollback-only

겉으로 보면 "조용히 롤백됐다"는 건데, 왜 롤백됐는지 이유가 안 보인다. 내 코드엔 예외처리도 했고, try-catch도 했는데 왜?

원인: Spring 트랜잭션 전파(Propagation) 구조 이해

핵심 개념

Spring의 기본 트랜잭션 전파 방식은 REQUIRED다. 즉, 이미 트랜잭션이 있으면 그 트랜잭션에 참여(join)한다.

문제는 여기서 발생한다.

[외부 트랜잭션 시작]
  └── 내부 서비스 호출 (REQUIRED → 외부 트랜잭션에 참여)
        └── 내부에서 예외 발생 → 트랜잭션에 rollback-only 마킹
  내부 예외를 try-catch로 잡음
  외부 트랜잭션 커밋 시도
      → BOOM: UnexpectedRollbackException

내부 메서드에서 예외가 발생해서 rollback-only로 마킹됐는데, 외부에서 예외를 잡아버리면 Spring은 "아 괜찮은 거구나"하고 커밋을 시도한다. 하지만 이미 롤백 마킹이 됐기 때문에 UnexpectedRollbackException이 터진다.

레벨별 해결 방법

Level 1 — 기초 (원인 파악부터)

상황 재현 코드:

@Service
@RequiredArgsConstructor
public class OrderService {
    private final PaymentService paymentService;

    @Transactional
    public void placeOrder(OrderRequest request) {
        // 주문 저장 로직...

        try {
            paymentService.processPayment(request.getPaymentInfo()); // 내부 트랜잭션 참여
        } catch (Exception e) {
            log.error("결제 실패: {}", e.getMessage()); // 예외 잡음
            // → 이 시점에 트랜잭션은 이미 rollback-only
        }

        // 커밋 시도 → UnexpectedRollbackException 발생!
    }
}

@Service
public class PaymentService {
    @Transactional // REQUIRED (기본값) → 외부 트랜잭션에 참여
    public void processPayment(PaymentInfo info) {
        // 내부 예외 발생
        throw new PaymentException("카드 한도 초과");
    }
}

트랜잭션 상태 디버깅:

@Transactional
public void placeOrder(OrderRequest request) {
    log.info("트랜잭션 활성: {}", TransactionSynchronizationManager.isActualTransactionActive());
    log.info("롤백 마킹: {}", TransactionSynchronizationManager.isCurrentTransactionReadOnly());
}

Level 2 — 실무 해결책 (전파 방식 변경)

방법 1: REQUIRES_NEW로 별도 트랜잭션 분리

@Service
public class PaymentService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    // ↑ 기존 트랜잭션과 완전히 분리된 새 트랜잭션 시작
    public void processPayment(PaymentInfo info) {
        // 이 트랜잭션이 롤백돼도 외부 트랜잭션에 영향 없음
        paymentRepository.save(/* ... */);
    }
}
⚠️ 주의: REQUIRES_NEW는 별도 DB 커넥션을 사용한다. 커넥션 풀 고갈 위험이 있으니 남발하면 안 된다.

방법 2: noRollbackFor 설정

@Transactional(noRollbackFor = PaymentException.class)
public void processPayment(PaymentInfo info) {
    // PaymentException이 발생해도 롤백하지 않음
}

방법 3: 예외를 잡지 말고 던지기

@Transactional
public void placeOrder(OrderRequest request) {
    try {
        paymentService.processPayment(request.getPaymentInfo());
    } catch (PaymentException e) {
        // 잡지 말고 그냥 던진다
        throw e; // 혹은 새 예외로 래핑
        // → 외부 호출자가 트랜잭션 롤백 처리
    }
}

Level 3 — 고급 (아키텍처 관점에서 설계)

이벤트 기반 분리 (트랜잭션 완료 후 처리)

@Service
@RequiredArgsConstructor
public class OrderService {
    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public void placeOrder(OrderRequest request) {
        Order order = orderRepository.save(Order.of(request));

        // 트랜잭션 커밋 후 이벤트 발행
        eventPublisher.publishEvent(new OrderPlacedEvent(order.getId()));
    }
}

@Component
public class PaymentEventHandler {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    // ↑ 외부 트랜잭션 커밋 후에 실행 → 트랜잭션 오염 없음
    public void handleOrderPlaced(OrderPlacedEvent event) {
        paymentService.processPayment(event.getOrderId());
    }
}

Facade 패턴으로 트랜잭션 경계 명확히

@Service
public class OrderFacade {
    // @Transactional 없음 → 트랜잭션 경계 없음

    public void placeOrder(OrderRequest request) {
        orderService.saveOrder(request);        // 각각 독립 트랜잭션
        paymentService.processPayment(request); // 각각 독립 트랜잭션
    }
}

@Service
public class OrderService {
    @Transactional // 이 메서드 범위만 트랜잭션
    public void saveOrder(OrderRequest request) { /* ... */ }
}

상황별 해결 체크리스트

상황권장 방법주의사항
내부 예외가 외부와 독립적으로 처리돼야 할 때REQUIRES_NEW커넥션 풀 사용량 증가
특정 예외는 롤백 안 해도 될 때noRollbackFor데이터 정합성 검토 필요
결제 같은 외부 I/O가 있을 때@TransactionalEventListenerAFTER_COMMIT 타이밍 주의
서비스 레이어 설계를 바꿀 수 있을 때Facade 패턴트랜잭션 경계 재설계 필요
빠르게 임시 수정이 필요할 때예외 재던지기호출부에서 처리 필요

자주 하는 실수

실수 1: try-catch에서 예외를 먹어버리기

// ❌ 잘못된 코드
try {
    innerService.doSomething();
} catch (Exception e) {
    log.error("에러 발생", e);
    // 예외를 삼켜버림 → UnexpectedRollbackException 확정
}

// ✅ 올바른 코드
try {
    innerService.doSomething();
} catch (Exception e) {
    log.error("에러 발생", e);
    throw new BusinessException("처리 실패", e); // 반드시 다시 던지기
}

실수 2: Self-invocation (같은 클래스 내 메서드 호출)

@Service
public class MyService {
    @Transactional
    public void outer() {
        inner(); // 프록시를 거치지 않음 → @Transactional 무시됨
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void inner() { /* ... */ }
    // → REQUIRES_NEW가 적용되지 않아서 여전히 같은 트랜잭션 사용
}

Self-invocation í•´ê²°:

@Service
@RequiredArgsConstructor
public class MyService {
    private final ApplicationContext context;

    public void outer() {
        MyService proxy = context.getBean(MyService.class); // 프록시 직접 가져오기
        proxy.inner();
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void inner() { /* ... */ }
}

정리

UnexpectedRollbackException은 트랜잭션 전파를 모르면 반드시 한 번은 만나는 에러다. 핵심은 하나다.

💡 내부 트랜잭션이 롤백 마킹되면, 같은 트랜잭션에 참여한 외부 트랜잭션도 롤백될 수밖에 없다.

해결은 ‫글 두 방향이다.

  • 트랜잭션을 분리한다 (REQUIRES_NEW, Facade 패턴, 이벤트 기반)
  • 예외를 제대로 다룬다 (먹지 말고 던지기, noRollbackFor)

실무에서는 REQUIRES_NEW를 무분별하게 쓰기보다 @TransactionalEventListener나 Facade 패턴으로 설계 단계에서 트랜잭션 경계를 명확히 하는 게 장기적으로 낫다.