본문 바로가기

MSA

Kotlin으로 직접 구현하며 배우는 MSA - 3. 주문과 결제 흐름을 모놀리식 안에서 처리하기

✨ 공부하며 성장하는 기록 공간입니다.


아직 부족한 점이 많고 배워야 할 것도 많지만, 그만큼 하나씩 알아가는 과정에서 큰 재미를 느끼고 있습니다.

이 블로그는 제가 공부한 내용을 정리하고, 직접 구현하며 겪은 고민과 시행착오를 기록하기 위한 공간입니다.

틀리거나 부족한 내용이 있을 수 있습니다. 잘못된 내용이나 더 나은 방향이 있다면 언제든지 편하게 지적해 주세요 😁


해당 포스팅 시리즈에서는 Kotlin/Spring 기반으로 MSA를 이해해보는 과정을 다룰 예정입니다.

단순히 기술을 사용하는 방법만 정리하기보다는, 초기 프로젝트 구축부터 시작해 특정 문제가 왜 발생했는지, 그 문제를 해결하기 위해 어떤 기술과 구조가 등장했는지, 그리고 실제 개발 과정에서는 어떻게 적용할 수 있는지를 함께 공부해보려고 합니다. 가능한 한 복잡한 개념도 쉽게 풀어내고, 제가 직접 이해한 방식으로 정리해보겠습니다.

 

서론

이전 글에서 Member, Product 도메인을 추가했다.

https://dev-hiro.tistory.com/199

 

Kotlin으로 직접 구현하며 배우는 MSA - 2. 회원, 상품 도메인 만들기

✨ 공부하며 성장하는 기록 공간입니다.아직 부족한 점이 많고 배워야 할 것도 많지만, 그만큼 하나씩 알아가는 과정에서 큰 재미를 느끼고 있습니다.이 블로그는 제가 공부한 내용을 정리하고,

dev-hiro.tistory.com

 

회원 가입, 상품 등록, 조회 API를 구현했지만, 아직 각 도메인은 서로 협력할 일이 거의 없었다.

이번에는 Order와 Payment 도메인을 추가하여 실제 커머스에서 발생하는 비즈니스 흐름을 만들어보려고 한다.

 

아직까지는 단순한 도메인들을 추가하고, 주문 과정에서 여러 도메인이 어떻게 협력하는지, 하나의 트랜잭션으로 처리할 수 있는 범위 및 결제 실패가 발생하면 어떤 문제가 생기는지 확인하려고 한다.

 

현재 프로젝트 구조

현재 프로젝트는 Gradle 멀티모듈로 구성되어 있다.

mini-commerce
 ├── common
 │    └── 공통 응답 및 에러 처리 모델
 │
 └── commerce-monolith
      ├── member
      ├── product
      ├── order
      └── payment

 

이전에 작성했던 대로, common은 공통 라이브러리이고, commerce-monolith만 실행 가능한 Spring Boot 어플리케이션이다.

이번 포스팅에서는 Payment 도메인과 Order 도메인을 동일한 패키지 내에 처리하기로 하였다.

 

Order 도메인 구현

기존 Member와 Product는 비교적 독립적으로 동작했다. 다른 도메인과 엮여서 작업이 되지 않았으며, 멤버 및 상품들을 등록, 조회하더라도 다른 도메인에 영향이 없었다.

 

반면 Order는 여러 도메인의 데이터를 사용해야한다.

개발자 관점에서 주문을 생성하려면 다음과 같은 작업이 필요할 것이다.

  1. 주문하는 회원이 실제로 있는지 확인한다.
  2. 주문하려는 상품이 존재하는지 확인한다.
  3. 상품의 재고가 충분한지 확인한다.
  4. 주문 수량만큼 재고를 차감한다.
  5. 주문 정보와 주문 상품을 저장한다.

흐름을 표현하면 다음과 같다.

Client
  |
  | POST /api/v1/orders
  v
OrderController
  |
  v
OrderService
  |
  +-- Member 조회
  |
  +-- Product 조회
  |
  +-- 재고 확인 및 차감
  |
  +-- Order / OrderItem 생성
  |
  v
