Skip to main content

3부 14장. 관측성: 보이지 않으면 고칠 수 없다

노트 검색은 잘 되는데, 가끔 delete_note가 두 번 실행된다. 어떤 날은 semantic_search_notes가 8초씩 걸린다. 또 어떤 날은 OAuth 토큰이 만료되어 실패했는데, 사용자 입장에서는 그냥 "도구가 이상해요"로 끝난다.

이럴 때 초보 운영자는 기능을 의심한다. 하지만 실전에서는 기능이 없어서 망하는 경우보다, 무슨 일이 일어났는지 볼 수 없어서 망하는 경우가 훨씬 많다. 관측성은 서버를 더 똑똑하게 만드는 장식이 아니라, 운영을 가능하게 만드는 눈과 귀다.

특히 MCP 서버는 평범한 REST API보다 더 까다롭다. 요청 하나가 도구 내부에서 여러 단계의 작업으로 퍼지고, 외부 API 호출과 상태 변경까지 이어지기 쉽다. 그래서 단순히 HTTP 요청-응답만 찍는다고 끝나지 않는다. 진짜 중요한 경계가 전송 계층이 아니라 도구 내부로 들어가 버리기 때문이다.1

3부 14장. 관측성: 보이지 않으면 고칠 수 없다

왜 MCP 운영은 더 안 보이는가

웹 서버는 보통 엔드포인트 단위로 보면 대략 감이 온다. /login이 느리면 인증을 보면 되고, /orders에서 500이 나면 주문 로직을 보면 된다. 그런데 MCP는 다르다. 사용자는 "이번 주 메모 요약해줘"라고 말했을 뿐인데, 실제로는 프롬프트 생성, 노트 검색, 벡터 DB 조회, 원문 읽기, 정렬, 요약까지 여러 단계가 연달아 일어날 수 있다.

여기서 사고가 나면 꼭 이런 질문이 따라온다.

  • 누가 어떤 도구를 호출했는가

  • 어떤 권한과 자격 증명으로 실행되었는가

  • 실제로 무슨 일이 일어났는가

  • 어떤 부작용과 비용이 남았는가

좋은 감사 추적은 이 네 가지 질문에 답할 수 있어야 한다.3 답을 못 하면 장애 대응은 분석이 아니라 추리 소설이 된다. "아마 이 요청이 먼저였겠지", "아마 같은 사용자가 다시 시도했겠지", "아마 삭제는 안 됐을 거야" 같은 말이 나오기 시작하면 이미 진 것이다.

14.1 로그·메트릭·트레이싱 표준

관측성은 보통 로그, 메트릭, 트레이싱 세 축으로 설명한다. 세 가지는 비슷해 보이지만 맡는 역할이 다르다.

로그: 사건의 기록

로그는 "무슨 일이 있었는가"를 남긴다. 다만 실무에서 유용한 로그는 긴 문장이 아니라 구조화된 레코드다. 문자열 한 줄로 남기면 사람이 읽기는 편할 수 있어도, 나중에 tool=delete_note, user_id=u_123, outcome=error 같은 조건으로 찾기가 어렵다.

MCP 서버의 로그는 특히 다음 필드를 기본으로 가져가는 편이 좋다.

  • timestamp, level, logger

  • trace_id, request_id, session_id

  • actor_id 또는 principal

  • tool, tool_version, auth_mode

  • duration_ms, outcome, error_class

  • idempotent, retry_safe, side_effects

  • input_summary, result_summary

여기서 중요한 것은 input_summary다. 메모 본문 전체, 액세스 토큰 전체, 임베딩 벡터 전체를 로그에 남기면 안 된다. 길이, 개수, 해시, ID 같은 요약 정보만 남겨야 한다. 관측성은 확대경이지, 데이터 유출 장치가 아니다.

그리고 stdio 서버라면 아주 중요한 규칙이 하나 있다. stdout은 JSON-RPC 프로토콜 전용이다. 디버깅한다고 print()를 찍는 순간, 로그 한 줄이 아니라 프로토콜 오염탄이 된다. stdio 환경에서는 운영 로그를 stderr나 파일, 외부 수집기로 보내야 한다.2

