본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 25. 17:11

MCP 속도 제한 (Rate Limiting): 세 번의 운영 환경 차단 사례를 통한 교훈

요약

MCP(Model Context Protocol) 서버 운영 중 발생한 속도 제한(Rate Limiting) 설계 오류와 세 차례의 운영 환경 차단 사례를 다룹니다. 일반적인 HTTP API와 다른 MCP의 상호작용 모델과 동시 요청 특성을 이해하고 올바른 제한 전략을 세우는 법을 설명합니다.

핵심 포인트

  • MCP 클라이언트는 스트리밍을 위해 연결을 유지하며 다수의 동시 요청을 발생시킴
  • 단순 IP 기반 속도 제한은 MCP의 연결 특성상 부적절할 수 있음
  • 동시 요청(Concurrent Requests)과 커넥션 풀 고갈 문제를 고려해야 함
  • 자동 재시도 기능이 결합될 경우 Thundering Herd 현상이 발생할 수 있음

MCP 속도 제한 (Rate Limiting): 세 번의 운영 환경 차단 사례를 통한 교훈

솔직히 말해서, MCP 서버에 속도 제한 (Rate Limiting)을 추가하는 작업은 지루할 것이라고 생각했습니다. 그냥 라이브러리 하나 가져다 놓고, 제한 사항 몇 개 설정하면 끝나는 일이라고 말이죠. 제가 얼마나 틀렸었는지 모릅니다.

3주 동안 운영 환경 (Production)에서 세 번이나 차단된 후에야, MCP를 위한 속도 제한 (Rate Limiting)은 일반적인 HTTP API의 그것과는 완전히 다르다는 것을 배웠습니다. 상호작용 모델이 다르고, 클라이언트가 기대하는 바가 다르며, 작은 실수 하나가 서버 전체를 매우 혼란스러운 방식으로 망가뜨릴 수 있습니다.

무엇이 차단되었는지, 제가 무엇을 망가뜨렸는지, 그리고 3주간의 고통 끝에 마침내 어떻게 작동하게 만들었는지 설명해 드리겠습니다.

배경 이야기: 왜 속도 제한 (Rate Limiting)이 필요했는가

저는 Papers를 구축해 왔습니다. 이는 모든 것을 MCP 서버를 통해 노출하는 개인 지식 베이스입니다. 즉, 어떤 AI 클라이언트(Claude Desktop, Cursor, ChatGPT)든 직접 연결하여 제가 일일이 복사해서 붙여넣지 않아도 제 노트를 검색하고, 컨텍스트를 가져오고, 새로운 노트를 추가할 수 있습니다.

MCP 서버 구축에 관한 70개 이상의 글을 쓰고 이 시스템을 운영 환경 (Production)에 올린 후, 저는 실제로 사용하기 시작했습니다. 그런데 어떤 일이 벌어졌을까요? 계속 차단되었습니다. GitHub나 OpenAI에 의한 것이 아니라, 바로 _저 자신_에 의해서였습니다. 정확히는, 제가 예상하지 못한 상황에서 여러 개의 동시 요청 (Concurrent Requests)을 보내며 연결을 계속 열어두는 저의 AI 클라이언트 때문이었습니다.

제가 처음에 어떻게 시작했는지, 그리고 무엇이 망가졌는지 보여드리겠습니다.

첫 번째 차단: 너무 많은 동시 요청 (Concurrent Requests)

처음 배포했을 때는 그냥 기본 Spring Boot 설정을 사용했습니다. 속도 제한 (Rate Limiting)도, 연결 제한 (Connection Limiting)도 없었습니다. 저는

  • 10초 동안 15개의 동시 tools/list 요청
  • 동시에 데이터베이스를 타격하는 8개의 동시 tools/call 요청
  • 리버스 프록시 (Reverse Proxy)의 커넥션 풀 고갈 (Connection pool exhaustion)
  • 연결 제한 (Connection limit) 초과로 인한 호스팅 제공업체의 5xx 에러

모든 시스템이 다운되었습니다. 솔직히 말해서, 정말 충격적이었습니다. 서버를 사용하는 건 그냥 '저'뿐이었거든요. 어떻게 이런 일이 일어날 수 있었을까요?

