728x90

사내에서 LLM 에이전트를 프로덕션에 올려본 사람이면 한 번쯤 겪는 상황이 있다. 로컬에서 프롬프트 몇 개 던져보면 기가 막히게 잘 답하는데, 실제 서비스에 붙여놓고 세션이 길어지면 갑자기 바보가 된다. 그러면 팀에서 나오는 얘기는 대체로 이렇다. "모델을 더 좋은 걸로 바꾸자", "프롬프트를 더 잘 써보자".

Dev.to에 올라온 "Your Agent Doesn't Have a Reasoning Problem, It Has a Memory Problem"은 정확히 이 지점을 찌른다. 저자가 만든 멀티 에이전트 게임(Horcrux Hunt)에서 에이전트에게 사람이 직접 다듬은 완벽한 컨텍스트를 주면 95% 이상 최적 판단을 했는데, 실제 게임 승률은 23%였다는 것이다. 추론 능력은 멀쩡했고, 문제는 그 모델에게 무엇을 기억시켜서 넣어주느냐였다.

왜 지금 이 얘기가 중요한가

인프라 하는 입장에서 보면 이건 사실 익숙한 문제다. 애플리케이션은 멀쩡한데 캐시 무효화를 잘못해서 stale 데이터를 뿌리는 상황, 로그가 너무 많아서 정작 필요한 에러 라인을 못 찾는 상황과 구조가 똑같다. 다만 LLM에서는 이 문제가 돈과 레이턴시로 즉시 환산된다는 게 차이다.

원문에서 관찰된 실패 유형은 네 가지로 정리된다. 이 분류가 꽤 실용적이라 그대로 옮긴다.

  • Stale Memory — 12턴에 받은 긍정 신호를 25턴에도 믿고 행동. 그 사이 상대가 위치를 바꿔놨다. 캐시 TTL 안 걸어둔 것과 같다.
  • Retrieval Failure — 8턴에 "여긴 비었음"이라는 신호가 컨텍스트 안에 분명히 있었는데, 그 뒤 4,800토큰에 파묻혀서 모델이 못 찾음. 6,000토큰 안에서 20토큰짜리 신호는 사실상 없는 것과 같았다.
  • Memory Overload — 40턴쯤 컨텍스트가 6,000토큰이 되니 생성에 25초. Lambda 타임아웃 30초에 걸려서 게임이 통째로 죽음.
  • Decay Problem — 3턴의 중요한 신호가 40턴에도 유효했는데, 시간 가중치나 관련도 스코어링 없이 그냥 시간순으로 쌓기만 하니 노이즈에 묻힘.

네 개 다 "추론 실패로 위장한 메모리 아키텍처 문제"다. 그리고 세 번째 항목 — Lambda 타임아웃 — 은 완전히 인프라 담당자 티켓으로 떨어지는 종류의 장애다.

핵심: 컨텍스트를 3계층으로 쪼갠다

원문이 제시하는 해법은 메모리를 비용이 다른 세 계층으로 분리하는 것이다. 인프라 하는 사람한테는 L1 캐시 / 애플리케이션 연산 / 영구 스토리지 3단 구조로 보면 바로 이해된다.

Layer 3  Event Store (DynamoDB)   — 전부 저장, 원가 ¢     → LLM에 절대 안 들어감
   ↓ 계산
Layer 2  Computation (Python)     — 확률/엔트로피 계산, 0 토큰
   ↓ 압축 주입
Layer 1  Context Window           — 지금 이 판단에 필요한 것만, $$$

포인트는 Layer 3는 절대 컨텍스트 윈도우에 직접 들어가지 않는다는 것. 전체 이력은 DynamoDB에 TTL 걸어서 쌓아두되, LLM한테는 Layer 2가 계산해낸 결과값만 준다.

Layer 2의 핵심은 신념(belief) 갱신 로직이다. 원문 코드를 최소한으로 다듬으면 이렇다.

