Skip to main content
Summary

모델을 골랐다면 이제 계약을 써야 한다

앞 장에서 모델 선택은 품질, 비용, 지연시간을 함께 다루는 설계 문제라고 했다. 그런데 실무에서 더 자주 깨지는 지점은 모델 선택 다음이다. 좋은 모델을 골랐는데도 답변이 들쭉날쭉하고, JSON이라고 했는데 설명 문장이 섞이고, 분류 라벨이 어느 날은 refund, 어느 날은 return_request, 또 어느 날은 환불문의로 나온다. 사람 눈에는 모두 비슷해 보일 수 있지만, 프로그램에는 완전히 다른 값이다.

여기서 관점을 바꿔야 한다. 프롬프트는 "AI에게 잘 부탁하는 말"이 아니다. 애플리케이션과 모델 사이의 계약이다. 계약에는 당사자, 업무 범위, 입력 자료, 금지 조건, 판단 기준, 납품 형식이 있어야 한다. "친절하게 답해 줘"는 부탁이고, "고객 문의를 다음 네 범주 중 하나로 분류하고, 근거 문장과 긴급도를 JSON으로 반환하라"는 계약이다.

특히 서비스에서는 출력 형식이 안정되어야 한다. 사용자의 화면은 모델의 산문을 그대로 읽는 것이 아니라, 응답 객체의 필드를 읽어 버튼을 그리고, DB에 저장하고, 후속 API를 호출한다. 따라서 프롬프트 설계의 목표는 "멋진 답변"이 아니라 "반복 실행해도 같은 계약을 지키는 결과"다.

프롬프트 설계와 구조화 출력

좋은 프롬프트는 여섯 칸으로 나뉜다

프롬프트가 불안정해지는 가장 흔한 이유는 모든 것을 한 문단에 섞어 쓰기 때문이다. 역할, 배경, 작업, 제약, 예시, 출력 형식이 뒤섞이면 모델은 무엇이 가장 중요한지 추측해야 한다. 공식 프롬프트 전략 문서도 프롬프트의 내용뿐 아니라 구조가 중요하며, 목표, 요청 사항, 컨텍스트, 예시, 제약조건, 응답 형식 같은 구성요소를 명확히 나누라고 설명한다.1

실무 프롬프트는 다음 여섯 칸으로 나누면 다루기 쉬워진다.

구성요소목적나쁜 예좋은 예
역할모델이 어떤 관점에서 판단할지 정한다."너는 전문가야.""너는 전자상거래 고객지원 문의를 분류하는 운영 분석가다."
맥락판단에 필요한 자료와 배경을 제공한다."아래 내용을 보고.""아래 입력은 고객이 앱 문의창에 남긴 원문이며, 개인정보는 마스킹되어 있다."
작업모델이 해야 할 일을 동사로 지정한다."처리해 줘.""문의 유형, 긴급도, 환불 관련 여부를 판정하라."
제약하면 안 되는 일과 애매한 경우의 처리 방식을 정한다."틀리지 마.""입력에 없는 주문 상태를 추정하지 말고 unknown으로 표시하라."
예시성공적인 입력과 출력의 짝을 보여 준다."예쁘게 JSON으로."실제 입력 2개와 기대 JSON 2개를 제공한다.
출력 형식프로그램이 소비할 구조를 고정한다."JSON처럼."필드명, 타입, enum, 필수값을 스키마로 정의한다.

이 여섯 칸을 모두 매번 길게 쓸 필요는 없다. 단순 작업에는 역할과 작업, 출력 형식만으로 충분하다. 하지만 업무 규칙이 조금이라도 들어가면 제약과 예시가 필요하다. 모델은 회사의 환불 정책, 분류 기준, 내부 용어를 모른다. 프롬프트에 쓰지 않은 기준은 모델이 자기 방식으로 채운다.

다음은 고객 문의 분류용 기본 템플릿이다.

<ROLE>
너는 전자상거래 고객지원 문의를 분류하는 운영 분석가다.
</ROLE>

