본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 21. 09:24

Redis 없이 구현하는 속도 제한(Rate Limiting): 서버리스에서 사용하는 3가지 패턴

요약

서버리스 환경에서 Redis 없이 효율적으로 속도 제한(Rate Limiting)을 구현하는 세 가지 패턴을 소개합니다. 인프라 비용과 지연 시간을 최소화하기 위해 요구 사항에 따른 최적의 접근 방식을 제안합니다.

핵심 포인트

  • 서버리스의 휘발성 메모리 특성상 인메모리 방식은 부적합함
  • 정확한 제한이 필요한 경우 Durable counters 패턴 사용
  • 낮은 지연 시간이 중요한 경우 Edge config token buckets 활용
  • 저장 공간 없이 남용을 방지하려면 Signed-window limits 권장
  • Durable counters(내구성이 있는 카운터)는 정확한 제한을 제공하지만 요청당 비용이 발생합니다.

  • Edge config token buckets(에지 설정 토큰 버킷)은 정밀도를 희생하는 대신 거의 제로에 가까운 지연 시간(latency)을 제공합니다.

  • Signed-window limits(서명된 윈도우 제한)는 저장 공간이 전혀 필요하지 않습니다.

  • 소규모 규모에서는 signed windows가 대부분의 남용 사례를 비용 없이 처리합니다.

서버리스(serverless) 환경에서 속도 제한(Rate limiting)을 처음 시도했을 때, 제가 가진 사고 모델이 완전히 깨졌습니다. 카운터를 유지할 수 있는 장기 실행 프로세스가 없습니다. 모든 요청은 메모리가 비어 있는 새로운 인스턴스에 도달할 수 있습니다. 저는 제한의 엄격함 정도에 따라 세 가지 다른 패턴을 사용하게 되었으며, 그 중 어느 것도 Redis를 실행할 필요가 없었습니다.

왜 일반적인 조언이 서버리스에서는 실패하는가

모든 튜토리얼은 INCR와 EXPIRE를 사용하는 Redis를 사용하라고 말합니다. 이는 몇 주 동안 유지되는 서버 프로세스가 있고 그 옆에 Redis 인스턴스가 있는 경우에는 아주 잘 작동합니다. 하지만 서버리스 환경에서 저는 그 어느 것도 가지고 있지 않았습니다.

제 함수들은 콜드 스타트(cold start)로 실행되어, 요청을 처리한 뒤 종료됩니다. 두 번의 호출 사이에 공유 메모리는 존재하지 않습니다. 저는 이를 직접 테스트했습니다. 모듈 수준 변수에 카운터를 저장하고 배포한 뒤, 엔드포인트를 계속해서 호출했습니다. 부하가 걸릴 때 플랫폼이 새로운 인스턴스를 계속 생성하기 때문에 카운터가 끊임없이 초기화되었습니다. 때로는 1이 보였고, 때로는 14가 보였으며, 결코 신뢰할 수 있는 숫자는 나오지 않았습니다.

따라서 동시 요청이 하나 이상 발생하는 순간 인메모리(in-memory) 방식은 제외됩니다. 남은 것은 외부 상태(external state)입니다. 명백한 답은 Upstash와 같은 관리형 Redis이며, 한동안 저는 비용을 지불하며 사용했습니다. 한 달에 약 10 EUR의 비용이 들었고, 요청이 Redis 리전까지 갔다가 돌아와야 했기 때문에 호출당 30~60밀리초(ms)의 지연 시간(latency)이 추가되었습니다. 하루에 200개의 요청을 받는 엔드포인트에게 이는 터무니없는 수준이었습니다.

더 깊은 문제는 Redis가 제가 소규모 규모에서는 실제로 겪고 있지 않았던 조정(coordination) 문제를 해결하려 한다는 점입니다. 저는 수천 개의 노드에 걸쳐 전역 제한(global limit)을 강제하려던 것이 아니었습니다. 저는 단 하나의 IP 주소나 하나의 API 키가 1분 안에 500개의 요청을 보내는 것을 막으려 했던 것입니다. 그것은 훨씬 더 작은 문제이며, 작은 문제에는 더 저렴한 해결책이 있습니다.

저는 요구 사항을 세 가지 범주로 나누었습니다. 첫째, 하루 호출 횟수가 1,000회로 제한된 유료 API 계층(tier)처럼 정확한 횟수가 중요한 엄격한 제한(strict limits)입니다. 둘째, 문의 양식(contact form)의 스로틀링(throttling)처럼 대략적인 수치만 맞으면 되는 완만한 제한(soft limits)입니다. 셋째, 아무것도 저장하지 않으면서 스크립트로 인한 대량 유입(floods)을 비용이 많이 들게 만들어 방지하고자 하는 남용 방지(abuse prevention)입니다.

