pay 총정리: 실패가 비싼 도메인을 밑바닥부터
목차
이 글 하나로
이 글은 결제 시스템 pay를 만들며 쓴 시리즈 열 편의 총정리다. 시리즈를 안 읽어도 이 한 편으로 전체 설계와 판단을 파악할 수 있게 썼고, 더 깊이 볼 지점마다 해당 편을 링크해 뒀다.
pay는 주문 → 승인 → 취소 → 정산 → 대사까지 결제의 전체 수명주기를 다루는 학습 프로젝트다. Java 21, Spring Boot, Spring Modulith 위에 MySQL·Redis·Kafka를 얹었고, 전체 코드와 설계 문서(도메인 조사·ERD·API 스펙·장애 시나리오), 507개 테스트, 라이브 검증 기록이 GitHub 저장소에 있다.
왜 결제였나. 실패가 비싼 도메인을 하나 제대로 파보고 싶었다. 카드 승인 요청을 보냈는데 응답이 안 오면 그 결제는 성공도 실패도 아니다. 서버가 죽었다 살아나면 어중간하게 걸친 결제를 누군가 정리해야 한다. 하루가 끝나면 내 장부와 PG 정산 파일이 한 푼도 안 틀리게 맞아야 한다. PG 연동 자체는 튜토리얼이 수십 개지만 이 빈 공간은 PG가 대신 채워주지 않는다. 그 빈 공간을 직접 만들었다.
전체를 관통한 원칙은 셋이다. 금액·가격·사용자 신원은 서버와 인증 컨텍스트가 정한다. 실패는 지운 셈 치지 않고 UNKNOWN 같은 상태로 남겨 복구가 확정한다. 돈이 맞는지는 복식부기 원장과 대사로 증명한다.
아키텍처: CI가 지키는 모듈 경계
모듈형 모놀리스를 골랐다. 1인 학습 프로젝트에서 MSA로 시작하면 서비스별 배포와 네트워크라는 인프라 복잡도가 정작 배우려던 도메인 설계를 덮어버린다. Spring Modulith 위에서 패키지 하나가 모듈 하나이고, 모듈 사이는 직접 호출 대신 도메인 이벤트로만 잇는다.
이 경계는 CI가 지킨다. ModularityTests가 모듈 규칙을 검증해서 경계를 깨면 빌드가 깨진다. 결제 이벤트는 나중에 Kafka로 외부화해 모놀리스 안에서만 울리던 이벤트를 프로세스 밖 소비자까지 배달했고, 그 소비자를 실제로 붙여보다 이중 인코딩 문제를 만나 고친 기록이 이벤트 소비 편에 있다.
결제 코어: 무엇을 신뢰하는가
검증 로직보다 신뢰 경계가 먼저였다. 결제 금액을 클라이언트가 보내는 순간 가격 조작이 가능해지므로, 서버가 주문을 근거로 금액을 계산하고 클라이언트 값은 대조용으로만 쓴다. 주문·결제는 상태머신으로 관리해 허용되지 않은 전이(PAID가 아닌 주문의 취소 같은)를 코드 레벨에서 거부한다.
중복 결제는 Idempotency-Key와 DB 유니크 제약으로 막는다. INSERT에 성공한 요청만 처리권을 가지므로 “따닥” 연타에도 결제는 한 번만 일어난다. 자세한 구현은 결제 코어와 실패 설계에 있다.

