포스트

ParityPay로 검증하는 결제 정합성 4 - 실험 26종이 찾아낸 결함 12건: 문서와 코드를 읽어서 나온 것은 하나도 없었다

엔지니어링 요약

Problem Decision Result

Problem

설계 문서는 촘촘했고 자동화 테스트는 전부 통과했다. 그런데 그 상태로 실제 부하를 주고, 프로세스를 죽이고, 인스턴스를 여러 대 띄우고, 프론트엔드를 실제 백엔드에 연결하자 결함이 12건 나왔다.

Decision

실험을 설계 문서의 '대비되어 있다'를 검증하는 수단으로 삼았다. 부하, 장애 주입, 측정 실험 26종을 돌리고, 결함마다 '왜 테스트가 못 잡았나'를 같이 기록했다.

Result

12건 중 6건은 문서에 '대비되어 있다'고 적혀 있던 것이었다. 1건은 테스트가 있었는데 통과했다. 반복된 패턴은 다섯 가지였고, 그중 '같은 결함을 두 번 찾았다'는 것이 가장 배울 것이 많았다.

ParityPay의 설계 문서는 열여섯 개였다. 제품 기획서부터 도메인 상태 전이, 원장 분개 카탈로그, 정합성과 장애 복구 설계, 테스트 전략까지. ADR도 열한 개였다. 자동화 테스트는 백엔드 317개, 프론트엔드 56개, E2E 7개이고 전부 통과한다.

그 상태에서 부하와 장애 실험 26종을 돌렸다. 결함 12건이 나왔다. 이 글은 그 12건이 무엇이었는지보다, 왜 문서와 테스트가 있었는데도 실험을 돌려야 나왔는지를 정리한 기록이다. 12건 중 문서나 코드를 설계 검토로 읽어서 나온 것은 하나도 없다. 실험을 준비하다 클라이언트 코드를 따라가서 나온 것이 둘, 실험 자체에서 나온 것이 열이다.

결함 목록

 결함드러난 계기문서에 “대비됨”이라 적혀 있었나
A마스킹된 계좌번호가 컬럼 길이를 넘침부하 
BOutbox 발행기가 운영 경로에서 전혀 동작하지 않음부하
C브로커 장애 시 발행 라운드 전체가 롤백됨트래픽 중 SIGKILL 
D정산 항목을 한 건씩 UPDATE포화 부하 
E원장 계정 최초 생성의 경합포화 부하 
F발행기 다중화 시 Aggregate 안의 순서가 깨져 정산 금액이 달라짐JVM 4대
G불변조건 지표가 스크레이프마다 원장 전체를 집계원장 200만 건
H스케줄 작업 여섯 개가 스레드 하나를 공유G 조사 중 
I잔액 변경이 트랜잭션 앞이라 잠금을 필요 이상으로 오래 쥠락 대기 관측
J주문번호로 검색하면 타임라인에서 원장이 빠짐프론트엔드를 실제 백엔드에 처음 연결 (테스트도 있었음)
K복구 작업이 틱당 한 배치만 처리해 초당 10건이 상한미확정 1,000건 적체 
L미확정 취소가 201로 나가고 조회 API가 없어 화면이 “취소됨”으로 보여줌취소 적체를 재려고 클라이언트를 읽다가

I는 2편, K와 L은 3편에서 다뤘다. 여기서는 나머지를 패턴으로 묶는다.

패턴 1: 테스트가 부르는 방법과 운영이 불리는 방법이 다르다

결함 B가 가장 뼈아팠다. 부하 20분 동안 Outbox 이벤트 22,572건이 전부 PENDING이었다. 발행 0건. Transactional Outbox는 이 프로젝트의 핵심 설계 중 하나이고, ADR이 있고, 통합 테스트가 있고, 전부 통과했다.

원인은 스케줄 메서드가 같은 빈의 @Transactional 메서드를 직접 호출해 프록시를 우회한 것이었다. 선점 쿼리의 전파 속성이 MANDATORY라 매 라운드가 IllegalTransactionStateException으로 끝났다. Spring을 써본 사람이면 아는 함정이다. 그런데 왜 테스트가 못 잡았는가. 모든 테스트가 publishBatch()주입된 빈으로 호출했다. 그 경로는 프록시를 거치므로 정상 동작한다. 운영에서 실제로 도는 경로(스케줄러가 부르는 메서드)는 한 번도 실행된 적이 없었다. 테스트는 발행 시점을 통제하려고 스케줄러를 꺼두고 있었다.

