CodeGym /행동 /ChatGPT Apps /구조화 로그와 요청 상관관계

구조화 로그와 요청 상관관계

ChatGPT Apps
레벨 17 , 레슨 0
사용 가능

1. 왜 ChatGPT App에서 구조화 로그가 필요한가

상황을 상상해 봅시다. 프로덕트 매니저가 이렇게 말합니다: “사용자들이 선물 선택 시 가끔 빈 목록이 보이고, 가끔은 체크아웃이 실패한다고 합니다. 내일 데모까지 고칠 수 있을까요?”. 당신에게는 다음이 있습니다:

  • 가끔 당신의 App을 호출하는 ChatGPT(가끔은 호출하지 않음)
  • 샌드박스의 위젯
  • 외부 상품 DB와 ACP를 호출하는 MCP 서버
  • 결제 시스템에서 오는 웹훅

그리고 MCP 어딘가에는 “something went wrong”, 백엔드 어딘가에는 “order failed” 같은 흩어진 텍스트 로그뿐입니다. 병렬 요청이 많은 상황에서는 완전한 혼돈이 됩니다. 어떤 로그가 어떤 사용자와 어떤 요청에 해당하는지 알 수가 없습니다.

바로 여기서 구조화된 JSON 로그와 통합 trace_id가 필요합니다. 이를 통해 다음이 가능합니다:

  • 하나의 식별자로 전체 체인을 파악: ChatGPT 요청부터 웹훅 "order.created"까지;
  • 서비스, 도구, 사용자, 시나리오별로 로그를 필터링;
  • “왜 체크아웃이 실패했는가”, “에이전트가 환각을 시작하기 전 무엇을 했는가” 같은 질문에 빠르게 답하기.

즉, 목표는 간단합니다. 프로덕션의 GiftGenius를 일반적인 마이크로서비스 애플리케이션 못지않게 디버깅하고 모니터링할 수 있게 만드는 것입니다.

2. 문자열 로그 vs 구조화 로그: 왜 console.log("앗")만으로는 더 이상 충분하지 않은가

일반적인 Next.js 개발에서는 많은 경우 문자열 로그로만 충분하다고 생각합니다. 사람이 읽을 수 있는 문장과 가끔 몇 가지 값을 출력하죠. 단일 서비스라면 그럭저럭 버틸 수 있습니다. 하지만 ChatGPT App 스택에서는 이런 로그가 매우 빠르게 혼란스러워집니다.

텍스트 로그는 파일이나 콘솔의 한 줄일 뿐입니다. 예를 들어:


console.error(`Error in suggestGifts for user ${userId}: ${error.message}`);

이런 메시지가 10만 건이라면 “어제 userId=…로 체크아웃 중 발생한 MCP 에러 전부”를 찾는 것만으로도 이미 쉽지 않습니다. 도구별 에러 대시보드를 자동으로 만드는 건 거의 불가능에 가깝습니다.

구조화 로그는 메시지 텍스트 외에도 레벨, 시간, 서비스, 식별자, 기술/비즈니스 컨텍스트 등 필드 세트를 갖는 JSON 객체입니다. 앞선 예시를 구조화하면:

logger.error({
    message: "suggest_gifts failed",
    user_id: userId,
    trace_id,
    service: "mcp",
    tool_name: "suggest_gifts",
    error_message: error.message,
});

각 필드는 로깅 시스템(ELK, Loki, Better Stack, Datadog 등)에 의해 인덱싱되며, 다음과 같은 쿼리를 작성할 수 있습니다: service="mcp" AND level="error" AND tool_name="suggest_gifts" 혹은 단순히 trace_id="..."로 검색할 수도 있습니다.

비교를 위해 작은 표를 보겠습니다.

비교 항목 문자열 로그 구조화(JSON) 로그
파싱 수동, 정규식(regex) 기반 필드 기반 자동 파싱
필드별 검색 복잡한 정규식 쿼리 간단한 표현식 field=value
집계와 대시보드 어렵고, 많은 편법 필요 아주 간단: count() , group by field
컨텍스트 확장 메시지 텍스트 안에 기술 스키마 변경 없이 새 필드 추가
요청 상관관계 병렬 요청에서는 거의 불가능 trace_id/request_id로 일반적인 검색

LLM 애플리케이션 세계에서는 문제의 절반이 “500 에러”가 아니라 “모델이 틀린 도구를 호출했다”는 유형입니다. 구조화 로그 없이는 사실상 눈을 가린 셈입니다.

3. ChatGPT App를 위한 JSON 로그의 구성

이제 GiftGenius 모든 레이어에서 사용할 “최소 표준” 로그 레코드에 합의하겠습니다. 완벽하진 않지만 80%의 과제를 커버합니다.

로그 필드를 몇 가지 그룹으로 나눠봅시다.

기술적 필드

기술적 필드는 관측 도구가 “이 레코드가 어디서 왔는지”를 이해하는 데 필요합니다.

TypeScript 타입으로 표현하면:

type LogLevel = "debug" | "info" | "warn" | "error";

