· 14분 읽기

PG 를 바꾸며 돈과 주문을 맞추는 법 – 아임포트(KCP)에서 토스페이먼츠 직접 연동으로


이 글의 코드는 실제 코드가 아니라 구조를 설명하려고 줄인 의사 코드다. 이름도 설명용으로 바꿨다.

1. 전환 전: 고객이 먼저 결제하고 서버가 나중에 확인한다

전환 전 구조(V1)에서는 FE 가 아임포트 SDK 로 KCP 결제창을 띄웠다. 고객이 결제창에서 결제를 마치면 KCP 쪽 결제는 이미 승인된 상태다. 그다음 서버가 주문번호와 아임포트 결제 id 를 받아 아임포트에 결제 정보를 조회하고 주문 금액과 맞는지 대조했다.

// V1: 고객이 결제창에서 결제를 끝낸 뒤에 서버가 불린다
fun completePayment(orderNo: String, impUid: String) {
    val receipt = iamport.getPayment(impUid)      // 이미 승인된 결제를 조회
    if (order.totalCost != receipt.amount) {
        throw WrongPaymentException()             // 틀려도 돈은 이미 빠져나간 상태
    }
    // ...
}
V1 은 KCP 결제창에서 돈이 먼저 움직이고 서버가 나중에 조회해 대조한다. V2 는 서버가 금액·구매 제한·배송일을 검증한 뒤 confirm 으로 승인하고, 그 뒤에 재고·마일리지·쿠폰을 처리한다
그림 1 · V1 과 V2 의 순서. V2 에서도 재고 확인은 돈이 움직인 뒤(주문 처리 칸)에 남았다. 이 위치가 5장의 주제다.

바꾼 뒤 구조(V2)는 토스페이먼츠 직접 연동이다. 가장 큰 차이는 승인 주체다. V1 은 고객이 결제를 끝낸 뒤 서버가 대조했고 V2 는 서버가 금액을 확인한 다음 서버가 직접 승인한다. 금액 조작은 돈이 움직이기 전에 걸러진다.

PG 를 바꾼 사업적 이유(수수료·계약 등)는 이 글에서 다루지 않는다.

2. 두 PG 를 같이 살리는 설계

전환은 하루에 끝나지 않는다. 배포한 순간부터 아임포트로 결제된 옛 주문과 토스로 결제된 새 주문이 함께 존재한다. 옛 주문에도 취소·환불·무통장 만료가 계속 들어온다. 실제로 석 달 동안 두 PG 의 결제건이 함께 살아 있었다. 그래서 필요한 것은 두 가지였다.

  1. 새 코드가 특정 PG 에 묶이지 않을 것: 인터페이스
  2. 들어온 요청이 어느 PG 의 결제건인지 가를 것: 판별

2-1. 벤더 무관 인터페이스

// 도메인 레이어에 둔다. 구현체(토스)는 infra 레이어에 둔다
interface PaymentClient {
    suspend fun confirm(req: ConfirmRequest, idempotencyKey: String? = null): PaymentResult
    suspend fun getPayment(paymentKey: String): PaymentResult
    suspend fun cancel(req: CancelRequest, idempotencyKey: String? = null): PaymentResult
    suspend fun cancelVirtualAccount(paymentKey: String, idempotencyKey: String? = null): PaymentResult
    suspend fun checkAccountHolder(bankCode: String, accountNumber: String): HolderCheckResult
}

서비스는 이 인터페이스만 주입받고 결과도 우리 쪽 모델(PaymentResult)로 돌려받는다. 멱등키는 구현체가 HTTP 헤더로 바꿔 보낸다.

결과 모델의 상태 변환에는 토스용과 함께 아임포트용 매퍼도 있다. 다만 커머스 서버에서 이 인터페이스를 구현한 클래스는 토스 하나뿐이다. 아임포트는 기존 V1 서비스를 그대로 두고 @Deprecated 를 붙였다.

곧 지울 아임포트를 새 인터페이스로 다시 감싸는 대신, V1 경로는 손대지 않고 새 코드만 인터페이스 위에 올렸다. 그래서 두 PG 가 만나는 곳은 "인터페이스 구현체 둘"이 아니라 "결제건을 보고 V1 / V2 경로를 고르는 분기점"이 됐다. 반대로 어드민 서버는 취소·환불·현금영수증만 하므로 두 PG 를 같은 인터페이스로 감싼 전략 패턴으로 갔다(2-4).