아래는 PKM-MCP에서 남길 만한 도구 호출 로그 예시다.

{
  "event": "tool_call_end",
  "tool": "search_notes",
  "tool_version": "pkm-mcp-0.8.0",
  "trace_id": "tr_9b7f",
  "session_id": "ses_a12",
  "actor_id": "user_42",
  "auth_mode": "oauth",
  "duration_ms": 87,
  "outcome": "success",
  "idempotent": true,
  "retry_safe": true,
  "input_summary": {
    "query_len": 12,
    "limit": 10
  },
  "result_summary": {
    "matched": 4,
    "truncated": false
  }
}

이 정도만 남아도, "느린 검색이 특정 사용자에게만 일어났는가?", "OAuth 사용자만 실패하는가?", "재시도해도 안전한 도구인가?" 같은 질문에 바로 답할 수 있다.

메트릭: 서버의 맥박

로그가 사건의 원문이라면, 메트릭은 상태를 압축한 숫자다. 대시보드에서 보는 요청 수, 에러율, P95 지연 시간 같은 것들이 메트릭이다.

PKM-MCP에서 최소한 보고 싶은 메트릭은 이 정도다.

  • 도구별 호출 수와 실패 수

  • 도구별 지연 시간 분포(P50, P95, P99)

  • 사용자별 혹은 테넌트별 호출량

  • semantic_search_notes의 벡터 조회 시간

  • OAuth 인증 실패 수와 토큰 갱신 수

  • 세션당 LLM/벡터 검색 비용 추정치

실무에서는 OpenTelemetry 기반 카운터와 히스토그램으로 시작하는 경우가 많다. 최근 MCP 관련 도구들도 구조화 로그와 함께 OTel 스팬, 카운터, 토큰 사용량 추적을 기본 축으로 잡는 흐름이 강하다.4

로그만 있으면 "무슨 일이 있었는지"는 알 수 있다. 하지만 메트릭이 없으면 "지금 전반적으로 나빠지고 있는지"를 즉시 감지하지 못한다. 장애는 보통 한 건의 예외가 아니라, 그래프가 살짝 기울기 시작하는 순간부터 시작된다.

트레이싱: 한 요청의 여행 지도

트레이싱은 요청 하나가 여러 시스템을 어떻게 지나갔는지 이어서 보여준다. 예를 들어 사용자가 Claude Code에서 PKM-MCP의 semantic_search_notes를 호출하면, 다음처럼 한 줄로 이어진 흔적이 남아야 한다.

tools/call semantic_search_notes → embed_query → vector_db.search → sqlite.read_notes → result_format

이때 각 단계가 스팬이다. 스팬에는 시작 시간, 종료 시간, 소요 시간, 상태, 속성이 붙는다. 그러면 "전체는 2.8초인데, 실제 병목은 벡터 DB가 아니라 SQLite 후처리였다" 같은 사실을 눈으로 확인할 수 있다.

트레이싱을 처음부터 완벽하게 하려다 지치기 쉽다. 그래서 권장 순서는 대개 이렇다. 먼저 구조화 로그를 만들고, 그다음 핵심 도구 세 개에 메트릭을 붙이고, 마지막으로 외부 의존성이 많은 경로부터 트레이싱을 건다. 한 번에 다 하려 하지 말고, 가장 자주 깨지는 길부터 지도에 올리면 된다.

14.2 도구 출력 토큰 한계와 잘린 응답

MCP 서버는 종종 "정답"을 너무 열심히 준다. 검색 결과 200건, 각 노트의 본문 2,000자, 관련 메타데이터까지 전부 한 번에 보내면 모델은 기쁘기 전에 먼저 질린다.

Claude Code는 MCP 도구 출력이 너무 커지면 경고를 보여준다. 문서 기준으로 10,000토큰을 넘는 출력에 경고가 뜨고, MAX_MCP_OUTPUT_TOKENS로 상한을 조정할 수 있다.5 HTTP/SSE 서버를 쓰든 stdio 서버를 쓰든, 이 한계는 "클라이언트가 알아서 처리하겠지" 하고 넘길 문제가 아니다. 좋은 서버는 애초에 결과를 잘게 나눠 준다.