class BeliefMap:
    def __init__(self, locations):
        self.beliefs = {loc: 1.0 / len(locations) for loc in locations}
        self.last_updated = {loc: 0 for loc in locations}

    def update(self, location, signal, turn):
        if signal == "positive":
            self.beliefs[location] *= 3.0
        elif signal == "negative":
            self.beliefs[location] *= 0.1
        elif signal == "destroyed":
            self.beliefs[location] = 0.0
        self.last_updated[location] = turn

        # 오래된 신념은 감쇠 — 그 사이 상황이 바뀌었을 수 있다
        for loc in self.beliefs:
            if self.last_updated[loc] < turn - 10:
                self.beliefs[loc] *= 0.7
        self._normalize()

여기서 *= 0.7 감쇠가 바로 캐시 TTL의 확률 버전이다. Stale Memory 문제를 코드 두 줄로 막는다. 이 계산은 파이썬으로 수십 마이크로초, 토큰 비용 0원이다. 같은 판단을 "50턴치 서사를 읽고 추론해줘"로 LLM에 맡기면 토큰과 레이턴시를 다 지불하게 된다.

그리고 Layer 1에 주입되는 건 이렇게 압축된다.

# Before: 50턴 서사 나열 → 2,000+ 토큰, $0.015, 8초
# After: 55 토큰, $0.0002, 1초
"Horcrux likely at: Hogwarts (34%), Azkaban (22%), Ministry (18%).
 Entropy: 1.4 bits (medium confidence). Budget: 3 actions remaining.
 Ron available in 2 turns. Last signal: negative @ Godric's Hollow."

97% 압축인데 왜 손실 압축이 통하느냐. 원문의 설명이 정확하다. 확률분포가 충분통계량(sufficient statistic)이기 때문이다. Hogwarts가 34%라면, 그게 3턴의 긍정 신호에서 왔든 20턴간 부정 신호가 없어서 왔든 판단에는 영향이 없다. 의사결정에 필요한 정보는 이미 확률 안에 다 들어있다.

엔트로피로 호출 자체를 게이팅한다

여기서 한 발 더 나간 게 재밌다. 모든 판단이 같은 비용을 쓸 필요는 없다는 것.

def decide(belief_map):
    h = calculate_entropy(belief_map)   # Shannon entropy, bits

    if h < 1.0:            # 확신 있음 → LLM 안 부름
        return heuristic(belief_map.top_target())        # 0 토큰
    elif h < 2.5:          # 애매함 → 압축 컨텍스트
        return llm_decide(compress_to_55_tokens(belief_map))
    else:                  # 진짜 어려움 → 투자할 가치 있음
        return llm_decide(build_full_context(belief_map))  # 200-500 토큰

원문 기준으로 전체 판단의 약 35%가 엔트로피 1.0 미만 구간에 들어가서 토큰 0원으로 처리됐다. 45%는 55토큰, 나머지 20%만 200~500토큰을 쓴다. 확률이 이미 한 곳에 쏠려 있는데 "그래도 LLM한테 한번 물어볼까"는 그냥 낭비라는 얘기다.

인프라 용어로 번역하면 이건 불확실성 기반 캐시 히트다. 우리가 CDN 앞단에서 "정적 리소스는 오리진까지 안 간다"고 판단하는 것과 같은 결정을, LLM 호출에 대해 하는 것. 참고로 엔트로피가 낮아도 10%는 일부러 다른 선택을 하는 ε-greedy 탐색을 넣어서 패턴 고착을 막았다고 한다.

실무 관점: 도입할 때 실제로 부딪히는 것들

1. "요약 압축"과 "상태 압축"을 헷갈리지 마라

가장 흔한 착각이다. 대화가 길어지면 보통 "지난 대화를 LLM으로 요약해서 넣자"로 간다. 그런데 이건 Layer 1으로 Layer 1 문제를 푸는 것이라 비용이 안 줄고, 요약할 때마다 정보가 비가역적으로 뭉개진다. 요약의 요약의 요약을 5번 하면 원본과 무관한 텍스트가 된다.

원문 방식은 다르다. 자연어를 요약하는 게 아니라 구조화된 상태(확률분포, 카운터, 플래그)를 코드로 유지하고, 그걸 렌더링해서 넣는다. 상태는 언제든 Layer 3에서 재계산 가능하니까 손실이 누적되지 않는다. 이게 핵심 차이다.

