728x90

사내에서 "에이전트 붙이자"는 말이 나오면 저는 요즘 반사적으로 한 가지를 묻는다. "그거 플로우차트 그릴 수 있어요?" 그릴 수 있으면 에이전트가 아니라 파이프라인이다. 이 질문 하나로 회의 시간이 40분 줄어든 경험이 여러 번 있다.

Dev.to에 올라온 James Anderson의 글 "Most 'AI Agents' Are Just If-Statements in a Trench Coat"이 정확히 그 얘기를 한다. 저자는 플래너, 툴, 리즈닝 루프까지 갖춘 에이전트를 만들어 데모에서 박수를 받았다. 그리고 프로덕션에 올렸더니 느리고, 비싸고, 재현 안 되는 방식으로 깨졌다. 화요일과 수요일에 같은 입력이 다르게 동작했고, 장애 원인은 자기가 통제하지도 관측하지도 못한 상류의 "자율적 결정" 세 개였다.

결국 그는 지루한 선형 파이프라인으로 다시 썼다. 고정 스텝, 리즈닝 루프 없음. 그리고 중요한 모든 축에서 더 나아졌다 — 더 빠르고, 더 싸고, 테스트 가능하고, 디버깅 가능해졌다. 그러고 나서 옛 "에이전트"의 로그를 보니 매 실행마다 똑같은 세 스텝을 밟고 있었다. Extract, transform, respond. 자율성을 단 한 번도 쓰지 않았다. 그가 만든 건 시스템 프롬프트를 얹은 for 루프였다.

인프라 담당자 입장에서 이 글이 중요한 이유는 감성적 공감 때문이 아니다. 자율성이 비용이라는 점, 그리고 그 비용이 청구서에 항목별로 찍힌다는 점을 명확하게 짚기 때문이다.

경계선은 딱 하나다: 제어 흐름을 누가 정하는가

원문의 정의는 단순하고, 저는 이보다 나은 정의를 아직 못 봤다.

  • 에이전트: 런타임에 모델이 자기 제어 흐름을 결정한다. 어떤 툴을 부를지, 다음 스텝이 무엇인지, 다시 루프를 돌지, 언제 멈출지를 모델이 본 것에 따라 동적으로 고른다.
  • 파이프라인: 제어 흐름을 설계 시점에 내가 고정한다. 스텝 1, 2, 3. 매번 같은 경로. LLM은 각 스텝 안에서 일하지만 스텝을 고르지는 못한다.

여기서 사람들이 자주 건너뛰는 부분: 고정된 스텝 안에서 LLM이 똑똑한 일을 하는 건 자율성이 아니다. 필드 추출, 티켓 분류, 요약 생성 — 그냥 LLM을 쓰는 것이다. 똑똑한 함수 호출이다. 자율성은 모델에게 핸들을 넘겼을 때만 발생한다.

대부분의 "에이전트"는 핸들을 넘기지 않는다. 고정된 경로를 유창한 자연어로 서술하고, 그 서술을 "추론"이라고 부른다.

리트머스 시험지는 이거다. 실행 전에 플로우차트를 그릴 수 있으면 파이프라인이다. 컨텍스트 검색 → 툴 호출 → 응답 포맷팅. 코드 한 줄 쓰기 전에 화이트보드에 그릴 수 있었나? 그럼 모델은 경로를 결정하는 게 아니다. 내가 이미 결정했고, 모델은 각 노드에서 일하면서 결정하는 것처럼 들리게 말하고 있을 뿐이다.

실무에서 이 구분을 코드로 보면 더 선명하다. 아래는 "에이전트"라고 불리던 코드를 파이프라인으로 벗겨낸 형태다.

# before: "에이전트" (실제로는 매번 같은 3스텝)
while not done:
    plan = llm.chat(f"다음에 뭘 해야 해? 상태: {state}")   # 매 턴 토큰 소비
    tool = parse_tool(plan)                                  # 파싱 실패 위험
    state = TOOLS[tool](state)
    done = "FINISH" in plan

