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

백오피스: 사람이 확정하게 만들면 끝인 줄 알았다

PaymentSpring Modulith대사운영 자동화결제 시스템
목차

개요

내 결제 기록과 PG 정산 파일이 안 맞는 건을 사람이 골라 확정하는 백오피스 화면을 만들면 끝인 줄 알았습니다. 직접 써 보니 아니었습니다. 원인을 고르라면서 판단 근거는 11곳에 흩어놨고, 확정한 뒤에 그 근거가 바뀌었고, 한쪽 모델을 고쳐놓고 반대쪽 파서는 그대로였습니다.

대사(reconciliation) 는 내 결제 기록과 PG(결제대행사)가 보내주는 정산 파일을 하루치씩 대조해 안 맞는 건을 찾는 작업입니다. 미확정 복구·보상·정산처럼 규칙으로 답이 나오는 일은 배치로 옮겼는데, 그래도 남는 게 있습니다. 금액이 1원 안 맞는 대사, 카드사가 끝내 답을 안 준 결제, 고객이 건 이의제기. 규칙으로 종결하면 틀린 판단이 장부에 남는 일들이라, 이 불일치를 사람이 근거를 보고 원인을 골라 확정하게 설계했습니다.

상황놓친 것고친 것
상황 1원인을 고르라면서 근거가 11곳에 흩어져 있었고, 그중 하나가 빠져도 화면이 알려주지 않았다조회 전용 모듈로 1회 호출에 모으고, 빠진 출처를 응답과 화면에 함께 싣는다
상황 2확정한 뒤 취소가 오면 승인 행을 덮어썼다승인과 취소를 각각의 거래일에 별도 행으로 보존
상황 3전제를 바꿔놓고 반대편 파서를 안 고쳤다음수 환불 행을 읽게 하고 건너뛴 행을 드러냄

개인 프로젝트로 만든 결제 시스템 pay의 개발 기록입니다. 실무 운영 경험이 아닙니다.

상황 1. 원인을 고르라면서 근거를 안 줬다

대사 불일치를 수기로 확정할 때 원인을 필수로 고르게 만들어놓고, 정작 그 판단에 필요한 정보는 11곳에 흩어져 있었습니다. 조회 전용 모듈로 한 번에 모았는데(API 7회 호출과 목록에서 눈으로 6번 찾던 것이 1회로), 만들고 나서 더 중요한 자리를 봤습니다. 그 조회 중 하나가 실패했을 때입니다. 조용히 빼면 화면에는 “기록이 없다”로 읽히고, 그 상태로 원인을 확정하면 틀린 판단이 장부에 남습니다.

내가 만든 화면이 이상했다

며칠 전 대사 불일치를 수기로 확정할 때 원인을 필수로 고르게 만들었습니다. 사유 없이 닫으면 같은 패턴이 다시 왔을 때 처음부터 조사해야 하니까.

그런데 그 화면이 사람에게 주는 정보가 이게 전부였습니다.

orderNo, 내부금액, 외부금액, 대사시각

“이 주문에 무슨 일이 있었는데?”에 답이 없습니다. 원인을 고르라고 해놓고 고를 근거를 안 준 것입니다.

그럼 지금은 어떻게 알아내야 하나 세어봤습니다.

필요한 정보현재 방법
주문GET /orders/{orderNo} (orderNo로 직접 되는 유일한 것)
결제paymentId를 먼저 알아내야 함
대사·원장·정산·보상·분쟁전체 목록을 받아 사람이 눈으로 찾음
에스크로·포인트·월렛·감사로그조회 API가 아예 없음

API 7번 호출 + 목록에서 6번 눈으로 찾기, 그리고 4종은 볼 방법이 없습니다.

어드민 컨트롤러가 20개인데 “주문 X번의 현재 진실”을 묻는 창구가 하나도 없었습니다.

경계를 어떻게 넘을 것인가

이 프로젝트는 Spring Modulith로 모듈 경계를 강제하고 테스트로 검증합니다. 대사 모듈이 볼 수 있는 건 shared·payment·audit 셋뿐입니다. 11곳을 읽으려면 이 경계를 어떻게든 넘어야 합니다.

세 갈래였습니다.

① 대사 모듈의 허용 의존을 늘린다 → 기각. allowedDependencies에 7개를 더 적으면 검증은 통과하지만 의미가 사라집니다. “목록에 적으면 뚫린다”가 되면 그 목록은 문서일 뿐입니다.

