프로젝트
약 9분 분량 학습 프로젝트/결제 시스템

pay 프로젝트 소개: 결제 실패를 정합하게 되돌리는 결제 시스템

PaymentSpring BootSpring ModulithJava 21MySQLRedisKafka
목차

pay는 주문, 승인, 취소부터 원장, 정산, 대사, 구독, 분쟁까지 결제의 전체 수명주기를 처리하는 Spring Modulith 기반 결제 백엔드입니다. 정상 처리보다 PG 타임아웃, 중복 요청, 이벤트 재전달, 부분 실패처럼 실패 이후를 정합하게 되돌리는 데 초점을 뒀습니다.

pay 아키텍처. 모듈형 모놀리스 17모듈, 모듈 간 통신은 도메인 이벤트(Outbox), 경계는 ModularityTests가 CI에서 강제

한눈에 보기

항목내용
한 줄 소개결제 승인·취소·정산·대사를 실패까지 포함해 정합하게 처리하는 결제 백엔드
기간2025.10 ~ 진행 중 (개인 프로젝트)
형태1인 개인 프로젝트. 설계·구현·검증 전부 직접 진행
기술Java 21, Spring Boot 3.4, Spring Modulith, MySQL 8.4, Redis, Kafka, Resilience4j
코드github.com/dj258255/payment-system

왜 만들었나

실패가 비싼 도메인을 하나 제대로 파보고 싶었습니다. 카드 승인 요청을 보냈는데 응답이 안 오면 그 결제는 성공도 실패도 아닙니다. 서버가 죽었다 살아나면 어중간하게 걸친 결제를 누군가 정리해야 합니다. 하루가 끝나면 내 장부와 PG 정산 파일이 한 푼도 안 틀리게 맞아야 합니다.

PG 연동 자체는 튜토리얼이 많습니다. 그런데 승인 이후 무엇이 틀어질 수 있고 그것을 어떻게 되돌리는지는 PG가 대신 채워주지 않습니다. 그 빈 공간을 직접 만들었습니다. 전체를 관통한 원칙은 셋입니다. 금액과 가격, 사용자 신원은 서버와 인증 컨텍스트가 정합니다. 실패는 지운 셈 치지 않고 UNKNOWN 같은 상태로 남겨 복구가 확정합니다. 돈이 맞는지는 복식부기 원장과 대사로 증명합니다.

누구를 위한 시스템인가

역할이 사람이 하는 일
구매자카드·포인트·월렛·복합결제로 주문합니다. 취소·구독·환불도 요청합니다
가맹점(판매자)구매확정 뒤 수수료와 부가세를 뗀 지급액을 정산으로 받습니다
운영자(정산·대사·FDS 담당)관리자 권한으로 미확정 결제 복구, 대사 불일치 확인과 수기 확정, 강제취소 2인 승인, 이상거래 심사 큐를 운영 백오피스에서 처리합니다
상담원미확정 사유나 장애 원인을 정리한 초안을 받아 검토한 뒤 고객에게 보낼지 정합니다. 초안은 사람이 검토하기 전에는 나가지 않습니다

시스템 권한은 구매자(ROLE_USER)와 운영자(ROLE_ADMIN) 둘로 나뉩니다. 상담원용 초안은 별도 로그인 화면 없이 운영 자동화 파이프라인의 출력으로 존재합니다.

주요 기능

1. 결제 코어

주문 생성부터 승인, 취소까지의 상태머신입니다. 결제 금액은 서버가 주문을 근거로 계산하고 클라이언트 값은 대조용으로만 씁니다. 아직 결제가 끝나지 않은 주문을 취소하는 요청처럼 허용되지 않은 상태 전이는 코드 레벨에서 거부합니다. 중복 결제는 Idempotency-Key와 DB 유니크 제약으로 막습니다.

pay 체크아웃 데모. 로그인부터 주문 생성, 결제 승인, 구매확정, 취소까지 실제 API 요청과 응답을 보여주는 화면

2. 실패 복구

PG 타임아웃은 실패로 단정하지 않고 UNKNOWN으로 남긴 뒤 복구 배치가 60초마다 PG에 다시 물어 확정합니다. 승인 뒤 재고가 부족해 취소해야 하는 망취소는 아웃박스에 적재해 재시도하는 보상 트랜잭션으로 처리합니다. 느린 PG에는 서킷브레이커를 달았습니다.

3. 웹훅

수신 즉시 서명을 검증합니다. 해석은 아웃박스에 실어 별도 스레드에서 처리합니다. PG가 10초 안에 200을 못 받으면 웹훅을 다시 보내는 규약 때문입니다. 결제 행보다 먼저 온 웹훅은 실패로 확정하지 않고 보류로 남깁니다.