Database

 

이 과정은 현재 하나의 애플리케이션 내부에서 이루어진다.

Order API

주문 생성 API는 다음과 같다.

POST /api/v1/orders

 

Request:

{
  "memberId": 1,
  "items": [
    {
      "productId": 1,
      "quantity": 2
    }
  ]
}

이전에 등록한 회원과 상품을 기준으로 주문을 생성한다.

상품 가격이 30,000원이고 수량이 2개라면 주문 금액은 60,000원이 된다.

{
    "data": {
        "orderId": 1,
        "memberId": 1,
        "status": "CREATED",
        "totalPrice": 60000,
        "items": [
            {
                "productId": 1,
                "productName": "Keyboard",
                "orderPrice": 30000,
                "quantity": 2
            }
        ]
    },
    "error": null,
    "success": true
}

현재 주문은 결제를 완료한 것이 아니므로 최초 상태는 CREATED다.

주문 생성과 결제 처리를 구분하여 이후 Payment 도메인에서 주문 상태를 변경하도록 구현했다.

Order과 OrderItem의 분리

주문 하나에는 여러 상품이 포함될 수 있다.

예를 들어, 키보드 2개와 마우스 1개를 주문했다고 가정할 때,

Order #1
 ├── OrderItem #1
 │    ├── Keyboard
 │    ├── 30,000원
 │    └── 수량 2개
 │
 └── OrderItem #2
      ├── Mouse
      ├── 20,000원
      └── 수량 1개

Order는 주문 전체에 대한 정보를 가지고, OrderItem은 각 상품의 정보를 가진다.

따라서 Order과 OrderItem은 일대다 관계로 모델링할 수 있다.

 

※주의

여기서, OrderItem에 상품명, 가격을 별도로 저장한다. 왜 그럴까?

 

예를 들어 상품의 가격이 변경되었다고 가정하자.

7월 1일
Keyboard 가격: 30,000원
→ 고객이 2개 주문

7월 10일
Keyboard 가격: 35,000원으로 변경

과거 주문을 조회할 때 현재 상품 테이블의 가격을 사용한다면 주문 금액이 달라질 수 있다.

하지만 과거 주문 내역은 실제 주문 당시의 가격을 유지해야 한다.

 

그래서 OrderItem에 상품 정보를 스냅샷으로 저장한다.

// 개념 설명을 위한 축약 코드
data class OrderItem(
    val productId: Long,
    val productName: String,
    val orderPrice: Long,
    val quantity: Int
)

따라서 목적은 다음과 같다.

  • Product: 현재 판매 중인 상품의 정보
  • OrderItem: 주문 시점에 확정된 상품의 정보

이렇게 구분하면 나중에 Product와 Order를 독립된 서비스로 분리하더라도 과거 주문 데이터를 유지하기 쉽다.

 

Order 생성에서 중요한 트랜잭션

개발자로서 주문에서 중요하게 생각할 부분이 생긴다.

바로 Race Condition인데, 어떤 상품에 대한 재고 차감과 주문 저장이다.

 

다음과 같은 상황을 생각해보자.

  1. 회원 검증 성공
  2. 상품 조회 성공
  3. 상품 재고 10개 -> 8개 감소
  4. Order 저장 중 예외 발생

이때, 재고만 감소하고 주문이 저장되지 않으면, 고객은 주문에 실패했는데 재고는 줄어든 상태가 된다.

 

이 문제를 해결하기 위해, 재고 차감과 주문 저장은 하나의 트랜잭션으로 처리해야한다.

Spring에서는 @Transactional을 사용하여 하나의 트랜잭션으로 묶을 수 있다.

 

개념적으로는

@Transactional
fun create(request: CreateOrderRequest): OrderResponse {
    // 1. 회원 조회 및 검증

    // 2. 상품 조회 및 재고 검증

    // 3. 재고 차감

    // 4. 주문 및 주문 상품 저장

    // 5. 결과 반환
}

