주문과 결제, 어디까지 한 트랜잭션으로 묶어야 할까?

2025. 8. 29. 17:01·Study/Architecture

저번 주차에 장애 대응 시스템을 구축하면서 분리했던 주문과 결제 API를 다시 하나로 합쳤다.

PG가 아직 시뮬레이터이기 때문에 별도의 인증/인가 로직이 존재하지 않았고, 실제 유저가 주문을 하는 플로우를 생각해 봤을 때 주문과 결제를 하나의 API로 묶는 게 자연스럽다는 판단이 들었기 때문이다.

그런데 막상 합치고 나니 여러 문제가 발생했다.

 

 

 

길어진 트랜잭션

합친 구조는 대략 이런 모습이었다.

한눈에 봐도 로직이 길어졌고, 주문이 너무 많은 책임을 떠안게 됐다.

주문 {
	주문 생성
	쿠폰 조회 & 사용
	재고 조회 & 차감
	포인트 조회 & 차감
	결제 요청
	주문 정보 데이터 플랫폼 전송
}
결제 요청 {
	주문 검증
	결제 생성
	PG 결제 요청 API 호출
	결제 상태 변경
	주문 상태 변경
}
결제 콜백 {
	주문 검증
	결제 검증
	콜백 결과 따라 결제 상태 변경
	콜백 결과 따라 주문 상태 변경
	주문 정보 데이터 플랫폼 전송
}

특히 실제 운영 상황을 가정하면 문제는 더 많아졌다.

  • PG API가 조금만 지연되더라도 주문 전체 응답이 길어짐
  • 주문 생성 중 락을 잡고 있는 시간이 길어지면서 동시 주문이 몰리면 다른 유저 주문까지 대기 상태로 밀림
  • 주문은 성공 상태로 저장됐는데 결제는 실패상태로 저장되는 애매한 상태가 발생해 상태를 구분하기 어려움
  • 실패 재시도를 하려 해도 어디까지 성공했는지 불명확함

트랜잭션도 길어지고 주문 로직에 여러 도메인의 책임이 얽히면서 장애 대응이 어려워졌다.

 

 

 

@Transactional 통한 트랜잭션 경계 분리

제일 먼저 시도한 건 트랜잭션 경계를 나누는 것이었다.

 

우선 주문의 상태를 세분화했다.

기존에는 CREATED, SUCCESS, FAILED 뿐이었는데, 실제 프로세스를 반영해 좀 더 디테일하게 정의했다.

public enum OrderStatus {
    CREATED,
    WAITING_PAYMENT,
    PAID,
    PAYMENT_FAILED,
    ORDER_FAILED,
    CANCELED,
    SHIPPING,
    DELIVERED,
}

상태를 세분화해 주문은 성공했지만 결제가 실패했다 같은 상황도 구분할 수 있었다.

 

그리고 결제 로직을 별도의 트랜잭션으로 분리해 결제 실패가 주문 전체 롤백으로 이어지지 않게 했다.