② 공유 커널에 계약을 둔다 → 기각. 이걸 고르려다 물렸습니다. sharedpackage-info에 이렇게 적혀 있었습니다.

공유 커널은 “의존해도 되는 것”만 담아야 하며, 도메인 로직은 넣지 않는다.

내가 쓴 규칙입니다. 규칙을 어기면서 경계를 지킨다고 말할 수는 없습니다.

③ 별도 조회 모듈을 만든다 → 채택. timeline 모듈을 새로 만들고 그것만 여러 모듈에 의존합니다. 의존 방향이 한쪽이라 다른 모듈들은 서로를 여전히 모릅니다. 이 모듈은 의존이 많지만, 그건 이 모듈의 일 자체가 “여러 곳을 모으는 것”이라 본질적 복잡도이고, 그 복잡도를 한 곳에 가둔 것이 요점입니다.

eBay가 공개한 Explainers와 같은 형태입니다. 각 도메인이 자기 조각을 내주고 위에서 조립합니다. 조각을 내주는 쪽을 TimelineContributor 인터페이스로 두고 도메인마다 하나씩 구현했습니다.

이 모양에는 이름이 있습니다. API Composition(Aggregator)입니다. 전담 조립자가 하위 여러 곳을 질의해 하나의 응답으로 합치는 형태입니다. 마이크로서비스에서는 서비스마다 따로 나가던 네트워크 왕복이 한 번으로 줄어드는 것이 이 패턴의 큰 이점입니다. 이 프로젝트는 모듈러 모놀리스라 같은 프로세스 안의 호출이어서 그 이점은 크지 않습니다. 여기서 이 패턴이 값을 한 자리는 모듈 경계를 뚫지 않고 여러 도메인을 한 화면에 모았다는 것입니다.

패턴 설명에는 일부가 실패했을 때 부분 결과를 돌려주는 전략도 같이 나옵니다. 그걸 어떻게 할지가 이 절에서 제일 중요한 결정이 됐습니다.

구현하다 두 번 막혔다

리포지토리가 전부 package-private이었다

OrderContributor부터 붙이려다 알았습니다.

interface OrderRepository extends JpaRepository<Order, Long> { } // public이 아닙니다

엔티티는 public인데 리포지토리는 아닙니다. 실수가 아니라 남의 저장소를 직접 열지 못하게 막은 것으로 보입니다.

public으로 열면 조회뿐 아니라 save()도 열립니다. 그래서 각 도메인이 읽기 전용 조회 서비스를 노출하게 했습니다. 저장소는 계속 숨고, 열리는 건 그 도메인이 “무엇을 보여줄지” 정한 결과뿐입니다. 엔티티를 그대로 내주지도 않습니다. 넘기면 받는 쪽이 markPaid() 같은 상태 전이를 부를 수 있으니까.

대가가 있습니다. 요약 문장을 조립기가 만들게 됐습니다. 도메인이 만들게 하면 도메인 → timeline 의존이 생겨 순환이 됩니다. 선택지가 셋인데 셋 다 나빴습니다. 저장소를 열면 save()까지 열리고, 도메인이 만들면 순환이 되고, 조립기가 만들면 요약이 도메인 밖으로 나갑니다. 가장 덜 나쁜 것을 골랐다고 적어뒀습니다.

orderNo가 공용 키가 아니었다

TimelineContributor 주석에 이렇게 썼습니다.

orderNo: 이 프로젝트의 모든 도메인이 공유하는 유일한 자연키다

틀렸습니다. 붙이다 보니 셋이었습니다.

쓰는 곳
orderNo주문·에스크로·정산·월렛·포인트·대사
paymentId결제 이력·원장
(targetType, targetId)감사로그

그리고 그게 다 맞습니다. 원장은 정산 분개도 담는데 정산은 하루치를 묶으므로 주문 하나에 대응하지 않습니다. 감사로그는 “누가 무엇에 손댔나”라서 대상이 주문일 수도 결제일 수도 있습니다. 한 종류로 강제하면 각자의 목적을 잃습니다.

조회 편의를 위해 모든 테이블에 orderNo를 심는 건 기각하고, 3단계 해석으로 갔습니다.

제일 중요했던 결정: 조회 하나가 실패하면 어떻게 하나

한 도메인 조회가 실패하면 어떻게 하나.

  • 전부 버린다 → “정산 조회가 죽어서 아무것도 못 본다”. 조사하려는 사람에게 최악입니다
  • 조용히 뺀다 → *“정산 기록이 없다”*로 읽힙니다. 그 상태로 원인을 확정하면 틀린 판단이 장부에 남습니다
  • 살리되 빠진 출처를 응답에 싣는다 ← 채택

