주문 및 결제 데이터 설계 고민

원태연·2024년 2월 17일

주문과 결제에 대한 데이터는 다른 사용자들의 데이터와 다르게 좀 더 안전하고 예민한 관리가 필요하다고 생각했다.
사용자가 작성한 리뷰 하나가 유실 되는 것과 결제 정보다 유실 되는 것의 중요도는 꽤나 차이나고, 처리 및 복구하는데 드는 비용이 많이 들기 때문이다.

주문/결제와 관련하여 처음 다루는 만큼 여러 상황들에 대해 고려하여 설계해보려 한다.

우선 결제 플로우를 알아야 하는데, 우리 서비스에서 각 카드사, 간편결제, 가상계좌 등 여러 결제 수단을 직접 연동하고 데이터를 관리하는 것은 현실적으로 불가능하다.
그래서 PG사를 통해 결제를 진행하고, 정산받는 방법이 제일 현실적이다.

그리고 우리는 PG사로 토스메이먼츠를 사용하기로 했다.

간단하게 주문-결제 플로우를 살펴보면 아래와 같다.

주문 접수 -> PG사 결제 요청 ->  결제 성공 -> 결제 최종 승인 -> 주문 완료
                                   |
                                   ᄂ> 결제 실패 -> 실패 처리

아래 토스메이먼츠에서 제공하는 플로우도 참고하자.
https://docs.tosspayments.com/guides/payment-widget/integration

image

위 과정을 고려해서 나는 아래와 같은 도메인을 구성하게 되었다.

  • 사용자의 주문을 저장하는 Order,
  • PG사로부터 받은 결제 저옵를 저장하는 TossPayment, (toss 페이먼츠를 사용하여 구체 이름을 사용하였습니다)
  • [주문]과 [결제]를 연관 짓는 OrderPayment

주문/결제 DB설계를 하면서 데이터 중복을 줄이는 정규화보단 반정규화를 택했다.
주문/결제가 이루어지고 나면 해당 데이터는 커머스 세상에 존재하는 상품이나 옵션등과 같은 데이터와의 관계가 끊어져야 한다고 생각했기 때문이다.
주문이 이루어지고 난 뒤, 상품이 삭제되거나 변경되는 등의 변경사항들로 부터 독립적일 수 있도록 설계했다.


Order

@Table(name = "orders") // MySql의 order 예약어와 겹쳐서 추가했습니다.
@Entity
class Order(
    @Id @GeneratedValue(strategy = IDENTITY)
    val id: Long = 0L,

    @Column(nullable = false)
    val memberId: Long,

    @Embedded
    @AttributeOverride(name = "value", column = Column(name = "orderNumber"))
    val orderNumber: OrderNumber, // 주문 번호입니다. id와 별개로 고객이 주문을 조회하거나 PG사의 고유한 주문번호를 전달할 때 사용됩니다.

    @Embedded
    val deliveryInfo: OrderShippingAddressInfo,

    @Embedded
    val productInfo: OrderProductInfo, // 주문한 상품에 대한 정보들을 포함 (상품 변경사항에 대비하여 반정규화 하여 저장)

    @Column(nullable = false)
    val isAbleToCancel: Boolean, // 주문 상태에 따른 환불/취소 가능 여부를 나타냄

    @Enumerated(STRING)
    @Column(nullable = false)
    val status: OrderStatus, // 주문의 상태
) : BaseEntity()


@Embeddable
class OrderShippingAddressInfo(
    @Column(nullable = false)
    val receiver: String,

    @Column(nullable = false)
    val phoneNumber: String,

    @Column(nullable = false)
    val zipCode: Int,

    @Column(nullable = false)
    val address: String,

    @Column(nullable = false)
    val detailAddress: String,
    val requestMessage: String, // 배송 요청 사항
) {
}