4. 원장·정산·대사

모든 자금 이동을 복식부기 분개로 남깁니다. 차변 합과 대변 합이 항상 같아야 한다는 불변식이 돈이 안 샜다는 증명입니다. 정산은 구매확정 시각을 기준으로 하루치를 집계해 수수료와 부가세를 떼고 지급액과 지급예정일을 계산합니다. 대사는 내 기록과 PG 정산 파일을 대조해 네 가지로 분류합니다. 자동으로 못 맞춘 건은 사람이 확정하는 큐로 넘깁니다.

pay 정산 데모. 총액 30,000원에서 수수료 810원과 VAT 81원을 떼고 29,109원을 지급하며 지급예정일은 2영업일 뒤로 계산되는 화면

5. 결제수단 확장

카드(실 토스페이먼츠 어댑터), 포인트, 선불 월렛, 빌링키 정기결제(구독, 실패 시 dunning 재청구), 가상계좌, 복합결제(포인트+카드)까지 지원합니다. 새 결제수단을 추가할 때마다 기존 수단이 가진 계약(예약·보상·취소·멱등·복구)을 빠짐없이 대칭시키는 것이 관건이었습니다.

6. 이상거래 탐지(FDS)와 분쟁

규칙 기반 룰 엔진으로 이상거래를 사후 탐지해 심사 큐에 올립니다. 판정은 자동 확정하지 않고 심사자가 검토합니다. 분쟁·차지백은 상태기계와 멱등 웹훅으로 처리합니다. 패소하면 원장 역분개로 이미 정산된 돈을 되돌립니다.

7. 운영

미확정 복구, 보상 재시도, 정산 집계, 에스크로 해제, 주문·가상계좌 만료, 정기결제 재청구, 아웃박스·멱등키 정리를 배치로 돌립니다. 강제취소에는 요청자와 승인자를 분리하는 maker-checker(2인 승인)를 걸었습니다. 미확정 결제가 가장 오래 방치된 시간, 대사 미해결 건수를 결제 도메인의 언어로 지표화해 Grafana 대시보드와 알림으로 노출합니다.

pay 운영 백오피스 데모. 미확정 결제 복구, 보상 태스크, 정산·대사, 강제취소(2인 승인), FDS 심사 큐로 이동하는 메뉴가 있는 화면

구조

모듈형 모놀리스를 골랐습니다. 1인 개인 프로젝트에서 MSA로 시작하면 서비스별 배포와 네트워크라는 인프라 복잡도가 정작 배우려던 도메인 설계를 덮어버립니다. Spring Modulith 위에서 패키지 하나가 모듈 하나입니다. 모듈 사이는 직접 호출 대신 도메인 이벤트로만 잇습니다. 이 경계는 ModularityTests가 CI에서 검증해서 깨면 빌드가 깨집니다.

pay ERD. 주문·결제·원장·정산·대사 핵심 테이블과 관계, 두 번 처리되면 안 되는 자리마다 걸린 유니크 제약

모듈은 크게 네 층입니다. 결제 코어(order, payment)가 주문 상태머신과 체크아웃 사가, 승인·취소·웹훅·미확정 복구를 맡습니다. 자금 정합성(ledger, escrow, settlement, reconciliation)이 복식부기, 자금 보류, 정산, 대사를 맡습니다. 결제 수단(point, wallet, subscription)이 포인트·선불 월렛·정기결제를 맡습니다. 운영 안전장치(fraud, dispute, notification, audit)가 이상거래 심사, 차지백, 멱등 소비, 감사 로그를 맡습니다. 여기에 회원·대기열·현금영수증 같은 플랫폼 모듈이 더해져 17개입니다.

고민한 선택

정답이 여럿일 수 있는 자리에서 내린 선택 네 가지입니다.

1. PG 타임아웃: 결과를 미확정으로 남기기

카드 승인 요청이 타임아웃되면 승인이 실제로 나갔는지 아무도 모릅니다. 세 가지를 견줬습니다. 즉시 실패는 가장 단순하지만 이미 승인된 결제를 실패로 잘못 적을 위험이 있습니다. 즉시 재시도는 승인 요청을 바로 다시 보냅니다. 중복 결제를 막으려면 PG의 멱등성 보장이 필요합니다. 결제 코어가 특정 PG의 보장을 전제하면 PG를 갈아 끼울 때 그 전제가 따라오지 않습니다.

미확정 보존을 골랐습니다. 대가는 결과가 확정될 때까지 고객이 기다린다는 것(202로 응답)과 복구 배치를 운영하는 비용입니다. 잘못 확정한 결제를 되돌리는 비용이 더 큽니다. PG를 갈아 끼워도 성립해야 한다고 판단해서 이 비용을 택했습니다.