2-2. 어느 PG 의 결제건인가: 저장된 키로 판별

별도 칼럼 없이 결제건에 남은 키의 종류로 판별한다.

enum class PgProvider {
    TOSS, IAMPORT, ZERO_PAYMENT;

    companion object {
        fun from(payment: Payment) = when {
            payment.paymentKey != null -> TOSS                       // 토스 결제 키가 있으면 토스
            payment.impUid != null && payment.impUid != payment.orderNo -> IAMPORT
            else -> ZERO_PAYMENT                                     // 0원 결제
        }
    }
}

// 취소·환불·만료가 들어오면 결제건을 보고 길을 고른다
when (PgProvider.from(payment)) {
    TOSS -> cancelWithToss(payment, amount)
    IAMPORT -> cancelWithIamport(payment, amount)
    ZERO_PAYMENT -> payment.refund(amount)
}

선물 거절, 품절 결제취소 등이 이 판별로 길을 나눈다.

FE 는 원격 토글로 V1 / V2 중 어느 결제 API 를 부를지 골랐다. 서버는 이 토글을 보지 않는다. 서버가 보는 것은 이미 저장된 결제건뿐이다. 그래서 토글을 끄고 켜도 이미 결제된 주문의 취소 경로는 흔들리지 않는다.

취소 API 도 손봤다. 처음 V2 취소 API 는 FE 가 토스 결제 키·취소 사유·면세 금액을 보내게 되어 있었다. 이를 주문번호 하나만 받도록 줄이고 서버가 DB 의 결제건으로 PG 를 판별하게 했다. V1 결제건이 들어오면 V1 취소로 넘긴다. FE 는 PG 를 몰라도 취소 API 하나만 부르면 된다.

2-3. 워커에 복사한 클라이언트를 공통 모듈로 옮기기

워커는 API 모듈에 의존할 수 없어서 처음엔 토스 클라이언트를 통째로 복사했다. 승인은 막아 두고 조회·취소만 썼다. 다음 날 클라이언트를 공통 모듈로 옮기고 사본(320줄)을 지웠다.

사본이 있으면 토스 API 가 바뀔 때 두 곳을 같이 고쳐야 하고 한쪽만 고치면 API 서버에서는 되는 취소가 워커에서는 실패한다. 공통 모듈로 옮긴 뒤로는 각 모듈에 빈 등록 설정만 남는다.

2-4. 어드민 서버: 두 PG 를 같은 인터페이스로

interface PaymentGatewayClient {
  getPayment(paymentKey: string): Promise<PaymentResult>
  cancelPayment(req: CancelRequest): Promise<PaymentResult>
  checkAccountHolder(bankCode: string, accountNumber: string): Promise<HolderCheckResult>
  createCashReceipt(req: CashReceiptRequest): Promise<CashReceiptResult>
}

class PaymentGatewayFactory {
  // 결제건 기준으로 구현체를 고른다
  getClient(payment: Payment): PaymentGatewayClient {
    return payment.paymentKey ? this.tossClient : this.iamportClient
  }
}

커머스 서버의 인터페이스를 참조해 만들었고 아임포트·토스 구현체를 팩토리가 결제건 기준으로 고른다.

3. V2 결제 흐름: 서버가 승인 버튼을 누른다

  1. FE 가 주문을 만들고, 응답의 결제 금액으로 토스 위젯 금액을 정한다.
  2. 고객이 위젯에서 인증하면 토스가 성공 URL 로 결제 키·주문번호·금액을 붙여 돌려보낸다. 아직 승인 전이다.
  3. FE 가 결제 완료 API 를 부르고 서버는 주문번호로 분산락을 잡는다.
  4. 서버가 금액·배송희망일(이후 구매 제한까지)을 검증하고, 통과해야만 토스 승인(confirm)을 부른다.
  5. 승인 결과로 결제 저장 → 재고 확인 → 주문 완료(재고 차감·마일리지·쿠폰) → 알림·배송·WMS 연동.
