B200(192GB) 8장 노드는 총 1.5TB의 VRAM과 NVLink를 통한 압도적인 대역폭을 제공한다.

측정에 앞서 모델 버전을 짚고 넘어가야 합니다. 2026년 8월 현재, GLM-5.3은 Zhipu AI(https://chat.z.ai/)의 자체 플랫폼과 API를 통해서만 서비스되고 있으며, Hugging Face에 오픈소스 가중치(Weights)로는 아직 공개되지 않았다.

따라서 사용 가능한 최신 오픈소스 버전이자 B200 8장의 VRAM(총 1.5TB)을 최대로 활용할 수 있는 GLM-5.2 (zai-org/GLM-5.2) 을 사용한다. (참고로 GLM-5 시리즈부터는 Hugging Face 저장소가 THUDM에서 zai-org로 변경되었다.)

Kimi의 경우 최근 공개된 Kimi-K3는 2.8T 파라미터 모델이므로 VRAM 요구량이 B200 8장의 용량을 초과한다. 따라서 동일한 인프라에서 실행 가능한 최적의 차선 버전인 moonshotai/Kimi-K2.6을 사용한다.

원본 가중치와 양자화(AWQ, GPTQ)

대형 언어 모델(LLM)은 수십억~수천억 개의 '파라미터(매개변수)'로 이루어져 있으며, 이 파라미터들이 숫자로 저장된 것이 바로 '가중치'이다.

  • 원본 가중치 (FP16 / BF16): 모델을 처음 학습시켰을 때의 상태 그대로, 1개의 파라미터를 저장하는 데 16비트(2바이트)를 사용한다. 데이터의 소수점 아래 아주 미세한 값까지 정확하게 보존하므로 모델이 낼 수 있는 최고의 추론 능력을 발휘한다.
  • 양자화 가중치 (AWQ, GPTQ, FP8 등): 16비트였던 숫자를 4비트(0.5바이트) 또는 8비트(1바이트)로 압축(반올림/근사치 변환)한 버전이다. 이미지로 비유하면 고해상도(원본) 이미지를 육안으로는 큰 차이가 없는 수준의 중간 해상도(양자화)로 압축해 용량을 줄인 것과 같다. AWQ와 GPTQ는 주로 4비트 정수(INT4)로 압축하는 기술이다.

LLM의 텍스트 생성 속도(TPOT)는 GPU의 '연산 속도'보다 'VRAM에서 GPU 코어로 데이터를 퍼 나르는 대역폭(Memory Bandwidth)'에 의해 결정되는 경우가 대부분이다.

  • 품질 (정확도와 추론력): 압축 과정에서 미세한 수치 손실이 발생하므로 양자화 모델의 지능이 원본 대비 약 1~5% 정도 미세하게 떨어질 수 있다. 복잡한 수학이나 코딩 문제에서 이 차이가 드러날 때가 있다.
  • 속도 (TPOT): 양자화 모델은 용량이 절반 이하로 줄어들었기 때문에, GPU가 VRAM에서 데이터를 읽어오는 속도가 2배 이상 빨라진다. 결과적으로 토큰이 훨씬 빠르게 출력(TPOT 감소)된다.

B200 8장 인프라는 장당 192GB로 총 1536GB(약 1.5TB)의 거대한 VRAM을 자랑한다. 하지만 GLM-5.2(744B)를 서빙할 때는 치명적인 수학적 제약이 발생한다.

  • 원본 가중치(BF16) 적용 시: 744B 파라미터 × 2바이트 = 모델 가중치 용량만 약 1.48TB입니다. 1.5TB의 VRAM에 1.48TB를 욱여넣으면, 사용자 요청의 문맥을 기억할 공간(KV Cache)이 사실상 0에 가까워진다. 이 상태에서는 질문을 조금만 길게 해도 즉시 OOM(Out Of Memory) 에러로 서버가 다운된다.
  • 양자화 가중치 적용 시: AWQ/GPTQ(4비트)를 적용하면 용량이 약 370GB 수준으로, FP8(8비트)을 적용하면 약 744GB 수준으로 줄어든다. 절반 이상의 VRAM(700GB 이상)이 KV Cache 여유 공간으로 남게 되어 수만~수십만 토큰의 긴 문맥도 넉넉하고 매우 빠르게 처리할 수 있다.
 
구분원본 (BF16 / FP16)양자화 (AWQ / GPTQ / FP8)
파라미터당 용량 16비트 (2 Byte) 4비트 (0.5 Byte) ~ 8비트 (1 Byte)
추론 능력 (지능) 100% (손실 없음) 95~99% 수준 유지
생성 속도 (TPOT) 느림 (메모리 병목 발생) 매우 빠름 (대역폭 효율 극대화)
B200 (1.5TB) 적용 가중치만 1.48TB 차지 (OOM 위험 높음) 적극 권장 (VRAM 여유 확보 및 고속 처리)

모델 다운로드 및 실행 (GLM-5.2, Kimi-K2.6)

1. 모델 다운로드 및 로컬 저장

GLM-5.2는 744B 크기의 Mixture-of-Experts(MoE) 모델이므로 다운로드 크기가 매우 크다. 안정적인 로드를 위해 Hugging Face CLI로 로컬 NVMe 스토리지에 미리 다운로드한다.

GLM-5.2-FP8

Kimi-K2.6

# Hugging Face CLI 설치
pip install -U huggingface_hub

# 모델 저장을 위한 디렉토리 생성
mkdir -p /models/ask
cd /models/ask

# GLM-5.2-FP8 다운로드
hf download zai-org/GLM-5.2-FP8 --local-dir ./GLM-5.2-FP8
#    --exclude "*f32*.safetensors" \
#    --exclude "*F32*.safetensors" \
#    --exclude "*bf16*.safetensors" \
#    --exclude "*BF16*.safetensors" \
#    --exclude "*.bin" \
#    --exclude "*.bin.index.json" \
#    --exclude "*.pt" \
#    --exclude "*.pth"

# Kimi-K2.6 다운로드
hf download moonshotai/Kimi-K2.6 --local-dir ./Kimi-K2.6-INT4
#    --exclude "*f32*.safetensors" \
#    --exclude "*F32*.safetensors" \
#    --exclude "*i32*.safetensors" \
#    --exclude "*I32*.safetensors" \
#    --exclude "*.bin" \
#    --exclude "*.bin.index.json" \
#    --exclude "*.pt" \
#    --exclude "*.pth"
 

 

Kimi-K2.6 은 16BF 일까 양자화 된 것일까??

du -sh /models/ask/Kimi-K2.6-INT4
555G   /models/ask/Kimi-K2.6-INT4
 

실제 다운로드된 가중치 파일들은 NVFP4패킹된 INT4(compressed-tensors) 같은 초고압축 포맷으로 구성된 파일들이다. 1조 개가 넘는 파라미터를 가진 Kimi-K2.6 모델이 이 초고압축 양자화를 거치면 디스크 용량이 정확히 550GB ~ 600GB 대역에 형성된다.

아래 config.json 을 보면 알 수 있다.

vi Kimi-K2.6-INT4/config.json

    "quantization_config": {
      ...
      "format": "pack-quantized",
      ...
      "quant_method": "compressed-tensors",
      "quantization_status": "compressed"
    }
 

또는 AWQ, GPTQ 같은 다른 양자화 방식일 경우 해당 이름이 적혀있다.

"quantization_config": {
  "quant_method": "fp8",
  "activation_scheme": "dynamic"
}
 

2. 모델 실행

B200 8장(총 1.5TB VRAM)의 NVLink 대역폭과 텐서 코어(Tensor Core) 가속을 끌어내기 위한 vLLM 실행 명령어와 성능 튜닝 옵션을 정리한다.

GLM-5.2는 가중치와 KV 캐시 모두 FP8로 처리하여 최고의 속도를 내고, Kimi-K2.6은 원본 가중치(BF16)를 유지하되 KV 캐시에 FP8 최적화를 적용하여 품질과 속도의 밸런스를 맞추도록 구성한다.

주요 성능 튜닝 옵션 및 상세 설명

핵심 아키텍처 및 메모리 설정:

  • --tensor-parallel-size 8: 8장의 B200을 하나의 논리적 유닛으로 묶어 텐서 병렬 처리를 수행한다. NVLink를 통해 노드 내 통신 병목을 없앤다.
  • --dtype bfloat16: 모델의 활성화(Activation) 연산 시 기본 데이터 타입을 BF16으로 설정한다. (가중치가 FP8이더라도 중간 연산의 정밀도 유지를 위해 BF16 캐스팅을 사용하는 것이 표준이다.)
  • --gpu-memory-utilization 0.90: 전체 1.5TB VRAM 중 90%(약 1.35TB)를 vLLM이 PagedAttention 알고리즘을 위해 시작 시점에 미리 할당받아 메모리 단편화를 방지한다.
  • --max-model-len 32768: 한 번의 요청에 처리할 수 있는 최대 컨텍스트 길이(입력+출력 토큰)를 제한한다. B200 8장의 VRAM 용량을 넘어서는 무한정 긴 프롬프트가 들어와 전체 서버가 OOM으로 다운되는 것을 방지하는 안전장치이다.
    • 32768 = 2^15 : 16비트에서 부호를 뺀 크기
    • 1. 단어 및 글자 수 기준 (대략적 환산)
      • 영어 기준:
        • 1 토큰 ≈ 약 0.75개 단어
        • 약 24,000 ~ 25,000개 단어 (Word)
      • 한국어 기준 (GLM / Kimi 최신 토크나이저 기준):
        • 1 토큰 ≈ 약 1.2 ~ 1.5글자 (어절/단어 기준 1단어당 1.5 ~ 2개 토큰 소모)
        • 약 15,000 ~ 20,000개 띄어쓰기 단어(어절)
        • 약 40,000 ~ 50,000 글자 (공백 포함)
    • 2. 파일 용량 기준 (kB)
      • 영어 텍스트: 1토큰당 약 4바이트 ➔ 약 128 kB ~ 130 kB
      • 한국어 텍스트: 1토큰당 약 4.5~5바이트 (한글 UTF-8은 문자가 3바이트 차지) ➔ 약 140 kB ~ 160 kB
    • 일반적인 텍스트 파일(.txt, UTF-8 인코딩) 형태로 변환했을 때의 용량이다.
    • 3. 실무 분량 체감 (어느 정도 양인가?)
      • A4 용지 기준: 폰트 10pt, 줄간격 160% 표준 문서 기준 약 50 ~ 60장 분량의 빽빽한 텍스트이다.
      • 소설/책 기준: 단편 소설 1권 전체, 혹은 일반 서적의 2~3개 장(Chapter) 분량 전체를 요약이나 생략 없이 한 번에 입력받거나 출력할 수 있는 수치이다.
    • 따라서 32,768 컨텍스트 길이는 웬만한 수십 페이지 분량의 논문 전체나 대용량 소스코드 파일 여러 개를 통째로 넘겨서 분석시키기에 충분한 용량이다.
  • --trust-remote-code: GLM과 Kimi 등 Hugging Face의 커스텀 모델 코드를 실행하기 위해 반드시 필요한 옵션이다.

속도(TTFT/TPOT) 설정 (B200 특화):

  • --quantization fp8 (GLM 전용): 디스크에서 읽어올 모델 가중치가 FP8 포맷임을 엔진에 알린다. VRAM 로드 속도와 GPU 연산 속도를 비약적으로 높인다.
  • --kv-cache-dtype fp8: (가장 중요한 성능 튜닝) 사용자의 문맥을 기억하는 공간(KV 캐시)을 BF16 대신 FP8로 압축하여 저장한다. 모델 가중치가 BF16(Kimi)이어도 KV 캐시를 FP8로 내리면 메모리 대역폭 소모가 절반으로 줄어들어 동시 처리량(Throughput)이 대폭 상승하고 TPOT가 감소한다. B200은 이를 하드웨어로 처리하여 품질 저하를 거의 발생시키지 않는다.
    • TPOT (Time Per Output Token) = 토큰 1개당 소요 시간
    • Throughput = 1 / TPOT (단일 사용자 기준)
  • --enable-chunked-prefill: 긴 프롬프트(Prefill)가 들어왔을 때 이를 한 번에 연산하지 않고 작은 청크(Chunk)로 쪼개어 생성(Decode) 연산과 번갈아 가며 처리한다. 첫 번째 토큰이 나오는 시간(TTFT)을 줄여 체감 응답 속도를 높인다.

구조 최적화 설정 (GLM-5.2 전용):

  • --speculative-config.method mtp & num_speculative_tokens 3: MTP(Multi-Token Prediction) 투기적 디코딩을 활성화한다. 한 번의 연산으로 다음 토큰을 예측하는 것에 더해, 그다음 3개의 토큰까지 동시에 예측하여 일치할 경우 한 번에 출력합니다. TPOT를 낮추는 기술이다.
  • --reasoning-parser glm45: GLM-5 계열 특유의 '생각하는 과정(Reasoning)' 텍스트 블록을 API가 정상적인 형태로 분리 파싱할 수 있게 해준다.
cd /models/ask

# GLM-5.2 (FP8 양자화 버전) 실행 명령어
python -m vllm.entrypoints.openai.api_server \
    --model /models/ask/GLM-5.2-FP8 \
    --served-model-name glm-5.2-fp8 \
    --tensor-parallel-size 8 \
    --dtype bfloat16 \
    --quantization fp8 \
    --kv-cache-dtype fp8 \
    --gpu-memory-utilization 0.90 \
    --max-model-len 32768 \
    --enable-chunked-prefill \
    --speculative-config.method mtp \
    --speculative-config.num_speculative_tokens 3 \
    --reasoning-parser glm45 \
    --trust-remote-code \
    --port 8000


# Kimi-K2.6 (INT4 양자화 버전) 실행 명령어
python -m vllm.entrypoints.openai.api_server \
    --model /models/ask/Kimi-K2.6-INT4 \
    --served-model-name kimi-k2.6-int4 \
    --tensor-parallel-size 8 \
    --dtype bfloat16 \
    --quantization compressed-tensors \
    --kv-cache-dtype fp8 \
    --gpu-memory-utilization 0.90 \
    --max-model-len 32768 \
    --enable-chunked-prefill \
    --trust-remote-code \
    --port 8000
 
 

모델 실행 시에 triton_kernels.matmu_orgs 가 없다고 에러가 난다면???

ERROR [config.py:29] Failed to import Triton kernels. Please make sure your triton version is compatible. Error: No module named 'triton_kernels.matmul_ogs'
 

vLLM 서버가 켜지고 작동 자체는 할 수 있다. (Triton 커널 로드에 실패하면 PyTorch의 기본 연산으로 대체(Fallback)하여 실행되도록 설계되어 있기 때문이다.)

에러 메시지의 Triton은 OpenAI에서 개발한 파이썬 기반의 GPU 커널 컴파일러이다. GLM 모델과 vLLM은 FP8 양자화나 MoE 모델의 행렬 곱 연산(matmul)을 극단적으로 가속하기 위해 이 Triton으로 작성된 커스텀 코드(triton_kernels.matmul_ogs)를 실행하려 시도한다. 하지만 현재 의 Triton 버전이 너무 낮거나 호환되지 않아 컴파일/임포트에 실패한 것이다.

vLLM 0.26.0 버전부터 대규모 MoE 모델 및 FP8 연산을 극도로 가속하기 위해 OpenAI의 공식 triton_kernels (특히 matmul_ogs 모듈)를 필수로 요구한다. 기본 파이썬 패키지 관리자(pip install triton)로는 이 커스텀 서브 패키지가 함께 설치되지 않기 때문에 수동으로 Github에서 직접 끌어와 설치해 주어야 한다.

vLLM 0.26.0 환경과 B200(Blackwell) 아키텍처의 텐서 코어 조합에 가장 최적화되고 안정적인 Triton 및 Triton Kernel 추천 버전은 3.6.0 이다.

아래의 명령으로 에러를 잡아야 한다.

# triton 버전 설치
pip install -U "triton==3.6.0"

# 특정 버전의 트리톤 커널 설치
pip install "triton_kernels @ git+https://github.com/triton-lang/triton.git@v3.6.0#subdirectory=python/triton_kernels"

# flash-attn 컴파일 (cpu 32개를 사용)
MAX_JOBS=32 pip install -U flash-attn --no-build-isolation -v

# vllm 버전 다시 확인용 설치
pip install vllm==0.26.0


# vLLM의 PyTorch 컴파일 캐시 삭제
rm -rf ~/.cache/vllm/torch_compile_cache

# Triton 커널 컴파일 캐시 삭제
rm -rf ~/.triton/cache
 
 

Step 3: TTFT 및 TPOT 측정 프로그래밍

LLM의 성능을 평가하는 핵심 지표는 다음과 같다.

  • TTFT (Time To First Token): 사용자가 요청을 보낸 시점부터 서버가 '첫 번째 단어(토큰)'를 반환하기까지 걸린 응답 대기 시간.
  • TPOT (Time Per Output Token): 첫 번째 토큰이 나온 이후, 후속 토큰들이 생성될 때 1개당 소요되는 평균 생성 시간.

이를 정확히 측정하려면 OpenAI 호환 API의 스트리밍(Streaming) 방식을 활용하여 서버로부터 쪼개져서(Chunk) 들어오는 데이터의 타임스탬프를 직접 프로그래밍하여 기록해야 한다.

mkdir -p /models/ask/scripts && cd /models/ask/scripts

# 1. 'bench-env'라는 이름으로 가상 환경 생성
python -m venv bench-env

# 2. 가상 환경 활성화 (프롬프트 앞에 '(bench-env)'가 생기면 성공)
source bench-env/bin/activate

# 3. 패키지 관리자(pip) 최신화
pip install --upgrade pip

# 4. 벤치마크 스크립트 구동에 필요한 필수 패키지 설치
# (OpenAI 호환 API 통신을 위한 라이브러리)
pip install openai requests

# 5. 스크립트 실행 (vLLM 서버가 8000번 포트로 띄워져 있어야 한다)
python requests_benchmark.py
 
 

성능 측정 및 데이터 확인용 Python 스크립트 (requests_benchmark.py):

import time
import json
import requests

# vLLM 서버 엔드포인트 주소
API_URL = "http://localhost:8000/v1/chat/completions"

MODEL_NAME = "glm-5.2-fp8" 
PROMPT = "B200 GPU 8장을 활용한 대규모 MoE 모델 병렬 처리 구조에 대해 자세히 설명해줘."

print(f"[{MODEL_NAME}] 모델 성능 측정 시작...")

headers = {"Content-Type": "application/json"}
payload = {
    "model": MODEL_NAME,
    "messages": [{"role": "user", "content": PROMPT}],
    "stream": True,
    "max_tokens": 1024
}

start_time = time.time()
first_token_time = None
output_tokens = 0
full_content = ""

try:
    response = requests.post(API_URL, headers=headers, json=payload, stream=True)
    response.raise_for_status()

    for line in response.iter_lines():
        if line:
            decoded_line = line.decode('utf-8')
            
            if decoded_line.startswith("data: "):
                json_str = decoded_line[6:] # "data: " 제거
                
                if json_str.strip() == "[DONE]":
                    break
                    
                try:
                    chunk = json.loads(json_str)
                    choices = chunk.get("choices", [])
                    
                    if not choices:
                        continue
                        
                    delta = choices[0].get("delta", {})
                    
                    # --- 핵심 수정 부분 ---
                    # 서버가 보내는 "content", "reasoning" 필드를 모두 확인
                    content = delta.get("content") or ""
                    # GLM-5.2는 "reasoning"을 사용하고, 다른 모델은 "reasoning_content"를 쓸 수 있으므로 둘 다 대응
                    reasoning = delta.get("reasoning") or delta.get("reasoning_content") or ""
                    
                    text_chunk = content + reasoning
                    
                    if text_chunk:
                        # 첫 실제 텍스트가 도착한 순간(TTFT) 기록
                        if first_token_time is None:
                            first_token_time = time.time()
                            
                        full_content += text_chunk
                        output_tokens += 1
                        
                        # 진행 상황을 콘솔에 실시간으로 살짝씩 출력 (선택 사항)
                        print(text_chunk, end="", flush=True)
                        
                except json.JSONDecodeError:
                    continue

except Exception as e:
    print(f"\n\n[API 통신 에러 발생]: {e}")
    exit(1)

end_time = time.time()

if first_token_time is None:
    print("\n\n❌ [측정 실패] 데이터를 받지 못했습니다.")
    exit(1)

# --- 지표 계산 ---
ttft = first_token_time - start_time
tpot = (end_time - first_token_time) / (output_tokens - 1) if output_tokens > 1 else 0.0
total_time = end_time - start_time
tokens_per_sec = 1 / tpot if tpot > 0 else 0

# --- 결과 출력 ---
print("\n\n" + "="*50)
print(f"=== {MODEL_NAME} vLLM 성능 측정 결과 ===")
print("="*50)
print(f"총 생성 토큰 수   : {output_tokens} tokens")
print(f"전체 소요 시간    : {total_time:.4f} 초")
print(f"TTFT (첫 토큰)    : {ttft:.4f} 초 (Prefill 지연)")
print(f"TPOT (토큰당 시간): {tpot:.4f} 초/토큰 (Decode 속도)")
print(f"Throughput        : {tokens_per_sec:.2f} tokens/sec")
print("="*50)
 
 

실행 결과

python requests_benchmark.py

==================================================
=== glm-5.2-fp8 vLLM 성능 측정 결과 ===
==================================================
총 생성 토큰 수    : 366 tokens
전체 소요 시간     : 4.3270 초
TTFT (첫 토큰)    : 0.0714 초 (Prefill 지연)
TPOT (토큰당 시간) : 0.0117 초/토큰 (Decode 속도)
Throughput       : 85.77 tokens/sec
==================================================
 
 

성능 측정 및 데이터 확인용 Python 스크립트 (openai_benchmark.py):

vLLM을 실행할 때 --reasoning-parser glm45 옵션을 넣었다. 최신 모델들은 딥시크(DeepSeek-R1)나 o1처럼 최종 답변을 내놓기 전에 혼자 '생각하는 과정(Reasoning)'을 거친다. 이 생각하는 과정에서 나오는 텍스트는 기존의 일반적인 content 필드가 아니라, reasoning 이라는 필드로 서버에서 전송된다. 일반적으로는 content 필드만 바라보고 있었기 때문에 데이터를 받을 수 없다.

이를 해결하려면 reasoningcontent를 모두 수집하도록 작성해야 한다.

import time
from openai import OpenAI

client = OpenAI(
    api_key="EMPTY",
    base_url="http://localhost:8000/v1"
)

MODEL_NAME = "glm-5.2-fp8"      # GLM 측정 시
# MODEL_NAME = "kimi-k2.6-int4"   # Kimi 측정 시
PROMPT = "B200 GPU 8장을 활용한 대규모 MoE 모델 병렬 처리 구조에 대해 자세히 설명해줘."

print(f"[{MODEL_NAME}] 모델 성능 측정 시작...")

start_time = time.time()
first_token_time = None
output_tokens = 0
full_content = ""

try:
    response = client.chat.completions.create(
        model=MODEL_NAME,
        messages=[{"role": "user", "content": PROMPT}],
        stream=True,
        max_tokens=1024
    )

    for chunk in response:
        if not chunk.choices:
            continue
            
        delta = chunk.choices[0].delta
        
        # 1. 일반적인 텍스트가 담기는 필드
        content = getattr(delta, "content", "") or ""
        
        # 2. 생각하는 과정(Reasoning)이 담기는 필드
        reasoning = getattr(delta, "reasoning", "") or ""
        reasoning_content = getattr(delta, "reasoning_content", "") or ""
        
        # 두 데이터를 합쳐서 실제 글자가 하나라도 들어왔는지 판별
        text_chunk = content + reasoning + reasoning_content
        
        if text_chunk:
            if first_token_time is None:
                first_token_time = time.time()
                
            full_content += text_chunk
            output_tokens += 1

            # --- 화면에 순차적으로 실시간 출력 ---
            print(text_chunk, end="", flush=True)

except Exception as e:
    print(f"\n[API 통신 에러 발생]: {e}")
    exit(1)

end_time = time.time()

if first_token_time is None:
    print("\n❌ [측정 실패] 데이터를 받지 못했습니다.")
    exit(1)

# --- 지표 계산 ---
ttft = first_token_time - start_time
tpot = (end_time - first_token_time) / (output_tokens - 1) if output_tokens > 1 else 0.0
total_time = end_time - start_time
tokens_per_sec = 1 / tpot if tpot > 0 else 0

# --- 결과 출력 ---
print("\n" + "="*50)
print(f"=== {MODEL_NAME} 벤치마크 결과 ===")
print("="*50)
print(f"총 생성 토큰 수   : {output_tokens} tokens")
print(f"전체 소요 시간    : {total_time:.4f} 초")
print(f"TTFT (첫 토큰)    : {ttft:.4f} 초")
print(f"TPOT (토큰당 시간): {tpot:.4f} 초/토큰")
print(f"Throughput        : {tokens_per_sec:.2f} tokens/sec")
print("="*50)
print(f"[생성된 텍스트 샘플]\n{full_content[:300]}...\n")
 
 

실행 결과

python openai_benchmark.py

==================================================
=== glm-5.2-fp8 벤치마크 결과 ===
==================================================
총 생성 토큰 수   : 389 tokens
전체 소요 시간    : 4.8275 초
TTFT (첫 토큰)    : 0.3401 초
TPOT (토큰당 시간): 0.0116 초/토큰
Throughput        : 86.46 tokens/sec
==================================================
반응형
Posted by seungkyua@gmail.com
,

새로운 MCP Spec 을 유즈케이스를 포함하여 이해하기 쉽게 정리. 물론 내가 이해하기 쉽게 gemini 의 도움을 받았다.

2026년 7월 28일에 발표된 Model Context Protocol(MCP) 사양 업데이트의 핵심은 '상태 유지(Stateful) 양방향 프로토콜에서 상태 비저장(Stateless) 요청/응답 프로토콜로의 전환'이다.

https://blog.modelcontextprotocol.io/posts/2026-07-28/ 기사에서 설명된 주요 변경 사항들을 바탕으로, 각 케이스별 이전 방식과 바뀐 방식을 유즈케이스로 정리하였다.

1. 핸드셰이크 및 세션 제거 (No handshake or sessions)

이번 업데이트의 가장 큰 변화로, 연결을 유지하는 세션 개념이 완전히 사라졌다.

  • 이전 방식 (Stateful): 클라이언트는 서버와 통신하기 전 반드시 initialize / initialized 핸드셰이크 과정을 거쳐야 했으며, 이후 모든 요청에 Mcp-Session-Id 헤더를 포함하여 세션을 유지해야 했다. 로드밸런서 환경에서는 특정 클라이언트가 동일한 서버 인스턴스에만 붙도록 'Sticky Session' 설정이 필수였다.
  • 바뀐 방식 (Stateless): 세션과 초기화 과정이 폐지되었다. 대신 모든 단일 요청이 독립적이며, 프로토콜 버전, 클라이언트 신원, 권한 등을 _meta 필드와 HTTP 헤더에 담아 보낸다. 아무 로드밸런서(Round-robin)나 거쳐 아무 서버 인스턴스에 요청이 도달해도 정상 처리된다. 서버가 상태를 유지해야 한다면 도구(Tool) 실행 결과로 명시적인 핸들(Handle) 값을 반환하여 클라이언트가 다음 요청 시 전달하게 한다.
// 바뀐 방식의 HTTP 요청 예시
POST /mcp HTTP/1.1
Host: api.example.com
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: search
Content-Type: application/json

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "search",
    "arguments": {
      "q": "2026 company OKRs"
    },
    "_meta": {
      "io.modelcontextprotocol/clientInfo": {
        "name": "my-enterprise-agent",
        "version": "1.0"
      }
    }
  }
}

