BE-commerce 프로젝트
약 5분 분량 결제·정산

대사 담당자가 결제 한 건의 진실을 찾는 데 13번 확인해야 했다: 근거를 한 화면에 모은 이유

목차

결제 대사는 PG 파일과 내부 주문·원장·정산 기록을 비교해 금액이나 상태가 왜 다른지 확정하는 업무입니다. 금액이 1원이라도 맞지 않으면 담당자는 근거를 보고 원인을 선택해야 합니다.

그런데 원인을 필수로 고르게 만들어 놓고 근거는 11곳에 흩어뒀습니다. 한 주문의 상태를 확인하려면 API 7번 호출과 목록 검색 6번이 필요했고, 4종의 근거는 화면에서 볼 방법조차 없었습니다.

결론부터

  • 대사 판단에 필요한 출처를 읽기 전용 타임라인으로 모아 API 호출을 7회에서 1회로 줄였습니다.
  • 조회가 일부 실패해도 조용히 생략하지 않고, 빠진 출처를 응답에 표시해 불완전한 상태에서 확정하지 않도록 했습니다.
  • 산수와 조회로 확정할 수 있는 원인만 규칙이 근거와 함께 제안하고, 위변조처럼 추가 확인이 필요한 원인은 후보로 남겼습니다.

조회 하나가 실패하면 — 조용히 빼지 않고 빠진 출처를 실었다

어드민 컨트롤러가 20개인데 “주문 X번의 현재 진실”을 묻는 창구는 없었습니다. 화면이 주던 정보는 orderNo, 내부금액, 외부금액, 대사시각이 전부라 “이 주문에 무슨 일이 있었는데?”에 답이 없었습니다.

한 도메인 조회가 실패했을 때가 가장 오래 고민한 지점입니다. 갈래가 셋이었습니다.

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

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

행은 그대로 남고 경고만 붙습니다. 정산·원장 조회 실패를 재현해 확인했고(ADR-011), 조립 계약은 테스트로 고정했습니다.

근거를 모으니 다음 질문이 남았습니다. 원인 자체를 제안할 수 있나? 원인 8종 중 4종은 산수·조회로 결정되고 2종은 후보만 내고 2종은 못 냅니다.

원인판별 방법규칙이
수수료 계산 차이차액 ≈ 내부금액 × 수수료율결정한다
부분취소 미반영차액 = 취소된 금액 (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원)로도 취소로도 설명되지 않는다. 취소 이력이 없다.

경계를 넘는 대신 조회 모듈 하나를 밖에 뒀다

Spring Modulith가 모듈 경계를 강제합니다. 대사 모듈의 허용 의존은 shared·payment·audit 셋뿐이라, 읽어야 할 11곳 중 나머지 8곳을 읽으려면 이 경계를 넘어야 했습니다.

11개 도메인이 각자 조각을 내주고 timeline 조회 모듈이 그것들만 한 방향으로 의존해 1개 응답으로 조립하는 구조

  • ① 허용 의존을 늘린다 → 기각. allowedDependencies에 7개를 더 적으면 8곳이 열리고 검증도 통과하지만 “목록에 적으면 뚫린다”가 되면 그 목록은 문서일 뿐입니다.
  • ② 공유 커널에 계약을 둔다 → 기각. shared의 package-info에 내가 적어둔 규칙이 있었습니다. 규칙을 어기면서 경계를 지킨다고 말할 수는 없습니다.

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

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

eBay가 공개한 Explainers가 각 도메인이 조각을 내주고 위에서 조립하는 형태이고, 이름은 API Composition(Aggregator)입니다. 여기서 값이 난 자리는 경계를 뚫지 않고 여러 도메인을 한 화면에 모았다는 것입니다.

구현에서 두 번 막혔습니다. 엔티티는 public인데 리포지토리는 package-private이라 붙이다 알았습니다.

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

public으로 열면 save()까지 열려서 각 도메인이 읽기 전용 조회 서비스를 노출하게 했습니다. 저장소는 숨기고 엔티티도 안 내줍니다(markPaid() 같은 상태 전이를 부를 수 있으니까). 대가는 요약 문장을 조립기가 만들게 된 것입니다. 도메인이 만들면 순환이 되고 저장소를 열면 save()까지 열려서 셋 중 가장 덜 나쁜 것을 골랐습니다.

그리고 TimelineContributor 주석에 *“orderNo는 이 프로젝트의 모든 도메인이 공유하는 유일한 자연키”*라고 썼는데 틀렸습니다. 붙이다 보니 셋이었습니다.

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

원장은 정산 분개도 담아 주문 하나에 대응하지 않고 감사로그는 대상이 주문일 수도 결제일 수도 있어, 한 종류로 강제하면 각자의 목적을 잃습니다. 그래서 3단계 해석으로 갔습니다.

결과

지표BeforeAfter
API 호출7회1회
목록에서 눈으로 찾기6회0회
조회 자체가 불가능하던 정보4종0종
백오피스 화면 이동6개 탭1개
응답 시간—중앙값 14.0ms · p95 16.7ms
DB 쿼리 수—25회(예상 10~14보다 많음)

쿼리 25회는 예상(10~14)보다 많습니다. 감사 구현체가 대상 3종(주문·결제·대사 결과)을 각각 별도 쿼리로 조회하고 나머지 기여자들도 한두 번씩 조회해 합이 25가 됐습니다. 조립 전체가 읽기 전용 하나인데도 쿼리 수는 기여자 수만큼 쌓입니다. 화면 조회에 14ms면 충분해 지금은 고치지 않되 예상과 실측이 갈렸다는 사실은 남깁니다.

남은 구멍

  • 경계를 지키느라 못 막은 확정 차단 — 화면이 경고를 띄워도 확정 버튼은 그대로 눌립니다(resolve()가 타임라인 완전성을 검사하지 않습니다). 완전성을 아는 timeline에 확정을 두자니 그 모듈은 스스로 *“읽기 전용. 조립기가 쓰기를 하면 도메인 규칙을 우회하는 뒷문이 된다”*고 선언해 뒀습니다. 조회 실패 시 열람은 되게·필수 출처가 빠지면 확정은 막게·그래도 확정해야 하면 별도 사유를 받게 셋으로 가르는 게 맞지만, 둘 다 보는 얇은 조립 층이 필요해 모듈을 늘리는 결정이 됩니다.
  • AI는 아직 안 붙였습니다 — 확정 직전 대조값(“일치율”)이 쌓이기 전에는 “AI 붙였습니다”는 되지만 “이만큼 나아졌습니다”는 못 됩니다.
프로필 사진
작성자 @범수

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

댓글

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