실패 설계: 타임아웃을 미확정으로 다룬다
이 시리즈에서 가장 공들인 지점이다. 결제 승인의 결과는 승인·거절·미확정 셋으로 갈리는데, 흔한 구현이 놓치는 게 미확정이다. 타임아웃을 실패로 처리하면 PG에서는 승인된 결제를 우리만 실패로 아는 최악의 불일치가 생긴다. 그래서 미확정은 UNKNOWN으로 보존하고, 1분마다 도는 복구 배치가 PG 조회로 진짜 결과를 확정한다.
승인 뒤 재고가 부족하면 이미 나간 돈을 되돌려야 한다. 이 망취소는 아웃박스에 적재해 재시도하는 보상 트랜잭션으로 처리했다. 느린 PG에는 서킷브레이커를 달았고, “장애가 나도 괜찮다”는 말은 Toxiproxy 카오스 테스트로 증명했다. DB가 데드락으로 “재시작하라”고 말하는 경우도 재시도해도 되는 실패로 분류해 자동 재시도로 흡수했다.
웹훅: 서명부터 검증한다
웹훅은 서명을 검증하고, 받은 즉시 처리하지 않는다. PG는 10초 안에 200을 못 받으면 웹훅을 다시 보내므로, 수신은 빠르게 200으로 끝내고 해석은 아웃박스에 실어 별도 스레드에서 처리하도록 비동기로 분리했다. 이 분리 과정에서 자기호출이 트랜잭션을 삼키는 함정과 오염된 트랜잭션 함정을 연달아 만났는데, 해석 실패를 아웃박스 재시도에 맡기는 쪽으로 정리했다. 결제를 붙인 뒤에는 분쟁 웹훅도 같은 원칙(서명 검증, 멱등 처리)으로 차지백 상태기계에 연결했다.
돈의 정합성: 원장·정산·대사
잔액을 숫자 하나로 덮어쓰지 않는다. 모든 자금 이동을 복식부기 분개로 남기고, 차변 합과 대변 합이 항상 같아야 한다는 불변식이 곧 돈이 안 샜다는 증명이 된다. 흔한 결제 예제가 거의 안 가는 곳이라 결제 코어 편 후반을 통째로 여기에 썼다.
정산은 하루치 결제를 집계해 수수료와 부가세를 떼고 판매자 지급액과 지급예정일을 계산한다. 처음엔 총액의 3%만 떼고 있었고, 부가세와 지급예정일이 빠져 있었다. 더 아픈 버그는 집계 키가 승인일이던 것. 구매확정으로 릴리스된 결제가 승인일 기준으로 묶이면서 정산이 조용히 가맹점에 돈을 안 주고 있었다. 이 두 사건은 정산 정확성 편에 있다. 대사는 내 기록과 PG 정산 파일을 대조해 네 가지로 분류하고, 자동으로 못 맞춘 건은 사람이 확정하는 큐로 넘긴다. 파일을 넣을 입구가 없어 업로드 API로 루프를 닫은 것까지가 한 세트다.

성능과 동시성: 수치로 골랐다
재고 차감 락은 세 가지를 같은 부하로 돌려 골랐다. 비관적 락, 낙관적 락, 조건부 UPDATE를 비교해 이중 차감과 마이너스 재고가 0건인지, 처리량이 어떤지 쟀고 조건부 UPDATE가 이겼다. 실측 수치는 결제 코어 편에 있다.
시스템 전체를 처음 부하테스트에 올렸을 때는 p95가 3초를 넘겼다. 병목을 추적하니 요청당 BCrypt 해싱이었고, JWT 인증으로 걷어낸 전후 수치를 성능·취소·보상 편에 남겼다. 폭주에는 두 겹으로 대비했다. 한정판 오픈처럼 유입 자체가 몰리는 상황은 Redis Sorted Set 선착순 대기열로 줄 세우고, 그걸 뚫고 오는 초과 요청은 429로 쳐내 성공한 요청의 속도를 지킨다. 둘 다 보안·대기열·유입제어 편에 있다.

