동시 환급 요청 6개가 모두 접수됐다
동시 요청과 중간 실패를 재현하고 변이 테스트로 검증의 빈틈을 확인했다.
같은 잔액을 보고 들어온 환급 신청 6개가 전부 접수됐다. 피트니스 센터 관리 서비스의 환급 기능을 로컬 PostgreSQL에서 테스트하다가 발견한 문제다.
이 서비스에는 돈과 포인트가 움직이는 곳이 많다. 센터는 크레딧을 충전하고 환급을 신청한다. 회원은 포인트로 리워드를 교환하고 취소한다. 청구서에는 좌석 수와 초과 좌석 요금이 붙는다. 이 코드의 상당 부분은 AI 코딩 에이전트와 같이 만들었다.
그런데 요청 두 개가 동시에 들어오거나 저장 도중에 한 단계가 실패하는 상황은 단위 테스트만으로는 잘 안 보인다. 그래서 10월 2일부터 3일까지, 금액이 바뀌는 코드에서 그런 상황을 일부러 만들어 봤다.
동시에 실행했을 때 생긴 문제
- 환급 신청: 같은 잔액으로 여러 신청이 접수되는 문제였다. 지갑을 잠근 상태에서 잔액과 기존 신청을 확인하고 신청을 만들도록 바꿨다.
- 리워드 교환: 교환 기록 저장이 실패하고 이어서 보상 환급까지 실패하면 재고는 0이 되고 포인트 차감은 그대로 남았다. 재고·포인트·배분·교환 기록을 한 트랜잭션으로 묶었다. 교환과 취소는 상품 → 지갑 순서로 잠근다.
- 인증 시도 횟수: 정답 요청이 남은 횟수를 읽은 뒤에 다른 요청들이 5회를 다 써 버려도 정답 요청은 성공했다. 같은 정답을 동시에 내면 두 번 성공하기도 했다. 성공을 저장할 때 남은 횟수와 만료 여부를 같이 확인하는 조건부 저장으로 바꿨다.
- 좌석 증설: 20석을 주는 계약에서 10석을 12석으로 늘렸는데도 320원이 붙었다. 제공 좌석을 넘는 만큼만 일할로 매기게 고쳤다.
과거 청구도 문제였다. 예전 청구는 그때의 계약 가격이나 좌석 수를 저장하지 않았다. 이걸 현재 가격으로 거꾸로 추정하지 않고 null로 남겼다. 질의 결과에도 근거가 없다고(evidenceAvailable=false) 표시한다. 새 청구와 크레딧 원장부터는 당시 입력과 순번을 남긴다.
이 테스트에는 운영 데이터 대신 합성 데이터를 사용했다.
테스트마다 맡긴 일
동시 요청, 중간 실패, 계산 오류는 드러나는 조건이 달라 테스트도 나눴다.
실제 PostgreSQL 테스트로는 잠금과 롤백이 진짜 동작하는지 봤다. 실패 주입 테스트로는 저장 단계 중 하나가 실패했을 때 데이터가 반쯤 바뀐 채로 남지 않는지 봤다. fast-check의 모델 기반 테스트는 명령을 무작위 순서로 이어 붙여서 실제 시스템과 단순한 모델이 같은 결과를 내는지 비교한다. 속성 테스트는 정해 둔 성질이 여러 입력에서 유지되는지 확인한다. 다만 규칙 자체를 잘못 정의했다면, 이 방법들이 그걸 찾아 주지는 않는다.
테스트는 믿을 만한가
그러면 테스트는 제대로 잡고 있을까. 이건 변이 테스트로 확인했다. 코드의 연산자나 조건을 일부러 바꿔 놓고 테스트가 그 변화를 잡아내는지 보는 방법이다. 청구 계산 전체와 환불·크레딧 차감의 금액 변경 구간에 Stryker를 돌렸다.
처음에는 변이 73개 중 59개(80.82%)만 잡았다. 잔액을 전부 쓰는 경우, 음수 좌석, 길이가 0인 청구 주기, 잠금 후 삭제 상태 재확인 같은 경계 사례를 테스트에 보탰더니 71개(97.26%)가 됐다. 남은 2개는 <= 0을 < 0으로 바꾼 변이인데, 0원이면 결과가 같아서 구분할 방법이 없다. 그 이유를 정책 파일에 적어 뒀다.
청구 유틸리티 파일은 행·분기 커버리지가 100%였는데도 살아남은 변이가 있었다. 코드가 실행됐다는 것과 테스트가 오류를 잡는다는 건 다른 얘기였다.
검사기도 실패해야 한다
검사가 실패해야 할 때 실패하지 않으면, 통과 기록은 아무 의미가 없다.
그래서 변이 검사 스크립트는 점수만 보지 않는다. 새로 살아남은 변이, 검토하지 않은 오류나 타임아웃, 검사 범위가 줄어든 경우를 모두 실패로 친다. 검사 명령 자체에도 결함을 넣어 봤다. 임시 사본에서 변경 경로를 빼거나, 함수 해시를 바꾸거나, 변이 범위를 지운 다음 검사가 정말 실패하는지 확인했다.
중간에 HTTP 요청이 실패한 실행도 있었다. 실패를 자동 재시도로 덮거나 일부 결과를 빼서 통과로 처리하지는 않았다. 마지막에는 전체 검증을 한 번에 실행한 결과를 확인했다.
한 번에 끝까지 통과하는지
마지막 실행에서 핵심 단위·속성 테스트 51개, 실제 DB 테스트 99개, 변이 73개 판정, 검사기 음성 대조가 모두 통과했다. API 단위 테스트 1,353개도 통과했다.
이 검증은 CI와 분리한 로컬 환경에서 실행했다. 리워드 교환에서 응답이 끊긴 뒤 다시 요청한 경우를 구분할 멱등키는 이번에 넣지 않았다. 결제 재시도 멱등성은 아직 정책이 없다.
구현과 실행은 주로 코딩 에이전트에 맡기고 검사 범위와 순서, 결과를 확인했다. 정상 요청이 성공하는지만 볼 때는 놓쳤던 문제가 동시 요청과 중간 실패에서 드러났다. 수정한 코드뿐 아니라 그 코드를 검사하는 테스트까지 확인해야 했다.