실무 적용 예를 들면 — 고객상담 에이전트라면 대화 전문 대신 이런 걸 유지한다.

{
  "intent": "refund_request",       "intent_confidence": 0.82,
  "verified_identity": true,
  "order_ids": ["A-1024"],
  "attempted_solutions": ["reship_declined"],
  "escalation_score": 0.4,
  "turns": 14
}

14턴 대화 원문이 3,000토큰이라면 이건 60토큰 남짓이다. 그리고 intent가 바뀌면 attempted_solutions를 감쇠시키는 로직(위의 *= 0.7)을 넣으면 stale 문제도 같이 해결된다.

2. 흔한 함정: 타임아웃은 항상 컨텍스트가 최대일 때 터진다

원문의 3번 실패 모드가 바로 이거다. 컨텍스트가 커질수록 생성 시간이 늘어나는데, 로컬 테스트는 짧은 세션으로만 하니까 절대 안 걸린다. 프로덕션에서 장시간 세션이 쌓이는 시점에 갑자기 터진다.

Lambda에서는 이런 식으로 나온다.

$ aws logs tail /aws/lambda/agent-orchestrator --since 10m

2024-XX-XX 14:22:31 START RequestId: 8f3a... Version: $LATEST
2024-XX-XX 14:23:01 2024-XX-XXT14:23:01.442Z 8f3a... Task timed out after 30.02 seconds
2024-XX-XX 14:23:01 END RequestId: 8f3a...
2024-XX-XX 14:23:01 REPORT RequestId: 8f3a... Duration: 30020.11 ms
    Billed Duration: 30000 ms  Memory Size: 512 MB  Max Memory Used: 214 MB

여기서 Max Memory Used: 214 MB가 함정이다. 메모리는 널널하니까 "메모리 문제 아니네" 하고 넘어가는데, 실제 원인은 컨텍스트 토큰 수에 비례한 LLM 응답 지연이다. Lambda 메모리를 올려도 안 고쳐진다. 컨텍스트를 줄여야 고쳐진다.

API Gateway를 앞에 뒀다면 그쪽 상한(기본 29초로 알려져 있으나 최신 설정값은 AWS 공식 문서 확인 필요)에 먼저 걸려서 이렇게 나오기도 한다.

{"message": "Endpoint request timed out"}
HTTP/1.1 504 Gateway Timeout

대응은 두 갈래다. 근본 대응은 컨텍스트 압축, 임시 대응은 동기 요청을 끊고 SQS/Step Functions로 비동기 전환 + 스트리밍 응답. 다만 비동기로 도망치면 토큰 비용은 그대로 남는다는 걸 잊지 말자.

3. 벡터 스토어를 만능으로 보지 마라

"컨텍스트가 넘친다 → RAG 붙이자 → 벡터 DB"는 반사신경처럼 나오는 답인데, 원문의 2번(Retrieval Failure)과 4번(Decay) 실패 모드는 벡터 검색만으로는 안 풀린다.

  • 임베딩 유사도는 최신성을 모른다. 12턴의 신호와 25턴의 신호가 의미상 비슷하면 둘 다 똑같이 잘 검색된다. 시간 가중치를 스코어에 직접 섞거나, 메타데이터 필터로 turn/timestamp 범위를 걸어야 한다.
  • 애초에 계산으로 얻을 수 있는 값은 검색할 이유가 없다. 확률분포처럼 결정론적으로 산출되는 상태를 벡터로 저장하고 유사도 검색으로 꺼내오는 건, RDB에 있는 값을 Elasticsearch 전문검색으로 찾는 것만큼 어색하다.

정리하면 계층별 도구 선택은 이렇게 나뉜다. 확정적 상태(카운터, 플래그, 확률, 잔여 예산)는 Redis 같은 세션 스토어, 전체 감사 로그와 재계산 소스는 DynamoDB/RDB에 TTL, 비정형 참조 지식(문서, 과거 유사 케이스)만 벡터 스토어. 하나로 다 하려다 셋 다 어정쩡해진다.

4. 계측 없이 압축하면 사고 난다

손실 압축은 "무엇을 버려도 되는지" 확신이 있을 때만 안전하다. 원문 사례에서는 확률분포가 충분통계량이라는 근거가 명확했다. 우리 서비스에서 그게 성립하는지는 별개 문제다.