타임아웃은 성공도 실패도 아니다. 자리 잡기, PG 승인, 확정 세 단계로 나눠 미확정을 남기고 복구가 다시 물어 닫는 흐름

2. 멀티 PG failover: 타임아웃엔 절대 넘기지 않는다

한 PG가 죽으면 다음 PG로 넘기는 라우팅을 만들었습니다. 그런데 타임아웃과 미확정 예외에는 failover하지 않게 막았습니다. 첫 PG에서 승인이 실제로 나갔을 수 있는데 다음 PG로 넘기면 이중 결제가 됩니다. failover에서 어려운 부분은 언제 넘기지 않을지를 정하는 일이었습니다.

이 판단 때문에 라우팅 기능 자체는 기본값이 꺼져 있습니다. 지금 계약된 PG가 하나뿐이라 두 경로 모두 가짜 구현으로 오가는 것은 failover를 증명하지 않는다고 봤습니다. 로직의 정확성(카드 거절과 타임아웃은 넘기지 않는다는 규칙)은 테스트로 고정해 뒀습니다. 실 PG가 둘 이상 계약되고 각 PG의 승인율을 실측할 수 있을 때 켜기로 했습니다.

3. 포인트 적립 동시성: DB 원자 증가로 락 충돌을 없앤다

동시 결제가 같은 포인트 계좌 행을 읽고 고치고 쓰면서 낙관적 락 충돌을 냈습니다. 그 충돌이 결제 승인 API의 응답까지 500으로 만들었습니다. 락 재시도, 이벤트로 비동기 분리, Redis로 세다 주기적으로 반영하는 방식, DB 원자 증가 넷을 비교했습니다. 비동기 분리는 결제 응답에 실패가 번지는 것은 막지만 같은 잔액의 동시 갱신 문제는 그대로 남습니다. Redis 방식은 DB에 반영하기 전 적립 내역이 유실될 위험이 있어 제외했습니다.

적립은 더하기라 순서가 무관합니다. 애플리케이션에서 잔액을 먼저 조회하지 않고 INSERT ... ON DUPLICATE KEY UPDATE로 DB에서 바로 더하게 바꿨습니다. 대가는 이 방식이 낙관적 락의 감시를 벗어난다는 것입니다. 그래서 version도 함께 올렸습니다. 사용·환불 경로가 낡은 잔액으로 덮어쓰는 사고를 그 충돌로 잡히게 했습니다.

동시 적립의 낙관적 락 충돌이 결제 API 오류로 번진 것을 원자 증가로 바꾼 전후. 같은 조건 재실험에서 2xx 응답 비율이 39.6%에서 100%(6,761/6,761)로 올랐다

4. AI: 규칙이 답하는 자리에는 안 붙였다

사람이 반복해서 확인하던 자리(대사 불일치 원인, 장애 로그 분석, 상담 초안)에 모델을 붙이기 전에 먼저 세어 봤습니다. 대사 원인 여덟 종 가운데 여섯은 산수나 조회로 결정됩니다. 여기에 모델을 쓰면 이미 답이 나온 것을 추측하게 만드는 셈이라 규칙 분류기로 남겼습니다.

장애 로그 원인 분석에서는 재현한 장애 12건에서 규칙이 7건을 맞히고 5건을 기권했습니다. 틀린 건 없었습니다. 모델은 11건을 맞히고 1건을 틀렸습니다. 정확도만 보면 모델이 앞섭니다. 그런데 규칙은 아는 것만 답하고 모르면 기권하는 반면 모델은 틀린 것도 확신 있게 답합니다. 그래서 둘 중 하나를 고르지 않고 규칙을 앞에 둔 채 규칙이 기권한 자리에만 모델을 부르는 구성(rule-first)으로 정했습니다. 이렇게 정한 자리 둘은 실제로 켰습니다. 대사 잔여 후보 쪽은 표본을 늘려 다시 재니 결론이 두 번 뒤집혀 결국 껐습니다.

일정과 작업 방식

첫 커밋은 2025년 10월 1일(Phase 0: Spring Modulith 뼈대)입니다. 이 글을 쓰는 시점 마지막 커밋은 2026년 9월 13일입니다. 저장소 전체 커밋은 365개입니다.

초기 뼈대는 커밋 메시지의 Phase 번호로 나뉘어 있습니다. 2025년 10월 한 달 안에 Phase 0(모듈 뼈대)부터 Phase 6(운영: 관측성 + DLQ 어드민)까지 이어졌습니다. 이후 2025년 11월부터 2026년 2월까지 결제수단 확장(카드·포인트·월렛·구독·가상계좌·FDS·필드 암호화)과 보안·운영 마감이 이어졌습니다. 2025년 10월부터 2026년 6월까지는 달마다 커밋 수가 14~16개로 고르게 나옵니다. 이 구간은 커밋 날짜만으로 작업 순서를 세밀하게 재구성하지는 않았습니다. 대신 커밋 메시지에 남긴 단위(체크아웃 사가 리팩터, 정산 집계 키 수정, 회원·분쟁 모듈 추가 같은)로 무엇을 했는지가 드러납니다.