원인은 이랬습니다: MCP 클라이언트는 스트리밍 (Streaming)을 위해 연결을 계속 열어두며, 여러 개의 진행 중인 대화 (Conversations)를 가질 수 있습니다. 각 대화는 연결을 활성 상태로 유지할 수 있습니다. 만약 Claude Desktop + Cursor + Claude Code를 모두 연결해 두었다면... 그 자체로 이미 여러 개의 연결이 있는 것입니다. 그리고 각 연결은 여러 개의 요청을 보낼 수 있습니다.

여기에 타임아웃 (Timeout) 시 자동 재시도 (Automatic retries) 기능까지 더해지면, 자신도 모르는 사이에 천둥 치는 들소 떼 (Thundering herd) 현상을 맞닥뜨리게 됩니다.

두 번째 블록: 잘못된 대상을 속도 제한하다

좋습니다, 그래서 속도 제한 (Rate limiting)을 추가했습니다. 간단하죠? 클라이언트당 요청 수를 계산하는 필터를 넣고, 제한을 넘어서는 것은 차단하면 끝입니다.

저는 Spring Security의 bucket4j 통합 기능을 사용하여 IP당 분당 10개의 요청으로 제한하도록 설정하고 상황을 종료했습니다.

그런데 다시 차단되었습니다. 이번에는 무슨 일이 일어났을까요?

MCP는 스트리밍을 수행합니다. 수명이 긴 스트리밍 응답 (Long-lived streaming responses) 말이죠. 하나의 tools/call이 10초 이상 콘텐츠를 스트리밍할 수 있습니다. 만약 '요청 수'를 기준으로 속도 제한을 건다면, 모든 연결 수와 메모리를 잡아먹는 동시 실행 중인 장기 스트림 (Long-running streams)의 범람을 여전히 막을 수 없습니다.

저는 각각 15초씩 걸리는 8개의 동시 스트리밍 요청을 받았고, 이는 여전히 제 서버를 다운시켰습니다. 속도 제한은 '얼마나 많은 요청이 있는지'는 세지만, '그 요청들이 얼마나 오래 연결되어 있는지' 또는 '얼마나 많은 대역폭 (Bandwidth)을 사용하는지'는 세지 않습니다.

그래서 이를 수정했습니다. 동시 연결 제한 (Concurrent connection limiting)을 추가했습니다. 이제 한 번에 X개의 동시 요청만 가질 수 있습니다. 좋습니다.

그런데 '세 번째'로 차단되었습니다.

세 번째 블록: 연결 끊김을 제대로 처리하지 못했다

이 문제는 정말 미묘했습니다.

속도 제한에 걸려 연결을 거부하면 어떤 일이 발생할까요? 일반적인 HTTP API라면 그냥 429 Too Many Requests를 반환하면 되고, 클라이언트가 이를 처리하면 끝입니다. 간단하죠.

하지만 MCP는 지속적인 스트리밍 연결 (persistent streaming connection)을 통해 JSON-RPC를 사용합니다. 만약 JSON-RPC 핸드셰이크 (handshake)가 완료되기도 전에 HTTP 레벨에서 연결 (connection) 자체를 거부한다면... 클라이언트는 JSON-RPC 에러 응답을 받지 못합니다. 그저 연결이 끊어질 뿐입니다.

그렇다면 클라이언트는 연결이 끊겼을 때 무엇을 할까요? 바로 재시도 (retry)를 합니다.

그래서 저는 다음과 같은 루프에 빠졌습니다:

  1. 클라이언트 연결 → HTTP 레벨에서 거부 → 클라이언트 연결 끊김 → 클라이언트 재시도 → 재연결 → 거부 → 재시도 → 반복
  2. 이는 서버를 여전히 다운시키는 '천둥 치는 들소 떼 (thundering herd)' 현상의 재시도 폭풍을 만들어냅니다.
  3. 왜냐하면 모든 실패한 연결 시도가 여전히 연결 슬롯 (connection slot)을 소비하기 때문입니다.

제가 이 실수를 저질렀다는 게 믿기지 않았습니다. 벌써 세 번째입니다.

좋습니다, 처음부터 다시 제대로 시작해 봅시다.

최종 해결책: MCP를 위한 계층적 속도 제한 (Layered Rate Limiting)

3주 동안 차단당한 끝에, 저는 MCP에서 실제로 작동하는 세 가지 계층의 속도 제한 (rate limiting) 방식을 찾아냈습니다:

  1. HTTP 레벨의 동시 연결 제한 (Concurrent connection limiting) — 수락할 활성 연결 (active connections)의 수를 제한합니다.
  2. JSON-RPC 레벨의 요청 속도 제한 (Request rate limiting) — 클라이언트당 요청 수를 제한합니다.
  3. 적절한 에러 처리 (Proper error handling) — 연결을 끊는 대신 JSON-RPC 에러를 전송합니다.