suspend fun processPayment(param: PaymentParam) = withOrderLock(param.orderNo) {
    val order = findOrder(param.orderNo)

    // 1) 클라이언트가 보낸 금액과 서버 주문 금액 비교 — 금액 조작은 여기서 끊긴다
    if (order.totalCost != param.amount) throw WrongPaymentException()

    // 2) 배송희망일 등 사전 검증 — 실패하면 승인을 보내지 않는다
    validateBeforeConfirm(order)

    // 3) 서버가 승인한다. 같은 결제 키로는 한 번만 승인되도록 멱등키를 붙인다
    val result = paymentClient.confirm(
        ConfirmRequest(param.paymentKey, param.orderNo, param.amount),
        idempotencyKey = "confirm:${param.paymentKey}",
    )

    handlePaymentResult(order, result)
}

3-1. 멱등키: 같은 요청은 한 번만

토스 POST API 는 Idempotency-Key 헤더를 받는다. 같은 키로 다시 보내면 토스가 처음 결과를 돌려주고 다시 실행하지 않는다. 승인 응답이 시간 초과로 끊겨 고객이 다시 눌러도 두 번 승인되지 않는다.

호출멱등키
승인confirm:{결제 키}
취소 / 부분취소cancel:{결제 키}:{금액}
가상계좌 입금 전 취소가상계좌 취소 전용 접두사 + 결제 키
품절로 인한 워커 취소워커 취소 전용 접두사 + 결제 키
무통장 만료 취소만료 전용 접두사 + 결제 키
선물 거절 부분취소취소하는 배송건 id 기반

4. 정합성: 트랜잭션 없이 돈과 주문을 맞추기

토스 승인은 외부 HTTP 호출이다. DB 트랜잭션은 토스에서 일어난 일을 되돌리지 못한다. 결제 경로는 Mongo 트랜잭션도 쓰지 않는다. 그래서 정합성은 아래 장치들의 조합으로 맞춘다.

장치무엇을 막나
주문번호 분산락 (Redisson, 결제·취소·웹훅이 같은 키)같은 주문의 결제와 취소, 웹훅이 동시에 도는 것. V1 에서 웹훅과 결제가 서로 다른 락 키를 써서 서로 막지 못한 버그를 전환 준비 중에 겪은 뒤의 설계다
승인 전 검증금액 조작·구매 한도 초과. 돈이 움직이기 전에 끊는다
멱등키재시도로 인한 이중 승인·이중 취소
상태 멱등 가드웹훅·재시도로 같은 결과가 또 들어올 때 이중 처리
조건부 findAndModify취소 시 "배송 준비 전 배송건만", "입금대기·결제완료 주문만" 상태를 바꾼다
단계별 진행 기록 + Slack승인은 됐는데 주문 처리가 중간에 실패한 상태를 사람이 바로 알게 한다

승인 직후의 처리는 이런 모양이다.

suspend fun handlePaymentResult(order: Order, result: PaymentResult) {
    // 멱등 가드: 웹훅·재시도로 같은 결과가 또 와도 상태가 같으면 아무것도 하지 않는다
    if (order.status == result.status) return alreadyProcessed(order)

    val done = mutableListOf<Step>()
    try {
        savePayment(result);              done += Step.PAYMENT_SAVE
        checkStockOrCancel(order);        done += Step.STOCK_CHECK   // 재고 확인이 승인 뒤에 있다
        completeOrder(order);             done += Step.ORDER_SAVE    // 재고 차감·마일리지·쿠폰
        // 알림·배송·WMS ...
    } catch (e: Exception) {
        // 어디까지 됐고 무엇이 남았는지 Slack 으로 보낸다 — 수동 조정의 출발점
        alert("결제 후처리 실패 order=${order.no} 완료=$done 미처리=${Step.ALL - done}")
        throw e
    }
}

4-1. 실패 지점별로 무슨 일이 일어나나

어디서 실패돈처리
승인 전(금액 불일치·주문 없음·구매 한도)안 움직임그냥 실패 응답
승인 호출이 시간 초과·응답 유실모름고객이 다시 누르면 같은 멱등키로 재전송 → 이미 승인됐으면 그 결과를 받는다
승인 뒤 재고 부족움직임주문을 취소완료로 저장하고 워커가 결제 취소(5장)
승인 뒤 저장·후속 처리 실패움직임자동 취소하지 않는다. 완료/미처리 단계를 Slack 으로 알리고 수동 조정

4-2. 승인 뒤 실패를 어떻게 다룰지: 세 번 바뀐 정책

