Post

결제 시스템 구축하기

저희의 핵심 요구사항은 외부 PG사(Payment Gateway) 를 활용해 안정적인 결제 시스템을 제공하는 것입니다. 특히 보안 및 전자금융거래법, 개인정보보호법 등 규제 준수를 위해 카드 번호 등 민감 정보는 절대 시스템에 저장하지 않고 PG사에 위임해야 했습니다. 내수 서비스이므로 통화는 KRW(원화)로 단일화했습니다.

또한 결제는 사용자 계정, 상품 주문, 재고, 정산 등 여러 내부 서비스와 상호작용합니다. 이 과정에서 특정 서비스 장애, 네트워크 오류, 외부 PG 응답 지연 등으로 인해 서비스 간 상태 불일치(정합성 문제) 가 쉽게 발생할 수 있습니다.

따라서 시스템은 다음 두 가지 설계 원칙을 만족해야 합니다.

1) 조정(Compensation) 처리 필요
외부 연동 과정에서 일부 단계만 성공하거나 실패할 경우, 이상 상태를 탐지하고 사후 정정할 수 있어야 합니다.
예: PG 승인 성공 → DB 기록 실패 같은 상황을 감지하고 재처리.

2) 멱등성(Idempotency) 보장
중복 요청, 재시도, 네트워크 타임아웃 등을 대비하여 동일한 결제 요청은 한 번만 처리 되도록 해야 합니다.

이를 통해 알 수 있는 기능적 요구사항은 다음과 같았습니다.

  • 결제 주문 생성 (CreateOrder)
  • 결제 승인 처리(Confirm) 및 PG 연동
  • 결제 취소 처리(Cancel)
  • 결제 상태 관리 (Initiated → Authorized → Confirmed → Canceled → Failed)
  • 원장(Ledger) 기록 저장
  • 판매자/사용자 지갑 잔액 업데이트

비기능적 요구사항은 다음과 같습니다.

  • 멱등성 보장 (Idempotency-Key)
  • 짧은 트랜잭션(조회 → 상태 변경만 처리)
  • 외부 I/O(PG) 호출을 트랜잭션에서 분리
  • 장애 발생 시 재처리 가능해야 함
  • 실패 원인에 따라 도메인 에러코드 매핑
  • 중복 승인 방지 및 동시성 제어

일단 이번 블로그에는 이론적 설명을 주로 다루고 그래서 어떻게 설계했는가는 다음에 다시 작성하도록 하겠습니다.

개략적 결제 설계안

실제 현금이 움직이는 결제 시스템은 자금 흐름에 따라 크게 두 단계로 나눌 수 있습니다.

  1. 대금 수신 흐름 (Authorization & Capture)
  2. 대금 정산 흐름 (Settlement)

이 구조가 유명한 2-Phase 결제 패턴 입니다.

대금 수신 흐름

전자상거래 사이트 아마존을 예로 들어봅시다. 아마존에서 사용자가 주문을 하면 실제 돈은 아마존의 예치 계정으로 들어오지만, 법적 소유권은 아마존이 아닌 판매자에게 귀속됩니다. 아마존은 일시적으로 자금 관리자 역할을 수행하며, 이 가운데 일부를 수수료로 취합니다.
이 단계에서 시스템은 주로 결제 승인(Authorize/Confirm) 만 처리합니다.

대금 정산 흐름

상품이 배송되거나 구매 확정 시점에:
판매 대금에서 수수료를 제외하고
잔액을 판매자 계정(지갑)으로 지급하는 단계입니다.
이 프로세스는 종종 일 단위 / 배치 작업 / 회계 시스템 연동을 통해 처리됩니다.

Untitled-(29).png

이러한 대금 흐름을 개략적인 다이어그램으로 표현하면 다음과 같은데요, 이 시스템의 각 구성요소를 함께 살펴봅시다.

결제 서비스

결제 서비스는 사용자로부터 결제 이벤트를 수락하고 결제 프로세스를 조율합니다. 일반적으로 가장 먼저 하는 일은 AML/CFT 와 같은 규정을 준수하는지, 자금 세탁이나 테러 자금 조달과 같은 범죄 행위의 증거가 있는지 평가하는 위험 점검입니다. 결제 서비스는 이 위험 확인을 통과한 결제만 처리합니다. 일반적으로 이러한 위험 확인 서비스는 매우 복잡하고 고도로 전문화되어 있기에 제3자 제공업체를 이용합니다.

저희는 일반적인 케이스와 같이 PSP를 통해 제3자 제공업체를 이용할 예정이었기에, PSP와 서비스의 분리가 중요하다고 생각했습니다. 따라서 다음을 설계 포인트로 잡았습니다.

  • 애플리케이션 계층에서 흐름을 조율
  • @Transactional + TransactionTemplate 기반으로 짧은 트랜잭션만 수행
  • PG 연동 호출은 트랜잭션 밖에서 수행 (네트워크 영향 제거)

구현에서 중요한 포인트는 다음과 같았습니다:

  • confirm() 은 3단계로 나뉜 2-Phase 흐름
  • 멱등키 선점 (PaymentCommandLog)
  • 예약(AUTHORIZED) 처리 (Payment.lockByOrderId)
  • PG 승인 호출 및 결과 반영 + Ledger 기록

Untitled-(30).png

결제 실행자

결제 실행자는 결제 서비스 공급자 (PSP)를 통해 결제 주문 하나를 실행합니다. 하나의 결제 이벤트는 여러 결제 주문을 포함할 수 있습니다.

이는 PaymentProvider 를 따로 두어 결제 실행자 역할을 수행하도록 설계했습니다.

  • 인터페이스: PaymentProvider
  • 실제 구현: provider.confirm, provider.cancel
  • 역할: 외부 PG사(Toss 등)에 결제 승인/취소 요청 이때, 네트워크 요청은 트랜잭션 밖에서 처리하도록하여 데드락 & 롤백 리스크 제거하고자 했습니다.

결제 서비스 공급자

결제 서비스 공급자, 즉 PSP 는 A 계정에서 B 계정으로 돈을 옮기는 역할을 담당합니다.
저희 서비스에서는 Toss 를 이용하기로 했습니다.

