송금 API 멱등성, '없음'을 믿지 않고 같은 키로 묻기
송금 응답이 유실된 결과불명 건을 조회 404로 닫으면 이중 송금이 됩니다. 같은 멱등키로 재요청해 확인과 재시도를 한 동작으로 만든 멱등성 설계와 잠금, 되돌리기 검증 기록
결제를 대행하는 서비스의 정산 송금을 NestJS와 PostgreSQL로 붙이고 있습니다.
송금 API를 부르면 대부분은 바로 접수됐다는 응답이 옵니다.
근데 가끔 응답이 오지 않습니다.
"보냈는데 돈이 나갔는지 모른다."
이 상태를 어떻게 닫느냐가 송금 연동에서 가장 비싼 질문이었습니다.
조회해서 없으면 다시 보내면 될 것 같지만, 그게 곧 이중 송금입니다.
결론부터 말하면, 결과조회의 '없음' 응답은 근거로 쓰지 않고 같은 멱등키·같은 본문으로 다시 요청해서 확인과 재시도를 한 동작으로 만들었습니다.
송금 API의 멱등성을 "중복 방지 장치"가 아니라 "결과를 다시 묻는 수단"으로 쓴 셈입니다.
송금 응답은 생각보다 자주 사라집니다
송금 요청이 결과를 모르는 상태가 되는 경로는 여러 개입니다.
- 요청은 갔는데 응답 전에 타임아웃이 났다
- 응답은 왔는데 본문을 읽을 수 없었다
- 응답의 거래키가 우리가 보낸 값과 다르다
셋 다 "상대 쪽에서 접수됐는가"를 알려주지 않습니다.
그래서 이 건들은 성공도 실패도 아닌 결과불명으로 따로 둡니다.
결과불명을 실패로 처리하면 재시도 대상이 되고, 실제로는 접수돼 있었다면 같은 돈이 두 번 나갑니다.
반대로 성공으로 처리하면 실제로 안 나간 돈이 나간 걸로 기록됩니다.
어느 쪽으로 틀려도 배포 후에야 드러나고, 이미 나간 송금은 되돌릴 수 없습니다.
조회 후 재전송이 이중 송금이 되는 이유
가장 먼저 떠오르는 복구 절차는 이렇습니다.
// ❌ 조회해서 없으면 새로 보낸다
const found = await gateway.inquire(transactionId)
if (found.outcome === 'not-found') {
await gateway.submit({ ...transfer, transactionId: newTransactionId() })
}문제는 '없음'이 "접수된 적 없다"가 아니라 "지금 조회에 안 보인다"라는 점입니다.
접수 처리가 아직 끝나지 않았으면 조회에는 안 보이다가, 잠시 뒤에 나타날 수 있습니다.
그 사이에 새 키로 다시 보내면 두 건이 모두 처리됩니다.
멱등키를 지원하는 API의 일반적인 권고도 같습니다.
응답이 유실되면 기존 멱등키로 다시 묻고, 새 멱등키로 다시 요청하지 않는다는 것입니다.
1. 처음엔 사람이 확인해야만 풀리게 했습니다
첫 설계는 결과불명을 자동으로 풀지 않고, 관리자의 결과 재조회로만 해제하는 것이었습니다.
조회 결과가 명확하면 그대로 확정하고, '없음'이면 결과불명을 유지했습니다.
안전하긴 했는데 출구가 없었습니다.
조회가 계속 '없음'이면 그 건은 영원히 결과불명이고, 남는 수단은 DB를 손으로 고치는 것뿐이었습니다.
사람이 DB를 고치는 쪽이 코드보다 더 위험하다는 판단에서 두 번째 설계로 넘어갔습니다.
2. 근거를 둘로 늘렸습니다 — 조회 + 취소
두 번째는 '없음' 하나만으로는 약하니 독립된 근거를 하나 더 붙이는 방식이었습니다.
조회도 '없음', 이체취소 API도 "그런 거래 없음"이면 관리자가 미접수로 확정할 수 있게 했습니다.
근데 송금 연동 대상을 바꾸면서 이 방식이 통째로 무너졌습니다.
두 번째 근거를 얻던 경로를 더는 같은 방식으로 쓸 수 없게 됐습니다.
그러면 다시 '없음' 하나만 남고, 그건 위에서 본 이유로 근거가 못 됩니다.
솔직히 두 번째 설계를 만들 때는 "근거가 둘이면 충분하다"고 생각했습니다.
근거의 개수보다 그 근거가 어떤 보장 위에 서 있는가가 중요했습니다.
취소 API의 응답은 그 사업자 규격에만 있는 보장이라, 연동 대상이 바뀌면 같이 사라집니다.
같은 키로 다시 묻는 멱등성 재요청
세 번째 설계는 업계 표준 쪽으로 돌아갔습니다.
멱등키를 지원하는 API는 보통 이렇게 동작합니다.
| 같은 키로 다시 보냈을 때 | 결과 |
|---|---|
| 그 키로 접수된 건이 있고 본문도 같다 | 기존 접수 결과를 그대로 돌려준다 |
| 그 키로 접수된 건이 있는데 본문이 다르다 | 충돌 응답 |
| 그 키로 접수된 건이 없다 | 지금 한 번 접수한다 |
즉 같은 키 재요청은 "있었으면 그 결과를 알려주고, 없었으면 지금 한 번만 처리하라"는 요청입니다.
확인과 재시도가 한 동작이라, 그 사이에 틈이 없습니다.
이 원칙은 결제 쪽 idempotency key 관행과 같습니다.
결과가 확정되면 다음 시도는 새 키, 결과를 모르면 같은 키입니다.
1. 본문은 첫 요청과 같아야 합니다
같은 키라도 본문이 다르면 기존 접수로 돌아오지 않고 충돌이 납니다.
그래서 재요청 본문은 지금의 신청 데이터가 아니라 첫 요청 때 남긴 송금 스냅샷과 고정값으로만 만듭니다.
// 재요청 본문은 송금 tx 에 저장된 스냅샷으로만 만든다
const input = {
transactionId: row.transactionId, // 첫 요청의 멱등키 그대로
bankCode: row.bankCode,
accountNumber: row.accountNumber,
amount: row.principalAmount,
receiverMemo: row.receiverMemo,
}신청 데이터에서 다시 읽으면, 그 사이 계좌를 정정한 건의 본문이 달라집니다.
그러면 같은 키인데도 "다른 내용"이 되어 확인이 안 됩니다.
2. 응답을 갈래로 나누고, 모르는 건 모른다고 둡니다
재요청 응답은 다섯 갈래로 분류합니다.
type SubmissionOutcome =
| { kind: 'accepted' } // 접수됨(기존 접수 반환 포함)
| { kind: 'already-submitted' } // 같은 키에 다른 내용이 이미 있다 = 존재 증언
| { kind: 'rejected-retryable' } // 처리 전 거절
| { kind: 'rejected-final' } // 요청·계좌 자체가 틀린 거절
| { kind: 'unreadable' } // 판정 불가분류표에 없는 거절 코드는 거절로 보지 않고 unreadable로 둡니다.
응답의 거래키가 보낸 값과 다를 때도 마찬가지입니다.
function classify(result: SubmitResult, sentTransactionId: string): SubmissionOutcome {
if (result.outcome === 'unreadable') return { kind: 'unreadable' }
// 다른 건의 응답일 수 있다. 미입금으로 읽으면 이중 송금 방향이다
if (result.transactionId !== sentTransactionId) return { kind: 'unreadable' }
if (result.outcome === 'accepted') return { kind: 'accepted' }
const code = result.errorCode
if (CONFLICT_CODES.has(code)) return { kind: 'already-submitted' }
if (RETRYABLE_CODES.has(code)) return { kind: 'rejected-retryable' }
if (FINAL_CODES.has(code)) return { kind: 'rejected-final' }
// 분류표에 없는 코드는 실패가 아니라 판정 불가
return { kind: 'unreadable' }
}모르는 코드를 "실패"로 읽으면 재접수 자격이 생기고, 그게 이중 송금의 입구가 됩니다.
멱등성 설계에서 제일 위험한 건 "모른다"를 "아니다"로 바꿔 읽는 순간입니다.
3. 확정해도 되는 거절과 안 되는 거절
가장 오래 고민한 부분입니다.
재요청이 거절됐다고 해서 전부 "그 키로 접수된 건이 없다"는 뜻은 아닙니다.
판단 기준은 하나입니다.
그 거절이 기존 접수 여부를 확인할 수 있는 단계에서 나왔는가.
- 본문 검증이나 잔액 확인 단계의 거절은, 같은 키 같은 본문의 접수가 있었다면 기존 결과가 돌아왔어야 할 자리입니다. 그래서 "이 키로 접수된 건이 없다"의 근거로 씁니다.
- 인증·접근 제한 같은 거절은 요청이 키를 보기도 전에 막힌 것입니다. 기존 접수에 대해 아무 말도 하지 않으므로 근거로 쓰지 않습니다.
// 미접수 근거가 되는 거절만 모아 둔다. 나머지 거절은 판정 불가
if (!NO_PRIOR_ACCEPT_EVIDENCE.has(outcome.errorCode)) {
return { kind: 'inconclusive', reason: `같은 키 재요청 거절 ${outcome.errorCode}` }
}근거가 되는 거절로 닫더라도 "확인한 미접수"가 아니라 "추론한 미접수"라는 표시를 남깁니다.
나중에 접수됐다는 증언이 늦게 도착하면 그 건을 다시 열 수 있어야 하기 때문입니다.