순서정책
처음승인 뒤 무엇이든 실패하면 바로 보상 취소
두 번째보상 취소 제거. 토스 웹훅(결제 상태 변경)이 다시 들어와 멱등 가드로 빠진 단계를 채운다는 전제
세 번째(확정)웹훅은 모니터링만 한다. 승인 이후 장애는 알림 + 수동 DB 조정(V1 아임포트 때와 같은 정책). 승인 뒤 실패 시 완료/미처리 단계를 담아 Slack 알림

처음 구현은 이랬다.

runCatching { confirmAndProcess() }
    .onFailure {
        // 승인 뒤 무엇이든 실패하면 바로 결제를 취소했다 → 지금은 지웠다
        if (pgConfirmed) paymentClient.cancel(CancelRequest(paymentKey, reason = "시스템 오류 보상 취소"))
    }

자동 보상 취소는 깔끔해 보이지만 함정이 있다. 승인 뒤 실패의 원인이 "주문 저장은 됐는데 알림 발송에서 예외" 같은 것이면, 자동 취소는 정상 주문의 돈을 돌려주고 주문은 결제완료로 남기는 새 불일치를 만든다. 확정 정책은 알림이 필요한 조합을 하나로 좁혔다. "결제 저장됨 + 주문은 아직 결제대기"다. 결제 저장 다음 주문 저장 사이에서 실패했다는 뜻이고 나머지 조합은 고객 재시도와 멱등 승인으로 자연스럽게 끝난다고 봤다.

5. 재고 동시성: 왜 승인 후 취소가 불가피했나

결제 V2 에서 가장 설명이 필요한 부분이다. 재고는 세 가지 사실이 맞물려 있다.

  1. 주문을 만들 때 재고를 확인하지만 예약하지 않는다. 확인은 읽기일 뿐이다.
  2. 결제 승인 뒤에 한 번 더 확인한다. 여기서 부족하면 주문을 취소완료로 저장하고 워커에 결제 취소를 맡긴다.
  3. 차감은 조건 없는 $inc 다. 수량이 음수가 돼도 막지 않는다. Mutex 는 한 프로세스 안에서만 줄을 세운다.
재고 1개를 두 사람이 사는 경합 애니메이션: 둘 다 주문 생성 통과, A 승인 후 차감, B 승인 후 재고 부족으로 워커가 결제 취소. 동시에 끝나면 둘 다 차감돼 재고 -1
움직이는 그림 1 · 재고 1개, 고객 2명. 위쪽은 '승인 후 취소'가 일어나는 순서이고, 구분선 아래는 두 확인이 같은 순간에 끝나 초과판매가 되는 경우다.
// 판정: 읽기만 한다
fun isEnoughStock(need: Map<String, Int>) =
    findStocks(need.keys).all { it.quantity - need.getValue(it.code) >= 0 }

// 차감: 조건 없는 $inc. Mutex 는 한 프로세스 안에서만 줄을 세운다 — 파드가 여럿이면 소용없다
suspend fun decrease(id: String, qty: Int) = mutex.withLock {
    stocks.findAndModify(where("id").isEqualTo(id), Update().inc("quantity", -qty))
}

// 승인 뒤 확인에서 부족하면: 주문은 품절 취소로 저장하고, 결제 취소는 워커에 맡긴다
if (!isEnoughStock(need)) {
    order.cancelBySoldOut()
    publishToWorker(PaymentCancelRequest(order.no))
    throw SoldOutException()
}

5-1. 왜 "취소"밖에 없나

B 의 주문에서 재고 부족을 안 시점에 B 의 돈은 이미 토스에서 승인됐다. DB 롤백으로는 이걸 되돌릴 수 없다. 남은 수단은 토스 취소 API 뿐이다. 그래서 주문을 품절 취소로 저장하고 워커에 결제 취소 요청을 보낸다. 워커는 전용 멱등키로 취소한다. FE 는 품절 취소 에러를 받으면 "품절로 결제가 취소됐다"는 안내 화면으로 보낸다.

이 순서는 V1 에서 그대로 물려받았다. V1 은 KCP 에서 결제가 끝난 뒤에야 서버가 개입하므로 재고를 승인 앞에서 볼 방법이 없었다. 승인 후 취소는 구조적으로 불가피했다. V2 는 서버가 승인 시점을 쥐었지만 재고 확인의 위치는 옮기지 않았다(최신 코드에서도 승인 뒤에 있다).

