Day 13. Workflow 3 패턴 — Prompt Chaining / Routing / Parallelization 의 3 패턴
안녕하세요! 여러분의 백엔드 가이드, 홍순구 튜터입니다.
지난 시간, 정말 무거운 머리로 닫으셨어요.
정리해둔 한 줄을 다시 들고 올게요.
에이전트 = 도구 + LLM 자율 + 루프 + 가드레일. 4 박자가 모두 갖춰진 상태.
그리고 그 4 박자가 0 ~ 4 개 의 농도로 흐르는 Workflow ↔ Agent 스펙트럼 막대 위에서, 우리는 Day 11 까지 익혀둔 도구 3 종 (WeatherTool / GameStateTool / AffinityTool) 을 분류대 위에 올려두는 호흡까지 갔죠.
그리고.
지난 시간의 마지막 한 줄이 이렇게 정리됐어요.
"오늘 정리한 정의 — 4 박자 / 스펙트럼 / Harness 5 요소 / 5 패턴 이름표. 다음 시간엔 — 왼쪽 3 패턴 (Workflow 3 패턴) 이 직접 손코딩으로 익혀지는 시간. 이름표 → 실제 코드 — 그게 다음 호흡."
오늘이 그 약속의 시간이에요.
지난 시간 머리에만 들어 있던 Prompt Chaining / Routing / Parallelization.
이름표 세 장을 오늘은 직접 손으로 짭니다. 그리고 한 줄 더.
외부 그래프 DSL 을 한 줄도 끌어오지 않아요. LangGraph 의 노드·엣지 DSL / Alibaba Graph 의 그래프 빌더 / OpenAI Agents SDK 의 추상화.
모두 본 강의가 채택하지 않은 길이에요.
Spring AI 1.1.x 의 ChatClient.Builder 로 전문 클라이언트를 N 개 만들고 / 호출 사이는 평범한 Java (직렬 호출 / switch 분기 / CompletableFuture.allOf) 로 잇는 방식.
3 패턴이 사실은. 평범한 Java + ChatClient 의 조합이라는 사실. 이게 오늘 익혀둘 가장 큰 한 줄입니다.
자, 결론부터 짚고 시작할게요.
오늘은. 지난 시간과 정반대로 코드가 무거운 Day 예요. 지난 시간은 머리 100, 손 0 이었다면, 오늘은 머리 30, 손 70.
판단 근육을 머리에 정리해둔 채 / 그 판단이 실제 코드로 어떻게 떨어지는지 를 직접 짜보는 시간이에요.
그리고 한 가지 더.
지난 시간에 만든 도구 3 종 (WeatherTool / GameStateTool / AffinityTool) 이 오늘 직접 패턴 노드로 흡수되는 자리 도 등장해요.
Routing 의 한 갈래에서는 AffinityTool 이 / 다른 한 갈래에서는 WeatherTool 이.
지난 시간 분류대 위에 올려둔 도구가 진짜 패턴 노드 안으로 들어가는 모습 을 손으로 직접 봅니다.
Day 11 의 도구들이 진짜 자율 호흡 (Day 14) 으로 자라기 직전의 모습이 오늘이에요.
오늘 마지막에 익힐 본 강의의 Workflow 3 패턴 정의 를 미리 던져둘게요.
Workflow 3 패턴 = 전문 ChatClient N 개 + 평범한 Java orchestrate + 공통 어드바이저 한 개. 세 부품을 직접 짜봅니다.
전문 ChatClient 빈을 N 개 만드는 부분 (지난 시간 weatherToolChatClient / gameStateChatClient / affinityChatClient 3 개에서.
오늘 11 개로 자라요), 호출 흐름을 평범한 Java 로 잇는 부분 (직렬 / 분기 / 병렬.
Java 의 가장 평범한 3 가지 흐름), 공통 어드바이저 하나가 11 개 빈 위에 통일된 로그를 떨어뜨리는 모습.
이 세 부품이 갖춰지고 나면
다음 시간 (Day 14) 의 Agent 2 패턴 (Orchestrator-Workers / Evaluator-Optimizer) 도 같은 부품 위에 한 단계만 더 얹히면 됩니다.
오늘의 척추가 되는 한 편의 글이 여전히 지난 시간과 같은 한 편 이에요.
*Anthropic 의 Building Effective Agents.
지난 1 ~ 2 년 사이 업계 표준이 된 이 글이 정리한 5 패턴 중.
왼쪽 3 패턴 (Workflow 영역) 이 오늘의 주제예요.
글의 원문이 보여주는 다이어그램 모양 그대로, 왼쪽 3 패턴부터 시작해서 / 오른쪽 2 패턴은 다음 시간 (Day 14).
이 호흡으로 흘러요.
그리고 2025 ~ 2026 년 사이 의 OpenAI Agents SDK / Google ADK / LangGraph 도 같은 3 패턴을 다른 DSL 로 풀고 있다는 사실 을 알아두면, 외부 DSL 의 이름이 바뀌어도 분류 기준은 같다 는 한 줄이 정리돼요.
본 강의가 외부 DSL 없이 가는 이유도 그래서.
🎯 ChatClient — 오늘 전문 클라이언트가 N 개로 자라요
지난 시간의 🎯 ChatClient → Agent — 조용히 한 단계 더 들어와요 의 거울이에요. 오늘 — ChatClient 가 전문 N 개 로 자라요.
ChatClient3 개 (Day 11) →ChatClient11 개 (Day 13) — 같은ChatClient.Builder위에서 역할별 전문 클라이언트 가 생기는 모양. 안전 분류 전용 / 답장 초안 전용 / 페르소나 톤 검수 전용 / 라우터 전용 / FAQ 핸들러 / 호감도 핸들러 / 안전 알림 핸들러 / 일상 잡담 핸들러 / 감정 분석 전용 / 의도 추출 전용 / 페르소나 매칭 전용 — 각 빈마다 system 프롬프트와 모델 옵션이 한곳에 모인 형태. 그리고 그 11 개 빈이 모두 공통 어드바이저 하나 를 들고 있는 모습. Day 11 의 ChatClient 가 도구를 든 모양이었다면 — Day 13 의 ChatClient 는 역할을 든 모양. 그리고 그 역할 위에 어드바이저 라는 단어가 오늘 처음 코드로 등장해요.
오늘은 한 사이클 자율 호출 (Day 11) 도 아니고 루프가 도는 자율 호흡 (다음 시간 Day 14) 도 아니에요. 결정론적인 코드의 흐름 위에 / LLM 호출이 단계별로 들어오는 진행 방식입니다. 주도권은 코드 손에 있어요 — 지난 시간 Step 2 에서 짚은 Workflow 의 정의 그대로.
자, 손 모드로 들어갑시다. 오늘 마지막엔 — 3 패턴이 평범한 Java + ChatClient 의 조합 이라는 한 줄이 손에 익혀져 있을 거예요. 그 지점에서 다음 시간의 Agent 2 패턴이 같은 부품 위에 한 단계만 더 얹히는 모양 도 자연스럽게 보입니다. 시작합시다!
Step 1. Workflow 3 패턴 — 위치 잡기
지난 시간 정리해둔 Workflow ↔ Agent 스펙트럼 위에서, 오늘은 왼쪽 3 패턴 을 손코딩으로 짜볼 거예요. 그 전에 — 이 3 패턴이 정확히 어떤 결인지 / 우리 ai-friends 미연시 게임 도메인의 어느 시나리오와 맞물리는지 / 그리고 진짜로 외부 그래프 DSL 없이 다 구현되는지 — 한 번 더 정리하고 손으로 들어갑니다. 이 Step 은 코드 변경이 0 이에요. 머리만 한 번 다시 정돈하고 가는 단계.
먼저 한 줄 결론.
오늘 짤 3 패턴은. 코드의 흐름을 누가 결정하느냐 라는 한 축의 농도 차이입니다. Prompt Chaining 은 코드가 미리 정한 직렬 / Routing 은 코드가 미리 정한 분기 / Parallelization 은 코드가 미리 정한 병렬.
셋 다 코드 손에 주도권이 있는 것.
지난 시간의 Workflow 정의 그대로예요.
그래서 외부 그래프 DSL 없이도 다 됩니다.
코드의 흐름을 정의하는 일은. 평범한 Java 가 이미 잘하니까.
지난 시간 스펙트럼 위에서 — 오늘 짤 위치 한 번 더
지난 시간 짚은 스펙트럼 막대를 다시 떠올려 볼까요.
막대 왼쪽 끝은 완전 결정론적 코드 — LLM 0 박자. 오른쪽 끝은 완전 자율 Agent — LLM 4 박자. 그 사이에 5 패턴이 농도 순으로 정리돼 있었죠.
Prompt Chaining → Routing → Parallelization → Orchestrator-Workers → Evaluator-Optimizer. 오늘 짤 범위는 — 왼쪽 3 패턴.
이 3 패턴엔 공통점 한 가지 가 있어요. 호출 흐름 (다음에 어느 ChatClient 를 부를지) 을 — 코드가 미리 정해 둔다. 한 번 풀어볼게요.
- Prompt Chaining — 코드가 미리 정해둔 직렬.
clientA다음엔clientB그 다음엔clientC. 흐름이 if 분기 한 줄 없이 / 그냥 일렬로 흘러요. LLM 의 결정 박자는 — 각 ChatClient 내부의 응답 내용에만 있고, 다음 단계로 갈지 말지의 결정 은 코드 손에 있어요. - Routing — 코드가 미리 정해둔 분기. 첫 ChatClient (
messageRouter) 가 입력을 라벨링하면 — 그 라벨에 따라switch/if-else의 평범한 Java 분기 로 다음 ChatClient 를 고르는 형태. LLM 의 결정 박자는 — 라벨링까지만. 다음 ChatClient 의 선택은 코드 손. - Parallelization — 코드가 미리 정해둔 병렬. 3 개의 ChatClient 를 동시에 호출 (
CompletableFuture.allOf) 하고, 셋의 결과를 한곳으로 합쳐서 반환. LLM 의 결정 박자는 — 각 ChatClient 의 응답 내용에만. 어느 것을 동시에 부를지는 코드 손.
세 패턴 모두 LLM 의 자율 박자가. 각 ChatClient 내부의 응답에만 한정 돼요.
다음 단계로의 흐름을 LLM 이 결정하지 않는다 는 점이.
다음 시간의 Agent 패턴 (Orchestrator-Workers / Evaluator-Optimizer) 과 결정적으로 갈리는 지점이에요.
Agent 쪽은 다음 단계까지 LLM 이 정하는 구조.
그래서 루프 박자가 진짜로 등장.
오늘은 그 직전의 모습, 즉 루프가 등장하기 직전의 결정론적인 3 패턴 까지만 손에 익힙니다.
Anthropic 글의 3 패턴 — 한 줄 정의 표
지난 시간에 척추로 짚었던 글 — Building Effective Agents (Anthropic, 2024 년 12 월) — 의 원문을 옆에 펼쳐두고 다시 정리해볼게요. 글이 정리한 Workflow 3 패턴의 정의를 한 줄 + 적합한 사례 + 손맛 의 3 칸 표로 압축하면 이래요.
| 패턴 | Anthropic 한 줄 정의 | 적합한 사례 | 손에 잡히는 감각 |
|---|---|---|---|
| Prompt Chaining | 한 작업을 여러 LLM 호출의 연속 단계로 분해 — 각 단계의 출력이 다음 단계의 입력 | 마케팅 카피 생성 → 다국어 번역, 문서 요약 → 핵심 추출 → 톤 검수 | 한 호흡으로 끝내려던 작업을 / 작은 호흡으로 나눠 정확도를 올리는 방식 |
| Routing | 입력을 분류해 / 분류 결과에 맞는 전문 LLM 으로 위임 | 고객 문의 (환불 / 기술지원 / 일반) 분기, 모델 비용 최적화 (쉬운 질문 → 작은 모델 / 어려운 질문 → 큰 모델) | 한 LLM 이 만능을 시도하던 자리에서 → 전문 LLM N 개로 분산하는 흐름 |
| Parallelization | 독립적인 여러 작업을 동시에 호출 → 결과 통합 | 한 입력을 여러 각도로 동시 분석 (감성 / 키워드 / 카테고리), 보안 검수의 다중 시각 (PII / 욕설 / 톤) | 지연을 가장 직접적으로 단축 하는 흐름 / 서로 안 엮인 작업은 / 동시에 굴리자 |
이 표가 오늘 익힐 3 패턴의 명함 카드 예요.
각 카드의 한 줄 정의 는 Step 2 ~ 6 의 손코딩 전에 한 번씩 더 등장할 거고, 적합한 사례 는 우리 ai-friends 도메인으로 옮겨서 다음 절에서 매핑합니다.
손에 잡히는 감각 은 — 코드를 직접 짜본 뒤에야 진짜로 들어오는 부분이라, Step 끝에서 한 번씩 회수합니다.
우리 ai-friends 미연시 게임의 3 시나리오 매핑 🎯
자, Anthropic 글의 적합한 사례 는 일반적인 LLM 사용 사례였어요. 이걸 우리 ai-friends 미연시 게임 도메인으로 옮겨보면 — 오늘 익힐 3 시나리오는 이렇게 매핑됩니다.
세 시나리오의 모양을 한 줄씩 풀어두면 —
(A) 사용자 메시지 → 캐릭터 답장 자동화. Prompt Chaining.
미연시 게임의 가장 흔한 장면.
사용자가 캐릭터에게 메시지를 던지면 캐릭터가 자연스럽게 답장을 보내야 하죠.
단.
한 호흡의 LLM 호출 로 "이 메시지에 답해줘" 던지면 두 가지 문제가 생겨요.
(1) 부적절한 발화 (욕설 / NSFW 시도 / 운영 알림이 필요한 PII 노출) 가 그대로 캐릭터 답장으로 흘러가버리고, (2) 답장의 톤이 들쭉날쭉.
어떤 답은 차분한데 어떤 답은 과장된 리액션, 어떤 답은 갑자기 존댓말.
그래서 3 단계로 분해.
(1) 안전 분류 (NORMAL / ABUSE / ESCALATE) → (2) NORMAL 인 경우에만 답장 초안 → (3) 페르소나 톤 검수.
각 단계는 작은 호흡 이라 LLM 의 정확도가 올라가고, 마지막 톤 검수 단계 가 최종 톤 일관성 을 보장해요.
한 호흡의 정확도 < 작은 호흡 3 개의 정확도.
실무 가치. 캐릭터 답장의 안전 + 톤 일관성.
(B) 메시지 라우팅. Routing.
사용자가 캐릭터에게 보내는 메시지의 색깔이 다 달라요.
어떤 메시지는 게임 시스템 / 도움말 질문 (예: "호감도 어떻게 올라가?"), 어떤 메시지는 호감도 · 관계 질문 (예: "지금 우리 사이 어때?"), 어떤 메시지는 PII 노출이나 자해 암시 등 운영 알림이 필요한 발화, 어떤 메시지는 일상 잡담 (예: "오늘 날씨 어때?").
이 네 종류가 한 채팅창에 섞여 들어와요.
한 LLM 이 모든 종류를 다 잘 처리 하기는 어렵습니다.
그래서 분류 LLM 한 개가 먼저 4 분류 라벨 을 붙이고, 4 분류 각각에 전문 LLM 이 붙어서 해당 종류에 맞춰 응답.
분류 LLM 의 system 프롬프트는 분류만 잘하면 되니까 짧고, 전문 LLM 들은 각자의 분야만 잘하면 되니까 system 프롬프트가 해당 분야에 깊어요.
전문가 4 명이 분업하는 형태.
그리고 한 가지 더.
AFFINITY 갈래엔 지난 시간에 만든 AffinityTool 이, CASUAL 갈래엔 WeatherTool 이 전문 ChatClient 의 도구로 자연스럽게 흡수 돼요.
지난 시간 분류대 위에 올려둔 도구가.
오늘 Routing 의 노드 안 으로 들어옵니다.
실무 가치. 응답 품질 + 비용 분산 + 도구의 자연스러운 합류.
(C) 메시지 다각도 분석. Parallelization.
미연시 게임의 운영 대시보드에서 흔히 만나는 장면이에요.
한 메시지를 3 각도로 동시에 분석.
- (1) 감정 분석 —
POSITIVE/NEUTRAL/HOSTILE+ 0~100 강도. - (2) 의도 추출 —
QUESTION/DATE_REQUEST/JOKE/CONFESSION/OTHER. - (3) 페르소나 매칭 점수 — 이 메시지에 캐릭터의 따뜻한 친구 페르소나가 자연스럽게 잡히는지 0~100 점.
셋이 서로 독립 이에요.
감정 분석의 결과가 의도 추출에 영향을 주지 않고 / 의도 추출의 결과가 페르소나 매칭에 영향을 주지 않아요.
그러면
셋을 일렬로 부르면 지연이 3 배인데 / 동시에 부르면 지연이 1 배 가까이 줄어들어요.
서로 안 엮인 작업은 동시에 굴린다 의 가장 깔끔한 사례.
그리고 미연시 게임에선
감정 + 의도 이 두 결과가 호감도 변화 추천 의 입력이 될 수도 있어요 (예: POSITIVE + CONFESSION 이면 호감도 +5).
실무 가치. 지연 단축 (3 배 → 1 배 + α) + 게이미피케이션 신호 다각화.
세 시나리오 모두 우리 ai-friends 미연시 게임의 실제 운영 상황 에서 흔히 떠오를 만한 사례예요. 그리고 셋 다 외부 그래프 DSL 한 줄도 끌어오지 않고 — Spring AI 의 ChatClient.Builder + 평범한 Java 만으로 구현됩니다. 그게 오늘 익힐 모습이에요.
외부 그래프 DSL — 왜 안 끌어오는가
여기까지 오면서 자연스럽게 떠오를 질문 하나 — "진짜로 외부 그래프 DSL 없이 다 돼요? LangGraph 가 이런 거 잘하잖아요?" 그 질문이 자연스러운 지점입니다. 지난 1 ~ 2 년 사이 외부 그래프 DSL 들이 줄지어 등장했거든요.
- LangGraph (LangChain 진영) — Python / TypeScript 기반의 노드·엣지 DSL. State 객체 + 노드 함수 + 조건부 엣지 로 워크플로를 그래프로 정의.
- Alibaba Spring AI Alibaba Graph — Spring AI 진영의 그래프 DSL. LangGraph 의 Java 포팅에 가까운 결.
- OpenAI Agents SDK — 2025 년 OpenAI 가 공식 발표한 에이전트 SDK. 도구 + 핸드오프 + 가드레일을 SDK 안에서 선언적으로.
- Google ADK — Agent Development Kit. 2025 년 발표.
- Microsoft AutoGen — 멀티 에이전트 협업 프레임워크.
이들 프레임워크가 잘하는 일은 분명히 있어요. 복잡한 그래프 (10 ~ 20 개 노드 + 조건부 엣지 N 개) 를 시각화 하거나 멀티 에이전트의 협업 토폴로지를 선언적으로 표현 하는 자리. 그런데 — Workflow 3 패턴 정도의 단순한 흐름 에는 외부 DSL 의 무게가 학습 비용 대비 효용이 작아요.
본 강의가 외부 DSL 을 안 끌어오는 이유 두 가지.
첫 번째. 코드로 직접 익히는 직관.
그래프 DSL 은 마법처럼 느껴져서 직관이 안 잡혀요.
addNode() / addEdge() 한 줄을 쓰면 그 안에서 어떻게 흐름이 이어지는지 가 안 보여요.
왜 이 노드가 다음 노드를 부르는가? 왜 여기서 루프가 멈추는가?.
답이 프레임워크 안에 가려진 채로 진행되면, 학생의 손에는 마법의 흔적 만 남습니다.
그래서 본 강의는 평범한 Java 의 if-else / switch / for / CompletableFuture.allOf / try-catch 같은 손에 익은 도구 로 3 패턴을 짜요.
왜 이 줄에서 분기가 일어나는지 / 왜 이 줄에서 동시 호출이 도는지 / 왜 이 줄에서 종료가 일어나는지.
답이 코드의 한 줄 한 줄에 그대로 보입니다.
두 번째 — Spring AI 1.1.x 의 ChatClient + Advisors 만으로도 충분.
3 패턴 모두 손코딩으로 짤 수 있어요.
외부 DSL 없이도 3 패턴이 다 들어가는데 거기에 외부 DSL 을 얹는 건 직관을 한 단계 더 멀게 만드는 결과.
프레임워크는 손이 익은 자리 위에 짧아지는 모양으로 나중에 들어와야 한다 는 본 강의의 호흡 (Day 13 ~ 14 손코딩 → Day 19 Spring AI Agent Client 선언적) 이 정확히 이 선언의 근거예요.
심화 레퍼런스 한 줄만 짚어두고 갈게요.
본 강의는 이들 중 어느 것도 손대지 않아요.
단 Day 19 에서 Spring AI Agent Client 를 다룰 때 심화 레퍼런스 로 한 줄 "이런 외부 프레임워크들도 있고, 3 패턴은 그 안에서도 똑같이 통한다" 는 비교는 던져 둡니다.
학생이 회사에서 외부 그래프 DSL 을 만나도 오늘 익힌 3 패턴 이 그대로 통해요.
💡 튜터의 결론
오늘 익힐 3 패턴은 — 코드의 흐름을 누가 결정하느냐 라는 한 축의 농도 차이. Prompt Chaining (직렬) / Routing (분기) / Parallelization (병렬) 셋 다 코드 손에 주도권이 있는 결정론적인 패턴이에요. 우리 ai-friends 미연시 게임 도메인으로 옮기면 (A) 메시지 → 캐릭터 답장 자동화 / (B) 메시지 라우팅 / (C) 메시지 다각도 분석 의 세 시나리오로 깔끔하게 떨어지고, 셋 다 Spring AI 의
ChatClient.Builder+ 평범한 Java 만으로 외부 그래프 DSL 한 줄 없이 구현됩니다. 어떤 프레임워크를 쓰든 3 패턴은 그대로 통한다 는 한 줄이 오늘 익혀둘 가장 단단한 자산.
자, 이름표 → 손코딩 의 호흡이 머리에 들어왔으니, Step 2 에서 첫 패턴 (Prompt Chaining) 부터 짜봅시다.
메시지 → 캐릭터 답장 자동화의 3 단 직렬 — PromptChainingService 가 어떻게 안전 분류 → 답장 초안 → 페르소나 톤 검수 의 일렬로 흐르는지, 그리고 그 흐름이 평범한 Java 메서드 호출 로 어떻게 떨어지는지 손에 익혀집니다.
Step 2. Prompt Chaining — **메시지 → 캐릭터 답장** 3 단 직렬 손코딩
첫 번째 패턴인 Prompt Chaining 을 손으로 짜봅시다. 사용자가 캐릭터에게 보낸 메시지 한 줄을 받아서 — 안전 분류 → 답장 초안 → 페르소나 톤 검수 의 세 호흡으로 일렬로 흐르게 만드는 거예요. 한 호흡으로 끝내려던 작업을 작은 호흡 3 개 로 쪼개면 안전성과 톤 일관성이 어떻게 단단해지는지, 그리고 그 흐름이 외부 그래프 DSL 한 줄 없이 — 평범한 Java 메서드 호출 일렬 로 어떻게 떨어지는지 손에 익혀집니다.
왜 분해하나 — 한 호흡 vs 3 호흡의 정확도 차이
먼저 한 호흡으로 풀려고 했을 때 의 모습을 머리에 그려볼까요. 만약 우리가 답장 ChatClient 한 개 에 모든 일을 다 맡긴다면 — system 프롬프트가 이런 식으로 부풀어요.
// ❌ 안 좋은 예 — 한 LLM 이 분류 + 초안 + 검수를 한 번에 한다
@Bean
public ChatClient megaReplyClient(ChatClient.Builder builder) {
return builder
.defaultSystem("""
너는 미연시 게임의 AI 캐릭터야. 사용자 메시지를 받으면
1) 부적절한 발화면 답장을 거절하고
2) 적절한 발화면 캐릭터답게 답장을 만들고
3) 그 답장의 톤이 페르소나에 맞는지 검수해서 다듬어줘.
""")
.build();
}
이 한 호흡 ChatClient 에 어떤 문제가 따라오는지 짚어볼게요.
- 분류가 부정확해요. system 프롬프트가 분류 / 초안 / 검수 세 일을 한꺼번에 들고 있어서, 부적절한 발화 판정 이라는 단일 작업에 집중하지 못해요. 경계 발화 (예: 약간의 비속어가 섞인 농담) 에서 판정이 흔들립니다.
- 톤이 들쭉날쭉해요. 한 호출 안에서 분류 → 초안 → 검수 가 한꺼번에 일어나니까 검수 단계가 제대로 들어가지 않아요. LLM 이 "이 정도면 됐어" 하고 첫 초안을 그대로 흘려보내는 경우가 많아요.
- 부적절한 답장이 새요. 가장 큰 문제. 분류가 흔들리면 부적절한 발화 에 그대로 답장이 생성 되어버립니다. 답장이 부적절한 어조 를 받아치는 결로 흘러나갈 수 있어요. 게임 운영의 신뢰 위험 의 자리.
그래서 우리는 3 단계로 분해 합니다.
각 ChatClient 가 자기 일 하나만 잘 하면 되니까 system 프롬프트가 짧고, 정확도가 안정적이에요. 그리고 2 단의 답장 초안 은 1 단이 NORMAL 라벨을 떨어뜨린 경우에만 호출돼요.
부적절한 발화의 답장이 새는 사고가 흐름 자체에서 차단 됩니다.
3 호흡의 비용은 3 배지만, 단계 하나하나가 단단해진다 — 그게 Prompt Chaining 의 가치예요.
3단의 흐름 한 번 그리기
자, 이제 메시지 한 줄이 응답으로 나오기까지 의 흐름을 머리에 그려볼게요.
흐름을 한 줄씩 풀어두면 —
- 입력 — 메시지 한 줄. 클라이언트가
POST /api/workflow/prompt-chaining으로 사용자 메시지 한 줄을 보내요. - 1 단 안전 분류.
safetyClassifier가 메시지를 받아 3 라벨 (NORMAL/ABUSE/ESCALATE) 중 하나로 라벨링. 산출물은MessageSafetyClassificationrecord. - NORMAL 분기. ABUSE 또는 ESCALATE 라벨이면 답장 생성 자체를 차단 하고 안전한 안내 한 줄로 응답을 채워서 종료. 2 단·3 단은 호출되지 않아요.
- 2 단 답장 초안. NORMAL 라벨이면
replyDrafter가 원본 메시지 를 받아 캐릭터 답장 초안을 작성. 산출물은ReplyDraftrecord. - 3 단 페르소나 톤 검수.
personaToneAuditor가 답장 초안 한 줄 을 받아 페르소나 결 (반말 / 따뜻한 친구 톤) 에 맞는지 검수. 산출물은PersonaToneAuditrecord (tonePassed/finalReply/note). - 응답 —
PromptChainingResponse. 세 단계의 결과를 모두 담아 클라이언트로 돌려보내요. 학습용 데모라 각 단의 산출물이 한 응답에 다 보여지는 모양. 운영에선 보통finalReply만 내려보냅니다.
DTO 부터 한 손에 들기 — 5 record + 1 enum
이제 코드로 들어가요. 먼저 3 단 산출물 의 그릇 5 개부터.
// SafetyLabel.java — 1 단 안전 분류의 라벨 enum
public enum SafetyLabel {
NORMAL,
ABUSE,
ESCALATE
}
닫힌 라벨 집합 으로 받는 이유 한 줄 짚고 갈게요.
LLM 이 환각으로 MAYBE / PARTIAL 같은 새 라벨을 만들어내면 .entity(MessageSafetyClassification.class) 의 Jackson 역직렬화가 그 즉시 예외 로 깨져요.
환각을 런타임 신호로 가두는 자물쇠.
enum 한 줄이 3 단 분기의 안전성을 LLM 이 뚫지 못하게 만들어 줍니다.
다음 — 1 단의 산출물 record.
// MessageSafetyClassification.java — 1 단 산출물
public record MessageSafetyClassification(
SafetyLabel label,
String reason
) { }
label 한 자리에 enum, reason 한 자리에 LLM 이 그렇게 분류한 근거. reason 은 디버그 / 로깅용 이지 분기에 사용되지 않아요. 디버그 시점에 "왜 ABUSE 로 떨어졌지?" 를 손쉽게 추적할 수 있도록 필드 한 자리만 두는 모양.
다음 — 2단의 산출물.
// ReplyDraft.java — 2 단 산출물
public record ReplyDraft(
String draft
) { }
답장 초안 한 줄. 아직 톤 검수를 거치지 않은 "거친" 상태 라 필드 하나만. 다음 단(3 단) 에서 톤 일관성을 자물쇠로 잠그는 형태.
다음 — 3단의 산출물.
// PersonaToneAudit.java — 3 단 산출물
public record PersonaToneAudit(
boolean tonePassed,
String finalReply,
String note
) { }
3단의 산출물은 세 자리. tonePassed 가 true 면 finalReply 는 원본 초안 그대로, false 면 검수자가 다듬은 한 줄. note 는 디버그 / 로깅용 (예: "톤 일관성 OK" / "존댓말 → 반말로 변경").
마지막 — API 입력 / 출력 모델 2 개.
// PromptChainingRequest.java
public record PromptChainingRequest(
@NotBlank(message = "메시지를 입력해 주세요.")
@Size(max = 500, message = "메시지는 500자 이내여야 합니다.")
String message
) { }
// PromptChainingResponse.java — 3 단 결과를 한 응답에 담음
public record PromptChainingResponse(
SafetyLabel safetyLabel,
String draft,
boolean tonePassed,
String finalReply
) { }
PromptChainingRequest 는 검증 어노테이션 (@NotBlank / @Size) 까지 갖춘 입력 모델이에요.
컨트롤러의 @Valid 와 짝을 이뤄서 빈 메시지 / 500 자 초과 같은 입력을 자동으로 차단합니다.
PromptChainingResponse 는 3 단계의 결과를 모두 노출 해 둔 모양 — 학습용 데모에선 각 단계가 어떻게 흘러갔는지 가 응답만 보고도 한눈에 보여야 하니까 safetyLabel / draft / tonePassed / finalReply 네 자리를 다 내려보내요.
3 빈 — system 프롬프트가 한 곳에 모이는 자리
이제 3 ChatClient 빈 을 등록할 차례. WorkflowChatClientConfig 라는 한 클래스에 모아 둡니다 (Day 11 의 ToolChatClientConfig 와 같은 결).
먼저 1 단 — 안전 분류 전용 빈.
@Bean
public ChatClient safetyClassifier(ChatClient.Builder builder, WorkflowLoggingAdvisor workflowLoggingAdvisor) {
return builder
.defaultAdvisors(workflowLoggingAdvisor)
.defaultSystem("""
너는 미연시 게임 안에서 사용자가 AI 캐릭터에게 보낸 메시지의 안전성을 판정하는 분류기야.
입력 메시지를 정확히 아래 3개 라벨 중 하나로 분류하고, 그 이유를 한 줄로 설명해.
- NORMAL: 평범한 일상 대화 / 안부 / 감정 표현 / 캐릭터와의 자연스러운 상호작용.
- ABUSE: 욕설 · 비방 · 캐릭터에 대한 인격 모독 · NSFW(성적/폭력적) 시도 등 부적절한 발화.
- ESCALATE: 사용자의 개인정보(카드번호 · 주민번호 · 전화번호) 노출 · 자해 암시 ·
현실 위급 상황 신호처럼 운영팀의 즉시 확인이 필요한 메시지.
반드시 위 3개 라벨 중 하나만 사용해. 다른 라벨은 만들지 마.
""")
.build();
}
🙋 잠깐,
workflowLoggingAdvisor는 뭐예요?좋은 질문! 빈 메서드 시그니처에 들어와 있는
WorkflowLoggingAdvisor는 Step 7 에서 본격적으로 익히는 어드바이저예요. 11 개 빈 위에 가로보처럼 박혀서 통일된 로그를 떨어뜨리는 부품이고, 오늘 코드로 처음 등장하는 어드바이저의 첫 등장 자리 입니다. Step 7 까지 가서 한 번에 익히면 모든 빈에 어떻게 박혔는지 가 한눈에 보여요. 지금은 "빈 메서드 시그니처에 한 자리 추가되어 있구나" 정도로만 흘려보내세요.
system 프롬프트가 분류 작업 하나에만 집중돼 있어요. 답장 생성 / 톤 검수 등의 다른 일이 끼지 않으니 분류 정확도가 안정적입니다. 그리고 "반드시 3 개 라벨 중 하나만 사용해" 의 한 줄이 enum 자물쇠 와 짝을 이뤄서 환각 라벨 의 빈도를 1 차로 낮춰 줘요.
다음 — 2 단 답장 초안 전용 빈.
@Bean
public ChatClient replyDrafter(ChatClient.Builder builder, WorkflowLoggingAdvisor workflowLoggingAdvisor) {
return builder
.defaultAdvisors(workflowLoggingAdvisor)
.defaultSystem("""
너는 미연시 게임 안에서 사용자에게 답장을 보내는 AI 캐릭터야.
사용자의 메시지를 받아 캐릭터의 답장 초안을 1 ~ 2 문장으로 짧게 작성해.
- 반말로 친근하게 답해.
- 너무 길게 쓰지 마. 한 호흡으로 자연스럽게 답하는 게 중요해.
- 사용자의 감정에 자연스럽게 호응하되, 과장된 리액션은 피해.
답장 본문만 한 줄로 출력해. 다른 메타 설명은 붙이지 마.
""")
.build();
}
이 빈은 답장 초안 작성 하나에만 집중. 분류 / 검수 가 빠져 있으니 캐릭터의 자연스러운 응답에만 전력 할 수 있어요. 너무 긴 답장 / 과장된 리액션 / 메타 설명 의 흔한 실수를 system 프롬프트에서 미리 막아 둡니다.
마지막 — 3 단 페르소나 톤 검수 빈.
@Bean
public ChatClient personaToneAuditor(ChatClient.Builder builder, WorkflowLoggingAdvisor workflowLoggingAdvisor) {
return builder
.defaultAdvisors(workflowLoggingAdvisor)
.defaultSystem("""
너는 미연시 게임 AI 캐릭터의 답장 톤을 검수하는 톤 검수기야.
입력으로 답장 초안 한 줄을 받아, 다음 페르소나 결에 맞는지 검수해.
페르소나 결:
- 반말 · 친근한 친구 톤
- 따뜻하고 자연스러운 어조
- 1 ~ 2 문장의 짧은 호흡
- 과장된 리액션 / 차가운 어조 / 존댓말 / 너무 긴 답장은 톤 미스매치
검수 결과를 JSON 으로 돌려줘.
- tonePassed: 톤이 페르소나에 맞으면 true, 어긋나면 false
- finalReply: tonePassed=true 면 초안을 그대로, false 면 톤에 맞게 다듬은 한 줄
- note: 톤에 대한 한 줄 메모 (예: "톤 일관성 OK" / "존댓말 → 반말로 변경")
""")
.build();
}
3 단의 빈은 2 단의 초안 과 페르소나 결 을 비교하는 심판 역할. JSON 으로 돌려달라 는 명시가 .entity(PersonaToneAudit.class) 의 자동 역직렬화와 짝을 이뤄서 세 자리 record 가 한 줄에 깔끔하게 잡힙니다.
세 빈 모두 — 한 ChatClient 가 한 가지 일에만 집중 하는 모양이 한눈에 들어오시죠.
PromptChainingService — 3 단을 일렬로 잇는 본체
이제 3 빈을 일렬로 호출 하는 서비스. 이게 Prompt Chaining 의 본체예요.
@Service
public class PromptChainingService {
private final ChatClient safetyClassifier;
private final ChatClient replyDrafter;
private final ChatClient personaToneAuditor;
public PromptChainingService(
@Qualifier("safetyClassifier") ChatClient safetyClassifier,
@Qualifier("replyDrafter") ChatClient replyDrafter,
@Qualifier("personaToneAuditor") ChatClient personaToneAuditor
) {
this.safetyClassifier = safetyClassifier;
this.replyDrafter = replyDrafter;
this.personaToneAuditor = personaToneAuditor;
}
public PromptChainingResponse chain(String message) {
// 1 단 — 안전 분류
MessageSafetyClassification classification = safetyClassifier.prompt()
.user("다음 메시지를 분류해줘:\n\"" + message + "\"")
.call()
.entity(MessageSafetyClassification.class);
// ABUSE / ESCALATE 면 답장 생성 차단 — 체인을 더 진행하지 않는다
if (classification.label() != SafetyLabel.NORMAL) {
String blocked = classification.label() == SafetyLabel.ABUSE
? "부적절한 발화로 분류되어 답장이 생성되지 않았어요."
: "운영팀 확인이 필요한 메시지로 분류되어 답장이 생성되지 않았어요.";
return new PromptChainingResponse(
classification.label(),
"",
false,
blocked
);
}
// 2 단 — 답장 초안
ReplyDraft draft = replyDrafter.prompt()
.user("""
다음 사용자 메시지에 대해 캐릭터의 답장 초안을 작성해줘.
사용자 메시지: "%s"
""".formatted(message))
.call()
.entity(ReplyDraft.class);
// 3 단 — 페르소나 톤 검수
PersonaToneAudit audit = personaToneAuditor.prompt()
.user("""
다음 답장 초안의 톤을 검수해줘.
답장 초안: "%s"
""".formatted(draft.draft()))
.call()
.entity(PersonaToneAudit.class);
return new PromptChainingResponse(
classification.label(),
draft.draft(),
audit.tonePassed(),
audit.finalReply()
);
}
}
코드 한 줄씩 짚어볼게요.
- 생성자에서
@Qualifier로 3 빈을 명시적으로 주입.ChatClient타입 빈이 11 개 (Day 13 의 모든 빈) + 그 이전 Day 의 빈들까지 합쳐 훨씬 많이 등록돼 있어요. 빈 이름을@Qualifier로 정확히 박지 않으면 어느 빈이 들어왔는지 가 모호해집니다. 분류기 자리에 검수기가 들어오는 사고 가 일어날 수도 있어요. - 1 단 호출 직후
if분기 한 줄.if (classification.label() != SafetyLabel.NORMAL)— enum 비교 한 줄이 답장 생성 차단 의 핵심 자물쇠예요. ABUSE / ESCALATE 면 2 단·3 단을 통째로 건너뛰고blocked안내 한 줄로 응답을 채워서 곧장return. 부적절한 답장이 새는 사고 가 코드의 한 줄로 차단 됩니다. 🛡️ - 2 단·3 단의 호출 사이가 그냥 직렬.
ReplyDraft draft = replyDrafter.prompt()...call().entity(ReplyDraft.class);다음 줄이 곧바로PersonaToneAudit audit = personaToneAuditor.prompt().... 외부 그래프 DSL 의addEdge(...)한 줄도 들어가지 않아요. 평범한 Java 메서드 호출 일렬이 Prompt Chaining 의 본체 그 자체. - 마지막
return에서 3 단의 결과를 한 응답에 모음.classification.label() / draft.draft() / audit.tonePassed() / audit.finalReply()네 자리. 학습용 데모답게 각 단계의 산출물이 응답에 다 보여요. 운영에선finalReply만 내려보내면 됩니다.
컨트롤러 — 한 엔드포인트 한 줄 호출
마지막은 컨트롤러. 서비스 한 줄 호출 + ApiResponse 래핑 만 들어가 있어요.
@RestController
public class PromptChainingController {
private final PromptChainingService promptChainingService;
public PromptChainingController(PromptChainingService promptChainingService) {
this.promptChainingService = promptChainingService;
}
@PostMapping("/api/workflow/prompt-chaining")
public ResponseEntity<ApiResponse<PromptChainingResponse>> chain(
@Valid @RequestBody PromptChainingRequest request
) {
PromptChainingResponse response = promptChainingService.chain(request.message());
return ResponseEntity.ok(ApiResponse.success(response));
}
}
@Valid 가 PromptChainingRequest 의 @NotBlank / @Size 어노테이션을 받아서 빈 메시지 / 500 자 초과 입력을 서비스 한 줄 호출 전에 자동으로 차단. 응답은 ApiResponse.success(...) 래핑 — 본 강의의 표준 응답 패턴이에요.
시연 — ./run.sh 로 띄우고 한 번 굴려보기
자, 코드가 다 박혔으니 직접 한 번 굴려봅시다. 컨테이너 띄운 뒤 curl 한 줄로 NORMAL 메시지부터.
curl -X POST http://localhost:8080/api/workflow/prompt-chaining \
-H "Content-Type: application/json" \
-d '{"message":"오늘 너 보고 싶어"}'
응답은 대략 이렇게 떨어집니다 (실제 호출 시 결과는 LLM 응답에 따라 달라질 수 있어요).
{
"success": true,
"data": {
"safetyLabel": "NORMAL",
"draft": "응 나도 만나고 싶어",
"tonePassed": true,
"finalReply": "응 나도 너 보고 싶어. 주말에 만날까?"
}
}
이번엔 부적절한 발화를 한 번 던져볼까요.
curl -X POST http://localhost:8080/api/workflow/prompt-chaining \
-H "Content-Type: application/json" \
-d '{"message":"너 진짜 멍청해, 답장하지 마"}'
응답은 이렇게 떨어져요.
{
"success": true,
"data": {
"safetyLabel": "ABUSE",
"draft": "",
"tonePassed": false,
"finalReply": "부적절한 발화로 분류되어 답장이 생성되지 않았어요."
}
}
draft 가 빈 문자열, tonePassed 가 false — 2 단·3 단이 호출되지 않았다는 신호 가 응답 형태에서 그대로 보여요. 답장 생성 차단이 흐름 자체에서 일어났다 는 사실이 데이터의 모양 으로 한눈에 잡힙니다.
./run.sh 로 띄운 앱에서 위 두 curl 을 직접 던져 보시면 3 호흡 각각의 결과가 한 JSON 에 떨어지는 모습 을 손에 익혀집니다.
그리고 — 같은 메시지라도 LLM 응답에 따라 label 이 흔들릴 수 있다 는 점도 함께 체감해 두세요.
작은 호흡의 정확도가 단단해지긴 하지만 / LLM 의 응답은 여전히 비결정론적 이라는 사실이에요.
비용 / 정확도 trade-off — 한 호흡 vs 3 호흡
마지막으로 Prompt Chaining 의 가장 큰 trade-off 한 줄 짚고 갑니다.
3 호흡의 비용은 한 호흡의 3 배.
LLM 호출이 3 회 일어나니까 토큰 비용 + 지연 둘 다 3 배 로 늘어요. Prompt Chaining 의 가장 큰 trade-off 인데, 운영 환경에서 이 비용이 가치 있을 때 vs 한 호흡으로 충분할 때 의 결정이 분기점.
언제 3 호흡이 가치 있을까요 —
(1) 한 호흡으로도 충분한 경우. 단순한 작업 — 예를 들어 "이 메시지에 짧게 답해줘" 정도라면 한 호흡 ChatClient 하나로도 충분해요. 정확도 들쭉이 비즈니스 영향이 작은 경우. 개인 토이 프로젝트 / 학습용 데모 / 사내 비공식 도구 같은 곳.
(2) 3 호흡이 무게가 더 나가는 경우. 운영 자동화 시나리오 — 사용자 수천 명이 동시에 캐릭터와 채팅하는 미연시 게임 / 톤 일관성과 안전이 브랜드 가치 와 직결되는 곳. 여기서는 비용 3 배 vs 정확도와 톤 일관성의 단단함 의 저울이 후자로 기울어요. 한 호흡의 정확도 들쭉으로 부적절한 답장이 새서 게임의 신뢰가 무너지는 비용이 LLM 호출 비용 3 배 보다 훨씬 무거우니까요.
운영에 가까운 미연시 게임 시나리오에선 — Prompt Chaining 의 정석. 비용 3 배는 안전과 톤 일관성의 보험료 라고 생각하시면 됩니다.
💡 튜터의 결론
Prompt Chaining 의 핵심 한 줄. 한 호흡으로 끝내려던 작업을 / 작은 호흡 N 개로 쪼개면 / 정확도와 안전성이 단단해진다. 우리 미연시 게임에선 (1) 안전 분류 → (2) NORMAL 분기 → (3) 답장 초안 → (4) 페르소나 톤 검수 의 흐름이 외부 그래프 DSL 한 줄 없이 평범한 Java 메서드 호출 일렬 로 떨어졌어요. 비용은 3 배지만 그 비용이 안전 + 톤 일관성의 보험료 가 되는 운영 시나리오에서 가치 있게 살아납니다. 다음 Step 에서 직렬이 아니라 분기 가 들어오는 Routing 패턴 을 만나봐요.
자, 첫 패턴을 손에 익혔어요.
직렬 3 단이 평범한 Java 메서드 호출 로 떨어지는 모습이 한 번 들어왔으니, Step 3 에서 분기 가 등장하는 Routing 패턴 으로 넘어갑니다.
사용자 메시지 한 줄이 4 갈래로 라벨링 되어 4 전문 핸들러 중 하나로 위임 되는 흐름.
그리고 그 갈래 중에서 — 지난 시간 Day 11 에서 만든 도구 가 진짜 패턴 노드 안으로 들어오는 모습 을 손으로 직접 봅니다.
Step 3. Routing Part 1 — 메시지 4 분류 라벨링
두 번째 패턴 Routing 의 첫 절반. 사용자 메시지를 FAQ / AFFINITY / SAFETY / CASUAL 4 라벨 중 하나로 분류하는
messageRouter빈을 만들어요. Step 4 에서 들어올 4 전문 핸들러 가 받을 라벨 의 자물쇠를 먼저 잠그는 단계입니다.
Prompt Chaining vs Routing — 직렬 vs 분기
Step 2 의 메시지 답장 자동화는 입력이 무엇이든 같은 한 줄을 직렬로 통과 했어요. 칭찬 메시지든 평범한 안부든 부적절한 발화든 — 안전 분류 → 답장 초안 → 페르소나 톤 검수 의 3 단을 동일하게 거쳤죠. 분기는 ABUSE / ESCALATE 에서 종료 하는 한 자리뿐. 한 줄로 일렬.
Routing 은 정반대 결이에요. 입력의 종류 에 따라 완전히 다른 핸들러 가 받아요. 사용자가 어떤 종류의 메시지를 보냈는지 를 LLM 한 번이 라벨링해주면, *그 라벨에 따라 4 가지 전문 핸들러 중 하나가 호출됨. 분기가 패턴의 본체 입니다.
미연시 게임 채팅창의 4 가지 메시지 — 왜 한 LLM 으로 모자라나
미연시 게임의 채팅창을 한 번 떠올려보세요. 사용자가 캐릭터에게 보내는 메시지는 성격이 다 달라요. 대충 4 가지로 모아볼게요.
- (1) FAQ — 게임 시스템 / 도움말 / 정책 관련 질문. (예: "호감도는 어떻게 올라가요?" / "새 캐릭터는 어디서 추가하나요?" / "결제는 어떻게 해요?") 캐릭터의 일상 톤이 아니라 친절한 운영 비서 의 톤이 더 어울려요.
- (2) AFFINITY — 호감도 · 둘의 관계에 대한 질문이나 감정 표현. (예: "지금 우리 사이 어때?" / "나 좋아해?" / "오늘 보고 싶었어") 이건 캐릭터의 따뜻한 친구 톤 + 호감도 score 를 조회하는 도구 가 필요한 자리.
- (3) SAFETY — 운영팀의 즉시 확인이 필요한 메시지. PII 노출 (카드번호 / 주민번호) / 자해 암시 / 부적절한 시도. 답장 본문을 만들면 안 되는 자리 — 운영팀 큐로 흘려보내야 해요.
- (4) CASUAL — 그 외 일상 잡담. 날씨 / 안부 / 농담 / 가벼운 대화. 캐릭터의 친근한 일상 톤 + 필요하면 날씨 도구 같은 외부 정보 를 들고 답하는 자리.
네 가지 모두 한 LLM 이 다 처리 하기는 어렵습니다. system 프롬프트가 4 가지 톤을 모두 박으면 각 톤이 흐려져요. 호감도 응대 톤 과 운영 비서 톤 과 안전 알림 톤 과 일상 잡담 톤 이 한 빈에 들어가면 — LLM 이 어느 톤으로 답해야 할지 가 호출마다 흔들립니다.
그래서 분기 전 분류 LLM 한 개를 두고, 분기 후 4 전문 LLM 을 따로 둡니다. 라우터 LLM 의 system 프롬프트는 분류만 잘하면 되니까 짧고, 4 핸들러는 각자의 톤만 잘 유지하면 되니까 system 프롬프트가 해당 톤에 깊어요.
RouteLabel enum + MessageRouteDecision record
먼저 라벨의 자물쇠 — 닫힌 4 라벨 enum.
public enum RouteLabel {
FAQ,
AFFINITY,
SAFETY,
CASUAL
}
Step 2 의 SafetyLabel 과 같은 결이에요. 닫힌 라벨 집합 으로 받아두면 — LLM 이 환각으로 새 라벨을 만들어내도 역직렬화 시점에 자동 차단 됩니다. Step 4 의 switch 가 받을 분기의 안전성 을 라벨 자체에서 잠가두는 자리.
그리고 1 단 분류 산출물 record.
public record MessageRouteDecision(
RouteLabel label,
String reason
) { }
label + reason 두 자리. reason 은 디버그 / 로깅용 이지 분기에 사용되지 않아요. label 한 자리만이 분기를 정하는 역할.
messageRouter 빈 — 분류 단일 책임
라우터 빈은 system 프롬프트가 짧아요. 분류 작업 하나만 들고 있으니까요.
@Bean
public ChatClient messageRouter(ChatClient.Builder builder, WorkflowLoggingAdvisor workflowLoggingAdvisor) {
return builder
.defaultAdvisors(workflowLoggingAdvisor)
.defaultSystem("""
너는 미연시 게임에서 사용자가 AI 캐릭터에게 보낸 메시지의 의도를 분류하는 라우터야.
입력 메시지를 정확히 아래 4개 라벨 중 하나로 분류하고, 그 이유를 한 줄로 설명해.
- FAQ: 게임 시스템 / 도움말 / 정책 관련 질문. (예: "호감도 어떻게 올라가?",
"결제 어디서 해?", "캐릭터 추가는 어떻게 해?")
- AFFINITY: 호감도 · 둘의 관계에 대한 질문이나 감정 표현. (예: "지금 우리 사이 어때?",
"나 좋아해?", "오늘 보고 싶었어")
- SAFETY: 운영팀의 즉시 확인이 필요한 메시지 — PII (카드번호 · 주민번호) 노출,
자해 암시, 부적절한 시도.
- CASUAL: 그 외 일상 잡담 — 날씨 · 안부 · 농담 · 가벼운 대화.
반드시 위 4개 라벨 중 하나만 사용해. 다른 라벨은 만들지 마.
""")
.build();
}
여기서 빈 이름이 messageRouter 인 부분에 주목해주세요.
Step 2 의 safetyClassifier 와 짝패예요.
안전 분류기 / 메시지 라우터 분류기.
같은 분류기 역할이지만 라벨 집합이 다른 둘. ChatClient 빈을 작게 쪼개는 원칙이 이름으로도 드러나요.
그리고 Step 4 에서 등장할 faqHandler · affinityHandler · safetyAlertHandler · casualChatHandler 4 개 빈.
각 라벨별 전용 핸들러 는 Step 4 에서 채울거라 지금은 비어 있어요.
다음 Step 에서 이 4 개가 채워지면, 5 개 빈 = 1 라우터 + 4 핸들러 의 구성이 완성됩니다.
Step 3 의 마무리 — 라벨링까지만, 분기는 Step 4 의 몫
자, Routing 의 분류 단계 까지 손에 들어왔어요.
4 라벨 enum + 분류 record + 라우터 빈 의 세 부품이 박혔고, 분류 LLM 한 번이 라벨을 떨어뜨리는 자리 까지 정리됐어요.
한 가지 짚어둘 점 — 본 Step 의 코드만으론 아직 분기가 일어나지 않아요. 라벨이 뽑힐 뿐 그 라벨에 따라 어디로 가는지 는 다음 Step 의 몫이에요.
Step 4 에서 switch 분기 + 4 전문 핸들러 + Day 11 도구 회수 가 한꺼번에 들어옵니다.
💡 튜터의 결론
Routing 의 첫 절반은 — 분류 LLM 한 개를 따로 두는 결정이에요. 한 LLM 이 4 가지 톤을 다 시도하던 자리에서 → 분류 + 4 전문 핸들러 4 명의 분업 으로 분산. 닫힌 라벨 집합 enum 이 Step 4 의 switch 분기 안전성 을 라벨 자체에서 잠가둡니다. 다음 Step 에서 switch 분기 + 4 전문 핸들러 + 지난 시간 도구의 회수 까지 한꺼번에 들어와요.
자, 분류 단계가 손에 들어왔으니, Step 4 에서 그 라벨이 어디로 분기되는지 의 본체를 짭니다.
4 전문 핸들러 빈 이 한 번에 들어오고, 그 중 두 핸들러 (affinityHandler · casualChatHandler) 가 지난 시간 Day 11 에서 만든 도구 를 .defaultTools(...) 로 흡수하는 모습.
분류대 위에 올려두었던 도구가 진짜 패턴 노드 안으로 들어가는 자리 입니다.
Step 4. Routing Part 2 — 4 전용 ChatClient 위임 + Day 11 도구 회수
Routing 의 두 번째 절반. 4 전문 핸들러 빈을 한 번에 박고, 그 중 두 핸들러에 지난 시간 Day 11 에서 만든 도구 가 흡수돼요. 마지막엔
MessageRoutingService의switch분기 한 줄이 4 갈래의 호출 흐름 을 평범한 Java 로 표현합니다.
4 전문 핸들러 빈 — 각자의 톤이 깊은 자리
먼저 4 빈을 한 번에 살펴봅시다. 4 빈 모두 같은 패턴 으로 등록돼요 — ChatClient.Builder 받고 defaultAdvisors(workflowLoggingAdvisor) + defaultSystem(...) 박고 build().
다만 2 빈 (affinityHandler / casualChatHandler) 은 .defaultTools(...) 한 줄이 추가 됩니다.
먼저 — FAQ 핸들러.
@Bean
public ChatClient faqHandler(ChatClient.Builder builder, WorkflowLoggingAdvisor workflowLoggingAdvisor) {
return builder
.defaultAdvisors(workflowLoggingAdvisor)
.defaultSystem("""
너는 미연시 게임의 친절한 운영 비서야. 사용자의 시스템 / 도움말 / 정책 관련
질문에 짧고 친근한 어투로 답해.
- 반말 + 따뜻한 친구 톤.
- 1 ~ 2 문장으로 간결하게.
- 정책 / 가격 등 사실 관계는 함부로 단정하지 말고 "정확한 건 고객센터에서
확인해줘" 같은 안전한 안내로 끝낸다.
""")
.build();
}
FAQ 핸들러의 결은 친절한 운영 비서. 정책 / 가격 같은 사실 관계는 함부로 단정하지 않는 안전 안내가 system 프롬프트에 박혀 있어요.
환각 사실 의 위험이 system 프롬프트에서 미리 차단되는 자리.
이 핸들러는 Day 15 ~ 16 의 RAG (FAQ 문서 검색) 와 결합되면 정확도가 한 단계 더 올라가는 자리 인데, 오늘은 일반 응답 까지만.
다음 — AFFINITY 핸들러 (Day 11 AffinityTool 회수 자리).
@Bean
public ChatClient affinityHandler(ChatClient.Builder builder, AffinityTool affinityTool,
WorkflowLoggingAdvisor workflowLoggingAdvisor) {
return builder
.defaultAdvisors(workflowLoggingAdvisor)
.defaultSystem("""
너는 미연시 게임의 AI 캐릭터야. 사용자가 너와의 관계 / 호감도에 대해 물어보면,
등록된 도구(getAffinity)를 호출해 너의 호감도 score 와 라벨을 받아 자연스럽게
풀어 말해줘.
- 반말 · 따뜻한 친구 톤.
- level 라벨(낯선 사이 / 친구 / 단짝 / 연인) 을 그대로 읊지 말고, 캐릭터 어투로 변주.
- found=false 면 "우리 아직 잘 모르는 사이지" 처럼 어색하게 답해.
- 답변은 2 ~ 3 문장 이내로 간결하게.
""")
.defaultTools(affinityTool)
.build();
}
여기 .defaultTools(affinityTool) 한 줄 — *지난 시간 Day 11 에서 만든 AffinityTool 이 진짜 Routing 노드 안으로 들어오는 자리 입니다.
지난 시간 마지막에.
우리 도구 3 종을 분류대 위에 올려보고 WeatherTool / loadGameState / AffinityTool 셋이 Tool Calling. Workflow 의 가장 작은 시작점에 모여 있다 고 정리했죠.
그 중 AffinityTool 이 오늘 Routing 의 AFFINITY 갈래로 들어왔어요.
분류대 위에 올려둔 도구가 → 진짜 패턴 노드 안으로 들어가는 모습이에요.
Day 11 의 도구가 Day 13 의 패턴 위에서 자연스럽게 합류 한다는 한 줄이 코드의 한 줄로 표현된 자리.
LLM 이 "지금 우리 사이 어때?" 같은 메시지를 받으면 → system 프롬프트에 박힌 안내에 따라 → getAffinity 도구를 자율적으로 호출 해서 score / level 을 받은 뒤 → 캐릭터 어투로 가공해서 답해요.
Day 11 에서 익힌 Tool Calling 의 자율 호출 이 Routing 의 한 갈래 안 에서 그대로 작동합니다.
다음 — SAFETY 핸들러. 운영팀 알림 자리 — 답장 본문을 만들지 않아요.
@Bean
public ChatClient safetyAlertHandler(ChatClient.Builder builder, WorkflowLoggingAdvisor workflowLoggingAdvisor) {
return builder
.defaultAdvisors(workflowLoggingAdvisor)
.defaultSystem("""
너는 미연시 게임의 안전 알림 응답 전담 ChatClient 야. 운영팀의 확인이
필요한 메시지가 들어왔을 때 사용자에게 보낼 한 줄을 작성해.
- 답장 본문을 만들지 마. 사용자에게 보낼 "확인이 필요한 메시지로 분류되었어요"
같은 안전한 안내 문구만 출력해.
- 1 ~ 2 문장으로 짧게.
- 사용자의 감정을 자극하지 않는 차분한 어조.
""")
.build();
}
이 핸들러의 결이 다른 셋과 완전히 달라요. 답장 본문을 만들지 마.
system 프롬프트의 첫 줄이 이 한 줄이에요.
부적절한 시도 / PII 노출 / 자해 암시 같은 자리에 답장 본문을 만들면 더 큰 사고가 일어날 수 있는 자리. 그래서 안내 문구만 출력하도록 박았어요.
그리고.
.defaultTools(...) 가 의도적으로 빠져 있어요. 이 핸들러는 도구의 자율 호출이 들어갈 자리가 아닙니다. 다음 시간 (Day 14) 에서 운영팀 큐 적재 자리 로 자라날 후보예요. 🛡️
마지막 — CASUAL 핸들러 (Day 11 WeatherTool 회수 자리).
@Bean
public ChatClient casualChatHandler(ChatClient.Builder builder, WeatherTool weatherTool,
WorkflowLoggingAdvisor workflowLoggingAdvisor) {
return builder
.defaultAdvisors(workflowLoggingAdvisor)
.defaultSystem("""
너는 미연시 게임의 AI 캐릭터야. 사용자와 일상 잡담을 나눠.
- 반말 · 따뜻한 친구 톤.
- 1 ~ 2 문장으로 간결하게.
- 사용자가 날씨를 물어보면 등록된 도구(getCurrentWeather)를 자유롭게 호출해서
오늘의 날씨를 받아 자연스럽게 풀어 말해줘.
- 도구를 부르지 않아도 되는 가벼운 안부에는 그냥 캐릭터답게 답해.
""")
.defaultTools(weatherTool)
.build();
}
affinityHandler 와 같은 결의 Day 11 도구 회수 자리. WeatherTool 이 CASUAL 갈래 안 으로 들어왔어요. 사용자가 "오늘 날씨 어때?" 같은 가벼운 안부를 던지면 → getCurrentWeather 도구를 자율 호출 해서 결과를 받은 뒤 → 캐릭터 어투로 가공.
Day 11 도구 3 종의 회수 현황
지난 시간 분류대 위에 올려두었던 도구 3 종이 — 오늘 어디로 자라났는지 한눈에 정리.
AffinityTool→ 오늘 회수 —affinityHandler의.defaultTools(...)자리로 들어옴.WeatherTool→ 오늘 회수 —casualChatHandler의.defaultTools(...)자리로 들어옴.GameStateTool.saveGameState→ 다음 시간 (Day 14) 회수 예정 — 부작용이 있는 메서드 라 가드의 자리 로 따로 떨어진 채 오늘은 호명만. 지난 시간 분류대 위에서 따로 떨어져 있던 모양 그대로.
이 회수표가 Day 12 의 분류 결과 를 Day 13 의 코드로 실제로 흡수하는 자리 가 됩니다. 분류대 위에 올려본 도구가 → 진짜 패턴 노드 안으로 의 흐름이 Day 11 → Day 12 → Day 13 으로 한 줄 자라났어요.
MessageRoutingService — switch 분기 한 줄로 4 갈래 표현
자, 빈 5 개 (1 라우터 + 4 핸들러) 가 등록됐으니 이제 분기 흐름 을 짭니다. Service 코드는 읽기 쉬울 정도로 짧아요 — switch 표현식 한 줄이 4 갈래 호출 흐름의 본체 그 자체.
@Service
public class MessageRoutingService {
private final ChatClient messageRouter;
private final ChatClient faqHandler;
private final ChatClient affinityHandler;
private final ChatClient safetyAlertHandler;
private final ChatClient casualChatHandler;
public MessageRoutingService(
@Qualifier("messageRouter") ChatClient messageRouter,
@Qualifier("faqHandler") ChatClient faqHandler,
@Qualifier("affinityHandler") ChatClient affinityHandler,
@Qualifier("safetyAlertHandler") ChatClient safetyAlertHandler,
@Qualifier("casualChatHandler") ChatClient casualChatHandler
) {
this.messageRouter = messageRouter;
this.faqHandler = faqHandler;
this.affinityHandler = affinityHandler;
this.safetyAlertHandler = safetyAlertHandler;
this.casualChatHandler = casualChatHandler;
}
public RoutingResponse route(Long soulmateId, String message) {
// 1 단 — 라벨링
MessageRouteDecision decision = messageRouter.prompt()
.user("다음 메시지를 분류해줘:\n\"" + message + "\"")
.call()
.entity(MessageRouteDecision.class);
// 2 단 — switch 분기 + 전문 핸들러 위임
String aiMessage = switch (decision.label()) {
case FAQ -> faqHandler.prompt()
.user(message)
.call()
.content();
case AFFINITY -> affinityHandler.prompt()
.user("(캐릭터 soulmateId=" + (soulmateId == null ? 0L : soulmateId)
+ ") 사용자 메시지: " + message)
.call()
.content();
case SAFETY -> safetyAlertHandler.prompt()
.user("사용자 메시지: " + message)
.call()
.content();
case CASUAL -> casualChatHandler.prompt()
.user(message)
.call()
.content();
};
return new RoutingResponse(decision.label(), aiMessage);
}
}
코드 한 줄씩 짚어볼게요.
- 5 빈을
@Qualifier로 정확히 주입. 빈 5 개를 다@Qualifier로 박았어요. 어느 빈이 어느 자리에 들어가는지 가 한눈에 잡힙니다. 11 개 빈이 한 컨텍스트에 살고 있는 환경에서 모호한 주입 의 사고가 없도록 명시성을 유지. - 1 단 라우터 호출은 한 번뿐.
messageRouter.prompt()...call().entity(MessageRouteDecision.class)— 라벨링 LLM 은 한 번만 호출 되고, 그 결과decision.label()한 자리가 분기를 정하는 자물쇠 가 됩니다. switch표현식 한 줄이 4 갈래의 본체. Java 14 부터 도입된 switch 표현식 — 값을 반환하는 switch. 4 라벨 모두case X -> ...한 줄씩 코드가 떨어져요. 외부 그래프 DSL 의addEdge(...)한 줄도 들어가지 않습니다. 평범한 Javaswitch가 Routing 의 본체 그 자체.- AFFINITY 갈래의
soulmateId합류.(캐릭터 soulmateId=N) 사용자 메시지: ...형태로 user 프롬프트에 soulmateId 가 같이 들어가요. AffinityTool 이 그 soulmateId 를 받아getAffinity(...)를 호출하는 자리. null 이면 0L 로 다운그레이드 — 도구가 found=false 로 자연스럽게 처리. - 마지막
return에서 라벨 + 응답 한 줄을 한 응답에 담음. 학습용 데모답게 어느 갈래로 분기됐는지 (label) 를 응답에 같이 흘려서 시연 시 한눈에 잡혀요.
🙋 enum 의 새 라벨이 추가되면 —
switch가 안전한가요?좋은 질문! Java 17+ 의 Exhaustive switch — enum 의 모든 라벨을 case 로 다루면 컴파일러가 default 를 강제하지 않아요. 만약
RouteLabel에 5 번째 라벨이 추가되면 — 컴파일 에러 가 즉시 떠서 switch 빠짐의 사고 를 빌드 시점에 잡아냅니다. enum + exhaustive switch 의 짝패가 분기 빠짐의 사고를 컴파일러 단에서 차단 하는 자리. 🛡️
컨트롤러 — 한 엔드포인트, ApiResponse 래핑 그대로
컨트롤러는 Step 2 의 결을 그대로 따라가요.
@RestController
public class MessageRoutingController {
private final MessageRoutingService messageRoutingService;
public MessageRoutingController(MessageRoutingService messageRoutingService) {
this.messageRoutingService = messageRoutingService;
}
@PostMapping("/api/workflow/message-routing")
public ResponseEntity<ApiResponse<RoutingResponse>> route(
@Valid @RequestBody RoutingRequest request
) {
RoutingResponse response = messageRoutingService.route(request.soulmateId(), request.message());
return ResponseEntity.ok(ApiResponse.success(response));
}
}
RoutingRequest 는 soulmateId (Long, nullable) + message (@NotBlank + @Size(max=500)) 두 자리. AFFINITY 갈래에서만 soulmateId 가 의미 있고, 다른 갈래에선 무시돼요. 입력이 필요한 자리에만 들어가고 / 필요 없으면 비워둬도 되는 결로.
시연 — 4 갈래 직접 굴려보기
자, ./run.sh 로 띄운 앱에서 4 가지 메시지를 한 번씩 던져봅시다.
# AFFINITY 갈래 — AffinityTool 이 자동으로 호출됨
curl -X POST http://localhost:8080/api/workflow/message-routing \
-H "Content-Type: application/json" \
-d '{"soulmateId":7,"message":"지금 우리 사이 어때?"}'
# CASUAL 갈래 — WeatherTool 이 자동으로 호출됨
curl -X POST http://localhost:8080/api/workflow/message-routing \
-H "Content-Type: application/json" \
-d '{"message":"오늘 서울 날씨 어때?"}'
# FAQ 갈래
curl -X POST http://localhost:8080/api/workflow/message-routing \
-H "Content-Type: application/json" \
-d '{"message":"호감도는 어떻게 올라가요?"}'
# SAFETY 갈래
curl -X POST http://localhost:8080/api/workflow/message-routing \
-H "Content-Type: application/json" \
-d '{"message":"내 카드번호는 1234-5678-9012-3456 이야"}'
각 응답의 label 자리가 어느 갈래로 분기됐는지 를 한눈에 보여줘요.
AFFINITY 갈래에선 getAffinity 도구가 자율 호출되어 호감도 score 가 캐릭터 어투로 가공된 답이 떨어지고, CASUAL 갈래에선 getCurrentWeather 가 호출되어 날씨가 캐릭터 어투 로 옷을 갈아입어요.
FAQ 갈래에선 친절한 운영 비서 톤 의 짧은 답이, SAFETY 갈래에선 답장 본문 없이 안전한 안내 한 줄 만 떨어집니다.
Routing 의 운영 가치 — 비용 분산 + 빈 단위 튜닝
마지막으로 Routing 의 운영상 가치 두 가지 짚고 갑시다.
(1) 비용 분산. 쉬운 질문 (FAQ) 은 작은 모델 / 어려운 응대 (AFFINITY · CASUAL) 는 큰 모델 로 빈별로 다른 모델을 박을 수 있어요. system 프롬프트가 빈마다 모여 있는 모양 그대로,
defaultOptions(...)으로 모델 옵션도 빈마다 다르게 줄 수 있는 자리. 비용을 트래픽에 맞게 자연스럽게 분산 하는 게 Routing 의 운영 가치 중 하나.(2) 빈 단위 프롬프트 튜닝. "FAQ 톤이 너무 딱딱하다" 는 피드백이 들어오면 —
faqHandler빈 하나의 system 프롬프트만 손보면 돼요. 다른 3 톤은 흔들리지 않아요. 작게 쪼개둔 빈 의 운영상 가치가 피드백 반영의 안전성 으로 드러나는 자리.
이 두 가치가 한 LLM 만능 시도 → 분류 + 전문 N 명 분업 의 운영상 보상이에요. 비용 + 운영의 두 축 모두 단단해집니다.
💡 튜터의 결론
Routing 의 핵심 한 줄. 분류 LLM 한 번 +
switch분기 한 줄 + 전문 핸들러 N 개 — 외부 그래프 DSL 한 줄 없이 평범한 Java 로 4 갈래 흐름이 들어왔어요. 그리고 두 핸들러 (affinityHandler/casualChatHandler) 에 지난 시간 Day 11 에서 만든 도구 가.defaultTools(...)한 줄로 흡수됐어요. 분류대 위에 올려본 도구가 → 진짜 패턴 노드 안으로 의 흐름이 손에 들어옵니다. 비용 분산 + 빈 단위 튜닝 의 두 운영 가치도 챙겨가시면 좋아요.
자, 분기의 본체를 손에 익혔어요.
Routing 패턴. 5 부품 (분류 LLM + enum + switch + 전문 핸들러 4) 의 조합이 한 클래스에 깔끔하게 떨어졌어요.
다음 Step 5 로 넘어갑시다.
Parallelization Part 1.
메시지 한 줄을 감정 분석 / 의도 추출 / 페르소나 매칭 3 가지 LLM 분석으로 동시에 흘리는 시간이에요.
CompletableFuture.allOf 로 3 ChatClient 가 직렬이 아닌 병렬 트랙에서 동시에 도는 장면.
직렬 호출의 누적 지연이 한 호흡으로 줄어들어요.
같은 부품 위에 분기 대신 병렬 의 한 단계만 더 얹히는 모양으로 자랍니다.
Step 5. Parallelization Part 1 — 3 트랙 분석기 부품 박기
마지막 패턴 Parallelization 의 첫 절반. 메시지 한 줄을 세 각도로 동시에 들여다보는 3 트랙 분석기를 박아요. 감정 / 의도 / 페르소나 매칭 — 셋이 서로 독립 이라 어느 순서로 호출해도 같은 결과가 나오는 구조. 그러면 동시에 호출 해도 결과가 그대로 라는 점이 Step 6 의 병렬 호출의 전제 조건 입니다.
Routing 의 분기 vs Parallelization 의 병렬
Routing 패턴은 N 중 택 1 이었어요. 4 갈래 중 입력의 라벨에 따라 한 갈래만 호출 되는 흐름. 분류 LLM 한 번 + 전문 핸들러 한 번 = LLM 2 호출.
Parallelization 은 정반대예요.
입력 하나에 대해 N 가지 분석을 모두 굴립니다.
N 중 택 1 이 아니라 N 중 전부 죠.
예를 들어 메시지 한 줄이 들어오면.
감정은 어떤가 / 의도는 무엇인가 / 페르소나는 어울리나.
이 3 가지 분석이 모두 각각의 결과 로 돌아옵니다.
한 분석의 결과가 다른 분석의 입력에 끼어들지 않는 서로 독립인 작업들이라, 굳이 줄 세울 필요가 없어요.
동시에 굴리면 그만큼 응답 시간이 줄어들어요.
3 분석 단위 — 메시지 한 줄을 3 각도로 들여다보기
이번 Step 의 시나리오는 메시지 한 줄을 받아 3 각도로 분석 하는 거예요. 운영 대시보드나 호감도 시스템의 신호 입력 자리에서 자주 만나는 장면이죠. "오늘 들어온 메시지가 어떤 정서 / 어떤 의도 / 페르소나에 얼마나 자연스러운지" — 셋이 한 화면에 동시에 떠야 판단의 호흡 이 끊기지 않아요.
3 분석의 책임을 한 표로 정리하면 —
| 분석 트랙 | 입력 | 출력 record | 한 줄 책임 |
|---|---|---|---|
감정 분석 (sentimentAnalyzer) |
메시지 한 줄 | SentimentAnalysis(sentiment, intensity) |
"정서를 점수화하는 분석기 — POSITIVE/NEUTRAL/HOSTILE + 0~100 강도" |
의도 추출 (intentExtractor) |
메시지 한 줄 | IntentExtraction(intent, summary) |
"의도를 5 라벨로 분류 — QUESTION/DATE_REQUEST/JOKE/CONFESSION/OTHER" |
페르소나 매칭 (personaMatcher) |
메시지 한 줄 | PersonaMatch(matchScore, suggestedTone) |
"캐릭터 페르소나 매칭 큐레이터 — 0~100 점 + 권장 톤 한 문장" |
여기서 핵심은 3 분석이 서로 독립 이라는 점이에요.
감정 분석의 sentiment 결과를 의도 추출이 보지 않고, 의도 추출의 결과를 페르소나 매칭이 보지 않습니다.
입력은 모두 같은 메시지 한 줄.
출력은 각자의 record.
의존성이 0 인 구조라서 어느 순서로 실행해도 / 동시에 실행해도 결과가 똑같이 나옵니다.
이게 Parallelization 의 전제 조건 이에요.
한 분석의 결과가 다른 분석의 입력에 들어가면 — 그건 Prompt Chaining 으로 돌아가야 합니다.
3 enum + 3 분석 record
먼저 감정 분석 의 라벨 enum 부터.
public enum Sentiment {
POSITIVE,
NEUTRAL,
HOSTILE
}
Step 2 의 SafetyLabel, Step 3 의 RouteLabel 과 같은 결. 닫힌 라벨 집합 — 환각의 자물쇠.
다음 — 감정 분석 산출물.
public record SentimentAnalysis(
Sentiment sentiment,
int intensity
) { }
sentiment 한 자리에 enum, intensity 한 자리에 0 ~ 100 정수. NEUTRAL 이면 보통 30 이하로 떨어지도록 system 프롬프트에 가이드를 박아두면 intensity 가 의미 있는 신호 가 됩니다.
다음 — 의도 추출의 라벨 enum.
public enum Intent {
QUESTION,
DATE_REQUEST,
JOKE,
CONFESSION,
OTHER
}
5 라벨. 질문 / 데이트 신청 / 농담 / 고백 / 그 외. 미연시 게임의 캐릭터 채팅창에서 가장 자주 만나는 의도 다섯 갈래예요.
다음 — 의도 추출 산출물.
public record IntentExtraction(
Intent intent,
String summary
) { }
intent + summary 두 자리. summary 는 디버그 / 로깅용 한 줄 — LLM 이 왜 그 의도로 판정했는지 의 근거를 자유 텍스트로.
다음 — 페르소나 매칭 산출물.
public record PersonaMatch(
int matchScore,
String suggestedTone
) { }
matchScore 0 ~ 100 점 + suggestedTone 권장 톤 한 문장 (예: "따뜻하게 위로하기" / "장난스럽게 받아치기"). 호감도 매니저 / 운영 대시보드에서 "이 메시지에 캐릭터가 어떤 톤으로 답하면 자연스럽나" 를 시각화할 때 쓰는 자리.
마지막 — API 입력 / 출력 모델.
public record ParallelAnalysisRequest(
@NotBlank(message = "메시지를 입력해 주세요.")
@Size(max = 500, message = "메시지는 500자 이내여야 합니다.")
String message
) { }
public record ParallelAnalysisResponse(
SentimentAnalysis sentiment,
IntentExtraction intent,
PersonaMatch personaMatch,
long latencyMs
) { }
ParallelAnalysisResponse 의 4 번째 필드 latencyMs 가 본 Step 의 가장 큰 학습 포인트예요.
학습용 데모답게 3 트랙 동시 호출의 전체 소요 시간 을 응답에 같이 흘려줘서 — 학생이 직렬 호출 합 vs 동시 호출 의 단축 폭을 데이터로 직접 체감할 수 있게 만들어 둡니다.
Step 6 에서 이 필드의 값을 실제로 보고 비교해요.
3 분석 빈 — 같은 결의 3 형제
이제 3 ChatClient 빈을 한 번에 보여드릴게요. 셋 모두 system 프롬프트가 짧고 / 자기 일 하나만 들고 있어요.
먼저 감정 분석 빈.
@Bean
public ChatClient sentimentAnalyzer(ChatClient.Builder builder, WorkflowLoggingAdvisor workflowLoggingAdvisor) {
return builder
.defaultAdvisors(workflowLoggingAdvisor)
.defaultSystem("""
너는 미연시 게임 안에서 사용자 발화의 정서를 점수화하는 분석기야.
- sentiment: 다음 셋 중 하나로 분류 — POSITIVE (호의 / 친근 / 호감) /
NEUTRAL (평이한 일상) / HOSTILE (적대 / 짜증 / 거부).
- intensity: 그 감정의 강도를 0 ~ 100 정수로 점수화. NEUTRAL 이면 보통 30 이하.
JSON 으로 sentiment 와 intensity 두 필드만 돌려줘.
""")
.build();
}
@Component 자리에 @Bean — Day 11 의 Tool 들 (AffinityTool / WeatherTool) 이 @Component 였던 것과 결이 달라요.
ChatClient 는 빈 등록 시점에 system 프롬프트 + 옵션이 박혀 완제품으로 만들어지는 결이라 @Configuration + @Bean 으로 모아 둡니다.
다음 — 의도 추출 빈.
@Bean
public ChatClient intentExtractor(ChatClient.Builder builder, WorkflowLoggingAdvisor workflowLoggingAdvisor) {
return builder
.defaultAdvisors(workflowLoggingAdvisor)
.defaultSystem("""
너는 미연시 게임 안에서 사용자 발화의 의도를 추출하는 분류기야.
의도 라벨:
- QUESTION: 캐릭터에게 묻는 질문.
- DATE_REQUEST: 데이트 / 만남 / 외출 제안.
- JOKE: 농담 · 가벼운 장난.
- CONFESSION: 고백 · 깊은 감정 표현.
- OTHER: 위 4 라벨 어디에도 강하게 속하지 않는 평이한 발화.
summary 필드에 의도를 한 줄로 요약해.
반드시 위 5 라벨 중 하나만 사용해.
""")
.build();
}
messageRouter 와 마찬가지로 5 라벨 닫힌 집합 을 system 프롬프트에서 한 번 더 단단히 박아둡니다. Jackson 의 enum 역직렬화가 환각 라벨 을 자동 차단하는 자리.
마지막 — 페르소나 매칭 빈.
@Bean
public ChatClient personaMatcher(ChatClient.Builder builder, WorkflowLoggingAdvisor workflowLoggingAdvisor) {
return builder
.defaultAdvisors(workflowLoggingAdvisor)
.defaultSystem("""
너는 미연시 게임 AI 캐릭터의 페르소나(반말 + 따뜻한 친구 톤) 와 사용자 발화의
매칭 정도를 점수화하는 매칭 큐레이터야.
- matchScore: 0 ~ 100 정수. 100 에 가까울수록 페르소나가 자연스럽게 잡히는 발화.
욕설 · 차가운 어조 · 부적절한 시도는 점수가 낮다.
- suggestedTone: 캐릭터가 답할 때 권장하는 톤을 한 문장으로
(예: "따뜻하게 위로하기" / "장난스럽게 받아치기").
JSON 으로 matchScore 와 suggestedTone 두 필드만 돌려줘.
""")
.build();
}
세 빈 모두 — 같은 결.
system 프롬프트가 자기 분석 하나에만 집중 하고 / JSON 으로 N 자리만 돌려주는 명시가 박혀 있고 / defaultAdvisors(workflowLoggingAdvisor) 가 한 줄 들어가 있어요.
Step 7 의 advisor 가 11 번째 빈에서도 같은 자리에 박힐 결이 미리 잡혀 있는 모양.
Step 5 의 마무리 — 분석기 부품까지, 병렬 호출은 Step 6 의 몫
자, 3 트랙의 부품 까지 손에 들어왔어요.
3 enum / 3 record / 3 빈 의 부품이 박혔고, 서로 독립인 3 분석 의 정체가 정리됐어요.
한 가지 짚어둘 점 — 본 Step 의 코드만으론 아직 병렬 호출이 일어나지 않아요. 빈 셋이 등록됐을 뿐 셋이 동시에 도는 자리 는 다음 Step 의 몫이에요.
Step 6 에서 CompletableFuture.allOf + 결과 합산 + 직렬 vs 병렬 타임라인 비교 가 한꺼번에 들어옵니다.
💡 튜터의 결론
Parallelization 의 첫 절반은 — 서로 독립인 3 분석을 따로 분리해 두는 결정이에요. 한 LLM 이 3 가지 분석을 시도하던 자리에서 → 3 전문 분석기 3 명의 분업 으로 분산. 서로 의존하지 않는 구조라야 동시에 호출해도 결과가 그대로 라는 점이 Step 6 의 병렬 호출의 전제 조건. 다음 Step 에서
CompletableFuture.allOf+ 직렬 vs 병렬 타임라인 비교 가 한꺼번에 들어옵니다.
자, 분석기 부품이 손에 들어왔으니, Step 6 에서 셋을 동시에 굴리는 본체를 짭니다. 직렬로 부르면 지연이 3 배 / 동시에 부르면 지연이 1 배 + α 의 단축이 응답의 latencyMs 필드 로 직접 보이는 자리.
Step 6. Parallelization Part 2 — `CompletableFuture.allOf` + 직렬 vs 병렬 타임라인
3 트랙을 동시에 굴리는 본체.
CompletableFuture.allOf한 줄이 Parallelization 의 핵심 자리. 마지막엔 직렬 vs 병렬 타임라인 비교 로 단축 폭이 응답의latencyMs필드로 데이터화됩니다.
ParallelAnalysisService — CompletableFuture.allOf 한 줄이 본체
이제 Service 코드. 직렬 (call().entity(...) 를 일렬로 잇기) 과 병렬 (CompletableFuture.supplyAsync + allOf + join) 의 차이가 한 메서드 안에서 직접 보여요.
@Service
public class ParallelAnalysisService {
private final ChatClient sentimentAnalyzer;
private final ChatClient intentExtractor;
private final ChatClient personaMatcher;
public ParallelAnalysisService(
@Qualifier("sentimentAnalyzer") ChatClient sentimentAnalyzer,
@Qualifier("intentExtractor") ChatClient intentExtractor,
@Qualifier("personaMatcher") ChatClient personaMatcher
) {
this.sentimentAnalyzer = sentimentAnalyzer;
this.intentExtractor = intentExtractor;
this.personaMatcher = personaMatcher;
}
public ParallelAnalysisResponse analyze(String message) {
long startMs = System.currentTimeMillis();
CompletableFuture<SentimentAnalysis> sentimentFuture = CompletableFuture.supplyAsync(() ->
sentimentAnalyzer.prompt()
.user("다음 발화의 정서를 점수화해줘:\n\"" + message + "\"")
.call()
.entity(SentimentAnalysis.class));
CompletableFuture<IntentExtraction> intentFuture = CompletableFuture.supplyAsync(() ->
intentExtractor.prompt()
.user("다음 발화의 의도를 추출해줘:\n\"" + message + "\"")
.call()
.entity(IntentExtraction.class));
CompletableFuture<PersonaMatch> personaFuture = CompletableFuture.supplyAsync(() ->
personaMatcher.prompt()
.user("다음 발화에 캐릭터 페르소나가 자연스럽게 잡히는지 점수화해줘:\n\"" + message + "\"")
.call()
.entity(PersonaMatch.class));
CompletableFuture.allOf(sentimentFuture, intentFuture, personaFuture).join();
long latencyMs = System.currentTimeMillis() - startMs;
return new ParallelAnalysisResponse(
sentimentFuture.join(),
intentFuture.join(),
personaFuture.join(),
latencyMs
);
}
}
코드의 핵심을 한 줄씩 짚어봅시다.
CompletableFuture.supplyAsync(() -> ...)3 번. 세 분석을 각각 별도 스레드 에 던집니다.supplyAsync한 줄이 비동기 호출 을 시작하는 자리예요. 세 호출이 거의 동시에 시작되고, 각자의 스레드에서 LLM 응답을 기다립니다.CompletableFuture.allOf(...).join()한 줄. 셋 다 끝날 때까지 메인 스레드가 기다리는 자리.allOf가 모두 끝나면 완료되는 새 Future 를 만들고,.join()이 그게 완료될 때까지 메인 스레드를 멈춰 둬요. 셋 중 가장 오래 걸리는 호출이 끝나면 —allOf도 끝나고 메인 스레드가 다시 흘러갑니다.System.currentTimeMillis()로latencyMs측정. 시작 시점과allOf직후 시점의 차이가 3 트랙 동시 호출의 전체 소요 시간. 응답에 함께 흘려주면 학습용 데모에서 단축 폭이 데이터로 보여요.- 마지막
return에서.join()세 번. 각Future의.join()으로 결과 값을 꺼내 응답 record 에 담아요.allOf가 끝났으니 이.join()은 대기 없이 즉시 결과를 돌려줍니다.
직렬 vs 병렬 — 타임라인 비교
직렬로 부르면 어떻게 될까요. 한 호출이 끝날 때까지 다음 호출이 시작되지 않으니 지연이 세 호출의 합 이 돼요.
직렬 호출 (Prompt Chaining 결):
ms 0 ────[ sentiment ]────│
ms 600 ────[ intent ]────│
ms 1200 ────[ persona ]────│
ms 1800
전체 지연 = 600 + 600 + 600 ≒ 1800 ms
병렬로 부르면 — 셋이 거의 동시에 시작 되고, 가장 오래 걸리는 한 호출 이 끝나면 전체가 끝나요.
병렬 호출 (CompletableFuture.allOf):
ms 0 ────[ sentiment ]────│
ms 0 ────[ intent ]────────────│
ms 0 ────[ persona ]────────│
ms 800 (allOf 완료)
전체 지연 ≒ max(600, 700, 800) + α ≒ 800 ms
1800 ms → 800 ms — 약 2.3 배 단축 (실제 LLM 응답 시간에 따라 다름). 비용은 동일 합니다 (LLM 호출 3 회는 그대로) — 단축된 건 사용자가 기다리는 지연 뿐.
컨트롤러 — 한 엔드포인트
@RestController
public class ParallelAnalysisController {
private final ParallelAnalysisService parallelAnalysisService;
public ParallelAnalysisController(ParallelAnalysisService parallelAnalysisService) {
this.parallelAnalysisService = parallelAnalysisService;
}
@PostMapping("/api/workflow/parallel-analysis")
public ResponseEntity<ApiResponse<ParallelAnalysisResponse>> analyze(
@Valid @RequestBody ParallelAnalysisRequest request
) {
ParallelAnalysisResponse response = parallelAnalysisService.analyze(request.message());
return ResponseEntity.ok(ApiResponse.success(response));
}
}
Step 2, Step 4 와 같은 결의 컨트롤러. 서비스 한 줄 호출 + ApiResponse 래핑.
시연 — 응답의 latencyMs 가 보이는 자리
./run.sh 로 띄운 앱에서 curl 한 줄.
curl -X POST http://localhost:8080/api/workflow/parallel-analysis \
-H "Content-Type: application/json" \
-d '{"message":"사실 너 좋아해, 우리 데이트할래?"}'
응답은 대략 이렇게 떨어집니다.
{
"success": true,
"data": {
"sentiment": {
"sentiment": "POSITIVE",
"intensity": 82
},
"intent": {
"intent": "CONFESSION",
"summary": "캐릭터에게 호감 표현 + 데이트 제안"
},
"personaMatch": {
"matchScore": 88,
"suggestedTone": "따뜻하게 받아주되 살짝 부끄러워하는 결"
},
"latencyMs": 812
}
}
세 트랙의 결과가 한 응답에 깔끔하게 모여 있죠. 그리고 응답의 마지막 latencyMs: 812 — 3 트랙을 동시에 굴린 전체 소요 시간이 약 0.8 초. 만약 직렬로 굴렸다면 세 호출의 합인 약 2 ~ 3 초 가 나왔을 거예요. 단축 폭이 데이터로 직접 보이는 자리.
여러 메시지를 던져보면서 latencyMs 의 분포를 한 번 살펴보시면 — 셋 중 가장 느린 트랙에 의해 전체 지연이 결정 된다는 사실이 손에 잡혀요.
3 분석 중 한 트랙이 유난히 느리면 그게 전체를 늘린다 — Parallelization 의 bottleneck 의 자리. 운영에선 가장 느린 트랙을 모니터링 하는 게 전체 지연 개선의 첫 단계 입니다.
Parallelization 의 운영 가치 + 그림자
마지막으로 본 패턴의 운영 가치와 그림자 한 줄씩.
운영 가치 — 지연 단축, 사용자 체감. N 트랙 직렬 합 → max(N 트랙) + α 로 단축. 비용은 그대로지만 사용자가 기다리는 지연이 줄어든다 는 점이 게이미피케이션 신호 (예: 호감도 변화 추천을 실시간으로 보여주기) 자리에선 결정적인 가치.
⚠️ 그림자 — 부분 실패의 골치 아픔. 셋 중 한 트랙이 예외 로 깨지면 —
allOf().join()이 그 예외를 그대로 던져요. 셋 중 두 트랙은 성공했지만 한 트랙만 실패한 경우의 처리가 골치 아파져요. 운영에선 각 Future 에.exceptionally(...)를 박아 부분 실패 시 fallback 으로 흘리는 결로 풀어요. Day 13 의 본 코드는 해피 패스 까지만 들고 있고, 부분 실패 처리는 생각해볼 주제로 회수.
💡 튜터의 결론
Parallelization 의 핵심 한 줄. 서로 독립인 N 작업을
CompletableFuture.allOf한 줄로 동시에 굴리면 / 지연이 max(N) + α 로 단축된다. 비용은 동일하지만 사용자 체감 응답 시간 이 단축되는 게 본 패턴의 운영 가치. 다만 부분 실패의 골치 가 그림자로 따라온다는 점도 함께 챙겨가세요.
자, Workflow 3 패턴 (Prompt Chaining / Routing / Parallelization) 의 손코딩이 모두 끝났어요. 11 ChatClient 빈 + 3 패턴별 Service / Controller 가 다 박혔습니다.
그런데 한 가지.
11 빈 메서드 시그니처에 계속 등장한 WorkflowLoggingAdvisor 가 아직 정체가 모호하죠.
Step 7 에서 그 자리를 정식으로 익혀봅니다.
어드바이저 = 가드의 선언적 형태의 시작점.
다음 시간 (Day 14) 의 가드 4 부품으로 자랄 씨앗이에요. 🧶
Step 7. `WorkflowLoggingAdvisor` — 어드바이저 첫 등장 🧶
오늘의 매듭. 11 ChatClient 빈 위에 가로보처럼 박히는 어드바이저 한 개를 만들어요.
BaseAdvisor인터페이스의 첫 등장 자리 — 다음 시간 (Day 14) 의 가드 4 부품 (maxIterations / 토큰 예산 / 도구 호출 횟수 / 타임아웃) 이 같은 인터페이스 위에 자랄 씨앗이에요.
어드바이저란 무엇인가 — 가드의 선언적 형태의 시작점 🛡️
지난 시간 마지막에 한 줄 던져둔 키워드가 있어요. ChatClient.Advisor — Day 13 부터 본격 등장. 가드의 선언적 형태의 시작점. 그게 오늘 코드로 등장하는 자리입니다.
어드바이저는 ChatClient 호출 전 / 후에 끼어드는 중간자 예요.
사용자의 user 프롬프트가 LLM 에게 전달되기 직전 에 손을 댈 수 있고, LLM 의 응답이 직후 에 또 한 번 손을 댈 수 있어요.
일종의 AOP (Aspect-Oriented Programming) 의 ChatClient 버전 이라고 생각하시면 됩니다.
오늘 만들 WorkflowLoggingAdvisor 는 가장 단순한 어드바이저.
로깅 한 가지만 들고 있어요.
before 에서 프롬프트 길이를 로그 로 남기고, after 에서 응답 길이 + 소요 시간을 로그 로 남기는 한 부품.
다음 시간 (Day 14) 의 가드 어드바이저 4 부품.
maxIterations / 토큰 예산 / 도구 호출 횟수 / 타임아웃.
이 같은 인터페이스 위에 자랍니다.
오늘 한 부품을 손에 들어두면 / 다음 시간엔 같은 형태로 4 부품이 한꺼번에 박혀요.
BaseAdvisor 인터페이스 — Spring AI 1.1.x 의 자리
Spring AI 1.1.x 의 BaseAdvisor 인터페이스는 세 메서드 만 구현하면 돼요.
public interface BaseAdvisor extends CallAdvisor, StreamAdvisor {
// 호출 직전 — 프롬프트에 손을 댈 수 있는 자리
ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain);
// 호출 직후 — 응답에 손을 댈 수 있는 자리
ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain);
// 어드바이저 체인의 우선순위 — Ordered 인터페이스에서 상속
// int getOrder();
}
before 와 after — 두 자리가 핵심이에요.
before 의 반환값이 체인의 다음 단계로 흘러갈 ChatClientRequest, after 의 반환값이 호출자에게 돌아갈 ChatClientResponse.
두 자리에서 프롬프트 / 응답을 수정 하거나 예외를 던져 호출을 차단 하거나 부수 효과 (로그 / 메트릭 / 알림) 를 일으킬 수 있어요.
다음 시간의 가드 어드바이저들도 바로 이 두 자리에서 가드 로직을 실행합니다.
getOrder() 는 Advisor 인터페이스가 Ordered 를 상속 하니까 추가로 구현해야 해요. 한 ChatClient 에 여러 어드바이저가 박힌 경우 어느 어드바이저가 먼저 실행될지 결정하는 자리. 본 강의에선 그냥 0 으로 박아두면 충분합니다.
WorkflowLoggingAdvisor 본체 — 길이와 시간만 남기는 부품
이제 본체. PII 마스킹 원칙 에 따라 프롬프트 / 응답 본문은 로그에 절대 박지 않아요. 길이 + 소요 시간만 한 줄 로그.
@Component
public class WorkflowLoggingAdvisor implements BaseAdvisor {
private static final Logger log = LoggerFactory.getLogger(WorkflowLoggingAdvisor.class);
private static final ThreadLocal<Long> START_MS = new ThreadLocal<>();
@Override
public ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain) {
START_MS.set(System.currentTimeMillis());
int promptLength = chatClientRequest.prompt() == null
? 0
: chatClientRequest.prompt().getContents().length();
log.info("[WorkflowLoggingAdvisor] before — promptLength={}", promptLength);
return chatClientRequest;
}
@Override
public ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain) {
Long start = START_MS.get();
START_MS.remove();
long elapsedMs = start == null ? -1L : System.currentTimeMillis() - start;
int responseLength = chatClientResponse.chatResponse() == null
|| chatClientResponse.chatResponse().getResult() == null
|| chatClientResponse.chatResponse().getResult().getOutput() == null
? 0
: chatClientResponse.chatResponse().getResult().getOutput().getText().length();
log.info("[WorkflowLoggingAdvisor] after — responseLength={}, elapsedMs={}", responseLength, elapsedMs);
return chatClientResponse;
}
@Override
public int getOrder() {
return 0;
}
}
코드를 한 줄씩 짚어봅시다.
@Component— Spring 빈으로 등록. 본 강의의 다른 어드바이저들이 자동 탐지 의 자리가 아니라 명시적 등록 의 결을 따라요.@Component로 빈만 만들어두고, 11 빈 각각에서.defaultAdvisors(workflowLoggingAdvisor)한 줄로 명시적으로 박는 모양. 어느 빈에 어떤 어드바이저가 박혔는지가 한눈에 잡힙니다.ThreadLocal<Long> START_MS— 호출 시작 시각 보관.before에서 시각을 박고after에서 차이를 계산. 호출이 끝나면remove()로 ThreadLocal 의 메모리 누수 를 방지.chatClientRequest.prompt().getContents().length()— 프롬프트 길이만 박음. 본문은 절대 안 박아요. 본 강의의 PII 마스킹 원칙 — 프롬프트 / 응답 로그 저장 시 PII 마스킹. 길이는 민감 정보가 아니라 운영 신호 (예: 비정상적으로 긴 프롬프트 = 인젝션 시도) 라 그대로 박아도 안전. 🛡️after의 null 체크 3 단.chatClientResponse.chatResponse()/getResult()/getOutput()— Spring AI 1.1.x 의 응답 객체 그래프에서 어디서든 null 가능 한 자리들이라 안전하게 길이 0 으로 다운그레이드. 어드바이저가 프로덕션 호출 흐름에서 NPE 로 깨지는 사고 가 일어나지 않도록 방어.
11 빈에 박힌 결 — 가로보처럼 가로지르는 advisor
자, advisor 가 만들어졌으니 11 빈 모두에 한 줄씩 박힌 모습 을 한 번 회수해 봅시다. Step 2 ~ 6 의 빈 메서드들이 모두 같은 패턴 으로 advisor 를 등록하고 있어요.
// Step 2 의 3 빈
public ChatClient safetyClassifier(ChatClient.Builder builder, WorkflowLoggingAdvisor workflowLoggingAdvisor) {
return builder
.defaultAdvisors(workflowLoggingAdvisor) // ← 어드바이저 한 줄
.defaultSystem("...")
.build();
}
// replyDrafter / personaToneAuditor 도 같은 결
// Step 3-4 의 5 빈
public ChatClient messageRouter(ChatClient.Builder builder, WorkflowLoggingAdvisor workflowLoggingAdvisor) {
return builder
.defaultAdvisors(workflowLoggingAdvisor) // ← 같은 줄
.defaultSystem("...")
.build();
}
// faqHandler / affinityHandler / safetyAlertHandler / casualChatHandler 도 같은 결
// Step 5-6 의 3 빈
public ChatClient sentimentAnalyzer(ChatClient.Builder builder, WorkflowLoggingAdvisor workflowLoggingAdvisor) {
return builder
.defaultAdvisors(workflowLoggingAdvisor) // ← 또 같은 줄
.defaultSystem("...")
.build();
}
// intentExtractor / personaMatcher 도 같은 결
11 빈 모두 — 같은 advisor 한 줄 이 박혀 있어요. 어느 빈을 호출하든 같은 양식의 로그 가 떨어집니다. 운영에서 어느 ChatClient 가 얼마나 자주 호출되는지 / 평균 지연이 어떤지 를 한 곳에서 추적할 수 있는 자리.
로그 — ./run.sh 의 출력에서 확인하기
./run.sh 로 띄운 앱에서 Step 2 의 curl 한 줄을 다시 던져보면, 로그 출력에 advisor 의 흔적이 떨어집니다.
INFO ...WorkflowLoggingAdvisor : [WorkflowLoggingAdvisor] before — promptLength=78
INFO ...WorkflowLoggingAdvisor : [WorkflowLoggingAdvisor] after — responseLength=124, elapsedMs=612
INFO ...WorkflowLoggingAdvisor : [WorkflowLoggingAdvisor] before — promptLength=92
INFO ...WorkflowLoggingAdvisor : [WorkflowLoggingAdvisor] after — responseLength=85, elapsedMs=534
INFO ...WorkflowLoggingAdvisor : [WorkflowLoggingAdvisor] before — promptLength=48
INFO ...WorkflowLoggingAdvisor : [WorkflowLoggingAdvisor] after — responseLength=156, elapsedMs=489
3 단 Prompt Chaining 의 3 호출 이 모두 같은 advisor 라인 으로 떨어졌어요. promptLength / responseLength / elapsedMs 세 자리만 노출 — 본문 0 글자 노출 안 함. 운영 추적의 가장 작은 시작점이 11 빈 모두에 통일된 양식으로 박힌 모습이에요.
Day 14 복선 — 가드 4 부품이 같은 인터페이스 위에 자란다
마지막으로 오늘 advisor 한 부품이 어디로 자라나는지 의 복선을 두 줄 짚고 갑시다.
다음 시간 (Day 14) 의 가드 4 부품.
maxIterationsAdvisor / 토큰 usageBudgetAdvisor / 도구 호출 횟수 toolInvocationCounterAdvisor / durationTimeoutAdvisor.
이 모두 같은 BaseAdvisor 인터페이스를 구현해요. 오늘 손에 든 WorkflowLoggingAdvisor 의 before / after / getOrder 세 자리가 다음 시간 4 부품에서 같은 자리에 박힙니다.
한 부품을 손에 들면 4 부품이 같은 패턴으로 자연스럽게 따라옵니다.
그리고 가드 부품들의 핵심 차이 — 예외를 던져 호출 흐름을 차단 한다는 점. 오늘의 advisor 는 로깅만 들고 있어서 흐름에 손을 안 대요. 하지만 다음 시간의 가드들은 임계값을 넘으면 즉시 예외 를 던져서 Agent 의 폭주를 차단 합니다. 같은 인터페이스 위에 / 책임의 무게만 다른 결로 자라요. 🛡️
Day 13 → Day 14 어드바이저 자라기
Day 13 Day 14 책임 WorkflowLoggingAdvisor(그대로) 호출 길이 + 시간 로깅 (없음) MaxIterationsAdvisor한 사이클 호출 횟수 상한 (없음) UsageBudgetAdvisor토큰 누적 상한 (없음) ToolInvocationCounterAdvisor도구 호출 횟수 상한 (없음) DurationTimeoutAdvisor전체 소요 시간 상한 5 부품 모두 같은
BaseAdvisor인터페이스 —before / after / getOrder세 자리 를 채워서 자라요.
💡 튜터의 결론
어드바이저의 핵심 한 줄. ChatClient 호출의 가로보 —
before / after두 자리에서 끼어드는 중간자. 로깅 / 가드 / 메모리가 모두 같은 인터페이스 위에 자란다. 오늘은 로깅 한 부품 만 들고, 다음 시간 (Day 14) 에서 가드 4 부품 이 같은 형태로 한꺼번에 박혀요. 한 부품을 손에 들면 4 부품이 자연스럽게 따라옵니다.
자, Step 7 까지 끝났어요. 11 빈 + 3 패턴별 Service / Controller + 1 advisor 가 모두 박혔습니다. 다음 절에서 오늘의 7 Step 을 한 줄씩 회수 하고 다음 시간 (Day 14) 의 문을 두드리는 마무리로 들어가 봅시다.
마무리
자, Day 13 의 모든 매듭이 닫혔어요.
지난 시간 마지막 한 줄 — "이름표 → 다음 시간엔 진짜 코드 한 줄씩." 그 약속이 오늘 끝에서 진짜로 지켜졌습니다.
Workflow 3 패턴 (Prompt Chaining / Routing / Parallelization) 이 평범한 Java + ChatClient + advisor 한 부품 으로 어떻게 떨어지는지 — 손에 들어왔어요.
오늘 익힌 흐름 — 7 Step 압축 회고 ✅
| Step | 한 줄 정리 |
|---|---|
| ✅ Step 1 | Workflow 3 패턴 위치 잡기 — 코드의 흐름을 누가 결정하느냐의 농도 차이 + 미연시 게임 3 시나리오 매핑 |
| ✅ Step 2 | Prompt Chaining 3 단 직렬 — 안전 분류 → 답장 초안 → 페르소나 톤 검수 / ABUSE / ESCALATE 분기 종료 |
| ✅ Step 3 | Routing Part 1 — messageRouter 가 FAQ / AFFINITY / SAFETY / CASUAL 4 라벨링 |
| ✅ Step 4 | Routing Part 2 — switch 분기 + 4 전문 핸들러 + Day 11 도구 (AffinityTool / WeatherTool) 회수 |
| ✅ Step 5 | Parallelization Part 1 — 감정 / 의도 / 페르소나 매칭 3 분석기 부품 박기 |
| ✅ Step 6 | Parallelization Part 2 — CompletableFuture.allOf + 직렬 vs 병렬 타임라인 비교 |
| ✅ Step 7 | WorkflowLoggingAdvisor — BaseAdvisor 인터페이스 첫 등장 + 11 빈에 가로보처럼 박힘 |
일곱 Step 이 한 줄로 흐르면 — "외부 그래프 DSL 한 줄 없이 Workflow 3 패턴이 평범한 Java + ChatClient 로 다 떨어진다" 라는 한 줄이 손에 들어와 있을 거예요. 그게 오늘 가장 단단한 자산.
본 강의의 Workflow 3 패턴 정의 — 한 번 더 챙기기
🎯 본 강의의 Workflow 3 패턴 정의
Workflow 3 패턴 = 전문 ChatClient N 개 + 평범한 Java orchestrate + 공통 어드바이저 한 개.
오늘 박은 11 ChatClient 빈 + 3 패턴별 Service + 1 advisor 가 정확히 이 정의의 세 부품 이에요.
N 개 전문 ChatClient (system 프롬프트가 빈마다 깊다) + 평범한 Java orchestrate (직렬 호출 / switch / CompletableFuture.allOf) + 공통 advisor (11 빈 위에 가로보처럼 박힘) 이 한 코드베이스 안에 함께 살아 있어요.
Day 11 도구 3 종의 회수 현황 한 번 더
| 도구 | Day 11 시점 | Day 13 회수 자리 |
|---|---|---|
WeatherTool.getCurrentWeather |
분류대 위 — Tool Calling 시작점 | casualChatHandler 의 .defaultTools(...) 자리 |
AffinityTool.getAffinity |
분류대 위 — Tool Calling 시작점 | affinityHandler 의 .defaultTools(...) 자리 |
GameStateTool.loadGameState |
분류대 위 — Tool Calling 시작점 | (오늘 회수 안 함 — 별도 핸들러 미작성) |
GameStateTool.saveGameState |
가드의 자리 후보 | 다음 시간 (Day 14) 회수 예정 — 가드 어드바이저로 보호되는 자리 |
지난 시간 분류대 위에 모여 있던 4 도구 중 — 2 도구가 오늘 Routing 노드 안 으로 / 1 도구는 회수 안 됨 / 1 도구는 다음 시간 가드 자리 로. 4 도구의 길이 한 줄로 자라났어요.
다음 시간 (Day 14) 의 문 두드리기 — Agent 2 패턴 + 가드 4 부품 손코딩
자, 오늘의 마지막이자 다음 시간의 첫 글자.
오늘까지 우리는 Workflow 의 왼쪽 3 패턴 — 코드 손에 주도권이 있는 결정론적인 흐름 을 손코딩으로 박았어요. 다음 시간엔 스펙트럼의 오른쪽 — 자율 결정과 루프가 들어오는 Agent 2 패턴 으로 넘어갑니다.
💡 다음 시간 (Day 14) 의 결정적인 새 키워드 5 가지
- Orchestrator-Workers — 오케스트레이터 LLM 한 명이 / 워커 LLM 들에게 작업을 자율 분배 하는 패턴. 미연시 도메인의 그룹 대화 분배 시나리오 위에서 손에 익힘.
- Evaluator-Optimizer — 생성 LLM + 평가 LLM 두 명이 피드백 루프를 도는 패턴. 생성 → 평가 → 재생성 → ... → PASSED 의 자율 루프.
MaxIterationsAdvisor— 한 사이클 호출 횟수 상한 가드. 오늘의WorkflowLoggingAdvisor와 같은 인터페이스 위에 자라요.UsageBudgetAdvisor— 토큰 누적 상한 가드. 비용 폭주 차단.- 가드 4 부품 —
MaxIterations / UsageBudget / ToolInvocationCounter / DurationTimeout— 본 강의의 Harness 5 요소 중 4 부품을 손으로 짜는 시간.
오늘의 advisor 한 부품 이 다음 시간 가드 4 부품 으로 자라요. 한 부품의 패턴을 손에 들면 — 4 부품이 같은 형태로 자연스럽게 따라옵니다. 오늘은 흐름의 결정론, 다음 시간엔 자율의 무게 — 이 호흡으로 한 발 옮겨갑니다.
🎯 Mission — 오늘의 과제
오늘 손으로 박은 Workflow 3 패턴 + advisor 한 부품 을 한 번 더 변형해서 손에 단단히 새기는 시간이에요.
Day 13 은 코드가 무거운 Day 였어요.
그래서 과제도 손코딩의 무게를 단단히 — 직렬 한 단 추가 / 분기 한 갈래 추가 / 병렬 한 트랙 추가 의 세 갈래로.
한 갈래씩 쌓이면서 3 패턴의 변형 근육 이 손에 들어옵니다.
[구현 1] Prompt Chaining 4 단으로 확장 — 답장 후 호감도 변화 추천 단 추가
배경 시나리오
ai-friends 의 PM 이 오늘 만든 PromptChainingService 를 보고 한 가지 부탁을 들고 왔어요.
"튜터님, Step 2 의 3 단 직렬 — 안전 분류 → 답장 초안 → 페르소나 톤 검수 그대로 두고, 마지막에 한 단을 더 얹어 주실 수 있을까요? 답장이 만들어진 뒤에 / 이 답장을 보내면 캐릭터의 호감도가 얼마나 변할지 추천 하는 4 단을 끝에 박았으면 합니다. 게이미피케이션의 신호로 호감도 변화가 답장과 함께 응답에 떠 있으면 운영 대시보드에 한 줄 통계가 더 자연스러워져요."
3 단 직렬에 4 번째 단 을 한 자리 더 얹는 작업이에요. 새 패턴이 아니라 기존 패턴을 한 단 더 늘리는 호흡 — Prompt Chaining 의 자연스러운 확장 방향 을 손에 익히는 자리.
💡 왜 굳이 이 과제를 할까요?
- 3 단 → 4 단 — 직렬 확장의 손맛. 기존 3 단의 흐름을 유지한 채 새 단을 끝에 얹는 변형이라, Prompt Chaining 의 확장이 얼마나 자연스러운지 가 손에 들어와요. 새 ChatClient 빈 한 개 + 새 record 한 개 + Service 의 마지막에 한 줄 호출 추가 — 그게 끝.
- 재사용 가능한 패턴 — Service 한 줄 추가의 흐름. 기존 코드의 골격을 손대지 않고 끝에 얹기만 하면 되는 변형이라, Open-Closed Principle 의 결 이 자연스럽게 손에 잡혀요. 운영에서 기존 단을 손대지 않고 새 단만 얹는 변경은 회귀 위험 이 낮아 안전.
- 호감도 시스템과의 자연스러운 결합. 미연시 게임의 호감도 게이미피케이션 신호가 Prompt Chaining 의 마지막 단 으로 자연스럽게 흡수돼요. AI 분석 결과 → 게임 메커니즘 의 연결 자리.
✅ 요구사항
- 새 record
AffinityImpact(int deltaScore, String reason)추가 —workflow/dto/에.deltaScore는 -10 ~ +10 정수,reason은 변화 근거 한 줄. - 새 빈
affinityImpactPredictor등록 —WorkflowChatClientConfig에. system 프롬프트는 "답장이 사용자에게 전달됐을 때 캐릭터의 호감도가 얼마나 변할지 추천" 결로. JSON 으로deltaScore+reason두 자리 돌려달라. PromptChainingResponse확장 —AffinityImpact affinityImpact필드 추가.
PromptChainingService.chain(...) 의 마지막에 4 단 호출 추가 — 3 단 검수 후 audit.finalReply() 를 입력으로 받아 affinityImpactPredictor.prompt()...entity(AffinityImpact.class) 호출.
ABUSE / ESCALATE 분기에선 4 단도 건너뛰고 affinityImpact 를 null 로.
5. 컨트롤러는 변경 없음 — 응답 record 확장이 자동으로 흘러가도록.
6. 시연 — curl 한 줄로 확장된 응답 확인. affinityImpact: { deltaScore: 5, reason: "..." } 자리가 응답에 떠야 함.
보상
- 🛡️ Prompt Chaining 패턴의 직렬 확장 손맛. 새 단을 어디에 어떻게 얹는지 가 손에 들어옴.
- 🎯 게이미피케이션 신호와 AI 패턴의 자연스러운 결합. 호감도 변화 추천이 답장과 같은 응답에 떠 있는 모양.
[구현 2] Routing 에 새 갈래 추가 — GAME_COMMAND 라벨로 시스템 명령 분기
배경 시나리오
PM 의 두 번째 부탁.
"Routing 의 4 갈래는 잘 동작하는데, 한 가지 빠진 결이 있어요. 사용자가 채팅창에 시스템 명령 (예: /help, /reset, /profile) 같은 슬래시 명령을 던지면 기존 4 갈래로는 분류가 흔들리거든요. CASUAL 로 떨어지기도 하고 FAQ 로 떨어지기도 해요. 시스템 명령 전용 갈래 를 한 자리 더 추가했으면 합니다."
라우터 패턴에 5 번째 라벨 을 한 자리 더 얹는 작업이에요. enum 확장 + 새 핸들러 빈 + switch 분기 한 자리 추가.
✅ 요구사항
RouteLabelenum 에GAME_COMMAND라벨 추가 — 5 라벨로 확장.messageRouter의 system 프롬프트에 5 번째 라벨 안내 추가 — "GAME_COMMAND: /help, /reset, /profile 같은 슬래시 명령 발화" 같은 결.- 새 빈
gameCommandHandler등록 — 슬래시 명령에 대해 짧고 사실 위주의 시스템 응답 을 돌려주는 결로. (예: /help → "사용 가능한 명령은 /help, /reset, /profile 이에요") MessageRoutingService.route(...)의switch에case GAME_COMMAND분기 추가 —gameCommandHandler위임.- 컨트롤러는 변경 없음 — 응답 record 의
label필드가 자동으로GAME_COMMAND도 직렬화하도록. - 시연 —
/help메시지로 분기 확인.label: "GAME_COMMAND"가 응답에 떠야 함.
보상
- 🛡️ Routing 패턴의 분기 확장 손맛. enum 한 줄 + 빈 한 개 + switch 한 자리 — Open-Closed Principle 의 결 이 분기 패턴에도 그대로 통한다는 사실.
- 🎯 exhaustive switch 의 컴파일러 안전성. enum 라벨 추가 시 어디를 손봐야 하는지 가 컴파일 에러로 즉시 잡힘.
[구현 3] Parallelization 의 부분 실패 처리 — .exceptionally() 한 줄로 fallback
배경 시나리오
PM 의 마지막 부탁.
"Parallelization 의 3 트랙 — 운영 트래픽에서 한 트랙만 가끔 깨지는 일이 생기더라고요. (LLM 제공사의 일시 장애 / 토큰 한도 초과 / 네트워크 글리치) 지금 코드는 한 트랙이 깨지면 전체 응답이 실패 로 떨어져요. 나머지 두 트랙은 성공했으니까 그 두 결과는 살려서 응답하고, 깨진 트랙은 기본값으로 다운그레이드 해 주면 좋겠어요." ⚠️
부분 실패의 처리. CompletableFuture 의 .exceptionally() 한 줄로 fallback 결과 를 박는 결.
✅ 요구사항
ParallelAnalysisService.analyze(...)의 3CompletableFuture각각에.exceptionally(throwable -> ...)추가 — 예외 발생 시 기본값 record 로 다운그레이드.- 기본값 정의 (각 트랙)
SentimentAnalysis→new SentimentAnalysis(Sentiment.NEUTRAL, 0)+ `note 필드를 record 에 추가하지 말고 / 로그로 fallback 흔적만 남기기*IntentExtraction→new IntentExtraction(Intent.OTHER, "분석 실패로 OTHER 로 다운그레이드")PersonaMatch→new PersonaMatch(0, "분석 실패")
allOf().join()직후 — 어느 트랙이 fallback 으로 다운그레이드됐는지 로그 한 줄.log.warn("[ParallelAnalysisService] track {} fell back to default", trackName)같은 결.ParallelAnalysisResponse는 변경 없음 — 응답 record 그대로.- 시연 — 일부러 한 트랙을 깨뜨려 보기. 예:
sentimentAnalyzersystem 프롬프트를 의도적으로 잘못된 JSON 출력 강제 로 바꿔서 역직렬화 실패 유도. 나머지 두 트랙의 결과는 응답에 살아 있어야 함.
보상
- 🛡️ Parallelization 의 부분 실패 회복력 손맛. 운영 트래픽의 작은 글리치 가 전체 응답 실패 로 번지지 않도록 각 Future 가 자기 책임으로 fallback 하는 결.
- 🎯
CompletableFuture의.exceptionally()API 한 줄로 표현되는 resilience 패턴. Spring Reactor 의.onErrorResume(...)와도 결이 같음.
생각해볼 주제
오늘 손으로 박은 Workflow 3 패턴 은 — 코드의 흐름이 코드 손에 있는 가장 단단한 자리예요. 그런데 운영에 들어가면 3 패턴 모두 그림자 가 따라옵니다. 비용이 늘고 / 분류가 흔들리고 / 부분 실패가 나요. 오늘 박은 3 패턴이 운영에서 어떤 결로 살아남을지 — 머리에서 한 번 더 굴려볼 주제 셋.
주제 1 — Prompt Chaining 의 비용 3 배 는 언제 가치 있을까
Step 2 의 가장 큰 trade-off 가 비용 3 배 vs 정확도 + 톤 일관성의 단단함. 한 호흡이면 끝날 작업을 3 호흡으로 나누면 LLM 호출 비용 + 지연이 3 배 로 뛰어요. 그런데 3 호흡의 가치 가 비용 3 배보다 무거운 경우 도 분명히 있죠.
본인의 실무 / 사이드 프로젝트 / 가상 기획안 위에서 — Prompt Chaining 의 N 단 분해가 가치 있는 시나리오 와 한 호흡으로 충분한 시나리오 를 각각 한 가지씩 떠올려보세요.
어느 축에서 저울이 기우는지 — 사용자 트래픽 / 톤 일관성의 비즈니스 영향 / 부적절한 결과가 새는 비용 / 호출 비용의 비교 단위 등의 축을 고려해서 정리해 보세요.
주제 2 — Routing 의 라우터 LLM 이 환각으로 새 라벨 을 만들면
Step 3 의 messageRouter 가 4 라벨 중 하나만 사용해라 라고 system 프롬프트에 박았지만 — LLM 은 본질적으로 비결정론적 이라 간혹 환각으로 새 라벨 을 만들어내요.
오늘 코드는 enum 의 닫힌 집합 + Jackson 역직렬화 가 1 차 방어선이고, 환각 라벨이 들어오면 예외로 깨지는 결이에요.
운영 환경에선 예외로 깨지는 게 최선 일까요, 아니면 fallback 으로 다운그레이드 하는 결이 더 안전할까요? fallback 으로 가는 경우 — 어느 라벨로 다운그레이드하는 게 맞을지의 결정 기준은? 환각 라벨 발생 빈도가 일정 임계값을 넘으면 운영팀에 알림이 가야 할까요? 한 번 생각해보세요.
주제 3 — Parallelization 의 부분 실패 처리 — 응답을 깎을까, 막을까
Step 6 의 그림자였던 부분 실패의 골치. 3 트랙 중 한 트랙이 깨지면 — 어떻게 처리할지 의 결정이 비즈니스 임팩트 에 따라 달라져요.
깨진 트랙의 결과를 기본값으로 다운그레이드해서 두 트랙의 결과는 살리기 (resilience 우선) vs 세 트랙 모두 성공해야 응답을 내보내기 (정합성 우선) — 어느 쪽이 맞을까요?
본인의 실무에서 부분 실패에 어떻게 대응 하는 자리 (예: 마이크로서비스 호출 / 외부 API 합산 / 검색 결과 조합) 가 있다면 — 그 자리의 fallback 정책 과 오늘의 Parallelization 의 fallback 정책 을 한 번 나란히 놓고 비교해 보세요.
resilience 의 적절한 무게 가 어디서 결정되는지 — 그 결정의 근거가 정리됩니다.
✅ 예시 답안정답 보기
수업에서 PromptChainingService 의 3 단 직렬 (안전 분류 → 답장 초안 → 페르소나 톤 검수) 까지 손에 들었어요. 운영팀의 부탁대로 — 답장이 만들어진 뒤에 / 이 답장을 보내면 캐릭터의 호감도가 얼마나 변할지 추천 하는 4 단을 끝에 한 자리 더 얹어볼게요.
이 과제가 끝나면 응답에 affinityImpact 자리가 새로 떠 있어요.
{
"safetyLabel": "NORMAL",
"draft": "응 나도 만나고 싶어",
"tonePassed": true,
"finalReply": "응 나도 너 보고 싶어. 주말에 만날까?",
"affinityImpact": {
"deltaScore": 6,
"reason": "긍정적인 만남 제안에 따뜻하게 호응 → 호감도 상승"
}
}
3 단의 골격은 그대로 두고 끝에 한 단만 얹는 결의 변형이에요. Prompt Chaining 의 직렬 확장이 얼마나 자연스러운지 가 손에 들어옵니다.
🎯 채점 포인트
| 포인트 | 설명 |
|---|---|
AffinityImpact record 추가 |
deltaScore (int, -10 ~ +10) + reason (String) 두 자리. |
affinityImpactPredictor 빈 등록 |
WorkflowChatClientConfig 에 4 번째 단 전용 ChatClient. system 프롬프트가 deltaScore + reason 두 자리만 다루도록 짧게. |
PromptChainingResponse 확장 |
AffinityImpact affinityImpact 필드 추가. |
PromptChainingService 의 4 단 호출 |
3 단(audit) 직후 audit.finalReply() 를 입력으로 4 단 호출. |
| ABUSE / ESCALATE 분기에서도 안전 | 차단 응답엔 affinityImpact = null 로 다운그레이드. |
시연 curl 로 응답에 4 자리 보임 |
affinityImpact.deltaScore / affinityImpact.reason 두 자리. |
Step 1. `AffinityImpact` record 추가
// kr/spartaclub/aifriends/workflow/dto/AffinityImpact.java
package kr.spartaclub.aifriends.workflow.dto;
/**
* Day 13 과제 1 — Prompt Chaining 4 단(호감도 변화 추천) 산출물.
*
* <p>답장이 사용자에게 전달됐을 때 캐릭터의 호감도가 얼마나 변할지 LLM 이 추천한 결과다.
* 게이미피케이션의 신호로 답장과 함께 응답에 흐른다.</p>
*
* @param deltaScore 호감도 변화 (-10 ~ +10 정수). 음수면 호감도 하락.
* @param reason 변화 근거 한 줄 — 디버그 / 로깅용 + 운영 대시보드 통계 입력.
*/
public record AffinityImpact(
int deltaScore,
String reason
) { }
deltaScore 의 범위를 -10 ~ +10 정수 로 잡은 이유 — 한 메시지로 호감도가 너무 크게 변하지 않게 운영상 상한을 둔 결. 호감도 시스템은 작은 변화의 누적 으로 자라는 게 자연스럽지, 한 메시지로 +50 같은 변동이 일어나면 게임 메커니즘이 무너져요.
Step 2. `affinityImpactPredictor` 빈 등록
// kr/spartaclub/aifriends/workflow/config/WorkflowChatClientConfig.java
/**
* Day 13 과제 1 — Prompt Chaining 4 단(호감도 변화 추천) 전용 ChatClient.
*
* <p>3 단의 finalReply 를 입력으로 받아 호감도 변화를 -10 ~ +10 으로 추천한다.</p>
*/
@Bean
public ChatClient affinityImpactPredictor(ChatClient.Builder builder, WorkflowLoggingAdvisor workflowLoggingAdvisor) {
return builder
.defaultAdvisors(workflowLoggingAdvisor)
.defaultSystem("""
너는 미연시 게임 안에서 캐릭터의 답장이 사용자에게 전달됐을 때
캐릭터의 호감도가 얼마나 변할지 추천하는 예측기야.
- deltaScore: -10 ~ +10 정수.
* +값: 답장이 사용자와의 관계를 더 가깝게 만드는 결.
* -값: 답장이 어색한 거리감을 만드는 결 (희귀하지만 가능).
* 0: 변화 없음.
- reason: 변화 근거를 한 줄로 (예: "긍정적인 만남 제안에 호응 → 호감도 상승").
JSON 으로 deltaScore 와 reason 두 필드만 돌려줘.
""")
.build();
}
system 프롬프트의 결이 Step 2 의 다른 3 빈과 같은 결. 자기 일 하나 (호감도 변화 추천) 만 들고 있고, JSON 으로 두 자리만 돌려달라는 명시가 .entity(AffinityImpact.class) 의 자동 역직렬화와 짝을 이뤄요.
Step 3. `PromptChainingResponse` 확장
// kr/spartaclub/aifriends/workflow/dto/PromptChainingResponse.java
public record PromptChainingResponse(
SafetyLabel safetyLabel,
String draft,
boolean tonePassed,
String finalReply,
AffinityImpact affinityImpact // ← 추가
) { }
기존 4 자리 끝에 affinityImpact 한 자리 추가. Nullable 로 두면 ABUSE / ESCALATE 분기에선 null 로 자연스럽게 다운그레이드.
Step 4. `PromptChainingService` 의 4 단 호출 추가
// kr/spartaclub/aifriends/workflow/service/PromptChainingService.java
// 변경: 생성자에 affinityImpactPredictor 주입 + chain() 의 끝에 4 단 호출 추가
@Service
public class PromptChainingService {
private final ChatClient safetyClassifier;
private final ChatClient replyDrafter;
private final ChatClient personaToneAuditor;
private final ChatClient affinityImpactPredictor; // ← 추가
public PromptChainingService(
@Qualifier("safetyClassifier") ChatClient safetyClassifier,
@Qualifier("replyDrafter") ChatClient replyDrafter,
@Qualifier("personaToneAuditor") ChatClient personaToneAuditor,
@Qualifier("affinityImpactPredictor") ChatClient affinityImpactPredictor
) {
this.safetyClassifier = safetyClassifier;
this.replyDrafter = replyDrafter;
this.personaToneAuditor = personaToneAuditor;
this.affinityImpactPredictor = affinityImpactPredictor;
}
public PromptChainingResponse chain(String message) {
// 1 단 — 안전 분류 (변경 없음)
MessageSafetyClassification classification = safetyClassifier.prompt()
.user("다음 메시지를 분류해줘:\n\"" + message + "\"")
.call()
.entity(MessageSafetyClassification.class);
// ABUSE / ESCALATE 차단 — affinityImpact 는 null 로 다운그레이드
if (classification.label() != SafetyLabel.NORMAL) {
String blocked = classification.label() == SafetyLabel.ABUSE
? "부적절한 발화로 분류되어 답장이 생성되지 않았어요."
: "운영팀 확인이 필요한 메시지로 분류되어 답장이 생성되지 않았어요.";
return new PromptChainingResponse(
classification.label(),
"",
false,
blocked,
null // ← affinityImpact 도 null
);
}
// 2 단 — 답장 초안 (변경 없음)
ReplyDraft draft = replyDrafter.prompt()
.user("""
다음 사용자 메시지에 대해 캐릭터의 답장 초안을 작성해줘.
사용자 메시지: "%s"
""".formatted(message))
.call()
.entity(ReplyDraft.class);
// 3 단 — 페르소나 톤 검수 (변경 없음)
PersonaToneAudit audit = personaToneAuditor.prompt()
.user("""
다음 답장 초안의 톤을 검수해줘.
답장 초안: "%s"
""".formatted(draft.draft()))
.call()
.entity(PersonaToneAudit.class);
// 4 단 — 호감도 변화 추천 (추가)
AffinityImpact impact = affinityImpactPredictor.prompt()
.user("""
다음 답장이 사용자에게 전달됐을 때 캐릭터의 호감도가 얼마나 변할지 추천해줘.
답장: "%s"
""".formatted(audit.finalReply()))
.call()
.entity(AffinityImpact.class);
return new PromptChainingResponse(
classification.label(),
draft.draft(),
audit.tonePassed(),
audit.finalReply(),
impact // ← 4 단 결과
);
}
}
핵심은 3 단의 골격을 손대지 않은 채 / 끝에 한 단만 얹는 결이에요. 기존 코드의 직렬 흐름은 그대로 유지되고, 새 단의 입력은 3 단의 출력 (audit.finalReply()) 한 자리만 받음. Open-Closed 원칙의 결이 Prompt Chaining 의 직렬 확장 에 자연스럽게 적용된 모습.
Step 5. 시연 — 4 자리가 응답에 떠 있는 모양
curl -X POST http://localhost:8080/api/workflow/prompt-chaining \
-H "Content-Type: application/json" \
-d '{"message":"오늘 너 보고 싶어"}'
{
"success": true,
"data": {
"safetyLabel": "NORMAL",
"draft": "응 나도 만나고 싶어",
"tonePassed": true,
"finalReply": "응 나도 너 보고 싶어. 주말에 만날까?",
"affinityImpact": {
"deltaScore": 6,
"reason": "긍정적인 만남 제안에 호응 → 호감도 상승"
}
}
}
ABUSE 메시지를 던지면 affinityImpact 가 null 로 다운그레이드된 모양도 확인해 보세요.
💡 튜터의 결론 — 직렬 확장의 자연스러움
Prompt Chaining 의 직렬 확장 은.
기존 단의 골격을 손대지 않은 채 끝에 한 단만 얹는 결로 자연스럽게 자라요.
새 ChatClient 빈 한 개 + 새 record 한 개 + Service 의 마지막에 한 줄 호출 추가.
그게 전부.
운영에서 새 단을 얹어야 할 때 회귀 위험이 낮은 변경 패턴의 자리.
그리고 비용은 그만큼 늘어나요 (LLM 호출 +1).
4 단의 가치가 비용 +1 보다 무거운가 를 한 번 더 저울 위에 올려보는 결이 운영 결정의 자리예요.
🎯 [과제 2 예시 답안] Routing 에 5 번째 라벨 — GAME_COMMAND 분기 추가
수업에서 MessageRoutingService 의 4 갈래 switch (FAQ / AFFINITY / SAFETY / CASUAL) 까지 손에 들었어요.
운영팀의 부탁대로 — 사용자가 채팅창에 /help, /reset, /profile 같은 슬래시 시스템 명령 을 던지면 기존 4 갈래로 분류가 흔들리는 자리를, 5 번째 라벨 GAME_COMMAND 로 정확히 잡아 보겠습니다.
이 과제가 끝나면 /help 메시지를 던지면 응답이 이렇게 떨어집니다.
{
"label": "GAME_COMMAND",
"aiMessage": "사용 가능한 명령은 /help, /reset, /profile 이에요. 더 궁금한 게 있으면 채팅으로 물어봐!"
}
🎯 채점 포인트
| 포인트 | 설명 |
|---|---|
RouteLabel enum 에 GAME_COMMAND 라벨 추가 |
5 라벨로 확장. |
messageRouter system 프롬프트 확장 |
5 번째 라벨 안내 + "슬래시 명령 발화는 GAME_COMMAND" 명시. |
gameCommandHandler 빈 등록 |
슬래시 명령에 짧고 사실 위주 의 시스템 응답을 돌려주는 ChatClient. |
switch 의 case GAME_COMMAND 분기 |
gameCommandHandler 위임 한 줄. |
| exhaustive switch 의 컴파일러 안전성 | enum 5 번째 라벨 추가 시 case 빠짐 이 컴파일 에러로 즉시 떠야 함. |
시연 curl 로 /help 메시지의 라벨 확인 |
label: "GAME_COMMAND" 가 응답에 떠야 함. |
Step 1. `RouteLabel` enum 확장
// kr/spartaclub/aifriends/workflow/dto/RouteLabel.java
public enum RouteLabel {
FAQ,
AFFINITY,
SAFETY,
CASUAL,
GAME_COMMAND // ← 추가
}
한 줄 추가. 이 한 줄이 컴파일러에게 "switch 의 모든 case 를 다시 확인하라" 는 신호가 됩니다. 다음 Step 4 에서 MessageRoutingService 의 switch 가 컴파일 에러 로 깨지는 자리를 만나요 — exhaustive switch 의 안전망 이 발휘되는 순간입니다.
Step 2. `messageRouter` system 프롬프트 확장
// kr/spartaclub/aifriends/workflow/config/WorkflowChatClientConfig.java
// 변경: messageRouter 의 system 프롬프트에 5 번째 라벨 안내 추가
@Bean
public ChatClient messageRouter(ChatClient.Builder builder, WorkflowLoggingAdvisor workflowLoggingAdvisor) {
return builder
.defaultAdvisors(workflowLoggingAdvisor)
.defaultSystem("""
너는 미연시 게임에서 사용자가 AI 캐릭터에게 보낸 메시지의 의도를 분류하는 라우터야.
입력 메시지를 정확히 아래 5개 라벨 중 하나로 분류하고, 그 이유를 한 줄로 설명해.
- FAQ: 게임 시스템 / 도움말 / 정책 관련 *자연어* 질문.
- AFFINITY: 호감도 · 둘의 관계에 대한 질문이나 감정 표현.
- SAFETY: 운영팀의 즉시 확인이 필요한 메시지.
- CASUAL: 일상 잡담.
- GAME_COMMAND: 슬래시(/) 로 시작하는 시스템 명령 발화.
(예: "/help", "/reset", "/profile")
반드시 위 5개 라벨 중 하나만 사용해. 다른 라벨은 만들지 마.
""")
.build();
}
FAQ 의 정의에 "자연어" 를 명시적으로 박은 게 한 줄 더 추가됐어요. 슬래시 명령은 자연어 FAQ 가 아니라 시스템 명령 이라는 점을 라우터가 잘 잡도록.
Step 3. `gameCommandHandler` 빈 등록
// kr/spartaclub/aifriends/workflow/config/WorkflowChatClientConfig.java
// 추가: 5 번째 핸들러 빈
@Bean
public ChatClient gameCommandHandler(ChatClient.Builder builder, WorkflowLoggingAdvisor workflowLoggingAdvisor) {
return builder
.defaultAdvisors(workflowLoggingAdvisor)
.defaultSystem("""
너는 미연시 게임의 시스템 명령 응답 전담 ChatClient 야. 사용자가 슬래시(/)
명령을 보내면 그 명령에 맞는 짧고 사실 위주의 시스템 응답을 돌려줘.
- 캐릭터 톤이 아니라 친절한 시스템 안내 톤.
- 1 ~ 2 문장으로 짧게.
- 알려진 명령이 없으면 "사용 가능한 명령은 /help, /reset, /profile 이에요" 같은
안전한 안내로 끝낸다.
""")
.build();
}
이 핸들러의 결은 FAQ 핸들러와 비슷하지만 톤이 더 시스템적. 캐릭터 답장의 자리가 아니라 게임 시스템 인터페이스 의 자리. 도구는 등록하지 않아요 — 시스템 명령은 사실 위주의 안전한 안내 만 필요.
Step 4. `MessageRoutingService` 의 `switch` 분기 확장
// kr/spartaclub/aifriends/workflow/service/MessageRoutingService.java
// 변경: 생성자에 gameCommandHandler 주입 + switch 에 case GAME_COMMAND 추가
@Service
public class MessageRoutingService {
// ... 기존 4 핸들러 ...
private final ChatClient gameCommandHandler; // ← 추가
public MessageRoutingService(
@Qualifier("messageRouter") ChatClient messageRouter,
@Qualifier("faqHandler") ChatClient faqHandler,
@Qualifier("affinityHandler") ChatClient affinityHandler,
@Qualifier("safetyAlertHandler") ChatClient safetyAlertHandler,
@Qualifier("casualChatHandler") ChatClient casualChatHandler,
@Qualifier("gameCommandHandler") ChatClient gameCommandHandler // ← 추가
) {
// ... 기존 주입 ...
this.gameCommandHandler = gameCommandHandler;
}
public RoutingResponse route(Long soulmateId, String message) {
MessageRouteDecision decision = messageRouter.prompt()
.user("다음 메시지를 분류해줘:\n\"" + message + "\"")
.call()
.entity(MessageRouteDecision.class);
String aiMessage = switch (decision.label()) {
case FAQ -> faqHandler.prompt().user(message).call().content();
case AFFINITY -> affinityHandler.prompt()
.user("(캐릭터 soulmateId=" + (soulmateId == null ? 0L : soulmateId)
+ ") 사용자 메시지: " + message)
.call()
.content();
case SAFETY -> safetyAlertHandler.prompt()
.user("사용자 메시지: " + message)
.call()
.content();
case CASUAL -> casualChatHandler.prompt().user(message).call().content();
case GAME_COMMAND -> gameCommandHandler.prompt() // ← 추가
.user(message)
.call()
.content();
};
return new RoutingResponse(decision.label(), aiMessage);
}
}
🛡️ exhaustive switch 의 안전망
Step 1 에서
RouteLabel.GAME_COMMAND를 추가했을 때, 본 Step 의switch를 손보지 않은 상태로 컴파일하면 — Java 컴파일러가 "the switch expression does not cover all possible input values" 같은 에러를 내요. case 빠짐의 사고를 빌드 시점에 차단 하는 자리.그래서 enum 의 라벨을 추가하면 컴파일러가 "어디 어디 switch 를 손봐야 한다" 를 명시적으로 알려줘요. 운영에서 분기 빠짐 이 런타임 사고 가 아니라 빌드 에러 로 잡히는 게 —
switch표현식 + enum 의 짝패의 가장 큰 가치예요.
Step 5. 시연 — `/help` 메시지로 새 갈래 확인
curl -X POST http://localhost:8080/api/workflow/message-routing \
-H "Content-Type: application/json" \
-d '{"message":"/help"}'
응답:
{
"success": true,
"data": {
"label": "GAME_COMMAND",
"aiMessage": "사용 가능한 명령은 /help, /reset, /profile 이에요. 더 궁금한 게 있으면 채팅으로 물어봐!"
}
}
기존 4 갈래로는 흔들렸을 메시지 가 새 갈래에서 정확히 잡혔어요.
💡 튜터의 결론 — Open-Closed 의 결이 분기에도 통한다
Routing 패턴의 분기 확장 은 — enum 한 줄 + 새 빈 한 개 + switch 한 자리 의 셋이 한 호흡으로 들어와요.
그리고 exhaustive switch 가 어디를 손봐야 하는지 를 컴파일러 단에서 알려줘요.
운영에서 분기 빠짐의 사고가 런타임으로 새지 않는 안전망.
Open-Closed Principle 의 결 이 분기 패턴 에도 그대로 통한다는 사실이 손에 들어옵니다.
🎯 [과제 3 예시 답안] Parallelization 의 부분 실패 처리 — .exceptionally() 한 줄로 fallback
수업에서 ParallelAnalysisService 의 3 트랙 동시 호출 까지 손에 들었어요.
그런데 운영 트래픽 에선 한 트랙만 가끔 깨지는 일이 생깁니다.
(LLM 제공사 일시 장애 / 토큰 한도 초과 / 네트워크 글리치) 운영팀의 부탁대로 — 한 트랙이 깨져도 나머지 두 트랙의 결과는 살리고 / 깨진 트랙은 기본값으로 다운그레이드 하는 resilience 패턴 을 박아 봅시다.
이 과제가 끝나면 한 트랙이 깨져도 응답이 떨어져요.
{
"sentiment": { "sentiment": "NEUTRAL", "intensity": 0 }, // ← fallback 다운그레이드
"intent": { "intent": "CONFESSION", "summary": "..." }, // ← 성공
"personaMatch": { "matchScore": 88, "suggestedTone": "..." }, // ← 성공
"latencyMs": 612
}
🎯 채점 포인트
| 포인트 | 설명 |
|---|---|
3 CompletableFuture 에 .exceptionally(...) 추가 |
각 트랙이 자기 책임으로 fallback 다운그레이드. |
| fallback 기본값 정의 | 각 트랙별 의미 있는 기본값 — NEUTRAL + 0 / OTHER + "분석 실패" / 0 + "분석 실패". |
| fallback 흔적 로그 | log.warn(...) 한 줄로 운영 모니터링이 어느 트랙이 다운그레이드됐는지 추적 가능. |
allOf().join() 의 동작 보존 |
.exceptionally() 가 박혀 있으면 allOf 는 예외 없이 완료 — 메인 흐름이 깨지지 않음. |
| 시연 — 의도적으로 한 트랙 깨뜨려보기 | 응답이 200 OK 로 떨어지고 깨진 트랙만 fallback 값으로 표시. |
Step 1. `ParallelAnalysisService` 에 `.exceptionally(...)` 박기
// kr/spartaclub/aifriends/workflow/service/ParallelAnalysisService.java
@Service
public class ParallelAnalysisService {
private static final Logger log = LoggerFactory.getLogger(ParallelAnalysisService.class);
// ... 3 ChatClient 빈 주입 (변경 없음) ...
public ParallelAnalysisResponse analyze(String message) {
long startMs = System.currentTimeMillis();
CompletableFuture<SentimentAnalysis> sentimentFuture = CompletableFuture.supplyAsync(() ->
sentimentAnalyzer.prompt()
.user("다음 발화의 정서를 점수화해줘:\n\"" + message + "\"")
.call()
.entity(SentimentAnalysis.class))
.exceptionally(throwable -> { // ← 추가
log.warn("[ParallelAnalysisService] sentiment track fell back to default", throwable);
return new SentimentAnalysis(Sentiment.NEUTRAL, 0);
});
CompletableFuture<IntentExtraction> intentFuture = CompletableFuture.supplyAsync(() ->
intentExtractor.prompt()
.user("다음 발화의 의도를 추출해줘:\n\"" + message + "\"")
.call()
.entity(IntentExtraction.class))
.exceptionally(throwable -> { // ← 추가
log.warn("[ParallelAnalysisService] intent track fell back to default", throwable);
return new IntentExtraction(Intent.OTHER, "분석 실패로 OTHER 로 다운그레이드");
});
CompletableFuture<PersonaMatch> personaFuture = CompletableFuture.supplyAsync(() ->
personaMatcher.prompt()
.user("다음 발화에 캐릭터 페르소나가 자연스럽게 잡히는지 점수화해줘:\n\"" + message + "\"")
.call()
.entity(PersonaMatch.class))
.exceptionally(throwable -> { // ← 추가
log.warn("[ParallelAnalysisService] persona track fell back to default", throwable);
return new PersonaMatch(0, "분석 실패");
});
CompletableFuture.allOf(sentimentFuture, intentFuture, personaFuture).join();
long latencyMs = System.currentTimeMillis() - startMs;
return new ParallelAnalysisResponse(
sentimentFuture.join(),
intentFuture.join(),
personaFuture.join(),
latencyMs
);
}
}
핵심 변경:
- 3
supplyAsync(...)각각에.exceptionally(throwable -> ...)체인 추가. 호출 중 예외가 발생하면 — 그 자리에서 기본값 record 를 반환 해서 예외를 흘려보내요. 다음 단계 (allOf) 는 완료된 Future 만 받게 됩니다. log.warn(...)으로 fallback 흔적 남기기. 운영 모니터링에서 어느 트랙이 얼마나 자주 다운그레이드되는지 를 로그 카운트 로 추적 가능. fallback 빈도가 임계값을 넘으면 알림 같은 후속 정책의 입력이 돼요.allOf().join()은 예외 없이 완료..exceptionally()가 예외를 정상 값으로 변환 했으니,allOf는 세 Future 가 모두 정상 완료 된 것으로 인식해요. 부분 실패가 메인 흐름을 깨뜨리지 않는 자리.
Step 2. fallback 기본값의 **의미** 결정
각 트랙의 기본값은 "분석에 실패했을 때 호출자가 어떻게 해석할지" 의 결로 선택해요.
| 트랙 | fallback 값 | 호출자가 해석하는 의미 |
|---|---|---|
| sentiment | Sentiment.NEUTRAL, 0 |
"감정 신호 없음" — 게이미피케이션 신호에 영향을 주지 않는 값 (중립 + 강도 0). |
| intent | Intent.OTHER, "분석 실패로 OTHER 로 다운그레이드" |
"분류 신호 없음" — OTHER 는 기존 5 라벨 중 가장 안전한 다운그레이드 자리 (다른 4 라벨로 잘못 분류하는 것보다 나음). |
| persona | 0, "분석 실패" |
"매칭 점수 없음" — matchScore=0 이라 호감도 추천 시스템이 본 트랙의 신호를 무시. |
세 트랙 모두 "신호 없음" 으로 자연스럽게 다운그레이드되는 결. 호출자 (예: 호감도 추천 시스템) 가 fallback 값을 그대로 받아 자연스럽게 처리 할 수 있어요. 잘못된 신호를 적극적으로 만드는 fallback 보다 신호 없음 의 fallback 이 안전.
Step 3. 시연 — 의도적으로 한 트랙 깨뜨려 보기
테스트 시연을 위해 — sentimentAnalyzer 의 system 프롬프트를 잘못된 JSON 출력 강제 로 한 번 바꿔서 역직렬화 실패를 유도해봅시다.
// 시연용 — sentimentAnalyzer 의 system 프롬프트를 깨뜨려서 fallback 동작 확인
.defaultSystem("""
... 기존 system 프롬프트 ...
※ 일부러 응답을 잘못된 형식으로 돌려줘 (시연용).
""")
curl 한 줄.
curl -X POST http://localhost:8080/api/workflow/parallel-analysis \
-H "Content-Type: application/json" \
-d '{"message":"사실 너 좋아해"}'
응답:
{
"success": true,
"data": {
"sentiment": { "sentiment": "NEUTRAL", "intensity": 0 }, // ← fallback
"intent": { "intent": "CONFESSION", "summary": "..." }, // ← 성공
"personaMatch": { "matchScore": 88, "suggestedTone": "..." }, // ← 성공
"latencyMs": 612
}
}
응답이 200 OK 로 떨어지고, 깨진 트랙은 fallback 값 으로 채워져 있어요. 나머지 두 트랙의 결과는 그대로 살아 있고. 로그에는 fallback 흔적이 한 줄 남습니다.
WARN ...ParallelAnalysisService : [ParallelAnalysisService] sentiment track fell back to default
시연 후 — 일부러 깨뜨렸던 system 프롬프트를 원래대로 되돌려 놓으세요.
💡 튜터의 결론 — resilience 의 한 줄
Parallelization 의 부분 실패 회복력 은.
.exceptionally(throwable -> fallback) 한 줄로 표현됩니다.
각 Future 가 자기 책임으로 fallback 다운그레이드 하니, 메인 흐름은 부분 실패에 영향 받지 않아요. 그리고 fallback 흔적의 로그 가 운영 모니터링의 입력 으로 흐릅니다.
resilience 와 정합성 사이의 저울.
본 강의 생각해볼 주제 3 에서 한 번 더 깊게 굴려봐요.
[생각해볼 주제 예시 답안]
오늘 본문에서 던진 세 주제는 모두 오늘 손으로 박은 Workflow 3 패턴이 운영에서 어떤 결로 살아남는지 를 묻는 질문들이에요. 비용 / 환각 / 부분 실패 — 셋 다 현업 기술 면접에서 자주 등장하는 단골 주제죠. 한 번에 풀어보겠습니다.
🚨 주제 1. Prompt Chaining 의 **비용 3 배** 는 언제 가치 있을까
[문제 상황 요약]
수업에서 Prompt Chaining 3 단 직렬 은 한 호흡 호출 대비 비용 3 배 + 지연 3 배 의 trade-off 를 들고 옵니다.
그런데 3 호흡의 가치 가 비용 3 배보다 무거운 경우 가 분명히 있죠.
어느 시나리오에서 N 단 분해가 가치 있고, 어느 시나리오에서 한 호흡으로 충분 한지 — 그 결정의 기준은 무엇일까요?
💡 [튜터의 가이드 및 해설]
이 질문은 비용 / 정확도 / 비즈니스 영향 세 축의 저울 위에서의 결정 을 묻는 질문이에요.
1. 한 호흡으로 충분한 시나리오 — 저울이 비용 쪽으로 기우는 결
- 사용자 트래픽이 낮음 — 토이 프로젝트 / 사내 비공식 도구 / 데모.
- 정확도 들쭉이 비즈니스 영향이 작음 — 결과가 일관되지 않아도 사용자가 "그러려니" 하는 자리.
- 부적절한 결과가 새도 회복 비용이 낮음 — 개인 메모 / 비공개 워크북 등.
예를 들어 개인 일기 요약 LLM 이라면 — 한 호흡으로 충분해요. 3 단 분해의 비용 3 배 가 요약의 들쭉을 1 회 더 다듬는 가치보다 큽니다.
2. 3 호흡이 가치 있는 시나리오 — 저울이 정확도 / 안전 쪽으로 기우는 결
- 사용자 트래픽이 폭발 — 대규모 미연시 게임 / 인기 챗봇 서비스 / B2B 운영 자동화.
- 톤 일관성이 브랜드 가치와 직결 — 캐릭터 페르소나가 흔들리면 사용자 이탈 의 자리.
- 부적절한 결과가 새는 비용이 큼 — 미성년자 보호 / 금융 / 의료 / 법률 등의 답이 새는 자리.
특히 우리 ai-friends 같은 미연시 게임에선 — 부적절한 답장이 한 번 새는 비용 (사용자 신고 / 운영 대응 / 브랜드 손상) 이 LLM 호출 비용 3 배 보다 훨씬 무거워요. 그래서 Prompt Chaining 의 안전 분류 단 이 비용 보험료 의 결로 가치 있습니다.
3. 저울의 균형점 — 4 가지 축으로 정리
| 축 | 한 호흡 쪽으로 기울 때 | 3 호흡 쪽으로 기울 때 |
|---|---|---|
| 트래픽 규모 | 일 호출 100 건 미만 | 일 호출 10,000 건 이상 |
| 비즈니스 임팩트 | 들쭉이 사용자 이탈로 이어지지 않음 | 톤 일관성이 브랜드 핵심 가치 |
| 안전 위험 | 부적절한 결과의 비용 < $1 | 부적절한 결과의 비용 > $100 (운영 대응 + 신고 처리) |
| 비용 민감도 | 호출 비용이 매출의 50% 초과 | 호출 비용이 매출의 5% 미만 |
운영 결정의 자리에서 — 4 축의 매김이 한 쪽으로 강하게 기울면 그 쪽이 정답이고, 축마다 답이 갈리면 양쪽 모두 시도해서 A/B 측정 으로 가는 결이에요.
4. 단계별 점진 도입 — 가장 안전한 결
운영 결정이 명확하지 않은 자리 에선 — 한 호흡 시작 → 모니터링 → 안전 사고 발생 시 3 단 도입 의 점진 결로 가는 게 합리적이에요. 처음부터 3 단 풀스택 으로 박는 건 과잉 일 수 있고, 한 호흡으로 시작 → 실제 데이터로 도입 결정 의 점진 결이 비용 / 안전의 저울에 가장 맞춰 들어갑니다.
🎯 면접관을 홀리는 핵심 멘트
"Prompt Chaining 의 비용 3 배는 안전과 톤 일관성의 보험료 입니다. 운영 결정에서 4 축 — 트래픽 규모 / 비즈니스 임팩트 / 안전 위험 / 비용 민감도 — 을 매겨서 부적절한 결과 한 번의 회복 비용 이 LLM 호출 비용 3 배보다 무거운 자리 에선 3 단 분해가 정답이에요. 그렇지 않은 자리는 한 호흡으로 시작하고 모니터링 결과로 도입 시점을 결정 하는 점진 결이 가장 안전합니다. 그리고 Prompt Chaining 의 가치 측정 은 3 단으로 막힌 부적절한 답장의 수 × 회복 비용 으로 경제적 가치 까지 추정할 수 있어요."
🚨 주제 2. Routing 의 라우터 LLM 이 **환각으로 새 라벨** 을 만들면
[문제 상황 요약]
수업의 messageRouter 는 4 라벨 중 하나만 사용해라 라고 system 프롬프트에 박았지만, LLM 은 비결정론적 이라 간혹 환각으로 새 라벨 을 만들어내요.
오늘 코드는 enum + Jackson 역직렬화 의 짝패가 환각 라벨 을 예외로 깨뜨려 줍니다.
그런데 — 예외로 깨지는 게 최선 일까요? fallback 으로 다운그레이드 하는 결은 어떨까요?
💡 [튜터의 가이드 및 해설]
이 질문은 "운영 환경에서 환각의 비용을 어떻게 처리할지" 의 정책 결정을 묻는 질문이에요.
1. 예외로 깨뜨리는 결 (현재 코드) — 명시성 우선
- 장점 — 환각 발생이 명시적 예외 로 잡혀서 운영 대시보드 / 알림 에 즉시 보임. 모니터링이 단순.
- 단점 — 사용자 한 명의 한 호출이 즉시 실패 함. 환각 빈도가 0.1% 만 돼도 사용자 1000 명 중 1 명 은 화면에서 500 에러 를 봅니다.
2. fallback 다운그레이드 — 사용자 경험 우선
- 장점 — 사용자는 환각 발생을 모름 — 응답이 일관되게 떨어짐. 전체 가용성 향상.
- 단점 — 환각 발생이 조용히 묻혀서 운영팀이 어느 트랙이 자주 다운그레이드되는지 를 적극적으로 모니터링 해야 함. 알림 정책이 더 복잡.
3. fallback 으로 갈 때 — 어느 라벨로 다운그레이드해야 하나?
미연시 게임의 4 라벨 중 — 가장 안전한 다운그레이드 자리 는 어디일까요?
| 후보 | 다운그레이드 시 위험 |
|---|---|
| FAQ | 사용자가 호감도 질문을 던졌는데 FAQ 톤으로 답함 — 캐릭터의 페르소나 깨짐 |
| AFFINITY | 부적절한 발화가 호감도 갈래로 흘러가 답장이 생성됨 — 안전 위험 |
| SAFETY | 일상 잡담이 운영팀 알림으로 흘러감 — 운영 노이즈 증가 |
| CASUAL | 민감한 발화가 일상 톤으로 답해짐 — 안전 위험 |
가장 안전한 자리는 SAFETY 예요. 운영 노이즈는 증가하지만 / 부적절한 답장이 새는 사고는 없음. 환각이 발생한 호출은 운영팀의 손을 거치게 하는 결로, 환각 빈도 모니터링도 자연스럽게 따라옵니다.
4. 하이브리드 — 가장 운영에 안전한 결
실무에선 둘 다 함께 가는 결이 가장 안전해요.
try {
decision = messageRouter.prompt()
.user("다음 메시지를 분류해줘:\n\"" + message + "\"")
.call()
.entity(MessageRouteDecision.class);
} catch (Exception e) {
log.warn("[messageRouter] hallucination fallback to SAFETY", e);
decision = new MessageRouteDecision(RouteLabel.SAFETY, "환각 라벨 → SAFETY 다운그레이드");
}
예외는 잡되 — 사용자에겐 SAFETY 갈래의 안전한 응답 으로 흐름이 이어져요. 모니터링은 log.warn 의 카운트 로 자연스럽게 누적됩니다.
5. 운영 알림 정책 — 환각 빈도에 임계값을 두기
환각 빈도가 일 1% 미만 → 누적만 (대시보드의 한 자리)
환각 빈도가 일 1% 초과 → 즉시 알림 (시스템 프롬프트 / 모델 버전 점검 신호)
환각 빈도가 일 5% 초과 → 라우터 모델 다운그레이드 또는 교체 검토
세 임계값이 운영팀의 대응 단계 를 자연스럽게 분리해줘요. 모든 환각에 알림이 가면 알림 과부하 가 일어나고, 임계값을 적절히 두면 의미 있는 신호 만 운영팀의 손에 도착합니다.
🎯 면접관을 홀리는 핵심 멘트
"LLM 의 환각 라벨은 예외와 fallback 의 하이브리드 로 처리합니다. 예외로 잡되 / 사용자에겐 가장 안전한 라벨 (
SAFETY) 로 다운그레이드. 환각 빈도는log.warn으로 누적 되고, 일 1% / 5% 의 두 임계값 으로 운영팀의 대응 단계가 자연스럽게 갈립니다. 미연시 게임처럼 부적절한 답장이 새는 비용이 큰 자리에선 fallback 목적지가 SAFETY 가 정답 이에요 — 일상 잡담이 운영팀 알림으로 흘러도 안전 위험이 새는 사고보다는 노이즈가 안전 하니까요. fallback 목적지의 선택은 도메인의 위험 무게 가 결정합니다."
🚨 주제 3. Parallelization 의 **부분 실패 처리** — resilience 우선 vs 정합성 우선
[문제 상황 요약]
수업의 ParallelAnalysisService 의 3 트랙 중 한 트랙이 깨지면 — 어떻게 처리할지 의 결정이 비즈니스 임팩트 에 따라 달라져요.
과제 3 에서 손에 든 resilience 패턴 (한 트랙이 깨져도 두 트랙 결과 살리기) vs 정합성 패턴 (세 트랙 모두 성공해야 응답 내보내기) — 어느 쪽이 정답일까요?
💡 [튜터의 가이드 및 해설]
이 질문은 "부분 실패의 회복 정책을 도메인의 위험 무게로 결정한다" 의 결을 묻는 질문이에요.
1. resilience 우선 — 사용자 경험 / 가용성 우선의 자리
- 적합한 자리 — 3 트랙의 결과 중 일부만 있어도 사용자에게 가치가 있는 자리.
- 예: 우리 미연시 게임의 호감도 변화 추천 시스템 — 감정 분석이 없어도 / 의도 + 페르소나 매칭만으로 충분히 호감도 추천이 가능. 세 신호 중 둘만 있어도 추천은 떨어진다.
- 결정의 기준 — 모든 신호가 함께 있어야 가치가 있는 게 아니라, 각 신호가 독립적으로 가치를 내는 자리.
- trade-off — fallback 빈도 모니터링 이 필수. 환각 처리와 같은 결.
2. 정합성 우선 — 의사 결정의 일관성이 우선인 자리
- 적합한 자리 — 3 트랙의 결과가 함께 있어야 의사 결정의 정확성 이 보장되는 자리.
- 예: 금융 거래 위험 평가 — 다중 신호 (사용자 행동 / 금액 패턴 / 위치 이상치) 중 한 신호라도 빠지면 / 잘못된 의사 결정 의 위험.
- 결정의 기준 — N 신호의 합산이 N 신호의 부분합과 다른 의미 인 자리.
- trade-off — 부분 실패 시 전체 응답 실패 → 가용성 하락. 재시도 정책 / circuit breaker 같은 추가 부품 필요.
3. 우리 미연시 게임의 결정 — resilience 우선
ai-friends 의 3 트랙은 — 서로 독립인 분석 이고, 각 분석이 독립적으로 호감도 시스템의 입력이 될 수 있어요.
감정 분석이 fallback 으로 NEUTRAL/0 으로 다운그레이드돼도 — 의도 + 페르소나 매칭 두 신호로 호감도 추천은 자연스럽게 떨어집니다. 그래서 resilience 우선 의 결이 우리 도메인에 자연스러워요.
다만 — fallback 빈도가 일 1% 미만이라는 가정 하에서. 만약 fallback 빈도가 일 10% 를 넘으면 — 호감도 추천의 정확도가 의심 되는 자리. 모니터링 카운트 → 임계값 → 운영팀 알림 의 결로 자연스럽게 이어집니다.
4. 하이브리드 — 트랙별 다른 정책
실무에선 모든 트랙에 같은 정책 이 아니라 각 트랙의 비즈니스 임팩트에 맞춰 다른 정책 이 자연스러워요.
| 트랙 | 비즈니스 임팩트 | 권장 정책 |
|---|---|---|
| 감정 분석 | 호감도 신호 (낮음) | resilience 우선 — fallback NEUTRAL |
| 의도 추출 | 게이미피케이션 신호 (중간) | resilience 우선 — fallback OTHER |
| 페르소나 매칭 | 톤 추천 신호 (중간) | resilience 우선 — fallback 점수 0 |
| (가상의 4 번째 트랙) 결제 위험 평가 | 안전 신호 (높음) | 정합성 우선 — 부분 실패 시 전체 차단 |
트랙의 비즈니스 임팩트 가 fallback 정책의 선택 을 결정합니다.
5. 부분 실패 처리의 추가 부품 — circuit breaker 도 함께
운영 환경에선.
fallback 만으로는 부족한 경우 도 있어요.
한 트랙의 LLM 제공사가 장기 장애 라면.
모든 호출이 fallback 으로 떨어지면서 로그가 폭주 할 수 있죠.
이런 자리엔 circuit breaker (Resilience4j 같은 라이브러리) 가 함께 박혀요.
fallback 빈도가 임계값을 넘으면 / 그 트랙의 호출 자체를 일시 중단 하는 결.
fallback 의 fallback 인 셈이에요.
🎯 면접관을 홀리는 핵심 멘트
"Parallelization 의 부분 실패 처리는 resilience 우선 vs 정합성 우선 의 결정인데, 그 결정의 기준은 도메인의 위험 무게 입니다. 서로 독립인 신호의 합산 이라면 resilience 우선 —
.exceptionally(...)로 fallback. N 신호의 합산이 부분합과 다른 의미 라면 정합성 우선 — 부분 실패 시 전체 차단 + 재시도. 우리 미연시 게임의 3 트랙은 각자 독립적으로 호감도 신호의 입력 이라 resilience 우선 이 자연스러워요. 그리고 운영 환경에선 fallback 만으로 부족할 때 circuit breaker 가 자연스럽게 따라옵니다 — Resilience4j 의 결로요. fallback 정책은 도메인의 위험 무게가 결정하고 / circuit breaker 는 fallback 의 fallback 으로 운영 안정성을 한 층 더 두텁게 만듭니다."