
한 줄 요약
같은 순간에 요청이 몰리면 시스템은 생각보다 쉽게 흔들릴 수 있습니다.
그래서 Exponential Backoff + Jitter, Token Bucket 같은 장치로 몰림을 나누어 보고, 결제처럼 상태를 바꾸는 요청에서는 Retry만 붙이기보다 Idempotency와 State를 함께 설계해야 한다고 생각합니다.
먼저 뽀빠이 식당 장면으로 Thundering Herd, Exponential Backoff + Jitter, Token Bucket을 가볍게 풀어 보고요. 그다음 결제 처리 예시를 보면서 Retry, Idempotency, Timeout, State가 왜 같이 묶여야 하는지 천천히 이어서 보려고 합니다. 사실 뽀빠이를 본적이 없습니다, 그저 그림이 귀여워서 예시로 삼아봤습니다
왜 같은 순간의 몰림이 더 위험할까
뽀빠이 식당에 시금치가 막 들어왔다고 가정해 볼게요. 손님이 많은 것 자체도 부담이지만, 더 위험한 순간은 모두가 같은 타이밍에 문으로 뛰어드는 순간이라고 생각합니다. 이것이 Thundering Herd, 즉 많은 요청이 같은 시점에 몰리면서 서로를 더 막아 세우는 현상입니다.
이 개념에서 더 중요하게 봐야 하는 것은 "양이 많다"보다 "타이밍이 겹친다"는 점입니다. 사람이 천천히 나누어 들어오면 식당은 어느 정도 버틸 수 있습니다. 그런데 모두가 동시에 들어오면 주문도, 계산도, 시금치 배분도 한 번에 막히게 되겠지요. 실제 서버도 비슷하다고 생각합니다. 같은 시각에 재시도나 캐시 만료가 겹치면 데이터베이스나 외부 시스템으로 요청이 한꺼번에 몰리면서 전체 응답 시간이 빠르게 나빠질 수 있습니다.
Exponential Backoff는 왜 필요할까
이 상황에서 가장 먼저 떠오르는 대응은 "다시 시도해 보세요"일 수 있습니다.
그런데 모두에게 같은 시간 뒤에 다시 오라고 하면, 식당 앞은 다시 같은 순간에 꽉 차게 됩니다. 그래서 Exponential Backoff, 즉 실패할수록 더 오래 기다리게 하는 방식이 필요하다고 생각합니다.
예를 들어 첫 실패 뒤에는 잠깐 기다리고, 또 실패하면 그보다 더 오래 기다리게 만드는 식입니다. 이렇게 하면 식당, 즉 서버가 잠깐이라도 숨을 돌릴 시간을 벌 수 있습니다. 일시적인 과부하나 짧은 장애를 한 번의 실패로 끝내지 않고 흡수하는 데 도움이 될 것이라고 기대하는데요. 반대로 기다림 시간이 길어질수록 사용자 입장에서는 응답이 더 느리게 느껴질 수 있다는 비용도 함께 따라옵니다.
여기서 Retry, 즉 실패한 요청을 다시 보내는 방식은 분명 유용한 도구일 수 있습니다. 다만 언제나 좋은 선택은 아니라고 생각합니다. 읽기 요청처럼 여러 번 보내도 상태가 바뀌지 않는 작업에는 비교적 안전합니다. 하지만 결제 승인처럼 Side Effect, 즉 실제 시스템 상태를 바꾸는 작업에는 훨씬 더 조심해서 붙여야 합니다.
Jitter는 왜 같이 봐야 할까
Exponential Backoff만으로는 조금 아쉬울 수 있습니다. 모두가 4초 뒤에 다시 오면, 4초 뒤에 또 한꺼번에 몰릴 가능성이 크기 때문입니다. 기다림은 늘어났지만, 다시 몰리는 시점은 여전히 같다고 볼 수 있습니다. 그래서 Jitter, 즉 작은 무작위 지연을 같이 넣습니다.
누구는 4.1초 뒤에 오고, 누구는 4.8초 뒤에 오게 만들어서 재시도 시점을 퍼뜨리는 것이지요. 이렇게 하면 같은 실패를 겪은 요청들도 완전히 같은 순간에 다시 몰리지 않습니다. AWS가 Exponential Backoff + Jitter를 같이 강조하는 이유도 여기에 있다고 생각합니다. 기다림 시간을 늘리는 것만으로는 부족하고, 재시도 타이밍 자체를 흩어야 효과가 더 잘 보일 수 있습니다.
이 선택의 장점은 시스템 전체가 한 번 더 크게 흔들릴 가능성을 줄인다는 점입니다. 대신 각 요청이 정확히 언제 다시 시도될지는 덜 예측 가능해집니다. 저는 이 지점이 흥미롭다고 느꼈는데요. 예측 가능성을 조금 내려놓는 대신 안정성을 얻는 선택이라고 볼 수 있기 때문입니다.
Token Bucket은 무엇을 다르게 풀어낼까
이번에는 식당 입구에 입장권 통 하나를 둔다고 생각해 볼게요. 이것이 Token Bucket, 즉 일정한 속도로 다시 채워지는 토큰 통 방식입니다. 입장권이 있으면 들어오고, 없으면 기다리게 하거나 구현에 따라 바로 돌려보내게 됩니다.
Token Bucket의 핵심은 무조건 막는 데 있지 않다고 생각합니다. 한동안 손님이 없었다면 입장권이 쌓여 있어서, 짧은 순간에 여러 명이 들어오는 것은 어느 정도 허용할 수 있습니다. 대신 오래 계속되는 과한 요청은 막습니다. 그러니까 순간적인 몰림은 조금 받아 주되, 지속적인 과부하는 제한하는 방식에 더 가깝습니다.
이 방식이 유용한 이유는 평균 속도를 조절하면서도 짧은 Burst, 즉 순간적인 급증은 어느 정도 허용할 수 있기 때문입니다. 반대로 통이 너무 작으면 정상 사용자도 막히고, 너무 크면 보호 장치 역할이 약해질 수 있습니다. 그래서 Token Bucket은 성능 최적화 도구라기보다 보호 장치로 이해하는 편이 더 자연스럽다고 생각합니다.
여기서 함께 보는 예시 코드에는 아직 Token Bucket 구현이 없습니다. 그래서 여기서는 개념 설명 중심으로만 다루려고 합니다. 다만 결제 버튼 연타나 짧은 시간에 반복되는 호출을 제어해야 한다는 점을 생각하면, 나중에 연결해 보기 좋은 주제라고는 느꼈습니다.
그런데 상태를 바꾸는 요청은 더 조심해야 합니다
앞의 세 개념은 모두 "몰림을 어떻게 줄일 것인가"에 집중합니다. 그런데 결제처럼 상태를 실제로 바꾸는 요청은 한 단계 더 어렵다고 생각합니다. 같은 요청을 다시 보내는 순간, 단순한 부하 문제가 아니라 중복 실행 문제가 생기기 때문입니다.
뽀빠이 식당에서 손님이 시금치 한 캔을 주문했다고 해볼게요. 직원의 답이 늦어지면 손님은 실패한 줄 알고 같은 주문을 한 번 더 넣을 수 있습니다. 이때 진짜 문제는 두 번째 주문 자체가 아니라, 첫 번째 주문이 이미 처리되고 있었는지 모른다는 점이라고 봅니다.
이것이 실제 시스템에서 Timeout 이후의 Retry가 위험한 이유입니다. Timeout, 즉 정해진 시간 안에 응답이 오지 않아서 실패로 간주하는 장치는 매우 중요합니다. 하지만 Timeout이 곧 실제 실패를 뜻하지는 않습니다. 클라이언트는 응답을 못 받았을 뿐인데, 서버나 외부 결제사는 이미 요청을 처리하고 있을 수 있기 때문입니다.
이 예시에서 Retry는 어디에 먼저 보일까
지금 함께 보는 코드에는 결제 쪽에 Exponential Backoff + Jitter가 직접 구현되어 있지는 않습니다. 대신 Retry가 실제로 보이는 곳은 좋아요 처리 흐름입니다. 여기서는 낙관적 락 충돌이 발생했을 때 최대 3번까지 다시 시도합니다.
이 구현을 보면 현재 방향이 어느 정도 보인다고 생각합니다. 한 번 실패했다고 바로 끝내지 않고 다시 시도할 수 있다는 판단은 이미 들어가 있습니다. 다만 이 Retry는 좋아요 수 증가처럼 범위가 비교적 분명한 충돌 상황에만 제한적으로 적용되어 있습니다. 그리고 여기에는 Backoff나 Jitter는 아직 없습니다. 다시 말해, 지금 구현은 "재시도 자체"는 있지만 "재시도를 어떻게 분산시킬 것인가"까지는 아직 다루지 않은 상태입니다.
이 점은 그대로 트레이드오프가 됩니다. 구현이 단순해서 이해하기 쉽고 빠르게 적용할 수 있다는 장점이 있습니다. 하지만 동시에 많은 충돌이 발생하는 상황에서는 같은 순간에 다시 부딪힐 가능성이 남아 있다고 볼 수 있습니다.
결제에서는 왜 Idempotency가 먼저 필요할까
결제 요청은 좋아요 수 증가보다 훨씬 위험합니다. 결제가 두 번 승인되면 사용자 돈이 두 번 빠질 수 있기 때문입니다. 그래서 결제에서는 Retry보다 먼저 Idempotency, 즉 같은 요청이 여러 번 들어와도 결과가 한 번만 반영되게 만드는 성질을 봐야 한다고 생각합니다.
지금 함께 보는 결제 코드는 그 방향을 이미 일부 보여 줍니다. 결제 생성 단계에서 먼저 같은 orderId의 결제가 있는지 확인하고, 데이터베이스에서도 같은 주문에 대해 결제가 하나만 만들어지도록 제약을 둡니다.
즉, 지금 예시 코드는 Idempotency Key를 별도 문자열로 받지는 않지만, 최소한 orderId를 기준으로 같은 주문에 대해 결제가 중복 생성되지 않게 막고 있습니다. 이것은 같은 키로 다시 온 요청에 이전 결과를 재사용해서 돌려주는 일반형 Idempotency라기보다, 같은 비즈니스 요청을 두 번 생성하지 않으려는 중복 결제 생성 방지에 더 가깝다고 생각합니다.
이 방식의 장점은 비교적 분명합니다. 애플리케이션 레벨에서 한 번 확인하고, 데이터베이스 레벨에서 한 번 더 막아서 중복 생성 가능성을 낮춥니다. 반대로 한계도 있습니다. 지금 구조는 orderId 기준의 중복 방지에 가깝습니다. 진짜 일반형 Idempotency처럼 요청 본문 동일성, 키 보관 기간, 처리 중 상태 응답 정책까지 폭넓게 다루는 구조는 아닙니다.
State가 없으면 Idempotency도 충분하지 않을 수 있습니다
Idempotency는 중복 생성만 막는 것으로 끝나지 않습니다. 같은 요청이 다시 들어왔을 때, 지금 이 요청이 처리 중인지, 이미 성공했는지, 실패했는지까지 알아야 합니다. 그래서 State, 즉 현재 처리 상태가 같이 필요합니다.
이 결제 예시의 상태는 PENDING, SUCCEEDED, FAILED, EXPIRED로 나뉩니다. 그리고 상태를 바꾸는 메서드는 모두 PENDING 상태에서만 동작합니다. 이미 성공했거나 실패한 결제를 다시 성공 처리하거나 실패 처리하려고 하면 예외가 납니다.
이 구조가 중요한 이유는 같은 요청이 두 번 들어왔을 때 단순히 "본 적 있다"만으로는 부족하기 때문이라고 생각합니다. 현재 PENDING이면 아직 처리 중이라는 뜻이고, SUCCEEDED면 이미 결과가 확정된 것입니다. 다만 현재 코드에서 이 상태값은 재요청에 대한 상태별 응답 정책 전체를 다루기보다, 상태 전이 제한과 중복 콜백 무시에 더 직접적으로 쓰이고 있습니다.
그래서 저는 이 결제 모델이 "중복 요청을 어떻게 막을 것인가"를 데이터베이스 제약만으로 풀지 않고, 상태 전이 규칙까지 같이 두고 있다는 점이 좋았습니다. Idempotency를 키 하나의 문제로만 보지 않고, 상태 흐름까지 함께 봐야 한다는 감각을 주기 때문입니다.
Timeout과 외부 응답 지연은 어떻게 이어질까
여기서는 외부 결제사 응답을 흉내 내는 시뮬레이터도 함께 봅니다. 여기에는 TIMEOUT 시나리오가 있고, 테스트에서는 이 상황이 내부 오류로 매핑되는지 검증합니다.
이 구현이 보여 주는 점은 비교적 분명합니다. 외부 결제사 응답이 늦거나 끊길 수 있다는 사실을 이미 실패 시나리오로 보고 있다는 뜻입니다. 다만 여기에는 아직 Retry with Exponential Backoff + Jitter가 직접 구현되어 있지는 않습니다. 그래서 현재는 "타임아웃 같은 실패를 어떻게 인식할 것인가"까지는 구현되어 있지만, "그 실패 뒤에 어떤 간격과 규칙으로 다시 시도할 것인가"는 다음 단계에 가깝다고 생각합니다.
그래서 이 구분을 같이 보고 넘어가면 좋겠습니다. 이미 있는 구현과 아직 없는 구현을 섞어 쓰면 글이 쉽게 흐려질 수 있기 때문입니다. 현재 함께 보는 코드에서는 TIMEOUT 시나리오를 테스트하고, 실제 결제 경로에서는 Circuit Breaker와 TimeLimiter도 적용하고 있습니다. 반면 Token Bucket과 Exponential Backoff + Jitter는 아직 학습 가이드와 설계 관점에서 읽는 편이 더 자연스럽습니다. 또 현재 TIMEOUT은 바로 성공이나 실패를 확정하는 대신 DEFERRED 응답으로 돌려서 잠시 보류하는 흐름과 연결되어 있습니다.
현재 결제 흐름은 어떤 판단을 보여 줄까
현재 결제 흐름을 보면 판단이 제법 분명하게 드러납니다. 먼저 결제를 만들고, 그다음 외부 요청을 보냅니다. 여기서 예외가 나면 실패 상태를 남기고 예외를 다시 던집니다. 반면 DEFERRED 응답이 오면 성공이나 실패로 바꾸지 않고 그대로 둡니다.
저는 이 지점이 꽤 중요하다고 생각합니다. 확실히 실패한 경우에는 실패 상태를 남기고, 외부 시스템이 즉시 확정하지 않은 경우에는 성급하게 성공이나 실패로 단정하지 않기 때문입니다. 즉, 이 결제 흐름은 지금도 "불확실한 상태를 무리하게 확정하지 않는다"는 쪽을 택하고 있습니다.
테스트도 같은 판단을 뒷받침합니다. fallback 성격의 응답이 오면 결제를 성공이나 실패로 바꾸지 않고 대기 상태로 두고, 이미 성공한 결제에 같은 승인 콜백이 다시 와도 무시합니다. 이런 부분을 보면 현재 코드가 중복 콜백과 불확실한 응답을 어떻게 다루고 있는지 더 또렷하게 보입니다.
하나로 보면 어떤 그림이 보일까
여기까지를 한 번에 놓고 보면 연결이 조금 더 선명해집니다. Thundering Herd는 왜 같은 순간의 몰림이 위험한지 설명합니다. Exponential Backoff + Jitter는 재시도 타이밍을 퍼뜨리는 방법을 설명합니다. Token Bucket은 들어오는 양 자체를 제한하는 방법을 설명합니다. 그리고 이 글에서 함께 보는 결제 코드는 그다음 단계인 Retry, Idempotency, State, Timeout 문제를 실제 흐름으로 보여 줍니다.
제가 보기에는 앞의 세 개념은 "트래픽과 재시도를 어떻게 분산하거나 제한할 것인가"에 가깝고, 현재 프로젝트 구현은 "상태를 바꾸는 요청에서 중복 실행과 불확실한 결과를 어떻게 다룰 것인가"에 더 가깝습니다. 둘은 따로 노는 주제가 아니라, 같은 실패 문제를 다른 층위에서 다루는 연결된 이야기라고 생각합니다.
아직 더 보고 싶은 부분
이번 글에서는 Thundering Herd, Exponential Backoff + Jitter, Token Bucket, Retry, Idempotency를 한 흐름으로 묶는 데 집중했습니다. 그래서 실제 수치 튜닝까지는 깊게 들어가지 않았습니다. 예를 들어 Backoff 간격을 얼마나 둘지, Jitter를 어떤 방식으로 줄지, Token Bucket의 크기와 채우는 속도를 얼마로 둘지, Idempotency Key를 별도 저장소로 확장할지까지는 아직 더 볼 여지가 있습니다.
또 지금 예시 코드에서 안전하게 근거로 삼을 수 있는 것은 Retry, 중복 결제 방지, 상태 전이, 중복 콜백 무시, Timeout 시나리오 테스트, 그리고 Circuit Breaker와 TimeLimiter 적용까지입니다. 반대로 Token Bucket, Bulkhead, Exponential Backoff + Jitter는 아직 실제 서비스 적용까지 다 왔다고 말하기는 어렵고, 조금 더 검증이 필요할 것이라고 생각합니다.
다음에 이어서 보고 싶은 질문
다음에는 이번에 함께 본 예시를 기준으로 좋아요 처리의 단순 Retry와 결제 흐름의 Idempotency를 더 직접 비교해 보고 싶습니다. 특히 Retry에 Backoff가 왜 아직 없는지, 결제 요청에서 Idempotency Key를 별도로 받는 구조로 확장하려면 무엇이 더 필요한지, 그리고 PENDING 상태의 재요청을 어떤 응답으로 돌려주는 것이 가장 안전할지까지 이어서 보면 좋겠다고 생각합니다.