화면에도 그대로 이어집니다. 서버가 정직해도 화면이 감추면 결과는 같으니까.

불완전한 타임라인, SETTLEMENT, LEDGER 조회 실패. 이 화면만 보고 원인을 확정하지 마세요.

행은 그대로 남고 경고만 붙습니다. 하나 죽었다고 조사를 막지는 않습니다.

그런데 경고만으로는 부족하다

여기 빈틈이 있습니다. 화면은 경고를 띄우지만 확정 버튼은 그대로 눌립니다.
resolve()가 타임라인 완전성을 검사하지 않습니다.

“서버가 정직해도 화면이 감추면 결과는 같다”고 써놓고, 정작 사람이 경고를 무시하는 경로는 안 막은 셈입니다.
제대로 하려면 셋으로 갈라야 합니다.

  • 조회가 실패해도 타임라인 열람은 가능하게
  • 필수 출처가 빠졌으면 원인 확정은 차단
  • 그래도 확정해야 하면 별도 사유를 받아 강제로 한 번 더 확인

지금은 셋 다 없고 경고만 있습니다. 그리고 고치려다 모듈 경계에 막혔습니다.

확정을 막으려면 “타임라인이 완전한가”를 알아야 하는데, 그건 timeline 모듈만 압니다.
그런데 timelinereconciliation을 의존하므로 반대 방향을 열면 순환이 됩니다.
timeline 쪽에 확정을 두는 것도 안 됩니다, 그 모듈은 스스로 “읽기 전용. 조립기가 쓰기를 하면
도메인 규칙을 우회하는 뒷문이 된다”
고 선언해 뒀습니다.

클라이언트가 완전성을 실어 보내게 하면 경계 문제는 사라지지만, 그건 1편에서 잡은 바로 그
실수
입니다. 검증의 기준값을 클라이언트에서 받는 것.

그래서 지금은 못 고쳤습니다. 제대로 하려면 둘 다 볼 수 있는 얇은 조립 층이 하나 더 필요하고,
그건 모듈을 늘리는 결정이라 근거가 더 필요합니다. 경계를 지키느라 못 막은 구멍으로 적어둡니다.

그래서 원인도 제안할 수 있을까

근거를 모았으니 다음 질문은 자연스럽습니다. 원인 자체를 제안할 수 있나?

여기서 AI를 떠올리기 쉬운데, 그 전에 세어봤습니다. 원인 8종 중 6종이 산수나 조회로 결정됩니다.

원인판별 방법규칙이
수수료 계산 차이차액 ≈ 내부금액 × 수수료율결정한다
부분취소 미반영차액 = 취소된 금액 (amount − balance)결정한다
거래일 경계인접일에 같은 주문이 있고 승인이 자정 ±2시간결정한다
PG 파일 지연이후 거래일 파일에 같은 주문이 잡혔나결정한다
망취소 반영 시점 차이승인 직후 취소 이력이 있나후보만 낸다
내부 기록 유실내부에 결제 자체가 있나후보만 낸다
같은 거래가 두 번 실림중복은 매칭 전에 걸러져, 남은 불일치만 보고는 못 가른다못 낸다
위변조 의심위 어느 것으로도 설명되지 않는다못 낸다

AI에게 시키면 산수가 이미 답하는 걸 추측하게 만드는 셈입니다. 그리고 남는 둘 중 하나가 위변조 의심 이고, 추측이 제일 위험한 자리입니다.

그래서 규칙으로 만들었습니다. 그리고 근거를 함께 냅니다.

[DECISIVE] FEE_CALCULATION_DIFF
차액 270원 = 내부 10,000원 × 270bps(= 2.7%) (기대 수수료 270원)

근거 없는 제안은 확인 비용만 늘립니다. 사람이 그게 맞는지 알려면 어차피 처음부터 조사해야 하니까. 숫자를 함께 내면 눈으로 검증됩니다.

가짜 데이터로 짰으면 못 봤을 것

규칙을 짜고 실제로 대사를 돌려 검증했습니다. 결제 4건을 만들고, 수수료 차이·부분취소 크기·누락·유령 주문을 담은 CSV를 넣어 진짜 불일치 5건을 만들었습니다.

돌려보니 이랬습니다.

[DECISIVE] FEE_CALCULATION_DIFF 차액 270원 = 10,000 × 270bps
[WEAK] SUSPECTED_TAMPERING ...수수료로도 설명되지 않으면 확인이 필요합니다

