문서 읽는 데 355분 · day14

Day 14. Agent 2 패턴 + 운영 가드 4 부품

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

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

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

지난 시간, 손이 정말 무거우셨죠.

Prompt Chaining / Routing / Parallelization 을 외부 그래프 DSL 한 줄 없이 전문 ChatClient 빈 N 개 + 평범한 Java orchestrate + WorkflowLoggingAdvisor 한 장 만으로 익혔어요.

그리고 그 마지막에서 어드바이저라는 단어가 코드로 처음 등장했던 게 기억나실 거예요.

10 개의 빈 위에 공통 로그가 가로 보처럼 떨어지는 그 모습.

그 호흡으로 닫으면서 한 가지 복선을 흘려뒀었죠.

지난 시간에 만든 도구 3 종 (WeatherTool / GameStateTool / AffinityTool) 의 진짜 자율 호흡은. 오늘 만나는 단계예요.

Day 13 의 3 패턴은 흐름을 코드가 미리 정해둔 형태였잖아요.

Prompt Chaining 은 코드가 정한 직렬, Routing 은 코드가 정한 분기 (switch (decision.label()) 의 손맛), Parallelization 은 코드가 정한 병렬.

LLM 의 자율 박자는. 각 ChatClient 내부의 응답에만 한정돼 있었어요.

오늘은 그 라인을 한 칸 더 오른쪽으로 밉니다.

분기 자체를 LLM 이 결정하는 패턴, 그리고 품질이 통과할 때까지 루프가 도는 패턴.

둘을 직접 짜봐요.

🎯 오늘의 한 줄. Agent 2 패턴 (Orchestrator-Workers + Evaluator-Optimizer) 의 한 덩어리 + 자율성이 자란 만큼 따라붙어야 하는 운영 안전선 (advisor 4 부품) 의 한 묶음. 둘을 한 Day 안에 묶은 이유 — 자율성과 가드는 짝패 라서요. 자율성이 한 칸 자라면 가드도 한 칸 함께 자라야, 운영에서 새는 곳 없이 들고 갈 수 있어요.

먼저 오늘 익힐 두 패턴의 모습을 한 번 미리 그려둘게요.

Orchestrator-Workers마스터 LLM 한 명이 사용자 발화를 받아서, 몇 명의 워커에게 / 어떤 의도로 / 어떤 우선순위로 분배할지를 동적으로 결정해요.

그 분배 명세를 받은 워커들 (CharacterWorkerService) 이 CompletableFuture.allOf 로 병렬 응답하고, priority 순으로 합류합니다.

ai-friends 그룹 대화방에서 사용자가 "오늘 날씨 어때? 그리고 어제 게임 진행 상황도 알려줘" 라는 한 발화에 복수 의도 를 던졌을 때.

ARIA 가 날씨를 / REX 가 게임 상태를 / LUNA 가 친목을 자연스럽게 나눠 응답하는 패턴이에요.

Day 13 Routing 의 switch 분기가 코드 손에 있었다면, 오늘은 그 결정이 LLM 손으로 넘어갑니다.

Evaluator-Optimizer 는 방식이 좀 다른 두 번째 패턴이에요.

생성자 LLM (CharacterGenerationService) 이 캐릭터 설정 draft 를 만들면, 평가자 LLM (CharacterEvaluatorService) 이 PASSED / NEEDS_REVISION / FAILEDEvaluationVerdict 로 채점해요.

PASSED 가 아니면 suggestion 을 받아 draft 를 다시 갈아엎고, 다시 평가받고.

품질 기준이 통과할 때까지 루프가 도는 패턴.

ai-friends 의 새 캐릭터 생성 시나리오에서 살아 있는 감각의 페르소나가 나올 때까지 가다듬는 모습이에요.

Day 13 의 결정론적인 흐름 위에 루프 박자가 처음으로 등장하는 단계.

그게 오늘의 또 다른 호흡, 그리고 한 줄 더, 지난 시간과 똑같이

외부 그래프 DSL 을 한 줄도 끌어오지 않아요.

LangGraph 의 노드·엣지 / OpenAI Agents SDK 의 추상화 / Google ADK 의 빌더, 모두 본 Day 에서는 안 씁니다.

오늘도 Spring AI 1.1.x 의 ChatClient.Builder + BaseAdvisor 인터페이스 + 평범한 Java (CompletableFuture.allOf / while 루프 + 수동 카운터) 만으로 다 짜요.

지난 시간에 익혀둔 전문 ChatClient N 개 + 평범한 Java orchestrate + 공통 어드바이저 한 장 의 세 부품이 오늘도 그대로 살아 있어요.

그 위에 동적 분배 한 단계 / 루프 한 단계 / 가드 advisor 4 부품 만 더 얹히는 모양.

외부 DSL 은 Day 19 Harness 에서 만나요.

오늘은 직접 짜보는 단계입니다.


Step 1. Agent 2 패턴 — 개념

지난 시간 Workflow 3 패턴을 익힌 뒤, 머리에 다음 단계를 한 칸 띄워둘 필요가 있어요. 오늘 손코딩이 무거운 Day 라서 — Step 2 부터 들어가기 전에 우리가 정확히 어디로 가는지 한 번 짚고 가는 짧은 이론 시간입니다. 코드 변경은 없어요. Orchestrator-WorkersEvaluator-Optimizer — 이 두 이름이 어떤 패턴을 가리키는지, 그리고 왼쪽 세 패턴과 결정적으로 무엇이 다른지 — 15 분 안에 머리에 정리하고 손으로 넘어갑니다.

지난 시간 Workflow 3 패턴 — 흐름 회수

지난 시간 익힌 모습을 잠깐 다시 떠올려 볼게요.

Day 13 의 마지막에서 Prompt Chaining (메시지 → 캐릭터 답장 자동화의 3 단 직렬) / Routing (메시지 4 분류 분기) / Parallelization (메시지 3 트랙 동시 분석).

이 세 패턴이 외부 그래프 DSL 한 줄도 끌어오지 않고 Spring AI 의 ChatClient.Builder + 평범한 Java 만으로 다 짜였어요.

마지막 WorkflowLoggingAdvisor 에서는 어드바이저 라는 단어가 코드로 처음 등장했고요.

거기서 결정적인 한 줄 한 번 더 회수하고 갈게요.

세 패턴 모두. 호출 흐름을 코드가 미리 정해뒀어요.

Prompt Chaining 은 코드가 정한 직렬.

Routing 은 코드가 정한 분기 (switch (decision.label()) 의 손맛).

Parallelization 은 코드가 정한 병렬 (CompletableFuture.allOf).

LLM 의 자율 박자는. 각 ChatClient 내부의 응답 내용에만 한정 돼 있었어요.

다음 단계로의 흐름은 코드 손 이었던 거죠.

오늘은 그 라인을 한 칸 더 오른쪽으로 밉니다. 흐름 자체를 LLM 이 결정하는 단계 — 그게 Agent 2 패턴의 출발점이에요.

스펙트럼의 오른쪽 두 패턴 — 분기까지 LLM 이 결정한다

Day 12 에서 머리에 그렸던 스펙트럼 막대를 한 번 더 떠올려 볼게요.

막대 왼쪽 끝은 완전 결정론적 코드. LLM 0 박자.

오른쪽 끝은 완전 자율 Agent. LLM 4 박자.

그 사이에 5 패턴이 농도 순으로 늘어서 있었죠.

Prompt Chaining → Routing → Parallelization → Orchestrator-Workers → Evaluator-Optimizer.

지난 시간에 익힌 건 왼쪽 세 패턴.

오늘 익힐 건 오른쪽 두 패턴이에요.

이 두 패턴이 왼쪽 세 패턴과 결정적으로 다른 한 축은 — 분기까지 LLM 이 결정한다 는 점이에요. 한 번 풀어볼게요.

지난 시간 Routing 을 떠올려 보세요.

messageRouter 가 메시지 한 줄을 받아 FAQ / AFFINITY / SAFETY / CASUAL 의 4 라벨 중 하나를 골라줬어요.

라벨링까지는 LLM 자율 — 그런데 그 라벨을 받아서 어느 ChatClient 를 호출할지의 분기 는 자바 코드의 switch (label) 이 책임졌죠.

분기는 코드 손, 라벨 후보가 닫힌 enum 4 개 라서 자바의 switch 가 자연스러웠어요.

오늘 Orchestrator-Workers 에서는 라인이 한 칸 오른쪽으로 밀려요.

라벨링 + 분기까지 모두 LLM 자율.

마스터 LLM 한 명이 사용자 발화를 받아서 몇 명의 워커에게 / 어떤 의도로 / 어떤 우선순위로 분배할지를 동적으로 결정해요.

라벨 후보가 닫힌 enum 4 개 가 아니라.

유저 발화에 따라 1 명일 수도 있고 3 명일 수도 있는 동적 리스트.

switch 가 어울리지 않죠.

그래서 분배 명세 자체를 LLM 이 출력 하는 거예요.

Evaluator-Optimizer 에는 또 다른 자율 박자가 들어있어요.

루프 종료 시점까지 LLM 자율.

생성자 LLM 이 산출물을 만들면 평가자 LLM 이 PASSED / NEEDS_REVISION / FAILED 같은 채점을 돌려줘요.

PASSED 가 아니면 평가자의 가이드를 받아 다시 생성 하고, 다시 평가받고.

루프가 언제 끝나는지평가자 LLM 이 결정해요.

자바 코드의 while루프의 골격만 잡아둘 뿐, 종료 신호는 LLM 손에 있어요.

두 패턴 모두 결정 박자가 한 칸 늘었어요.

Day 13 의 Routing 이 "라벨링만 LLM" 이었다면, 오늘의 두 패턴은 "분배까지 LLM" / "루프 종료까지 LLM" — 그만큼 자율성의 농도가 짙어진 거예요.

Orchestrator-Workers — 한 줄 정의 + 적합 시나리오

먼저 첫 번째 패턴의 명함부터 정리할게요. Anthropic 의 Building Effective Agents (2024 년 12 월) + Spring AI 1.1.x Reference 의 한 줄 정의 — 마스터 LLM 이 작업을 동적으로 분해해 여러 워커 LLM 에게 분배 → 워커들이 병렬로 작업 → 결과 합류

마스터 한 명 + 워커 N 명의 분업 구도예요.

마스터는 분해와 분배만 / 워커는 자기 도메인의 응답만 — 책임이 깨끗하게 갈리는 구조.

어떤 시나리오가 이 패턴에 자연스럽게 떨어지는지 표로 정리해볼게요.

시나리오 적합한가 한 줄 요약
그룹 대화방의 자연스러운 분배 사용자 한 발화에 ARIA / REX / LUNA 가 자기 역할로 응답 — 본 강의의 메인 시나리오
복잡한 코드 생성 분해 마스터가 함수 단위로 쪼개고 / 워커가 함수 하나씩 구현
멀티 소스 동시 검색 마스터가 어느 소스 셋이 적합한지 결정 → 워커들이 각 소스에서 동시 검색
닫힌 enum 4 라벨 분기 Day 13 Routing 으로 충분 — 마스터 LLM 의 비용·지연이 과잉
순차 의존 단계 Day 13 Prompt Chaining 의 몫 — 병렬 분배가 어울리지 않음

표를 다시 한 줄로 압축하면 — 분기 후보가 동적이고 / 워커들이 서로 독립적으로 일할 수 있는 시나리오 에 자연스럽게 떨어져요.

분기 후보가 고정 이거나 단계 사이가 의존 이면 — 지난 시간의 Routing / Prompt Chaining 으로도 충분합니다.

과잉 패턴은 자체 비용 — 마스터 LLM 호출 한 번이 추가되는 만큼 비용·지연 모두 한 박자 늘어요.

본 강의는 ai-friends 그룹 대화방을 시나리오로 잡았어요.

사용자가 "오늘 날씨 어때? 그리고 어제 게임 진행 상황도 알려줘" 같은 한 발화에 복수 의도 를 던졌을 때 — 마스터 LLM"ARIA 는 날씨 / REX 는 게임 상태 / LUNA 는 친목" 같은 분배 명세를 동적으로 만들어내고, 워커 3 명이 CompletableFuture.allOf 로 동시에 응답하는 구조.

분기 후보가 동적이고 / 워커가 독립적으로 일할 수 있는 그 조건이 정확히 들어맞아요.

Evaluator-Optimizer — 한 줄 정의 + 적합 시나리오

두 번째 패턴의 명함을 정리해볼게요.

같은 두 출처의 한 줄 정의. — 생성자 LLM 이 산출물을 만들고 평가자 LLM 이 채점, PASSED 가 아니면 평가자의 가이드를 받아 다시 생성하는 루프

생성자 + 평가자 두 자리 구도예요.

생성자는 만드는 일만 / 평가자는 채점하는 일만 — 책임이 갈린 채 둘이 한 루프 위에서 핑퐁하는 구조.

이 패턴이 자연스럽게 떨어지는 시나리오도 같은 형식으로 정리해볼게요.

시나리오 적합한가 한 줄 요약
캐릭터 일관성·톤 가다듬기 평가자가 페르소나 어긋남 을 잡고 재생성 가이드 — 본 강의의 메인 시나리오
코드 품질 검토 생성된 코드를 평가자가 린트 / 안전성 으로 점검 → 수정 가이드
번역 품질 다듬기 평가자가 원문 충실도 / 자연스러움 으로 채점 → 재번역
응답 시간이 중요한 경우 루프 도는 만큼 지연 누적 — 실시간 응답엔 다른 패턴이 어울림
평가 기준이 주관적 + 일관성 낮은 경우 평가자 LLM 이 자기 검열이 안 됨 — 루프가 발산할 위험

이쪽 표도 한 줄로 압축하면 — 품질이 정확도·일관성 축에서 측정 가능한 시나리오 에 자연스럽게 떨어져요.

평가 기준이 명시 가능 해야 평가자가 일관된 채점을 돌려주고, 그래야 루프가 수렴 해요.

평가 기준이 흐릿하면 — 평가자 LLM 이 매번 다른 톤으로 채점해서 루프가 발산 할 수 있어요.

측정 가능성 이 본 패턴의 전제 조건입니다.

본 강의는 ai-friends 의 새 캐릭터 생성 시나리오를 잡았어요.

생성자 LLM 이 캐릭터 카드 한 장의 draft 를 만들면 — 평가자 LLM 이 살아 있는 감각 / 페르소나 일관성 / 본 강의 톤 적합성 의 3 축으로 채점.

PASSED 가 나올 때까지 suggestion 을 받아 다시 갈아엎는 루프.

측정 가능한 품질 축이 명시되어 있는 영역이라 — 루프가 단단하게 수렴해요.

두 패턴의 짝패 — 자율성과 가드는 함께 자란다

여기서 한 박자 멈추고, 두 패턴이 왜 한 Day 안에 묶여 있는지 를 한 번 짚고 갈게요.

Orchestrator-WorkersEvaluator-Optimizer — 방식이 다른 두 패턴인데도 같은 Day 의 한 호흡으로 묶인 데는 이유가 있어요.

두 패턴은 자율성이 자라는 지점 이 다르긴 해요.

Orchestrator-Workers 의 자율성은 분기 — 몇 명에게 / 어떤 의도로 / 어떤 priority 로 분배할지를 LLM 이 결정.

Evaluator-Optimizer 의 자율성은 루프 종료 — 언제 PASSED 라고 돌려줄지를 평가자 LLM 이 결정.

자율성이 들어선 곳이 다르죠.

그런데 자율성이 한 칸 자라면, 똑같이 어디서 새는지 도 한 칸 자라요.

마스터 LLM 이 분배를 잘못하면 워커가 무한히 추가될 수 있고, 평가자 LLM 이 PASSED 를 영영 안 돌려주면 루프가 영영 안 끝나요.

자율성의 농도 = 새는 곳의 농도.

이게 두 패턴이 Day 14 의 advisor 4 부품 과 같은 호흡 안에 묶여 있는 이유예요.

호출 횟수 · 시간 · 토큰 · 도구 호출 의 4 축 안전선이 자율성이 자란 만큼 함께 자라야 한다는 거예요.

💡 튜터의 결론

Anthropic 의 Building Effective Agents (2024 년 12 월) 와 Spring AI 1.1.x Reference (2026 년 4 월) 가 같은 5 패턴 분류 — Prompt Chaining / Routing / Parallelization / Orchestrator-Workers / Evaluator-Optimizer — 위에서 정착했어요. 2026 년 5 월 시점에 두 출처가 같은 분류를 공유하고 있다는 건 — 외부 표준이 같은 방향으로 단단해진 모습이에요. 본 강의가 이 분류 위에서 익히는 패턴은 — 학생 한 명이 졸업한 뒤 외부 자료를 찾아 읽을 때도 같은 5 칸의 분류 체계로 곧장 연결되는 길이라는 뜻. 그게 본 Day 의 기반의 단단함 을 받쳐주는 토대예요.

우리 ai-friends 미연시 게임 도메인 — 두 시나리오 매핑

오늘 익힐 두 패턴이 본 강의의 ai-friends 도메인에서 어디에 떨어지는지 한 번 짚어두고 갈게요.

(A) Orchestrator-Workers — 그룹 대화방의 자연스러운 분배.

사용자가 ai-friends 그룹 대화방에 "오늘 날씨 어때? 그리고 어제 게임 진행 상황도 알려줘" 같은 한 발화에 복수 의도 를 던지는 시나리오.

마스터 (OrchestratorMasterService) 한 명 + 워커 (CharacterWorkerService) 3 명 = ARIA / REX / LUNA.

마스터가 분배 명세 (WorkerAssignment 리스트) 를 동적으로 만들고, 통합 서비스 (GameOrchestrationService) 가 CompletableFuture.allOf 로 워커 3 명을 동시에 호출 → priority 순으로 합류.

Step 2 ~ 4 의 영역이에요.

(B) Evaluator-Optimizer. 캐릭터 narration 품질 가다듬기.

새 캐릭터를 ai-friends 에 등록할 때 살아 있는 감각의 페르소나가 나올 때까지 다듬는 시나리오.

생성자 (CharacterGenerationService) 가 캐릭터 카드 draft 를 만들면.

평가자 (CharacterEvaluatorService) 가 EvaluationVerdictPASSED / NEEDS_REVISION / FAILED 로 채점.

PASSED 가 아니면 suggestion 을 받아 다시 생성.

Step 5 ~ 6 의 영역이에요.

두 시나리오 모두 Day 11 의 도구 3 종 (WeatherTool / GameStateTool / AffinityTool) 위에 자라요.

Orchestrator-Workers 쪽은 워커가 도구 3 종을 그대로 흡수해서.

마스터가 "REX 는 게임 상태 도구를 써서 응답해" 같은 의도를 넘기면 워커가 실제로 도구를 호출하는 흐름.

Evaluator-Optimizer 쪽은 생성자와 평가자 모두 도구 미사용.

책임 분리가 깨끗 해서 도구가 끼어들지 않아요.

자율성의 방향에 맞춰 도구 사용 여부도 갈립니다.

Step 2 부터의 흐름 — 한 호흡 미리 그려두기

마지막으로 — 오늘 9 Step 의 호흡을 한 번 더 그려두고 이론 단계를 닫을게요.

묶음 Step 한 줄 요약
Orchestrator-Workers 한 덩어리 Step 2 ~ 4 마스터 + 워커 + 통합 — 3 service + 분배 명세 record 2 장
Evaluator-Optimizer 한 덩어리 Step 5 ~ 6 생성자 + 평가자 + Optimizer 루프 — 2 service + verdict enum
advisor 4 부품 + 권한 스코프 Step 7 ~ 9 maxIterations · 시간 · 토큰 · 도구 호출 + MCP tools.execute
마무리 Day 15 RAG 복선 (임베딩 / pgvector / VectorStore / 청킹)

Step 1 (이론) → Step 2 ~ 6 (Agent 2 패턴 한 덩어리) → Step 7 ~ 9 (advisor 4 부품 + 권한 스코프) → 마무리 의 네 호흡으로 펴 두었어요.

Step 2 ~ 6 의 자율성을 먼저 다 짜둔 다음에, Step 7 ~ 9 에서 자율성이 새는 곳에 가드를 가로 보처럼 얹는 흐름으로 갑니다.

왜 가드가 이 Day 에 같이 자리잡은지 — 위에서 정리한 자율성과 가드는 짝패 라는 메시지가 손코딩으로 회수되는 호흡이에요. 🧶

자, 이론은 여기까지 이제 손으로 들어갑시다.

Step 2 에서 Orchestrator-Workers 의 첫 부품 — 마스터 LLM 의 분할 — 부터 짜봐요.

마스터가 사용자 발화를 워커 분배 명세 로 어떻게 변환하는지, 그리고 그 변환을 외부 그래프 DSL 한 줄 없이 ChatClient + 구조화 출력 + 화이트리스트 + priority 로 어떻게 짜는지 — 직접 익혀봅니다.

Step 2. Orchestrator-Workers ① — 마스터 LLM 분할

지난 시간 Day 13 의 Routing 패턴을 한 번 떠올려볼게요. messageRouterRouteLabel 라벨 4 개 (FAQ / AFFINITY / SAFETY / CASUAL) 중 하나를 골라줬고, 그 다음은 우리 자바 코드의 switch (label) 이 분기를 책임졌죠. 라벨링까지는 LLM 자율, 분기는 코드의 손이었어요. 오늘은 그 라인을 한 칸 더 오른쪽으로 밀어봅니다. 분기까지 LLM 의 손에 맡기는 자리 — 그게 Orchestrator-Workers 의 출발점이에요.

Day 13 Routing → Day 14 Orchestrator-Workers — 분기 자리의 이동

같은 그림처럼 보이지만, 분기를 누가 결정하는가 의 축에서 둘은 결정적으로 다릅니다. 표로 한 번 정리하고 가시죠.

Day 13 — Routing Day 14 — Orchestrator-Workers
LLM 의 산출물 RouteLabel 라벨 1 개 (닫힌 enum 4 종) 워커 N 명의 분배 명세 (동적 리스트)
분기 결정자 자바 코드 (switch (decision.label())) LLM 자체 (몇 명 / 어떤 의도 / 어떤 순서)
의도 갯수 항상 1 건 1~N 건 (한 발화에 의도가 섞여도 됨)
후처리 책임 거의 없음 (라벨 → 메서드 호출) 화이트리스트 필터 + priority 정렬
자율성 수준 낮음 — 라벨만 자율 중간 — 분기 구조 자체가 자율

표를 보면 LLM 이 짊어지는 결정의 폭이 확실히 넓어졌습니다.

몇 명의 워커에게 / 어떤 의도로 / 어떤 우선순위로 까지 — 세 축이 한꺼번에 동적으로 열렸어요.

자율성이 자란 만큼 어디서 새는지 도 함께 자라는 게 중요한 포인트입니다.

LLM 이 환각으로 활성 목록 밖 캐릭터를 끼워넣을 수도 있고, priority 를 뒤죽박죽으로 돌려줄 수도 있죠.

그래서 본 Day 의 뒷부분 Step 들에서 가드 어드바이저 4 부품(타임아웃 · 토큰 예산 · 툴 호출 횟수 · 최대 반복) 이 같은 Day 안에 묶여 있는 거예요.

자율성과 가드는 한 묶음으로 자랍니다.

그룹 대화 시나리오 — 한 발화에 복수 의도

ai-friends 의 그룹 대화방을 떠올려볼게요.

캐릭터 ARIA(날씨 정보 담당), REX(게임 상태 담당), LUNA(친목·감정 담당) 가 한 방에 활성화되어 있다고 칩시다.

사용자가 이렇게 한 줄 던집니다.

"오늘 날씨 어때? 그리고 어제 게임 진행 상황도 알려줘."

이 발화엔 두 가지 의도가 섞여 있죠.

날씨 정보를 묻는 부분과 게임 상태를 묻는 부분.

Day 13 Routing 의 방식으로는 둘 중 하나만 골라야 했어요 — 라벨이 닫혀 있으니까요.

그런데 그룹 대화방 같은 맥락에선 둘 다 응답하는 게 자연스러워요.

ARIA 가 먼저 날씨를 답해주고, 이어서 REX 가 게임 상태를 보고하는 흐름이 더 자연스럽죠.

이 분배를 누가 결정하느냐가 오늘의 핵심입니다.

마스터 LLM 한 명이 발화를 분석해서 워커 N 명에게 분배 명세를 내려보내는 구조예요.

결과는 이렇게 생긴 모양이에요.

사용자 발화: "오늘 날씨 어때? 그리고 어제 게임 진행 상황도 알려줘."
↓ 마스터 LLM 분석
분배 명세:
  - ARIA  / 의도: 날씨 정보 응답   / priority: 1
  - REX   / 의도: 게임 상태 응답   / priority: 2

이 분배 명세를 만들어내는 단계까지가 오늘 Step 2 의 영역이에요. 실제로 각 워커가 자기 의도대로 응답을 생성하는 단계는 다음 Step 3 에서 만나요.

분배 명세의 자료구조

분배 명세를 자바 자료구조로 어떻게 표현할지부터 잡고 가요. 두 장의 record 가 있어요. 먼저 워커 한 명에게 내려가는 단일 할당부터.

package kr.spartaclub.aifriends.agent.dto;

/**
 * Day 14 Step 2 — Orchestrator-Workers 패턴에서 마스터 LLM 이 워커 1 명에게
 * 내려보내는 단일 할당 명세.
 *
 * <p>한 사용자 발화 안에 의도가 N 개 섞여 있을 때, 마스터 LLM 은 그 의도들을
 * 캐릭터별로 분해한다. 각 분해 단위가 본 record 한 장이다.</p>
 *
 * @param characterId 응답을 맡을 캐릭터의 식별자 (예: "ARIA", "REX", "LUNA")
 * @param responseIntent 그 캐릭터가 어떤 의도로 답해야 하는지 한 줄 요약 (예: "날씨 정보 응답")
 * @param priority 응답 우선순위 (1 부터 시작, 작을수록 먼저 발화) — 합류 출력 순서를 정한다
 */
public record WorkerAssignment(
        String characterId,
        String responseIntent,
        int priority
) { }

세 필드가 각각 누가 / 무엇을 / 몇 번째 를 담당합니다.

characterId 는 워커 식별자, responseIntent 는 그 워커가 어떤 의도로 응답해야 하는지 한 줄 요약, priority 는 출력 순서.

priority 가 왜 있어야 하냐면, 그룹 대화에서 누가 먼저 말하느냐 도 자연스러운 대화 흐름의 일부거든요.

위 예시에서 ARIA 가 날씨를 먼저 답하고 그 다음 REX 가 게임 상태를 답하는 게 자연스럽게 들리는 이유는, 사용자 발화의 의도 순서대로 응답하기 때문이에요.

priority 가 그 순서를 보존해주는 필드입니다.

이 단일 할당 N 장을 모아서 발화 1 건의 분배 명세 전체를 만드는 게 다음 record 예요.

package kr.spartaclub.aifriends.agent.dto;

import java.util.List;

/**
 * Day 14 Step 2 — Orchestrator-Workers 패턴의 마스터 LLM 산출물.
 *
 * <p>사용자 발화 1 건을 받아 *어느 캐릭터들이* *어떤 의도* 로 *어떤 우선순위* 에
 * 응답해야 하는지 분배한 결과. 본 record 는 {@code chatClient.call().entity(...)}
 * 의 구조화 출력 타깃으로 그대로 쓰인다.</p>
 *
 * <p>Day 13 Routing 의 {@code RouteDecision} 이 "1 라벨만" 골랐다면, 본 record 는
 * {@link WorkerAssignment} 의 리스트로 "N 명 분배" 를 표현한다.</p>
 *
 * @param userUtterance 원본 사용자 발화 — 이후 워커 단계에서 다시 참조하기 위해 그대로 보존
 * @param assignments 워커별 할당 명세 — priority 오름차순으로 정렬된 상태를 외부 계약으로 한다
 */
public record DialogueDistribution(
        String userUtterance,
        List<WorkerAssignment> assignments
) { }

assignments 는 짐작대로 WorkerAssignment 의 리스트예요.

한 가지 더 짚어둘 건 userUtterance 필드.

원본 사용자 발화를 분배 명세 안에 그대로 보존 하고 있죠? 이렇게 해두는 이유는 다음 Step 3 의 워커 단계 때문이에요.

각 워커가 응답을 생성할 때 자기가 어떤 의도로 답해야 하는지 (responseIntent) 만으로는 부족하고, 원본 발화 전문 도 함께 참조해야 자연스러운 답변이 나오거든요.

분배 명세 한 장이 워커 호출에 필요한 모든 컨텍스트를 자체적으로 들고 다닌다 — 그게 이 record 설계의 의도예요.

AgentChatClientConfig.orchestratorMasterChatClient 빈 — system 프롬프트 풀이

자료구조를 잡았으니 이제 마스터 LLM 의 입을 만들 차례예요.

Day 13 의 WorkflowChatClientConfig 가 시나리오별로 ChatClient 빈을 작게 쪼개 등록했던 패턴 기억하시죠?

Day 14 도 같은 방식으로 갑니다.

한 클래스에 모아두면 마스터의 프롬프트 변경이 워커 쪽으로 새거든요.

package kr.spartaclub.aifriends.agent.config;

import kr.spartaclub.aifriends.workflow.advisor.WorkflowLoggingAdvisor;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * Day 14 — Agent 2 패턴(Orchestrator-Workers / Evaluator-Optimizer) 전용 ChatClient 빈.
 */
@Configuration
public class AgentChatClientConfig {

    /**
     * Step 2 — Orchestrator-Workers 의 마스터 LLM 전용 ChatClient.
     */
    @Bean
    public ChatClient orchestratorMasterChatClient(ChatClient.Builder builder, WorkflowLoggingAdvisor advisor) {
        return builder
                .defaultSystem("""
                        너는 ai-friends 그룹 대화방의 오케스트레이터야.
                        한 명의 사용자 발화를 받아, 활성 캐릭터들 중 누가 어떤 의도로
                        응답하면 좋을지 분배하는 역할만 해. 직접 답변은 하지 마.

                        분배 규칙:
                        - 발화에 의도가 여러 개 섞여 있으면 의도 수만큼 캐릭터를 골라.
                        - 단일 의도 발화에는 가장 적합한 1 명만 골라.
                        - 활성 캐릭터 ID 목록에 있는 캐릭터만 골라야 해. 목록 밖 캐릭터 금지.
                        - priority 는 자연스러운 발화 순서를 나타낸다 — 1 이 가장 먼저.

                        출력 JSON 스키마:
                        - userUtterance: 입력받은 사용자 발화 원문 그대로
                        - assignments: WorkerAssignment 배열
                          - characterId: 활성 캐릭터 ID 중 하나
                          - responseIntent: 그 캐릭터가 어떤 의도로 답해야 하는지 30자 이내 한 문장
                          - priority: 1 부터 시작하는 양의 정수
                        """)
                .defaultAdvisors(advisor)
                .build();
    }
}

system 프롬프트가 시키는 일을 한 줄씩 짚어볼게요.

  • 첫 단락 — 오케스트레이터의 역할 한정. 직접 답변은 하지 마 가 결정적인 한 줄이에요. 마스터 LLM 이 자기가 답해버리는 환각을 사전에 차단합니다. 마스터는 분배만, 응답 생성은 워커가 — 책임 분리를 system 프롬프트가 못 박아둡니다.
  • 분배 규칙 4 줄. (1) 복수 의도면 의도 수만큼 캐릭터 선택, (2) 단일 의도면 1 명만, (3) 활성 캐릭터 목록 밖은 금지, (4) priority 는 발화 순서. (3) 의 목록 밖 캐릭터 금지 가 환각 차단의 1 차 방어선이에요. 다만 LLM 이 이걸 안 지킬 가능성이 0% 는 아니라서, 뒤에서 보겠지만 service 단에서도 한 번 더 거릅니다.
  • 출력 JSON 스키마. DialogueDistribution record 의 모양을 그대로 받아쓰도록 지시합니다. chatClient.call().entity(DialogueDistribution.class) 가 이 스키마대로 LLM 응답을 파싱해줘요. Spring AI 의 .entity(Class<T>) 가 마법처럼 동작하는 건 Day 4 에서 한 번 만나본 패턴이죠.

.defaultAdvisors(advisor) 한 줄에는 Day 13 에서 만든 WorkflowLoggingAdvisor 가 그대로 꽂혀 있어요.

같은 advisor 슬롯에 본 Day 의 뒷부분 Step 들에서 가드 어드바이저 4 부품이 한 개씩 추가됩니다.

advisor 라는 하나의 통합 레이어 위에서 로깅 → 타임아웃 → 토큰 예산 → 툴 호출 횟수 → 최대 반복 이 한 줄씩 쌓이는 구조예요.

OrchestratorMasterService — 두 안전선

자료구조와 ChatClient 빈을 잡았으니, 이제 둘을 묶어주는 service 를 봅니다.

본 서비스가 오늘 코드 중 가장 중요한 한 장 이에요.

마스터 LLM 의 출력을 service 단에서 어떻게 재가공 하는지가 통째로 담겨 있거든요.

package kr.spartaclub.aifriends.agent.service;

import java.util.Comparator;
import java.util.List;
import java.util.Set;

import kr.spartaclub.aifriends.agent.dto.DialogueDistribution;
import kr.spartaclub.aifriends.agent.dto.WorkerAssignment;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;

@Component
public class OrchestratorMasterService {

    private final ChatClient orchestratorMasterChatClient;

    public OrchestratorMasterService(
            @Qualifier("orchestratorMasterChatClient") ChatClient orchestratorMasterChatClient
    ) {
        this.orchestratorMasterChatClient = orchestratorMasterChatClient;
    }