Mcp-Session-Id에 어떤 정보들이 저장되었고, 왜 그것이 필요했었는지 이해가 안 가는 것은 지극히 정상이다. 특히 클라우드 네이티브와 쿠버네티스 환경에 익숙하다면, '왜 굳이 HTTP 통신에 세션을 유지해서 상태(Stateful)를 만들었지?' 하고 의문을 가지시는 것이 당연하다.

이전의 MCP(Model Context Protocol)는 주로 LLM(AI 에이전트)과 서버 간의 지속적인 대화형 상호작용을 전제로 설계되었기 때문에, 서버 메모리에 특정 세션(현재 AI와의 대화 흐름)에 종속된 임시 상태들을 유지하려 했다.

Mcp-Session-Id를 키(Key)로 삼아 서버 메모리(또는 Redis 등)에 저장해두었던 '특별한 정보'들의 대표적인 예시는 다음과 같다.

유즈케이스 예제:

1. 리소스 구독 및 변경 알림 (Resource Subscriptions)

가장 대표적인 세션 사용 사례이다. AI 에이전트가 특정 파일이나 데이터베이스의 실시간 변경 사항을 추적해야 할 때 사용되었다.

  • 저장된 정보 예: Session-Id: 1234는 현재 github/repo/issue#5 리소스를 구독 중임.
  • 어떻게 썼나: 서버는 백그라운드에서 이슈 업데이트를 모니터링하다가 변경이 발생하면, 메모리에서 해당 리소스를 구독 중인 Mcp-Session-Id를 찾아 열려있는 Streamable HTTP 스트림을 통해 AI에게 알림을 쏴주었다.
  • 문제점: 쿠버네티스 환경에서 파드(Pod)가 재시작되거나 오토스케일링으로 인해 트래픽이 다른 노드로 라우팅되면, 구독 정보(세션)가 날아가서 AI가 더 이상 알림을 받지 못하는 치명적인 단점이 있었다.

