728x90

GeekNews에 OAuth를 제대로 이해하고 쓰고 있는지 되묻는 팟캐스트가 올라왔다. 주제는 새롭지 않다. 소셜 로그인 붙여본 사람이면 다 아는 얘기다. 그런데 막상 "인가 코드를 왜 따로 받아요?", "PKCE는 SPA만 쓰는 거 아닌가요?" 물어보면 답이 갈린다. 나도 몇 년간 "라이브러리가 알아서 해주니까"로 넘겼던 구간이 있었고, 사내 SSO를 직접 붙이면서 리다이렉트 URI 검증 하나 때문에 반나절을 날린 뒤에야 스펙을 제대로 읽었다.

이 글은 그 삽질을 정리한 것이다. 프로토콜 교과서 요약이 아니라, 실제로 어디서 터지고 왜 터지는지에 초점을 맞춘다.

1. OAuth는 인증이 아니라 인가다 — 구성 요소부터 다시

가장 많이 하는 오해가 "OAuth 로그인"이라는 표현이다. OAuth 2.0은 인가(Authorization) 프레임워크다. "이 앱이 내 구글 드라이브 파일 목록을 읽어도 된다"를 위임하는 프로토콜이지, "이 사람이 누구인지"를 증명하는 프로토콜이 아니다. 사용자 신원 확인은 그 위에 얹은 OpenID Connect(OIDC)의 영역이고, 그때 나오는 게 ID Token(JWT)이다.

이 구분이 실무에서 왜 중요하냐면, 액세스 토큰으로 사용자를 식별하려다 사고가 나기 때문이다. 액세스 토큰은 "이 요청이 어떤 권한 범위를 가지냐"를 담을 뿐, 그 토큰이 어느 클라이언트에게 발급됐는지 검증하지 않고 신원 근거로 쓰면 다른 앱에서 받은 토큰을 그대로 던져서 남의 계정으로 들어가는 시나리오가 열린다. 이게 흔히 말하는 confused deputy 문제다. 신원이 필요하면 OIDC의 ID Token을 쓰고, aud(audience)가 내 클라이언트 ID인지 반드시 확인해야 한다.

구성 요소는 네 개다. 비유하자면 호텔이다.

  • Resource Owner (사용자) — 방 주인. 권한의 원천이다.
  • Client (앱) — 방 청소를 대행하러 온 업체. 주인의 허락이 있어야 들어간다.
  • Authorization Server (인가 서버) — 프런트 데스크. 주인 확인 후 카드키를 발급한다.
  • Resource Server (자원 서버) — 객실 문. 카드키만 확인하고 열어준다. 주인이 누군지는 모른다.

핵심은 자원 서버가 사용자 비밀번호를 절대 모른다는 점이다. 카드키(액세스 토큰)는 유효기간이 있고, 특정 층(scope)만 열리고, 분실해도 재발급으로 무효화된다. 비밀번호를 앱에 직접 주는 방식(그래서 Resource Owner Password Credentials Grant는 OAuth 2.1에서 제거됐다)과 비교하면 차이가 명확하다.

2. 왜 코드와 토큰을 두 번에 나눠 받나 — Front/Back Channel

Authorization Code Grant의 흐름을 압축하면 이렇다.

[1] 브라우저 → 인가 서버   GET /authorize?response_type=code
                          &client_id=my-app&redirect_uri=https://app.example.com/callback
                          &scope=openid profile&state=xyz
                          &code_challenge=E9Me...&code_challenge_method=S256
[2] 사용자 로그인 + 동의
[3] 인가 서버 → 브라우저   302 Location: https://app.example.com/callback?code=AUTH_CODE&state=xyz
[4] 앱 백엔드 → 인가 서버  POST /token  (code + client_secret + code_verifier)
[5] 인가 서버 → 앱 백엔드  { "access_token": "...", "refresh_token": "...", "expires_in": 3600 }

여기서 [1]~[3]이 Front Channel, [4]~[5]가 Back Channel이다. 이 구분이 전부라고 봐도 된다.

Front Channel은 브라우저 리다이렉트를 타고 흐른다. 즉 URL에 실린다. URL은 브라우저 히스토리에 남고, Referer 헤더로 새나가고, 프록시/WAF/CDN 접근 로그에 그대로 찍히고, 사용자가 주소창을 복사해서 슬랙에 붙일 수도 있다. 여기에 액세스 토큰을 실으면 그 토큰은 사실상 여러 군데에 복사본이 남는다.

그래서 Front Channel로는 일회용·단기 인가 코드만 흘린다. 코드는 그 자체로 아무 리소스에도 접근할 수 없다. 실제 토큰은 서버끼리 직접 통신하는 Back Channel(TLS로 보호되는 서버-투-서버 POST)에서만 오간다. 이 채널에서는 client_secret으로 클라이언트 신원까지 확인한다.