실제 구현에서는 주문 생성 흐름에 @Transactional을 적용하여 재고 차감과 주문 저장을 하나의 작업 단위로 관리한다.

 

@Transactional은 어떻게 동작할까?

잠시 Transactional은 어떻게 동작하는지 확인해보자.

Spring의 선언적 트랜잭션은 기본적으로 AOP Proxy를 통해 동작한다. 스프링(Spring)에서 AOP Proxy는 IoC 컨테이너(Spring IoC Container) 내부의 후처리기(BeanPostProcessor), 구체적으로는 AbstractAutoProxyCreator 같은 빈 후처리기들이 빈을 초기화하는 시점에 동적으로 생성한다.

  • 생성 방식 (2가지):
    • JDK Dynamic Proxy: 인터페이스가 있는 경우 java.lang.reflect.Proxy를 이용해 인터페이스 기반 프록시 생성.
    • CGLIB Proxy: 인터페이스가 없거나 설정(예: 스프링 부트 기본값)에 따라 바이트코드를 조작하여 클래스 기반 프록시 생성.

 

일반적으로 Service 메소드를 호출한다고 생각하지만, 트랜잭션이 적용된 Bean을 외부에서 호출하면 Spring이 생성한 Proxy를 거치게 된다. 

흐름을 살펴보면

OrderController
     |
     v
OrderService Proxy
     |
     +-- Transaction 시작
     |
     v
OrderService.create()
     |
     +-- 재고 차감
     +-- 주문 저장
     |
     v
Transaction 종료
     |
     +-- 정상 종료 → Commit
     |
     +-- 롤백 대상 예외 → Rollback

과 같다.

 

이 과정에서 Spring의 TransactionInterceptor와 PlatformTransactionManager가 트랜잭션 처리를 담당한다.

중요한 것은 @Transactional이 붙은 메서드 내부의 SQL을 단순히 묶는다는 개념을 넘어, 하나의 비즈니스 작업을 어떤 범위까지 원자적으로 보장할 것인지 정의한다는 것이다.

 

또한 기본적인 Proxy 방식에서는 같은 객체 내부에서 자신의 @Transactional 메서드를 직접 호출하는 Self-Invocation이 트랜잭션 인터셉터를 거치지 않을 수 있다.

 

그래서 트랜잭션 경계는 보통 외부에서 호출되는 Application Service의 메서드에 명확하게 정의하는 것이 좋다.

재고 차감은 어떻게 DB에 반영될까?

Spring Data JPA에서는 영속 상태의 엔티티를 변경하면 JPA의 Dirty Checking을 통해 변경 사항을 반영할 수 있다.

예를 들어 다음은 개념을 단순화한 코드다.

fun decreaseStock(quantity: Int) {
    require(quantity > 0)

    if (stockQuantity < quantity) {
        throw InsufficientStockException()
    }

    stockQuantity -= quantity
}

Product 엔티티가 영속성 컨텍스트에서 관리되는 상태라면, 재고를 변경한 뒤 트랜잭션이 정상 종료될 때 변경 감지와 Flush 과정을 통해 UPDATE SQL이 실행될 수 있다.

 

그리고 트랜잭션이 Commit되면 DB 변경 사항이 확정된다.

 

다만 @Transactional이 있다고 해서 재고 동시성 문제까지 해결되는 것은 아니다.

서로 다른 두 요청이 동시에 마지막 재고를 조회하면, 트랜잭션 격리 수준과 구현 방식에 따라 재고 정합성 문제가 발생할 수 있다.

이 문제는 이후 실제 동시 주문 시나리오를 만들고 Optimistic Lock이나 Pessimistic Lock 등을 비교할 때 다뤄보려고 한다.

 

실제 요청 및 응답

Order 요청

 

Payment 도메인 구현

주문을 생성했으니 이번에는 결제를 처리해야 한다. 다만, 이번 프로젝트는에서 중요한 것은 실제 돈을 결제하는 기능이 아니라, 결제 결과에 따라 주문 상태가 어떻게 변화하는지를 확인하는 것이기 때문에 실제 연동과 같은 부분은 진행하지 않고, Mock으로 진행하려고 한다.