2. 다중 단계 작업의 임시 컨텍스트 (Multi-step Temporary Workspace)

AI가 도구를 여러 번 호출하여 복잡한 작업을 수행할 때, 이전 작업의 중간 결과물을 서버가 기억해두는 용도이다.

  • 저장된 정보 예: Session-Id: 5678의 임시 작업 공간(Temp Directory 경로, 또는 인메모리 데이터프레임).
  • 어떻게 썼나:
    1. AI가 "대용량 CSV 파일 다운로드해서 필터링해줘" (데이터 MCP 서버의 도구 A 호출)
    2. 서버는 다운로드 후 필터링한 데이터를 Session-Id: 5678 공간에 임시 저장하고 성공 여부만 반환.
    3. AI가 "그 데이터로 차트 그려줘" (데이터 MCP 서버의 도구 B 호출)
    4. 서버는 요청 헤더의 Session-Id: 5678를 보고 앞서 저장해둔 임시 데이터를 꺼내어 차트를 생성.
  • 상태 비저장(Stateless)으로의 변화: 이제 세션이 없으므로, 도구 A는 결과를 임시 저장한 뒤 handle_id (예: "temp_file_999")를 반환해야 한다. AI는 도구 B를 호출할 때 인자(Argument)로 handle_id: "temp_file_999"를 명시적으로 넘겨주는 방식으로 바뀌었다.

  • Tool Schema (도구 정의 예시)
{
  "name": "draw_chart",
  "description": "이전 작업에서 가공한 데이터로 차트를 그린다.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "workspace_token": { // 개발자가 임의로 정의한 핸들 이름
        "type": "string",
        "description": "이전 데이터 전처리 작업에서 반환받은 임시 토큰값을 넣는다."
      }
    },
    "required": ["workspace_token"]
  }
}

 