이 각각은 서로 다른 패턴에 대응됩니다. 1인 스튜디오로서 제가 어떻게 인프라를 가볍게 유지하는지에 대한 더 넓은 그림을 알고 싶다면, Claude Blueprint에서 제가 사용하는 의사 결정 프레임워크를 확인할 수 있습니다. 아래는 세 가지 패턴과 실제 소규모 규모에서 각 패턴을 적용하며 겪었던 트레이드오프(tradeoffs)입니다.

패턴 1: Durable-Object 스타일 카운터

정확한 횟수가 필요할 때, 저는 Durable Object를 사용합니다. Cloudflare Workers에서는 이를 Durable Objects라고 부릅니다. 핵심 아이디어는 상태(state)를 보유하고 하나의 키에 대해 한 번에 하나의 요청을 처리하는, 주소 지정이 가능한 단일 인스턴스를 만드는 것입니다. 카운터를 소유한 아주 작은 싱글 스레드 액터(actor)라고 생각하면 됩니다.

구조는 다음과 같습니다. API 키와 함께 요청이 들어오면, 해당 키로부터 Durable Object ID를 도출합니다. 어떤 에지 로케이션(edge location)에서 함수가 실행되더라도, 해당 키에 대한 모든 요청은 동일한 객체로 라우팅됩니다. 객체 내부에서 스토리지에 대해 일반적인 INCR(증가) 연산을 실행하고, 제한 사항과 대조한 뒤, 필요에 따라 값을 감소시키거나 요청을 거부합니다.

이 방식의 장점은 정확성(correctness)입니다. 하나의 객체가 하나의 키를 직렬(serially)로 처리하기 때문에 경합 조건(race conditions)이 발생하지 않습니다. 동일한 밀리초(millisecond)에 도착한 두 개의 요청은 큐에 쌓여 순서대로 계산됩니다. 덕분에 매번 정확한 숫자를 얻을 수 있습니다. 사용량에 따라 과금되는 유료 계층(metered paid tier)에서는 이것이 매우 중요한데, 하루 1,000회 호출에 비용을 지불하는 고객은 경합 조건 때문에 1,043회가 아닌 정확히 1,000회를 제공받아야 하기 때문입니다.

비용은 실제로 발생하지만 적은 편입니다. 각 Durable Object 요청은 Worker 요청과는 별도로 과금됩니다. 제 트래픽 기준으로, 한 달에 50,000회의 사용량 기반 호출(metered calls)을 처리하는 엔드포인트는 약 1유로(EUR) 정도의 비용이 추가되었습니다. 호출이 실제 수익과 직결되는 유료 사용의 경우 이 정도는 괜찮습니다. 하지만 무료 문의 양식에 사용하기에는 낭비가 될 수 있습니다.

저의 경우 지연 시간(Latency)은 Redis보다 나은 약 5~15밀리초(ms) 정도였습니다. 객체가 단일 Redis 리전(Region)에 있는 대신, 요청 근처의 엣지(Edge)에 존재하기 때문입니다. 콜드 객체(Cold object)에 대한 첫 번째 요청은 약 40밀리초(ms) 정도로 더 느리지만, 이후에는 워밍업(Warm)됩니다.

주의해야 할 트레이드오프(Tradeoff)는 단일 인스턴스 병목 현상(Single-instance bottleneck)입니다. 하나의 키에 대한 모든 요청이 하나의 객체를 통해 직렬화되기 때문에, 매우 빈번하게 사용되는 단일 핫 키(Hot key)는 대기열(Queue)이 될 수 있습니다. 사용자별 제한(Per-user limits)의 경우에는 특정 사용자 한 명이 문제가 될 만큼의 트래픽을 생성하지 않으므로 괜찮습니다. 하지만 모든 글로벌 트래픽을 하나의 객체로 라우팅하려고 시도한다면 병목 지점(Chokepoint)을 만들게 될 것입니다. 저는 파티션 키(Partition key)를 좁게 유지하여, API 키당 하나의 객체를 할당하며 절대 공유되는 글로벌 객체를 사용하지 않습니다.

저는 카운트가 정확해야 하고 호출에 가치가 부여된 경우에만 이 패턴을 사용합니다. 그 외의 모든 경우에는 다음 두 가지 패턴이 더 저렴합니다.

패턴 2: Edge Config 토큰 버킷 (Token Buckets)

