처리율 제한기 개발
기존에 존재했던 댓글 제한 기능을 보완하기 위해 Golang으로 처리율 제한기 미들웨어를 개발했습니다. yml 파일을 사용해서 동적으로 알고리즘을 선택하고 API 별로 제한설정을 적용할 수 있습니다.
개발 배경과 기존 기능의 한계
운영 중인 서비스(메이플 주문서 시뮬레이터)에는 기본적인 댓글 도배 방지 기능이 있습니다. 동일한 사용자가 1분에 3회 이상 댓글을 작성하지 못하도록 제한하는 구조로, Redis를 이용해 사용자별 요청 횟수를 카운팅 했습니다.
하지만 운영 중 예상치 못한 문제가 발생했습니다. Redis의 TTL(만료 시간)이 매 요청마다 갱신되다 보니, 제한시간이 밀리는 현상이 생긴 것입니다. 결과적으로 정상 사용자의 요청까지도 지속적으로 차단되는 문제가 발생했습니다.
예를 들어, 1분에 3회까지만 허용하고 싶었지만 실제로는 첫 댓글 작성 후 1분이 지나도 요청이 통과되지 않았습니다.
1
2
3
4
00:00 - 댓글 작성 (카운터=1, TTL=1분)
00:30 - 댓글 작성 (카운터=2, TTL=1분으로 갱신)
00:50 - 댓글 작성 (카운터=3, TTL=1분으로 갱신)
01:10 - 댓글 작성 (거부됨, TTL=다시 1분으로 갱신)
또한 모든 요청이 WAS(Spring Boot)에 도달한 뒤에야 거부 판단이 이뤄졌기 때문에, 비정상 요청조차 서버 부하의 원인이 되었습니다.
이 두가지 문제는 명확했습니다.
- TTL 갱신 구조로 인한 잘못된 요청 제한
- 거부 판단이 WAS내부에서 수행되어 불필요한 부하 발생
이를 해결하기 위해, 요청이 애플리케이션에 도달하기 전에 필터링하는 독립적인 처리율 제한 미들웨어를 개발하기로 했습니다.
직접 구현한 이유
처리율 제한 기능은 이미 NGINX 에서도 제공합니다. 하지만 NGINX의 처리율 제한은 누출 버킷(Leaky Bucket) 알고리즘 하나만 지원하며, Redis 같은 외부 저장소와 연동하거나 사용자, 엔드포인트 단위로 세밀하게 제한하기 어렵습니다.
제가 구현하려던 시스템은 다음의 요구사항을 모두 만족해야했습니다.
- 사용자 또는 API 별로 서로 다른 쿼터 설정
- Redis와 연동하여 분산 환경에서도 동작
- 알고리즘을 동적으로 교체 가능(YAML 설정 기반)
이런 유연성을 확보하기 위해 직접 미들웨어를 개발하기로 했습니다.
처리량 제한기를 직접 만든 이유
처리량 제한 기능을 사용하기 위해서 저처럼 꼭 직접 기능을 개발할 필요는 없습니다. 원래도 웹서버로 NGINX를 사용하고 있는데, NGINX에서도 처리량 제한 기능을 제공하기 때문에 그것을 활용해도 됩니다. 하지만 NGINX의 처리량 제한 기능은 기대하던 것과는 달랐다.
뒤에서도 후술하겠지만 처리량을 제한하는데도 토큰 버킷, 누출 버킷, 슬라이딩 윈도우 카운터, 슬라이딩 윈도우 로깅, 고정 윈도 카운터 등등 여러가지 알고리즘이 존재한다. NGINX 가 제공하는 처리량 제한기능은 누출 버킷 알고리즘을 사용하고 있었고, 이것이 내가 원했던 방식과는 달랐다. NGINX 만으로는 Redis와 같은 외부 저장소와 연동하면서 알고리즘을 구현하기 까다로운 것도 이유 중 하나였습니다.
또 단일 고정 전략이 아니라 상황에 따라 알고리즘을 실험/적용해보고 싶었습니다. 사용자나 엔드포인트별로 서로 다른 쿼터나 가중치를 줄 수 있는 세밀한 제어가 가능한 제한기를 만들어보고 싶었다. 이러한 기술적인 욕심으로 저는 처리량 제한기 미들웨어를 직접 구현하였다.
기술 선택 - Golang
Golang을 선택한 이유는 낮은 지연, 동시성, 운영 단순성이었습니다. 고루틴과 채널기반의 동시성 모델이 수천개의 요청을 병렬로 처리하기에 최적이었고, 누출 버킷이나 이동 윈도우 기반 알고리즘 구현 시에도 구조가 간결했습니다.
Java 언어는 안정적이고 익숙한 생태계였지만 오버헤드나 메모리 부담이 크고, Rust는 성능이 뛰어나지만 복잡도가 높고 개발생산성이 낮다는 이유에서 후보에서 제외했습니다.
설계 목표와 아키텍처
새로운 처리율 제한기는 다음을 목표로 설계했습니다.
- 요청이 WAS에 도달하기 전에 필터링 - 미들웨어에서 직접 요청을 수락/거부 결정
- 알고리즘 선택의 유연성 - 운영 중에서 YAML 설정을 수정해 전략 변경 가능
- Redis 기반의 분산 동작 - 여러 인스턴스가 동일한 상태를 공유
- API 단위 및 사용자 단위 제어 - 세밀한 정책 적용을 통한 서비스 보호
gate-limiter는 리버스 프록시 형태로 동작합니다. NGINX 앞단이나 NGINX-WAS 사이에 배치되어, 모든 요청을 검사한 뒤 허용된 요청만 WAS로 전달합니다.
알고리즘 비교 및 선택
처리율 제한 알고리즘은 여러가지가 있습니다. 저는 다양한 트래픽 특성을 실험하기 위해 다섯가지 알고리즘을 지원하도록 설계했씁니다.
| 알고리즘 | 특징 | 장점 | 단점 |
|---|---|---|---|
| 토큰 버킷 | 주기적으로 토큰 채움 | 단기 폭발 트래픽 완화 | 짧은 주기 설정 시 불안정 |
| 누출 버킷 | 큐 기반 일정 처리 | 안정적 처리율 | 지연 큐 포화 시 손실 발생 |
| 고정 윈도우 카운터 | 일정 구간별 카운트 | 단순 구현 | 경계 트래픽 누적 문제 |
| 이동 윈도우 로깅 | 로그 기반 시간 필터 | 정확한 제어 | 메모리 사용량 증가 |
| 이동 윈도우 카운터 | 이전 윈도우 가중치 반영 | 메모리 효율, 정확성 균형 | 근사치 계산 필요 |
최종적으로 제 서비스에는 이동 윈도우 카운터 알고리즘을 기본 전략으로 사용했습니다. 짧은 시간대에 몰리는 요청에도 부드럽게 대응하면서 메모리 사용량도 효율적이기 때문입니다.
알고리즘의 세부적인 내용은 아래와 같습니다.
토큰 버킷 알고리즘
토큰 버킷 알고리즘은 위 다섯가지 알고리즘 중 가장 간단한 알고리즘이다.
- 요청 단위마다 버킷을 두고 버킷마다 토큰을 채워넣는다.
- 요청이 들어올때마다 토큰 하나를 소비한다.
- 토큰이 남아있다면 요청이 통과되고, 토큰이 남아있지 않다면 거부된다.
- 토큰은 주기적으로 채워진다.
토큰을 주기적으로 채워주는 기능을 구현하기 위해서 주기적으로 존재하는 모든 버킷을 순회하는 것이 아니라, 요청이 도착했을 때 해당 버킷을 마지막으로 사용한 이후로 충분한 시간이 지났다면 토큰을 채워주는 방법을 사용해서 오버헤드를 줄였다.
이 알고리즘을 사용할 때는 두가지 파라미터를 조절해야한다.
- 버킷 크기: 버킷에 담을 수 있는 토큰의 수
- 토큰 공급 주기: 초당(혹은 분당)몇개의 토큰을 버킷에 공급할 것인가
누출 버킷 알고리즘
누출 버킷 알고리즘은 시간단위로 요청 처리율이 고정되어 있는 알고리즘이다. 큐를 활용하는 알고리즘인데 실제로 구현할 때는 큐 자료구조를 사용한 것이 아니라 Golang의 채널(channel)을 활용했다. 채널에 데이터를 넣고 주기적으로 채널을 비워주면서 처리되도록 했다. 주기적으로 채널을 비우면서 요청을 처리하기 때문에 많은 요청에도 안정적인 요청을 원하는 경우 사용하면 좋은 알고리즘이다. (대학 수강신청을 생각해보자)
- 요청이 도착하면 채널이 가득 차있는지 확인한다.
- 채널에 빈자리가 있는 경우 채널에 요청을 추가한다.
- 채널에 빈자리가 없는 경우 요청은 버려진다.
- 지정된 주기마다 큐에서 요청을 꺼내서 처리한다.
이 알고리즘의 경우 기본적인 기능은 구현했지만 채널에 빈자리가 없어 버려지는 요청이 처리되지 않고 버려지는 것에 대한 보완이 필요하다. 메세지 큐에 도착한 요청을 넣어두고 주기적으로 꺼내서 채널에 요청을 담는 방법으로 문제를 해결할 수 있을 것 같다.
고정 윈도우 카운터 알고리즘
타임라인을 윈도우라는 고정된 단위로 나누고 각 윈도우마다 카운터를 붙이는 방법이다.
- 요청이 들어올때마다 윈도우의 카운터 값이 1 증가한다.
- 윈도우의 카운터 값이 임계치와 같거나 큰 경우 들어오는 요청은 버려진다.
- 윈도우의 카운터 값이 임계치보다 작은 경우 요청이 받아들여진다.
1분간 3번의 요청으로 제한된 경우를 그림으로 나타내면 아래와 같다.
하지만 이 알고리즘은 경계 부분에 트래픽이 집중 될 경우 기대했던 시스템의 처리 한도보다 더 많은 양의 요청을 처리하게 된다는 단점이 있다.
01:00 주변의 경계 부분에 트래픽이 집중되어 00:30 ~ 01:30 사이에는 1분간 6개의 요청이 처리된 것을 볼 수 있다.
이동 윈도우 로깅 알고리즘
이동 윈도우 로깅 알고리즘을 사용하면 트래픽이 경계부근에 몰리는 경우 기대보다 많은 요청을 처리하게 되는 문제를 해결할 수 있다.
- 로그가 비어있다면 요청을 허용하고 타임스탬프를 기록한다.
- 로그가 비어있지 않다면
- 윈도우 범위 밖에 있는 타임스탬프가 있는지 확인하고 삭제한다.
- 윈도우 내 타임스탬프의 갯수가 임계치보다 작다면 요청을 허용한다.
- 윈도우 내 타임스탬프의 갯수가 임계치와 같거나 크다면 요청을 거부한다.
아래 그림은 분당 2개 요청이 한도인 시스템을 나타낸 것이다. 00:50에 도착한 요청의 경우 [00:00, 00:50) 윈도우에 이미 로그가 2개 들어가 있었기 때문에 요청이 차단된 것을 확인할 수 있다. 01:40에 도착한 요청의 경우에는 [00:40, 01:40) 범위 안의 요청만이 1분 윈도우 안의 요청이기 때문에 00:40 이전의 요청들은 모두 삭제되고, 01:40에 도착한 요청이 들어왔을 때 윈도우의 사이즈가 2 이기 때문에 허용되는 것을 볼 수 있다.
실제 구현에서는 Redis를 활용해서 버킷마다 Sorted Set을 두고 타임스탬프를 저장했다. Sorted Set을 활용해서 가장 오래된 타임스탬프가 무엇인지 확인하거나, 이미 윈도우 범위를 벗어난 타임스탬프를 쉽게 파악할 수 있었다.
이동 윈도우 카운터 알고리즘
이동 윈도우 카운터 알고리즘은 고정 윈도우 알고리즘과 이동 윈도우 로깅 알고리즘을 결합한 형태이다. 현재 윈도우가 직전 고정 시간대와 현재 고정 시간대를 얼마나 차지하고 있는지, 그 가중치에 따라서 현재 윈도우의 요청 수를 근사치로 계산하는 방법이다.
현재 윈도우에 몇개의 요청이 있는지는 다음과 같이 계산할 수 있다. 현재 1분간 요청 수 + 직전 1분간 요청 수 * 이동 윈도우와 직전 1분이 겹치는 비율 공식에 따라 그림에 나타나 있는 현재 윈도우에 들어가 있는 요청은 $3 + 4 \times \frac{70}{100} = 5.8$ 개 이다. 내림할지, 올림할지 반올림할지는 본인이 결정하면 된다.
나는 댓글 작성 요청 제한기능을 구현하면서 이 이동 윈도우 카운터 알고리즘을 사용했다. 메모리 효율도 좋고 이전 시간대의 처리율에 따라 현재 윈도우의 상태를 계산하기 때문에 짧은 시간에 몰리는 트래픽에도 잘 대응되기 때문이다.
YAML 기반 동적 구성
모든 정책은 외부 설정 파일에서 관리됩니다. 이는 코드 수정 없이 운영 중 정책 변경을 가능하게 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
rateLimiter:
strategy: sliding_window_counter
identity:
key: ipv4
header: X-Forwarded-For
client:
limit: 50
windowSeconds: 60
apis:
- identifier: comment_write
path:
expression: regex
value: ^/api/item/\d+/comment$
method: POST
limit: 5
windowSeconds: 60
target: https://gongnomok.com
strategy: 사용 알고리즘 선택identity: 사용자 식별 기준 (IP, 헤더 등)apis: 엔드포인트별 처리율 정책target: 허용된 요청의 전달 대상
정책 변경 시 YAML만 수정하면 즉시 반영되도록 설계했습니다.
구현시 주요 고려 사항
- Redis Sorted Set 활용: 이동 윈도우 로깅 알고리즘에서 타임스탬프 정렬 및 범위 삭제를 효율적으로 처리
- TTL 기반 자동 청소(
ExpireSecodns): 오래된 버킷 데이터는 자동 삭제되어 메모리 낭비 방지 - HTTP 헤더로 피드백 제공:
X-RateLimit-Remaining: 남은 요청 수X-RateLimit-Reset: 제한 리셋까지 남은 시간X-RateLimit-Retry-After: 재요청 대기 시간
효과와 성과
gate-limiter 도입 이후 비정상 댓글 요청 차단이 정상적으로 동작하였고, 특히 WAS에 도달하지 않아도 차단되는 구조 덕분에 서버 리소스 효율성이 눈에 띄게 향상되었습니다.
부적절한 다수의 요청이 전달되는 상황에서 시스템 자원 효율 부문에서 성과가 있었습니다.
- 부적절한 요청이 WAS에 도달하지 않기 때문에, 평균 CPU 사용률이 약 20~30% 감소
- WAS로 도착하는 총 요청 수가 40~60% 감소
- 요청 거부가 네트워크 레벨에서 처리되므로, 평균 응답 지연이 수 ms 단위로 단축
기술 구조 개선으로 인한 파급 효과도 있었습니다.
- 기존에 존재하던 WAS 내부의 rate limit 로직 제거로, 코드 라인 수가 줄고 유지보수성이 향상됨.
- 기능이 미들웨어로 분리되면서 애플리케이션 코드 수정 없이 정책 변경 가능
- 로직이 공통 미들웨어로 추상화되어, 신규 API 추가 시 동일한 rate limit 정책을 재사용 가능
개발 과정을 돌아보며
이 프로젝트는 단순히 댓글 제한기능을 강화하기 위해서 시작했지만, 인프라 계층에서의 제어와 서비스 안정성 확보라는 주제를 실험해본 경험이였습니다.
직접 구현을 통해 다음을 배웠습니다.
- 네트워크 레벨에서의 요청제어가 애플리케이션 안정성에 미치는 영향
- 알고리즘 선택이 실제 트래픽 패턴에 얼마나 중요한지
- 운영 중 실험 가능한 설정 구조(YAML 기반)의 가치
운영 프로젝트에 gate-limiter를 도입한 이후, 단순히 트래픽 제어 효율이 향상된 것 뿐만 아니라 개발 효율성 전반에도 긍정적인 변화가 있었습니다. 명확한 제한 정책이 미들웨어 계층으로 분리되면서, 애플리케이션 로직은 본래의 비즈니스 기능에만 집중할 수 있게 되었습니다. 이로 인해 팀 내 코드의 가독성과 일관성이 높아졌고, 신규 기능 개발이나 배포 시 예기치 않은 부하 문제를 걱정할 필요가 줄어들었습니다.
무엇보다도 이러한 전환을 통해 서비스 안정성에 대한 자신감이 커졌습니다. 이제는 갑작스러운 트래픽 급증 상황에서도 사용자 경험이 저하되지 않고, 장애 대응도 훨씬 단순해졌습니다. 향후 시스템 확장을 고려한 이번 설계를 기반으로, 유연하면서 안정적인 기술 선택을 지속해 나갈 예정입니다.