중요한 점은 이것이다. MAX_MCP_OUTPUT_TOKENS를 키우는 것은 진통제일 뿐 치료제가 아니다. 서버가 거대한 응답을 마구 내보내면 다음 문제가 따라온다.

  • 모델 컨텍스트를 불필요하게 많이 먹는다

  • 도구 호출 하나가 세션 전체 품질을 떨어뜨린다

  • 같은 정보를 매번 중복 전송하게 된다

  • 사용자는 느리고 장황한 도구를 싫어하게 된다

그래서 PKM-MCP의 검색 도구는 처음부터 요약 중심으로 설계하는 편이 좋다. 예를 들어 search_notes는 이런 원칙을 따른다.

  • 기본 limit를 작게 둔다

  • 본문 전체 대신 미리보기(snippet)를 우선 반환한다

  • 총 개수와 next_cursor를 함께 반환한다

  • 정말 본문 전체가 필요하면 get_note나 Resource 읽기로 분리한다

즉, "찾기"와 "가져오기"를 분리하는 것이다. 이는 RAG 품질에도 좋다. 검색 도구는 후보를 잘 추려 주고, 실제 본문 읽기는 별도 단계로 나누는 편이 모델이 문맥을 더 잘 쓴다.

아래처럼 응답 형태를 바꾸면 출력 폭주를 많이 줄일 수 있다.

{
  "query": "oauth workers",
  "count": 3,
  "items": [
    {
      "id": "note_101",
      "title": "Cloudflare Workers 배포 메모",
      "snippet": "Workers에서는 stateless 설계가 중요하고...",
      "updated_at": "2026-04-20T10:15:00Z"
    }
  ],
  "next_cursor": "eyJvZmZzZXQiOjN9",
  "truncated": true
}

클라이언트 측에서는 큰 출력으로 자주 경고가 난다면 상한을 조정할 수 있고, 서버 작성자는 페이지네이션이나 결과 크기 힌트를 제공해 협력할 수 있다.5 결국 좋은 MCP 서버는 많이 말하는 서버가 아니라, 필요한 만큼만 정확히 말하는 서버다.

14.3 재연결, 백오프, 회로 차단

운영에서 진짜 무서운 것은 "완전히 죽은 서버"보다 "애매하게 아픈 서버"다. 가끔 응답하고, 가끔 타임아웃 나고, 가끔만 실패하는 서버가 제일 사람을 미치게 만든다. 그래서 회복 전략이 필요하다.

재연결: 연결은 끊어진다

원격 MCP 서버는 네트워크를 탄다. 그러니 언젠가는 끊어진다. Claude Code는 HTTP/SSE 서버가 세션 중간에 끊기면 1초부터 시작해 지연 시간을 두 배로 늘리며 최대 5번까지 자동 재연결한다. 이때 서버는 /mcp에서 pending 상태로 보이고, 실패가 계속되면 failed로 바뀐다. 반면 stdio 서버는 로컬 프로세스라 자동 재연결 대상이 아니다.5

이 사실이 의미하는 바는 단순하다. 원격 PKM-MCP는 가능하면 무상태에 가깝게 만들고, 같은 요청을 다시 받아도 안전하도록 설계해야 한다. 재연결은 편의 기능이지, 상태 복원 마법이 아니다.

백오프: 다시 시도하되, 몰려가지 말 것

재시도는 무조건 좋은 것이 아니다. 잘못된 인증 토큰으로 실패한 요청을 5번 더 보내 봐야 더 틀릴 뿐이다. 반대로 일시적인 503이나 네트워크 타임아웃은 한두 번 더 시도하면 살아나는 경우가 많다.

그래서 재시도는 오류 분류와 함께 가야 한다.

  • 재시도해 볼 만한 오류: 타임아웃, 연결 끊김, 429, 503, 일시적 DNS 실패

  • 재시도하면 안 되는 오류: 400, 401, 403, 입력 검증 실패, 권한 부족, 존재하지 않는 리소스

  • 애매한 경우: 쓰기 작업의 타임아웃