interface BaseLogFields {
    timestamp: string;    // ISO 8601 UTC
    level: LogLevel;      // "info", "error"...
    service: string;      // "app-widget", "mcp", "agent", "commerce", "webhook"
    env: "dev" | "staging" | "prod";
    message: string;      // 이벤트에 대한 간단한 설명
}

timestamp는 UTC의 ISO 형식을 사용하는 것이 좋습니다("2025-11-21T10:15:30.123Z"). 그러면 서로 다른 서비스의 로그도 타임존 문제 없이 시간순으로 정렬할 수 있습니다. serviceenv는 예를 들어 프로덕션 MCP 로그와 dev의 위젯 로그를 구분하는 데 도움이 됩니다. 이는 나중에 OpenTelemetry를 도입해 service.name, service.version 같은 공통 컨벤션을 사용할 때도 특히 유용합니다.

상관관계 필드

이 강의에서 가장 중요한 부분입니다. 이것 없이는 이벤트를 서로 연결할 수 없습니다.

인터페이스에 다음을 추가합니다:

interface CorrelationFields {
    trace_id: string;        // 시나리오 전체의 엔드 투 엔드 ID
    span_id?: string;        // (옵션) 특정 오퍼레이션의 ID
    parent_span_id?: string; // (옵션) 부모 오퍼레이션 ID
    request_id?: string;     // HTTP 요청 또는 tool-call의 로컬 ID
    agent_run_id?: string;   // 에이전트 실행 ID(있다면)
    tool_call_id?: string;   // 특정 도구 호출의 ID
    checkout_session_id?: string; // ACP/결제 세션 ID
}

trace_id가 주인공입니다. “사용자가 선물 추천을 요청했고, 우리가 추천하고, 주문을 만들고, 웹훅을 받았다”라는 시나리오에 관련된 모든 로그에서 이 값은 동일해야 합니다. span_idparent_span_id는 이후에 분산 트레이싱 스타일로 “오퍼레이션 트리”를 구성하게 해 주지만, 시작 단계에서는 trace_idrequest_id만으로도 충분합니다.

비즈니스 컨텍스트

비즈니스 컨텍스트 없는 기술 로그는 “어딘가에서, 언젠가, 무언가가 일어남”에 불과합니다. 어떤 사용자가 어떤 시나리오의 어떤 단계에서 영향을 받았는지 알아야 합니다.

인터페이스를 확장합니다:

interface BusinessFields {
    user_id?: string;     // 익명 ID, 이메일 아님
    tenant_id?: string;   // B2B의 경우 조직/계정
    flow?: string;        // 예: "gift_recommendation" 또는 "checkout"
    step?: string;        // 예: "collect_requirements" 또는 "create_checkout"
}

원칙은 간단합니다. 식별자는 내부용(당신의 DB에서 나온 UUID 등)일 수 있지만, PII(이메일, 전화번호, 실명)를 포함해서는 안 됩니다. 이에 대해서는 보안 섹션에서 더 이야기합니다.

에러 필드

에러는 별도의 주제입니다. 일반적인 에러 로그는 적어도 타입, 코드, 텍스트로 나누는 것이 좋습니다:

interface ErrorFields {
    error_type?: "validation" | "upstream" | "timeout" | "system";
    error_code?: string;       // HTTP 상태, DB 코드 또는 자체 enum
    error_message?: string;    // 간결하고 안전한 메시지
    stack?: string;            // 스택 트레이스(용량/PII에 유의)
}

중요한 점: error_message에 민감한 정보(예: “카드 4111 1111 1111 1111에 대해 실패”)가 들어가면 안 됩니다. "payment provider declined card"처럼 안전한 메시지와 코드 조합이 좋습니다.

완전한 로그 인터페이스

모든 것을 합치면:

export interface LogEvent
    extends BaseLogFields,
        CorrelationFields,
        BusinessFields,
        ErrorFields {
    // 추가 필드를 위한 확장 공간
    [key: string]: unknown;
}

이 인터페이스는 MCP 서버, 커머스 백엔드, 에이전트에서 모두 사용할 수 있습니다. 그러면 모든 서비스가 동일한 형식으로 로그를 작성하게 되고, 상관관계 분석은 고난이 아니라 산책이 됩니다.

4. GiftGenius를 위한 최소 JSON 로거(MCP 서버)

아주 미니멀하게 시작해 보겠습니다. MCP 서버가 Node.js/TypeScript 애플리케이션이라고 가정합시다. logger 유틸을 만듭니다:

// mcp/logging.ts
import { LogEvent, LogLevel } from "./types";

function log(level: LogLevel, event: Omit<LogEvent, "level" | "timestamp">) {
    const enriched: LogEvent = {
        timestamp: new Date().toISOString(),
        level,
        env: process.env.NODE_ENV === "production" ? "prod" : "dev",
        ...event,
    };

    // JSON을 stdout으로 출력 — 이후 로그 수집 시스템이 수집
    console.log(JSON.stringify(enriched));
}

export const logger = {
    debug: (event: Omit<LogEvent, "level" | "timestamp">) =>
        log("debug", event),
    info: (event: Omit<LogEvent, "level" | "timestamp">) =>
        log("info", event),
    warn: (event: Omit<LogEvent, "level" | "timestamp">) =>
        log("warn", event),
    error: (event: Omit<LogEvent, "level" | "timestamp">) =>
        log("error", event),
};

