
프롬프트 엔지니어링 실제: 구조화된 출력 얻기
기초 프롬프팅을 넘어서 실제로 신뢰할 수 있는 구조화된 출력을 얻는 방법. 시스템/유저/어시스턴트 역할 설계, Few-shot, CoT, JSON 모드, Function Calling, Zod + AI SDK로 타입 안전한 LLM 응답을 만드는 완전 가이드.

기초 프롬프팅을 넘어서 실제로 신뢰할 수 있는 구조화된 출력을 얻는 방법. 시스템/유저/어시스턴트 역할 설계, Few-shot, CoT, JSON 모드, Function Calling, Zod + AI SDK로 타입 안전한 LLM 응답을 만드는 완전 가이드.
AI Agent를 만들 때 반복적으로 등장하는 핵심 패턴 세 가지—Tool Use, ReAct, Chain of Thought—를 실제 TypeScript 코드와 함께 정리했다. 이 패턴을 이해하면 Agent 설계가 훨씬 명확해진다.

ChatGPT는 질문에 답하지만, AI Agent는 스스로 계획하고 도구를 사용해 작업을 완료한다. 이 차이가 왜 중요한지 정리했다.

둘 다 같은 Transformer 자식인데 왜 다를까? '빈칸 채우기'와 '이어 쓰기' 비유로 알아보는 BERT와 GPT의 결정적 차이. 프로젝트에서 겪은 시행착오와 선택 가이드.

서버에서 잘 오던 데이터가 갑자기 앱을 죽입니다. 'type Null is not a subtype of type String' 에러의 원인과, 안전한 JSON 파싱을 위한 Null Safety 전략을 정리해봤습니다.