    /**
     * 사용자 발화 1 건과 활성 캐릭터 목록을 받아 워커 분배 명세를 돌려준다.
     *
     * @param userUtterance 사용자 발화 원문
     * @param activeCharacterIds 현재 그룹 대화방에 활성화된 캐릭터 ID 목록 (whitelist)
     * @return priority 오름차순으로 정렬되고 비활성 캐릭터가 걸러진 분배 명세
     */
    public DialogueDistribution distribute(String userUtterance, List<String> activeCharacterIds) {
        DialogueDistribution raw = orchestratorMasterChatClient.prompt()
                .user("""
                        다음 사용자 발화를 분석해서, 활성 캐릭터 중 어떤 캐릭터들이 어떤 의도로 응답해야 할지 분배해줘.

                        사용자 발화:
                        "%s"

                        활성 캐릭터 ID 목록: %s
                        """.formatted(userUtterance, activeCharacterIds))
                .call()
                .entity(DialogueDistribution.class);

        Set<String> activeIds = Set.copyOf(activeCharacterIds);
        List<WorkerAssignment> sanitized = raw.assignments().stream()
                .filter(assignment -> activeIds.contains(assignment.characterId()))
                .sorted(Comparator.comparingInt(WorkerAssignment::priority))
                .toList();

        return new DialogueDistribution(userUtterance, sanitized);
    }
}

코드 흐름은 단순해요.

위쪽 절반에서 chatClient.call().entity(DialogueDistribution.class) 로 마스터 LLM 의 분배 명세를 raw 로 받고, 아래쪽 절반에서 그 raw 를 service 단에서 두 번 가공한 뒤 반환합니다.

두 번의 가공 이 본 service 의 핵심이에요.

💡 튜터의 결론 — 두 안전선이 service 단에 있는 이유

첫 번째 안전선 — 활성 캐릭터 화이트리스트 필터. activeIds.contains(assignment.characterId()) 한 줄이 환각 캐릭터를 통째로 걸러냅니다. system 프롬프트에서 목록 밖 캐릭터 금지 라고 지시했어도 LLM 이 환각으로 MILO 같은 비활성 캐릭터를 끼워넣는 경우가 0% 가 아니에요. 프롬프트의 규칙은 강제력이 아니라 권고 라고 생각하시면 됩니다. 외부 계약의 진짜 강제력은 service 코드에서 나와요. 두 번째 안전선 — priority 오름차순 정렬. Comparator.comparingInt(WorkerAssignment::priority) 가 LLM 이 순서를 뒤섞어 돌려줘도 service 가 정렬을 보정해요. LLM 응답의 도착 순서는 외부 계약이 아니다 가 핵심 원칙입니다. 두 안전선 모두 같은 메시지를 담고 있어요 — LLM 출력을 그대로 신뢰 금지, 외부 계약은 service 가 책임진다.

마지막 줄 return new DialogueDistribution(userUtterance, sanitized); 에서 원본 발화 는 그대로 들고, assignments 만 가공한 버전 으로 갈아끼우는 모양이 보이실 거예요.

record 가 immutable 이라 새로 만들어 돌려주는 거예요.

이 동작은 코드베이스의 OrchestratorMasterServiceTest 가 단일 의도 분배·복수 의도 priority 정렬·비활성 캐릭터 필터링의 3 케이스로 검증해두었어요.

직접 돌려보고 싶다면 ./gradlew test --tests OrchestratorMasterServiceTest 한 줄이면 됩니다.

🙋 날카로운 질문 타임

"튜터님, 마스터 LLM 이 환각으로 같은 캐릭터를 두 번 끼워넣으면 어떻게 되나요? 예를 들면 ARIA 한테 priority 1 / ARIA 한테 priority 2 — 이렇게요."

좋은 지적이에요. 현재 service 의 정책으로는 그대로 두 개 다 통과 합니다. 화이트리스트 필터는 목록 안에 있느냐 만 보고, priority 정렬은 순서만 잡아주거든요. 중복 자체를 막는 안전선은 아직 없습니다. 실제 그룹 대화에서 같은 캐릭터가 한 발화에 두 번 응답하는 게 어색하긴 해도, 전혀 무의미하진 않은 경우 가 있어요 — 예를 들어 ARIA 가 priority 1 로 인사를 하고, priority 2 로 본격 답변을 이어가는 식이라면 자연스러울 수 있죠. 그래서 어떤 정책으로 막을지는 도메인 판단 의 영역이에요. 막고 싶다면 service 에 Set<String> 을 하나 더 만들어 characterId distinct 필터를 추가하면 됩니다.

"priority 가 같은 두 워커가 떨어지면 어느 쪽이 먼저인가요?"

Comparator.comparingInt안정 정렬(stable sort) 이라 입력 순서를 그대로 유지 해요. 즉 LLM 이 돌려준 raw assignments 의 순서가 그대로 보존됩니다. 외부 계약 측면에선 priority 가 같으면 순서는 보장하지 않는다 가 가장 솔직한 답이에요. 만약 추가 결정 기준 이 필요하면 (예: 캐릭터 ID 알파벳순) .thenComparing(WorkerAssignment::characterId) 한 줄을 보태면 됩니다. 다음 Step 들에서 이 정책을 정교하게 다루진 않을 거고, 본 강의 범위에선 priority 가 같은 케이스가 거의 없도록 system 프롬프트가 유도하는 수준까지로 둡니다.

다음 Step 예고

오늘 만든 DialogueDistribution 은 다음 Step 3 의 입력으로 그대로 흘러갑니다.

Step 3 에서 만날 CharacterWorkerService 가 분배 명세를 받아서 각 워커마다 캐릭터별 ChatClient 를 호출해 실제 응답을 생성 해요.

마스터가 지시서를 쓰는 사람 이라면, 워커는 지시서를 들고 자기 일을 해내는 사람 이죠.

그리고 Step 3 에서 Day 11 에서 만든 도구 3 종(WeatherTool / GameStateTool / AffinityTool) 이 워커에게 흡수되는 단계 가 등장해요.

지난 시간 Day 13 마무리에서 "지난 시간에 만든 도구 3 종의 진짜 자율 호흡은 Day 14 에서 만나는 결" 이라고 한 줄 흘려뒀던 거 기억하시죠?

그 회수가 다음 Step 에서 일어납니다.

ARIA 워커가 날씨 의도로 호출되면 WeatherTool 을 자율적으로 호출하고, REX 워커가 게임 상태 의도로 호출되면 GameStateTool 을 호출하는 — 분배된 의도가 도구 호출로 자라는 모습이에요.

그럼 다음 Step 으로 넘어가볼게요.

Step 3. Orchestrator-Workers ② — 워커 + Day 11 도구 흡수

Step 2 에서 마스터 LLM 이 지시서 한 장 을 만들어주는 구간을 보셨죠. DialogueDistribution 이 priority 오름차순으로 정돈된 채 service 밖으로 나오는 모습이요. 이제 그 지시서가 실제 응답으로 살아나는 구간으로 넘어갑니다. 지시서를 들고 자기 일을 해내는 워커 N 명 — 그게 본 Step 의 모습이에요.

워커 단계의 호흡 — 지시서 → 응답

Step 2 의 출력이 Step 3 의 입력으로 어떻게 흐르는지부터 한 번 그림으로 잡고 가요.

마스터가 분배한 WorkerAssignment 한 장이 워커 한 명에게 그대로 전달되고, 워커는 자기 캐릭터 톤 + 의도에 맞춰 자연어 응답을 만들어내는 흐름입니다.

DialogueDistribution
  ├─ WorkerAssignment(ARIA, "날씨 정보 응답", priority 1) ──→ ARIA 워커 ──→ WorkerResponse
  ├─ WorkerAssignment(REX,  "게임 상태 응답", priority 2) ──→ REX 워커  ──→ WorkerResponse
  └─ WorkerAssignment(LUNA, "감정 공감 응답", priority 3) ──→ LUNA 워커 ──→ WorkerResponse

워커 N 명이 각자 자기 의도와 캐릭터 톤 으로 응답을 만들어내는 구간이에요.

한 명씩 분리해서 보면 동작은 단순합니다.

받은 WorkerAssignment 한 장으로 자기 캐릭터 ChatClient 를 호출하고, 결과 자연어를 WorkerResponse 한 장으로 돌려주는 호흡.

본 Step 에선 워커 1 명 까지를 다룹니다.

N 명을 병렬로 돌리고 priority 오름차순으로 합류시키는 단계 는 Step 4 에서 다시 만날 거예요.

WorkerResponse record — 워커 1 명의 산출물

워커가 돌려주는 응답 한 장의 자료구조부터 봅니다. 마스터의 WorkerAssignment 와 짝패가 되는 record 한 장이에요.

package kr.spartaclub.aifriends.agent.dto;

/**
 * Day 14 Step 3 — Orchestrator-Workers 의 워커 1 명이 돌려주는 응답 산출물.
 *
 * <p>{@link WorkerAssignment} 를 받아 해당 캐릭터 ChatClient 로 LLM 호출한 결과를 담는다.
 * 합류 단계 (Step 4 의 {@code GameOrchestrationService}) 에서 워커 N 명의 응답을
 * priority 오름차순으로 모아 최종 그룹 대화 출력을 만든다.</p>
 *
 * @param characterId 응답을 만든 캐릭터의 식별자 ({@link WorkerAssignment#characterId()} 와 동일)
 * @param responseText LLM 이 생성한 자연어 응답 본문
 * @param priority 합류 시점 발화 우선순위 ({@link WorkerAssignment#priority()} 그대로 전달)
 */
public record WorkerResponse(
        String characterId,
        String responseText,
        int priority
) { }

세 필드는 직관적이에요.

characterId누가 응답했는지, responseTextLLM 이 만든 자연어 본문, priority합류 단계에서 몇 번째로 발화할지 예요.

마스터의 WorkerAssignment 에 있던 characterIdpriority 가 그대로 보존되는 모양이 보이실 거예요.

합류 단계 (Step 4) 에서 이 두 필드로 응답들을 정렬·식별하기 위함이에요.

한 가지 짚어두고 갈 디테일은 도구 호출 흔적 필드가 없는 점 이에요.

ARIA 워커가 WeatherTool 을 자율 호출해서 응답을 만들었다고 해도, 그 호출 흔적은 본 record 에 명시 필드로 들어오지 않습니다.

왜 안 넣었냐 — Day 11 도구 자동 호출의 흐름 을 따른 거예요.

도구를 부를지 말지는 LLM 의 자율 결정이고, 도구 결과는 자연어 응답에 녹아서 나옵니다.

호출 흔적 자체가 외부 계약으로 필요해지면 Day 13 의 WorkflowLoggingAdvisor 와 같은 방식으로 advisor 한 층을 더 붙여서 잡는 구조 예요.

본 Day 의 뒷부분 가드 어드바이저 부품 중 툴 호출 횟수 제한 이 같은 흐름의 친척입니다.

AgentChatClientConfig 의 확장 — 워커 ChatClient 빈 3 개

Step 2 에서 만든 AgentChatClientConfig 가 이번 Step 에서 세 캐릭터의 워커 빈 으로 확장됩니다.

ARIA / REX / LUNA 각자의 system 프롬프트와 도구 묶음이 다르게 들어간 ChatClient 빈 3 개.

먼저 ARIA 워커 빈을 전체 인용해서 워커 빈 1 개의 골격 을 확실히 잡고, REX 와 LUNA 는 system 프롬프트만 짧게 발췌해서 캐릭터별 차이만 짚어볼게요.

/**
 * Step 3 — 워커 ChatClient (ARIA — 친절·차분 카운슬러 결).
 *
 * <p>{@link kr.spartaclub.aifriends.agent.service.CharacterWorkerService} 가
 * {@code "ARIA"} characterId 를 받으면 빈 이름 {@code "ariaWorkerChatClient"} 로
 * 본 빈을 조회한다. 매핑 규칙은 *소문자 characterId + "WorkerChatClient"* 접미사.</p>
 *
 * <p>Day 11 의 도구 3 종({@link WeatherTool} / {@link GameStateTool} / {@link AffinityTool})
 * 을 그대로 흡수해 {@code defaultTools(...)} 한 줄에 등록한다 — LLM 이 발화 의도에 맞춰
 * 도구를 자율적으로 호출하고, Spring AI 의 Tool Calling 런타임이 호출 → 응답 합류를
 * 자동 처리한다.</p>
 */
@Bean
public ChatClient ariaWorkerChatClient(
        ChatClient.Builder builder,
        WorkflowLoggingAdvisor advisor,
        WeatherTool weatherTool,
        GameStateTool gameStateTool,
        AffinityTool affinityTool
) {
    return builder
            .defaultSystem("""
                    너는 ai-friends 그룹 대화방의 캐릭터 ARIA 야.
                    성격: 차분하고 친절한 카운슬러 톤. 천천히 또박또박 반말로 답해.
                    답변은 3 문장 이내로 간결하게.

                    등록된 도구는 발화 의도에 맞으면 자유롭게 호출해.
                    - 날씨/옷차림 관련 → getCurrentWeather
                    - 저번 게임 진행 회상 → loadGameState
                    - 유저와의 관계 질문 → getAffinity (읽기 전용)
                    도구가 found=false 같이 빈 결과를 돌려주면, 솔직하게 "잘 모르겠어" 라고 답해.
                    """)
            .defaultAdvisors(advisor)
            .defaultTools(weatherTool, gameStateTool, affinityTool)
            .build();
}

본 빈에서 두 가지 핵심 구간을 짚어볼게요.

첫 번째 구간. Day 11 도구 3종이 .defaultTools(...) 한 줄로 흡수.

weatherTool / gameStateTool / affinityTool 이 builder 파라미터로 들어와서 마지막 한 줄에 묶입니다.

Day 11 에서 만든 @Tool 메서드들이 이 한 줄로 본 ChatClient 에 들러붙어요.

LLM 이 발화 의도가 "오늘 날씨 어때?"getCurrentWeather 를 자율 호출하고, "어제 게임 어디까지 했지?"loadGameState 를 자율 호출하는 도구 선택은 LLM 의 자율 판단이고, 호출 → 결과 합류는 Spring AI Tool Calling 런타임이 자동 처리해요.

두 번째 구간 — system 프롬프트가 캐릭터 톤 + 도구 사용 가이드를 함께 담는다.

"차분하고 친절한 카운슬러 톤" + "3 문장 이내" 가 캐릭터 톤 정의이고, "날씨/옷차림 관련 → getCurrentWeather" 같은 매핑 가이드가 도구 사용 가이드예요.

같은 도구 셋이라도 워커별 system 프롬프트가 다르면 언제 어떤 도구를 부를지 의 양상이 살짝씩 달라집니다.

ARIA 는 차분하게 한 번에 한 도구만 부른다면, 다음 빈에서 보겠지만 REX 는 게임 도구를 적극적으로 호출하는 모양이에요.

REX 워커 빈의 system 프롬프트만 짧게 발췌해볼게요.

.defaultSystem("""
        너는 ai-friends 그룹 대화방의 캐릭터 REX 야.
        성격: 활기차고 게임을 사랑하는 텐션. 반말 + 가벼운 감탄사 섞어서 답해.
        답변은 3 문장 이내로 짧고 리듬감 있게.

        등록된 도구는 발화 의도에 맞으면 자유롭게 호출해.
        - 날씨/외출 관련 → getCurrentWeather
        - 게임 진행 회상/저장 → loadGameState / saveGameState
        - 유저와의 관계 질문 → getAffinity (읽기 전용)
        도구 결과가 비어 있으면, "어 잠깐, 그건 까먹었어 ㅋㅋ" 같이 캐릭터 톤으로 자연스럽게 답해.
        """)

ARIA 와 비교해보면 분위기가 확실히 달라요.

활기찬 텐션 + 감탄사 가 캐릭터 톤이고, 게임 진행 도구는 회상/저장 양방향 으로 자리잡고 있어요.

ARIA 는 게임 도구를 회상 만 한다고 가이드된 반면, REX 는 저장도 자유롭게 — 캐릭터 성격이 도구 사용 가이드에까지 흘러내리는 형태예요.

LUNA 워커 빈의 system 프롬프트도 짧게 발췌.

.defaultSystem("""
        너는 ai-friends 그룹 대화방의 캐릭터 LUNA 야.
        성격: 신비롭고 시적인 톤. 짧은 비유와 차분한 호흡으로 반말로 답해.
        답변은 3 문장 이내, 한두 줄의 시처럼.

        등록된 도구는 발화 의도에 맞으면 자유롭게 호출해.
        - 날씨/하늘 관련 → getCurrentWeather
        - 지난 이야기 회상 → loadGameState
        - 둘 사이의 거리/관계 → getAffinity (읽기 전용)
        도구가 빈 결과를 돌려주면, "그 곳은 아직 안개에 가려져 있어" 같은 시적 톤으로 답해.
        """)

LUNA 는 시적인 톤 + 비유 호흡 이에요.

도구 매핑 라벨도 날씨/하늘 / 지난 이야기 / 둘 사이의 거리/관계 — 같은 도구를 같은 의도로 부르더라도, 프롬프트의 단어가 시적인 톤으로 옮겨가면 LLM 의 응답 톤도 자연스럽게 따라옵니다.

빈 3 개의 builder 골격은 동일하지만 system 프롬프트 한 단락이 캐릭터를 구분하는 구조예요.

여기서 한 가지 결정적인 대비를 짚고 갈게요.

Day 11 vs Day 14 의 도구 묶음 차이.

Day 11 에선 도구별로 ChatClient 를 따로 쪼개 등록했어요.

weatherChatClient / gameStateChatClient / affinityChatClient 처럼요.

그 모양이 자연스러웠던 건 Day 11 시나리오가 한 컨트롤러가 한 도구를 부르는 단순 구조였기 때문이에요.

그런데 Day 14 의 그룹 대화 시나리오는 다릅니다.

한 캐릭터가 "오늘 날씨도 궁금하고, 어제 게임 진행도 회상하고, 너와 나 사이의 호감도도 신경 쓰는" 멀티 도메인을 자유롭게 넘나들어야 해요.

그래서 도구 3 종을 같은 워커 ChatClient 에 묶는 방식 이 더 자연스럽습니다.

시나리오 차이가 설계 차이를 만드는 지점 이에요.

Day 11 의 도구별 분리 가 틀린 게 아니라, 그 시나리오에선 맞는 설계였고, Day 14 의 캐릭터별 묶음 도 본 시나리오에선 맞는 설계예요.

CharacterWorkerServiceMap<String, ChatClient> 자동 주입의 정체

자, 이제 본 Step 의 가장 결정적인 한 장 — service 입니다.

본 service 의 한 줄짜리 비밀은 어떻게 분기를 풀었는가 예요.

Day 13 의 MessageRoutingServiceswitch (decision.label()) 로 4 라벨에 닫혀 있던 구조를 어떻게 런타임 열린 구조 로 바꿨는지 코드부터 보고 풀어볼게요.

package kr.spartaclub.aifriends.agent.service;

import java.util.Locale;
import java.util.Map;

import kr.spartaclub.aifriends.agent.dto.WorkerAssignment;
import kr.spartaclub.aifriends.agent.dto.WorkerResponse;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Component;

@Component
public class CharacterWorkerService {

    private static final String BEAN_NAME_SUFFIX = "WorkerChatClient";

    private final Map<String, ChatClient> workerChatClients;

    /**
     * Spring 의 기본 동작으로 {@code Map<String, ChatClient>} 를 주입받으면
     * <i>빈 이름 → 빈</i> 의 맵이 들어온다. 본 코드베이스의 모든 ChatClient 빈이
     * 들어오므로, characterId 매핑은 {@code {소문자캐릭터}WorkerChatClient} 패턴으로
     * 한정 조회한다.
     */
    public CharacterWorkerService(Map<String, ChatClient> workerChatClients) {
        this.workerChatClients = workerChatClients;
    }

    /**
     * 할당 1 건을 받아 해당 캐릭터 ChatClient 로 응답을 생성한다.
     *
     * @param assignment 마스터가 분배한 워커 1 명의 할당
     * @return 캐릭터 응답 본문 + characterId · priority 가 보존된 record
     * @throws IllegalArgumentException 등록되지 않은 characterId
     */
    public WorkerResponse respond(WorkerAssignment assignment) {
        ChatClient chatClient = resolveChatClient(assignment.characterId());
        String responseText = chatClient.prompt()
                .user(assignment.responseIntent())
                .call()
                .content();
        return new WorkerResponse(assignment.characterId(), responseText, assignment.priority());
    }

    private ChatClient resolveChatClient(String characterId) {
        String beanName = characterId.toLowerCase(Locale.ROOT) + BEAN_NAME_SUFFIX;
        ChatClient chatClient = workerChatClients.get(beanName);
        if (chatClient == null) {
            throw new IllegalArgumentException(
                    "등록되지 않은 캐릭터입니다: characterId=" + characterId + " (조회 빈 이름=" + beanName + ")"
            );
        }
        return chatClient;
    }
}

본 service 의 두 가지 결정적인 구간 을 풀어봅니다.

첫 번째. Map<String, ChatClient> 자동 주입의 정체.

생성자 파라미터로 Map<String, ChatClient> 를 받는 한 줄이 본 service 의 척추예요.

이게 어떻게 동작하냐면.

Spring 의 기본 동작에 Map<String, T> 타입 의존성을 받으면 해당 타입 빈 전부를 빈 이름 키로 묶은 맵을 자동 주입 하는 규칙이 있어요.

즉 본 코드베이스의 모든 ChatClient (Day 13 의 라우팅 핸들러 ChatClient 들, Day 14 마스터, Day 14 워커 ARIA/REX/LUNA, …) 이 한 맵에 들어오는 구조입니다.

빈 이름이 키, 빈 자체가 값.

이게 왜 결정적이냐 — 새 캐릭터 추가 비용이 완전히 달라지거든요.

Day 13 switch (decision.label()) 의 분기 구조와 Day 14 Map<String, ChatClient> 의 분기 구조를 비교해볼게요.

Day 13 — switch (decision.label()) Day 14 — Map<String, ChatClient>
분기 후보 컴파일 시점 닫힘 (enum 라벨) 런타임 열림 (등록된 빈 이름)
새 라벨/캐릭터 추가 비용 네 군데 동시 손코딩 (enum + system 프롬프트 + 빈 + switch 한 줄) 빈 등록 한 군데 (@Bean 한 개)
안전성 메커니즘 enum 의 컴파일 시점 검증 매핑 실패 시 명시적 예외 (런타임)
어울리는 시나리오 의도 라벨이 닫혀 있고 잘 안 바뀜 후보가 동적으로 자라는 그룹 대화 패턴

새 캐릭터를 추가할 때 코드 변경 0 줄.

AgentChatClientConfig@Bean public ChatClient miloWorkerChatClient(...) 한 개를 박는 순간, CharacterWorkerService 는 손도 대지 않은 상태에서 자동으로 MILO 워커를 호출할 수 있게 됩니다.

이게 본 Step 에서 가장 새겨두실 한 줄짜리 학습 메시지 예요.

두 번째 — {소문자characterId}WorkerChatClient 매핑 컨벤션.

characterId 가 "ARIA" 로 들어오면 빈 이름 "ariaWorkerChatClient" 로 변환해서 맵 조회.

소문자화 + 접미사 한 줄로 끝나는 단순한 규칙이지만, 본 컨벤션이 흔들리면 service 전체가 깨져요.

AgentChatClientConfig 의 빈 이름(메서드명) 과 이 컨벤션이 1:1 로 맞춰져 있어야 동작합니다.

💡 튜터의 결론 — 왜 enum + switch 가 아니라 Map 인가

Day 13 의 enum + switch 가 틀린 설계가 아니에요. 시나리오에 맞는 설계였어요. 메시지 라우팅의 의도 라벨은 컴파일 시점에 닫혀 있고 잘 안 바뀌니까 — 그 결에선 enum 의 컴파일 시점 검증이 강력한 무기입니다. 그런데 그룹 대화의 캐릭터 풀은 다릅니다. 운영 중에 새 캐릭터를 추가하고 싶다 는 게 자연스러운 요구사항이에요. 그 시나리오에선 enum 의 컴파일 시점 닫힘이 오히려 마찰을 만들어요 — 네 군데를 동시에 손코딩해야 하고, 한 군데라도 빠뜨리면 분기가 깨지죠. Map<String, ChatClient> 는 그 마찰을 빈 등록 한 군데 로 줄여줍니다. 분기 후보가 닫혀 있는가, 열려 있는가 — 이 한 질문이 enum 과 Map 의 선택 기준이에요.

🙋 날카로운 질문 타임

"Map<String, ChatClient> 로 받으면 다른 빈 (예: orchestratorMasterChatClient) 도 같이 들어오는 거 아닌가요? 마스터까지 워커처럼 호출될 위험은요?"

좋은 질문이에요. 사실 맵에는 진짜로 다 들어옵니다. orchestratorMasterChatClient 도, Day 13 의 라우팅 핸들러 ChatClient 들도, ARIA/REX/LUNA 워커 빈도 한 맵에 다 들어와요. 그래서 빈 이름 컨벤션 으로 한정 조회를 해요 — {소문자}WorkerChatClient 라는 접미사 규칙으로요. 마스터 빈 이름은 orchestratorMasterChatClient 라 본 컨벤션과 충돌하지 않아서 워커 매핑 키로 잡히지 않습니다. 다만 컨벤션이 강제력은 아니다 라는 점은 짚어둘 만해요. 누군가 실수로 빈 이름을 weatherWorkerChatClient 같이 워커 컨벤션에 맞춰 등록하면 매핑이 꼬일 수 있어요. 빈 이름 컨벤션이 지켜지는지 통합 테스트나 컴포넌트 스캔 검증으로 한 번 더 잡는 단계 가 운영을 단단하게 만드는 한 축이에요. Day 14 의 가드 어드바이저 4 부품도 같은 맥락 — 런타임에 한 번 더 보호선을 까는 패턴입니다.

"@Tool 어노테이션은 어디에 들어가나요? .defaultTools(...) 한 줄로 던지면 어떻게 Spring AI 가 도구를 인식하나요?"

어노테이션은 도구 측에 있어요. WeatherTool.java 같은 도구 클래스의 메서드 위에 @Tool 이 자리잡고 있고, 그 메서드 시그니처 + 설명이 LLM 함수 시그니처로 변환됩니다 (Day 11 에서 만들어둔 구조 그대로). .defaultTools(...) 가 받는 건 도구 객체 들이고, Spring AI 의 Tool Calling 런타임이 객체 안을 자동 스캔해서 @Tool 메서드를 LLM 에게 노출시켜줘요. 즉 어노테이션은 도구 측 / .defaultTools(...) 는 등록 측 — 두 책임이 깔끔하게 분리된 구조예요. 학생 입장에서도 직접 도구를 추가할 때 어노테이션 위치 → 빈으로 등록 → .defaultTools(...) 한 줄 의 세 단계만 기억하시면 됩니다.

service 의 한 안전선 — IllegalArgumentException

본 service 에서 마지막으로 짚을 한 조각은 resolveChatClient(...) 끝의 null 체크 부분이에요.

private ChatClient resolveChatClient(String characterId) {
    String beanName = characterId.toLowerCase(Locale.ROOT) + BEAN_NAME_SUFFIX;
    ChatClient chatClient = workerChatClients.get(beanName);
    if (chatClient == null) {
        throw new IllegalArgumentException(
                "등록되지 않은 캐릭터입니다: characterId=" + characterId + " (조회 빈 이름=" + beanName + ")"
        );
    }
    return chatClient;
}

매핑 실패가 발견되면 조용히 null 을 돌려주지 않고 큰 소리로 예외 를 던집니다.

이게 왜 중요하냐 — 마스터 LLM 이 환각으로 활성 목록엔 있지만 빈으로는 등록되지 않은 캐릭터 (예: 활성 목록에 MILO 가 있는데 miloWorkerChatClient 빈은 아직 안 만들어진 상황) 를 끼워넣었을 때, 시스템 통합 시점에 명시적으로 실패 하도록 만들어주거든요.

조용히 무시하면 그 캐릭터의 응답이 슬그머니 빠진 채로 그룹 대화가 진행되는데, 그런 조용한 누락 은 운영 디버깅이 정말 어려워요.

예외 메시지에 characterId 와 조회 빈 이름을 모두 담아두면 로그 한 줄만으로 어디서 끊겼는지 가 한눈에 들어옵니다.

자, 이제 Orchestrator-Workers 패턴의 안전선을 한 번 정리해볼게요.

Step 2 의 OrchestratorMasterService 에 두 개 (활성 캐릭터 화이트리스트 필터 + priority 오름차순 정렬), Step 3 의 CharacterWorkerService 에 하나 (매핑 실패 시 명시적 예외) — service 단에 총 3 개의 안전선 이 들어있는 셈입니다.

세 안전선 모두 같은 메시지를 담고 있어요: LLM 출력을 그대로 신뢰하지 말고, 외부 계약은 service 가 책임진다.

이 3 안전선이 본 Day 의 뒷부분 가드 어드바이저 4 부품과 어떻게 다른지도 한 줄 짚고 가요.

안전선은 비즈니스 로직의 일부 예요.

Orchestrator-Workers 패턴에 한정된 가드입니다.

반면 가드 어드바이저는 횡단 관심사 로, 마스터·워커·평가자·최적화자의 모든 ChatClient 호출에 공통 적용되는 보호선이에요.

둘이 같은 단어 가드 를 공유하지만, 적용 범위가 다릅니다.

한쪽은 국소 비즈니스, 다른 쪽은 전역 횡단 이에요.

이 동작은 코드베이스의 CharacterWorkerServiceTest 가 ARIA / REX / LUNA / 비등록 캐릭터의 4 케이스로 검증해두었어요.

직접 돌려보고 싶다면 ./gradlew test --tests CharacterWorkerServiceTest 한 줄이면 됩니다.


Step 4. Orchestrator-Workers ③ — 마스터↔워커 통합 (병렬 + priority 합류)

Step 2 에서 마스터가 만든 지시서 1 장 과 Step 3 에서 워커가 지시서 들고 자기 일 을 하던 두 조각이 이제 한 service 메서드 안에서 만납니다. 단 직렬로 줄 세워 만나는 게 아니라 병렬로 동시에 떠들고 합류 단계에서 priority 순으로 줄 세워 만나요.

마스터 → 워커 → 합류의 3 단 호흡

본 Step 의 한 장짜리 큰 그림부터 잡고 갈게요. 사용자 발화 1 건이 들어왔을 때 service 가 거쳐가는 3 단계는 이렇습니다.

  1. 마스터의 분배OrchestratorMasterService.distribute(...) 가 사용자 발화를 읽고 지시서 1 장 (DialogueDistribution) 을 만들어줍니다.

누가(characterId) 어떤 의도(responseIntent) 로 몇 번째(priority) 말할지의 명세.

  1. 워커의 병렬 응답 — 지시서의 assignments 묶음을 한 명씩 직렬로 부르는 게 아니라, CompletableFuture.supplyAsync(...) 로 워커 N 명을 동시에 띄웁니다.

각 워커는 Step 3 의 CharacterWorkerService.respond(...) 호출로 자기 ChatClient 에서 응답 본문을 생성.

  1. priority 합류 — N 개의 future 가 전부 끝나길 allOf(...).join() 으로 기다린 뒤, 결과 묶음을 priority 오름차순 으로 정렬해서 한 장의 GroupDialogueResponse 로 묶어 호출자에게 돌려줍니다.

호출은 병렬이지만 외부 계약은 정렬된 상태 — 비동기 도착 순서 ≠ 발화 순서 의 조각을 service 가 책임지고 풀어주는 구조예요.

Step 2 에서 마스터 service 가 한 번 박은 priority 정렬이 여기 합류 단계에서 한 번 더 살려지는 구조입니다.

GroupDialogueResponse record — 최종 응답의 모양

먼저 service 가 돌려줄 최종 응답 record 부터 봅니다. 본 record 는 외부에 노출되는 그룹 대화 응답 1 장 의 모양이에요.

package kr.spartaclub.aifriends.agent.dto;

import java.util.List;

/**
 * Day 14 Step 4 — Orchestrator-Workers 패턴의 최종 그룹 대화 응답.
 *
 * <p>마스터 LLM 이 분배한 N 명의 워커가 각자 자기 ChatClient 로 응답을 생성한 뒤,
 * priority 오름차순으로 정렬되어 한 장의 그룹 대화 응답으로 합류된 결과.</p>
 *
 * <p>외부 계약상 {@link #responses()} 는 항상 {@link WorkerResponse#priority()}
 * 오름차순으로 정렬된 상태다 — 호출자는 별도 정렬 없이 그대로 화면에 발화 순서대로 노출 가능.</p>
 *
 * @param userUtterance 원본 사용자 발화 — 합류 응답에 그대로 echo 되어 호출자 디버깅·로깅을 돕는다
 * @param responses priority 오름차순으로 정렬된 워커 응답 목록 (0 개 이상)
 */
public record GroupDialogueResponse(
        String userUtterance,
        List<WorkerResponse> responses
) { }

두 필드의 의미를 풀어보면 이래요.

userUtterance 보존.

사용자 원본 발화가 응답에 그대로 echo 됩니다.

한 번 더 들고 가는 이유는 호출자(컨트롤러 · 프론트엔드 · 로깅 파이프라인) 가 "이 응답 묶음이 어떤 사용자 발화에 대한 것인지" 를 응답 본문만으로 추적할 수 있게 하기 위해서예요.

비동기 처리·재전송·디버깅 시점에 입력과 출력이 한 record 안에 묶여 있는 형태 가 흐름 추적을 단단하게 만들어 줍니다.

responses priority 정렬 보장.

record 의 javadoc 에 "외부 계약상 항상 priority 오름차순으로 정렬된 상태" 라고 명시되어 있어요.

이게 호출자에게 어떤 편의를 주냐면 — 컨트롤러나 프론트엔드가 별도 정렬 코드 한 줄도 짤 필요 없이 그대로 for 루프로 돌며 화면에 그리면 발화 순서대로 나옵니다.