근거 문장과 동작이 어긋납니다. *“설명되지 않으면”*이라고 써놓고 설명된 경우에도 붙였습니다. 규칙을 각각 독립적으로 돌렸기 때문입니다.

이러면 사람이 매번 배제 확인을 해야 해서 제안이 오히려 일을 늘립니다. 규칙에 순서를 두고, 대신 배제 사유를 근거에 남기게 고쳤습니다.

차액 5,000원이 수수료(270원)로도 취소로도 설명되지 않는다. 취소 이력이 없다.

단위 테스트만 짰으면 못 봤을 것입니다. 각 규칙은 따로 보면 다 맞으니까.

기준선은 만들어놓고 값을 몰랐다

문서에는 이 분류기가 AI가 넘어야 할 기준선이라고 적어뒀는데, 정작 그 값을 재는 수단이 없었습니다. 정답은 사람이 원인을 고르는 순간에 나오므로, 확정 직전에 분류기를 한 번 더 돌려 대조하게 했습니다.

다만 이걸 “정확도”라고 부르지 않습니다. 사람이 제안을 보고 확정하니 앵커링 편향이 있어 실제 정확도의 상한에 가깝습니다. 그래서 “일치율”이라고 부르고, 이 값이 쌓이기 전에는 AI를 붙이지 않기로 했습니다. 지금 붙이면 “AI 붙였습니다”는 되지만 “이만큼 나아졌습니다”는 못 됩니다.

얼마나 걸리나

지표
응답 시간 중앙값14.0ms
p9516.7ms
DB 쿼리 수25회

쿼리 25회는 예상(10~14)보다 많습니다. 예상이 틀렸습니다. 감사 쪽 구현체가 대상 3종(주문·결제·대사 결과)을 각각 별도 쿼리로 조회하고, 나머지 기여자들도 한두 번씩 조회하다 보니 합이 25가 됐습니다. 트랜잭션은 조립 전체가 읽기 전용 하나로 묶여 있는데도, 쿼리 수는 기여자 수만큼 쌓입니다.

지금은 고치지 않습니다. 사람이 화면에서 누르는 조회로 14ms면 충분하고, 이걸 요구하는 결함이 없습니다. 다만 예상과 실측이 갈렸다는 사실은 남깁니다.

결과

지표BeforeAfter
API 호출7회1회
목록에서 눈으로 찾기6회0회
조회 자체가 불가능하던 정보4종0종
백오피스 화면 이동6개 탭1개
응답 p95(없음)16.7ms

여기까지에서 배운 걸 하나만 고르면 이거입니다.

설계 문서에 쓴 문장을 코드가 지키는지는 별개입니다. 근거 문장에 *“설명되지 않으면”*이라고 써놓고 설명된 경우에도 붙이는 코드를 짰고, *“모든 도메인이 공유하는 유일한 키”*라고 써놓고 실제로는 셋이었습니다.

둘 다 실제로 돌려보고 알았습니다.


상황 2. 이미 내린 결론이 조용히 무효가 됐다

근거를 모아줬으니 이제 사람이 원인을 고를 수 있습니다. 그런데 고르고 나면 그 판정은 계속 맞나.

남의 사례를 읽다가 우리 걸 봤다

기술 블로그를 읽다 보면 “우리도 그런가?” 싶은 대목이 나옵니다. 배치 집계에서 기간 경계를 잘못 잡아 데이터가 새는 이야기가 그랬습니다. 소비 시각을 기준으로 날짜를 정하면, 23시 59분 건이 0시에 처리될 때 다음 날로 밀립니다.

우리 대사는 어떻게 하고 있나 봤습니다. 승인 경로는 괜찮았습니다. 이벤트 페이로드에 승인 시각이 실려 있고 그걸로 거래일을 정합니다. 소비 시점의 현재 시각을 쓰지 않습니다. 여기까진 안심했습니다.

그런데 취소 경로를 보다가 다른 게 걸렸습니다.

// 대사 모듈 — 취소 이벤트를 받으면
record.applySettleableBalance(남은금액);

덮어씁니다.

재현부터 했다

여기서 “아마 문제일 것”이라고 쓰고 넘어갈 수도 있었습니다. 그런데 그건 추측이지 결함이 아닙니다. 로컬에 띄워서 그대로 해봤습니다.

  1. 1만 원 주문을 승인합니다
  2. PG 정산 파일을 올려 대사를 돌린다 → MATCHED로 확정
  3. 그 뒤 3천 원을 부분취소합니다