마지막 항목이 중요하다. create_note가 타임아웃 났다고 해서 곧바로 다시 호출하면, 첫 번째 요청이 사실은 성공했을 수도 있다. 이럴 때는 재시도보다 먼저 "정말 생성되지 않았는지" 확인해야 한다. 그래서 쓰기 도구에는 idempotency_key나 고유 요청 ID를 두는 것이 좋다.

그리고 재시도 간격은 고정 1초가 아니라 지수 백오프를 쓴다. 1초, 2초, 4초, 8초처럼 늘리고, 여기에 약간의 지터(jitter)를 섞어 여러 클라이언트가 한꺼번에 덤비지 않게 한다. 이런 패턴은 MCP 프록시 도구들에서도 기본 회복 전략으로 채택되고 있다.6

회로 차단: 아픈 서버를 잠깐 쉬게 하기

회로 차단기는 말 그대로 퓨즈다. 어떤 의존성이 계속 실패하면, 한동안은 아예 호출하지 않고 즉시 실패시킨다. 괜히 아픈 서비스에 계속 요청을 던져 전체 시스템까지 같이 아프게 만들지 않기 위해서다.

상태는 보통 세 가지다.

  • Closed: 정상 상태, 요청을 통과시킨다

  • Open: 실패가 임계치를 넘겨 즉시 거절한다

  • Half-Open: 잠시 후 테스트 요청 몇 개만 허용해 회복 여부를 본다

예를 들어 PKM-MCP가 외부 GitHub API에서 이슈를 가져오는 동안 GitHub 쪽 장애가 났다고 하자. 이때 매 요청마다 30초씩 기다리며 실패하는 것보다, 5번 연속 실패 후 60초 동안 Open으로 돌려서 빠르게 실패시키는 편이 낫다. 사용자는 적어도 "지금 GitHub 연동이 아프다"는 사실을 바로 알고, 다른 메모 기능은 계속 쓸 수 있다.6

만들기: PKM-MCP에 구조화된 로그와 감사 로그 추가

이제 메인 프로젝트에 눈을 달아 보자. 목표는 두 가지다. 하나는 운영용 구조화 로그를 남기는 것, 다른 하나는 사후 추적용 감사 로그를 남기는 것이다. 둘은 비슷해 보이지만 목적이 다르다.

구조화 로그는 디버깅과 모니터링을 위한 것이다. 양이 많고, 상대적으로 짧게 보관할 수 있다. 감사 로그는 누가 무엇을 했는지 남기는 기록이다. 더 엄격하게 보관하고, 수정 불가에 가깝게 다뤄야 한다.

1. 먼저 남길 필드를 정한다

PKM-MCP의 감사 로그에는 최소한 아래 정보가 들어가야 한다.

  • 누가: actor_id, tenant_id, session_id

  • 무엇을: tool, action, resource_type, resource_id

  • 어떻게: auth_mode, scopes, ip_hash

  • 결과: outcome, error_class, duration_ms

  • 영향: side_effects, cost_estimate, retry_safe

여기서 ip_hash처럼 원문 대신 해시를 저장하는 습관이 좋다. 개인정보를 그냥 로그에 흘리지 않기 위해서다.

2. stdout 대신 stderr로 JSON 로그를 보낸다

아래는 Python 기준의 단순한 JSON 로거다. stdio 서버에서도 안전하게 쓰려면 sys.stderr로 붙인다.2

# app/observability.py
import json
import logging
import sys
from contextvars import ContextVar
from datetime import datetime, timezone

request_ctx: ContextVar[dict] = ContextVar('request_ctx', default={})

class JsonFormatter(logging.Formatter):
    def format(self, record):
        payload = {
            'ts': datetime.now(timezone.utc).isoformat(),
            'level': record.levelname.lower(),
            'logger': record.name,
            'message': record.getMessage(),
        }
        payload.update(request_ctx.get())
        payload.update(getattr(record, 'fields', {}))

        if record.exc_info:
            payload['exception'] = self.formatException(record.exc_info)

        return json.dumps(payload, ensure_ascii=False)