<CONTEXT>
입력은 고객이 모바일 앱 문의창에 남긴 원문이다.
오탈자, 감정 표현, 불완전한 문장이 포함될 수 있다.
</CONTEXT>

<TASK>
고객 문의를 하나의 주요 카테고리로 분류하고,
긴급도와 상담원 이관 필요 여부를 판단하라.
</TASK>

<CONSTRAINTS>
입력에 명시되지 않은 주문 상태, 배송사, 결제 성공 여부를 추정하지 마라.
카테고리는 refund, delivery, account, product, other 중 하나만 사용하라.
위협, 법적 분쟁, 개인정보 노출, 반복 장애가 보이면 긴급도를 high로 지정하라.
근거는 입력 원문에서 확인 가능한 표현만 사용하라.
</CONSTRAINTS>

<INPUT>
{customer_message}
</INPUT>

<OUTPUT>
정해진 JSON 스키마를 따른다.
</OUTPUT>

이 템플릿의 핵심은 말투가 아니다. 판단의 경계를 정한 것이다. "고객이 화가 나 보이면 high"라고 쓰는 대신, 어떤 신호가 high인지 적었다. "적절히 분류"라고 쓰는 대신, 허용되는 라벨을 제한했다. 이것이 재현성을 만든다.

업무별 프롬프트 패턴

문서 요약, 분류, 정보 추출은 제미나이 애플리케이션에서 가장 자주 등장하는 세 가지 업무다. 겉으로는 모두 "텍스트를 읽고 답하기"처럼 보이지만, 프롬프트 계약은 서로 다르다.

문서 요약은 원문을 줄이는 작업이다. 이때 중요한 것은 길이, 관점, 누락 금지 항목이다. "요약해 줘"라고 하면 모델은 독자가 누구인지, 어느 정도 길이를 원하는지, 어떤 정보가 핵심인지 추측한다. 반면 "임원 보고용으로 5문장 이내, 비용·일정·리스크를 반드시 포함"이라고 하면 결과가 안정된다.

다음 회의록을 임원 보고용으로 요약하라.

요약 기준:
- 전체 분량은 5문장 이내
- 결정된 사항과 미결 사항을 구분
- 비용, 일정, 책임자가 언급되면 반드시 포함
- 원문에 없는 수치나 책임자를 만들지 말 것

원문:
"""
{meeting_notes}
"""

분류는 자유로운 설명보다 일관된 라벨이 중요하다. 따라서 라벨 정의와 애매한 경우의 규칙을 먼저 정해야 한다. "긍정/부정으로 나눠 줘"는 쉬워 보이지만, "배송은 느렸지만 상담은 친절했다" 같은 문장을 만나면 흔들린다. 이런 경우에는 "주요 불만 대상 기준으로 분류" 또는 "복수 감정이면 negative 우선" 같은 정책을 넣어야 한다.

정보 추출은 원문에서 특정 필드를 뽑는 작업이다. 여기서는 모델이 없는 값을 만들어 내지 않도록 하는 것이 중요하다. 계약서에서 계약 시작일, 종료일, 위약금 조항을 추출한다고 해 보자. 원문에 종료일이 없으면 빈 문자열을 채우는 것이 아니라 null이나 unknown을 반환해야 한다. 그래야 후속 시스템이 "추출 실패"와 "값이 실제로 비어 있음"을 구분할 수 있다.

좋은 추출 프롬프트에는 성공 예시뿐 아니라 실패 예시도 들어간다.

작업: 계약서 원문에서 핵심 날짜와 금액을 추출한다.

규칙:
- 원문에 명시된 값만 추출한다.
- 날짜는 YYYY-MM-DD 형식으로 정규화한다.
- 날짜를 확정할 수 없으면 null을 사용한다.
- 금액은 숫자와 통화를 분리한다.
- 조항이 존재하지 않으면 has_penalty_clause=false로 둔다.

예시:
입력: "계약은 2026년 3월 1일부터 1년간 유효하다. 위약금은 500만원이다."
출력: {"start_date":"2026-03-01","end_date":"2027-02-28","penalty_amount":5000000,"currency":"KRW","has_penalty_clause":true}