카드 유형

카트사는 신용 카드 업무를 처리하는 조직으로, 잘 알려진 카드 유형으로는 비자, 마스터카드, 디스커버리 등이 있지만, 이 생태계는 매우 복잡해 넘어가도록 하겠습니다.

원장

원장은 결제 트랜잭션에 대한 금융 기록입니다. 예를 들어 사용자가 판매자에게 1달러를 결제하면 사용자로부터 1달러를 인출하고 판매자에게 1달러를 지급하는 기록을 남깁니다. 원장 시스템은 전자상거래 웹사이트의 총 수익을 계산하거나 향후 수익을 예측하는 등 결제 후 분석에서 매우 중요한 역할을 합니다.

따라서 저는 모든 확정(confirmed) / 취소(canceled) / 실패(failed) 상태 변화에 원장 기록을 남기고자 했습니다. 나중에 정산, 회계 처리, 리포팅, 금전 불일치 추적 분석를 대비하여 결제 이력을 모두 회계 시스템에 남기고자 했습니다.

지갑

지갑에는 판매자의 계정 잔액을 기록합니다. 특정 사용자가 결제한 총 금액을 기록할 수도 있습니다. 다만, 저는 원장을 중심으로 설계하고 지갑까지 따로 유도하진 않았습니다. 원장을 쌓아놓으면 언제든 지갑 합산을 유도할 수 있다고 생각했기 때문입니다.

이번 글에서는 대략적인 설계에 대한 초안 작성이 주이기 때문에, 세부적으로 어떻게 구현했고, 왜 구현했는지는 다음 글에 작성하도록 하겠습니다.

그림에서 볼 수 있듯, 일반적 결제 흐름은 다음과 같습니다.

  1. 사용자가 주문하기 버튼을 클릭하면 결제 이벤트가 생성되어 결제 서비스로 전송
  2. 결제 서비스는 결제 이벤트를 데이터베이스에 저장
  3. 때로는 단일 결제 이벤트에서 여러 결제 주문이 포함될 수 있다. 한 번 결제로 여러 판매자의 제품을 처리하는 경우가 그 예시. 전자상거래 웹사이트에서 한 결제를 여러 결제 주문으로 분할하는 경우, 결제 서비스는 결제 주문마다 결제 실행자를 호출.
  4. 결제 실행자는 결제 주문을 데이터베이스에 저장.
  5. 결제 실행자가 외부 PSP를 호출하여 신용 카드 결제를 처리.
  6. 결제 실행자가 결제를 성공적으로 처리하고 나면 결제 서비스는 지갑을 갱신해 특정 판매자 잔고 기록.
  7. 지갑 서버는 갱신된 잔고 정보를 데이터베이스에 저장.
  8. 지갑 서비스가 판매자 잔고를 성공적으로 갱신 시 결제 서비스는 원장을 호출.
  9. 원장 서비스는 새 원장 정보를 데이터베이스에 추가.

결제 서비스 데이터 모델

결제 서비스에는 결제 이벤트와 결제 주문의 두 개의 개념을 유지하는 것이 일반적입니다. 따라서 대부분의 전자상거래/PG 시스템은 다음과 같이 설계합니다.

  • 결제 이벤트: 사용자가 “결제하기” 버튼을 누른 행위
  • 결제 주문: 실제로 요청되는 결제 건(다건 가능) Amazon, 쿠팡, 배민 등 대부분의 전자상거래에서 동일한 개념을 사용합니다. 하나의 결제 이벤트 안에는 여러 판매자 또는 여러 상품이 포함될 수 있기 때문에 결제는 종종 N개의 결제 주문으로 분리됩니다.

결제 시스템용 저장소 솔루션을 고를 때 일반적으로 성능은 가장 중요한 고려사항이 아닙니다. 대신 다음 사항에 중점을 둡니다.

  1. 안정성: 안정성이 검증되었는가? 즉, 다른 대형 금융 회사에서 수년동안 긍정적 피드백을 받으며 사용된 적 있는가?
  2. 운영성: 모니터링 및 데이터 탐사에 필요한 도구가 풍부하게 지원되는가?
  3. 인력 수급: 데이터베이스 관리자 (DBA) 채용 시장이 성숙했는가? 다시 말해 숙련된 DBA를 쉽게 채용할 수 있는가?

일반적으로 NoSQL 보다는 ACID 트랜잭션을 지원하는 전통적 관계형 DB를 선호합니다. 우리 시스템에서도 Payment / CommandLog / Ledger 저장소는 RDB를 사용합니다. 결제 이벤트 테이블에는 자세한 결제 이벤트 정보가 저장됩니다.

결제 주문 테이블에서 Payment Status 는 결제 주문의 실행 상태를 유지하는 열거 자료형이며, 실행 상태로는 NOT_STARTED, EXECUTING, SUCCESS, FAILD 등이 있습니다.

우리 시스템의 실제 Enum은 비슷한 구조지만, 좀 더 PG 연동 중심으로 튜닝되어 있습니다. 업계 개념을 우리 코드에 대응시키면 다음과 같습니다.

1
INITIATED → AUTHORIZED → CONFIRMED → CANCELED / FAILED
일반 용어실제 구현 PaymentStatus
NOT_STARTEDINITIATED
EXECUTINGAUTHORIZED
SUCCESSCONFIRMED
FAILEDFAILED 또는 CANCELED

즉, 이론적 설계는 그대로 유지하되, PaymentService는 PG 연동 플로우에 맞게 세분화했습니다.

업데이트 로직은 다음과 같습니다.

  1. Payment Status 의 초깃값은 NOT_STARTED 이다.
  2. 결제 서비슨느 결제 실행자에 주문을 전송하면 status 값을 EXECUTING 으로 변경한다.
  3. 결제 서비스는 결제 처리자의 응답에 따라 status 값을 SUCCESS 또는 FAILED 로 바꾼다.

지갑 서비스가 있다는 가정 하에, Payment 의 Status 값이 SUCCESS 로 결정되면 결제 서비스는 지갑 서비스를 호출해 판매자 잔액을 업데이트하고 wallet_updated 필드 값을 TRUE로 업데이트합니다.

