학습 자료

JSON 으로 받아낸다 — 구조화 출력


Article

9강에서 few-shot 으로 압축 강도를 끌어올렸다. 하지만 지금까지 메모 정리 비서가 뱉는 건 결국 사람이 읽는 텍스트 줄이다. "- [내일] 마케팅 예산안 초안" 같은 문자열은 사람 눈엔 편해도, 이걸 나중에 캘린더 앱에 등록하거나 정렬·필터링하려는 프로그램 입장에선 다시 파싱해야 하는 골칫거리다. 오늘은 그 출력을 JSON 으로 바꾼다.

문장으로 JSON 을 시켜본다

my-ai-assistant 폴더에 naive-json.ts 를 만든다. 시스템 프롬프트의 <output_format> 을 JSON 배열로 바꿨다.

import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic();

const SYSTEM_PROMPT = `<role>
너는 메모 정리 비서다. 사용자가 두서없이 적은 메모에서 할 일을 뽑아 목록으로 만든다.
</role>

<task>
입력 메모에서 "해야 할 일"을 모두 찾아낸다. 마감이 있으면 그대로 살리고, 없으면 "미정"으로 표시한다.
</task>

<output_format>
JSON 배열로만 답한다. 각 원소는 { "deadline": string, "task": string } 형태다.
마감이 급한 순서로 정렬한다. 마감이 없는 항목은 맨 뒤에 둔다.
</output_format>`;

const FEW_SHOT: Anthropic.MessageParam[] = [
  {
    role: "user",
    content:
      "내일까지 부서 워크샵 장소를 예약해야 하고, 다다음 주 화요일에 있을 신입사원 환영회 준비물도 미리 챙겨놔야 함.",
  },
  {
    role: "assistant",
    content:
      '[{"deadline":"내일","task":"워크샵 예약"},{"deadline":"다다음 주 화요일","task":"환영회 준비물"}]',
  },
];

async function organize(note: string) {
  const response = await client.messages.create({
    model: "claude-haiku-4-5",
    max_tokens: 300,
    system: SYSTEM_PROMPT,
    messages: [...FEW_SHOT, { role: "user", content: note }],
  });

  const text = response.content[0].type === "text" ? response.content[0].text : "";
  console.log(`\n--- 원본 응답 ---\n${text}`);

  try {
    const parsed = JSON.parse(text);
    console.log("--- JSON.parse 성공 ---");
    console.log(parsed);
  } catch (err) {
    console.log("--- JSON.parse 실패 ---");
    console.log((err as Error).message);
  }
}

await organize(
  "내일까지 3분기 마케팅 예산안 초안을 작성해서 팀장님한테 검토받아야 하고, 이번 주 금요일에는 신규 입사자 온보딩 자료도 준비해야 함. 시간 날 때 회사 근처 도서관에 반납할 책도 챙기기.",
);

실행한다.

npx tsx --env-file=.env naive-json.ts
터미널 — npx tsx --env-file=.env naive-json.ts
  • --- 원본 응답 ---
  • ```json
  • [
  • {"deadline":"내일","task":"3분기 마케팅 예산안 초안 작성 및 팀장님 검토"},
  • {"deadline":"이번 주 금요일","task":"신규 입사자 온보딩 자료 준비"},
  • {"deadline":"미정","task":"도서관에 책 반납"}
  • ]
  • ```
  • --- JSON.parse 실패 ---
  • Unexpected token '`', "```json [ "... is not valid JSON

시스템 프롬프트에 "JSON 배열로만 답한다"고 못 박았는데도, 모델은 마크다운 코드펜스(```json ... ```)로 감싸서 보냈다. 대화형 AI 로 훈련된 모델은 코드를 보여줄 때 코드펜스를 쓰는 습관이 아주 강하게 배어 있어서, 문장으로 "쓰지 마라"고 해도 이 습관을 완전히 이기지 못할 때가 있다.