3. 페이지네이션 커서 및 순차적 조회 상태 (Pagination & Cursor)

대량의 리소스(예: 수천 개의 로그 라인, 수백 개의 프롬프트 목록)를 여러 번에 걸쳐 가져올 때, '어디까지 읽었는지'를 기억하는 용도이다.

  • 저장된 정보 예: Session-Id: 9012는 logs/error.log의 '1,500번째 라인'까지 읽었음.
  • 어떻게 썼나: AI가 "다음 목록 줘"라고 요청 바디 없이 호출해도, 서버는 세션 ID를 조회하여 1,501번째 라인부터 응답해 주었다.
  • 문제점: 전형적인 안티 패턴이다. RESTful API처럼 커서 토큰(Cursor Token)이나 Offset을 클라이언트가 명시적으로 보내는 것이 맞다. 이번 업데이트로 이 역시 클라이언트가 토큰을 관리하도록 변경되었다.

4. 진행 중인 트랜잭션 관리 (Active Transactions)

데이터베이스의 Transaction이나, 인프라 프로비저닝 시 Lock을 걸어두는 행위를 세션과 묶어 관리했다.

  • 저장된 정보 예: Session-Id: 3456이 DB에 BEGIN TRANSACTION을 걸어두었음.
  • 어떻게 썼나: AI가 특정 데이터를 수정하고 최종 확인(Commit)을 하기 전까지, 세션 ID를 기반으로 해당 DB 커넥션이나 Lock을 유지했다. 만약 클라이언트와의 세션 연결이 끊어지면(네트워크 단절 등), 서버가 이를 감지하고 트랜잭션을 롤백(Rollback)하는 용도로 사용되었다.