그래서 도입 순서는 반드시 이래야 한다. 먼저 턴별 토큰 수와 레이턴시를 로깅해서 어디서 폭발하는지 본다. 그다음 압축 버전과 원본 버전을 같은 입력으로 돌려 판단이 갈리는 케이스를 세어본다(shadow 비교). 마지막에 전환한다. 순서를 뒤집으면 "왜 이번 달부터 에이전트가 이상하죠?" 소리를 듣는다.

728x90
728x90

1. 왜 지금 MCP 로드맵을 봐야 하나

MCP(Model Context Protocol)를 처음 붙였을 때 느낌은 대체로 비슷하다. "어, 생각보다 별거 없네." JSON-RPC 기반으로 tools/list 받아오고 tools/call 던지면 끝이다. 사내 위키 검색, Jira 티켓 조회 정도 붙이는 데는 반나절이면 충분하다.

문제는 그다음이다. 실제로 운영에 넣어보면 이런 것들이 터진다.

  • Terraform plan/apply를 도구로 노출했더니 3분 넘게 걸리는 호출에서 커넥션이 끊긴다.
  • 배포 승인 같은 사람 개입(human-in-the-loop)이 필요한 작업을 요청·응답 한 번으로 표현할 방법이 없다.
  • 에이전트가 사내 API를 호출하는데, 그 요청의 주체가 누구인지 감사 로그에서 구분이 안 된다. 전부 서비스 계정 하나로 찍힌다.
  • 팀별로 MCP 서버를 만들다 보니 툴이 300개를 넘어갔고, tools/list 응답만 수만 토큰이 되면서 모델이 엉뚱한 툴을 고르기 시작한다.

이번 MCP 로드맵이 짚은 지점이 정확히 여기다. 요약에 따르면 앞으로 수개월간 장시간 실행되는 에이전트 작업, 에이전트 신원·권한 위임, 대규모 Tool 탐색을 핵심 과제로 잡고, 요청·응답 중심 구조를 넘어 서버 주도 이벤트, Webhook·Channel, 실행 중 작업 제어를 강화하겠다는 것이다.

이건 사실상 "MCP를 RPC 라이브러리에서 분산 작업 시스템 프로토콜로 확장하겠다"는 선언이다. 우리가 지난 15년간 메시지 큐, 워크플로 엔진, OAuth 인가 서버로 풀어온 문제들이 그대로 올라와 있다. 그래서 인프라 하는 사람이 볼 값어치가 있다.

참고: 아래 내용 중 구체적인 메시지 스키마·필드명은 확정 명세가 아니라 설계 개념을 설명하기 위한 예시다. 실제 필드명과 릴리스 일정은 공식 명세 문서 확인 필요.

2. 핵심 개념: 요청·응답을 넘어서면 뭐가 달라지나

장시간 작업 = "티켓 발급" 패턴

비유하자면 지금까지의 MCP tool call은 편의점 계산대다. 물건 내밀면 바로 결제되고 끝. 반면 장시간 작업은 세탁소에 가깝다. 옷 맡기면 접수증(task id)을 주고, 나중에 찾으러 가거나 "다 됐어요" 문자를 받는다.

HTTP 세계에서 이미 표준화된 패턴이 있다. 202 Accepted + Location 헤더로 상태 조회 URL을 주는 방식이다. MCP도 결국 비슷한 모양으로 갈 것으로 보인다. 개념적으로는 이렇다.

// 1) 툴 호출 → 즉시 완료 대신 작업 접수
{"jsonrpc":"2.0","id":7,"result":{
  "task":{"id":"task_9f3a","status":"working","pollInterval":5}
}}

// 2) 상태 조회 (개념 예시)
{"jsonrpc":"2.0","id":8,"method":"tasks/get","params":{"taskId":"task_9f3a"}}
{"jsonrpc":"2.0","id":8,"result":{
  "status":"input_required",
  "message":"prod 클러스터 apply 승인이 필요합니다"
}}

// 3) 실행 중 취소
{"jsonrpc":"2.0","id":9,"method":"tasks/cancel","params":{"taskId":"task_9f3a"}}