코드펜스가 아예 나올 수 없게 만든다

문장으로 더 강하게 요청하는 대신, 모델이 코드펜스를 쓸 자리 자체를 없앤다. Anthropic API 는 assistant 역할의 메시지를 우리가 미리 채워 보낼 수 있는데, 이를 prefill 이라 부른다. messages 배열 맨 끝에 { role: "assistant", content: "[" } 를 넣으면, 모델은 "내가 이미 [ 라고 말하기 시작한 상태"에서 이어 쓴다. 코드펜스도, 인사말도 붙일 자리가 없다.

prefill — 모델의 첫 마디를 우리가 정한다
  1. messages 끝에 assistant: "[" 추가이건 실제로 API 에 보내는 메시지다
  2. 모델은 "[" 로 시작한 자기 말을 이어 쓴다코드펜스·인사말을 새로 시작할 수 없다
  3. 응답엔 "[" 이 빠져 있다모델이 이어 쓴 부분만 돌아오므로, 코드에서 직접 앞에 붙여야 한다
prefill 은 트릭이 아니라 API 가 지원하는 정식 기능이다. assistant 메시지는 반드시 모델이 새로 생성한 것일 필요가 없다.

index.ts 를 만든다. naive-json.ts 와 시스템 프롬프트는 같고, 마지막에 assistant: "[" 를 추가했다.

import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic();

const SYSTEM_PROMPT = `<role>
너는 메모 정리 비서다. 사용자가 두서없이 적은 메모에서 할 일을 뽑아 목록으로 만든다.
</role>

<task>
입력 메모에서 "해야 할 일"을 모두 찾아낸다. 마감이 있으면 그대로 살리고, 없으면 "미정"으로 표시한다.
</task>

<output_format>
JSON 배열로만 답한다. 각 원소는 { "deadline": string, "task": string } 형태다.
마감이 급한 순서로 정렬한다. 마감이 없는 항목은 맨 뒤에 둔다.
코드펜스나 설명 문장을 절대 덧붙이지 않는다.
</output_format>`;

const FEW_SHOT: Anthropic.MessageParam[] = [
  {
    role: "user",
    content:
      "내일까지 부서 워크샵 장소를 예약해야 하고, 다다음 주 화요일에 있을 신입사원 환영회 준비물도 미리 챙겨놔야 함.",
  },
  {
    role: "assistant",
    content:
      '[{"deadline":"내일","task":"워크샵 예약"},{"deadline":"다다음 주 화요일","task":"환영회 준비물"}]',
  },
];

async function organize(note: string) {
  const response = await client.messages.create({
    model: "claude-haiku-4-5",
    max_tokens: 300,
    system: SYSTEM_PROMPT,
    // 마지막에 "[" 로 시작하는 assistant 턴을 미리 채워둔다(prefill).
    messages: [...FEW_SHOT, { role: "user", content: note }, { role: "assistant", content: "[" }],
  });

  const continuation = response.content[0].type === "text" ? response.content[0].text : "";
  // 우리가 미리 채운 "[" 는 응답에 다시 포함되지 않으므로 직접 이어붙인다.
  const fullText = "[" + continuation;
  console.log(`\n--- 원본 응답(이어붙인 결과) ---\n${fullText}`);

  const parsed = JSON.parse(fullText);
  console.log("--- JSON.parse 성공 ---");
  console.log(parsed);
  return parsed;
}

await organize(
  "내일까지 3분기 마케팅 예산안 초안을 작성해서 팀장님한테 검토받아야 하고, 이번 주 금요일에는 신규 입사자 온보딩 자료도 준비해야 함. 시간 날 때 회사 근처 도서관에 반납할 책도 챙기기.",
);
await organize(
  "이번 주 안에 치과 예약을 다시 잡아야 하고, 프로젝트 문서 최종 리뷰는 오늘 중으로 끝내야 함. 엄마 생신 선물로 뭘 살지 다음 달 초까지 정해야 함.",
);

