포스트

[order] 결제와 배송을 분리하며 이해한 트랜잭션 셀프 인보케이션

[order] 결제와 배송을 분리하며 이해한 트랜잭션 셀프 인보케이션

주문 시스템을 구현하면서 결제 완료 후 배송 정보를 생성하는 기능을 추가하고 있었다. 처음에는 같은 서비스 내부에서 배송 생성 메서드를 호출하는 단순한 구조를 생각했다. 기능적으로는 문제가 없었지만, 트랜잭션 학습 과정에서 객체가 자기 자신의 메서드를 직접 호출하는 셀프 인보케이션 개념을 알게 되었다.

1
2
payment.complete();
createShipping(payment.getOrder());

현재 구조에서는 별다른 문제가 없지만, 추후 배송 생성 로직에 별도의 트랜잭션 전파 옵션을 적용하거나 독립적인 처리 흐름이 필요해질 경우 예상과 다른 동작이 발생할 수 있다고 한다. 그래서 왜 이런 현상이 발생하는지 직접 원인을 분석해보았다.

트랜잭션 셀프 인보케이션이란?

스프링의 @Transactional은 AOP 프록시를 기반으로 동작한다.

외부에서 서비스 메서드를 호출하면 실제 객체가 바로 실행되는 것이 아니라, 스프링이 생성한 프록시 객체가 먼저 요청을 가로챈다. 프록시는 트랜잭션 시작, 커밋, 롤백과 같은 부가 기능을 수행한 뒤 실제 비즈니스 로직을 실행한다.

문제는 동일한 클래스 내부에서 트랜잭션 메서드를 호출할 때 발생한다.

1
2
3
4
5
6
7
8
9
10
@Transactional
public 응답DTO completePayment(Long paymentId) {
    ...
	createShipping(order);
}

@Transactional(propagation = REQUIRES_NEW)
public void createShipping(Order order) {
    ...
}

위 코드에서 createShipping() 호출은 프록시를 거치지 않는다.

이미 실제 객체 내부에 진입한 상태이기 때문에 스프링이 만든 프록시가 개입할 수 없고, this.createShipping()과 동일한 호출이 발생한다. 따라서 createShipping()에 선언된 REQUIRES_NEW는 적용되지 않는다. 프록시가 전파 옵션을 처리하지 못하기 때문에 새로운 트랜잭션도 생성되지 않으며, 결과적으로 기존 트랜잭션 내부에서 실행되는 것과 같은 동작을 하게 된다.

또한 createShipping()이 독립 트랜잭션으로 실행될 것이라고 기대했더라도 실제로는 동일한 트랜잭션 안에서 동작하므로, 내부에서 예외가 발생하면 결제 처리까지 함께 롤백될 수 있다.

원인을 이해하기 위해 프록시 구조를 살펴봤다

처음에는 단순히 “같은 클래스 내부에서 트랜잭션 옵션 적용이 안 된다” 그리고 “이미 트랜잭션이 시작된 상태라서 내부 메서드에서 REQUIRES_NEW로 새로운 트랜잭션을 생성할 수 없는 걸까?”가 궁금했다. 그래서 프록시 구조를 통해 자세히 이해하고 싶었다.

아래는 실제 동작을 단순화한 프록시 구조이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 실제 구조를 단순화한 예시
public class PaymentService$$EnhancerBySpringCGLIB /*(프록시 이름)*/ extends PaymentService {
    
    private PaymentService target; // PaymentService 객체를 가지고 있음
    @Override
    public void completePayment(Long id) {
        try {
            // 1. 프록시가 가로채서 사전 작업 수행
            TransactionManager.beginTransaction();
            
            // 2. 진짜 객체의 핵심 비즈니스 로직 호출
            target.completePayment(id);
            
            // 3. 프록시가 사후 작업 수행
            TransactionManager.commit();
        } catch (Exception e) {
            // 4. 예외 발생 시
            TransactionManager.rollback();
        }
    }
}