입력: "본 계약은 서명일로부터 효력이 발생한다."
출력: {"start_date":null,"end_date":null,"penalty_amount":null,"currency":null,"has_penalty_clause":false}

예시는 모델에게 "이렇게 하라"는 샘플이면서 동시에 테스트 케이스다. 예시와 실제 스키마가 다르면 모델은 둘 중 어느 쪽을 따라야 할지 흔들린다. 예시를 추가할수록 좋아지는 것이 아니라, 정확한 예시를 추가할수록 좋아진다.

"JSON처럼 답하라"의 한계

많은 개발자가 처음에는 프롬프트 마지막에 이렇게 쓴다.

반드시 JSON으로만 답해.
설명하지 마.
마크다운 코드블록도 쓰지 마.

이 방식은 간단한 데모에서는 꽤 잘 동작한다. 하지만 운영에서는 충분하지 않다. 모델이 실수로 앞에 "물론입니다"를 붙일 수도 있고, 필드명을 바꿀 수도 있고, 숫자를 문자열로 줄 수도 있다. 프롬프트만으로 JSON을 요구하는 것은 종이에 "이 양식대로 제출"이라고 쓰는 것과 같다. 구조화 출력은 시스템에 양식을 등록하고, 그 양식에 맞춰 제출하게 하는 것에 가깝다.

Gemini API는 기본적으로 구조화되지 않은 텍스트를 반환하지만, 응답 스키마를 지정하면 모델 출력이 특정 JSON 구조나 enum 값에 맞도록 제약할 수 있다. 공식 문서는 generateContent 호출에서 응답 스키마를 제공해 유효한 JSON과 특정 라벨 집합을 생성할 수 있다고 설명한다.2 Firebase AI Logic 문서도 응답 스키마를 "모델 응답의 청사진"처럼 사용해 후처리를 줄이고, application/json과 text/x.enum 같은 MIME 유형을 사용할 수 있다고 안내한다.3

즉, 구조화 출력은 단순한 출력 취향이 아니다. UI 렌더링, DB 저장, 워크플로 분기, 후속 API 호출을 위한 애플리케이션 계약이다.

Python에서 구조화 출력 사용하기

다음 예제는 고객 문의를 분류해 바로 프로그램 객체로 검증하는 흐름이다. 모델은 gemini-2.5-flash를 기준으로 둔다. 실제 집필·운영 시점에는 공식 SDK 버전과 모델 지원 범위를 다시 확인해야 한다.

from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field
from google import genai

client = genai.Client()

class Category(str, Enum):
    refund = "refund"
    delivery = "delivery"
    account = "account"
    product = "product"
    other = "other"

class Urgency(str, Enum):
    low = "low"
    medium = "medium"
    high = "high"

class TicketClassification(BaseModel):
    category: Category = Field(description="Primary category of the customer request.")
    urgency: Urgency = Field(description="Operational urgency of the request.")
    needs_human: bool = Field(description="Whether a human agent should review this ticket.")
    evidence: str = Field(description="Short quote or paraphrase from the input that supports the decision.")
    missing_info: list[str] = Field(default_factory=list, description="Information required but not present in the input.")

customer_message = "환불 신청했는데 아직 돈이 안 들어왔어요. 세 번째 문의입니다."

prompt = f"""
너는 전자상거래 고객지원 문의를 분류하는 운영 분석가다.

규칙:
- 카테고리는 입력의 주요 의도를 기준으로 하나만 고른다.
- 반복 문의, 법적 분쟁, 개인정보 노출, 결제 장애는 urgency를 high로 둔다.
- 입력에 없는 주문 상태나 환불 처리 결과를 추정하지 않는다.
- evidence는 입력에서 확인 가능한 내용만 쓴다.

고객 문의:
{customer_message}
"""

response = client.models.generate_content(
    model="gemini-2.5-flash",
    contents=prompt,
    config={
        "temperature": 0.1,
        "response_mime_type": "application/json",
        "response_schema": TicketClassification,
    },
)