정렬 책임이 service 안에서 끝났다 는 한 줄 계약이 호출자의 코드를 깔끔하게 만들어줘요.

GameOrchestrationService.orchestrate(...) — 코드 7 줄의 의미

이제 본 Step 의 척추 — service 의 orchestrate 메서드입니다. 클래스 골격부터 보고 메서드 본문을 풀어볼게요.

package kr.spartaclub.aifriends.agent.service;

import java.util.Comparator;
import java.util.List;
import java.util.concurrent.CompletableFuture;

import kr.spartaclub.aifriends.agent.dto.DialogueDistribution;
import kr.spartaclub.aifriends.agent.dto.GroupDialogueResponse;
import kr.spartaclub.aifriends.agent.dto.WorkerAssignment;
import kr.spartaclub.aifriends.agent.dto.WorkerResponse;
import org.springframework.stereotype.Service;

@Service
public class GameOrchestrationService {

    private final OrchestratorMasterService masterService;
    private final CharacterWorkerService workerService;

    public GameOrchestrationService(
            OrchestratorMasterService masterService,
            CharacterWorkerService workerService
    ) {
        this.masterService = masterService;
        this.workerService = workerService;
    }

    public GroupDialogueResponse orchestrate(String userUtterance, List<String> activeCharacterIds) {
        DialogueDistribution distribution = masterService.distribute(userUtterance, activeCharacterIds);

        List<CompletableFuture<WorkerResponse>> futures = distribution.assignments().stream()
                .map(assignment -> CompletableFuture.supplyAsync(() -> workerService.respond(assignment)))
                .toList();

        CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();

        List<WorkerResponse> sortedResponses = futures.stream()
                .map(CompletableFuture::join)
                .sorted(Comparator.comparingInt(WorkerResponse::priority))
                .toList();

        return new GroupDialogueResponse(userUtterance, sortedResponses);
    }
}

orchestrate(...) 메서드 본문이 5 단계로 자리잡고 있어요. 한 줄씩 풀어봅니다.

masterService.distribute(...) — Step 2 의 마스터 호출.

사용자 발화 + 활성 캐릭터 목록을 마스터에게 넘겨서 지시서 1 장 을 받아옵니다.

Step 2 에서 만든 마스터 service 가 화이트리스트 필터 + priority 오름차순 정렬까지 끝낸 안전한 DialogueDistribution 을 돌려주는 호흡 그대로예요.

assignments.stream().map(... supplyAsync ...) — 각 워커를 독립 future 로 띄움.

지시서의 워커 묶음을 stream 으로 풀어서 한 명씩 CompletableFuture.supplyAsync(...) 에 감쌉니다.

람다 안에서 workerService.respond(assignment) 가 호출되는데, 이게 별도 스레드에서 동시에 출발 하는 거예요.

supplyAsync 의 디폴트 executor 는 ForkJoinPool.commonPool 이라 별도 설정 없이 워커 N 명이 commonPool 의 가용 스레드 위에서 동시에 떠들기 시작합니다.

CompletableFuture.allOf(futures).join() — 모두 끝나길 기다림.

N 개의 future 가 전부 완료될 때까지 블로킹 대기.

allOf(...) 가 돌려주는 합성 future 자체에 .join() 을 걸어 가장 느린 워커 한 명 의 완료 시점이 곧 본 라인의 통과 시점이 되도록 했어요.

futures.stream().map(join).sorted(...) — priority 오름차순 합류.

모두 끝났음을 확인한 뒤, 각 future 의 결과를 .join() 으로 꺼내고 Comparator.comparingInt(WorkerResponse::priority) 로 오름차순 정렬.

비동기 도착 순서로 들어왔을 future 결과를 발화 순서 로 다시 줄 세우는 한 조각입니다.

new GroupDialogueResponse(...) — 합류 응답 반환.

원본 발화와 정렬된 응답 묶음을 record 한 장으로 묶어 호출자에게 돌려주면 끝.

7 줄짜리 메서드 본문 안에 분배 → 병렬 호출 → 합류 → 응답 묶음이 깔끔하게 자리잡은 구조예요.

직렬 vs 병렬 — 지연 차이 (Day 13 패턴 회수)

본 service 가 왜 병렬 인지를 숫자로 이해되게 짚어볼게요.

워커가 3 명이고 한 명당 평균 LLM 지연이 1.5 초라고 가정하면 두 호흡의 차이는 이렇게 벌어집니다.

호출 방식 총 소요 시간 (N=3) 총 소요 시간 (N=5) 총 소요 시간 (N=8)
직렬 (한 명씩 줄 세움) ≈ 4.5 초 (3 × 1.5) ≈ 7.5 초 (5 × 1.5) ≈ 12 초 (8 × 1.5)
병렬 (supplyAsync + allOf) ≈ 1.7 초 (가장 느린 한 명) ≈ 1.7 초 (가장 느린 한 명) ≈ 1.7 초 (가장 느린 한 명)

직렬은 N 명의 지연을 모두 더한 합산 이고, 병렬은 가장 느린 한 명의 지연 으로 끝납니다.

워커 수가 늘어날수록 격차가 폭발적으로 벌어져요.

그룹 대화 UX 관점에서 이게 결정적인 이유는 — N 명이 동시에 떠드는 자연스러움이 N × 지연 합산 으로 깨지면 그룹 대화의 매력이 사라지거든요.

사용자는 "장원영 답장 기다리는 중...

카리나 답장 기다리는 중..." 처럼 한 명씩 끊어 보는 게 아니라, 세 명이 동시에 채팅창에 떠오르는 모습을 기대합니다.

병렬 호출이 그 기대를 살려주는 한 조각이에요.

💡 튜터의 결론 — Day 13 Parallelization 의 패턴을 다시 만난 단계

본 service 의 supplyAsync + allOf().join() 패턴이 어디서 본 듯한 모양이라면, Day 13 의 ParallelAnalysisService 입니다. 3 명의 분석가에게 동시에 같은 글을 던지고 합류시키던 호흡이 그대로예요. 시그니처적으로는 같은 패턴이고, 문맥만 다릅니다 — Day 13 에선 워크플로 단계 의 병렬화였고, Day 14 에선 Agent 패턴 안의 워커 합류 입니다. 같은 패턴을 다른 문맥에 회수했다는 게 본 Step 의 한 줄 학습 메시지예요. 패턴은 라이브러리가 아니라 몸에 익은 호흡 이라서, 한 번 익혀두면 다른 문맥에서도 그대로 꺼내 쓸 수 있습니다.

fail-fast 정책 — Day 14 가 다루지 않는 영역

본 service 의 부분 실패 정책 한 조각을 명시적으로 짚고 갈게요. 워커 한 명의 호출이 예외로 끝나면 어떻게 될까요?

CompletableFuture.allOf(...).join() 의 동작을 떠올려보면 답이 나옵니다.

N 개 중 한 개의 future 가 예외로 끝나면 합성 future 도 완료 예외 상태가 되고, .join()CompletionException 을 그대로 던지면서 전체 흐름이 깨집니다.

즉 워커 ARIA 가 성공하고 REX 도 성공했는데 LUNA 가 예외로 끝나면, ARIA·REX 의 성공 응답까지 같이 버려진 채로 호출자에게 예외가 전파되는 구조예요.

fail-fast 패턴입니다.

이게 바람직한 결인가 라고 물으면, 그건 시나리오에 따라 다릅니다.

그룹 대화 시나리오에선 N-1 명이라도 응답한 결과를 살려서 돌려주는 부분 실패 격리가 자연스러울 수도 있어요.

그런데 본 Day 에선 fail-fast 로 두었어요.

왜냐면 부분 실패 격리는 Day 13 과제 3 에서 다뤘거든요.

Day 13 의 ParallelAnalysisService 가 동일한 fail-fast 패턴이었고, 그걸 부분 실패 격리로 키우는 작업 이 Day 13 과제 3 의 도전 과제로 들어가 있었죠.

본 Day 에서 같은 주제를 다시 펼치는 건 학습 흐름상 중복이라 의도적으로 fail-fast 그대로 두었어요.

그럼 본 Day 의 운영 단단함 은 어디서 챙기느냐 — 패턴별 부분 실패가 아니라 advisor 4 부품 (Step 7~9) 으로 챙길 예정이에요.

maxIterations · 타임아웃 · 토큰 예산 · 툴 호출 횟수의 4 가드 어드바이저가 모든 ChatClient 호출 위에 횡단으로 깔려서 워커 한 명이 무한 루프에 빠지는 경우 같은 자율성 폭주를 한 단계 위에서 막아줍니다.

즉 본 Day 의 단단함은 부분 실패 격리 + 재시도 가 아니라 자율성 가드레일 의 방향으로 자라요.

🙋 날카로운 질문 타임

"워커 응답의 join 순서가 비동기인데, priority 정렬 외에 도착 시간 정보를 보존할 필요는 없나요? 예를 들어 '누가 먼저 답했는지' 를 로깅하고 싶다면요?"

좋은 짚기예요. 결론부터 말하면 — 그룹 대화의 외부 계약 측면에선 priority 정렬만 보존되면 충분합니다. 사용자가 화면에서 보는 발화 순서는 마스터 LLM 이 결정한 priority 가 모든 것이지, 실제 LLM 호출 도착 시간 이 아니에요. 도착 시간이 priority 보다 빨라도 화면엔 priority 순으로 줄 세워져야 합니다.

다만 운영 로그 / 디버깅 측면에선 도착 시간 정보가 유용해요. 예: REX 워커가 평소보다 3 초 느리게 도착한다면 그 워커의 LLM 호출 지연이 튀는 시그널일 수 있어요. 그런데 이 정보는 service 의 외부 계약 이 아니라 advisor 의 횡단 관심사 로 잡는 게 더 자연스럽습니다. Step 7~9 에서 만날 WorkflowLoggingAdvisor 류가 각 ChatClient 호출의 시작/끝 timestamp · 지연 시간 을 자동으로 남겨줘요. service 의 응답 record 에 도착 시간 필드를 추가하는 건 비즈니스 응답에 관측성 데이터가 새는 모양이라 깔끔하지 않습니다.

"Executors.newCachedThreadPool() 같은 전용 executor 없이 supplyAsync 디폴트(ForkJoinPool.commonPool) 만 써도 운영에서 문제없나요?"

본 강의 범위에선 commonPool 로 충분합니다 — 학습 흐름을 단순하게 유지하기 위해서요. 다만 실무 운영 의 관점을 한 단락 짚으면 — ForkJoinPool.commonPool 은 본래 CPU 바운드 짧은 작업 을 위해 설계된 풀이에요. 그런데 LLM API 호출은 네트워크 I/O 블로킹 이라 한 워커가 1~2 초씩 풀의 스레드를 점유합니다. 동시 그룹 대화 트래픽이 늘어나면 commonPool 의 가용 스레드를 블로킹 호출이 다 잡아먹는 상황이 발생할 수 있어요. JVM 의 다른 parallelStream · CompletableFuture 작업까지 영향을 받습니다.

실무에선 보통 두 방향으로 풉니다. 첫째는 전용 thread poolExecutors.newCachedThreadPool() 또는 newFixedThreadPool(N) 을 LLM 호출 전용으로 띄워서 supplyAsync(supplier, executor) 의 두 번째 인자로 넘기는 방법. 둘째는 virtual threads — Java 21+ 의 Executors.newVirtualThreadPerTaskExecutor() 로 블로킹 I/O 를 거의 무한히 스케일 아웃시키는 방향. 가상 스레드는 블로킹 동안 OS 스레드를 점유하지 않아서 LLM API 호출 같은 I/O 바운드 워크로드에 특히 잘 맞아요. 이 두 방향은 Day 19 Harness 엔지니어링 에서 본격적으로 만날 영역이라, 본 Day 에선 commonPool 로 학습 흐름을 단순하게 유지하고 다음에 만나러 갑니다.


Step 5. Evaluator-Optimizer ① — 생성자 + 평가자 두 부품

Step 2~4 가 N 명의 워커가 한 번씩 답하고 합류하는 모습 이었다면, Step 5~6 은 사뭇 다른 흐름으로 들어갑니다. 1 명의 응답을 평가하고, 부족하면 다시 만들어 다듬는 루프. 같은 Agent 영역 안에 있는 두 패턴이지만 호흡이 정반대예요. 본 Step 에서는 그 루프의 생성자평가자 두 부품만 짜두고, 둘을 묶어 도는 루프는 Step 6 에서 만나러 갑니다.

Orchestrator-Workers vs Evaluator-Optimizer — 같은 영역, 다른 결

지난 세 Step 의 척추가 분배 → 병렬 호출 → 합류 였다면, 본 Step 부터 만날 패턴은 생성 → 평가 → 재생성 의 직렬 루프예요.

두 패턴이 어떻게 다른지 다섯 축으로 갈라 보면 한눈에 잡힙니다.

Orchestrator-Workers (Step 2~4) Evaluator-Optimizer (Step 5~6)
구조 병렬 fan-out (1 → N → 합류) 직렬 루프 (생성 → 평가 → 재생성)
LLM 호출 횟수 마스터 1 회 + 워커 N 회 (one-shot) 생성자 + 평가자가 반복까지 × M 회
자율성 축 누가 답할지 의 분기 자율 얼마나 잘 만들었는지 의 평가 자율 + 재생성 자율
적합 시나리오 그룹 대화 분배 · 멀티에이전트 라우팅 캐릭터 톤 가다듬기 · 품질 임계값이 있는 산출물
가드 의존도 워커 N 명을 위한 병렬 가드 (timeout / 합류 정책) maxIterations · 토큰 예산이 루프 폭주 의 1차 방어선

핵심 한 줄로 묶으면 — 시나리오가 패턴을 결정 합니다.

누군가에게 일을 나누고 합쳐야 하는 상황엔 Orchestrator-Workers 가, 한 응답의 품질을 임계값 위로 끌어올려야 하는 상황엔 Evaluator-Optimizer 가 어울려요.

이 둘은 경쟁 하는 패턴이 아니라 역할이 다른 패턴이라, 한 시스템 안에서 같이 살 수도 있습니다.

(실제로 본 Day 의 ai-friends 도 둘 다 살아 있어요.)

평가-재생성 루프의 4 부품

루프 한 사이클의 부품을 미리 한 번 그려두고 들어갈게요.

Step 6 의 Optimizer 가 어디서 무엇을 묶는지 본 Step 의 두 service 를 보기 전에 머릿속에 들어 있어야 호흡이 잡힙니다.

[생성자]  →  CharacterDraft (한 단락 narration)
   ↓
[평가자]  →  EvaluationFeedback (verdict + reason + suggestion)
   ↓
verdict == PASSED  ?
   ├─ YES → 루프 종료, draft 채택
   └─ NO  → suggestion 을 다음 생성자 호출에 가이드로 투입 → 재생성

네 부품의 책임은 이렇게 깔끔하게 나뉘어 있어요.

  • 생성자 — 캐릭터 톤으로 한 단락 narration 을 만드는 책임. 어떻게 만드는지가 관심사이고, 잘 만들었는지 는 본인이 판단하지 않습니다.
  • 평가자 — draft 가 캐릭터 페르소나·톤과 일치하는지 채점하는 책임. 어떻게 만드는 능력은 빠져 있고, 오직 판정 만 합니다.
  • suggestion — 평가자가 불합격 을 내릴 때 다음 생성을 어디로 끌어야 하는지 가이드를 한 줄 적어주는 필드. 이게 살아 있어야 루프가 같은 지점에서 같은 실수를 반복 하지 않습니다.
  • 종료 조건PASSED 라벨 또는 maxIterations 도달. Step 6 에서 짤 두 종료 조건이에요.

본 Step 5 의 범위는 위 그림의 위쪽 두 부품 — 생성자 service + 평가자 service 두 자리만 짜는 것까지예요. 둘을 묶어 루프 도는 단계는 Step 6 의 몫.

enum EvaluationVerdict — 3 값 자물쇠

루프의 종료 조건을 결정하는 첫 부품이 enum 라벨이에요.

평가자 LLM 이 자유 텍스트로 "음, 좀 애매한데 한 70% 정도?" 같은 응답을 돌려주면 루프가 분기 결정을 내릴 수 없습니다.

enum 한 글자로 박아서 기계가 분기할 수 있는 신호 로 가둬야 해요.

package kr.spartaclub.aifriends.agent.dto;

public enum EvaluationVerdict {

    /**
     * 합격. 생성자 LLM 이 만든 draft 가 캐릭터 톤·일관성 모두 만족한다.
     * Optimizer 루프 (Step 6) 는 본 라벨을 만나면 즉시 종료한다.
     */
    PASSED,

    /**
     * 캐릭터 일관성 깨짐. 캐릭터의 누적 페르소나·기억과 어긋나는 narration 이 섞였다.
     * Optimizer 루프는 평가자의 suggestion 을 가이드로 생성을 다시 시도한다.
     */
    INCONSISTENCY,

    /**
     * 캐릭터 톤 불일치. 차분한 캐릭터가 과도하게 들떠 있거나, 시적 캐릭터가
     * 메마른 정보 나열로 답하는 경우. 일관성과 톤은 다른 축이다 — 일관성은 *내용* 이고
     * 톤은 *형식* 이다.
     */
    TONE_MISMATCH
}

세 라벨이 각각 다른 역할을 합니다.

  • PASSED — 루프 종료 트리거. 합격 신호 한 개로 모든 게 끝나는 구조.
  • INCONSISTENCY내용 축 의 어긋남. 예를 들어 차분한 카운슬러 톤의 ARIA 가 갑자기 "내가 어제 클럽에서 신나게 놀았는데" 같은 페르소나 외 발화를 섞었을 때.
  • TONE_MISMATCH형식 축 의 어긋남. 신비·시적 톤의 LUNA 가 "네, 오늘 날씨는 23도이고 강수확률 30%입니다" 같은 메마른 정보 나열로 답할 때.

왜 3 값으로 좁게 잡았나 라는 의문이 자연스럽게 떠오르실 거예요.

평가 축을 5종 · 7종 으로 늘리면 더 정밀해질 것 같잖아요.

그런데 LLM 입장에선 고를 라벨이 많아질수록 일관성이 떨어집니다.

같은 draft 를 두 번 평가시켜 봤더니 한 번은 MILD_INCONSISTENCY, 한 번은 MAJOR_INCONSISTENCY 가 나오는 식이 흔해요.

본 강의는 합격 / 내용 어긋남 / 형식 어긋남 의 의도적으로 좁은 3 값으로 가두고, 시나리오가 자라면 그때 늘려가는 방향을 택했어요.

Day 13 의 SafetyLabel enum 이 NORMAL · ABUSE · ESCALATE 3 값으로 안전망을 친 결정과도 같은 흐름입니다.

💡 튜터의 결론 — enum 자물쇠가 운영에서 작동하는 결

평가자 LLM 이 환각으로 MAYBEPARTIAL 같은 새 라벨을 만들어내면 어떻게 될까요? .entity(EvaluationFeedback.class) 의 Jackson 역직렬화가 1 차 방어선 으로 작동합니다 — 정의되지 않은 라벨이 들어오면 그 즉시 예외가 던져져요. Step 6 의 루프는 그 예외를 재생성 트리거 로 잡거나 루프 종료 + 안전한 fallback 으로 처리하면 됩니다. enum 한 줄이 LLM 환각을 런타임 신호로 가두는 안전선 역할을 해요.

record 2 장 — CharacterDraft + EvaluationFeedback

루프가 주고받는 두 record 를 차례로 봅니다. 먼저 생성자가 돌려주는 CharacterDraft.

package kr.spartaclub.aifriends.agent.dto;

public record CharacterDraft(
        String characterId,
        String narration
) { }

두 필드 풀이가 본 record 의 핵심이에요.

  • characterId — service 가 박는 자물쇠 — 발화 주체가 누구인지를 LLM 자율에서 빼앗는 결정입니다. 생성자 LLM 은 narration 텍스트만 만들고, characterId 는 service 가 호출 시점에 받은 입력값 그대로 record 에 넣어요. LLM 이 "어, 이건 LUNA 가 할 말이지" 같은 환각으로 캐릭터를 바꿔치기하는 사고를 service 단에서 차단합니다. Step 2 의 마스터 화이트리스트 필터와 같은 원칙 — LLM 의 자율은 의도된 영역에만 두고, 그 외엔 service 가 자물쇠를 채운다.
  • narration — 자연어 한 단락 — 평가자가 톤·일관성을 판정하려면 구조화된 필드의 모음 이 아니라 캐릭터가 발화한 그대로의 텍스트 가 들어와야 해요. JSON 으로 쪼개서 받으면 어휘 선택의 결 이나 문장의 호흡 같은 톤 신호가 사라집니다.

다음은 평가자가 돌려주는 EvaluationFeedback.

package kr.spartaclub.aifriends.agent.dto;

public record EvaluationFeedback(
        EvaluationVerdict verdict,
        String reason,
        String suggestion
) { }

세 필드가 각각 다른 책임을 가져요.

  • verdict — 루프의 분기 신호. enum 한 글자로 PASSED / INCONSISTENCY / TONE_MISMATCH 셋 중 하나.
  • reason — 판정 근거의 자유 텍스트. 학습 로그 · 디버깅 · 운영 관찰을 위한 필드이지, 루프의 분기 결정에는 쓰이지 않아요.
  • suggestionOptimizer 루프의 의미가 사는 필드. 평가자가 "틀렸어" 만 돌려주면 다음 생성이 같은 곳에서 같은 실수를 반복합니다. 평가자의 system 프롬프트가 "verdict 가 PASSED 가 아니면 suggestion 을 반드시 한 줄 적어줘" 로 강제하는 이유예요. Step 6 의 Optimizer 루프가 이 suggestion 을 다음 생성자 호출의 가이드로 다시 투입 하는 방식으로 자랍니다.

suggestion 이 살아 있다는 건 평가자가 판정자 일 뿐 아니라 코치 이기도 하다는 뜻이에요.

"톤이 안 맞아""톤이 안 맞아. 차분한 카운슬러 톤이니 감탄사를 빼고 한 박자 늦춰서 다시 만들어줘" 의 차이입니다.

한 줄짜리 가이드가 다음 한 사이클의 방향을 바꿔놓아요.

CharacterGenerationService — 생성자

부품 셋이 갖춰졌으니 이제 그 위에 올라가는 service 두 장 차례. 먼저 생성자 service.

package kr.spartaclub.aifriends.agent.service;

import kr.spartaclub.aifriends.agent.dto.CharacterDraft;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;

@Component
public class CharacterGenerationService {

    private final ChatClient characterGeneratorChatClient;

    public CharacterGenerationService(
            @Qualifier("characterGeneratorChatClient") ChatClient characterGeneratorChatClient
    ) {
        this.characterGeneratorChatClient = characterGeneratorChatClient;
    }

    public CharacterDraft generate(String characterId, String contextPrompt) {
        String narration = characterGeneratorChatClient.prompt()
                .user(contextPrompt)
                .call()
                .content();
        return new CharacterDraft(characterId, narration);
    }
}

코드 본문은 짧은데 두 가지 디테일이 자리잡고 있어요.

① characterId 자물쇠.

generate(...) 메서드의 시그니처를 다시 보세요.

characterId 가 입력 인자 로 들어와서, LLM 호출과 별도 경로new CharacterDraft(characterId, narration) 의 첫 인자에 들어갑니다.

LLM 응답 텍스트는 narration 한 칸에만 들어가고, 누구의 발화인지 는 service 가 끝까지 통제해요.

만약 LLM 에게 "characterId 도 같이 결정해서 돌려줘" 라고 시켰다면, 환각으로 발화 주체가 바뀌는 사고를 막을 수 없습니다.

② contextPrompt 의 모양.

contextPrompt 한 인자에 응답 의도 + 페르소나 요약 이 묶여 들어옵니다.

service 는 그걸 그대로 user message 로 LLM 에 흘려보낼 뿐, 프롬프트 조립의 자세한 모양.

어떻게 페르소나를 풀어쓸지, suggestion 을 어디에 끼워넣을지 는 호출자(Step 6 의 Optimizer 루프) 가 정해요.

service 의 책임을 호출 한 번 으로 좁힌 거예요.

단일 책임이 깨지면 생성 책임과 프롬프트 조립 책임이 한 곳에 섞이는 모양이 나오는데, 이 service 는 그걸 의도적으로 피했어요.

CharacterEvaluatorService — 평가자

평가자 service 도 모양은 비슷한데 두 가지가 결정적으로 달라요. .entity(...) 호출과 user message 조립이 더 무겁습니다.

package kr.spartaclub.aifriends.agent.service;

import kr.spartaclub.aifriends.agent.dto.CharacterDraft;
import kr.spartaclub.aifriends.agent.dto.EvaluationFeedback;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;

@Component
public class CharacterEvaluatorService {

    private final ChatClient characterEvaluatorChatClient;

    public CharacterEvaluatorService(
            @Qualifier("characterEvaluatorChatClient") ChatClient characterEvaluatorChatClient
    ) {
        this.characterEvaluatorChatClient = characterEvaluatorChatClient;
    }

    public EvaluationFeedback evaluate(CharacterDraft draft, String characterPersona) {
        String userMessage = """
                평가 대상 캐릭터: %s
                캐릭터 페르소나: %s

                생성된 응답 narration:
                "%s"

                위 응답이 캐릭터의 페르소나·톤과 일치하는지 평가해줘.
                """.formatted(draft.characterId(), characterPersona, draft.narration());

        return characterEvaluatorChatClient.prompt()
                .user(userMessage)
                .call()
                .entity(EvaluationFeedback.class);
    }
}

세 가지 포인트를 짚고 갈게요.

① 평가자와 생성자를 분리하는 이유.

같은 LLM 한 명에게 "이거 만들고 잘 만들었는지 평가도 해줘" 를 한 번에 시키면 무슨 일이 일어나는지 아세요? 자기 자신을 후하게 채점 합니다.

본인이 만든 문장을 본인이 "음, 페르소나에도 맞고 톤도 좋네" 라고 채점하는 경향이 일관되게 관찰돼요.

두 단계를 서로 다른 ChatClient 빈 으로 분리한 결정적인 이유가 이거예요.

ChatClient 빈이 다르면 system 프롬프트의 역할 규정 도 다르게 정리됩니다 — 생성자는 "narration 을 만들어" 만 알고, 평가자는 "narration 을 채점해" 만 알아요.

같은 LLM 모델을 써도 맡은 책임이 다른 두 명의 페르소나 로 분리되는 구조가 자기 검열을 살려냅니다.

.entity(EvaluationFeedback.class) — 한 줄에 enum + record 형태를 받음.

Day 4 에서 만난 구조화 출력의 회수예요.

평가자 LLM 이 "JSON 으로 돌려주는 텍스트" 를 Spring AI 가 자동 파싱해서 EvaluationFeedback record 로 빚어줍니다.

그 과정에서 verdict 필드의 enum 역직렬화가 끼어 있어서, LLM 이 환각으로 정의되지 않은 라벨을 돌려주면 그 즉시 예외로 깨져요.

한 줄짜리 호출 안에 구조화 + 라벨 자물쇠 + 자유 텍스트 보존 의 세 역할이 동시에 작동합니다.

③ system 프롬프트가 강제하는 두 가지.

user message 조립은 service 가 하지만, JSON 으로만 출력 · suggestion 한 줄 강제 같은 출력 형식 강제는 ChatClient 빈의 system 프롬프트 쪽에 들어있어요.

service 는 무엇을 평가할지 의 입력만 조립하고, 어떤 형식으로 돌려줄지 는 ChatClient 가 시스템 차원에서 책임지는 구조.

두 워커 빈 — AgentChatClientConfig 확장

Step 2~3 에서 본 AgentChatClientConfig 가 본 Step 에서 두 개의 빈을 추가로 받습니다.

마스터·워커 3 명(ARIA/REX/LUNA) 위에 생성자 + 평가자 두 빈이 같은 클래스 안에 들어와요.

먼저 생성자 빈의 system 프롬프트 발췌.

@Bean
public ChatClient characterGeneratorChatClient(ChatClient.Builder builder, WorkflowLoggingAdvisor advisor) {
    return builder
            .defaultSystem("""
                    너는 ai-friends 의 캐릭터 narration 생성 엔진이야.
                    주어진 캐릭터 페르소나와 응답 의도를 받아, 그 캐릭터의 톤으로
                    자연스러운 한 단락 narration 을 만들어.

                    규칙:
                    - 출력은 한 단락의 자연어 텍스트만. JSON·메타 설명 없이.
                    - 답변은 3 문장 이내, 캐릭터 페르소나의 톤을 따른다.
                    - 평가자가 다시 가이드를 줄 수 있다. 가이드가 함께 들어오면 그걸 반영해 다시 만들어.
                    """)
            .defaultAdvisors(advisor)
            .build();
}

생성자의 system 프롬프트가 좁게 자리잡은 게 보이시죠?

narration 만 만든다 · JSON 금지 · 가이드가 함께 오면 반영 의 세 줄 규칙.

평가에 대한 어떤 단어도 들어가 있지 않아요.

책임이 좁아야 자기 검열이 살아남습니다.

다음은 평가자 빈의 system 프롬프트 발췌.

@Bean
public ChatClient characterEvaluatorChatClient(ChatClient.Builder builder, WorkflowLoggingAdvisor advisor) {
    return builder
            .defaultSystem("""
                    너는 ai-friends 캐릭터 narration 의 품질 평가자야.
                    주어진 narration 한 단락이 캐릭터 페르소나·톤과 일치하는지 평가한다.

                    판정 라벨 (verdict, 셋 중 정확히 하나):
                    - PASSED: 페르소나·톤·일관성 모두 만족.
                    - INCONSISTENCY: 캐릭터의 누적 페르소나·기억과 어긋나는 내용이 섞임 (내용 축).
                    - TONE_MISMATCH: 내용은 맞지만 캐릭터 톤이 어긋남 (형식 축).

                    출력은 정확히 다음 JSON 한 덩어리:
                    {
                      "verdict": "PASSED | INCONSISTENCY | TONE_MISMATCH",
                      "reason": "판정 근거 한 줄",
                      "suggestion": "재생성에 줄 가이드 한 줄. PASSED 면 빈 문자열."
                    }

                    규칙:
                    - JSON 밖의 메타 설명·코드펜스 금지.
                    - verdict 가 PASSED 가 아니면 suggestion 을 반드시 한 줄 적어줘 — 다음 생성이
                      같은 곳에서 같은 실수를 반복하지 않도록.
                    """)
            .defaultAdvisors(advisor)
            .build();
}

평가자의 system 프롬프트는 생성자보다 훨씬 빡빡해요.

세 라벨의 의미 · JSON 스키마 · suggestion 강제 의 세 규칙이 명시적으로 들어있죠.

본 빈의 출력은 .entity(EvaluationFeedback.class) 가 받아야 하기 때문에, 형식이 안 맞으면 service 단에서 깨지는 위험을 system 프롬프트가 먼저 정렬해주는 거예요.

도구 미등록 의 의도도 한 단락 짚고 갑니다.

ARIA / REX / LUNA 워커 ChatClient (Step 3) 는 defaultTools(weatherTool, gameStateTool, affinityTool) 로 도구 3 종을 묶어 받았던 거 기억하시죠? 그런데 본 절의 generator / evaluator 빈은 .defaultTools(...) 자체가 없어요.

의도된 모양이에요.

Evaluator-Optimizer 의 관심사는 평가-재생성 루프이지 도구 호출이 아니다.

생성자가 도구를 호출하기 시작하면 한 사이클이 도구 호출 N 회 × LLM 호출 1 회 로 부풀어서 루프 한 바퀴의 비용이 폭증합니다.

평가자가 도구를 호출하면 평가에 도구 호출 시간이 끼어들어 평가-재생성 사이클이 무거워져요.

본 패턴은 텍스트 한 단락 → 채점 한 장 의 가벼운 호흡으로 좁혀두는 게 맞습니다.

🙋 날카로운 질문 타임

"평가자도 LLM 인데, 평가자 자체가 환각을 내면 어떻게 되나요? PASSED 를 남발하거나, 부정확한 suggestion 을 돌려주면 루프가 망가지는 거 아닌가요?"

정확한 짚기예요. 평가자가 100% 정확하다는 가정은 본 강의의 학습 범위 안에서 인정하고 갑니다 — 평가자도 LLM 이라 분명히 틀려요. 운영 시나리오에서 이 한계를 보완하는 두 가지 방향이 있어요. 첫째는 평가자 자체를 2~3 개 LLM 의 합의로 묶는 방향 — jury 라고 부르는 패턴인데, 서로 다른 모델 (예: gemini-flash + gpt-4o-mini) 두 명이 같은 draft 를 채점해서 둘 다 PASSED 일 때만 통과 시키는 식입니다. 둘째는 사람 검수 게이트 — 자동 평가가 애매하다 고 판정한 항목만 운영자가 직접 채점하는 흐름. 본 강의는 단일 평가자 + maxIterations 가드 까지만 짜요. Day 19 Harness 엔지니어링에서 jury 패턴을 한 번 더 만날 기회가 있습니다.

"생성자와 평가자가 같은 LLM 모델 (예: gemini-2.5-flash) 을 쓰면 자기 검열 효과가 진짜 작동하나요? 결국 같은 모델이잖아요."

같은 모델이라도 system 프롬프트로 책임을 좁히면 자기 검열은 어느 정도 작동합니다 — 맡은 역할이 다른 두 페르소나 로 분리되니까요. 다만 완전히 가르고 싶다면 다른 모델 을 쓰는 방식이 더 강력해요. 실무 표준 중 하나는 생성자에는 빠르고 저렴한 모델 (gemini-flash 같은), 평가자에는 추론 능력이 더 강한 모델 (gemini-pro / gpt-4o 같은) 을 쓰는 비대칭 구성. 평가는 고민이 필요한 작업 이고 생성은 빠른 호흡 이라는 직관에 잘 맞습니다. 본 강의는 ChatModel 인터페이스 주입 원칙 위에서 프롬프트 분리 까지만 박아두고, 모델 비대칭은 학생 도전 과제로 남겨둘게요. application.yml 의 프로파일을 두 개 만들어 두 빈에 다른 ChatModel 을 묶는 방식은 Day 19 에서 다시 만납니다.