여기서 인프라 관점의 핵심은 상태를 어디에 두느냐다. task id를 발급한 순간부터 MCP 서버는 stateless가 아니다. 서버 인스턴스 3대를 ALB 뒤에 두고 있는데 task 상태를 프로세스 메모리에 들고 있으면, 다음 폴링이 다른 인스턴스로 가는 순간 끝이다.

$ curl -s -X POST https://mcp.internal/rpc \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":8,"method":"tasks/get","params":{"taskId":"task_9f3a"}}'

{"jsonrpc":"2.0","id":8,"error":{"code":-32602,"message":"Unknown task: task_9f3a"}}

이 에러를 스테이징에서 만나면 십중팔구 스티키 세션이 없거나 상태 저장소가 로컬인 경우다. Redis나 DB로 빼고, task 레코드에 TTL을 반드시 걸어라. 걸지 않으면 취소되지 않은 좀비 task가 쌓여서 결국 메모리나 테이블이 부푼다.

서버 주도 이벤트: 폴링을 걷어낸다

로드맵이 Webhook과 Channel을 언급한 건, 폴링만으로는 감당이 안 되기 때문이다. 에이전트 10개가 각각 5초마다 20개 task를 폴링하면 초당 40 RPS가 아무 일도 안 하면서 나간다. 게다가 폴링은 클라이언트가 살아있을 때만 동작한다. 배치성 에이전트가 잠들어 있는 동안 작업이 끝나면 알 방법이 없다.

세 가지 전달 방식의 트레이드오프를 정리하면 이렇다.

방식장점대가
폴링구현 단순, 방화벽 무관지연·낭비, 클라이언트 상시 기동 필요
SSE/스트리밍 채널실시간, 연결 유지 중엔 최고LB 타임아웃·재연결·resume 처리 필요
Webhook클라이언트가 죽어도 도착수신 엔드포인트 필요, 서명 검증·재시도·멱등성 필수

Webhook을 쓰기로 했다면 세 가지는 무조건 챙겨야 한다. 서명 검증(HMAC), 멱등키(같은 이벤트 두 번 와도 안전하게), 재시도 시 지수 백오프. 이건 MCP라서 특별한 게 아니라 Stripe·GitHub Webhook 다뤄본 사람이면 몸에 익은 것들이다. 그대로 가져오면 된다.

# 수신 측 서명 검증 (FastAPI 예시)
import hmac, hashlib
from fastapi import Request, HTTPException

SECRET = b"whsec_..."

async def verify(req: Request) -> bytes:
    raw = await req.body()
    sig = req.headers.get("x-mcp-signature", "")
    ts  = req.headers.get("x-mcp-timestamp", "")
    expect = hmac.new(SECRET, ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expect, sig):
        raise HTTPException(401, "signature mismatch")
    return raw

헤더 이름은 구현체마다 다르니 공식 명세 확인 필요하지만, 구조는 어디나 같다. 타임스탬프를 서명 대상에 포함시켜 리플레이를 막는 것까지가 세트다.

신원과 권한 위임: "누가 이 API를 부른 건가"

이게 로드맵에서 가장 실무적으로 아픈 부분이다. 지금 대부분의 사내 MCP 서버는 이렇게 생겼다.

[사용자] → [LLM 에이전트] → [MCP 서버] --(서비스 계정 토큰)--> [사내 API]
                                          ↑ 여기서 신원이 증발한다

사내 API 감사 로그에는 svc-mcp-bot만 찍힌다. 김대리가 요청한 건지 박과장이 요청한 건지 모른다. 보안팀이 이걸 발견하는 순간 프로젝트는 멈춘다. 실제로 겪어봤다.

제대로 하려면 토큰 교환(RFC 8693 Token Exchange) 개념이 필요하다. 사용자의 토큰을 MCP 서버가 받아서, 다운스트림 API용 토큰으로 교환하되 스코프는 좁히는 방식이다.

$ curl -s -X POST https://idp.internal/oauth2/token \
  -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
  -d subject_token="$USER_ACCESS_TOKEN" \
  -d subject_token_type=urn:ietf:params:oauth:token-type:access_token \
  -d audience=https://api.internal/deploy \
  -d scope="deploy:read deploy:plan" \
  -u "mcp-server:$CLIENT_SECRET"