result = TicketClassification.model_validate_json(response.text)
print(result.category, result.urgency, result.needs_human)

여기서 중요한 부분은 마지막 두 줄이다. response_mime_type은 응답을 JSON으로 받겠다는 선언이고, response_schema는 어떤 필드와 타입을 기대하는지 정의한다. 그리고 애플리케이션은 model_validate_json 같은 검증 단계로 모델 응답을 다시 확인한다. 구조화 출력을 쓰더라도 검증 코드를 생략하면 안 된다. 서비스의 마지막 책임자는 모델이 아니라 애플리케이션이다.

JavaScript에서 구조화 출력 사용하기

JavaScript와 TypeScript에서는 객체 스키마를 직접 정의하거나 Zod 같은 검증 라이브러리와 함께 사용할 수 있다. 아래 예제는 @google/genai의 스키마 객체를 사용한 간단한 형태다.

import { GoogleGenAI, Type } from "@google/genai";

const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });

const ticketSchema = {
  type: Type.OBJECT,
  properties: {
    category: {
      type: Type.STRING,
      enum: ["refund", "delivery", "account", "product", "other"],
      description: "Primary category of the customer request."
    },
    urgency: {
      type: Type.STRING,
      enum: ["low", "medium", "high"],
      description: "Operational urgency of the request."
    },
    needsHuman: {
      type: Type.BOOLEAN,
      description: "Whether a human agent should review this ticket."
    },
    evidence: {
      type: Type.STRING,
      description: "Evidence from the input."
    },
    missingInfo: {
      type: Type.ARRAY,
      items: { type: Type.STRING },
      description: "Information required but not present."
    }
  },
  required: ["category", "urgency", "needsHuman", "evidence", "missingInfo"]
};

const customerMessage = "배송완료라고 뜨는데 물건을 못 받았습니다. 경비실에도 없어요.";

const response = await ai.models.generateContent({
  model: "gemini-2.5-flash",
  contents: `
너는 전자상거래 고객지원 문의를 분류하는 운영 분석가다.
입력에 없는 사실을 추정하지 말고, 정해진 스키마로만 응답하라.

고객 문의:
${customerMessage}
  `,
  config: {
    temperature: 0.1,
    responseMimeType: "application/json",
    responseSchema: ticketSchema
  }
});

const parsed = JSON.parse(response.text);
console.log(parsed);

Python SDK는 response_mime_type처럼 스네이크 케이스를 쓰고, JavaScript SDK는 responseMimeType처럼 카멜 케이스를 쓴다. REST API에서는 generationConfig 안에 responseMimeType, responseSchema 형태로 들어간다. 이름이 조금씩 다르더라도 개념은 같다. "응답은 JSON이어야 한다"와 "JSON의 모양은 이것이어야 한다"를 분리해 선언하는 것이다.

스키마는 작게 시작한다

구조화 출력을 처음 설계할 때 가장 흔한 실수는 스키마를 너무 크게 만드는 것이다. 한 번에 고객 감정, 제품군, 환불 가능성, 정책 위반, 추천 답변, 상담원 메모, 후속 API 파라미터까지 모두 뽑으려 한다. 그러면 프롬프트도 길어지고, 스키마도 길어지고, 오류가 났을 때 어디서 흔들렸는지 찾기 어려워진다.

처음에는 작은 객체와 enum부터 시작하는 편이 좋다.

{
  "category": "refund",
  "urgency": "high",
  "needs_human": true
}

이 세 필드가 안정적으로 나오면 근거 문장, 누락 정보, 추천 답변을 추가한다. 그다음에 후속 API 호출에 필요한 주문번호, 날짜, 금액 같은 필드를 붙인다. 작은 스키마를 통과한 뒤 확장해야 디버깅이 쉽다.

스키마 설계에서는 필드 설명도 중요하다. status라는 필드명은 너무 모호하다. 주문 상태인지, 처리 상태인지, 모델 판단 상태인지 알 수 없다. refund_status나 classification_status처럼 구체적으로 쓰고, 설명에는 값의 의미를 적는다.