소프트 제한(Soft limits)을 위해 저는 엣지 설정(Edge config) 저장소를 기반으로 하는 토큰 버킷(Token bucket)을 사용합니다. Vercel은 이를 Edge Config라고 부르고, Cloudflare는 KV를 제공하며, 두 서비스 모두 데이터가 함수 근처에 복제되어 있기 때문에 한 자릿수 밀리초 내에 읽을 수 있는 키-값 저장소(Key-value store)를 제공합니다.

토큰 버킷은 다음과 같이 작동합니다. 각 키는 용량(예: 60개 토큰)을 가진 버킷을 할당받으며, 이 버킷은 특정 비율(예: 초당 1개 토큰)로 다시 채워집니다. 모든 요청은 토큰을 하나씩 가져갑니다. 버킷이 비어 있으면 요청은 거부됩니다. 영리한 부분은 제가 계속 돌아가는 타이머 카운터(Ticking counter)를 저장하지 않는다는 점입니다. 저는 두 개의 숫자, 즉 마지막 업데이트 시점의 토큰 개수와 해당 업데이트의 타임스탬프(Timestamp)를 저장합니다. 각 요청마다 경과된 시간을 기반으로 그동안 얼마나 많은 토큰이 채워졌어야 하는지 계산하여 이를 더하고, 용량 제한을 적용한 뒤, 1을 뺍니다.

이러한 계산 방식 덕분에 저는 타이머가 아니라 실제 요청이 있을 때만 쓰기(Write) 작업을 수행합니다. 한 시간 동안 아무도 건드리지 않은 버킷은 그대로 유지되며, 다음 요청이 들어올 때 한 번의 단계로 전체 리필(Refill)을 계산합니다. 이는 쓰기 작업을 낮게 유지해 주는데, 이는 엣지 설정의 읽기(Read)는 저렴하고 빠르지만 쓰기는 더 느리고 때로는 비용이 부과되기 때문에 매우 중요합니다.

솔직한 트레이드오프(Tradeoff)는 일관성(Consistency)입니다. 엣지 설정(Edge config)은 최종적 일관성(Eventually consistent)을 따릅니다. 만약 두 개의 요청이 동일한 순간에 서로 다른 두 지역(Region)에 도달한다면, 두 요청 모두 동일한 오래된 버킷(Stale bucket)을 읽어 통과될 수 있습니다. 트래픽이 낮을 때는 이런 일이 거의 발생하지 않으며, 발생하더라도 최악의 상황은 한 번의 추가 요청이 허용되는 정도입니다. 문의 양식(Contact form)이나 검색 엔드포인트(Search endpoint)의 스로틀링(Throttling)을 위해, 가끔 60번 대신 61번의 요청을 허용하는 것은 해롭지 않습니다.

실제 수치를 측정해 보았습니다. 읽기(Read)는 2~8밀리초(ms) 내에 완료되었습니다. 쓰기(Write)에 대한 최종적 일관성 윈도우(Eventual-consistency window)는 보통 1초 미만이었습니다. 정확도가 중요하지 않은 엔드포인트의 경우, 이것이 제가 발견한 비용과 속도 사이의 최적의 균형입니다. 소규모 스튜디오가 처리하는 규모에서는 사실상 비용이 들지 않습니다.

엄격한 정확성이 요구되지 않을 때 최종적 일관성을 가진 저장소(Eventually-consistent storage)를 선택하는 일반적인 원칙에 대해서는 백엔드 구조화에 관한 제 노트에서 다룬 바 있으며, 토큰 버킷(Token bucket)은 그 트레이드오프의 가장 깔끔한 예시입니다. API를 계속 두드리는 대신 유사한 저빈도 쓰기(Low-write) 주기로 소셜 포스트를 예약하고 싶다면, 저는 직접 큐(Queue)를 구축하는 대신 Buffer를 사용합니다.

패턴 3: 저장소 없는 서명된 윈도우 제한 (Signed-Window Limits With No Storage)

세 번째 패턴은 아무것도 저장하지 않으며, 제가 가장 많이 사용하는 방식입니다. 핵심은 서명된 시간 윈도우(Signed time window)입니다. 서버가 사용자가 몇 번의 요청을 보냈는지 기억하는 대신, 클라이언트가 윈도우 정보를 인코딩한 서명된 토큰(Signed token)을 지니고 다니며 수학적 계산을 통해 제한을 수행합니다.