# after: 파이프라인 (LLM은 가치 있는 지점에만)
def run(doc: str) -> dict:
    fields  = llm_extract(doc)            # LLM: 구조화 추출
    norm    = normalize(fields)           # 순수 함수, 테스트 가능
    answer  = llm_answer(norm)            # LLM: 문장 생성
    return {"fields": norm, "answer": answer}

after 쪽에서 LLM이 하는 똑똑한 일은 하나도 줄지 않았다. 여전히 추출하고 생성한다. 다만 작업의 구조를 즉흥적으로 만들게 하는 걸 멈췄다. 구조는 애초에 지능이 필요한 부분이 아니었다. 내가 이미 알고 있던 부분이다.

코스튬 값이 청구서에 찍히는 방식

"그래도 동작하는데 뭐가 문제냐"는 반박이 나온다. 문제 되는 사람은 그걸 운영하고, 돈 내고, 새벽 2시에 디버깅하는 사람들이다. 원문이 정리한 항목을 인프라 관점으로 다시 풀어보면 이렇다.

1. 비결정성 → 버그가 재현되지 않는다. 모델이 경로를 고르면 같은 입력이 실행마다 다른 경로를 탄다. "제가 해봤을 땐 됐는데요"가 영구 상태가 된다. 장애 대응 프로세스가 성립하지 않는다.

2. 디버깅 붕괴. 고정 파이프라인이 깨지면 어느 스텝이 실패했는지 안다. 에이전트는 스텝 4에서 내린 결정 때문에 스텝 12에서 깨진다. 코드를 디버깅하는 게 아니라 선택에 대한 포렌식을 하게 된다.

3. 실패 표면의 곱셈. 고정 5스텝 파이프라인은 확인할 게 5개다. 결정 5개를 하는 에이전트는 각 결정이 틀릴 수 있고, 조합으로 틀릴 수 있고, 그 순서가 매 실행 바뀐다.

4. 비용과 지연. 리즈닝 루프는 고정 시퀀스보다 훨씬 많은 모델 호출을 만든다. 생각하고, 다시 생각하고, 반성하고, 또 돌기로 한다. 이미 답을 알고 있던 경로를 모델이 숙고하는 데 토큰당 돈을 낸다.

5. 테스트 불가. 회귀 테스트는 고정된 경로 집합이 있어야 한다. 에이전트는 정의상 그게 없다. 프로덕션에서 자율적 결정을 하는 그 컴포넌트가 동시에 신뢰할 만한 테스트를 쓸 수 없는 컴포넌트다.

인프라 쪽에서 실제로 아픈 지점을 하나 더 붙이자면 멱등성이다. 파이프라인은 스텝별 재시도 정책을 내가 정한다. 스텝 2가 타임아웃 나면 스텝 2만 재시도한다. 에이전트는 루프 안에서 어떤 툴이 몇 번 호출됐는지가 실행마다 다르다. 그 툴이 결제 API나 티켓 생성 API라면? 중복 실행 방어를 에이전트 바깥에 깔아야 한다. 툴 호출 단위 idempotency key를 강제하는 래퍼를 씌우는 게 최소 방어선이다.

# 툴 래퍼: 멱등성 + 호출 상한 + 예산 상한
import hashlib, time

CALL_BUDGET = {"n": 0, "max": 12}
SEEN = {}   # 실제로는 Redis SETNX 권장

def guarded(fn):
    def wrap(**kw):
        CALL_BUDGET["n"] += 1
        if CALL_BUDGET["n"] > CALL_BUDGET["max"]:
            raise RuntimeError("tool call budget exceeded")
        key = hashlib.sha1(f"{fn.__name__}:{sorted(kw.items())}".encode()).hexdigest()
        if key in SEEN:            # 같은 인자 재호출은 캐시 반환
            return SEEN[key]
        SEEN[key] = fn(**kw)
        return SEEN[key]
    return wrap