{"access_token":"eyJhbGciOi...","issued_token_type":"urn:ietf:params:oauth:token-type:access_token",
 "token_type":"Bearer","expires_in":300,"scope":"deploy:read deploy:plan"}

발급된 토큰의 클레임에는 원 사용자(sub)와 대리 주체(act, actor)가 함께 들어간다. 다운스트림 API는 "박과장을 대신해 mcp-server가 호출함"을 그대로 로깅할 수 있다. 감사 요건이 여기서 해결된다.

여기에 더해 스코프를 툴 단위로 쪼개는 설계가 필요하다. 실수하기 쉬운 게 MCP 서버 전체에 하나의 스코프를 주는 것이다. 그러면 읽기 툴 하나 쓰려고 쓰기 권한까지 딸려온다. 최소한 read / write / destructive 세 등급으로는 나누고, destructive 계열은 사람 승인 없이는 실행 불가로 강제하는 게 안전하다. 앞서 말한 input_required 상태가 이 지점에서 쓰인다.

수천 개 Tool: 카탈로그를 검색 문제로 바꾸기

툴이 300개 넘어가면 tools/list를 통째로 컨텍스트에 넣는 방식은 무너진다. 토큰도 문제지만 더 큰 문제는 정확도다. 이름이 비슷한 툴 40개 중에서 모델이 맞는 걸 고르길 기대하는 건 무리다.

해법 방향은 결국 두 단계다.

  1. 계층화: 카테고리/네임스페이스 단위로 먼저 좁히고, 그 안에서 툴 목록을 편다. infra.k8s.*, infra.terraform.*, biz.jira.* 식으로 접두어 규칙을 강제하면 이후에 뭘 하든 편해진다.
  2. 검색형 탐색: 툴 설명을 임베딩해 두고 "지금 하려는 일"에 맞는 상위 N개만 노출한다. 사실상 툴 카탈로그를 RAG로 다루는 것이다.

여기서 자주 하는 실수. 툴 목록이 매 호출마다 바뀌면 캐시가 다 깨진다. 프롬프트 캐싱을 쓰고 있다면 툴 정의 블록은 안정적으로 유지되는 게 유리하다. 세션 시작 시점에 툴 세트를 고정하고, 정말 필요할 때만 notifications/tools/list_changed 같은 변경 알림으로 갱신하는 편이 낫다. 매번 동적으로 셔플하면 캐시 히트율이 바닥을 친다.

3. 실무 관점: 도입 전 체크리스트와 흔한 함정

흔한 함정 1 — LB/프록시 타임아웃

SSE나 장시간 스트리밍을 붙이면 거의 반드시 만나는 게 이거다.

upstream timed out (110: Connection timed out) while reading upstream,
client: 10.0.3.41, server: mcp.internal, request: "POST /rpc HTTP/1.1"

클라이언트 쪽에서는 이렇게 보인다.

McpError: MCP error -32001: Request timed out
    at Client._onresponse (.../client/index.js)

nginx면 proxy_read_timeout, proxy_buffering off, ALB면 idle timeout을 손봐야 한다. 다만 타임아웃을 늘리는 건 임시방편이다. 근본 해법은 장시간 작업을 task로 분리해서 커넥션 수명과 작업 수명을 떼어놓는 것이고, 로드맵이 가려는 방향도 그쪽이다.

흔한 함정 2 — 인증 실패를 툴 에러로 삼키기

MCP 서버가 다운스트림 401을 그냥 텍스트로 감싸서 모델에게 돌려주면, 모델은 "권한이 없나 보다" 하고 다른 툴로 우회를 시도한다. 이건 위험하다. 인증/인가 실패는 프로토콜 레벨 에러로 명확히 올리고, 재인증 플로우로 유도해야 한다.

{"jsonrpc":"2.0","id":12,"error":{
  "code":-32603,
  "message":"Unauthorized",
  "data":{"reason":"insufficient_scope","required":"deploy:apply",
          "authorizeUrl":"https://idp.internal/authorize?..."}}}