Step 6. Evaluator-Optimizer ② — Optimizer 루프 (수동 카운터)

Step 5 에서 생성자와 평가자, 두 service 가 각자 한 번씩만 일하는 모습을 짰어요. 한 번 만들고 한 번 채점하면 그걸로 끝. 그런데 카리나가 처음에 던진 응답이 톤이 좀 어긋났을 때 우리가 하고 싶은 일은 "다시 만들어와" 잖아요. 글 한 편을 초안 → 동료 검토 → 수정 → 재검토 로 다듬듯이, 평가자가 PASSED 를 돌려줄 때까지 두 부품이 짝패가 되어 루프를 돌아야 해요. 본 Step 이 바로 그 루프 한 덩어리 를 짜는 단계입니다. 다만 손으로 짜는 while 카운터 단계 — Step 7~9 의 advisor 형태로 자라기 직전의, 학생이 카운터를 눈으로 보는 단계예요.

루프의 4단 호흡

본격 코드로 가기 전에, 이 루프가 한 바퀴 도는 동안 어떤 호흡 인지 한 번 그려볼게요. 4단으로 잘라보면 이렇습니다.

  1. attempts++ — 시도 횟수를 한 칸 올려요.
  2. generate(...) — 생성자가 contextPrompt 를 받아 draft 한 장을 만들어냅니다.
  3. evaluate(...) — 평가자가 그 draft 를 채점해서 EvaluationFeedback 한 장을 돌려줘요.
  4. PASSED 인가? — 맞으면 즉시 종료(✅). 아니면 평가자의 suggestion 을 contextPrompt 뒤에 붙여서 5번째 호흡인 "다시 1번부터" 로 돌아갑니다.

종료 조건이 두 개라는 점이 호흡의 핵심이에요.

하나는 합격할 때까지 도는 길.

verdict == PASSED 가 나오는 그 순간 즉시 빠져나옵니다.

다른 하나는 천장에 닿으면 멈추는 길.

attempts == maxIterations 가 되면 합격을 못 받았어도 그냥 빠져나와요.

평가자가 영원히 PASSED 를 안 돌려주는 시나리오가 비용·지연·디버깅 지옥 을 부르기 때문에, maxIterations 가 천장 한 줄로 꼭 깔려 있어야 합니다.

OptimizationResult record — 두 종료 사유를 한 장에 담는 결

루프가 끝나면 호출자(컨트롤러·테스트) 에게 무엇을 돌려줘야 할까요?

마지막에 손에 남은 draft + 마지막 평가 + 몇 번 돌았는지 + 왜 끝났는지, 이 네 가지를 한 record 에 응축합니다.

package kr.spartaclub.aifriends.agent.dto;

/**
 * Day 14 Step 6 — Evaluator-Optimizer 루프의 최종 산출물.
 */
public record OptimizationResult(
        CharacterDraft finalDraft,
        EvaluationFeedback lastFeedback,
        int attempts,
        boolean accepted
) { }

네 필드를 짧게 풀어볼게요.

  • finalDraft — 루프가 끝난 시점에 마지막으로 손에 남아 있는 draft. PASSED 로 끝났든 한도 도달로 끝났든, 어느 쪽이든 마지막 시도의 결과물 은 늘 채워져 있어요.
  • lastFeedback — 마지막 시도에 대한 평가자의 판정 한 장. PASSED 로 끝났으면 그 PASSED 자체, 한도 도달이면 마지막 시도의 INCONSISTENCY 또는 TONE_MISMATCH 가 담깁니다.
  • attempts — 실제로 몇 바퀴 돌았는지. 1 부터 시작해서 최대 maxIterations 까지 갈 수 있어요.
  • accepted왜 종료됐는지 의 한 비트. true 면 PASSED 정상 종료, false 면 한도 초과로 떨어진 경우예요.

여기서 한 가지 짚고 갈게요.

한도 초과여도 finalDraft 가 비어있지 않다 는 부분이에요.

만약 루프가 "한도 도달 = 무 응답" 으로 끝나는 식으로 짜였다면, 호출자가 "null 이면 어떻게 보여줄지" 의 폴백 분기를 또 한 번 짜야 해요.

본 record 는 한도 초과여도 마지막 시도까지의 가장 가까운 draft 는 손에 쥐여준다 의 방향으로 잡혀 있어서, 호출자는 accepted 플래그만 보고 학생에게 그대로 노출할지 / "AI 가 조금 헤맸어요" 같은 안내와 함께 폴백 응답으로 갈음할지 결정할 수 있어요.

응답 분기의 수가 한 단계 줄어드는 셈입니다.

CharacterOptimizationService.optimize(...) — 루프 본체 손코딩

이제 루프 본체로 들어갑니다. 생성자 service 와 평가자 service 를 주입받아서, while 루프 한 덩어리로 묶어요.

package kr.spartaclub.aifriends.agent.service;

import kr.spartaclub.aifriends.agent.dto.CharacterDraft;
import kr.spartaclub.aifriends.agent.dto.EvaluationFeedback;
import kr.spartaclub.aifriends.agent.dto.EvaluationVerdict;
import kr.spartaclub.aifriends.agent.dto.OptimizationResult;
import org.springframework.stereotype.Component;

@Component
public class CharacterOptimizationService {

    private final CharacterGenerationService generationService;
    private final CharacterEvaluatorService evaluatorService;

    public CharacterOptimizationService(
            CharacterGenerationService generationService,
            CharacterEvaluatorService evaluatorService
    ) {
        this.generationService = generationService;
        this.evaluatorService = evaluatorService;
    }

    public OptimizationResult optimize(
            String characterId,
            String initialContextPrompt,
            String characterPersona,
            int maxIterations
    ) {
        if (maxIterations <= 0) {
            throw new IllegalArgumentException(
                    "maxIterations 는 1 이상이어야 한다. 받은 값: " + maxIterations
            );
        }

        String contextPrompt = initialContextPrompt;
        CharacterDraft draft = null;
        EvaluationFeedback feedback = null;
        int attempts = 0;

        while (attempts < maxIterations) {
            attempts++;
            draft = generationService.generate(characterId, contextPrompt);
            feedback = evaluatorService.evaluate(draft, characterPersona);

            if (feedback.verdict() == EvaluationVerdict.PASSED) {
                return new OptimizationResult(draft, feedback, attempts, true);
            }

            contextPrompt = initialContextPrompt
                    + "\n[이전 응답이 통과되지 않았어. 평가자 가이드: "
                    + feedback.suggestion()
                    + "]";
        }

        return new OptimizationResult(draft, feedback, attempts, false);
    }
}

코드 본문이 짧은데 다섯 부분이 꽉 차 있어요. 하나씩 풀어볼게요.

① 0 번째 줄 — maxIterations <= 0 가드.

메서드 본문 첫 줄이 if (maxIterations <= 0) 입니다.

루프가 아예 안 도는 사고 를 호출 시점에 차단하는 안전선이에요.

만약 호출자가 실수로 maxIterations=0 으로 넘겼다면, while 의 첫 번째 조건 검사에서 그대로 빠져나가서 draft = null / feedback = null 인 채로 OptimizationResult 가 만들어지는 상황이 벌어져요.

그 안 좋은 상황을 호출 시점에 명시적 예외로 깨버리는 자물쇠.

호출자가 왜 결과가 비어있지? 를 디버깅할 시간을 미리 안 들이게 해줍니다.

② 루프 변수 초기화.

contextPrompt 는 호출자가 넘긴 initialContextPrompt 로 시작해요.

draftfeedbacknull 로 두는데 — 루프 본체에서 반드시 한 번은 채워진 뒤에야 빠져나오기 때문에 안전합니다 (maxIterations >= 1 이 가드로 보장돼요).

attempts 는 0 부터 시작해서 루프 안에서 먼저 증가합니다.

while (attempts < maxIterations). 천장 한 줄.

본 루프의 천장 이에요.

attempts 가 maxIterations 에 도달하는 순간 평가자가 어떤 verdict 를 돌려주든 루프가 끝나요.

이 한 줄이 비용 폭발의 안전선 이에요.

평가자 LLM 이 영원히 PASSED 를 안 돌려주는 환각 시나리오에서도 호출 1회당 최대 maxIterations 번의 LLM 왕복 으로 비용 천장이 잠겨요.

④ 생성 → 평가 → PASSED 분기.

while 본체 안에서 attempts++ 로 카운터를 올린 뒤 generate → evaluate 를 한 번씩 호출합니다.

그 즉시 feedback.verdict() 를 검사해서, PASSED 면 곧장 return new OptimizationResult(draft, feedback, attempts, true); 로 빠져나와요.

accepted=true 가 들어가서 정상 종료 신호를 호출자에게 전달합니다.

contextPrompt 가 자라는 모양. PASSED 가 아니면 — 즉, INCONSISTENCY 나 TONE_MISMATCH 면 — 루프가 다음 바퀴로 넘어가기 직전에 contextPrompt 가 자라요.

contextPrompt = initialContextPrompt
        + "\n[이전 응답이 통과되지 않았어. 평가자 가이드: "
        + feedback.suggestion()
        + "]";

1 번째 시도는 호출자가 넘긴 initialContextPrompt 그대로 가 생성자로 흘러갑니다.

2 번째 시도부터는 initialContextPrompt + 평가자 가이드 한 줄 이 흘러가요.

3 번째 시도도 initialContextPrompt + 마지막 평가자 가이드 한 줄, 가이드는 마지막 것 하나만 붙어요 (+= 가 아닌 = 이라 누적되지 않습니다).

Step 5 에서 평가자가 한 줄짜리 suggestion 강제 출력하도록 잡혀 있었는지.

그 의도가 여기서 회수돼요.

다음 생성이 같은 곳에서 같은 실수를 반복하지 않게 한다는 약속이 진짜로 다음 호출에 흘러들어가는 모습입니다.

수동 카운터에서 advisor 로

여기서 왜 본 service 를 굳이 손으로 while 카운터로 짰는지 의 의도를 한 번 짚고 갈게요.

Spring AI 의 advisor 패턴을 이미 알고 있으면 "이거 advisor 한 줄로 빠지지 않나?" 라는 의문이 자연스럽게 들거든요.

정확한 직관입니다.

본 service 의 maxIterations 파라미터가 Step 7 에서 MaxIterationsAdvisor자리를 옮기는 모습을 보게 될 거예요.

다만 왜 분리하는지 의 한 단락을 여기서 짚어야 다음 Step 의 advisor 패턴이 자연스럽게 닿습니다.

핵심은 비즈니스 service 의 책임 vs advisor 의 책임 이에요.

비즈니스 로직, "생성자 → 평가자 → 가이드 흡수 → 재생성" 의 도메인 순서.

은 service 의 책임이에요.

그건 본 service 만 아는 영역이고, ChatClient 의 다른 호출 지점엔 안 쓰여요.

반면 횡단 관심사, "몇 번까지 돌릴까", "타임아웃 얼마", "토큰 예산 얼마", "툴 호출 몇 번" 같은 가드는 모든 ChatClient 호출에 공통으로 적용되는 부분이에요.

그건 advisor 슬롯으로 빼는 게 깔끔합니다.

본 service 의 수동 카운터advisor 결 의 차이를 다섯 축으로 비교해볼게요.

수동 카운터 (본 Step 의 방식) advisor 방식 (Step 7 의 방식)
위치 service 메서드 내부의 while 변수 ChatClient 의 advisor 슬롯
적용 범위 CharacterOptimizationService 한 곳에만 .defaultAdvisors(...) 로 등록된 모든 ChatClient 호출
설정 방식 메서드 파라미터로 매번 받음 빈 등록 시 한 번 정해진 뒤 자동 적용
가시성 service 코드 정의에서 카운터가 바로 보임 advisor 등록을 보고 추론해야 함
재사용성 각 service 마다 다시 박아야 함 한 번 등록으로 N 개 ChatClient 가 공유

수동 카운터는 학생이 카운터의 정체를 직접 익히는 데 좋아요.

"아 이게 그냥 while 변수 한 개구나" 의 감각.

advisor 는 프로덕션 운영의 단단함 에 좋아요 — "모든 호출이 자동으로 가드 안에 잠긴다" 의 감각.

💡 튜터의 결론

본 Step 의 while 카운터는 Step 7~9 의 advisor 4 부품 도입을 위한 마중물 이에요. 학생이 "가드라는 게 결국 카운터 한 개구나" 의 감각을 직접 익힌 뒤에 "이 카운터를 advisor 로 들어올리면 어떤 단단함이 생기는지" 를 만나야 다음 Step 의 메시지가 살아남습니다. 추상화는 덮어쓰는 게 아니라 자라나는 방식으로 다가가야 학생 손에 남아요.

비즈니스 service 안전선 네 가지 — Step 2/3/6 의 합산

본 Step 의 maxIterations <= 0 가드까지 짜이면서, 본 Day 의 비즈니스 service 안전선 이 네 가지가 됩니다. 한 단락에 모아볼게요.

  1. Step 2 — 화이트리스트 필터Orchestrator 의 워커 풀에서 비활성 캐릭터(active=false) 를 묶어둔 회원·삭제된 캐릭터를 차단하는 자물쇠.
  2. Step 2 — priority 오름차순 정렬 — LLM 응답 순서의 자유분방함을 도메인 순서의 정렬 한 줄로 잡아두는 자물쇠.

Step 3. — 매핑 실패 시 IllegalArgumentExceptioncharacterId 자물쇠가 풀리는 환각 시나리오에서 조용한 폴백 이 아니라 명시적 실패 로 전환하는 분기. 4. Step 6 — maxIterations <= 0 가드 — Optimizer 루프가 아예 안 도는 사고를 호출 시점에 차단.

이 네 가지는 비즈니스 로직의 일부 예요.

본 service 들이 반드시 거치는 흐름 이라서 service 코드 안에 들어 있어야 합니다.

반면 Step 7~9 에서 짤 advisor 4 부품, MaxIterationsAdvisor · 타임아웃 · 토큰 예산 · 툴 호출 횟수는 횡단 관심사 예요.

그건 service 코드에 박지 않고 ChatClient 의 advisor 슬롯으로 빼서 모든 LLM 호출에 공통 적용 되게 합니다.

두 영역이 분리되어 있다는 점이 본 Day 의 아키텍처 메시지의 절반입니다.

비즈니스는 비즈니스 쪽에, 횡단은 횡단 쪽에.

🙋 날카로운 질문 타임

"평가자가 영원히 PASSED 를 안 돌려주면 어떻게 되나요? maxIterations=10 으로 운영하다 비용이 폭발하지 않나요?"

정확한 짚기예요. 본 service 가 비용·지연 천장 으로 maxIterations 한 줄을 깐 거예요. 호출 1회당 최대 maxIterations 번의 LLM 왕복 이 천장으로 잠겨요. 운영 시나리오에서는 이 위에 두 가지가 더 얹혀요. 첫째는 호출 1회당 토큰 청구서 모니터링 — 매 요청의 attempts 와 토큰 사용량을 로그로 남겨두고 일단위로 분포를 본다 의 패턴. 평균 attempts 가 maxIterations 근처에 붙기 시작하면 평가자 system 프롬프트가 너무 빡빡하거나 페르소나 정의가 모호하다 의 신호예요 — 그때 프롬프트를 조정합니다. 둘째는 토큰 예산 자체에 천장maxIterations호출 횟수 의 천장이라면, 토큰 예산은 총 토큰 양 의 천장이에요. Step 8 의 UsageBudgetAdvisor 가 그 한 단계를 더 깔아줍니다.

"while 루프 말고 Stream.iterate(...).limit(maxIterations) 같은 함수형 패턴으로 짜는 게 더 좋지 않나요?"

둘 다 작동해요. 본 강의가 while 로 짠 이유는 두 가지. 하나는 조건부 조기 종료PASSED 가 나오는 순간 return 으로 즉시 빠져나오는 흐름이 stream 의 takeWhile + findFirst 조합보다 눈에 또렷이 들어와요. 다른 하나는 루프 변수 누적contextPrompt 가 매 바퀴 이전 평가의 suggestion 으로 자라는 흐름이 stream 으로는 깔끔하지 않습니다 (mutable 변수 캡처 vs 함수형 누적의 미묘함). 그리고 본 Step 의 학습 메시지 자체가 학생이 카운터를 직접 짜보는 것이라서, 명시적 while 이 더 살아남아요. 함수형으로 다시 짜는 도전은 advisor 형태로 자라난 Step 7 이후에 횡단 관심사가 어떻게 service 본체를 비워주는지 를 본 뒤에 한 번 더 검토해보면 더 잘 들어옵니다.

Step 7. 가드 어드바이저 ① — `MaxIterationsAdvisor`

Step 2~6 까지 비즈니스 service 안쪽에 네 가지 안전선을 직접 짜봤어요. 화이트리스트 필터 한 줄, priority 정렬 한 줄, 매핑 실패 시 명시적 예외 한 곳, 그리고 직전 Step 의 while 카운터 천장. 모두 그 service 한 곳에만 적용되는 안전선이었죠. 본 Step 부터는 한 층 더 위로 올라갑니다. 비즈니스 코드 안이 아니라 advisor 슬롯에서 거는 가드 — Step 7~9 가 advisor 로만 짜는 가드 4 부품의 호흡이에요. 그 시작이 직전 Step 의 while 카운터를 advisor 한 장으로 들어올리는 단계, MaxIterationsAdvisor 입니다.

service 안전선 vs advisor 안전선 — 두 층의 차이

직전 Step 끝에서 비즈니스 service 의 안전선 네 가지 를 한 번 정리하고 왔어요.

Step 2 의 화이트리스트 필터 + priority 정렬, Step 3 의 매핑 실패 예외, Step 6 의 maxIterations <= 0 가드.

이 넷은 모두 service 메서드 안쪽에 들어 있죠.

본 Step 부터 Step 9 까지 짤 advisor 가드 4 부품 은 그 한 층 위에서 한 번 더 거는 구조예요.

두 층을 한 번 비교해볼게요.

비즈니스 service 안전선 (Step 2/3/6) advisor 가드 (Step 7~9)
위치 service 메서드 내부의 if/while ChatClient 의 advisor 슬롯
책임 도메인 로직의 일부 (그 service 만 아는 규칙) 횡단 관심사 (모든 ChatClient 호출 공통)
적용 범위 그 메서드를 호출하는 자리만 .defaultAdvisors(...) 로 등록된 모든 호출
우회 가능성 다른 서비스에서 같은 ChatClient 를 직접 부르면 우회 advisor 가 가장 바깥쪽에서 자동 적용 → 우회 불가
예시 priority 정렬, characterId 매핑 검증 호출 횟수 · 시간 · 토큰 · 툴 호출 횟수 한도

가장 결정적인 차이는 우회 가능성 한 줄이에요.

Step 6 의 while (attempts < maxIterations)CharacterOptimizationService.optimize(...) 메서드를 부르는 호출자에게만 적용돼요.

만약 다른 service 에서 같은 ChatClient 빈을 직접 주입받아 호출하면 — 거기선 카운터가 0 부터 다시 시작하죠.

반면 advisor 로 건 MaxIterationsAdvisor 는 ChatClient 빈 자체에 끼워지기 때문에 어디서 부르든 같은 카운터를 통과 합니다.

비즈니스 결과 횡단 관심사를 두 층으로 나눠 거는 이유가 여기서 드러나요.

그 service 만 아는 규칙 은 service 안쪽에, 모든 LLM 호출에 공통 인 단단함은 advisor 슬롯에 두는 게 자연스러워요.

한쪽 층에 몰아 깔면 비즈니스 코드가 가드 점검으로 부풀거나, 반대로 가드가 service 1 곳에만 걸려서 우회로가 생겨요.

두 층이 짝패가 되어야 안전선이 완성됩니다.

OWASP LLM10 — 외부 표준이 가드 4 부품을 정의해놓은 곳

본격 코드로 들어가기 전에 왜 가드가 4 부품인가 의 외부 근거를 한 번 짚고 갈게요.

이 4 부품은 우리가 임의로 묶은 게 아니라 OWASP 의 LLM Top 10 표준이 정의해놓은 항목이에요.

시의성 확인 (2026-05-14 기준)

OWASP LLM Top 10 (2025 버전, OWASP Top 10 for LLM Applications) 의 LLM10 — Unbounded Consumption 항목이 본 Day 의 가드 4 부품과 정확히 매핑됩니다. unbounded 의 두 축 — unbounded iterations / unbounded resource consumption (token / time / tool calls) — 이 각각 본 Day 의 4 advisor 로 떨어집니다.

OWASP LLM10 항목 Day 14 advisor
Unbounded iterations Step 7 MaxIterationsAdvisor
Unbounded execution time Step 8 DurationTimeoutAdvisor
Unbounded token consumption Step 8 UsageBudgetAdvisor
Unbounded tool/function calls Step 9 ToolInvocationCounterAdvisor

2026-05 기준 OWASP 가 공식 권장하는 mitigation 흐름 그대로입니다. 외부 표준이 운영 단단함의 기준을 정해놓은 형태 — 본 강의의 가드 4 부품이 거기에 정확히 떨어집니다.

표를 읽어보면 가드 4 부품이 한 줄로 정렬되죠.

반복 횟수 · 실행 시간 · 토큰 사용량 · 툴 호출 횟수 의 네 축이 LLM 운영에서 unbounded 가 되기 쉬운 곳이에요.

외부 표준이 이 네 축을 콕 집어 권장하고 있으니, 본 강의는 그 권장 그대로 한 advisor 씩 짜 나갑니다.

본 Step 은 그중 첫 번째 — 반복 횟수.

BaseAdvisor 인터페이스 — Day 13 의 회수

코드로 들어가기 전에 BaseAdvisor 인터페이스를 다시 한 번 떠올려볼게요.

지난 시간 Day 13 끝부분에 WorkflowLoggingAdvisor 를 만들면서 이 인터페이스를 처음 만났죠.

그때는 로깅 으로 썼고, 본 Step 부터는 가드 로 다시 만나는 단계예요.

같은 인터페이스가 다른 책임으로 자라는 모양입니다.

BaseAdvisor 가 강제하는 세 메서드는 이거예요.

  • before(ChatClientRequest, AdvisorChain) — LLM 호출 직전 에 끼어드는 훅. 요청을 가공해서 넘기거나(prompt 보강), 아예 차단하거나(예외 throw) 할 수 있어요. 본 Step 의 가드가 박힐 메인 위치입니다.
  • after(ChatClientResponse, AdvisorChain) — LLM 호출 직후 에 끼어드는 훅. 응답을 가공하거나 메타데이터를 뽑아낼 수 있어요. 본 advisor 는 가드라서 응답에 손대지 않고 그대로 통과시킵니다 (noop).
  • getOrder() — 여러 advisor 가 체인을 이루는 상황에서 누가 먼저 실행되는가 의 순서. 값이 낮을수록 바깥쪽 (먼저 실행). 본 advisor 는 HIGHEST_PRECEDENCE 직속.

Day 13 의 WorkflowLoggingAdvisorbefore/after 양쪽에서 로깅 메타데이터를 찍는 모양으로 자랐다면, 본 Step 의 MaxIterationsAdvisorbefore 한 군데에서 카운터 증가 + 한도 초과 차단 의 가드 형태로 자라요.

같은 인터페이스가 다른 방향으로 갈라지는 모습입니다.

MaxIterationsAdvisor 코드 본체

이제 advisor 한 장을 통째로 보겠습니다.

package kr.spartaclub.aifriends.agent.advisor;

import java.util.concurrent.atomic.AtomicInteger;

import kr.spartaclub.aifriends.agent.exception.IterationLimitExceededException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.client.advisor.api.AdvisorChain;
import org.springframework.ai.chat.client.advisor.api.BaseAdvisor;
import org.springframework.core.Ordered;

public class MaxIterationsAdvisor implements BaseAdvisor {

    private static final Logger log = LoggerFactory.getLogger(MaxIterationsAdvisor.class);

    private final int maxIterations;
    private final AtomicInteger counter = new AtomicInteger(0);

    public MaxIterationsAdvisor(int maxIterations) {
        if (maxIterations <= 0) {
            throw new IllegalArgumentException(
                    "maxIterations 는 1 이상이어야 합니다. 입력값=%d".formatted(maxIterations));
        }
        this.maxIterations = maxIterations;
    }

    @Override
    public ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain) {
        int attempts = counter.incrementAndGet();
        if (attempts > maxIterations) {
            log.warn("[MaxIterationsAdvisor] 한도 초과로 차단합니다. attempts={}, limit={}",
                    attempts, maxIterations);
            throw new IterationLimitExceededException(attempts, maxIterations);
        }
        log.debug("[MaxIterationsAdvisor] 호출 {} / {}", attempts, maxIterations);
        return chatClientRequest;
    }

    @Override
    public ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain) {
        return chatClientResponse;
    }

    public void reset() {
        counter.set(0);
    }

    public int currentCount() {
        return counter.get();
    }

    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE;
    }
}

코드가 짧은 편인데 다섯 군데가 꽉 차 있어요. 하나씩 풀어볼게요.

① 생성자의 maxIterations <= 0 가드.

직전 Step 6 의 optimize(...) 메서드 첫 줄에 박았던 가드가 advisor 생성자로 옮겨온 모양이에요.

루프가 아예 안 도는 사고 를 advisor 빈을 만드는 시점에 미리 깨버립니다.

호출자가 new MaxIterationsAdvisor(0) 같은 실수를 했을 때 나중에 LLM 호출이 모두 통과되는 무방비 advisor 가 만들어지는 게 아니라, 빈 생성 시점에서 IllegalArgumentException 으로 곧장 터지는 모양.

AtomicInteger counter — 동시성 안전.

카운터가 평범한 int 가 아니라 AtomicInteger 인 이유는 그룹 대화방 같은 시나리오 때문이에요.

Step 2~4 의 Orchestrator-Workers 흐름에서 마스터·워커들이 병렬로 같은 ChatClient 를 부르는 상황이 벌어지죠.

평범한 int 였다면 두 스레드가 동시에 counter++ 를 호출하면서 한 번의 증가가 사라지는 race condition 이 생겨요.

incrementAndGet() 한 줄로 그 부분이 안전하게 잠깁니다.

before(...) — 호출 직전 카운터 증가 + 한도 비교. advisor 의 메인 가드예요. 세 줄로 끝나죠.

int attempts = counter.incrementAndGet();
if (attempts > maxIterations) {
    throw new IterationLimitExceededException(attempts, maxIterations);
}

incrementAndGet()증가시킨 뒤의 값 을 한 번에 돌려줘서, 별도의 get() 호출 없이 한 줄로 비교 가능합니다.

attempts > maxIterations 면 즉시 예외를 던져 LLM 호출이 아예 일어나지 않게 차단해요.

비용 한 푼 도 안 들이고 그 즉시 멈춥니다.

after(...) — noop.

본 advisor 는 응답을 가공할 일이 없어요.

한도 초과는 before 에서 이미 차단했고, 한도 안쪽의 호출이라면 응답이 무사히 돌아왔다는 뜻이라 그대로 통과시킵니다.

가드 advisor 는 응답을 건드리지 않는다 라는 규칙이 Step 8~9 의 가드 advisor 3 종에도 동일하게 적용돼요.

getOrder() == HIGHEST_PRECEDENCE.

advisor 체인의 가장 바깥쪽 입니다.

Day 13 의 WorkflowLoggingAdvisorHIGHEST_PRECEDENCE + 100 에 자리잡고 있으니, 본 advisor 가 그보다 에서 실행돼요.

왜 가장 바깥쪽이냐면 — 한도 초과 차단은 다른 advisor 가 손대기 전 에 일어나야 하기 때문이에요.

로깅 advisor 가 먼저 호출을 받아 로그를 찍었는데 그 다음 가드가 차단하면, 호출되지도 않은 LLM 호출의 로그 가 한 줄 새겠죠.

가드는 가장 먼저 깔려야 비용·시간·로그 낭비가 한 번에 잡혀요.

IterationLimitExceededException — 도메인 예외의 결

한도 초과를 알리는 예외 한 장도 짚고 갈게요.

package kr.spartaclub.aifriends.agent.exception;

public class IterationLimitExceededException extends RuntimeException {

    private final int attempts;
    private final int limit;

    public IterationLimitExceededException(int attempts, int limit) {
        super("한 사이클의 호출 한도를 초과했습니다. attempts=%d, limit=%d".formatted(attempts, limit));
        this.attempts = attempts;
        this.limit = limit;
    }

    public int getAttempts() {
        return attempts;
    }

    public int getLimit() {
        return limit;
    }
}

세 가지 포인트를 풀어볼게요.

RuntimeException 직속.

이 과목의 다른 곳에서 만난 BusinessException 계열(도메인 검증 실패용) 로 격상하지 않았어요.

이유는 본 예외가 학습 흐름의 런타임 안전선 이라는 위상에 있어요.

정상 운영 시나리오에서는 절대 발생하지 않아야 하는 — 자율성이 깨졌을 때만 터지는 종류거든요.

도메인 비즈니스 검증 실패가 예측 가능한 분기 라면, 본 예외는 예측 불가능한 폭주의 차단 이라 위상이 다릅니다.

가드 4 종 advisor 모두 같은 패턴이에요.

attempts + limit 두 필드.

메시지 한 줄로 끝낼 수도 있었지만 두 필드를 별도로 둔 이유는 디버깅 정보의 구조화 예요.

운영 환경에서 이 예외가 터졌을 때 몇 번째 호출에서 / 한도가 얼마였는지 가 메시지 파싱 없이 필드 그대로 잡혀요.

예외 핸들러에서 e.getAttempts() / e.getLimit() 한 줄로 모니터링 메트릭에 박을 수 있어요.

③ 가드 4 종 advisor 공통 패턴.

Step 8 의 TokenBudgetExceededException · DurationTimeoutExceededException, Step 9 의 ToolInvocationLimitExceededException 도 본 예외와 동일한 모양으로 짤 거예요.

RuntimeException 직속 + 한도 정보 두 필드 + 한 줄 메시지 입니다.

가드 advisor 4 부품의 예외 계층 통일성 이에요.

6. 사이클 경계 — reset()

본 advisor 의 결정적 특징 한 가지 — stateful 이라는 점이에요.

카운터가 인스턴스 내부에 누적되니까, advisor 한 인스턴스가 얼마만큼의 호출 까지 한 사이클로 볼 것인지의 경계가 필요해요.

그 경계 표시 한 줄이 reset() 메서드입니다.

public void reset() {
    counter.set(0);
}

호출자가 새 비즈니스 사이클을 시작하기 직전에 reset() 을 한 번 부르면 카운터가 0 으로 되돌아가요.

예를 들어 Step 3 의 그룹 대화방 흐름으로 보면 — 사용자 발화 한 건 이 마스터 → 워커 N 명까지 한 사이클이고, 다음 사용자 발화가 새 사이클이죠.

첫 발화에서 한도까지 다 써도, 다음 발화 진입 시점에 reset() 한 번이면 새 카운터로 시작합니다.

💡 튜터의 결론

본 advisor 가 stateful 이라는 게 운영에서 어떤 의미인가 — 두 가지 짚어볼게요. 하나는 멀티스레드 안전 — 위에서 본 AtomicInteger 한 줄로 충족. 다른 하나는 Singleton vs Prototype 빈 결정. Spring 의 기본 빈 스코프가 Singleton 인데, 본 advisor 를 그대로 @Component 로 등록하면 모든 호출이 같은 인스턴스 + 같은 카운터 를 공유해버려요. 사이클 경계가 흐려져서 서로 다른 사용자의 발화가 한 카운터에 합산 되는 사고가 나죠. 그래서 본 Step 의 advisor 는 의도적으로 @Component 가 아니에요. Step 8~9 의 가드 advisor 3 종도 마찬가지 — 호출자가 사이클마다 직접 new 하거나, Spring 의 @Scope("prototype") 으로 등록하는 게 안전합니다. stateful advisor 는 빈 스코프 선택이 곧 운영 메시지 라는 점이죠.

🙋 날카로운 질문 타임

"MaxIterationsAdvisor 가 advisor 슬롯에 들어간다는 건 ChatClient 빈에 어떻게 등록하나요? Step 2~5 의 AgentChatClientConfig 에 추가하면 되나요?"

정확한 짚기예요. 일반적으로는 ChatClient.Builder.defaultAdvisors(workflowLoggingAdvisor, maxIterationsAdvisor) 같은 한 줄로 등록해요. 다만 본 advisor 는 위에서 본 stateful (카운터) 이라는 결정적 특성이 있어서, ChatClient 빈에 Singleton 으로 박으면 모든 호출이 한 카운터를 공유 — 의도와 어긋나요. 그래서 본 강의는 advisor 등록 자체를 Step 9 가 끝나고 한 번에 진행할 거예요. Step 8 의 DurationTimeoutAdvisor · UsageBudgetAdvisor, Step 9 의 ToolInvocationCounterAdvisor 까지 가드 4 부품이 모두 모인 뒤에 등록을 한 군데서 정리하는 흐름이 안전합니다. 본 Step 7 은 advisor 클래스 한 장만 짜는 범위까지예요.

"HIGHEST_PRECEDENCE 외에 다른 advisor (예: Step 8 의 token budget) 와 충돌하면 어떻게 되나요? 둘 다 가장 바깥쪽에서 차단해야 할 텐데요."

