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
,

2024년 행사 Recap

반응형
Posted by seungkyua@gmail.com
,
반응형
Posted by seungkyua@gmail.com
,

1. sudo 사용자 추가, 보안 s/w 내리기, swap off

$ sudo adduser ask

$ cat <<EOF | sudo tee /etc/sudoers.d/sudoers-ask
ask     ALL=(ALL:ALL)   NOPASSWD:ALL
EOF

$ sudo systemctl stop ufw
$ sudo systemctl disable ufw
$ sudo systemctl stop apparmor.service
$ sudo systemctl disable apparmor.service
$ sudo swapoff -a

 

2. module load

$ cat <<EOF | sudo tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF

$ sudo modprobe overlay
$ sudo modprobe br_netfilter

 

3. network forwarding 설정

$ cat <<EOF | sudo tee /etc/sysctl.d/99-kubernetes.conf
net.bridge.bridge-nf-call-iptables  = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward                 = 1
EOF


$ sudo sysctl --system
$ sudo iptables -P FORWARD ACCEPT

 

4. containerd 설치

$ sudo apt-get update
$ sudo apt-get install -y apt-transport-https ca-certificates curl gpg
$ sudo install -m 0755 -d /etc/apt/keyrings
$ sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
$ sudo chmod a+r /etc/apt/keyrings/docker.asc

$ echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

$ sudo apt-get update
$ sudo apt-get install -y containerd.io

$ sudo mkdir -p /etc/containerd
$ sudo containerd config default | sudo tee /etc/containerd/config.toml

$ sudo sed -i 's/SystemdCgroup \= false/SystemdCgroup \= true/g' /etc/containerd/config.toml

$ sudo systemctl restart containerd
$ sudo systemctl enable containerd
$ sudo systemctl status containerd

 

5. crictl 설치

#----------------------------------------------------------
# 서버가 arm64 or amd64 인지 확인하여 설치
#----------------------------------------------------------
$ VERSION="v1.30.0"
$ curl -L https://github.com/kubernetes-sigs/cri-tools/releases/download/$VERSION/crictl-${VERSION}-linux-arm64.tar.gz --output crictl-${VERSION}-linux-arm64.tar.gz

$ sudo tar zxvf crictl-$VERSION-linux-arm64.tar.gz -C /usr/local/bin
$ rm -f crictl-$VERSION-linux-arm64.tar.gz

#----------------------------------------------------------
# crictl 이 어느 container 를 접속할 것인지 세팅
#----------------------------------------------------------
$ cat <<EOF | sudo tee /etc/crictl.yaml
runtime-endpoint: unix:///run/containerd/containerd.sock
image-endpoint: unix:///run/containerd/containerd.sock
timeout: 2
debug: false
pull-image-on-create: false
EOF

$ sudo bash -c "crictl completion > /etc/bash_completion.d/crictl"
$ source ~/.bashrc


#----------------------------------------------------------
# containerd 설정 확인
#----------------------------------------------------------
$ sudo crictl info

 

6. kubectl 설치

#----------------------------------------------------------
# 서버가 arm64 or amd64 인지 확인하여 설치
#----------------------------------------------------------
$ curl -LO "https://dl.k8s.io/release/v1.30.0/bin/linux/arm64/kubectl"
$ chmod +x ./kubectl
$ sudo mv ./kubectl /usr/local/bin/kubectl

 

7.  Kubernetes 설치

$ mkdir -p ~/kubeadm && cd ~/kubeadm

#-----------------------------------------------
# kubernetes 다운로드 key 와 url 등록
#-----------------------------------------------
$ curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.30/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg

$ echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.30/deb/ /' | sudo tee /etc/apt/sources.list.d/kubernetes.list
$ sudo apt-get update


#========================================================================
# kubelet 설치 (아직 kubelet 이 뜨지는 않음)
#========================================================================
$ sudo apt-get install -y kubelet="1.30.3-*" kubeadm="1.30.3-*"
$ sudo systemctl enable --now kubelet
$ sudo systemctl start kubelet

#========================================================================
# 1. kubeadm 설치
#========================================================================
$ sudo kubeadm config images pull

#------------------------------------------------------------
# 자기 노드의 ip: --apiserver-advertise-address
# multi control-plane 일 경우 L4 ip: --control-plane-endpoint
# cgroup driver 세팅 (https://kubernetes.io/docs/tasks/administer-cluster/kubeadm/configure-cgroup-driver/)
#------------------------------------------------------------
$ vi kubeadm-config.yaml

