문서 읽는 데 182분 · day13

Day 13. Workflow 3 패턴 — Prompt Chaining / Routing / Parallelization 의 3 패턴

전체 26강 중 14강 · 스프링 AI
난이도 · 심화선수지식자바 기초스프링 부트

ℹ️스프링 부트로 만든 ai-friends 프로젝트 위에 얹어 진행해요. 자바·스프링이 처음이라면 먼저 “서버 만들기” 트랙부터 권해요.

안녕하세요! 여러분의 백엔드 가이드, 홍순구 튜터입니다.

지난 시간, 정말 무거운 머리로 닫으셨어요.

정리해둔 한 줄을 다시 들고 올게요.

에이전트 = 도구 + 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 개 로 자라요.

ChatClient 3 개 (Day 11) → ChatClient 11 개 (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단의 흐름 한 번 그리기

자, 이제 메시지 한 줄이 응답으로 나오기까지 의 흐름을 머리에 그려볼게요.

흐름을 한 줄씩 풀어두면 —

  1. 입력 — 메시지 한 줄. 클라이언트가 POST /api/workflow/prompt-chaining 으로 사용자 메시지 한 줄을 보내요.
  2. 1 단 안전 분류. safetyClassifier 가 메시지를 받아 3 라벨 (NORMAL / ABUSE / ESCALATE) 중 하나로 라벨링. 산출물은 MessageSafetyClassification record.
  3. NORMAL 분기. ABUSE 또는 ESCALATE 라벨이면 답장 생성 자체를 차단 하고 안전한 안내 한 줄로 응답을 채워서 종료. 2 단·3 단은 호출되지 않아요.
  4. 2 단 답장 초안. NORMAL 라벨이면 replyDrafter원본 메시지 를 받아 캐릭터 답장 초안을 작성. 산출물은 ReplyDraft record.
  5. 3 단 페르소나 톤 검수. personaToneAuditor답장 초안 한 줄 을 받아 페르소나 결 (반말 / 따뜻한 친구 톤) 에 맞는지 검수. 산출물은 PersonaToneAudit record (tonePassed / finalReply / note).
  6. 응답 — 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단의 산출물은 세 자리. tonePassedtruefinalReply원본 초안 그대로, 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 자 초과 같은 입력을 자동으로 차단합니다.

PromptChainingResponse3 단계의 결과를 모두 노출 해 둔 모양 — 학습용 데모에선 각 단계가 어떻게 흘러갔는지 가 응답만 보고도 한눈에 보여야 하니까 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 는 뭐예요?

좋은 질문! 빈 메서드 시그니처에 들어와 있는 WorkflowLoggingAdvisorStep 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));
    }
}

@ValidPromptChainingRequest@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 가 빈 문자열, tonePassedfalse2 단·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 명의 분업 으로 분산. 닫힌 라벨 집합 enumStep 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 에서 만든 도구 가 흡수돼요. 마지막엔 MessageRoutingServiceswitch 분기 한 줄이 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 도구 회수 자리. WeatherToolCASUAL 갈래 안 으로 들어왔어요. 사용자가 "오늘 날씨 어때?" 같은 가벼운 안부를 던지면 → 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 으로 한 줄 자라났어요.

MessageRoutingServiceswitch 분기 한 줄로 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(...) 한 줄도 들어가지 않습니다. 평범한 Java switchRouting 의 본체 그 자체.
  • 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));
    }
}

RoutingRequestsoulmateId (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
) { }

ParallelAnalysisResponse4 번째 필드 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 필드로 데이터화됩니다.

ParallelAnalysisServiceCompletableFuture.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: 8123 트랙을 동시에 굴린 전체 소요 시간이 약 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();
}

beforeafter — 두 자리가 핵심이에요.

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 인터페이스를 구현해요. 오늘 손에 든 WorkflowLoggingAdvisorbefore / 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 — messageRouterFAQ / 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 WorkflowLoggingAdvisorBaseAdvisor 인터페이스 첫 등장 + 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 의 자연스러운 확장 방향 을 손에 익히는 자리.

