코드는 동작하는 것만 봤는데, 이번에 왜 이렇게 설계했는지 보려고 했다.
이 글에서 얻어갈 것들
- 결제 vs 주문 - 무엇이 본질인지 판단하는 기준
- "돈이 빠지는 순간" 을 기준으로 시스템을 나누는 방법
- 요구사항을 기능이 아니라 흐름으로 읽는 시각
- 도메인 객체로 로직을 캡슐화하는 패턴 - PaymentDiscount 설계
- 상태, 이력, 실패를 설계하는 방식
핵심 요악
결제는 복잡하지만 본질이 아니다. 주문이 본질이고, 결제는 그 주문을 완성시키는 수단이다.
그리고 결제 시스템에서 반드시 새겨야 할 한 가지
돈이 빠지기 전과 후는 완전히 다른 세계다. 이 경계를 모르면 시스템은 반드시 깨진다.
1. 결제를 공부하면서 처음 든 질문
처음 결제 코드를 봤을 때는 API 3개, PG 연동, 할인 계산 정도로 읽혔다. 그냥 외부 API 붙이는 거 아닌가? 싶었는데, 파고들수록 다른 기능들과 성질이 달랐다.
상품 조회, 장바구니, 주문 생성은 전부 우리 서버 안에서 끝난다. 실패해도 재시도하면 그만이다. 결제는 다르다. 외부 시스템(PG사) 이 개입하는 순간, 우리가 통제할 수 없는 변수가 들어온다.
- 네트워크가 끊기면?
- PG사가 장애나면?
- 응답이 오긴 했는데 금액이 다르면?
이런 걸 생각하다 보니 결제 설계의 핵심이 "성공을 만드는 것"" 이 아니라 "실패를 흡수하는 구조를 만드는 것" 이라는 게 보이기 시작했다.
2. 진짜 요구사항은 해피케이스 바깥에 있다.
강의에서 인상 깊었던 부분이 있다. 커머스 서비스에서 기획서는 보통 이런 식으로 온다고 했다.
주문 생성 -> 결제 -> 주문 처리 완료
이게 가장 가장 위험한 문장이라는 말이 와닿았다. 기획자가 모르는게 아니라, 예외 케이스는 원래 개발자가 먼저 들고 가야 하는 거라고, 그래서 기획에 역으로 물어봐야 할 것들이 있다.
- PG사가 장애 났을 때 주문이 어떻게 됩니까?
- 결제 실패한 주문을 다시 결제할 수 있어야 합니까?
- 잔액부족, 한도 초과 실패 이력을 별도로 보관해야 합니까?
- 결제 타임아웃이 발생하면 주문 상태는 어떻게 처리합니까?
흐름도만 받아서 구현하면 기능은 돌아간다. 하지만 실제 운영에서 터지는 문제의 대부분은 이 흐름도에 없다는 것, 이게 공부하면서 가장 크게 느낀 점이다.
요구사항은 주어지는 것이 아니라, 개발자가 끌어내는 것이다.
3. 세 개의 API 가 말해주는 구조
POST /v1/payments ← 우리가 받는다 (결제 계획 생성)
POST /v1/payments/callback/success ← PG사가 우리를 호출한다
POST /v1/payments/callback/fail ← PG사가 우리를 호출한다
처음에 그냥 API 3개라고 봤는데, 콜백 두 개는 성질이 다르다. 우리가 호출하는게 아니라 PG사가 결과를 우리에게 알려주는 것이다.
결제의 흐름 제어권은 외부에 있고, 우리는 그 결과를 받아서 처리하는 역할이다.
그래서 사실 우리가 능동적으로 설계하는 건 1개뿐이고, 나머지 2개는 외부의 언어에 맞춰 열어두는 창구다.
이걸 이해하니까 왜 콜백 파라미터가 우리 컨벤션이 아닌 PG사 스펙을 따르는지도 납득이 됐다.
4. 결제 생성 - "의도"를 저장하는 단계
결제 생성 API 에서 실제 돈은 빠지지 않는다.
이 단계에서 본질은
이 주문을 어떻게 결제할지 확정하는 것
@PostMapping("/v1/payments")
fun create(user: User, @RequestBody request: CreatePaymentRequest): ApiResponse<CreatePaymentResponse> {
val order = orderService.getOrder(user, request.orderKey, OrderState.CREATED)
val ownedCoupons = ownedCouponService.getOwnedCouponsForCheckout(user, order.items.map { it.productId })
val pointBalance = pointService.balance(user)
val id = paymentService.createPayment(
order = order,
paymentDiscount = request.toPaymentDiscount(ownedCoupons, pointBalance),
)
return ApiResponse.success(CreatePaymentResponse(id))
}
코드를 보면 순서가 있다. 주문 조회 -> 보유 쿠폰 조회 -> 포인트 잔액 조회.
이 세가지를 먼저 가져오는 이유는 사용자가 요청한 할인이 실제로 가능한지 검증하기 위해서다. 그리고 이 검증과 계산을 PaymentDiscount라는 객체에 위임한다.
PaymentDiscount
이 객체의 진짜 역할은 단순 계산이 아니다.
비즈니스 규칙을 객체 안에 가두는 것
data class CreatePaymentRequest(
val orderKey: String,
val useOwnedCouponId: Long?,
val usePoint: BigDecimal?,
) {
fun toPaymentDiscount(ownedCoupons: List<OwnedCoupon>, pointBalance: PointBalance): PaymentDiscount {
return PaymentDiscount(
ownedCoupons = ownedCoupons,
pointBalance = pointBalance,
useOwnedCouponId = useOwnedCouponId ?: -1,
usePointAmount = usePoint ?: BigDecimal.valueOf(-1),
)
}
}
data class PaymentDiscount(
private val ownedCoupons: List<OwnedCoupon>,
private val pointBalance: PointBalance,
val useOwnedCouponId: Long,
private val usePointAmount: BigDecimal,
) {
val couponDiscount: BigDecimal
val usePoint: BigDecimal
init {
// 쿠폰을 쓰겠다고 했으면, 실제 보유 쿠폰 목록에서 찾아서 할인 금액 계산
// 없거나 유효하지 않으면 예외 — 서비스 레이어가 이 로직을 알 필요 없음
couponDiscount = if (useOwnedCouponId > 0) {
ownedCoupons.firstOrNull { it.id == useOwnedCouponId }
?.coupon?.discount ?: throw CoreException(ErrorType.OWNED_COUPON_INVALID)
} else {
BigDecimal.ZERO
}
// 포인트를 쓰겠다고 했으면, 잔액 초과 여부 먼저 검증
// 포인트 잔액은 이미 위에서 조회해서 넘겨줬기 때문에 DB 재조회 없음
usePoint = if (usePointAmount > BigDecimal.ZERO) {
if (usePointAmount > pointBalance.balance)
throw CoreException(ErrorType.POINT_EXCEEDS_BALANCE)
usePointAmount
} else {
BigDecimal.ZERO
}
}
fun paidAmount(orderPrice: BigDecimal): BigDecimal {
val amount = orderPrice - (couponDiscount + usePointAmount)
// 할인 금액이 주문 금액보다 크면 음수 — 비즈니스상 허용하지 않음
if (amount < BigDecimal.ZERO) throw CoreException(ErrorType.PAYMENT_INVALID_AMOUNT)
return amount
}
}
처음 이 코드를 보면서 "왜 굳이 클래스로 만들었지?" 라는 생각이 든다. 서비스에서 직접 계산해도 되지 않을까?
근데 서비스 레이어에 쿠폰 검증 로직, 포인트 잔액 검증 로직, 최종 금액 계산 로직을 다 넣으면 어느 순간 함수가 50줄을 넘는다. 그리고 할인 정책이 바뀌는 날, 어디를 고쳐야 할지 몰라서 코드 전체를 뒤진다.
PaymentDiscount 는 "할인" 이라는 개념을 하나의 경계로 묶은 것이다. 쿠폰과 포인트가 어떻게 계산되는지는 이 객체만 알면 된다. 서비스는 그냥 결과를 받아 쓰면 된다. 할인 정책이 바뀌어도 변경 범위가 이 객체 안으로 제한된다.
즉 로직을 옮긴 것이 아니라 책임의 경계를 만든 것이다.
한 가지 더 눈에 띈 것. paidAmount 에서 0원을 허용하고 있다.
쿠폰 + 포인트로 주문 금액이 전부 커버되면 0원 결제가 가능하다는 뜻이다. "0원 결제를 허용할 것인가" 는 비즈니스 결정이고, 코드는 그 결정의 기록이다. 이런 것들이 요구 사항으로 확인돼야 하는 것들이라는 걸 이번에 배웠다.
5. 결제 성공 - 이 코드에서 가장 중요한 경계
이것이 이 글의 핵심이다.
경계
- 승인 전 → 돈이 안 빠짐
- 승인 후 → 돈이 이미 빠짐
// ── 여기까지는 고객 돈이 빠지지 않는다 ──
val order = orderRepository.findByOrderKeyAndStateAndStatus(
orderKey, OrderState.CREATED, EntityStatus.ACTIVE
) ?: throw CoreException(ErrorType.NOT_FOUND_DATA)
// CREATED 상태인지 확인 — 이미 결제된 주문이 중복 처리되는 걸 막는다
val payment = paymentRepository.findByOrderId(order.id)
?: throw CoreException(ErrorType.NOT_FOUND_DATA)
if (payment.userId != order.userId) throw CoreException(ErrorType.NOT_FOUND_DATA)
// 결제건의 유저와 주문의 유저가 같은지 확인 — 타인의 주문을 결제하는 케이스를 막는다
if (payment.state != PaymentState.READY) throw CoreException(ErrorType.PAYMENT_INVALID_STATE)
// READY가 아니면 이미 처리됐거나 비정상 상태 — 중복 승인 방지
if (payment.paidAmount != amount) throw CoreException(ErrorType.PAYMENT_AMOUNT_MISMATCH)
// 우리 DB의 결제 금액과 PG사가 실제 청구한 금액이 다르면 위험 신호 — 반드시 차단
/**
* NOTE: PG 승인 API 호출 => 성공 시 다음 로직으로 진행 | 실패 시 예외 발생
* 이 줄 이후부터 실제로 고객 돈이 빠진다
*/
// ── 여기부터는 고객 돈이 이미 빠졌다 ──
payment.success(externalPaymentKey, PaymentMethod.CARD, approveCode)
order.paid()
// 쿠폰 사용 처리
// 포인트 차감 + 적립
// 이력 저장
승인 전 : 얼마든지 검증하고 예외를 던져도 된다. 고객 돈은 안전하다.
승인 후: 고객 통장에서 이미 돈이 나갔다. 여기서 예외가 터지면 "돈은 빠졌는데 주문 상태가 완료가 안 된" 최악의 상황이 된다. 커머스 장애 중 상당수가 이 지점에서 발생한다고 한다.
복구하려면 수동으로 DB를 직접 조작하거나, PG사와 데이터를 대사(reconciliation)해서 불일치 건을 하나씩 찾아야 한다.
그래서 승인 이후의 부수 작업(이력 저장 등)은 실패해도 예외를 삼키고 로깅만 하도록 처리하는 게 더 안전할 수 있다. 이력 저장 실패 때문에 결제 완료 처리 자체가 막혀서는 안 되기 때문이다.
6. 결제 실패 - 상태가 아니라 기록이다.
이 코드는 실패 처리는 의도적으로 심플하다.
fun fail(orderKey: String, code: String, message: String) {
val order = orderRepository.findByOrderKeyAndStateAndStatus(
orderKey, OrderState.CREATED, EntityStatus.ACTIVE
) ?: throw CoreException(ErrorType.NOT_FOUND_DATA)
val payment = paymentRepository.findByOrderId(order.id)
?: throw CoreException(ErrorType.NOT_FOUND_DATA)
// 주문 상태: 변경하지 않는다
// 결제 상태: 변경하지 않는다
// 트랜잭션 히스토리에 실패 이력만 남긴다
transactionHistoryRepository.save(
TransactionHistoryEntity(
type = TransactionType.PAYMENT_FAIL,
...
message = "[$code] $message",
)
)
}
처음 보면 이상하다.
상태 변경 없음, 주문도 그대로, 결제 상태도 그대로 대신 "이력"만 남긴다.
실패했으면 상태를 PAYMENT_FAILED로 바꿔야 하지 않나?
바꾸지 않은 이유가 있다. 결제가 실패해도 주문은 여전히 CREATED상태다. 고객은 같은 주문으로 다시 결제를 시도할 수 있다. 만약 PAYMENT_FAILED 상태를 추가하면, 이 상태에서 제결재를 허용하는 로직이 새로 필요해진다. 상태 전이 규칙이 하나 더 생기고, 그걸 처리하는 코드도 늘어난다.
상태가 늘어날수록 시스템은 조용히 복잡해진다. 버그는 보통 상태 전이가 다르게 흘렀을 때 터진다.
운영팀에서 "결제 실패한 주문 현황을 보고 싶다" 는 요청이 오면?
트랜잭션 히스토리로 조회하면 된다.
-- 주문은 생성됐지만 결제가 실패한 건
SELECT o.*
FROM orders o
WHERE o.state = 'CREATED'
AND EXISTS (
SELECT 1 FROM transaction_history th
WHERE th.order_id = o.id
AND th.type = 'PAYMENT_FAIL'
)
상태를 늘리지 않아도 필요한 정보는 다 꺼낼 수 있다. 이 부분이 공부하면서 인상 깊었다. 상태를 추가하는게 기본값처럼 생각됐는데, 이력 테이블을 잘 설계해두면 상태 없이도 충분한 경우가 있다는 것.
7. 왜 주문이 결제보다 중요한가?
강의 초반부터 "결제가 중요할까, 주문이 중요할까?" 라는 질문을 계속 가져가라고 했다. 코드를 다 보고 나서 내린 결론은 주문이 더 중요하다는 것이다.
| 관점 | 주문 (Order) | 결제 (Payment) |
| 본질 | 핵심 도메인 | 수단 |
| 의존성 | 내부 중심 | 외부 의존 (PG사) |
| 확장 방향 | 배송, 취소, 반품 등 연결 | PG사 연동에 국한 |
| 데이터 오너십 | 우리가 주도 | 외부 데이터 포함 |
주문은 순수하다. 어떤 쿠폰으로 할인됐는지, 포인트가 얼마나 쓰였는지 모른다. 그건 결제의 관심사다. 이 순수함을 지켜야 나중에 배송,취소, 반품이 붙어도 주문이 흔들리지 않는다.
언어에서 드러나는 차이
주문이 배송된다. 결제가 배송된다고는 하지 않는다.
이 언어의 차이가 설계의 방향을 알려준다.
결제 수단은 바뀔 수 있다. PG사를 갈아타거나, 간편결제가 추가될 수 있다. 하지만 "고객이 무엇을 얼마에 샀는가" 라는 주문의 본질은 바뀌면 안된다. 그래서 결제의 복잡도가 주문 쪽으로 스며들지 않도록 경계를 지키는 게 중요하다는 것. 코드를 통해 실제로 느꼈다.
결제는 바뀔 수 있지만 주문은 바뀌면 안된다.
설계로 번역하면
- 결제 로직이 주문을 오염시키면 안된다.
- 외부 복잡도가 내부로 전파되면 안된다.
처음 결제 코드를 봤을 떄는 그냥 기능 하나였다. 파고들수록 이 코드 안에 꽤 많은 결정들이 담겨 있었다.
왜 할인 계산을 객체로 분리했는지, 왜 실패 시 상태를 바꾸지 않았는지, 왜 주문을 중심 축으로 잡았는지
코드를 읽는다는 건 동작을 이해하는 것이고, 잘 읽는다는 건 그 코드가 왜 그 선택을 했는지를 이해하는 것이다. 이번 공부에서 그 차이를 조금 느낀 것 같다.
결제는 수단이고, 주문이 본질이다. 이걸 이해하는 순간 코드는 단순해지고 설계는 강해진다.