결함 C도 같은 자리다. 브로커가 죽어 있는 동안 이벤트 126건이 attempt_count = 0으로 멈췄다. 백오프도 실패 기록도 없이 같은 배치를 계속 재시도했다. 배치 전송으로 바꾸면서 send() 호출을 try 밖에 뒀고, 메타데이터를 못 받으면 그 자리에서 던지는 예외가 발행 트랜잭션 전체를 롤백시켰다. 발행 테스트는 MessageBroker 포트를 목으로 바꿔놓고 있었다. 목은 시킨 대로만 던지고, 실제 어댑터가 브로커 없이 어떻게 실패하는지는 아무도 확인하지 않았다.

두 결함의 교훈은 하나다. 테스트에서 부르는 방법과 운영에서 불리는 방법이 다르면, 통과한 테스트가 아무것도 보장하지 못한다. 조치는 트랜잭션 경계를 별도 빈으로 분리하고, 스케줄 경로를 직접 실행하는 회귀 테스트를 추가하는 것이었다. 브로커 어댑터는 실제 브로커 없이 어떻게 실패하는지를 확인하는 테스트 4건을 넣었다.

패턴 2: “대비되어 있다”와 “확인했다”는 다르다

결함 F. 발행기를 4대 띄워 20,000건을 비웠다. 유실 0, 중복 0. 그런데 브로커 도착 순서에서 50건이 역전됐다. ADR에는 “FOR UPDATE SKIP LOCKED로 다중 인스턴스에 대비되어 있다”고 적혀 있었고, 그 문장은 유실과 중복에 대해서는 맞았다. 잠금은 제 몫을 했다. 아무도 순서를 요구사항으로 적어두지 않았을 뿐이다.

순서가 왜 중요한지는 소비자 코드를 다시 읽고서야 알았다. 정산 소비자는 취소 이벤트를 받았을 때 그 결제의 판매 항목이 이미 있는지로 처리를 가른다. 취소가 구매확정보다 먼저 도착하면 “확정 전 취소”로 오해하고 버린다. 10,000원 결제에 4,000원 취소일 때 판매자에게 3,600원이 더 나간다. 테스트로 이 금액을 고정했다.

조치는 선점 쿼리가 파티션 키마다 가장 앞선 미발행 이벤트 하나만 집어가게 하는 것이었다. 앞선 형제가 아직 PENDING이면 그것을 잡은 발행기가 아직 커밋하지 않았다는 뜻이고, 커밋은 브로커 ACK 뒤에 일어나므로, 뒤 이벤트가 후보가 되는 시점에는 앞 이벤트가 이미 브로커에 있다. 대가는 head-of-line 대기다. 한 이벤트가 계속 실패하면 같은 Aggregate의 뒤 이벤트도 멈춘다. 순서를 지키려면 그래야 하고, 멈춘 사실은 적체 지표로 드러난다.

이 결함이 말하는 것은 “다중 인스턴스로 준비되어 있다”는 문장이 ADR에 있었고 아무도 두 대를 띄워보지 않았다는 사실이다. 준비와 확인은 다른 일이다.

결함 G. 불변조건 지표가 게이지를 읽을 때마다 원장 전체를 집계했다. 지갑 87개 규모에서는 보이지 않던 비용이 원장 항목 200만 건에서 스크레이프 한 번에 4.5초가 됐다. Prometheus 간격이 5초이므로 사실상 쉬지 않고 도는 상태였다. 더 커지면 스크레이프가 시간 초과되고, 그러면 이 지표만이 아니라 모든 지표가 사라진다. 불변조건을 지키는 장치 자체가 데이터가 늘수록 먼저 무너지도록 되어 있었다. 원장은 append-only라 이 비용은 단조 증가한다.

값을 30초마다 계산해 캐시하고 게이지는 캐시를 읽게 했다. 그러자 새 위험이 생겼다. 갱신이 멈춘 채 마지막 0이 계속 보이면 아무 일도 없는 것처럼 보인다. 그래서 refresh_age_seconds를 함께 내보내고 경보를 걸었다. 갱신이 실패하면 값을 0으로 덮지 않고 마지막 값을 유지한 채 나이가 자라게 둔다. 스크레이프 응답은 12~17ms가 됐고, DB 점유는 90%에서 21%로 내려갔다.

G를 조사하다 결함 H가 나왔다. spring.task.scheduling.pool.size가 설정되지 않아 기본값 1이었다. 발행기, 복구 4종, 대사 배치가 전부 같은 스레드였다. 원장 200만 건에서 불변조건 갱신이 6.26초 걸리는 동안 이벤트 발행이 멈췄다. 새로 만든 문제가 아니라 대사 배치와 복구 작업에 이미 있던 위험이었고, 느린 작업 하나가 생기고서야 보였다.