결제 API

POST /api/v1/payments

Request

{
  "orderId": 1,
  "shouldFail": false
}

shouldFail 값에 따라 결과가 달라진다.

  • false: 결제 승인
  • true: 결제 실패

결제 금액은 요청에서 직접 전달받지 않는다.

이미 생성된 Order의 totalPrice를 기준으로 결정한다.

 

클라이언트가 결제 금액을 임의로 변경하지 못하도록 서버에 저장된 주문 금액을 사용하기 위한 설계다.

실제 PG 연동에서는 이 외에도 결제 승인 금액과 주문 금액을 대조하는 별도의 검증이 필요하다.

 

결제 성공

shouldFail=false인 경우 다음과 같이 처리된다.

Order (CREATED)
       |
       v
Payment 요청
       |
       v
Payment (APPROVED)
       |
       v
Order (PAID)

응답 예시

{
  "success": true,
  "data": {
    "paymentId": 1,
    "orderId": 1,
    "amount": 60000,
    "status": "APPROVED",
    "orderStatus": "PAID"
  }
}

Payment는 결제 결과를 저장하고, Order는 주문 상태를 저장한다.

서로 다른 도메인이지만 같은 애플리케이션 안에 있으므로 Payment의 Application Service에서 Order의 상태를 변경하는 로직을 직접 호출할 수 있다.

 

결제 실패

이번에는 shouldFail=true로 요청해보자.

{
  "orderId": 1,
  "shouldFail": true
}

결과는 다음과 같다.

여기서 눈여겨볼 점은 다음 부분이다.

{
  "success": true,
  "data": {
    "status": "FAILED"
  }
}

API 응답의 success는 true인데, Payment 상태는 FAILED다.

 

success=true는 API 요청이 정상적으로 처리되었다는 의미로 사용하고, PaymentStatus.FAILED는 결제가 승인되지 않았다는 비즈니스 결과를 의미한다.

 

결제 거절을 정상적인 비즈니스 결과로 모델링했기 때문에 시스템 예외를 발생시키지 않고 실패 결과를 저장할 수 있는 것이다.

 

결제 실패 VS 트랜잭션 롤백

이번 구현에서 가장 중요하게 이해해야 할 부분이다.

우리는 결제 실패 시 다음 상태를 저장하도록 했다.

Payment.status = FAILED
Order.status = PAYMENT_FAILED

그렇다면 @Transactional이 있는데 결제 실패 시 왜 Rollback되지 않을까?

이유는 비즈니스 실패와 트랜잭션 실패는 서로 다르기 때문이다.

 

예를 들어 Fake Payment에서 shouldFail=true인 경우에는 시스템 에러가 발생한 것이 아니다.

개발자가 의도한 대로 결제를 실패 처리한 것이다.

따라서 실패 결과를 저장하고 메서드가 정상 종료된다면 트랜잭션은 Commit될 수 있다.

 

반면 결제 정보를 저장하는 도중 예상하지 못한 RuntimeException이 전파되면 Spring은 기본 설정에서 트랜잭션을 Rollback 처리한다.

상황 Payment Order 트랜잭션
결제 승인 APPROVED PAID Commit
정상적인 결제 거절 FAILED PAYMENT_FAILED Commit
DB 저장 중 RuntimeException 변경 확정 안 됨 변경 확정 안 됨 Rollback

위 표는 Payment 처리 내부에서 두 엔티티 변경이 하나의 정상적인 Spring 트랜잭션으로 묶여 있다는 전제다.

 

다만 실제 결제 시스템에서 어떠한 결과를 HTTP 성공 응답으로 정의할지, 어떤 결과를 4xx나 5xx로 처리할지는 별도의 API 계약과 도메인 정책에 따라 결정해야 한다.

 

주문과 결제 트랜잭션 관계 파악

현재 프로젝트는 Order와 Payment가 같은 애플리케이션, 같은 DB를 사용한다.

하지만 주문 생성과 결제완료는 하나의 트랜잭션이 되는 것은 아니다.

 

[첫 번째 HTTP 요청]