이 절차가 끝나고 나면 결제 서비스는 다음 단계로 원장 서비스를 호출해 원장 데이터베이스의 ledger_updated 필드를 TRUE로 갱신합니다.

동일한 checkout_id 아래 모든 결제 주문이 성공적으로 처리되면 결제 서비스는 결제 이벤트 테이블의 is_payment_doneTRUE 로 업데이트합니다. 이는 결제 업계에서 통용되는 후처리(Payout/Settlement) 2단계 모델입니다.

일반적으로 종결되지 않은 결제 주문을 모니터링 하기 위해 주기적으로 실행되는 작업(scheduled job) 을 마련해둡니다. 이 작업은 임계값 형태로 설정된 기간이 지나도록 완료되지 않은 결제 주문이 있을 경우 살펴보도록 엔지니어에게 경보를 보냅니다.

저는 지갑 서비스를 따로 두지 않았으므로, 실제 구현(PaymentService.confirm())은 다음과 같이 설계하고자 했습니다.

  1. INITIATED 로 저장 (createOrder)
  2. authorized() 로 예약 (A-2 단계)
  3. confirmed() 또는 failed() (PG 승인 결과 반영)

또한 원장 업데이트는 LedgerEntry를 통해 다음과 같이 대응하고자 했습니다.

1
2
3
4
5
6
7
8
9
10
paymentRepo.append(
    LedgerEntry.of(
        managed.getOrderId(),
        "USER:{user}",
        "MERCHANT:eco",
        amount,
        now,
        "confirm"
    )
);

이를 통해 다음을 만족하고자 했습니다.

  • 주문 상태 → 재무 변화 기록
  • 지갑 잔액 추적 가능
  • 정산 시스템 확장 가능

즉, wallet / ledger 플래그 대신 append-only ledger를 사용하도록 설계했습니다.

복식부기 원장 시스템

원장 시스템에는 복식부기라는 아주 중요한 설계 원칙이 있는데요. 복식부기는 모든 결제 시스템에 필수 요소이며 정확한 기록을 남기는데 핵심적 역량을 합니다. 모든 결제 거래를 두 개의 별도 원장 계좌에 같은 금액을 기록합니다. 한 계좌에서는 차감이 이루어지고 다른 계좌에서는 입금이 이루어집니다.

복식부기 시스템에서 모든 거래 항목의 합계는 0이어야 합니다. 이 시스템을 활용하면 자금 흐름을 시작부터 끝까지 추적 가능하며 결제 주기 전반에 걸쳐 일관성을 보장할 수 있습니다.

이를 지키기 위해 다음과 같이 대응하고자 했습니다.

1
2
3
4
- `LedgerEntry`에는 `sourceAccount` 와 `targetAccount`가 있다.
- 항상 같은 금액을 양쪽에 기록한다.
- `USER`:`{id}` → 차감
- `MERCHANT`:`{id}` → 증가 <BR>

이 구조로 인해 원장 합계 = 0이 되는 것을 보장하도록 했습니다.

외부 결제 페이지

대부분 기업은 신용카드 정보를 내부에 저장하지 않는데 이는 신용 카드 정보를 내부에 저장할 경우 복잡한 규정을 준수해야하기 때문입니다.

신용 카드 정보를 취급하지 않기에 PSP 에서 제공하는 외부 신용 카드 페이지를 사용할 것입니다.

또한 PCI DSS + 전자금융거래법을 회피하기 위해 다음을 지키고자 했습니다.

1
2
3
- `PaymentService`는 `paymentKey`, `orderId`, `amount`만 저장한다.
- 카드번호, CVC, 만료일은 전부 PG사의 `Hosted Payment Page`에서 처리한다.
- 백엔드 DB에는 카드 관련 정보를 저장하지 않는다.

대금 정산 흐름

대금 정산 (pay-out) 흐름의 구성 요소는 대금 수신 흐름과 아주 유사합니다. 한가지 차이는 PSP를 사용해 구매자의 신용카드에서 전자상거래 웹사이트 은행 계좌로 돈을 이체하는 대신, 정산 흐름에서는 타사 정산 서비스를 사용해 전자상거래 웹사이트 은행 계좌에서 판매자 은행 계좌로 돈을 이체한다는 점입니다.

일반적으로 결제 시스템은 대금 정산을 위해 외상매입금 지급 서비스 제공업체를 이용합니다. 대금 정산에도 다양한 부기 및 규제 요구사항이 있기 때문입니다.

이를 위해 다음과 같이 확장 가능하다 생각했습니다.

  • LedgerEntry 기반으로 정산 집계 가능
  • Scheduled Job 으로 일일 정산 배치 가능
  • 정산 실패 시 (은행 API 문제)

PaymentCommandLog와 동일한 방식으로 재처리 가능
다만, 공수 상 일단 확장성으로 두고 넘어가겠습니다.

상세 설계

이제 시스템을 더 빠르고 안전하게 만들어 봅시다. 분산 시스템에서 오류와 장애는 피할 수 없을 뿐만 아니라 흔한 일입니다. 예를 들어 고객이 ‘결제’ 버튼을 여러 번 누르면 어떻게 될까요? 여러 번 요금이 청구되어야 할까요? 네트워크 연결 불량으로 인해 결제 실패는 어떻게 처리해야 할까요?

따라서 다음에 대해 설계해볼 예정입니다.

  • PSP 연동
  • 조정 (Reconciliation)
  • 결제 지연 처리
  • 내부 서비스 간 통신
  • 결제 실패 처리
  • 정확히 한 번 전달
  • 일관성
  • 보안

PSP 연동

결제 시스템이 은행이나 카드 네트워크(VISA, MasterCard 등)에 직접 연결할 수 있다면 PSP 없이 결제가 가능합니다. 그러나 이런 직접 연결은 아주 큰 회사(아마존, 페이팔, 토스, 신용카드사급)에서 가능한 방식입니다. 대부분의 기업은 PSP(Payment Service Provider)를 통해 결제를 수행합니다.