def build_logger(name='pkm-mcp'):
    logger = logging.getLogger(name)
    logger.setLevel(logging.INFO)
    handler = logging.StreamHandler(sys.stderr)
    handler.setFormatter(JsonFormatter())
    logger.handlers = [handler]
    logger.propagate = False
    return logger

logger = build_logger()

이제 어디서 로그를 찍든 request_ctx에 담긴 trace_id, session_id, actor_id가 자동으로 붙는다. 로그가 갑자기 줄을 맞춰 입기 시작하는 순간이다.

3. 입력은 요약해서 남긴다

노트 서버는 텍스트를 다루기 때문에, 원문을 그대로 로그에 남기면 금방 민감 정보 저장소가 된다. 그래서 도구별 요약 함수를 하나 둔다.

# app/log_summarizer.py
import hashlib


def sha_preview(text: str) -> str:
    return hashlib.sha256(text.encode('utf-8')).hexdigest()[:12]


def summarize_input(tool_name: str, kwargs: dict) -> dict:
    if tool_name in {'search_notes', 'semantic_search_notes'}:
        query = kwargs.get('query', '')
        return {
            'query_len': len(query),
            'query_hash': sha_preview(query) if query else None,
            'limit': kwargs.get('limit', 10),
        }

    if tool_name in {'create_note', 'update_note'}:
        title = kwargs.get('title', '')
        content = kwargs.get('content', '')
        return {
            'note_id': kwargs.get('note_id'),
            'title_len': len(title),
            'content_chars': len(content),
            'content_hash': sha_preview(content) if content else None,
        }

    if tool_name == 'delete_note':
        return {'note_id': kwargs.get('note_id')}

    return {'keys': sorted(kwargs.keys())}

이렇게 하면 "어떤 종류의 요청이 얼마나 많이 들어왔는가"는 알 수 있지만, 메모 본문 그 자체는 로그에 남지 않는다.

4. 공통 데코레이터로 도구 호출을 감싼다

관측성은 한두 군데 열심히 찍는다고 생기지 않는다. 모든 주요 도구에 같은 형식으로 붙어야 한다. 그래서 공통 래퍼를 만드는 편이 좋다.

# app/observed.py
import time
import uuid
from functools import wraps

from app.observability import logger, request_ctx
from app.log_summarizer import summarize_input
from app.audit import append_audit


def observed_tool(tool_name: str, *, read_only: bool, idempotent: bool, retry_safe: bool):
    def decorator(fn):
        @wraps(fn)
        async def wrapper(*args, **kwargs):
            trace_id = uuid.uuid4().hex
            session_id = kwargs.pop('_session_id', 'unknown')
            actor_id = kwargs.pop('_actor_id', 'anonymous')
            auth_mode = kwargs.pop('_auth_mode', 'none')

            token = request_ctx.set({
                'trace_id': trace_id,
                'session_id': session_id,
                'actor_id': actor_id,
                'tool': tool_name,
            })

            started = time.perf_counter()
            input_summary = summarize_input(tool_name, kwargs)

            logger.info('tool_call_start', extra={'fields': {
                'event': 'tool_call_start',
                'auth_mode': auth_mode,
                'read_only': read_only,
                'idempotent': idempotent,
                'retry_safe': retry_safe,
                'input_summary': input_summary,
            }})

            try:
                result = await fn(*args, **kwargs)
                duration_ms = int((time.perf_counter() - started) * 1000)

                logger.info('tool_call_end', extra={'fields': {
                    'event': 'tool_call_end',
                    'outcome': 'success',
                    'duration_ms': duration_ms,
                }})

                append_audit({
                    'trace_id': trace_id,
                    'session_id': session_id,
                    'actor_id': actor_id,
                    'tool': tool_name,
                    'auth_mode': auth_mode,
                    'outcome': 'success',
                    'duration_ms': duration_ms,
                    'input_summary': input_summary,
                    'side_effects': [] if read_only else ['state_changed'],
                })
                return result

            except Exception as e:
                duration_ms = int((time.perf_counter() - started) * 1000)
                error_class = e.__class__.__name__

                logger.exception('tool_call_error', extra={'fields': {
                    'event': 'tool_call_error',
                    'outcome': 'error',
                    'duration_ms': duration_ms,
                    'error_class': error_class,
                }})

                append_audit({
                    'trace_id': trace_id,
                    'session_id': session_id,
                    'actor_id': actor_id,
                    'tool': tool_name,
                    'auth_mode': auth_mode,
                    'outcome': 'error',
                    'duration_ms': duration_ms,
                    'error_class': error_class,
                    'input_summary': input_summary,
                    'side_effects': [],
                })
                raise
            finally:
                request_ctx.reset(token)

        return wrapper
    return decorator