apiVersion: kubeadm.k8s.io/v1beta3
kind: InitConfiguration
nodeRegistration:
  criSocket: "/var/run/containerd/containerd.sock"

---
apiVersion: kubeadm.k8s.io/v1beta3
kind: ClusterConfiguration
apiServer:
  certSANs:
  - 127.0.0.1
  - localhost
  - <Node private IP>
  - <Node public IP>
networking:
  serviceSubnet: 10.233.0.0/18
  podSubnet: 10.233.64.0/18
  dnsDomain: "cluster.local"


#----------------------------------------------------------------
# kubeadm init 을 하고 나면 /var/lib/kubelet/config.yaml 이 생성되어
# kubelet 이 정상적으로 실행됨
#----------------------------------------------------------------
$ sudo kubeadm init --config kubeadm-config.yaml --v=5


#------------------------------------------------------------
# kubeconfig
#------------------------------------------------------------
$ mkdir -p ~/.kube
$ sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
$ sudo chown $(id -u):$(id -g) $HOME/.kube/config

 

8. Calico 설치

$ mkdir -p ~/calico && cd ~/calico

$ curl -LO https://raw.githubusercontent.com/projectcalico/calico/v3.28.0/manifests/tigera-operator.yaml

$ kubectl create -f tigera-operator.yaml

$ curl -LO https://raw.githubusercontent.com/projectcalico/calico/v3.28.0/manifests/custom-resources.yaml

#------------------------------------------------------------
# yaml 을 열어서 pod 네트워크를 확인하고 변경
#------------------------------------------------------------
$ vi custom-resources.yaml
...
    cidr: 10.233.64.0/18
...

$ kubectl create -f custom-resources.yaml

#------------------------------------------------------------
# calico 설치 확인
#------------------------------------------------------------
$ kubectl get pods -n calico-system

 

9. kubectl bash completion

$ source <(kubectl completion bash)
$ kubectl completion bash > ~/.kube/completion.bash.inc

$ printf "
# kubectl shell completion
source '$HOME/.kube/completion.bash.inc'
" >> $HOME/.bash_aliases

$ source $HOME/.bash_aliases

 

10. taint 제거

$ kubectl taint nodes --all node-role.kubernetes.io/control-plane-

 

11. 설치 테스트

$ mkdir -p ~/sample-yaml && cd ~/sample-yaml

$ cat <<EOF | tee ./nginx-service.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: nginx-deployment
  name: nginx-deployment
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.21.0
        ports:
        - containerPort: 80

---
apiVersion: v1
kind: Service
metadata:
  name: nginx-service-nodeport
spec:
  selector:
    app: nginx
  ports:
  - protocol: TCP
    port: 8080
    targetPort: 80
    nodePort: 30180
  type: NodePort
  externalTrafficPolicy: Local
EOF


$ kubectl apply -f nginx-service.yaml

$ kubectl get pods

$ curl <Node ip>:30180
반응형
Posted by seungkyua@gmail.com
,

Minikube 설치하기

minkube 설치는 아래 사이트를 참고한다.

https://minikube.sigs.k8s.io/docs/start/

$ curl -LO <https://storage.googleapis.com/minikube/releases/latest/minikube-darwin-arm64>
$ install minikube-darwin-arm64 /Users/ask/bin/minikube

start cluster

https://minikube.sigs.k8s.io/docs/drivers/docker/

driver 를 docker 로 사용하기 때문에 docker 를 미리 설치해 놓아야 한다.

$ docker context use default

이후에 minikube 로 cluster 를 생성한다.

$ minikube start --driver=docker --memory=4096
--- output ---
😄  Darwin 13.5.2 (arm64) 의 minikube v1.33.0
✨  유저 환경 설정 정보에 기반하여 docker 드라이버를 사용하는 중
📌  Using Docker Desktop driver with root privileges
👍  Starting "minikube" primary control-plane node in "minikube" cluster
🚜  Pulling base image v0.0.43 ...
🔥  Creating docker container (CPUs=2, Memory=4096MB) ...
❗  This container is having trouble accessing <https://registry.k8s.io>
💡  To pull new external images, you may need to configure a proxy: <https://minikube.sigs.k8s.io/docs/reference/networking/proxy/>
🐳  쿠버네티스 v1.30.0 을 Docker 26.0.1 런타임으로 설치하는 중
    ▪ 인증서 및 키를 생성하는 중 ...
    ▪ 컨트롤 플레인을 부팅하는 중 ...
    ▪ RBAC 규칙을 구성하는 중 ...
