프롬프트 엔지니어링 실제: 구조화된 출력 얻기
1. 프롤로그 — "왜 JSON으로 출력해달라고 했는데 이상한 게 나오지?"
LLM을 프로덕션에 처음 붙여보면 곧 이 장면을 만나게 된다.
프롬프트: "사용자 리뷰를 분석해서 JSON으로 반환해줘.
sentiment는 positive/negative/neutral 중 하나야."
LLM 응답:
"물론이죠! 분석 결과를 JSON 형식으로 제공해 드리겠습니다:
\`\`\`json
{
"sentiment": "POSITIVE", ← positive가 아니라 POSITIVE
"score": "높음", ← number가 아니라 string
"issues": null ← 없다면 [] 이어야 하는데
}
\`\`\`
이 결과는 전반적으로 긍정적인 감정을 보여주고 있습니다." ← JSON 이후에 텍스트가 더 붙음
프로덕션에서 JSON.parse()가 터진다. 필드 이름이 가끔 다르게 나온다. 배열이어야 할 필드가 null로 온다. 이런 비결정적 동작이 LLM을 "아이디어 검증"에는 써도 "프로덕션 기능"으로 쓰기 꺼리게 만드는 원인이다.
이 글은 그 문제를 해결하는 실전 기법들을 다룬다.
2. 역할(Role) 구조 이해하기
현대 LLM API는 대화를 세 가지 역할로 구성한다.
| 역할 | 역할 | 누가 작성? |
|---|---|---|
system | 모델의 전반적 행동/페르소나 설정 | 개발자 |
user | 사용자 입력, 질문, 요청 | 사용자 또는 개발자 |
assistant | 모델의 이전 응답 (Few-shot에 활용) | 모델 또는 개발자 |
System 프롬프트의 중요성
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% 보장은 안 된다. 아래에서 더 강력한 방법을 다룬다.
3. Few-shot Prompting (예시 기반 학습)
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, 그리고 이슈가 있는 케이스까지 포함해라.
4. Chain-of-Thought (CoT) — 복잡한 추론에는 생각할 공간을
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가 필요한가?
| 작업 유형 | CoT 필요성 | 예시 |
|---|---|---|
| 단순 분류 | 낮음 | sentiment 분류 |
| 정보 추출 | 낮음 | 엔티티 추출 |
| 복잡한 추론 | 높음 | 계약서 분석, 코드 리뷰 |
| 수치 계산 | 높음 | 비용 계산, 공식 적용 |
| 다단계 판단 | 높음 | 의료 트리아지, 법률 검토 |
5. JSON 모드와 Function Calling
프롬프트 엔지니어링만으로는 구조화된 출력을 100% 보장할 수 없다. 더 강력한 방법이 필요하다.
JSON 모드 (OpenAI)
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이 보장되지만, 스키마(필드 이름, 타입, 값 범위)는 여전히 보장되지 않는다.
Structured Outputs (OpenAI)
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
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'
}
6. Zod + AI SDK로 타입 안전한 출력
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);
}
}
7. 흔한 실패 패턴과 해결책
실패 1: 모델이 프롬프트 지시를 무시함
증상: "JSON으로만 출력해"라고 했는데 설명 텍스트가 붙어옴
원인: System 프롬프트 약함, 지시가 모호함
해결:
1. System 프롬프트에서 금지 사항을 명시적으로 열거
2. Few-shot 예시로 원하는 형식을 보여줌
3. JSON 모드 또는 Function Calling 사용
실패 2: enum 값이 일관되지 않음
증상: "positive", "POSITIVE", "긍정", "Positive" 등 다양하게 나옴
원인: 프롬프트에서 가능한 값을 명확히 제시하지 않음
해결:
- JSON 스키마의 enum 필드 활용
- Few-shot에서 정확히 같은 값을 사용
- 출력 후 후처리로 정규화 (toLowerCase 등)
실패 3: 선택적 필드에서 null vs 빈 배열 혼용
증상: issues가 null로 오기도 하고, []로 오기도 함
원인: 빈 케이스 처리를 모델이 스스로 결정함
해결:
- Zod 스키마: z.array(z.string()).default([])
- 프롬프트: "없으면 반드시 빈 배열 []을 사용하세요"
- Few-shot에서 빈 배열 케이스를 명시적으로 보여줌
실패 4: 중첩된 객체 구조에서 필드 누락
증상: 중첩 객체의 일부 필드가 없거나 다른 이름으로 옴
원인: 복잡한 중첩 구조를 모델이 완벽하게 따르기 어려움
해결:
- 스키마를 최대한 평탄화(flatten)하라
- 필요한 경우에만 중첩 사용
- required 필드를 명시적으로 열거
- Function Calling의 strict 모드 활용
실패 5: 숫자 타입 혼용
증상: 가격이 "50000" (string)으로 오거나 50,000 (콤마 포함)으로 옴
원인: 모델이 숫자를 텍스트로 표현하는 경향
해결:
- 프롬프트: "숫자는 반드시 콤마 없는 정수 또는 부동소수점으로"
- Zod: z.number() (자동 타입 검증)
- 후처리: parseFloat(String(value).replace(/,/g, ''))
8. Temperature와 Top-p 튜닝
구조화된 출력에서는 창의성보다 일관성이 중요하다. 파라미터 세팅을 그에 맞게 조정해라.
Temperature
모델 출력의 "랜덤성"을 조절한다.
| 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 (Nucleus Sampling)
Top-p는 확률 합이 p가 되는 상위 토큰들 중에서만 샘플링한다. Temperature와 비슷한 효과지만 다른 메커니즘이다.
- Top-p=1.0: 모든 토큰 고려 (기본값)
- Top-p=0.9: 확률 상위 90% 토큰만 고려
- Top-p=0.1: 극도로 제한적
구조화된 출력에는 top-p는 건드리지 말고 temperature만 낮추는 게 일반적으로 더 예측 가능한 결과를 준다. Temperature와 Top-p를 동시에 변경하는 건 대부분의 경우 필요 없다.
9. 프롬프트 버전 관리
프롬프트도 코드다. 버전 관리가 필요하다.
// 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)를 확인해라.
10. 모델별 특성 비교
같은 프롬프트라도 모델마다 특성이 다르다.
| 모델 | 구조화 출력 신뢰성 | 속도 | 비용 | 특징 |
|---|---|---|---|---|
| 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이 좋은 균형점이다.
11. 결론
구조화된 출력 획득의 단계별 전략:
- 프롬프트 레벨 (빠른 시작): System 프롬프트에서 형식 명시 + Few-shot 예시
- JSON 모드 (유효한 JSON 보장):
response_format: { type: "json_object" } - Function Calling / Structured Outputs (스키마 보장): 프로덕션 권장
- Zod + AI SDK (타입 안전성): TypeScript 코드베이스의 최종 형태
한 가지 원칙을 기억해라: 프롬프트가 모호할수록 모델이 자유롭게 해석한다. 구조화된 출력이 필요할 때는 모호함을 철저히 제거하고, 기술적 메커니즘(Structured Outputs, Function Calling)으로 보장해라.
프롬프트 엔지니어링은 "마법 주문"이 아니라, 명세서(specification) 작성에 가깝다. 좋은 프로덕트 스펙처럼 — 예외 케이스, 엣지 케이스, 형식 요구사항을 빠짐없이 문서화할 때 LLM도 제대로 동작한다.