POST /api/v1/orders
       |
       v
Transaction A
 ├── 회원/상품 확인
 ├── 재고 차감
 └── 주문 생성
       |
     COMMIT
     
 [두 번째 HTTP 요청]

POST /api/v1/payments
       |
       v
Transaction B
 ├── Payment 저장
 └── Order 상태 변경
       |
     COMMIT

두 요청은 각각 독립적으로 실행된다.

Transaction A가 Commit된 뒤에 Transaction B가 시작된다.

그렇다면 다음과 같은 상황도 가능하다.

주문 생성 성공
    |
    v
재고 차감 완료
    |
    v
Transaction A COMMIT
    |
    v
결제 요청이 발생하지 않음

이 경우 주문은 CREATED 상태로 남아 있고 재고는 이미 차감된 상태가 된다.

 

또한 주문이 생성된 뒤 결제 요청이 실패하더라도, 이전에 Commit된 주문 생성 트랜잭션까지 자동으로 Rollback되지는 않는다.

트랜잭션의 경계가 어디인지가 중요하다.

이번 프로젝트에서는 이 문제를 당장 해결하지 않고, 향후 주문과 결제 흐름을 고도화할 때 다시 살펴볼 예정이다.

 

현재 확인할 수 있는 문제

현재 구현은 간단한 학습용 커머스 흐름을 만드는 데 목적이 있다.

하지만 실제 서비스라면 몇 가지 추가 정책이 필요하다.

 

결제 실패 후 재고는 어떻게 해야 할까?

현재는 주문을 생성할 때 재고를 차감한다.

그리고 결제가 실패하면 주문 상태를 PAYMENT_FAILED로 변경한다.

그런데 이 과정에서 차감된 재고를 다시 복구하지는 않는다.

재고 10개
    |
    v
주문 2개 생성
    |
    v
재고 8개
    |
    v
결제 실패
    |
    v
재고 여전히 8개

이 상태가 계속된다면 실제 판매할 수 있는 재고와 시스템이 기록하는 재고가 달라질 수 있다.

따라서 실제 시스템에서는 결제 실패 시 주문 취소, 재고 복구 또는 재결제 가능 기간을 포함한 정책이 필요하다.

동일한 결제 요청이 여러 번 발생한다면?

네트워크 지연 때문에 클라이언트가 결제 요청을 두 번 보낼 수도 있다.

또는 응답을 받지 못해 같은 요청을 재시도할 수 있다.

현재 구현에서는 이미 결제가 완료된 주문에 대해 재결제를 제한한다.

 

하지만 이것만으로 완전한 멱등성이 보장되는 것은 아니다.

예를 들어 동시에 들어온 두 요청이 모두 주문 상태를 CREATED로 확인한다면, 별도의 동시성 제어가 없는 경우 중복 처리가 발생할 여지가 있다.

 

실제 결제 시스템에서는 Idempotency Key, DB Unique Constraint, 상태 전이 검증 등을 함께 고려해야 한다.

결제에 실패한 주문을 다시 결제할 수 있을까?

현재는 결제 가능한 주문 상태를 제한하는 구조이기 때문에, PAYMENT_FAILED 상태의 주문에 대해 바로 재결제하는 흐름은 제공하지 않는다.

실제 커머스라면 결제 수단을 변경하거나 일정 시간 내 재결제하도록 허용할 수도 있다.

 

이를 위해서는 단순히 상태값을 늘리는 것이 아니라 어떤 상태에서 어떤 상태로 이동할 수 있는지 명확하게 정의해야 한다.

지금은 이런 요구사항이 없으므로 복잡한 상태 머신을 도입하지 않았다.

하지만 주문과 결제 기능이 확장된다면 중요한 설계 과제가 될 것이다.

 

MSA로 분리한다면?

현재는 Order와 Payment가 같은 애플리케이션에 존재한다.

commerce-monolith
 ├── OrderService
 ├── PaymentService
 └── Shared Database

두 도메인 간 호출은 같은 프로세스 안에서 이루어지는 메서드 호출이다.