🔗  bridge CNI (Container Networking Interface) 를 구성하는 중 ...
🔎  Kubernetes 구성 요소를 확인...
    ▪ Using image gcr.io/k8s-minikube/storage-provisioner:v5
🌟  애드온 활성화 : storage-provisioner, default-storageclass

❗  /Users/ask/bin/kubectl is version 1.28.2, which may have incompatibilities with Kubernetes 1.30.0.
    ▪ Want kubectl v1.30.0? Try 'minikube kubectl -- get pods -A'
🏄  끝났습니다! kubectl이 "minikube" 클러스터와 "default" 네임스페이스를 기본적으로 사용하도록 구성되었습니다.

디폴트 메모리를 config 에 세팅할 수 도 있다.

config 세팅은 기존에 만든 minikube node 에는 적용이 안되고 새롭게 만드는 노드에만 적용된다.

$ minikube config set memory 4096

node 추가

minikube 에 worker node 를 추가할 수 있다.

$ minikube node add

node 가 추가된 것을 볼 수 있다.

$ kubectl get nodes
NAME           STATUS   ROLES           AGE     VERSION
minikube       Ready    control-plane   8m22s   v1.30.0
minikube-m02   Ready    <none>          86s     v1.30.0

node 에 Role 을 추가하고 싶으면 다음과 같이 지정한다.

$ kubectl label node minikube-m02 node-role.kubernetes.io/node=enabled --overwrite

$ kubectl get nodes
NAME           STATUS   ROLES           AGE     VERSION
minikube       Ready    control-plane   15m     v1.30.0
minikube-m02   Ready    node            8m12s   v1.30.0

minikube 명령어

  • 현재 설치된 minkube node 를 조회한다.
$ minkube status
--- output ---
minikube
type: Control Plane
host: Running
kubelet: Running
apiserver: Running
kubeconfig: Configured

minikube-m02
type: Worker
host: Running
kubelet: Running
  • minikube 로 만든 kubernetes cluster 의 모든 namespace 를 잠시 멈춘다.
$ minkube pause -A
--- output --
⏸️  Pausing node minikube ...
⏸️  Pausing node m02 ...
⏯️  Paused 16 containers
  • minkube 로 pause 한 cluster 를 다시 실행한다.
$ minikube unpause -A
--- output ---
⏸️  Unpausing node minikube ...
⏸️  Unpausing node m02 ...
⏸️  Unpaused 16 containers
  • minikube 모든 노드를 중지한다.
$ minikube stop --all=true
--- output ---
✋  "minikube-m02" 노드를 중지하는 중 ...
🛑  "minikube-m02"를 SSH로 전원을 끕니다 ...
✋  "minikube" 노드를 중지하는 중 ...
🛑  "minikube"를 SSH로 전원을 끕니다 ...
🛑  2개의 노드가 중지되었습니다.
  • aged 란 이름의 다른 쿠버네티스 버전을 추가로 설치한다.
$ minikube start -p aged --kubernetes-version=v1.28.2
  • 모든 minikube 로 만든 kubernetes cluster 를 삭제한다.
minikube delete --all
  • minikube addons 목록을 살펴본다.