또한 누락값 정책을 정해야 한다. 문자열 필드에 빈 문자열을 넣을지, null을 허용할지, unknown enum을 둘지 결정해야 한다. 이 선택은 단순 취향이 아니라 후속 시스템의 로직을 바꾼다. 예를 들어 DB에서 null은 "값이 없음"이고, unknown은 "판단할 수 없음"이라는 업무 의미를 가질 수 있다.

스키마도 토큰을 사용한다는 점을 잊으면 안 된다. Firebase 문서는 응답 스키마 크기가 입력 토큰 한도에 반영된다고 설명한다.3 필드 설명을 길게 쓰면 모델이 이해하기 쉬워지지만 비용과 컨텍스트도 늘어난다. 설명은 길게 쓰는 것이 아니라 정확하게 써야 한다.

JSON Schema 지원 범위와 속성 순서

Gemini API의 구조화 출력은 계속 개선되고 있다. Google은 2025년 11월 구조화 출력 개선을 발표하면서 Gemini API의 JSON Schema 지원을 확대했고, Pydantic과 Zod를 더 자연스럽게 사용할 수 있으며, anyOf, $ref, 숫자 범위 제약, additionalProperties, type: null, 튜플형 배열에 쓰이는 prefixItems 같은 키워드 지원을 추가했다고 밝혔다. 또한 Gemini 2.5 이상에서는 스키마의 키 순서를 출력에서도 더 잘 보존하도록 개선했다고 설명했다.4

다만 여기서 중요한 실무 원칙이 있다. "Gemini API가 지원한다"와 "내가 쓰는 SDK, 플랫폼, 모델, 버전 조합에서 똑같이 지원한다"는 같은 말이 아니다. Firebase AI Logic 같은 클라이언트 SDK 문서는 지원되는 스키마 필드의 하위 집합을 별도로 안내하며, 지원되지 않는 필드는 무시될 수 있다고 설명한다.3 따라서 복잡한 JSON Schema 기능을 사용할수록 반드시 현재 사용하는 SDK 문서와 모델 문서를 확인해야 한다.

출력 순서도 무시하면 안 된다. 프로그램은 JSON 필드 순서에 의존하지 않는 것이 원칙이지만, 사람이 읽는 로그, 예시 기반 프롬프트, 모델의 생성 품질에는 순서가 영향을 줄 수 있다. 예시에서는 category, urgency, needs_human, evidence 순서로 보여 주고, 스키마에서는 전혀 다른 순서로 정의하면 모델이 불필요하게 흔들릴 수 있다. 스키마, 예시, 검증 코드의 필드 순서를 맞추면 사람이 디버깅하기도 쉽다.

enum은 분류 업무의 안전벨트다

분류 작업에서는 enum이 특히 강력하다. enum은 허용되는 값의 목록이다. 고객 감정을 분류할 때 모델에게 자유롭게 답하게 두면 positive, good, satisfied, 긍정, 약간 긍정처럼 비슷하지만 다른 값이 생긴다. 이 값들은 분석 대시보드에서 모두 다른 라벨이 된다.

분류 라벨은 업무 정책과 함께 정의해야 한다.

필드허용값판단 기준
categoryrefund, delivery, account, product, other문의의 주요 해결 경로
urgencylow, medium, high장애, 반복 문의, 법적 위험, 금전 손실 가능성
sentimentnegative, neutral, positive, mixed고객의 감정 표현과 불만 대상

여기서 other를 둘지 말지는 중요하다. other가 없으면 모델은 억지로 가장 가까운 라벨을 고른다. other가 너무 쉽게 허용되면 쓸모없는 분류가 늘어난다. 그래서 other에는 설명이 필요하다. 예를 들어 "다섯 카테고리 중 어느 해결 경로에도 연결되지 않을 때만 사용"이라고 적어야 한다.

긴급도도 마찬가지다. high는 "화가 났다"가 아니라 "즉시 대응하지 않으면 손실이 커질 수 있다"에 가까워야 한다. 감정과 운영 긴급도를 섞으면 불만이 큰 사용자의 단순 문의가 모두 high가 된다.