따라서 PSP 연동 방식은 다음 두 가지로 나눌 수 있습니다.

1. 민감 정보 직접 수집 방식 (Direct API 방식)

회사가 신용카드 정보를 직접 수집하고 저장하는 방식입니다.

  • 회사는 자체 결제 페이지를 개발
  • 사용자 카드 정보를 수집
  • PSP는 은행 연결 및 카드 라우팅 처리

그러나 이 방식은 PCI-DSS / 전자금융거래법 / 암호화 요구 등을 모두 만족해야 하고 감사, 모니터링, 보안 비용이 매우 큽니다. 따라서 이 방식은 국내에서는 대부분 PG 직접 입점한 대기업(카카오페이, 네이버페이, 쿠팡페이, 토스 같은 회사)에서 사용합니다.

2. 외부 결제 페이지 방식 (Hosted Payment Page)

복잡한 규정 및 보안 문제를 피하기 위해 민감 결제 정보를 저장하지 않겠다고 결정하는 경우 대부분 이 방식을 사용합니다.

  • PSP가 카드 입력 폼을 제공
  • 회사는 paymentKey, amount, orderId 같은 정보만 저장
  • 모든 인적/민감 정보는 PSP 쪽에서 처리

제가 채택한 방식도 해당 방식인데요, 카드 번호, 만료일, CVC, 소유자 이름을 저장하지 않는 대신 paymentKeyorderId만 저장합니다. 따라서 DB에는 민감 정보가 남지 않도록 합니다.

Untitled-(28).png

그림에서는 간결한 설명을 위해 결제 실행자, 원장, 지갑 등은 생략했습니다. 결제 서비스가 전체 결제 프로세스를 조율합니다.

  1. 사용자가 클라이언트 브라우저에서 ‘결제’ 버튼을 클릭합니다. 클라이언트는 결제 주문 정보를 담아 결제 서비스를 호출합니다.
  2. 결제 주문 정보를 수신한 결제 서비스는 결제 등록 요청을 PSP 로 전송합니다. 이 등록 요청에는 결제 금액, 통화, 결제 요청 만료일, 리다렉션 URL 등 결제 정보가 포함됩니다. 결제 주문이 정확히 한 번만 등록될 수 있도록 UUID 필드를 둡니다. 이 UUID 는 비중복 난수라고도 부르는데, 일반적으로 이를 결제 주문 ID 로 사용합니다.
  3. PSP 는 결제 서비스에 토큰을 반환합니다. 토큰은 등록된 결제 요청을 유일하게 식별하는, PSP가 발급한 UUID 입니다. 나중에 이 토큰을 사용해 결제 등록 및 결제 실행 상태를 확인할 수 있습니다.
  4. 결제 서비스는 PSP 가 제공하는 외부 결제 페이지를 호출하기 전 토큰을 데이터베이스에 저장합니다.
  5. 토큰을 저장하면 클라이언트는 PSP 가 제공하는 외부 결제 페이지를 표시합니다. 따라서 외부 결제 페이지는 일반적으로 다음 두 가지 정보를 필요로 합니다.

    a. 4단계에서 받은 토큰 : PSP 의 자바스크립트 코드는 이 토큰을 사용해 PSP 의 백엔드에서 결제 요청에 대한 상세 정보를 검색합니다. 이 과정을 통해 알아내야 하는 중요 정보 하나는 사용자에게서 받을 금액입니다.

    b. 리디렉션 URL : 결제가 완료되면 호출될 웹페이지 URL 입니다. PSP 자바스크립트 결제가 완료되면 브라우저는 리디렉션 URL 로 돌려보냅니다. 일반적으로 리디렉샨 URL 은 결제 상태를 표시하는 전자상커래 웹사이트상 한 페이지로, 9 단계의 웹훅 URL 과는 다릅니다.

  6. 사용자는 신용 카드 번호, 소유자 이름, 카드 유효기간 등 결제 세부 정보를 PSP 웹페이지에 입력한 다음 결제 버튼을 클릭하고, PSP 는 결제 처리를 시작합니다.
  7. PSP 가 결제 상태를 반환합니다.
  8. 이제 사용자는 리디렉션 URL 이 가리키는 웹 페이지로 보내집니다. 이때 보통 7 단계에서 수신된 결제 상태가 URL 에 추가됩니다.
  9. 비동기적으로 PSP는 웹훅을 통해 결제 상태와 함께 결제 서비스를 호출합니다. 웹훅은 결제 시스템 측에서 PSP 를 처음 설정할 때 등록한 URL 입니다. 결제 시스템이 웹훅을 통해 결제 이벤트를 다시 수신하면 결제 상태를 추출하여 결제 주문 데이터베이스 테이블의 status 필드를 최신 상태로 업데이트합니다.

이 흐름을 저희 코드와 대응하면 다음과 같습니다.

1. 사용자가 결제 버튼 클릭 (클라이언트 → 서버)

사용자가 브라우저에서 ‘결제하기’ 버튼을 클릭하면 클라이언트는 주문 정보(orderId, amount)를 기반으로 결제 요청을 시작합니다. 이 시점에서 서버는 아직 결제를 확정하지 않습니다. 단지 “결제 프로세스가 시작되었다”는 맥락만 가집니다.

2. 결제 요청 등록 (클라이언트 → PSP)

클라이언트는 Toss가 제공하는 결제 위젯을 호출하며 다음 정보를 전달합니다.

1
2
3
4
- 결제 금액 (amount)
- 주문 번호 (orderId)
- 주문 이름 (orderName)
- 성공/실패 리디렉션 URL (successUrl, failUrl)

이 단계에서 중요한 점은, 결제 금액·통화·리디렉션 URL 같은 정보는 이 단계에서 이미 PSP에 등록된다는 점입이다. 즉, 이 정보들은 나중에 백엔드에서 다시 Toss로 보내지지 않습니다.

3. PSP가 결제 토큰 발급 (paymentKey)

PSP는 결제 요청을 하나의 고유한 결제 객체로 생성하고, 이를 식별하기 위한 paymentKey 를 발급합니다.

