이번 장에서는 결제 시스템을 설계해보도록 한다. 전자상거래에서 가장 중요한 것은 결제 시스템이고, 이러한 결제 시스템은 안정적이고 확장 가능하며 유연해야 한다.
위키 백과에 따르면, "결제 시스템은 금전적 가치의 이전을 통해 금융 거래를 정산하는 데 사용되는 모든 시스템"이다.
신뢰성 및 내결함성 : 결제 실패는 신중하게 처리해야 한다.
내부 서비스(결제 시스템, 회계 시스템)와 외부 시스템(결제 서비스 제공업체) 간의 조정 프로세스
이 시스템은 하루에 100만 건의 트랜잭션을 처리해야 하는데, 이는 초당 10건의 트랜잭션(TPS)이다.
10TPS는 일반적인 데이터베이스로 별 문제 없이 처리 가능한 양이므로, 처리 대역폭 대신 결제 트랜잭션의 정확한 처리에 초점을 맞춰 면접을 진행해야 한다.
결제 흐름은 자금의 흐름을 반영하기 위해 크게 두 단계로 세분화 된다.
전자상거래 사이트 아마존을 예로 들어보자. 구매자가 주문을 하면 아마존의 은행 계좌로 돈이 들어오는데, 이것이 바로 대금 수신 흐름이다.
이 돈은 아마존의 은행 계좌에 있지만 소유권이 전부 아마존에 있는 것은 아니다. 판매자가 상당 부분을 소유하며, 아마존은 수수료를 받고 자금 관리자 역할만 수행한다.
나중에 제품이 배송되고 나면, 그때까지 계좌에 묶여 있던 판매 대금에서 수수료를 제외한 잔액이 판매자의 은행 계좌로 지급된다. 이것이 대금 정산 흐름이다.
대금 수신 흐름을 개략적인 다이어그램으로 표현하면 아래 그림과 같다.
결제 서비스
결제 서비스는 사용자로부터 결제 이벤트를 수락하고 결제 프로세스를 조율한다. 일반적으로 가장 먼저 하는 일은 AML/CFT와 같은 규정을 준수하는지, 자금 세탁이나 테러 자금 조달과 같은 범죄 행위의 증거가 있는지 평가하는 위험 점검이다.
결제 서비스는 이 위험 확인을 통과한 결제만 처리한다. 일반적으로 위험 확인 서비스는 매우 복잡하고 고도로 전문화가 되어있기 때문에 제3자 제공업체를 이용한다.
결제 실행자
결제 실행자는 결제 서비스 공급자(PSP)를 통해 결제 주문 하나를 실행한다. 하나의 결제 이벤트에는 여러 결제 주문이 포함될 수 있다.
결제 서비스 공급자
PSP는 A 계정에서 B 계정으로 돈을 옮기는 역할을 담당한다. 본 예제의 경우에는 구매자의 신용 카드 계좌에서 돈을 인출하는 역할을 맡는다.
카드 유형
카드사는 신용 카드 업무를 처리하는 조직이다. 잘 알려진 카드 유형으로는 비자, 마스터카드, 디스커버리 등이 있다.
원장
원장은 결제 트랜잭션에 대한 금융 기록이다. 예를 들어 사용자가 판매자에게 1달러를 결제하면 사용자로부터 1달러를 인출하고 판매자에게 1달러를 지급하는 기록을 남긴다. 원장 시스템은 전자상거래 웹사이트의 총 수익을 계산하거나 향후 수익을 예측하는 등, 결제 후 분석에서 매우 중요한 역할을 한다.
지갑
지갑에는 판매자의 계정 잔액을 기록한다. 특정 사용자가 결제한 총 금액을 기록할 수도 있다. 위의 그림에서도 볼 수 있듯이, 일반적인 결제 흐름읕 다음과 같다.
POST /v1/payments
이 엔드포인트는 결제 이벤트를 실행한다. 앞에서 언급했듯 하나의 결제 이벤트에는 여러 결제 주문이 포함될 수 있다. 요청 매개변수는 아래와 같다.
payment_orders는 아래 형태를 가진다.
payment_order_id가 전역적으로 고유한 ID라는 점에 유의하자. 결제 실행자가 타사 PSP에 결제 요청을 전송할 때, PSP는 payment_order_id를 중복제거 ID로 사용한다. 이는 멱등키라고도 한다.
amount 필드의 데이터 유형이 double이 아닌 string이라는 것에 유의하자. 이는 아래와 같은 이유로 string 타입으로 정의한다.
따라서 전송 및 저장 시 숫자는 문자열로 보관하는 것이 좋다. 표시하거나 계산에 쓸 때만 숫자로 변환한다.
GET /v1/payments/:{id}
이 엔드포인트는 payment_order_id가 가리키는 단일 결제 주문의 실행 상태를 반환한다.
이 결제 API는 잘 알려진 일부 PSP의 API와 유사하다.
결제 서비스에는 결제 이벤트와 결제 주문의 두 개의 테이블이 필요하다.
결제 시스템용 저장소 솔루션을 고를 때 일반적으로 성능은 가장 중요한 고려사항이 아니고, 다음 사항에 중점을 둔다.
일반적으로는 NoSQL/NewSQL 보다는 ACID 트랜잭션을 지원하는 전통적인 관계형 데이터베이스를 선호한다.
결제 이벤트 테이블에는 자세한 결제 이벤트 정보가 저장된다. 테이블 스키마는 아래와 같다.
결제 주문 테이블에는 각 결제 주문의 실행 상태가 저장된다.
결제 주문 테이블에서 payment_order_status는 결제 주문의 실행 상태를 유지하는 열거 자료형 (enum) 이다. 실행 상태로는 NOT_STARTED, EXECUTING, SUCCESS, FAILED 등이 있다.
payment_order_status의 값이 SUCCESS로 되면 결제 서비스는 지갑 서비스를 호출하여 판매자 잔액을 업데이트하고 wallet_updated 필드 값은 TRUE로 업데이트 한다. 여기서는 지갑 업데이트가 항상 성공한다고 가정하고 설계를 단순화하였다.
이 절차가 끝나면 결제 서비스는 다음 단계로 원장 서비스를 호출하여 원장 데이터베이스의 ledger_updated 필드를 TRUE로 갱신한다.
동일한 checked_id 아래의 모든 결제 주문이 성공적으로 처리되면 결제 서비스는 결제 이벤트 테이블의 is_payment_done을 TRUE로 업데이트 한다. 일반적으로, 종결되지 않은 결제 주문을 모니터링 하기 위해 주기적으로 실행되는 작업을 마련해둔다. 이 작업은 임계값 형태로 설정된 기간이 지나도록 완료되지 않은 결제 주문이 있을 경우 살펴보도록 엔지니어에게 경보를 보낸다.
원장 시스템에는 복식부기라는 아주 중요한 설계 원칙이 있다. 복식부기는 모든 결제 시스템에 필수 요소이며 정확한 기록을 남기는데 핵심적인 역할을 한다. 모든 결제 거래를 두 개의 별도 원장 계좌에 같은 금액으로 기록한다. 한 계좌에서는 차감이 이루어지고 다른 계좌에서는 입금이 이루어진다.
복식부기 시스템에서 모든 거래 항목의 합계는 0이어야 한다. 이 시스템을 활용하면 자금의 흐름을 시작부터 끝까지 추적할 수 있으며 결제 주기 전반에 걸쳐 일관성을 보장할 수 있다.
대부분의 기업은 신용 카드 정보를 취급하지 않기 위해 PSP에서 제공하는 외부 신용 카드 페이지를 사용한다. 웹사이트의 경우 이 외부 신용 카드 페이지는 위젯 또는 iframe이며, 모바일 어플리케이션의 경우에는 결제 SDK에 포함된 사전에 구현된 페이지다. 결국 우리의 결제 서비스가 아니라 PSP가 제공하는 외부 결제 페이지가 직접 고객 카드 정보를 수집한다는 것이다.
대금 정산 흐름의 구성 요소는 대금 수신 흐름과 아주 유사하다. 한 가지 차이는 PSP를 사용하여 구매자의 신용 카드에서 전자상거래 웹사이트 은행 계좌로 돈을 이체하는 대신, 정산 흐름에서는 타사 정산 서비스를 사용하여 전자상거래 웹사이트 은행 계좌에서 판매자 은행 계좌로 돈을 이체한다는 점이다.
일반적으로 결제 시스템은 대금 정산을 위해 티팔티와 같은 외상매입금 지급 서비스 제공 업체를 이용한다.
대부분의 회사는 다음 두 가지 방법 중 하나로 결제 시스템을 PSP와 연동한다.
아래는 외부 결제 페이지의 작동 방식을 설명한 것이다.
지금까지 외부 결제 페이지가 잘 동작할 때 시스템들이 어떻게 상호 연동하는지 설명했다. 그러나 실제로는 위의 아홉 단계 각각이 네트워크 문제로 실패할 수 있다. 실제로 장애가 발생하면 체계적으로 처리할 수 있는 방법이 있을까?
조정(reconciliation)이 바로 그 방법이다.
시스템 구성 요소가 비동기적으로 통신하는 경우 메시지가 전달되거나 응답이 반환된다는 보장이 없다. 이는 시스템 성능을 높이기 위해 비동기 통신을 자주 사용하는 결제 관련 사업에 일반적인 문제다. PSP나 은행 같은 외부 시스템도 비동기 통신을 선호한다. 그렇다면 어떻게 정확성을 보장할 수 있을까?
답은 조정이다. 관련 서비스 간의 상태를 주기적으로 비교하여 일치하는지 확인하는 것이다.
매일 밤 PSP나 은행은 고객에게 정산 파일을 보낸다. 정산 파일에는 은행 계좌의 잔액과 하루 동안 해당 계좌에서 발생한 모든 거래 내역이 기재되어 있다. 조정 시스템은 정산 과일의 세부 정보를 읽어 원장 시스템과 비교한다.
조정은 결제 시스템의 내부 일관성을 확인할 때도 사용된다. 예를 들어, 원장과 지갑의 상태가 같은지 확인할 수 있다.
조정 중에 발견된 차이는 일반적으로 재무팀에 의뢰하여 수동으로 고친다.
발생 가능한 불일치 문제 및 해결 방안은 다음 세 가지 범주로 나눌 수 있다.
앞서 설명한 것처럼 결제 요청은 많은 컴포넌트를 거치며, 내부 및 외부의 다양한 처리 주체와 연동한다. 대부분의 경우 결제 요청은 몇 초만에 처리되지만, 완료되거나 거부되기까지 몇 시간 또는 며칠이 걸리는 경우도 있다. 다음은 결제 요청이 평소보다 오래 걸리게 되는 몇 가지 사례다.
결제 서비스는 처리하는 데 시간이 오래 걸리는 이런 요청도 처리할 수 있어야한다. 구매 페이지가 외부 PSP에 호스팅 되는 경우(요즘은 아주 일반적인 관행) PSP는 다음과 같이 처리한다.
결제 요청이 최종적으로 완료되면 PSP는 방금 언급한 사전에 등록된 웹혹을 호출한다. 결제 서비스는 내부 시스템에 기록된 정보를 업데이트하고 고객에게 배송을 완료한다.
내부 서비스 통신에는 동기식과 비동기식의 두 가지 패턴이 있다.
동기식 통신
HTTP와 같은 동기식 통신은 소규모 시스템에서는 잘 작동하지만 규모가 커지면 단점이 분명해진다. 동기식 통신에서 한 요청에 응답을 만드는 처리 주기는 관련된 서비스가 많을수록 길어진다. 단점은 다음과 같다.
- 성능 저하: 요청 처리에 관계된 서비스 가운데 하나에 발생한 성능 문제가 전체 시스템의 성능에 영향을 끼친다.
- 장애 격리 곤란: PSP 등의 서비스에 장애가 발생하면 클라이언트는 더 이상 응답을 받지 못한다.
- 높은 결합도: 요청 발신자는 수신자를 알아야만 한다.
- 낮은 확장성: 큐를 버퍼로 사용하지 않고서는 갑자스러운 트래픽 중가에 대
웅할 수 있도록 시스템을 확장하기 어렵다.
비동기 통신
비동기 통신은 크게 두 가지 범주로 나눌 수 있다.
단일 수신자: 각 요청(메시지)은 하나의 수신자 또는 서비스가 처리한다. 일반적으로 공유 메시지 큐를 사용해 구현한다. 큐에는 복수의 구독자가 있을 수 있으나 처리된 메시지는 큐에서 바로 제거된다. 아래 그림을 보면 서비스 A와 서비스 B는 모두 같은 메세지 큐를 구독한다.
서비스 A와 서비스 B가 각각 m1과 m2를 처리하면 그림과 같이 두 메시지는 모두 큐에서 사라진다.
다중 수신자: 각 요청(메시지)은 여러 수신자 또는 서버가 처리한다. 카프카는 이런 시나리오를 잘 처리할 수 있다. 소비자가 수신한 메시지는 카프카에서 바로 사라지지 않는다. 따라서 동일한 메시지를 여러 서비스가 받아 처리할 수 있다. 따라서 결제 시스템 구현에 적합한데, 하나의 요청이 푸시 알림 전송, 재무 보고 업데이트, 분석 결과 업데이트 등의 다양한 용도에 쓰 일 수 있기 때문이다. 아래 그림을 보면 카프카에 발행된 하나의 결제 이벤트가 결제 시스템, 분석 서비스, 결제 청구 서비스 등에 입력으로 활용된다.
일반적으로 보자면 동기식 통신은 설계하기는 쉽지만 서비스의 자율성을 높이기에는 적합하지 않다. 의존성 그래프가 커지면 전반적 성능은 낮아진다. 비동기 통신은 설계의 단순성과 데이터 일관성을 시스템 확장성 및 장애 감내 능력과 맞바꾼 결과다. 비즈니스 로직이 복잡하고 타사 서비스 의존성이 높은 대규모 결제 시스템에는 비동기 통신이 더 나은 선택이다.
모든 결제 시스템은 실패한 결제를 적절히 처리할 수 있어야 한다. 안정성 및 결합 내성은 결제 시스템의 핵심적 요구사항이다. 이 문제를 해결하는 몇 가지 기법을 알아보자.
결제 상태 추적
결제 주기의 모든 단계에서 결제 상태를 정확하게 유지하는 것은 매우 중요하다. 실패가 일어날 때마다 결제 거래의 현재 상태를 파악하고 재시도 또는 환불이 필요한지 여부를 결정한다. 결제 생태는 데이터 추가만 가능한 데이터베이스 테이블에 보관한다.
재시도 큐 및 실패 메세지 큐
실패를 잘 처리하기 위해서는 아래 구조와 같이 재시도 큐와 실패 메시지 큐를 두는 것이 바람직하다.
실무에서 이런 큐가 어떻게 쓰이는지 궁금하다면 우버에서 카프카를 활용해 결제 시스템 안정성과 결함 내성 요건을 어떻게 충족하고 있는지 살펴보면 좋다.
결제 시스템에 발생 가능한 가장 심각한 문제 중 하나는 고객에게 이중으로 청구하는 것이다. 결제 주문이 정확히 한 번만 실행되도록 결제 시스템을 설계하는 것이 중요하다.
언뜻 보기에 메시지를 정확히 한 번 전달하는 것은 매우 어려운 문제처럼 느껴지지만, 문제를 두 부분으로 나누면 훨씬 쉽게 해결할 수 있다. 수학적으로 보자면, 다음의 요건이 충족되면 주어진 연산은 정확히 한 번 실행된다.
지금부터 재시도를 통해 최소 한 번 실행을 보증하는 방법과, 멱등성 검사를 통해 최대 한 번 실행을 보증하는 방법을 알아보도록 하겠다.
재시도
간혹 네트워크 오류나 시간 초과로 인해 결제 거래를 다시 시도해야 하는 경우가 있다. 재시도 메커니즘을 활용하면 어떤 결제가 최소 한 번은 실행되도록 보장 가능하다.
예를 들어, 아래 그림에서와 같이 클라이언트가 10단러 결제를 시도하지만 네트워크 연결 상태가 좋지 않아 결제 요청이 계속 실패하는 경우를 생각해 보자. 이 사례에서는 네트워크가 결국 복구되어 네 번째 시도 만에 요청이 성공한다.
재시도 메커니즘을 도입할 때는 얼마나 간격을 두고 재시도할지 정하는 것이 중요하다. 일반적으로 사용되는 전략은 다음과 같다.
적절한 재시도 전략을 결정하는 것은 어렵다. '모든 상황에 맞는' 해결책은 없다. 다만 일반적으로 적용 가능한 지침은, 네트워크 문제가 단시간 내에 해결 될 것 같지 않다면 지수적 백오프를 사용하라는 것이다. 지나치게 공격적인 재시도 전략은 컴퓨딩 자원을 낭비하고 서비스 과부하를 유발한다. 에러 코드를 반환할 때는 Retry-After 헤더를 같이 붙여 보내는 것이 바람직하다.
재시도 시 발생할 수 있는 잠재적 문제는 이중 결제다. 다음 두 가지 시나리오를 살펴보자.
시나리오 1: 결제 시스템이 외부 결제 페이지를 통해 PSP와 연동하는 환경에서 클라이언트가 결제 버튼을 두 번 중복 클릭한다.
시나리오 2: PSP가 결제를 성공적으로 하였으나 네트위크 오류로 인해 응답이 결제 시스템에 도달하지 못했다. 사용자가 '결제' 버튼을 다시 클릭하거나 클라이언트가 결제를 다시 시도한다.
이중 결제를 방지하려면 결제는 '최대 한 번' 이루어져야 한다. '최대 한 번 실 행'은 다른 말로 멱등성이라고도 부른다.
멱등성은 최대 한 번 실행을 보장하기 위한 핵심 개념이다. API 관점에서 보자면 멱등성은 클라이언트가 같은 API 호출을 여러 번 반복해도 항상 동일한 결과가 나온다는 뜻이다.
클라이언트와 서버 간의 통신을 위해서는 일반적으로 클라이언트가 생성하고 일정 시간이 지나면 만료되는 고유한 값을 멱등키로 사용한다. 스트라이프, 페이팔 같은 많은 기술 회사가 UUID를 멱등키로 권장하며 실제로 널리 쓰인다. 결제 요청의 멱등성을 보장하기 위해서는 HTTP 헤더에 <멱등 키: 값>의 형태로 멱등 키를 추가하면 된다.
그럼 이 멱등성을 가지고 이중 결제 문제를 어떻게 해결할까 ?
시나리오 1: 고객이 '결제' 버튼을 빠르게 두 번 클릭하는 경우
아래 그림에서 사용자가 '결제'를 클릭하면 멱등 키가 HTTP 요청의 일부로 결제 시스템에 전송된다. 전자상거래 웹사이트에서 멱등 키는 일반적으로 결제가 이루어지기 직전의 장바구니 ID이다.
결제 시스템은 두 번째 요청을 재시도로 처리하는데, 요청에 포함된 멱등 키를 이전에 받은 적이 있기 때문이다. 그런 경우 결제 시스템은 이전 결제 요청의 가장 최근 상태를 반환한다.
동일한 멱등 키로 동시에 많은 요청을 받으면 결제 서비스는 그 가운데 하나만 처리하고 나머지에 대해서는 429 Too Many Requests 상태 코드를 반환한다.
멱등성을 지원하는 한 가지 방법은 데이터베이스의 고유 키 제약 조건을 활용하는 것이다. 예를 들어, 데이터베이스 테이블의 기본 키를 멱등 키로 사용한다. 그 경우 시스템은 다음과 같이 동작한다.
시나리오 2: PSP가 결제를 성공적으로 처리했지만 네트워크 오류로 응답이 결제 시스템에 전달되지 못하여, 사용자가 '결제' 버튼을 다시 클릭하는 경우
위의 그림을 다시보자. 2단계 및 3단계에서 결제 서비스는 PSP에 비중복 난수를 전송하고 PSP는 해당 난수에 대응되는 토큰을 반환한다. 이 난수는 결제 주문을 유일하게 식별하는 구실을 하며, 해당 토큰은 그 난수에 일대일로 대응된다. 따라서 토큰 또한 결제 주문을 유일하게 식별 가능하다.
사용자가 '결제' 버튼을 다시 누른다 해도 결제 주문이 같으니 PSP로 전송되는 토큰도 같다. PSP는 이 토큰을 멱등 키로 사용하므로, 이중 결제로 판단하고 종전 실행 결과를 반환한다.
결제 실행 과정에서 상태 정보를 유지 관리하는 여러 서비스가 호출된다.
분산 환경에서는 서비스 간 통신 실패로 데이터 불일치가 발생할 수 있다. 지금부터 결제 시스템에서 발생 가능한 데이터 일관성 문제를 해결하는 기법들을 살펴보자.
내부 서비스 간에 데이터 일관성을 유지하려면 요청이 '정확히 한 번 처리'되도록 보장하는 것이 아주 중요하다.
내부 서비스와 외부 서비스(PSP) 간의 데이터 일관성 유지를 위해서는 일반적으로 멱등성과 조정 프로세스를 활용한다. 외부 서비스가 멱등성을 지원하는 경우, 결제를 재시도할 때는 같은 멱등 키를 사용해야 한다. 그러나 외부 서비스가 멱등 API를 지원하더라도 외부 시스템이 항상 옳다고 가정할 수는 없으므로, 조정 절차를 생략할 수는 없다.
데이터를 다중화하는 경우에는 복제 지연으로 인해 기본 데이터베이스와 사본 데이터가 불일치하는 일이 생길 수 있다. 일반적으로 이 문제에는 두 가지 해결 방법이 있다.
사이버 공격과 카드 도난에 대응하기 위한 몇 가지 기술을 간략하게 살펴보자.
이번 장에서는 대금 수신 흐름과 정산 흐름을 살펴보았다. 결제 시스템은 많이 복잡하기 때문에 많은 주제를 다뤘지만 아직 언급하고 넘어갈 주제가 많다. 대표적으로는 아래와 같은 것들이 있으니, 참고하면 좋을 것 같다.