이건 Pino나 Winston은 아니지만, 이 강의에서 중요한 건 “모든 로그를 적절한 필드를 갖춘 JSON으로 기록한다”는 아이디어입니다.

이제 MCP 도구 suggest_gifts의 핸들러에서 사용해 봅시다.

5. MCP 도구 로깅: 입력부터 출력까지

이미 사용자 선호를 받아 SKU 목록을 반환하는 suggest_gifts 도구 핸들러가 있다고 가정합니다. 여기에 로그를 추가하겠습니다.

HTTP 헤더 x-trace-id에서 미리 trace_id를 가져왔다고 합시다(이를 헤더에 넣는 방법은 다음 블록의 상관관계 파트에서 다룹니다).

// mcp/tools/suggestGifts.ts
import { logger } from "../logging";

export async function suggestGiftsTool(args: SuggestGiftsArgs, ctx: {
  traceId: string;
  userId?: string;
}) {
  logger.info({
    message: "suggest_gifts called",
    service: "mcp",
    trace_id: ctx.traceId,
    user_id: ctx.userId,
    tool_name: "suggest_gifts",
    flow: "gift_recommendation",
    step: "fetch_candidates",
  });

  try {
    const gifts = await fetchGiftsFromCatalog(args);

    logger.info({
      message: "suggest_gifts succeeded",
      service: "mcp",
      trace_id: ctx.traceId,
      user_id: ctx.userId,
      tool_name: "suggest_gifts",
      flow: "gift_recommendation",
      step: "rank_candidates",
      result_count: gifts.length,
    });

    return gifts;
  } catch (error: any) {
    logger.error({
      message: "suggest_gifts failed",
      service: "mcp",
      trace_id: ctx.traceId,
      user_id: ctx.userId,
      tool_name: "suggest_gifts",
      flow: "gift_recommendation",
      step: "fetch_candidates",
      error_type: "upstream",
      error_message: error.message,
    });
    throw error;
  }
}

이제 하나의 trace_id로 다음을 모두 확인할 수 있습니다:

  • 도구가 실제로 호출되었는지;
  • 몇 개의 후보가 나왔는지;
  • 어떤 단계에서 실패했는지.

이 과정에서 이메일이나 사용자 이름은 등장하지 않습니다. 내부 user_id만 사용합니다.

6. ChatGPT App에서 trace_id는 어디에서 생성되는가

이제 trace_id가 어디에서 생성되어야 하는지 살펴봅시다. 중요한 점은, 이것이 특정 요청에 묶여 있지 않다는 것입니다. trace_id는 비즈니스 오퍼레이션의 식별자입니다. 따라서 두 가지 전형적인 상황을 구분해야 합니다:

“좁은” MCP 도구

도구가 하나의 작은 작업을 수행하고 바로 결과를 반환하는 경우(인터랙티브 UI 없음)입니다:

  • get_gifts_for_budget
  • calculate_price
  • save_lead

이 경우 다음과 같이 생각하는 것이 편합니다: MCP 도구 한 번 호출 = 하나의 비즈니스 요청 = 하나의 trace. 엔드 투 엔드 trace_idMCP 게이트웨이/서버 측에서 tool-call이 들어올 때 생성됩니다(또는 OpenTelemetry를 사용한다면 기존 트레이싱 컨텍스트에서 가져옵니다). 이후 이 trace_id는 내부 호출(REST 서비스, DB, 큐 등) 전체에서 사용되며, 로그의 trace_id 필드로 기록됩니다.

ChatGPT와 Apps SDK는 여기에 개입하지 않습니다. 단지 JSON-RPC tool-call을 보낼 뿐이며, 트레이싱은 당신의 통제 영역에서 시작됩니다.

“넓은” MCP 도구(위젯 반환)

여기서는 도구가 비즈니스 오퍼레이션을 끝까지 완료하지 않고, 인터랙티브한 장면을 시작합니다. 즉, 위젯을 반환하고, 위젯은 샌드박스에서 수많은 fetch() 요청(선물 목록 로드, 필터, 체크아웃 등)을 보냅니다.

이런 시나리오에서 엔드 투 엔드 트레이싱은 다음과 같습니다:

  • 주요 비즈니스 오퍼레이션은 위젯의 백엔드로 가는 HTTP 요청 안에 있습니다.
  • 따라서 위젯에서 백엔드로 보내는 각 의미 있는 fetch() 요청은 자체 trace_id를 받으며, 이는 백엔드/게이트웨이(해당 fetch의 첫 서버 hop)에서 생성됩니다.

ChatGPT도, 위젯 자체도 trace_id의 “진실의 근원”이 아닙니다. 이들은 session_id, widget_id, user_id같은 보조 식별자를 요청에 실어 보낼 수 있을 뿐이고, trace_id의 생성과 관리는 서버에서 이뤄집니다.

“좁은” MCP 도구: tool-call당 하나의 trace

