
AI에게 결제 코드를 작성하게 하고 AI에게 리뷰를 시켰더니 '1엔도 결제하지 않은 사람에게 "결제가 완료되었습니다"라고 표시하고 있었다'
요약
Claude Code를 이용해 구현한 결제 시스템에서 발생한 치명적인 논리 오류와 이를 AI 리뷰어로 발견한 사례를 다룹니다. 정상 작동 케이스가 아닌, 네트워크 불안정 등 '고장 난 상황'에 대한 테스트 부재가 어떻게 잘못된 결제 완료 메시지로 이어지는지 분석합니다.
핵심 포인트
- 정상 작동(Happy Path) 테스트만으로는 운영 환경의 예외 상황을 방어할 수 없음
- 광범위한 정규 표현식 사용이 RPC 에러를 결제 완료로 오인하게 만드는 버그 유발
- AI 리뷰어를 통해 구현 코드의 '고장 나는 방식'에 대한 심층적인 검증 가능
- 시스템 복구 가능성을 고려한 테스트 설계의 중요성 강조
이 기사는 로컬의 D1 / vitest에서 재현한 테스트 코드와 리뷰 지적을 바탕으로 합니다. 테스트넷에서의 실제 결제 및 본방 배포는 실시하지 않았습니다.
이 기사만으로 알 수 있는 것
- 서명 검증·멱등성(Idempotency)이라는 "올바름"의 계층에 버그가 없더라도, 결제 시스템을 운영 환경에 내보낼 수 없는 이유가 될 수 있다는 점
- AI (Claude Code)에게 구현하게 한 코드를 AI에게 결제 전용 관점에서 리뷰하게 하여 나온 구체적인 5가지 "고장 나는 방식"
- "올바르게 작동하는" 테스트와 "고장 났을 때 복구할 수 있는" 테스트는 별개이며, 후자는 의식하지 않으면 단 하나도 작성되지 않는다는 점
- 고장 나는 방식별 "왜 테스트를 통과하는가"와 "자신의 코드에서 확인하는 방법" (북마크용 표를 마지막에 배치했습니다)
JPYC (스테이블코인)의 가스리스(Gasless) 결제를 Claude Code에게 구현하게 했다. 지불자가 서명하기만 하면,
가스비를 부담하는 릴레이어(Relayer)가 온체인(On-chain)으로 전송하는 구성의 결제 링크 서비스 내부 구현
에 관한 이야기다. 서명 검증과 멱등성 DB 프리미티브(Primitive)에는 구멍이 없었다. 그럼에도 불구하고 그대로는
운영 환경에 내보낼 수 없었다.
이유는 5가지. 모두 "올바르게 작동할 때"의 이야기가 아니라, "고장 난 후"의 이야기였다.
1엔도 내지 않은 사람에게 "이 결제는 이미 완료되었습니다"라고 표시하고 있었다
가장 무서웠던 것이 이것이었다. 전송 전에 eth_call로 시뮬레이션하고, 실패하면 이유를 분류하여 기록하는 코드에 다음과 같은 정규 표현식이 있었다.
해당 코드 (2줄)
if (/authorization is used|authorization already used|nonce/.test(m)) return "nonce_used";
// ^^^^^^^
컨트랙트(Contract)가 실제로 반환하는 "authorization is used"를 포착할 목적으로 추가한 패턴의 일부인 /nonce/가 너무 광범위했다. 이 정규 표현식은 RPC 자체가 반환하는 "nonce too low"와 같은 네트워크 측의 에러 문구에도 매칭된다.
어떤 일이 벌어지느냐 하면, RPC가 잠시 레이트 리밋(Rate limit)이나 타임아웃을 일으킨 것만으로도 -
client.call이 revert 이외의 이유로도 예외(Exception)를 던짐 - 예외 메시지에 우연히nonce라는 문자열이 포함됨 -nonce_used(= "그 서명은 이미 사용됨")로 분류됨 - 결제가 종료 상태인failed로 확정되어, 지불자에게는 "이 결제는 이미 완료되었습니다"라고 표시됨
1엔도 내지 않은 사람에게 결제 완료라는 취지의 화면을 보여주게 된다. RPC는 몇 초 후에는 복구되지만, 결제는 이미 종료 상태이므로 되돌릴 수 없다.
왜 테스트에서 발견되지 않았느냐 하면, 단위 테스트는 정상계와 "컨트랙트가 명확하게 revert 하는 이상계"밖에 준비하지 않았기 때문이다. RPC 자체가 불안정해지는 케이스를 상정한 테스트가 단 하나도 없었다. 성공 경로는 올바르게 작동하므로, 그 부분만 보고 있는 테스트는 전부 통과해 버린다.
이것은 단발성 버그가 아니라, 5가지 "고장 나는 방식" 중 하나였다
이 버그를 발견한 것은 결제 관련 구현을 전용 리뷰 에이전트에게 통과시켰을 때였다.
총평은 다음과 같았다 (요약).
서명 검증과 멱등성 DB 프리미티브에는 구멍이 없습니다. 서명 내용과 DB 기대값의 대조에 누락은 없으며, 검증을 바이패스(Bypass)할 수 있는 경로도, 지불자로부터 이중으로 인출할 수 있는 경로도 발견되지 않았습니다. 내보낼 수 없는 이유는 결제가 실패했을 때 되돌아올 수 없기 때문입니다.
"올바르게 작동하는 것"에 대한 테스트는 작성했지만, "고장 났을 때 복구할 수 있는 것"에 대한 테스트는 단 하나도 작성하지 않았습니다. 결제 시스템에서 실제로 사고가 되는 것은 전자가 아니라 후자라는 것이 이번 구현을 통해 얻은 가장 큰 배움이었다.
테스트가 두터운 계층에 대한 지적은 제로. 테스트가 없는 계층에서 중대한 지적이 전부 나왔다
지적의 양상에는 확실한 편향이 있었다.
| 계층 | 테스트의 두께 | 지적 |
|---|---|---|
| 서명 검증 (순수 함수) | 두터움 (변조·리플레이·유효 기간 등을 개별적으로 테스트) | 제로 |
| ... |
"올바르게 작동하는" 테스트는 구현하면서 자연스럽게 작성할 수 있다. 입력과 기대하는 출력이 명확하기 때문이다.
반면, "고장 났을 때 복구할 수 있는" 테스트는 의식하지 않으면 단 하나도 작성하지 않는다. 정상계를 다 작성한 시점에서 "작동했다"라는 성취감이 생겨, 거기서 손을 멈춰버리기 때문이다.
이것은 AI에게 구현을 시켰기 때문에 발생한 일은 아니다. 인간이 구현하더라도 똑같은 편향(Bias)이 발생한다. 다만,
AI 주도 개발(AI-driven development)을 하면 이 편향이 보이지 않게 된다고 느꼈다. 구현과 함께 테스트도 고속으로 생성되기 때문에,
「테스트가 있다」는 사실 자체가 안심 요인이 되어버려, 그 테스트가 무엇을 검증하지 않고 있는지를
확인하는 수고를 생략하기 쉽다. 테스트의 개수나 망라율(Coverage)이 아니라, 어느 계층에 테스트가 있고 어느 계층에 없는지를
보는 것이 이러한 종류의 편향을 잡아내는 데 효과적이다.
고장 유형 1 — 상태만 먼저 진행시키고, 회수 경로가 3개 모두 헛스윙한다
결제 상태를 「전송 중(sending)」으로 진행시킨 다음, 실제 서명·전송 처리를 호출하는 순서로 되어 있었다.
처리 순서
상태를 submitted로 업데이트 ← 여기서 상태만 먼저 진행됨
서명·전송 처리를 호출 ← 여기서 예외(Exception)가 발생하면, 트랜잭션은 단 하나도 존재하지 않음
이 상태는 회수가 불가능했다. 입금 확정 경로는 여러 개가 있지만, 전부 헛스윙한다.
| 회수 경로 | 이 상태에서의 결과 |
|---|---|
| 트랜잭션의 receipt를 확인 | 애초에 해당 행이 없으므로 확인할 것이 없음 |
| ... |
3개 모두 「트랜잭션이 실제로 전송되었다는 전제」로 작성되어 있었다. 전송되기 전에 떨어지는
가장 앞단의 케이스를 어떤 회수 경로도 상정하지 않았다. 지불자는 「블록체인에서
확인 중...」을 무한히 폴링(Polling)하게 된다.
수정한 방침은 「상태를 먼저 진행시키는 처리」와 「진행된 상태를 되돌리는 처리」를 반드시 한 쌍으로 준비하는 것이었다.
개념적으로는 다음과 같이 작성할 수 있다.
개념 코드 (수정한 방침)
// 상태를 진행시키기 전에, 짝이 되는 회수 경로가 존재하는지 자문한다
await advanceState(paymentId, "submitted");
try {
...
배움: 「상태를 먼저 진행시킨 후 외부 I/O를 수행한다」 패턴은, 진행된 상태를 되돌리거나 회수하는 경로를 반드시 세트로 준비해야 한다. 성공할 때만을 보고 있으면, 이러한 누락은 테스트를 통과해버린다.
고장 유형 2 — 외부 시스템의 장애를 「결제의 실패」로 확정 짓는다
서두에서 소개한 /nonce/
이야기를 일반화하면 다음과 같다.
해당 코드
} catch (e) {
return { ok: false, failureCode: classifyRevert(raw) }; // 기본값은 "reverted"
}
...
client.call은 컨트랙트의 revert 이외(타임아웃·5xx·Rate Limit)에도 예외를 던진다.
그럼에도 불구하고, 이 예외를 무조건 「결제 실패」로 취급하여 종단 상태(Terminal state)로 넘겨버렸다.
수정한 방침은 심플하다. 분류할 수 없는 것은 「분류할 수 없음」으로 취급하고, 종단 상태로 넘기지 않는 것이다.
결제 실패를 확정하기 전에, 온체인(On-chain)의 사실(인가 사용 상태)로 확인을 거친 뒤 판단한다.
「분류할 수 없는 것을 안전한 쪽(=재시도 또는 보류)으로 넘기는 것」과 「분류할 수 없는 것을 위험한 쪽(=종단 실패)으로 넘기는 것」 사이에서, 사고가 났을 때의 피해가 비대칭적이라는 점이 맹점이었다.
전자는 최악의 경우 지불자를 기다리게 할 뿐이지만, 후자는 잘못된 완료 표시라는 돌이킬 수 없는 결과로 이어진다.
배움: 외부 시스템의 에러 문구로 패턴 매칭(Pattern matching)을 한다면, 범위를 좁게 잡는 것이 안전하다. 범위를 넓게 잡으면,
그 외부 시스템의 다른 문맥에서 나오는 문구까지 잡아버린다. 그리고 분류 실패의 비용이 비대칭적인 처리에서는, 「모르면 위험한 쪽으로 넘긴다」는 설계 자체를 의심하는 편이 좋다.
고장 유형 3 — 구현은 했는데, 호출하는 곳이 어디에도 없다
가스비(Gas price)를 높여 재전송하는 「스피드업(Speed-up)」 메커니즘을 구현했지만, 그것을 호출하는 코드가 어디에도 없었다. grep을 해보고 나서야 처음 깨달았다.
grep 실제 출력
$ grep -rn "signPersistAndBroadcast" src/
src/lib/relayer.ts:234:export async function signPersistAndBroadcast(...)
src/lib/payment-service.ts:281: const r = await signPersistAndBroadcast({ ... attempt: 1 ...})
...
호출부의 attempt가 항상 1로 고정되어 있었다. 즉, 스피드업 경로는 죽어 있었다.
Relayer의 EOA는 nonce가 연속적이어야 하므로, 하나가 막히면 그날 이후의 모든 결제가 중단된다.
덧붙여, 이 기능 자체에도 또 다른 함정이 있었다. 재전송할 때마다 nonce를 새로 채번하게 되면,
그것은 '치환 (replacement)'이 아니라 '두 번째 전송'이 된다. 막힌 부분은 해소되지 않고 가스(gas) 비용만 두 배로 든다.
재전송할 때는 동일한 nonce를 명시적으로 고정해야 한다. 구현을 했더라도 호출되는 곳이 없다면
이 함정 자체를 알아차릴 수 없으므로, 순서상으로는 '호출부(caller)가 있는지'를 먼저 확인했어야 했다.
배움: "구현했다"와 "경로에 올라와 있다"는 별개다. 설계 문서에 적은 함수명은,
호출부를 전부 grep 하여 확인한다. (설계 자체는 틀리지 않았다. 이것은 별개의 문제다.)
고장 유형 4 — "번호가 진행되었다"를 "사실이 유지되고 있다"로 오독함
해당 코드
if (finalizedBlock < BigInt(payment.settled_block)) return false;
return markFinalized(...); // → Webhook 발화
"확정된 블록 번호가 입금 시의 블록 번호를 초과하면 확정"이라는 판정 로직을 사용했는데,
블록 번호는 reorg (reorganization)가 발생해도 진행된다. 한 번 입금 확정으로 간주된 트랜잭션이 이후 체인에서 사라지더라도,
번호만 보고 있으면 반드시 최종 확정으로 판정되어 가맹점에 Webhook이 발화한다.
이런 일이 발생하면 가맹점은 입금되지 않은 주문에 대해 상품을 발송하게 된다.
이런 종류의 실수는 블록 번호나 "확인 횟수"를 단순한 정수 카운터로 취급할 때 발생하기 쉽다.
카운터는 전제가 무너져도 값 자체는 계속 진행되기 때문에, 카운터가 진행되었다는 것과 카운터가 가리키고 있었을 사실이 여전히 참이라는 것은 별도로 확인해야 한다. 블록체인 결제에 국한되지 않고,
비동기적으로 확정되는 분산 시스템 전반에서 동일한 형태의 함정이 발생할 수 있다.
배움: "시간이나 번호가 진행되었다"는 사실이 "그 사실이 지금도 유지되고 있다"는 증거가 되지는 않는다.
확정을 선언하기 직전에, 사실 그 자체(온체인 상의 인가 상태)를 다시 한번 확인한다.
고장 유형 5 — "비밀인지 여부"를 env 분류만으로 관리함
인증 없이 누구나 접속할 수 있는 페이지에 RPC 엔드포인트 목록을 그대로 전달하던 부분이 있었다.
해당 코드
// 인증 없이 누구나 접속할 수 있는 페이지
rpcUrls: (env.RPC_URLS ?? "").split(",")
RPC_URLS는 "비밀 정보"로 취급하는 설정으로 분류되어 있었지만, 현재로서는 퍼블릭 RPC만
넣어두었기에 실질적인 피해는 없다. 하지만 운영 환경에서 API 키가 포함된 유료 RPC를 설정하는 순간,
해당 페이지를 여는 모든 사람에게 API 키가 배포되는 셈이 된다.
체인별 상수 테이블 측에서는 이 구분을 타입(type)으로 나누어 두었다.
타입 정의 (해당 부분만)
/**
* **클라이언트에 전달해도 좋은** RPC 엔드포인트 (`wallet_addEthereumChain` 용).
*
...
배움: "비밀인지 여부"를 env 분류만으로 관리하면, 용도에 따라 비밀도가 달라지는 값(같은 "RPC URL"이라도 공개용과 서버 전용이 있음)에서 사고가 난다. 클라이언트에 전달해도 좋은 값은
타입 레벨에서 별도의 필드로 나누어 두어야 한다.
5가지 유형의 공통점
| 고장 유형 | 증상 | 테스트를 통과하는 이유 | 내 코드에서의 확인 방법 |
|---|---|---|---|
| 1. 상태만 먼저 진행됨 | 결제자가 무한히 폴링(polling)을 지속함 | 성공 경로(success path)만 준비되어 있음 | 상태를 진행시킨 직후 throw할 수 있는 행을 grep 한다 |
| ... |
수정한 뒤에 추가한 테스트는 정상 경로가 아니라 이 표의 오른쪽 두 열만을 겨냥해서 작성했다.
"고장 났을 때 회수할 수 있는가"에 초점을 맞추면, 테스트의 수가 늘어나지 않더라도 지적의 재발을 방지할 수 있다.
AI에게 리뷰를 시키는 쪽의 이야기
2026년 시점에서 AI에게 결제 코드를 작성하게 하는 것 자체는 이제 드문 일이 아니다. 차별화가 있다면,
"리뷰를 시켰다"는 사실이 아니라, 나온 지적의 구체성에 있었다. 효과적이라고 느낀 것은
다음 두 가지다.
관점을 미리 구조화해 두었다. 결제라는 영역에 특화된 리뷰 전용 에이전트에게,
확인해 주길 바라는 관점(서명 검증(Signature Verification)・멱등성(Idempotency)・실패 시의 복구(Recovery)・비밀 정보의 취급 등, 여러 카테고리)을
미리 전달해 두면, 그 구조에 따라 보고가 돌아온다. 사람이 읽어도 "어느 카테고리에
지적이 몇 건 있는지"를 한눈에 알 수 있어, 누락 확인이 빨라진다. -
"OK라고 적는 항목은 반드시 코드를 읽고 확인한 것만 작성할 것"이라고 지시했다. 이를 지시하기 전에는,
리뷰의 총평이 구현 계획서의 요약에 가까운 낙관적인 내용이 되기 일쑤였다. "의심스러우면 지적한다"
측으로 기울어지는 것이 적당하며, 낙관적인 OK는 사고의 원인이 된다.
또 하나, 구현하는 AI와 리뷰하는 AI를 같은 세션·같은 문맥에서 실행하지 않는 것도
효과가 있었다. 같은 문맥을 유지한 채 자기 리뷰(Self-review)를 시키면, 구현 당시의 선입견까지 그대로 이어져 버려,
"구현했다고 생각하는 것"과 "실제로 호출되고 있는 것" 사이의 괴리(고장 유형 3이 바로 이것)를 알아차리기 어렵다.
신선한 문맥에서 코드만을 읽게 하는 편이, grep을 해서야 비로소 깨닫게 되는 누락을 찾아내기 쉬웠다.
관점표 자체의 설계(8개 카테고리로 나눈 이유, AI에게 읽힐 것을 전제로 어떻게 작성할 것인가)는,
그것만으로도 한 편의 기사가 될 분량이기에 여기서는 깊게 다루지 않겠다.
이 5가지는 Web3 결제에 국한된 이야기가 아니다
5가지 고장 유형을 다시 살펴보면, 고유명사(JPYC・EIP-3009・Relayer)를 제외하더라도 성립하는
형태를 띠고 있다. 결제라는 주제가 특히 엄격한 이유는, **"고장 난 후에 누가 손해를 보는가"가 명확하고,
게다가 취소할 수 없다**는 점에 있다. 같은 형태의 버그가 재고 관리나 메시지 전송에서 발생하더라도,
대부분의 경우 재시도(Retry)나 수동 수정으로 흡수할 수 있다. 결제는 흡수할 수 없다. 그렇기에 이 5가지를
낱낱이 찾아내는 작업 그 자체에 시간을 쓸 가치가 있었다.
요약
수정은 10개 파일, +667행 / -53행의 변경이 되었다. 숫자만 보면 커 보이지만, 내용은 기능 추가가 아니라,
모두 "고장 났을 때 어떻게 되돌아갈 것인가"에 대한 배선이었다. 서명 검증이나 멱등성 로직 자체에는
한 줄도 손대지 않았다. 다음에 리뷰를 받을 때도, 가장 먼저 의심하게 될 부분은 분명 이 계층이 될 것이다.
JPYC 관련 Web3 프로덕트를 비롯한 각종 개발을 개인적으로 진행하고 있습니다.
개발은 Claude Code와 OpenClaw와 함께하고 있으며, OpenClaw에 관한 이야기도 앞으로 늘려갈 예정입니다.
일상의 깨달음은 X(@katsuo_crypto)에서 발신하고 있습니다.
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기