필드 구조는 구현마다 다르니 공식 명세 확인 필요. 중요한 건 "모델이 알아서 판단하게 두지 말고, 클라이언트가 처리할 수 있는 신호로 내려보내라"는 원칙이다.

흔한 함정 3 — 멱등성 없는 재시도

Webhook 재시도, 에이전트 자동 재시도, 사용자 수동 재시도가 겹치면 같은 배포가 세 번 돈다. 상태 변경 툴에는 클라이언트가 생성한 멱등키를 파라미터로 받아라. 서버는 그 키로 24시간 정도 결과를 캐싱하고, 같은 키면 실행 없이 이전 결과를 돌려준다. 이 한 줄이 사고를 몇 번 막아준다.

운영 체크리스트

  • 관측성: trace id를 사용자 → 에이전트 → MCP 서버 → 다운스트림 API 전 구간에 전파. 툴 이름·소요시간·토큰 사용량을 span attribute로. OpenTelemetry 붙여두면 나중에 "어떤 툴이 제일 자주 실패하나"를 바로 본다.
  • 레이트 리밋: 에이전트는 사람보다 훨씬 빠르고 집요하다. 서버 전체 + 사용자별 + 툴별 3계층으로. 특히 destructive 툴은 별도로 조여라.
  • 타임아웃 계층: 툴 실행 타임아웃 < MCP 요청 타임아웃 < LB idle 타임아웃 순서가 지켜지는지 확인. 역전되면 원인 파악이 지옥이 된다.
  • 킬 스위치: 툴 단위로 즉시 비활성화하는 플래그. 사고 났을 때 서버 전체를 내리지 않고 문제 툴만 끄는 수단이 반드시 필요하다.
  • 버전 고정: MCP 명세가 활발히 움직이는 중이다. 클라이언트/서버 SDK 버전을 lock 하고, 업그레이드는 스테이징에서 프로토콜 버전 네고 로그부터 확인한 뒤에.

지금 당장 해야 하나?

솔직히 말하면, 로드맵 항목들은 아직 움직이는 표적이다. 명세가 확정되기 전에 예상 스키마에 맞춰 구현하면 나중에 다 뜯어야 한다. 대신 프로토콜과 무관한 부분은 지금 해두면 손해가 없다.

  • task 상태를 외부 저장소로 빼기 (어차피 필요)
  • 툴 네이밍 규칙과 read/write/destructive 등급 정하기
  • 토큰 교환 기반 위임 구조 설계 (IdP 쪽 작업이라 MCP와 독립)
  • trace 전파와 툴별 메트릭

반대로 Webhook 수신 엔드포인트, Channel resume 로직처럼 명세에 직접 묶인 것은 확정 릴리스를 기다리는 편이 낫다.

4. 정리

한 줄 요약: MCP가 "함수 호출 프로토콜"에서 "분산 작업 프로토콜"로 넘어가는 중이고, 그 과정에서 우리가 이미 아는 큐·워크플로·OAuth 문제들이 그대로 재등장한다.

누가 언제 봐야 하냐면 —

  • 사내 에이전트 플랫폼을 설계 중이라면 지금. 상태 저장 위치와 권한 위임 구조는 나중에 바꾸기가 정말 어렵다.
  • MCP 서버를 이미 몇 개 굴리고 있다면 지금. 툴 네이밍과 권한 등급 정리는 서버가 5개일 때 하는 것과 50개일 때 하는 것의 난이도가 다르다.
  • PoC 단계라면 급하지 않다. 짧은 요청·응답 툴 몇 개로 가치를 먼저 증명하고, 위 내용은 "다음 단계에 이런 게 온다" 정도로 알아두면 충분하다.

개인적으로 가장 중요하다고 보는 건 권한 위임이다. 나머지는 기술적으로 풀면 되지만, 신원이 증발하는 구조는 보안 리뷰 한 번에 프로젝트를 통째로 멈추게 만든다. 아키텍처 다이어그램 그릴 때 "이 화살표에 실린 신원은 누구인가"를 화살표마다 적어보길 권한다. 빈칸이 나오는 곳이 나중에 터질 곳이다.

참고 자료

728x90

+ Recent posts