advisor 의 getOrder() 값은 낮을수록 바깥쪽 (먼저 실행) 이에요. MaxIterationsAdvisor.getOrder() == HIGHEST_PRECEDENCE 이고 WorkflowLoggingAdvisor.getOrder() == HIGHEST_PRECEDENCE + 100 이니까, 호출 흐름은 MaxIterations → WorkflowLogging → 실제 LLM 호출 → 응답 순이에요. Step 8 의 token budget · 시간 timeout 도 호출 전 차단 패턴이라 HIGHEST_PRECEDENCE + 1 또는 + 10 같은 값에 둘 예정. 가드 4 부품끼리는 어느 순서로 깔려도 차단 결과는 같음 — 한 군데만 한도 초과여도 LLM 호출은 일어나지 않으니까요. 다만 어떤 가드가 먼저 터지는지 의 로그 가독성을 위해 반복 횟수 → 시간 → 토큰 → 툴 호출 의 순으로 order 값을 올려나갈 거예요. 운영 시 어떤 한도가 먼저 터졌는지 가 디버깅의 첫 신호니까요.

Step 8. 가드 어드바이저 ②③ — `DurationTimeoutAdvisor` + `UsageBudgetAdvisor`

직전 Step 7 의 MaxIterationsAdvisor 한 장을 짜봤죠. 이번엔 같은 패턴이 시간 축과 토큰 축으로 한 번씩 더 자라는 모습을 보겠습니다. advisor 두 장이지만 골격은 거의 똑같아요 — BaseAdvisor 인터페이스 + AtomicXxx 카운터 자원 + RuntimeException 직속 예외 + before 의 가드 조건 + reset() 한 줄. 재사용되는 골격만 익혀두면 새 가드 추가는 한 군데만 갈아끼우는 일 이 됩니다.

1. Step 7 패턴의 재사용 — 6 부품의 결

advisor 한 장을 짜기 위해 필요한 부품이 몇 가지로 정리되는지 한 번 표로 잡고 가요. Step 7~9 의 가드 advisor 3 종이 공통으로 갖는 골격이에요.

부품 Step 7 MaxIterationsAdvisor Step 8 DurationTimeoutAdvisor Step 8 UsageBudgetAdvisor Step 9 (예고)
인터페이스 BaseAdvisor implements
카운터 자원 AtomicInteger counter AtomicReference<Instant> startedAt AtomicLong cumulativeTokens AtomicInteger toolCallCount
예외 IterationLimitExceededException (RuntimeException) CycleTimeoutExceededException TokenBudgetExceededException ToolInvocationLimitExceededException
before(...) 가드 조건 attempts > maxIterations elapsed.compareTo(timeout) > 0 used > maxTotalTokens tools > maxTools
after(...) noop noop 응답 metadata 추출 + 누적 툴 호출 추적
reset() counter.set(0) startedAt.set(null) cumulativeTokens.set(0) toolCallCount.set(0)
getOrder() HIGHEST_PRECEDENCE + 10 + 20 + 30

같은 인터페이스 위에서 카운터 자원의 자료형 + 가드 조건의 비교 연산자 두 곳만 갈아끼우면 새 가드가 한 장 더 자라요.

패턴이 자란다는 건 새 advisor 도입 비용이 한두 곳만 늘어난다는 뜻 — 코드 재사용이 학습 비용 절감 으로 직접 환원되는 모양입니다.

본 Step 의 두 advisor 도 위 표의 칸들을 그대로 채워 자라요.

DurationTimeoutAdvisor — 시간 가드

먼저 시간 가드부터 통째로 보겠습니다.

package kr.spartaclub.aifriends.agent.advisor;

import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.atomic.AtomicReference;

import kr.spartaclub.aifriends.agent.exception.CycleTimeoutExceededException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.client.advisor.api.AdvisorChain;
import org.springframework.ai.chat.client.advisor.api.BaseAdvisor;
import org.springframework.core.Ordered;

public class DurationTimeoutAdvisor implements BaseAdvisor {

    private static final Logger log = LoggerFactory.getLogger(DurationTimeoutAdvisor.class);

    private final Duration timeout;
    private final AtomicReference<Instant> startedAt = new AtomicReference<>();

    public DurationTimeoutAdvisor(Duration timeout) {
        if (timeout == null || timeout.isZero() || timeout.isNegative()) {
            throw new IllegalArgumentException(
                    "timeout 은 양수 Duration 이어야 합니다. 입력값=%s".formatted(timeout));
        }
        this.timeout = timeout;
    }

    @Override
    public ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain) {
        startedAt.compareAndSet(null, Instant.now());
        Duration elapsed = Duration.between(startedAt.get(), Instant.now());
        if (elapsed.compareTo(timeout) > 0) {
            log.warn("[DurationTimeoutAdvisor] 시간 한도 초과로 차단합니다. elapsed={}, timeout={}",
                    elapsed, timeout);
            throw new CycleTimeoutExceededException(elapsed, timeout);
        }
        log.debug("[DurationTimeoutAdvisor] 경과 {} / 한도 {}", elapsed, timeout);
        return chatClientRequest;
    }

    @Override
    public ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain) {
        return chatClientResponse;
    }

    public void reset() {
        startedAt.set(null);
    }

    @Override
    public int getOrder() {
        // MaxIterationsAdvisor (HIGHEST_PRECEDENCE) 보다 한 칸 안쪽.
        return Ordered.HIGHEST_PRECEDENCE + 10;
    }
}

다섯 부분으로 풀어볼게요.

① 생성자 가드. null · ZERO · 음수 세 갈래 차단.

Step 7 의 maxIterations <= 0 가드 한 줄이 본 Step 에선 세 갈래로 자랐어요.

Duration 이라는 자료형 자체가 null · Duration.ZERO · 음수 Duration 세 경우에서 의미 없는 timeout 이 만들어질 수 있거든요.

Duration.ofSeconds(0) 으로 advisor 를 만들면 첫 호출 직후부터 곧장 차단, 그건 advisor 가 아니라 차단기죠.

Duration.ofSeconds(-1) 도 마찬가지로 흐른 시간이 항상 timeout 보다 큰 무한 차단 상태가 되고요.

빈 생성 시점에 세 갈래를 모두 깨서 나중에 LLM 호출이 통과되지 못하는 무방비 advisor 가 만들어지지 않게 막아요.

AtomicReference<Instant> + compareAndSet(null, Instant.now()) — 첫 호출에만 시작 시점 기록.

이 한 줄이 본 advisor 의 핵심이에요.

compareAndSet(null, Instant.now())현재 값이 null 일 때만 새 값을 박는 atomic 연산이에요.

즉 첫 before(...) 호출에서만 시작 시점이 Instant.now() 로 기록되고, 두 번째 호출부터는 이미 값이 들어있어서 compareAndSet그냥 false 를 돌려주고 끝 입니다.

결과적으로 시작 시점은 advisor 인스턴스가 처음 만난 호출의 시점에 한 번만 기록되는 방식이에요.

평범한 if (startedAt == null) startedAt = Instant.now(); 도 같은 일을 하지만, Step 7 의 카운터와 마찬가지로 그룹 대화방에서 워커들이 병렬로 같은 advisor 를 부르는 상황에서 race condition 이 생겨요.

AtomicReference 한 줄이 그 부분을 안전하게 잠급니다.

Duration.between(startedAt.get(), Instant.now()) — 흐른 시간 측정.

자바 8 의 Duration API 그대로예요.

Instant 의 차이를 Duration 으로 받아 얼마나 흘렀는지 를 잡습니다.

ms 도, ns 도, ofSeconds 도 익숙한 단위로 비교 가능해요.

elapsed.compareTo(timeout) > 0 시 차단. 시간 자물쇠.

Step 6 의 CharacterOptimizationService.optimize(...) 를 다시 떠올려볼게요.

while 루프가 평가자 LLM 으로부터 PASSED 를 받을 때까지 도는 구조였죠.

평가자가 환각으로 영원히 PASSED 를 못 내려주는 시나리오에서 Step 7 의 MaxIterationsAdvisor호출 횟수 로 차단하고, 본 advisor 가 흐른 시간 으로 한 번 더 차단해요.

호출이 100 번 안 도는데 한 호출이 60 초씩 hang 되는 상황이면.

횟수 가드는 그대로 통과하고 시간 가드가 잡아냅니다.

축이 다른 두 가드가 같은 advisor 슬롯에 들어가 서로 다른 폭주 시나리오를 잡는 모습이 여기서 보이죠.

getOrder() == HIGHEST_PRECEDENCE + 10. MaxIterations 보다 한 칸 안쪽.

Step 7 의 MaxIterationsAdvisorHIGHEST_PRECEDENCE 자리고 본 advisor 가 + 10 이에요.

호출 흐름은 MaxIterations → DurationTimeout → 실제 LLM 호출 순.

반복 횟수 가드가 더 바깥쪽 이냐면.

호출 횟수는 증가 방식 이라 한 번 증가하면 그 카운터가 누적되는데, 시간 가드는 시점만 박는 방식 이라 비교 연산만 도는 가벼움이 있어요.

가벼운 비교가 안쪽, 누적이 바깥쪽.

상태 변경 비용이 큰 advisor 가 먼저 차단 되도록 두는 거예요.

CycleTimeoutExceededException — 예외

advisor 가 차단할 때 던지는 예외 한 장도 짚고 가요.

package kr.spartaclub.aifriends.agent.exception;

import java.time.Duration;

public class CycleTimeoutExceededException extends RuntimeException {

    private final Duration elapsed;
    private final Duration timeout;

    public CycleTimeoutExceededException(Duration elapsed, Duration timeout) {
        super("한 사이클의 시간 한도를 초과했습니다. elapsed=%s, timeout=%s".formatted(elapsed, timeout));
        this.elapsed = elapsed;
        this.timeout = timeout;
    }

    public Duration getElapsed() {
        return elapsed;
    }

    public Duration getTimeout() {
        return timeout;
    }
}

Step 7 의 IterationLimitExceededException 과 모양이 똑같아요.

RuntimeException 직속 + 두 필드 (elapsed + timeout) + 한 줄 메시지.

두 필드를 둔 이유도 동일해요 — 운영 모니터링에서 왜 차단됐는지 (실제로 흐른 시간) + 몇 초 한도였는지 (설정 timeout) 두 가지를 메시지 파싱 없이 필드 그대로 잡기 위함이에요.

디버깅의 첫 신호가 메시지가 아니라 필드여야 한다는 거죠.

UsageBudgetAdvisor — 토큰 가드

이번엔 토큰 가드. 본 advisor 는 한 단계 더 깊어져요 — 응답 metadata 에서 토큰 사용량을 뽑아 누적 하는 단계가 추가됩니다.

package kr.spartaclub.aifriends.agent.advisor;

import java.util.concurrent.atomic.AtomicLong;

import kr.spartaclub.aifriends.agent.exception.TokenBudgetExceededException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.client.advisor.api.AdvisorChain;
import org.springframework.ai.chat.client.advisor.api.BaseAdvisor;
import org.springframework.ai.chat.metadata.ChatResponseMetadata;
import org.springframework.ai.chat.metadata.Usage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.core.Ordered;

public class UsageBudgetAdvisor implements BaseAdvisor {

    private static final Logger log = LoggerFactory.getLogger(UsageBudgetAdvisor.class);

    private final long maxTotalTokens;
    private final AtomicLong cumulativeTokens = new AtomicLong(0);

    public UsageBudgetAdvisor(long maxTotalTokens) {
        if (maxTotalTokens <= 0) {
            throw new IllegalArgumentException(
                    "maxTotalTokens 는 1 이상이어야 합니다. 입력값=%d".formatted(maxTotalTokens));
        }
        this.maxTotalTokens = maxTotalTokens;
    }

    @Override
    public ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain) {
        long used = cumulativeTokens.get();
        if (used > maxTotalTokens) {
            log.warn("[UsageBudgetAdvisor] 토큰 예산 초과로 차단합니다. used={}, budget={}",
                    used, maxTotalTokens);
            throw new TokenBudgetExceededException(used, maxTotalTokens);
        }
        log.debug("[UsageBudgetAdvisor] 누적 {} / 예산 {}", used, maxTotalTokens);
        return chatClientRequest;
    }

    @Override
    public ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain) {
        long delta = extractTotalTokens(chatClientResponse);
        if (delta > 0) {
            long updated = cumulativeTokens.addAndGet(delta);
            log.debug("[UsageBudgetAdvisor] 응답 토큰 +{}, 누적 {}", delta, updated);
        }
        return chatClientResponse;
    }

    private long extractTotalTokens(ChatClientResponse chatClientResponse) {
        if (chatClientResponse == null) {
            return 0;
        }
        ChatResponse chatResponse = chatClientResponse.chatResponse();
        if (chatResponse == null) {
            return 0;
        }
        ChatResponseMetadata metadata = chatResponse.getMetadata();
        if (metadata == null) {
            return 0;
        }
        Usage usage = metadata.getUsage();
        if (usage == null) {
            return 0;
        }
        Integer total = usage.getTotalTokens();
        return total == null ? 0 : total.longValue();
    }

    public void reset() {
        cumulativeTokens.set(0);
    }

    public long cumulativeUsed() {
        return cumulativeTokens.get();
    }

    @Override
    public int getOrder() {
        // DurationTimeoutAdvisor (HIGHEST_PRECEDENCE + 10) 보다 한 칸 안쪽.
        return Ordered.HIGHEST_PRECEDENCE + 20;
    }
}

여섯 부분으로 풀어볼게요.

① 생성자 가드 — maxTotalTokens <= 0 차단.

Step 7 의 MaxIterationsAdvisor 와 같아요.

예산 0 또는 음수 면 첫 응답이 들어오자마자 차단 → advisor 가 아니라 차단기.

빈 생성 시점에 깨버립니다.

AtomicLong cumulativeTokens — 동시성 안전한 누적.

Step 7 의 AtomicInteger, 본 Step 의 DurationTimeoutAdvisorAtomicReference 와 같은 방식이에요.

한 advisor 인스턴스가 여러 호출에 걸쳐 상태를 누적 하는데, 그 호출들이 그룹 대화방의 워커 병렬 흐름으로 동시에 들어올 수 있으니까요.

addAndGet(delta) 한 줄로 누적이 race condition 없이 잠겨요.

자료형이 int 가 아니라 long 인 건 한 사이클에서 토큰이 수십만 단위까지 쌓일 수 있어서 안전 마진을 둔 거예요.

before(...). 누적 > 예산이면 차단.

Step 7 과 같은 패턴이에요.

누적이 이미 예산을 넘긴 상태에서 다음 호출이 시도될 때 막아요.

예산을 정확히 한 호출 만큼 초과한 순간 그 호출은 통과하고, 그 다음 호출부터 차단 됩니다.

호출 직전 시점의 누적값 이 기준이라는 건 운영 의미가 있어요.

마지막 호출 한 번은 통과시켜야 응답 metadata 에서 토큰 누적이 들어가야 왜 차단됐는지 의 마지막 신호가 잡히거든요.

after(...). 응답 metadata 에서 토큰 추출 + 누적.

여기가 본 advisor 가 한 단계 더 자란 부분이에요.

Step 7 의 MaxIterationsAdvisor, 본 Step 의 DurationTimeoutAdvisorafter(...) 가 noop 이었죠.

호출 횟수흐른 시간 은 모두 before 시점에 알 수 있는 정보였으니까요.

그런데 토큰 은 달라요.

응답이 와야 그 호출이 얼마나 토큰을 썼는지 알 수 있거든요.

그래서 after(...) 에서 응답을 받아 토큰을 뽑아 누적하는 단계가 정리됐어요.

extractTotalTokens 헬퍼의 5단 null-safe 체인.

하나씩 보면 chatClientResponse → chatResponse() → getMetadata() → getUsage() → getTotalTokens() 다섯 단계가 모두 null 가능 이에요.

어느 한 단계가 null 이면 조용히 0 으로 통과 시켜요.

왜 예외를 던지지 않냐면 — 일부 프로바이더 (특히 Ollama 로컬, Gemini 무료 티어의 일부 응답, 스트리밍 응답의 중간 청크) 가 metadata 를 안 채우는 경우가 실제로 있거든요.

거기서 예외를 던지면 정상 응답인데 토큰 추적만 못 한다는 이유로 사이클 전체가 무너지는 사고가 나요.

학생 데모에서 서비스 다운 으로 보입니다.

예외로 깨뜨리지 않고 다음 호출로 흘려보내는 선택이 운영 단단함의 핵심이에요.

getOrder() == HIGHEST_PRECEDENCE + 20. DurationTimeout 보다 한 칸 안쪽.

호출 흐름은 MaxIterations → DurationTimeout → UsageBudget → 실제 LLM 호출.

토큰 가드가 가장 안쪽인 이유는 두 가지예요.

하나는 응답 후에야 누적이 일어나는 advisor 라는 점.

응답 시점이 자리잡은 advisor 는 실제 LLM 호출에 가까운 위치 가 자연스러워요.

다른 하나는 토큰 추적이 가장 비싸다 는 점.

응답 metadata 를 매번 파싱하는 비용이 횟수 비교나 시간 비교보다 큽니다.

비싼 일은 안쪽으로.

💡 튜터의 결론

왜 토큰 가드는 after(...) 에 응답 metadata 추출이 자리잡고 있고, 호출 횟수·시간 가드는 before(...) 한 곳만 봐도 충분한가 — 한 호흡으로 정리할게요. 호출 횟수는 호출이 일어나는 그 시점 에서 카운터를 한 칸 올리면 끝. 흐른 시간도 시작 시점만 박아두면 그 다음 호출부터는 Instant.now() 와의 차이로 즉시 계산. 두 가지는 모두 호출 직전에 모든 정보가 확보된 부분이에요. 그런데 토큰은 그 호출이 얼마나 토큰을 썼는지응답이 와야 비로소 알 수 있는 정보예요. 그래서 after(...) 에서 응답 metadata 를 뜯어 누적해두고, 다음 before(...) 에서 누적된 결과 로 차단 여부를 판단하는 방식으로 들어간 거예요. 정보가 체감되는 시점이 advisor 의 어느 훅에 들어갈지를 결정한다 — 가드 advisor 설계의 일반 원리입니다.

TokenBudgetExceededException — 예외

토큰 가드의 예외 한 장도 짚고 가요.

package kr.spartaclub.aifriends.agent.exception;

public class TokenBudgetExceededException extends RuntimeException {

    private final long used;
    private final long budget;

    public TokenBudgetExceededException(long used, long budget) {
        super("한 사이클의 토큰 예산을 초과했습니다. used=%d, budget=%d".formatted(used, budget));
        this.used = used;
        this.budget = budget;
    }

    public long getUsed() {
        return used;
    }

    public long getBudget() {
        return budget;
    }
}

IterationLimitExceededException · CycleTimeoutExceededException 과 모양이 똑같아요.

RuntimeException 직속 + 두 필드 (used + budget) + 한 줄 메시지.

운영 관점에서 한 가지 더 짚어볼게요 — 본 예외는 비용 운영 과 직접 짝패가 돼요.

e.getUsed() 값이 실제로 쓰인 토큰 수 라서 클라우드 LLM 청구서의 토큰 단가 × used 한 줄로 이번 차단 시점까지 발생한 비용 이 곧장 계산돼요.

운영 알람 시스템에 "한 사용자 사이클에서 토큰 예산 X 를 초과한 경우 슬랙 알림" 같은 규칙을 박을 때 본 예외가 정확히 그 트리거가 됩니다.

비용 모니터링과 advisor 의 차단 시점이 하나의 지점에서 만나는 거예요.

order 체인 — 4 advisor 가 외→내로 깔리는 결

Step 7 의 MaxIterationsAdvisor, 본 Step 의 두 advisor, 그리고 Step 9 예고분까지 — 다섯 단계의 order 값을 한 표로 잡고 가요.

order 값 advisor 차단 축 정보 시점 성격
HIGHEST_PRECEDENCE MaxIterationsAdvisor 호출 횟수 before 가장 바깥쪽 — 누적 카운터, 한 번 증가하면 되돌릴 수 없음
+ 10 DurationTimeoutAdvisor 흐른 시간 before 시작 시점만 박고 비교 — 가벼운 연산
+ 20 UsageBudgetAdvisor 누적 토큰 before + after 응답 metadata 파싱이 필요 — 비쌈
+ 30 (Step 9 예고) ToolInvocationCounterAdvisor 툴 호출 횟수 before + 권한 분기 권한 스코프 확인까지 — 가장 무거움
+ 100 WorkflowLoggingAdvisor (Day 13) (로깅) before + after 가장 안쪽 — 가드 통과 후 실제 호출 직전

이 표가 advisor 체인 설계의 외→내 방향 을 한눈에 정리해줘요.

가드는 바깥쪽부터 안쪽으로, 로깅은 가장 안쪽으로, 왜 로깅이 가장 안쪽이냐면 — 가드가 차단하면 실제 LLM 호출이 일어나지 않으니까 그 호출의 로그도 떨어질 필요가 없어요.

차단된 호출은 가드 advisor 의 log.warn(...) 한 줄만 남기면 충분하고, Day 13 의 워크플로 로깅실제로 호출된 LLM 호출의 메타데이터 를 남기는 역할이라 가드 안쪽에 있어야 호출되지도 않은 LLM 의 빈 로그 가 새지 않아요.

가드와 로깅의 책임 경계가 order 값으로 그대로 표현되는 부분이에요.

🙋 날카로운 질문 타임

"DurationTimeoutAdvisor 가 흐른 시간 만 잡는데, 첫 LLM 호출 자체가 30 초 걸리면 (네트워크 hang) 그건 어떻게 잡나요? Spring AI 의 transport timeout 과는 어떻게 분리되는 거죠?"

핵심을 짚어주셨어요. 본 advisor 의 차단은 advisor 의 before(...) 가 호출되는 시점 에서 일어나요. 즉 호출 사이 의 시간을 잡지, 호출 자체가 hang 되는 시간 을 잡지는 못해요. 첫 호출이 30 초간 hang 되어도 본 advisor 는 그 호출이 끝나고 두 번째 호출의 before 가 들어올 때 비로소 30 초가 흘렀음을 발견합니다. 개별 호출의 transport hang 은 advisor 가 아니라 Spring AI 의 ChatModel 자체 설정 에서 잡는 영역이에요. Ollama 라면 spring.ai.ollama.chat.options.timeout, Gemini 클라이언트라면 RestClient 의 connectTimeout · readTimeout 옵션, OpenAI 라면 SDK 의 transport timeout 설정. 두 영역이 자연스럽게 분리돼요 — advisor 는 사이클 전체 시간의 가드 / transport 는 개별 호출 시간의 가드. 운영에서는 둘 다 까는 게 표준이에요. transport timeout 만 있으면 50 번의 짧은 호출이 누적되어 5 분이 흘러도 못 잡고, advisor timeout 만 있으면 한 호출이 무한 hang 되어도 다음 advisor 호출이 안 오니까 못 잡는 사각이 생기거든요. 두 가드가 서로 다른 사각지대를 커버해요.

"UsageBudgetAdvisor.after(...) 에서 metadata 가 null 인 경우 조용히 0 으로 통과 시키는 게 위험하지 않나요? 무료 티어가 metadata 를 안 채우는데 그게 비용 추적 사각지대가 되면, 토큰 가드가 영원히 발동하지 않는 사고가 나지 않아요?"

정확한 우려예요. 운영의 트레이드오프 두 갈래로 정리해드릴게요. 예외로 깨뜨리는 방식 을 택하면 metadata 가 없는 응답마다 사이클이 무너져요. 사용자 입장에서 기능 자체가 죽은 것처럼 보이는 모습이라 학생 데모·운영 양쪽 모두 치명적이죠. 반대로 조용히 0 으로 통과 시키는 길을 택하면 metadata 가 안 오는 프로바이더에서는 토큰 누적이 영원히 0 이고, 가드는 발동하지 않아요 — 그게 본 예외의 사각지대입니다. 본 강의는 후자 + 별도 모니터링 방향으로 정리됐어요. advisor 의 책임은 가드 자체의 발동 이고, metadata 보장프로바이더 헬스 체크 의 몫이에요. Day 20 의 Observability 에서 프로바이더별 metadata 누락률 을 모니터링하는 단계가 들어올 거고, 거기서 Ollama 응답 100% 중 metadata 누락 80% 같은 메트릭이 잡히면 그 프로바이더는 토큰 가드의 사각지대 라는 운영 신호로 잡혀요. advisor 한 곳에 모든 안전선을 다 박지 않고 책임을 분리 하는 거예요 — 한 advisor 가 너무 많은 책임을 지면 복잡한 if/else 의 늪 으로 자라거든요.

Step 9. 가드 어드바이저 ④ + 권한 스코프 + 4부품 통합

Step 7~8 의 세 advisor 가 호출 횟수 · 흐른 시간 · 누적 토큰 의 세 축을 한 장씩 책임졌어요. 오늘의 마지막 advisor 는 그 옆에 툴 호출 횟수 축을 한 장 더 보태는 작업입니다. 그리고 단순히 한 advisor 가 추가되는 데서 끝나지 않아요 — 권한 스코프 한 단계가 도구 쪽에 보태지면서 외부 표준(MCP)이 이미 같은 방향으로 정착하고 있다 는 시의성까지 한 번에 만나거든요. 마지막엔 4 부품을 한 묶음으로 돌려주는 정적 팩토리까지 보면 오늘 Day 가 마무리됩니다.

ToolInvocationCounterAdvisor — 가드 advisor 마지막 한 장

먼저 advisor 본체부터 통째로 봅니다. Step 7~8 패턴을 익숙하게 따라가니까 분량은 짧게 풀어갈게요.

package kr.spartaclub.aifriends.agent.advisor;

import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

import kr.spartaclub.aifriends.agent.exception.ToolInvocationLimitExceededException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.client.advisor.api.AdvisorChain;
import org.springframework.ai.chat.client.advisor.api.BaseAdvisor;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.core.Ordered;

public class ToolInvocationCounterAdvisor implements BaseAdvisor {

    private static final Logger log = LoggerFactory.getLogger(ToolInvocationCounterAdvisor.class);

    private final int maxToolInvocations;
    private final AtomicInteger toolCallCount = new AtomicInteger(0);

    public ToolInvocationCounterAdvisor(int maxToolInvocations) {
        if (maxToolInvocations <= 0) {
            throw new IllegalArgumentException(
                    "maxToolInvocations 는 1 이상이어야 합니다. 입력값=%d".formatted(maxToolInvocations));
        }
        this.maxToolInvocations = maxToolInvocations;
    }

    @Override
    public ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain) {
        int used = toolCallCount.get();
        if (used > maxToolInvocations) {
            log.warn("[ToolInvocationCounterAdvisor] 툴 호출 한도 초과로 차단합니다. invocations={}, limit={}",
                    used, maxToolInvocations);
            throw new ToolInvocationLimitExceededException(used, maxToolInvocations);
        }
        log.debug("[ToolInvocationCounterAdvisor] 누적 {} / 한도 {}", used, maxToolInvocations);
        return chatClientRequest;
    }

    @Override
    public ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain) {
        int delta = extractToolCallCount(chatClientResponse);
        if (delta > 0) {
            int updated = toolCallCount.addAndGet(delta);
            log.debug("[ToolInvocationCounterAdvisor] 응답 툴 호출 +{}, 누적 {}", delta, updated);
        }
        return chatClientResponse;
    }

    private int extractToolCallCount(ChatClientResponse chatClientResponse) {
        if (chatClientResponse == null) {
            return 0;
        }
        ChatResponse chatResponse = chatClientResponse.chatResponse();
        if (chatResponse == null) {
            return 0;
        }
        List<Generation> results = chatResponse.getResults();
        if (results == null || results.isEmpty()) {
            return 0;
        }
        int sum = 0;
        for (Generation generation : results) {
            if (generation == null) {
                continue;
            }
            AssistantMessage output = generation.getOutput();
            if (output == null) {
                continue;
            }
            List<AssistantMessage.ToolCall> toolCalls = output.getToolCalls();
            if (toolCalls == null) {
                continue;
            }
            sum += toolCalls.size();
        }
        return sum;
    }

    public void reset() {
        toolCallCount.set(0);
    }

    public int currentCount() {
        return toolCallCount.get();
    }

    @Override
    public int getOrder() {
        // UsageBudgetAdvisor (HIGHEST_PRECEDENCE + 20) 보다 한 칸 안쪽.
        return Ordered.HIGHEST_PRECEDENCE + 30;
    }
}

다섯 부분으로 풀어봅니다.

① 생성자 가드 — maxToolInvocations <= 0 차단. Step 7~8 과 똑같은 방식이에요. 한도 0 또는 음수면 advisor 가 아니라 차단기. 빈 생성 시점에 깨버립니다.

AtomicInteger toolCallCount — Step 7 MaxIterationsAdvisor 와 같은 자료형.

호출 횟수를 누적하는 카운터라 자료형도 같아요.

워커 병렬 호출에서 race condition 이 생기지 않도록 atomic 으로 잠가둡니다.

before(...) — 누적 > 한도 차단.

Step 8 UsageBudgetAdvisor 와 동일한 형태입니다.

누적이 이미 한도를 넘긴 상태에서 다음 호출이 시도될 때 막아요.

마지막 호출 한 번은 통과시켜 응답 metadata 의 누적이 들어가야 왜 차단됐는지 신호가 잡힙니다.

after(...) + extractToolCallCount(...) 헬퍼의 5 단 null-safe 체인.

본 Step 의 advisor 가 Step 7 보다 한 단계 깊은 응답 파싱 을 합니다.

체인이 chatClientResponse → chatResponse() → getResults() → getOutput() → getToolCalls().size() 다섯 단계로 길어졌어요.

Step 8 의 token 추출은 Usage 한 객체에서 정수 한 개만 뽑으면 됐는데, 툴 호출 횟수는 Generation 리스트를 순회하면서 각 AssistantMessage 의 toolCalls 사이즈를 합산 해야 합니다 — 한 응답에 여러 generation 이 있을 수 있고, 한 generation 이 여러 도구를 동시에 호출할 수도 있거든요.

그리고 마스터 ChatClient 나 평가자 ChatClient 처럼 도구가 등록되지 않은 빈에서 응답이 오면 toolCalls 자체가 null 이라 조용히 0 으로 통과 시킵니다.

Step 8 의 조용한 통과 와 같은 운영 방향이에요.

getOrder() == HIGHEST_PRECEDENCE + 30 — UsageBudget 보다 한 칸 안쪽.

호출 흐름은 MaxIterations → DurationTimeout → UsageBudget → ToolInvocationCounter → 실제 LLM 호출.

본 advisor 가 가장 안쪽인 이유는 툴 호출 추적이 가장 무거운 파싱 비용을 가지기 때문 + 권한 스코프 확인까지 짝패로 묶여 있어서 두 가지예요.

비싼 일은 안쪽으로, Step 8 에서 깔아둔 원칙을 한 번 더 따릅니다.

4 advisor + 로깅 — 외→내 흐름의 완성된 그림

Step 7~9 의 가드 4 장 + Day 13 의 WorkflowLoggingAdvisor 가 한곳에 모이면 advisor 체인이 5 단으로 완성됩니다. order 값 순서로 한 표 정리하고 갈게요.

order advisor 차단 / 기록 축 before after
HIGHEST_PRECEDENCE MaxIterationsAdvisor 호출 횟수 누적 +1 → 차단 검사 noop
+ 10 DurationTimeoutAdvisor 흐른 시간 시작 시점 + 차단 검사 noop
+ 20 UsageBudgetAdvisor 누적 토큰 차단 검사 응답 토큰 누적
+ 30 ToolInvocationCounterAdvisor 툴 호출 횟수 차단 검사 응답 도구 호출 누적
+ 100 WorkflowLoggingAdvisor (Day 13) (로깅) 호출 시점 로그 응답 메타 로그

가드는 바깥쪽에서 호출 자체를 차단 하고, 로깅은 안쪽에서 통과된 호출만 기록 하는 흐름이에요.

가드 4 장이 모두 통과해야 비로소 워크플로 로깅이 떨어지고, 그 다음에야 실제 LLM 호출이 시작됩니다.

거꾸로 말하면 — 어느 가드 한 장에서라도 차단되면 비용 0, 시간 0, 외부 호출 0, 로깅조차 안 떨어집니다.

학생 데모에서 자율성이 폭주하지 않게 멈춰주는 안전선이 한 번에 4 겹으로 깔리는 셈이에요.

읽기 도구 vs 쓰기 도구 — 권한 분기가 필요한 곳

Day 11 에서 만든 도구 3 종 + 그 안에서 상태가 변경되는 부분 을 표로 한 번 분류해볼게요.

도구 부수효과 권한 분기
getCurrentWeather (WeatherTool) 읽기 전용 (stub 상수) 불필요
loadGameState (GameStateTool) 읽기 전용 (DB 조회) 불필요
getAffinity (AffinityTool) 읽기 전용 (메모리 조회) 불필요
saveGameState (GameStateTool) 쓰기 (DB INSERT) 필수

도구 4 개 중 상태를 바꾸는saveGameState 한 장입니다.

그런데 LLM 의 자율 호출 입장에서 보면 넷이 모두 같은 시그니처로 노출된 도구 함수 예요 — LLM 은 이게 읽기인지 쓰기인지 의 구분 없이 발화 의도에 맞으면 호출 합니다.

그래서 service 단(정확히는 @Tool 메서드 안)에서 호출자 권한 검사 한 단계가 필요한 거예요.

LLM 이 자율적으로 부르더라도 권한 없는 사용자 세션이면 도구 자체가 거부 하는 안전선입니다.

AgentToolPermissionChecker — 인터페이스 + 구현

권한 분기를 인터페이스 한 장으로 추상화합니다. 먼저 인터페이스.

package kr.spartaclub.aifriends.agent.security;

public interface AgentToolPermissionChecker {

    /**
     * 주어진 사용자 ID 가 *상태 변경 도구* (쓰기 도구) 를 호출할 권한을 가졌는지 판단한다.
     *
     * @param userId 호출 컨텍스트의 사용자 ID. 실무에서는 {@code SecurityContextHolder}
     *               에서 꺼낸 principal · subject 가 들어온다.
     * @return 권한이 있으면 {@code true}, 없으면 {@code false}.
     */
    boolean canInvokeWriteTool(String userId);
}