단일 DB 트랜잭션을 사용할 수 있는 처리 범위에서는 Spring의 @Transactional로 여러 데이터 변경을 함께 관리하기도 쉽다.

하지만 나중에 서비스를 분리하면 다음과 같은 구조가 된다.

order-service
 ├── OrderService
 └── Order DB

      |
      | REST 또는 gRPC
      v

payment-service
 ├── PaymentService
 └── Payment DB

이때는 상황이 달라진다.

 

Order와 Payment가 서로 다른 프로세스에서 실행되고, 서로 다른 DB를 사용하게 된다.

결제 서비스가 결제를 성공적으로 처리했는데 주문 서비스에 결과를 전달하지 못하는 상황이 생길 수 있다.

order-service                 payment-service
     |                               |
     |------- 결제 요청 ------------>|
     |                               |
     |                          결제 성공
     |                          DB COMMIT
     |                               |
     |<---- 응답 중 네트워크 장애 ---X
     |
  결제 결과 불명확

이때 주문 서비스는 결제가 실패했다고 단정할 수 없다.

결제가 성공했지만 응답만 유실된 것일 수도 있기 때문이다.

 

또한 Order DB를 Rollback한다고 해서 이미 Commit된 Payment DB의 변경 사항이 자동으로 취소되지 않는다.

이것이 분산 환경에서 데이터 정합성을 관리하기 어려운 이유 중 하나다.

 

이 문제를 해결하기 위한 접근 방법으로는 Saga Pattern이 있다.

Saga는 여러 로컬 트랜잭션으로 이루어진 비즈니스 흐름을 관리하고, 중간에 문제가 발생했을 때 필요한 보상 작업을 수행하도록 설계하는 패턴이다.

 

예를 들어 결제 실패가 확정되었다면 차감한 재고를 복구하는 작업이 필요할 수 있다.

 

먼저 서비스를 분리하고, 실제로 서비스 간 통신과 장애 상황을 경험한 다음 추후에 어떤 방식이 적절한지 비교해볼 예정이다.

 

마무리

이번 글에서는 모놀리식 애플리케이션 안에 Order와 Payment 도메인을 추가했다.

Member와 Product가 개별 데이터를 관리하는 단계였다면, Order와 Payment부터는 여러 도메인이 함께 하나의 비즈니스 흐름을 수행하기 시작한다.

특히 이번 구현을 통해 다음과 같은 점을 확인할 수 있었다.

  • 주문은 회원과 상품 정보를 조합하며, 주문 당시의 상품 정보와 가격을 보존해야 한다.
  • 재고 차감과 주문 저장은 데이터 정합성을 위해 하나의 트랜잭션으로 관리할 필요가 있다.
  • 결제 실패라는 비즈니스 결과가 반드시 트랜잭션 Rollback을 의미하는 것은 아니다.
  • 같은 애플리케이션과 DB를 사용하더라도 서로 다른 API 요청은 별도 트랜잭션으로 처리된다.
  • 도메인을 물리적으로 분리하면 지금까지 단순했던 메서드 호출이 네트워크 통신으로 바뀌고, 분산 데이터 정합성 문제가 발생할 수 있다.

아직은 모놀리식 구조가 훨씬 간단하다.

그래서 지금 당장 서비스를 분리하거나 Saga, Kafka 등을 도입하지 않을 생각이다.

먼저 기본 비즈니스 흐름을 완성하고, 이후 구조를 변경하면서 왜 새로운 기술이 필요한지 직접 경험해보려고 한다.

다음 글에서는 주문이나 결제가 완료되었을 때 사용자에게 알림을 보내는 Notification 도메인을 추가할 예정이다.

처음에는 같은 프로세스 안에서 직접 호출하는 단순한 구조로 시작한다.

그리고 이후 Notification을 독립 서비스로 분리하면서 호출 실패, 응답 지연, 메시지 유실 등의 문제를 다뤄볼 예정이다.

 

다음 글: Kotlin으로 직접 구현하며 배우는 MSA - 4. Notification 도메인과 동기 호출

 


 

코드 참조