Day 4. 구조화 출력(Structured Output) — "LLM 의 답을 String 이 아니라 Record 로 받자"
안녕하세요! 여러분의 백엔드 가이드, 홍순구 튜터입니다.
Day 3, 정말 알차게 보내셨죠.
지난 시간 우리는 프롬프트를 자바 String 이 아니라 버전 관리가 필요한 설계 자산 으로 다루는 감각을 손에 익혔어요.
RCTFE 5축 으로 시스템 프롬프트의 뼈대를 세웠고, ChatClient.Builder 로 페르소나 빌더를 짰고, {userName} 익명 슬롯으로 PII 도 막았고, 마지막엔 프롬프트를 classpath:prompts/soulmate/system-v1.st 파일로 빼서 "코드 배포 없이 프롬프트만 갈아끼우는" 운영 감각까지 체득했어요.
그런데 지난 시간 마무리 섹션에서 제가 이런 말을 흘리고 도망쳤죠.
"Day 3 의
# Format섹션 — 'aiMessage 는 문자열, choices 는 배열, affectionDelta 는 정수' — 이거 자연어로 LLM 에 JSON 구조를 설명하는 방식 인데, 사실 이 아픔을 한 방에 잘라주는 도구가 따로 있어요.BeanOutputConverter.
오늘이 그 약속을 지키는 날입니다.
💡 오늘 수업의 핵심 "LLM 응답을 타입 안전한 Record 로 받는다" 🎯
오늘 수업은 한 문장으로 요약돼요.
"LLM 의 답을
String으로 받지 마라.Record로 받아라."
여기서 두 가지 기술이 등장해요.
BeanOutputConverter<T>— 우리가 정의한 자바record의 JSON 스키마를 Spring AI 가 자동 생성 해서 시스템 프롬프트의# Format섹션에 자동 주입 해줘요.
지난 시간 우리가 손으로 쓴 "aiMessage 는 문자열, choices 는 배열..." 같은 자연어 설명, 이제 자바 타입 선언 한 번이면 끝이에요.
.call().entity(Class<T>)패턴 — 호출 결과를String대신 우리가 원하는record타입으로 바로 받아요.
ObjectMapper.readValue(...) 도, try { ... } catch (JsonProcessingException e) { ... } 도 내가 안 짭니다.
Spring AI 가 해줘요.
이 두 가지가 손에 익으면, 지난 시간 본 우리 GeminiService 의 그 무거운 코드를 압축할 수 있어요.
그 무거운 코드란 — responseJsonSchema 를 LinkedHashMap 으로 손 조립하고, OBJECT_MAPPER.readValue(rawText.trim(), GeminiParsedResponse.class) 를 try-catch 로 감싸고, 파싱 실패하면 BusinessException(ErrorCode.AI_UNAVAILABLE) 던지던 그 30 줄이에요.
이걸 단 두 줄 로 줄일 수 있습니다.
🙋 한 학생의 걱정
"튜터님, 솔직히 말씀드리면요. 지난 시간 Day 3 끝나고
# Format섹션에 'aiMessage 는 문자열로...' 라고 쓰면서도 좀 찝찝했거든요. 이거 자연어로 쓰는 게 정말 LLM 한테 잘 전달이 되는 건가? 모델이 키 이름 헷갈리면 어떡하나? 그리고 우리 레거시GeminiService보면 Gemini 한테responseJsonSchema라는 진짜 JSON Schema 를 넘기던데, Spring AI 도 이런 걸 안에서 해주나요? 아니면 그냥 자연어로 '이렇게 줘~' 하고 비는 건가요? "
날카로운 질문이에요.
정답을 살짝만 미리 말씀드리면 — Spring AI 는 둘 다 합니다. BeanOutputConverter 는 우리 record 의 필드를 분석해서 JSON Schema 텍스트를 만들어 시스템 프롬프트 끝에 자동으로 끼워넣어요. "이렇게 줘~" 가 아니라 "이 스키마를 준수하는 JSON 만 줘" 라고 강제하는 거죠.
그리고 응답이 와도 그냥 믿고 파싱하는 게 아니라, 모델이 깨뜨렸을 때의 복구 전략 (재시도 / fallback / strict mode) 도 같이 짜야 해요.
그 트레이드오프까지가 오늘 Step 6 의 주제예요.
🎯 학습 목표
BeanOutputConverter<T>의 내부 동작 (record → JSON Schema → 프롬프트 주입 → 응답 역직렬화) 을 한 사이클로 이해합니다..call().entity(Class<T>)/.entity(ParameterizedTypeReference<T>)호출 패턴을 손에 익혀서 단일 record ·List<T>·Map<K,V>응답을 모두 타입 안전하게 받습니다.- 우리 ai-friends 의
SoulmateChatService가String대신AiReplyrecord 를 반환하도록 리팩토링하고, 컨트롤러까지 타입을 흘려보내는 흐름을 직접 만듭니다. - 레거시
GeminiService의responseJsonSchema손 조립 +ObjectMapper.readValue수동 파싱 30 줄 을 Spring AI 추상화로 어떻게 걷어낼 수 있는지 비교합니다 (아직 완전히 걷어내지는 않습니다 — Day 5 ChatMemory 와 함께 가야 자연스러워요). - JSON 파싱이 깨졌을 때의 복구 전략 — 재시도(
retry) · 폴백(fallback) · 엄격 모드(strict) 의 트레이드오프를 손으로 비교하고 우리 앱에 맞는 정책을 고릅니다. - 스키마 크기 ↔ 토큰 비용 트레이드오프 를 이해해서, "record 를 어디까지 풍부하게 만들어도 되는가" 의 손익분기점 감각을 잡습니다.
Step 1: "수동 JSON 파싱의 4 가지 함정" — `GeminiService` 의 try-catch 지옥을 한 번 더 펼쳐보기
자, 본격적으로 새 도구 (BeanOutputConverter) 를 손에 쥐기 전에, 왜 그게 필요한지 부터 몸으로 느끼고 가야 해요. 그래야 도구가 들어왔을 때 "와, 이거 진짜 살았다" 라는 감각이 옵니다.
오늘은 우리가 이미 코드베이스에 갖고 있는 레거시 GeminiService 를 한 번 더 펼쳐서, 거기 숨어있는 4 가지 함정 을 하나씩 해부해볼게요.
지난 시간까지 우리가 알던 GeminiService
ai-friends/src/main/java/kr/spartaclub/aifriends/service/GeminiService.java 을 잠깐 다시 펼쳐봅시다.
Day 3 Step 7 에서 우리는 이 클래스의 시스템 프롬프트 부분만 외부 .st 파일로 빼냈어요.
그런데 사실 이 클래스는 JSON 응답을 만들고 받는 부분 도 같이 손으로 짜고 있었어요.
그쪽은 지난 시간엔 안 건드렸죠.
그 부분, 이렇게 생겼어요. 두 덩어리만 발췌해볼게요.
// 1번 덩어리: 응답 JSON Schema 를 손으로 조립
private static Map<String, Object> buildResponseJsonSchema() {
Map<String, Object> schema = new LinkedHashMap<>();
schema.put("type", "object");
Map<String, Object> properties = new LinkedHashMap<>();
properties.put("aiMessage", Map.of(
"type", "string",
"description", "캐릭터가 플레이어에게 하는 대사. 화면에 그대로 표시됩니다."));
properties.put("choices", Map.of(
"type", "array",
"items", Map.of("type", "string"),
"description", "플레이어가 고를 수 있는 선택지 2~4개. 없으면 빈 배열 []."));
properties.put("affectionDelta", Map.of(
"type", "integer",
"description", "호감도 증감치. -5 ~ +5 정수."));
schema.put("properties", properties);
schema.put("required", List.of("aiMessage", "choices", "affectionDelta"));
return schema;
}
// 2번 덩어리: LLM 이 돌려준 JSON 문자열을 ObjectMapper 로 수동 파싱
String rawText = response.extractText();
if (!StringUtils.hasText(rawText)) {
throw new BusinessException(ErrorCode.AI_UNAVAILABLE);
}
log.info("Gemini raw JSON length={}", rawText.length());
GeminiParsedResponse parsed;
try {
parsed = OBJECT_MAPPER.readValue(rawText.trim(), GeminiParsedResponse.class);
} catch (Exception e) {
log.error("Gemini JSON 응답 파싱 실패. raw 길이={}", rawText != null ? rawText.length() : 0, e);
throw new BusinessException(ErrorCode.AI_UNAVAILABLE);
}
if (parsed == null) {
throw new BusinessException(ErrorCode.AI_UNAVAILABLE);
}
겉으로 보면 "잘 짜여 있는데?" 싶죠. 동작도 잘 해요. 하지만 이 코드 안에 4 가지 함정이 숨어있어요. 하나씩 까봅시다.
함정 ① — JSON Schema 손 조립 지옥
buildResponseJsonSchema() 메서드를 다시 보세요.
LinkedHashMap 을 new 하고, properties 라는 또 다른 LinkedHashMap 을 만들고, 거기에 Map.of("type", "string", "description", "...") 같은 걸 손으로 박고 있어요.
JSON Schema 명세를 자바 자료구조로 옮겨 적는 작업이에요.
지금은 필드가 3 개뿐이니까 봐줄 만한데, 만약 PM 이 다음 스프린트에 이런 요구를 들고 오면요?
"튜터님, 캐릭터 응답에
emotion필드 (happy/sad/angry/neutral중 하나) 도 추가해주세요. 그리고relatedKeywords라는List<String>도요. 아, 그리고metadata라는Map<String, String>도..."
매 필드가 늘어날 때마다 우리는 세 군데 를 같이 고쳐야 해요.
GeminiParsedResponserecord 에 필드 추가buildResponseJsonSchema()의properties.put(...)한 줄 추가required리스트에도 추가 (필수 필드라면)
그리고 만약 1번만 고치고 2번을 깜빡하면? 컴파일은 통과합니다. LLM 도 응답을 줍니다. 다만 LLM 이 새 필드를 안 채워줘서 record 에 null 이 박힐 뿐이에요. 디버깅 한참 헤매죠.
함정 ② — ObjectMapper.readValue try-catch 지옥
2 번 덩어리의 코드, 다시 보세요.
GeminiParsedResponse parsed;
try {
parsed = OBJECT_MAPPER.readValue(rawText.trim(), GeminiParsedResponse.class);
} catch (Exception e) {
log.error("Gemini JSON 응답 파싱 실패. raw 길이={}", ...);
throw new BusinessException(ErrorCode.AI_UNAVAILABLE);
}
LLM 이 JSON 을 깨뜨려서 보낼 가능성이 0% 가 아니에요. 모델이 가끔 이런 짓을 합니다.
- 앞에
```json마크다운 펜스를 붙여서 보냄 (Gemini 의responseMimeType: application/json을 쓰면 거의 안 그러긴 하지만, OpenAI 는 자주 그래요) - 뒤에 부연 설명을 한 줄 더 붙임 ("위 JSON 은 ~~ 의미입니다")
- 키 이름을 살짝 바꿈 (
affectionDelta→affection_delta) - 문자열 안의 특수문자 이스케이프 누락
이게 터지면 우리 코드의 대응은 AI_UNAVAILABLE 를 던지고 끝. 사용자한텐 그냥 "AI 가 응답하지 않습니다" 만 보여요. 사실은 모델이 응답을 줬는데 우리가 못 읽은 거 인데도요.
재시도 한 번이라도 해볼 수 있을까요? 지금 코드에선 재시도 로직을 손으로 짜야 해요. 한 번 더 호출하고, 그래도 깨지면 또 잡고... 이걸 구현하기 시작하면 코드가 또 30 줄 늘어납니다.
함정 ③ — record 와 Schema 의 "이중 진실"
이게 가장 음흉한 함정이에요. 우리한텐 지금 "진실을 담은 두 군데" 가 있어요.
GeminiParsedResponserecord — 자바 컴파일러가 강제하는 진실buildResponseJsonSchema()의Map— LLM 한테 보내는 진실
이 둘이 항상 같아야 동작이 안정적이에요. 그런데 자바 컴파일러는 둘이 어긋나는 걸 절대 못 잡아줘요. 그도 그럴 게, schema 는 그냥 Map<String, Object> 안에 들어있는 문자열이거든요. 컴파일러 입장에선 그냥 평범한 자료구조죠.
이런 코드, 6 개월 뒤에 새로 합류한 동료가 record 만 바꾸고 schema 는 안 바꾸면? 터지지 않고 조용히 동작하는 버그 가 됩니다. 운영에서 가장 무서운 게 이거예요.
함정 ④ — 프로바이더 락인
이게 Day 2 와 직접 연결되는 함정이에요. 우리가 Day 2 에서 뭘 배웠죠?
"
spring.ai.model.chat프로퍼티 한 줄로 Ollama ↔ Gemini 가 갈아끼워진다. 이게ChatModel추상화의 힘이다."
자, 그런데 GeminiService.buildResponseJsonSchema() 의 결과는 어디로 들어가죠? GeminiRequest.GenerationConfig 의 responseJsonSchema 필드로요.
이거 Gemini API 만의 기능 이에요.
OpenAI 는 같은 위치에 response_format: { type: "json_schema", schema: ... } 라는 다른 스키마 형식을 써요.
Ollama 는 또 다르고요.
Anthropic 은 또 달라요.
즉, 지금 코드는 "Gemini 한테만 작동하는 JSON 강제 방식" 에 단단히 묶여 있어요. Day 2 에서 배운 그 추상화가, 응답 스키마 강제 부분에선 안 통한다는 뜻 이에요.
만약 다음 주에 PM 이 "비용 때문에 일부 트래픽을 Ollama 로 돌리자" 라고 하면, 우리는 GeminiService 를 통째로 들어내고 OllamaService 를 새로 짜야 해요. JSON 강제 로직이 프로바이더에 묶여 있어서요.
🙋 날카로운 질문 타임 — 학생 질문
"튜터님, 그래도 Gemini 의
responseJsonSchema는 모델 레벨에서 JSON 을 강제하니까 깨질 가능성이 거의 없잖아요. Spring AI 의BeanOutputConverter가 그걸 포기하면 오히려 후퇴하는 거 아닌가요? 🤔"
아주 좋은 의심이에요. 정답은 — 포기하지 않습니다. Spring AI 의 BeanOutputConverter 는 두 가지를 동시에 해요.
- JSON Schema 텍스트 를 자동으로 만들어서 시스템 프롬프트에 끼워넣어요 — 이건 모든 프로바이더에서 통하는 보편적 강제 방법이에요. (= 프로바이더 락인 회피)
- 추가로, 프로바이더가 네이티브 JSON 모드 (Gemini 의
responseMimeType, OpenAI 의response_format) 를 지원하면 그것도 활용해요. 다만 활성화 여부는 프로바이더별 옵션 으로 따로 관리합니다.
즉 후퇴가 아니라 양다리 예요. 보편 강제 (프롬프트 안에 schema 박기) 를 깔아두고, 모델이 지원하면 네이티브도 쓰는 식이죠. 이 그림은 Step 3 에서 BeanOutputConverter 의 내부를 들여다보면서 다시 자세히 봅시다.
💡 튜터의 결론
지금까지 본 4 가지 함정을 한 표로 정리하면 이래요.
| # | 함정 | 증상 | 운영에서 얼마나 아픈가 |
|---|---|---|---|
| ① | JSON Schema 손 조립 | 필드 추가 시 세 군데를 함께 수정 | 중간 — 깜빡 한 번이면 사일런트 버그 |
| ② | readValue try-catch |
모델이 깨뜨리면 그대로 AI_UNAVAILABLE |
큼 — 사용자에게 보이는 장애 |
| ③ | record ↔ schema 이중 진실 | 컴파일러가 못 잡는 어긋남 | 최대 — 6 개월 뒤 미래의 나를 괴롭힘 |
| ④ | 프로바이더 락인 | Ollama 로 못 갈아끼움 | 큼 — Day 2 에서 배운 추상화가 깨짐 |
이 4 가지를 한 번에 잡는 도구 가 바로 다음 Step 의 주인공, .entity(Class<T>) 와 그 뒤에 숨어있는 BeanOutputConverter 예요. Step 2 에서는 가장 단순한 record 하나로 PoC 를 돌려보고, Step 3 에서는 그 도구의 내부를 한 꺼풀씩 벗겨볼 거예요.
자, 짐을 다 봤으니 이제 짐을 내려놓으러 갑시다!
Step 2: `.entity(Class)` 첫 호출 — 가장 단순한 `record` 로
자, Step 1 에서 짐을 4 개나 발견했죠. 그 짐을 한 번에 내려놓는 도구가 어떻게 생겼는지, 이번엔 가장 단순한 예제 로 먼저 만나볼 거예요. 의도적으로 ai-friends 의 복잡한 도메인 (소울메이트 페르소나, 호감도, 선택지 등) 은 잠시 옆으로 치워두고, 새 데모용 컨트롤러 하나만 추가해봅시다.
💡 왜 메인 코드 (
SoulmateChatService,GeminiService) 를 바로 안 고치나요? 새 도구를 도입할 땐 항상 가장 작은 표면적 에서 먼저 검증하는 게 안전해요. 우리가 쓰던 큰 코드를 바로 갈아엎으면, 도구 자체의 이슈와 도메인 이슈가 뒤섞여서 디버깅이 지옥이 됩니다. Step 5 에서 본격 리팩토링할 때까지는 데모 컨트롤러로 도구만 손에 익혀요.
가장 단순한 record 하나 — Quote
데모용 도메인은 일부러 단순하게 잡을게요. 명언 한 줄 을 받는 record 입니다.
public record Quote(String text, String author) { }
필드 두 개.
끝.
text 는 명언 본문, author 는 누가 한 말.
이거 하나만 보고 LLM 이 "아 명언 본문이랑 작가 이름을 JSON 으로 줘야 하는구나" 라고 알아챌 수 있을까요? — 놀랍게도 가능합니다. 그게 어떻게 가능한지가 오늘 Step 3 의 주제예요.
일단 Step 2 에선 먼저 돌아가는 걸 본 다음에 그 비밀을 풀어봅시다.
데모 컨트롤러 — StructuredOutputDemoController
Day 1 에서 짠 HelloAiController 기억하시죠? 그 패턴을 그대로 따라갑니다.
package kr.spartaclub.aifriends.structured.api;
import kr.spartaclub.aifriends.common.response.ApiResponse;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* Day 4 Step 2 — 구조화 출력 PoC 데모 컨트롤러.
*
* 가장 단순한 record (Quote) 하나로 .entity(Class<T>) 의 동작을 체험하기 위한 일회용 데모.
* SoulmateChatService 같은 본 도메인 코드는 Step 5 에서 본격 리팩토링한다.
*/
@RestController
public class StructuredOutputDemoController {
private final ChatClient chatClient;
public StructuredOutputDemoController(ChatClient.Builder builder) {
// Day 2 에서 강조한 ChatModel 인터페이스 주입 원칙은 그대로.
// ChatClient.Builder 는 spring-ai-starter 가 자동 주입해주는 빌더이고,
// 그 뒤에 들어있는 ChatModel 은 spring.ai.model.chat 프로퍼티로 결정된다.
this.chatClient = builder.build();
}
public record Quote(String text, String author) { }
/**
* topic 에 관한 짧은 명언 한 줄을 Quote record 로 받아 표준 응답 래퍼에 담아 반환한다.
*
* 핵심: chatClient ... .call().entity(Quote.class) — 이 한 줄.
* 응답은 ApiResponse 로 감싸서 GlobalExceptionHandler 의 에러 응답과 형태를 통일한다.
*/
@GetMapping("/api/structured/quote")
public ResponseEntity<ApiResponse<Quote>> quote(@RequestParam(defaultValue = "용기") String topic) {
Quote quote = chatClient.prompt()
.user(u -> u.text("'{topic}' 에 관한 짧은 명언 한 줄을 알려줘.").param("topic", topic))
.call()
.entity(Quote.class); // ← 오늘의 주인공
return ResponseEntity.ok(ApiResponse.success(quote));
}
}
코드 전체가 30 줄도 안 돼요. 그리고 진짜 중요한 건 마지막 한 줄, .call().entity(Quote.class) 입니다. 이거 하나가 Step 1 에서 본 4 가지 짐을 한 번에 들어 옮기는 일꾼이에요.
⚠️ 참고: 위 데모는 Step 5 의 본격 리팩토링 전 일회용 PoC 입니다. Day 5 이후엔 다른 컨트롤러들과 패키지를 정리하면서 자연스럽게 정돈될 거예요. 지금은 "도구의 모양만 손에 익히는 게 목적" 이라는 호흡으로 가벼이 봅시다.
한 번 돌려봅시다 🚀
.env · 프로파일 세팅은 Day 1~3 에서 다 해놨으니 그대로 기동만 하면 됩니다.
cd ai-friends
./run.sh up
# 호출 1 — 기본 토픽 ("용기")
curl "http://localhost:8080/api/structured/quote"
# 호출 2 — 토픽 변경
curl "http://localhost:8080/api/structured/quote?topic=인내"
응답은 이런 식으로 떨어져요. (정확한 본문은 모델 따라 달라요)
{
"success": true,
"data": {
"text": "용기는 두려움이 없는 것이 아니라, 두려움을 이겨내는 판단이다.",
"author": "넬슨 만델라"
}
}
{
"success": true,
"data": {
"text": "인내는 쓰지만, 그 열매는 달다.",
"author": "장 자크 루소"
}
}
JSON 파싱 코드 한 줄도 안 짰는데 record 가 채워져서 ApiResponse.success(...) 한 단계만 감싸 컨트롤러 응답으로 흘러나갔어요. Spring MVC 가 Quote 객체를 자동으로 JSON 직렬화해서 응답 본문으로 던지고, 우리는 표준 래퍼만 한 줄 얹은 거예요. 🎯
Step 1 의 4 가지 짐, 어떻게 됐나?
기억하시죠? Step 1 에서 봤던 4 가지 함정.
| # | 함정 | Step 2 PoC 에서는? |
|---|---|---|
| ① | JSON Schema 손 조립 | 사라짐 — record Quote(String, String) 한 줄이 끝 |
| ② | readValue try-catch |
사라짐 — .entity(Quote.class) 가 알아서 함 |
| ③ | record ↔ schema 이중 진실 | 사라짐 — 진실의 원본이 record 하나뿐 |
| ④ | 프로바이더 락인 | 사라짐 — chatClient 는 ChatModel 인터페이스 위에 있는 추상화. Ollama 로 갈아끼워도 그대로 동작 |
코드 30 줄에서 짐 4 개가 동시에 빠졌어요. 짐이 빠진 공간에 우리는 도메인 본질 만 남길 수 있게 됐죠.
🙋 날카로운 질문 타임 — 학생 질문
"튜터님, 잠깐만요. 우리 컨트롤러 코드 어디에도 'JSON 으로 응답해' 라는 말을 안 넣었잖아요? 시스템 프롬프트도 비었고, user 메시지도 그냥 명언 달라는 자연어인데, LLM 이 어떻게 알고 JSON 으로 돌려주죠? 마법인가요? "
마법이 아니라 BeanOutputConverter 가 뒤에서 일하고 있는 거예요. .entity(Quote.class) 를 호출하는 순간 Spring AI 는 다음 두 가지를 합니다.
Quote.class를 분석해서 JSON Schema 텍스트 를 자동 생성. (대충 "필드 두 개: text 는 String, author 는 String. 이런 형태의 JSON 만 응답해" 같은 자연어 + 예시)- 이 텍스트를 여러분의 프롬프트 끝에 슬쩍 끼워 넣어서 LLM 한테 보냄. 그래서 LLM 은 시스템 프롬프트에 적힌 적도 없는 JSON 형식 지시를 받게 돼요.
즉, 여러분의 프롬프트 + Spring AI 가 자동 생성한 Format 지시문 이 합쳐져서 LLM 한테 가는 거예요. 지난 시간 (Day 3) 의 RCTFE 5 축 중 F (Format) 축이 자동화 된 셈이죠.
그 자동 생성된 텍스트가 정확히 어떤 모양인지, 그리고 어떻게 끼워넣어지는지는 — 다음 Step 3 에서 BeanOutputConverter.format() 메서드를 직접 호출해서 콘솔에 찍어볼 거예요. 비밀을 직접 눈으로 보면 마법이 평범한 도구로 바뀝니다.
💡 튜터의 결론
Step 2 의 한 문장 요약은 이래요.
"
record한 줄 +.entity(Class<T>)한 줄 = LLM 응답을 타입 안전하게 받기."
이게 오늘 우리가 손에 쥔 첫 도구예요. 아직은 "왜 동작하는지" 는 몰라요. 그냥 동작합니다. 그리고 Step 3 에선 그 비밀을 들여다보고, Step 4 에선 List<T> · Map<K,V> 같은 더 복잡한 타입까지 확장하고, Step 5 에선 ai-friends 의 본 도메인 (소울메이트) 을 진짜로 갈아엎을 거예요.
지금 이 PoC 컨트롤러는 Step 2 의 단계 학습용 일회용 코드 입니다. 한 번 돌려보고 머리에 도구의 모양을 박아두는 게 목적이에요.
자, 도구의 모양을 봤으니 이제 도구의 속을 까볼 시간입니다. Step 3 으로 갈까요?
Step 3: `BeanOutputConverter` 해부 — `getFormat()` 이 프롬프트에 무엇을 끼워넣는가
자, Step 2 에서 .entity(Quote.class) 한 줄로 마법처럼 record 가 채워지는 걸 봤죠. 그런데 마법은 좀 거슬려요. 운영에서 마법은 디버깅 못 하는 코드가 됩니다. 모델이 가끔 엉뚱한 답을 줬을 때 "왜 그랬지?" 를 추적하려면, 마법의 안쪽에서 정확히 무슨 일이 일어나는지 한 번은 직접 봐둬야 해요.
오늘 Step 3 의 목표는 단 하나예요. .entity(Class<T>) 가 LLM 한테 정확히 무엇을 보내는지 우리 눈으로 보는 것. 그 핵심 일꾼이 바로 BeanOutputConverter 입니다.
BeanOutputConverter 가 누구인가
먼저 정체부터 짚고 갑시다. org.springframework.ai.converter.BeanOutputConverter<T> 는 Spring AI 가 제공하는 두 얼굴의 일꾼 이에요.
| 얼굴 | 메서드 | 책임 |
|---|---|---|
| 첫째 | getJsonSchema() · getFormat() |
record T 를 분석해 LLM 한테 보낼 텍스트 를 만들어준다 |
| 둘째 | convert(String) |
LLM 이 돌려준 JSON 문자열을 T 인스턴스로 역직렬화 한다 |
.call().entity(Class<T>) 는 이 둘을 한 번에 해주는 편의 메서드예요. 호출 흐름을 풀어 쓰면 이렇습니다.
entity(Quote.class)진입 → 내부에서BeanOutputConverter<Quote>인스턴스 생성getFormat()호출 → 결과를 사용자 프롬프트 끝에 자동으로 붙여서 LLM 호출- LLM 응답 (문자열 JSON) 도착
convert(rawText)호출 →Quote인스턴스 반환
마법처럼 보였던 한 줄이 사실은 이 4 단계예요. 그리고 우리가 가장 보고 싶은 건 2 번에서 정확히 어떤 텍스트가 보내지는가 죠. 직접 봅시다.
디버그 엔드포인트 추가
Step 2 에서 만든 StructuredOutputDemoController 에 작은 엔드포인트 하나만 더 얹으면 돼요. LLM 호출은 안 합니다. 그냥 BeanOutputConverter<Quote> 를 손으로 인스턴스화해서 두 메서드를 호출한 결과를 그대로 응답 본문으로 떨어뜨려요.
import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.http.MediaType;
// ... 기존 import 생략
@GetMapping(value = "/api/structured/quote/format-debug",
produces = MediaType.TEXT_PLAIN_VALUE)
public String quoteFormatDebug() {
BeanOutputConverter<Quote> converter = new BeanOutputConverter<>(Quote.class);
return """
=== BeanOutputConverter<Quote>.getJsonSchema() ===
%s
=== BeanOutputConverter<Quote>.getFormat() ===
%s
""".formatted(converter.getJsonSchema(), converter.getFormat());
}
핵심 포인트 몇 가지.
text/plain으로 응답합니다. JSON 안에 또 JSON Schema 텍스트가 들어가면 이스케이프 지옥이 펼쳐져서 가독성이 망해요. 학습 목적이니 평문 그대로 — 다른 엔드포인트들은ApiResponse로 감싸지만, 이 디버그 출력만은 일부러 raw 평문으로 둬요. "BeanOutputConverter 가 LLM 한테 보내는 텍스트를 있는 그대로" 보여주는 게 학습 의도이니까요.- 이 엔드포인트는 LLM 호출 없음 입니다.
BeanOutputConverter의 첫 번째 얼굴(스키마 생성) 만 보는 거예요. 그래서 Ollama 가 안 떠 있어도, Gemini 키가 없어도 동작합니다. 🎯 - Step 2 데모 컨트롤러처럼 이 엔드포인트도 일회용 학습 보조 도구 예요. 본격 리팩토링 할 때 정돈됩니다.
한 번 돌려봅시다 🚀
cd ai-friends
./run.sh up
curl http://localhost:8080/api/structured/quote/format-debug
응답이 두 섹션으로 떨어져요. 하나씩 차분히 살펴봅시다.
첫 번째 섹션 — getJsonSchema() 의 결과
=== BeanOutputConverter<Quote>.getJsonSchema() ===
{
"$schema" : "https://json-schema.org/draft/2020-12/schema",
"type" : "object",
"properties" : {
"author" : {
"type" : "string"
},
"text" : {
"type" : "string"
}
},
"additionalProperties" : false
}
겨우 record Quote(String text, String author) 한 줄을 적었을 뿐인데, JSON Schema 한 덩어리가 자동으로 만들어졌어요. 이 출력에서 짚어둘 포인트가 4 개 있습니다.
$schema메타필드 —https://json-schema.org/draft/2020-12/schema라는 URL 이 박혀 있어요. "이건 JSON Schema 명세 Draft 2020-12 를 따른다" 는 표지판이에요.
이 표지판이 있으면 모델이 schema 의 의미를 더 정확히 해석 합니다 (요즘 모델은 모두 JSON Schema 를 학습 데이터에서 봤거든요).
type: "object"— 응답 루트가 객체여야 한다는 뜻.
record 가 곧 객체로 매핑되는 거죠.
만약 우리가 record QuoteList(List<Quote> items) 같이 짰다면 여기에도 object 가 박히지만, 그 안의 items 필드가 array 가 됐을 거예요.
(이건 다음 Step 4 의 주제예요.)
properties안의author,text— 알파벳 순으로 정렬되어 있어요 (a가t보다 앞이라).
record 선언 순서랑 다를 수 있어요.
헷갈리지 마세요.
이 정렬은 BeanOutputConverter 가 내부적으로 안정적인 비교를 위해 정렬해서 출력하는 거예요.
모델이 응답할 때의 키 순서랑은 무관 합니다.
additionalProperties: false— 이게 가장 중요해요.
"위 schema 에 정의되지 않은 필드는 응답에 넣지 마라" 는 강제 신호예요.
모델이 text, author 외에 자기 멋대로 language 같은 필드를 추가하는 걸 막아요.
만약 추가했다 하더라도 BeanOutputConverter 의 convert() 단계에서 ObjectMapper 가 무시해버립니다.
우리 record 가 안전하게 채워지는 핵심 방어선이에요.
💡 한 줄 직관 — record 는 자바 컴파일러가 강제하는 진실, JSON Schema 는 LLM 한테 강제하는 진실. 두 진실이 자동 동기화돼요. Day 4 오프닝에서 약속했던 "이중 진실 함정 ③" 이 여기서 사라지는 거예요.
두 번째 섹션 — getFormat() 의 결과
이게 진짜 주인공이에요. getJsonSchema() 는 그냥 스키마 텍스트일 뿐이고, 실제로 LLM 한테 보내지는 건 getFormat() 의 결과 입니다.
=== BeanOutputConverter<Quote>.getFormat() ===
Your response should be in JSON format.
Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation.
Do not include markdown code blocks in your response.
Remove the ```json markdown from the output.
Here is the JSON Schema instance your output must adhere to:
```{
"$schema" : "https://json-schema.org/draft/2020-12/schema",
"type" : "object",
"properties" : {
"author" : {
"type" : "string"
},
"text" : {
"type" : "string"
}
},
"additionalProperties" : false
}```
자세히 뜯어보면 이 텍스트는 자연어 지시문 4 줄 + JSON Schema 박스 1 개 로 구성돼요.
| 줄 | 메시지 | 이유 |
|---|---|---|
| 1 | "JSON 형식으로 응답해" | 응답을 자연어 산문이 아니라 파싱 가능한 형식으로 강제 |
| 2 | "설명 붙이지 말고 RFC8259 준수만" | "이 JSON 은 ~~ 의미입니다" 같은 부연 설명 차단 |
| 3 | "markdown 코드 블록 쓰지 마" | ```...``` 펜스로 감싸서 응답하는 모델 습관 차단 |
| 4 | "```json 펜스 빼라" | 3 번을 한 번 더 강조 (특히 OpenAI 계열이 자주 어김) |
| 박스 | 위 schema | 모델이 정확히 무엇을 채워야 하는지 명세 |
자, 이제 진짜 중요한 질문 — 이 텍스트가 언제 어디로 보내지는 거죠?
답: .entity(Quote.class) 를 호출하는 그 순간, Spring AI 가 여러분이 작성한 user 메시지 끝에 이 텍스트를 슬쩍 붙여서 LLM 한테 보냅니다. 즉 LLM 입장에서 받는 user 메시지는 이렇게 생겼어요.
'용기' 에 관한 짧은 명언 한 줄을 알려줘.
Your response should be in JSON format.
Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation.
Do not include markdown code blocks in your response.
Remove the ```json markdown from the output.
Here is the JSON Schema instance your output must adhere to:
```{ "$schema": ..., "properties": { "text": ..., "author": ... }, "additionalProperties": false }```
위쪽이 우리가 짠 부분, 아래쪽이 BeanOutputConverter 가 자동으로 붙인 부분이에요. 합쳐진 결과 가 LLM 한테 가는 거고요. 🎯
Day 3 의 RCTFE 와 연결 짓기
지난 시간 (Day 3) 우리가 시스템 프롬프트의 RCTFE 5 축 중 F (Format) 섹션에 손으로 뭐라고 썼었죠?
# Format
- aiMessage: 화면에 보여질 캐릭터의 대사 (문자열)
- choices: 유저가 고를 수 있는 다음 발화/행동 2~4개의 배열
- affectionDelta: 호감도 증감치, -5~+5 정수
자연어로 형식을 설명하는 방식이었어요. 잘 동작했지만 단점이 있었죠 — 필드 추가하면 두 군데를 같이 고쳐야 했고, 모델이 affectionDelta 를 affection_delta 로 바꿔도 막을 수 없었어요.
💡 Day 5 예고 — 위 6 줄짜리
# Format섹션은 BeanOutputConverter 도입과 함께 통째로 들어내요. 다음 시간 Step 6 (수렴) 에서prompts/soulmate/system-v1.st파일을 진짜로 사용하기 시작 할 때, 코드와 프롬프트가 같은 정보를 두 군데 들고 있는 중복 을 BeanOutputConverter 자동 주입으로 갈음하면서 자연스럽게 사라지는 결입니다.
이제 Step 3 의 메커니즘을 알았으니 다시 보면 Day 3 의 그 Format 섹션이 정확히 getFormat() 의 출력으로 자동 대체 되는 거예요. 자연어 설명이 아닌 JSON Schema 명세 로요. RCTFE 의 F 축이 코드(record) 에서 자동 생성 되는 거죠.
| Day | F (Format) 축의 정체 |
|---|---|
| Day 3 | 시스템 프롬프트 안에 손으로 적은 자연어 설명 |
| Day 4 | record 에서 BeanOutputConverter 가 자동 생성한 JSON Schema 텍스트 |
이렇게 자동화되니까 "나는 record 를 진실의 원본으로 다루기만 하면 된다" 는 감각이 새겨져요. Format 섹션의 동기화 책임이 사라지는 거예요.
🙋 날카로운 질문 타임 — 학생 질문
"튜터님, 그러면 Day 3 에서 Gemini 한테
responseJsonSchema라는 옵션으로 schema 를 직접 넘기던 그 방식은요? 그건 더 강력한 강제 아닌가요? 모델 레벨에서 JSON 만 뱉도록 보장하는 거잖아요.getFormat()은 결국 프롬프트 안의 텍스트일 뿐인데, 모델이 마음만 먹으면 무시할 수 있는 거 아니에요? 🤔"
날카로운 의심이에요. 정답을 깔끔히 정리하면 두 방식은 강도와 보편성이 다른 거예요.
| 방식 | 강제 위치 | 보편성 | 강도 |
|---|---|---|---|
getFormat() 자동 주입 |
프롬프트 텍스트 안 | 모든 LLM 에서 통함 | 중간 — 모델이 "잘 따라달라" 고 부탁받는 형태 |
Gemini responseJsonSchema |
API 레벨 옵션 | Gemini 전용 (OpenAI · Ollama 형식 다름) | 강함 — 모델 출력 단계에서 강제 |
OpenAI response_format: json_schema |
API 레벨 옵션 | OpenAI 전용 | 강함 |
getFormat() 만 쓰면 보편적이지만 강도가 약하고, API 레벨 옵션을 쓰면 강하지만 프로바이더에 묶여요. Spring AI 의 진짜 영리한 점은 양쪽을 다 깔아둔다 는 거예요. 즉:
getFormat()으로 프롬프트에 보편적 강제를 깔고- 추가로 프로바이더가 네이티브 JSON 모드를 지원하면 그것도 같이 켜요 (Spring AI 의
BeanOutputConverter와 각 프로바이더 옵션 연동)
여러분의 코드에서는 .entity(Class<T>) 한 줄만 쓰면 되고, 어떤 프로바이더를 쓰든 할 수 있는 만큼 강하게 강제 됩니다. Day 2 에서 배운 추상화 위에 이번엔 응답 강제까지 얹혀진 셈이에요.
"또 하나, 튜터님.
getFormat()의 자연어 4 줄이 영어로 적혀 있는데, 한국어 모델에는 좀 어색하지 않나요? 한국어로 적힌 프롬프트에 영어 지시문이 갑자기 끼어들면 모델이 헷갈릴 것 같은데요?"
좋은 관찰이에요. 정답은 "실무에선 거의 문제 없다" 입니다. 요즘 LLM (Gemini 2.5 계열, GPT-4o/4.1 계열, Llama 4 계열) 은 다국어를 자유롭게 섞어 처리해요. 영어 지시문 + 한국어 사용자 메시지 → 한국어 JSON 응답이 자연스럽게 떨어집니다.
다만 로컬 한국어 특화 소형 모델 (예: 작은 한국어 LoRA 가 붙은 7B 모델) 에선 가끔 영어 지시문에 끌려가 영어로 답변하는 경우가 있어요.
그럴 땐 BeanOutputConverter 의 생성자에 커스텀 ObjectMapper 를 넣어 동작을 비틀거나, Spring AI 의 setFormat(String) API 로 format 텍스트 자체를 한국어로 갈아치우는 우회로가 있어요.
단, 우회로는 우리 강의 스코프 밖이고, 우리가 쓰는 Gemini 2.5 Flash · Ollama gemma3 정도면 영어 지시문 그대로 두고 써도 안 깨져요.
💡 튜터의 결론
Step 3 의 한 문장 요약은 이래요.
"
.entity(Class<T>)의 마법은 사실 마법이 아니다.BeanOutputConverter가 record 에서 JSON Schema 를 자동 생성해 사용자 프롬프트 끝에 슬쩍 붙이는 것뿐이다."
마법이 평범한 도구로 변환된 순간이에요. → 평범하니까 디버깅 가능 하고, 디버깅 가능하니까 운영에 올릴 수 있어요. Step 1 의 함정 ③ "이중 진실" 이 사라진 진짜 메커니즘을 우리 눈으로 봤죠.
| Step | 진척 |
|---|---|
| Step 1 | 수동 파싱의 4 가지 함정 식별 |
| Step 2 | .entity(Class<T>) 한 줄로 함정 4 개 모두 사라지는 PoC |
| Step 3 | 그 한 줄의 안쪽을 직접 보고, getFormat() 텍스트를 우리 손으로 출력 |
여기까지가 단일 record 의 이야기였어요.
그런데 진짜 운영에선 응답이 항상 단일 객체일 수가 없죠.
List<Quote> 같은 컬렉션이나 Map<String, Integer> 같은 동적 키 집합도 받아야 해요.
이걸 받으려면 Class<T> 만으로는 부족하고 ParameterizedTypeReference<T> 라는 또 하나의 도구가 필요해져요.
자, 도구의 모양도 봤고 속도 깠으니 이제 다양한 용기에 담아볼 시간입니다. Step 4 로 갈까요?
Step 4: `List` · `Map` — `ParameterizedTypeReference` 와 제네릭 타입 처리
자, Step 2/3 까지 우리가 받아본 응답은 모두 단일 객체 (Quote record 한 덩어리) 였어요. 그런데 운영에선 그것만 가지고 안 됩니다. 이런 요구가 흔해요.
- 명언을 3 개 한 번에 받고 싶다 →
List<Quote> - 문장에서 키워드별 등장 횟수 를 받고 싶다. 키 이름은 미리 정할 수 없다 →
Map<String, Integer>
오늘 Step 4 의 목표는 이런 컬렉션 타입을 타입 안전하게 받는 방법, 그리고 그 과정에서 자바의 한 가지 오래된 한계 (type erasure) 를 맞닥뜨리는 경험입니다. 만나야 할 새 도구는 하나, ParameterizedTypeReference<T> 예요.
첫 시도가 막히는 자리
Step 2 의 패턴을 그대로 따라 가서, 명언을 3 개 받고 싶다고 해봅시다. 자연스럽게 이렇게 쓰고 싶어져요.
// 기대 — 컴파일러가 "List<Quote> 가 아니라고" 뱉어주길.
// 현실 — 컴파일은 통과한다.
List<Quote> quotes = chatClient.prompt()
.user("명언 3 개 줘.")
.call()
.entity(List.class); // ❓
이 코드, 컴파일은 통과해요. 컴파일러는 행복합니다. 그런데 받아본 결과를 한번 까보면 Quote 가 아니라 LinkedHashMap 들이 들어있는 리스트가 나와요. quotes.get(0).text() 같은 호출은 런타임에 ClassCastException 으로 깨집니다.
왜 이렇게 됐을까요? 자연스럽게 떠오르는 다음 시도는 이거죠.
// 자바 문법상 작성 자체가 불가능하다.
.entity(List<Quote>.class) // ❌ 컴파일 에러: "cannot select from parameterized type"
자바에선 List<Quote> 라는 클래스 리터럴이 존재하지 않아요. 왜?
자바 type erasure 1 분 복습
자바의 제네릭은 컴파일 타임에는 강하게 검증되지만, 런타임에는 그 정보가 지워집니다. 컴파일된 바이트코드에는 List 만 남고 <Quote> 부분은 사라져요. 이걸 type erasure 라고 해요.
| 단계 | List<Quote> quotes |
|---|---|
| 작성 | List<Quote> — 컴파일러가 type 검사 |
| 컴파일 결과 | List — <Quote> 정보 소멸 |
| 런타임 | List 객체. "이 List 의 원소가 Quote 다" 라는 정보 없음 |
그래서 클래스 리터럴 (SomeClass.class) 도 type 파라미터를 못 담아요. List.class 는 있어도 List<Quote>.class 는 없습니다. 자바 언어 차원의 한계예요.
💡 한 줄 직관 —
Class<T>한 개만으로는 컬렉션의 원소 타입을 LLM 한테 알릴 방법이 없다. 그래서 컴파일러를 한 번 속여서 type 정보를 런타임까지 살리는 트릭 이 필요해진다.
ParameterizedTypeReference 의 익명 서브클래스 트릭
스프링 코어가 오래 전부터 들고 있던 도구가 있어요. org.springframework.core.ParameterizedTypeReference<T> — Spring AI 도 그대로 빌려 씁니다. 사용법은 한 줄로 끝나요.
new ParameterizedTypeReference<List<Quote>>() {}
중괄호 두 개 {} 가 핵심이에요. 이게 익명 서브클래스를 즉석에서 만든다 는 자바 문법이거든요. 그러면 다음 일이 벌어져요.
- 우리는
ParameterizedTypeReference<List<Quote>>의 익명 서브클래스 인스턴스를 만든 셈 - 자바는 클래스의 정의에는 type 파라미터 정보를 보관해요 (서브클래스의 superclass type 정보는 살아남아요)
ParameterizedTypeReference가 자기 superclass 의 type 파라미터를 리플렉션 으로 끄집어내서List<Quote>라는 정보를 살려둠
즉, 빈 중괄호 한 쌍이 type erasure 의 빈틈을 때우는 표 인 거예요. 처음 보면 이상해 보이지만 자바 생태계 전체가 이 트릭을 씁니다 (Jackson 의 TypeReference, Spring 의 ParameterizedTypeReference, Guava 의 TypeToken 모두 같은 원리).
이 한 줄을 .entity(...) 의 또 다른 오버로드 — .entity(ParameterizedTypeReference<T>) — 에 그대로 넘기면 됩니다.
List<Quote> 엔드포인트 추가
Step 2/3 의 데모 컨트롤러에 메서드 하나만 더 얹어요. import 두 줄 (ParameterizedTypeReference, java.util.List) + 메서드 하나가 늘어나요. 응답은 Step 2 와 같은 방식으로 ApiResponse 로 한 번 더 감싸 표준 형태를 유지합니다.
import org.springframework.core.ParameterizedTypeReference;
import java.util.List;
@GetMapping("/api/structured/quotes")
public ResponseEntity<ApiResponse<List<Quote>>> quotes(
@RequestParam(defaultValue = "용기") String topic,
@RequestParam(defaultValue = "3") int count) {
List<Quote> quotes = chatClient.prompt()
.user(u -> u.text("'{topic}' 에 관한 짧은 명언을 서로 다른 인물의 것으로 {count} 개 알려줘.")
.param("topic", topic)
.param("count", count))
.call()
.entity(new ParameterizedTypeReference<List<Quote>>() {});
return ResponseEntity.ok(ApiResponse.success(quotes));
}
핵심 한 줄은 여전히 .entity(new ParameterizedTypeReference<List<Quote>>() {}) — 이게 우리 trick. 마지막 줄의 ApiResponse.success(quotes) 는 본 강의의 표준 응답 형태를 유지하기 위한 한 단계 래핑이에요. 자, 한 번 돌려봅시다.
curl "http://localhost:8080/api/structured/quotes?topic=인내&count=3"
응답 (모델 따라 본문은 달라요):
{
"success": true,
"data": [
{ "text": "인내는 쓰지만, 그 열매는 달다.", "author": "장 자크 루소" },
{ "text": "기다림은 사랑의 또 다른 이름이다.", "author": "마하트마 간디" },
{ "text": "고요는 기다림이 자란 자리다.", "author": "라이너 마리아 릴케" }
]
}
List<Quote> 가 data 필드에 그대로 채워져서 응답으로 흘러나갔어요. 컴파일러는 이게 진짜 List<Quote> 라는 걸 알고 있고, 우리는 quotes.get(0).text() 같은 호출을 안전하게 쓸 수 있어요. 🎯
무대 뒤 — List<Quote> 의 schema 는 어떻게 생겼을까
Step 3 에서 BeanOutputConverter<Quote> 의 schema 를 들여다봤었죠. 같은 방식으로 BeanOutputConverter<List<Quote>> 의 schema 도 직접 출력해볼 수 있어요. 이렇게 떨어집니다.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "array",
"items": {
"type": "object",
"properties": {
"author": { "type": "string" },
"text": { "type": "string" }
},
"additionalProperties": false
}
}
핵심 변화 두 가지.
- 루트가
"type": "array"— 응답 전체가 배열이라는 신호. Step 3 의 단일 Quote 에서는 루트가"type": "object"였죠. items필드 안에 Quote 의 schema 가 그대로 박혔어요. JSON Schema 의 표준 표현이에요 — "이 배열의 각 원소는 이 모양의 객체다."
즉 List<Quote> 라는 자바 타입이 array of Quote 라는 JSON Schema 로 자동 번역된 거예요. Spring AI 가 우리 타입 정보를 정확히 받아내서 schema 를 깊이 있게 만들 수 있다는 증거죠.
Map<String, Integer> — 키가 미리 정해지지 않을 때
이번엔 다른 종류의 응답이에요. 키 이름을 우리가 미리 정할 수 없는 데이터. 예를 들어:
"다음 문장에서 핵심 키워드를 추출해 등장 횟수를 알려줘."
키워드가 무엇이 될지는 입력 문장에 따라 다르죠. record 로는 표현이 어려워요 (필드 이름이 컴파일 타임에 정해져 있어야 하니까). 이럴 때 자연스러운 선택이 Map<String, Integer> 예요.
엔드포인트는 똑같은 패턴 (응답을 ApiResponse 로 감싸는 것까지 포함).
import java.util.Map;
@GetMapping("/api/structured/keyword-counts")
public ResponseEntity<ApiResponse<Map<String, Integer>>> keywordCounts(
@RequestParam(defaultValue = "Spring AI 는 자바 백엔드에서 Spring으로 LLM 을 다루는 표준 추상화를 제공한다.") String text) {
Map<String, Integer> counts = chatClient.prompt()
.user(u -> u.text("다음 문장에서 핵심 명사를 추출해 키워드별 등장 횟수를 JSON 객체로 반환해줘. 문장: {text}")
.param("text", text))
.call()
.entity(new ParameterizedTypeReference<Map<String, Integer>>() {});
return ResponseEntity.ok(ApiResponse.success(counts));
}
호출.
curl "http://localhost:8080/api/structured/keyword-counts?text=Spring AI 는 자바 백엔드에서 Spring으로 LLM 을 다루는 표준 추상화를 제공한다."
응답 예시.
{
"success": true,
"data": {
"Spring": 2,
"자바": 1,
"백엔드": 1,
"LLM": 1,
"추상화": 1
}
}
깔끔하죠. 그런데 이 Map<String, Integer> 응답에는 한 가지 솔직한 한계 가 있어요. Step 5~6 으로 넘어가기 전에 잠깐 짚고 갑시다.
Map 응답의 솔직한 한계 — schema 강제력이 약하다 ⚠️
BeanOutputConverter<Map<String, Integer>> 의 schema 를 출력해보면 이렇게 떨어져요.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false
}
properties 가 아예 없어요. 그리고 additionalProperties: false 가 박혀 있죠. 이걸 글자 그대로 해석하면 "필드를 하나도 가지지 않은 빈 객체만 허용" 이라는 모순적인 신호예요.
왜 이렇게 됐을까요? 컴파일 시점에 어떤 키가 들어올지 알 수 없기 때문 에 schema 생성기가 properties 자리를 비워둘 수밖에 없어요. 그리고 안전 장치로 additionalProperties: false 를 박아둔 건데, 둘이 합쳐지면 schema 가 사실상 "아무것도 허용 안 함" 을 말해요.
그런데 실제로는 동작합니다. 왜?
- 모델은 schema 를 절대적 명령으로 받지 않아요. "참고하라" 정도로 받아들이고, 사용자 메시지("키워드별 등장 횟수를 JSON 으로 반환") 와 함께 종합해서 응답을 만들죠. 모델 입장에선 사용자 의도가 더 강해 보이니까 정상적인 객체를 응답해줘요.
- 응답 역직렬화 단계에선 schema 검증이 안 일어나요. Spring AI 는 받은 JSON 문자열을 ObjectMapper 로 그냥
Map<String, Integer>에 부어넣어요. 모순적인 schema 든 말든 ObjectMapper 한텐 무관해요.
즉 모델의 관용성 + 클라이언트 deserialization 의 schema 무관성 이 합쳐져서 동작하는 거예요. 우아하진 않아요. 솔직히 record 로 받을 수 있는 응답이라면 record 가 더 안전해요. 트레이드오프를 표로 정리하면 이래요.
| 자바 타입 | schema 생성 품질 | 모델 강제력 | 키 변동 대응 | 권장 시나리오 |
|---|---|---|---|---|
record Foo(...) |
매우 좋음 | 강함 (필드 명세 있음) | ❌ 컴파일 타임 고정 | 기본 선택 — 키가 미리 알려진 응답 |
record FooList(List<Foo> items) |
좋음 | 강함 | 원소 수만 가변 | List 응답 + 메타데이터 같이 받고 싶을 때 |
List<Foo> |
좋음 | 강함 | 원소 수만 가변 | List 응답 단독, 메타데이터 불필요 |
Map<String, V> |
약함 (properties 비어있음) | 약함 (모델의 관용에 의존) | ✅ 키가 동적이어도 OK | 키 이름이 미리 모르는 경우의 마지막 선택 |
결론: Map 은 마지막 선택지. 가능한 record 로 모델링하고, 진짜 동적 키일 때만 Map 으로 가자.
🙋 날카로운 질문 타임 — 학생 질문
"튜터님, 그러면
List<Quote>도 항상 record 로 한 번 더 감싸는 게 좋을까요? 예를 들어record QuoteList(List<Quote> items, int totalCount)처럼요. 굳이 List 직접 받을 이유가 있나요?"
좋은 감각이에요. 메타데이터를 같이 받고 싶다면 record 로 감싸는 게 거의 항상 더 좋습니다. 표로 정리하면:
| 패턴 | 장점 | 단점 |
|---|---|---|
List<Quote> 직접 |
코드 짧음. 컨트롤러 응답이 그대로 JSON 배열 | 메타데이터 못 실음 (totalCount, generatedAt 등) |
record QuoteList(List<Quote> items, int total, String topic) |
메타데이터 같이 옴. 응답 형태 확장에 강함 | 코드 살짝 길어짐. 응답 JSON 한 단계 깊어짐 ({"items": [...]}) |
실무에선 거의 항상 record 로 감쌉니다. 응답이 늘어나도 record 에 필드만 추가하면 되거든요.
List 직접 반환은 정말 메타데이터가 필요 없는 단순 케이스 (또는 빠른 PoC) 에서만 쓰세요.
우리 ai-friends 의 본 도메인 (Step 5 에서 다룰 AiReply) 도 record 로 갈 거예요 — 메시지 + 선택지 + 호감도 변화량을 한 객체에 묶어야 하니까.
"튜터님, 그럼 ParameterizedTypeReference 가 필요한 경우는 사실상
List<T>정도만 남는 거 아닌가요? 굳이 깊게 알 필요 있나요?"
좋은 질문이에요. 정답은 "한 번은 알아두면 평생 도움 된다" 입니다. 이유 두 가지.
- 자바 생태계 전반에서 같은 트릭이 등장해요. Jackson 의
TypeReference, Spring 의ParameterizedTypeReference, Guava 의TypeToken, RestClient/WebClient 의 응답 타입 지정 — 다 같은 원리예요.
Spring AI 한 곳에서 익혀두면 다른 도구에서도 즉시 보입니다. 2. 나중에 응답을 record 로 감쌀 때도 종종 등장해요. 예: Map<String, List<Quote>> (카테고리별 명언 묶음) 같은 구조는 record 로 못 받습니다. record 로 못 표현하는 형태가 등장하면 결국 ParameterizedTypeReference 로 돌아오게 돼요.
요약: 자주 쓰진 않지만, 알아둬야 할 때 모르면 한참 헤매는 도구. 오늘 한 번 짚어두는 게 효율적이에요.
💡 튜터의 결론
Step 4 의 한 문장 요약은 이래요.
"
Class<T>만으로 컬렉션을 못 받는 자바의 한계를,new ParameterizedTypeReference<...>() {}익명 서브클래스 트릭으로 우회한다. 그리고 가능한 record 로 감싸라."
여기까지가 데모 컨트롤러로 도구 손에 익히는 마지막 Step 이에요. Step 1~4 동안 우리는:
| Step | 진척 |
|---|---|
| 1 | 수동 파싱의 4 가지 함정 식별 |
| 2 | .entity(Class<T>) 로 단일 record PoC |
| 3 | BeanOutputConverter 의 내부 (schema 자동 생성 + 프롬프트 자동 주입) |
| 4 | ParameterizedTypeReference 로 List/Map 같은 컬렉션 처리 |
도구가 다 들어왔어요.
이제 데모 컨트롤러를 떠나 진짜 우리 ai-friends 코드 로 옮겨갈 시간입니다.
지난 시간 (Day 3) 우리가 한 축씩 다듬어둔 SoulmateChatService 를 String 반환에서 AiReply record 반환 으로 갈아엎고, 그 과정에서 지난 시간까지 못 건드린 레거시 GeminiService 의 ObjectMapper 수동 파싱 30 줄도 함께 정돈할 거예요.
자, 도구는 다 챙겼으니 이제 본 도메인으로 갑시다. Step 5 로 갈까요?
Step 5: ai-friends 본격 리팩토링 — `SoulmateChatService` 가 `AiReply` 를 반환하도록
자, 데모 컨트롤러로 도구를 충분히 손에 익혔어요. Step 1~4 까지 우리가 만진 코드는 본 도메인 밖 의 일회용 학습용 컨트롤러였죠. 이제 본 도메인 으로 돌아갈 시간입니다.
오늘의 과업은 명확해요. Day 3 까지 쌓아둔 SoulmateChatService 의 String chat(...) 메서드를 AiReply chat(...) 로 갈아엎기. 그게 다예요. 시그니처 한 줄, 호출 체인 한 줄, 그리고 record 한 개 신설.
잠깐 — 오늘 손대는 자리는 lab 의 진화 단계 입니다 (in-place 형 수술은 Day 5)
Day 3 Step 2 에서 약속드렸죠. "
SoulmateChatService는 학습용 lab 이고, Day 5 마무리에 기존AiChatController의 백엔드를 흡수한다". 오늘 Step 5 가 그 lab 을 한 단계 더 자라게 하는 단계예요.
시점 lab ( SoulmateChatService) 가 받는 것그 시점 prod ( AiChatController백엔드) 상태Day 3 (지난 시간) ChatClient + PromptTemplate (RCTFE 5 축) RestClient+ 수동 JSON 파싱 그대로Day 4 (오늘) + AiReplyrecord +.entity(AiReply.class)— 응답 구조화GeminiService.parseGeminiResponse가 deprecated 후보 로 표시 (들어내기 직전)Day 5 + MessageChatMemoryAdvisor+JdbcChatMemoryRepositoryAiChatController의 백엔드를 lab 으로 갈아끼움 →GeminiService제거 (수렴 지점)그래서 오늘 우리는 두 가지를 동시에 안 합니다. 레거시
GeminiService의 30 줄짜리ObjectMapper수동 파싱 — 그건 Step 7 에서 한 번 더 짚지만, 들어내는 건 Day 5 의 일 이에요. 오늘은 lab 한 곳만 깔끔하게 record 반환으로 자라는 걸 손에 익힙니다. 외부 계약(메서드 시그니처)을 동결한 채 내부만 교체 하는 in-place 형 수술의 정석은 Day 3 Step 5 (GeminiService의 PromptTemplate 도입) 에서 이미 한 번 손에 익혔고, Day 5 에서 한 번 더 와요.
변경 전 — Day 3 까지의 SoulmateChatService
먼저 지난 시간 끝낸 코드를 한 번 더 펼쳐봅시다. 위치: chat/service/SoulmateChatService.java.
public String chat(String anonymizedUserName, String mood, String userMessage) {
return soulmateChatClient.prompt()
.system(system -> system
.text("""
너는 {userName} 님의 AI 친구야.
유저의 현재 기분은 '{mood}' 이야.
답변은 3문장 이내로, 반말로 친근하게 해.
""")
.param("userName", anonymizedUserName)
.param("mood", mood))
.user(userMessage)
.call()
.content();
}
마지막 두 줄이 오늘 우리가 칼을 댈 곳이에요.
.content()— LLM 응답을 평문 String 으로 그대로 반환- 메서드 시그니처도
String chat(...)
지금까지는 친구 한 마디 받아 화면에 그대로 뿌리는 정도라 String 으로 충분했어요. 그런데 ai-friends 는 미연시 게임 도메인이에요. 게임 한 턴엔 3 가지가 같이 흐르죠.
| 한 턴에 필요한 데이터 | 의미 |
|---|---|
| 캐릭터의 대사 | 화면에 표시할 한 줄 |
| 다음 선택지 2~3 개 | 플레이어가 고를 행동 후보 |
| 호감도 변화량 | 이 한 턴으로 캐릭터의 호감도가 얼마 변했는지 |
이 셋을 한 응답에 묶어서 받아야 해요. 그리고 그걸 record 로 받는 게 오늘의 목표.
AiReply record 신설 — chat/dto/AiReply.java
먼저 새 record 파일을 만들어요. 위치는 도메인 친화적인 chat.dto 패키지로.
package kr.spartaclub.aifriends.chat.dto;
import java.util.List;
public record AiReply(
String aiMessage,
List<String> choices,
int affectionDelta
) { }
세 줄짜리 record. 이게 곧 LLM 응답의 진실의 원본 이에요. Step 3 에서 봤듯이 이 record 의 모양에서 BeanOutputConverter 가 JSON Schema 를 자동 생성하고, Step 1 의 함정 ③ "이중 진실" 이 깨끗하게 사라집니다.
💡 이름 짓기 — 왜
GeminiParsedResponse가 아니라AiReply인가? 레거시GeminiService안에 거의 같은 모양의GeminiParsedResponse라는 record 가 있어요 (필드 셋 동일). 그런데 Day 4 부터는 응답 타입을 프로바이더 중립적인 이름 으로 가져갑니다. Day 2 에서 추상화한ChatModel인터페이스의 정신과 같아요 — Ollama 로 갈아끼우든 Gemini 든, 우리 도메인 코드는 "AI 의 답장(AiReply)" 만 알면 됩니다. 이름이 곧 약속이에요.
SoulmateChatService 시그니처 + 호출 체인 갈아엎기
이제 본체를 칠 차례. 시그니처 + 호출 체인의 한 줄 + 시스템 프롬프트에 두 줄 보강. 이게 전부예요.
import kr.spartaclub.aifriends.chat.dto.AiReply;
public AiReply chat(String anonymizedUserName, String mood, String userMessage) {
return soulmateChatClient.prompt()
.system(system -> system
.text("""
너는 {userName} 님의 AI 친구야.
유저의 현재 기분은 '{mood}' 이야.
답변은 3문장 이내로, 반말로 친근하게 해.
유저가 이어서 보낼 만한 짧은 답장 후보(choices) 를 2~3개 함께 제안해.
이번 한 턴으로 너에 대한 호감도(affectionDelta) 가 -5~+5 사이에서 얼마나 변할지 정수로 추정해.
""")
.param("userName", anonymizedUserName)
.param("mood", mood))
.user(userMessage)
.call()
.entity(AiReply.class);
}
세 군데가 바뀌었어요.
| 자리 | 지난 시간 | 오늘 |
|---|---|---|
| 메서드 반환 타입 | String |
AiReply |
| 호출 체인 끝 | .content() |
.entity(AiReply.class) |
| 시스템 프롬프트 | 톤 가이드 3 줄만 | + choices 가이드 + affectionDelta 가이드 (2 줄 추가) |
한 박스 회고 — Step 1 의 함정 ② 가 오늘 한 줄로 사라졌어요
좀 더 멀리서 한 번 짚고 갈게요. 우리가 오늘 Step 5 에서 바꾼 건
SoulmateChatService.chat(...)의 마지막 한 줄 뿐이에요. 그런데 그 한 줄이 Step 1 에서 본 레거시GeminiService의 30 줄짜리 짐을 어떻게 압축했는지 한 번 비교해보면 — 오늘 학습의 무게감이 다르게 잡힙니다.
축 Before — 레거시 GeminiService(Step 1 함정 ②)After — 오늘의 SoulmateChatServiceJSON 받기 String rawText = response.extractText();.entity(AiReply.class)파싱 OBJECT_MAPPER.readValue(rawText.trim(), GeminiParsedResponse.class)(자동 — BeanOutputConverter가 처리)파싱 실패 처리 try { ... } catch(Exception e) { log + throw BusinessException(AI_UNAVAILABLE) }(Step 6 에서 recover-retry로 별도 다룸)Schema 손 조립 buildResponseJsonSchema()—Map.put으로type/properties/required30 줄(자동 — record 컴포넌트에서 추출) 코드 줄 수 약 30 줄 호출 체인의 마지막 한 줄 Step 1 에서 "이 30 줄이 진짜 다 필요한가?" 라고 의심했었죠. 오늘 Step 5 에서 그 답이 나왔어요. lab (
SoulmateChatService) 측은 그 짐을 한 줄로 줄였고, prod (GeminiService) 측의 30 줄은 Day 5 의 ChatMemory 합류 시점에 통째로 들어내집니다. 오늘AiReply가 만들어진 건 그 Day 5 의 들어내기를 위한 다리 (record) 가 미리 깔린 거예요.
특히 시스템 프롬프트 추가 두 줄 을 짚어둘게요.
record 에 choices, affectionDelta 필드가 생긴 것만으로는 부족해요.
BeanOutputConverter 가 schema 를 자동 주입해서 "이런 키들이 있는 JSON 으로 줘" 라고 모델한테 강제하긴 하는데, 모델이 각 필드를 어떤 의미로 채워야 하는지 는 우리가 시스템 프롬프트에서 알려줘야 해요.
안 알려주면 모델이 choices 를 빈 배열 [] 로, affectionDelta 를 0 으로 채워버립니다 — schema 는 통과하지만 비어 있는 응답이죠.
💡 한 줄 직관 —
BeanOutputConverter는 형식 을 강제하고, 시스템 프롬프트는 내용 을 강제한다. 둘이 짝이다.
컨트롤러까지 타입 흘려보내기 + 표준 응답 래핑
서비스가 record 를 반환하니, 컨트롤러도 record 를 그대로 받아 응답하면 됩니다. 다만 본 강의의 모든 새 컨트롤러는 표준 응답 래퍼 ApiResponse<T> 로 한 번 더 감싸요 — Step 2~4 의 데모 컨트롤러에서 봤던 그 패턴이에요.
import kr.spartaclub.aifriends.chat.dto.AiReply;
import kr.spartaclub.aifriends.common.response.ApiResponse;
import org.springframework.http.ResponseEntity;
@GetMapping("/api/chat/soulmate")
public ResponseEntity<ApiResponse<AiReply>> soulmate(
@RequestParam Long userId,
@RequestParam String mood,
@RequestParam String message
) {
String anonymizedName = userAnonymizer.anonymize(userId);
AiReply reply = service.chat(anonymizedName, mood, message);
return ResponseEntity.ok(ApiResponse.success(reply));
}
import 두 줄, 반환 타입 한 줄, 마지막 줄의 래핑 한 줄. 끝이에요. 익명화 로직(UserAnonymizer.anonymize) 은 Day 3 에서 박아둔 그대로 — 그쪽은 손댈 이유가 없어요. 응답 타입 한 군데가 바뀌면 그 타입을 흘려받는 모든 자리가 자동으로 따라온다 는 게 record 기반 흐름의 깔끔함이에요.
💡 왜 ApiResponse 로 또 감싸나요?
GlobalExceptionHandler가 모든 예외 응답을 자동으로ApiResponse.fail(...)로 감싸기 때문이에요. 정상 응답을 raw 로 두면 같은 엔드포인트가 정상 응답은 raw, 에러 응답은{"success": false, ...}형태로 비대칭이 됩니다. 표준 래핑 한 줄로 그 비대칭이 사라져요. Day 4 부터 만드는 모든 새 컨트롤러는 이 패턴을 따라요.
한 번 호출해봅시다 🚀
cd ai-friends
./run.sh up
curl "http://localhost:8080/api/chat/soulmate?userId=1&mood=우울&message=오늘 진짜 별로였어"
응답 (모델 따라 본문은 달라요).
{
"success": true,
"data": {
"aiMessage": "에이, 무슨 일 있었어? 천천히 얘기해봐. 내가 들어줄게.",
"choices": [
"괜찮아, 그냥 좀 피곤해서",
"사실 회사에서 일이 있었어",
"잘 모르겠어, 그냥 우울해"
],
"affectionDelta": 1
}
}
data 안 세 필드가 모두 의미 있게 채워져서 떨어졌죠. record 의 형식 강제 + 시스템 프롬프트의 내용 가이드, 두 짝이 같이 일했기 때문이에요. 미연시 게임 프론트엔드는 이 JSON 한 덩어리만 받으면 data 를 꺼내서 화면에 캐릭터 대사 + 선택지 버튼 3 개 + 호감도 게이지 변화 애니메이션을 모두 띄울 수 있어요.
잠깐 — 레거시 GeminiService 는 어떻게 됐나?
여기서 한 가지 의문이 들 수 있어요.
"튜터님, Step 1 에서 까봤던 그 무거운
GeminiService—responseJsonSchema손 조립 +ObjectMapper.readValue30 줄 — 그건 안 걷어내나요? Day 3 마무리에서 'Day 4 에서 걷어낸다' 고 약속하셨잖아요?"
좋은 기억이에요. 정답은 — 오늘은 일부러 안 걷어냅니다. 이유 두 가지.
GeminiService는 시스템 프롬프트가 풀 페르소나 (캐릭터의 성별·이름·성격·취미·말투 5 슬롯) 로 짜여 있어요. 단순히.entity(AiReply.class)로 갈아끼우는 것만으론 부족하고, 대화 이력 (이전 턴들) 까지 같이 흘려야 자연스러운 미연시 게임이 돼요.
그게 Day 5 ChatMemory 의 주제입니다.
- Day 4 에서 모든 걸 다 걷어내면 학습 호흡이 너무 가팔라져요. 오늘은 "구조화 출력 도구를 손에 익히고, 가벼운 SoulmateChatService 부터 갈아끼운 다음, 그 패턴을 Day 5 의 ChatMemory 와 함께 본격 페르소나에 적용한다" 는 두 단계 작업으로 나누는 게 안전해요.
| Day | 작업 |
|---|---|
| Day 4 (오늘) | SoulmateChatService (간단 버전) 의 응답 구조화 → AiReply 도입 |
| Day 5 | GeminiService 폐기 → ChatMemory 기반 본 페르소나 서비스로 통합. 그때 AiReply 가 그대로 쓰여요 |
즉 오늘 만든 AiReply record 는 Day 5 까지 가는 다리 예요. 단순 케이스에서 패턴을 굳히고, 다음 Day 에서 본 도메인 풀 페르소나에 같은 패턴을 입힙니다. 한 턴씩 진도를 빼는 호흡이에요.
🙋 날카로운 질문 타임 — 학생 질문
"튜터님, 그러면
AiReply는 record 가 아니라 일반 클래스로 짜도 되지 않나요? Lombok@Data같은 거 붙이면 비슷하게 깔끔해질 텐데요."
기술적으로는 가능해요. 하지만 record 가 BeanOutputConverter 와 더 잘 맞습니다. 이유:
-
불변성 (Immutability) — record 는 모든 필드가 final 이고 setter 가 없어요. 한 번 LLM 한테서 받은 응답이 코드 어디선가 변형될 수 없죠. "AI 가 보내준 답장은 그 자체로 진실" 이라는 개념과 어울려요.
-
JSON Schema 자동 생성에 친화적 — record 의 정식 컴포넌트(component) 는 Jackson · jsonschema-generator 가 안전하게 인식해요.
Lombok @Data 는 컴파일 타임에 setter/getter 를 생성하는 방식이라 일부 도구와 마찰이 있을 수 있어요 (대부분 해결됐지만, record 가 더 정직).
- 선언이 짧다 — 세 줄짜리 record vs 클래스 +
@Data+ 필드 3 개 + 생성자. 응답 DTO 처럼 "데이터 그릇" 역할만 하는 타입엔 record 가 거의 항상 더 깔끔해요.
물론 불변이면 안 되는 경우 (예: 엔티티 — JPA 가 setter 로 변경을 추적해야 함) 는 record 를 쓸 수 없어요. AI 응답 DTO 처럼 "받은 그대로 흘리는" 데이터 는 record 가 정답.
"튜터님, 그럼 시스템 프롬프트에 'choices 2~3 개' 라고 자연어로 적은 거랑, record 안에
List<String> choices로 적은 거랑 — 두 군데 다 있는 거잖아요? 이중 진실 함정 다시 생기는 거 아닌가요?"
아주 날카로운 질문이에요. 정직하게 답하면 "부분적으로 맞다" 입니다. 정확히 정리하면:
| 정보 | 진실의 위치 | BeanOutputConverter 가 자동 동기화 가능? |
|---|---|---|
키 이름 (aiMessage, choices, affectionDelta) |
record 의 컴포넌트 이름 | ✅ 자동 — 실수 불가 |
키의 타입 (String, List<String>, int) |
record 의 컴포넌트 타입 | ✅ 자동 — 실수 불가 |
| 각 필드에 어떤 내용을 채워야 하는지 | 시스템 프롬프트의 자연어 | ❌ 수동 — 여전히 우리 책임 |
그러니까 Step 1 의 함정 ③ "이중 진실" 중에서 "형식의 이중 진실" 은 사라졌어요 (record 한 곳이 진실의 원본). 하지만 "의미의 이중 진실" 은 아직 남아 있어요 (필드의 의미는 시스템 프롬프트가 결정). 후자는 어떤 도구로도 자동화하기 어려워요 — 결국 모델한테 자연어로 의미를 알려줘야 하니까요.
이 한계를 Day 4 까지 의 사실로 안고 갑니다.
다만 record 의 javadoc 주석에 각 필드의 의미를 자세히 써두면 프롬프트와 record 가 한 화면에서 동기화되어 보이긴 해요 (오늘 만든 AiReply 의 javadoc 처럼).
완벽한 단일 진실은 아니지만 "형식의 진실 + 의미의 자연어 가이드 + 둘이 한 PR 안에서 함께 고쳐지는 운영 규율" 까지가 현실적인 방어선이에요.
###💡 튜터의 결론
Step 5 의 한 문장 요약은 이래요.
"
String반환 한 곳을AiReply반환으로 갈아엎고, 그 변화가 컨트롤러·테스트까지 자동으로 흘러가는 걸 본다."
오늘 우리가 손댄 줄 수를 한 번 세어봅시다.
| 파일 | 변화 |
|---|---|
chat/dto/AiReply.java |
신규 (3 필드 record) |
chat/service/SoulmateChatService.java |
시그니처 1 줄 + 호출 체인 1 줄 + 프롬프트 2 줄 |
chat/controller/SoulmateChatController.java |
반환 타입 1 줄 + ApiResponse.success(...) 래핑 한 줄 |
chat/service/SoulmateChatServiceTest.java |
어설션 갱신 |
chat/controller/SoulmateChatControllerTest.java |
content().string → $.data.{필드} jsonPath |
5 개 파일, 합쳐서 코드 100 줄 이하. 응답을 record 로 받는다는 작은 변화가 도메인 코드 전체에 깔끔하게 퍼진 거예요.
그리고 더 중요한 건 — Day 3 까지의 자산이 모두 살아남았다 는 점이에요.
| Day 3 자산 | 오늘 살아남은 자리 |
|---|---|
| RCTFE 5 축 시스템 프롬프트 | 그대로 + Format 축이 자동화 |
{userName} 익명 슬롯 |
그대로 |
ChatClient.Builder defaultSystem 빈 (soulmateChatClient) |
그대로 |
UserAnonymizer PII 차단 |
그대로 |
새 도구를 도입할 때 이전 자산을 안 부순다 는 게 잘 설계된 추상화의 신호예요. Spring AI 의 .entity() 패턴이 그 신호를 보여준 거고요.
자, 본 도메인의 한 축 (SoulmateChatService) 이 record 기반으로 깨끗하게 정리됐어요.
다음 Step 6 에선 한 발 더 들어가요 — 모델이 JSON 을 깨뜨려서 보냈을 때 어떻게 복구할 것인가. retry · fallback · strict mode 의 트레이드오프를 손으로 비교합니다.
운영에서 진짜 안 깨지게 하려면 한 번 더 깊이 들어가야 해요.
도메인을 정돈했으니 이제 방어선을 칠 시간입니다. Step 6 으로 갈까요?
Step 6: JSON 파싱 실패 복구 전략 — retry · fallback · strict mode 의 트레이드오프
자, Step 5 까지 우리는 응답을 record 로 받는 깨끗한 길을 닦았어요. 그런데 운영 한 달째, PM 이 슬랙으로 이런 메시지를 던집니다.
"튜터님, 어제 새벽 3시에 30 분 동안
/api/chat/soulmate가 5xx 응답률 12% 찍었어요. 로그 보니까 'Could not parse the given text...' 라는 예외가 줄줄이 떴고요. 모델이 가끔 형식을 안 지켜서 응답하나 봐요. 이거 어떻게 막죠?"
오늘 Step 6 의 정확한 주제입니다. Step 1 의 함정 ② "readValue try-catch 지옥" — 그 부분에 BeanOutputConverter 가 들어와서 코드는 깔끔해졌지만, 모델이 깨뜨려서 보내올 가능성 자체가 0% 가 된 건 아니에요. 그 0% 가 아닌 사건을 어떻게 다룰지가 오늘의 주제입니다.
모델이 JSON 을 깨뜨리는 진짜 사례
먼저 현실 감각을 잡고 갑시다. 실제로 운영 LLM 에서 관찰되는 깨짐 패턴은 이렇게 분류됩니다.
| 패턴 | 예시 | 빈도 |
|---|---|---|
| 마크다운 펜스 부착 | ```json\n{...}\n``` 로 응답을 감쌈 |
OpenAI 계열 가끔, Gemini 거의 없음 |
| 부연 설명 첨부 | 위 JSON 은 ~~ 의미입니다. 같은 산문이 뒤에 붙음 |
작은 오픈모델에서 흔함 |
| 키 이름 변형 | affectionDelta → affection_delta |
다국어 모델에서 가끔 |
| 자유 형식 응답 | JSON 을 아예 무시하고 평문 산문으로 답 | 시스템 프롬프트가 약하면 가끔 |
| JSON 자체 깨짐 | 닫는 따옴표 누락, 이스케이프 오류 | 매우 드물지만 0% 아님 |
BeanOutputConverter 안의 ResponseTextCleaner 가 마크다운 펜스 같은 흔한 케이스 일부는 자동으로 정리해줘요.
하지만 자유 형식 응답 이나 JSON 자체 깨짐 은 정리할 도리가 없어 그대로 convert() 단계에서 깨집니다.
이때 던져지는 게 RuntimeException(JsonProcessingException) 이에요.
💡 확인하고 싶다면 — Spring AI 1.1.x 의
BeanOutputConverter.convert(String text)소스를 보면objectMapper.readValue(...)호출이JsonProcessingException을 잡아RuntimeException으로 wrapping 해서 던지고 있어요. 즉 우리는 평범한RuntimeException으로 잡으면 됩니다.
복구 전략 3 축
이런 깨짐을 만났을 때 운영 코드가 취할 수 있는 자세는 크게 세 가지예요.
| 전략 | 한 줄 설명 | 사용자가 보는 결과 | 디버깅 가시성 |
|---|---|---|---|
| Retry (재시도) | 같은 호출을 N 회 다시 던짐 | 약간의 지연 후 정상 응답 | 로그에 "attempt 1/N failed" 로 흔적 남음 |
| Fallback (폴백) | 실패하면 사전 정의한 안전 응답을 돌려줌 | 항상 200 OK, 다만 품질 낮음 | 로그에만 보임, 사용자엔 침묵 |
| Strict (엄격) | 실패하면 즉시 5xx 던짐 | 5xx 응답을 봄 (앱이 에러 화면) | 사용자·메트릭·알림 모두 명확 |
세 전략은 사용자 경험 ↔ 디버깅 가시성 사이의 트레이드오프예요.
- Retry + Fallback 결합 — 사용자에겐 항상 답이 가지만, 가끔 품질이 낮은 답이 갑니다. UX 우선.
- Strict — 사용자에겐 가끔 에러가 보이지만, 운영팀은 문제를 즉시 인지합니다. 운영 우선.
어느 시나리오에 어느 전략?
정리하면 결정 가이드는 이런 표입니다. 실제 면접에서도 자주 나오는 분류예요.
| 사용자 행동의 성격 | 권장 전략 | 이유 |
|---|---|---|
| 재미·심심 풀이 (오늘 운세, 명언, 추천) | Fallback | 답이 약간 무뚝뚝해도 OK. 사용자 흐름 끊기지 않게 |
| 의사결정 보조 (요약, 분류) | Retry → Fallback | 가능한 한 정확한 응답을 시도하되 끊기지 않도록 |
| 결제·주문·인증 같은 트랜잭션 보조 | Strict | 사용자한테 잘못된 결과를 주면 진짜 큰일. 즉시 멈추는 게 정답 |
| 비동기 배치 처리 (야간 분석) | Retry → 실패 큐 | UX 압박 없으니 충분히 재시도, 실패는 인스펙션 큐로 |
ai-friends 같은 미연시 게임은 첫째 줄에 가까워요. 호감도 변화량이 한 턴 잘못 나와도 게임이 멈추진 않으니 Retry + Fallback 결합 이 자연스러운 선택이에요. 단, 우리가 만든 데모 컨트롤러로 그 패턴을 손에 익혀봅시다.
데모 — recover-retry 엔드포인트
데모 컨트롤러에 메서드 하나를 더 얹어요. 실제 LLM 호출 없이 학습 시뮬레이션으로 가요. 진짜 LLM 호출에서 깨짐을 일관되게 재현하기는 어려워서, 이 시뮬레이션이 패턴을 명료하게 보는 데 더 좋아요.
상수 + 헬퍼
@Slf4j
@RestController
public class StructuredOutputDemoController {
private static final Quote FALLBACK_QUOTE = new Quote(
"지금은 명언을 가져올 수 없어요. 잠시 후 다시 시도해주세요.",
"ai-friends");
private static final String SIMULATED_VALID_RAW = """
{"text":"용기는 두려움이 없는 것이 아니라, 두려움을 이겨내는 판단이다.","author":"넬슨 만델라"}
""";
private static final String SIMULATED_BROKEN_RAW =
"이건 JSON 이 아니라 모델이 자유롭게 답해버린 평문입니다. 파싱 실패 시뮬레이션용.";
// ... (기존 quote / quotes / keyword-counts 생략)
}
Retry → Fallback 결합 메서드
@GetMapping("/api/structured/quote/recover-retry")
public ResponseEntity<ApiResponse<Quote>> recoverWithRetryThenFallback(
@RequestParam(defaultValue = "2") int simulatedFailures) {
BeanOutputConverter<Quote> converter = new BeanOutputConverter<>(Quote.class);
int maxAttempts = 3;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
// simulatedFailures 만큼 초반 시도는 일부러 깨뜨림. 이후엔 정상 응답.
String simulated = (attempt <= simulatedFailures) ? SIMULATED_BROKEN_RAW : SIMULATED_VALID_RAW;
try {
Quote quote = converter.convert(simulated);
log.info("recover-retry: attempt {}/{} succeeded", attempt, maxAttempts);
return ResponseEntity.ok(ApiResponse.success(quote));
} catch (RuntimeException e) {
log.warn("recover-retry: attempt {}/{} failed: {}", attempt, maxAttempts, e.getMessage());
}
}
log.warn("recover-retry: all {} attempts failed, returning fallback", maxAttempts);
return ResponseEntity.ok(ApiResponse.success(FALLBACK_QUOTE));
}
핵심을 두 군데로 압축할 수 있어요.
- for 루프 + try-catch — 한 번 실패해도 다음 시도로 진행. 성공하면 즉시 return.
- 루프를 빠져나오면 fallback — 모든 attempt 가 실패해야만 fallback 으로 떨어짐. 사용자한테는 항상 200 OK.
💡 왜
@Retryable(Spring Retry) 같은 라이브러리를 안 쓰나요? 의존성 추가 + 어노테이션 + 별도 컨피그가 필요해서 학습 비용이 커요. 또 Spring AI 1.1.x 는spring.ai.retry.*자동 설정 으로 ChatModel 레벨에서RetryTemplate을 박아주는 길이 따로 있어요 —application.yml한 줄(예:spring.ai.retry.max-attempts=3) 이면 ChatModel 호출 자체에 재시도가 자동으로 깔립니다. 운영 정책 (써킷브레이커 · Rate Limit · 캐싱) 과 묶이는 본격 학습은 Day 19 Harness 엔지니어링 의 주제예요. 오늘은 그 자동화 뒤에서 어떤 로직이 도는지 손으로 한 번 짜보는 데 의미를 둡니다.
한 번 돌려봅시다 🚀
# 시뮬레이션: 첫 2회 실패 → 3번째 성공
curl "http://localhost:8080/api/structured/quote/recover-retry?simulatedFailures=2"
{
"success": true,
"data": {
"text": "용기는 두려움이 없는 것이 아니라, 두려움을 이겨내는 판단이다.",
"author": "넬슨 만델라"
}
}
# 시뮬레이션: 모든 3회 실패 → fallback 으로 떨어짐
curl "http://localhost:8080/api/structured/quote/recover-retry?simulatedFailures=3"
{
"success": true,
"data": {
"text": "지금은 명언을 가져올 수 없어요. 잠시 후 다시 시도해주세요.",
"author": "ai-friends"
}
}
두 응답 모두 200 OK + success: true 예요. 사용자한테는 흐름이 끊기지 않고 답이 가는데, 두 번째 응답에선 author 가 ai-friends 로 박혀서 운영팀이 "아, 이건 fallback 이구나" 를 식별할 수 있어요. 로그도 동시에 떨어지죠.
WARN recover-retry: attempt 1/3 failed: com.fasterxml.jackson.core.JsonParseException: ...
WARN recover-retry: attempt 2/3 failed: ...
WARN recover-retry: attempt 3/3 failed: ...
WARN recover-retry: all 3 attempts failed, returning fallback
운영에선 위 로그 패턴을 모니터링 도구(예: Elastic, Grafana Loki) 에서 "all attempts failed" 로 카운트 잡아서 알림 거는 게 표준이에요. 사용자엔 침묵, 운영엔 신호 — 이 분리가 fallback 정책의 핵심이에요. 🚨
Strict 모드는 어떻게 짜나?
코드는 안 박지만 패턴만 보여드릴게요. Strict 는 try-catch 자체를 걷어내거나, 잡되 즉시 도메인 예외로 변환해서 던지면 끝이에요.
// 의사코드 — 시연 엔드포인트 추가하지 않습니다 (개념만)
public Quote recoverStrict() {
BeanOutputConverter<Quote> converter = new BeanOutputConverter<>(Quote.class);
try {
return converter.convert(maybeBrokenRaw);
} catch (RuntimeException e) {
// GlobalExceptionHandler 가 ApiResponse.fail(...) 으로 5xx 응답을 떨어뜨려준다.
throw new BusinessException(ErrorCode.AI_RESPONSE_INVALID);
}
}
이 흐름의 응답은 자연스럽게 5xx + ApiResponse.fail 모양이 됩니다.
{
"success": false,
"error": {
"status": 500,
"code": "...",
"message": "..."
}
}
사용자 앱은 에러 화면을 띄우고, 운영팀은 5xx 메트릭으로 즉시 인지해요. 결제·주문·인증 같은 도메인이면 이 정책이 정답에 가깝습니다.
우리 ai-friends 의 정책 결정
자 이제 ai-friends 도메인 (미연시 게임) 의 정책을 어떻게 잡을지 한 번 정리해봅시다.
| 엔드포인트 | 사용자 행동 | 권장 전략 | 이유 |
|---|---|---|---|
/api/chat/soulmate (한 턴 대화) |
캐주얼 대화 | Retry → Fallback | 한 턴 답이 무뚝뚝해도 게임 흐름 끊기지 말아야 |
| (Day 5+) 호감도 정산 / 엔딩 분기 | 게임 진행 결정 | Strict | 잘못된 호감도로 엔딩이 갈리면 사용자 경험 망가짐 |
| (Day 6+) 스트리밍 한 글자씩 | 캐주얼 + 실시간 | Retry 만, Fallback 없음 | 스트리밍 중간에 fallback 끼워 넣기 어려움 |
오늘 우리가 짠 SoulmateChatService.chat(...) 은 첫 줄 시나리오 — 그러니까 retry → fallback 이 가장 자연스러워요. 다만 오늘 그 적용을 손대지 않습니다. 이유는 두 가지.
- Spring AI 자동 RetryTemplate 으로 한 줄에 깔립니다.
application.yml의spring.ai.retry.max-attempts=3한 줄이면 ChatModel 호출 자체에 재시도가 박혀요. 미리 손으로 짜두면 그걸 다시 들어내는 작업이 됩니다. - 운영 정책과 묶이는 본격 학습은 Day 19 Harness. retry · 써킷브레이커 · Rate Limit · 캐싱이 한 곳에서 정돈돼요. 손 코딩 retry 가 거기서 어떻게 선언적으로 줄어드는지 직접 비교합니다.
오늘은 데모 트랙 (recover-retry 엔드포인트) 으로 패턴만 익히고, 본 도메인 적용은 위 두 길로 미루는 호흡이에요. 손으로 한 번 짜본 패턴이 한 줄 설정 / 운영 정책 묶음으로 줄어드는 걸 봐야 "역시 자동화·운영 추상화의 가치가 이만큼이구나" 라는 감각이 깊게 박혀요.
🙋 날카로운 질문 타임 — 학생 질문
"튜터님, retry 횟수 N 을 몇으로 잡아야 하나요? 3 이 적당한가요? 5? 10?"
운영에서 자주 나오는 질문이에요. 답은 "세 가지를 곱해보고 결정해라" 입니다.
| 변수 | 영향 |
|---|---|
| 한 호출의 평균 응답 시간 | retry 가 N 회면 최악 사용자 응답 시간이 N × T |
| 모델 단가 (1k 토큰당 $) | retry 가 N 회면 호출 비용도 N 배 |
| 깨짐의 시간 상관성 | "한 번 깨지면 또 깨질 가능성" — 보통 매우 낮음 |
경험적 가이드 — 텍스트 생성에선 보통 2~3 회 가 스윗 스팟. 5 회 이상은 비용·지연 손실이 retry 효과를 넘어가고, 1 회로는 모델의 일시적 변덕을 흡수하기 부족해요. 우리 데모도 3 회로 잡았어요 (maxAttempts = 3).
"튜터님, fallback 응답을 model 이 만든 답처럼 자연스럽게 위장하는 건 어떨까요? 예를 들어 'ai-friends' 라는 author 박지 말고 '익명' 으로 하면 사용자가 fallback 인지 모를 텐데요."
기술적으론 가능하지만 권장하지 않습니다. 이유 두 가지:
- 운영 디버깅 가시성 — 사용자 신고 ("어제 받은 명언이 좀 이상해요") 가 들어왔을 때 우리가 그 응답이 fallback 이었는지 진짜 모델 응답이었는지 식별할 수 있어야 해요. 응답 자체에 "이건 fallback" 이라는 흔적이 있으면 탐색이 빨라요.
- 신뢰의 누적 — 사용자가 한두 번 fallback 응답을 받아도 "AI 가 가끔 그래" 정도로 받아들이는데, 위장된 fallback 이 누적되면 "이 서비스는 AI 가 답하는 척만 한다" 는 의심이 생겨요. 솔직하게 fallback 임을 드러내는 게 장기 신뢰에 더 좋아요.
응답 형태는 같게 (필드 누락 없이) 가져가되, author 같은 식별 가능한 필드에 fallback 표지 를 박는 게 표준이에요. 우리 데모도 그래요 — author: "ai-friends".
💡 튜터의 결론
Step 6 의 한 문장 요약은 이래요.
"
BeanOutputConverter가 들어와도 깨짐의 가능성이 0 이 되진 않는다. 그래서 retry · fallback · strict 의 트레이드오프 위에서 우리 도메인의 정책을 의식적으로 고른다."
오늘 짚은 결정 변수를 한 표로 정리하면:
| 결정 변수 | 우리 답 |
|---|---|
| 깨짐 패턴 인지 | 5 가지 (마크다운/부연/키 변형/자유 형식/JSON 자체 깨짐) |
| 복구 전략 3 축 | retry · fallback · strict |
| 결합 패턴 | retry → fallback 이 가장 흔함 |
| 우리 도메인 (캐주얼 대화) 정책 | retry → fallback (spring.ai.retry.* 즉시, 운영 정책 묶음은 Day 19 Harness) |
| retry N | 보통 2~3, 우리 데모는 3 |
| fallback 응답의 식별 가능성 | 필수 — 운영 디버깅을 위해 |
| Step | 진척 |
|---|---|
| 1 | 수동 파싱의 4 가지 함정 식별 |
| 2 | .entity(Class<T>) 로 단일 record PoC |
| 3 | BeanOutputConverter 의 schema 자동 생성 + 프롬프트 자동 주입 메커니즘 |
| 4 | ParameterizedTypeReference 로 List/Map 처리 |
| 5 | ai-friends 본 도메인 (SoulmateChatService) 을 record 반환으로 갈아엎기 |
| 6 | JSON 깨짐의 복구 전략 3 축 + retry → fallback 손코딩 |
남은 한 Step (Step 7) 은 가벼워요. 스키마 크기 ↔ 토큰 비용 — record 를 어디까지 풍부하게 만들어도 되는지의 손익분기점 감각. 그리고 마무리. 거의 다 왔습니다.
자, 깨짐의 길까지 정돈했으니 마지막으로 얼마나 풍부하게 만들지 의 감각만 잡으면 끝이에요. Step 7 로 갈까요?
Step 7: 스키마 크기 ↔ 토큰 비용 — "record 를 어디까지 키워도 되는가"
자, Step 5 에서 우리가 만든 AiReply 는 3 필드 였어요 — aiMessage, choices, affectionDelta. 깔끔하죠. 그런데 운영을 시작한 PM 이 또 슬랙을 던집니다.
"튜터님, 한 턴에 캐릭터 표정(
emotion), 배경 음악 추천(backgroundMusic), 다음 씬 묘사(sceneDescription), 캐릭터 포즈(characterPose), 대화 종료 신호(shouldEndConversation)... 다 같이 받으면 안 될까요? 한 호출로 다 끝내면 프론트가 정말 깔끔할 것 같은데요."
기능적으로는 가능해요. record 에 필드만 추가하면 되니까. 그런데 이게 그렇게 공짜가 아니에요. 오늘 마지막 Step 은 그 비용 — 스키마가 부풀어 오를수록 매 호출마다 추가로 들어가는 토큰의 비용 — 의 감각을 잡는 시간입니다.
잠깐 — schema 가 비용에 어떻게 잡히는가?
Step 3 에서 봤죠. BeanOutputConverter 는 getFormat() 결과를 사용자 프롬프트 끝에 자동으로 붙여서 LLM 한테 보냅니다. 즉 record 가 만들어내는 JSON Schema 텍스트가 매 호출마다 입력 토큰 으로 들어가요.
| 호출 1 회의 입력 토큰 = | 시스템 프롬프트 + 사용자 메시지 + getFormat() 의 schema 텍스트 |
여기서 schema 텍스트의 크기가 record 의 풍부함에 따라 변합니다. 그리고 입력 토큰은 모델 단가에 따라 직접 비용으로 환산됩니다 — 호출량이 많아질수록 schema 1 byte 가 진짜 돈이 돼요.
직접 비교해보자 — /schema-size 디버그 엔드포인트
데모 컨트롤러에 비교용 record 3 개를 inner 로 박아두고, 한 엔드포인트가 셋의 schema 크기를 한꺼번에 보여주게 했어요. LLM 호출 없는 학습 보조 출력이에요 (Step 3 의 format-debug 와 같은 톤).
public record QuoteMini(String text, String author) { }
public record AiReplyLike(String aiMessage, List<String> choices, int affectionDelta) { }
public record AiReplyBig(
String aiMessage,
List<String> choices,
int affectionDelta,
String emotion,
String backgroundMusic,
List<String> tags,
int turnNumber,
String sceneDescription,
String characterPose,
boolean shouldEndConversation,
Map<String, Integer> additionalStats
) { }
@GetMapping(value = "/api/structured/schema-size", produces = MediaType.TEXT_PLAIN_VALUE)
public String schemaSize() {
StringBuilder sb = new StringBuilder();
appendSchemaInfo(sb, "QuoteMini (2 fields)", QuoteMini.class);
appendSchemaInfo(sb, "AiReplyLike (3 fields, incl. List)", AiReplyLike.class);
appendSchemaInfo(sb, "AiReplyBig (11 fields, incl. List + Map)", AiReplyBig.class);
return sb.toString();
}
private void appendSchemaInfo(StringBuilder sb, String label, Class<?> clazz) {
BeanOutputConverter<?> converter = new BeanOutputConverter<>(clazz);
String schema = converter.getJsonSchema();
int bytes = schema.getBytes().length;
int approxTokens = (int) Math.ceil(bytes / 4.0); // 1 token ≈ 4 bytes 휴리스틱
sb.append("=== ").append(label).append(" ===\n");
sb.append(schema).append('\n');
sb.append("length: ").append(bytes).append(" bytes ≈ ")
.append(approxTokens).append(" tokens (estimated, English-heavy heuristic)\n\n");
}
bytes / 4.0 은 영어/JSON 위주 텍스트의 거친 토큰 환산 휴리스틱이에요. OpenAI 의 BPE 토크나이저 기준으로 영어는 평균 4 바이트당 1 토큰 정도. 정확한 값은 토크나이저 (Gemini 의 SentencePiece, GPT 의 o200k_base 등) 마다 달라요. 어디까지나 감각 잡기용 근사치예요.
실제 측정 결과
호출해보면 이렇게 떨어져요.
curl http://localhost:8080/api/structured/schema-size
세 개 schema 의 측정값을 표로 정리하면:
| record | 필드 수 | bytes | est. tokens | Mini 대비 |
|---|---|---|---|---|
QuoteMini |
2 | 236 | 59 | 1.0× |
AiReplyLike |
3 (List 포함) | 351 | 88 | 1.5× |
AiReplyBig |
11 (List + Map 포함) | 880 | 220 | 3.7× |
핵심 관찰 두 가지.
- 필드 1 개 → 3 개 (+List) 로 가는 비용은 50% 증가 — 별로 안 큼. 우리
AiReply정도는 합리적 풍부함이에요. - 3 개 → 11 개 (+List + Map) 로 가면 2.5 배 — 매 호출마다 추가로 132 tokens 가 들어가요. 이게 호출 수 × 모델 단가로 곱해지면 슬슬 비용이 보이기 시작해요.
비용으로 환산해보자
매 호출마다 추가되는 132 tokens 를 실제 돈으로 환산해봅시다. 2026 5월 기준 입력 토큰 단가는 이래요.
| 모델 | 입력 토큰 단가 (per 1M tokens) | 우리 실습에서 쓰나? |
|---|---|---|
| Gemini 2.5 Flash | 무료 티어 RPM·RPD 한도 내 무료 / 유료 전환 시 $0.30 | ✅ 학생 실습은 무료 티어, 유료 비교용으로 단가 표기 |
| GPT-4o | $2.50 | ❌ 강의 실습엔 안 쓰지만 비교용 |
월 호출량 시나리오로 곱해보면 (유료 단가 기준):
| 월 호출량 | Gemini 2.5 Flash 추가 비용 | GPT-4o 추가 비용 |
|---|---|---|
| 10 만 회 | $0.004 | $0.033 |
| 100 만 회 | $0.04 | $0.33 |
| 1,000 만 회 | $0.40 | $3.30 |
뭔가 작아 보이죠? 그런데 위 숫자는 추가 132 tokens 만 잡은 거예요.
실제론 응답 토큰 (출력) 도 같이 늘고 — record 가 풍부하면 모델이 채워야 할 양도 그만큼 커요 (Gemini 2.5 Flash 의 출력 단가는 $2.50/1M 으로 입력의 8배 가까이 비싸요).
그리고 GPT-4o 보다 훨씬 비싼 프런티어 모델 (Claude 4.x Opus, GPT-4.1 Pro 등) 을 쓰면 단가가 5~10 배 또 뜁니다.
💡 한 줄 직관 — Gemini 2.5 Flash 무료 티어에선 schema 비대화가 별로 안 보이지만, 유료 전환 + 대규모 트래픽 으로 가는 순간 schema 1 byte 가 진짜 비용이 돼요. 우리 ai-friends 같은 학습 단계에선 무료 티어 안에서 여유롭게 키워도 되지만, 운영 단계에선 의식적으로 절제할 줄 알아야 해요.
그럼 어디까지 키워도 되나? 🎯
실무 가이드를 표로 정리하면 이래요.
| record 크기 | 권장 시나리오 | 주의할 것 |
|---|---|---|
| 2~3 필드 | 한 가지 의미를 깔끔하게 (Quote, AiReply) | 메타데이터 추가 시 record 로 한 번 감싸 정돈 |
| 5~8 필드 | 한 도메인 응답을 모은 것 | "이 필드를 진짜 매 호출마다 받아야 하나?" 한 번 의심 |
| 10 필드 이상 | 거의 항상 의심 신호 | 응답을 두 호출로 쪼개거나, 일부 필드를 lazy 로 분리 검토 |
| 20 필드 이상 | 거의 항상 안티패턴 | 한 호출에 너무 많은 의미 — 도메인이 섞여있을 가능성 |
ai-friends 의 AiReply (3 필드) 는 첫째 줄에 들어가요. 만약 PM 이 위 11 필드 요구를 들고 오면 우리가 "다음 두 가지 중 하나로 분리합시다" 라고 답할 수 있어야 해요.
- 호출을 두 개로 쪼개기 —
aiMessage + choices + affectionDelta는 매 턴,emotion + backgroundMusic + tags는 5 턴마다 한 번 같은 식으로 - lazy 필드 분리 — 매 턴 받는 핵심 응답엔 Mini 만, 추가 정보가 필요할 때 별도 endpoint 호출
이게 운영 감각이에요 — "기능이 가능한가" 가 아니라 "이 비용을 매 호출마다 낼 가치가 있는가" 로 묻는 습관.
모델 응답 품질에도 영향이 있어요 ⚠️
비용 외에 한 가지 더 — schema 가 너무 커지면 모델 응답 품질도 떨어져요. 직관적으론 이상하지만 이유는 명확해요.
- 모델의 attention 이 분산됨 — 11 개 필드를 채우라고 요구하면 모델이 한 필드당 쓸 수 있는 "주의" 가 그만큼 줄어요. 핵심 필드 (
aiMessage) 의 품질이 미세하게 떨어집니다. - 모델의 "JSON 채우기 모드" 가 강해짐 — 자유로운 창의성보다 "11 개 필드를 다 채워야 해" 라는 강박이 우세해져서 응답이 형식적이 돼요. 캐릭터 톤이 살짝 무뚝뚝해질 수 있어요.
- 에러 발생률 미세 증가 — 필드가 많아질수록 모델이 한 필드를 빠뜨리거나 잘못 채울 확률도 누적적으로 증가해요. Step 6 의 retry 정책이 더 자주 작동하게 됨.
이 셋이 합쳐지면 "응답을 풍부하게 받으려고 record 를 키웠는데 오히려 응답이 무뚝뚝해지는" 역설이 일어날 수 있어요. 실측해보지 않으면 못 잡는 함정이에요.
🙋 날카로운 질문 타임 — 학생 질문
"튜터님, schema 가 매 호출마다 들어간다면 prompt caching 같은 걸로 캐시할 순 없나요? 같은 schema 면 한 번만 보내고 다음부턴 참조만 하면 되잖아요."
날카로운 질문이에요. 정답은 — "가능하다, 그런데 모델 측의 지원이 필요하다" 입니다.
- Anthropic Claude —
prompt_caching으로 schema 같은 정적 prefix 를 캐시해서 캐시 hit 입력 토큰 비용 90% 절감 가능. 캐시 hit 의 TTL 은 기본 5 분 (1 시간 옵션도 유료로 제공) 이라 트래픽 패턴에 따라 효과가 달라요. - OpenAI — Automatic Prompt Caching (자동, 사용자 코드 변경 없음). 동일 prefix 가 반복되면 자동으로 캐시. 50% 비용 절감.
- Gemini — Context Caching 명시적 지원. API 호출 시 캐시할 부분을 등록하고 캐시 ID 를 받아 후속 호출에 첨부. 절감률은 75% 안팎.
즉 schema 비용 절감 카드는 있어요.
다만 본 강의 범위 밖 이에요 — Spring AI 가 prompt caching 을 어떻게 추상화하는지는 1.1.x 시점 기준 프로바이더별로 처리 방식이 달라서 깊이 들어가면 학습 호흡이 흔들려요.
"cache 카드가 있다는 사실만 인지하고, schema 자체는 처음부터 작게 짜는 습관이 더 중요" 가 오늘 챙길 결론이에요. 💡
"튜터님, 그럼 schema 를 일부러 짧게 쓰려고 record 필드명을 한 글자 (
a,b,c) 로 짧게 짜는 건 어떨까요?"
기술적으론 가능해요. 한 필드명을 aiMessage (9 글자) → m (1 글자) 으로 줄이면 schema 도 살짝 줄고, 응답 JSON 도 줄어요. 다만 거의 항상 권장하지 않습니다. 이유 두 가지:
- 코드 가독성 ↓↓↓ —
reply.m()와reply.aiMessage()중 어느 쪽이 6 개월 뒤 새로 합류한 동료에게 친절할까요? 답이 명백해요. - 모델의 응답 품질 ↓ — 모델은 학습 데이터에서
aiMessage같은 의미 있는 키 이름을 자주 봤지m은 아니에요. 의미 있는 키 이름이 모델한테도 "이 필드에 어떤 종류의 값을 채워야 하는지" 의 단서를 줘요. 키를 짧게 줄이면 응답 정확도가 미세하게 떨어집니다.
요약: "필드명을 줄여서 토큰을 아끼는 건 닭 잡으려고 소 잡는 칼을 깎는 격". record 의 의미 있는 이름은 그대로 두고, 절제는 필드 개수 자체 에서 해요.
💡 튜터의 결론
Step 7 의 한 문장 요약은 이래요.
"record 의 풍부함은 공짜가 아니다. 매 호출마다 schema 만큼의 입력 토큰이 따라간다. 우리 도메인엔 2~3 필드가 스윗 스팟."
7 단계를 모두 거친 우리의 도구 상자를 한 번 정리해봅시다.
| Step | 도구 / 감각 |
|---|---|
| 1 | 수동 파싱의 4 가지 함정 식별 — Schema 손 조립 / readValue try-catch / 이중 진실 / 프로바이더 락인 |
| 2 | .entity(Class<T>) — 단일 record 응답 |
| 3 | BeanOutputConverter 의 schema 자동 생성 + 프롬프트 자동 주입 메커니즘 |
| 4 | ParameterizedTypeReference — List<T> · Map<K,V> 같은 컬렉션 처리 |
| 5 | ai-friends 본 도메인 (SoulmateChatService) 을 record 반환으로 갈아엎기 |
| 6 | retry · fallback · strict 복구 전략 — UX vs 가시성 트레이드오프 |
| 7 | schema 크기 ↔ 토큰 비용 — 풍부함의 손익분기점 감각 |
이 7 가지가 손에 다 들어왔다면 오늘 Day 4 의 학습 목표는 모두 달성한 거예요. LLM 응답을 타입 안전한 record 로 받고, 깨짐을 우아하게 복구하고, 비용까지 의식하는 운영 감각 까지 한 번에 꿰뚫어본 하루였어요.
이제 마무리 섹션으로 넘어가서 회고하고, 브랜치를 보존하고, 다음 시간 (Day 5) ChatMemory 의 풍경을 살짝 미리 보여드릴게요. 도구를 다 챙겼으니 이젠 대화의 흐름 을 만들 차례입니다.
마무리
오늘의 여정 한눈에
Day 4 의 3 시간을 한 문장으로 요약하면 — "LLM 응답을 String 이 아니라 Record 로 받기 시작한 하루" 였어요.
| Step | 한 줄 요약 | 실무에서 기억해야 할 감각 |
|---|---|---|
| 1 | 수동 JSON 파싱의 4 가지 함정 | "Schema 손 조립 / readValue try-catch / 이중 진실 / 프로바이더 락인 — 다 한 도구로 사라진다" |
| 2 | .entity(Class<T>) 첫 호출 |
"record 한 줄 + .entity(...) 한 줄 = 타입 안전한 LLM 응답" |
| 3 | BeanOutputConverter 의 내부 |
"마법이 아니라 schema 자동 생성 + 프롬프트 자동 주입. 디버깅 가능한 도구" |
| 4 | ParameterizedTypeReference |
"List<T> · Map<K,V> 는 익명 서브클래스 트릭 한 줄로" |
| 5 | SoulmateChatService → AiReply 반환 |
"응답 타입 한 군데 바꾸면 컨트롤러·테스트가 자동으로 따라온다" |
| 6 | retry · fallback · strict 복구 전략 | "사용자 경험 ↔ 디버깅 가시성 트레이드오프 위에서 정책을 의식적으로 고른다" |
| 7 | schema 크기 ↔ 토큰 비용 | "record 풍부함은 공짜가 아니다. 매 호출마다 schema 만큼 입력 토큰이 따라간다" |
이 7 개를 다 외우라는 게 아니에요. "record 가 진실의 원본" 과 "풍부함은 공짜가 아니다" 두 문장만 3 개월 뒤에도 기억하시면 오늘 수업은 성공이에요.
Day 5 예고 — "ChatMemory: 대화의 흐름을 만들기"
오늘 우리가 만든 SoulmateChatService.chat(...) 을 다시 한 번 떠올려 봅시다.
public AiReply chat(String anonymizedUserName, String mood, String userMessage) {
return soulmateChatClient.prompt()
.system(...)
.user(userMessage)
.call()
.entity(AiReply.class);
}
자, 한 가지 질문 — 이 메서드를 같은 사용자가 두 번 연달아 호출하면 어떻게 될까요?
호출 1) 사용자: "오늘 진짜 별로였어"
모델: "에이, 무슨 일 있었어? 천천히 얘기해봐."
호출 2) 사용자: "사실 회사에서 일이 있었어"
모델: ???
모델은 두 번째 호출에서 첫 번째 대화를 전혀 기억하지 못해요. 매 호출이 독립적인 stateless 호출이니까요. "사실 회사에서 일이 있었어" 라는 문장만 받으면 모델은 "어디 회사? 무슨 일? 무엇에 대한 답?" 을 모릅니다. 미연시 게임 도메인에서 이건 치명적이에요. 캐릭터가 매 턴마다 처음 만난 사람처럼 행동하면 게임이 안 되거든요.
Day 5 의 주제가 정확히 이 문제예요 — ChatMemory.
// Day 5 에서 만날 모양 (오늘은 코드에 넣지 않습니다 — 개념 예고)
chatClient.prompt()
.advisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
.user(userMessage)
.call()
.entity(AiReply.class);
Advisor 라는 새 도구가 등장해요. ChatMemory 가 MessageChatMemoryAdvisor 한 줄로 들어오면서 Day 4 의 .entity(AiReply.class) 패턴이 대화 이력 위에서도 그대로 살아남는 풍경이에요. Day 4 의 도구들이 Advisor 라는 더 큰 추상화 위에 자연스럽게 합류하는 거죠.
💡 Day 5 마무리 시점 예고 — 위
chat(name, mood, msg)시그니처는 Day 5 Step 5 에서 학습용 lab 으로 conversationId 를 한 인자 더 받게 자라요. 그리고 Day 5 Step 6 에서는 도메인 정합 prod 시그니처chat(soulmateId, msg)로 한 번 더 자라요. 그 단계에서 오늘 미뤄둔 GeminiService 30 줄 들어내기 약속도 같이 회수돼요. lab 이 prod 를 흡수하는 수렴 Day 가 다음 시간이에요.
Day 5 에서 다룰 것:
ChatMemory인터페이스 +JdbcChatMemoryRepository영속화 (서버 재시작해도 대화 살아남음)MessageWindowChatMemory— sliding window 로 토큰 윈도우 관리MessageChatMemoryAdvisor— 대화 이력을 자동으로 프롬프트에 주입conversationId기반 세션 격리 — 사용자별·세션별 대화 분리- 레거시
GeminiService의 시스템 프롬프트 + RestClient 수동 호출을 완전히 들어내고 ChatMemory 기반 본 페르소나 서비스로 통합
Day 4 의 빈 공간(레거시 GeminiService 수동 파싱) 을 ChatMemory 와 함께 채우러 갑니다.
Step 6 의 손 코딩 retry 는 별개 트랙 — application.yml 의 spring.ai.retry.* 한 줄로도 즉시 ChatModel 레벨에 깔리고, 운영 정책(써킷브레이커·Rate Limit·캐싱) 과 묶이는 본격 학습은 Day 19 Harness 엔지니어링 의 주제예요.
Day 5 수렴 로드맵 — lab 이 prod 를 흡수하는 마지막 코너
Day 5 예고를 봤으니, 이제 이 lab 이 어디서 끝맺어지는지 못박을 차례입니다.
Day 3 마무리에서 깔아둔 약속을 다시 펴볼게요 — "SoulmateChatController 는 학습용 lab 이고, Day 5 마무리 시점에 기존 AiChatController 의 백엔드를 흡수한다".
Day 4 가 끝난 지금, 그 lab 이 한 단계 더 자랐어요 (AiReply record 반환).
다음 한 코너가 마지막 흡수입니다.
| Day | lab (SoulmateChatService) 가 새로 얻은 것 |
그 시점 prod (AiChatController 백엔드) 상태 |
|---|---|---|
| 3 | ChatClient + PromptTemplate | RestClient + 수동 JSON 파싱 그대로 |
| 4 (오늘) | + AiReply record + .entity(AiReply.class) |
GeminiService.parseGeminiResponse 가 deprecated 후보 로 표시 |
| 5 | + MessageChatMemoryAdvisor + JdbcChatMemoryRepository |
AiChatController 의 백엔드를 lab 으로 갈아끼움 → GeminiService 제거 (수렴 지점) |
오늘의 도전 과제 (Homework)
[구현 1] hello-ai/v3 엔드포인트에 구조화 출력 + 복구 전략 적용해보기
배경 시나리오
Day 3 에서 우리는 hello-ai/v3 라는 튜터 페르소나 엔드포인트를 만들었어요. 평문 String 답변을 돌려주는 단순한 형태였죠. 한 달 후 PM 이 또 슬랙을 던집니다.
"튜터님, 학생들이 답변을 받고 나면 '그럼 다음 질문은 뭘 해볼까?' 를 망설이는 데이터가 보여요. 튜터 답변 옆에 추천 후속 질문 2~3 개 도 같이 받을 수 있을까요? 프론트가 그걸 칩(chip) 으로 보여주면 학생이 바로 클릭해서 다음 대화로 이어갈 수 있을 것 같은데요."
전형적인 단일 String → 구조화 record 전환 요구예요. 오늘 Step 5 에서 SoulmateChatService 에 했던 작업을 hello-ai/v3 에도 그대로 굴려보세요.
💡 왜 굳이 이 과제를 할까요?
- 오늘 배운 패턴 굳히기 —
.entity(Class<T>)+ApiResponse래핑 + retry → fallback 까지의 전 과정을 다른 도메인에 한 번 더 적용하면서 패턴이 익숙해집니다. - 이전 Day 자산을 부수지 않고 확장 — Day 3 의
tutor-v1.st외부 프롬프트와 PromptTemplate 슬롯을 그대로 쓰면서 응답 구조만 갈아엎는 경험. "기존 자산 재사용 + 새 도구 도입" 의 실무 호흡을 굳혀요.
✅ 요구사항
- 새 응답 record 정의 (예:
kr.spartaclub.aifriends.hello.dto.TutorReply)- 필드:
String answer(튜터 답변),List<String> suggestedQuestions(추천 후속 질문 2~3 개) - record 위치는
hello/dto/패키지로 신설 (chat 의chat/dto/와 동일 패턴)
- 필드:
HelloAiV3Controller(또는 같은 자리의 컨트롤러) 수정- 반환 타입:
String→ResponseEntity<ApiResponse<TutorReply>> - ChatClient 호출:
.call().content()→.call().entity(TutorReply.class)
- 반환 타입:
- 시스템 프롬프트 보강
tutor-v1.st(또는 같은 위치) 의 Format 섹션에 "답변 후, 학생이 이어서 던질 만한 후속 질문 2~3 개를 함께 제안해" 한두 줄 추가- 스키마 자체는 BeanOutputConverter 가 자동 주입하지만, 각 필드를 어떻게 채울지 의 의미는 시스템 프롬프트가 알려줘야 합니다 (Step 5 의 "형식 ↔ 내용 짝" 원칙)
- retry → fallback 적용
- 컨트롤러 안에 (또는 helper 메서드로) Step 6 의
recover-retry패턴 그대로 적용 — 최대 3 회 재시도, 모두 실패하면 fallbackTutorReply반환 - Fallback 의
answer는 "지금 답을 만들 수 없어요. 잠시 후 다시 질문해주세요." 같은 솔직한 안내,suggestedQuestions는 빈 리스트
- 컨트롤러 안에 (또는 helper 메서드로) Step 6 의
- 테스트 갱신
- 기존 컨트롤러 테스트의
content().string(...)어설션을jsonPath("$.data.answer")/jsonPath("$.data.suggestedQuestions")로 변경 - retry → fallback 시나리오를 의도적으로 시뮬레이션하는 테스트 1 건 추가 (Step 6 의 임시 dump 테스트 패턴 참고)
- 기존 컨트롤러 테스트의
확인 방법
./run.sh up
# 정상 호출 — 답변 + 추천 질문이 같이 떨어지는지
curl "http://localhost:8080/api/hello-ai/v3?message=의존성 주입이 뭔가요?"
응답이 다음 형태로 떨어지면 통과예요. ✅
{
"success": true,
"data": {
"answer": "의존성 주입은 객체가 필요한 의존 객체를 직접 만들지 않고 외부에서 받아 쓰는 패턴이에요. ...",
"suggestedQuestions": [
"그럼 의존성 주입을 안 쓰면 뭐가 안 좋나요?",
"Spring 에선 어떻게 자동으로 주입해주나요?",
"생성자 주입과 필드 주입 중 뭐가 더 좋나요?"
]
}
}
제약 / 금지
- 오늘 안 배운 기술 사용 금지 —
Advisor(Day 5),ChatMemory(Day 5),@Retryable, Spring Retry 라이브러리,spring.ai.retry.*자동 RetryTemplate (Day 19 Harness), Resilience4j (Day 19 Harness) 모두 금지. 오늘 배운.entity(Class<T>)+ 손 코딩 retry/fallback + ApiResponse 만으로 풀어요. - 시스템 프롬프트의 Format 섹션을 자연어 JSON 명세로 손 조립하지 마세요. record 의 BeanOutputConverter 가 schema 를 자동 주입하니, Format 섹션엔 "각 필드의 의미" 만 자연어로 적습니다.
- 응답을 ApiResponse 로 래핑하지 않고 raw record 로 반환하지 마세요. 본 강의 표준 — 모든 새 컨트롤러는 ApiResponse 래핑.
[구현 2] schema 비대화의 손익분기점 직접 측정해보기
배경 시나리오
Step 7 에서 우리는 QuoteMini (2 필드) → AiReplyBig (11 필드) 사이 schema 가 약 3.7 배 커지는 걸 봤어요. 그런데 "그럼 우리 도메인에선 정확히 어디가 손익분기점인가?" 는 여전히 감각적인 답이었죠. 이번 과제에선 직접 숫자로 그 손익분기점을 그려봅니다.
💡 왜 굳이 이 과제를 할까요?
- 추측을 측정으로 바꾸기 — "응답을 풍부하게 받자" 라는 PM 의 요구에 우리가 "이 비용이 따라옵니다, 표 보세요" 라고 숫자로 답할 수 있어야 합니다. 운영 의사결정의 무게는 측정에서 나와요.
- trade-off 의 비선형성 체감 — 필드 수와 schema bytes 의 관계는 단순 비례가 아니에요. List, Map, nested record 등이 추가되면 비선형적으로 부풀어 오릅니다. 직접 측정하지 않으면 못 잡는 감각이에요.
✅ 요구사항
- 5 단계 record 만들기 — 데모 컨트롤러나 별도 학습 패키지에 inner record 5 개 추가
- Step 1: 2 필드 (String × 2) — Quote 같은 단순체
- Step 2: 4 필드 (String × 3 + int × 1)
- Step 3: 8 필드 (String × 5 + int × 2 + boolean × 1)
- Step 4: 16 필드 (위 + List × 2 + nested record × 2)
- Step 5: 32 필드 (위 + Map × 2 + enum × 2 — Java 의 enum 으로)
- 측정 엔드포인트 —
GET /api/structured/schema-size-extended같은 디버그 평문 엔드포인트 (Step 7 의schema-size패턴 그대로)- 5 단계 record 각각의
getJsonSchema()길이를 출력 - 정수 추정 토큰 수도 함께 (Step 7 의
bytes / 4휴리스틱) - Mini 대비 배수도 함께 출력
- 5 단계 record 각각의
- (선택) 실제 LLM 호출 latency 측정 — 5 단계 record 각각으로 동일 사용자 메시지를 LLM 한테 보내고 응답 시간을 측정
- 각 단계 3 회씩 호출, 평균 지연 시간 기록
- 모델 호출 비용 발생하니 무료 Gemini Flash 한 모델만 쓰세요. 강의 실습 비용 정책 (CLAUDE.md 3 번) 준수
- 결과 보고 — markdown 표 한 장 + 짧은 분석 (3 줄 정도)
확인 방법
curl http://localhost:8080/api/structured/schema-size-extended
출력에서 다음 표를 손으로 옮겨 정리하세요.
| record | 필드 수 | bytes | est. tokens | Mini 대비 | (선택) 평균 latency |
|---|---|---|---|---|---|
| Step 1 | 2 | ? | ? | 1.0× | ? ms |
| Step 2 | 4 | ? | ? | ?× | ? ms |
| Step 3 | 8 | ? | ? | ?× | ? ms |
| Step 4 | 16 | ? | ? | ?× | ? ms |
| Step 5 | 32 | ? | ? | ?× | ? ms |
표 아래에 "우리 도메인의 손익분기점은 X 필드" 같은 결론을 한두 문장으로 적으세요.
제약 / 금지
- 유료 모델 사용 금지 — 측정에 GPT-4o, Claude 4.x Sonnet 같은 유료 모델 쓰지 마세요. Gemini 2.5 Flash 무료 티어로 충분.
- 응답 토큰까지 측정하려고 하지 마세요. 응답 토큰 측정엔 별도 토크나이저 라이브러리가 필요해서 학습 호흡이 깨집니다. schema 입력 토큰의 부풀어 오름 한 가지에만 집중.
🤔 생각해볼 주제
이 섹션의 목적 — 정답이 정해져 있지 않은 질문들이에요. 오늘 배운 내용에서 한 발 떨어져 "만약 나라면 어떻게 할까?" 를 스스로 정리해보는 시간. 각 주제마다 5~10 분씩, 가능하면 문장으로 적어보세요. 머릿속 생각과 글로 적은 생각은 다릅니다.
주제 1 — JSON Schema 강제력의 한계, 우리는 어디까지 모델을 신뢰할 것인가?
오늘 Step 3 에서 봤듯이 BeanOutputConverter 는 schema 텍스트를 프롬프트에 자동으로 끼워넣어요. 이건 자연어 instruction 이지 하드 강제 가 아니에요. 즉 모델이 "이번엔 무시하고 자유롭게 답해야겠다" 라고 결정하면 깨질 수 있는 약속이에요.
업계엔 이걸 더 강하게 강제하는 옵션도 있어요.
- OpenAI
response_format: { type: "json_schema", strict: true }— API 레벨에서 schema 적합성을 토큰 생성 단계에서 강제. 응답이 schema 를 100% 만족 - Gemini
responseMimeType: application/json+responseSchema— 비슷한 강도의 모델 측 강제
🎯 핵심 질문 — 우리 ai-friends 같은 캐주얼 게임 도메인에서 강한 schema 강제 (네이티브 JSON 모드) 와 약한 강제 (BeanOutputConverter 자동 instruction) 중 어느 쪽이 더 적절할까요? 그 결정에 영향을 주는 변수는 무엇일까요?
직관적으론 "강한 강제가 항상 좋은 거 아닌가?" 같지만, 실제론 트레이드오프가 있어요.
- 강한 강제 — 응답 형식의 100% 보장. 다만 모델의 창의성/표현력 미세 저하 가 보고됨. 또 프로바이더에 묶임 (OpenAI 의 strict 와 Gemini 의 responseSchema 형식이 다름 — Day 2 추상화 정신과 충돌)
- 약한 강제 — 형식 보장은 95~99%. 창의성 보존. 프로바이더 중립 (Spring AI 추상화에서 자연스럽게 동작). 깨질 0~5% 를 retry/fallback 으로 흡수
도메인 성격 (캐주얼 vs 트랜잭션) · 깨짐의 비용 · 프로바이더 lock-in 의 부담 · 응답 창의성의 가치 등 여러 변수가 얽혀요. 여러분 도메인에선 어떤 변수가 결정을 뒤집는지 구체적으로 적어보세요.
주제 2 — Fallback 응답의 정직성 — 사용자에게 "이건 fallback" 임을 알려야 하나?
Step 6 의 recover-retry 시연에서 우리는 fallback Quote 의 author 를 "ai-friends" 로 박아둬서 운영팀이 식별할 수 있게 했어요. 그런데 사용자한테는 이 응답이 fallback 이라는 사실을 어떻게 알릴지 (또는 알리지 않을지) 가 별개의 문제예요.
- (A) 위장 fallback — 사용자는 일반 응답과 구분 못 함. UX 끊기지 않음. 단 fallback 이 누적되면 "AI 가 답하는 척만 한다" 는 의심 발생 가능
- (B) 솔직한 fallback — 응답에 명시적 표지 (예:
"isFallback": true, "잠시 후 다시 시도해주세요" 라는 안내) 가 박힘. 사용자가 즉시 인지하지만 흐름이 끊김 - (C) 부분 위장 — 사용자에겐 자연스러운 톤으로 보이되, 응답 메타데이터에 fallback flag 박아 운영팀만 식별 가능
🎯 핵심 질문 — 여러분이 운영하는 미연시 게임 (ai-friends) 의 사용자에게 fallback 응답을 어떻게 표현하시겠어요? 그 결정 뒤에 어떤 윤리·UX·운영의 고민이 있나요?
이 주제는 단순한 기술 결정을 넘어 AI 시스템의 신뢰성 정직성 의 영역이에요. 의료·법률·금융 도메인에선 정직한 fallback 이 거의 필수 (틀린 답을 정답처럼 주면 사용자 피해 큼). 반면 추천·엔터테인먼트 도메인에선 위장 fallback 이 더 자연스러울 수 있어요. ai-friends 가 어디 위치하는지부터 정해보세요.
주제 3 — 모델의 자율성 vs schema 의 강제, 균형을 어디에?
Step 7 에서 봤듯이 record 가 풍부할수록 schema 가 부풀어 오르고, 모델의 attention 이 분산돼요. 반대로 record 가 빈약하면 응답이 뭉뚱그려져서 후속 처리가 어려워져요. 풍부함과 빈약함 사이 적정선 이 도메인마다 다르다는 게 오늘 결론이었죠.
이걸 더 깊게 들여다보면 — "모델한테 무엇을 결정하게 둘 것인가" 의 자율성 문제로 이어집니다.
- schema 가 모든 필드를 강제하면 — 모델은 "11 개 빈 칸을 채우는 작업자" 가 됩니다. 자율성 0%. 응답 일관성 ↑, 창의성 ↓
- schema 를 핵심 1~2 필드로만 좁히면 — 모델한테 "어떤 정보를 응답에 담을지" 의 결정권이 일부 넘어갑니다. 자율성 ↑, 응답 일관성 ↓
- 두 응답 모드를 시나리오별로 분기 — 정형 시나리오 (호감도 정산) 는 강한 schema, 자유 대화 (캐주얼 한 턴) 는 약한 schema. 가장 정교하지만 코드 복잡성 증가
🎯 핵심 질문 — 여러분이 LLM 기반 시스템을 설계할 때 "모델한테 자율성을 얼마나 줄 것인가" 의 기준선을 무엇으로 잡으시겠어요? 그 기준선이 도메인별·시나리오별로 어떻게 달라져야 할까요?
이 질문엔 정답이 없어요. 하지만 "의식적으로 결정한 자율성 기준선" 이 있는 시스템은 그렇지 않은 시스템보다 운영 안정성이 항상 더 높습니다. 여러분의 도메인 (ai-friends 든, 본인 사이드 프로젝트든) 에서 이 기준선을 한 번 명시적으로 적어보세요. 적어보지 않으면 무의식 중에 "기본값" 으로 굳어지거든요.
✅ 예시 답안정답 보기
수업 본문 Step 5 에서 우리는 SoulmateChatService 의 응답을 String → AiReply record 로 갈아엎었어요. 이번 과제에선 같은 패턴을 Day 3 끝 시점의 hello-ai/v3 엔드포인트에도 적용해봅니다.
Day 3 끝 시점의 v3 는 이미 TutorReply(topicTag, reply) record + ApiResponse 래핑까지는 되어 있어요.
다만 그 record 는 입력 echo 용 이라 LLM 이 진짜 채우는 게 아니라 사용자 RequestParam 을 그대로 응답에 박아넣는 구조였죠.
오늘 과제는 그 record 의 의미를 "LLM 이 직접 채우는 구조화 응답" 으로 진화시키는 겁니다 — 시그니처를 (answer, suggestedQuestions) 로 갈아엎고, retry → fallback 까지 손으로 한 번 더 굴려서 오늘 패턴을 굳혀요.
이 과제가 끝나면 같은 엔드포인트의 응답이 이렇게 바뀝니다.
[과제 전 — Day 3 끝, 입력 echo 용 record]
{
"success": true,
"data": {
"topicTag": "Spring AI",
"reply": "의존성 주입은 객체가 필요한 의존 객체를 직접 만들지 않고..."
}
}
[과제 후 — LLM 이 채우는 구조화 ApiResponse + TutorReply]
{
"success": true,
"data": {
"answer": "의존성 주입은 객체가 필요한 의존 객체를 직접 만들지 않고...",
"suggestedQuestions": [
"그럼 의존성 주입을 안 쓰면 뭐가 안 좋나요?",
"Spring 에선 어떻게 자동으로 주입해주나요?",
"생성자 주입과 필드 주입 중 뭐가 더 좋나요?"
]
}
}
답변 옆에 추천 후속 질문 칩까지 붙어서 프론트가 학생 흐름을 자연스럽게 이어줄 수 있어요. 단계별로 살펴봅시다.
Step 1. `TutorReply` record 시그니처 진화
기존 kr.spartaclub.aifriends.hello.TutorReply 의 시그니처를 갈아엎습니다. Step 5 에서 chat/dto/AiReply.java 를 만든 패턴과 동일한 호흡으로, "BeanOutputConverter 가 분석해 schema 를 자동 주입하는 record" 로 의미를 재정의하는 거예요.
package kr.spartaclub.aifriends.hello;
import java.util.List;
/**
* Day 4 과제 1 — `hello-ai/v3` 의 구조화 응답 record.
*
* <p>BeanOutputConverter 가 이 record 를 분석해 JSON Schema 를 자동 생성하고,
* 사용자 프롬프트 끝에 format 지시문을 자동 주입한다. 시스템 프롬프트 (tutor-v3-structured.st) 가
* 각 필드의 의미를 자연어로 알려준다 — record 가 형식을, 시스템 프롬프트가 내용을 강제하는 짝.</p>
*
* @param answer 튜터의 답변 본문 (3~6 문장, 평문)
* @param suggestedQuestions 학생이 이어서 던질 만한 짧은 후속 질문 후보 (보통 2~3 개)
*/
public record TutorReply(
String answer,
List<String> suggestedQuestions
) { }
두 필드 record. 시그니처는 갈아엎였지만 클래스의 위치(hello/TutorReply.java)와 패키지는 그대로라 v3-ab 가 쓰는 HelloAbResponse 같은 다른 DTO 들의 임포트 그래프엔 영향이 없어요. Step 1 의 함정 ③ "이중 진실" 은 이 record 한 곳이 진실의 원본이 되어 사라집니다.
Step 2. 새 시스템 프롬프트 파일 — `tutor-v3-structured.st`
기존 tutor-v1.st 는 v3 + v3-ab 두 엔드포인트가 공유하고 있어요. 그 파일의 # Format 섹션 이 "평문으로 응답한다, JSON 안 됨" 이라고 명시되어 있어서 이걸 그냥 수정하면 v3-ab 가 깨집니다. 새 파일을 신설해 격리하세요.
src/main/resources/prompts/hello/tutor-v3-structured.st 를 새로 만듭니다.
# Role
너는 유저의 학습을 돕는 친근한 동료 튜터 AI야.
선생님처럼 위에서 가르치는 게 아니라, 같이 공부하는 또래 튜터의 자세로 말한다.
# Context
- 지금 대화 중인 학생의 익명 ID: {userName}
- 오늘 주로 다루는 주제 태그: {topicTag}
# Task
1. 전문용어는 처음 쓸 때 반드시 한 번 일상 언어로 풀어서 설명한다.
2. answer 는 3~6 문장 사이. 너무 짧거나 장황하지 않게.
3. 학생을 칭할 때는 {userName} 대신 "너" 같은 친근한 2 인칭만 사용한다 (식별자 노출 금지).
4. suggestedQuestions 는 학생이 위 답변을 받고 자연스럽게 이어서 던질 만한
**짧은 후속 질문** 2~3 개를 제안한다. 각 질문은 한 줄(최대 30 자) 이내로 간결하게.
# Format
- 각 필드(answer, suggestedQuestions)의 의미는 위 Task 를 참조한다.
- (응답 형식 자체는 BeanOutputConverter 가 자동으로 강제한다 — 자연어 설명 불필요)
# Example
## 예시 1 — 전문용어를 풀어서 설명 + 후속 질문
User: "{topicTag}" 의 의존성 주입이 뭐야?
Assistant (answer): "좋은 질문이야! 의존성 주입(Dependency Injection)을 한 줄로 풀면 '필요한 부품을 내가 직접 만들지 않고 외부에서 받는 것'이야. 예를 들어 커피머신이 원두를 스스로 고르는 게 아니라 매장 매니저가 원두를 건네주는 식이지."
Assistant (suggestedQuestions): ["DI 안 쓰면 뭐가 안 좋아?", "스프링은 어떻게 주입해줘?", "생성자 주입이 더 좋다는 이유?"]
핵심 변화 두 가지.
# Format섹션이 의미만 남고 형식은 BeanOutputConverter 에 위임 — 지난 시간까지 우리가 손으로 쓰던 "JSON 어떻게 줘" 자연어 설명이 빠졌어요. Step 3 에서 본 것처럼 record 의 schema 가 자동 주입되니 더 이상 자연어로 형식을 명세할 필요가 없어요.suggestedQuestions의 의미 가이드 — 모델한테 "이 필드를 어떤 종류의 값으로 채워야 하는지" 를 자연어로 알려줘야 합니다. 이걸 빠뜨리면 모델이 빈 배열[]로 채워버려요 (Step 5 의 "형식 ↔ 내용 짝" 원칙).
Step 3. `HelloAiController` 수정
이제 본체 — helloV3 메서드의 반환 타입 + 호출 체인을 갈아엎습니다. 추가로 retry → fallback 까지 한 번에.
package kr.spartaclub.aifriends.hello;
import kr.spartaclub.aifriends.common.response.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@Slf4j
@RestController
public class HelloAiController {
private final ChatClient chatClient;
private final PromptTemplate tutorSystemTemplateV3Structured;
/** retry 가 모두 실패했을 때 사용자에게 돌려줄 안전 응답. */
private static final TutorReply FALLBACK_TUTOR_REPLY = new TutorReply(
"지금 답을 만들 수 없어요. 잠시 후 다시 질문해주세요.",
List.of()
);
public HelloAiController(
ChatClient.Builder chatClientBuilder,
// ... (기존 v1 / v2 / v3-ab 의존성 생략)
@Value("classpath:prompts/hello/tutor-v3-structured.st") Resource tutorV3StructuredResource
) {
this.chatClient = chatClientBuilder.build();
this.tutorSystemTemplateV3Structured = new PromptTemplate(tutorV3StructuredResource);
}
/**
* Day 4 과제 1 — Day 3 의 평문 응답을 TutorReply record + retry → fallback 으로 업그레이드.
*/
@GetMapping("/api/hello-ai/v3")
public ResponseEntity<ApiResponse<TutorReply>> helloV3(
@RequestParam(defaultValue = "의존성 주입이 뭔가요?") String message,
@RequestParam(defaultValue = "Spring AI") String topicTag
) {
String anonymizedUserName = "tutor-student-1";
String renderedSystemPrompt = tutorSystemTemplateV3Structured.render(Map.of(
"userName", anonymizedUserName,
"topicTag", topicTag
));
TutorReply reply = callWithRetryThenFallback(renderedSystemPrompt, message);
return ResponseEntity.ok(ApiResponse.success(reply));
}
/**
* Step 6 의 recover-retry 패턴을 그대로 차용 — 최대 3 회 재시도, 모두 실패하면 fallback.
* 진짜 LLM 호출이라 의도적 시뮬레이션은 없고, 모델이 자연스럽게 깨뜨린 경우에만 catch 가 동작한다.
*/
private TutorReply callWithRetryThenFallback(String systemPrompt, String userMessage) {
int maxAttempts = 3;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
TutorReply reply = chatClient.prompt()
.system(systemPrompt)
.user(userMessage)
.call()
.entity(TutorReply.class);
log.info("hello-ai/v3: attempt {}/{} succeeded", attempt, maxAttempts);
return reply;
} catch (RuntimeException e) {
log.warn("hello-ai/v3: attempt {}/{} failed: {}", attempt, maxAttempts, e.getMessage());
}
}
log.warn("hello-ai/v3: all {} attempts failed, returning fallback", maxAttempts);
return FALLBACK_TUTOR_REPLY;
}
// ... (기존 helloV3Ab 등 나머지 메서드 그대로 유지)
}
세 군데 변화를 짚어볼게요.
| 자리 | 지난 시간 (Day 3 끝 시점) | 오늘 (과제 1 결과) |
|---|---|---|
| 응답 record 시그니처 | TutorReply(topicTag, reply) — 입력 echo + 평문 reply |
TutorReply(answer, suggestedQuestions) — LLM 이 채우는 구조화 응답 |
| LLM 호출 체인 끝 | .call().content() (평문 String) |
.call().entity(TutorReply.class) |
| 시스템 프롬프트 파일 | tutor-v1.st (v3 + v3-ab 공유) |
tutor-v3-structured.st (신규, v3 격리) |
| 응답 표준 래퍼 | ApiResponse.success(...) (Day 3 에서 도입됨) |
그대로 유지 |
| retry → fallback | 없음 (한 번 실패하면 5xx) | 최대 3 회 재시도 + 안전 fallback |
특히 callWithRetryThenFallback 는 Step 6 의 recover-retry 패턴을 그대로 가져온 형태 예요. 다만 이번엔 진짜 LLM 호출이라 시뮬레이션 (SIMULATED_BROKEN_RAW) 이 없어요 — 모델이 자연스럽게 깨뜨린 경우에만 catch 가 동작합니다.
🙋 날카로운 질문 타임 — 학생 질문
"새 prompt 파일 (tutor-v3-structured.st) 을 만들지 말고 기존 tutor-v1.st 의 # Format 섹션만 수정하면 안 되나요?"
기술적으론 가능하지만 부작용 이 있어요. tutor-v1.st 는 Day 3 과제 2 의 v3-ab 엔드포인트도 공유하고 있어서, 그 파일의 # Format 섹션을 "JSON 으로 줘" 로 바꾸면 v3-ab 의 응답이 망가져요. v3-ab 는 평문 응답을 가정하고 짜여 있거든요.
파일을 분리 하면 두 엔드포인트가 독립적으로 진화할 수 있어요. 이게 Day 3 Step 7 에서 강조했던 "프롬프트는 코드가 아니라 데이터, 버전 관리해야 할 자산" 의 정신과 정합해요. 한 파일을 여러 엔드포인트가 공유하면 한쪽 변경이 다른 쪽을 깨뜨릴 위험이 누적돼요.
"callWithRetryThenFallback 메서드를 컨트롤러 안에 두지 말고 별도 helper 클래스로 빼야 하지 않나요?"
좋은 본능이에요. 단 Day 4 의 학습 호흡상은 컨트롤러 안 인라인이 더 자연스럽습니다. 이유:
Spring AI 자동 RetryTemplate 으로 곧 한 줄에 깔립니다. application.yml 의 spring.ai.retry.max-attempts=3 한 줄이면 ChatModel 호출 자체에 재시도가 박혀요.
별도 helper 로 빼서 잘 정리해두면 그걸 들어내야 해요.
운영 정책(써킷브레이커·Rate Limit·캐싱) 과 묶는 본격 학습은 Day 19 Harness 엔지니어링.
미리 helper 만드는 건 학습 단계에선 과한 추상화.
2. 인라인이 학습 가독성 더 높음 — Step 6 의 recover-retry 패턴과 1:1 매핑되어 학생이 비교하기 쉬워요.
운영 코드에선 helper 로 빼는 게 옳지만, 그 역할은 Spring AI 의 spring.ai.retry.* 자동 설정 (즉시) 과 Day 19 Harness 의 운영 정책 묶음이 맡습니다.
Step 4. 기존 컨트롤러 테스트 회귀 보정
Day 3 에서 박은 HelloAiControllerTest 의 v3 케이스 두 개는 (topicTag, reply) 시그니처와 .call().content() 모킹을 가정하고 있어요.
시그니처를 갈아엎은 지금 시점엔 이 두 케이스를 .entity(eq(TutorReply.class)) 모킹 + $.data.answer / $.data.suggestedQuestions jsonPath 로 갱신해야 컴파일·검증이 모두 살아납니다.
@Test
@DisplayName("GET /api/hello-ai/v3 - tutor-v3-structured.st 의 슬롯이 치환되어 .system() 에 주입되고, .entity(TutorReply.class) 가 돌려준 record 가 ApiResponse.data 로 응답된다")
void helloV3_bindsTopicTagAndReturnsTutorReplyEntity() throws Exception {
reset(chatClient);
TutorReply stub = new TutorReply(
"좋은 질문이야! 의존성 주입은 외부에서 부품을 받아오는 패턴이야...",
List.of("DI 안 쓰면 뭐가 안 좋아?", "스프링은 어떻게 주입해줘?", "생성자 주입이 더 좋다는 이유?")
);
given(chatClient.prompt()
.system(anyString())
.user(anyString())
.call()
.entity(eq(TutorReply.class)))
.willReturn(stub);
clearInvocations(chatClient.prompt());
mockMvc.perform(get("/api/hello-ai/v3")
.param("message", "의존성 주입이 뭔가요?")
.param("topicTag", "Java"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.answer").value("좋은 질문이야! ..."))
.andExpect(jsonPath("$.data.suggestedQuestions").isArray())
.andExpect(jsonPath("$.data.suggestedQuestions.length()").value(3));
// 시스템 프롬프트가 신규 격리 파일(tutor-v3-structured.st) 의 표지를 담고 있어야 한다.
ArgumentCaptor<String> systemCaptor = ArgumentCaptor.forClass(String.class);
then(chatClient.prompt()).should().system(systemCaptor.capture());
assertThat(systemCaptor.getValue())
.contains("# Role").contains("# Context").contains("# Task")
.contains("Java").contains("tutor-student-1")
.contains("suggestedQuestions") // 신규 파일 고유 표지
.doesNotContain("{userName}", "{topicTag}");
}
핵심은 chatClient.prompt().system(anyString()).user(anyString()).call().entity(eq(TutorReply.class)) 체인 — Step 5 본문의 SoulmateChatServiceTest 패턴과 완전히 동일합니다.
eq(TutorReply.class) 가 빠지면 generic type erasure 때문에 mockito 가 매칭을 못 잡아요.
Step 5. 한 번 호출해서 확인
./run.sh up # (참고: 도커 없이 잠깐 띄우고 싶다면 ./gradlew bootRun 도 가능)
curl "http://localhost:8080/api/hello-ai/v3?message=DI 가 뭐야?&topicTag=Spring"
응답이 평문 String 이 아닌 JSON 객체 로 떨어지면 통과예요.
{
"success": true,
"data": {
"answer": "좋은 질문이야! DI(의존성 주입)는 객체가 필요한 부품을 직접 만들지 않고 외부에서 받는 패턴이야. 예를 들어 커피머신이 원두를 스스로 고르지 않고 매니저가 건네주는 식이지. Spring 에선 컨테이너가 그 매니저 역할을 맡아.",
"suggestedQuestions": [
"DI 안 쓰면 뭐가 안 좋아?",
"스프링은 어떻게 주입해줘?",
"생성자 주입이 더 좋다는 이유?"
]
}
}
suggestedQuestions 가 빈 배열로 떨어진다면 시스템 프롬프트의 Task 4번 가이드가 모델한테 충분히 강하게 전달되지 않은 것 — 시스템 프롬프트의 의미 가이드를 한 번 더 다듬으세요. 이게 Step 5 의 "형식 ↔ 내용 짝" 의 실전 디버깅 감각이에요.
💡 채점 포인트 요약
| # | 포인트 | 만점 기준 |
|---|---|---|
| 1 | TutorReply record 시그니처 진화 | (topicTag, reply) → (answer, suggestedQuestions). 위치는 기존 그대로 hello/TutorReply.java |
| 2 | 시스템 프롬프트 분리 | tutor-v1.st 수정 X, 새 파일 (tutor-v3-structured.st) 신설 |
| 3 | 컨트롤러 시그니처 | ResponseEntity<ApiResponse<TutorReply>> (Day 3 에서 도입한 형태 그대로) |
| 4 | 호출 체인 | .call().entity(TutorReply.class) |
| 5 | retry → fallback | 최대 3 회 재시도 + 안전 fallback record |
| 6 | 응답 표준 래핑 | ApiResponse.success(...) (Day 3 에서 이미 도입, 유지) |
| 7 | 시스템 프롬프트 의미 가이드 | suggestedQuestions 의 의미 + 개수 가이드 명시 |
| 8 | 테스트 jsonPath | $.data.answer, $.data.suggestedQuestions 형태 |
| 9 | 기존 v3 단위 테스트 회귀 보정 | (topicTag, reply) 검증 → (answer, suggestedQuestions) 검증으로 갱신 |
7 번이 가장 자주 빠지는 포인트예요.
record 시그니처만 갈아엎고 시스템 프롬프트의 의미 가이드를 빠뜨리면 LLM 이 빈 필드를 줍니다 — 형식만 맞고 내용이 빈약한 응답이 떨어져요. 9 번도 흔히 빠집니다 — Day 3 에서 박아둔 HelloAiControllerTest 의 v3 케이스를 그대로 두면 컴파일 깨지거나 빨갛게 떨어져요.
🚀 실무 개선 포인트 (심화)
(1) 시스템 프롬프트의 버전 관리
현재는 tutor-v3-structured.st 한 파일이지만 운영에선 v3.1, v3.2 식으로 minor 버전을 두고 A/B 테스트하는 게 일반적이에요. Day 3 의 v3-ab 패턴을 응용하면 됩니다.
(2) Fallback 응답의 메트릭화
callWithRetryThenFallback 의 마지막 fallback 분기가 발동하면 메트릭 카운터를 증가 시키세요 (Micrometer 의 Counter 사용). 운영 대시보드에서 fallback 발동 비율을 모니터링해야 모델 품질 저하를 즉시 인지할 수 있어요.
(3) spring.ai.retry.* 자동 RetryTemplate / Day 19 Harness 로의 마이그레이션 준비
오늘 인라인으로 짠 retry 로직은 Spring AI 의 spring.ai.retry.* application.yml 한 줄로 ChatModel 레벨에서 자동 적용 가능합니다.
운영 정책(써킷브레이커·Rate Limit·캐싱) 과 묶이는 본격 학습은 Day 19 Harness 엔지니어링.
미리 그 자리를 인지하고 있으면 마이그레이션 PR 이 깨끗해져요.
🎯 [과제 2 예시 답안] schema 비대화의 손익분기점 직접 측정해보기
Step 7 의 schema-size 엔드포인트는 3 단계 (Mini/Standard/Big) 만 비교했어요. 이번 과제에서는 5 단계로 늘려 여러분 도메인의 손익분기점을 숫자로 그려봅니다.
Step 1. 5 단계 record 디자인
데모 컨트롤러나 별도 학습 패키지에 inner record 5 개를 추가합니다. 본문 Step 7 의 Mini/Standard/Big 패턴을 그대로 확장.
// Step 1 — 2 필드 (baseline)
public record SchemaStep1(String text, String author) { }
// Step 2 — 4 필드 (String 추가 + int 추가)
public record SchemaStep2(
String text,
String author,
String category,
int year
) { }
// Step 3 — 8 필드 (String 5 + int 2 + boolean 1)
public record SchemaStep3(
String text,
String author,
String category,
String language,
String source,
int year,
int wordCount,
boolean isClassic
) { }
// Step 4 — 16 필드 (위 + List 2 + nested record 2)
public record SchemaStep4(
String text,
String author,
String category,
String language,
String source,
int year,
int wordCount,
boolean isClassic,
List<String> tags,
List<String> relatedAuthors,
AuthorInfo authorInfo,
SourceInfo sourceInfo,
String mood,
String era,
int sentimentScore,
boolean recommendedForBeginners
) { }
public record AuthorInfo(String fullName, String nationality, int birthYear) { }
public record SourceInfo(String bookTitle, String publisher, int pageNumber) { }
// Step 5 — 32 필드 (위 + Map 2 + enum 2 + 추가 필드들)
public record SchemaStep5(
String text,
String author,
String category,
String language,
String source,
int year,
int wordCount,
boolean isClassic,
List<String> tags,
List<String> relatedAuthors,
AuthorInfo authorInfo,
SourceInfo sourceInfo,
String mood,
String era,
int sentimentScore,
boolean recommendedForBeginners,
Map<String, Integer> readerScores,
Map<String, String> translations,
Genre primaryGenre,
DifficultyLevel difficulty,
String summary,
String contextNote,
int linesCount,
boolean hasMetaphor,
boolean hasAlliteration,
String themeColor,
int popularityRank,
List<String> themes,
String recommendedAge,
boolean isQuotable,
int characterCount,
String culturalOrigin
) { }
public enum Genre { POETRY, PROSE, APHORISM, DIALOGUE }
public enum DifficultyLevel { BEGINNER, INTERMEDIATE, ADVANCED }
💡 enum 의 schema 효과 — Java enum 은 BeanOutputConverter 가 schema 의
enum키워드로 변환해줘요."primaryGenre": { "type": "string", "enum": ["POETRY", "PROSE", "APHORISM", "DIALOGUE"] }형태로. 모델 응답을 정해진 값으로 강하게 강제할 수 있어 정합성 ↑, 다만 schema 텍스트는 enum 값 개수만큼 더 부풀어 올라요.
Step 2. 측정 엔드포인트 추가
본문 Step 7 의 schemaSize() 엔드포인트와 같은 패턴.
@GetMapping(value = "/api/structured/schema-size-extended", produces = MediaType.TEXT_PLAIN_VALUE)
public String schemaSizeExtended() {
StringBuilder sb = new StringBuilder();
appendSchemaInfo(sb, "Step 1 (2 fields)", SchemaStep1.class);
appendSchemaInfo(sb, "Step 2 (4 fields)", SchemaStep2.class);
appendSchemaInfo(sb, "Step 3 (8 fields)", SchemaStep3.class);
appendSchemaInfo(sb, "Step 4 (16 fields)", SchemaStep4.class);
appendSchemaInfo(sb, "Step 5 (32 fields)", SchemaStep5.class);
return sb.toString();
}
appendSchemaInfo 헬퍼는 본문에 박힌 그대로 재사용.
Step 3. 측정 결과 표
curl http://localhost:8080/api/structured/schema-size-extended 를 호출해 결과를 받고, 표로 정리합니다. (실제 측정값은 모델/Spring AI 버전에 따라 약간 다를 수 있으니 본인 환경의 실측치를 적으세요.)
다음은 예시 측정값 (참고용 — 본인 측정과 약간 다를 수 있어요).
| record | 필드 수 | bytes | est. tokens (bytes/4) | Mini 대비 |
|---|---|---|---|---|
| Step 1 | 2 | 236 | 59 | 1.0× |
| Step 2 | 4 | 410 | 103 | 1.7× |
| Step 3 | 8 | 690 | 173 | 2.9× |
| Step 4 | 16 | 1,520 | 380 | 6.4× |
| Step 5 | 32 | 2,950 | 738 | 12.5× |
⚠️ 위 숫자는 예시 — 실제 측정 결과는 본인이 정의한 record 의 필드 이름 길이, enum 값 개수, nested record 구조에 따라 달라집니다. 표 칸은 본인 측정값으로 채우세요.
🙋 날카로운 질문 타임 — 학생 질문
"Step 4 → Step 5 가 약 2 배 늘어나는데, 필드 개수도 16 → 32 로 2 배예요. 선형 비례라는 뜻인가요?"
표면적으로는 그래 보이지만 실제론 요소별로 증가율이 달라요.
| 추가 요소 타입 | schema 추가 비용 (대략) |
|---|---|
primitive 필드 (String, int) |
1 필드당 ~50 bytes |
List<String> |
1 필드당 ~80 bytes |
| nested record (Quote 같은 단순 구조) | 1 필드당 ~150 bytes |
Map<String, V> |
1 필드당 ~50 bytes (properties 가 비어 있어서 의외로 작음) |
| enum (값 5 개 기준) | 1 필드당 ~120 bytes |
즉 필드 개수보다 "어떤 종류의 필드인가" 가 schema 크기를 더 많이 좌우 합니다. nested record 와 enum 이 가장 비싸요. 본인 record 디자인 시 "이 필드를 nested record 로 표현할 가치가 있나, 아니면 평면 필드로 펼치는 게 효율적인가" 를 한 번 의심하는 습관이 듭니다.
Step 4. (선택) 실제 LLM latency 측정
5 단계 record 각각으로 동일한 사용자 메시지를 LLM 한테 보내고 응답 시간을 측정합니다. 무료 Gemini Flash 한 모델만 쓰세요 (CLAUDE.md 비용 정책).
측정 코드 예시:
@GetMapping(value = "/api/structured/schema-latency", produces = MediaType.TEXT_PLAIN_VALUE)
public String measureLatency() {
StringBuilder sb = new StringBuilder();
measureOne(sb, "Step 1", SchemaStep1.class);
measureOne(sb, "Step 2", SchemaStep2.class);
// ... Step 3, 4, 5
return sb.toString();
}
private <T> void measureOne(StringBuilder sb, String label, Class<T> clazz) {
int trials = 3;
long total = 0;
for (int i = 0; i < trials; i++) {
long start = System.currentTimeMillis();
chatClient.prompt()
.user("'용기' 에 관한 명언을 한 줄 알려줘.")
.call()
.entity(clazz);
total += (System.currentTimeMillis() - start);
}
sb.append(label).append(" avg latency: ").append(total / trials).append("ms\n");
}
예시 측정 결과 (본인 환경에 따라 다름):
| record | 평균 latency |
|---|---|
| Step 1 (2 fields) | 850 ms |
| Step 2 (4 fields) | 920 ms |
| Step 3 (8 fields) | 1,150 ms |
| Step 4 (16 fields) | 1,580 ms |
| Step 5 (32 fields) | 2,200 ms |
Step 1 → Step 5 가 약 2.6 배 — schema bytes 증가 (12.5 배) 보단 적게 늘었지만, 응답 토큰 생성 시간이 누적적으로 추가됐기 때문이에요.
Step 5. 결론 — "내 도메인의 손익분기점" 적기
표 아래에 본인 결론을 1~2 문장으로 적으세요. 예시:
"Step 3 (8 필드) 까지는 latency 가 1.5 초 이내라 미연시 게임 한 턴 응답 UX 로 받아들일 만하다. Step 4 (16 필드) 부터 1.5 초를 넘기 시작하므로 우리 도메인의 손익분기점은 8~12 필드 사이로 잡고, 그 이상의 풍부함이 필요하면 호출을 두 개로 쪼개거나 lazy 필드를 분리하는 설계를 우선 검토한다."
이런 식으로 숫자에 기반한 의식적 결정 이 운영 의사결정의 무게를 만들어요.
💡 채점 포인트 요약
| # | 포인트 | 만점 기준 |
|---|---|---|
| 1 | 5 단계 record 디자인 | 2/4/8/16/32 필드, List·Map·nested·enum 골고루 포함 |
| 2 | 측정 엔드포인트 | text/plain 으로 5 단계 모두 한 화면에 출력 |
| 3 | 측정 결과 표 | bytes / est. tokens / Mini 대비 모두 채워짐 |
| 4 | (선택) latency 측정 | Gemini 2.5 Flash 무료 티어만 사용, 각 단계 3 회 평균 |
| 5 | 결론 문장 | "손익분기점은 X 필드, 이유는 Y" 형태로 숫자에 기반 |
5 번이 가장 빠지기 쉬운 포인트 — 숫자만 모으고 결론을 안 적으면 측정의 의미가 절반입니다.
🚀 실무 개선 포인트 (심화)
(1) 응답 토큰까지 측정
본 과제에선 입력 schema 토큰만 측정했지만 실제 비용은 입력 + 출력 토큰 합. 토크나이저 라이브러리 (예: jtokkit) 를 써서 응답 토큰도 측정하면 더 정직한 비용 그림이 그려져요.
(2) Prompt Caching 효과 측정
Step 7 의 날카로운 질문에서 언급한 prompt caching 을 실제로 켜보고 같은 측정을 다시 해보세요. Anthropic Claude 의 prompt_caching 또는 OpenAI 의 자동 캐시가 schema 비용을 얼마나 깎는지 직접 숫자로 확인할 수 있어요.
(3) 정적 vs 동적 schema 의 캐시 친화성
prompt caching 은 schema 가 호출 간 동일 해야 hit. 우리 기본 설계처럼 record 한 종류만 쓰면 자연스럽게 cache hit 가 되지만, 사용자별로 schema 가 바뀌는 dynamic 설계는 cache miss 가 누적돼요. 이 감각도 측정으로 잡을 수 있어요.
🤔 [생각해볼 거리] 면접관을 사로잡는 LLM 응답 설계의 깊이
혼자 고민해도 좋고, 스터디 팀원들과 치열하게 논쟁해 보셔도 좋습니다.
모두 현업 기술 면접에서 "이 지원자, 단순 LLM 호출이 아니라 응답 설계의 트레이드오프를 깊게 고민해봤구나?" 하고 면접관을 감탄하게 만드는 단골 질문들입니다.
🚨 주제 1. JSON Schema 강제력의 한계 — 우리는 어디까지 모델을 신뢰할 것인가?
🔑 [문제 상황 요약]
오늘 Step 3 에서 본 BeanOutputConverter 는 schema 텍스트를 프롬프트에 자연어 instruction 으로 주입해요. 이건 자연어 부탁이지 하드 강제가 아니에요. 모델이 마음 먹으면 깨질 수 있는 약속입니다.
업계엔 더 강한 강제 옵션도 있어요.
- OpenAI
response_format: { type: "json_schema", strict: true }— 토큰 생성 단계에서 schema 적합성을 강제. 응답이 schema 를 100% 만족 - Gemini
responseMimeType: application/json+responseSchema— 비슷한 강도의 모델 측 강제
직관적으론 "강한 강제가 항상 좋은 거 아닌가?" 같지만 실제론 트레이드오프가 있어요.
💡 [튜터의 가이드 및 해설]
이 문제는 응답 정확성 보장 ↔ 모델 표현력 ↔ 프로바이더 lock-in 의 3 축 균형을 묻는 질문입니다.
1. 강한 강제 (네이티브 JSON 모드) 의 장단
| 측면 | 평가 |
|---|---|
| 형식 보장 | ✅ 100% |
| 응답 표현력 | ⚠️ 미세 저하 — 모델이 "schema 채우기 모드" 로 들어가면서 자유로운 톤이 줄어든다는 보고들이 있음 |
| 프로바이더 lock-in | ❌ — OpenAI / Gemini / Anthropic 모두 형식이 다르고 strict 의 의미도 미묘하게 다름. Day 2 ChatModel 추상화가 깨짐 |
| 빠른 프로바이더 전환 | ❌ — 코드 한 군데가 아니라 application.yml + 컨트롤러 양쪽을 같이 손봐야 |
2. 약한 강제 (BeanOutputConverter 자동 instruction) 의 장단
| 측면 | 평가 |
|---|---|
| 형식 보장 | ⚠️ 95~99% — 0~5% 깨짐을 retry/fallback 으로 흡수해야 |
| 응답 표현력 | ✅ 모델이 자연스러운 톤 유지 |
| 프로바이더 lock-in | ✅ Spring AI 의 .entity() 한 줄로 모든 프로바이더 동작 |
| 빠른 프로바이더 전환 | ✅ application.yml spring.ai.model.chat 한 줄 |
3. 도메인별 권장 — "정확성 위험 비용" 으로 결정
정확성 깨짐 → 사용자 피해가 큰가?
↓
┌───┴────┐
YES NO
│ │
강한 강제 약한 강제
+ retry + retry → fallback
+ strict + 자연스러운 톤 우선
| 도메인 | 권장 |
|---|---|
| 결제·인증·법률·의료 응답 | 강한 강제 + lock-in 감수 |
| 일반 챗봇·요약·번역 | 약한 강제 + retry → fallback |
| ai-friends 같은 캐주얼 게임 | 약한 강제 — 표현력 우선, 깨짐 비용 작음 |
| 프로토타입·실험 단계 | 약한 강제 — 프로바이더 자유도 우선 |
🎯 면접관을 홀리는 핵심 멘트
"JSON 응답의 강제 강도는 '형식 보장 ↔ 모델 표현력 ↔ 프로바이더 lock-in' 의 3 축 트레이드오프입니다. 결제·인증처럼 정확성 깨짐의 비용이 큰 도메인은 OpenAI strict 같은 네이티브 JSON 모드를 받아들이고 lock-in 까지 감수하는 게 옳습니다. 반면 캐주얼 챗봇이나 요약 같은 도메인은 Spring AI 의
BeanOutputConverter+ retry → fallback 조합이 더 적합합니다 — 깨짐 0~5% 를 retry 로 흡수하고, 그 대가로 모델의 표현력과 프로바이더 자유도를 모두 가져갈 수 있기 때문입니다. 핵심 판단 기준은 '정확성 깨짐이 사용자한테 얼마나 큰 피해를 주는가' 입니다."
🚨 주제 2. Fallback 응답의 운영 정직성 — 사용자에게 "이건 fallback" 임을 알릴 의무가 있나?
🔑 [문제 상황 요약]
Step 6 의 recover-retry 시연에서 우리는 fallback Quote 의 author 를 "ai-friends" 로 박아둬서 운영팀이 식별할 수 있게 했어요. 그런데 사용자한테는 이 응답이 fallback 이라는 사실을 어떻게 알릴지 (또는 알리지 않을지) 가 별개의 문제예요.
세 가지 옵션이 있어요.
- (A) 위장 fallback — 사용자는 일반 응답과 구분 못 함. UX 매끄러움
- (B) 솔직한 fallback — 응답에 명시 표지 (
"isFallback": true) 박힘. 사용자 즉시 인지 - (C) 부분 위장 — 사용자에겐 자연스럽게, 응답 메타데이터에만 fallback flag
💡 [튜터의 가이드 및 해설]
이 문제는 사용자 경험 ↔ 시스템 신뢰성 ↔ 윤리적 정직성 의 균형을 묻는 질문입니다.
1. 위장 fallback 의 위험 — 신뢰의 누적 침식
위장 fallback 은 단기 UX 엔 좋아 보이지만 장기적으론 위험해요.
1 회: "AI 가 답을 줬구나" (사용자 만족)
10 회: 가끔 답이 좀 이상하지만 그러려니
100 회: 이 서비스 AI 답변이 좀 거칠다는 평이 누적
1000 회: "이거 진짜 AI 가 답하는 거 맞아?" 의심 누적
→ 사용자 신뢰 침식, 발견 시 브랜드 타격
특히 fallback 이 자주 발동하는 시스템 (예: 모델 품질 저하기) 에선 위장이 유지될 수 없어요.
2. 솔직한 fallback 의 비용 — 흐름 단절
사용자: "요약해줘"
시스템: "지금 답변을 만들 수 없어요. 잠시 후 다시 시도해주세요."
사용자: 흐름 끊김 → 다른 도구 (ChatGPT 직접) 로 이탈 가능성
너무 정직하면 사용자가 우리 서비스를 우회해버려요.
3. 부분 위장 — 가장 현실적
대부분의 운영 시스템은 (C) 를 택해요.
| 표면 (사용자 보는 것) | 자연스러운 응답 |
|---|---|
| 메타데이터 (응답 헤더 / 로깅) | X-AI-Fallback: true 또는 data.metadata.source: "fallback" |
이러면 사용자는 흐름이 끊기지 않고, 운영팀은 메트릭으로 fallback 비율을 추적할 수 있어요. 다만 사용자 신고 발생 시 (예: "어제 받은 답이 이상해요") 메타데이터로 식별 가능해야 해요. 이게 핵심 — 위장의 흔적을 운영 측에는 남겨두는 정직.
4. 도메인별 정직성 기준
| 도메인 | 권장 정직성 수준 |
|---|---|
| 의료·법률·금융 응답 | 완전 정직 (B) — 잘못된 답이 사용자 피해 큼 |
| 검색·추천 결과 | 부분 위장 (C) — 결과 품질 저하 시 메트릭만 |
| 캐주얼 챗봇·게임 | 부분 위장 (C) — 사용자 흐름 우선 |
| 자동화 에이전트 (사용자 부재) | 완전 정직 (B) — 다음 단계 자동화 시스템한테 정확한 신호 필요 |
🎯 면접관을 홀리는 핵심 멘트
"Fallback 응답의 정직성은 '사용자 경험 ↔ 시스템 신뢰성 ↔ 윤리적 정직성' 의 균형 문제입니다. 의료·법률·금융처럼 잘못된 응답이 사용자에게 피해를 주는 도메인은 명시적 정직성 (
isFallback: true) 이 필수입니다. 반면 캐주얼 챗봇이나 게임은 사용자 흐름을 끊지 않는 부분 위장 — 표면에는 자연스럽게, 응답 메타데이터에만 fallback flag — 가 가장 실용적입니다. 핵심은 위장하더라도 운영 측 메트릭과 디버깅에는 흔적이 남아야 한다 는 점입니다. 사용자 신고가 들어왔을 때 우리가 그 응답이 fallback 이었는지 식별할 수 있어야 해요. 이게 위장과 거짓의 차이입니다."
🚨 주제 3. 모델 자율성 ↔ schema 강제 균형 — 우리 도메인의 기준선은?
🔑 [문제 상황 요약]
Step 7 에서 봤듯이 record 가 풍부할수록 schema 가 부풀어 오르고 모델 attention 이 분산돼요. 반대로 record 가 빈약하면 응답이 뭉뚱그려져서 후속 처리가 어려워요. 이건 단순 비용 문제를 넘어 "모델한테 무엇을 결정하게 둘 것인가" 의 자율성 문제로 이어집니다.
세 가지 자율성 모드가 있어요.
- schema 가 모든 필드를 강제 — 모델은 빈 칸을 채우는 작업자. 자율성 0%
- schema 를 핵심 1~2 필드로만 좁힘 — 모델한테 "어떤 정보를 응답에 담을지" 결정권 일부 위임. 자율성 ↑
- 시나리오별 분기 — 정형 시나리오는 강한 schema, 자유 대화는 약한 schema
💡 [튜터의 가이드 및 해설]
이 문제는 응답 일관성 ↔ 모델 창의성 ↔ 시스템 복잡성 의 균형을 묻는 질문입니다.
1. 자율성 0% (강한 schema) 의 함정 — 응답이 무뚝뚝해진다
// 11 필드 record 강제 → 모델 attention 분산
record FullGameTurn(
String dialogue, List<String> choices, int affection,
String emotion, String bgm, String pose, ...
) { }
모델이 11 칸을 다 채우려고 노력하다 보면 핵심 (dialogue) 의 표현력이 미세 저하돼요. 캐릭터가 "웃으며 말한다" 정도의 풍성한 묘사 대신 "안녕" 같은 짧은 답이 나오는 빈도가 늘어요.
2. 자율성 100% (schema 없음) 의 함정 — 후속 처리 불가
// 그냥 String 으로 받음
String reply = chatClient.prompt()...call().content();
모델이 자유롭게 답하는 건 좋은데, 그 답을 우리 코드가 어떻게 파싱할지가 막혀요. 호감도 변화량을 추출하려면 결국 정규식이나 또 다른 LLM 호출이 필요해져요. 자율성을 너무 주면 우리 시스템 비용이 폭증 합니다.
3. 시나리오별 분기 — 가장 정교한 답
// 정형 시나리오 (호감도 정산) — 강한 schema
record AffectionScore(int delta, String reason) { }
// 자유 대화 (한 턴) — 약한 schema (핵심만)
record DialogueReply(String message) { }
ai-friends 의 시나리오를 두 갈래로 나눠 각각 다른 record 를 쓰는 거예요. 정형 시나리오는 정확성 우선, 자유 대화는 표현력 우선.
4. 도메인별 자율성 기준선 가이드
| 도메인 | 권장 자율성 |
|---|---|
| 트랜잭션 (결제·주문) | 0~10% — 모든 필드 강하게 강제 |
| 분석·요약 | 30~50% — 핵심 필드는 강제, 부가 정보는 자유 |
| ai-friends 캐주얼 대화 | 50~70% — aiMessage + affectionDelta 정도만 강제 |
| 창작 보조 (소설·시) | 70~90% — 형식보다 표현력 우선 |
| 자유 채팅 | 90~100% — schema 거의 없음 |
5. "의식적으로 결정한 자율성 기준선" 의 운영 가치
이 결정을 명시적으로 적어둔 시스템은 그렇지 않은 시스템보다 운영 안정성이 항상 더 높아요.
- PR 리뷰 시 기준점 — "이 record 에 필드를 추가하면 우리 자율성 기준선을 넘어요" 같은 토론 가능
- 모델 변경 시 마이그레이션 가이드 — 다른 모델로 바꿀 때 "이 자율성 수준에서도 이 모델이 잘 작동하나?" 를 검증하는 기준
- 신규 팀원 온보딩 — "우리 시스템은 X% 자율성 기준선" 한 줄로 설계 철학 전달
🎯 면접관을 홀리는 핵심 멘트
"LLM 시스템 설계의 본질적 결정은 '모델한테 자율성을 얼마나 줄 것인가' 입니다. 자율성 0% (모든 필드 강제) 는 응답 일관성을 보장하지만 모델 표현력이 저하되고, 자율성 100% (schema 없음) 는 표현력은 살지만 후속 처리 비용이 폭증합니다. 가장 정교한 답은 시나리오별 분기 — 정형 시나리오 (호감도 정산, 결제 의사결정 등) 는 강한 schema, 자유 대화는 약한 schema 로 두 record 를 별도 운영하는 것입니다. ai-friends 같은 캐주얼 대화는 자율성 50~70% 가 적정선이라고 판단합니다 —
aiMessage와affectionDelta정도만 강하게 강제하고, 나머지는 모델한테 결정권을 넘기는 방식입니다. 이 기준선을 명시적으로 정해서 팀에 공유하면 PR 리뷰·모델 변경·신규 팀원 온보딩이 모두 일관되게 흘러갑니다."