위젯이 없는 “좁은” 도구의 플로우는 다음과 같습니다:

sequenceDiagram
    participant ChatGPT as ChatGPT / Agent
    participant MCP as MCP Server
    participant GiftAPI as Gift API
    participant Pricing as Pricing API

    ChatGPT->>MCP: JSON-RPC tools.call get_gifts
    MCP->>MCP: start trace (trace_id = T-123)
    MCP->>GiftAPI: GET /gifts (x-trace-id = T-123)
    GiftAPI-->>MCP: 200 OK (trace_id = T-123)
    MCP->>Pricing: GET /price (x-trace-id = T-123)
    Pricing-->>MCP: 200 OK (trace_id = T-123)
    MCP-->>ChatGPT: tool result (선택적으로 trace_id 포함)

패턴:

  • tool-call이 MCP에 들어올 때 trace를 생성합니다(또는 traceparent/x-trace-id에서 이미 존재하는 값을 가져옵니다);
  • 해당 tool-call의 전체 경로(서비스 호출, DB, 캐시)는 동일한 trace_id로 로깅됩니다;
  • 위젯은 등장하지 않으므로 로그에도 위젯은 없습니다.

이 접근은 다음을 제공합니다:

  • 하나의 오퍼레이션에 대한 명확한 “스냅샷”: “MCP 도구 suggest_gifts → Gift API → Pricing API → 응답”
  • 도구 호출 1회당 trace_id 1개

“넓은” MCP 도구: 위젯과 여러 개의 trace

이제 MCP 도구가 위젯을 반환하는 GiftGenius 시나리오입니다:

  1. ChatGPT가 MCP 도구를 호출합니다. 예: open_gift_widget.
  2. MCP 도구는 위젯 설명(layout, initial state)을 생성해 반환합니다.
  3. 위젯이 샌드박스에 마운트되고 자체 흐름으로 동작합니다:
    • GET /api/gifts?budget=50&page=1
    • GET /api/gifts?budget=50&filter=for_developers
    • POST /api/checkout
    • POST /api/save-lead
  4. 각각의 HTTP 요청이 당신의 Next.js 백엔드/게이트웨이에 도착하며, 그곳에서 새로운 trace를 생성합니다:
fetch #1  -> trace_id = T-501  (첫 번째 선물 페이지 로드)
fetch #2  -> trace_id = T-502  (“for_developers” 필터 적용)
fetch #3  -> trace_id = T-503  (체크아웃 생성)
...

요약하면:

  • MCP 도구는 “넓은” 역할을 합니다. 핵심 목표는 위젯을 여는 것이며, 전체 비즈니스 체인을 완료하는 것이 아닙니다.
  • 실제 비즈니스 로직(선물 목록, 상위 선물 선택, 체크아웃)은 백엔드에 있으며, 위젯의 fetch()를 처리합니다.
  • 하나의 비즈니스 시나리오로 묶인 fetch() 요청 묶음은 고유한 trace_id를 갖고, 이는 서버가 HTTP 요청 진입 시 생성합니다.

추가로 각 trace에 다음을 함께 전달할 수 있습니다:

  • session_id(ChatGPT 세션 ID가 있다면),
  • widget_id,
  • user_id,
  • tool_run_id 또는 기타 컨텍스트.

trace_id로는 특정 오퍼레이션(“체크아웃 #3”)을, session_id / widget_id로는 하나의 위젯/세션에서 벌어진 모든 일을 확인할 수 있습니다.

7. 요청 상관관계: trace_id가 App, MCP, 위젯, 백엔드를 어떻게 통과하는가

이제 가장 흥미로운 부분입니다. 필요한 식별자들이 ChatGPT, MCP 서버, 위젯, 커머스 백엔드, 웹훅까지 모든 레이어를 통과하도록 하는 방법을 살펴봅니다.

trace_id가 흐르는 요청 플로우(“넓은” 경우의 다이어그램)

GiftGenius의 예시 플로우는 다음과 같습니다:

sequenceDiagram
    participant ChatGPT as ChatGPT UI
    participant MCP as MCP Server
    participant Widget as GiftGenius Widget
    participant Backend as Next.js Backend
    participant ACP as Commerce API
    participant WH as Webhook Handler

    ChatGPT->>MCP: tools.call open_gift_widget
    MCP-->>ChatGPT: Widget description (layout, config)
    ChatGPT->>Widget: 샌드박스에서 위젯 렌더링

    Widget->>Backend: GET /api/gifts (trace_id = T-501, Backend에서 생성)
    Backend->>ACP: GET /gifts (x-trace-id = T-501)
    ACP-->>Backend: 200 OK (trace_id = T-501)
    Backend-->>Widget: 선물 JSON 응답(로그에는 trace_id = T-501)

    Widget->>Backend: POST /api/checkout (trace_id = T-503, Backend에서 생성)
    Backend->>ACP: POST /checkout (x-trace-id = T-503)
    ACP-->>Backend: 200 OK (trace_id = T-503)
    ACP-->>WH: webhook order.created (x-trace-id = T-503)
    WH->>WH: 이벤트 기록(trace_id = T-503)

주의할 점:

  • 이 시나리오에서 trace_id위젯에서 생성되지 않습니다.
  • 백엔드(Next.js route handler, API 게이트웨이 등)의 HTTP 요청 진입 지점에서 생성됩니다.
  • 이후 trace_id는 다음으로 전달됩니다:
    • 백엔드 로그,
    • ACP 호출 시 x-trace-id 헤더,
    • ACP가 이를 반환/전파한다면 웹훅으로까지.

6.5. 위젯 호출을 위한 백엔드에서의 trace_id 생성과 전파

trace_id위젯이 아닌 백엔드에서 생성된다는 점을 명확히 하기 위해 예시를 다시 작성해 보겠습니다.

// app/api/mcp/tools/call/route.ts (Next.js backend, MCP로의 프록시)
import { NextRequest, NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";
import { logger } from "@/mcp/logging";

export async function POST(req: NextRequest) {
  // 외부(예: 게이트웨이)에서 trace_id가 왔다면 사용.
  // 없다면 백엔드 진입 시 새로 생성.
  const incomingTraceId = req.headers.get("x-trace-id");
  const traceId = incomingTraceId ?? uuidv4();
  const requestId = uuidv4();

  logger.info({
    message: "mcp.tools.call received from widget",
    service: "backend",
    trace_id: traceId,
    request_id: requestId,
  });

  const body = await req.json();

  const res = await fetch(process.env.MCP_SERVER_URL!, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-trace-id": traceId,
    },
    body: JSON.stringify(body),
  });

  const json = await res.json();

  logger.info({
    message: "mcp.tools.call completed",
    service: "backend",
    trace_id: traceId,
    request_id: requestId,
  });

  return NextResponse.json(json);
}