핵심 아이디어는 다음과 같습니다. 시간을 60초 단위와 같은 고정된 윈도우로 나눕니다. 특정 클라이언트와 윈도우에 대해 서버 비밀키(Server secret)를 사용하여 HMAC 서명을 계산합니다. 클라이언트가 윈도우 내에서 엔드포인트에 처음 접속하면, 윈도우 시작 시간과 요청 횟수를 포함하고 위조할 수 없도록 서명된 작은 토큰을 발급합니다. 클라이언트는 다음 요청 시 해당 토큰을 다시 보냅니다. 저는 서명을 검증하고, 횟수를 읽고, 증가시킨 뒤, 다시 서명하여 새로운 토큰을 반환합니다.

토큰에 서명이 되어 있기 때문에 클라이언트는 횟수에 대해 거짓말을 할 수 없습니다. 또한 윈도우 (window) 정보가 인코딩되어 있으므로, 이전 윈도우의 오래된 토큰은 단순히 무시되며 횟수가 초기화됩니다. 저는 어떤 데이터베이스에도 쓰기 작업을 하지 않습니다. 전체 상태는 클라이언트와 에지 (edge) 사이를 이동하는 서명된 토큰 안에 존재합니다.

저는 단순한 남용 방지 (abuse slowing)를 위해 상태가 없는 (stateless) 변형 방식도 사용합니다. 클라이언트가 아주 작은 작업 증명 (proof-of-work)을 해결하거나, 10초 후에 만료되는 서명된 논스 (nonce)를 포함하도록 요구합니다. 이 방식은 저에게 단 한 번의 HMAC 검증 비용, 즉 대략 마이크로초 단위의 비용만 발생시키지만, 공격자에게는 플러딩 (flood)을 시도할 때 실제 CPU 비용을 발생시킵니다. 심각한 봇넷 (botnet)을 막지는 못하겠지만, 가벼운 스크립트를 실행할 가치가 없게 만듭니다.

저를 설득한 수치들은 다음과 같습니다: 스토리지 읽기 0, 쓰기 0, 그리고 모든 것이 프로세스 내 암호화 (in-process crypto)로 이루어지기 때문에 1밀리초 미만의 지연 시간 (latency)입니다. 한 달에 100,000건의 요청이 발생하는 환경에서 이 패턴은 이미 지불하고 있는 함수 호출 비용 외에 추가 비용이 전혀 들지 않았습니다. 실제 비용이 ElevenLabsMagnific와 같은 유료 서비스로의 다운스트림 (downstream) 호출에서 발생하는 음성 또는 이미지 엔드포인트의 경우, 명백한 플러딩을 차단하는 무료 전방 차단기 (front-line throttle)는 자체적인 오버헤드를 추가하지 않으면서도 비용이 많이 드는 부분을 보호해 줍니다.

결론 (Bottom Line)

저는 하나의 속도 제한기 (rate limiter)만 사용하는 것이 아니라 세 가지를 사용하며, 선택 기준은 횟수가 얼마나 중요한지, 그리고 누가 요청 비용을 지불하는지에 달려 있습니다. 숫자가 정확해야 하고 호출에 비용이 수반되는 경우에는 Durable-object 카운터를 사용합니다. 대략적으로 맞으면 충분하고 지연 시간이 거의 제로에 가깝기를 원할 때는 Edge config 토큰 버킷 (token buckets)을 사용합니다. 비용 없이 실수로 인한 플러딩을 막고 아무것도 저장하고 싶지 않을 때는 서명된 윈도우 (signed-window) 제한을 사용합니다.

제가 계속해서 다시 배우는 교훈은, Redis는 대부분의 소규모 프로젝트에는 필요하지 않은 조정 (coordination) 문제를 해결한다는 것입니다. 공유된 외부 상태 (shared external state)를 찾기 전에, 제가 실제로 전역적인 정확한 카운트가 필요한지 아니면 단지 남용의 비용을 높이고 싶은 것인지 자문해 봅니다. 대개는 후자이며, 후자는 거의 비용이 들지 않습니다.

만약 당신이 1인 운영자로서 가벼운 서버리스 백엔드 (serverless backends)를 구축하고 있다면, 동일한 절제가 모든 곳에 적용됩니다. 튜토리얼이 가정한 방식이 아니라, 실제 요구 사항을 충족하는 가장 저렴한 패턴을 선택하십시오. 저는 Claude Blueprint에서 이러한 의사 결정 과정을 더 자세히 다루고 있으며, 그곳에서 유료 인프라의 벽 없이 어떻게 1인 스튜디오를 운영하는지 설명합니다.

이 기사에는 제휴 링크가 포함되어 있습니다. 이 링크를 통해 가입하시면, 귀하에게 추가 비용 부담 없이 저에게 소액의 수수료가 지급될 수 있습니다. (광고)

AI 자동 생성 콘텐츠

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

원문 바로가기
0

댓글

0