$ minikube addons list
|-----------------------------|----------|--------------|--------------------------------|
|         ADDON NAME          | PROFILE  |    STATUS    |           MAINTAINER           |
|-----------------------------|----------|--------------|--------------------------------|
| ambassador                  | minikube | disabled     | 3rd party (Ambassador)         |
| auto-pause                  | minikube | disabled     | minikube                       |
| cloud-spanner               | minikube | disabled     | Google                         |
| csi-hostpath-driver         | minikube | disabled     | Kubernetes                     |
| dashboard                   | minikube | disabled     | Kubernetes                     |
| default-storageclass        | minikube | enabled ✅   | Kubernetes                     |
| efk                         | minikube | disabled     | 3rd party (Elastic)            |
| freshpod                    | minikube | disabled     | Google                         |
| gcp-auth                    | minikube | disabled     | Google                         |
| gvisor                      | minikube | disabled     | minikube                       |
| headlamp                    | minikube | disabled     | 3rd party (kinvolk.io)         |
| helm-tiller                 | minikube | disabled     | 3rd party (Helm)               |
| inaccel                     | minikube | disabled     | 3rd party (InAccel             |
|                             |          |              | [info@inaccel.com])            |
| ingress                     | minikube | disabled     | Kubernetes                     |
| ingress-dns                 | minikube | disabled     | minikube                       |
| inspektor-gadget            | minikube | disabled     | 3rd party                      |
|                             |          |              | (inspektor-gadget.io)          |
| istio                       | minikube | disabled     | 3rd party (Istio)              |
| istio-provisioner           | minikube | disabled     | 3rd party (Istio)              |
| kong                        | minikube | disabled     | 3rd party (Kong HQ)            |
| kubeflow                    | minikube | disabled     | 3rd party                      |
| kubevirt                    | minikube | disabled     | 3rd party (KubeVirt)           |
| logviewer                   | minikube | disabled     | 3rd party (unknown)            |
| metallb                     | minikube | disabled     | 3rd party (MetalLB)            |
| metrics-server              | minikube | disabled     | Kubernetes                     |
| nvidia-device-plugin        | minikube | disabled     | 3rd party (NVIDIA)             |
| nvidia-driver-installer     | minikube | disabled     | 3rd party (Nvidia)             |
| nvidia-gpu-device-plugin    | minikube | disabled     | 3rd party (Nvidia)             |
| olm                         | minikube | disabled     | 3rd party (Operator Framework) |
| pod-security-policy         | minikube | disabled     | 3rd party (unknown)            |
| portainer                   | minikube | disabled     | 3rd party (Portainer.io)       |
| registry                    | minikube | disabled     | minikube                       |
| registry-aliases            | minikube | disabled     | 3rd party (unknown)            |
| registry-creds              | minikube | disabled     | 3rd party (UPMC Enterprises)   |
| storage-provisioner         | minikube | enabled ✅   | minikube                       |
| storage-provisioner-gluster | minikube | disabled     | 3rd party (Gluster)            |
| storage-provisioner-rancher | minikube | disabled     | 3rd party (Rancher)            |
| volumesnapshots             | minikube | disabled     | Kubernetes                     |
| yakd                        | minikube | disabled     | 3rd party (marcnuri.com)       |
|-----------------------------|----------|--------------|--------------------------------|
  • dashboard 를 설치한다.
$ minikube addons enable dashboard
--- output ---
💡  dashboard is an addon maintained by Kubernetes. For any concerns contact minikube on GitHub.
You can view the list of minikube maintainers at: <https://github.com/kubernetes/minikube/blob/master/OWNERS>
    ▪ Using image docker.io/kubernetesui/dashboard:v2.7.0
    ▪ Using image docker.io/kubernetesui/metrics-scraper:v1.0.8
💡  Some dashboard features require the metrics-server addon. To enable all features please run:

	minikube addons enable metrics-server

🌟  'dashboard' 애드온이 활성화되었습니다
  • metrics-server 를 설치한다.
$ minikube addons enable metrics-server
--- output ---
💡  metrics-server is an addon maintained by Kubernetes. For any concerns contact minikube on GitHub.
You can view the list of minikube maintainers at: <https://github.com/kubernetes/minikube/blob/master/OWNERS>
    ▪ Using image registry.k8s.io/metrics-server/metrics-server:v0.7.1
🌟  'metrics-server' 애드온이 활성화되었습니다
  • dashboard 에 접속한다.
$ minikube dashboard --url

minikube trouble shooting

minikube 를 설치하고 pod 가 생성안되는 가장 큰 이유는 proxy 때문이다. 회사에서 proxy 를 사용한다면 proxy 세팅을 추가로 해줘야 한다.

아래 내용은 proxy 이외의 문제일 때 해결 방법이다. 

  • docker hub 으로 부터 이미지를 다운 받지 못하는 문제
Error response from daemon: Get "<https://registry-1.docker.io/v2/>": tls: failed to verify certificate: x509: certificate signed by unknown authority

minikube 의 docker-env 를 확인한 후 세팅한다.

$ minikube -p minikube docker-env
--- output ---
export DOCKER_TLS_VERIFY="1"
export DOCKER_HOST="tcp://127.0.0.1:53131"
export DOCKER_CERT_PATH="/Users/ask/.minikube/certs"
export MINIKUBE_ACTIVE_DOCKERD="minikube"

$ docker context create minikube --description "Minikube" --docker "host=tcp://localhost:53131,ca=/Users/ask/.minikube/certs/ca.pem,cert=/Users/ask/.minikube/certs/cert.pem,key=/Users/ask/.minikube/certs/key.pem"
$ docker context use minikube

위의 경우에는 minikube 를 띄울 때 --insecure-registry 를 추가한다.

$ minikube start --insecure-registry="registry-1.docker.io"

아니면 minikube 로 노드에 접속해서 docker 에 인증서를 추가한다.

$ minikube ssh
$ sudo su -