MCP 서버 쪽에서는 이 헤더를 읽어 trace_id를 자체 로그에 사용하기만 하면 됩니다(섹션 5의 예시처럼).

위젯은 trace_id의 존재를 몰라도 됩니다. /api/mcp/tools/call을 호출하기만 하면 됩니다. 다만, UI에서 트레이싱과 묶어 동작을 표시/로깅하고 싶다면 응답에 trace_id를 포함해 돌려줄 수 있습니다. 그러면 자체 JSON 로그에 "service": "app-widget" 같은 값을 넣을 수 있습니다(클라이언트 로깅 또는 SaaS 분석 도구 등).

위젯에서 MCP를 호출하는 클라이언트 예시

// app/lib/mcpClient.ts (위젯)
export async function callMcpTool(toolName: string, args: unknown) {
  const res = await fetch("/api/mcp/tools/call", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      // trace_id는 여기서 생성하지 않음 — 백엔드에서 생성됨
    },
    body: JSON.stringify({ toolName, args }),
  });

  // 백엔드가 응답 본문에 trace_id를 넣어주면 저장 가능
  const data = await res.json();
  return data;
}

원한다면 백엔드 핸들러를 확장해 JSON 응답에 trace_id를 포함시킬 수 있습니다. 그러면 위젯은 다음을 할 수 있습니다:

  • "service": "app-widget", "trace_id": "..." 형태로 이벤트 로깅;
  • 개발자를 위한 trace 링크 표시.

그러나 원칙은 동일합니다. 근원 trace_id는 위젯이 아니라 서버입니다.

ACP/커머스로 trace_id를 전파

이제 MCP 도구 create_checkout_session 내부에서 커머스 API를 호출할 때 헤더로 trace_id를 계속 전달합니다:

// mcp/tools/createCheckout.ts
import { logger } from "../logging";

export async function createCheckoutTool(
  args: CreateCheckoutArgs,
  ctx: { traceId: string; userId?: string }
) {
  logger.info({
    message: "create_checkout called",
    service: "mcp",
    trace_id: ctx.traceId,
    user_id: ctx.userId,
    tool_name: "create_checkout_session",
    flow: "checkout",
    step: "create_session",
  });

  const res = await fetch(process.env.COMMERCE_URL + "/checkout", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-trace-id": ctx.traceId,
    },
    body: JSON.stringify({
      userId: ctx.userId,
      ...args,
    }),
  });

  if (!res.ok) {
    logger.error({
      message: "checkout API failed",
      service: "mcp",
      trace_id: ctx.traceId,
      user_id: ctx.userId,
      flow: "checkout",
      step: "create_session",
      error_type: "upstream",
      error_code: String(res.status),
    });
    throw new Error("Checkout API failed");
  }

  const data = await res.json();

  logger.info({
    message: "checkout session created",
    service: "mcp",
    trace_id: ctx.traceId,
    user_id: ctx.userId,
    flow: "checkout",
    step: "create_session",
    checkout_session_id: data.sessionId,
  });

  return data;
}

커머스 백엔드 역시 x-trace-id를 읽어 자체 JSON 로그에 기록합니다. 그러면 하나의 trace_id로 다음을 모두 볼 수 있습니다:

  • 백엔드에서 시작된 위젯의 수신 HTTP 요청(여기서 trace 생성),
  • MCP로의 프록시(있다면),
  • 내부 create_checkout_session 호출,
  • 커머스 API 요청,
  • 커머스 백엔드의 응답,
  • 그리고 헤더를 계속 전파한다면 order.created 웹훅까지.