5-2. 왜 미리 빼 두지(예약) 않았나

재고 자체에 대해 이유를 적은 기록은 없다. 가장 가까운 근거는 나중에 같은 결제 경로에 "구매 한도"를 넣을 때 남긴 설명이다.

수량을 미리 차감해두는 방식은 쓰지 않았다. 취소·환불이 일어날 때마다 차감분을 되돌리는 처리를 여러 경로(어드민의 취소·환불 포함)에 추가해야 하고, 하나라도 빠뜨리면 살 수 있는데 못 사는 문제가 생긴다. … 진짜 방어선은 결제 승인 직전이다. … 걸리면 PG 승인 자체를 안 보낸다 — 재고 부족 때 쓰는 자동취소·환불 경로와 달리 승인 후 뒤처리가 필요 없다.

예약은 "되돌릴 곳"을 늘린다. 위젯에서 이탈한 주문, 결제 실패, 주문 취소, 어드민 환불, 무통장 만료처럼 모든 출구가 예약분을 돌려줘야 하고 한 곳만 빠져도 재고가 묶인다. 그 비용 대신 "승인 직후 부족하면 취소"라는 출구 하나를 뒀다.

5-3. 남은 구멍: 동시에 통과하면 취소가 아니라 초과판매

그림의 구분선 아래 경우다. A·B 의 승인 후 확인이 같은 순간에 끝나면 둘 다 "1 ≥ 1" 을 읽고 통과한다. 차감은 조건이 없으니 두 번 빠져 재고가 -1 이 된다. 이 경우 결제 취소는 일어나지 않고 차감 뒤 수량이 0 이하라서 Slack "재고 부족" 알림만 간다. 주문번호 락은 같은 주문끼리만 막고 다른 주문끼리는 막지 않는다.

개선 방향(아직 적용하지 않음): V2 는 서버가 승인 시점을 쥐고 있으니 두 가지를 할 수 있다.

  • 차감을 조건부로 바꾼다: findAndModify({_id, quantity: {$gte: n}}, {$inc: {quantity: -n}}). 0행이면 부족이다. 확인과 차감이 원자적으로 하나가 된다.
  • 그 차감을 승인 직전으로 옮기고, 승인이 실패하면 되돌린다. 되돌릴 곳이 "이 요청의 승인 실패" 하나라 예약보다 출구가 훨씬 적다.

6. 무통장입금(가상계좌)

가상계좌는 승인 응답이 DONE 이 아니라 WAITING_FOR_DEPOSIT 으로 온다. 돈은 고객이 은행에서 입금할 때 들어오고 토스가 그 사실을 콜백으로 알려 준다.

무통장입금 애니메이션: 위젯에서 가상계좌 선택, confirm 결과 입금대기, 재고 차감, 입금 콜백 수신과 secret 검증, 결제완료, 재전송 콜백은 멱등 가드로 무시, 미입금 시 00:30 만료 취소와 마일리지 쿠폰 환급
움직이는 그림 2 · 발급 → 입금 콜백 → (재전송 무시) → 미입금 만료.

6-1. 발급: 재고는 이때 뺀다

승인 결과가 입금대기면 주문 완료 처리가 이때 돈다. 재고 차감과 마일리지·쿠폰 사용이 입금 전에 일어난다. 입금을 기다리는 며칠 동안 다른 사람이 재고를 가져가지 않게 하려는 것이다. 입금 요청 알림톡도 이때 나간다(기한은 토스가 준 입금 기한). 환불 계좌는 승인 응답에서 30분 안에 한 번만 받을 수 있어 승인 직후 저장한다.

6-2. 입금 콜백: 한 번만 처리하기

suspend fun onDepositCallback(orderNo: String, status: String, secret: String?): Response {
    if (status != "DONE") return ok()                          // 입금 완료만 받는다
    if (!verifySecret(orderNo, secret)) throw InvalidSecretException()

    return withOrderLock(orderNo) {                            // 결제·취소와 같은 락
        val pg = paymentClient.getPayment(paymentKeyOf(orderNo)) // 콜백 내용 대신 토스 조회 결과를 믿는다
        verifyAmount(pg)
        markPaid(orderNo)                                      // 이미 결제완료면 멱등 가드가 흘려보낸다
    }
}