내부 스냅샷(지금) 7000
대사 판정(확정된 것) 10000

갈라졌습니다.

이게 왜 나쁜가. 대사는 “이 거래는 맞았다”고 결론을 내리는 작업입니다. 그 결론이 나간 뒤에 근거 데이터가 조용히 바뀌면, 나중에 누가 확인하러 왔을 때 판정과 데이터가 안 맞습니다. 그런데 다시 대사를 돌릴 계기가 없습니다. 취소가 사흘 뒤에 오면 그 사흘 전 날짜를 누가 다시 볼 일이 없으니까.

틀린 게 아니라, 틀린 걸 아무도 모르는 상태가 됩니다.

문제가 하나가 아니었다

파고들수록 갈라졌습니다.

첫째, 내가 모델링한 정산 파일 계약과 다릅니다. 환불은 원 거래 행을 수정해서 오지 않습니다. 취소일 파일에 음수 행으로 따로 옵니다. 우리는 원 거래일 행을 덮어썼으니, 취소일 대사를 돌리면 그 음수 행이 “외부에만 있는 기록”으로 잡힙니다. 정상 환불이 예외 큐에 쌓입니다. 사람이 봐야 할 진짜 불일치 사이에 노이즈가 섞입니다.

둘째, 원장과 어긋납니다. 원장은 결제로 생긴 돈의 흐름을 회계 장부처럼 기록하는 모듈이고, 그 기록 한 줄을 분개라고 부릅니다. 원장은 취소를 역분개로 쌓습니다. 원래 줄을 고치지 않고 반대 방향 줄을 새로 답니다. 회계에서 지워진 기록은 기록이 아니니까. 그런데 대사 스냅샷만 덮어쓰고 있었습니다. 같은 사건을 두 모듈이 다르게 기록하고 있었던 셈입니다.

셋째, 이벤트에 시각이 없었습니다. 취소 이벤트에 취소 시각이 안 실려 있었습니다. 소비 시점의 현재 시각을 쓸 수밖에 없는데, 그러면 처음에 걱정했던 그 경계 문제가 그대로 생깁니다. 아웃박스 소비가 밀리면 23시 59분 취소가 다음 날 거래일로 갑니다.

고친 방향

승인과 취소를 각각의 거래일에 별도 행으로 쌓기로 했습니다.

seq 0 : +10,000 (승인일)
seq 1 : -3,000 (취소일)

원 거래 행은 손대지 않습니다. 이미 나간 판정이 계속 유효합니다. 취소일 대사에서는 그 날의 음수 행끼리 대조됩니다. PG 파일 모양과 같아집니다.

제약을 지우지 않고 넓혔다

여기가 실수하기 쉬운 데였습니다. 기존 유니크는 order_no 하나였습니다. 주문당 한 행이라는 뜻입니다. 이제 여러 행이 필요하니 없애면 될 것 같지만, 그 제약은 아웃박스 재시도로 같은 취소가 두 번 들어오는 걸 막던 장치이기도 했습니다.

지우는 대신 넓혔습니다.

ALTER TABLE internal_records ADD COLUMN seq INT NOT NULL DEFAULT 0;
ALTER TABLE internal_records DROP INDEX uk_internal_record_order;
ALTER TABLE internal_records ADD CONSTRAINT uk_internal_record_order_seq
UNIQUE (order_no, seq);

멱등성을 애플리케이션 코드만으로 지키려 하면 언젠가 뚫립니다. DB가 마지막 방어선이어야 합니다.

취소 순번은 결제 도메인이 이미 부여하고 있던 값을 그대로 씁니다. 대사가 자기 번호를 새로 매기면, 같은 취소인지 판단할 근거가 사라집니다.

승인이 먼저 온다는 전제가 깔려 있었다

취소를 반영하는 코드는 승인 스냅샷이 없으면 그대로 넘어갔습니다.

if (internalRecords.findByOrderNo(event.orderNo()).isEmpty()) {
return; // 승인 스냅샷이 없는 결제 — 대사 대상이 아닙니다
}

의도는 “대사 대상이 아닌 결제”를 거르는 것입니다. 그런데 이벤트 순서가 역전되면 이야기가 달라집니다.
아웃박스 재발행이나 재시도로 취소가 승인보다 먼저 처리되면, 스냅샷이 아직 없어서 그 취소가 조용히 버려집니다.

외부 리뷰에서 이 지점을 지적받고 고쳤습니다. 버리지 않고 예외를 던집니다.