💡 왜 굳이 이 과제를 할까요?

  1. 3 단 → 4 단 — 직렬 확장의 손맛. 기존 3 단의 흐름을 유지한 채 새 단을 끝에 얹는 변형이라, Prompt Chaining 의 확장이 얼마나 자연스러운지 가 손에 들어와요. 새 ChatClient 빈 한 개 + 새 record 한 개 + Service 의 마지막에 한 줄 호출 추가 — 그게 끝.
  2. 재사용 가능한 패턴 — Service 한 줄 추가의 흐름. 기존 코드의 골격을 손대지 않고 끝에 얹기만 하면 되는 변형이라, Open-Closed Principle 의 결 이 자연스럽게 손에 잡혀요. 운영에서 기존 단을 손대지 않고 새 단만 얹는 변경은 회귀 위험 이 낮아 안전.
  3. 호감도 시스템과의 자연스러운 결합. 미연시 게임의 호감도 게이미피케이션 신호가 Prompt Chaining 의 마지막 단 으로 자연스럽게 흡수돼요. AI 분석 결과 → 게임 메커니즘 의 연결 자리.

✅ 요구사항

  1. 새 record AffinityImpact(int deltaScore, String reason) 추가workflow/dto/ 에. deltaScore-10 ~ +10 정수, reason변화 근거 한 줄.
  2. 새 빈 affinityImpactPredictor 등록WorkflowChatClientConfig 에. system 프롬프트는 "답장이 사용자에게 전달됐을 때 캐릭터의 호감도가 얼마나 변할지 추천" 결로. JSON 으로 deltaScore + reason 두 자리 돌려달라.
  3. PromptChainingResponse 확장AffinityImpact affinityImpact 필드 추가.

PromptChainingService.chain(...) 의 마지막에 4 단 호출 추가 — 3 단 검수 후 audit.finalReply() 를 입력으로 받아 affinityImpactPredictor.prompt()...entity(AffinityImpact.class) 호출.

ABUSE / ESCALATE 분기에선 4 단도 건너뛰고 affinityImpactnull 로. 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 분기 한 자리 추가.

✅ 요구사항

  1. RouteLabel enum 에 GAME_COMMAND 라벨 추가 — 5 라벨로 확장.
  2. messageRouter 의 system 프롬프트에 5 번째 라벨 안내 추가"GAME_COMMAND: /help, /reset, /profile 같은 슬래시 명령 발화" 같은 결.
  3. 새 빈 gameCommandHandler 등록 — 슬래시 명령에 대해 짧고 사실 위주의 시스템 응답 을 돌려주는 결로. (예: /help → "사용 가능한 명령은 /help, /reset, /profile 이에요")
  4. MessageRoutingService.route(...)switchcase GAME_COMMAND 분기 추가gameCommandHandler 위임.
  5. 컨트롤러는 변경 없음 — 응답 record 의 label 필드가 자동으로 GAME_COMMAND 도 직렬화하도록.
  6. 시연 — /help 메시지로 분기 확인. label: "GAME_COMMAND" 가 응답에 떠야 함.

보상

  • 🛡️ Routing 패턴의 분기 확장 손맛. enum 한 줄 + 빈 한 개 + switch 한 자리 — Open-Closed Principle 의 결 이 분기 패턴에도 그대로 통한다는 사실.
  • 🎯 exhaustive switch 의 컴파일러 안전성. enum 라벨 추가 시 어디를 손봐야 하는지 가 컴파일 에러로 즉시 잡힘.

[구현 3] Parallelization 의 부분 실패 처리 — .exceptionally() 한 줄로 fallback

배경 시나리오

PM 의 마지막 부탁.

"Parallelization 의 3 트랙 — 운영 트래픽에서 한 트랙만 가끔 깨지는 일이 생기더라고요. (LLM 제공사의 일시 장애 / 토큰 한도 초과 / 네트워크 글리치) 지금 코드는 한 트랙이 깨지면 전체 응답이 실패 로 떨어져요. 나머지 두 트랙은 성공했으니까 그 두 결과는 살려서 응답하고, 깨진 트랙은 기본값으로 다운그레이드 해 주면 좋겠어요." ⚠️

부분 실패의 처리. CompletableFuture.exceptionally() 한 줄로 fallback 결과 를 박는 결.