비유하자면 인가 코드는 "택배 수령 번호"다. 남이 봐도 번호만으로는 물건을 못 받는다. 신분증(client_secret)까지 있어야 창구에서 물건(토큰)을 준다. 반면 Implicit Grant는 프런트 채널에 물건 자체를 던지는 방식이었고, 그래서 OAuth 2.1에서 제거 대상이 됐다.

인가 코드에는 세 가지 방어가 걸린다. (1) 짧은 수명 — RFC 6749는 최대 10분을 권고하고 실제 구현체들은 보통 1분 내외로 잡는다. (2) 일회성 — 한 번 교환되면 즉시 폐기. (3) 재사용 감지 시 해당 코드로 발급된 토큰까지 함께 무효화. 3번을 구현 안 한 인가 서버가 은근히 있으니 자체 구현할 땐 꼭 챙겨야 한다.

redirect_uri 검증이 무너지면

공격자가 redirect_uri를 자기 서버로 바꿔치기할 수 있으면 위 설계가 통째로 무너진다. 코드가 공격자 서버로 배달되니까. 그래서 인가 서버는 사전 등록된 URI와 완전 일치(exact match)로 비교해야 한다. 부분 일치나 와일드카드를 허용하면 오픈 리다이렉트와 조합돼서 뚫린다.

실무에서 제일 자주 보는 에러가 이거다.

Error 400: redirect_uri_mismatch
The redirect URI in the request, http://localhost:3000/callback,
does not match the ones authorized for the OAuth client.

원인 대부분은 사소하다. 끝의 슬래시 유무(/callback vs /callback/), http/https 차이, 로컬 개발 시 localhost127.0.0.1 혼용, 그리고 ALB/Nginx 뒤에서 X-Forwarded-Proto를 안 넘겨줘서 앱이 스킴을 http로 조립하는 경우다. 마지막 케이스가 특히 악질인데, 로컬에선 되고 스테이징에 올리면 깨진다.

# Nginx 리버스 프록시 뒤 Spring Boot에서 흔한 원인
location / {
    proxy_pass http://app:8080;
    proxy_set_header Host              $host;
    proxy_set_header X-Forwarded-Proto $scheme;   # 이거 빠지면 http로 조립됨
    proxy_set_header X-Forwarded-Host  $host;
    proxy_set_header X-Forwarded-Port  $server_port;
}

Spring Boot라면 여기에 server.forward-headers-strategy=framework(또는 native)를 함께 켜야 실제로 반영된다. 프레임워크별 설정 키는 버전마다 다르니 각 공식 문서 확인 필요.

3. PKCE는 왜 모두의 필수가 되었나