이때, paymentKey는 PSP가 관리하는 UUID 이며 이후 승인(confirm), 취소(cancel), 조회는 모두 이 값을 기준으로 수행됩니다.

즉, PSP 관점에서의 결제 식별자입니다.

4. 결제 토큰을 내부 상태로 저장 (짧은 트랜잭션 ①)

결제창으로 이동하기 전에, 서버는 이 결제를 내부적으로 “예약 상태”로 저장합니다. 이때 수행되는 작업은 다음과 같습니다.

1
2
3
4
- 멱등키(Idempotency-Key) 선점
- PaymentCommandLog 생성 (IN_PROGRESS)
- Payment 상태를 AUTHORIZED 로 전이
- 필요 시 orderId 기준 락 획득

이 모든 작업은 아주 짧은 DB 트랜잭션(REQUIRES_NEW) 안에서 처리됩니다.

이 단계의 목적은 “이 결제 요청은 우리가 책임지고 처리 중이다” 라는 사실을 DB에 남기는 것입니다.

5. 외부 결제 페이지 표시 (클라이언트 → PSP)

클라이언트는 Toss의 외부 결제 페이지로 이동합니다. 사용자는 이 페이지에서 카드 번호, 만료일, CVC 등 민감한 결제 정보를 입력한다.

이 정보들은 절대 서버로 전달되지 않습니다.

1
2
- 카드 정보는 PSP에서만 처리
- 서버는 paymentKey만 알고 있음

즉, 보안 및 규제(Payments / PCI-DSS)를 만족하기 위한 구조입니다.

6. 사용자가 결제 완료 (PSP 내부 처리)

PSP는 카드사 승인, 인증, 한도 체크 등을 수행한다. 이 과정은 전적으로 PSP 내부에서 일어난다. s 이 시점에서도 서버는 아직 결제 성공 여부를 확정하지 않습니다.

7. 결제 결과 리디렉션 (PSP → 클라이언트)

결제가 완료되면 PSP는 브라우저를 successUrl로 리디렉션하며 다음 세 값을 쿼리 파라미터로 전달합니다.

1
2
3
- paymentKey
- orderId
- amount

이 세 값이 백엔드 결제 승인(confirm)에 필요한 전부입니다.

8. 결제 승인 요청 (백엔드 → PSP, 트랜잭션 없음)

이제 서버는 Toss의 결제 승인 API를 호출합니다.

1
2
3
4
5
{
  "paymentKey": "...",
  "orderId": "...",
  "amount": 10000
}

이 호출은 의도적으로 DB 트랜잭션 밖에서 수행합니다. 이 이유에 대해서는 추후 설명하겠습니다.

9. 결제 결과 반영 (짧은 트랜잭션 ② + Webhook)

PSP 승인 결과를 받은 뒤, 서버는 다시 짧은 트랜잭션을 열어 다음 작업을 수행합니다.

1
2
3
4
- `Payment` 상태 전이
- `CONFIRMED` / `FAILED` / `CANCELED`
- `LedgerEntry` 원장 기록 추가 (복식부기)
- `PaymentCommandLog` 상태를 `SUCCEEDED` 또는 `FAILED` 로 종료

이미 멱등키가 선점되어 있기 때문에, 이 단계는 재시도에도 안전합니다.

또한 PSP는 결제 승인 결과를 동기 응답뿐 아니라 Webhook으로도 비동기 전송합니다. 이는 결제 시스템에서 매우 일반적인 설계이며, 단순한 중복 전달이 아니라 안정성을 높이기 위한 의도된 중복입니다.

우리 시스템에서도 결제 승인 결과는 다음 두 경로로 도달할 수 있는데요,

  1. 동기 경로
    • successUrl → 서버 → confirm(paymentKey, orderId, amount)
  2. 비동기 경로
    • PSP → Webhook → 결제 서비스

이 두 경로는 서로 다른 타이밍, 서로 다른 네트워크 조건에서 도착할 수 있습니다. 따라서 “한 번만 온다”는 가정을 하면 안 되고, 중복·지연·순서 뒤바뀜을 전제로 설계해야 합니다.

이때 핵심이 되는 개념이 바로 조정(Reconciliation) 입니다.

이러한 조정에 대해서는 바로 다음에 이어 작성하겠습니다.

image.png

image.png

조정

결제 시스템은 내부 서비스뿐 아니라 PSP, 은행, 카드사 등 외부 시스템과 비동기적으로 통신합니다. 비동기 통신의 특성상 다음은 절대 보장되지 않습니다.

1
2
3
4
- 메시지가 반드시 전달된다는 보장
- 응답이 반드시 돌아온다는 보장
- 응답이 항상 한 번만 온다는 보장
- 응답이 올바른 순서로 온다는 보장

결제 도메인에서는 성능과 안정성을 위해 이런 비동기 구조를 의도적으로 선택합니다. 따라서 정확성은 “즉시”가 아니라 사후 검증과 조정으로 보장합니다.

이 역할을 하는 것이 조정(Reconciliation) 이며, 결제 시스템에서는 흔히 마지막 방어선으로 간주됩니다.

외부 시스템과의 조정 (PSP / 은행)

매일 밤 PSP 나 은행은 고객에게 정산 (settlement) 파일을 보냅니다. 정산 파일에는 은행 계좌의 잔액과 하루 동안 해당 계좌에서 발생한 모든 거래 내역이 기재되어 있습니다. 조정 시스템은 정산 파일의 세부 정보를 읽어 내부 원장(LedgerEntry) 시스템과 비교합니다.

PSP 기준으로 성공한 결제인데 우리 시스템의 Payment가 FAILED로 남아 있다면 → 명백한 불일치

이런 경우를 탐지하기 위해 조정은 필수입니다.

다음 그림은 조정 프로세스가 시스템의 어디에서 이루어지는지를 보여줍니다.

Untitled-(30).png

내부 시스템 간 조정 (Payment ↔ Ledger)

조정은 결제 시스템의 내부 일관성을 확인할 때도 사용됩니다. 예를 들어, 원장과 지갑의 상태가 같은지를 확인할 수 있습니다.