suspend fun verifySecret(orderNo: String, secret: String?): Boolean {
    if (secret == null) return false
    val pg = paymentClient.getPayment(paymentKeyOf(orderNo))
    return pg.secret == null || pg.secret == secret
}
  • 위조 방지: 콜백에 실린 secret 을, 토스에 결제를 다시 조회해 받은 secret 과 맞춰 본다. 콜백 내용을 그대로 믿지 않고 상태·금액도 토스 조회 결과로 본다.
  • 중복 방지: 토스는 콜백을 여러 번 보낼 수 있다. 주문번호 락으로 동시 처리를 막고 이미 결제완료면 멱등 가드가 그대로 성공을 돌려준다. 같은 웹훅이 동시에 여러 건 들어와도 결제 처리가 한 번만 되는지는 실제 Redis 컨테이너를 띄운 테스트로 확인했다.
  • 재고는 다시 빼지 않는다. 발급 때 이미 뺐다.

6-3. 만료: 석 달 반 동안 아무도 처리하지 않았다

순서일어난 일
처음토스 웹훅(결제 상태 변경: 만료) → 워커 만료 취소
두 번째"미사용 웹훅 핸들러 정리"로 그 핸들러를 지움. 만료 취소 메시지의 유일한 발행처가 사라짐
세 번째"V2 는 토스 웹훅이 처리한다"는 전제로 레거시 만료 스케줄러에 V2 스킵을 다시 넣음. 그 웹훅은 이미 없었다
석 달 반 뒤미입금 만료 주문이 입금대기로 남아 마일리지·쿠폰이 환급되지 않던 것을 발견, 만료 전용 처리로 복구

복구하면서 바뀐 점도 있다.

  • 만료 기준: 결제건에 남은 실제 입금기한. 아임포트 시절에는 "생성 +3일" 고정이었다.
  • 실행 시각: 토스 상점 설정이 "발급 3일 뒤 23:59" 라 만료가 자정 직전에 몰린다. 그래서 00:30 에 하루 한 번 돈다.
  • PG 실상태 가드: 만료 직전에 입금됐을 수 있다. 토스 상태가 DONE 이면 취소하지 않는다. 옛 코드는 입금 완료 건도 주문만 취소완료로 바꿔 입금액이 돌아가지 않을 수 있었다.
  • 재고는 24시간 안에 만료된 건만 복구: 몇 달 묵은 백로그의 재고를 한꺼번에 올리면, 그 사이 다른 경로로 맞춰 둔 현재 수량이 부푼다. 마일리지·쿠폰 환급과 주문 취소는 백로그에도 한다.
  • 알림톡은 보내지 않는다: 오래전에 끝난 주문에 뒤늦은 취소 안내가 가면 혼란만 준다.

"이 핸들러는 안 쓴다"와 "이건 저쪽이 처리한다"는 둘 다 남이 처리한다는 믿음이다. 두 믿음이 서로를 가리키면 아무도 처리하지 않는다. 지금은 만료 처리의 유일한 경로가 어디인지 코드 주석에 경위와 함께 적어 두었다.

6-4. 환불 계좌와 은행 코드

무통장 결제의 환불은 고객 계좌로 돈을 보내야 하므로, 환불 계좌 예금주를 토스 API 로 확인한 뒤 취소를 요청한다. 여기서 은행 코드가 두 번 발목을 잡았다.

fun toTossBankCode(raw: String): String {
    val code = raw.removePrefix("BK")          // 아임포트 형식 BK04 → 04
    return if (code == "04") "06" else code     // KB국민은행만 토스 코드가 다르다
}
  • BK 접두사: FE 와 아임포트가 쓰던 형식이 그대로 넘어와 토스가 거절했다.
  • 국민은행 코드: 표준·아임포트는 04, 토스는 06. 국민은행 계좌만 예금주 조회와 환불이 실패했다.
  • 화면에 마스킹(***)된 계좌가 그대로 다시 넘어와 예금주 검증이 실패하던 것도 막았다. * 가 있으면 DB 원본을 쓴다.

7. 그 밖에 걸린 것들

