Wedge 프로젝트에서 웨딩 견적 챗봇을 Spring AI로 구현하면서 네 가지 문제를 만났다.
버전 호환 문제, GPT가 JSON 대신 텍스트를 반환하는 문제, 직렬화 오류까지.
각 문제를 어떻게 해결했는지, 그리고 더 나은 방식인 Function Calling / Structured Output으로
어떻게 개선할 수 있는지까지 함께 기록한다.
웨딩 견적 챗봇은 3턴에 걸쳐 사용자에게 서비스 종류, 예산, 인원을 물어보고 예상 견적을 반환하는 기능이다.
[1턴] 어떤 서비스가 필요하세요? → 웨딩 사진작가
[2턴] 예산은 어느 정도 생각하시나요? → 100~200만원
[3턴] 하객은 몇 분 정도 예상하시나요? → 100명
↓
{
"message": "예상 견적을 안내해 드립니다.",
"selectedServices": ["웨딩 사진작가"],
"priceRange": "100~150만원",
"summary": "스냅사진 기준 예상 견적입니다."
}
기술 스택은 이렇다.
전체 아키텍처를 보면 이렇다.
Client
→ POST /api/chatbot/estimate {turn, userMessage, sessionId?}
→ EstimateChatService
→ Redis에서 이전 대화 이력 조회
→ SystemPrompt + 이력 + 현재 메시지 조합
→ ChatClient.prompt().call()
→ 응답 Redis에 저장
→ 3턴 완료 시 JSON 파싱 후 Redis 키 삭제
→ ChatResponse {sessionId, message, isDone, estimateResult?}
org.springframework.boot.autoconfigure.condition.OnBeanCondition:
RestClientAutoConfiguration not present
build.gradle에 Spring AI 1.0.0-SNAPSHOT을 추가했더니 서버 자체가 뜨지 않았다.
Spring AI 1.x는 Spring Boot 3.x 기준으로 만들어진 라이브러리다. Spring Boot 4.x에서 RestClientAutoConfiguration 클래스의 패키지 경로가 바뀌었는데, Spring AI 1.x가 이 변경을 반영하지 못한 것이다.
| Spring Boot | Spring AI |
|---|---|
| 3.x | 1.x |
| 4.x | 2.x |
Spring AI 2.0.0-M2로 변경했다. M2는 Milestone 버전이라 milestone 저장소를 별도로 추가해야 한다.
// build.gradle
repositories {
mavenCentral()
// milestone 저장소 추가 필수
maven { url 'https://repo.spring.io/milestone' }
}
dependencies {
implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter:2.0.0-M2'
}
# application.yml
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
options:
model: gpt-4o-mini
Spring AI가 ChatClient.Builder는 자동 등록하지만 ChatClient 자체는 직접 등록해야 한다. 이걸 빠뜨리면 No qualifying bean of type 'ChatClient' 에러가 난다.
// ChatClientConfig.java
@Configuration
public class ChatClientConfig {
@Bean
public ChatClient chatClient(ChatClient.Builder builder) {
return builder.build();
}
}
기존에는 RestClient로 OpenAI API를 직접 호출했다.
// 기존 방식 — RestClient 직접 호출
Map<String, Object> requestBody = Map.of(
"model", "gpt-4o-mini",
"messages", buildContents(sessionId, userMessage), // Gemini 방식
"temperature", 0.7
);
ResponseEntity<Map> response = restClient.post()
.uri("https://api.openai.com/v1/chat/completions")
.header("Authorization", "Bearer " + apiKey)
.body(requestBody)
.retrieve()
.toEntity(Map.class);
// 응답에서 텍스트 추출
String content = ((Map) ((Map) ((List) response.getBody()
.get("choices")).get(0)).get("message")).get("content").toString();
Spring AI로 전환하면 이렇게 된다.
// Spring AI 방식
String result = chatClient.prompt()
.messages(buildMessages(sessionId, userMessage, turn))
.call()
.content();
API URL, 인증 헤더, JSON 구조, 응답 파싱을 전부 Spring AI가 처리한다. 코드량이 절반 이하로 줄었다.
3턴 완료 시 아래와 같은 응답이 와야 하는데
{
"message": "예상 견적을 안내해 드립니다.",
"selectedServices": ["웨딩 사진작가"],
"priceRange": "100~150만원",
"summary": "스냅사진 기준 예상 견적입니다."
}
실제로는 이런 응답이 왔다.
네, 알겠습니다! 웨딩 사진작가 서비스에 대한 견적을 안내해 드리겠습니다.
선택하신 서비스: 웨딩 사진작가
예산 범위: 100~200만원
...
JSON 파싱을 시도하면 당연히 실패한다.
GPT는 대화를 자연스럽게 이어가려는 성향이 있다. 시스템 프롬프트에 "3턴 완료 시 JSON으로만 응답하라"고 명시해도, GPT 입장에서 3턴이 완료됐는지를 스스로 판단하기 어렵다. 대화 맥락만으로는 지금이 마지막 턴인지 모른다.
3턴 완료 시점에 "반드시 JSON만 반환하라"는 메시지를 명시적으로 주입했다.
// EstimateChatService.java
private List<Message> buildMessages(String sessionId, String userMessage, int turn) {
List<Message> messages = new ArrayList<>();
// 1. 시스템 프롬프트 (역할, 대화 흐름, 출력 형식 정의)
messages.add(new SystemMessage(promptProvider.getSystemPrompt()));
// 2. Redis에서 이전 대화 이력 복원
List<MessageHistory> history = getHistory(sessionId);
history.forEach(h -> {
messages.add(new UserMessage(h.getUserMessage()));
messages.add(new AssistantMessage(h.getAssistantMessage()));
});
// 3. 현재 사용자 메시지
messages.add(new UserMessage(userMessage));
// 4. 3턴 완료 시 JSON 반환 강제 메시지 주입
if (turn == 3) {
messages.add(new UserMessage(
"모든 정보가 수집되었습니다. " +
"반드시 JSON 형식으로만 응답하세요. " +
"다른 텍스트나 설명은 절대 포함하지 마세요. " +
"JSON 외 어떤 내용도 출력하지 마세요."
));
}
return messages;
}
// 실제 ChatClient 호출
public ChatResponse chat(String sessionId, int turn, String userMessage) {
List<Message> messages = buildMessages(sessionId, userMessage, turn);
String response = chatClient.prompt()
.messages(messages)
.call()
.content();
// Redis에 대화 이력 저장
saveHistory(sessionId, userMessage, response);
if (turn == 3) {
// Redis 세션 삭제
redisTemplate.delete(sessionId);
EstimateResult result = parseEstimateResult(response);
return ChatResponse.done(sessionId, result);
}
return ChatResponse.ongoing(sessionId, response);
}
3턴 완료 후 JSON 반환을 강제했는데도 이런 응답이 왔다.
다음은 예상 견적입니다:
{"message":"예상 견적을 안내해 드립니다.","selectedServices":["웨딩 사진작가"],"priceRange":"100~150만원","summary":"스냅사진 기준입니다."}
더 궁금한 점이 있으시면 말씀해 주세요.
JSON 앞뒤로 텍스트가 붙어서 objectMapper.readValue(response, EstimateResult.class)가 실패한다.
{ 와 } 사이의 JSON만 추출하는 방어적 파싱 로직을 적용했다.
// EstimateChatService.java
private EstimateResult parseEstimateResult(String response) {
try {
// { } 사이의 JSON만 추출
int start = response.indexOf("{");
int end = response.lastIndexOf("}") + 1;
if (start == -1 || end == 0) {
log.warn("응답에서 JSON을 찾을 수 없음: {}", response);
return EstimateResult.fallback(response);
}
String json = response.substring(start, end);
return objectMapper.readValue(json, EstimateResult.class);
} catch (JsonProcessingException e) {
log.error("견적 결과 파싱 실패: {}", e.getMessage());
// 파싱 실패 시 AI 원본 응답을 message로 폴백
return EstimateResult.fallback(response);
}
}
// EstimateResult.java
@Getter
@NoArgsConstructor
public class EstimateResult {
private String message;
private List<String> selectedServices;
private String priceRange;
private String summary;
// JSON 파싱 실패 시 폴백 — AI 원본 응답을 message에 담아 반환
public static EstimateResult fallback(String rawResponse) {
EstimateResult result = new EstimateResult();
result.message = rawResponse;
result.selectedServices = List.of();
result.priceRange = "확인 필요";
result.summary = "견적 생성에 문제가 발생했습니다. 다시 시도해주세요.";
return result;
}
}
백엔드에서 isDone: true로 내려줘야 하는데, 프론트에서 done: true로 받아지는 문제가 발생했다.
Java에서 boolean 타입 필드에 is 접두사가 붙으면 Lombok이 isDone() getter를 생성한다. Jackson은 getter 이름을 기반으로 JSON 키를 결정하는데, isDone() → is 접두사를 제거하고 done으로 직렬화한다.
// Lombok이 생성하는 getter
public boolean isDone() { return this.isDone; }
// Jackson이 읽는 방식
// isDone() → "is" 제거 → "done"으로 직렬화
@JsonProperty로 직렬화 키를 명시했다.
// ChatResponse.java
@Getter
public class ChatResponse {
private final String sessionId;
private final String message;
@JsonProperty("isDone") // 직렬화 키 명시
private final boolean isDone;
private final EstimateResult estimateResult;
// 진행 중 응답
public static ChatResponse ongoing(String sessionId, String message) {
return new ChatResponse(sessionId, message, false, null);
}
// 완료 응답
public static ChatResponse done(String sessionId, EstimateResult result) {
return new ChatResponse(sessionId, result.getMessage(), true, result);
}
}
이 문제는 boolean 필드에 is 접두사를 쓸 때 자주 발생한다. 해결 방법은 두 가지다.
// 방법 1: @JsonProperty로 키 명시 (현재 적용)
@JsonProperty("isDone")
private final boolean isDone;
// 방법 2: 필드명을 done으로 바꾸기 (is 접두사 제거)
private final boolean done;
지금 구현의 근본적인 문제는 프롬프트로 출력 형식을 제어하는 데 의존한다는 점이다. 아무리 강하게 명시해도 GPT가 JSON 외 텍스트를 붙이는 경우가 생기고, 방어적 파싱 로직이 필요해진다.
Spring AI는 이 문제를 해결하는 두 가지 방법을 제공한다.
Spring AI의 BeanOutputConverter를 사용하면 GPT 응답을 Java 객체로 직접 변환할 수 있다. 프롬프트에 JSON 스키마를 자동으로 주입해주고, 응답을 파싱까지 해준다.
// EstimateChatService.java — Structured Output 적용
public EstimateResult chatWithStructuredOutput(
String sessionId, int turn, String userMessage) {
// BeanOutputConverter: EstimateResult 스키마를 프롬프트에 자동 주입
BeanOutputConverter<EstimateResult> converter =
new BeanOutputConverter<>(EstimateResult.class);
List<Message> messages = buildMessages(sessionId, userMessage, turn);
// 3턴 완료 시 출력 형식 지시 메시지 추가
if (turn == 3) {
// converter.getFormat()이 JSON 스키마를 반환함
messages.add(new UserMessage(
"견적을 계산해주세요. 반드시 아래 형식으로만 응답하세요:\n"
+ converter.getFormat()
));
}
String response = chatClient.prompt()
.messages(messages)
.call()
.content();
if (turn == 3) {
// converter.convert()가 JSON 파싱까지 처리
return converter.convert(response);
}
saveHistory(sessionId, userMessage, response);
return null;
}
converter.getFormat()이 반환하는 JSON 스키마는 이런 형태다.
Your response should be in JSON format.
Do not include any explanations, only provide a RFC8259 compliant JSON response.
The JSON should follow this schema:
{
"message": "string",
"selectedServices": ["string"],
"priceRange": "string",
"summary": "string"
}
스키마를 직접 작성할 필요 없이 Java 클래스 구조에서 자동으로 생성해준다.
Function Calling은 GPT가 특정 함수를 호출하는 형태로 응답하도록 강제한다. 응답 자체가 함수 호출 파라미터 구조이기 때문에 텍스트가 섞일 여지가 없다.
// EstimateFunction.java — GPT가 호출할 함수 정의
@Component
public class EstimateFunction
implements Function<EstimateFunction.Request, EstimateFunction.Response> {
// GPT에게 전달될 함수 입력 스키마
public record Request(
@JsonProperty(required = true)
@JsonPropertyDescription("사용자가 선택한 웨딩 서비스 목록")
List<String> selectedServices,
@JsonProperty(required = true)
@JsonPropertyDescription("예상 가격 범위 (예: 100~200만원)")
String priceRange,
@JsonProperty(required = true)
@JsonPropertyDescription("견적 요약 메시지")
String summary,
@JsonProperty(required = true)
@JsonPropertyDescription("사용자에게 보여줄 안내 메시지")
String message
) {}
public record Response(boolean success) {}
@Override
public Response apply(Request request) {
// 실제로는 여기서 견적 저장, 알림 발송 등 처리 가능
return new Response(true);
}
}
// ChatClientConfig.java — 함수 빈 등록
@Configuration
public class ChatClientConfig {
@Bean
public ChatClient chatClient(ChatClient.Builder builder) {
return builder
.defaultFunctions("estimateFunction") // 기본 함수 등록
.build();
}
@Bean
@Description("웨딩 견적 계산 함수. 3턴 완료 시 반드시 이 함수를 호출해야 합니다.")
public Function<EstimateFunction.Request, EstimateFunction.Response> estimateFunction(
EstimateFunction function) {
return function;
}
}
// EstimateChatService.java — Function Calling 적용
public ChatResponse chatWithFunctionCalling(
String sessionId, int turn, String userMessage) {
List<Message> messages = buildMessages(sessionId, userMessage, turn);
if (turn == 3) {
messages.add(new UserMessage(
"모든 정보가 수집되었습니다. " +
"반드시 estimateFunction을 호출해서 견적을 반환하세요."
));
}
// GPT가 텍스트 대신 함수 호출로 응답
ChatResponse response = chatClient.prompt()
.messages(messages)
.call()
.chatResponse();
// 함수 호출 결과에서 파라미터 추출
if (turn == 3) {
// Spring AI가 함수 호출을 자동으로 처리하고 결과를 반환
// EstimateFunction.apply()가 호출되고 Request 객체로 파라미터를 받음
redisTemplate.delete(sessionId);
return ChatResponse.done(sessionId, extractEstimateResult(response));
}
String content = response.getResult().getOutput().getText();
saveHistory(sessionId, userMessage, content);
return ChatResponse.ongoing(sessionId, content);
}
| 구분 | Structured Output | Function Calling |
|---|---|---|
| 응답 형식 보장 | 프롬프트로 유도 (100% 보장 X) | 함수 호출로 강제 (구조 보장) |
| 구현 난이도 | 낮음 | 중간 |
| 텍스트 혼입 가능성 | 있음 (방어적 파싱 필요) | 없음 |
| 추가 기능 | 없음 | 함수 실행 (DB 저장, 알림 등) |
| 적합한 경우 | 단순 JSON 반환 | 외부 시스템 연동이 필요한 경우 |
현재 구현처럼 단순히 JSON을 반환받는 용도라면 Structured Output이 구현이 단순해서 더 적합하다. 견적 결과를 바탕으로 추천 프리랜서 자동 검색, 예약 생성 같은 추가 액션이 필요하다면 Function Calling이 맞다.
이번 챗봇 구현에서 배운 것을 정리하면 이렇다.
Spring AI 버전은 Spring Boot 버전과 반드시 맞춰야 한다. Boot 3.x → AI 1.x, Boot 4.x → AI 2.x. 버전이 맞지 않으면 AutoConfiguration 단계에서 실패한다.
GPT 프롬프트로 출력 형식을 완전히 제어하기 어렵다. 아무리 강하게 명시해도 텍스트가 붙어 나오는 경우가 있다. 파싱 로직은 항상 방어적으로 작성해야 한다. 더 나은 방법은 Structured Output이나 Function Calling을 사용하는 것이다.
Java boolean 필드에 is 접두사를 쓸 때는 직렬화 키를 명시하라. @JsonProperty("isDone")으로 키를 명시하지 않으면 Jackson이 done으로 직렬화한다.
ChatClient 빈은 직접 등록해야 한다. Spring AI는 ChatClient.Builder만 자동 등록한다. ChatClient 자체는 @Bean으로 직접 등록해야 한다.