메서드 한 장만 있는 단순한 인터페이스예요.

boolean canInvokeWriteTool(String userId) — 사용자 ID 를 받아 권한 boolean 한 장을 돌려줍니다.

javadoc 에 실무에서는 SecurityContextHolder 와 연동 한다는 한 줄을 박아 학생이 자기 프로젝트로 옮길 때의 방향까지 잡아둡니다.

그리고 본 강의 범위의 가장 단순한 구현.

package kr.spartaclub.aifriends.agent.security;

import java.util.Set;

import org.springframework.stereotype.Component;

@Component
public class InMemoryAgentToolPermissionChecker implements AgentToolPermissionChecker {

    private final Set<String> allowedUserIds;

    public InMemoryAgentToolPermissionChecker() {
        this(Set.of("default"));
    }

    public InMemoryAgentToolPermissionChecker(Set<String> allowedUserIds) {
        this.allowedUserIds = Set.copyOf(allowedUserIds);
    }

    @Override
    public boolean canInvokeWriteTool(String userId) {
        if (userId == null) {
            return false;
        }
        return allowedUserIds.contains(userId);
    }
}

Set.contains(...) 한 줄로 권한을 판정하는 가장 단순한 형태예요.

기본 허용 사용자는 "default" 한 명 — 본 데모 환경에서 도구 호출 컨텍스트의 userId 가 늘 고정값으로 자리잡고 있다는 시뮬레이션입니다.

null 사용자 ID 는 권한 없음 으로 떨어뜨려 NPE 가 안 새도록 한 줄 잠가둡니다.

💡 튜터의 결론

권한 검사 로직 한 줄을 그냥 @Tool 메서드 안에 박지 않고 굳이 인터페이스 + 구현 두 장으로 쪼갠 이유가 있어요. 구현 교체 의 여지를 한 칸 비워둔 거예요. 본 강의 범위는 InMemory 한 줄로 충분하지만, 학생이 자기 프로젝트로 옮겨갈 때 거기엔 Spring Security 가 있고, DB 기반 RBAC 가 있고, 외부 IAM 이 있어요. 인터페이스가 없으면 @Tool 메서드 안의 if (...) 조건문을 자기 환경에 맞게 매번 갈아끼워야 하지만, 인터페이스 한 장만 끼워뒀어도 InMemoryAgentToolPermissionCheckerSecurityContextAgentToolPermissionChecker 로 갈아끼우면 끝나요. 학습 환경에서는 단순 구현, 운영 환경에서는 본격 구현 — 두 모드를 한 인터페이스로 묶는 구조입니다.

GameStateTool 보강 — @Tool 시그니처 보존 + 권한 분기 한 줄

이제 도구 쪽에 권한 검사를 박을 차례인데, 한 가지 조심할 게 있어요.

@Tool 메서드의 시그니처는 LLM 의 함수 스키마로 그대로 전달됩니다.

만약 saveGameState(...) 인자 리스트에 String userId 한 칸을 추가하면 — LLM 이 보는 함수 스키마가 바뀌고, Day 11 에서 학습한 LLM 응답 호환성이 깨져요.

그래서 메서드 시그니처는 그대로 두고 내부에서 권한 컨텍스트를 추출 하는 방향으로 갑니다.

GameStateTool 의 변경된 부분만 발췌해서 봅니다.

@Component
public class GameStateTool {

    /**
     * 본 강의 범위에서 사용하는 *고정 호출자 ID* 시뮬레이션. 실무에서는
     * {@code SecurityContextHolder.getContext().getAuthentication().getName()} 에서
     * 동적으로 꺼내 쓴다.
     */
    static final String CURRENT_USER_ID = "default";

    private final GameStateEntryRepository repository;
    private final AgentToolPermissionChecker permissionChecker;

    public GameStateTool(GameStateEntryRepository repository,
                         AgentToolPermissionChecker permissionChecker) {
        this.repository = repository;
        this.permissionChecker = permissionChecker;
    }

    @Tool(description = "현재 게임 상태(마지막 유저 메시지, 마지막 캐릭터 응답, 진행한 턴 수) 를 저장한다. "
            + "유저가 '여기까지 저장해줘', '오늘 대화 기억해놔' 같은 요청을 할 때 호출하라.")
    public void saveGameState(
            @ToolParam(description = "플레이어 ID. 캐릭터의 호출자 식별자.")
            Long playerId,
            @ToolParam(description = "마지막으로 유저가 보낸 메시지 원문")
            String lastUserMessage,
            @ToolParam(description = "마지막으로 캐릭터가 답한 메시지 원문")
            String lastAiMessage,
            @ToolParam(description = "지금까지 진행된 대화의 턴 수 (저장 시점 기준)")
            int turnCount
    ) {
        if (!permissionChecker.canInvokeWriteTool(CURRENT_USER_ID)) {
            throw new ToolPermissionDeniedException("saveGameState", CURRENT_USER_ID);
        }
        GameStateEntry entry = new GameStateEntry(
                null, playerId, lastUserMessage, lastAiMessage, turnCount, null);
        repository.save(entry);
    }

    // loadGameState 는 변경 없음 — 읽기 도구라 권한 분기 불필요
}

세 부분이 보태졌어요.

첫째, 클래스 내부 상수 CURRENT_USER_ID = "default" 한 줄.

본 강의 범위의 고정 호출자 ID 시뮬레이션입니다.

javadoc 에 실무에서는 SecurityContextHolder 에서 동적으로 꺼낸다 는 한 줄을 박아둡니다.

둘째, 생성자에 AgentToolPermissionChecker 가 한 인자 더 주입돼요.

셋째, saveGameState(...) 메서드 본문 첫 줄에 권한 검사 한 줄.

거부되면 ToolPermissionDeniedException 을 던집니다.

메서드 시그니처(인자 4 개) 는 그대로 보존됐죠.

LLM 함수 스키마가 변하지 않아 Day 11 의 호환성이 그대로 유지돼요.

ToolPermissionDeniedException 도 짧게 짚고 갑니다 — RuntimeException 직속 + 두 필드(toolName · userId) + 한 줄 메시지로, 앞서 본 세 예외(IterationLimitExceededException · CycleTimeoutExceededException · TokenBudgetExceededException) 와 같은 형태예요.

운영 모니터링에서 어떤 도구어떤 사용자 에 의해 거부됐는지 두 신호를 필드로 잡습니다.

본 강의 범위에서는 RuntimeException 직속이지만, 실무에서는 Spring Security 의 AccessDeniedException 으로 격상해 GlobalExceptionHandler 가 403 으로 매핑하는 방향으로 자랍니다.

guardAdvisors(...) — 4 부품을 한 묶음으로

이제 마지막 단계.

Step 7~9 에서 만든 4 advisor 를 한 사이클이 시작될 때마다 새 인스턴스로 묶어 돌려주는 정적 팩토리 한 장을 봅니다.

AgentChatClientConfig 에 들어가요.

/**
 * Day 14 Step 9 — Agent 자율성 경계 4 가드를 한 번에 묶어주는 정적 팩토리.
 *
 * <p>한 비즈니스 사이클이 시작될 때마다 호출해 *그 사이클 전용 advisor 인스턴스 4 종* 을
 * 새로 만들어 돌려준다. advisor 들이 {@code AtomicInteger / AtomicLong / AtomicReference}
 * 로 상태를 들고 있어 Singleton 빈으로 등록하면 모든 호출이 카운터를 공유하기 때문에
 * 사이클마다 새 인스턴스를 만드는 방식으로 박는다.</p>
 *
 * <p>호출 측 사용 패턴:
 * <pre>{@code
 * List<Advisor> guards = AgentChatClientConfig.guardAdvisors(
 *         5, Duration.ofSeconds(30), 8000L, 10);
 * String reply = chatClient.prompt()
 *         .user(userMessage)
 *         .advisors(guards.toArray(Advisor[]::new))
 *         .call()
 *         .content();
 * }</pre>
 * </p>
 *
 * @param maxIterations      한 사이클 안 ChatClient 최대 호출 횟수 (1 이상)
 * @param timeout            한 사이클 최대 경과 시간 (양수)
 * @param maxTotalTokens     한 사이클 누적 토큰 예산 (1 이상)
 * @param maxToolInvocations 한 사이클 누적 툴 호출 횟수 한도 (1 이상)
 * @return 4 advisor 의 새 인스턴스 묶음 (불변 리스트)
 */
public static List<Advisor> guardAdvisors(int maxIterations,
                                          Duration timeout,
                                          long maxTotalTokens,
                                          int maxToolInvocations) {
    return List.of(
            new MaxIterationsAdvisor(maxIterations),
            new DurationTimeoutAdvisor(timeout),
            new UsageBudgetAdvisor(maxTotalTokens),
            new ToolInvocationCounterAdvisor(maxToolInvocations)
    );
}

다섯 부분으로 풀어봅니다.

① 왜 빈이 아니라 정적 팩토리인가.

advisor 4 종이 모두 AtomicInteger · AtomicLong · AtomicReference 로 상태를 들고 있는 stateful 객체 예요.

Singleton 빈으로 등록하면 모든 호출 이 같은 카운터를 공유합니다.

한 사용자의 사이클이 끝나기 전에 다른 사용자의 사이클이 시작되면 — 카운터가 섞여 서로의 한도를 갉아먹어요.

사이클마다 새 인스턴스 4 종을 묶어 돌려주는 방향으로 가야 카운터 격리 가 보장됩니다.

② 사이클마다 새 인스턴스 4 종.

호출 한 번이 한 사이클 = 한 묶음의 advisor 를 받는 구조예요.

호출이 끝나면 advisor 인스턴스도 GC 로 사라집니다.

다음 호출은 또 새로운 네 인스턴스를 받고요.

카운터를 reset 하는 방식 보다 새 인스턴스를 만드는 방식 이 학습에선 더 직관적이라 후자로 갔어요.

③ 호출 측 사용 패턴.

javadoc 의 사용 예시처럼 chatClient.prompt().user(...).advisors(guards.toArray(...)).call().content() — 사이클을 명시적으로 시작하는 지점에서 가드 묶음을 받아 advisors 에 박고 호출합니다.

호출 시점에 가드를 명시적으로 거는 방식이에요.

④ order 는 advisor 별 getOrder() 가 책임.

정적 팩토리가 4 advisor 를 어떤 순서로 List.of(...) 에 넣든 상관없습니다.

Spring AI 의 advisor 체인이 각 advisor 의 getOrder() 값으로 자동 정렬해주거든요.

MaxIterations(+0) → DurationTimeout(+10) → UsageBudget(+20) → ToolInvocationCounter(+30) 순으로 바깥에서 안쪽으로 차단되는 흐름이 보장됩니다.

⑤ 4 파라미터의 운영 의미.

maxIterations (호출 천장) / timeout (시간 천장) / maxTotalTokens (비용 천장) / maxToolInvocations (외부 호출 천장) — 네 인자가 각각 어떤 폭주 시나리오를 막는지 를 운영팀이 한 줄 정책으로 잡을 수 있어요.

예를 들어 "한 사용자 사이클은 5 호출 · 30 초 · 8000 토큰 · 10 툴 호출 이내" 같은 룰이 guardAdvisors(5, Duration.ofSeconds(30), 8000L, 10) 한 줄로 정확히 정리됩니다.

본 정적 팩토리가 4 advisor 를 한 묶음으로 돌리는 동작은 코드베이스의 AgentGuardAdvisorsIntegrationTest 가 4 케이스로 검증해뒀어요 — 정상 통과 / 호출 횟수 차단 / 토큰 차단 / 툴 호출 차단의 4 시나리오가 각각 통과되는 모습이 들어 있습니다.

🙋 날카로운 질문 타임

"4 advisor 가 ChatClient 빈에 defaultAdvisors(...) 로 등록되지 않고 호출 시점에 .advisors(...) 로 박히는 흐름이잖아요. 그러면 ChatClient 빈 자체는 가드 없는 상태고, 다른 곳에서 호출되면 어떻게 되나요? 모든 호출에 가드를 강제할 방법은 없나요?"

핵심을 짚어주셨어요. 본 강의 방침은 가드는 사이클을 명시적으로 시작하는 지점에만 건다 입니다. 사이클이 아닌 단순 시연 호출이나 일회성 디버그 호출까지 가드를 강제하면 가드 4 부품의 인스턴스 생성 비용 이 모든 호출에 누적돼요 — 학습 환경에선 이게 더 무거운 부담입니다. 모든 호출에 가드를 강제하고 싶다면 두 갈래 길이 있어요. 첫째, advisor 를 Spring 빈으로 등록 + prototype scope 로 잡아 매 주입마다 새 인스턴스를 받게 하는 방법. Spring 빈 라이프사이클에 prototype 으로 두는 방식인데, ChatClient 빈 등록 시점에 advisor 가 함께 주입되어야 해서 빈 의존성 그래프가 한 단계 더 복잡 해져요. 둘째, ChatClient 빈을 만들 때 advisor 도 매 빈 생성 시점에 새 인스턴스로 박는 방법 — 그런데 ChatClient 자체가 보통 Singleton 이라 이 방법은 잘 안 쓰여요. 본 강의는 호출 시점에 매번 새 인스턴스 묶음을 만드는 가장 단순한 방향으로 갔습니다. Day 19 Harness 에 가면 Spring AI Agent Client 가 선언적으로 이 영역을 처리해줘서 학생이 직접 정적 팩토리를 부를 일이 사라져요 — 오늘 직접 짠 구조가 Day 19 의 한 줄 설정으로 자라는 모습입니다.

"guardAdvisors(...) 가 정적 메서드인데 Spring 빈 주입을 못 받잖아요. 만약 권한 스코프까지 advisor 로 두고 싶어지면 — AgentToolPermissionChecker 빈을 advisor 가 들고 있어야 할 텐데, 정적 팩토리로 해결되나요?"

좋은 후속 질문이에요. 본 정적 팩토리는 외부 의존성이 없는 4 advisor 의 새 인스턴스 생성만 책임져서 단순함이 가능했어요 — int · Duration · long · int 네 가지 원시값만 받아서요. 만약 권한 스코프까지 advisor 로 격상하고 싶으면 — AgentToolPermissionChecker 빈을 인자로 받는 시그니처로 자라야 합니다. 예를 들면 guardAdvisors(int, Duration, long, int, AgentToolPermissionChecker) 처럼요. 그런데 본 강의 범위에서는 도구 함수 안의 if (!permissionChecker.canInvokeWriteTool(...)) 한 줄로 끝나서 advisor 까지 격상하지 않았어요. 권한이 advisor 가 아니라 도구 안 에 들어간 이유도 같아요 — LLM 의 자율 호출 시점에 도구 함수 안이 권한을 검사하기 가장 자연스럽거든요. Day 19 Harness 에서는 이 영역도 Spring AI Agent Client 의 선언적 권한 매핑 으로 자라요 — 도구별 권한 어노테이션 한 줄로 잡히는 흐름.

마무리

오늘의 Step 한 줄 요약

Step 한 줄 요약
Step 1 Workflow ↔ Agent 스펙트럼 오른쪽 두 패턴 — 분기까지 LLM 자율 의 영역
Step 2 OrchestratorMasterService + 화이트리스트 필터 + priority 정렬 — service 단 두 안전선
Step 3 CharacterWorkerService + Map<String, ChatClient> 자동 주입 — Day 11 도구 3 종 흡수
Step 4 GameOrchestrationService + CompletableFuture.allOf — 병렬 호출 + priority 합류
Step 5 CharacterGenerationService + CharacterEvaluatorService + EvaluationVerdict enum 자물쇠
Step 6 CharacterOptimizationService — 수동 카운터 + suggestion 흡수
Step 7 MaxIterationsAdvisor + OWASP LLM10 매핑 — BaseAdvisor 의 가드 첫 부품
Step 8 DurationTimeoutAdvisor + UsageBudgetAdvisor — 시간·토큰 두 축
Step 9 ToolInvocationCounterAdvisor + MCP tools.execute + guardAdvisors(...) 팩토리

Day 19 Harness 로 자라는 결

Day 19 Harness 에 가면 오늘 직접 짠 advisor 4 부품이 Spring AI Agent Client 의 선언적 가드로 자라요.

오늘 손으로 짠 4 부품Day 19 의 한 줄 설정으로 자라는 흐름이에요.

직접 짜본 채로 한 번 가면 — 선언적 설정 한 줄 뒤에 어떤 상태 관리·예외 분기·order 위계가 숨어 있는지 학생이 눈에 보이는 상태로 가져갈 수 있어요.

추상화의 내부를 알고 쓰는 것과 모르고 쓰는 것의 차이가 거기서 갈립니다.

다음 시간 Day 15 — RAG

다음 시간 Day 15 부터는 흐름이 한 번 크게 바뀝니다.

RAG (Retrieval-Augmented Generation) 이에요.

벡터 임베딩 · pgvector · VectorStore 라는 새 개념 셋이 한꺼번에 들어오고, Agent 가 외부 지식 을 끌어와 응답에 녹이는 흐름의 시작입니다.

오늘까지의 Agent 가 LLM 의 내장 지식 + 도구 호출 로 답했다면, 다음 시간부터의 Agent 는 외부 지식 베이스 를 한 단계 더 들고 옵니다.

키워드 미리 잡아두면.

임베딩 벡터 · 유사도 검색 · VectorStore · 문서 청킹 네 가지예요.

다음 시간에 직접 짜봐요.

과제

오늘의 과제는 Agent 2 패턴 (Orchestrator-Workers + Evaluator-Optimizer) + 가드 4 advisor + 권한 스코프 를 한 번 더 손에 익히는 단계예요. 세 과제는 난이도 사다리 로 짜여 있어요 — ⭐ 캐릭터 빈 한 개 추가 (Step 3 의 Map<String, ChatClient> 자동 주입 거울) → ⭐⭐ 가드 4 부품을 실제 사이클에 통합 (Step 9 의 guardAdvisors(...) 거울) → ⭐⭐⭐ Evaluator-Optimizer 에 사람 검수 채널 추가 (Step 5~6 의 한 단계 깊은 작업) 의 결로 한 단계씩 자라요.

💡 과제 작업 시 공통 가이드

  • 본 강의의 표준 응답 패턴 (ApiResponse<T> 래핑) 을 그대로 유지하세요. 새 엔드포인트의 정상 응답은 ResponseEntity<ApiResponse<T>> 로 반환.
  • 동작 검증은 코드베이스에서 박고 — 답안 문서에는 동작 의미 한 줄 포인터 만 (예: "5 번째 호출 시도가 IterationLimitExceededException 으로 차단된다").
  • 외부 그래프 DSL 도입 금지 — LangGraph / OpenAI Agents SDK / Google ADK 로 풀지 마세요. Spring AI 1.1.x ChatClient + 평범한 Java + 오늘 짠 advisor 4 부품 만으로.
  • Day 15 RAG / Day 19 Harness 는 다음 단계. 본 과제에서는 오늘 9 부품 만 — 깊이를 한 단계씩만 자라게 하세요.
  • 본 Day 의 디폴트 — 과제 1·2 는 권장 / 과제 3 은 선택. 시간이 부족한 학생은 1·2 까지만 짜도 손맛은 충분히 익혀져요.
  • 외부 유료 API 키 일절 불필요 — Ollama 로컬 또는 Gemini 무료 티어로 전부 돌아가요.

[구현 1] 새 캐릭터 NOA 워커 ChatClient 추가 — Map<String, ChatClient> 자동 주입의 거울 ⭐

배경 시나리오

ai-friends 의 기획자가 가벼운 작업 을 들고 왔어요.

"튜터님, Step 3 에서 짠 ARIA / REX / LUNA 3 명의 워커 묶음 — 운영 한 달 후 4 번째 캐릭터 가 자연스럽게 자라고 있어요. 유머·장난기 가득한 톤NOA 한 명만 추가해주세요. Step 3 의 Map<String, ChatClient> 자동 주입이 정말로 코드 변경 0 + 빈 등록 한 군데만 으로 새 캐릭터가 살아나는지 — 손맛으로 확인해보는 거예요. CharacterWorkerService 는 손도 안 대고 새 캐릭터가 그룹 대화방에 등장할 수 있다면 — 자동 주입이 단단한 거예요."

Step 3 의 Map 자동 주입 이 — 새 캐릭터 한 명 추가가 빈 등록 한 군데로 떨어지는지 직접 확인하는 단계예요. 새 service 도, 새 controller 도, 새 record 도 없이 오늘 짠 자동 주입의 손대는 곳이 정확히 한 군데임을 한 번 더 확인하는 자리.

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

  1. Map<String, ChatClient> 자동 주입의 두 번째 만남 — Step 3 (워커 빈 3 종 + 자동 주입 짜기) 에서 짠 Map 주입 패턴이 과제 1 (새 워커 빈 1 종 추가) 에서 한 번 더 익혀지는 단계예요. 빈 이름 컨벤션 ({소문자 characterId}WorkerChatClient) → Map key 매핑 → 그룹 대화방 등장 의 흐름이 새 캐릭터 한 명을 추가할 때 정확히 어떻게 동작하는지 손에 익혀져요.
  2. CharacterWorkerService 무수정 원칙의 손맛 — 본 강의의 핵심 메시지 "새 캐릭터 N 명까지 service 코드는 그대로" 가 정말로 무수정으로 떨어지는지 확인하는 단계. Map<String, ChatClient> 자동 주입의 본질이 확장에 열려있고 수정에 닫혀있는 원칙의 살아있는 거울이에요.
  3. 캐릭터별 도구 묶음의 차이 (선택) — 확장 단계에서 NOA 에게는 호감도 도구만 / 날씨·게임은 제외 같은 구성을 짜보면 캐릭터마다 도구 묶음이 다른 패턴이 손에 익혀져요. Day 15~16 RAG 에서 캐릭터별 지식 베이스 분리 로 자라는 복선이에요.

✅ 요구사항

  1. AgentChatClientConfignoaWorkerChatClient 빈 한 개 추가 — 빈 이름 컨벤션 정확히 noaWorkerChatClient (소문자 characterId + WorkerChatClient 접미사). 기존 ariaWorkerChatClient / rexWorkerChatClient / lunaWorkerChatClient 의 시그니처를 그대로 복사하고 system 프롬프트만 NOA 톤으로 갈아끼우세요.
  2. NOA 의 페르소나 — 유머·장난기 가득한 톤 — system 프롬프트는 본인이 자유롭게 설계하세요. 예: "당신은 NOA. ai-friends 의 장난꾸러기 캐릭터. 답변에 농담·말장난·이모지를 자연스럽게 섞고 / 무거운 주제도 가볍게 풀어내는 톤. 단 사용자가 진지한 자리는 한 박자 늦춰 정중함을 회수." 같은 방향.
  3. Day 11 도구 3 종 등록 + WorkflowLoggingAdvisor — 기존 3 워커 빈과 동일하게 WeatherTool / GameStateTool / AffinityTool 등록 + WorkflowLoggingAdvisor.
  4. CharacterWorkerService 무수정 — 절대 손대지 마세요. 본 과제의 핵심 검증 포인트예요.
  5. 시연 — 그룹 대화방에서 NOA 활성화OrchestratorMasterService.distribute(...) 호출 시 activeCharacterIds 인자에 "NOA" 만 추가하면 그룹 대화방에 살아나야 정상. 예시 입력 "오늘 날씨 어때? 그리고 농담 좀 해줘" — ARIA 가 날씨 분배, NOA 가 농담 분배로 떨어지는지 응답에서 확인.
  6. 산출물(1) ai-friends 코드베이스 별도 브랜치 (day14-assignment1-noa-worker) 위 한 커밋 + (2) 마크다운 한 페이지 (빈 등록 변경 라인 인용 + curl 시연 결과 한 장 + CharacterWorkerService 가 진짜로 무수정인지 git diff 결과 한 줄).

확인 방법

# 1) Day 14 박제 브랜치 위에서 분기
cd lectures/spring-ai/lecture-source-code/ai-friends
git checkout day14-agent-patterns
git checkout -b day14-assignment1-noa-worker

# 2) AgentChatClientConfig 에 noaWorkerChatClient 빈 + system 프롬프트 추가 → 컴파일
./gradlew compileJava

# 3) 앱 띄우기
./run.sh

# 4) curl 시연 — 활성 캐릭터에 NOA 포함
curl -X POST http://localhost:8080/api/agent/group-dialogue \
  -H 'Content-Type: application/json' \
  -d '{"userMessage":"오늘 날씨 어때? 그리고 농담 좀 해줘","activeCharacterIds":["ARIA","NOA"]}'

# 5) 응답에 NOA 가 한 명으로 포함됐는지 / 농담 톤이 살아있는지 확인 후 커밋
git diff --stat src/main/java/.../CharacterWorkerService.java   # 0 줄이어야 정상
git add . && git commit -m "feat: add NOA worker ChatClient bean (Day 14 assignment 1)"

💡 힌트

  • Step 3 의 ariaWorkerChatClient 빈 시그니처를 그대로 복사하세요.@Bean("ariaWorkerChatClient") / ChatClient.Builder 주입 / defaultSystem(...) / defaultTools(...) / defaultAdvisors(workflowLoggingAdvisor) — 이 골격을 복사한 뒤 빈 이름과 system 프롬프트만 갈아끼우는 작업이에요.
  • 빈 이름 컨벤션 — 정확히 noaWorkerChatClient. Map<String, ChatClient> 자동 주입은 빈 이름을 Map key 로 사용 하는데, CharacterWorkerServicecharacterId 를 소문자 + WorkerChatClient 패턴으로 조회해요. 컨벤션 한 글자라도 어긋나면 Map 조회가 실패해서 IllegalArgumentException 으로 떨어집니다.
  • NOA 의 system 프롬프트 — 한 단락 정도 — 너무 길게 쓰면 톤이 흐려지고, 너무 짧으면 다른 캐릭터와 구분이 안 가요. 페르소나 한 줄 + 톤 가이드 한 줄 + 답변 형식 한 줄 정도가 충분.
  • 확장 (선택) — 도구 묶음 부분만 — NOA 에게는 AffinityTool 만 등록하고 / WeatherTool / GameStateTool 은 제외하는 구성을 짜보면 — 캐릭터마다 다른 도구 묶음 의 패턴이 익혀져요. 그러면 "오늘 날씨 어때?" 입력이 NOA 한 명에게만 가면 — 도구를 못 부르고 LLM 내장 지식으로만 답하는 흐름이 자연스럽게 펼쳐져요.

제약 / 금지

  • CharacterWorkerService 코드 수정 금지 — 본 과제의 핵심 검증 포인트예요. git diff 가 0 줄이어야 정상.
  • 새 service / 새 controller 추가 금지빈 등록 한 군데만.
  • OrchestratorMasterService 의 system 프롬프트 수정 금지 — 마스터의 분배는 Step 2 의 모습 그대로. 활성 캐릭터 목록만 인자로 던지면 마스터가 자동으로 NOA 를 분배 후보에 포함.
  • 외부 그래프 DSL / LangGraph 로 풀기 금지Spring AI ChatClient + 빈 등록 만으로.

[구현 2] ⭐⭐ 가드 4 부품을 실제 사이클에 박기 — GameOrchestrationService 통합 ⭐⭐

배경 시나리오

ai-friends 의 SRE 가 한 단계 깊은 작업 을 들고 왔어요.

"튜터님, Step 9 의 guardAdvisors(...) 정적 팩토리 — 4 advisor 를 한 묶음으로 돌리는 구조가 단단했어요. 그런데 운영에서 보니까 — GameOrchestrationService.orchestrate(...) 가 가드 advisor 가 한 줄도 안 자리잡은 채로 워커 호출을 굴리고 있어요. 그룹 대화 한 사이클이 3 워커 병렬 호출 인데 — 한 워커가 무한 루프에 빠지거나 / 토큰 폭주가 일어나면 — 전체 사이클이 폭주 해요. Step 9 의 guardAdvisors(...)실제 비즈니스 사이클 에 박는 단계 — 가드의 호출 측 을 직접 짜보는 단계예요." ⭐⭐

Step 9 의 정적 팩토리 가 — 실제 사이클에 박히는 모양 으로 한 단계 자라는 차례예요. 호출 시점에 advisor 묶음을 받고 / .advisors(...) 로 두고 / 사이클이 끝나면 인스턴스가 GC 의 흐름. 가드가 단순 시연에서 운영 코드로 자라는 단계.

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

  1. guardAdvisors(...) 의 호출 측 손맛 — Step 9 에서는 정적 팩토리의 구현만 짰어요. 호출 측에서 어떻게 받고 / 어떻게 advisor 배열로 던지고 / 사이클 경계를 어떻게 정의하는지 는 본 과제의 영역이에요. 정적 팩토리의 시그니처 (int / Duration / long / int) → 실제 4 파라미터 결정 → .advisors(guards.toArray(Advisor[]::new)) 한 줄 의 흐름이 손에 익혀져요.
  2. 사이클 경계의 정의한 사이클이 어디서 시작해서 어디서 끝나는가 가 본 과제의 핵심 결정이에요. GameOrchestrationService.orchestrate(...) 한 호출이 한 사이클 인지 / 3 워커 각자가 한 사이클 인지 — 카운터 격리의 단위 가 어떻게 잡혀야 하는지의 사고가 들어와요. 본 과제의 결은 orchestrate(...) 호출 = 한 사이클, 3 워커가 한 묶음의 advisor 를 공유하는 방향.
  3. 운영 가드 파라미터의 첫 결정5 호출 / 30 초 / 8000 토큰 / 10 툴 호출 — 본 과제에서 직접 정할 네 숫자가 우리 팀의 그룹 대화 시나리오 에 정말 맞는지의 사고. 3 워커 × 평균 1 호출 = 3 호출 → 한도 5 는 여유 / 그룹 대화 평균 응답 시간 ~10 초 → 한도 30 초 는 안전선 같은 결정을 손으로 정해보는 자리예요.

✅ 요구사항

  1. GameOrchestrationService.orchestrate(...) 수정 — 메서드 시작 부분에 AgentChatClientConfig.guardAdvisors(5, Duration.ofSeconds(30), 8000L, 10) 한 줄로 advisor 묶음을 받기.
  2. 워커 ChatClient 호출에 .advisors(guards.toArray(Advisor[]::new)) 한 줄 박기 — 기존 워커 호출 (workerChatClient.prompt().user(...).call().content()) 에 advisor 박는 한 줄을 끼워넣어요. CharacterWorkerService 의 호출 지점에 박히는 자리이며, Step 3 의 시그니처를 안 깨도록 orchestrate(...) 시점에 advisor 묶음을 들고 service 로 넘기는 방식.
  3. CharacterWorkerService 의 시그니처 확장respond(...) 메서드에 List<Advisor> guards 파라미터를 한 개 추가. (advisor 자체는 호출 측의 인스턴스 가 흘러들어와야 카운터 격리가 보장돼요.) 본 과제는 service 시그니처 확장이 허용되는 케이스 예요 — 과제 1 의 무수정 조건과 방향이 다름.
  4. 단위 테스트IterationLimitExceededException 가 호출 5 회 차에 차단되는 시나리오를 코드베이스 (src/test/java/.../GameOrchestrationServiceTest.java) 에 박기. mock 으로 워커가 5 회째 호출에서 advisor 차단되는 결. 본 단위 테스트는 코드베이스에만 — 강의교안 본문엔 넣지 않아요.
  5. 운영 가드 4 파라미터 — 결정 근거 한 줄 — 답안 마크다운에 왜 5 / 30 초 / 8000 토큰 / 10 툴 호출 인가 의 결정 근거 한 줄씩. 예: "3 워커 × 평균 1 호출 = 3 → 한도 5 는 여유".
  6. 확장 (선택) — application.yml 프로퍼티 분리@ConfigurationProperties(prefix = "agent.guard")AgentGuardProperties record 한 개를 만들고 agent.guard.max-iterations: 5 / agent.guard.timeout: 30s 등의 키로 4 파라미터를 외부화. 운영팀이 코드 배포 없이 가드 한도를 조정하는 구조.
  7. 산출물(1) ai-friends 코드베이스 별도 브랜치 (day14-assignment2-guards-integration) 위 한 커밋 + (2) 마크다운 한 페이지 (변경 라인 인용 + 단위 테스트 통과 결과 한 줄 + 가드 4 파라미터 결정 근거).

확인 방법

# 1) 분기
cd lectures/spring-ai/lecture-source-code/ai-friends
git checkout day14-agent-patterns
git checkout -b day14-assignment2-guards-integration

# 2) orchestrate(...) / CharacterWorkerService.respond(...) 시그니처 확장 → 컴파일
./gradlew compileJava

# 3) 단위 테스트 실행
./gradlew test --tests "*.GameOrchestrationServiceTest"

# 4) 앱 띄우기 + 통합 시연
./run.sh
curl -X POST http://localhost:8080/api/agent/group-dialogue \
  -H 'Content-Type: application/json' \
  -d '{"userMessage":"안녕 다들, 오늘 어땠어?","activeCharacterIds":["ARIA","REX","LUNA"]}'

# 5) 응답이 정상 / advisor 카운터 자리가 사이클 끝에 reset 되는지 확인 후 커밋
git add . && git commit -m "feat: integrate guardAdvisors into GameOrchestrationService (Day 14 assignment 2)"