@Transactional
public void createOrder(OrderCommand.Create command) {
    Order order = orderService.create();

	try {
		couponUseService.use();
		stockDecreaseService.decrease();
		pointUseService.use();
	} catch(Exception e) {
        log.error("주문 처리 중 예외 발생: {}", e.getLocalizedMessage());
        order.markOrderFailed();
	}
		
	paymentFacade.requestPayment(); // REQUIRES_NEW
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void requestPayment() {
	...
}

 

 

한계

하지만 이 방법도 한계가 있었다.

  • 주문 로직이 여전히 결제를 직접 호출하기 때문에 의존성이 그대로 유지됨
  • 결제 실패 시 주문 쪽에서 보상 트랜잭션을 직접 작성해야 하므로 로직이 복잡해짐
  • 알림, 데이터 전송 같은 후속 작업이 늘어나면 모두 주문 로직에 추가됨

REQUIRES_NEW는 트랜잭션의 물리적 분리는 하게 했지만 도메인 간 책임까지는 분리해주지 못했다.

 

이때부터 고민이 깊어졌다.

주문이 결제까지 책임지는 게 맞을까? 재시도나 복구 같은 건 어디까지 주문이 알아야 할까?

 

 

주문이 결제를 알아야 할까?

지금 상태에서는 너무 많은 책임이 주문에 몰려있었다.

도메인 입장에서 생각해 보면 알 필요가 없는 정보가 너무 많았다.

  • 주문은 주문 자체의 정합성만 책임지면 됨
  • 결제는 외부 PG와의 상호작용을 책임지면 됨

서로 다른 유스케이스를 하나로 묶으니 장애 대응도 어렵고 결합도만 높아졌다.

따라서 주문은 주문이 생성됐다는 사실만 알리고, 후속 단계는 필요한 도메인이 이어받아 처리하도록 끊어내야 했다.

 

 

 

이벤트 기반 분리

트랜잭션의 경계만 나눠주는 것으로는 부족했기 때문에 이벤트를 도입하기로 했다.

스프링의 ApplicationEventPublisher를 사용해 이벤트를 발행하고 핸들러에서 후속 작업을 실행하도록 했다.

  • 커맨드(Command): 무엇을 하라는 지시, 후속작업을 알고 있다.
  • 이벤트(Event): 무엇이 일어났다는 사실, 후속작업을 알지 못한다.

 

 

이벤트 기반의 함정

그렇다고 모든걸 이벤트로 분리하는 게 과연 괜찮을까?

 

주문 정합성 검증 코드를 이벤트로 분리한다고 가정해 보자.

@Transactional
public void createOrder(OrderCommand.Create command) {
    Order order = orderService.create();

    eventPublisher.publish(CouponUseEvent.of());
    eventPublisher.publish(StockDecreaseEvent.of());
    eventPublisher.publish(PointUseEvent.of());

    paymentFacade.requestPayment();
}

이 경우 정합성이 맞지 않아도 일단 주문은 완료된다.

사용자는 주문 완료 알림을 받았다가 곧바로 주문 취소 알림을 받아야 하는데, 그렇게 된다면 데이터 정합성뿐 아니라 사용자 경험까지 해칠 수 있었다.

 

또한 이벤트 실패 시에도 문제가 발생했다.

기존에는 하나의 트랜잭션 내에 모든 로직이 있었기 때문에 어디에선가 예외가 발생하면 전체가 롤백됐다.

하지만 이벤트가 실패한다면?

복구 로직도 별도 트랜잭션에서 수행해야 하고, 그것도 실패하면 재시도가 모호해졌다.

 

따라서 모든 것을 이벤트로 분리한다는 접근은 오히려 더 큰 사이드 이펙트를 불러올 수 있었다.

 

 

이벤트 분리 기준

그만큼 분리 기준을 명확하게 세울 필요가 있었고, 다음과 같은 기준으로 분리해보고자 했다.

  • 정합성이 중요한 핵심 로직은 메인 트랜잭션에 포함
  • 다른 도메인의 데이터는 조회(Read)는 가능하지만, 수정(Create/Update/Delete)은 하지 않음
  • 후속 작업(결제 요청, 알림, 데이터 적재 등)은 이벤트로 분리

 

정리하면 이렇게 나눌 수 있었다.

분리하지 않을 로직 (핵심 정합성 로직)

  • 주문 생성, 주문 검증, 결제 생성, 결제 검증

분리할 로직 (부가적인 후속 작업)

  • 쿠폰 사용, 포인트 이력 저장, 결제 요청, 리소스 원복, 다른 도메인 상태 변경, 외부 API 호출

 

 

 

실제 적용

주문

주문 {
	주문 생성
	쿠폰 조회
	쿠폰 사용 이벤트 발행
	재고 조회 & 차감
	포인트 조회 & 차감
	주문 생성 이벤트 발행
}
  • 쿠폰 사용 이벤트 핸들러 → 쿠폰 사용 커맨드
  • 주문 성공 이벤트 핸들러 → 결제 요청 커맨드, 쿠폰 데이터 플랫폼 전송 이벤트

 

 

결제

결제 {
	주문 검증
	결제 생성
	PG 결제 요청 API 호출
	결제 상태 변경
	결제 요청 성공 이벤트 발행
}
  • 결제 요청 성공 이벤트 핸들러 → 주문 상태 변경 커맨드

 

 

결제 콜백

결제 콜백 {
	주문 검증
	결제 검증
	결제 상태 변경
	결제 성공/실패 이벤트 발행
}
  • 결제 성공 이벤트 핸들러 → 주문 상태 변경 커맨드, 데이터 플랫폼 전송 이벤트
  • 결제 실패 이벤트 핸들러 → 주문 상태 변경 커맨드, 리소스 원복 커맨드, 데이터 플랫폼 전송 이벤트

 

 

전체 시퀀스

sequenceDiagram
    participant U as User
    participant O as OrderFacade
    participant E as EventPublisher
    participant H1 as OrderEventHandler
    participant P as PaymentFacade
    participant H2 as PaymentEventHandler
    participant C as Callback(PG)
    participant H3 as CallbackEventHandler

    U->>O: 주문 요청
    O->>O: 주문 생성 & 검증<br/>쿠폰/재고/포인트 처리
    O->>E: 주문 생성 이벤트 발행
    O-->>U: 주문 결과 반환

    E-->>H1: 주문 성공 이벤트
    H1->>P: 결제 요청 커맨드

    P->>P: 결제 생성 & PG API 호출
    P->>E: 결제 요청 성공 이벤트 발행

    E-->>H2: 결제 요청 성공 이벤트
    H2->>O: 주문 상태 변경

    opt 결제 콜백
	    C->>P: 결제 콜백 호출
	    P->>E: 결제 성공/실패 이벤트 발행
	
	    E-->>H3: 결제 결과 이벤트
	    H3->>O: 주문 상태 변경
	    H3->>외부: 데이터 플랫폼 전송
	    opt 결제 콜백 결과가 실패인 경우
		    H3->>O: 리소스 원복
	    end
    end

 

 

분리 결과

이벤트를 통해 트랜잭션을 분리한 결과, 도메인 사이 결합도를 낮추고 경계를 선명히 할 수 있었다.

여기서 얻은 이점은 다음과 같다.

  • 트랜잭션 경계 명확화: 핵심 정합성 로직만 메인 트랜잭션에 묶이고 나머지는 별도 트랜잭션으로 분리
  • 도메인 간 결합도 감소: 주문은 주문만, 결제는 결제만 책임지도록 구조 단순화
  • 보상 트랜잭션 단순화: 실패 처리 로직이 주문 코드에 얽히지 않고 이벤트 핸들러에서 독립적으로 수행
  • 확장성: 이벤트 단위로 여러 전략을 선택할 수 있고 알림이나 데이터 전송 같은 후속 작업 확장이 쉬워짐

 

 

 

마무리

이번에는 API를 합쳤다가 발생한 문제로 인해 트랜잭션 경계를 명확히 나누는 작업을 수행했다.

이 과정 속에서 어떤 문제는 단순히 @Transactional과 옵션으로는 해결되지 않는다는 것과 도메인 간 결합도가 너무 높으면 장애 대응도 복잡해진다는 것을 겪었다.

결국 이벤트 기반 구조로 변경하면서 주문은 주문만, 결제는 결제만 책임지는 형태로 정리할 수 있었다.

도메인의 경계와 책임을 나눈다는 것이 단순히 코드 레벨의 분리나 트랜잭션 분리 문제가 아니라 설계 차원의 중요한 문제라는 점을 배울 수 있었다.

 

하지만 이벤트를 적용하면서 또 다른 고민들도 생겼다.

  • 이벤트가 발행되지 않는다면 어떻게 검증하고 대응할 것인가?
  • 동일 이벤트가 여러 번 발행된다면 멱등성을 어떻게 보장할 것인가?
  • 여러 이벤트가 동시에 처리될 때 순서를 어떻게 보장할 것인가?

이런 안정성 문제는 또 다른 과제로 남았다.

 

스프링 기본 이벤트만으로는 이 부분을 다루기 어렵기 때문에 메시지 브로커(Kafka, RabbitMQ 등)나 별도의 이벤트 인프라를 고려해야겠다는 생각도 들었다.

그래서 다음 주에는 메시지 브로커 중 하나인 카프카를 사용해 안정성도 확보할 수 있도록 해보려 한다.

반응형

'Study > Architecture' 카테고리의 다른 글

실시간 상품 랭킹? Redis ZSET만 쓰면 끝인 줄 알았는데요  (0) 2025.09.12
내부 이벤트를 넘어 Kafka 기반 이벤트 파이프라인으로  (0) 2025.09.05
장애 대응 시스템 구축하기  (0) 2025.08.22
캐시 구조 개선  (0) 2025.08.22
읽기 성능 개선 보고서  (4) 2025.08.15
'Study/Architecture' 카테고리의 다른 글
  • 실시간 상품 랭킹? Redis ZSET만 쓰면 끝인 줄 알았는데요
  • 내부 이벤트를 넘어 Kafka 기반 이벤트 파이프라인으로
  • 장애 대응 시스템 구축하기
  • 캐시 구조 개선
haylee
haylee
개발하면서 보고 듣고 느낀 것들을 정리하는중
  • haylee
    haylee
    haylee
    • 홈
    • GitHub
    • LinkedId
    • 분류 전체보기 (19)
      • Project (6)
        • 회고 (6)
      • Study (13)
        • Database (3)
        • Test (1)
        • Architecture (9)
  • hELLO· Designed By정상우.v4.10.1
haylee
주문과 결제, 어디까지 한 트랜잭션으로 묶어야 할까?
상단으로

티스토리툴바