클라우드 및 쿠버네티스 인프라를 다루시는 입장에서는 "왜 L7 로드밸런서에서 Sticky Session(Session Affinity)을 강제해야만 했는가?"에 대한 해답이 바로 이 Mcp-Session-Id 때문이었다.

이전 스펙에서는 위와 같은 상태들(구독 정보, 임시 컨텍스트 등)이 특정 서버 파드(Pod)의 메모리에 결합되어 있었기 때문에, 라운드 로빈(Round-robin)으로 다른 파드에 요청이 들어가면 "존재하지 않는 세션"이라며 에러가 발생했다. 이를 해결하려면 별도의 Redis 같은 외부 저장소를 강제해야만 했다.

이번 2026년 7월 28일 업데이트로 이런 암묵적인 세션 의존성을 모두 걷어내고, 상태 관리를 클라이언트에게 명시적으로 위임하거나 응답 데이터 자체에 포함(Self-describing)시킴으로써, 어떤 로드밸런서를 거쳐 어떤 파드에 요청이 떨어져도 동작하는 진정한 클라우드 네이티브 아키텍처로 진화했다고 볼 수 있다.

 

2. 다중 왕복 요청 (Multi Round-Trip Requests, MRTR)

서버가 클라이언트(또는 사용자)에게 역으로 정보를 요청해야 할 때 사용하는 방식이 변경되었다.

  • 이전 방식 (Streams): 서버가 사용자에게 확인을 받거나(Elicitation) 추가 입력을 받아야 할 때, 항상 열려 있는 양방향 스트림(Streamable HTTP 또는 WebSocket)을 통해서만 서버발 요청(elicitation/create 등)을 보낼 수 있었다.
  • 바뀐 방식 (MRTR): Stateless 환경에 맞게 스트림이 불필요해졌다. 서버가 추가 입력이 필요하면 응답으로 resultType: "input_required"를 반환한다. 클라이언트는 사용자에게 입력을 받은 뒤, 원래 보냈던 요청 바디에 inputResponses를 추가하여 재시도(Retry)한다.

유즈케이스 예제: 데이터베이스 테이블 삭제 권한 확인 (Elicitation)

// 1. 클라이언트의 최초 요청 (테이블 삭제)
{
  "method": "tools/call",
  "params": { "name": "drop_table", "arguments": { "table": "users" } }
}

// 2. 서버의 응답 (바뀐 방식: 확인 필요)
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "input_required",
    "requests": [
      {
        "id": "req-123",
        "type": "confirmation",
        "message": "정말로 'users' 테이블을 삭제하시겠습니까?"
      }
    ]
  }
}

// 3. 클라이언트의 재시도 요청 (사용자 승인 완료)
{
  "method": "tools/call",
  "params": {
    "name": "drop_table", 
    "arguments": { "table": "users" },
    "inputResponses": {
      "req-123": { "approved": true }
    }
  }
}

 

3. 헤더 기반 라우팅 (Header-based routing)

API 게이트웨이나 방화벽(WAF)에서의 라우팅 및 제어 방식이 개선되었다.

  • 이전 방식: 라우터나 게이트웨이가 특정 도구(Tool)에 대해 트래픽을 제어(예: 과금, 속도 제한)하려면, 무거운 JSON 바디를 직접 파싱하여 method나 name 값을 찾아내야 했다.
  • 바뀐 방식: 모든 HTTP 요청 헤더에 Mcp-Method 와 Mcp-Name 이 필수로 포함된다. 게이트웨이는 바디를 열어볼 필요 없이 헤더만 보고 라우팅이나 Rate Limiting을 수행할 수 있다.

유즈케이스 예제: 리소스 소모가 큰 이미지 생성 도구에 대한 API 게이트웨이 속도 제한

// 게이트웨이는 아래 헤더만 읽고 "image_generator" 도구에 대해 초당 10회 제한 룰을 즉각 적용할 수 있음
POST /mcp HTTP/1.1
Mcp-Method: tools/call
Mcp-Name: image_generator

 

4. 리스트 결과 캐싱 지원 (List results are cacheable)

목록을 불러오는 API의 효율성이 극대화되었다.

  • 이전 방식: tools/list, prompts/list 등을 호출할 때 캐시 기준이 없어, 클라이언트는 서버에 재연결할 때마다 도구 목록을 처음부터 다시 받아오거나 임의로 자체 캐싱을 해야 했다.
  • 바뀐 방식: 목록 반환 응답에 명시적으로 ttlMs(캐시 유지 시간)와 cacheScope가 포함된다. 클라이언트는 이를 바탕으로 불필요한 재요청을 줄일 수 있다.

유즈케이스 예제: 툴 카탈로그 캐싱

// 서버의 응답 예시
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "tools": [
      { "name": "calculator", "description": "기본 계산기" },
      { "name": "weather", "description": "날씨 조회" }
    ],
    "ttlMs": 3600000,  // 1시간 동안 캐시 유효
    "cacheScope": "public" 
  }
}

 

 

5. 인가(Authorization) 및 보안 강화

보안 취약점 패치 및 OAuth 호환성이 강화되었다.

  • 이전 방식: DCR(Dynamic Client Registration)을 주로 사용했으며, 데스크탑 앱이나 CLI 환경에서 OAuth 진행 시 redirect_uri 오류가 자주 발생했다. 또한 인가 서버 혼동 공격에 취약할 수 있었다.
  • 바뀐 방식: RFC 9207에 따라 iss 파라미터를 반환 및 검증하며, CLI 환경의 리다이렉트 에러 해결을 위해 application_type을 설정한다. 궁극적으로 DCR은 더 이상 권장되지 않으며(Deprecated), 정적인 CIMD(Client ID Metadata Documents) 체계로 전환된다.

엔터프라이즈 환경에서 인증/인가 아키텍처를 설계하실 때 가장 까다로운 부분 중 하나가 표준 OAuth 2.0 스펙과의 호환성 및 보안 취약점 방어이다. 이번 MCP 2026-07-28 업데이트의 인가(Authorization) 섹션은 바로 이 부분을 글로벌 표준 스펙(RFC)에 맞게 끌어올린 것이다.

기사에서 언급된 핵심 변경 사항인 1) 네이티브 앱(CLI/Desktop) 리다이렉트 호환성 강화 2) 인가 서버 혼동 공격(Mix-up Attack) 방어(RFC 9207)를 중심으로, 이전과 바뀐 방식을 상세히 비교했다. (유즈케이스 포함)

강화 포인트 1: CLI/데스크탑 환경의 OAuth 리다이렉트 문제 해결 (application_type)