LLM을 프로덕션에 처음 붙여보면 곧 이 장면을 만나게 된다.
프롬프트: "사용자 리뷰를 분석해서 JSON으로 반환해줘.
sentiment는 positive/negative/neutral 중 하나야."
LLM 응답:
"물론이죠! 분석 결과를 JSON 형식으로 제공해 드리겠습니다:
\`\`\`json
{
"sentiment": "POSITIVE", ← positive가 아니라 POSITIVE
"score": "높음", ← number가 아니라 string
"issues": null ← 없다면 [] 이어야 하는데
}
\`\`\`
이 결과는 전반적으로 긍정적인 감정을 보여주고 있습니다." ← JSON 이후에 텍스트가 더 붙음
프로덕션에서 JSON.parse()가 터진다. 필드 이름이 가끔 다르게 나온다. 배열이어야 할 필드가 null로 온다. 이런 비결정적 동작이 LLM을 "아이디어 검증"에는 써도 "프로덕션 기능"으로 쓰기 꺼리게 만드는 원인이다.
이 글은 그 문제를 해결하는 실전 기법들을 다룬다.
현대 LLM API는 대화를 세 가지 역할로 구성한다.
| 역할 | 역할 | 누가 작성? |
|---|---|---|
system | 모델의 전반적 행동/페르소나 설정 | 개발자 |
user | 사용자 입력, 질문, 요청 | 사용자 또는 개발자 |
assistant | 모델의 이전 응답 (Few-shot에 활용) | 모델 또는 개발자 |
System 프롬프트는 LLM에게 "너는 어떤 존재야, 어떻게 행동해"를 알려주는 설명서다. 여기서 출력 형식을 강제하면 일관성이 훨씬 높아진다.
const systemPrompt = `
당신은 사용자 리뷰를 분석하는 감성 분석 전문가입니다.
## 출력 규칙 (반드시 준수)
- 반드시 유효한 JSON만 출력하세요. 다른 텍스트는 절대 포함하지 마세요.
- JSON 코드 블록(\`\`\`json ... \`\`\`) 사용 금지
- 마크다운 사용 금지
- 설명 텍스트 추가 금지
## JSON 스키마
{
"sentiment": "positive" | "negative" | "neutral",
"confidence": 0.0 ~ 1.0 사이 숫자,
"key_phrases": string[] (최대 3개),
"issues": string[] (문제 없으면 빈 배열 [])
}
`;
이렇게 System 프롬프트에서 출력 형식을 구체적으로 지정하면 일관성이 높아진다. 하지만 여전히 100% 보장은 안 된다. 아래에서 더 강력한 방법을 다룬다.
LLM에게 "이렇게 해"라고 설명하는 것보다, 실제 예시를 보여주는 것이 훨씬 효과적이다.
const messages = [
{
role: "system" as const,
content: "당신은 리뷰 감성 분석 전문가입니다. 반드시 JSON만 출력하세요."
},
// Few-shot 예시 1: positive 케이스
{
role: "user" as const,
content: "리뷰: 정말 훌륭한 제품이에요! 배송도 빠르고 품질도 좋아요."
},
{
role: "assistant" as const,
content: JSON.stringify({
sentiment: "positive",
confidence: 0.95,
key_phrases: ["훌륭한 제품", "빠른 배송", "좋은 품질"],
issues: []
})
},
// Few-shot 예시 2: negative 케이스
{
role: "user" as const,
content: "리뷰: 포장이 엉망이었고 제품에 흠집이 있었어요."
},
{
role: "assistant" as const,
content: JSON.stringify({
sentiment: "negative",
confidence: 0.88,
key_phrases: ["포장 불량", "제품 흠집"],
issues: ["포장 상태 불량", "제품 품질 문제"]
})
},
// Few-shot 예시 3: edge case — 중립
{
role: "user" as const,
content: "리뷰: 그냥 보통이에요. 특별히 좋지도 나쁘지도 않네요."
},
{
role: "assistant" as const,
content: JSON.stringify({
sentiment: "neutral",
confidence: 0.72,
key_phrases: ["보통", "특별하지 않음"],
issues: []
})
},
// 실제 입력
{
role: "user" as const,
content: `리뷰: ${userReview}`
}
];
Few-shot의 핵심은 예시의 다양성이다. Happy path만 보여주면 edge case에서 망한다. negative, neutral, 그리고 이슈가 있는 케이스까지 포함해라.
CoT는 LLM이 최종 답변을 내놓기 전에 단계적으로 생각하도록 유도하는 기법이다. 복잡한 분석이나 판단이 필요한 경우에 정확도가 크게 향상된다.
// CoT 없는 프롬프트 (단순 분류)
const withoutCoT = `
이 계약서가 유효한지 판단하세요.
계약서: "${contractText}"
출력: {"valid": boolean, "reason": string}
`;
// CoT 있는 프롬프트 (단계적 추론)
const withCoT = `
이 계약서의 유효성을 다음 단계로 분석하세요:
1. 계약 당사자 확인: 양 당사자가 명시되어 있는가?
2. 계약 목적 확인: 계약의 목적이 명확한가?
3. 합의 사항 확인: 의무와 권리가 명시되어 있는가?
4. 법적 요건 확인: 서명, 날짜 등 법적 형식을 갖췄는가?
5. 최종 판단: 위 분석을 종합한 유효성 판단
각 단계를 "analysis" 필드에 기술하고, 최종 결론을 "valid"와 "reason"으로 출력하세요.
{
"analysis": {
"parties": "string",
"purpose": "string",
"obligations": "string",
"legal_requirements": "string"
},
"valid": boolean,
"reason": "string"
}
계약서: "${contractText}"
`;
CoT의 핵심: 생각의 흔적(analysis)을 JSON에 포함시켜라. 그러면 LLM이 결론을 내기 전에 실제로 단계를 거쳐 추론한다. 그리고 그 추론 과정이 응답에 남아서 디버깅도 쉬워진다.
| 작업 유형 | CoT 필요성 | 예시 |
|---|---|---|
| 단순 분류 | 낮음 | sentiment 분류 |
| 정보 추출 | 낮음 | 엔티티 추출 |
| 복잡한 추론 | 높음 | 계약서 분석, 코드 리뷰 |
| 수치 계산 | 높음 | 비용 계산, 공식 적용 |
| 다단계 판단 | 높음 | 의료 트리아지, 법률 검토 |
프롬프트 엔지니어링만으로는 구조화된 출력을 100% 보장할 수 없다. 더 강력한 방법이 필요하다.
response_format: { type: "json_object" } 옵션을 주면 모델이 반드시 유효한 JSON을 반환한다.
import OpenAI from 'openai';
const openai = new OpenAI();
const response = await openai.chat.completions.create({
model: 'gpt-4o-mini',
response_format: { type: 'json_object' }, // JSON 모드 활성화
messages: [
{
role: 'system',
content: '리뷰 감성 분석 결과를 JSON으로 반환하세요. sentiment는 positive/negative/neutral 중 하나입니다.'
},
{
role: 'user',
content: `리뷰: ${userReview}`
}
]
});
// 이제 JSON.parse()가 터지지 않음 (파싱 에러 보장됨)
const result = JSON.parse(response.choices[0].message.content!);
JSON 모드의 제약: 유효한 JSON이 보장되지만, 스키마(필드 이름, 타입, 값 범위)는 여전히 보장되지 않는다.
JSON 스키마를 명시적으로 지정하면 모델이 그 스키마에 맞는 출력을 반환한다. 더 강력하다.
const response = await openai.chat.completions.create({
model: 'gpt-4o-2024-08-06', // Structured Outputs 지원 모델
response_format: {
type: 'json_schema',
json_schema: {
name: 'sentiment_analysis',
strict: true, // 스키마를 엄격하게 준수
schema: {
type: 'object',
properties: {
sentiment: {
type: 'string',
enum: ['positive', 'negative', 'neutral'] // 값 범위 제한!
},
confidence: {
type: 'number',
minimum: 0,
maximum: 1
},
key_phrases: {
type: 'array',
items: { type: 'string' },
maxItems: 3
},
issues: {
type: 'array',
items: { type: 'string' }
}
},
required: ['sentiment', 'confidence', 'key_phrases', 'issues'],
additionalProperties: false
}
}
},
messages: [...]
});
Function Calling은 "모델이 함수를 호출할 수 있다"는 개념이지만, 실제로는 구조화된 출력을 얻는 최강의 방법이다.
const response = await openai.chat.completions.create({
model: 'gpt-4o-mini',
tools: [
{
type: 'function',
function: {
name: 'analyze_sentiment',
description: '리뷰 텍스트의 감성을 분석합니다',
parameters: {
type: 'object',
properties: {
sentiment: {
type: 'string',
enum: ['positive', 'negative', 'neutral'],
description: '감성 분류'
},
confidence: {
type: 'number',
description: '분류 확신도 (0.0 ~ 1.0)'
},
key_phrases: {
type: 'array',
items: { type: 'string' },
description: '핵심 표현 (최대 3개)'
},
issues: {
type: 'array',
items: { type: 'string' },
description: '발견된 문제점 목록'
}
},
required: ['sentiment', 'confidence', 'key_phrases', 'issues']
}
}
}
],
tool_choice: { type: 'function', function: { name: 'analyze_sentiment' } },
messages: [
{ role: 'system', content: '당신은 리뷰 감성 분석 전문가입니다.' },
{ role: 'user', content: `리뷰: ${userReview}` }
]
});
// Function Call 결과 파싱
const toolCall = response.choices[0].message.tool_calls?.[0];
if (toolCall) {
const result = JSON.parse(toolCall.function.arguments);
console.log(result.sentiment); // 항상 'positive' | 'negative' | 'neutral'
}
ai 패키지(Vercel AI SDK)와 Zod를 조합하면 TypeScript 타입 안전성을 완전히 확보할 수 있다.
import { openai } from '@ai-sdk/openai';
import { generateObject } from 'ai';
import { z } from 'zod';
// Zod 스키마 정의
const SentimentSchema = z.object({
sentiment: z.enum(['positive', 'negative', 'neutral']),
confidence: z.number().min(0).max(1),
key_phrases: z.array(z.string()).max(3),
issues: z.array(z.string()),
});
// 타입 추론
type SentimentResult = z.infer<typeof SentimentSchema>;
// {
// sentiment: "positive" | "negative" | "neutral";
// confidence: number;
// key_phrases: string[];
// issues: string[];
// }
async function analyzeSentiment(review: string): Promise<SentimentResult> {
const { object } = await generateObject({
model: openai('gpt-4o-mini'),
schema: SentimentSchema,
prompt: `다음 리뷰의 감성을 분석하세요: "${review}"`,
system: '당신은 리뷰 감성 분석 전문가입니다.'
});
// object는 SentimentResult 타입으로 자동 추론됨
// Zod 검증도 자동으로 이루어짐
return object;
}
// 사용
const result = await analyzeSentiment("정말 좋은 제품이에요!");
console.log(result.sentiment); // TypeScript가 'positive' | 'negative' | 'neutral'로 알고 있음
generateObject는 내부적으로 Function Calling 또는 Structured Outputs를 사용하고, Zod 스키마로 추가 검증까지 한다. 타입 에러가 런타임 전에 컴파일 타임에서 잡힌다.
const ProductExtractionSchema = z.object({
products: z.array(z.object({
name: z.string(),
category: z.enum(['electronics', 'clothing', 'food', 'other']),
price: z.number().positive().optional(),
attributes: z.record(z.string()), // 동적 키-값
})),
total_count: z.number().int().nonnegative(),
confidence: z.number().min(0).max(1),
});
const { object } = await generateObject({
model: openai('gpt-4o'),
schema: ProductExtractionSchema,
prompt: `다음 쇼핑 목록에서 제품 정보를 추출하세요: "${shoppingList}"`,
});
// object.products는 Array<{name: string; category: ...}>로 타입 안전
import { streamObject } from 'ai';
const { partialObjectStream } = await streamObject({
model: openai('gpt-4o-mini'),
schema: SentimentSchema,
prompt: `리뷰 분석: "${review}"`,
});
// 부분 결과를 스트리밍으로 받음
for await (const partial of partialObjectStream) {
// partial은 DeepPartial<SentimentResult>
if (partial.sentiment) {
console.log('Sentiment:', partial.sentiment);
}
}
증상: "JSON으로만 출력해"라고 했는데 설명 텍스트가 붙어옴
원인: System 프롬프트 약함, 지시가 모호함
해결:
1. System 프롬프트에서 금지 사항을 명시적으로 열거
2. Few-shot 예시로 원하는 형식을 보여줌
3. JSON 모드 또는 Function Calling 사용
증상: "positive", "POSITIVE", "긍정", "Positive" 등 다양하게 나옴
원인: 프롬프트에서 가능한 값을 명확히 제시하지 않음
해결:
- JSON 스키마의 enum 필드 활용
- Few-shot에서 정확히 같은 값을 사용
- 출력 후 후처리로 정규화 (toLowerCase 등)
증상: issues가 null로 오기도 하고, []로 오기도 함
원인: 빈 케이스 처리를 모델이 스스로 결정함
해결:
- Zod 스키마: z.array(z.string()).default([])
- 프롬프트: "없으면 반드시 빈 배열 []을 사용하세요"
- Few-shot에서 빈 배열 케이스를 명시적으로 보여줌
증상: 중첩 객체의 일부 필드가 없거나 다른 이름으로 옴
원인: 복잡한 중첩 구조를 모델이 완벽하게 따르기 어려움
해결:
- 스키마를 최대한 평탄화(flatten)하라
- 필요한 경우에만 중첩 사용
- required 필드를 명시적으로 열거
- Function Calling의 strict 모드 활용
증상: 가격이 "50000" (string)으로 오거나 50,000 (콤마 포함)으로 옴
원인: 모델이 숫자를 텍스트로 표현하는 경향
해결:
- 프롬프트: "숫자는 반드시 콤마 없는 정수 또는 부동소수점으로"
- Zod: z.number() (자동 타입 검증)
- 후처리: parseFloat(String(value).replace(/,/g, ''))
구조화된 출력에서는 창의성보다 일관성이 중요하다. 파라미터 세팅을 그에 맞게 조정해라.
모델 출력의 "랜덤성"을 조절한다.
| Temperature | 동작 | 적합한 사용 사례 |
|---|---|---|
| 0.0 | 항상 가장 확률 높은 토큰 선택 | 구조화 출력, 분류, 추출 |
| 0.3~0.5 | 약간의 다양성 | 요약, Q&A |
| 0.7~1.0 | 창의적 | 글쓰기, 브레인스토밍 |
| 1.0+ | 매우 창의적/불안정 | 실험적 사용 |
구조화된 출력에는 temperature=0 또는 0.1 이하를 권장한다.
const { object } = await generateObject({
model: openai('gpt-4o-mini'),
schema: SentimentSchema,
temperature: 0, // 최대 일관성
prompt: `리뷰 분석: "${review}"`,
});
Top-p는 확률 합이 p가 되는 상위 토큰들 중에서만 샘플링한다. Temperature와 비슷한 효과지만 다른 메커니즘이다.
구조화된 출력에는 top-p는 건드리지 말고 temperature만 낮추는 게 일반적으로 더 예측 가능한 결과를 준다. Temperature와 Top-p를 동시에 변경하는 건 대부분의 경우 필요 없다.
프롬프트도 코드다. 버전 관리가 필요하다.
// prompts/sentiment-analysis/v1.ts
export const SENTIMENT_ANALYSIS_PROMPT = {
version: '1.0.0',
system: `당신은 리뷰 감성 분석 전문가입니다...`,
description: '초기 버전 — 기본 감성 분류',
createdAt: '2026-01-01',
};
// prompts/sentiment-analysis/v2.ts
export const SENTIMENT_ANALYSIS_PROMPT_V2 = {
version: '2.0.0',
system: `당신은 리뷰 감성 분석 전문가입니다.
이제 감성 강도(intensity)도 분석합니다...`,
description: 'v2 — 감성 강도 필드 추가',
createdAt: '2026-03-01',
};
또한 프롬프트 성능을 측정하는 테스트 셋을 유지해라.
// tests/prompts/sentiment-analysis.test.ts
const testCases = [
{
input: "정말 훌륭한 제품이에요!",
expected: { sentiment: "positive" },
},
{
input: "포장이 엉망이었어요.",
expected: { sentiment: "negative" },
},
{
input: "그냥 보통이에요.",
expected: { sentiment: "neutral" },
},
];
describe('Sentiment Analysis Prompt', () => {
it.each(testCases)('correctly classifies "$input"', async ({ input, expected }) => {
const result = await analyzeSentiment(input);
expect(result.sentiment).toBe(expected.sentiment);
});
});
프롬프트를 변경할 때마다 이 테스트를 돌려서 회귀(regression)를 확인해라.
같은 프롬프트라도 모델마다 특성이 다르다.
| 모델 | 구조화 출력 신뢰성 | 속도 | 비용 | 특징 |
|---|---|---|---|---|
| GPT-4o | 매우 높음 | 느림 | 비쌈 | Structured Outputs 지원 |
| GPT-4o-mini | 높음 | 빠름 | 저렴 | 비용 효율 최고 |
| Claude Sonnet 3.7 | 높음 | 중간 | 중간 | 긴 컨텍스트 강함 |
| Claude Haiku 3.5 | 중간 | 매우 빠름 | 저렴 | 간단한 추출 작업 |
| Gemini Flash 2.0 | 높음 | 매우 빠름 | 저렴 | 멀티모달 |
구조화된 출력의 신뢰성이 중요하다면 GPT-4o + Structured Outputs가 현재 가장 강력하다. 비용이 중요하다면 GPT-4o-mini + Function Calling이 좋은 균형점이다.
구조화된 출력 획득의 단계별 전략:
response_format: { type: "json_object" }한 가지 원칙을 기억해라: 프롬프트가 모호할수록 모델이 자유롭게 해석한다. 구조화된 출력이 필요할 때는 모호함을 철저히 제거하고, 기술적 메커니즘(Structured Outputs, Function Calling)으로 보장해라.
프롬프트 엔지니어링은 "마법 주문"이 아니라, 명세서(specification) 작성에 가깝다. 좋은 프로덕트 스펙처럼 — 예외 케이스, 엣지 케이스, 형식 요구사항을 빠짐없이 문서화할 때 LLM도 제대로 동작한다.