취소: 결제에 없던 질문들
취소를 만들며 결제할 때는 없던 질문들을 만났다. 포인트+카드 복합결제의 환불은 왜 포인트부터인지, 부분취소 때 재고는 복원해야 하는지, 남의 주문을 취소하려는 요청은 어느 단계에서 걸러야 하는지(소유권 검증을 잔액 조회보다 앞에 둬야 IDOR로 정보가 새지 않는다) 같은 것들이다. 성능·취소·보상 편이 이 판단들의 기록이다.
부분취소는 재감사에서 한 번 더 문제가 됐다. 전액취소는 멱등인데 부분취소는 델타 차감이라 이벤트 재배달 때 또 빠질 수 있었다. 취소 후 잔액을 절대값으로 세팅하는 방식으로 멱등하게 고쳤다.
체크아웃 사가: PG 콜을 트랜잭션 밖으로
처음엔 체크아웃 전체가 한 트랜잭션이었다. 트랜잭션 안에서 외부 PG를 호출하면 PG가 느려질 때 DB 커넥션을 붙잡은 채 기다리고, 커넥션 풀이 마르면 앱 전체가 멈추는 연쇄 장애로 번진다. ADR에는 “안다”고 적어놓고 코드는 그대로였다.
그래서 예약(트랜잭션) → PG 승인(트랜잭션 밖) → 확정·보상(트랜잭션) 세 단계로 뜯었다. 원자성을 포기한 대가로 예약만 하고 확정 전에 죽은 멈춘 사가가 생기는데, 복구 배치가 PG 조회로 완결하거나 되돌린다. 리팩터 과정은 사가 편에 있다.
결제수단 확장: 계약의 대칭
새 결제수단 추가의 본질은 기존 수단이 가진 계약(예약·보상·취소·멱등·복구)을 빠짐없이 대칭시키는 데 있었다. 하나라도 반만 베끼면 그 틈으로 돈이 샌다. 결제수단 확장 편에서 넓힌 폭은 이렇다.
- 실 PG 어댑터: 가짜 PG를 실 토스페이먼츠 어댑터로 구현 하나만 갈아끼워 교체했다. 어댑터 경계를 애초에 그어둔 보상이었다.
- 멀티 PG 라우팅: 한 PG가 죽으면 다음 PG로 넘긴다. 단,
TIMEOUT엔 절대 failover하지 않는다. 첫 PG에서 승인됐을 수 있는데 다음 PG로 넘기면 이중 결제가 되기 때문이다. 만들어두고 배선을 잊었다가 나중에 연결한 기록도 남겼다. - 포인트+카드 복합결제: 부분 사용과 환불 순서(포인트 우선)까지 다뤘다.
- 구독: 빌링키 자동결제에, 실패하면 바로 해지하는 대신 재시도 일정을 두는 dunning을 붙였다. 빌링키도 dunning도 만들어놓고 정작 부를 입구가 없어 나중에 배선했다.
- 선불 월렛: 잔액을 덮어쓰지 않는 원장식 관리에 전금법 기명 한도 200만원을 도메인 규칙으로 넣었다. 체크아웃에 배선하며 결제수단마다 롤백 계약이 다르다는 것(포인트는 트랜잭션 안이라 자동 롤백, 월렛·카드는 커밋된 부수효과라 명시적 보상 필요)을 배웠다.
- 가상계좌: 입금 대기라는 비동기 상태와, 문서를 깊게 읽어야 보이는 두 함정을 처리했다.
- FDS: 탐지 정확도보다 아키텍처 판단(동기 차단이냐 사후 탐지냐)이 먼저였다. 판정 엔진만 있고 심사할 곳이 없어 REVIEW 큐를 나중에 완성했다.
- 필드 암호화와 현금영수증: 키를 코드에 두지 않는 암호화, 암호화한 컬럼을 검색하는 법, 결제를 취소하면 영수증도 취소하는 연쇄까지 챙겼다.
운영: happy path 다음에 오는 것들
자동 복구가 포기하는 순간은 반드시 온다. 그때 사람이 이어받을 도구를 만들었다. 미확정 결제 강제 조회, 보상 재처리, 수기 대사 확정, 그리고 강제취소에는 한 사람이 남의 돈을 다루지 못하게 maker-checker 2인 승인을 걸었다. 어드민 편과 운영 완성 편에 나눠 담았다.