8. 로그 레벨: LLM 애플리케이션에서의 DEBUG, INFO, WARN, ERROR

로그 레벨은 정보의 홍수에서 익사하지 않도록 도와줍니다. ChatGPT App에서는 다음처럼 해석하는 것이 편합니다:

  • DEBUG — dev/staging에서 유용한 상세 기술 정보. 예: 축약된 프롬프트, 에이전트의 중간 상태, 외부 API의 “원시” 응답(PII 제외). 프로덕션에서는 매우 주의가 필요합니다.
  • INFO — 정상적인 비즈니스 이벤트: “suggest_gifts succeeded, 10 candidates”, “checkout session created”, “webhook order.created processed”. 프로덕션에서도 켜 두어도 됩니다.
  • WARN — 비정상 상황이나 시스템은 계속 동작. 예: “upstream timeout으로 캐시된 카탈로그로 폴백”, “모델이 유효하지 않은 tool args 반환 — 다른 스키마로 재시도”.
  • ERROR — 명백한 실패: 시나리오가 의도대로 완료되지 못함. 예: “checkout API failed”, “failed to persist order”, “tool crashed with unhandled exception”.

편의를 위해 간단한 헬퍼를 추가해 수동 문자열 비교를 피할 수 있습니다:

type LogLevel = "debug" | "info" | "warn" | "error";

function isProd() {
  return process.env.NODE_ENV === "production";
}

export function shouldLogLevel(level: LogLevel): boolean {
  if (isProd()) {
    return level === "info" || level === "warn" || level === "error";
  }
  return true; // dev에서는 모두 로그
}

그리고 logger.debugshouldLogLevel("debug")이 true일 때만 호출합니다.

특히 프로덕션에서 전체 프롬프트와 모델 응답을 DEBUG 로그로 남기는 것은 위험합니다. 사용자가 실수로 채팅에 붙여넣은 비밀번호, 키, 각종 PII가 포함될 수 있습니다.

9. 로그 보안: PII 스크럽과 시크릿

로그는 쉽게 과유불급이 됩니다. “모든 것을” 기록하기 시작하면:

  • 데이터 보호 법규를 위반할 수 있고,
  • 공격자에게 도움을 줄 수 있으며(시크릿과 토큰은 로그에서 바로 추출 가능),
  • 로그 시스템 접근 권한을 누구에게 줄지 스스로 두려워하게 됩니다.

따라서 원칙은 간단합니다. 무엇이 일어났는지를 이해할 만큼만 기록하되, 데이터를 탈취하기에는 부족하게 하라.

권장 실무:

  1. user_id를 로깅하고 이메일이나 전화번호는 기록하지 않습니다. 디버깅을 위해 이메일이 꼭 필요하다면 해시하거나 마스킹("a***@gmail.com")하세요.
  2. 전체 토큰("sk-..."), 리프레시 토큰, client_secret, 비밀번호는 절대 로그에 남기지 않습니다. 정말 필요하다면 앞/뒤 4글자만과 타입을 남기세요(예: “sk-***1234”).
  3. tool_inputtool_output에 주의하세요. 사용자 입력이 그대로 포함될 수 있습니다. 프로덕션에서는 전체를 로깅하지 말거나 다음과 같이 하세요:
    • 검증을 통과한 타입화된 필드만 로깅,
    • 합리적인 크기로 자르고 정규식 기반 스크럽(이메일, 카드 번호 등) 적용.

아주 단순한 sanitizer 예시(간략화):

export function sanitize(text: string): string {
  return text
    .replace(/sk-[a-zA-Z0-9]{20,}/g, "sk-***redacted***")
    .replace(/\b\d{16}\b/g, "****-****-****-****"); // 카드 번호
}

사용자 입력을 로깅할 때:

logger.debug({
  message: "raw_user_message",
  service: "app-widget",
  trace_id,
  user_id,
  raw: sanitize(userMessage),
});

이 코드는 상용 수준과는 거리가 있지만, 아이디어는 잘 보여줍니다. 먼저 정화(sanitize)하고, 그 다음 로깅하세요.

10. 실습: GiftGenius의 gift_recommended 이벤트

이제 연습을 해 봅시다. 사용자를 위해 GiftGenius가 최종 “최고의 선물”을 선택했을 때 기록할 gift_recommended 로그 이벤트를 설계합니다.

이 이벤트는 다음 질문에 답할 수 있어야 합니다:

  • 어떤 사용자(내부 ID)인가?
  • 어떤 선물(SKU)인가?
  • 어떤 시나리오의 어떤 단계인가?
  • 그리고 나머지 로그와 연결할 trace_id는 무엇인가?

동시에 PII와 시크릿을 포함하지 않아야 합니다.

예시:

