ParityPay로 검증하는 결제 정합성 6 - 대사: "기관에 물어보지 못했다"와 "기관에 기록이 없다"는 다른 상태다
엔지니어링 요약
Problem
건별 복구(3편)가 끝나도 우리 기록과 외부기관 기록이 같다는 보장은 없다. 대사가 그 마지막 확인인데, 처음 구현한 대사는 기관 표를 같은 DB에서 조인하고 있었다. 조회는 항상 성공하고, 비어 있으면 '기관에 기록이 없다'가 됐다. 대사가 실패할 수 없는 구조였다.
Decision
기관을 별도 프로세스에 더해 별도 데이터베이스로 떼어 조인 자체를 불가능하게 만들고, 명세를 기관 API로 받되 받지 못한 회차는 아무것도 기록하지 않고 멈춘다. 차이는 6가지로 분류하고 자동 보정하는 유형은 두지 않았다. 보정은 새 분개이며 요청자와 승인자가 달라야 한다.
Result
명세를 받지 못한 날은 불일치 0건, 이벤트 0건, 실행 기록 0건으로 멈춘다. 가드를 빼면 우리 기록 전부가 INTERNAL_ONLY 불일치가 되는 것을 확인했다. 원장·스냅샷 어긋남은 탐지 54ms, 재구축 104ms에 원장은 손대지 않는다. 대사 배치는 API 부하에서 측정 가능한 저하를 만들지 않았다.
3편은 외부 응답이 사라진 거래 하나를 어떻게 확정하는지에 관한 글이었다. 복구 작업이 기관에 물어보고, 답에 따라 성공·실패로 수렴시키고, 답을 못 들으면 사람에게 넘긴다. 그 절차가 끝나면 그 거래는 확정된 상태다.
그런데 확정됐다는 것과 맞다는 것은 다르다. 우리가 성공으로 확정한 거래가 기관에는 실패로 남아 있을 수 있고, 기관이 처리한 거래가 우리에게는 아예 없을 수 있다. 복구는 우리가 아는 미확정 거래만 다룬다. 우리가 모르는 차이를 찾는 것이 대사(reconciliation)의 일이다. 이 글은 그 대사를 만들면서 무엇을 결정했고, 그중 하나가 처음에 어떻게 틀려 있었는지를 적는다.
검증기는 두 층이다
대사를 말하기 전에 위치를 정해야 한다. ParityPay에는 정합성을 확인하는 장치가 둘 있고 역할이 다르다.
| 실시간 불변조건 지표 | 대사 | |
|---|---|---|
| 무엇을 비교하는가 | 우리 안의 두 표현. 원장 차변·대변 합계, 원장 계산값과 잔액 스냅샷 | 우리 기록과 외부기관 기록 |
| 언제 | 30초마다 계산해 캐시, Prometheus가 읽음 | 회차 단위. 충전과 정산 지급을 각각 |
| 잡는 것 | INV-001, INV-003, INV-010 위반. 평소 0이어야 하고 0이 아니면 그 자체가 장애 | 우리는 성공인데 기관에 없음, 기관은 처리했는데 우리에게 없음, 금액·상태가 다름 |
| 잡지 못하는 것 | 외부와의 차이. 원장이 내부적으로 완벽해도 기관과 어긋날 수 있다 | 회차 사이의 차이. 지연 허용 시간 안의 차이는 아직 차이가 아니다 |
실시간 지표는 4편 결함 G에서 다뤘다. 원장 전체를 스크레이프마다 집계하다가 200만 건에서 4.5초가 됐고, 캐시와 refresh_age_seconds로 바꿨다. 이 글은 오른쪽 열이다.
비교 키와 여섯 가지 차이
대사는 내부 기록과 외부 기록을 외부 참조 ID로 맞춰 보고 금액, 결과, 원장 유무를 비교한다. 차이는 여섯 가지로 분류한다.
| 유형 | 뜻 | 자동 보정 |
|---|---|---|
INTERNAL_ONLY | 우리는 성공으로 알고 돈을 움직였는데 외부에 근거가 없다 | 안 함 |
EXTERNAL_ONLY | 외부는 처리했는데 우리에게 기록이 없다 | 안 함 |
STATUS_MISMATCH | 양쪽에 있지만 결과가 다르다. 또는 지연 허용 시간을 넘겨 아직 PENDING이다 | 안 함 |
AMOUNT_MISMATCH | 양쪽에 있지만 금액이 다르다 | 절대 안 함 |
DUPLICATE | 같은 외부 참조를 내부 기록 둘 이상이 가리킨다 | 안 함 |
LEDGER_MISSING | 업무는 성공했는데 원장 거래가 없다 | 안 함 |
표의 마지막 열이 전부 “안 함”이다. 설계 문서에는 autoResolvable 플래그가 있고 enum에도 그 필드가 있는데, 값이 true인 유형은 하나도 없다. 처음부터 그렇게 정한 것은 아니고, 유형을 하나씩 검토하면서 “원인을 모른 채 숫자를 맞추면 진짜 문제를 덮는다”는 이유가 여섯 번 반복됐다. 플래그를 지우지 않고 남겨 둔 것은, 언젠가 자동 보정해도 되는 유형이 생기면 그것이 예외라는 것을 코드가 말하게 하기 위해서다.
LEDGER_MISSING은 외부와의 차이가 아니라 내부 차이인데 대사에 들어 있다. 원장이 시스템 오브 레코드이므로(1편) 업무는 성공인데 원장 거래가 없다는 것은 잔액이 근거 없이 움직였다는 뜻이고, 외부와의 어떤 차이보다 심각하다. 외부 기록을 비교하는 김에 같이 본다.
두 가지 규칙이 노이즈를 거른다.
- 지연 허용 시간 안의 차이는 아직 차이가 아니다. 외부 기록이 우리보다 늦게 도착하는 것은 정상이다. 방금 성공한 충전이 기관 명세에 아직 없다고 해서
INTERNAL_ONLY로 올리면 운영자는 매 회차 노이즈에 묻힌다. PENDING인 내부 기록도 허용 시간 안에는 기다리고, 넘기면STATUS_MISMATCH가 된다. - 외부가 실패로 기록한 요청은 우리 쪽에 아무것도 없어도 문제가 아니다. 요청이 기관에 닿았다가 거절된 것이고, 우리가 확정하지 않은 것이 맞다.
이 규칙 전부가 ReconciliationMatcher라는 I/O 없는 함수에 있다. 내부 기록 목록과 외부 기록 목록, 허용 시간, 현재 시각을 받아 차이 목록을 돌려준다. 대사의 판단 규칙이 이 함수의 전부이므로 단위 테스트 11건으로 여섯 유형과 허용 시간 경계를 전수 검증한다. 판단을 DB 조회와 섞어 두면 “이 조합에서 어떤 유형이 나오는가”를 시험하려고 매번 데이터를 심어야 한다.
대사가 실패할 수 없었다
여기까지는 설계대로 됐다. 문제는 대사가 외부 기록을 어떻게 얻는가에 있었다.
이 글을 쓰기 직전까지 대사는 기관의 표를 같은 데이터베이스에서 직접 읽었다. Mock 은행과 Mock PG는 이미 별도 프로세스로 떼어 놓은 뒤였는데, 표는 우리 데이터베이스에 남아 있었고 대사는 그 표를 그냥 조인했다. 편했고, 빨랐고, 테스트도 우리 JdbcTemplate으로 기관 표를 비우거나 고치면 됐다.
그러면 대사는 실패할 수 없다. 조회는 언제나 성공한다. 결과가 비어 있으면 그것은 “기관에 기록이 없다”가 된다. “기관에 물어보지 못했다”라는 상태가 코드에 존재하지 않았다. 3편에서 건별 복구의 핵심이 “기록 없음”과 “조회 불가”를 구분하는 것이라고 썼는데, 대사에는 그 구분이 없었다. 건별로는 구분하고 회차로는 구분하지 못하는 시스템이었다.
이것이 왜 나쁜지는 가드 없이 빈 명세를 돌려 보면 나온다. 기관이 명세를 못 주는 날, 대사는 빈 목록을 받아 정상적으로 진행하고, 우리 쪽 성공 기록 전부가 INTERNAL_ONLY 불일치가 된다. 운영자는 존재하지 않는 문제 수백 건을 놓고 보정 분개를 검토하게 된다. 대사가 할 수 있는 가장 나쁜 일이다. 아무것도 하지 않는 것보다 나쁘다.
조치는 둘이었다.
첫째, 명세를 기관 API로 받는다. GET /mock-bank/statements/...로 받고, 받지 못하면 ReconciliationSourceUnavailableException이 올라가 그 회차는 불일치 0건, 이벤트 0건, 실행 기록 0건으로 멈춘다. 명세가 돌아오면 정상으로 끝난다. 이 시험(anUnavailableStatementStopsTheRunInsteadOfFlaggingEverything)은 가드를 빼고 빈 목록을 돌려주게 바꿔 실제로 실패하는 것을 본 뒤에 통과를 믿었다. 명세 가용성은 건별 조회 가용성과 따로 제어한다. 실제로 둘은 다른 시스템이고, “건별 조회는 되는데 일별 명세가 안 나오는” 날이 대사에는 더 위험하다.
둘째, 데이터베이스를 나눈다. 기관이 자기 데이터베이스와 자기 마이그레이션을 갖고, 우리 쪽 V19 마이그레이션이 기관 표를 내보냈다. 같은 컨테이너를 쓰더라도 데이터베이스가 다르면 조인이 불가능하다. 첫째 조치만으로는 부족한 이유가 여기 있다. API로 받도록 고쳐도 표가 같은 DB에 남아 있으면 누군가 언젠가 “그냥 조인하면 되는데”라고 생각할 수 있고, 그 생각은 틀리지 않다. 편하니까. 경계는 기억이 아니라 불가능이어야 한다. 조인할 표가 없으면 그 코드는 존재할 수 없다.
같이 바뀐 것이 셋 있다.
- 타임라인 API도 건별 조회 API로 옮겼다. 기관에 물어보지 못한 경우는 조용히 빼지 않고
UNAVAILABLE줄로 표시한다. 빼면 운영자가 “기관에 기록이 없다”로 읽는다. 화면에서도 두 상태는 달라야 한다. - 테스트가 우리
JdbcTemplate으로 기관 표를 만지던 것이 전부 끊겼다. 기관 쪽/admin/reset과 기록 조작 통로에 부탁한다. 남의 장부에 우리가 직접 쓸 수 없다는 사실이 시험 코드에도 나타난다. - 그 과정에서 실수를 하나 했다. 장애 모드 초기화(
reset())에 장부 비우기를 합쳐 놓았더니, 장애를 주입했다가 정상으로 되돌리는 시험들이 방금 만든 외부 기록까지 지워 복구가 확정할 대상을 잃었다. 복구 시험 6건이 실패했다. “장애를 끈다”와 “기록을 지운다”는 다른 동작이라 나눴다.
트랜잭션 경계가 따라 움직였다
명세가 HTTP로 오면서 트랜잭션 경계를 옮겨야 했다. 예전 구조는 한 메서드 안에서 기관 표를 읽고 비교하고 기록했다. 같은 DB니까 문제가 없었다. 명세가 네트워크 너머에서 오면 그 구조는 DB 트랜잭션을 연 채 기관 응답을 기다리는 구조가 된다. 기관이 느린 날 커넥션이 그만큼 묶인다.
그래서 기록을 받는 것은 트랜잭션 밖(ReconciliationService), 비교와 기록만 트랜잭션 안(ReconciliationTransactions)이다. 두 빈으로 나눈 이유는 5편 결함 B와 같다. 같은 빈 안에서 호출하면 프록시를 거치지 않아 트랜잭션이 열리지 않는다. 같은 함정을 두 번째 만난 것이고, 이번에는 구조를 바꾸기 전에 알았다.
트랜잭션 안에서 하는 일은 셋이다. 실행 기록을 먼저 남기고(불일치가 실행을 참조하므로), 매처가 돌려준 차이를 기록하고, 건수를 갱신한다. 이미 열려 있는 같은 차이는 다시 올리지 않는다. 같은 문제를 매 회차마다 새 불일치로 올리면 운영자는 한 문제를 열 번 본다(repeatedRunsDoNotDuplicateOpenMismatches). 반대로 원인을 고치지 않고 해결 처리만 하면 다음 회차에 다시 열린다(resolvingWithoutFixingTheCauseReopensTheMismatch). 해결은 운영자의 선언이 아니라 다음 회차가 확인하는 것이다.
보정은 새 분개이고, 두 사람이 필요하다
차이를 찾는 것까지가 대사의 일이다. 금액을 고치지 않는다. 보정은 근거와 승인을 갖춘 별도 작업이고, 코드가 세 가지를 강제한다.
- 기존 원장을 고치지 않는다. 보정은 새 분개다. 1편의 INV-006(확정 원장 불변)이 트리거로 UPDATE를 막고 있으므로 다른 방법이 없기도 하다.
- 요청자와 승인자가 달라야 한다. 헤더에 아무 문자열이나 적어 자기 요청을 승인할 수 없다. 승인자가 실제로 존재하고, 승인 권한이 있으며, 요청자와 다른 사람인지 확인한다(
adjustmentRequiresTwoDifferentPeople,approverMustHaveApprovalAuthority). - 사유와 근거(불일치 ID)가 없으면 보정할 수 없다. 해결된 불일치는 다시 해결할 수 없다.
금액을 직접 고치는 API는 없다. 운영 작업은 결과가 “변화 없음”이어도 감사 로그로 남고, 그 로그는 트리거로 수정·삭제가 막혀 있다. 원장을 움직이는 유일한 운영 쓰기가 이 보정 분개이므로, 세션이 살아 있어도 비밀번호를 다시 묻는다. 자리를 비운 단말이나 넘겨받은 탭이 그대로 승인하면 안 된다.
조사 결과 조치가 필요 없다고 판단하는 경로도 따로 있다. 그때도 사유는 남는다.
내부 대사: 원장과 스냅샷
외부와의 대사 옆에 내부 대사가 하나 더 있다. 잔액 스냅샷과 원장 계산값의 일치(INV-010)다. 1편에서 이 이중 표현이 조회를 빠르게 하는 대신 “둘이 어긋날 수 있다”는 새 위험을 만든다고 썼고, 그 위험을 실제로 만들어 봤다(F-010).
손상은 SQL로 스냅샷을 직접 고쳐 주입했다. 운영 코드에는 스냅샷을 임의로 바꾸는 경로가 없기 때문이다.
| 단계 | 결과 |
|---|---|
| 탐지 (검증 API) | matches=false, 스냅샷 0원 vs 원장 48,831,000원 |
| 탐지 (지표) | balance_snapshot_drift 0 → 1 |
| 재구축 | 운영자 API 1회 호출로 스냅샷이 원장 값으로 복구, 지표 1 → 0 |
| 원장 | 손대지 않음. 거래 22,485건·항목 44,970건·차변 합계 = 대변 합계 = 3,872,408,000원, 전후 동일 |
| 감사 로그 | 주체·승인자·사유·전후 값 |
개발 노트북에서 원장 항목 44,970건·지갑 87개 기준으로 재구축 API 왕복(손상 주입 → 복구 완료) 중앙값 104ms, 전 지갑 탐지 쿼리 54ms였다. 3회 측정, 워밍업 1회 제외.
여기서도 자동으로 고치지 않는다. 스냅샷이 어긋났다는 것은 어딘가 잘못됐다는 뜻인데, 배경 작업이 조용히 맞춰버리면 원인을 조사할 증거가 사라진다. 그래서 한 번에 전부 맞추는 API도 없다. 목록 API는 찾아주기만 하고 고치지 않는다. 어긋난 지갑이 많다고 해서 이 판단이 달라지지 않는다. 쓸 수 있는 값은 원장 계산값 하나뿐이므로 이 경로로 없는 돈을 만들 수 없고, 원장을 읽은 뒤 쓰기 전에 정상 업무가 잔액을 바꿨다면 버전 충돌로 거부한다. 원장 잔액이 처리중 금액보다 작아 가용 잔액이 음수가 되는 경우(INV-003)도 맞추지 않고 사람에게 넘긴다.
외부 대사와 내부 대사의 규칙이 같다. 찾는 것과 고치는 것을 분리하고, 고치는 쪽은 자동화하지 않는다.
대사 배치는 API를 느리게 만드는가
정산 배치와 대사가 도는 동안 API가 얼마나 느려지는지 봤다(P-004). 반복마다 결제 승인 1건과 구매확정 1건, 배치는 판매자 4곳의 정산 계산과 전체 대사를 60초 동안 쉬지 않고 반복. 같은 부하를 배치 없이, 배치와 함께 두 번 걸었다. 3회 실행.
| 구성 | 결제 p95 (기준선 → 혼합) | 대사 p95 | 60초 동안 만든 회차 |
|---|---|---|---|
| 변경 전 | 25.5ms → 13.3ms | 8.0ms | 490 |
| 변경 후 | 24.7ms → 9.9ms | 6.2ms | 507 |
혼합 구간이 오히려 빠른 것은 개선이 아니라 순서 때문이다. 혼합 구간은 기준선 뒤에 돌아서 JVM과 캐시가 더 덥다. 그래서 이 실험의 결론은 “빨라졌다”가 아니라 “측정 가능한 저하가 없었다”이다. 커넥션 풀은 활성 최대 4, 대기 0이었다. 부하를 RATE=40으로 올리자 기준선에서 이미 오류 1,100여 건이 나와 기기 용량을 넘었고, 그 상태에서는 배치의 영향과 환경 포화를 구분할 수 없어 비교가 성립하지 않았다. 다만 그 실행이 정산 계산의 건별 UPDATE(결함 D)를 드러냈다. 대사가 아니라 정산 쪽 결함이었고, 4편에 있다.
한계와 운영에서 추가할 것
- 기관 명세는 Mock 기관의 API 응답이다. 실제 PG나 은행의 일별 명세 파일(고정 폭, CSV, 영업일 컷오프, 기관마다 다른 형식)을 파싱하는 문제는 다루지 않았다. 대사의 어려움 중 상당 부분이 거기에 있다는 것을 안다.
- 영업일·시간대 처리를 하지 않았다. 비교 키에 영업일이 설계돼 있지만 컷오프 시각을 넘긴 거래를 어느 날 회차에 넣는지는 정하지 않았다.
- 지연 허용 시간은 설정값 하나다. 기관별·경로별로 다른 지연 특성을 반영하지 않는다.
- 대량 손상 절차가 없다. 스냅샷 재구축은 지갑 하나씩이다. 87개 규모에서는 문제없지만, 수천 지갑이 동시에 어긋난 상황의 절차는 만들지 않았다. 일괄 API를 일부러 두지 않은 것과 절차가 없는 것은 다른 문제다.
- 대사 결과의 알림 설계가 얕다. 지연 허용 시간을 넘긴 차이만 승격한다는 규칙은 있지만, 어떤 유형 몇 건부터 누구에게 알리는지는 정하지 않았다.
실무 경험과의 관계
실무에서 다룬 정산은 정산이 도는 동안 기준 데이터를 못 바꾸게 하기였다. 우리 안의 기준 데이터가 정산 도중 바뀌지 않게 하는 문제이고, 비교 대상은 우리 자신이었다. 외부기관과의 대사를 만든 적은 없다.
2021년의 결제대행 연동에는 대사가 없었거나, 있었다면 운영자가 대행사 관리 화면과 우리 DB를 눈으로 비교했을 것이다. 기억나지 않는다고 그 글에 적었다. 이 프로젝트에서 대사를 만들고 나서 알게 된 것은, 눈으로 비교하는 대사에도 “기관에 물어보지 못했다”는 상태가 없다는 점이다. 관리 화면이 안 열리는 날 운영자는 비교를 못 한 것이지 차이가 없는 것이 아닌데, 기록에는 아무것도 남지 않는다. 코드로 만든 대사가 같은 DB를 조인하고 있었을 때와 정확히 같은 모양이다. 도구가 달라도 구분이 없으면 결과는 같다.
정리
- 건별 복구는 우리가 아는 미확정 거래를 확정한다. 대사는 우리가 모르는 차이를 찾는다. 확정됐다는 것과 맞다는 것은 다르다.
- 차이 여섯 유형 중 자동 보정하는 유형은 없다. 찾는 것과 고치는 것을 분리하고, 고치는 쪽은 새 분개·두 사람·사유로만 연다. 스냅샷 재구축도 같은 규칙이다.
- “기관에 기록이 없다”와 “기관에 물어보지 못했다”를 구분하지 못하는 대사는 명세를 못 받은 날 모든 기록을 불일치로 만든다. 아무것도 하지 않는 것보다 나쁘다.
- 그 구분은 기관 표를 같은 DB에서 조인할 수 있는 한 생기지 않는다. 데이터베이스를 나눠 조인을 불가능하게 만들었다. 경계는 기억이 아니라 불가능이어야 한다.
- 외부 호출이 생기면 트랜잭션 경계가 따라 움직인다. 받는 것은 밖, 비교와 기록은 안.
댓글
아직 댓글이 없습니다