패턴 3: 테스트가 있었는데 증명할 수 있는 것보다 적게 주장했다

결함 J는 종류가 다르다. 프론트엔드를 실제 백엔드에 처음 연결한 날 20분 만에 나왔다. 운영 콘솔에서 주문번호로 검색하면 타임라인에 원장 줄이 없었다. 같은 거래를 결제 ID로 검색하면 나왔다. 원장은 reference_id에 결제 ID를 저장하는데, 쿼리가 입력값을 그대로 비교했기 때문이다. 고객은 보통 주문번호를 들고 온다.

timelineByOrderId 테스트는 처음부터 있었다. 그런데 PAYMENT 키가 있는지만 확인했다. 원장이 통째로 빠져도 통과한다. 테스트가 증명할 수 있는 것보다 적게 주장한 것이고, 같은 날 프론트엔드에서도 같은 실수를 발견했다. 결제 연타 테스트가 버튼 비활성화만 증명하고 있었다.

프론트엔드 테스트 43개는 전부 MSW로 API를 대역하고 있었다. MSW 핸들러가 타임라인 응답을 통째로 만들어주므로, 프론트 테스트는 원장 줄이 서버에서 오지 않는다는 것을 볼 방법이 없었다. 목은 자기가 흉내 내는 계약이 틀렸을 때 그 사실을 알려주지 않는다. 실제 통신 한 번이 목 43개보다 많은 것을 말했다. 그래서 목을 쓰지 않는 E2E를 남겼다. 실제 API, 실제 기관, 실제 브라우저로 결제부터 장애 주입과 복구까지 한 바퀴 돌고, 결함 J를 되살리면 실제로 실패한다. 무거우므로 PR 게이트가 아니라 주간 실행이다.

결함 L도 같은 자리다. 서비스 단 테스트는 취소가 UNKNOWN으로 보존되는 것을 확인하고 있었다. HTTP 상태 코드와 화면은 아무도 보지 않았다. 계약의 한 층 아래만 본 것이다.

패턴 4: 동시에, 또는 그 규모로 지나간 적이 없었다

결함 E. 부하 시작 직후 결제 몇 건이 500으로 실패했다. duplicate key value violates unique constraint. 원장 계정을 “조회 후 없으면 삽입”으로 만들고 있었고, 계정은 첫 사용 시점에 생기므로 부하가 시작되는 순간 같은 계정을 여러 요청이 동시에 만들려 했다. PostgreSQL은 제약 위반으로 트랜잭션 전체를 중단시키므로 예외를 잡아 넘길 수도 없다. INSERT ... ON CONFLICT DO NOTHING 후 조회로 바꿨다. 이 저장소가 멱등성에서 이미 쓰던 방식이었다. 계정 생성은 모든 테스트가 지나가는 경로인데, 동시에 지나간 적이 없었다.

결함 D. 포화 상태에서 정산 계산 한 건이 평균 16.3초, 최대 30.4초였다. 회차에 항목을 묶을 때 항목마다 UPDATE를 보냈다. 회차 하나에 최대 1,000번 왕복이다. 그동안 트랜잭션이 열린 채 커넥션을 붙잡고, API 요청은 풀을 기다리다 30초에 실패했다. WHERE item_id = ANY (?) 한 문장으로 묶었다. 435.7ms → 74.8ms. 항목 몇 개짜리 테스트에서는 보이지 않는 비용이다.

결함 A. 마스킹된 계좌번호 길이가 입력 길이에 비례해 VARCHAR(30)을 넘었다. 테스트는 현실적인 계좌번호만 썼고, 임의 길이 입력을 넣어본 적이 없었다.

패턴 5: 같은 결함을 두 번 찾았다

결함 K는 B와 같은 모양이다. Outbox 발행기가 틱당 한 배치만 보내던 것을 앞선 실험에서 고쳤는데, 복구 작업 네 개가 똑같은 구조로 남아 있었다. 미확정 1,000건을 쌓아보고서야 드러났다. 정확성은 멀쩡했다. 1,000건 전부 정확히 한 번 들어왔다. 문제는 그중 45%가 클라이언트의 90초 안에 답을 받지 못했다는 것이다.

같은 결함을 두 번 찾았다는 것은, 첫 번째 때 “다른 곳에도 같은 구조가 있는가”를 묻지 않았다는 뜻이다. 결함을 고치는 것과 그 결함의 종류를 고치는 것은 다른 일이다. 이번에는 네 작업에 같이 적용했고, 그때 함께 물었어야 할 질문을 그때는 묻지 않았다는 것을 적어뒀다.