코드를 보여드리겠습니다. 이 코드는 모두 운영 환경에서 테스트되었으며, 여러분의 Spring Boot MCP 서버에 바로 적용할 수 있습니다.

1단계: 동시 연결 필터 (Concurrent Connection Filter)

먼저, 수락할 활성 동시 연결 (active concurrent connections)의 수를 제한해야 합니다. 이는 요청이 처리되기도 전에 발생하는 '천둥 치는 들소 떼' 현상으로부터 서버를 보호합니다.

@Component
public class ConnectionCountFilter extends OncePerRequestFilter {

...

이것은 간단하지만, 제가 이전에 저질렀던 핵심적인 실수는 본문(body) 없이 그냥 429를 반환했다는 점입니다. 적절한 JSON-RPC 에러 메시지를 보내면 클라이언트가 맹목적으로 재시도하는 대신 우아하게(gracefully) 이를 처리할 수 있습니다.

2단계: Bucket4j를 이용한 요청당 속도 제한 (Per-Request Rate Limiting)

다음으로, 클라이언트별 속도 제한 (Rate Limiting)이 필요합니다. 분산 속도 제한 (Distributed Rate Limiting)을 위해 Redis와 함께 Bucket4j를 사용하지만, 단일 인스턴스를 실행 중이라면 인메모리 (In-memory) 방식으로 처리할 수 있습니다.

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class McpRateLimitingFilter extends OncePerRequestFilter {
...

인메모리 속도 제한 (개인용 서버에 충분함):

@Configuration
public class BucketConfig {
    // 1분 동안 활동이 없을 경우 기본 토큰 만료
...

3단계: 우아한 성능 저하 (Graceful Degradation)를 통한 적절한 타임아웃 (Timeout) 처리

제가 예상하지 못했던 한 가지는 MCP 도구 (Tools)가 실행되는 데 시간이 걸릴 수 있다는 점입니다. 많은 컨텍스트 (Context)를 가져와야 하는 검색의 경우 10초 이상 걸릴 수도 있습니다. 타임아웃 설정이 너무 짧으면 요청이 절반만 완료된 상태가 되고, 클라이언트가 재시도(Retry)를 하게 되어 중복 작업이 발생합니다.

타임아웃을 적절히 처리하기 위해 이 필터를 추가했습니다:

@Component
public class McpTimeoutFilter extends OncePerRequestFilter {

...

그리고 커넥터 (Connector)가 비동기 (Async)를 허용하도록 설정하십시오:

@Configuration
public class MvcConfig implements WebMvcConfigurer {

...

4단계: 전체 그림 — 필터 순서 (Filter Ordering)

순서가 중요합니다! 먼저 연결 수 (Connection Count)를 확인한 다음, 속도 제한을 적용하고, 그 후에 요청을 처리해야 합니다. 이렇게 하면 어차피 거절할 요청에 리소스를 낭비하기 전에 조기에 차단할 수 있습니다.

1. ConnectionCountFilter (먼저 동시 연결 수 확인)
   → 제한 초과 시, JSON-RPC 에러와 함께 즉시 거절
2. McpRateLimitingFilter (요청 속도 제한 확인)
...

제가 실수했던 부분: 이 접근 방식의 장단점

자, 솔직해집시다. 이 방식이 완벽하지는 않습니다. 무엇이 잘 작동하고 무엇이 그렇지 않은지 말씀드리겠습니다.

장점 ✅

  1. 실제로 천둥 치는 들소 떼 (Thundering Herd) 현상을 막아줍니다 — 이 3단계 접근 방식을 추가한 이후로 다시 차단된 적이 없습니다. 3주가 지난 지금도 여전히 안정적으로 작동하고 있습니다.
  2. 클라이언트가 의미 있는 에러 메시지를 받습니다 — 단순히 연결이 끊기는 대신 "속도 제한 초과 (rate limit exceeded)" 메시지를 받게 되므로, 클라이언트는 무한 재시도를 하는 대신 사용자에게 해당 메시지를 보여줄 수 있습니다.
  3. 단순합니다 — 개인용 서버를 운영하는 데 거창한 분산 시스템 (distributed stuff)은 필요하지 않습니다. 인메모리 속도 제한 (In-memory rate limiting)만으로도 충분히 잘 작동합니다.
  4. 즉시 적용 가능한 코드 (Drop-in code) — 이 세 가지 필터를 그대로 복사해서 프로젝트에 붙여넣기만 하면 바로 작동합니다. 제가 직접 해봤으니, 여러분도 할 수 있습니다.

단점 ❌

  1. IP 기반 속도 제한은 완벽하지 않습니다 — NAT 뒤에 있는 경우, 동일한 IP를 사용하는 여러 클라이언트가 제한을 공유하게 됩니다. 개인용 서버라면 괜찮지만, 다중 사용자 (multi-user) 환경이라면 API 키 기반으로 속도 제한을 걸어야 합니다.
  2. 토큰 버스트 (Token bursting)가 없습니다 — 제가 사용하는 탐욕적 재충전 (greedy refill) 방식은 완전한 버스트를 허용하며, 이는 개인적인 용도로는 제가 원하는 방식입니다. 더 엄격한 제한이 필요하다면 다른 재충전 전략 (refill strategy)을 사용해야 합니다.
  3. 메시지 크기를 제한하지 않습니다 — 대역폭을 잡아먹는 거대한 페이로드 (payloads) 공격을 여전히 받을 수 있습니다. 이것이 문제가 된다면 크기 검사 (size checking) 기능도 추가해야 합니다. 제 개인적인 용도로는 아직 필요하지 않았습니다.

실제 수치: 개인용 MCP 서버에 적합한 설정

실험을 거친 끝에, 저에게 잘 맞는 설정값은 다음과 같습니다:

  • 최대 동시 연결 수 (Max concurrent connections): 20 — 3~4개의 AI 클라이언트가 동시에 연결되어 클라이언트당 여러 요청을 보내기에 충분한 수치입니다. 혼자 사용하는 서버라면 이 정도면 아주 넉넉합니다.
  • 속도 제한 (Rate limit): 분당 60회 요청 — 각 요청은 tools/list 또는 tools/call이므로, 분당 60회는 사람이 사용하기에는 필요 이상으로 많지만, 통제 불능의 재시도가 서버를 죽이는 것을 방지하기에는 충분히 낮은 수치입니다.
  • 타임아웃 (Timeout): 5분 — 도구 (tools)가 복잡한 쿼리를 실행하더라도 충분한 시간을 제공합니다. 실제로 이 타임아웃에 걸린 적은 없지만, 멈춰버린 요청 (hung requests)이 영원히 남아있는 것을 방지하기 위해 설정해 두었습니다.

다중 사용자 MCP 서버라면 무엇을 다르게 할 것인가?

만약 이것이 여러 사용자가 있는 공개 서버였다면, 저는 몇 가지 사항을 변경했을 것입니다:

  1. IP가 아닌 API 키별로 속도 제한 (Rate limit) — 각 사용자에게 개별적인 속도 제한 버킷 (Rate limit bucket)을 할당합니다. MCP는 API 키 인증을 지원하므로 이 방식이 자연스럽게 적용됩니다.
  2. 사용자당 동시 연결 제한 (Concurrent connection limit) 하향 — 전체 20개가 아닌 사용자당 10개로 제한합니다.
  3. 탐욕적 재충전 (Greedy refill) 대신 토큰 버킷 (Token bucket)을 사용한 엄격한 속도 제한 — 한 사용자가 모든 리소스를 독점하는 것을 방지합니다.
  4. 요청 크기 제한 (Request size limiting) 추가 — 극도로 큰 요청이 대역폭을 잠식하는 것을 방지합니다.
  5. 모니터링 및 알림 (Monitoring and alerts) — 속도 제한이 빈번하게 작동할 때 알림을 받도록 설정하여, 사용자가 화를 내기 전에 제한 값을 조정할 수 있도록 합니다.

마치며: MCP 속도 제한은 로켓 과학처럼 어렵지는 않지만, 기존과는 다릅니다

솔직히 저는

이 기사는 개인 지식 베이스를 위한 MCP 서버 구축 시리즈의 일부입니다. 실제 운영 환경에서 사용 중인 이 모든 필터들을 포함한 전체 코드는 GitHub 프로젝트에서 확인하실 수 있습니다.

AI 자동 생성 콘텐츠

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

원문 바로가기
0

댓글

0