Step1. Understand the Problem and Establish Design Scope
결제 시스템은 각각의 사람들에게 다른 의미로 받아들여질 수 있다. 몇몇은 애플페이나 구글 페이와 같은 디지털 월렛 개념으로 생각할수도 있고 몇몇은 페이팔이나 스트라이프처럼 결제를 담당하는 백엔드 시스템으로 생각할 수 있다. 따라서 인터뷰를 시작하기 전 요구사항을 명확히 해야한다.
- 나: 어떤 종류의 결제 시스템을 만들어야하나요?
- 면접관: 아마존과 같은 이커머스의 결제 시스템입니다. 아마존에서 사용자가 주문을 진행하면 결제 시스템은 돈의 이동에 관한 모든 것을 처리합니다.
- 나: 신용카드, 페이팔, 계좌이체 등 여러가지 종류 중 어디까지 지원해야하나요?
- 면접관: 이번 면접에서는 신용카드만을 대상으로 합니다.
- 나: 신용카드 결제처리를 직접 다뤄야하나요?
- 면접관: 아닙니다. 스트라이프와 같은 서드파티를 사용합니다.
- 나: 시스템에 신용카드 정보를 저장해야하나요?
- 면접관: 보안과 관련된 문제로 인해 해당 정보를 직접 저장하진 않습니다. 서드파티에서 다뤄주는 기능에 의존합니다.
- 나: 전세계 적으로 사용되는 시스템인가요? 각 나라마다의 화폐와 국제 결제를 지원해야하나요?
- 면접관: 좋은 질문입니다. 맞습니다. 전 세계를 대상으로 동작해야하지만 본 인터뷰에서는 하나의 화폐만 사용한다고 가정합니다.
- 나: 하루에 얼마나 많은 결제 트랜잭션이 발생하나요?
- 면접관: 하루에 100만건이 발생합니다.
- 나: 아마존과 같은 이커머스 시스템처럼 판매자에게 매달 정산하는 흐름도 구현해야 하나요?
- 면접관: 맞습니다.
- 나: 더 알아야할 사항이 있나요?
- 면접관: 결제 시스템은 수많은 내부 시스템(계정, 분석 등)과 외부 시스템(PG사 등)과 연동됩니다. 따라서 그 중 하나의 서비스라도 실패한다면 데이터의 불일치가 발생할 수 있습니다. 그러므로 이러한 불일치를 수정할 수 있어야합니다. 이 또한 요구사항 중 하나입니다.
Functional requirements
- Pay-in flow: 결제 시스템은 고객에게 판매자를 대신하여 돈을 받는다.
- Pay-out flow: 결제 시스템은 전 세계에 있는 판매자에게 돈을 보낸다.
Non-functional requirements
- 신뢰성과 내결함성. 결제 실패는 신중하게 처리되어야 한다.
- 내부 서비스와 외부 서비스 간 조정 프로세스가 필요하다. 그리고 이러한 프로세스는 시스템 전체의 결제 정보가 일치하는지 비동기적으로 확인한다.
Back-of-the-envelope estimation
시스템은 하루에 100만개의 트랜잭션을 처리해야하며 이는 1,000,000 트랜잭션 / 10^5초 = 초당 10 트랜잭션(TPS)이다. 10 TPS는 일반적인 데이터베이스에서 큰 숫자가 아니다. 따라서 이 시스템 설계 인터뷰의 초점은 높은 처리량을 목표로 하는것이 아니라 결제 시스템이 거래를 올바르게 처리하는지에 대해 맞춰져있다.
Step2. Propose High-Level Design and Get Buy-In
결제 흐름은 다음의 두 단계로 분류된다.
- Pay-in flow
- Pay-out flow
아마존과 같은 이커머스의 예제를 살펴보자. 구매자가 주문을 하면 돈은 아마존의 계좌로 들어가고 이게 pay in이다. 돈은 아마존 계좌에 있지만 아마존 소유는 아니다. 대부분은 판매자의 소유이며 아마존은 수수료를 받고 돈을 관리하는 역할만 수행한다. 이후 제품이 배송되면 수수료를 제외한 잔액이 아마존 계좌에서 판매자의 계좌로 이체된다. 이게 pay out이다. Figure 1은 이를 간단하게 도식화로 나타낸다.
Pay-in flow
Figure 2는 pay in 흐름을 나타낸다. 각 세부 요소들을 살펴보자.
Payment service
결제 서비스는 유저에게 결제 이벤트를 받은 후 결제 프로세스를 조율한다. 일반적으로 가장 먼저 하는 일은 특정 규정 준수 여부 평가와 자금 세탁, 테러 자금 조달과 같은 범죄 활동이 아닌지 점검하는 것이다. 이러한 검사가 통과된 결제건만이 이후 프로세스로 넘어간다. 이런 검사 로직은 굉장히 복잡하므로 보통 서드 파티에서 담당한다.
Payment executor
Payment executor는 Payment Service Provider를 통해 결제를 실행한다. 결제 이벤트에는 여러 결제 주문이 포함될 수 있다.
Payment Service Provider (PSP)
PSP는 A에서 B의 계좌로 돈을 이체한다. 위 예제에서는 구매자의 신용카드 계좌에서 돈을 이체한다.
Card schemes
Card schemes는 신용카드를 운영하는 조직이다. Visa, MasterCard, Discovery등이 있다.
Ledger
Ledger(원장)는 거래 내역을 기록한다. 예를 들어 유저가 판매자에게 1달러를 지불하면 이를 유저의 차변 1달러로 기록하고 판매자에게 1달러를 입금한다. 원장 시스템은 이커머스 사이트의 총 수익을 계산하거나 향후 수익을 예측하는 등 분석에 사용되므로 매우 중요하다.
Wallet
Wallet은 판매자의 계좌 잔액을 보관한다. 또한 특정 유저가 총 지불한 금액을 기록할수도 있다. Figure 2에 나와있듯이 일반적인 흐름은 다음과 같다.
- 유저가 주문하기 버튼을 클릭하면 결제 이벤트가 생성되어 결제 시스템에 전달된다.
- 결제 시스템은 해당 이벤트를 데이터베이스에 저장한다.
- 경우에 따라 하나의 주문 이벤트에 여러개의 주문이 담겨있을수도 있다. 예를 들어 결제 과정에서 여러 판매자의 제품을 선택하고 한번에 결제하는 행위등이다. 만약 이러한 결제건을 여러개로 나눈다면 결제 시스템은 Payment executor에 의해 하나의 주문 이벤트가 아닌 여러 주문 이벤트로 분할하여 호출될 것이다.
- Payment executor는 데이터베이스에 주문을 저장한다.
- Payment executor는 신용카드 결제 처리를 위해 외부 PSP를 호출한다.
- Payment executor의 결제 작업이 성공하면 결제 시스템은 판매자에게 얼마 만큼의 금액이 지급됐는지 기록한다.
- 지갑 서버는 수정된 잔액을 데이터베이스에 저장한다.
- 지갑 서버가 판매자의 잔액 정보를 성공적으로 업데이트한 이후 결제 서비스는 Ledger를 호출하여 업데이트를 지시한다.
- Ledger는 새로운 ledger 정보를 데이터베이스에 저장한다.
APIs for payment service
결제 시스템은 RESTful API로 디자인한다.
POST /v1/payments
이 엔드포인트는 결제 이벤트를 실행시킨다. 위에서 언급했듯이 단건 주문 이벤트에 여러개의 주문이 포함될 수 있다. 파라미터는 다음과 같다.
payment_orders는 다음과 같다.
payment_order_id가 글로벌 유니크함에 주목하자. Payment executor가 PSP에 결제 요청을 보낼 때 payment_order_id는 PSP에서 중복 제거된 키로써 사용된다. (또한 멱등성있는 키)
또 살펴보면 amount필드가 double이 아니라 string타입인 걸 알 수 있다. double은 좋은 선택이 아닌데 그 이유는,
- 다양한 프로토콜, 소프트웨어, 하드웨어는 역직렬화 시 각기 다른 precision을 지원할 수 있다. 이 차이로 인해 의도하지 않은 반올림 오류가 발생할 수 있다.
- amount가 굉장히 크거나 굉장히 작을 수 있다.
따라서 전송 또는 저장할 때 문자열 포맷을 유지하길 권장한다. 이는 실제 표시나 계산에 사용될때만 숫자로 변경해서 사용된다.
GET /v1/paymenrs/{id}
이 엔드포인트는 payment_order_id에 해당하는 결제 실행 상태를 조회한다. 위에서 언급한 결제 API는 일부 잘 알려진 PSP의 API와 유사하다.
The data model for payment service
결제 서비스를 위해서는 2개의 테이블이 필요하다. 각각 결제 이벤트와 결제 주문이라는 테이블이다. 우리가 결제 시스템을 위한 저장소를 결정할 때 퍼포먼스는 그렇게 큰 중요 요소가 아니다. 대신 아래와 같은 사항에 집중한다.
- 입증된 안정성. 다른 대형 금융권 회사에서 해당 저장소를 수년동안 사용해왔을 때 긍정적인 피드백을 받았는지에 대한 여부
- 모니터링 및 조사 툴 같은 지원 도구가 많은 저장소
- 시장에서 다룰 수 있는 DBA가 많은 저장소
보통 우리는 NoSQL/NewSQL보다 ACID 트랜잭션을 지원하는 관계형 데이터 베이스를 선호한다.
결제 이벤트 테이블은 결제 이벤트 정보의 세부 사항들을 포함한다.
결제 주문 테이블은 각 주문의 실행 상태를 저장한다.
테이블에 대해 자세히 살펴보기 전에 몇가지 배경 지식에 대해 다뤄보자.
- checkout_id는 FK이다. 하나의 체크아웃은 여러개의 주문을 포함한 결제 이벤트를 생성한다.
- 구매자의 신용카드에서 금액을 인출하기 위해 서드파티 PSP를 호출해도 돈이 판매자에게 직접 이체되지는 않는다. 대신 돈은 이커머스의 계좌로 이체된다. 이러한 흐름을 pay in이라고 부른다. 이후 배달 완료 등의 pay out 조건이 만족되면 판매자는 pay out 프로세스를 시작한다. 그래야만 이커머스 계좌에서 판매자의 계좌로 돈이 이체된다. 그러므로 pay in 흐름에서 우린 판매자의 계좌는 알 필요가 없고 오직 구매자의 카드 정보만 있으면 된다.
결제 주문 테이블에서 payment_order_status는 Enum 타입으로써 결제 주문의 상태를 관리한다. 이는 NOT_STARTED, EXECUTING, SUCCESS, FAILED등이 되겠다. 이 상태의 업데이트 로직은 다음과 같다.
- 최초 상태는 NOT_STARTED이다.
- 결제 서비스가 payment executor에게 결제 주문 이벤트를 보내면 상태는 EXECUTING으로 변경된다.
- 그리고 payment executor의 응답에 따라 SUCCESS 또는 FAILED로 상태를 변경한다.
payment_order_status가 SUCCESS이면 결제 서비스는 지갑 서비스를 호출하여 판매자 잔액을 업데이트하고 wallet_updated 필드를 true로 변경한다. 여기서는 지갑 업데이트가 항상 성공한다고 가정하여 설계를 단순화시킨다.
지갑 업데이트가 완료되면 결제 서비스의 다음 단계는 ledger(원장 서비스)를 호출하여 ledget_updated 필드를 true로 변경하는 것이다. 동일한 checkout_id를 가진 모든 결제 주문이 성공했을 때 결제 서비스는 결제 이벤트 테이블의 is_payment_done 필드를 true로 변경한다. 또한 배치 작업이 진행중인 결제 주문의 상태를 모니터링하기 위해 특정 주기로 실행된다. 그리고 결제 주문이 특정 임계값 내에 완료되지 않으면 개발자가 이를 확인할 수 있게 알림을 보낸다.
Double-entry ledger system
원장 시스템에는 double-entry라는 굉장히 중요한 디자인 원칙이 있다. Double-entry 시스템은 모든 결제 시스템의 핵심이자 정확한 회계 장부이다. 이는 모든 결제 트랜잭션을 동일한 금액으로 별도 원장 계정에 기록한다.
Double-entry 시스템의 모든 거래 항목의 합은 0이 되어야함을 나타낸다. 1센트를 잃었다는 것은 다른 사람이 1센트를 얻었다는 것을 의미한다. 이 시스템은 종단간 추적성을 제공하고 결제 주기 전반에 걸쳐 일관성을 보장한다.
Hosted payment page
대다수의 회사들은 신용카드 정보를 내부적으로 보관하지 않는것을 선호한다. 이는 관계 법령이 굉장히 복잡하기 때문이다. 신용카드 정보를 직접 다루는 것을 피하기 위해 PSP가 제공하는 호스팅 신용카드 페이지를 사용한다. 웹 사이트의 경우 위젯이나 iframe이고 모바일인 경우 결제 SDK에서 구축된 페이지일 것이다. 여기서 중요한 점은 PSP가 당사의 결제 서비스에 의존하지 않고 고객 카드 정보를 직접 입력받는 호스팅된 페이지를 제공한다는 것이다.
Pay-out flow
pay-out의 흐름은 pay-in과 굉장히 유사하다. 한가지 차이점은 pay-in의 경우 PSP를 사용하여 구매자의 신용카드에서 이커머스 계좌로 돈을 이체하지만 pay-out은 서드파티를 사용하여 이커머스 계좌의 돈을 판매자의 계좌로 이체한다는 것이다. 보통 결제 시스템은 pay-out을 처리하기 위해 Tipalti와 같은 서드파티 업체를 사용한다. pay-out에도 많은 규제 사항이 있기 때문이다.
Step3. Design Deep Dive
이번 섹션에서는 시스템을 더 빠르고 강력하며 안전하게 만드는게 초점을 맞춘다. 분산 시스템에서 장애는 피할 수 없을뿐만 아니라 흔한 일이다. 예를 들어 구매자가 결제하기 버튼을 여러번 누르면 어떻게 될까? 여러번 결제가 될까? 네트워크 이슈로 인한 결제 실패는 어떻게 처리해야 할까? 이러한 여러가지 상황에 대해 깊게 살펴보자.
PSP integration
만약 결제 시스템이 은행이나 Visa, MasterCard에 직접 연결될 수 있다면 PSP없이도 결제가 가능하다. 이러한 직접 연결은 일반적이지 않으며 매우 특수한 경우이다. 일반적으로 그러한 비용을 감당할 수 있는 대규모 회사들이 그렇다. 대부분 회사의 결제 시스템은 다음 두가지 방법 중 한가지로 PSP와 통합한다.
- 회사가 민감한 결제정보를 안전하게 저장할 수 있고, 그렇게 하기로 결정했다면 API를 이용하여 통합할 수 있다. 회사가 결제 사이트를 개발하고 민감한 결제 정보를 수집 및 저장한다. PSP는 단지 은행 또는 카드사와의 연결만을 담당한다.
- 만약 회사가 법적인 이슈로 인해 결제 관련 민감 정보를 저장하지 않기로 했다면 PSP는 결제 세부정보를 수집하고 이를 PSP에 안전하게 저장할 수 있는 결제 페이지를 제공한다. 이는 대다수의 회사가 사용하는 방식이다.
Figure 4를 사용하여 호스팅된 결제페이지가 어떻게 작동하는지 나타낸다.
위 그림에서는 간소화를 위해 payment executor, ledger, wallet을 제외시켰다. 결제 서비스는 아래의 전체 결제 프로세스를 관장한다.
- 유저가 브라우저에서 체크아웃 버튼을 누른다. 클라이언트는 주문 정보와 함께 결제 시스템을 호출한다.
- 주문 정보를 받은 후 결제 서비스는 PSP에게 결제 등록 요청을 보낸다. 이 요청에는 결제 수량, 화폐, 결제 만료 시간, 리다이렉트 주소 등의 결제 정보가 포함되어있다. 주문은 단 한번만 이루어져야 하기 때문에 UUID 필드 또한 포함된다. 이 UUID는 일반적으로 주문의 ID이다.
- PSP는 결제 서비스에게 토큰을 반환한다. 이 토큰은 PSP입장에서 해당 결제건에 대한 유니크한 식별값이다. 나중에 이 토큰을 통해 결제 상태나 흐름에 대해 조사할 수 있다.
- 결제 서비스는 PSP의 결제 페이지를 호출하기 전 토큰을 데이터베이스에 저장한다.
- 토큰이 저장된 후 클라이언트는 PSP의 호스팅된 결제 페이지를 보여준다. Figure 5에서는 Stripe 결제 사이트를 예시로 들었다. Stripe는 결제 UI를 표시하고 민감한 정보를 수집하며 PSP를 직접 호출하여 결제를 완료하는 자바스크립트 라이브러리를 제공한다. 민감한 정보는 Stripe에 의해 수집되며 절대 우리 결제 서비스에 도달하지 않는다. 호스팅된 결제 페이지는 일반적으로 두 가지 정보가 필요하다.
- 4번에서 받은 토큰이다. PSP의 자바스크립트 코드는 해당 토큰을 사용하여 PSP 백엔드에서 결제 요청에 대한 세부 정보를 찾는다.
- 또다른 중요 정보는 리다이렉트 URL이다. 이 URL은 결제가 완료되면 호출된다. PSP의 자바스크립트가 결제를 완료하면 브라우저를 해당 주소로 리다이렉트한다. 보통 이 URL은 이커머스 사이트의 체크아웃 상태를 보여주는 페이지 주소이다. 리다이렉트 URL과 9번에 나올 웹훅 URL은 다름에 유의하자.
- 유저가 PSP 사이트에서 결제 정보를 채우고 나면 PSP는 결제를 시작한다.
- PSP가 결제 상태를 반환한다.
- 이제 웹 페이지는 리다이렉트 URL로 이동한다. 7번에서 받은 결제 상태는 일반적으로 URL뒤에 추가된다. 예를 들어, https://site.com/?tokenId=1234&payResult=X324F 와 같은 형태이다.
- PSP는 웹훅을 통해 결제 상태를 담아서 결제 서비스를 비동기적으로 호출한다. 웹훅은 PSP 초기 설정 시 PSP에 등록한 결제 시스템측 URL이다. 결제 시스템이 웹훅을 통해 결제 이벤트를 받은 후 결제 상태를 추출하고 payment order 테이블의 payment_order_status 필드를 업데이트한다.
Reconciliation
시스템의 요소들이 비동기적으로 통신하는 경우 메시지가 도착하거나 응답이 반환된다는 보장이 없다. 이는 시스템 성능을 높이기 위해 종종 비동기 통신을 사용하는 결제 비즈니스에서는 굉장히 일반적이다. PSP나 은행과 같은 외부 시스템도 비동기 통신을 선호한다. 그렇다면 어떻게 정확성을 보장할 수 있을까?
정답은 조정이다. 이는 관련 서비스간의 상태를 주기적으로 비교하여 일치하는지 확인하는 방법이며 일반적으로 결제 시스템의 마지막 방어선으로 사용된다. 매일 밤 PSP나 은행은 고객에게 정산 파일을 보낸다. 해당 파일에는 계좌 잔고와 하루동안 발생한 모든 트랜잭션에 대한 기록이 들어있다. 조정 시스템은 정산 파일을 파싱하여 원장 시스템과 비교한다. Figure 6은 시스템에서 조정 프로세스의 적합한 위치를 나타낸다.
조정은 내부적으로 결제 시스템의 일치성 검증에도 사용된다. 예를 들어 원장과 지갑의 상태가 다르면 조정 시스템을 사용하여 불일치를 감지할 수 있다.
이러한 조정간 발생한 불일치를 고치기 위해 우리는 재무팀과 함께 수동 조정을 실행한다. 불일치 및 조정은 일반적으로 세가지 카테고리로 분류된다.
- 불일치는 분류 가능하며 조정은 자동화될 수 있다. 이 경우 불일치의 원인과 해결방법을 알고 있으며 조정을 자동화하는게 효율적이다.
- 불일치는 분류 가능하지만 조정은 자동화될 수 없다. 이 경우 불일치의 원인과 해결 방법을 알고 있지만 자동 프로그램을 만드는 비용이 너무 높다. 따라서 불일치는 백로그로 들어가고 재무팀이 수동으로 수정한다.
- 불일치를 분류할 수 없다. 이 경우 정확한 원인을 알 수 없기 때문에 재무팀이 직접 조사한다.
Handling payment processing delays
이전에 이야기했듯이 종단간 결제 요청은 많은 요소들을 통해 내부/외부 시스템을 거친다. 대다수의 결제 요청은 몇초내로 성공하지만 결제 요청이 중단되고 때로는 완료되거나 거부되기까지 오래걸리는 상황이 있다. 다음과 같은 상황이 그러한 예이다.
- PSP가 결제 요청을 비정상적인 요청으로 판단하고 사람의 수동 검토를 요구한다.
- 신용카드가 3D 보안 인증과 같은 추가 인증을 요구한다.
결제 서비스는 위와 같이 시간이 오래 걸릴 수 있는 결제 요청들을 잘 처리해야한다. 결제 페이지가 PSP가 제공하는 외부 페이지인 경우 보통 아래와 같이 처리된다.
- 클라이언트에게 PENDING 상태를 리턴한다. 그리고 유저에게 해당 정보를 노출한다. 또한 고객이 현재 결제 상태를 확인할 수 있는 페이지도 제공한다.
- PSP가 PENDING 상태의 결제를 추적하고 웹훅을 통해 결제 서비스에게 알려준다.
최종적으로 결제 요청이 완료 처리가 되면 위에서 등록한 웹훅을 호출한다. 결제 서비스는 해당 웹훅을 통해 내부 결제 상태를 업데이트하고 고객에서 배송을 시작한다.
또는 일부 PSP는 웹훅 대신 결제 서비스에서 직접 상태를 폴링하여 추적하도록 만들기도 한다.
Communication among internal services
내부 서비드들과 통신할 때는 보통 2가지 방법이 있는데, 동기적인 방법과 비동기적인 방법이다.
Synchronous communicaton
HTTP 통신과 같은 동기식 방법은 소규머 시스템에서는 잘 동작하지만 규모가 커질수록 단점이 부각된다. 이는 많은 서비스에 의존성을 가지기 때문에 긴 요청/응답에 대한 싸이클을 생성한다. 단점은 아래와 같다.
- 낮은 퍼포먼스. 연결된 여러 서비스 중 하나에 장애가 발생하면 전체 시스템에 영향이 간다.
- 장애 격리의 어려움. PSP 또는 기타 서비스가 실패하면 클라이언트는 응답을 받지 못한다.
- 강한 결합력. 요청을 보내는 측에서는 수신자가 누구인지 알고 있다.
- 어려운 확장. 버퍼 역할을 하는 큐가 없다면 갑작스러운 트래픽 증가에 시스템을 확장하기 어렵다.
Asynchronous communication
비동기 통신은 아래와 같은 2가지 분류로 나뉘어진다.
- 단일 수신기: 각 요청은 하나의 수신기 또는 서비스에서 처리된다. 이는 일반적으로 공유되는 메시지 큐에 의해 구현된다. 메시지 큐는 여러명의 구독자를 가질 수 있지만 메시지가 처리되면 그 즉시 큐에서 삭제된다. 구체적인 예를 한번 살펴보자. Figure 9에서 서비스 A와 B는 공유 메시지 큐에 대한 구독자이다. m1과 m2가 서비스 A와 B에서 각각 소비되면 Figure 10에 표시된 대로 두 메시지 모두 큐에서 제거된다.
- 다중 수신기: 각 요청은 다중 수신기 또는 서비스에 의해 처리된다. 카프카가 보통 이렇게 동작한다. 컨슈머가 메시지를 수신하면 카프카에서 메시지를 삭제하지 않는다. 동일한 메시지는 다른 서비스들에서 처리될 수 있다. 이는 결제 서비스와 궁합이 잘 맞는데, 동일한 요청이 푸시 알림을 보내거나 결제 리포트를 업데이트하는 등 여러가지 일을 해야될수도 있기 때문이다. Figure 11이 이러한 예제를 보여준다. 결제 이벤트들은 카프카로 발행되며 결제 시스템, 분석 서비스, 정산 서비스등과 같은 각기 다른 서비스들이 메시지를 가져간다.
일반적으로 동기 통신 설계가 더 간단하지만 서비스가 자율적이진 않다. 의존성이 증가할수록 전반적인 성능은 저하한다. 비동기 통신은 확장성과 장애 복원력을 가지고 있지만 동기 통신에 비해 설계가 복잡하다. 수많은 서드파티앱과 복잡한 비즈니스 로직을 가진 결제 서비스의 경우 비동기 통신이 더 나은 선택지이다.
Handling failed payments
모든 결제 시스템은 실패한 거래를 처리해야한다. 신뢰성과 내결함성은 핵심 요구사항이다. 우리는 이러한 과제를 해결하기 위해 몇가지 기술을 검토한다.
Tracking payment state
전체 결제 생명주기에서 모든 단계에 대해 명확한 상태를 가지는것은 굉장히 중요하다. 결제 실패가 발생하면 우리는 현재 상태를 파악하고 재시도해야할 지 환불을 해줄 지 결정해야한다. 결제 상태는 추가만 가능한 데이터베이스에서 유지된다. (update, delete 불가, insert만 가능)
Retry queue and dead letter queue
결제 실패를 적절하게 처리하기 위해 다음과 같이 재시도 큐와 데드레터 큐를 사용한다.
- Retry queue: 일시적인 에러의 경우 재시도 큐로 들어간다.
- Dead letter queue: 메시지가 반복적으로 실패하면 결국에는 DLQ로 들어간다. DLQ는 문제가 있는 메시지를 디버깅하고 검사하여 메시지가 성공하지 못한 이유를 확인하는데 유용하다.
- 실패건이 재시도 가능한지 확인한다.
- 재시도 가능하면 재시도 큐로 들어간다.
- 인자 오류와 같은 재시도 불가능한 실패건은 데이터베이스에 저장된다.
- 결제 시스템은 재시도 큐의 메시지를 컨슈밍하고 실패한 결제건을 재시도한다.
- 결제가 다시 실패하는 경우
- 재시도 횟수가 임계값보다 낮다면 다시 재시도 큐로 들어간다.
- 임계값 이상이라면 DLQ로 들어간다. 그리고 추후 디버깅 목적으로 활용된다.
Exactly-once delivery
결제 시스템이 가질 수 있는 가장 심각한 문제 중 하나는 고객에게 이중으로 요금을 청구하는 것이다. 우리 시스템에서 정확히 한번 결제를 진행하는건 굉장히 중요한 요소이다.
언뜻 보기에 정확히 1번 시도하는건 해결하기 굉장히 어려워 보이지만 문제를 2가지 요소로 분리하면 생각보다 해결하기 쉽다. 수학적으로 다음과 같은 경우 작업은 정확히 한번만 실행된다.
- 한번 이상 실행된다.
- 동시에 최대 한번 실행된다.
재시도를 통해 적어도 1회를, 멱등성 검사를 통해 최대 1회 구현에 대해 설명해본다.
Retry
때때로 네트워크 타임아웃등의 오류로 인해 결제를 재시도할 필요가 생긴다. 재시도는 적어도 한번을 보장한다. Figure 13에서 클라이언트가 10달러 결제를 시도했는데 네트워크 이슈로 인해 해당 결제 건이 실패하였다. 이 예제에서 네트워크는 결국 복구되며 결제는 4번의 시도끝에 결국 성공했다.
재시도 간 적절한 인터벌을 주는것 또한 중요하다. 아래는 일반적인 재시도 전략이다.
- 즉시 재시도: 즉시 재시도 요청을 보낸다.
- 고정된 인터벌: 고정된 시간동안 대기 후 재시도 요청을 보낸다.
- 증가하는 인터벌: 첫 번째 재시도는 짧은 시간 동안 대기하고 점진적으로 대기 시간을 늘려가며 재시도한다.
- 지수 백오프: 재시도가 실패할 때 마다 재시도 사이 대기시간이 두배로 늘어난다. 예를 들어 첫 번째 요청이 실패하면 1초 후 다시 시도한다. 두 번째가 실패하면 2초를 대기한다. 세 번째가 실패하면 4초를 기다린다.
- 취소: 클라이언트가 요청을 취소한다. 이는 실패가 영구적이거나 반복적인 요청이 성공할 가능성이 없을 때 일반적으로 사용하는 방법이다.
적절한 재시도 전략을 수립하는 것은 쉬운일이 아니다. 모든 경우에 적합하게 적용되는 방법은 없기 때문이다. 일반적으로 네트워크 문제가 짧은 시간 내에 해결될 가능성이 없는 경우 지수 백오프를 사용한다. 지나치게 공격적인 재시도 전략은 리소스를 낭비하고 서비스 과부하를 일으킬 수 있다. 따라서 Retry-After 헤더와 함께 오류 코드를 제공하는것이 좋다.
재시도의 잠재적인 문제는 이중 결제이다. 아래의 두가지 시나리오를 살펴보자.
- 시나리오 1: PSP가 제공한 호스팅된 결제 사이트에서 클라이언트가 결제 버튼을 두 번 클릭한 경우
- 시나리오 2: PSP에서는 결제가 성공했지만 네트워크 이슈로 인해 결제 성공 결과가 우리 결제 서비스에 도달하지 못한 경우. 이러한 경우 유저는 "결제하기" 버튼을 다시 누르게 되고 결제가 두 번 발생한다.
이중 결제를 피하기 위해 결제는 정확히 한번만 발생해야한다. 이러한 최대 1회 보장을 멱등성이라고도 부른다.
Idempotency
멱등성은 최대 1회를 보장하는 키 포인트이다. API관점에서 멱등성은 클라이언트가 동일한 호출을 반복적으로 수행해도 동일한 결과를 생성함을 의미한다.
클라이언트와 서버 간 통신을 위해 멱등성 키는 보통 클라이언트 쪽에서 생성되며 특정 시간내에 만료되는 유니크한 값이다. 보통 UUID가 멱등성 키로 사용되며 Stripe, Paypal등의 회사에서도 권장된다. 멱등성 있는 요청을 수행하기 위해서는 멱등성 키를 HTTP헤더에 포함하여 요청을 보낸다.
이제 멱등성에 대한 기본 원리를 이해했으니 이를 이용하여 어떻게 이중 결제를 해결할 수 있는지 살펴보자.
Scenario 1: what if a customer click the "pay" button quickly twice?
Figure 14를 보면 유저가 "결제" 버튼을 클릭하면 멱등성 키는 HTTP 요청을 통해 결제 서비스로 전송된다. 이커머스 웹 사이트에서 멱등성 키는 보통 체크아웃 이전 장바구니의 ID이다.
두 번째 요청의 경우 결제 시스템이 이미 멱등성 키를 확인했기 때문에 재시도로 간주된다. 헤더에 멱등성 키를 포함하여 전송했기 때문에 결제 서비스는 해당 키에 해당하는 이전 요청의 최신 상태를 반환한다.
만약 동일한 멱등성 키를 통해 수많은 동시 요청이 들어오는 경우 단 하나의 요청만 처리되고 나머지는 429 상태 코드 응답을 받게 된다.
멱등성을 보장하기 위해 데이터베이스의 유니크 제약조건을 사용할수도 있다. 예를 들어 테이블의 PK키는 멱등성 키로 제공된다.
- 결제 시스템이 결제 요청을 받으면 데이터베이스 테이블에 새로운 로우를 생성한다.
- 로우 생성이 성공하는 경우 이전에 동일한 결제 요청이 없었다는 뜻이다.
- 동일 PK값이 존재하여 로우 생성이 실패하는 경우 이미 처리한 결제 요청이라는 뜻이다. 따라서 이 요청은 결제처리되지 않는다.
Scenario 2. The payment is successfully processed by the PSP, but the response fails to reach our payment system due to network errors. Then the user clicks the "pay" again
Figure 4에서 2번을 보면 결제 서비스는 PSP에 nonce와 함께 결제 요청을 보내고 PSP는 일치하는 토큰을 반환한다. Nonce는 결제 주문을 고유하게 나타내고 토큰은 Nonce에 고유하게 매핑된다. 따라서 토큰은 결제 주문건마다 고유하게 생성 및 매핑된다.
유저가 "결제" 버튼을 두번 누르는 경우 결제 주문은 동일하기 때문에 PSP로부터 전달받은 토큰 또한 동일하다. PSP에게 토큰은 멱등성 키로써 사용되기 때문에 이중 결제 요청이라는 것을 알 수 있고 위에서 설명한 이전 결제 건의 상태를 반환하게 된다.
Consistency
결제 실행 시 여러개의 Stateful 서비스가 호출된다.
- 결제 서비스는 Nonce, 토큰, 결제 주문, 실행 상태 등 결제 관련 데이터를 보관한다.
- 원장 서비스는 계좌 데이터를 보관한다.
- 지갑 서비스는 판매자의 계좌 잔액을 보관한다.
- PSP는 결제 실행 상태를 보관한다.
- 신뢰성을 높이기 위해 데이터는 여러 데이터베이스간 복제될 수 있다.
분산 환경에서 각 서비스 간 통신 장애는 데이터의 불일치를 초래할 수 있다. 이러한 데이터 불일치를 해결하는 몇가지 테크닉에 대해 살펴보자.
내부 서비스 간 데이터 일관성을 보장하기 위해서는 정확히 한번 처리하는것이 매우 중요하다. 내부 서비스와 외부 서비스(PSP) 간 데이터 일관성을 보장하기 위해서는 일반적으로 멱등성과 조정에 의존한다. 만약 외부 서비스가 멱등성을 보장하면 우리는 결제 재시도 간 멱등성 키를 사용할 수 있다. 외부 서비스가 멱등성 API를 지원한다고 해도 외부 시스템을 완전히 신뢰할 수 없기에 조정 또한 필요하다.
만약 데이터가 복제되었다면 복제 지연으로 인해 Primary와 Replica 데이터베이스 사이 데이터 불일치가 발생할 수 있다. 이 문제를 해결하기 위해 2가지 선택지가 존재한다.
- 읽기와 쓰기 요청 모두 Primary 데이터베이스에서 처리한다. 이 방법은 설정은 쉽지만 확장성에 좋지 않다. Replica는 데이터 안정성을 보장하기 위해 사용되지만 어떠한 트래픽도 처리하지 않기 때문이다.
- 모든 Replica가 동기화된 상태인지 보장한다. Paxos, Raft와 같은 알고리즘을 사용하거나 YugabyteDB, CockroachDB와 같은 분산 데이터베이스를 사용할 수 있다.
Payment security
결제 보안은 굉장히 중요하다. 시스템 설계의 마지막 부분에서 사이버 공격 및 카드 도난에 대처하기 위한 몇가지 기술을 간략하게 다뤄본다.
Step 4. Wrap Up
이번 챕터에서 우리는 pay-in, pay-out 흐름에서 다뤄야할 재시도, 멱등성 및 일관성에 대해 깊이 있게 살펴보았다. 결제 오류 처리 및 보안도 이장의 마지막 부분에서 다루었다.
결제 시스템은 굉장히 복잡하다. 본문을 통해 많은 주제를 다뤘지만 아직 언급해볼만한 주제들이 더 있다.
- 모니터링. 현대 어플리케이션에서 모니터링은 필수 요소이다. 광범위한 모니터링을 통해 "특정 결제 방식의 평균 수락률은 얼마인가?", "우리 서버의 CPU 사용량은 얼마인가?" 등과 같은 질문에 답할 수 있다. 그리고 이러한 지표를 대시보드에 생성하고 표시할수도 있다.
- 알림. 무언가 비정상적인 동작이 감지되면 온콜 개발자에게 즉시 알림을 가도록 해야한다.
- 디버깅 툴. "왜 결제가 실패했나?" 는 가장 많이 물어볼만한 질문이다. 고객 파트와 개발자의 빠른 디버깅을 위해 결제 서버의 거래 상태, 처리 서버 기록, PSP 기록 등을 검토할 수 있는 도구를 개발하는 것은 굉장히 중요하다.
- 환율. 해외 사용자를 위해 환율에 대한 고려가 필요하다.
- 위치 고려. 지역마다 결제 방법이 다를 수 있다.
- 현금 결제. 인도, 브라질 등 몇몇의 나라에서 현금 결제는 매우 일반적이다.
- 구글/애플 결제 통합
Chapter Summary
Reference
https://newsletter.pragmaticengineer.com/p/designing-a-payment-system
'Coding > 시스템 디자인' 카테고리의 다른 글
틴더(Tinder) 시스템 디자인 (0) | 2024.05.16 |
---|---|
Uber는 어떻게 Redis를 통해 초당 4천만 읽기를 제공하는가 (81) | 2024.05.07 |
광고 클릭 이벤트 시스템 디자인 (0) | 2023.12.14 |
채팅 서비스 시스템 디자인 (0) | 2023.12.13 |
Whatsapp(왓츠앱) 시스템 디자인 (0) | 2023.11.30 |