@Embeddable
class OrderProductInfo(

    @Column(nullable = false)
    val quantity: Int,

    @Column(nullable = false)
    val originalPrice: BigDecimal,

    @Column(nullable = false)
    val discountRate: Int,

    @Column(nullable = false)
    val discountPrice: BigDecimal,

    @Column(nullable = false)
    val deliveryFee: BigDecimal,

    @Column(nullable = false)
    val totalPrice: BigDecimal, // 상품에 대한 최종적으로 결제해야하는 금액

    @Column(nullable = false)
    val productId: Long = 0, // 상품에 대해 조회하거나 삭제/변경 여부를 확인 하기 위해 참조값을 가짐 

    @Column(nullable = false)
    val productName: String,

    @Column(nullable = false)
    val thumbnailUrl: String,

    @Column(nullable = false)
    val storeId: Long = 0,

    @Column(nullable = false)
    val storeName: String,

    @Column(nullable = false)
    val deliveryMethod: DeliveryMethod,

    @Column(nullable = false)
    @Enumerated(EnumType.STRING)
    val sex: Sex,
) {
}




Payment

PG사로부터 받는 결제 관련한 정보를 저장한다.

토스에서도 발생한 결제에 대한 데이터들을 저장하고 있고, 발생한 결제에대한 번호와 주문 번호를 통해 해당 데이터를 조회할 수 있지만 매번 요청하지 않더라도 필요한 정보들에 대해선 가지고 있는게 좋다고 생각했다. 토스페이먼츠 결제 객체

카드정보나 영수증 등의 민감 정보는 최대한 PG사로부터 조회 하는 방식으로, 저장을 피했다.
사용자의 민감 정보를 저장하는 경우 법적인 정책도 따라야 하고, 책임의 소재도 커지기 때문이다.(https://www.law.go.kr/법령/개인정보보호법/(20230915,19234,20230314)/제23조)

@Entity
class Payment(
    @Id @GeneratedValue(strategy = IDENTITY)
    val id: Long = 0L,

    @Column(nullable = false)
    val paymentKey: String,

    @Embedded
    val orderNumber: OrderNumber, 

    @Column(nullable = false)
    val orderName: String,

    @Enumerated(STRING)
    @Column(nullable = false)
    val method: TossPaymentMethod,

    @Column(nullable = false)
    val totalAmount: String, // 결제 금액

    @Enumerated(STRING)
    @Column(nullable = false)
    val status: TossPaymentStatus, 

    @Column(nullable = false)
    val requestedAt: String,

    @Column(nullable = false)
    val approvedAt: String,

    @Column(nullable = false)
    val useEscrow: String, 

    @Enumerated(STRING)
    @Column(nullable = false)
    val type: TossPaymentType,
)




OrderPayment

주문에 대한 결제 정보들을 저장합니다.

@Entity
class OrderPayment(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0L,

    val orderId: Long,

    val paymentId: Long,
)



Ex) 내가 생각한 결제 플로우 샘플

제가 의도한 플로우에 맞게 예시를 하나 생각해보자.

  1. 먼저 사용자가 여러 상품을 담고 주문을 요청한다.
[
  {
    "productId": 1, // 8천원
    "quantity": 1
  },
  {
    "productId": 2, // 4천원
    "quantity": 1
  },
  {
    "productId": 3, // 3천원
    "quantity": 2
  },
]




2. 요청에 따라 초기 상태인 [주문 접수]상태로 주문을 저장한다.

Idorder_numberproduct_nametotal_pricestatus...
120240217SATMEMBER1D1상품18000ORDER_CHECKING...
220240217SATMEMBER1D1상품24000ORDER_CHECKING...
320240217SATMEMBER1D1상품36000ORDER_CHECKING...




3. 사용자는 연동된 PG사의 서비스를 통해 결제를 한다.
Screenshot 2024-02-17 at 16 48 50


  1. (성공한 경우) PG사에서 결제가 성공한 뒤, 지정한 redirectUrl로 성공 응답을 클라이언트에서 받는다. successUrl에 대한 docs

  2. 해당 정보를 백엔드 서버로 보낸다.

{
  "paymentType": "PAYMENT_TYPE",
  "orderId": "20240217SATMEMBER1D1",
  "paymentKey": "BSD2UvV12M0-eDO322q"
}





6. 서버에서는 PG사에 최종 결제 승인 요청을 보낸 뒤, 성공하면 결제(payment)에 대한 정보를 응답 받는다.