$ update-ca-certificates --fresh
$ openssl s_client -showcerts -verify 5 -connect k8s.gcr.io:443 < /dev/null 2>/dev/null | openssl x509 -outform PEM | tee ~/k8s.gcr.io.crt
$ openssl s_client -showcerts -verify 5 -connect registry-1.docker.io:443 < /dev/null 2>/dev/null | openssl x509 -outform PEM | tee ~/registry-1.docker.io.crt
$ openssl s_client -showcerts -verify 5 -connect auth.docker.io:443 < /dev/null 2>/dev/null | openssl x509 -outform PEM | tee ~/auth.docker.io.crt
$ cp ~/k8s.gcr.io.crt /usr/local/share/ca-certificates/
$ cp ~/registry-1.docker.io.crt /usr/local/share/ca-certificates/
$ cp ~/auth.docker.io.crt /usr/local/share/ca-certificates/
$ update-ca-certificates

$ systemctl restart docker

Kind 설치

kind 는 아래 설치 사이트를 참조한다.

https://kind.sigs.k8s.io/docs/user/quick-start/

$ [ $(uname -m) = arm64 ] && curl -Lo ./kind <https://kind.sigs.k8s.io/dl/v0.22.0/kind-darwin-arm64>
$ chmod +x kind
$ mv kind ~/bin/kind

kind config 세팅

image 는 아래 사이트에서 확인 가능하다.

https://github.com/kubernetes-sigs/kind/releases

kind config 는 여기를 참조한다.

https://kind.sigs.k8s.io/docs/user/quick-start/#configuring-your-kind-cluster

ingress 를 사용하기 위해서는 아래 내용 처럼 port mapping 을 해야 한다.

https://kind.sigs.k8s.io/docs/user/ingress

$ vi kind-config.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
# patch the generated kubeadm config with some extra settings
kubeadmConfigPatches:
- |
  apiVersion: kubelet.config.k8s.io/v1beta1
  kind: KubeletConfiguration
  evictionHard:
    nodefs.available: "0%"
# patch it further using a JSON 6902 patch
kubeadmConfigPatchesJSON6902:
- group: kubeadm.k8s.io
  version: v1beta3
  kind: ClusterConfiguration
  patch: |
    - op: add
      path: /apiServer/certSANs/-
      value: my-hostname
nodes:
- role: control-plane
  image: kindest/node:v1.29.2@sha256:51a1434a5397193442f0be2a297b488b6c919ce8a3931be0ce822606ea5ca245
#  extraPortMappings:
#  - containerPort: 80
#    hostPort: 10080
#    listenAddress: "0.0.0.0" # Optional, defaults to "0.0.0.0"
#    protocol: tcp # Optional, defaults to tcp
- role: worker
  image: kindest/node:v1.29.2@sha256:51a1434a5397193442f0be2a297b488b6c919ce8a3931be0ce822606ea5ca245
#featureGates:
#  FeatureGateName: true

kind create cluster

$ KIND_EXPERIMENTAL_PROVIDER=docker && kind create cluster --name kind --config kind-config.yaml
--- output ---
Creating cluster "kind" ...
 ✓ Ensuring node image (kindest/node:v1.29.2) 🖼
 ✓ Preparing nodes 📦 📦
 ✓ Writing configuration 📜
 ✓ Starting control-plane 🕹️
 ✓ Installing CNI 🔌
 ✓ Installing StorageClass 💾
 ✓ Joining worker nodes 🚜
Set kubectl context to "kind-kind"
You can now use your cluster with:

kubectl cluster-info --context kind-kind

Thanks for using kind! 😊
  • kind cluster 보기
$ kind get clusters
  • context 로 cluster 조회하기
$ kubectl cluster-info --context kind-kind
  • cluster 삭제하기
$ kind delete cluster --name kind
반응형
Posted by seungkyua@gmail.com
,

스터디에서 준비해야 할 내용 리스트

배포 전략에 대한 개념 설명

  • Rolling Update
  • Blue/Green
  • Canary
  • Recreate

 

Argo CD 설치, Argo Rollout 설치

  • Argo CD 로 github 을 연동하여 gitops 구현해 보기
  • Argo Rollout 을 활용하여 Blue/Green 구현해 보기
  • Kubernetes 에서 Deployment 로 배포된 서비스가 Rolling Update 되는 로직을 설명
  • (선택) Nginx Ingress Controller 를 활용하여 Canary 배포를 구현해 보기

 

반응형
Posted by seungkyua@gmail.com
,

Kubernetes 를 세미나를 하거나 모임에서 만나면 쿠버네티스를 알고 싶어 하시는 분들이 제일 문제 물어보는 것이 있다.

