살아남기: 버튼을 광클하는 사용자로부터 데이터 지키기(feat. 멱등성)
손가락은 간절했고, 서버는 그대로 행동했다
다들 이런 경험은 있을 거에요
수강신청.. 선착순 쿠폰 이벤트.. 콘서트 티켓팅..
우리 모두 그때 간절한 마음으로 넘어가지 않는 페이지를 바라보며 버튼만 클릭하던 기억을..
그건 우리 모두의 본능이었죠
하지만 그 버튼이 ‘결제/주문’ 버튼이라면 어떨까요?
퇴근하고 집에 누워있던 우리에게 팀장님의 전화벨이 울리네요
“지금 한 개의 주문에서 결제가 5번이 일어났다는데?”
열심히 일을 하고 퇴근하며 “제발 결제 버튼을 여러 번 누르지 말아줘..!” 기도하던 우리에게 기적은 일어나지 않았고, 현실로 닥쳐왔네요
살아남기 위한 우리의 전략..!
급하게 회사로 달려가며 고민을 시작하는 나
“어떻게 해야 우다다다 광클을 해도 한 번만 동작하게 하지?..”
그때 머릿속을 스쳐 가는 여러 가지 방법들..
“화면에서 결제 버튼을 한 번 클릭하면 막아버리면 되지 않을까?”
“아니.. 아니야 그건 사용성도 너무 떨어지고 네트워크 오류로 결제가 안 됐을 땐 어떻게 다시 결제 버튼을 눌러..”
“그러면 결제하기 전에 이미 결제가 되어있는지 확인을 해볼까?”
“하지만.. 동시에 여러 요청이 올 때는 결과 저장이 되기 전에 또 처리할 수도 있을텐데..”
그 때 머리속에 스쳐가는 며칠전 페스티벌 참여 일정
“아..! 띠지!!!”
아..! 맞다, 띠지!!!
우리가 페스티벌에 놀러 갔을 때를 상상해 볼까요?
아침 일찍 줄을 서서 오랜 기다림 이후 우리 차례가 왔어요
스태프가 입장권 예매 정보를 확인하고 신분 확인을 하고 우리 팔목에 띠지를 달아주죠
그리고 설레는 첫 입장..
앗! 그런데 갑자기 화장실이 너무 급해서 다시 나와서 화장실을 달려가요
급한 일을 해결하고 다시 입장하려는데 우리는 다시 줄을 서나요?
예매 정보를 확인하고.. 신분을 확인하고.. 띠지를 다시 받나요?
우리는 그냥 팔목을 쓱 들어서 띠지를 보여주고 입장하면 돼요
1번 보여주든, 10번 보여주든 스태프는 새로운 띠지를 주는 대신 매번 말할 거에요
“확인됐어요~ 재밌게 관람하세요!”
띠지와 멱등성
우리가 몇 번을 나갔다가 들어와도 다시 검사를 하거나 새로운 띠지를 주지 않아요
이미 입장 가능하다는 사실을 확인만 해줄 뿐이죠
이 띠지 같은 성질을 컴퓨터 공학에서는 멱등성(Idempotent)이라고 불러요
우리가 원하는 성질이죠?
띠지는 처음 입장할 때만 주고 몇 번을 나갔다가 들어와도 새로운 띠지를 주는 게 아니라 기존의 띠지를 확인만 해요
=>
결제는 처음 눌렀을 때만 수행하고 몇 번을 클릭해도 결제의 최종 상태만 반환해요
그래서 결제에서 띠지는 뭔데?
페스티벌에서 스태프가 우리의 팔목을 보고 ‘이미 입장한 사람’임을 알아보듯, 우리의 서버도 이 요청이 ‘이미 처리된 요청’인지 알아볼 수 있는 이름표가 필요해요
그리고 이 디지털 띠지를 **멱등키(Idempotent Key)**라고 불러요
멱등키는 아주 단순하면서 강력한 힘을 발휘하는 친구예요
- 클라이언트(앱/웹)에서 결제 요청을 보낼 때, 이번 요청의 고유 번호(멱등키)를 헤더에 실어 보내요
- 서버에서는 요청받으면 먼저 고유 번호(멱등키)를 확인하고 “이미 처리한 적 있는 요청인가?”를 확인해요
- 처리 중이거나 완료된 요청이라면 “아! 이미 처리중/처리된 요청이구나” 판단하고 다시 한번 결제를 수행하는 게 아니라 결과를 그대로 돌려줘요
우리가 띠지만 보여주고 쓰윽 들어갔던 것처럼, 서버도 실제 결제 로직을 타는 대신 이전의 결과만 쓰윽 내어주는 것이죠

※ 아주 간단한 코드 예시
// 결제 요청 시 'Idempotency-Key'(띠지)를 함께 받습니다.
public Response processPayment(String idempotencyKey, PaymentRequest request) {
// 1. 이미 이 '띠지'로 입장(처리)한 기록이 있는지 확인합니다.
if (paymentRepository.existsByKey(idempotencyKey)) {
// 2. 이미 처리됐다면, 새로운 결제를 하지 않고 처리된 결과를 반환합니다.
return Response.alreadyProcessed();
}
// 3. 처음 보는 '띠지'라면 결제를 진행합니다.
Payment result = paymentService.execute(request);
// 4. 결제가 끝나면 '띠지' 번호를 명부에 기록해 둡니다.
paymentRepository.saveKey(idempotencyKey, result);
return Response.success(result);
}
우리의 첫 번째 생존 전략 — 멱등성
물론, 우리가 제대로 사용하기 위해서는 고려할 점이 매우 많아요
우리는 페스티벌처럼 명확한 입구/출구가 서버에서 어디인지,
결제 직전에 네트워크 오류가 발생해서 다시 한번 결제해야 한다든지,
결제까지는 성공했지만 응답받지 못해서 우리의 데이터베이스에선 실패로 남아있다든지
많은 고민이 추가로 필요해질 거에요
하지만 적어도 오늘 우리는 최소한 ‘결제 버튼 광클’이라는 공포에서 한 걸음 물러나게 되었어요
우리 한 번 깊게 고민 하는 시간을 가져보고 오늘의 첫 번째 생존!
너무나 고생하셨어요!!