이제 도구 함수는 비즈니스 로직에 집중하고, 기록은 래퍼가 맡는다.

5. 감사 로그는 별도 테이블에 저장한다

감사 로그는 파일만으로도 시작할 수 있지만, 조회와 보존 정책을 생각하면 SQLite 테이블 하나를 두는 편이 낫다.

# app/audit.py
import json
import sqlite3
from pathlib import Path
from datetime import datetime, timezone

DB_PATH = Path('data/pkm_audit.db')
DB_PATH.parent.mkdir(parents=True, exist_ok=True)


def init_audit_db():
    with sqlite3.connect(DB_PATH) as conn:
        conn.execute('''
        create table if not exists audit_logs (
            id integer primary key autoincrement,
            ts text not null,
            trace_id text,
            session_id text,
            actor_id text,
            tool text not null,
            auth_mode text,
            outcome text not null,
            duration_ms integer,
            error_class text,
            input_summary text,
            side_effects text
        )
        ''')


def append_audit(event: dict):
    with sqlite3.connect(DB_PATH) as conn:
        conn.execute(
            '''
            insert into audit_logs (
                ts, trace_id, session_id, actor_id, tool, auth_mode,
                outcome, duration_ms, error_class, input_summary, side_effects
            ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            ''',
            (
                datetime.now(timezone.utc).isoformat(),
                event.get('trace_id'),
                event.get('session_id'),
                event.get('actor_id'),
                event.get('tool'),
                event.get('auth_mode'),
                event.get('outcome'),
                event.get('duration_ms'),
                event.get('error_class'),
                json.dumps(event.get('input_summary', {}), ensure_ascii=False),
                json.dumps(event.get('side_effects', []), ensure_ascii=False),
            )
        )

실전에서는 여기에 tenant_id, resource_id, scope_set, ip_hash, tool_version도 추가하는 편이 좋다. 특히 3부 이후의 OAuth 환경에서는 사용자 분리와 권한 추적이 훨씬 중요해진다.

6. PKM 도구에 적용한다

이제 도구에 붙이는 일은 의외로 간단하다.

from app.observed import observed_tool

@mcp.tool()
@observed_tool('search_notes', read_only=True, idempotent=True, retry_safe=True)
async def search_notes(query: str, limit: int = 10):
    rows = note_repo.search(query=query, limit=limit)
    return {
        'count': len(rows),
        'items': [
            {
                'id': row.id,
                'title': row.title,
                'snippet': row.content[:160],
                'updated_at': row.updated_at,
            }
            for row in rows
        ],
        'truncated': False,
    }


@mcp.tool()
@observed_tool('delete_note', read_only=False, idempotent=True, retry_safe=True)
async def delete_note(note_id: str):
    deleted = note_repo.soft_delete(note_id)
    if not deleted:
        raise ValueError('note not found')
    return {'ok': True, 'note_id': note_id}

여기서 delete_note를 idempotent=True로 둔 이유는 소프트 삭제를 쓴다고 가정했기 때문이다. 이미 삭제된 노트를 다시 삭제해도 최종 상태가 같다면 재시도 안전성을 높일 수 있다. 반대로 create_note는 보통 idempotent=False이며, idempotency_key 없이 재시도하면 위험하다.

7. 메트릭과 트레이싱 훅도 같이 심어 둔다

이번 장의 만들기 목표는 로그와 감사 로그지만, 도구 래퍼에 메트릭과 트레이싱 훅을 미리 넣어 두면 다음 운영이 편해진다.