"쿠버네티스를 잘 모르는데 이제부터 공부하고 싶어요. 어떤거 부터 하면 좋을까요?"

그 동안은 이는 리눅스를 무엇부터 공부해야 할까요? 와 거의 비슷한 이야기인거 같다. 그래서 키워드로 스스로 공부할 수 있도록 키워드로 리스트를 만들었다.

컨테이너

  • Docker 노트북에 설치
  • 리눅스 네임스페이스 & cgroup
  • dockerfile 작성 및 docker image 만들기
  • docker image 실행/종료/삭제 하기
  • docker hub 에 가입하여 개인 계정에 image 올리기

Kubernetes

  • Kubernetes 는 누가 만들었을까? 어떻게 해서 나오게 된 것일까? (Google Borg 로 검색)
  • CNCF (Cloud Native Computing Foundation) - 이 재단이 어떤 재단인지 알아보기
  • Kubernetes 는 기능이 어떤 것들이 있을까?
    • 기본적으로 Kubernetes 는 컨테이너 이미지를 실행시키는 등 관리하는 역할 수행
  • Kubernetes 노트북에 설치 (아래 3개 중에 하나)
    • kind
    • k3s
    • minikube
  • Kubernetes client 설치
    • kubectl 설치
  • Kubernetes Architecture 확인
    • Control plane
      • etcd (key-value store)
      • kube-apiserver
      • kube-controller
      • kube-scheduler
    • Node
      • kube-proxy
      • kubelet
    • Network plugin
  • Kubernetes 에서 Pod 란 무엇인가?
  • Kubernetes 에 Nginx 이미지를 pod 로 띄우기
    • pod yaml 만들기
    • kubectl 로 pod yaml 을 Kubernetes 에 배포하기
  • Pod 는 어떻게 뜨는 것일까?
    • 사용자가 pod yaml 을 작성
    • 사용자가 kubectl 로 pod yaml 을 kubernetes 로 보냄
    • kubernetes api-server 가 이를 받아서 etcd 에 저장
    • kube-scheduler 가 pod 를 원하는 node 로 스케줄링
    • 스케줄링된 node 에 실행중인 kubelet 이 pod 를 실행
  • Kubernetes Resource 알아보기
    • Pod
    • ReplicaSet
    • Deployment
    • Label & Selector
    • Service
      • ClusterIP
      • NodePort
      • LoadBalancer
    • Ingress & Ingress Controller
    • StatefulSet
    • DaemonSet
    • StorageClass
    • Persistent Volume
    • Persistent Volume Claim
    • ConfigMap
    • Secret
    • ServiceAccount
    • ClusterRole
    • ClusterRoleBinding
  • Custom Controller (Operator 란 무엇인가?)
  • CNCF Projects 알아보기
    • Graduated Projects
    • Incubating Projects
    • Sandbox Projects
반응형
Posted by seungkyua@gmail.com
,

OPA 실행 파일 설치

$ curl -L -o opa <https://github.com/open-policy-agent/opa/releases/download/v0.62.1/opa_darwin_arm64_static>
$ chmod +x opa
$ mv opa ~/bin/

참고로 홈 디렉토리 아래의 bin 디렉토리에 실행 패스가 잡혀있기 때문에 해당 디렉토리로 이동시킨 것이다.

Intellij 와 Open Policy Agent Plugin 설치

JetBrain 의 IntelliJ 가 있다면 Open Policy Agent 플러그인을 설치하여 rego 프로그램을 작성할 수 있다.

hello 프로그램을 작성하여 실행하여 보자.

먼저 번들용 chap1 디렉토리를 만든다.

$ mkdir -p chap1

chap1 번들 아래에 hello.rego 파일을 만든다.

package hello

default allow_hello = false
default allow_world = false

allow_hello {
    "hello" != ""
}

allow_world {
    "world" != "world"
}

패키지 hello 는 디렉토리와 상관없으며 chap1 디렉토리를 만들었기 때문에 bundle 은 chap1 이다.

allow_hello 와 allow_world 는 rule 을 나타낸다. OPA 1.0 미만은 if 문이 없기 때문에 비교문 만으로 표현한다. OPA 1.0 부터는 if 문을 사용한다.

opa 실행은 input json 에 대한 output json 이 결과로 나오는데 input 소스를 보면 input 이 필요없기 때문에 빈 input.json 파일을 만든다.

$ touch input.json 

현재까지의 디렉토리 구조를 보면 다음과 같다.

rego-example.iml 은 IntelliJ 의 OPA 플러그인에서 사용하는 파일이므로 신경쓸 필요가 없다.