if (internalRecords.findByOrderNo(event.orderNo()).isEmpty()) {
throw new ReconciliationException("RECON_SNAPSHOT_NOT_READY",
"승인 스냅샷이 아직 없어 취소를 반영할 수 없습니다. 재배달 대기: " + event.orderNo());
}

리스너가 예외를 던지면 그 이벤트는 미완료로 남고 아웃박스가 다시 배달합니다. 그때는 승인이
처리돼 스냅샷이 있습니다. 별도 보류 큐를 만들 필요가 없었습니다, 이미 있는 재배달 장치가
그 일을 합니다. 테스트 이름도 “조용히 넘어간다”에서 “버리지 않고 재배달에 맡긴다”로 바꿨습니다.

무한 재시도가 걱정될 수 있는데, 취소는 승인된 결제에만 발행되므로 스냅샷이 영원히 안 생기는
경우는 도메인상 없습니다.

이름이 같다고 같은 문제가 아니다

작업 중에 정산 모듈에도 applySettleableBalance가 있는 걸 봤습니다. 전역 검색해서 한 번에 고칠 뻔했습니다.

보니까 정산 쪽은 덮어쓰는 게 맞았습니다. 정산은 “지금 얼마를 지급할 것인가”라는 현재 상태를 다룹니다. 취소가 있으면 지급액이 줄어드는 게 맞고, 그 값은 하나뿐입니다. 반면 대사는 “무슨 일이 있었나”라는 사건 이력을 다룹니다. 쌓아야 맞습니다.

같은 이름이 두 곳에 있어서 이 구분이 흐려져 있었습니다. 일괄 수정했으면 멀쩡한 정산을 망가뜨렸을 것입니다.

고친 뒤

같은 시나리오를 다시 돌렸습니다.

대상고치기 전고친 후
승인 행10,000 → 7,000으로 덮어씀10,000 그대로 (seq=0)
취소기록 없음−3,000 별도 행 (seq=1)
확정된 판정10,000, 스냅샷과 갈라짐10,000, 일치 유지
순번 금액 거래일
0 10000 2026-08-30
1 -3000 2026-08-30

이 재현은 같은 날 승인하고 취소해서 두 행의 거래일이 같습니다. 취소가 며칠 뒤 오면 seq=1의 거래일은 그 취소일이 됩니다. 그 분리가 이 수정의 요점이라 테스트로 따로 고정했습니다.

테스트 4건으로 고정했습니다. 음수 행으로 쌓이는지, 거래일이 취소일 기준인지, 같은 취소가 두 번 와도 한 번만 들어가는지, 승인 스냅샷이 없으면 무시하는지. 같은 날에 승인과 취소가 다 있으면 합산되는 것도 확인했습니다. 전체 스위트는 591개 통과.

상황 2가 남긴 것

순서 역전은 외부 리뷰가 짚어줘서 고쳤고, 경고를 무시한 확정은 경계에 막혀 아직 못 막았습니다.

남의 사례를 읽는 방식도 달라졌습니다. 처음엔 “우리도 그 문제가 있나” 하고 찾았는데, 그 문제는 없었습니다. 대신 그걸 확인하려고 코드를 읽다가 다른 게 나왔습니다. 사례는 답이 아니라 어디를 들여다볼지 알려주는 것에 가까웠습니다.


상황 3. 내가 바꾼 모델의 반대편을 안 봤다

취소를 별도 행으로 쌓게 고쳤습니다. 그런데 그 음수 행을 읽어야 할 쪽은 안 고쳤습니다.
며칠 뒤 넘겨뒀던 목록을 열어보다 알았습니다.

넘겼던 목록을 열어본다

실험용 정산 파일을 만들어 대사를 돌렸더니 응답에 이게 있었습니다.

{"external":13,"skipped":3,"matched":4,...}

3행이 건너뛰어졌습니다. 그때는 실험이 급해서 “파서가 요약행을 거르는 기능이 있으니 그거겠지”하고 넘겼습니다. 나중에 정리하면서 “모르는 것” 목록에 적어뒀습니다.

며칠 뒤 그 목록을 마저 보다가 열어봤습니다.

7| 2026-08-30 21:56:52,,psp-f5,0
8| 2026-08-30 21:56:52,,psp-f6,0
9| 2026-08-30 21:56:52,,psp-f7,0

주문번호가 빈 행이었습니다. 내 시드 스크립트가 주문 생성에 실패한 슬롯을 그대로 CSV에 쓴 것입니다. 파서는 정상이었고, 내 테스트 데이터 버그였습니다.