샘플 응답

{
  "mId": "tosspayments",
  "lastTransactionKey": "9C62B18EEF0DE3EB7F4422EB6D14BC6E",
  "paymentKey": "5EnNZRJGvaBX7zk2yd8ydw26XvwXkLrx9POLqKQjmAw4b0e1",
  "orderId": "MC4wODU4ODQwMzg4NDk0",
  "orderName": "토스 티셔츠 외 2건",
  "taxExemptionAmount": 0,
  "status": "DONE",
  "requestedAt": "2024-02-13T12:17:57+09:00",
  "approvedAt": "2024-02-13T12:18:14+09:00",
  "useEscrow": false,
  "cultureExpense": false,
  "card": {
    "issuerCode": "71",
    "acquirerCode": "71",
    "number": "12345678****000*",
    "installmentPlanMonths": 0,
    "isInterestFree": false,
    "interestPayer": null,
    "approveNo": "00000000",
    "useCardPoint": false,
    "cardType": "신용",
    "ownerType": "개인",
    "acquireStatus": "READY",
    "receiptUrl": "https://dashboard.tosspayments.com/receipt/redirection?transactionId=tviva20240213121757MvuS8&ref=PX",
    "amount": 1000
  },
  "virtualAccount": null,
  "transfer": null,
  "mobilePhone": null,
  "giftCertificate": null,
  "cashReceipt": null,
  "cashReceipts": null,
  "discount": null,
  "cancels": null,
  "secret": null,
  "type": "NORMAL",
  "easyPay": {
    "provider": "토스페이",
    "amount": 0,
    "discountAmount": 0
  },
  "easyPayAmount": 0,
  "easyPayDiscountAmount": 0,
  "country": "KR",
  "failure": null,
  "isPartialCancelable": true,
  "receipt": {
    "url": "https://dashboard.tosspayments.com/receipt/redirection?transactionId=tviva20240213121757MvuS8&ref=PX"
  },
  "checkout": {
    "url": "https://api.tosspayments.com/v1/payments/5EnNZRJGvaBX7zk2yd8ydw26XvwXkLrx9POLqKQjmAw4b0e1/checkout"
  },
  "currency": "KRW",
  "totalAmount": 1000,
  "balanceAmount": 1000,
  "suppliedAmount": 909,
  "vat": 91,
  "taxFreeAmount": 0,
  "method": "카드",
  "version": "2022-11-16"
}
  1. 결제 객체의 정보 중 필요한 정보들을 서버에 저장한다.
    table: payment
Idorder_numberpaymentKeytotal_amountstatus...
120240217SATMEMBER1D1BSD2UvV12M0-eDO322q15000DONE...
  1. 주문에 대한 결제 결과를 바탕으로 주문의 상태에 반영한다. (완료)
    table: orders
Idorder_numberproduct_nametotal_pricestatus...
120240217SATMEMBER1D1상품18000PAYMENT_CONFIRMED...
220240217SATMEMBER1D1상품24000PAYMENT_CONFIRMED...
320240217SATMEMBER1D1상품36000PAYMENT_CONFIRMED...
  1. 각 주문들에 결제 정보를 기록해둔다.
    table: order_payment
Idorder_idpayment_id
111
221
331



개선 사항

앞서 말했듯이 주문/결제는 다른 도메인과 다르게 굉장히 민감한 데이터이다. 돈이 걸려있기 때문..!
그래서 데이터의 무결성과 정확도가 매우 중요하고, 변경사항이 존재하지 않도록 구성하면 좋다고 생각을 하는데,
지금 구조에서 떠오르는 문제가 2가지 정도 있었다.

1. 상품 반정규화로 인한 중복데이터 저장

주문에서 상품의 변경에 영향을 최소화 하고자 반정규화 하여 저장하고 있었다.
만약, 1번 상품이 1000명의 사용자가 구매한다고 하면 1000개의 중복데이터가 쌓이게 된다. (order테이블에 product관련한 모든 컬럼이 중복됨)
현재 설계에서는 대략 8개의 컬럼이 row개수 만큼 중복된다.
product의 변경으로부터 독립적이기 위해 반정규화를 했지만, 반정규화의 단점을 고스란히 겪게 되었다.