호출 상한(max)이 없으면 무슨 일이 벌어지는지는 한 번 겪어보면 안다. 루프가 자기 출력을 잘못 파싱해서 같은 툴을 계속 부르고, 토큰 청구서가 조용히 불어난다. 상한은 기능이 아니라 서킷 브레이커다.

실무 도입: 관측성부터 깔고, 자율성은 마지막에 준다

비결정적 시스템을 운영하려면 순서가 있다. 저는 이 순서를 지키지 않아서 두 번 고생했다.

먼저 트레이싱. LLM 호출을 하나의 span으로 잡고, 실행 전체를 trace로 묶는다. 스텝 이름, 모델명, 입력/출력 토큰 수, 재시도 횟수, 그리고 결정 근거를 속성으로 남긴다. 에이전트에서 가장 아쉬운 로그는 "왜 이 툴을 골랐는지"가 없는 로그다. 최소한 모델이 반환한 tool_call 원문은 그대로 저장해야 나중에 리플레이가 된다.

# 스텝 단위 span (OpenTelemetry Python)
from opentelemetry import trace
tracer = trace.get_tracer("llm-pipeline")

with tracer.start_as_current_span("llm.extract") as sp:
    out = llm_extract(doc)
    sp.set_attribute("llm.model", "gpt-4o-mini")
    sp.set_attribute("llm.tokens.in", out.usage.prompt_tokens)
    sp.set_attribute("llm.tokens.out", out.usage.completion_tokens)
    sp.set_attribute("app.step", "extract")

실행 결과 로그는 대충 이런 모양으로 남는다. 스텝별로 지연과 토큰이 분리돼야 어디가 비싼지 보인다.

$ python run_pipeline.py --doc sample.txt
[trace 4f1a...c9] llm.extract   ok   842ms  in=1_204 out=186
[trace 4f1a...c9] normalize      ok     3ms
[trace 4f1a...c9] llm.answer    ok   1_310ms in=402  out=311
[trace 4f1a...c9] TOTAL         ok   2_155ms tokens=2_103

그다음 평가(eval). 파이프라인은 경로가 고정이라 스텝별 골든 데이터셋을 만들 수 있다. 추출 스텝은 필드 정확도, 생성 스텝은 규칙 기반 체크 + 사람 샘플링. 이게 회귀 테스트 역할을 한다. 에이전트로 가면 경로가 달라지니 "최종 결과만" 평가하게 되고, 어디서 틀렸는지는 다시 포렌식이다.

흔한 함정

툴 호출을 자유 텍스트에서 파싱하면 반드시 만난다. 실제로 가장 자주 보는 에러는 이 계열이다.

Traceback (most recent call last):
  File "agent/loop.py", line 88, in parse_tool
    return json.loads(raw)
  File "/usr/lib/python3.11/json/__init__.py", line 346, in loads
    return _default_decoder.decode(s)
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