✅ 요구사항

  1. ParallelAnalysisService.analyze(...) 의 3 CompletableFuture 각각에 .exceptionally(throwable -> ...) 추가 — 예외 발생 시 기본값 record 로 다운그레이드.
  2. 기본값 정의 (각 트랙)
  • SentimentAnalysisnew SentimentAnalysis(Sentiment.NEUTRAL, 0) + `note 필드를 record 에 추가하지 말고 / 로그로 fallback 흔적만 남기기*
  • IntentExtractionnew IntentExtraction(Intent.OTHER, "분석 실패로 OTHER 로 다운그레이드")
  • PersonaMatchnew PersonaMatch(0, "분석 실패")
  1. allOf().join() 직후 — 어느 트랙이 fallback 으로 다운그레이드됐는지 로그 한 줄. log.warn("[ParallelAnalysisService] track {} fell back to default", trackName) 같은 결.
  2. ParallelAnalysisResponse 는 변경 없음 — 응답 record 그대로.
  3. 시연 — 일부러 한 트랙을 깨뜨려 보기. 예: sentimentAnalyzer system 프롬프트를 의도적으로 잘못된 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 의 messageRouter4 라벨 중 하나만 사용해라 라고 system 프롬프트에 박았지만 — LLM 은 본질적으로 비결정론적 이라 간혹 환각으로 새 라벨 을 만들어내요.

오늘 코드는 enum 의 닫힌 집합 + Jackson 역직렬화 가 1 차 방어선이고, 환각 라벨이 들어오면 예외로 깨지는 결이에요.

운영 환경에선 예외로 깨지는최선 일까요, 아니면 fallback 으로 다운그레이드 하는 결이 더 안전할까요? fallback 으로 가는 경우 — 어느 라벨로 다운그레이드하는 게 맞을지의 결정 기준은? 환각 라벨 발생 빈도가 일정 임계값을 넘으면 운영팀에 알림이 가야 할까요? 한 번 생각해보세요.

주제 3 — Parallelization 의 부분 실패 처리 — 응답을 깎을까, 막을까

Step 6 의 그림자였던 부분 실패의 골치. 3 트랙 중 한 트랙이 깨지면 — 어떻게 처리할지 의 결정이 비즈니스 임팩트 에 따라 달라져요.

깨진 트랙의 결과를 기본값으로 다운그레이드해서 두 트랙의 결과는 살리기 (resilience 우선) vs 세 트랙 모두 성공해야 응답을 내보내기 (정합성 우선) — 어느 쪽이 맞을까요?

본인의 실무에서 부분 실패에 어떻게 대응 하는 자리 (예: 마이크로서비스 호출 / 외부 API 합산 / 검색 결과 조합) 가 있다면 — 그 자리의 fallback 정책 과 오늘의 Parallelization 의 fallback 정책 을 한 번 나란히 놓고 비교해 보세요.

resilience 의 적절한 무게 가 어디서 결정되는지 — 그 결정의 근거가 정리됩니다.

✅ 예시 답안정답 보기

수업에서 PromptChainingService3 단 직렬 (안전 분류 → 답장 초안 → 페르소나 톤 검수) 까지 손에 들었어요. 운영팀의 부탁대로 — 답장이 만들어진 뒤에 / 이 답장을 보내면 캐릭터의 호감도가 얼마나 변할지 추천 하는 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 메시지를 던지면 affinityImpactnull 로 다운그레이드된 모양도 확인해 보세요.

💡 튜터의 결론 — 직렬 확장의 자연스러움

Prompt Chaining 의 직렬 확장 은.

기존 단의 골격을 손대지 않은 채 끝에 한 단만 얹는 결로 자연스럽게 자라요.

새 ChatClient 빈 한 개 + 새 record 한 개 + Service 의 마지막에 한 줄 호출 추가.

그게 전부.

운영에서 새 단을 얹어야 할 때 회귀 위험이 낮은 변경 패턴의 자리.

그리고 비용은 그만큼 늘어나요 (LLM 호출 +1).

4 단의 가치가 비용 +1 보다 무거운가 를 한 번 더 저울 위에 올려보는 결이 운영 결정의 자리예요.


🎯 [과제 2 예시 답안] Routing 에 5 번째 라벨 — GAME_COMMAND 분기 추가

수업에서 MessageRoutingService4 갈래 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.
switchcase 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

수업에서 ParallelAnalysisService3 트랙 동시 호출 까지 손에 들었어요.

그런데 운영 트래픽 에선 한 트랙만 가끔 깨지는 일이 생깁니다.

(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 이 **환각으로 새 라벨** 을 만들면

[문제 상황 요약]

수업의 messageRouter4 라벨 중 하나만 사용해라 라고 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-offfallback 빈도 모니터링 이 필수. 환각 처리와 같은 결.

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 으로 운영 안정성을 한 층 더 두텁게 만듭니다."

더 배우려면

실무 프로젝트까지 가고 싶다면

팀스파르타 백엔드 부트캠프에서 인스타그램 클론을 풀스택으로 완성합니다.