업계 표준: 멱등키를 15일에서 24시간으로 줄일 뻔했다
목차
개요
이 프로젝트는 기능을 한 덩어리 만들 때마다 그 코드를 제가 다시 훑습니다. 처음 훑는 것을 자가 감사, “이제 됐나” 하고 같은 눈으로 한 번 더 보는 것을 재감사, 코드가 아니라 문서와 주석을 전부 코드에 대 보는 것을 전수 감사라고 부릅니다.
자가 감사로 “이건 고쳐야 한다”고 적어둔 하드닝 후보가 셋 있었습니다. 고치기 전에 공식 문서와 공개된 장애 사례에 대 봤더니 그중 둘은 내 판단 쪽이 틀렸습니다. 하나는 결제 재시도를 막아주는 기간을 15일에서 24시간으로 줄이려던 것이었고, 다른 하나는 방향 자체가 반대였습니다.
멱등키는 같은 결제 요청이 두 번 들어와도 한 번만 처리되게 하려고 요청에 붙이는 고유 값입니다. 그 값을 얼마나 보관하느냐가 재시도를 얼마나 오래 막아주는지를 정합니다.
- “Stripe는 멱등키를 24시간 보관하니 15일은 과하다” → 이 시스템이 모델링한 토스페이먼츠가 15일이었습니다. 철회
- “외부 콜을 트랜잭션 밖으로 빼는 건 득이 없다” → 실사고 사례를 보고 인정. 그런데 이 모놀리스에서 사가로 쪼개면 오히려 손해였습니다
둘 다 같은 실수였습니다. “업계에선 이렇게 한다”를 이 시스템이 어느 계약을 따르는지 안 보고 들이댄 것.
같은 회차에 코드 주석에 써둔 설명 하나도 반증됐고, 그 자리에 넣은 새 설명마저 틀려서 마지막에 재현으로 닫았습니다.
| 상황 | 다룬 것 |
|---|---|
| 상황 1 | 내가 예전에 쓴 설명이 틀렸다 |
| 상황 2 | 내 하드닝 추천을 공식 문서와 장애 사례에 대 봤다 |
| 상황 3 | 내가 쓴 문서가 거짓말을 하고 있었다 |
개인 프로젝트로 만든 결제 시스템 pay의 개발 기록입니다. 실무 운영 경험이 아닙니다.
재감사가 짚은 것: 내가 예전에 쓴 설명이 틀렸다
재감사가 코드 밖에서 하나를 더 짚었습니다. 내가 예전 글과 코드 주석에 써둔 설명이 틀렸다는 것입니다. saveAndFlush가 필요한 이유를 OSIV(Open Session In View, 요청이 끝날 때까지 JPA 세션을 열어두는 설정) 탓으로 적어놨는데 아니었습니다.
0. 한 번 더 봤더니 또 있었다
재감사에서 정산 버그들을 잡고 “이제 됐나?” 하며 같은 눈으로 한 번 더 훑었더니 코드 밖에서 하나가 더 나왔습니다. 내가 예전에 써둔 설명이 틀렸다는 사실입니다.
1. 내가 틀렸던 설명
두 번째는 코드 밖에서 나왔습니다. 내 과거 글입니다. 재감사가 이렇게 짚었습니다.
코드 곳곳에 “
saveAndFlush가 필요한 건 OSIV를 꺼서 dirty-check 자동 flush가 안 되기 때문”이라는 주석이 있는데, 이건 기술적으로 틀렸습니다.
찔렸습니다. 예전에 영속 유실 버그를 잡은 글에서 내가 그렇게 설명했기 때문입니다. 다시 보니 이랬습니다.
일반적인 read-write 트랜잭션 안에선, OSIV를 켜든 끄든 managed 엔티티의 변경은 커밋 때 dirty-check로 flush됩니다. 증거가 같은 코드베이스에 있었습니다.
settle()은items.forEach(SettlementItem::markSettled)를 save 없이 부르는데 정상 SETTLED됩니다. “OSIV off면 자동 flush가 안 된다”면 이게 동작하면 안 됩니다. 내 설명은 이렇게 반증됐습니다.
그럼 그 버그의 진짜 원인은 뭐였을까?
그때는 세션 FlushMode가 MANUAL이었기 때문이라고 고쳐 적었습니다.
@Transactional(readOnly = true)조회가 끼면 Hibernate가 FlushMode를 바꾼다는 게 근거였습니다.그 정정도 틀렸습니다. 나중에 바깥 트랜잭션이 read-write인지 read-only인지, 안쪽 조회에 readOnly가 붙는지를 조합한 네 경우를 재현해 보니, 바깥이 read-write면 참여한 안쪽
readOnly는 무시됩니다. FlushMode는AUTO로 남고 변경은 정상 flush됩니다. 실제로 dirty check를 막는 건 엔티티가 detached인 경우뿐이었습니다.반례 하나로 옛 설명을 반증한 것까지는 맞았는데, 그 자리에 넣은 새 설명을 다시 검증하지 않았습니다. 틀린 설명을 조금 덜 틀린 설명으로 바꾼 셈입니다. 세 번째에야 재현으로 닫았습니다. 재밌는 건 정작
CheckoutService의 다른 주석은 “readOnly 조회로 세션 flush가 MANUAL”이라고 정확히 적혀 있었다는 점. 같은 코드베이스 안에서 주석끼리 설명이 엇갈리고 있었던 셈입니다.
그래서 정정했습니다. 부정확한 주석을 열두 곳 고쳤습니다. 요지는 “readOnly 조회로 세션 FlushMode가 MANUAL이 되거나 detached라 dirty-check 자동 flush를 신뢰할 수 없어 명시 영속한다”입니다. 이후 코드가 늘어 지금은 같은 취지의 주석이 열네 곳입니다. 여기에 더해, 4편 글에도 정정 노트를 달았습니다. saveAndFlush를 쓰는 정책 자체는 유효합니다. 틀린 건 이유였습니다.
지울 수도 있었습니다. 어차피 draft고, 아무도 안 봤을 수도 있습니다. 안 지웠습니다. “예전엔 이렇게 이해했는데 다시 보니 틀렸고, 진짜는 이거다”를 남기는 쪽이 처음부터 다 맞은 척하는 것보다 정직합니다. 틀린 걸 고친 흔적도 기록입니다.
내 하드닝 추천을 웹서칭으로 검증했다
골라둔 하드닝 후보 셋을 공식 문서와 공개된 장애 사례에 대 봤습니다. 하나는 잘못된 수치를 들이대고 있었고 다른 하나는 방향 자체가 반대였습니다. 둘 다 내 추천이었습니다.
- 검증한 것 : 재감사가 골라둔 하드닝 후보 셋. 둘은 뒤집혔고, 나머지 하나는 고칠 필요 자체가 없었습니다.
- ① 멱등키 보관 기간 15일을 24시간으로 → 철회. 근거는 “Stripe·Razorpay가 24시간이니 15배 과하다”였는데, 이 시스템이 모델링한 토스페이먼츠가 15일을 명시하고 있었습니다
- ② PG 호출을 트랜잭션 밖으로 빼는 건 득이 없다 → 인정, 그러나. 실사고 사례 앞에서 방향은 인정했지만, 이 모놀리스에서 사가로 쪼개면 오히려 손해였습니다
- 대조 기준 : 일반론이 아니라 이 시스템이 어느 계약을 따르는지를 봤습니다. 공식 문서와 공개된 장애 사례입니다
→ 둘 다 같은 실수였습니다. “업계에선 이렇게 한다”를 계약을 안 보고 들이댄 것입니다.
0. “그거 진짜 고치는 게 맞아?”
재감사는 버그에 더해 하드닝 후보도 남겼습니다. 재감사로 골라둔 후보 셋을 웹서칭으로 실무 기준과 대조했더니, 하나는 잘못된 수치를 들이대고 있었고 다른 하나는 방향 자체가 반대였습니다. 둘 다 내 추천이었습니다. 대조 전에 스스로 물은 질문은 하나. “이거 진짜 고칠 값어치가 있나, 아니면 내가 억지 부리는 건가?“
1. 멱등 TTL: “24시간으로 줄여라” 했다가 철회
첫 후보는 멱등키였습니다. 코드를 보니 IdempotencyRecord.TTL = Duration.ofDays(15), 15일이었습니다. 내 판단은 이랬습니다.
“Stripe·Razorpay는 멱등키를 24시간 보관한다. 15일은 15배 과하다. 24h로 줄이자.”
그럴듯했습니다. 그런데 코드 주석에 “토스페이먼츠와 동일하게 15일”이라고 적혀 있었습니다. 웹서칭해 보니 토스페이먼츠 개발자센터가 명시하고 있었습니다.
“멱등키는 처음 요청에 사용한 날부터 15일 간 유효합니다.”
내가 틀렸습니다. 15일은 이 시스템이 모델링하는 토스페이먼츠(한국 PG)에 의도적으로 맞춘 값이었습니다. 이 프로젝트는 클라이언트가 보낸 멱등키를 토스 요청에도 그대로 실어 보내므로, 우리 API의 재시도 보장 기간을 PG와 같게 두는 게 맞습니다. 그 연결이 없었다면 “토스가 15일이니 우리도 15일”은 여전히 약한 근거였습니다. Stripe의 24시간이라는 미국 PG 기준을 토스를 모델링한 시스템에 무심코 들이댄 것입니다. PG마다 정책이 다릅니다. Adyen은 최소 7일, Stripe 24h, 토스 15일.
그래서 “TTL을 줄여라”는 철회했습니다. 대신 진짜 문제는 따로 있었습니다.
TTL은 15일인데, 만료된 레코드를 지우는 장치가 없어서
idempotency_keys테이블이 무한히 자랍니다. 아웃박스 테이블이 그랬던 것과 같은 계열입니다. 아웃박스엔 정리 스케줄러를 붙였는데 멱등 테이블은 빠져 있었습니다.
TTL은 그대로 두고(토스 정합), 유효기간 지난 레코드만 주기적으로 벌크 삭제하는 스케줄러를 붙였습니다.
@Modifying@Query("delete from IdempotencyRecord r where r.expiresAt < :threshold")int deleteByExpiresAtBefore(Instant threshold); // 한 건씩 아니라 벌크 삭제웹서칭이 경고한 게 하나 더 있었습니다. “한 건씩 지우면 정리가 뒤처집니다(Stripe도 그래서 TTL 인덱스로 옮김)“는 것. 그래서 엔티티 loop 삭제를 피하고 단일 벌크 DELETE로 했습니다. 실 MySQL로 만료(5일 전) 삭제·유효(15일 후) 유지를 확인했습니다.
“업계 표준”을 안다고 아무 데나 들이대면 안 됩니다. 이 시스템이 어느 PG를 모델링하는지가 정답을 바꿉니다. 검증 안 했으면 멀쩡한 값을 15배 잘못 줄일 뻔했습니다.
2. PG 콜 in 트랜잭션: “득 없다” 했다가, 실사고들 앞에서 인정
두 번째는 반대 방향으로 틀렸습니다. 체크아웃이 PG 승인(외부 HTTP)을 DB 트랜잭션 안에서 호출하고 있었습니다. 처음 내 결론은 이랬습니다.
“안티패턴이긴 한데, 정합성은 이미 UNKNOWN 복구로 해결됐고, 커넥션 점유는 fast-fail로 완화되니 득이 별로 없다. 그냥 문서화하자.”
그런데 읽는 분이 되물었습니다. “득이 진짜 없어? 현업은 어떤데?” 웹서칭해 보니 이번에도 내가 틀렸습니다.
외부 API 콜 중 DB 커넥션을 붙잡는 건 “classic leak pattern, silent killer”로 불립니다. 실제 장애 사례가 수두룩합니다. 다운스트림 지연으로 인한 연쇄 결제 실패, 느린 쿼리가 커넥션을 소진시킨 수 시간 outage, 트래픽 2배에 커넥션 풀 고갈. 심지어 “서킷브레이커도 기다리는 동안 풀이 찬다”고 명시돼 있었습니다. 내가 “완화됐다”던 그 서킷브레이커가 완전히 막지 못한다는 뜻입니다. 득은 실재했습니다.
“득 없다”는 접었습니다. 그럼 고쳐야 하나? 여기서 한 겹이 더 있었습니다.
현업 정석은 이 흐름을 사가로 쪼개는 것. 짧은 로컬 트랜잭션(예약) → PG(트랜잭션 밖) → 확정/보상입니다. 그런데 트레이스해 보니 이 시스템은 모듈러 모놀리스라 체크아웃이 단일 트랜잭션이었습니다. (여기서 “곧 진짜 ACID 원자성”이라고 적었는데, 그게 틀렸습니다. 아래에서 다시 봅니다.) 사가로 쪼개면 그 원자성을 포기하게 되고, 예약(포인트 선점)이 커밋된 뒤 확정 단계를 크래시로 못 하면 어중간하게 멈춘 사가가 남습니다. 이걸 복구하는 게 사가의 진짜 어려운 부분이고, 여기까지 안 하면 오히려 회귀(포인트 누수)입니다.
“외부 콜을 트랜잭션 밖으로”는 대규모·MSA의 규칙입니다. 모놀리스에서 사가 복잡도를 떠안는 건 이 규모에선 과할 수 있습니다. 커넥션 점유 위험은 connection-timeout: 3s가 이미 상한을 걸어준다고 적었습니다.
그런데 이 결론도 틀렸다
두 번 틀렸습니다.
첫째, connection-timeout: 3s는 PG 호출을 3초로 제한하지 않습니다. 그건 HikariCP가
풀에서 커넥션을 빌리려고 기다리는 시간 상한입니다. 이미 빌린 커넥션을 트랜잭션이 얼마나
오래 쥐고 있는지, PG HTTP 요청이 언제 끝나는지와는 무관합니다.
DB 커넥션 획득 → 트랜잭션 시작 → PG 호출 30초 대기 → 커넥션도 30초 점유connection-timeout은 이 30초를 끊지 않습니다. 풀이 찬 뒤 다음 요청이 3초 기다리다 실패할 뿐입니다.
점유를 실제로 묶는 건 PG HTTP의 read timeout인데, 확인해 보니 그게 설정돼 있지 않았습니다.
RestClient.builder()에 타임아웃 지정이 아예 없었습니다. 즉 커넥션 점유에 상한 자체가 없었습니다.
지금은 connect 2초·read 5초를 걸었고, 끊긴 호출은 실패가 아니라 TIMEOUT(=UNKNOWN)으로
복구 배치에 흐릅니다.
둘째, “단일 트랜잭션이라 진짜 ACID 원자성”도 틀렸습니다. DB 트랜잭션은 로컬 변경끼리만
원자적으로 묶습니다. PG HTTP 호출은 거기 참여하지 않습니다.
PG 승인 성공 → 응답 수신 → DB 커밋 실패 또는 프로세스 종료카드에서는 빠졌는데 우리 DB엔 결제가 없습니다. UNKNOWN 복구가 존재한다는 사실 자체가
이 구조도 이미 분산 정합성 문제를 갖고 있다는 증거입니다. 완전히 원자적이라면 복구가 필요 없습니다.
그래서 유지 근거를 다시 썼습니다. “ACID라서 안전하다”가 아니라 이렇게 적어야 맞습니다.
외부 호출을 트랜잭션 안에 둔 위험은 처음 본 것보다 컸고, 완화책이라고 적어둔
connection-timeout은 그 위험을 막는 설정이 아니었습니다. 다만 복구 경로 없이 사가로 옮기는 것도
더 위험합니다. 먼저 PG HTTP 타임아웃과 호출 동시성 상한을 두고 풀 점유를 실측한 뒤,
멈춘 시도를 복구할 수 있게 되면 3단계로 옮깁니다.
그리고 아직 안 잰 게 있습니다. 필요한 커넥션 수는 대략 초당 결제 요청 × PG 응답 시간입니다.
20 TPS에 PG가 3초면 대기만으로 60개가 필요한데 풀은 그보다 작습니다. PG 지연을 주입해 풀 점유를
재기 전까지는 “여유 있다”고 쓰지 않기로 했습니다.
최종 판단은 리팩터 대신 설계 결정 기록으로 남기는 것이었습니다. 지금 왜 이 구조를 유지하는지, 무엇이 갖춰지면 옮길 것인지를 적어 두는 문서입니다.
체크아웃 트랜잭션 경계에 대한 기록에 이렇게 적었습니다. 안티패턴을 인지하고(실사고 인용), 이 모놀리스에선 로컬 변경의 원자성을 위해 한시적으로 단일 트랜잭션을 유지하며, fast-fail·서킷·UNKNOWN 복구로 완화하고, 스케일이 요구하면 3단계 사가로 이행하는 경로(멈춘 사가 복구 포함)를 명시했습니다. 그 기록도 이번에 같이 고쳤습니다. 틀린 근거로 내린 결론은 결론이 맞아도 기록으로서 값이 없습니다.
3. 대사 자동 스케줄: 고칠 필요가 없었다
셋째 후보는 대사를 정해진 시각에 자동으로 돌리자는 것이었습니다. 그런데 이미 PG 정산 파일이 도착하면 대사가 시작되고, 밀린 건이 쌓이면 알림이 뜹니다. 시각을 정해 또 돌릴 이유가 없었습니다.
앞의 둘과는 성격이 다릅니다. 둘은 내 판단이 틀린 것이었고, 이건 없는 문제를 만든 것이었습니다. 과잉 진단이었습니다.
내가 쓴 문서가 거짓말을 하고 있었다
남의 문서를 깊게 읽는 건 노력의 문제입니다. 더 어려운 건 내가 쓴 문서입니다. 전수 감사가 두 군데를 짚었고, 둘은 같은 거짓말의 앞뒤였습니다.
README가 쓰지도 않는 기술을 쓴다고 적고 있었다
감사가 이걸 짚었습니다.
README는 “MySQL + JPA + QueryDSL”, “settlement: Spring Batch 정산”이라 명시하나,
build.gradle에 둘 다 의존조차 없습니다.
쓰지도 않는 기술을 쓴다고 적어놨던 것입니다. 초기 계획엔 있었는데 실제론 안 쓰게 됐고, 문서만 안 고친 듯합니다. 사소해 보여도 정직성 문제입니다. 코드를 읽는 사람이 문서를 믿을 수 없게 됩니다.
그래서 고쳤습니다. QueryDSL은 삭제하고, 정산은 “일 단위 배치 집계(서비스 루프; 대용량은 Spring Batch로 확장 여지)“로 사실화했습니다. README에 “가정과 한계” 절도 새로 뒀습니다. 데모 사용자(인메모리), 단일 통화(KRW), 로컬 기본 시크릿(운영은 env 필수), 멀티 PG 미배선 같은 걸 숨기지 않고 적었습니다.
“이건 이렇게 가정했고 여기까진 안 했다”를 적는 쪽이 “다 완벽하다”고 적는 것보다 믿음직합니다. 결제처럼 신뢰가 생명인 도메인에선 특히 그렇습니다.
그런데 한 군데가 아니었다
위를 고치고 한참 뒤, 정산을 손보다 그 잔당을 봤습니다. 정산 모듈 package-info가 이렇게 적혀 있었습니다.
/** <p>Spring Batch 기반 일 단위 거래 집계 → 수수료 계산 → ... */Spring Batch를 안 쓰는데 쓴다고 적혀 있었습니다(실제론 서비스 루프). README는 앞서 고쳤는데 package-info엔 같은 주장이 남아 있던 것. “서비스 루프 집계, 대용량은 Spring Batch로 확장 여지”로 사실화했습니다. 문서의 거짓말은 한 군데에서 끝나지 않습니다. 같은 주장을 여러 곳에 복붙해뒀다면 전부 찾아 고쳐야 합니다.
남는 생각
내가 쓴 걸 내가 다시 읽는 게 제일 어려웠습니다. 내가 쓴 걸 내가 다시 읽으면 쓸 때 생각한 것이 그대로 보입니다. 코드가 그 사이에 바뀌었어도.
그래서 나중에는 문서와 코드를 기계로 대조하는 테스트를 만들게 됐습니다. ERD 문서 대 마이그레이션, API 스펙 대 실제 에러 코드, 원인 분류 수 대 규칙. 사람이 알아채길 기다리지 않는 쪽이 쌌습니다.
댓글
댓글 수정/삭제는 GitHub Discussions에서 가능합니다.