$ tree .
.
├── chap1
│   ├── hello.rego
│   └── input.json
└── rego-example.iml

이를 cli 로 실행하면 다음과 같다.

$ opa eval -f pretty -b chap1 data.hello    

--- output ---
{
  "allow_hello": true,
  "allow_world": false
}

opa eval 명령어로 rule 을 평가할 수 있다.

-f pretty 결과를 보기 쉽게 출력하라는 의미이다.

-b cha1 은 Bundle 을 입력해야 하는데 chap1 디렉토리 의 아래 rego 파일을 실행하다.

data.hello 는 Query 를 의미하며, 여기에는 package 나 rule 을 넣으면 된다. data 는 명시적으로 붙혀서 data.패키지 로 입력하면 된다.

allow_hello rule 을 실행하기 위해서는 query 부분에 rule 까지 넣어주면 된다.

$ opa eval -f pretty -b chap1 data.hello.allow_hello

--- output ---
true

결과 값으로 json 형태가 디퐅트로 출력되는데 -f pretty 를 제거하면 json 으로 결과가 출력된다.

$ opa eval -b chap1 data.hello

--- output ---
{
  "result": [
    {
      "expressions": [
        {
          "value": {
            "allow_hello": true,
            "allow_world": false
          },
          "text": "data.hello",
          "location": {
            "row": 1,
            "col": 1
          }
        }
      ]
    }
  ]
}

input 을 위해서 빈 input.json 파일을 만들었는데 이를 활용하는 명령을 추가할 수 있다.

input 값을 실제로 사용하지는 않기 때문에 결과 값은 동일하다.

$ opa eval -b chap1 -i chap1/input.json data.hello

--- output ---
{
  "result": [
    {
      "expressions": [
        {
          "value": {
            "allow_hello": true,
            "allow_world": false
          },
          "text": "data.hello",
          "location": {
            "row": 1,
            "col": 1
          }
        }
      ]
    }
  ]
}

실행을 cli 로 하지 말고 IntelliJ 에서 실행하는 방법은 아래와 같이 입력하면 된다.

메뉴에서 Run >> Edit Configurations... 을 실행한다.

앞에서 설명한 cli 에서 입력한 내용을 그대로 넣으면 된다.

Run 버튼을 클릭하면 다음과 같이 결과가 나온다.

소스는 아래 사이트에서 다운받을 수 있다. (계속 업데이트 될 예정)

https://github.com/seungkyua/rego-example.git

반응형
Posted by seungkyua@gmail.com
,

인터페이스가 다른 인터페이서를 가지는 임베딩 방식을 사용하여 인터페이스를 선언할 수 있다. 예를 들어 io.ReadCloser 인터페이스는 io.Reader 와 io.Closer 인터페이스를 가지고 있다.

type Reader interface {
        Read(p []byte) (n int, err error)
}

type Closer interface {
        Close() error
}

type ReadCloser interface {
        Reader
        Closer
}

이 경우 ReadCloser 인터페이스는 아래와 동일 효과를 갖는다.

type ReadCloser interface {
        Read(p []byte) (n int, err error)
        Close() error
}

그런데 인터페이스를 struct 타입 안에 임베딩할 수 도 있다.

이렇게 struct 타입 안에 인터페이스를 넣는 이유는 보통 Stub 으로 유닛 테스트 코드를 만들기 쉽기 때문이다.

아래와 같이 Calculator 라는 스트럭트가 있다고 하자.

type Calculator struct {
    Resolver MathResolver
}

여기에는 MathResolver 인터페이스 타입의 필드를 가지고 있다.

type MathResolver interface {
    Resolve(expression string) (float64, error)
}

이렇게 인터페이스를 만들어 놓으면 MathResolver 를 Stub 으로 구현하여 테스트 코드를 만들 수 있다.

Calculator 는 계산 표현식을 가지고 실제 계산하여 결과 값을 리턴하는 Process 라는 메소스도 가진다.

func (c Calculator) Process(r io.Reader) (float64, error) {
    expression, err := readOneLine(r)
    if err != nil {
        return 0, err
    }
    if len(expression) == 0 {
        return 0, errors.New("no expression to read")
    }
    answer, err := c.Resolver.Resolve(expression)
    return answer, err
}

readOneLine 함수는 계산 표현식을 한 줄만 읽어들이는 함수이며, 이렇게 읽어들인 함수를 MathResolver 타입의 Resolve 함수에 아규먼트로 넘겨서 결과를 받아오는 구조이다.