PKCE(Proof Key for Code Exchange, RFC 7636)는 원래 모바일 앱을 위해 나왔다. 네이티브 앱은 client_secret을 안전하게 못 숨긴다(APK 뜯으면 나온다). 그리고 커스텀 스킴 리다이렉트(myapp://callback)는 같은 스킴을 등록한 악성 앱이 가로챌 수 있다. 코드를 탈취당하면 시크릿이 없어도 토큰 교환이 되어버린다.

PKCE의 아이디어는 단순하다. 클라이언트가 매 요청마다 랜덤 문자열 code_verifier를 만들고, 그 SHA-256 해시를 code_challenge로 인가 요청에 실어 보낸다. 토큰 교환 때 원본 verifier를 제시한다. 인가 서버는 해시가 맞는지 확인한다. 코드만 훔친 공격자는 verifier를 모르니 교환에 실패한다.

# PKCE 값 직접 만들어보기 (bash)
code_verifier=$(openssl rand -base64 60 | tr -d '\n=+/' | cut -c1-64)
code_challenge=$(printf '%s' "$code_verifier" \
  | openssl dgst -sha256 -binary \
  | openssl base64 | tr '+/' '-_' | tr -d '=')

echo "verifier : $code_verifier"
echo "challenge: $code_challenge"
verifier : Xk2pQ7mLdR9vNzTfA3sB8cJyH5gW1eU0iO6rP4nMqK
challenge: p3sVYw-Kk9c1nA2fRb7ZQxL0dEmT8uJhGvNyI4oS6XU

주의할 점: base64url이라 +-, /_로 치환하고 패딩 =는 제거해야 한다. 이걸 안 해서 invalid_grant 나는 케이스를 여러 번 봤다.

{
  "error": "invalid_grant",
  "error_description": "PKCE verification failed: code_verifier does not match code_challenge"
}

또 하나 흔한 함정은 verifier 저장 위치다. SPA에서 리다이렉트 왕복 사이에 verifier를 어딘가 보관해야 하는데, 이걸 메모리에만 두면 새로고침이나 새 탭 리다이렉트에서 날아간다. sessionStorage가 현실적인 절충안이고, 백엔드가 있다면 서버 세션에 담는 BFF(Backend for Frontend) 패턴이 더 낫다.

OAuth 2.1은 이 PKCE를 퍼블릭 클라이언트뿐 아니라 컨피덴셜 클라이언트에도 요구하는 방향으로 정리됐다. "서버에 secret 있는데 왜?"라는 반문이 나오는데, PKCE가 막는 건 시크릿 유출이 아니라 인가 코드 주입(authorization code injection) 공격이기 때문이다. 공격자가 자기 세션에서 얻은 코드를 피해자 브라우저에 주입해 피해자 계정에 자기 계정을 연결시키는 식의 시나리오는 시크릿이 있어도 성립한다. PKCE는 "이 코드를 요청한 브라우저 세션"과 "이 코드를 교환하는 세션"이 같은지를 묶어준다.

OAuth 2.1이 정리하는 주요 변경 사항은 대체로 이렇게 알려져 있다. Implicit Grant와 Password Grant 제거, PKCE 필수화, redirect_uri 완전 일치 요구, 베어러 토큰을 쿼리 스트링에 싣는 것 금지. 다만 2.1은 여전히 드래프트 단계를 거치는 중이라 최종 문서의 정확한 문구와 상태는 공식 문서 확인 필요.

4. 실무 트러블슈팅과 아키텍처 선택

리프레시 토큰 로테이션의 함정

퍼블릭 클라이언트에 리프레시 토큰을 줄 때는 로테이션이 권장된다. 리프레시할 때마다 새 리프레시 토큰을 발급하고 기존 것은 폐기한다. 폐기된 토큰이 다시 쓰이면 "탈취됐다"고 보고 해당 토큰 패밀리 전체를 무효화한다.

여기서 실제로 터지는 게 동시 요청 레이스다. 탭 3개가 동시에 만료된 액세스 토큰으로 API를 호출하면 세 개의 리프레시 요청이 동시에 나간다. 하나만 성공하고 나머지 둘은 이미 폐기된 토큰을 쓴 게 되어 패밀리 전체가 무효화되고, 사용자는 아무 이유 없이 로그아웃된다.

{
  "error": "invalid_grant",
  "error_description": "Refresh token has been revoked (token reuse detected)"
}

대응은 두 갈래다. 클라이언트 쪽에서는 리프레시 요청을 단일 프로미스로 묶어 큐잉한다(모든 대기 요청이 하나의 리프레시 결과를 공유). 서버 쪽에서는 짧은 유예 시간(grace period)을 둬서 방금 로테이션된 토큰의 재사용은 몇 초간 허용하는 구현이 많다. 이 유예 지원 여부는 IdP마다 다르니 도입 전 확인해야 한다.

// 리프레시 단일화 — 프런트에서 가장 자주 필요한 패턴
let refreshing = null;

async function getAccessToken() {
  if (isExpired(token)) {
    refreshing ??= fetch('/auth/refresh', { method: 'POST', credentials: 'include' })
      .then(r => {
        if (!r.ok) throw new Error('refresh failed');
        return r.json();
      })
      .finally(() => { refreshing = null; });   // 성공/실패 무관하게 해제
    token = await refreshing;
  }
  return token.access_token;
}

게이트웨이에서 토큰 검증

API 게이트웨이나 서비스 메시에서 JWT를 검증할 때 자주 빠뜨리는 게 있다. iss, aud, exp는 대부분 챙기는데 알고리즘 고정을 안 한다. 검증 라이브러리가 헤더의 alg를 그대로 신뢰하면 alg: none이나 HS256 혼동 공격 여지가 생긴다. 허용 알고리즘을 화이트리스트로 못박아야 한다.

JWKS 캐싱도 함정이다. 인가 서버가 키를 로테이션했는데 게이트웨이가 JWKS를 무기한 캐싱하면 전 서비스가 401을 뱉는다. 반대로 캐시 없이 매 요청 JWKS를 조회하면 인가 서버가 죽는다. 보통 캐시 TTL을 두되, 모르는 kid가 오면 즉시 한 번 재조회하는 방식(rate limit 걸어서)이 정석이다.

# 발급받은 토큰 검사 — 디버깅 첫 단계
curl -s https://idp.example.com/.well-known/openid-configuration | jq '.issuer, .jwks_uri, .token_endpoint'

# 페이로드만 빠르게 까보기 (검증 아님, 디버깅 전용)
echo "$ACCESS_TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq .
"https://idp.example.com"
"https://idp.example.com/.well-known/jwks.json"
"https://idp.example.com/oauth2/token"

{
  "iss": "https://idp.example.com",
  "a
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