우리 시스템 기준으로 보면 다음 관계가 중요합니다.

1
2
3
- `Payment` 상태 = `CONFIRMED`
- 해당 결제에 대한 `LedgerEntry` 존재
- `PaymentCommandLog` 상태 = `SUCCEEDED`

이 셋 중 하나라도 어긋나면 조정 대상입니다.

예를 들어:

1
2
- `Payment`는 `CONFIRMED`인데 `LedgerEntry`가 없음
- `CommandLog`는 `IN_PROGRESS`인데 `Payment`는 이미 `CONFIRMED`

이런 상황은:

1
2
3
- 트랜잭션 경계 문제
- 프로세스 중단
- 서버 장애

등으로 충분히 발생할 수 있습니다.

조정 결과 처리 방식

보통 조정 중 발견된 차이는 일반적으로 재무팀에 의뢰해 수동으로 고칩니다. 발생 가능한 불일치 문제 및 해결방안은 다음 3가지 범주로 나눌 수 있는데요,

1. 어떤 유형의 문제인지 알고 있고 문제 해결 절차를 자동화할 수 있는 경우 : 원인과 해결 방안을 알고, 자ㅇ화 프로그램을 작성하는 것이 비용 효율적인 경우. 엔지니어는 발생한 불일치 문제의 분류와 조정 작업을 모두 자동화할 수 있다.

시스템 내 예시 :

PSP는 승인 성공 → PaymentAUTHORIZED 상태에 머물러 있음 → confirm() 재실행 가능

PaymentCONFIRMEDCommandLogIN_PROGRESSCommandLogSUCCEEDED로 보정

이런 경우는 배치/스케줄러로 자동 조정 가능합니다.

2. 어떤 유형의 문제인지 알지만 문제 해결 절차를 자동화할 수는 없는 경우: 불일치의 원인과 해결방안을 알고는 있지만 자동 조정 프로그램의 작성 비용이 너무 높다. 발생한 불일치 문제는 작업 대기열에 넣고 재무팀에서 수동으로 수정.

예시 :

1
2
- 승인 시점과 취소 시점이 애매하게 겹친 경우
- 환율, 수수료 정책이 개입된 경우

이런 경우는:

1
2
- 조정 대상 큐에 적재
- 재무/운영팀이 수동 처리

3. 분류할 수 없는 유형의 문제인 경우: 불일치가 어떻게 발생했는지 알지 못하는 경우로, 이런 불일치 문제는 특별 작업 대기열에 넣고 재무팀에서 조사.

즉, 이 경우는:

1
2
- 별도 조사 큐로 이동
- 원인 분석 후 처리

결제 지연 처리

앞서 설명한 것처럼 겨레 요청은 많은 컴포넌트를 거치며, 내와 외부 다양한 처리 주체와 연동합니다. 대부분 경우 결제 요청은 몇 초 안에 처리되지만, 완료되거나 거부되기까지 몇 시간 또는 며칠이 걸리는 경우도 있습니다. 다음은 결제 요청이 평소보다 오래 걸리는 몇 가지 사례입니다.

  • PSP 가 해당 결제 요청의 위험성이 높다 보고 담당자 검토를 요구하는 경우

  • 신용 카드사가 구매 확인 용도로 카드 소유자의 추가 정보를 요청하는 3D 보안 인증 같은 추가 보호 장치를 요구하는 경우

결제 서비스는 처리하는데 시간이 오래 걸리는 이런 요청도 처리할 수 있어야 합니다. 구매 페이지가 외부 PSP 에 호스팅 되는 경우, PSP 는 다음과 같이 처리합니다.

  • PSP 는 결제와 대기 (pending) 상태임을 알리는 상태 정보를 클라이언트에 반환하고, 클라이언트는 이를 사용자에게 표시합니다. 클라이언트는 또한 고객이 현재 결제 상태를 확인할 수 있는 페이지도 제공합니다.

  • PSP 는 우리 회사를 대신해 대기 중인 결제의 진행 상황을 추척하고, 상태가 바뀌면 PSP 에 등록된 웹훅을 통해 결제 서비스를 알립니다.

결제 요청이 최종적으로 완료되면 PSP는 방금 언급한 사전 등록 웹훅을 호출합니다. 결제 서비스는 내부 시스템에 기록된 정보를 업데이트하고 고객에게 배송 완료합니다.

이때 우리 서버는: Webhook 수신 시 Payment 상태를 갱신하고 Ledger 반영 또는 대기 유지합니다.

대안으로, 어떤 PSP는 웹훅을 통해 결제 서비스에 결제 상태 변경을 알리는 대신, 결제 서비스로 하여금 대기 중인 결제 요청의 상태를 주기적으로 확인 (polling) 하기도 합니다.

결제 실패 처리

모든 결제 시스템은 실패한 결제를 적절히 처리할 수 있어야 합니다. 안정성 및 결함 내성은 결제 시스템의 핵심적 요구사항입니다.

이 문제를 해결하는 몇 가지 기법을 알아봅시다.

결제 상태 추척

결제 주기의 모든 단계에서 결제 상태를 정확하게 유지하는 것은 매우 중요한데요, 실패가 일어날 때마다 결제 거래의 현재 상태를 파악하고 재시도 또는 환불이 필요한지 여부를 결정합니다. 결제 상태는 데이터 추가만 가능한 데이터베이스 테이블에 보관합니다.

우리 시스템에서는:

1
2
3
- `Payment` 상태 전이 기록
- `PaymentCommandLog` 상태 기록
- `LedgerEntry`는 `append-only`

을 채택합니다.

따라서 이 구조 덕분에 언제, 어떤 단계에서 실패했는지 추적 가능하며, 재시도/조정의 기준점 확보가 가능합니다.

재시도 큐 및 실패 메세지 큐

실패를 우아하게 처리하기 위해선 그림과 같이 재시도 큐와 실패 메세지 큐를 두는 것이 바람직합니다.

  • 재시도 큐 : 일시적 오류 같은 재시도 가능 오류는 재시도 큐에 보낸다.
  • 실패 메세지 큐 : 반복적으로 처리에 실패한 메세지는 결국 실패 메세지 큐로 보낸다. 이 큐는 문제가 있는 메세지를 디버깅하고 격리해 성공적으로 처리되지 않는 이유를 파악하기 위한 검사에 유용하다.