그래서product의 변경사항을 기록하는 snapshot 형태의 테이블을 추가하여 최소한의 중복데이터만 쌓이도록 구성하는 것이다.

  1. product_snapshot테이블을 추가하고 product에는 product_snapshot_id 컬럼을 추가한다.

table: product_snapshot

Id...product_nameprice
5...최초등록상품A8000
6...다른상품의이름B2000

table: product

Id...product_snapshot_id
1...5
2...6
  1. 이런 상황에서 productId = 1, 2을 주문했다고 하자.
    그럼 아래와 같은 데이터가 추가될 것이다.
Idorder_numberproduct_snapshot_idtotal_pricestatus...
120240217SATMEMBER1D158000CHECKING...
120240217SATMEMBER1D162000CHECKING...

사용자가 주문을 조회하면 product_snapshot_id 통해 주문한 상품에 대한 정보들을 조회 할 수 있다.

  1. 이때, 판매자가 productId = 1 인 상품의 이름 "최초등록상품AA"로, 가격을 9000원으로 수정하면 product_snapshot을 추가하고, product는 product_snapshot_id를 수정한다.

table: product_snapshot

Id...product_nameprice
5...최초등록상품A8000
6...다른상품의이름B2000
7...최초등록상품AA9000

table: product

Id...product_snapshot_id
1...7 (5 -> 7로 변경)
2...6

이런식으로 변경에 영향 받지 않고, 데이터를 중복하여 저장하는 걸 피할 수 있을 것 같다.

2. 주문 상태에 대한 동시성 문제

주문 접수 -> 결제 -> 배송 준비 -> 완료 등 하나의 주문에 대해 여러 상태들이 변경 될 수 있다.
특히 하나의 주문을 두고 여러 요청이 올 경우가 굉장히 많을걸로 예상된다.
ex) "결제 완료"된 상태의 주문이 있는 경우,

  • "고객의 변심으로 환불 요청 버튼 클릭"
  • "배송사에서 해당 주문을 접수하고 배송중으로 상태 변경"
  • "스토어(사장님)에서 재고 부족으로 주문 취소"

처럼 다양한 이해관계자로부터 요청을 받을 수 있다.
중요한 부분이고, 그만큼 동시성 이슈에 대해 정확하게 처리해야 한다고 생각한다.
직접 "락"을 거는 방법도 있지만 최대한 Lock을 사용하지 않는 방식이 더 안전하다고 생각한다.
간단하게 row를 쌓으면서 활용할 수 있다.

  1. 먼저, 주문 상태 컬럼을 order_payment 테이블로 옮긴다.
Idorder_idpayment_idorder_status
111PAYMENT_CONFIRMED
221PAYMENT_CONFIRMED
331PAYMENT_CONFIRMED

이제 주문에 대한 상태는 order_payment 테이블로 부터 조회하여 결정되는데요.
이때, 위 상황으로 주문 상태가 수정되는 경우, column을 수정하는게 아닌 새로운 row를 쌓는다.

Idorder_idpayment_idorder_status
111PAYMENT_CONFIRMED
221PAYMENT_CONFIRMED
331PAYMENT_CONFIRMED
411REFUND_REQUESTED

이렇게 쌓고, orderId = 1인 주문의 상태는 order_payment의 최신 등록된 row의 order_status를 활용하는 방식이다.
이 방식의 장점은 lock을 추가적으로 구성하지 않고도 DBMS에서 제공되는 방식으로 동시성 이슈를 해결 할 수 있다는 점과
application에서 상태 변화를 위해 entity가 가변이 되어야 하는데 이를 방지 할 수 있다.
var status: OrderStatus -> val status: OrderStatus
application에서 Order 객체가 돌아다니다가 해당 필드를 수정하는(특히 JPA transactional내에서) 순간 치명적일 수 있기 때문에, val로 선언할 수 있다는 점이 큰 장점이라고 생각한다.

profile
앞으로 넘어지기

3개의 댓글

comment-user-thumbnail
2024년 4월 11일

엄청나네요...

2개의 답글