Day 14. Agent 2 패턴 + 운영 가드 4 부품
안녕하세요! 여러분의 백엔드 가이드, 홍순구 튜터입니다.
지난 시간, 손이 정말 무거우셨죠.
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 / FAILED 의 EvaluationVerdict 로 채점해요.
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-Workers 와 Evaluator-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-Workers 와 Evaluator-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) 가 EvaluationVerdict 의 PASSED / 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 패턴을 한 번 떠올려볼게요.
messageRouter가RouteLabel라벨 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 스키마.
DialogueDistributionrecord 의 모양을 그대로 받아쓰도록 지시합니다.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>을 하나 더 만들어characterIddistinct 필터를 추가하면 됩니다.
"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 는 누가 응답했는지, responseText 는 LLM 이 만든 자연어 본문, priority 는 합류 단계에서 몇 번째로 발화할지 예요.
마스터의 WorkerAssignment 에 있던 characterId 와 priority 가 그대로 보존되는 모양이 보이실 거예요.
합류 단계 (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 의 캐릭터별 묶음 도 본 시나리오에선 맞는 설계예요.
CharacterWorkerService — Map<String, ChatClient> 자동 주입의 정체
자, 이제 본 Step 의 가장 결정적인 한 장 — service 입니다.
본 service 의 한 줄짜리 비밀은 어떻게 분기를 풀었는가 예요.
Day 13 의 MessageRoutingService 가 switch (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 단계는 이렇습니다.
- 마스터의 분배 —
OrchestratorMasterService.distribute(...)가 사용자 발화를 읽고 지시서 1 장 (DialogueDistribution) 을 만들어줍니다.
누가(characterId) 어떤 의도(responseIntent) 로 몇 번째(priority) 말할지의 명세.
- 워커의 병렬 응답 — 지시서의
assignments묶음을 한 명씩 직렬로 부르는 게 아니라,CompletableFuture.supplyAsync(...)로 워커 N 명을 동시에 띄웁니다.
각 워커는 Step 3 의 CharacterWorkerService.respond(...) 호출로 자기 ChatClient 에서 응답 본문을 생성.
- 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 pool —
Executors.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 이 환각으로
MAYBE나PARTIAL같은 새 라벨을 만들어내면 어떻게 될까요?.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— 판정 근거의 자유 텍스트. 학습 로그 · 디버깅 · 운영 관찰을 위한 필드이지, 루프의 분기 결정에는 쓰이지 않아요.suggestion— Optimizer 루프의 의미가 사는 필드. 평가자가 "틀렸어" 만 돌려주면 다음 생성이 같은 곳에서 같은 실수를 반복합니다. 평가자의 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단으로 잘라보면 이렇습니다.
attempts++— 시도 횟수를 한 칸 올려요.generate(...)— 생성자가 contextPrompt 를 받아 draft 한 장을 만들어냅니다.evaluate(...)— 평가자가 그 draft 를 채점해서EvaluationFeedback한 장을 돌려줘요.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 로 시작해요.
draft 와 feedback 은 null 로 두는데 — 루프 본체에서 반드시 한 번은 채워진 뒤에야 빠져나오기 때문에 안전합니다 (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 안전선 이 네 가지가 됩니다. 한 단락에 모아볼게요.
- Step 2 — 화이트리스트 필터 —
Orchestrator의 워커 풀에서 비활성 캐릭터(active=false) 를 묶어둔 회원·삭제된 캐릭터를 차단하는 자물쇠. - Step 2 —
priority오름차순 정렬 — LLM 응답 순서의 자유분방함을 도메인 순서의 정렬 한 줄로 잡아두는 자물쇠.
Step 3. — 매핑 실패 시 IllegalArgumentException — characterId 자물쇠가 풀리는 환각 시나리오에서 조용한 폴백 이 아니라 명시적 실패 로 전환하는 분기.
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 MaxIterationsAdvisorUnbounded execution time Step 8 DurationTimeoutAdvisorUnbounded token consumption Step 8 UsageBudgetAdvisorUnbounded tool/function calls Step 9 ToolInvocationCounterAdvisor2026-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 의 WorkflowLoggingAdvisor 가 before/after 양쪽에서 로깅 메타데이터를 찍는 모양으로 자랐다면, 본 Step 의 MaxIterationsAdvisor 는 before 한 군데에서 카운터 증가 + 한도 초과 차단 의 가드 형태로 자라요.
같은 인터페이스가 다른 방향으로 갈라지는 모습입니다.
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 의 WorkflowLoggingAdvisor 가 HIGHEST_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 의 MaxIterationsAdvisor 가 HIGHEST_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 의 DurationTimeoutAdvisor 의 AtomicReference 와 같은 방식이에요.
한 advisor 인스턴스가 여러 호출에 걸쳐 상태를 누적 하는데, 그 호출들이 그룹 대화방의 워커 병렬 흐름으로 동시에 들어올 수 있으니까요.
addAndGet(delta) 한 줄로 누적이 race condition 없이 잠겨요.
자료형이 int 가 아니라 long 인 건 한 사이클에서 토큰이 수십만 단위까지 쌓일 수 있어서 안전 마진을 둔 거예요.
③ before(...). 누적 > 예산이면 차단.
Step 7 과 같은 패턴이에요.
누적이 이미 예산을 넘긴 상태에서 다음 호출이 시도될 때 막아요.
즉 예산을 정확히 한 호출 만큼 초과한 순간 그 호출은 통과하고, 그 다음 호출부터 차단 됩니다.
호출 직전 시점의 누적값 이 기준이라는 건 운영 의미가 있어요.
마지막 호출 한 번은 통과시켜야 응답 metadata 에서 토큰 누적이 들어가야 왜 차단됐는지 의 마지막 신호가 잡히거든요.
④ after(...). 응답 metadata 에서 토큰 추출 + 누적.
여기가 본 advisor 가 한 단계 더 자란 부분이에요.
Step 7 의 MaxIterationsAdvisor, 본 Step 의 DurationTimeoutAdvisor 는 after(...) 가 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 (...)조건문을 자기 환경에 맞게 매번 갈아끼워야 하지만, 인터페이스 한 장만 끼워뒀어도InMemoryAgentToolPermissionChecker만SecurityContextAgentToolPermissionChecker로 갈아끼우면 끝나요. 학습 환경에서는 단순 구현, 운영 환경에서는 본격 구현 — 두 모드를 한 인터페이스로 묶는 구조입니다.
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 도 없이 오늘 짠 자동 주입의 손대는 곳이 정확히 한 군데임을 한 번 더 확인하는 자리.
💡 왜 굳이 이 과제를 할까요?
Map<String, ChatClient>자동 주입의 두 번째 만남 — Step 3 (워커 빈 3 종 + 자동 주입 짜기) 에서 짠 Map 주입 패턴이 과제 1 (새 워커 빈 1 종 추가) 에서 한 번 더 익혀지는 단계예요. 빈 이름 컨벤션 ({소문자 characterId}WorkerChatClient) → Map key 매핑 → 그룹 대화방 등장 의 흐름이 새 캐릭터 한 명을 추가할 때 정확히 어떻게 동작하는지 손에 익혀져요.CharacterWorkerService무수정 원칙의 손맛 — 본 강의의 핵심 메시지 "새 캐릭터 N 명까지 service 코드는 그대로" 가 정말로 무수정으로 떨어지는지 확인하는 단계.Map<String, ChatClient>자동 주입의 본질이 확장에 열려있고 수정에 닫혀있는 원칙의 살아있는 거울이에요.- 캐릭터별 도구 묶음의 차이 (선택) — 확장 단계에서 NOA 에게는 호감도 도구만 / 날씨·게임은 제외 같은 구성을 짜보면 캐릭터마다 도구 묶음이 다른 패턴이 손에 익혀져요. Day 15~16 RAG 에서 캐릭터별 지식 베이스 분리 로 자라는 복선이에요.
✅ 요구사항
AgentChatClientConfig에noaWorkerChatClient빈 한 개 추가 — 빈 이름 컨벤션 정확히noaWorkerChatClient(소문자 characterId +WorkerChatClient접미사). 기존ariaWorkerChatClient/rexWorkerChatClient/lunaWorkerChatClient의 시그니처를 그대로 복사하고 system 프롬프트만 NOA 톤으로 갈아끼우세요.- NOA 의 페르소나 — 유머·장난기 가득한 톤 — system 프롬프트는 본인이 자유롭게 설계하세요. 예: "당신은 NOA. ai-friends 의 장난꾸러기 캐릭터. 답변에 농담·말장난·이모지를 자연스럽게 섞고 / 무거운 주제도 가볍게 풀어내는 톤. 단 사용자가 진지한 자리는 한 박자 늦춰 정중함을 회수." 같은 방향.
- Day 11 도구 3 종 등록 +
WorkflowLoggingAdvisor— 기존 3 워커 빈과 동일하게WeatherTool/GameStateTool/AffinityTool등록 +WorkflowLoggingAdvisor. CharacterWorkerService무수정 — 절대 손대지 마세요. 본 과제의 핵심 검증 포인트예요.- 시연 — 그룹 대화방에서 NOA 활성화 —
OrchestratorMasterService.distribute(...)호출 시activeCharacterIds인자에"NOA"만 추가하면 그룹 대화방에 살아나야 정상. 예시 입력 "오늘 날씨 어때? 그리고 농담 좀 해줘" — ARIA 가 날씨 분배, NOA 가 농담 분배로 떨어지는지 응답에서 확인. - 산출물 — (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 로 사용 하는데,CharacterWorkerService가 characterId 를 소문자 +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 의 흐름. 가드가 단순 시연에서 운영 코드로 자라는 단계.
💡 왜 굳이 이 과제를 할까요?
guardAdvisors(...)의 호출 측 손맛 — Step 9 에서는 정적 팩토리의 구현만 짰어요. 호출 측에서 어떻게 받고 / 어떻게 advisor 배열로 던지고 / 사이클 경계를 어떻게 정의하는지 는 본 과제의 영역이에요. 정적 팩토리의 시그니처 (int / Duration / long / int) → 실제 4 파라미터 결정 →.advisors(guards.toArray(Advisor[]::new))한 줄 의 흐름이 손에 익혀져요.- 사이클 경계의 정의 — 한 사이클이 어디서 시작해서 어디서 끝나는가 가 본 과제의 핵심 결정이에요.
GameOrchestrationService.orchestrate(...)한 호출이 한 사이클 인지 / 3 워커 각자가 한 사이클 인지 — 카운터 격리의 단위 가 어떻게 잡혀야 하는지의 사고가 들어와요. 본 과제의 결은 한orchestrate(...)호출 = 한 사이클, 3 워커가 한 묶음의 advisor 를 공유하는 방향. - 운영 가드 파라미터의 첫 결정 — 5 호출 / 30 초 / 8000 토큰 / 10 툴 호출 — 본 과제에서 직접 정할 네 숫자가 우리 팀의 그룹 대화 시나리오 에 정말 맞는지의 사고. 3 워커 × 평균 1 호출 = 3 호출 → 한도 5 는 여유 / 그룹 대화 평균 응답 시간 ~10 초 → 한도 30 초 는 안전선 같은 결정을 손으로 정해보는 자리예요.
✅ 요구사항
GameOrchestrationService.orchestrate(...)수정 — 메서드 시작 부분에AgentChatClientConfig.guardAdvisors(5, Duration.ofSeconds(30), 8000L, 10)한 줄로 advisor 묶음을 받기.- 워커 ChatClient 호출에
.advisors(guards.toArray(Advisor[]::new))한 줄 박기 — 기존 워커 호출 (workerChatClient.prompt().user(...).call().content()) 에 advisor 박는 한 줄을 끼워넣어요.CharacterWorkerService의 호출 지점에 박히는 자리이며, Step 3 의 시그니처를 안 깨도록orchestrate(...)시점에 advisor 묶음을 들고 service 로 넘기는 방식. CharacterWorkerService의 시그니처 확장 —respond(...)메서드에List<Advisor> guards파라미터를 한 개 추가. (advisor 자체는 호출 측의 인스턴스 가 흘러들어와야 카운터 격리가 보장돼요.) 본 과제는 service 시그니처 확장이 허용되는 케이스 예요 — 과제 1 의 무수정 조건과 방향이 다름.- 단위 테스트 —
IterationLimitExceededException가 호출 5 회 차에 차단되는 시나리오를 코드베이스 (src/test/java/.../GameOrchestrationServiceTest.java) 에 박기. mock 으로 워커가 5 회째 호출에서 advisor 차단되는 결. 본 단위 테스트는 코드베이스에만 — 강의교안 본문엔 넣지 않아요. - 운영 가드 4 파라미터 — 결정 근거 한 줄 — 답안 마크다운에 왜 5 / 30 초 / 8000 토큰 / 10 툴 호출 인가 의 결정 근거 한 줄씩. 예: "3 워커 × 평균 1 호출 = 3 → 한도 5 는 여유".
- 확장 (선택) —
application.yml프로퍼티 분리 —@ConfigurationProperties(prefix = "agent.guard")의AgentGuardPropertiesrecord 한 개를 만들고agent.guard.max-iterations: 5/agent.guard.timeout: 30s등의 키로 4 파라미터를 외부화. 운영팀이 코드 배포 없이 가드 한도를 조정하는 구조. - 산출물 — (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>—Advisor는org.springframework.ai.chat.client.advisor.api.Advisor타입. ChatClient 의.advisors(...)메서드는 varargAdvisor...를 받으니 —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 차단되는 시나리오로 검증. 본 단위 테스트는 코드베이스에만 박고 강의 본문엔 인용하지 않아요. - 확장 자리 —
@ConfigurationProperties—record 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 도 환각을 낼 수 있다는 사실에 대한 첫 안전망.
💡 왜 굳이 이 과제를 할까요?
- 평가자 LLM 의 자기 환각 안전망 — Step 5 에서 enum 3 값 자물쇠 가 LLM 환각의 첫 안전망이었어요. 그런데 enum 값 자체는 맞는데 / 그 enum 값의 적합성 판정이 환각 인 경우는 enum 자물쇠로 못 막아요. "평가자가 자기 확신도를 함께 던지면 / 임계값 아래는 사람 검수로 흘리는" 분기가 enum 자물쇠 밖의 두 번째 안전선 — 자율성과 안전선의 짝패가 한 단계 깊어지는 차례예요.
- 사람 검수 채널의 첫 등장 — Day 19 Harness 의 휴먼-인-더-루프 가 본 과제에서 작은 단위로 처음 등장 합니다. 큐 한 개 / Optional 한 개 / 임계값 하나 만으로 — 완전 자율 vs 사람 게이트 의 분기점이 손에 익혀져요.
EvaluationFeedbackrecord 확장의 호환성 감각 — 기존 record 에Float confidence한 필드가 추가될 때 기존 코드 (system 프롬프트 / 직렬화 / null 처리) 에 어떤 방식으로 흘러야 깨지지 않는지의 사고. record 확장의 손맛 이 단단해지는 단계.
✅ 요구사항
HumanReviewRequestrecord 추가 —kr.spartaclub.aifriends.agent.dto.HumanReviewRequest의record HumanReviewRequest(String characterId, String draftNarration, EvaluationVerdict verdict, Float confidence, String reason) {}형태. characterId / 초안 본문 / 평가자 판정 / 평가자 확신도 / 사유 의 5 필드.EvaluationFeedbackrecord 확장 —Float confidence필드 추가 — 기존 record (String reason/String suggestion등) 에Float confidence한 칸 추가. 값 범위 0.0 ~ 1.0. 기존 직렬화 호환을 위해 Nullable 로 두고 — null 일 땐1.0으로 자연스럽게 흡수.CharacterEvaluatorService의 system 프롬프트 확장 — 기존 평가자 프롬프트에 "verdict / reason / suggestion / confidence (0.0~1.0, 자기 확신도)" 네 항목을 요청. 0.5 미만은 자기 판정이 불확실 / 0.9 이상은 매우 확신 의 기준을 자연어로 가이드.HumanReviewQueue단순 InMemory 한 장 —@Component의 단순 클래스 한 장 (HumanReviewQueue) + 내부에Queue<HumanReviewRequest>또는List<HumanReviewRequest>. 메서드 두 개 —enqueue(HumanReviewRequest)/pollAll().CharacterOptimizationService.optimize(...)분기 추가 —EvaluationVerdict.PASSED분기 직전에confidence < 0.7분기 한 줄. 미달이면humanReviewQueue.enqueue(...)로 흘려보내고,OptimizationResult의 종료 사유에 세 번째 값HUMAN_REVIEW_REQUIRED추가는 금지 (enum 자체 수정 금지). 대신 PASSED 로 흡수 시키되 큐에 row 한 개가 남는 구조.- 단위 테스트 — confidence 0.5 의 평가자 판정 이 사람 검수 큐에 row 한 개를 추가하는 흐름을 코드베이스 테스트로 박기. 코드베이스에만 — 강의 본문엔 넣지 않아요.
- 임계값 결정 —
0.7의 결정 근거 — 답안 마크다운에 왜 0.7 인가 의 결정 근거 한 줄. 너무 낮으면 큐가 안 차고 안전망이 무력 / 너무 높으면 큐가 폭주해 사람 검수 대기열이 운영을 막음 의 균형점. - 확장 (선택) —
HumanReviewEntryJPA Entity — 큐를 영속 (HumanReviewEntryJPA Entity +HumanReviewRepository) 으로 두고 운영 콘솔에서 검수 결과를 다시 흡수하는 채널까지. Day 19 Harness 의 휴먼-인-더-루프 를 미리 익혀보는 단계. - 산출물 — (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 의
EvaluationFeedbackrecord 시그니처를 다시 펼쳐보세요. —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 Entity —
HumanReviewEntryEntity +HumanReviewRepository형태로 영속화. 운영 콘솔 (@GetMapping("/admin/human-review/pending")) 에서 큐를 조회 + 검수 결과를 다시 흡수하는 채널 의 모양. Day 19 Harness 의 휴먼-인-더-루프 의 첫 등장.
제약 / 금지
EvaluationVerdictenum 자체 수정 금지 — 새 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) 평가자 jury — 2 명의 평가자 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 의
EvaluationVerdict3 값 자물쇠 — 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 영속 권한 —
UserToolPermissionEntity + 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 로 박는 게 핵심 함정.
CharacterWorkerService 가 characterId.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대신 rawObject[]변환 —.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.7 은 PASSED 의 정상 범위 (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}")로 외부화하는 방식이 자연스러워요. 답안에서는 상수 분리 만으로 충분. HumanReviewQueue를List<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 의 그림자 만.
실무 개선 포인트 (심화)
HumanReviewEntryJPA Entity + 운영 콘솔 —HumanReviewRepository+@GetMapping("/admin/human-review/pending")엔드포인트로 운영팀이 검수 큐를 조회 + 결과를 다시 흡수 하는 채널까지 자라요. Day 19 Harness 의 휴먼-인-더-루프 의 첫 박힘.- 평가자 jury (확장) — 본 과제는 단일 평가자 + confidence 의 방식이지만 — 2 명의 평가자 LLM 의 합의 / 불일치 시 사람 검수 의 방식으로 자라는 게 생각해볼 주제 2 의 영역이에요. 호출 비용 2 배지만 평가자 정확도가 한 단계 자라는 모양.
- confidence 분포 메트릭 흡수 —
Micrometer의DistributionSummary로 평가자 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 미만으로 떨어지는 신호가 잡히면 — 단일 평가자에
maxIterations5 회로는 부족합니다. 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 단계 모두에서 깨지지 않는 추상화 를 찾아두는 것. 외부 표준이 바뀌어도 구현체만 갈아끼우는 방식이면 진화에 단단합니다."