문제대응
위젯으로 만든 결제를 일반 시크릿 키로 승인위젯 시크릿 키로 통일
0원 결제(마일리지·쿠폰 전액). 토스를 부를 결제가 없다금액이 0 이면 PG 없이 처리. 실제 주문 금액도 0원일 때만 허용
영수증 없는 0원 건 취소 시 PG 를 부르려 함환불 금액이 0원이면 PG 취소를 건너뜀
기존 화면·엑셀은 아임포트 영수증 모양을 기대토스 결과를 기존 영수증 모양으로 변환(상태값도 기존 값으로)
GET 요청에 Content-Type 을 붙여 Ktor 오류Accept 로 교체
웹훅 처리 실패에 200 을 돌려주면 토스가 재시도하지 않는다실패면 500 을 돌려주는 응답 헬퍼. 단, 8장 "남은 것" 참고
운영 컨테이너가 토스 인증서 체인을 신뢰하지 못할 경우루트 CA 두 개를 컨테이너 이미지의 신뢰 저장소에 추가

토스 호출은 테스트에서 모두 mock 으로 대체돼 토스 접속 없이 돈다. 인터페이스를 도메인에 둔 덕분이다. 결제 통합 테스트는 Mongo(레플리카셋)·Redis 를 Testcontainers 로 띄운다.

8. 배운 것, 남은 것

  • 인터페이스의 진짜 효과는 "PG 교체"보다 "테스트"였다. 커머스 서버에서 구현체는 하나뿐이지만, 덕분에 결제 경로 전체를 토스 없이 통합 테스트로 돌릴 수 있다.
  • "어느 PG 인가"는 설정이 아니라 데이터로 판단한다. 토글은 새 결제의 입구만 바꾸고 이미 결제된 주문은 저장된 키를 따른다. 전환 기간 내내 옛 주문의 취소가 흔들리지 않은 이유다.
  • 돈이 움직이는 칸 앞에 무엇을 두느냐가 설계의 대부분이다. 금액·구매 한도는 승인 앞으로 갔고 재고는 아직 뒤에 있다.

남은 것:

  • 재고 확인이 승인 뒤에 있고 차감이 조건 없는 $inc 다. 동시 통과 시 초과판매가 된다(5-3).
  • 입금 콜백 처리 함수는 내부 처리 결과와 상관없이 성공을 돌려준다. 실패해도 200 이 나가 토스가 재시도하지 않을 수 있다.
  • V2 0원 결제는 결제 키 자리에 주문번호가 저장돼 PG 판별이 토스로 분류한다. 판별을 쓰는 선물 거절 경로에서 0원 V2 건이 토스 취소로 갈 수 있다(실제 발생 여부는 확인하지 못했다).
  • 웹훅 secret 대조는 토스 쪽 secret 이 비어 있으면 통과한다. 의도된 동작이지만, 콜백 내용 대신 토스 조회 결과로 처리하는 것이 실질적인 방어선이라는 점은 알고 있어야 한다.

요약

  • 아임포트(포트원)를 거쳐 NHN KCP 로 결제하던 구조(V1)를 토스페이먼츠 직접 연동(V2)으로 바꿨다. 석 달 동안 두 PG 의 결제건이 함께 살아 있었고 그 뒤 아임포트를 걷어냈다.
  • V1 은 고객이 결제창에서 이미 결제를 끝낸 뒤 서버가 대조했다. V2 는 서버가 금액을 확인한 다음 직접 승인하므로, 금액 조작은 돈이 움직이기 전에 걸러진다.
  • 벤더 무관 결제 인터페이스를 도메인에 두고, 결제건에 남은 키의 종류로 어느 PG 로 갈지 고른다. 취소·환불·만료가 들어올 때마다 이 판별로 길을 나눈다.
  • 토스 승인은 외부 호출이라 DB 트랜잭션에 넣을 수 없다. 주문번호 분산락, 승인 전 검증, 멱등키, 상태 멱등 가드, 단계별 실패 알림으로 맞춘다. 승인 뒤 자동 보상 취소는 쓰다가 걷어냈다.
  • 재고를 예약하지 않고 승인 뒤에 확인하므로, 승인 직후 재고가 없으면 이미 받은 돈을 취소할 수밖에 없다. 동시에 통과하면 취소 대신 초과판매가 되는 구멍도 남아 있다.
  • 무통장입금은 발급 시점에 재고를 빼고, 입금 콜백은 secret 대조 + 같은 락 + 멱등 가드로 한 번만 처리한다. 만료 처리는 두 변경이 서로 상대가 한다고 믿어 석 달 반 동안 아무도 처리하지 않았고 만료 전용 처리로 복구했다.

댓글

댓글을 불러오는 중…