{
  "timestamp": "2025-11-21T10:22:33.456Z",
  "level": "info",
  "service": "agent",
  "env": "prod",
  "message": "gift_recommended",
  "trace_id": "a3b9e8c2-1f47-4ec5-9bdf-9d4e0c123abc",
  "agent_run_id": "run_7f1d2c",
  "user_id": "u_123456",
  "flow": "gift_recommendation",
  "step": "final_choice",
  "recommended_sku": "SKU-SPACE-MUG-001",
  "price_cents": 2499,
  "currency": "USD",
  "reason_summary": "recipient_likes_space_and_practical_gadgets"
}

여기서 중요한 점:

  • user_id를 로깅하지만 이메일이나 이름은 기록하지 않습니다.
  • SKU와 가격은 일반적인 비즈니스 데이터로, PII로 간주되지 않습니다.
  • reason_summary는 짧은 기술적 태그이며, 사용자 문장을 그대로 넣지 않습니다.
  • trace_idagent_run_id가 있어, 이 선택에 이르는 과정에서 에이전트가 어떤 도구를 호출했는지 추적할 수 있습니다.

반대로 로그에 넣지 말아야 할 것:

  • 모델의 응답 텍스트 전체(“사람이 읽을” 설명),
  • 사용자 프롬프트(“동료에게 줄 선물을 찾고 있어요, 그녀의 전화번호는 …, 주소는 …” 등),
  • 어떠한 결제 데이터도.

11. 로그 예시: 성공한 tool-call과 ACP 오류

정리를 위해 두 개의 작은 JSON 예시를 봅니다.

MCP에서 성공한 tools.call

{
  "timestamp": "2025-11-21T10:20:00.000Z",
  "level": "info",
  "service": "mcp",
  "env": "prod",
  "message": "tools.call completed",
  "trace_id": "a3b9e8c2-1f47-4ec5-9bdf-9d4e0c123abc",
  "request_id": "req_01JCQ5CZ0YQ6TM7E5W8H3N3F2Y",
  "tool_name": "suggest_gifts",
  "user_id": "u_123456",
  "flow": "gift_recommendation",
  "step": "rank_candidates",
  "result_count": 12,
  "latency_ms": 430
}

이 로그 하나만으로도 다음이 보입니다:

  • 어떤 도구인지,
  • 어떤 사용자에 대한 것인지,
  • 어떤 시나리오인지,
  • 소요 시간과 반환된 후보 개수.

trace_id로 동일한 요청에 해당하는 UI와 에이전트의 로그를 쉽게 찾을 수 있습니다.

ACP/체크아웃 오류

{
  "timestamp": "2025-11-21T10:21:05.789Z",
  "level": "error",
  "service": "commerce",
  "env": "prod",
  "message": "checkout failed",
  "trace_id": "a3b9e8c2-1f47-4ec5-9bdf-9d4e0c123abc",
  "checkout_session_id": "cs_test_9YpQvJH8",
  "user_id": "u_123456",
  "flow": "checkout",
  "step": "charge_customer",
  "error_type": "upstream",
  "error_code": "PAYMENT_DECLINED",
  "error_message": "payment provider declined card",
  "provider": "stripe",
  "amount_cents": 2499,
  "currency": "USD"
}

여전히 카드 번호는 없고, 에러 코드와 안전한 메시지만 있습니다. 그리고 같은 trace_id이므로, 이 로그를 gift_recommended와 연결해 어느 단계에서 체인이 끊겼는지 파악할 수 있습니다.

12. 로그가 쓰레기가 되지 않도록 하는 방법

“우리가 멋지게 로깅할 수 있으니, 전부 다 로깅하자”는 유혹이 큽니다. 그렇게 하면 곧 유용한 이벤트가 묻히는 기가바이트급 JSON 소음이 생깁니다.

실용적인 조언 몇 가지:

  • “함수 X에 들어왔다”는 중복 로그는 정보 가치가 낮습니다. 시나리오 시작/끝, 외부 API 호출, 워크플로 단계 전환, 에러 같은 의미 있는 이벤트를 로깅하세요.
  • 자주 발생하는 작업(예: 상품 카탈로그 요청)은 샘플링을 도입하세요. 1/N 요청만 전체 로깅하고, 나머지는 에러 때만 자세히 기록합니다.
  • 프로덕션에서는 DEBUG를 꺼 두세요(혹은 매우 제한적으로). 프롬프트/응답 로깅이 필요하다면 제한적으로, 그리고 스크럽을 적용하세요.

메트릭과 SLO는 다음 강의에서 다루겠지만, 지금도 중요한 점은 이겁니다. 로그는 “디버깅용”만이 아니라 ChatGPT 스택 전체의 관측성 기반입니다.

강의 초반의 “빈 목록”과 실패하는 체크아웃을 기억하나요? 여기 소개한 로깅 체계를 갖추면, 원하는 trace_id로 모든 요청을 몇 분 만에 찾고, suggest_gifts(도구가 몇 명의 후보를 반환했는지, 어느 단계에서 실패했는지)와 "checkout failed" 로그(결제 시스템의 error_code)를 함께 볼 수 있습니다. “로그의 죽음의 수프”가 아닌, “요청부터 웹훅까지”의 명확한 시나리오가 됩니다.