긴 문서에서는 질문의 위치와 근거 규칙이 중요하다

Gemini의 긴 컨텍스트는 긴 문서 분석에 큰 장점이 있다. 하지만 긴 문서를 넣었다고 해서 모델이 모든 문장을 같은 밀도로 검토한다고 가정하면 위험하다. 사람이 300쪽 보고서를 읽을 때도 앞부분과 뒷부분, 제목과 표, 최근에 본 문장에 더 영향을 받는다. 모델도 프롬프트 구조에 영향을 받는다.

긴 문서를 다룰 때는 작업 지시를 문서 앞뒤에 모두 배치하는 편이 안전하다. 앞에는 역할과 기준을 두고, 문서 뒤에는 최종 질문과 출력 형식을 다시 둔다. 질문을 끝부분에 배치하면 모델이 마지막으로 해야 할 일을 더 명확히 잡는다.

<ROLE>
너는 기업 내부 감사 보고서를 검토하는 리스크 분석가다.
</ROLE>

<TASK>
문서 전체를 근거로 주요 리스크, 반복 이슈, 출처 간 충돌을 찾는다.
</TASK>

<RULES>
- 문서 일부가 아니라 전체 문서에 근거해 종합하라.
- 근거가 없으면 "근거 없음"이라고 답하라.
- 서로 다른 출처가 충돌하면 어느 출처가 무엇을 말하는지 분리해 표시하라.
- 원문에 없는 원인이나 책임자를 추정하지 마라.
</RULES>

<DOCUMENTS>
[SOURCE: audit_2025]
...

[SOURCE: finance_memo]
...

[SOURCE: hr_report]
...
</DOCUMENTS>

<FINAL_QUESTION>
위 모든 출처를 종합해 리스크 목록을 작성하라.
각 리스크에는 source_ids, evidence, confidence를 포함하라.
</FINAL_QUESTION>

여러 출처를 넣을 때는 출처 ID를 반드시 붙이는 것이 좋다. 문서 A, 문서 B처럼 대충 나누는 것보다 audit_2025, finance_memo, hr_report처럼 후속 시스템이 추적할 수 있는 식별자를 쓰는 편이 낫다. 그래야 모델이 "인사 보고서와 재무 메모가 충돌한다"고 답했을 때 실제 문서로 돌아갈 수 있다.

또한 "전체 문서 기반"이라는 지시를 넣어야 한다. 특정 페이지나 최근 문맥만 보고 답하는 것을 줄이는 데 도움이 된다. 물론 이 지시가 완벽한 보장은 아니다. 중요한 업무에서는 긴 컨텍스트 결과를 다시 검증하거나, 문서 단위 추출과 최종 종합을 나누는 방식이 더 안전하다.

구조화 출력과 함수 호출은 다르다

구조화 출력과 함수 호출은 모두 "모델이 기계가 읽을 수 있는 형태로 답하게 한다"는 점에서 비슷해 보인다. 하지만 역할은 다르다.

구조화 출력은 최종 결과를 객체로 받는 기능이다. 예를 들어 고객 문의 분류 결과, 계약서 추출 결과, 요약 카드, 분석 리포트의 섹션 목록을 JSON으로 받는다. 이 JSON은 화면에 표시하거나 DB에 저장하거나 후속 로직의 입력으로 쓴다.

함수 호출은 모델이 외부 도구를 호출할 필요가 있을 때 사용한다. 예를 들어 "주문 상태를 조회해야 답할 수 있다"고 판단하면 get_order_status(order_id) 같은 함수를 호출하도록 한다. 함수 호출의 핵심은 모델이 직접 답을 만드는 것이 아니라, 어떤 도구를 어떤 인자로 부를지 결정하는 것이다.

간단히 나누면 이렇다.