실행한다.

npx tsx --env-file=.env index.ts
터미널 — npx tsx --env-file=.env index.ts
  • --- 원본 응답(이어붙인 결과) ---
  • [{"deadline":"내일","task":"3분기 마케팅 예산안 초안 작성 및 팀장님 검토"},{"deadline":"이번 주 금요일","task":"신규 입사자 온보딩 자료 준비"},{"deadline":"미정","task":"도서관에 책 반납"}]
  • --- JSON.parse 성공 ---
  • [
  • { deadline: '내일', task: '3분기 마케팅 예산안 초안 작성 및 팀장님 검토' },
  • { deadline: '이번 주 금요일', task: '신규 입사자 온보딩 자료 준비' },
  • { deadline: '미정', task: '도서관에 책 반납' }
  • ]
  • --- 원본 응답(이어붙인 결과) ---
  • [{"deadline":"오늘","task":"프로젝트 문서 최종 리뷰"},{"deadline":"이번 주 안에","task":"치과 예약"},{"deadline":"다음 달 초","task":"엄마 생신 선물 정하기"}]
  • --- JSON.parse 성공 ---
  • [
  • { deadline: '오늘', task: '프로젝트 문서 최종 리뷰' },
  • { deadline: '이번 주 안에', task: '치과 예약' },
  • { deadline: '다음 달 초', task: '엄마 생신 선물 정하기' }
  • ]

두 번 다 코드펜스 없이, 파싱 가능한 JSON 문자열이 그대로 왔다. naive-json.ts 는 실패하고 index.ts 는 성공한 차이는 딱 하나, assistant: "[" 한 줄을 messages 끝에 추가한 것뿐이다.

naive-json.ts vs index.ts

그런데 이것으로 완전히 안전할까

JSON.parse 가 성공했다고 끝난 게 아니다. 두 가지가 아직 남아 있다.

JSON.parse 성공 ≠ 완전히 안전
정상 출력성공그대로 쓴다
max_tokens 부족으로 중간에 잘림실패 (문법 오류)여기까진 오늘 봤다
문법은 맞는데 필드가 빠지거나 타입이 다름성공런타임에서야 터지므로 더 위험하다

세 번째 줄이 특히 무섭다. JSON.parse 는 문법만 확인하지, deadline 필드가 진짜 있는지 task 가 문자열인지는 확인해주지 않는다. parsed[0].deadline.toUpperCase() 같은 코드는 필드가 없으면 파싱 단계가 아니라 한참 뒤, 화면에 뿌리려는 순간 에러를 던진다.

정리하면

시스템 프롬프트에 JSON 을 요구하는 것만으로는 코드펜스를 완전히 막지 못한다. messages 끝에 assistant: "[" 를 prefill 로 넣으면, 모델이 형식을 어길 자리 자체가 사라진다.

다시 짚어보기
  1. 01

    문장으로 JSON 만 요구했지만 코드펜스가 붙었다

    JSON.parse 가 백틱(`) 때문에 실패했다.

    npx tsx --env-file=.env naive-json.ts
  2. 02

    assistant: "[" 를 prefill 로 넣었다

    모델이 이미 시작된 자기 말을 이어 쓰게 만들어, 코드펜스가 나올 자리를 없앴다.

    npx tsx --env-file=.env index.ts
  3. 03

    JSON.parse 성공은 끝이 아니라는 것도 확인했다

    문법이 맞아도 필드 존재·타입까지 보장되진 않는다. 그건 검증의 몫이다.

다음 강의에서는 오늘 남겨둔 두 가지 위험, 중간에 잘린 JSON과 문법은 맞지만 모양이 틀린 JSON을 실제로 겪어보고, 검증과 재시도로 막는다. 여기까지 오면 Course A 의 결과물, 똑똑한 CLI 어시스턴트가 완성된다.

JSON 으로 받아낸다 — 구조화 출력 — 디코드랩(DCODELAB)