@Transactional을 선언하면 스프링은 원본 객체 대신 프록시 객체를 생성한다. 그리고 메서드 호출 시 프록시가 먼저 실행되어 트랜잭션을 시작하고, 실제 비즈니스 로직 수행 후 커밋 또는 롤백을 처리한다.

실제 객체 내부에서 다른 메서드를 호출하는 순간에는 이미 프록시를 통과한 상태이므로 더 이상 프록시가 개입할 수 없다. 결국 셀프 인보케이션이 발생하는 이유는 트랜잭션 자체의 제약이 아니라, 프록시를 거치지 않는 내부 호출 구조 때문이라는 것을 알게 되었다. REQUIRES_NEW 역시 프록시가 호출될 때만 해석되므로 내부 호출에서는 적용되지 않는다.

서비스 간 결합도를 줄이기 위해 이벤트를 적용했다

셀프 인보케이션 문제를 해결하는 방법은 여러 가지가 있다. 별도의 서비스로 분리하거나 자기 자신을 주입받는 방식도 존재한다.

현재 구조에서 셀프 인보케이션이 즉시 문제를 일으키는 것은 아니었지만 결제 완료 이후 배송 생성까지 하나의 서비스가 직접 처리하는 구조는 서비스 간 결합도가 높아질 수 있다고 판단했다.

또한 이전 포스팅에서 적용했던 도메인 모델 패턴의 방향에 맞춰 각 도메인이 자신의 책임만 가지도록 설계하고자 했다. 따라서 결제 완료 사실만 전달하고 배송 도메인이 이를 처리할 수 있도록 이벤트 기반 구조를 선택했다. 이를 통해 트랜잭션 문제를 예방하는 것뿐 아니라 도메인 간 의존성도 줄일 수 있었다.

구현 순서는 다음과 같다.

  1. 결제 완료 시 전달할 데이터를 담는 이벤트 객체를 생성한다.
  2. 배송 도메인에서 이벤트를 수신하는 리스너를 생성한다.
  3. PaymentService 에서 결제 완료 후 이벤트를 발행한다.
1
2
eventPublisher.publishEvent(
    new PaymentCompletedEvent(orderId));
  1. 이벤트 수신 후 배송 생성


마지막으로 테스트 코드를 작성하여 이벤트 발행과 리스너가 정상적으로 동작하는 것을 확인하였다.

이벤트 테스트 코드

회고 및 배운 점

이번 과정을 통해 스프링이 내부적으로 어떤 방식으로 트랜잭션을 관리하는지 이해할 수 있었다. 그리고 트랜잭션 전파 옵션을 올바르게 적용하기 위해서는 프록시를 통해 메서드가 호출되는지 먼저 확인해야 한다는 점을 알게 되었다.

결과적으로 이벤트 기반 구조를 적용하면서 결제는 결제의 책임만, 배송은 배송의 책임만 가지도록 분리할 수 있었고, 향후 기능이 추가되더라도 유연하게 확장할 수 있는 구조를 만들 수 있었다.

향후 학습 목표 (26-06-26)

현재의 도메인 분리 구조는 Spring ApplicationEvent를 활용한 동기/비동기 방식의 이벤트 발행을 사용하고 있다. 이 구조는 결제 트랜잭션이 커밋된 후 배송 트랜잭션이 처리되는 도중 서버가 다운되거나 예외가 발생할 경우, 결제는 완료되었으나 배송은 생성되지 않는 데이터 불일치 문제(이벤트 유실)가 발생할 수 있다.

이를 해결하기 위해서는 RabbitMQ, Kafka 같은 메시징 큐 를 도입하여 이벤트 전달을 보장해야 함을 인지하고 있다.

하지만 현재는 분산 시스템 인프라를 도입하여 아키텍처의 복잡도를 높이기보다는, 신입 개발자로서 객체지향적 도메인 설계와 스프링의 트랜잭션/AOP 동작 원리를 이해의 우선순위가 높다고 판단했다. 향후 기본기를 학습한 후, 메시징 큐 방식을 도입하여 문제를 해결하겠다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.