돈을 바로 주지 않는 장치도 있다. 구매확정 전까지 판매자 정산을 보류하는 에스크로다. 구매확정 이벤트가 죽은 채 쌓이는 걸 추적하다 구매확정 전에 정산되고 있던 도메인 모순을 찾은 게 이 장치 덕이었다.
관측성은 결제 도메인의 언어로 만들었다. CPU 대신 미확정 결제 최고 경과 시간, 대사 미해결 건수 같은 지표를 SLO 대시보드와 알림으로 노출했다. 배포하는 순간 결제가 끊기지 않게 운영성 마감도 별도로 정리했다. 하드닝 편에 있다.

보안: 상태의 수명까지가 설계
무상태 JWT의 약점은 폐기다. 로그아웃하거나 탈취가 의심되는 토큰은 Redis denylist로 무효화하는데, 재시작하면 블랙리스트가 사라지는 문제까지 쫓아가 보안 상태의 수명을 설계로 끌어올렸다. 암호화 키는 수백만 건 재암호화 없이 바꿀 수 있어야 해서 envelope 암호화로 키 로테이션을 준비했고, 금고만 만들고 실제 컬럼에 안 넣고 있던 걸 발견해 배선까지 마쳤다. 보안·대기열 편과 하드닝 편에 걸쳐 있다.
회원과 분쟁: 시스템의 가장자리
데모 계정뿐이던 인증에 진짜 회원 도메인을 붙였다. 기존 코드가 숫자 userId를 전제하고 있어서 그 계약을 지키며 붙이는 게 관건이었다. 결제 다음 단계인 분쟁·차지백은 상태기계, 멱등 웹훅, 패소 시 원장 역분개로 처리했다. 이의제기에 지면 이미 정산된 돈을 장부에서 정확히 되돌려야 하기 때문이다. 둘 다 기능 확장 편에 있다.
실기동과 자가 감사: “동작한다”와 “맞다”는 다르다
유닛테스트가 초록불이어도 시스템은 틀릴 수 있다. Flyway로 실 MySQL에 올려 돌리자마자 “승인됐습니다”라고 응답하고 DB엔 없는 결제 확정 버그가 나왔다. 원인은 readOnly 트랜잭션이 같은 트랜잭션의 flush 모드를 바꿔 저장이 유실되는 것. 같은 패턴을 전수 조사해 한꺼번에 고쳤다. 실기동 편의 기록이다.
기능을 다 붙인 뒤에는 신규 코드를 정면으로 감사했다. 취소 경로가 월렛이라는 결제수단을 몰라 환불이 증발했고, 거절 후 재시도하면 멱등 장치가 오히려 공짜 결제를 만들었다. 재감사 2회차에서는 부분취소 멱등 문제와 함께, 예전에 써둔 설명 자체가 틀렸던 것도 찾았다. 하드닝 추천이라고 만들어둔 항목들을 웹서칭으로 재검증하니 하나는 수치가 틀렸고 하나는 방향이 틀렸다. 자가 감사의 전 과정은 정밀 감사 편과 재감사 편에 있다.
남은 한계
일부러 선을 그은 것들이 있다. 쿠폰은 정책을 더 고민하려고 남겨뒀다. 다통화, 정산 공휴일 캘린더, 분쟁 대응기한 자동 처리는 실서비스라면 이렇게 확장한다는 메모만 문서에 남겼다. 어디까지 했고 왜 거기서 멈췄는지를 적는 쪽이 못 하는 걸 하는 척하기보다 정직하다고 봤다.
시리즈 각 편은 위 요약을 하나씩 풀어낸 기록이다. 아래 목록에서 관심 가는 편부터 봐도 된다.
댓글
댓글 수정/삭제는 GitHub Discussions에서 가능합니다.