측정 도구도 틀린다

결함은 아니지만 같이 적어둘 것이 있다. 첫 부하 실행에서 370만 반복에 HTTP 요청 4건이 기록됐다. 선언하지 않은 변수를 읽어 매 반복이 즉시 예외로 끝나고 있었다. 부하 도구의 요약만 보면 “엄청난 처리량”으로 착각할 수 있다. 요청 수와 응답 코드 분포를 먼저 확인해야 한다.

실험 하니스가 처리량을 상위 15개 문장 목록에서 찾다가 대상이 밀려나자 0으로 보고한 일도 있었다. 값이 아니라 측정 실패였다. 잔액 UPDATE를 문자열로 찾다가 JDBC와 Hibernate가 만드는 SQL이 달라 엉뚱한 문장을 집어온 일도 있었다. 측정 도구를 믿을 수 없으면 측정도 믿을 수 없다.

어떤 실험이 결함을 찾았는가

12건을 계기별로 다시 보면 다섯 종류다.

계기결함공통점
부하A, B, D, E한 건씩은 문제없던 것이 동시에, 또는 많이 지나가면 드러난다
프로세스 종료, 브로커 장애C정상 경로만 테스트하면 실패 경로의 실패 방식을 모른다
인스턴스 다중화, 데이터 규모F, G, H, K“대비되어 있다”는 한 대, 작은 규모에서의 문장이다
서버 안쪽 관측I응답 시간은 원인을 가리키지만 원인의 크기는 문장 단위로 봐야 나온다
실제 연결J, L목은 계약이 틀렸을 때 알려주지 않는다

다섯 종류 모두 “정상 요청이 정상 응답을 받는” 경로 밖에 있다. 설계 문서와 단위 테스트는 그 경로를 잘 다룬다. 결함은 그 밖에 있었고, 그 밖으로 나가려면 실제로 부하를 주고, 죽이고, 여러 대를 띄우고, 연결해야 했다.

실무 경험과의 관계

실무에서는 Datadog과 Sentry로 관측했고, 장애가 나면 원인을 추적했다. 그건 이미 일어난 장애를 다루는 일이다. 장애를 일부러 만들어보는 일은 실무 시스템에서 하기 어렵다. 프로세스를 죽이거나 브로커를 내리는 실험은 트래픽이 있는 시스템에서는 허락되지 않는다.

이 프로젝트에서 한 것은 그 반대다. 장애가 나기 전에 장애를 만들었다. 그리고 그 결과의 절반은 “코드가 틀렸다”가 아니라 “테스트가 잘못된 것을 증명하고 있었다”였다. 실무로 돌아가면 가장 먼저 물을 것은 이것이다. 이 테스트는 운영에서 불리는 경로를 부르고 있는가. 이 “대비되어 있다”는 누가 확인했는가. 이 목은 계약이 바뀌면 알려주는가.

정리

  • 문서 16개, ADR 11개, 테스트 380개가 있는 상태에서 실험 26종이 결함 12건을 찾았다. 읽어서 나온 것은 하나도 없다.
  • 6건은 “대비되어 있다”고 적혀 있던 것이었다. 준비와 확인은 다르다.
  • 1건은 테스트가 있었는데 통과했다. 테스트가 증명할 수 있는 것보다 적게 주장하면 통과가 의미를 잃는다.
  • 테스트가 부르는 방법과 운영이 불리는 방법이 다르면(B, C), 목이 계약을 대신하면(J, L), 한 대와 작은 규모에서만 확인했으면(F, G, H), 통과한 테스트는 그 조건 안에서만 참이다.
  • 같은 결함을 두 번 찾았다면(B, K), 첫 번째 때 “같은 구조가 또 있는가”를 묻지 않은 것이다.
  1. 1 ParityPay로 검증하는 결제 정합성 1 - 장애가 나도 지켜야 할 금융 불변조건 여섯 가지
  2. 2 ParityPay로 검증하는 결제 정합성 2 - 잠금을 필요 이상으로 오래 쥐고 있었다: 추론을 측정으로 바꾼 기록
  3. 3 ParityPay로 검증하는 결제 정합성 3 - 외부 승인 응답이 사라졌을 때: 타임아웃은 실패가 아니다
  4. 4 ParityPay로 검증하는 결제 정합성 4 - 실험 26종이 찾아낸 결함 12건: 문서와 코드를 읽어서 나온 것은 하나도 없었다
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

댓글

아직 댓글이 없습니다