목적사용할 기능예시
결과를 고정된 객체로 받고 싶다.구조화 출력문의 분류 JSON, 계약 정보 추출 JSON
외부 시스템에서 정보를 가져와야 한다.함수 호출주문 조회, 재고 확인, 티켓 생성
검색·URL·코드 실행 결과를 바탕으로 답해야 한다.도구 사용과 그라운딩최신 정책 확인, URL 분석, 계산 실행

Gemini 3 계열에서는 구조화 출력과 Google Search grounding, URL Context, Code Execution, File Search, Function Calling 같은 내장 도구를 함께 사용하는 확장 가능성이 안내되어 있다.2 하지만 본문 실습의 안정 축은 generateContent 기반 구조화 출력이다. 도구 결합은 모델과 버전별 지원 범위가 달라질 수 있으므로, 실제 서비스에서는 "이 모델에서 이 조합이 지원되는가"를 먼저 확인해야 한다.

실패하는 프롬프트의 전형적인 냄새

프롬프트가 실패할 때는 대개 비슷한 냄새가 난다. 모델 탓으로 돌리기 전에 다음 패턴을 먼저 찾아야 한다.

  • 스키마는 urgency를 요구하지만 예시에는 priority가 들어 있다.

  • enum은 refund, delivery인데 프롬프트 본문에는 "환불, 배송, 계정 중 하나"라고 적혀 있다.

  • "절대 추정하지 마"라고 해 놓고, 동시에 "가능성이 높은 값을 채워라"라고 지시한다.

  • 역할 설명이 지나치게 길어 실제 작업보다 페르소나가 더 중요해졌다.

  • 출력 형식은 JSON이라고 했지만 예시는 마크다운 표로 제공했다.

  • 긴 문서에서 "위 내용을 요약"이라고만 하고, 전체 문서 기반인지 특정 섹션 기반인지 밝히지 않았다.

  • 검색이나 URL 근거가 필요한 질문인데, 모델 내부 지식만으로 답하게 했다.

이런 문제는 모델을 Pro로 바꿔도 사라지지 않는다. 비싼 모델은 모호한 지시를 더 그럴듯하게 수행할 뿐이다. 잘못된 계약은 좋은 변호사를 붙여도 좋은 계약이 되지 않는다.

디버깅은 축소 재현부터 시작한다

프롬프트 디버깅은 일반 소프트웨어 디버깅과 비슷하다. 실패 사례를 줄이고, 원인을 분리하고, 검증 코드를 추가한다. 다음 순서가 가장 실용적이다.

  1. 실패 입력을 하나 고른다. 운영 로그에서 실제로 깨진 입력을 가져오되 개인정보는 제거한다.

  2. 프롬프트를 최소화한다. 역할, 핵심 규칙, 입력, 출력 스키마만 남기고 장식을 제거한다.

  3. 스키마를 단순화한다. 문제가 되는 필드 2~3개만 남겨 모델이 어느 지점에서 흔들리는지 본다.

  4. 예시와 스키마를 맞춘다. 필드명, enum 값, null 처리, 배열 구조가 완전히 같은지 확인한다.

  5. 검증 코드를 추가한다. JSON 파싱, Pydantic/Zod 검증, enum 검사, 필수 필드 검사를 통과해야 다음 단계로 보낸다.

  6. 실패 입력을 모은다. 한두 개가 아니라 30~100개 정도의 실패·경계 사례를 작은 테스트셋으로 만든다.

  7. 모델과 파라미터를 재검토한다. 온도, 출력 길이, 사고 예산, 모델 등급을 바꿔야 하는지 마지막에 판단한다.

중요한 순서는 마지막이다. 모델을 바꾸는 것은 디버깅의 시작이 아니라 끝에 가깝다. 스키마가 잘못됐거나 예시가 충돌한다면 모델을 바꿔도 비용만 늘어난다.

검증 코드는 단순해도 효과가 크다.

from pydantic import ValidationError

try:
    result = TicketClassification.model_validate_json(response.text)
except ValidationError as e:
    # 1차 실패: 같은 모델에 더 작은 스키마로 재시도하거나
    # 2차 실패: 상위 모델로 폴백하거나
    # 최종 실패: 사람 검토 큐로 보낸다.
    log_failed_output(response.text, str(e))
    enqueue_for_human_review(customer_message)