로컬 환경에서 실행되는 AI 에이전트(CLI나 데스크탑 앱)가 서버의 리소스에 접근하려면 OAuth 2.0 인가 코드(Authorization Code) 흐름을 타야 한다. 이때 코드를 돌려받기 위해 로컬호스트([http://127.0.0.1](http://127.0.0.1))를 리다이렉트 URI로 사용하게 되는데, 엄격한 보안을 적용하는 엔터프라이즈 인가 서버들은 HTTPS가 아니라는 이유로 이를 거부하는 문제가 있었다.

❌ 이전 방식 흐름 (에러 발생)

인가 서버가 해당 클라이언트가 '웹 서버'인지 '로컬 앱'인지 구분하지 못해 발생하는 문제이다.

  1. [AI 에이전트] ➔ [인가 서버]: 동적 클라이언트 등록(DCR) 요청
    • Payload: {"redirect_uris": ["[http://127.0.0.1:8080/callback](http://127.0.0.1:8080/callback)"]}
  2. [인가 서버] ➔ [AI 에이전트]: 🔴 등록 거부 (Error)
    • 사유: "보안 정책 상 redirect_uri는 반드시 https://로 시작해야 합니다."
  3. 결과: 터미널에서 실행되는 AI 에이전트는 OAuth 로그인을 시도조차 할 수 없고 연동이 실패합니다.

✅ 바뀐 방식 흐름 (application_type: "native" 도입)

표준 RFC 8252 (OAuth 2.0 for Native Apps)를 준수하여 클라이언트의 '유형'을 명시한다.

  1. [AI 에이전트] ➔ [인가 서버]: 동적 클라이언트 등록(DCR) 요청
    • Payload: {"redirect_uris": ["[http://127.0.0.1:8080/callback](http://127.0.0.1:8080/callback)"], "application_type": "native"}
  2. [인가 서버] ➔ [AI 에이전트]: 🟢 등록 승인 (Success)
    • 판단 로직: "아, 이 클라이언트는 웹 서버가 아니라 네이티브 앱이구나. 네이티브 앱은 로컬 루프백 주소(127.0.0.1)를 사용하는 것이 표준이므로 허용해 주자."
  3. [사용자 브라우저] ➔ [인가 서버]: 로그인 및 권한 승인 진행
  4. [인가 서버] ➔ [AI 에이전트 (로컬 8080포트)]: 리다이렉트 성공
    • [http://127.0.0.1:8080/callback?code=SplxlOBeZQQYbYS6WxSbIA](http://127.0.0.1:8080/callback?code=SplxlOBeZQQYbYS6WxSbIA)
  5. 결과: AI 에이전트가 코드를 정상적으로 수신하고 엑세스 토큰으로 교환한다.

 

강화 포인트 2: 인가 서버 혼동 공격 방어 (RFC 9207 iss 검증)

클라이언트(AI 에이전트)가 여러 개의 인가 서버(예: 사내 SSO 서버, 외부 파트너사 SSO 등)와 통신할 수 있을 때 발생할 수 있는 보안 취약점(Mix-up Attack)을 원천 차단하는 스펙이다.

악의적인 공격자가 클라이언트를 속여, 클라이언트가 정상적인 서버(A)에 로그인하려고 했으나 실제로는 공격자의 서버(B)로 코드를 보내게 만드는 공격이다.

❌ 이전 방식 흐름 (취약점 존재)

리다이렉트 시 돌아오는 응답에 이 코드를 '누가' 발급했는지에 대한 정보가 없다.

  1. [AI 에이전트] ➔ [사용자 브라우저]: 정상 인가 서버(Trust-IdP)로 로그인 요청 시도. (단, 네트워크나 설정 변조로 인해 악성 서버(Evil-IdP)를 경유하게 됨)
  2. [Evil-IdP] ➔ [Trust-IdP]: 공격자가 클라이언트인 척 Trust-IdP로 요청을 토스함.
  3. [Trust-IdP] ➔ [사용자 브라우저]: 사용자 로그인 완료 후 브라우저를 통해 리다이렉트 시킴.
    • [http://127.0.0.1:8080/callback?code=AuthCode_1234](http://127.0.0.1:8080/callback?code=AuthCode_1234)
  4. [AI 에이전트]: 코드를 수신함. 하지만 이 코드가 Trust-IdP에서 온 것인지, Evil-IdP에서 온 것인지 구분할 단서가 없음.
  5. [AI 에이전트] ➔ [Evil-IdP]: 에이전트는 설정 변조된 대로 악성 서버(Evil-IdP)의 토큰 엔드포인트로 code=AuthCode_1234를 전송함.
  6. 결과: 🔴 공격자(Evil-IdP)가 정상적인 인가 코드를 탈취하여 토큰을 훔쳐냄.

✅ 바뀐 방식 흐름 (RFC 9207 적용)

인가 서버는 리다이렉트 시 반드시 iss (Issuer, 발급자 식별자) 파라미터를 포함해야 하고, 클라이언트는 이를 엄격히 검증한다.

  1. [AI 에이전트] ➔ [사용자 브라우저]: 정상 인가 서버(Trust-IdP)로 로그인 요청 시도. (공격 개입)
  2. [Trust-IdP] ➔ [사용자 브라우저]: 사용자 로그인 완료 후 리다이렉트 시킴. (이때 발급자 정보 iss를 반드시 포함함)
    • [http://127.0.0.1:8080/callback?code=AuthCode_1234](http://127.0.0.1:8080/callback?code=AuthCode_1234)&iss=[https://trust-idp.example.com](https://trust-idp.example.com)
  3. [AI 에이전트]: 코드와 함께 iss 값을 수신함.
  4. [AI 에이전트의 내부 검증 로직 가동]:
    • "내가 애초에 이 로그인을 시작할 때 설정했던 인가 서버 주소가 어디였지?" ➔ [https://evil-idp.com](https://evil-idp.com) (공격자에 의해 변조된 주소)
    • "지금 코드를 발급했다고 주장하는 곳(iss)은 어디지?" ➔ [https://trust-idp.example.com](https://trust-idp.example.com)
    • "두 주소가 다르네? 누군가 중간에 장난을 쳤구나!"
  5. 결과: 🟢 에이전트는 토큰 교환을 즉시 중단(Abort)하고 인가 코드를 폐기하여 탈취를 방지한다.


부가 변경 사항: DCR 퇴출과 CIMD 도입

이러한 OAuth 연동을 위해 클라이언트를 서버에 등록하는 과정 자체도 바뀐다. 기존의 동적 클라이언트 등록(DCR)은 클라이언트가 실행될 때마다 인가 서버에 자신을 등록하는 API를 호출해야 했다.

새로운 스펙에서는 CIMD (Client ID Metadata Documents)를 표준으로 삼는다. 이는 쿠버네티스에서 CRD나 ConfigMap으로 상태를 선언(Declarative)해두는 것과 유사한 접근이다. 클라이언트의 메타데이터(퍼블릭 키, 리다이렉트 URI 등)를 정적인 JSON 문서 형태로 호스팅하고, 인가 서버는 해당 문서를 읽어 신뢰 관계를 구축한다. 이는 인가 서버 측의 상태 관리 부담을 줄이고 인프라적 안정성을 높이는 방향이다.

이번 변경 사항인 DCR(동적 클라이언트 등록) 퇴출과 CIMD(클라이언트 ID 메타데이터 문서) 도입은, 인가 서버(Authorization Server)마저도 상태 비저장(Stateless)과 선언적(Declarative) 아키텍처로 전환하려는 클라우드 네이티브의 철학이 깊게 반영된 조치이다.

특히 쿠버네티스 환경의 GitOps(선언적 상태 관리) 익숙하시다면, 이 변화가 '명령형(Imperative) API 호출을 통한 상태 생성'에서 '선언형(Declarative) 문서 호스팅을 통한 상태 증명'으로 넘어가는 과정이라는 점을 쉽게 체감하실 수 있을 것이다.

이전의 DCR 방식과 새로운 CIMD 방식을 상세히 비교했다.

❌ 이전 방식: DCR (Dynamic Client Registration)

DCR은 클라이언트(AI 에이전트)가 실행될 때마다 인가 서버의 /register API를 호출하여 자신을 동적으로 등록하고, 인가 서버로부터 고유한 client_id를 발급받는 방식이다.

1. DCR 순서 흐름 (Stateful & Imperative)

  1. [AI 에이전트] ➔ [인가 서버]: 최초 실행 시 등록 API 호출
    • POST /register
    • Body: {"client_name": "My Agent", "redirect_uris": ["http://localhost:8080/cb"]}
  2. [인가 서버 내부 로직]: 전달받은 정보를 자신의 데이터베이스(RDBMS 등)에 저장(Stateful)하고, 새 client_id와 client_secret을 생성.
  3. [인가 서버] ➔ [AI 에이전트]: 발급된 자격 증명 반환
    • Body: {"client_id": "auto-gen-uuid-1234", "client_secret": "..."}
  4. [AI 에이전트] ➔ [인가 서버]: 사용자 로그인을 위해 인가 요청 시작
    • GET /authorize?client_id=auto-gen-uuid-1234&...
  5. [인가 서버 내부 로직]: DB에서 auto-gen-uuid-1234를 조회하여 등록된 redirect_uri가 맞는지 검증 후 로그인 진행.

🚨 DCR의 문제점 (엔터프라이즈 환경)

  • 상태 관리의 부담: 수많은 에이전트 파드(Pod)가 뜰 때마다 등록을 요청하면 인가 서버의 DB에 쓰레기 데이터(Orphaned Clients)가 무한정 쌓인다.
  • 보안 취약점: 아무나 /register 엔드포인트를 찔러 클라이언트를 무단으로 등록할 수 있어, 엔터프라이즈 환경에서는 보통 이 기능을 닫아두거나 관리에 골머리를 앓았다.

✅ 바뀐 방식: CIMD (Client ID Metadata Document)

CIMD 체계에서는 client_id 자체가 하나의 URL 주소가 된다. 클라이언트는 인가 서버에 자신을 '등록'해 달라고 부탁하는 대신, 자신의 정보(리다이렉트 주소, 공개 키 등)가 담긴 정적인 JSON 문서를 특정 URL에 퍼블리싱(호스팅)해 둔다.

1. CIMD 순서 흐름 (Stateless & Declarative)

  1. [사전 준비 (GitOps/배포 단계)]: 개발자나 파이프라인이 AI 에이전트의 메타데이터 JSON 파일을 사내 웹 서버(또는 S3 등)에 정적으로 배포한다.
    • URL: [https://my-agent.ktcloud.com/client.json](https://my-agent.ktcloud.com/client.json)
  2. [AI 에이전트] ➔ [인가 서버]: 에이전트가 인가 서버에 등록하는 과정을 생략하고, 즉시 로그인(인가) 요청을 시작한다. 이때 client_id로 자신의 문서 URL을 보낸다.
    • GET /authorize?client_id=[https://my-agent.ktcloud.com/client.json](https://my-agent.ktcloud.com/client.json)&...
  3. [인가 서버] ➔ [해당 URL]: 인가 서버는 전달받은 client_id가 URL 형태인 것을 보고, 해당 주소로 메타데이터 문서를 Fetch(조회) 한다.
    • GET [https://my-agent.ktcloud.com/client.json](https://my-agent.ktcloud.com/client.json)
  4. [인가 서버 내부 로직]: 받아온 정적 JSON 문서의 내용(리다이렉트 주소, 암호화 퍼블릭 키 등)과 현재 요청을 대조하여 검증한다. (DB 조회 불필요)
  5. [인가 서버] ➔ [사용자 브라우저]: 검증 성공 시 사용자 로그인 화면 노출 및 이후 OAuth 흐름 정상 진행.

 

1) 무엇과 무엇을 대조하는가?

인가 서버는 현재 들어온 API 요청이 유효한지 확인하기 위해, 요청 파라미터를 JSON 문서(클라이언트의 메타데이터나 JWKS 등)의 내용과 대조한다.

  • 리다이렉트 주소(Redirect URI) 검증:
    • 요청 값: 클라이언트가 인가 코드(Auth Code)를 받기 위해 파라미터로 보낸 redirect_uri (예: [https://app.example.com/callback](https://app.example.com/callback))
    • JSON 값: 정적 JSON 문서에 명시된 허용 가능한 redirect_uris 배열.
    • 대조 로직: 요청받은 주소가 JSON 문서의 배열 안에 존재하는지 확인합니다. 일치하지 않으면 인가 서버는 탈취된 주소로 간주하고 에러를 반환한다.
  • 암호화 퍼블릭 키(Public Key) 검증:
    • 요청 값: 클라이언트가 보낸 서명된 요청(Signed Request Object) 또는 클라이언트 인증용 JWT.
    • JSON 값: 정적 JSON 문서(보통 JWKS 형태)에 포함된 퍼블릭 키(Public Key).
    • 대조 로직: JSON 문서에서 추출한 퍼블릭 키를 사용하여 클라이언트가 보낸 서명의 유효성을 수학적으로 검증합니다. 서명이 일치하면 위변조되지 않은 요청임이 증명된다.

2) 왜 인가 서버의 DB 조회가 필요 없는가?

인가 서버의 DB를 조회해야 한다는 것은 상태 유지(Stateful)를 전제로 하는 전통적인 방식이다. 클라이언트를 사전에 등록하고 그 정보를 DB에 저장해 두어야 하기 때문이다.

하지만 설명에 나온 아키텍처에서는 정적 JSON 문서 자체가 DB를 대체하는 '신뢰의 원천(Source of Truth)' 역할을 한다.

  1. 신뢰 체인(Trust Chain)의 이동: 인가 서버는 사전에 약속된 안전한 URL(예: /.well-known/openid-configuration 또는 클라이언트의 신뢰할 수 있는 호스트)에서 JSON 문서를 가져온다. HTTPS/TLS 등을 통해 이 문서를 안전하게 가져왔거나 문서 자체가 서명되어 있다면, 인가 서버는 그 문서 안의 데이터를 100% 신뢰할 수 있다.
  2. DB 부하 감소 및 유연성 확보: 클라이언트의 설정이 바뀔 때마다 인가 서버의 DB를 업데이트할 필요가 없다. 클라이언트 측에서 자신의 정적 JSON 문서만 업데이트하면, 인가 서버는 요청이 들어올 때마다 최신 JSON을 읽어와(또는 캐싱하여) 즉시 검증할 수 있다.
  3. 무상태(Stateless) 검증: 중앙 DB에 접근하기 위한 네트워크 비용이나 트랜잭션을 일으키지 않고, 메모리 상에서 가져온 JSON 객체와 Request 객체만 비교하면 되므로 처리 속도가 빠르고 병목 현상이 줄어든다.

 

유즈케이스 예제: CIMD HTTP/JSON 예제

1) 호스팅된 클라이언트 메타데이터 문서 (CIMD JSON) 웹 서버가 정적으로 서빙하는 [https://my-agent.ktcloud.com/client.json](https://my-agent.ktcloud.com/client.json) 파일의 내용이다. 에이전트의 스펙이 선언적으로 명시되어 있다.

{
  "client_name": "kt cloud Internal Enterprise Agent",
  "client_uri": "https://my-agent.ktcloud.com",
  "redirect_uris": [
    "http://127.0.0.1:8080/callback"
  ],
  "application_type": "native",
  "token_endpoint_auth_method": "private_key_jwt",
  "jwks_uri": "https://my-agent.ktcloud.com/jwks.json" 
}

 

2) AI 에이전트의 최초 인가(로그인) HTTP 요청 에이전트는 인가 서버에 동적 등록을 할 필요 없이 바로 브라우저를 띄워 아래 주소로 보낸다.

GET /oauth2/authorize?
  response_type=code
  &client_id=https%3A%2F%2Fmy-agent.ktcloud.com%2Fclient.json  <-- URL 자체가 클라이언트 ID
  &redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Fcallback
  &scope=mcp_tools
  &state=xyz123 HTTP/1.1
Host: auth.ktcloud.com

 

 

6. Tasks(백그라운드 작업) 정식 확장 편입

오래 걸리는 작업(Long-running tasks)을 처리하는 방식이 정비되었다.

  • 이전 방식: 코어 기능에서 실험적으로 제공되었고, 알림을 받으려면 각기 다른 HTTP GET 엔드포인트를 사용해야 했다.
  • 바뀐 방식: io.modelcontextprotocol/tasks라는 정식 확장(Extension) 프레임워크로 분리되었습니다. 폴링 방식의 tasks/get과 상태 업데이트를 위한 tasks/update가 도입되었으며, 모든 변경 알림은 단일 subscriptions/listen 스트림으로 통합되었다.

 

유즈케이스: 10GB 대용량 로그 데이터 분석 (소요 시간 10분)

AI 에이전트(클라이언트)가 서버에게 "최근 한 달 치 10GB 웹 서버 로그에서 에러 패턴을 분석해 줘"라고 요청한다. 10분이 걸리는 작업이므로 기존 동기식(Synchronous) HTTP 요청이었다면 무조건 타임아웃(Timeout)이 발생했을 것이다.

에이전트가 "10GB 로그 분석"을 요청하면 즉각적인 결과 대신 Task ID를 받는다. 이후 단일 구독 스트림(subscriptions/listen)을 통해 진행도(30% -> 70% -> 완료) 알림을 효율적으로 수신할 수 있다.

이를 Tasks 확장과 subscriptions/listen을 활용해 비동기식(Asynchronous)으로 처리하는 흐름이다.

1. 순서도 (Sequence Flow)

  1. [구독 연결] 에이전트 ➔ 서버: 먼저 subscriptions/listen 스트림(Streamable HTTP)을 열어 알림 채널을 유지한다.
  2. [작업 지시] 에이전트 ➔ 서버: 도구(Tool)를 호출하여 데이터 분석을 지시한다.
  3. [즉시 응답] 서버 ➔ 에이전트: 작업을 큐(Queue)에 넣고, 즉시 Task ID를 반환한다. (HTTP 연결 종료)
  4. [백그라운드 실행] 서버: 백그라운드 워커가 10GB 로그 분석을 시작한다.
  5. [상태 변경 알림] 서버 ➔ 에이전트: 분석이 50% 진행되었을 때, 1번에서 열어둔 스트림으로 "상태 변경됨" 알림을 푸시(Push)한다.
  6. [상세 조회] 에이전트 ➔ 서버: 알림을 받은 에이전트는 tasks/get을 호출해 진행도를 확인한다. (응답: "50% 진행 중")
  7. [완료 알림] 서버 ➔ 에이전트: 작업이 100% 완료되면 스트림으로 다시 완료 알림을 푸시(Push)한다.
  8. [결과 조회] 에이전트 ➔ 서버: 에이전트가 마지막으로 tasks/get을 호출하여 최종 분석 리포트를 받아온다.
  • 에이전트가 "10GB 로그 분석"을 요청하면 즉각적인 결과 대신 Task ID를 받는다. 이후 단일 구독 스트림(subscriptions/listen)을 통해 진행도(30% -> 70% -> 완료) 알림을 효율적으로 수신할 수 있다.

 

2. HTTP / JSON-RPC 호출 예제 시나리오

Step 1: 스트림 구독 (Client ➔ Server) 클라이언트가 먼저 서버의 알림을 받기 위해 단일 스트림 채널을 연다. 이 연결은 계속 유지된.

POST /mcp/subscriptions/listen HTTP/1.1
Host: api.ktcloud.com
Content-Type: application/json

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "subscriptions/listen",
  "params": {
    "topics": ["io.modelcontextprotocol/tasks"] // Tasks 관련 알림만 구독하겠다고 선언
  }
}

 

Step 2: 작업 지시 (Client ➔ Server) 분석 툴을 호출한다. (이 요청은 일반적인 HTTP 요청이므로 금방 끊어진다.)

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "name": "analyze_large_logs",
    "arguments": { "target_size": "10GB" }
  }
}

Step 3: 즉시 응답 (Server ➔ Client) 서버는 데이터를 처리하기 전에 예약표(taskId)만 먼저 던져준다.

{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "status": "accepted",
    "taskId": "task-log-9991"
  }
}

Step 4 & 5: 백그라운드 진행 및 푸시 알림 (Server ➔ Client) 진행 상태가 변하면, 아까 열어둔 subscriptions/listen 스트림을 통해 서버가 이벤트를 쏩니다.

// (스트림을 통해 서버가 일방적으로 내려보내는 데이터)
{
  "jsonrpc": "2.0",
  "method": "notifications/tasks/updated",
  "params": {
    "taskId": "task-log-9991"
  }
}

💡 주의: 알림에는 "50% 진행됨" 같은 무거운 데이터가 들어있지 않습니다. 오직 "이 Task에 변화가 생겼다"는 신호만 간다.

Step 6: 상세 조회 (Client ➔ Server) 알림을 받은 에이전트가 "뭐가 변했지?" 하고 단발성 API(tasks/get)로 찔러본다. (Polling)

// 요청
{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tasks/get",
  "params": { "taskId": "task-log-9991" }
}

// 응답
{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "taskId": "task-log-9991",
    "status": "running",
    "progress": 50,
    "message": "파싱 중 (5GB/10GB)"
  }
}

Step 7 & 8: 완료 알림 및 최종 결과 수령 작업이 끝나면 다시 스트림으로 알림이 오고, 클라이언트가 다시 tasks/get을 호출하면 최종 결과를 준다.

// 최종 응답
{
  "jsonrpc": "2.0",
  "id": 4,
  "result": {
    "taskId": "task-log-9991",
    "status": "completed",
    "result_data": {
      "error_count": 402,
      "top_error": "ConnectionTimeout",
      "report_url": "https://api.ktcloud.com/reports/log-9991.pdf"
    }
  }
}

 

왜 이렇게 설계했을까요?

만약 스트림(Push)에 10GB 분석 결과를 통째로 담아서 보낸다면 스트림 트래픽이 폭주하고 네트워크가 불안정해질 수 있다. 반대로 스트림 없이 클라이언트가 1초마다 무지성으로 "다 됐어?" 하고 물어보는 방식(순수 Polling)은 서버에 엄청난 HTTP 부하(DDos와 유사)를 일으킨다.

새로운 MCP의 subscriptions/listen + tasks/get 방식은 가벼운 신호(Signal)는 스트림으로 Push하고, 무거운 데이터(Payload)는 필요할 때만 HTTP로 Fetch(가져오기) 하는 클라우드 네이티브의 이벤트 주도(Event-driven) 아키텍처 베스트 프랙티스를 그대로 프로토콜 스펙으로 가져온 것이다.

 

Node.js (TypeScript/Express)로 구현한 슈도 코드

import express from 'express';

const app = express();
app.use(express.json());

// 구독 중인 클라이언트들의 HTTP 응답(Response) 스트림 객체를 담아둘 Map
// 세션 ID(Mcp-Session-Id)가 아니라, 순수하게 현재 열려있는 TCP 연결 객체를 관리합니다.
const activeStreams = new Set<express.Response>();

/**
 * 1. 단일 MCP 엔드포인트 및 헤더 기반 라우팅
 */
app.post('/mcp', (req, res) => {
    // 바뀐 SPEC: JSON 바디를 열어보지 않고 헤더만으로 라우팅 (API 게이트웨이 친화적)
    const mcpMethod = req.headers['mcp-method'];
    const mcpName = req.headers['mcp-name'];

    // --- A. 구독(스트림) 연결 요청 처리 ---
    if (mcpMethod === 'subscriptions/listen') {
        // SSE(text/event-stream)가 아닌 일반 NDJSON 스트림 설정
        res.setHeader('Content-Type', 'application/x-ndjson'); 
        res.setHeader('Transfer-Encoding', 'chunked'); // HTTP/1.1 청크 스트리밍 핵심
        res.setHeader('Connection', 'keep-alive');

        // 연결된 응답 객체를 Set에 저장 (알림을 쏠 타겟)
        activeStreams.add(res);

        // 클라이언트가 네트워크를 끊으면 Set에서 제거하여 메모리 누수 방지
        req.on('close', () => {
            activeStreams.delete(res);
        });

        // 구독 성공 즉시 응답 (스트림을 닫지 않기 위해 res.end()를 호출하지 않음)
        const successResponse = {
            jsonrpc: "2.0",
            id: req.body.id,
            result: { status: "subscribed" }
        };
        // JSON 객체를 String으로 변환 후 개행문자(\n)를 붙여서 전송
        res.write(JSON.stringify(successResponse) + '\n');
        return; // 스트림 열어둔 채로 대기
    }

    // --- B. 일반 도구 호출 처리 ---
    if (mcpMethod === 'tools/call' && mcpName === 'analyze_large_logs') {
        const taskId = `task-${Date.now()}`;
        
        // 백그라운드 워커에 작업 위임 (비동기)
        startBackgroundWorker(taskId, req.body.params.arguments);

        // 클라이언트에게는 HTTP 즉시 종료 (200 OK) 및 Task ID 반환
        res.json({
            jsonrpc: "2.0",
            id: req.body.id,
            result: { status: "accepted", taskId: taskId }
        });
        return;
    }

    res.status(404).send('Not Found');
});

/**
 * 2. 백그라운드 작업 상태 변경 시 클라이언트(들)에게 알림 푸시
 */
function notifyTaskUpdate(taskId: string) {
    // 서버가 클라이언트에게 쏘는 순수 JSON-RPC Notification 형식 (응답 ID가 없음)
    const notification = {
        jsonrpc: "2.0",
        method: "notifications/tasks/updated",
        params: {
            taskId: taskId
        }
    };

    const payload = JSON.stringify(notification) + '\n';

    // 현재 열려있는 모든 스트림에 데이터를 밀어 넣음 (Write)
    // (실제 프로덕션 환경에서는 해당 Task를 요청한 특정 사용자/채널의 스트림으로 필터링하는 로직이 추가됩니다)
    for (const streamRes of activeStreams) {
        // SSE의 event: / data: 래핑 없이 순수 JSON 텍스트만 전송
        streamRes.write(payload); 
    }
}

// 가상의 백그라운드 워커 시뮬레이션
function startBackgroundWorker(taskId: string, args: any) {
    setTimeout(() => {
        // 5초 뒤 진행 상태 변경 알림 발생
        notifyTaskUpdate(taskId);
    }, 5000);
}

app.listen(3000, () => console.log('MCP Stateless Server running on port 3000'));

 

 

 

반응형
Posted by seungkyua@gmail.com
,