# 또는 스키마 강제 시
openai.BadRequestError: Error code: 400 - {'error': {'message':
"Invalid schema for response_format 'ToolCall': ... 'additionalProperties'
is required to be supplied and to be false", 'type': 'invalid_request_error'}}

대응은 두 갈래다. (a) 자유 텍스트 파싱을 버리고 function calling / structured output 스키마를 쓴다 — 다만 스키마 제약(필수 필드, additionalProperties 등)이 프로바이더마다 다르니 공식 문서 확인이 필요하다. (b) 파싱 실패 시 재시도 횟수를 못 박고, 초과하면 폴백 경로로 빠진다. 무한 재시도는 위에서 말한 청구서 사고로 직행한다.

② 타임아웃이 중첩된다. 에이전트 루프는 "전체 예산"이 있어야 한다. 툴 호출 하나에 30초 타임아웃을 걸어도 루프가 10번 돌면 5분이다. API 게이트웨이 타임아웃(보통 훨씬 짧다)에 먼저 잡혀서 클라이언트는 504를 받고, 백엔드 루프는 계속 돌면서 토큰을 태운다. 요청 단위 deadline을 만들어 컨텍스트로 내려보내고, 남은 시간이 없으면 루프를 즉시 종료해야 한다.

③ "if문 3개짜리 분기"를 에이전트라고 부르기. 원문 표현대로, 케이스 세 개짜리 if문은 그냥 switch 붙은 파이프라인이다. 진짜 자율성이 필요한 건 미리 열거할 수 없을 만큼 분기 공간이 큰 경우다. 분기를 셀 수 있으면 명시적으로 쓰자. 코드로 쓴 switch는 테스트가 되고, 프롬프트에 숨긴 switch는 안 된다.

④ 컨텍스트가 조용히 커진다. 루프가 돌 때마다 이전 관찰 결과를 프롬프트에 다 넣으면 토큰이 스텝 수에 비례해 늘어난다. 요약 압축이나 슬라이딩 윈도우를 안 넣으면 어느 순간 컨텍스트 초과 에러로 실패하고, 그 실패는 입력 데이터 길이에 따라 들쭉날쭉해서 재현이 어렵다.

그럼 언제 진짜 에이전트인가

원문도 "에이전트는 항상 나쁘다"고 하지 않는다. 자율성이 값을 하는 경우를 셋으로 정리한다.

  • 스텝을 미리 알 수 없을 때. 열린 조사, 탐색, 원인 모르는 문제 디버깅. 플로우차트 자체가 발견 대상이라 그릴 수가 없다.
  • 진짜 멀티홉. "찾고, 찾은 것의 정체에 따라 다음을 판단한다." 스텝 1을 돌리기 전엔 스텝 2를 정말로 알 수 없는 경우.
  • 분기가 사실상 무한할 때. 미리 열거하기엔 가능성 공간이 너무 큰 경우.

그리고 이 경우에도 원칙은 자율성 최소화다. 하드코딩할 수 있는 건 다 하드코딩하고, 런타임 결정은 정말 필요한 한 지점에만 남긴다. 원문의 관찰이 인상적인데, 프로덕션에서 실제로 잘 돌아가는 "에이전틱" 시스템을 들여다보면 대개 거의 고정된 파이프라인에 조심스럽게 제약된 결정 지점이 한두 개 있는 형태다. 자유롭게 돌아다니는 리즈닝 루프가 아니다.

왜 그럼 다들 에이전트를 만드냐. 원문의 답이 불편하지만 정확하다. 에이전트는 작업이 아니라 만드는 사람을 위해 만들어진다. 데모가 잘 되고, 진짜 AI처럼 느껴지고, "agentic"은 이력서와 IR 자료에 쓰기 좋은 단어다. "결정론적 파이프라인"으로는 그 신호가 안 나온다. 그 이유 중 어느 것도 내 작업이 에이전트를 필요로 하는지와는 관계가 없다.

정리

한 줄 요약: 플로우차트를 미리 그릴 수 있으면 파이프라인으로 만들어라. 자율성은 정당화해야 하는 비용이다.

누가 언제 써야 하는지 정리하면:

  • 지금 파이프라인으로 가라 — 문서 처리, 분류, 요약, 티켓 라우팅, 정형 추출. 즉 업무 형태를 이미 이해하고 있는 대부분의 백오피스 작업. LLM은 각 스텝 안에서만 쓴다.
  • 제약된 결정 지점 하나만 추가 — 분기 조건을 규칙으로 못 쓰겠고, 케이스가 열 개 안팎으로 셀 수 있을 때. 단, 분기는 코드에 명시하고 모델은 라벨만 고르게 한다.
  • 진짜 에이전트 — 조사·탐색·원인 규명처럼 경로가 실행 중 발견되는 작업. 이때는 툴 호출 상한, 요청 deadline, 멱등성 키, trace 기반 리플레이를 먼저 깔고 시작한다. 이 네 개 없이 에이전트를 프로덕션에 올리면 새벽 2시에 후회한다.

원문의 마지막 문장을 저도 빌리고 싶다. 데모에서 이기는 시스템보다, 수요일에도 여전히 돌아가는 시스템이 목표였다. 코트를 벗겨보면 안에 있는 게 더 마음에 들 것이다.

참고 자료

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