거기서 끝냈어도 됐는데, 파서를 열어본 김에 아래로 내려갔습니다.

그리고 진짜를 찾았다

/** 금액 토큰 파싱 — 실패하거나 음수면 null(스킵 신호). */
private static Long parseAmount(String raw) {
...
return value < 0 ? null : value; // ← 음수를 버립니다
}

테스트 이름까지 이랬습니다.

@DisplayName("음수 amount는 skip한다(금액 음수 금지)")

그런데 같은 패키지의 다른 파일에는 이렇게 적혀 있습니다.

/** @param amount 그 행의 금액. 환불·챠지백은 음수로 온다 */
public record ExternalRecord(String orderNo, long amount, String transactionId)

그리고 상황 2에서 내가 고친 것은 이랬습니다.

/** 취소를 <b>별도 행</b>으로 만든다. 금액은 음수다. */

한 저장소 안에서 세 문장이 서로 어긋나 있었습니다.

어디에 적혀 있나음수(환불)를 어떻게 다루나
InternalRecord: 상황 2에서 고친 것취소를 음수 행으로 쌓는다
ExternalRecord 계약 주석”환불·챠지백은 음수로 온다
PgSettlementCsvParser버린다

무슨 일이 벌어지나

상황 2에서 바꾼 전제가 이거였습니다.

이 프로젝트가 모델링한 PG 정산 파일 계약에서는, 환불이 원 거래 행 수정이 아니라 취소일 파일의 음수 행으로 들어옵니다.

그래서 내부 기록도 취소를 음수 행으로 쌓게 바꿨습니다. 그런데 그 음수 행을 읽어야 할 파서가 버리고 있습니다.

환불이 있는 날의 대사는 이렇게 됩니다.

내부: -3,000 (상황 2에서 만든 취소 행)
외부: 없음 (파서가 버림)
결과: INTERNAL_ONLY ← 정상 환불이 예외 큐로

정상적인 환불이 매번 “사람이 확인해야 할 불일치”로 쌓입니다. 대사 예외 큐는 진짜 문제를 골라내는 자리인데, 거기가 정상 건으로 오염됩니다.

재현했습니다.

승인 10,000 / 환불 -3,000 두 행짜리 파일
→ 파싱된 행 1, 건너뛴 행 1
→ 환불 -3,000원이 사라졌습니다

왜 못 봤나

상황 2를 할 때 내부 쪽만 봤습니다.

InternalRecord를 고치고, 이벤트에 취소 시각을 더하고, 마이그레이션을 쓰고, 테스트를 붙이고, 재현으로 확인했습니다. 꼼꼼히 했다고 생각했습니다.

외부 파서는 “내가 바꾸지 않은 코드” 로 분류돼 있었습니다. 바꿀 일이 없으니 읽을 일도 없었습니다.

그런데 그때 바꾼 건 한쪽의 구현이 아니라 양쪽이 공유하는 모델이었습니다. “취소는 음수 행이다”는 내부에만 해당하는 규칙이 아닙니다.

한쪽 모델을 바꾸면 반대쪽 계약도 같이 봐야 합니다.

그리고 이걸 잡을 수 있었던 자리가 하나 있었습니다. ExternalRecord의 주석이 이미 “환불은 음수로 온다”고 적어놨습니다. 문서는 맞았고 코드가 틀렸는데, 둘을 대조하는 테스트가 없었습니다.

같이 나온 것: 건너뛴 행이 안 보인다

파서는 세 가지 이유로 행을 건너뜁니다.

  • 컬럼 수 부족(요약행 등)
  • 주문번호 없음
  • 금액 해석 불가

그런데 전부 똑같이 카운트만 합니다. 그래서 이런 행이 조용히 사라집니다.

2026-08-30,,psp-x,500000 ← 50만원인데 주문번호가 없음

운영자가 보는 건 skipped: 3뿐입니다. 그게 요약행 3개인지 50만원짜리 거래 3건인지 알 수 없습니다.

대사의 존재 이유가 “돈이 모르게 움직이지 않는다”인데, 파일에 금액이 적힌 채 매칭조차 되지 않은 행이 숫자 하나로 뭉개집니다.

이제 줄 번호·사유·금액을 남기고, 금액이 걸린 채 건너뛴 행을 따로 세어 실행 요약과 경고 로그에 올립니다.

정산 파일에 금액이 있는데 건너뛴 행 2건 — 원본 확인 필요:
[SkippedRow[line=3, reason=주문번호 없음, amount=500000], ...]