결국 ChatGPT App의 좋은 로깅 스택은 “stdout에 뭔가를 쓴다”가 아니라 다음과 같습니다:

  • trace_id의 올바른 생성 지점(“좁은” 도구의 경우 MCP 게이트웨이/서버, “넓은” 시나리오에서 위젯의 fetch()는 백엔드 진입에서),
  • 각 의미 있는 비즈니스 호출마다 App → MCP → commerce → webhooks까지 이어지는 단일 trace_id,
  • 공통 JSON 로그 스키마(service, env, user_id, flow, step, tool_name 등),
  • PII와 시크릿의 신중한 처리(스크럽, 마스킹, 프로덕션에서 제한적인 DEBUG),
  • 의미 있는 로그 레벨과 소음 최소화.

이런 기반 위에서야 메트릭, SLO, 알림 같은 다른 관측성 도구가 훨씬 유용해지고, 단순히 “로그를 모은다”가 아니라 진짜로 ChatGPT App의 품질과 안정성을 관리할 수 있습니다.

13. 구조화 로그와 상관관계 작업에서의 흔한 실수

오류 №1: 모든 서비스에 걸친 단일 trace_id 부재.
전형적인 사례: MCP 게이트웨이는 하나의 ID, 커머스 백엔드는 또 다른 ID를 생성하고, 웹훅은 상관관계를 전혀 모릅니다. 위젯 로그에는 trace_id가 등장하지 않기도 합니다. 그러면 결국 “대략 시간대가 비슷하니 이거겠지” 같은 수동 탐색이 됩니다. 올바른 접근은 제어 가능한 진입점(“좁은” 도구는 MCP 서버, 위젯의 fetch()는 백엔드/게이트웨이)에서 trace_id를 생성하고, HTTP 헤더, JSON 필드, 에이전트 컨텍스트 등 경계를 넘을 때마다 이를 전파하는 것입니다.

오류 №2: 위젯에서 trace_id를 생성하고 이를 “진실”로 간주.
가끔은 이렇게 보일 수 있습니다. “그냥 React 위젯에서 crypto.randomUUID()를 만들고 헤더에 붙이면 되잖아.” 문제는 이렇게 하면 trace_id가 클라이언트에 있고, 서버 측 실제 트레이싱(OpenTelemetry, 게이트웨이, 다른 서비스)과 불일치할 수 있다는 점입니다. 훨씬 신뢰할 수 있는 방식은 trace_id가 서버 경로 전체를 통제하는 지점(Next.js 백엔드, API 게이트웨이, MCP 서버)에서 생성되는 것입니다. 위젯은 필요하면 그 ID를 읽고 로깅만 하면 됩니다.

오류 №3: “디버깅 편의”를 이유로 PII와 시크릿을 로깅.
개발 초기에 전체 프롬프트, 토큰, 카드 번호, 이메일 전체를 그냥 로그에 쓰는 건 “아주 편리”해 보입니다. 몇 달 뒤엔 시한폭탄이 됩니다. 로그 접근 권한이 독극물처럼 변하고, 보안 감사가 불편한 질문을 던지며, 스크린샷 한 장도 보여주기 두려워집니다. 처음부터 스크럽을 도입하고, 내일 급히 지워야 할 것들은 애초에 기록하지 마세요.

오류 №4: 일부 레이어에서 구조 없는 문자열 로그.
가끔은 MCP와 커머스에서 멋진 JSON 로그를 쓰면서, 위젯에서는 console.log("step 1", data) 정도로 남겨둡니다. 그러면 체인의 앞과 뒤가 끊겨버립니다.

오류 №5: ERROR 레벨 남용.
사소한 일탈(“모델이 후보 0개 반환 → 폴백 표시”)도 모두 ERROR로 기록하면 프로덕션 알림이 늘 불이 납니다. 팀은 곧 알림에 무감각해집니다. “WARN — 이상하지만 복구됨; ERROR — 사용자 시나리오가 실제로 깨짐”을 명확히 구분하세요.

오류 №6: 서비스 간 로그 스키마 불일치.
어떤 서비스에서는 traceId, 다른 곳에서는 correlation_id, 또 다른 곳에서는 requestId라면, 어떤 로그 시스템도 구해 주지 못합니다. 우리가 LogEvent로 합의한 것처럼 단일 스키마를 정하고(App 위젯, MCP 서버, 에이전트, ACP, 웹훅 등) 이를 모든 컴포넌트에서 지키는 것이 중요합니다. 그러면 엔드 투 엔드 대시보드 구성과 인시던트 조사에 걸리는 시간이 일에서 분 단위로 줄어듭니다.

오류 №7: 로그 크기 “최적화”를 명분으로 핵심 필드 삭제.
공간을 아끼겠다며 “user_idflow는 빼자, 사소하잖아”라고 결정하기도 합니다. 그러다 “어떤 사용자에게 체크아웃이 더 자주 실패하는가?” 같은 질문에 답해야 할 때, 정보가 없다는 걸 깨닫게 됩니다. 무엇을 줄일지 선택해야 한다면, 요청/응답 본문 같은 긴 텍스트 페이로드와 디버그 필드를 줄이세요. 식별자와 핵심 컨텍스트 속성은 아니어야 합니다.

코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION