
LLM이 정확하게 답변하도록 입력을 정교하게 설계하는 기술.
PromptTemplate부터 구조화된 출력(Structured Output)까지, 실전 코드와 함께 정리합니다.
프롬프트(Prompt) 는 LLM이 사용자의 의도를 정확히 파악하여 답변하도록 돕는 구체적인 명령문이다. LLM은 프롬프트를 입력받아 학습된 패턴과 지식을 기반으로 최적의 결과물을 생성한다.
| 요소 | 설명 | 예시 |
|---|---|---|
| 상황 (Context) | 어떤 배경이나 환경에서 작업하는가 | "당신은 시니어 Spring 개발자입니다." |
| 내용 (Task) | 구체적으로 무엇을 수행해야 하는가 | "다음 코드의 문제점을 분석해줘." |
| 형식 (Format) | 어떤 구조나 스타일로 응답해야 하는가 | "JSON 형식으로, 항목별로 나누어 설명해줘." |
| 원칙 | 설명 |
|---|---|
| 명확하고 구체적인 요청 | 모호함 없이 답변의 범위와 방향을 명확히 정의 |
| 배경 정보 제공 | LLM이 의도를 파악할 수 있도록 상세한 맥락 함께 제공 |
| 간결한 문장 사용 | 수식어가 많은 복잡한 문장보다 직관적인 문장 선호 |
| 적절한 예시 사용 | 원하는 스타일과 형식을 구체적 예시로 제시 |
| 다단계 질문 피하기 | 하나의 프롬프트에는 하나의 질문만 담기 |
| 역할 부여하기 | 특정 작업에 집중하도록 모델에게 명확한 역할 부여 |
PromptTemplate은 정적인 텍스트와 동적인 데이터를 결합하여 LLM이 이해할 수 있는 최종 프롬프트를 만드는 설계도 역할을 한다. {variable} 형태의 자리 표시자(Placeholder)로 실행 시점에 데이터를 주입할 수 있다.
| 구분 | PromptTemplate | SystemPromptTemplate |
|---|---|---|
| 역할 | 사용자의 질문(User Message) 구조화 | AI의 페르소나·지침(System Message) 설정 |
| 메시지 타입 | UserMessage 생성 | SystemMessage 생성 |
| 사용 사례 | "분야가 {subject}인 뉴스 요약해줘" | "당신은 {language} 전문 번역가입니다." |
| 메서드 | 역할 |
|---|---|
create() | 바인딩된 데이터로 최종 Prompt 객체 생성 |
render(Map<String, Object>) | 자리 표시자에 데이터를 채운 최종 문자열 반환 |
template(String) | 사용할 프롬프트 원본 텍스트 설정 |
방법 1 — render() → String으로 호출
@Test
void usePromptTemplateTest() {
// 1. 템플릿 정의
String templateString = "{person}의 명언 3개를 한국어로 출력해줘";
PromptTemplate promptTemplate = PromptTemplate.builder()
.template(templateString)
.build();
// 2. 데이터 바인딩 및 렌더링
String renderedContent = promptTemplate.render(Map.of("person", "스티브잡스"));
// 3. ChatClient 호출
String answer = chatClient.prompt(renderedContent)
.call()
.content();
}
방법 2 — create() → Prompt 객체로 호출
@Test
void usePromptTemplate2Test() {
PromptTemplate promptTemplate = PromptTemplate.builder()
.template("{person}의 명언 3개를 한국어로 출력해줘")
.build();
// create()로 Prompt 객체 생성
Prompt prompt = promptTemplate.create(Map.of("person", "스티브잡스"));
String answer = chatClient.prompt(prompt)
.call()
.content();
}
응답 예시
1) "항상 배고프고, 항상 어리석게 있어라." — 스티브 잡스
2) "혁신은 리더와 추종자를 구분한다." — 스티브 잡스
3) "당신의 시간은 한정되어 있으니, 다른 사람의 삶을 사느라 낭비하지 마라." — 스티브 잡스
@Test
void useSystemPromptTest() {
String message = "스프링 DI에 대해 설명해줘";
SystemPromptTemplate promptTemplate = SystemPromptTemplate.builder()
.template("당신은 이제부터 {role} 전문가입니다. 전문 용어를 섞어서 답변하세요.")
.build();
Prompt prompt = promptTemplate.create(Map.of("role", "스프링"));
String answer = chatClient.prompt(prompt)
.user(message)
.call()
.content();
}
LLM에 전달되는 프롬프트는 단순 질문 하나가 아니라 대화 히스토리를 포함하는 리스트 구조다.
| 타입 | 역할 |
|---|---|
SystemMessage | AI의 역할, 제약 사항, 답변 형식 정의 |
UserMessage | 사용자의 질문이나 명령 (이력 포함 가능) |
AssistantMessage | 이전 AI 답변. 포함하면 대화 맥락을 기억하는 것처럼 동작 |
@Test
void test() {
List<Message> messageList = new ArrayList<>();
// 1. 시스템 페르소나 설정
messageList.add(new SystemMessage("당신은 친절한 요리사입니다. 사용자의 질문에 요리 비유를 들어 답변하세요."));
// 2. 이전 대화 맥락 추가 (Few-shot)
messageList.add(new UserMessage("안녕? 첫 번째 질문이야. 오늘 날씨 어때?"));
messageList.add(new AssistantMessage("안녕하세요! 오늘은 신선한 샐러드처럼 상큼한 날씨네요."));
// 3. 현재 질문 추가
messageList.add(new UserMessage("그럼 이런 날씨엔 어떤 공부를 하면 좋을까?"));
String answer = chatClient.prompt()
.messages(messageList)
.call()
.content();
}
💡
messages()메서드에List<Message>를 통째로 전달하면 순서대로 대화 맥락이 구성된다.
ChatClient.Builder의 default* 메서드를 사용하면 애플리케이션 전역에서 공통으로 적용될 속성을 정의할 수 있다.
| 메서드 | 설명 | 주요 용도 |
|---|---|---|
defaultSystem() | 기본 System Message 설정 | AI 역할, 답변 스타일, 제약 사항 전역 정의 |
defaultUser() | 기본 User Message 설정 | 공통 질문 접두어·형식 지정 |
defaultOptions() | 기본 ChatOptions 설정 | Temperature, 모델명, 최대 토큰 수 일괄 적용 |
@Configuration
public class LittlePrinceConfig {
@Bean
public ChatClient littlePrinceChatClient(ChatClient.Builder builder) {
return builder
.defaultSystem("""
너는 '어린 왕자'야.
지구에서 만난 친구들에게 너의 별 B612와 장미, 그리고 여우에 대해 이야기해줘.
말투는 항상 '~야', '~해' 같은 다정한 반말을 사용해줘.
""")
.defaultOptions(ChatOptions.builder()
.temperature(0.7)
.maxTokens(800)
.build())
.build();
}
}
@Service
public class LittlePrinceService {
private final ChatClient chatClient;
// 기본 설정 그대로 사용
public String chat(String message) {
return chatClient.prompt()
.user(message)
.call()
.content();
}
// 개인화 대화 — 기존 클라이언트 복제 후 일부 변경
public String personalizedChat(String userName, String message) {
return ChatClient.builder(chatClient.mutate())
.defaultSystem(s -> s.replace("너는 '어린 왕자'야.",
"너는 '어린 왕자'고, 지금 대화하는 친구 이름은 {name}이야."))
.build()
.prompt()
.system(s -> s.param("name", userName))
.user(message)
.call()
.content();
}
}
사전 예시 없이 지시사항만으로 작업을 수행하게 하는 기법. LLM이 학습 과정에서 습득한 광범위한 지식을 활용한다.
@Test
void test() {
String text = "오늘은 영하 20도로 피부가 아플 정도로 추워";
PromptTemplate promptTemplate = PromptTemplate.builder()
.template("""
다음 텍스트의 감정을 [긍정, 부정, 중립] 중 하나로 분류하세요.
레이블만 반환하세요.
텍스트: {input}
""")
.build();
String sentiment = chatClient.prompt()
.user(promptTemplate.render(Map.of("input", text)))
.call()
.content();
// 출력: 부정
}
💡 감정 분류처럼 응답이 범주화된 경우
maxTokens(4)로 제한하면 불필요한 설명을 방지하고 비용을 절감할 수 있다.
지시사항과 함께 예시 데이터를 제공하여 모델이 문맥 내에서 학습하게 하는 기법.
| 구분 | 특징 |
|---|---|
| 정확도 향상 | 복잡한 규칙이나 톤앤매너를 예시로 직접 학습 |
| 구조화된 출력 | JSON, CSV 등 특정 형식을 예시로 보장 |
| 일관성 유지 | 여러 번 호출해도 동일 형식·스타일 유지 |
구성: SystemMessage(지침) + User/Assistant Pairs(예시) + UserMessage(실제 질문)
@Test
void test() {
String reviewText = "이 상품은 색상도 맘에 들고 기능도 좋아요. 정말 맘에 듭니다.";
String answer = chatClient.prompt()
.messages(
// 1. 지침 (System Message)
new SystemMessage("당신은 리뷰 감정 분석가입니다. 응답은 반드시 JSON 형식으로 'sentiment'와 'confidence'를 포함해야 합니다."),
// 2. 예시 1
new UserMessage("이 제품 정말 최고예요! 배송도 빨랐습니다."),
new AssistantMessage("{\"sentiment\": \"positive\", \"confidence\": 0.98}"),
// 3. 예시 2
new UserMessage("생각보다 별로네요. 디자인은 예쁜데 품질이 떨어집니다."),
new AssistantMessage("{\"sentiment\": \"negative\", \"confidence\": 0.85}"),
// 4. 실제 질문
new UserMessage(reviewText)
)
.call()
.content();
// 출력: {"sentiment": "positive", "confidence": 0.95}
}
💡 AI가 답변한 것처럼
AssistantMessage를 미리 넣어주는 것이 퓨-샷의 핵심이다. 모델은 이를 보고 응답 형식을 그대로 따른다.
SystemMessage를 통해 LLM에게 특정 페르소나를 주입하는 기법.
public String getPersonalizedResponse(String role, String userInput) {
return this.chatClient.mutate()
.build()
.prompt()
.system(sp -> sp
.text("당신은 {role}입니다. 해당 역할의 전문성과 문체를 사용하여 답변하세요.")
.param("role", role))
.user(userInput)
.call()
.content();
}
// 호출: getPersonalizedResponse("30년 경력의 프랑스 요리 쉐프", "크리스마스에 가족과 즐길 수 있는 요리 추천해줘")
구체적인 질문 → 추상적인 배경 원리로 후퇴(Step-back) 한 후 그 지식을 바탕으로 최종 답변을 도출하는 기법.
3단계 프로세스
1. Step-back Question 생성
원래 질문에서 핵심 개념을 추출해 더 넓은 범위의 질문 생성
2. 배경 지식 확보
스텝-백 질문으로 일반 원리·역사적 배경을 먼저 확보
3. 최종 답변 생성
원래 질문 + 배경 지식을 결합해 구체적 결론 도출
public String getStepBackAnswer(String originalQuestion) {
// 1단계: Step-back 질문 생성
String stepBackQuestion = chatClient.prompt()
.user(u -> u.text("다음 질문에서 핵심적인 원리나 배경을 묻는 더 포괄적인 질문 하나만 생성해줘. 질문: {question}")
.param("question", originalQuestion))
.call()
.content();
// 2단계: 배경 지식 확보
String backgroundKnowledge = chatClient.prompt()
.user(stepBackQuestion)
.call()
.content();
// 3단계: 최종 답변 생성
return chatClient.prompt()
.system("너는 제공된 배경 지식을 바탕으로 사용자의 질문에 논리적으로 답변하는 전문가야.")
.user(u -> u.text("""
[배경 지식]: {knowledge}
[사용자 질문]: {question}
위 배경 지식을 참고하여 질문에 대해 구체적이고 정확하게 답변해줘.
""")
.param("knowledge", backgroundKnowledge)
.param("question", originalQuestion))
.call()
.content();
}
실행 예시
[원래 질문] 우리 집 냉장고 문이 잘 안 닫히는데 어떻게 고쳐야 해?
[Step-back 질문]
냉장고 문이 잘 닫히지 않는 문제의 근본 원인(문 패킹·경첩·문 틈·기기 수평·내부 압력 등)은
무엇이고, 이를 진단·예방·수리할 때 적용되는 기본 원리와 점검 순서는 무엇인가?
[최종 답변]
가장 흔한 원인은 문고무(가스켓) 오염·손상 또는 문 정렬 문제입니다.
아래 순서대로 점검해 보세요...
💡 RAG 시스템과 결합 시 스텝-백 질문으로 더 광범위한 문서를 검색할 수 있어 검색 품질이 향상된다.
LLM이 결과만 내놓는 것이 아니라 중간 논리 과정을 스스로 나열하게 만드는 기법.
| 종류 | 설명 |
|---|---|
| Zero-Shot CoT | "단계별로 생각해 보자(Let's think step by step)" 문구만으로 추론 능력 활성화 |
| Few-Shot CoT | 풀이 과정이 포함된 예시를 몇 개 제공하여 논리 전개 방식 학습 |
public String solveComplexProblem(String problem) {
return this.chatClient.prompt()
.system("""
당신은 논리적인 분석가입니다.
문제를 해결할 때 반드시 다음 단계를 준수하세요:
1. 주어진 정보를 분석합니다.
2. 단계별로 추론 과정을 나열합니다.
3. 최종 결론을 도출합니다.
""")
.user(u -> u.text("""
문제: {problem}
도움말: 한 걸음씩 차근차근 생각해 봅시다.
""")
.param("problem", problem))
.call()
.content();
}
실행 예시
[질문] A는 B보다 크고 B는 C보다 작아. 누가 제일 커?
[CoT 답변]
1) 주어진 정보 분석
- A > B, C > B
2) 단계별 추론
- A와 C 사이의 관계는 주어지지 않음
- A=5, B=3, C=4 이면 A가 제일 큼
- A=5, B=3, C=7 이면 C가 제일 큼
3) 최종 결론
주어진 정보만으로 누가 제일 큰지 결정할 수 없다.
💡
temperature를 낮게(0.3~0.5) 설정하면 엉뚱한 방향으로 추론(Hallucination)하는 것을 방지하고 더 일관된 논리 구조를 유지할 수 있다.
동일한 프롬프트를 여러 번 병렬로 요청하고 가장 많이 중복된 답변(다수결) 을 최종 결과로 선택하는 기법.
동일 프롬프트 → N회 요청 → 다양한 추론 경로 → 다수결(Majority Voting) → 최종 답변
temperature를 약간 높게(0.7) 설정하여 응답 다양성 확보public String getConsistentSentiment(String review) {
int sampleCount = 5;
// 1. 5회 요청 → 응답 리스트 생성
List<String> votes = IntStream.range(0, sampleCount)
.mapToObj(i -> fetchSentiment(review))
.toList();
// 2. 다수결 집계
return votes.stream()
.collect(Collectors.groupingBy(s -> s, Collectors.counting()))
.entrySet().stream()
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey)
.orElse("판단 불가");
}
private String fetchSentiment(String review) {
return chatClient.prompt()
.system("""
당신은 리뷰 분석가입니다.
응답은 반드시 [긍정, 부정]중 하나로 분류하세요.
레이블만 반환하세요.
""")
.user(review)
.options(ChatOptions.builder()
.model("gpt-4o-mini")
.temperature(0.7)
.build())
.call()
.content();
}
LLM은 기본적으로 텍스트를 생성하지만, 개발자는 파싱 가능한 데이터가 필요하다. Spring AI는 이 과정을 자동화한다.
형식 지침 생성 → LLM 호출 → JSON 추출 → Java 객체 역직렬화
public interface StructuredOutputConverter<T> extends Converter<String, T>, FormatProvider {
// FormatProvider: LLM에게 전달할 출력 형식 지침 생성
// Converter: LLM 응답(String)을 Java 객체(T)로 변환
}
| 구현체 | 설명 |
|---|---|
BeanOutputConverter | Java Bean(POJO) 기반 JSON 생성 및 변환. 가장 범용적 |
MapOutputConverter | Map<String, Object> 형태로 변환 |
ListOutputConverter | List<String> 형태로 변환 |
| 방식 | 설명 |
|---|---|
| 저수준 | converter.getFormat()으로 지침 직접 삽입 → converter.convert(response)로 변환 |
| 고수준 | ChatClient의 .call().entity(Class<T>)로 내부 자동 처리 |
LLM 응답을 List<String>으로 변환한다. 쉼표(,)로 구분된 리스트 형식을 지침으로 생성한다.
저수준 방식
@Test
void lowLevelTest() {
// 1. 컨버터 초기화
ListOutputConverter converter = new ListOutputConverter();
// 2. {format} 플레이스홀더 포함 템플릿 작성
PromptTemplate promptTemplate = PromptTemplate.builder()
.template("인천에서 유명한 관광지 목록 5개를 출력하세요. {format}")
.build();
// 3. 포맷 지침 바인딩
Prompt prompt = promptTemplate.create(Map.of("format", converter.getFormat()));
String response = chatClient.prompt(prompt).call().content();
// 4. String → List<String> 변환
List<String> locations = converter.convert(response);
// 출력: [인천 차이나타운, 월미도, 송도 센트럴파크, 을왕리 해수욕장, 소래포구]
}
고수준 방식
@Test
void highLevelTest() {
List<String> locations = chatClient.prompt()
.user("인천에서 유명한 관광지 목록 5개를 출력하세요.")
.call()
.entity(new ListOutputConverter()); // 자동 변환
// 출력: [인천 차이나타운, 월미도, 송도 센트럴파크, 인천대공원, 을왕리 해수욕장]
}
LLM 응답을 Java POJO(Record 포함)로 변환한다. 내부적으로 Jackson으로 JSON을 객체에 매핑한다.
공통 DTO 정의
public record University(String city, List<String> names) {}
단일 객체 변환 — 저수준
@Test
void lowLevelTest() {
BeanOutputConverter<University> converter = new BeanOutputConverter<>(University.class);
PromptTemplate promptTemplate = PromptTemplate.builder()
.template("{city}의 대학교 이름 5개를 출력하세요. {format}")
.build();
Prompt prompt = promptTemplate.create(Map.of("city", "인천", "format", converter.getFormat()));
String response = chatClient.prompt(prompt).call().content();
University university = converter.convert(response);
// 출력: University[city=인천, names=[인하대학교, 인천대학교, 경인교육대학교, 연세대학교 국제캠퍼스, 가천대학교]]
}
단일 객체 변환 — 고수준
@Test
void highLevelTest() {
University university = chatClient.prompt()
.user("%s의 대학교 이름 5개를 출력하세요.".formatted("인천"))
.call()
.entity(University.class);
// 출력: University[city=인천, names=[인하대학교, 인천대학교, ...]]
}
리스트 변환 — ParameterizedTypeReference 활용
@Test
void lowLevelTest() {
// List<T> 변환 시 ParameterizedTypeReference 필수
BeanOutputConverter<List<University>> converter =
new BeanOutputConverter<>(new ParameterizedTypeReference<>() {});
PromptTemplate promptTemplate = PromptTemplate.builder()
.template("""
다음 도시들의 대학교 목록 5개를 출력하세요.
도시: {cities}
{format}
""")
.build();
Prompt prompt = promptTemplate.create(Map.of("cities", "서울,인천,부산", "format", converter.getFormat()));
String response = chatClient.prompt(prompt).call().content();
List<University> universities = converter.convert(response);
// 출력: [University[city=서울, names=[서울대학교, 연세대학교, ...]], ...]
}
LLM 응답을 Map<String, Object> 형태로 변환한다. 별도 클래스 없이 동적 구조의 데이터를 처리할 때 유용하다.
@Test
void highLevelTest() {
String template = "{university}대학교의 정보를 출력하세요.";
Map<String, Object> information = chatClient.prompt()
.user(u -> u.text(template).param("university", "서울"))
.call()
.entity(new MapOutputConverter());
// 출력: {established=1946, name_en=Seoul National University, students={...}, ...}
}
entity()의 출력 형식 지침(사용자 메시지)과 시스템 메시지를 분리하면 더 높은 정확도를 얻을 수 있다.
| 구분 | 위치 | 역할 |
|---|---|---|
| 시스템 메시지 | 전역 설정 | 역할 정의, 분석 가이드라인, Few-shot 예시 |
| entity() 지침 | 사용자 메시지 하단 | JSON 구조 정의, 타입 매핑 |
public record TextClassification(String text, Sentiment classification) {
public enum Sentiment { POSITIVE, NEUTRAL, NEGATIVE }
}
@Test
void test() {
String text = "오늘은 날씨가 매우 추워 외출하기 싫어. 비가 오면 시원하고 좋은데, 좋은 건가 나쁜건가?";
TextClassification classification = chatClient.prompt()
// 1. 분석 로직 + 페르소나 (시스템 메시지)
.system("""
당신은 텍스트의 감정을 분석하는 전문가입니다.
입력된 문장의 뉘앙스를 살펴 [POSITIVE, NEUTRAL, NEGATIVE] 중 하나로 분류하세요.
- 단어뿐만 아니라 문맥의 흐름을 파악하십시오.
- 확실하지 않은 경우 NEUTRAL로 분류하십시오.
- 유효한 JSON으로 반환하십시오.
""")
// 2. 사용자 메시지
.user(u -> u.text("텍스트: {text}").param("text", text))
.call()
// 3. 자동 변환 (BeanOutputConverter가 내부적으로 JSON 구조 지침 추가)
.entity(TextClassification.class);
// 출력: TextClassification[text=오늘은..., classification=NEUTRAL]
}
| 기법 | 핵심 | 적합한 상황 |
|---|---|---|
| Zero-Shot | 예시 없이 지시만으로 수행 | 번역, 요약 등 일반적 작업 |
| Few-Shot | 예시 데이터로 형식·규칙 학습 | 특정 출력 형식, 도메인 특화 분류 |
| Role Prompting | 페르소나 주입으로 전문성 강화 | 전문가 답변, 특정 문체 적용 |
| Step-Back | 배경 원리 먼저 파악 후 답변 | 복잡한 진단, 근본 원인 분석 |
| CoT | 중간 추론 과정을 나열 | 수학, 논리 문제, 다단계 추론 |
| Self-Consistency | 다수결로 최종 답변 선택 | 환각 감소, 신뢰성이 중요한 분류 |
구조화된 출력은 단순히 형식을 강제하는 것이 아니라, LLM의 응답을 신뢰 가능한 데이터로 만드는 과정이다. 시스템 메시지(비즈니스 로직)와
entity()(구조 정의)를 분리하는 것이 핵심이다.
Copyright ⓒ TeamSparta · 기술 강의노트를 바탕으로 제작된 블로그입니다.