이제 Proecess 메소드에 대한 테스트 코드를 만들어 보자.

첫번째로 테스트 코드로 MathResolverStub 스트럭트와 Resolve 메소드를 간단히 구현한다.

type MathResolverStub struct{}

func (mr MathResolverStub) Resolve(expr string) (float64, error) {
    switch expr {
    case "2 + 4 * 10":
        return 42, nil
    case "( 2 + 4 ) * 10":
        return 60, nil
    case "( 2 + 4 * 10":
        return 0, fmt.Errorf("invalid expression: %s", expr)
    }
    return 0, nil
}

다음으로 이 스텁을 사용한 테스트 코드를 작성한다.

func TestCalculatorProcess(t *testing.T) {
    c := embed.Calculator{Resolver: MathResolverStub{}}
    in := strings.NewReader(`2 + 4 * 10
( 2 + 4 ) * 10
( 2 + 4 * 10`)

    data := []float64{42, 60, 0}
    expectedErr := errors.New("invalid expression: ( 2 + 4 * 10")
    for _, d := range data {
        result, err := c.Process(in)
        if err != nil {
            if err.Error() != expectedErr.Error() {
                t.Errorf("want (%v) got (%v)", expectedErr, err)
            }
        }
        if result != d {
            t.Errorf("Expected result %f, got %f", d, result)
        }
    }
}

MathResolverStub 를 가지는 Calculator 를 생성하여 Calculator 의 Process 메소드를 테스트할 수 있는 코드를 쉽게 작성할 수 있다.

테스트 코드의 전체 작성은 다음과 같다.

$ mkdir -p interface/embed
$ vi interface/embed/calculate.go

package embed

import (
    "errors"
    "io"
)

type Calculator struct {
    Resolver MathResolver
}

type MathResolver interface {
    Resolve(expression string) (float64, error)
}

func (c Calculator) Process(r io.Reader) (float64, error) {
    expression, err := readOneLine(r)
    if err != nil {
        return 0, err
    }
    if len(expression) == 0 {
        return 0, errors.New("no expression to read")
    }
    answer, err := c.Resolver.Resolve(expression)
    return answer, err
}

func readOneLine(r io.Reader) (string, error) {
    var out []byte
    b := make([]byte, 1)
    for {
        _, err := r.Read(b)
        if err != nil {
            if err == io.EOF {
                return string(out), nil
            }
        }
        if b[0] == '\n' {
            break
        }
        out = append(out, b[0])
    }
    return string(out), nil
}
$ vi interface/embed/calculate_test.go

package embed_test

import (
    "errors"
    "fmt"
    "strings"
    "testing"

    "github.com/seungkyua/go-test/interface/embed"
)

type MathResolverStub struct{}

func (mr MathResolverStub) Resolve(expr string) (float64, error) {
    switch expr {
    case "2 + 4 * 10":
        return 42, nil
    case "( 2 + 4 ) * 10":
        return 60, nil
    case "( 2 + 4 * 10":
        return 0, fmt.Errorf("invalid expression: %s", expr)
    }
    return 0, nil
}

func TestCalculatorProcess(t *testing.T) {
    c := embed.Calculator{Resolver: MathResolverStub{}}
    in := strings.NewReader(`2 + 4 * 10
( 2 + 4 ) * 10
( 2 + 4 * 10`)

    data := []float64{42, 60, 0}
    expectedErr := errors.New("invalid expression: ( 2 + 4 * 10")
    for _, d := range data {
        result, err := c.Process(in)
        if err != nil {
            if err.Error() != expectedErr.Error() {
                t.Errorf("want (%v) got (%v)", expectedErr, err)
            }
        }
        if result != d {
            t.Errorf("Expected result %f, got %f", d, result)
        }
    }
}

실행을 위한 세팅 명령어는 다음과 같다.

$ go mod init github.com/seungkyua/go-test
$ go mod tidy
$ go mod vendor

$ go work init
$ go work use .

go work 는 비지니스 모듈 패키지를 아직 github 에 커밋하지 않은 상태에서 로컬의 최신 패키지 참조를 위해서 필요하다.

전체 소스 트리는 다음과 같다.

$ tree .                   
.
├── README.md
├── go.mod
├── go.work
└── interface
    └── embed
        ├── calculate.go
        └── calculate_test.go

다음은 테스트 실행 결과이다.

$ go test interface/embed/calculate_test.go
ok      command-line-arguments  0.390s

소스는 아래의 링크에서 다운 받을 수 있다.

https://github.com/seungkyua/go-test.git

반응형
Posted by seungkyua@gmail.com
,