합계행도 같이 잡히는데, 그게 맞다

합계,,,510000도 “금액이 있는데 주문번호가 없는 행”이라 함께 잡힙니다.

처음엔 이걸 걸러내려다 그만뒀습니다. 구분할 수 없기 때문입니다. 합계,,,510000과 “주문번호가 빠진 51만원 거래”는 파일만 보고 가릴 방법이 없습니다.

놓치는 쪽(진짜 돈)보다 사람이 한 번 더 보는 쪽(합계행)이 낫습니다. 그래서 테스트도 “합계행도 잡힌다”로 고정해뒀습니다.

넘긴 목록이 알려준 것

이 건은 “모르겠다”고 적어둔 목록에서 나왔습니다.

실험 중에는 넘길 수밖에 없는 것들이 생깁니다. 그때 중요한 건 넘겼다는 사실을 적어두는 것인 것 같습니다. 안 적었으면 skipped: 3은 영원히 안 열어봤을 것입니다.

그리고 내 목록에 빠진 칸이 있었다

원인 분류표에 빠진 게 없는지는 내 목록만 봐서는 알 수 없습니다. 밖에서 잣대를 가져와야 합니다. 라벨된 대사 예외 데이터셋은 결제사 운영 데이터라 공개된 게 없었고, 대신 여러 PSP 운영 자료가 공통으로 드는 여섯 가지 분류(타이밍 격차, 금액 불일치, FX 변동, 은행 입금 누락, PSP 기록 누락, 중복)를 찾았습니다. 표준 규격은 아니라서 “빠진 칸이 있는지” 확인하는 용도로만 썼습니다.

대조하니 다섯은 덮고 있었고 중복 하나가 비어 있었습니다.

엔진은 이미 거래 식별자로 중복을 걸러내고 경고 로그도 남깁니다. 그런데 예외 큐를 확정하는 사람에게는 고를 어휘가 없었습니다. OTHER로 갈 수밖에 없고, 그러면 반복돼도 집계에 안 잡힙니다. 원인을 코드로 받는 이유가 통째로 무너집니다.

DUPLICATE_RECORD를 원인에 추가하고, 실행 요약이 중복 행 수를 싣게 했습니다. 로그는 확정하는 사람이 보는 곳이 아닙니다. 규칙 제안은 안 붙였습니다. 중복은 매칭 전에 걸러지므로 남은 불일치만 보고는 판단할 수 없습니다.

이 대조 자체도 테스트로 남겼습니다. 매핑표를 테스트가 들고 있고 비는 칸에는 왜 비었는지를 쓰게 강제합니다. 통화가 늘거나 새 원인이 생기면 여기가 먼저 걸립니다.

상황 3이 남긴 것

내가 바꾸지 않은 코드가 내 변경 때문에 틀려질 수 있습니다. 상황 2에서 “취소는 음수 행”이라는
규칙을 새로 만들었는데, 그 규칙을 아는 코드는 내가 고친 쪽뿐이었습니다.

그리고 원인 목록은 내가 여덟 개를 세었는데, 밖의 여섯 개와 맞춰보기 전까지 하나가 빈 줄 몰랐습니다.
혼자 만들면 시야가 만든 만큼입니다. 밖에서 잣대를 가져오는 게 가장 싼 검사였습니다.


세 상황을 관통하는 것

셋 다 판정을 사람에게 넘긴 뒤의 문제였습니다.

  • 상황 1. 판단을 요구하면서 판단에 필요한 것을 안 줬습니다. 11곳에 흩어진 걸 1회 호출로 모았습니다
  • 상황 2. 판단은 받아놨는데 그 전제가 바뀐 걸 알려주지 않았습니다. 취소를 별도 행으로 쌓아 전제 자체를 보존했습니다
  • 상황 3. 전제를 고쳐놓고 반대편에 알리지 않았습니다. 파서가 정상 환불을 버려 예외 큐를 오염시키고 있었습니다

그리고 셋 다 터진 적이 없습니다. 상황 1은 내가 직접 화면을 써 보다 이상하다고 느껴서 알았고,
상황 2는 남의 사례를 읽다가 우리 코드를 열어봐서 알았고,
상황 3은 “모르겠다”고 넘겨둔 목록을 며칠 뒤 다시 열어봐서 알았습니다.
장애가 알려주기를 기다렸으면 셋 다 지금도 그대로였을 것입니다.

프로필 사진
작성자 @범수

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

댓글

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