이 코드는 멋진 AI 기능이 아니다. 하지만 운영 안정성을 만든다. 구조화 출력 실패율이 높다면 프롬프트, 스키마, 입력 데이터 중 어딘가가 잘못됐다는 신호다. 이 지표는 13장과 16장에서 다룰 운영 대시보드와 트러블슈팅의 핵심 지표가 된다.

프롬프트 버전도 코드처럼 관리한다

프롬프트는 소스 코드다. 모델에게 전달되는 문자열일 뿐이라고 가볍게 보면 안 된다. 프롬프트 한 줄이 바뀌면 분류 기준, 비용, 지연시간, 법적 위험이 모두 달라질 수 있다.

운영 프롬프트에는 최소한 버전이 있어야 한다.

prompt_id: support_ticket_classifier
prompt_version: 2026-05-13.1
model: gemini-2.5-flash
schema_version: ticket_classification.v3

로그에는 사용자 입력 전체를 무조건 남기지 않더라도, 프롬프트 버전, 모델명, 스키마 버전, 파싱 성공 여부, 토큰 사용량, 지연시간, 폴백 여부는 남겨야 한다. 그래야 "어제부터 환불 문의가 other로 많이 분류된다"는 문제가 생겼을 때 원인을 찾을 수 있다.

프롬프트 수정도 테스트를 거쳐야 한다. 예를 들어 고객 문의 분류 프롬프트를 바꾼다면 대표 입력 100개를 이전 버전과 새 버전에 동시에 넣고 차이를 본다. 자동 해결률이 올랐는지, high 긴급도가 과도하게 늘었는지, 상담원 이관이 줄어들며 위험 문의를 놓치지는 않았는지 확인한다. 이것이 11장에서 다룰 평가와 프롬프트 관리의 출발점이다.

실전 설계 규칙

프롬프트와 구조화 출력을 설계할 때는 다음 규칙을 기본값으로 삼으면 좋다.

  • 프롬프트는 역할, 맥락, 작업, 제약, 예시, 출력 형식을 분리해 작성한다.

  • 판단 기준은 "적절히", "간단히", "중요하면" 같은 말 대신 측정 가능한 조건으로 쓴다.

  • 구조화 출력은 "JSON처럼 답하라"가 아니라 response_mime_type과 스키마로 고정한다.

  • 스키마는 작은 객체와 enum부터 시작해 검증한 뒤 확장한다.

  • 예시의 필드명, 값, 순서, null 처리 방식은 스키마와 일치시킨다.

  • 긴 문서에서는 전체 문서 기반, 근거 없음 처리, 출처 충돌 표시를 명시한다.

  • 모든 구조화 출력은 애플리케이션 코드에서 다시 검증한다.

  • 실패 사례는 버리지 말고 테스트셋으로 모은다.

이 규칙들은 화려하지 않다. 하지만 실제 서비스를 안정시키는 것은 대개 화려한 프롬프트 문구가 아니라 이런 기본기다.

다음 단계로 넘어가기 전에

이제 모델을 고르고, 프롬프트 계약을 쓰고, 출력 스키마까지 고정하는 방법을 잡았다. 여기까지 오면 AI Studio에서 실험한 결과를 실제 코드로 옮길 준비가 된다. 다음 장에서는 Gemini API와 공식 SDK의 기본기를 다룬다. API 키, 클라이언트 초기화, generateContent, 스트리밍, 환경변수, 에러 처리의 기초를 익히면 지금 만든 프롬프트 계약이 실제 애플리케이션의 한 부분으로 들어간다.

프롬프트는 말이고, 스키마는 약속이다. API는 그 약속을 서비스로 실행하는 통로다.

참고

1Prompt design strategies - Gemini API

2Structured outputs - generateContent API

3Generate structured output using the Gemini API

4Improving Structured Outputs in the Gemini API

책 목록

제미나이 공략집

전체 목차 보기

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

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

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

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

참여하기