from opentelemetry import metrics, trace

meter = metrics.get_meter('pkm-mcp')
tracer = trace.get_tracer('pkm-mcp')

tool_calls = meter.create_counter('pkm_tool_calls_total')
tool_failures = meter.create_counter('pkm_tool_failures_total')
tool_duration = meter.create_histogram('pkm_tool_duration_ms', unit='ms')

그리고 observed_tool 안에서 이런 식으로 붙인다.

with tracer.start_as_current_span(f'tool.{tool_name}') as span:
    span.set_attribute('mcp.tool', tool_name)
    span.set_attribute('mcp.read_only', read_only)
    tool_calls.add(1, {'tool': tool_name})
    ...
    tool_duration.record(duration_ms, {'tool': tool_name, 'outcome': 'success'})

이 정도만 해도 나중에 Jaeger, Tempo, Datadog, Langfuse, Prometheus 같은 곳으로 연결할 길이 열린다.4

8. 실제로 어떤 로그가 남는가

성공한 검색은 이런 로그로 남길 수 있다.

{
  "ts": "2026-04-26T11:20:41.133Z",
  "level": "info",
  "logger": "pkm-mcp",
  "message": "tool_call_end",
  "trace_id": "6c7e4b1e2e334f90a3d7d1bdb4c3a9c4",
  "session_id": "ses_123",
  "actor_id": "user_42",
  "tool": "search_notes",
  "event": "tool_call_end",
  "outcome": "success",
  "duration_ms": 54
}

실패한 삭제는 감사 로그에 이렇게 남는다.

{
  "trace_id": "bcf813...",
  "session_id": "ses_123",
  "actor_id": "user_42",
  "tool": "delete_note",
  "auth_mode": "oauth",
  "outcome": "error",
  "duration_ms": 12,
  "error_class": "ValueError",
  "input_summary": {
    "note_id": "note_999"
  },
  "side_effects": []
}

이제 누가 실패했는지, 어떤 도구가 느린지, 재시도해도 되는지, 삭제가 실제로 일어났는지 같은 질문에 서버가 입을 열기 시작한다.

운영 체크리스트

PKM-MCP를 배포하기 전에 아래 세 가지만 확인해도 운영 난이도가 크게 내려간다.

  • 모든 도구 호출에 trace_id, session_id, actor_id가 붙는가

  • 쓰기 도구에 대해 idempotent와 retry_safe 판단이 명시되어 있는가

  • 검색 결과가 페이지네이션과 요약 중심으로 설계되어 있는가

여기에 하나만 더 보태자. 장애가 났을 때 로그를 읽을 사람은 미래의 나다. 대개 새벽 두 시의 나다. 그 사람은 친절한 설명보다도, 정리된 필드와 일관된 이벤트 이름을 더 사랑한다.

정리

관측성의 핵심은 "기록을 많이 남기는 것"이 아니다. "복구에 필요한 증거를 남기는 것"이다.

로그는 사건을 설명하고, 메트릭은 이상 징후를 빨리 알려 주고, 트레이싱은 병목의 위치를 보여 준다. 여기에 감사 로그까지 붙으면, PKM-MCP는 단순히 돌아가는 서버에서 운영 가능한 서버로 올라선다.

이제 우리의 개인 지식 서버는 메모를 읽고 쓰는 것만 하는 수준을 넘어, 문제가 생겼을 때 스스로 무슨 일이 있었는지 증언할 수 있는 서버가 되었다. 서버가 말을 잘하면, 운영자는 밤에 조금 더 잘 잔다.

참고

1MCP Observability: Logging, Auditing, and Debugging Agent-Server Interactions in Production

2MCP Logging & Observability: Debugging Servers You Can't See Into

3MCP Observability: Logging, Auditing, and Debugging Agent-Server Interactions in Production

4Observability - mcp-agent

5Connect Claude Code to tools via MCP

6GitHub - DainoJung/mcp-shield: Production-grade resilience middleware for MCP servers — timeout, retry, circuit breaker, structured logging.

책 목록

만들면서 배우는 MCP: PKM-MCP로 끝내는 설계·구현·운영·배포

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기