멱등성만으로는 동시성이 해결되지 않습니다
같은 키 재요청 경로는 세 군데에서 탑니다.
주기 폴링, 관리자 결과 재조회, 관리자 미접수 확정입니다.
셋이 같은 송금 건에 겹치면, 한쪽의 거절이 다른 쪽의 접수보다 먼저 "미접수"를 확정할 수 있습니다.
멱등키는 상대 서버에서의 중복 접수를 막아주지만, 우리 DB의 판정 순서까지 지켜주지는 않습니다.
1. 송금 건 단위로 잠갔습니다
PostgreSQL advisory lock으로 송금 건 하나당 재요청 하나만 돌게 했습니다.
잠금을 잡은 뒤 상태를 다시 읽고 보냅니다.
return this.prisma.$transaction(async tx => {
const [{ locked }] = await tx.$queryRaw<{ locked: boolean }[]>`
SELECT pg_try_advisory_xact_lock(${SAME_KEY_LOCK_NAMESPACE}, ${transferTransactionId}) AS locked
`
if (!locked) return { kind: 'busy' } as const
return this.resubmitSameKeyLocked(transferTransactionId, now, options)
})xact 계열이라 트랜잭션이 끝나면 잠금이 자동으로 풀립니다.
세션 단위 잠금처럼 해제를 잊을 일이 없습니다.
2. 기다리는 잠금은 커넥션 풀을 먹는 구조였습니다
처음에는 pg_advisory_xact_lock, 즉 잠금이 풀릴 때까지 기다리는 버전을 썼습니다.
근데 잠금을 쥔 쪽은 외부 API를 최대 세 번(접수 두 번 + 결과조회) 부르는 동안 트랜잭션을 열어 둡니다.
그동안 기다리는 쪽도 트랜잭션을 열고 커넥션을 붙잡고 있으니, 겹치는 요청이 늘면 정작 잠금 보유자가 쓸 커넥션이 바닥날 수 있는 구조였습니다.
그래서 pg_try_advisory_xact_lock으로 바꿨습니다.
못 잡으면 기다리지 않고 바로 busy를 돌려줍니다.
폴링은 다음 회차에 다시 보고, 관리자 화면은 "진행 중"으로 409를 받습니다.
첫 접수가 거절돼 미입금으로 닫는 경로처럼 꼭 잠금을 잡아야 하는 곳은 트랜잭션 밖에서 짧게 재시도합니다.
트랜잭션 안에서 기다리면 다시 커넥션을 쥔 채 기다리는 구조로 돌아가기 때문입니다.
헤맸던 부분들
연동 전환 PR 안에서, 첫 구현 커밋 뒤에 같은 경로를 고치는 커밋이 네 번 더 들어갔습니다.
대부분이 "판정 순서" 문제였습니다.
1. 확정을 잠금 밖에서 하고 있었습니다
처음엔 재요청만 잠금 안에서 하고, 관리자 흐름의 "미접수 확정" 쓰기는 잠금이 끝난 뒤에 했습니다.
그 틈에 폴링의 재요청이 끼어들어 접수를 받아 오면, 관리자 쪽은 이미 낡은 판단으로 미접수를 닫습니다.
재요청 거절 판정과 미접수 확정을 같은 잠금 안으로 옮겼습니다.
2. 충돌 응답의 증언을 늦게 기록했습니다
같은 키에 다른 내용이 있다는 충돌 응답은 "접수된 건이 있다"는 증언입니다.
처음엔 결과조회를 마친 뒤에 접수 시각을 기록했는데, 결과조회가 실패하면 그 증언이 통째로 사라졌습니다.
그러면 다음 회차의 거절이 그 건을 미입금으로 닫을 수 있습니다.
충돌 응답을 받으면 결과조회보다 먼저 접수 시각부터 남기도록 바꿨습니다.
3. 닫히지 않은 걸 닫혔다고 보고했습니다
미입금 확정 함수에는 "이미 접수 응답을 받은 건은 닫지 않는다"는 가드가 있습니다.
근데 가드에 막혀 아무것도 안 바뀌어도 재요청 결과는 "미접수 확정"으로 돌아가고 있었습니다.
확정 함수의 반환값을 보고, 막혔으면 판정 불가로 돌려주게 고쳤습니다.
되돌리기 검증으로 확인한 것
테스트가 정말 이 판단들을 지키는지, 코드를 일부러 망가뜨리고 돌려봤습니다.
원본 저장소는 건드리지 않고 복제본에서 했습니다.
npx jest src/transfer src/admin-transfer
# Tests: 274 passed, 274 total| 망가뜨린 것 | 결과 |
|---|---|
| 미접수 근거 필터 제거(모든 거절을 근거로 사용) | 274건 중 5건 실패 |
| 잠금을 못 잡아도 그대로 진행 | 274건 중 1건 실패 |
| 재요청에 새 멱등키 사용 | 274건 중 1건 실패 |
셋 다 의도한 테스트가 의도한 이유로 빨개졌습니다.
"새 멱등키" 뮤테이션은 "첫 요청과 같은 멱등키·같은 본문으로 보낸다" 테스트가, 잠금 뮤테이션은 "진행 중이면 보내지 않는다" 테스트가 잡았습니다.
이 멱등성 설계로도 남는 한계
1. 상대가 멱등키보다 잔액을 먼저 보면 근거가 흔들립니다
"잔액 단계 거절 = 이 키로 접수된 건 없음"은 상대 서버가 멱등키를 먼저 확인한다는 전제 위에 서 있습니다.
잔액을 먼저 보는 구현이라면, 이미 접수됐지만 조회에 아직 안 보이고 잔액이 바닥난 건이 거절로 보일 수 있습니다.
이 순서는 상대 쪽 확인이 필요한 질문으로 남아 있습니다.
2. 판정 불가는 결국 사람에게 갑니다
인증 거절, 모르는 코드, 응답 불일치는 자동으로 닫지 않습니다.
안전한 쪽으로 기운 설계라, 운영자가 확인해야 할 건이 쌓일 수 있습니다.
3. 되돌릴 수 없는 결정입니다
코드는 되돌릴 수 있지만, 이 경로로 미입금 확정된 뒤 새 키로 재접수된 송금은 되돌릴 수 없습니다.
그래서 근거가 되는 거절 목록은 넓히는 쪽보다 좁히는 쪽으로만 바꿉니다.
정리
좋은 점
- 같은 키 재요청이 확인과 재시도를 한 동작으로 묶어, '없음'과 '재전송' 사이의 틈이 사라졌습니다
- 결과불명 처리가 상대 API의 멱등성 보장 하나에 모여, 판단 근거를 설명하기 쉬워졌습니다
- 근거가 상대 사업자의 특정 API가 아니라 멱등키라는 표준 보장 위에 서서, 연동 대상이 바뀌어도 원리가 유지됩니다
- 모르는 응답을 실패가 아니라 판정 불가로 두어 이중 송금의 입구를 좁혔습니다
- 비대기 잠금으로 겹친 재요청이 커넥션 풀을 먹지 않습니다
고려할 점
- 미접수 근거는 상대 서버의 처리 순서에 기대고 있습니다
- 판정 불가 건의 운영 부담이 생깁니다
송금이나 출금처럼 되돌릴 수 없는 외부 호출이라면, 결과조회의 '없음'으로 닫지 말고 같은 멱등키 재요청을 기본으로 두는 것을 추천합니다.
상대 API가 멱등키를 지원하지 않는다면, 그때는 자동 확정을 포기하고 사람의 확인을 출구로 남기는 쪽이 안전합니다.
참고 자료
연관 게시글