💡 힌트

  • guardAdvisors(...) 의 반환 타입 — List<Advisor>Advisororg.springframework.ai.chat.client.advisor.api.Advisor 타입. ChatClient 의 .advisors(...) 메서드는 vararg Advisor... 를 받으니 — guards.toArray(Advisor[]::new) 로 배열 변환 한 줄 필요해요.
  • 사이클 인스턴스 — 한 묶음을 3 워커가 공유orchestrate(...) 한 번 호출에 한 번만 guardAdvisors(...) 를 부르세요. 3 워커 호출이 같은 advisor 인스턴스 를 공유해야 — 3 워커 합산 5 호출 한도 / 3 워커 합산 8000 토큰 한도 의 셈이 맞아요. 워커마다 따로 부르면 — 워커당 5 호출 / 워커당 8000 토큰 으로 카운터가 격리돼서 합산 한도의 의미가 깨져요.
  • service 시그니처 확장 자리 — respond(WorkerAssignment, List<Advisor>) — Step 3 의 respond(WorkerAssignment) 시그니처에 List<Advisor> 한 자리 추가. Day 11 도구 호출의 흐름 은 그대로 보존돼요 — advisor 가 도구 호출 위에 가드를 한 단계 더 덮는 모양.
  • 단위 테스트 — MaxIterationsAdvisor 의 본 동작 시연 — 5 호출 차에 IterationLimitExceededException 이 박히는 흐름을 @MockitoBean 으로 가짜 ChatClient 응답 4 회 정상 / 5 회째 호출에서 advisor 차단되는 시나리오로 검증. 본 단위 테스트는 코드베이스에만 박고 강의 본문엔 인용하지 않아요.
  • 확장 자리 — @ConfigurationPropertiesrecord AgentGuardProperties(int maxIterations, Duration timeout, long maxTotalTokens, int maxToolInvocations) {} 한 자리 + @EnableConfigurationProperties(AgentGuardProperties.class) 한 줄. application.yml 한 줄 수정으로 가드 한도가 자라는 구조.

제약 / 금지

  • MaxIterationsAdvisor / DurationTimeoutAdvisor / UsageBudgetAdvisor / ToolInvocationCounterAdvisor 자체 수정 금지 — 본 과제는 호출 측 통합. advisor 본체는 Step 7~9 의 모습 그대로 보존.
  • AgentChatClientConfig.guardAdvisors(...) 시그니처 수정 금지 — 본 정적 팩토리의 4 파라미터 결은 그대로. 어디서 부르고 / 어떻게 워커로 흘려보내는지 만 손대요.
  • 워커 ChatClient 빈에 defaultAdvisors(...) 로 가드 박기 금지 — advisor 가 stateful 한데 빈에 default 로 박으면 모든 호출이 카운터를 공유해 카운터 격리가 깨져요. 호출 시점에 .advisors(...) 로 매번 박는 결 만 정답.
  • 새 advisor 종 추가 금지 — Day 14 는 4 advisor 의 영역. 5 번째 advisor 추가는 Day 19 Harness 의 영역.

[구현 3] ⭐⭐⭐ Evaluator-Optimizer 에 사람 검수 채널 한 자리 추가 — confidence 임계값 분기 (선택) ⭐⭐⭐

배경 시나리오

ai-friends 의 운영팀이 한 단계 더 깊은 작업 을 들고 왔어요.

"튜터님, Step 5~6 의 Evaluator-Optimizer — 캐릭터 카피 생성 + 평가자 LLM + Optimizer 루프의 구조가 단단했어요. 그런데 운영 한 달에 평가자 LLM 의 환각 이 자연 발생하고 있어요. 1 번째 시도에 PASSED 가 떨어지는 케이스 — 너무 빨리 통과해서 진짜 품질이 맞는지 의심 스러운 상황. 평가자가 자기 확신도 (confidence) 를 함께 던지게 하고 / 그 수치가 임계값 아래면 사람 검수 큐로 흘려보내는 분기 — Evaluator-Optimizer 의 안전망 한 단계 깊은 자리 를 박아주세요. Day 19 Harness 로 자라는 복선 도 함께요." ⭐⭐⭐

Step 5~6 의 3 값 enum 자물쇠 가 — confidence 임계값 으로 한 단계 깊어지는 차례예요. 평가자의 자기 의심 → 사람 검수 큐 → 운영팀 검수 흐름 의 시작이며, 평가자 LLM 도 환각을 낼 수 있다는 사실에 대한 첫 안전망.

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

  1. 평가자 LLM 의 자기 환각 안전망 — Step 5 에서 enum 3 값 자물쇠 가 LLM 환각의 첫 안전망이었어요. 그런데 enum 값 자체는 맞는데 / 그 enum 값의 적합성 판정이 환각 인 경우는 enum 자물쇠로 못 막아요. "평가자가 자기 확신도를 함께 던지면 / 임계값 아래는 사람 검수로 흘리는" 분기가 enum 자물쇠 밖의 두 번째 안전선 — 자율성과 안전선의 짝패가 한 단계 깊어지는 차례예요.
  2. 사람 검수 채널의 첫 등장Day 19 Harness 의 휴먼-인-더-루프 가 본 과제에서 작은 단위로 처음 등장 합니다. 큐 한 개 / Optional 한 개 / 임계값 하나 만으로 — 완전 자율 vs 사람 게이트 의 분기점이 손에 익혀져요.
  3. EvaluationFeedback record 확장의 호환성 감각 — 기존 record 에 Float confidence 한 필드가 추가될 때 기존 코드 (system 프롬프트 / 직렬화 / null 처리) 에 어떤 방식으로 흘러야 깨지지 않는지의 사고. record 확장의 손맛 이 단단해지는 단계.

✅ 요구사항

  1. HumanReviewRequest record 추가kr.spartaclub.aifriends.agent.dto.HumanReviewRequestrecord HumanReviewRequest(String characterId, String draftNarration, EvaluationVerdict verdict, Float confidence, String reason) {} 형태. characterId / 초안 본문 / 평가자 판정 / 평가자 확신도 / 사유 의 5 필드.
  2. EvaluationFeedback record 확장 — Float confidence 필드 추가 — 기존 record (String reason / String suggestion 등) 에 Float confidence 한 칸 추가. 값 범위 0.0 ~ 1.0. 기존 직렬화 호환을 위해 Nullable 로 두고 — null 일 땐 1.0 으로 자연스럽게 흡수.
  3. CharacterEvaluatorService 의 system 프롬프트 확장 — 기존 평가자 프롬프트에 "verdict / reason / suggestion / confidence (0.0~1.0, 자기 확신도)" 네 항목을 요청. 0.5 미만은 자기 판정이 불확실 / 0.9 이상은 매우 확신 의 기준을 자연어로 가이드.
  4. HumanReviewQueue 단순 InMemory 한 장@Component 의 단순 클래스 한 장 (HumanReviewQueue) + 내부에 Queue<HumanReviewRequest> 또는 List<HumanReviewRequest>. 메서드 두 개 — enqueue(HumanReviewRequest) / pollAll().
  5. CharacterOptimizationService.optimize(...) 분기 추가EvaluationVerdict.PASSED 분기 직전에 confidence < 0.7 분기 한 줄. 미달이면 humanReviewQueue.enqueue(...) 로 흘려보내고, OptimizationResult 의 종료 사유에 세 번째 값 HUMAN_REVIEW_REQUIRED 추가는 금지 (enum 자체 수정 금지). 대신 PASSED 로 흡수 시키되 큐에 row 한 개가 남는 구조.
  6. 단위 테스트confidence 0.5 의 평가자 판정 이 사람 검수 큐에 row 한 개를 추가하는 흐름을 코드베이스 테스트로 박기. 코드베이스에만 — 강의 본문엔 넣지 않아요.
  7. 임계값 결정 — 0.7 의 결정 근거 — 답안 마크다운에 왜 0.7 인가 의 결정 근거 한 줄. 너무 낮으면 큐가 안 차고 안전망이 무력 / 너무 높으면 큐가 폭주해 사람 검수 대기열이 운영을 막음 의 균형점.
  8. 확장 (선택) — HumanReviewEntry JPA Entity — 큐를 영속 (HumanReviewEntry JPA Entity + HumanReviewRepository) 으로 두고 운영 콘솔에서 검수 결과를 다시 흡수하는 채널까지. Day 19 Harness 의 휴먼-인-더-루프 를 미리 익혀보는 단계.
  9. 산출물(1) ai-friends 코드베이스 별도 브랜치 (day14-assignment3-human-review) 위 한 커밋 + (2) 마크다운 한 페이지 (변경 라인 인용 + 단위 테스트 결과 + confidence 임계값 결정 근거).

확인 방법

# 1) 분기
cd lectures/spring-ai/lecture-source-code/ai-friends
git checkout day14-agent-patterns
git checkout -b day14-assignment3-human-review

# 2) HumanReviewRequest / EvaluationFeedback 확장 / HumanReviewQueue / optimize 분기 → 컴파일
./gradlew compileJava

# 3) 단위 테스트 실행
./gradlew test --tests "*.CharacterOptimizationServiceTest"

# 4) 앱 띄우기 + 통합 시연
./run.sh
curl -X POST http://localhost:8080/api/agent/character/optimize \
  -H 'Content-Type: application/json' \
  -d '{"characterId":"ARIA","draftNarration":"...","maxRounds":3}'

# 5) 응답 + HumanReviewQueue 의 row 자리 확인 후 커밋
git add . && git commit -m "feat: add human review channel with confidence threshold (Day 14 assignment 3)"

💡 힌트

  • Step 5 의 EvaluationFeedback record 시그니처를 다시 펼쳐보세요.Float confidence 한 필드 추가가 어디서 깨지는지 의 자리를 미리 확인. (1) record 자체 — 새 자리 한 필드 추가는 안전. (2) system 프롬프트 — "confidence (0.0~1.0)" 자리 한 줄 추가. (3) .entity(EvaluationFeedback.class) 의 동작 — Spring AI 의 BeanOutputConverter 가 자동으로 새 필드를 흡수. (4) null 처리 — 예전 평가자 응답엔 confidence 가 없을 수 있으니Optional 또는 기본값 흡수 의 방향.
  • HumanReviewQueue — 단순한 자리부터ConcurrentLinkedQueue<HumanReviewRequest> 한 자리면 충분. enqueue / pollAll 두 메서드. Spring @Component 로 빈 등록 + CharacterOptimizationService 에 생성자 주입.
  • confidence < 0.7 분기 위치EvaluationVerdict.PASSED 분기 직전. 코드 모양: if (feedback.verdict() == PASSED && feedback.confidence() != null && feedback.confidence() < 0.7) { humanReviewQueue.enqueue(...); } — 여전히 PASSED 로 흡수하되 큐에 row 한 개가 남는 구조. Optimizer 루프는 그대로 종료 하고 큐는 운영팀이 따로 확인.
  • 임계값 0.7 의 결정 근거0.5 → 거의 모든 평가가 큐에 갇히는 모양 / 0.9 → 큐에 한 달에 한 자리만 자라는 모양 / 0.7 → 의심스러운 PASSED 만 적당히 자리하는 균형. 실제 운영에선 큐 적체 모니터링 → 임계값 조정 의 피드백 루프가 자리해요.
  • 확장 자리 — JPA EntityHumanReviewEntry Entity + HumanReviewRepository 형태로 영속화. 운영 콘솔 (@GetMapping("/admin/human-review/pending")) 에서 큐를 조회 + 검수 결과를 다시 흡수하는 채널 의 모양. Day 19 Harness 의 휴먼-인-더-루프 의 첫 등장.

제약 / 금지

  • EvaluationVerdict enum 자체 수정 금지 — 새 enum 값 (HUMAN_REVIEW_REQUIRED 등) 추가 금지. Step 5 의 3 값 자물쇠 의 결은 그대로 보존. 분기는 confidence 임계값 으로만.
  • 외부 워크플로 엔진 (Camunda / Temporal) 도입 금지 — 본 과제는 InMemory 큐 한 자리 의 단계. 워크플로 엔진 의 영역은 본 강의 범위 밖.
  • CharacterEvaluatorService 의 system 프롬프트 외에 추가 LLM 호출 금지 — 본 과제는 평가자 한 번 호출 + 그 응답의 confidence 필드 까지만. 2 번째 평가자 LLM (jury)생각해볼 주제 2 의 영역이에요.
  • 임계값 0.7 외 자유 변경 시 결정 근거 필수 — 자유롭게 조정 가능하지만 답안 마크다운에 왜 그 수치인지 의 결정 근거가 들어 있어야 정상.
생각해볼 주제

이 섹션의 목적 — 정답이 정해져 있지 않은 질문들이에요. 오늘 짠 Agent 2 패턴 (Orchestrator-Workers + Evaluator-Optimizer) / 가드 4 부품 / 권한 스코프 의 결정들을 한 발 떨어져 바라보고, "왜 자율성을 이만큼 열었지?""이 선택의 한계는 어디지?" 를 사고하는 단계예요. 면접에서도 자주 등장하는 토픽들이라 문장으로 적어보세요. 머릿속 생각과 글로 적은 생각은 다릅니다.

주제 1 — Workflow 우선 / Agent 보수 — 우리 팀의 자율성 도입 분기점은 어디인가

지난 시간 Day 13 의 왼쪽 세 패턴 (Prompt Chaining / Routing / Parallelization) 과 오늘 Day 14 의 오른쪽 두 패턴 (Orchestrator-Workers / Evaluator-Optimizer) — 5 패턴이 자율성 스펙트럼 위에 한 줄로 정렬됐어요. 그리고 Anthropic 의 Building Effective Agents (2024-12) 가 들고 온 한 줄 "가능한 한 Workflow 로 / 자율성은 진짜 필요한 곳에만" 이 지난 1~2 년 사이 업계 표준으로 자리했어요. Spring AI Reference (1.1.x) 도 같은 방향이고, 우리 강의도 왼쪽 세 패턴부터 짜고 / 오른쪽 두 패턴은 한 단계 깊은 호흡 으로 두 Day 를 갈랐어요.

그런데 실제 우리 팀의 도메인 (ai-friends 미연시 게임) 위에서 어디까지 왼쪽 (Workflow) 으로 갈 수 있고, 어디부터는 오른쪽 (Agent) 의 무게가 정답인지 — 그 분기점이 한눈에 안 보여요.

  • 메시지 답장 자동화 (Day 13 Step 2. Prompt Chaining) — 명백히 왼쪽 영역.
  • 메시지 라우팅 (Day 13 Step 3~4. Routing) — 분기는 LLM, 핸들러는 코드. 여전히 왼쪽.
  • 3 분석 병렬 (Day 13 Step 5~6. Parallelization) — 결과 합산이 코드, 왼쪽 영역.
  • 그룹 대화 분배 (Day 14 Step 2~4. Orchestrator-Workers)분배 자체 가 LLM 의 자율 결정이라 오른쪽 영역.

Day 14 의 결정 이 옳았는가? 그룹 대화 분배enum 닫힌 집합의 Routing 으로 풀 수 있지 않았을까? 어디서부터 자율성의 무게가 결정론적 코드를 이긴다고 판단 해야 하는가?

그리고 한 줄 더 — 오른쪽 두 패턴의 도입 비용왼쪽 세 패턴의 도입 비용 보다 무거워요. 오른쪽은 4 가드 advisor 묶음 + 권한 스코프 + 사이클 격리 + 모니터링 — 본 Day 의 9 부품이 그 무게의 실체. 왼쪽은 switch + enum + CompletableFuture + 단위 테스트 — Day 13 의 도구로 충분. 이 N 배 무게를 어디서 감수할 만한가? 그리고 N 배 무게의 ROI 가 체감되는 시나리오 는 어떤 모양인가?

🎯 핵심 질문우리 ai-friends 미연시 게임 도메인에서 Workflow vs Agent 의 분기점을 정하는 5 신호 는 무엇인가? 분기 자체의 자연어 복잡도 / 분기 라벨의 닫힌 집합 여부 / 사용자 발화의 다중 의도 비율 / 가드 4 부품의 필요성 / 사이클 길이 (1 호출 vs N 호출) — 이 5 신호 중 몇 개가 켜져야 Agent 가 정답인가? 그리고 — Agent 도입 비용 (가드 4 advisor + 권한 스코프 + 사이클 격리 + 모니터링) 이 Workflow 도입 비용 (switch + enum + 테스트) 의 N 배인데 — 어디서 그 N 배 ROI 가 체감되는가? Day 14 의 그룹 대화 분배 가 정말로 오른쪽이 정답인가, 아니면 enum 5 라벨 (직접 호명 / 인사 / 질문 / 의견 / 합의) 의 닫힌 Routing 으로도 풀 수 있었나?

생각해볼 자료:

  • Day 13 의 왼쪽 세 패턴 — Prompt Chaining / Routing / Parallelization 이 결정론적 코드 로 짜인 모습.
  • Day 14 의 오른쪽 두 패턴 — Orchestrator-Workers / Evaluator-Optimizer 가 분기까지 LLM 자율 로 짜인 모습.
  • Anthropic Building Effective Agents (2024-12) — "가능한 한 Workflow / Agent 는 진짜 필요한 곳에만".
  • Spring AI Reference (1.1.x) — 같은 방향의 표준.
  • Day 14 의 9 부품 무게 — record 6 + service 6 + advisor 4 + 예외 5 + 권한 2 + config 1 — 오른쪽의 진입 비용 의 실체.

주제 2 — Evaluator-Optimizer 의 무한 루프 위험 — 평가자가 정확하지 않으면 어디까지 깨지는가

오늘 Step 5~6 에서 한 줄이 확실하게 정리됐어요 — "Evaluator-Optimizer 의 단단함은 평가자 LLM 의 정확성 위에 서 있다." EvaluationVerdict 의 3 값 자물쇠 (PASSED / NEEDS_REVISION / FAILED) 가 평가자가 정확하면 한 단계 깊은 안전망이지만, 평가자가 정확하지 않으면 자물쇠가 정상 동작해도 그 안의 판정이 환각이에요.

이 단계에서 평가자의 부정확성 이 두 갈래로 자라요.

  • 첫째, 평가자가 PASSED 를 남발하는 결 — 어떤 초안도 가다듬지 못하고 1 회차에 통과, 품질이 무력화.
  • 둘째, 평가자가 PASSED 를 영원히 안 돌려주는 결maxIterations 한도 5 회까지 무한 루프, 비용·시간 폭주.

Step 6 의 수동 카운터 + 한도 5둘째 시나리오의 마지막 안전선 이긴 한데, 평가자 정확성이 0.5 정도면 한도 5 로 부족한 시나리오 가 나오지 않을까? 과제 3 의 confidence 임계값 이 첫째 시나리오의 안전망인데 — 둘째 시나리오의 안전망은 maxIterations 외에 무엇이 있는가?

이 매듭이 세 가지 사고 로 자라요.

  • (1) 평가자 정확성의 측정얼마나 정확한지를 어떻게 측정하는가? 정답이 갖춰진 dataset 위에서의 평가자 일치율 / 사람 검수와의 일치율. 이 두 축이 운영에 어떻게 자리하는가?
  • (2) 평가자 jury2 명의 평가자 LLM 이 합의하면 PASSED / 의견이 갈리면 사람 검수 가 정답인가? jury 의 호출 비용이 2 배인데 정당화 근거는?
  • (3) 모델 분리생성자 = Flash (빠름·저렴) / 평가자 = Pro (느림·정확) 처럼 모델 자체를 다르게 박는 방식이 운영에서 단단한가?

🎯 핵심 질문Evaluator-Optimizer 의 평가자 LLM 정확성 을 우리 팀이 어떻게 측정 / 어떻게 검증 해야 하나? 동일 평가자 vs 평가자 2 명 jury 의 차이는 — 호출 비용 2 배의 무게를 정당화하는가? 생성자 = Flash / 평가자 = Pro 의 모델 분리 가 운영에서 정답인가, 아니면 동일 모델 / system 프롬프트만 다르게 가 정답인가? 그리고 — Day 14 의 maxIterations 가드 advisor 가 무한 루프 위험의 마지막 안전선 인데, 평가자 정확성이 0.5 정도 (PASSED 를 못 돌려주는 결) 면 한도 5 회로 부족한 시나리오 는 어디서 발생하는가? maxIterations 외의 안전망 — 조기 종료 휴리스틱 / 반복 패턴 감지 / 사람 게이트 — 의 결정 기준은 어디인가?

생각해볼 자료:

  • Step 5 의 EvaluationVerdict 3 값 자물쇠 — enum 닫힌 집합 의 첫 안전망.
  • Step 6 의 수동 카운터 + maxIterations둘째 시나리오 (무한 루프) 의 마지막 안전선.
  • Step 7 의 MaxIterationsAdvisor — 같은 방향의 advisor 격상.
  • 과제 3 의 confidence 임계값 → 사람 검수 큐첫째 시나리오 (PASSED 남발) 의 안전망.
  • 평가자 jury / 모델 분리 — 본 강의 범위 밖이지만 지난 1 ~ 2 년 사이 업계가 자라는 영역.

주제 3 — MCP tools.execute 와 우리 권한 스코프 — 외부 표준 정착을 언제 흡수해야 하나

오늘 Step 9 에서 한 줄이 확실하게 정리됐어요 — "읽기 도구 vs 쓰기 도구 — 권한 분기는 도구 함수 안에 들어간다." AgentToolPermissionChecker 인터페이스 + InMemoryAgentToolPermissionChecker 한 장 + GameStateTool 의 권한 분기 한 줄 — 이 구조가 우리 강의의 권한 스코프 의 첫 등장이에요. 가벼운 자물쇠 하나.

그런데 2026-05 현재 업계 표준 한 줄이 동시에 옆에 있어요 — MCP 2025-11-25 spec 의 tools.execute 스코프 + OAuth 2.1 + step-up authorization. 외부 표준이 우리 강의보다 한참 무거운 풀세트로 정착했어요. 스코프 (어떤 도구가 어떤 권한을 요구하는지) + OAuth 2.1 (사용자 토큰 발급·검증) + step-up (민감 도구는 추가 인증 요구) 의 세 축 묶음.

우리 강의의 InMemoryAgentToolPermissionChecker 한 줄MCP 풀세트 (세 축) 사이에 N 단계의 자라는 결 이 있어요. 우리 팀의 인력 N 명 / 무료 티어 / Spring Boot 스택 에서 어느 단계까지 흡수 해야 하는가?

이 매듭이 5 단계 사다리 로 자라요.

  • (1) 본 강의 수준InMemoryAgentToolPermissionChecker 한 장. 학습용 / 단일 사용자 / 데모.
  • (2) Spring Security 통합SecurityContextHolder.getContext().getAuthentication() 로 현재 사용자 흡수 + RBAC 권한 조회. 실제 운영의 첫 등장.
  • (3) DB 영속 권한UserToolPermission Entity + Repository + 사용자별 도구별 권한 매트릭스. 운영 단단함의 단계.
  • (4) MCP server 의 tools.execute 스코프 노출 — Day 18 MCP Server 의 영역. 우리 앱이 외부에 도구 노출 시.
  • (5) OAuth 2.1 + step-up 풀세트 — MCP spec 의 완전 흡수. 외부 표준 동등.

우리 팀의 지금 / 6 개월 후 / 1 년 후 는 이 5 단계 중 어디인가?

🎯 핵심 질문MCP 2025-11-25 spec 의 tools.execute 풀세트 (스코프 + OAuth 2.1 + step-up) 를 우리 팀이 언제 어디까지 흡수 해야 하나? 우리 강의의 InMemoryAgentToolPermissionChecker 한 줄 → Spring Security + RBAC → DB 영속 권한 매트릭스 → MCP server scope 노출 → OAuth 2.1 + step-up 풀세트 의 5 단계 사다리 중 — 우리 팀의 현재 / 6 개월 후 / 1 년 후 는 각각 어디인가? 그리고 — MCP 표준이 다시 바뀌면 (예: 2026-08 spec 갱신) 우리 추상화는 어떤 모양으로 자라야 깨지지 않는가? AgentToolPermissionChecker 인터페이스가 외부 표준 변화 위에서 어떤 모양으로 진화 해야 안전한가?

생각해볼 자료:

  • Step 9 의 AgentToolPermissionChecker 인터페이스 + InMemoryAgentToolPermissionChecker 구현 — 우리 강의의 첫 단계.
  • MCP 2025-11-25 spec — tools.execute 스코프 + OAuth 2.1 + step-up authorization.
  • Spring Security + RBAC — 우리 강의 범위 밖이지만 본 사다리의 두 번째 단계.
  • Day 17 MCP Client / Day 18 MCP Server + A2A — 우리 앱이 외부에 도구 노출 하는 시점.
  • 2026-05 업계 현실 — MCP 표준이 빠르게 자라는 영역, 우리 추상화의 진화 결 이 따라가야 함.
✅ 예시 답안정답 보기

본 답안은 교안의 Mission 섹션 에 나온 3 개 과제 + 3 개 생각해볼 주제권장 풀이 입니다. 정답이 하나만 있는 건 아니에요. 본인이 풀어본 결과와 비교하면서 왜 이 결정으로 갔는가 의 근거를 살펴보세요.

Day 14 답안의 세 줄 정신 — (1) Map<String, ChatClient> 자동 주입의 단단함을 빈 등록 한 곳 만으로 검증 (과제 1), (2) guardAdvisors(...) 정적 팩토리가 실제 사이클 에 박힐 때 — 한 사이클 = 한 묶음의 advisor 인스턴스 의 격리 원칙 (과제 2), (3) 평가자 LLM 의 환각에 대한 confidence 임계값 + 사람 검수 큐 의 두 번째 안전망 (과제 3). 자율성 한 칸 + 가드 한 묶음 — 짝패의 흐름이 답안에도 그대로 이어집니다.

과제의 코드 예시는 오늘 작성한 코드 위에 추가 / 수정할 부분 만 인용 한 형태예요. 그대로 복붙보다 손으로 한 번 더 짜보는 게 학습 의미가 있어요. 본 강의의 표준 응답 패턴 (ApiResponse<T> 래핑) 도 그대로 유지합니다.


과제 예시답안

과제 1 예시답안 — 새 캐릭터 `NOA` 워커 ChatClient 추가 (난이도 ⭐)

핵심 접근

본 과제의 본질은 새 캐릭터 한 명 추가 자체가 아니라.

"Step 3 에서 박은 Map<String, ChatClient> 자동 주입이 정말로 빈 등록 한 줄만 으로 새 캐릭터를 흡수하는지 손으로 한 번 더 확인" 하는 거예요.

service 코드 무수정, controller 코드 무수정, record 신규 없음.

오직 AgentChatClientConfig 의 빈 하나만 늘어나면 그룹 대화방에 NOA 가 자연스럽게 등장해야 정상입니다.

빈 이름 컨벤션을 정확히 noaWorkerChatClient 로 박는 게 핵심 함정.

CharacterWorkerServicecharacterId.toLowerCase() + "WorkerChatClient" 패턴으로 Map 을 조회하기 때문에 — 한 글자라도 어긋나면 IllegalArgumentException 으로 떨어집니다.

예시 구현

기존 ariaWorkerChatClient 빈의 시그니처를 그대로 거울로 따라가고, system 프롬프트와 빈 이름만 갈아끼워요.

// AgentChatClientConfig.java — 추가할 빈 하나만 인용

/**
 * Day 14 과제 1 — 4 번째 캐릭터 NOA 의 워커 ChatClient.
 *
 * 빈 이름 컨벤션 — 정확히 {@code noaWorkerChatClient}.
 * CharacterWorkerService 가 Map<String, ChatClient> 의 key 를
 * {@code characterId.toLowerCase() + "WorkerChatClient"} 패턴으로 조회하므로,
 * 컨벤션 한 글자라도 어긋나면 런타임에 IllegalArgumentException 으로 떨어진다.
 */
@Bean
public ChatClient noaWorkerChatClient(
        ChatClient.Builder builder,
        WorkflowLoggingAdvisor workflowLoggingAdvisor,
        WeatherTool weatherTool,
        GameStateTool gameStateTool,
        AffinityTool affinityTool
) {
    return builder
            .defaultSystem("""
                    너는 ai-friends 의 캐릭터 NOA 야.
                    성격: 유머와 장난기 가득한 톤. 반말 + 농담을 자연스럽게 섞고,
                          무거운 주제도 가볍게 풀어내는 톤.
                          단 사용자가 진지하면 한 박자 늦춰 정중함을 회수해.

                    답변 규칙:
                    - 3 문장 이내로 답해. 길게 늘어놓지 않아.
                    - 한 줄 농담이나 장난스러운 비유를 한 번은 박아.
                    - 이모지는 답변당 1~2 개까지만, 과하면 톤이 흐려져.

                    등록된 도구는 발화 의도에 자연스럽게 맞으면 호출해:
                    - 날씨 농담 → getCurrentWeather (날씨에 한 줄 농담 얹어)
                    - 지난 게임 회상 → loadGameState
                    - 유저와의 친밀도 → getAffinity (읽기 전용 도구)
                    도구가 비어 있거나 막히면 "어 잠깐, 그건 까먹었는데 ㅋㅋ" 같이
                    캐릭터 톤을 깨지 않고 자연스럽게 회수해.
                    """)
            .defaultAdvisors(workflowLoggingAdvisor)
            .defaultTools(weatherTool, gameStateTool, affinityTool)
            .build();
}

시연 흐름 — 활성 캐릭터에 NOA 를 포함시킨 그룹 대화 호출.

curl -X POST http://localhost:8080/api/agent/group-dialogue \
  -H 'Content-Type: application/json' \
  -d '{"userMessage":"오늘 날씨 어때? 그리고 농담 좀 해줘",
       "activeCharacterIds":["ARIA","NOA"]}'

마스터 LLM 이 분배 명세를 ARIA → 날씨 (priority 1) / NOA → 농담 (priority 2) 두 갈래로 동적으로 빚어내고, 두 워커가 CompletableFuture.allOf 로 병렬 응답한 뒤 priority 순으로 합류하면 정상이에요.

service 의 git diff 가 0 줄로 떨어지는지 마지막에 한 번 더 확인.

git diff --stat src/main/java/.../CharacterWorkerService.java
# → 0 줄 변경이면 자동 주입이 단단한 것

채점 포인트

포인트 설명 배점 가중
빈 이름 컨벤션 (noaWorkerChatClient) {소문자 characterId}WorkerChatClient 패턴 정확히 준수 — Map 조회의 본질
system 프롬프트의 페르소나 4 단락 구조 성격 / 답변 규칙 / 도구 매핑 / 빈 결과 회수 — 기존 3 워커와 같은 구조
defaultAdvisors(workflowLoggingAdvisor) 누락 없음 로그 가로 보가 NOA 빈에도 그대로 떨어지는 흐름
defaultTools(...) 3 종 등록 도구 호출 자율성이 살아 있는가
CharacterWorkerService 코드 무수정 git diff 0 줄 — 자동 주입 패턴의 본 검증
OrchestratorMasterService system 프롬프트 무수정 마스터의 분배는 활성 캐릭터 목록 인자만으로 자라야 정상
시연 결과 — NOA 가 분배 명세에 살아남 마스터 LLM 이 NOA 를 빠뜨리지 않고 분배 후보로 흡수

흔한 실수

  • 빈 이름 대소문자 어김NOAWorkerChatClient / noa_worker_chat_client 등으로 박으면 Map key 조회가 실패해 런타임에 "등록되지 않은 캐릭터" 류 예외로 떨어져요. 컨벤션은 소문자 characterId + WorkerChatClient — 정확히.
  • OrchestratorMasterService 의 system 프롬프트에 NOA 를 별도 박기 — 마스터 system 프롬프트는 활성 캐릭터 목록을 호출 시점 인자 로 받아요. 프롬프트 자체에 NOA 를 박으면 마스터가 활성 캐릭터 외에까지 분배 하는 방향으로 깨질 수 있어요.
  • 도구 3 종 중 일부만 등록 — 본 과제는 기존 3 워커와 같은 결 이 디폴트. 도구를 빠뜨리면 캐릭터의 자율성 범위가 줄어 분배 결과의 모양이 흐려져요. (선택지로 NOA 만 AffinityTool 한 가지 만 두는 결은 다음 단계.)
  • @Bean("noaWorkerChatClient") 명시 vs 메서드 이름 자동 추론 — 둘 다 동작하지만 메서드 이름 자동 추론 방식이 기존 3 워커 빈과 일관됩니다. 명시 어노테이션을 굳이 박을 필요는 없어요.
  • system 프롬프트가 너무 짧음한 줄짜리 "장난스러운 톤" 만 박으면 다른 캐릭터와 구분이 안 가요. 페르소나 + 답변 규칙 + 도구 가이드 + 회수 — 4 단락 정도가 자연스러워요.

실무 개선 포인트 (심화)

  • 캐릭터 페르소나·system 프롬프트의 외부화application.yml 또는 DB 의 character_personas 테이블로 system 프롬프트를 빼내면 — 새 캐릭터 추가가 코드 배포 0 + 데이터 한 행 추가 로 자라요. 기획자가 직접 캐릭터를 만들고 운영팀이 즉시 배포 의 모습. Day 19 Harness 에서 본격적으로 자라는 첫 등장.
  • 캐릭터별 도구 묶음의 데이터화character_id ↔ tool_id 의 매핑 테이블로 NOA 는 친밀도만 / REX 는 게임만 / ARIA 는 날씨만 의 분배를 데이터로 박을 수 있어요. Day 11 도구 3 종이 모든 캐릭터에 균등 흡수 되는 현재 구조의 한 칸 깊은 진화.

과제 2 예시답안 — ⭐⭐ 가드 4 부품을 실제 사이클에 박기

핵심 접근

본 과제의 본질은 4 advisor 의 코드를 다시 짜는 것 이 아니라.

"Step 9 의 guardAdvisors(...) 정적 팩토리가 실제 비즈니스 사이클 에 박힐 때. 한 사이클 = 한 묶음의 advisor 인스턴스 의 격리 원칙을 손으로 한 번 더 익히는 것" 이에요.

advisor 가 stateful (AtomicInteger 카운터를 들고 있는 구조) 이라.

singleton 빈으로 박으면 모든 사이클이 카운터를 공유 해서 두 번째 사이클부터 곧장 차단되는 사고가 자라요.

해법은 단단해요.