Untitled-(31).png

  1. 재시도가 가능한지 확인한다.

    a. 재시도 가능 실패는 재시도 큐로 보낸다.
    b. 잘못된 입력과 같이 재시도가 불가능한 실패는 오류 내역을 데이터베이스에 저장한다.

  2. 결제 시스템은 재시도 큐에 쌓인 이벤트를 읽어 실패한 결제를 재시도한다.

  3. 결제 거래가 다시 실패하는 경우엔 다음과 같이 처리한다.

    a. 재시도 횟수가 임계값 이내라면 해당 이벤트를 다시 재시도 큐로 보낸다.
    b. 재시도 횟수가 임계값을 넘으면 해당 이벤트를 실패 메세지 큐로 넣는다. 이런 이벤트에 대해선 별도 조사가 필요할 수 있다.

현재 시스템에서는 큐를 직접 사용하지 않지만, PaymentCommandLog가 동일한 역할을 수행합니다.

1
2
3
- IN_PROGRESS → 재시도 가능
- FAILED → 추가 재시도 차단
- 실패 사유 문자열(failureReason) 저장

이는 추후:

1
2
- 메시지 큐(Kafka, SQS)
- 재시도 워커

로 확장 가능한 구조입니다.

정확히 한 번 전달

결제 시스템에 발생 가능한 가장 심각한 문제 중 하나는 고객에게 이중으로 청구하는 것입니다. 걀제 주문이 정확히 한 번만 실행되도록 결제 시스템을 설계하는 것이 중요한데요.

언뜻 보기엔 메세지를 정확히 한 번 전달하는게 매우 어려운 문제처럼 느껴지지만, 문제를 두 부분으로 나누면 훨씬 쉽게 해결할 수 있습니다. 수하걱으로 보자면 다음 요건이 충족하면 주어진 연산은 정확히 한 번 실행됩니다.

1. 최소 한 번은 실행된다.
2. 최대 한 번 실행된다.

지금부터 재시도를 통해 최소 한 번 실행을 보장하는 방법과, 멱등성 검사를 통해 최대 한 번 실행을 보증하는 방법을 알아보고자 합니다.

재시도

간혹 네트워크 오류나 시간 초과로 인해 결제 거래를 다시 시도해야하는 경우가 있습니다. 재시도 메커니즘을 활용하면 어떤 결제가 최소 한 번은 실행되도록 보장 가능합니다. 예를 들어, 그림과 같이 클라이언트가 10달러 결제를 시도하지만 네트워크 연결 상태가 좋지 않아 결제 요청이 계속 실패하는 경우를 생각해봅시다. 이 사례에서는 네트워크가 결국 복구되어 4번째 시도만에 요청이 성공합니다.

Untitled-(32).png

재시도 메커니즘 도입 시 얼마나 간격을 두고 재시도할지 정하는 것이 중요한데요, 일반적으로 사용되는 전략은 다음과 같습니다.

  • 즉시 재시도 : 클라이언트는 즉시 요청을 다시 보낸다.
  • 고정 간격: 재시도 전 일정 시간 기다리는 방안.
  • 증분 간격 : 재시도 전 기다리는 시간을 직전 재시도 대비 2배씩 늘려가는 방안. 예를 들어, 요청에 처음 실패하면 1초 후 재시도하고, 두번째 실패하면 2초, 세 번째 실패 시 3초를 기다린 후 재시도.
  • 취소 : 요청을 철회하는 방안. 실패가 영구적이거나 재시도를 하더라도 성공 가능성이 낮은 경우 흔히 사용.

적절한 재시도 전략을 결정하는 건 어려운데요. 모든 상황에 맞는 해결책은 없지만, 일반적으로 적용 가능한 지침은 네트워크 문제가 단시간에 해결될 것 같지 않다면 지수적 백오프를 사용하라는 것입니다.

지나치게 공격적인 재시도 전략은 컴퓨팅 자원을 낭비하고 서비스 과부하를 유발합니다. 에러 코드 반환 시 Retry-After 헤더를 같이 붙여 보내는 것이 바람직합니다.

재시도 시 발생할 수 있는 잠재적 문제는 이중 결제인데요. 다음 두 가지 시나리오를 생각해봅시다.

  • 시나리오 1 : 결제 시스템이 외부 결제 페이지를 통해 PSP 와 연동하는 환경에서 클라이언트가 결제 버튼을 두 번 중복 클릭
  • 시나리오 2 : PSP 가 결제를 성공적으로 했으나 네트워크 오류로 인해 응답이 결제 시스템에 도달하지 못해 사용자가 결제 버튼을 다시 클릭하거나 클라이언트가 결제 재시도

이중 결제를 방지하려면 결제는 '최대 한 번' 이루어져야 하는데요, 최대 한 번 실행을 다른 말로 멱등성이라 부릅니다.

멱등성

멱등성은 최대 한 번 실행을 보장하기 위한 핵심 개념입니다. 이는 연산을 여러 번 실행해도 최초 실행 결과가 그대로 보존되는 특성을 일컫는데요.

API 관점에서 보자면 멱등성은 클라이언트가 같은 API 호출을 여러 번 반복해도 항상 동일한 결과가 나온다는 뜻입니다.

클라이언트와 서버 간 통신을 위해선 일반적으로 클라이언트가 생성하고 일정 시간 지나면 만료되는 고유값을 멱등 키로 사용합니다. 또한 많은 회사가 UUID 를 멱등 키로 권장하며 실제로 널리 쓰이는데요, 저희가 채택한 토스페이먼츠 또한 마찬가지입니다. 따라서 결제 요청의 멱등성을 보장하기 위해서는 HTTP 헤더에 <멱등키 : 값>의 형태로 멱등 키를 추가하면 됩니다.

저희 서비스에서도 최대 한 번 실행을 보장하기 위해

1
2
3
- Idempotency-Key
- PaymentCommandLog
- Terminal 상태(CONFIRMED / FAILED / CANCELED) 방어