2026년 7월부터는 커밋 메시지가 감사와 재검토 성격(문서와 코드의 불일치 정정, 실측 재확인)으로 바뀝니다. 8월부터는 AI 운영 자동화 실험(루브릭 설계, 블라인드 비교, 모델 비교)이 다수를 차지합니다. 2026년 9월에는 작업 단위가 GitHub 이슈와 PR로 뚜렷하게 남습니다. 저장소에는 이슈 3건과 PR 80건이 있고 두 번호는 같은 시퀀스를 공유해 최대 번호가 83까지 갑니다. 각 PR은 대개 하나의 작은 변경(예: PR #83 FDS 학습기 검증, PR #79 규칙 값에 이름을 붙여 모델에게 넘긴다)에 대응합니다. 9월 한 달의 커밋만 163개입니다.

설계 결정은 커밋 로그에 흩어지지 않게 ADR(Architecture Decision Record) 21편으로 따로 남겼습니다. docs/adr/에 있으며 트레이드오프가 있는 결정마다 배경과 대안, 대가를 한 편씩 적었습니다. 위 고민한 선택 네 가지도 각각 ADR 문서가 근거입니다.

직접 재 본 결과

아래 수치는 모두 로컬 환경(Apple Silicon, 실 MySQL 8.4, 실 Redis)에서 조건을 명시해 잰 것입니다. 실 트래픽 결과가 아닙니다.

  • 포인트 적립을 원자 증가로 바꾼 뒤, 한 계정·가상 사용자 30명·같은 조건 재실험에서 결제 승인 API의 2xx 응답 비율이 39.6%에서 100%(6,761/6,761)로 올랐습니다. 포인트 잔액 978,400원과 원장 대조 금액 978,400원이 일치해 불일치는 0건이었습니다.
  • 장애 원인 분석 재현 12건(qwen3:8b, 4회 반복 동일)에서 규칙은 7건을 맞히고 5건을 기권했으며 틀린 건은 0건, 모델은 11건을 맞히고 1건을 틀렸습니다. 12건이라는 표본 크기와 유형당 1~4건뿐이라는 제약은 그대로입니다.
  • 운영자가 PG와 내부 거래 내역이 다른 이유를 확인하는 조회를 7회에서 1회로 통합했습니다.
  • 구매확정이 승인일보다 늦어진 주문이 정산에서 영구히 빠지던 문제를 구매확정 시각 기준 집계로 고쳤습니다. 이 흐름은 회귀 테스트로 고정했습니다.

아직 못 한 것

  • 다통화는 최소 단위와 통화를 함께 갖도록 값 타입을 고치고 통화가 다르면 연산을 막는 단계까지 갔습니다(ADR-016 1~3단계). 원장을 통화별로 가르고 거래에 적용 환율을 저장하는 단계(4~6단계)는 보류했습니다. 승인 시점이 아니라 매입일 환율을 쓰는 카드사 계약을 실측할 방법이 없어 여기서 멈췄습니다.
  • 정산 공휴일 캘린더와 분쟁 대응기한 자동 처리는 실서비스라면 이렇게 확장한다는 메모만 문서에 남겼습니다.
  • 쿠폰은 정책을 더 고민하려고 범위 밖으로 남겨뒀습니다.
  • 멀티 PG failover는 로직과 테스트만 있고 기본값이 꺼져 있습니다. 실 PG가 둘 이상 계약되기 전까지는 켤 계획이 없습니다.

직접 실행해 보기

Docker와 Docker Compose, JDK 21이 필요합니다.

Terminal window
git clone https://github.com/dj258255/payment-system.git
cd payment-system
docker compose up -d
./gradlew bootRun

로컬 데모 계정은 사용자 1, 2(비밀번호 user-local-only)와 운영자 admin, admin2(비밀번호 admin-local-only)입니다. 이 값은 로컬 전용 기본값이며 운영 환경에서는 환경변수나 시크릿 매니저로 반드시 교체해야 합니다. 스토어는 http://localhost:8080/, 운영 백오피스는 http://localhost:8080/admin.html에서 열립니다.

더 보기

프로필 사진
작성자 @범수

오늘의 노력이 내일의 전문성을 만든다고 믿습니다.

댓글

댓글 수정/삭제는 GitHub Discussions에서 가능합니다.