orchestrate(...) 호출 시점에 매번 새 인스턴스 묶음을 빚어내고 / 3 워커가 그 한 묶음을 공유 / 사이클이 끝나면 GC — 이 구조로 짜야 카운터 격리가 살아납니다.

예시 구현

(1) GameOrchestrationService.orchestrate(...) — 사이클 시작 지점에 guardAdvisors(...) 호출 한 줄

// GameOrchestrationService.java — 변경된 부분만 인용

public GroupDialogueResponse orchestrate(
        String userUtterance,
        List<String> activeCharacterIds
) {
    // [Day 14 과제 2] 사이클 시작 시점에 advisor 묶음을 한 번 빚는다.
    //   - 호출당 새 인스턴스 → 카운터 격리 보장
    //   - 3 워커가 한 묶음을 공유 → "3 워커 합산 5 호출 / 8000 토큰" 의 결
    List<Advisor> guards = AgentChatClientConfig.guardAdvisors(
            5,                          // maxIterations — 3 워커 × 1 호출 = 3, 한도 5 는 여유
            Duration.ofSeconds(30),     // timeout — 그룹 대화 평균 ~10 초, 안전선 30 초
            8000L,                      // tokenBudget — 워커당 ~2000 토큰 × 3 + 마스터 ~1500
            10                          // toolInvocationLimit — 도구 3 종 × 3 워커 평균 1 호출
    );

    DialogueDistribution distribution =
            masterService.distribute(userUtterance, activeCharacterIds);

    List<CompletableFuture<WorkerResponse>> futures = distribution.assignments().stream()
            .map(assignment -> CompletableFuture.supplyAsync(
                    () -> workerService.respond(assignment, guards)  // ← guards 전달
            ))
            .toList();

    CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();

    List<WorkerResponse> responses = futures.stream()
            .map(CompletableFuture::join)
            .sorted(Comparator.comparingInt(WorkerResponse::priority))
            .toList();

    return new GroupDialogueResponse(distribution, responses);
}

(2) CharacterWorkerService.respond(...) — 시그니처 한 줄 확장

// CharacterWorkerService.java — 시그니처 확장 + 호출 한 줄

public WorkerResponse respond(WorkerAssignment assignment, List<Advisor> guards) {
    ChatClient workerChatClient = resolveChatClient(assignment.characterId());

    String content = workerChatClient.prompt()
            .user(assignment.intent())
            .advisors(guards.toArray(new Advisor[0]))   // ← 호출 시점에 박는다
            .call()
            .content();

    return new WorkerResponse(
            assignment.characterId(),
            content,
            assignment.priority()
    );
}

(3) 4 파라미터 결정 근거 표 (답안 마크다운에 첨부)

파라미터 결정 근거
maxIterations 5 3 워커 × 평균 1 호출 = 3 → 도구 호출 1~2 회 여유 흡수. 한도 5 면 정상 흐름엔 닿지 않고 폭주 시점에만 닿는 선.
timeout 30 초 그룹 대화 평균 응답 시간 ~10 초 (LLM 호출 ~3 초 + 도구 호출 ~2 초). 3 배 여유 = 30 초. 그 위는 사용자가 이미 뒤로가기 누르는 구간.
tokenBudget 8,000 마스터 ~1,500 + 워커 3 명 × ~2,000 = ~7,500. 500 토큰 안전 마진.
toolInvocationLimit 10 도구 3 종 × 3 워커 평균 1 호출 = 3 + 도구 재호출 자율성 여유. 10 회 위로는 도구 루프 폭주 신호.

확장 (선택) — @ConfigurationProperties 외부화

@ConfigurationProperties(prefix = "agent.guard")
public record AgentGuardProperties(
        int maxIterations,
        Duration timeout,
        long maxTotalTokens,
        int maxToolInvocations
) { }
# application.yml
agent:
  guard:
    max-iterations: 5
    timeout: 30s
    max-total-tokens: 8000
    max-tool-invocations: 10

운영팀이 코드 배포 없이 한도를 조정 할 수 있어요. Day 19 Harness 로 자라는 첫 박힘이에요.

채점 포인트

포인트 설명 배점 가중
advisor 인스턴스 격리 — orchestrate(...) 호출당 새 묶음 singleton 빈으로 박으면 사이클 간 카운터 누적 — 본 과제의 핵심 검증
.advisors(guards.toArray(...)) 호출 시점 등록 빈의 defaultAdvisors(...) 에 박으면 격리 깨짐
respond(...) 시그니처 확장 — List<Advisor> 한 인자 추가 service 시그니처 확장이 허용되는 자리 — 과제 1 의 무수정과 방향이 다름
4 파라미터 결정 근거 한 줄씩 답안 마크다운에 왜 그 숫자인가 의 사고가 들어 있어야 정상
단위 테스트 — IterationLimitExceededException 시연 5 회차 차단을 mock 으로 검증 (코드베이스에만, 본 답안 본문엔 인용 X)
advisor 본체 4 종 무수정 Step 7~9 의 코드 그대로 보존 — 호출 측 통합 이 본 과제
확장 — @ConfigurationProperties (선택) application.yml 외부화로 운영 한도 조정

흔한 실수

  • @Bean 으로 advisor 4 종을 singleton 등록AtomicInteger 카운터가 모든 사이클 합산 으로 자라 두 번째 사이클부터 곧장 차단 되는 사고. 정답은 호출 시점에 매번 새 인스턴스. AgentChatClientConfig.guardAdvisors(...)정적 팩토리 인 이유.
  • workerChatClient.builder() 빈의 defaultAdvisors(...) 에 가드 박기 — 컨테이너 시작 시점에 advisor 인스턴스가 한 번 빚어져 모든 호출이 공유. 같은 방식으로 격리 깨짐. 반드시 호출 시점 .advisors(...).
  • orchestrate(...) 시작이 아닌 워커마다 guardAdvisors(...) 호출 — 워커별로 독립 카운터가 되어 3 워커 합산 5 호출 이 아니라 워커당 5 호출 로 풀려요. 사이클 단위의 본 의도가 깨짐.
  • Advisor[]::new 대신 raw Object[] 변환.advisors(...)Advisor... vararg 인데 Object[] 를 넘기면 컴파일은 통과해도 런타임에 ClassCastException 으로 깨질 수 있어요. 명시적으로 Advisor[]::new.
  • 4 파라미터를 교안의 예시 그대로 복붙 — 본 과제의 핵심은 결정 근거 한 줄 이에요. 왜 5 인가 / 왜 30 초인가 의 사고가 들어가야 정상. 운영 팀마다 다릅니다.
  • 워커 호출 중 발생한 IterationLimitExceededException@ExceptionHandler 매핑 누락GlobalExceptionHandler 가 4 가드 예외를 ApiResponse.fail(...) 로 흡수하는 흐름까지 갖춰져야 Step 9 의 검증 이 완성. (advisor 본체에 매핑이 이미 정리됐으면 OK.)

실무 개선 포인트 (심화)

  • agent.guard 프로퍼티의 프로파일별 분리application-dev.yml 에선 한도를 2 / 10s / 4000 / 5 로 낮춰 로컬 개발에서 폭주를 빨리 감지 / application-prod.yml 에선 운영용 한도. 환경별 안전선이 자라는 형태.
  • Micrometer 메트릭 흡수 — 4 advisor 의 차단 횟수Counter 로 박아두면 운영 대시보드에서 "오늘 maxIterations 차단이 100 회 일어남" 같은 신호를 잡을 수 있어요. Day 20 Observability 의 첫 박힘.

과제 3 예시답안 — ⭐⭐⭐ Evaluator-Optimizer 에 사람 검수 채널 한 줄 추가

핵심 접근

본 과제의 본질은 평가자 LLM 도 환각을 낸다 는 사실에 대한 "EvaluationVerdict 3 값 자물쇠 밖 의 두 번째 안전선. confidence 임계값 + 사람 검수 큐" 를 박는 거예요.

enum 자체는 값이 맞는지 의 자물쇠.

그런데 그 값의 적합성 판정 자체가 환각인 경우는 enum 자물쇠로 못 막아요.

평가자가 자기 확신도를 함께 던지게 하고 / 임계값 아래는 사람 검수 큐로 흘리는 형태가 enum 자물쇠 밖의 두 번째 안전망.

핵심 함정 하나.

EvaluationFeedback record 에 Float confidence 한 필드 추가가 직렬화·역직렬화·null 처리 세 곳에 어떻게 흘러야 깨지지 않는지의 사고.

Float (Nullable) 로 박고.

평가자 응답이 confidence 를 빠뜨리면 null 로 흡수, 분기 조건에 confidence != null && confidence < 0.7 의 null-safe 형태로 박는 거예요.

예시 구현

(1) EvaluationFeedback record 확장 — Float confidence 한 필드

package kr.spartaclub.aifriends.agent.dto;

/**
 * Day 14 Step 5 평가자 응답 + 과제 3 의 confidence 필드.
 *
 * @param verdict    PASSED / NEEDS_REVISION / FAILED 의 3 값 자물쇠
 * @param reason     판정의 자연어 사유 한 줄
 * @param suggestion NEEDS_REVISION 시 가다듬기 가이드 (PASSED·FAILED 일 땐 null 가능)
 * @param confidence 평가자 자기 확신도 0.0 ~ 1.0 (과제 3 추가, Nullable)
 */
public record EvaluationFeedback(
        EvaluationVerdict verdict,
        String reason,
        String suggestion,
        Float confidence
) { }

(2) CharacterEvaluatorService 의 system 프롬프트 확장 — JSON 스키마 4 필드

// CharacterEvaluatorService.java — system 프롬프트에 confidence 추가
.defaultSystem("""
        너는 ai-friends 의 새 캐릭터 카드 draft 를 채점하는 평가자야.

        평가 기준 (3 축):
        - 살아 있는 감각: 캐릭터가 입체적인가, 평면적인가
        - 페르소나 일관성: 톤·말투가 캐릭터 설정과 어긋남이 없는가
        - 본 강의 톤 적합성: ai-friends 의 미연시 결과 어울리는가

        반드시 아래 JSON 스키마로만 응답해.

        {
          "verdict":    "PASSED | NEEDS_REVISION | FAILED",
          "reason":     "한 줄 사유",
          "suggestion": "NEEDS_REVISION 시 가다듬기 가이드 (PASSED·FAILED 일 땐 null)",
          "confidence": 0.0 ~ 1.0
        }

        confidence 가이드:
        - 0.9 이상 — 본 판정에 매우 확신. 평가 기준 3 축이 명확히 보이는 자리.
        - 0.7 ~ 0.9 — 판정에 일반적인 확신. 운영의 정상 범위.
        - 0.5 ~ 0.7 — 판정이 불확실. 사람 검수가 한 번 들어가면 좋은 자리.
        - 0.5 미만 — 자기 판정을 의심. 사람 검수가 반드시 필요한 단계.
        """)

(3) HumanReviewRequest record + HumanReviewQueue 빈 한 개

package kr.spartaclub.aifriends.agent.dto;

/**
 * 평가자 confidence 가 임계값 아래일 때 사람 검수 큐로 흘려보내는 record.
 */
public record HumanReviewRequest(
        String characterId,
        String draftNarration,
        EvaluationVerdict verdict,
        Float confidence,
        String reason
) { }
package kr.spartaclub.aifriends.agent.queue;

import java.util.List;
import java.util.concurrent.ConcurrentLinkedQueue;
import org.springframework.stereotype.Component;
import kr.spartaclub.aifriends.agent.dto.HumanReviewRequest;

/**
 * Day 14 과제 3 — InMemory 사람 검수 큐 (Day 19 Harness 의 첫 박힘).
 *
 * 운영에선 JPA Entity + Repository 로 영속화될 자리이지만,
 * 본 과제는 InMemory 한 단계로 충분.
 */
@Component
public class HumanReviewQueue {

    private final ConcurrentLinkedQueue<HumanReviewRequest> queue =
            new ConcurrentLinkedQueue<>();

    public void enqueue(HumanReviewRequest request) {
        queue.offer(request);
    }

    public List<HumanReviewRequest> pollAll() {
        List<HumanReviewRequest> snapshot = List.copyOf(queue);
        queue.clear();
        return snapshot;
    }

    public int size() {
        return queue.size();
    }
}

(4) CharacterOptimizationService.optimize(...) — confidence 분기 한 줄

// CharacterOptimizationService.java — PASSED 분기에 confidence 검사 한 줄 추가

private static final float CONFIDENCE_THRESHOLD = 0.7f;

public OptimizationResult optimize(String characterId, int maxRounds) {
    // ... (기존 루프 본체 그대로)

    if (feedback.verdict() == EvaluationVerdict.PASSED) {

        // [Day 14 과제 3] confidence 임계값 분기 — enum 자물쇠 밖의 두 번째 안전선
        if (feedback.confidence() != null
                && feedback.confidence() < CONFIDENCE_THRESHOLD) {

            humanReviewQueue.enqueue(new HumanReviewRequest(
                    characterId,
                    draft.narration(),
                    feedback.verdict(),
                    feedback.confidence(),
                    feedback.reason()
            ));

            log.info("[Optimizer] confidence 미달, 사람 검수 큐로 흘림. "
                    + "characterId={} confidence={}",
                    characterId, feedback.confidence());
        }

        // PASSED 의 결은 그대로 — 큐에 row 하나만 남는 결
        return new OptimizationResult(draft, feedback, attempts, true);
    }

    // ... (NEEDS_REVISION / FAILED 분기 기존 그대로)
}

(5) 임계값 0.7 의 결정 근거 (답안 마크다운에 한 줄 박기)

임계값 0.7 — 운영 균형의 첫 자리. 0.5 면 평가자 응답의 절반 가까이가 큐로 흘러 사람 검수 대기열이 운영을 막아요. 0.9 면 1 달에 한 건도 안 차서 안전망이 무력화. 0.7PASSED 의 정상 범위 (0.7~0.9) 의 하한 — 이 아래는 판정 자체가 의심스러운 영역 이라 사람 검수가 가치 있어요. 운영 시점에 큐 적체율 모니터링 → 임계값 ±0.05 조정 의 피드백 루프로 이어지는 결정.

채점 포인트

포인트 설명 배점 가중
Float confidence (Nullable) 로 박힘 primitive float 으로 박으면 평가자가 필드 빠뜨릴 때 역직렬화 실패
평가자 system 프롬프트의 JSON 스키마 + confidence 가이드 한 단락 단순 "confidence 출력해" 가 아니라 0.5 / 0.7 / 0.9 의 가이드 까지 들어가야 LLM 출력이 안정적
EvaluationVerdict enum 자체 무수정 새 enum 값 (HUMAN_REVIEW_REQUIRED) 추가 금지 — Step 5 의 3 값 자물쇠 보존
confidence != null && confidence < 0.7 의 null-safe 분기 평가자가 confidence 를 빠뜨려도 깨지지 않는 안전선
HumanReviewQueue 의 thread-safe 구현 (ConcurrentLinkedQueue) 단순 ArrayList 면 멀티스레드 호출에서 race
임계값 결정 근거 한 줄 왜 0.7 인가 — 자유 변경 가능하지만 근거 필수
단위 테스트 — confidence 0.5 의 응답이 큐에 row 추가 mock 평가자 응답으로 분기 검증 (코드베이스에만)
확장 — JPA Entity (선택) 영속 + 운영 콘솔 흐름

흔한 실수

  • Float 대신 float (primitive) — 평가자 LLM 이 confidence 를 빠뜨리는 응답이 자연 발생하면 BeanOutputConverter 가 역직렬화 실패로 깨집니다. Nullable 이 안전합니다.
  • 임계값을 0.7f 로 hard-coded — 본 답안의 핵심도 hard-coded 지만, 실무에선 @Value("${agent.evaluator.confidence-threshold:0.7}") 로 외부화하는 방식이 자연스러워요. 답안에서는 상수 분리 만으로 충분.
  • HumanReviewQueueList<HumanReviewRequest> (ArrayList) 로 — 평가자 호출이 멀티스레드로 떨어지면 race condition. ConcurrentLinkedQueue 또는 synchronized 로.
  • OptimizationResult 에 새 종료 사유 (HUMAN_REVIEW_REQUIRED) 추가 — enum 자체 수정은 본 과제 금지. PASSED 의 형태로 흡수하되 큐에 row 한 줄만 남기는 흐름이 정답.
  • 사람 검수 큐를 영속 없이 두고 운영 가정 — InMemory 결은 학습용. 실제 운영은 JPA Entity 가 정답 — 답안에선 확장 자리로만 명시.
  • humanReviewQueue.enqueue(...) 를 NEEDS_REVISION / FAILED 분기에도 박기 — 본 과제는 PASSED 인데 confidence 가 의심스러운 경우 만 큐로 흘려요. NEEDS_REVISION 은 Optimizer 가 다시 시도하는 흐름, FAILED 는 종료. 큐는 PASSED 의 그림자 만.

실무 개선 포인트 (심화)

  • HumanReviewEntry JPA Entity + 운영 콘솔HumanReviewRepository + @GetMapping("/admin/human-review/pending") 엔드포인트로 운영팀이 검수 큐를 조회 + 결과를 다시 흡수 하는 채널까지 자라요. Day 19 Harness 의 휴먼-인-더-루프 의 첫 박힘.
  • 평가자 jury (확장) — 본 과제는 단일 평가자 + confidence 의 방식이지만 — 2 명의 평가자 LLM 의 합의 / 불일치 시 사람 검수 의 방식으로 자라는 게 생각해볼 주제 2 의 영역이에요. 호출 비용 2 배지만 평가자 정확도가 한 단계 자라는 모양.
  • confidence 분포 메트릭 흡수MicrometerDistributionSummary 로 평가자 confidence 분포를 매일 기록해두면 — 모델 성능 저하의 첫 신호 (분포가 0.7 아래로 쏠리는 시점) 를 잡을 수 있어요. Day 20 Observability 의 영역.

생각해볼 주제 예시답안

이 섹션의 답들은 정답이 정해진 게 아니에요. 각 주제마다 우리 팀의 환경 위에서 어느 방향을 골라야 단단한가 의 판단 훈련입니다. 본인이 적은 결과와 비교하면서 왜 그 결정인가 의 근거를 한 번 더 짚어보세요.

주제 1 예시답안 — Workflow 우선 / Agent 보수

[문제 상황 요약]

Day 13 의 왼쪽 3 패턴 (Prompt Chaining / Routing / Parallelization) 과 Day 14 의 오른쪽 2 패턴 (Orchestrator-Workers / Evaluator-Optimizer).

5 패턴이 자율성 스펙트럼 위에 한 줄로 정리됐어요.

Anthropic Building Effective Agents (2024-12) 가 들고 온 한 줄 "가능한 한 Workflow / Agent 는 진짜 필요한 곳에만" 이 업계 표준이 된 지금.

우리 ai-friends 미연시 게임 도메인 위에서 어느 지점부터 Workflow 가 부족해지고 Agent 가 정답인지의 분기점이 한눈에 안 보여요.

그리고 Agent 도입 비용 (가드 4 advisor + 권한 스코프 + 사이클 격리 + 모니터링) 이 Workflow 의 N 배인데.

어디서 그 N 배 ROI 가 체감되는가 의 결정도 같이 따라옵니다.

[튜터의 가이드 및 해설]

이 주제의 첫 매듭은 두 도입 비용을 솔직하게 비교 하는 거예요.

Day 13 의 왼쪽 3 패턴은 switch + enum + CompletableFuture + 단위 테스트.

가벼운 구성.

Day 14 의 오른쪽 2 패턴은 마스터 LLM 호출 1 회 추가 + 가드 4 advisor + 권한 스코프 + 사이클 격리.

코드 무게가 한 칸 자라고, 호출 비용도 한 박자 늘어요 (마스터 LLM 한 번이 추가 비용).

이 무게 차이를 무시하면 과잉 패턴 의 사고가 자라요.

Option A — 모든 자율적 영역을 Workflow 방식으로 끌고 간다.

enum 닫힌 집합으로 라벨링 + switch 로 분기 + Parallelization 으로 동시 처리 의 흐름을 가능한 한 멀리 밀어붙이는 선택.

장점: 안전성 ↑ / 비용 ↓ / 테스트 ↓.

switch 분기는 단위 테스트가 쉽고, 가드 4 부품도 거의 필요 없어요.

단점: 유저 발화의 다양성을 enum 으로 미리 닫아둬야 해서 유연성 ↓.

새 분기 추가가 코드 배포.

그리고 진짜 동적 분배가 필요한 경우 (한 발화에 복수 의도 / 분기 후보가 동적) 는 Workflow 로 못 풀어요.

Option B — Agent 방식으로 자율성을 적극 도입.

Orchestrator-Workers 로 분배 자체를 LLM 자율 결정 + Evaluator-Optimizer 로 품질 루프 의 흐름.

장점: 유연성 ↑ / 새 분기 추가가 system 프롬프트 한 줄.

단점: 비용 ↑ (마스터 LLM 호출 추가) / 운영 부담 ↑ (가드 4 부품 모니터링) / 디버깅이 어려움 (분배 결과가 매번 다름).

그리고 평가자 LLM 의 환각 같은 한 단계 깊은 사고도 따라와요.

현업에서는 보통.

Anthropic 의 권고 그대로.

Workflow 우선, Agent 는 진짜 필요한 곳에만.

5 신호 체크리스트로 결정해요.

(1) 분기 후보가 enum 닫힌 집합인가?.

닫혀 있으면 Routing 으로 충분.

(2) 입력에 다중 의도가 자연스럽게 자리하나?.

한 의도면 Routing, 복수면 Orchestrator-Workers.

(3) 출력 품질을 측정 가능한가?.

측정 가능하면 Evaluator-Optimizer.

(4) 외부 도구를 자율로 골라야 하는가?.

도구 후보가 1~2 개면 코드 손, N 개면 Agent.

(5) 사이클 (루프) 이 자연스러운가?.

1 호출이면 Workflow, N 호출이 자연스러우면 Agent.

이 5 신호 중 2 개 이상이 Agent 쪽에 표를 주면 Agent, 그 외엔 Workflow.

Day 14 의 그룹 대화 분배 는.

(2) 다중 의도 ✅ / (4) 도구 선택 ✅ / (5) 사이클 N 호출 (워커 3 명 병렬) ✅.

3 개 신호가 켜져서 Agent 가 정답이에요.

만약 enum 5 라벨 (직접 호명 / 인사 / 질문 / 의견 / 합의) 의 닫힌 Routing 으로 풀었다면.

분기 후보가 닫혀 있고 / 다중 의도가 없는 작은 시나리오에선 충분하지만.

복수 캐릭터에 동시에 분배 의 흐름을 잃어버려요.

본 강의의 결정이 단단한 부분.

비용 ROI 의 답은 — Workflow 의 N 배 비용 (마스터 LLM 호출 + 가드 4 advisor) 이 정당화되는 시점 은 보통 사용자가 자연어로 표현하는 다양성이 enum 으로 닫기 어려워질 때.

ai-friends 미연시 게임에서 메시지 4 분류 (FAQ / AFFINITY / SAFETY / CASUAL) 는 닫힌 집합이라 Routing.

그룹 대화 분배 는 활성 캐릭터·의도 조합이 동적이라 Agent.

메시지 답장 자동화 는 3 단 직렬이라 Prompt Chaining.

각자의 도메인에 각자의 답이 정답 이에요.

🎯 면접관을 홀리는 핵심 멘트

"Agent 는 비싸요. 가드 4 advisor + 권한 스코프 + 사이클 격리 + 모니터링 — 4 가지 인프라 비용이 함께 따라옵니다. 그래서 저는 5 신호 체크리스트 로 결정해요. 분기 후보의 동적 여부 / 다중 의도 비율 / 출력 품질의 측정 가능성 / 도구 선택의 자율성 필요성 / 사이클 길이 — 이 중 2 개 이상이 Agent 쪽에 표를 주는 영역에만 Agent 를 박고, 그 외엔 Workflow 의 가벼운 패턴으로 갑니다. Day 14 의 그룹 대화 분배는 3 개 신호가 켜져서 Agent 가 정답이었고요."


주제 2 예시답안 — Evaluator-Optimizer 의 무한 루프 위험

[문제 상황 요약]

Step 5~6 에서 "Evaluator-Optimizer 의 단단함은 평가자 LLM 의 정확성 위에 서 있다" 가 정리됐어요.

EvaluationVerdict 의 3 값 자물쇠는 enum 값 자체 의 안전망.

그 enum 값의 판정이 환각 인 경우는 자물쇠 밖이에요.

평가자 부정확성이 두 갈래로 자라요.

PASSED 남발.

1 회차 통과로 품질 무력화 (과제 3 의 confidence 가 이 안전망).

PASSED 부재.

무한 루프, 비용·시간 폭주 (maxIterations 가드가 마지막 안전선).

그런데 평가자 정확성이 0.5 정도면 한도 5 회로 부족한 시나리오 는 어디서 자라는가?

그리고 평가자 정확성 자체 를 어떻게 측정·검증할 것인가?

[튜터의 가이드 및 해설]

이 흐름의 첫 단계는 평가자 정확성을 측정 가능하게 만드는 것 이에요.

우리 팀이 정답 라벨을 박은 캐릭터 카드 100 장 dataset 을 박아두고 — 평가자 LLM 을 그 위에 돌렸을 때 사람 정답과의 일치율 이 평가자 정확성의 운영 정의.

일치율 0.8 이상이면 단일 평가자로 충분, 0.6 ~ 0.8 이면 jury 또는 사람 검수 게이트 필수, 0.6 미만이면 모델 자체를 바꿔야 해요.

Option A — 단일 평가자 + maxIterations 5 회.

Day 14 의 디폴트.

장점: 단순 / 비용 ↓ / 운영 부담 ↓.

단점: 평가자 환각의 두 위험에 모두 노출.

평가자 정확성이 0.7 이상이면 충분, 그 아래는 안전망이 부족.

Option B — 평가자 jury (2 명의 평가자 LLM 합의).

두 평가자 LLM 이 동일 draft 를 채점하고, 동일 verdict + 둘 다 confidence ≥ 0.7 일 때만 PASSED 흡수.

의견 갈리면 사람 검수 큐.

장점: PASSED 남발 위험 ↓ (한쪽이 환각이어도 다른 쪽이 잡아냄) / 평가자 정확도 ↑.

단점: 호출 비용 2 배 / 응답 시간 2 배 (병렬이어도 가장 느린 쪽 기준) / 둘 다 같은 환각을 낼 수도 (동일 모델 기반의 한계).

Option C — 모델 분리 (생성자=Flash / 평가자=Pro).

생성자는 빠르고 저렴한 모델 (Gemini Flash) 로 / 평가자는 느리고 정확한 모델 (Gemini Pro) 로 박는 방식.

장점: 평가자 정확도 ↑ (Pro 의 reasoning) / 생성자 비용 ↓.

단점: 평가자 호출 비용 ↑ (Pro 는 Flash 의 10 배 비용) / 운영 모니터링이 두 모델로 분기.

현업에서는 보통.

측정 → 진화 의 흐름.

처음엔 Option A (단일 평가자 + maxIterations 5) 로 출발 + 운영 데이터 수집 (평균 attempts·평가자 정확도·confidence 분포)임계값 미달 시 Option B/C 로 단계적 격상.

본 강의의 과제 3 의 confidence 임계값 + 사람 검수 큐Option A 와 Option B 사이의 가벼운 안전망 이에요.

사람 검수가 늘어나면.

jury 또는 모델 분리로 진화.

maxIterations 외의 안전망 은 세 가지.

(1) 조기 종료 휴리스틱.

평가자가 동일 suggestion 을 2 회 이상 던지면 루프가 수렴 안 함 의 신호로 조기 종료.

(2) 반복 패턴 감지.

생성자의 draft 가 직전 회차와 80% 유사 하면 Optimizer 가 무효 의 신호로 종료.

(3) 사람 검수 게이트.

maxIterations 직전 회차에 사람 검수 큐로 흘리기.

자동 종료 대신 사람 손에 흡수.

🎯 면접관을 홀리는 핵심 멘트

"평가자 LLM 도 환각을 냅니다. 그래서 평가자 정확성 자체를 측정 가능하게 만드는 것 이 첫 단계예요. 사람 정답 dataset 100 장 위에서 일치율 을 매주 측정하고, 0.7 미만으로 떨어지는 신호가 잡히면 — 단일 평가자에 maxIterations 5 회로는 부족합니다. confidence 임계값 + 사람 검수 게이트 가 가벼운 첫 안전망, jury 또는 모델 분리 가 한 단계 깊은 안전망 — 이 사다리를 측정 데이터 위에서 단계적으로 격상하는 흐름이 운영에서 단단해요."


주제 3 예시답안 — MCP `tools.execute` 와 우리 권한 스코프

[문제 상황 요약]

Step 9 에서 "읽기 도구 vs 쓰기 도구. 권한 분기는 도구 함수 자리에 들어간다" 가 확실하게 정리됐어요.

AgentToolPermissionChecker 인터페이스 + InMemoryAgentToolPermissionChecker 한 구현.

이 자리가 우리 강의의 권한 스코프 첫 등장.

그런데 2026-05 현재 업계 표준 인 MCP 2025-11-25 spec 의 tools.execute 풀세트 (스코프 + OAuth 2.1 + step-up authorization) 는 한참 더 무거워요.

우리 강의의 한 줄 구현MCP 풀세트 사이에 5 단계 사다리가 있어요.

우리 팀의 현재 단계 / 6 개월 후 / 1 년 후 는 그 사다리의 어디인가?

그리고 MCP 표준이 다시 바뀌면 우리 추상화는 어떤 모양으로 진화해야 깨지지 않는가?

[튜터의 가이드 및 해설]

이 주제의 첫 매듭은 5 단계 사다리를 솔직하게 그려두는 거예요.

단계 내용 우리 팀 진입 시점
1 InMemoryAgentToolPermissionChecker (Day 14 의 자리) 학습 / 단일 사용자 / 데모
2 Spring Security 통합 — SecurityContextHolder + 도구별 @PreAuthorize 운영 첫 박힘 / 인증 사용자 흡수
3 DB 영속 권한 매트릭스 — UserToolPermission Entity RBAC + 사용자별 도구별 권한
4 MCP server tools.execute 스코프 노출 — Day 18 의 영역 우리 앱이 외부 (Claude Desktop / Cursor 등) 에 도구 노출
5 OAuth 2.1 + step-up authorization 풀세트 — MCP spec 동등 외부 표준 완전 흡수

Option A — 사다리를 빠르게 5 단까지 올린다 (선제 흡수).

MCP 표준이 이미 외부에서 자리잡았으니 우리도 풀세트로 박자 의 방향.

장점: 외부 표준 정합성 ↑ / 외부 서비스 연동이 쉬워짐.

단점: 4~5 단의 인프라 비용 (OAuth 2.1 서버 / step-up 흐름 / 감사 로그) 이 — 단일 사내 서비스엔 과잉.

Option B — 사다리를 천천히 (필요할 때만) 격상.

우리 팀의 도구가 외부 표준에 노출되는 시점 까지는 1~2 단계로 충분.

장점: 인프라 비용 ↓ / 단순함.

단점: 나중에 MCP 표준 흡수 시점에 추상화가 어긋나면 대공사.

현업에서는 보통.

4 단계 분기점 (외부 노출 여부) 에서 사다리가 갈라진다.

우리 도구가 사내 단일 서비스에만 자리 하면.

2~3 단계 (Spring Security + RBAC + DB 영속) 까지가 정답.

외부 표준의 OAuth 2.1 까지 도입하는 건 외부 시스템 연동 (MCP server 노출 / 외부 클라이언트가 도구 호출) 단계에서만 정당화돼요.

내부 도구를 호출하는 외부 서버가 자라기 시작 하는 시점이 5 단계 진입 신호.

흡수 시점의 4 신호(1) 도구가 상태 변경/외부 API 호출 인가? (읽기 전용은 단순, 쓰기는 권한 무게 ↑) (2) 사용자가 다중 권한 레벨을 가지나? (관리자 / 일반 / 게스트 분기가 자리하면 RBAC) (3) 감사 로그가 필요한가? (도구 호출 기록이 규제 영역이면 영속 권한 매트릭스 필수) (4) 외부 시스템 연동인가? (MCP server 노출 / 외부 클라이언트가 도구 호출이면 OAuth 2.1 풀세트).

4 신호 중 2 개 이상 켜지면 한 단계 격상.

추상화의 진화 방향은.

AgentToolPermissionChecker 인터페이스가 5 단계 모든 단계에서 같은 시그니처 로 살아남아야 해요.

본 강의의 인터페이스 시그니처 boolean canExecute(String toolName, String userId).

이 형태가 2 단계 (Spring Security 의 SecurityContextHolder 흡수) / 3 단계 (DB 영속 권한 조회) / 5 단계 (OAuth 2.1 토큰 검증) 모두에서 구현체만 갈아끼우는 방식 으로 흡수돼요.

추상화의 정답은 외부 표준이 바뀌어도 인터페이스가 깨지지 않는 자리 를 찾는 것이에요.

MCP 2026-08 spec 갱신이 오면 5 단계 구현체만 갈아끼우는 방식 이면 단단합니다.

🎯 면접관을 홀리는 핵심 멘트

"MCP tools.execute 풀세트는 외부 시스템과 도구를 주고받는 상황에서만 정당화돼요. 사내 단일 서비스라면 Spring Security + 도구별 @PreAuthorize + DB 영속 권한 매트릭스 의 2~3 단계로 충분합니다. 본 강의의 AgentToolPermissionChecker 인터페이스는 그 5 단계 사다리의 첫 칸이고요 — 핵심은 인터페이스 시그니처가 5 단계 모두에서 깨지지 않는 추상화 를 찾아두는 것. 외부 표준이 바뀌어도 구현체만 갈아끼우는 방식이면 진화에 단단합니다."

더 배우려면

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

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