을 사용합니다.

이제 이러한 멱등성이 어떻게 이중 결제 문제 해결에 도움이 되는지 알아보겠습니다.

시나리오 1: 고객이 결제 버튼을 빠르게 2번 클릭하는 경우

그림에서 사용자가 결제를 클릭하면 멱등키가 HTTP 요청의 일부로 결제 시스템에 전송됩니다. 전자상거래 웹사이트에서 멱등 키는 일반적으로 결제가 이루어지기 전 장바구니 ID 입니다. 결제 시스템은 두 번째 요청을 재시도로 처리하는데, 요청에 포함된 멱등 키를 이전에 받은 적 있기 때문입니다. 그런 경우, 결제 시스템은 이전 결제 요청을 가장 최근 상태로 반환합니다.

jemog-eobs-eum-(2).png

동일한 멱등키로 동시에 많은 요청을 받으면 결제 서비스는 그 가운데 하나만 처리하고 나머지에 대해서는 429 Too Many Requests 상태 코드를 반환합니다.

멱등성을 지원하는 한 가지 방법은 데이터베이스의 고유 키 제약 조건을 활용하는 것입니다. 예를 들어, 데이터베이스 테이블의 기본 키를 멱등 키로 사용합니다. 그 경우 시스템은 다음과 같이 동작합니다.

  1. 결제 시스템은 결제 요청을 받으면 데이터베이스 테이블에 새 레코드를 넣으려 시도합니다.
  2. 새 레코드 추가에 성공했다는 것은 이전에 처리한 적 없는 결제 요청이라는 뜻입니다.
  3. 새 레코드 추가에 실패했다는 것은 이전에 받은 적 있는 결제 요청이라는 뜻이라, 그런 중복 요청은 처리하지 않습니다.

시나리오 2: PSP가 결제를 성공적으로 처리했으나 네트워크 오류로 응답이 결제 시스템에 전달되지 못해, 사용자가 결제 버튼을 다시 클린한 경우

결제 서비스는 PSP 에 비중복 난수를 전송하고 PSP 는 해당 난수에 대응되는 토큰을 반환합니다. 이 난수는 결제 주문을 유일하게 식별하는 구실을 하며, 해당 토큰은 그 난수에 일대일로 대응됩니다. 따라서 토큰 또한 결제 주문을 유일하게 식별 가능한데요.

사용자가 결제 버튼을 다시 누른다 해도 결제 주문이 같으니 PSP 로 전송되는 토큰도 같습니다. PSP는 이 토큰을 멱등 키로 사용하므로, 이중 결제로 판단하고 종전 실행 결과를 반환합니다.

일관성

결제 실행 과정에서 상태 정보를 유지 관리하는 여러 서비스가 호출됩니다.

  1. 결제 서비스는 비중복 난수, 토큰, 결제 주문, 실행 상태 등의 결제 관련 데이터를 유지 관리합니다.
  2. 원장은 모든 회계 데이터를 보관합니다.
  3. 지갑은 판매자의 계정 잔액을 유지합니다.
  4. PSP는 결제 실행 상태를 유지합니다.
  5. 데이터는 안정성을 높이기 위해 데이터베이스 사본에 복제될 수 있습니다.

분산 환경에서는 서비스 간 통신 실패로 데이터 불일치가 발생할 수 있습니다. 결제 시스템에서 발생 가능한 데이터 일관성 문제를 해결하는 기법을 살펴보겠습니다.

내부 서비스 간에 데이터 일관성을 유지하려면 요청이 '정확히 한 번 처리' 되도록 보장하는 것이 아주 중요합니다. 내부 서비스와 외부 서비스 간의 데이터 일관성 유지를 위해서는 일반적으로 멱등성과 조정 프로세스를 활용합니다. 외부 서비스가 멱등성을 지원하는 경우, 결제를 재시도할 때는 같은 멱등키를 사용해야 합니다. 그러나 외부 서비스가 멱등 API를 지원하더라도 외부 시스템이 항상 옳다고 가정할 수는 없으므로 조정 절차를 생략할 수 없습니다. 데이터를 다중화하는 경우에는 복제 지연으로 인해 기본 데이터베이스와 사본 데이터가 불일치하는 일이 생길 수 있습니다. 일반적으로 이 문제에서는 두 가지 해결 방법이 있습니다.

  1. 주 데이터베이스에서만 읽기와 쓰기 연산을 처리합니다. 이 접근법은 설정하기는 쉽지만 규모 확장성이 떨어진다는 단점이 있습니다.
  2. 모든 사본이 항상 동기화되도록 합니다. 따라서 합의 알고리즘을 사용하거나 합의 기반 분산 데이터베이스를 사용합니다.

결제보안

결제보안 또한 매우 중요한데요, 사이버 공격이나 카드 도난에 대응하기 위한 몇 가지 기술을 간략하게 살펴보겠습니다.

문제해결책
요청/응답 도청 (request/response eavesdropping)HTTPS 사용
데이터 변조 (data tempering)암호화 및 무결성 강화 모니터링
중간자 공격 (man-in-the middle attack)인증서 고정(certificate pinning)고 함께 SSL 사용
데이터 손실여러 지역에 걸쳐 데이터베이스 복제 및 스냅숏 생성
카드 도난토큰화(tokenization). 실제 카드 번호를 사용하는 대신 토큰을 저장하고 결제에 사용
PCI 규정 준수PCI DSS는 브랜드 신용 카드를 처리하는 조직을 위한 정보 보안 표준
사기(fraud)주소 확인, 카드 확인번호(CVV), 사용자 행동분석 등

현재 저희 시스템에서는 조정이 완벽하게 구현이 되어있지 않습니다. 조정을 관리자의 수동 처리 영역으로 남겨두고 있고, Webhook 수신 처리(웹훅 엔드포인트) + 조정 배치(주기적 비교) 부분은 아직 진행되어 있지 않는데요, 추후 자동 조정으로 확장한 후 추가 블로그 글을 작성해두겠습니다.

This post is licensed under CC BY 4.0 by the author.