Day 11. Tool Calling — "주도권이 한 번 뒤집히는 주제. @Tool 어노테이션 한 줄에 LLM 이 내가 짠 Java 메서드를 직접 골라 호출하는 장면, 그리고 에이전트의 가장 작은 시작점" 🛠
안녕하세요! 여러분의 백엔드 가이드, 홍순구 튜터입니다.
Day 10, 정말 단단하게 닫으셨어요.
지난 시간 우리는 5 감 진화의 마지막 한 발 을 놨어요.
텍스트 (Day 1~6) → 그림 (Day 7~8) → 음성 (Day 9) → 영상 (Day 10) 까지 — 캐릭터의 오감이 모두 채워졌고, 비용 게이트 / 비동기 폴링 / 선택 실습 정책 이라는 세 단어가 손에 잡혔어요.
지갑 안전 + 손맛 100% 의 호흡으로 단단히 마무리됐죠.
그런데 지난 시간 마무리에서 제가 결을 한 번 뒤집어 두는 한 줄 을 던져두고 도망갔어요.
"5 감 모두가 캐릭터에게 자리잡았어요. 그런데 — 지금까지의 모든 모달리티는 우리가 LLM 에게 명령하는 흐름이었어요. 우리가 코드를 짜고, LLM 이 그 코드 안에서 호출되는 모양. 다음 시간 — 그 방향이 한 번 뒤집혀요. LLM 이 스스로 도구를 골라 쓰는 풍경. 내가 만든 Java 메서드 한 줄에
@Tool어노테이션 붙이면, LLM 이 알아서 그 함수를 호출하는 장면. 에이전트의 시작점."
오늘이 바로 그 약속의 날이에요.
자, 큰 흐름부터 단단하게 짚고 시작할게요.
오늘은 — 주도권이 LLM 쪽으로 한 번 넘어가는 Day 예요. Day 1~10 까지 우리는 항상 운전대 앞에 앉아 있었어요. chatClient.prompt(...).call(), chatModel.call(...) (Day 8 Vision), imageModel.call(...), videoPollingClient.submit(...) —
모든 호출의 시작 은 우리 코드의 곳 였죠.
오늘은 한 단 다릅니다.
우리는 도구만 등록 해두고 — 언제 어느 도구를 부를지는 LLM 이 결정하는 풍경.
코드 제공자는 우리, 의사 결정자는 LLM.
이 흐름이 손에 잡혀야 다음 주 Day 12~14 의 에이전트 시리즈 가 무리 없이 흘러요.
💡 오늘 수업의 핵심 "@Tool 어노테이션 한 줄에 LLM 이 내 Java 메서드를 스스로 골라 호출 하는 장면. 우리는 도구를 등록 하고, LLM 이 언제 어느 도구를 부를지 결정 합니다. 주도권이 LLM 으로 넘어가는 첫 지점 — 그게 에이전트의 가장 작은 시작점." 🎯
오늘 수업은 한 문장으로 요약돼요.
"Tool Calling 은 LLM 이 우리 앱 내부의 Java 메서드를 직접 호출 하는 메커니즘.
@Tool어노테이션 +@ToolParam으로 함수와 파라미터를 LLM 에게 설명 해두면, LLM 이 사용자 질문을 보고 어느 도구를 부를지 스스로 판단 한다. Day 4 의 구조화 출력 (LLM 이 JSON 을 쓴다) 의 자연스러운 확장 — Day 11 의 Tool (LLM 이 함수를 쓴다). 5 감 위에 손 이 한 단 더 쌓이는 모양이에요."
🙋 한 학생의 걱정
"튜터님, LLM 이 함수를 호출한다 는 게 무슨 말이에요? LLM 은 텍스트만 뱉는 거 아니었어요? 그리고 — 주도권이 LLM 에게 넘어간다 는 게 좀 무서운데요. 무한 루프 돌거나 엉뚱한 함수를 부르면 어떡해요? "
그 걱정 너무 잘 알아요. 세 가지로 짧게 풀어드릴게요.
첫째, LLM 이 함수를 직접 호출 하지는 않아요. 정확히는 — LLM 이 "이 함수를 이런 인자로 부르고 싶어요" 라는 JSON 스타일 응답 을 뱉으면 —
Spring AI 가 그걸 가로채서 실제 우리 Java 메서드를 대신 호출 하고, 결과를 다시 LLM 한테 돌려주는 거예요. 즉 Day 4 에서 본 구조화 출력의 확장 이에요.
Day 4 에서 LLM 이 Quote(content, author) 라는 record 형태의 JSON 을 뱉어줬죠? 그 흐름을 함수 호출 시그니처 로 한 단 더 밀어붙인 게 Tool Calling 이에요.
둘째, 주도권이 넘어간다 는 표현은 비유 예요. 실제로는 — LLM 이 도구 목록을 보고 골라서 호출 요청을 뱉고, 우리 코드가 그걸 실행하고, 결과를 LLM 에게 돌려주는 한 사이클이 도는 구조예요. 우리 코드는 여전히 모든 도구의 본체 를 쥐고 있어요. 어떤 도구를 등록할지 / 도구 안에서 무슨 일을 할지 는 100% 우리 손. 다만 언제 부를지의 타이밍 만 LLM 한테 양보하는 거예요.
주도권을 100% 양도하는 게 아니라 — 의사 결정의 한 단계만 넘기는 거. 🌱
셋째, 무한 루프 / 엉뚱한 호출 걱정 — 맞아요, 그래서 가드레일이 필요해요. 그런데 Day 11 오늘은 가드레일 본격 구현을 하지 않아요. 오늘은 Tool Calling 의 가장 단순한 장면 을 손에 잡는 시간. 가드레일 (maxIterations, 토큰 예산, 도구 호출 횟수 제한) 의 본격 구현은 Day 14 (Agent 패턴 + 자율성 경계) 에서 손으로 직접 짤 거예요.
오늘은 마지막 Step (Step 5) 에서 도구 남용 방지의 그림자 만 맛보기 로 살짝 짚고 넘어가요. 오늘은 Tool 의 흐름 / 다음 시간은 에이전트의 흐름 / 다다음 시간은 가드레일의 흐름 — 이 세 곳가 한 주에 차례로 자리잡아요. 🛡
💡 튜터의 결론 — 오늘 짚을 흐름은 세 단어 예요.
@Tool(메서드를 LLM 에게 노출) /ChatClient.Builder.tools(...)(Day 3~6 에서 익힌 ChatClient 가 도구 를 손에 쥐는 부분) / 주도권 한 단계 양도 (의사 결정의 한 단계만 LLM 으로). 이 셋이 한 줄에 모이면 — Day 4 의 구조화 출력 이 Day 11 의 함수 호출 로 자연스럽게 펼쳐지는 흐름이 보여요.
🎯 ChatClient — 오늘 Tool Calling 의 게이트웨이 로 다시 자리잡습니다
ChatClient 자체는 새 얼굴이 아니에요.
Day 3 부터 ChatClient.Builder 패턴으로 페르소나·프롬프트 템플릿을 조립했고, Day 4~6 에서는 구조화 출력 (.entity(...)), ChatMemory advisor, 스트리밍 (.stream().content()) 까지 모두 ChatClient 위에서 흘렀어요.
그런데 Day 7~9 에서는 멀티모달의 자매 추상화 (ImageModel · TranscriptionModel · TextToSpeechModel) 가 등장하면서 호출 경로가 한 단 옆으로 갈렸어요.
Day 8 Vision 만 UserMessage.media(...) 를 chatModel.call(new Prompt(userMessage)) 로 넘기는 시그니처라 ChatModel 직접 호출 이 다시 무거워졌고요.
그래서 Day 7~10 의 코드 색이 ChatModel 계열로 살짝 기울어 있던 거예요.
오늘 —
ChatClient가 Tool Calling 의 게이트웨이 로 회수돼요. Tool Calling 의 핵심 메서드.tools(...)는ChatClient.Builder(혹은ChatClient.prompt(...)의 fluent chain) 위에만 살아 있어요. 그래서 Day 11 부터 마지막 Day 20 까지 우리의 기본 호출 인터페이스 가 다시ChatClient로 돌아옵니다..tools(...)·.system(...)·.user(...)를 체이닝으로 짚는 패턴이 오늘부터 다시 주력. Day 3~6 에서 익힌ChatClient가 / 오늘 도구 를 손에 쥐고 / 끝까지 함께 갑니다.
이 회수는 오늘 Step 2 에서 손으로 직접 짜볼 거예요. 별 거 아닌 것처럼 보이지만 — 남은 10 Day (Day 11~20) 의 모든 코드가 이 ChatClient 위에서 흘러요. 오늘이 그 조용한 큰 분기점 이에요.
오늘의 진행 순서 — 로드맵
| Step | 주제 | 한 줄 요약 |
|---|---|---|
| Step 1 | Tool Calling 원리 + ReAct 간단 소개 | LLM 이 함수를 부르는 흐름 의 이론 한 지점 |
| Step 2 | ChatClient.tools(...) 회수 + WeatherTool |
비오니까 우산 챙겨 — 도구 등록의 가장 작은 풍경 |
| Step 3 | GameStateTool (save / load) |
DB 영속화 도구 — 부작용 (side effect) 이 있는 도구의 모양 |
| Step 4 | AffinityTool (호감도 조회) |
읽기 전용 도구 — 캐릭터의 내면을 LLM 이 직접 들여다봄 |
| Step 5 | 도구 남용 방지 맛보기 | 가드레일의 왜 — 본격 구현은 Day 14 의 곳 |
| 마무리 | 방금 만든 게 에이전트인가? | Day 12 에이전트 정의로 넘어가는 복선 |
다섯 Step 이 한 줄로 흐르면 — @Tool 한 줄의 등록 → 부작용 있는 도구 / 읽기 전용 도구 두 가족 → 도구 남용의 그림자 까지 Tool Calling 의 전체 흐름 이 한 도시에 모여요. 그리고 마지막 마무리에서 — "방금 만든 게 에이전트인가?" 라는 한 질문이 Day 12 의 문 을 두드릴 거예요.
🛠 Step 1. Tool Calling 의 원리 — **주문서 한 장이 오가는 결**
🙋 한 학생의 진짜 궁금한 한 줄
"튜터님, @Tool 어노테이션 한 줄 만 박으면 LLM 이 정말 알아서 그 함수를 부른다고요? 그 사이에 무슨 일이 진짜로 일어나는 거예요? LLM 은 텍스트 뱉는 모델인데, 함수를 호출 한다는 게 직관적으로 안 잡혀요... "
이게 오늘 가장 단단히 짚어야 하는 부분이에요.
@Tool 한 줄에 마법처럼 함수가 불린다 가 아니라 — 사이에 주문서 한 장이 오가는 작은 사이클 이 숨어 있어요.
이 사이클의 모습을 머리에 정리해두지 않으면, 다음 Step 부터 왜 같은 도구가 두 번 불릴 때도 있고 / LLM 이 도구를 안 부르고 그냥 텍스트로 답할 때도 있는지 가 영원히 미스터리로 남아요.
오늘 이 한 부분만 단단히 짚고 가요.
식당 주문서 비유 — 5 단계 사이클
비유 하나로 풀어볼게요. 손님이 식당에 들어와서 음식을 주문하는 장면 을 떠올려보세요.
손님이 "오늘 서울 날씨 어떤지 보고, 비 오면 우산 챙기라고 알려줘" 라고 웨이터에게 말해요.
웨이터는 곧장 음식을 만들지 않아요.
메뉴판에 "날씨 조회 도구 — 도시명을 받아서 현재 기온과 강수 여부를 알려줌" 이라는 항목이 있는 걸 보고, 주방에 주문서 한 장 을 넘겨요.
"날씨 조회, 도시: 서울" 이라고 적힌 종이 한 장.
주방에서 진짜 일 (외부 API 호출, DB 조회 등) 을 다 해서 "서울, 18°C, 비" 라는 결과를 다시 웨이터에게 돌려주면 — 웨이터가 그제서야 "오늘 서울은 18도에 비 와요. 우산 챙기세요!" 라고 손님에게 자연어로 풀어 답하는 거예요.
이 모습이 그대로 Tool Calling 의 사이클이에요. 다섯 단계로 정리해두면:
(1) 사용자 질문 — 손님이 자연어로 질문 ("서울 날씨 알려줘")
(2) LLM 이 도구 목록 보고 호출 요청 JSON 생성 — 웨이터가 메뉴(등록된 도구 목록)를 훑고 "이건 getCurrentWeather 도구의 부분네" 라고 판단해서 {"tool": "getCurrentWeather", "args": {"city": "서울"}} 같은 JSON 주문서 를 뱉음. 주의 — LLM 이 직접 함수를 부르지 않아요. 단지 "이 함수를 이런 인자로 부르고 싶어요" 라는 JSON 을 뱉을 뿐.
(3) Spring AI 가 가로채서 실제 메서드 실행 — 주방(우리 Java 코드) 이 그 JSON 을 받아서 진짜 WeatherTool.getCurrentWeather("서울") 메서드를 호출. 외부 API 든 DB 든 진짜 부작용이 있는 일 은 전부 여기서 일어남.
(4) 결과를 LLM 에게 돌려줌 — 메서드 반환값 ("서울, 18°C, 비") 을 다시 LLM 의 컨텍스트에 "도구 실행 결과는 이거였어요" 로 끼워 넣음.
(5) LLM 이 최종 답변 생성 — 그 결과를 보고 LLM 이 자연어로 "오늘 서울은 18도에 비 와요. 우산 꼭 챙기세요!" 라고 정리해서 사용자에게 응답.
이 다섯 단계가 한 사용자 요청 안에서 자동으로 한 사이클 돌아요.
우리는 (1) 의 사용자 질문을 받아 ChatClient 에 넘기고 (5) 의 최종 답변을 받아 사용자에게 돌려주는 바깥 두 지점 만 직접 만지는 모양입니다.
(2)~(4) 는 Spring AI 가 모두 알아서 처리 해줘요.
주도권이 LLM 으로 한 단계 넘어갔다 는 비유는, 정확히 (2) 단계의 의사 결정 곳 를 LLM 이 쥔다는 의미.
도구 본체 와 등록 자체 는 여전히 우리 손이에요. 🌱
@Tool / @ToolParam 의 진짜 역할 — 설명이 곧 프롬프트
여기서 오늘 가장 미묘한 한 부분 를 짚어둘게요. @Tool 과 @ToolParam 의 description 은 단순한 주석이 아니라 — 프롬프트의 일부로 LLM 에게 전달되는 진짜 텍스트예요.
의사 코드로 한 줄만 보여드리면:
@Tool(description = "지정한 도시의 현재 날씨를 조회한다")
public String getCurrentWeather(
@ToolParam(description = "조회할 도시명 (예: 서울, 부산)") String city
) { ... }
이 description 두 줄은 우리에게 보이는 코드 주석 이 아니라 — Spring AI 가 LLM 에게 시스템 프롬프트의 일부로 그대로 밀어넣어요.
LLM 은 사용자 질문을 보면서 "등록된 도구 중에 이런 설명이 붙은 게 있구나. 사용자가 날씨 라고 했으니 이 도구를 부르면 되겠네" 라고 판단하는 거예요.
즉 — description 을 대충 쓰면 LLM 이 도구를 못 찾고 / description 을 정확하게 쓰면 LLM 이 정확하게 골라요. 이 흐름이 오늘 가장 비직관적인 지점 예요.
어노테이션 = 단순 마커, 가 아니라 어노테이션 = LLM 에게 보내는 문서.
(2) 단계의 LLM 이 도구 목록을 훑을 때 진짜로 이 텍스트를 읽고 판단 한다는 사실 — 한 번 기억해두세요.
도구 description 작성은 기능 명세 작성 이 아니라 LLM 향 마케팅 문구 작성 에 가까워요. 🎯
ReAct 패턴 — 생각 → 행동 → 관찰 → 다시 생각 의 흐름
여기서 한 번 살짝만 짚고 갈 단어가 하나 있어요.
ReAct (Reasoning + Acting) 패턴. 학계에서 2022 년 생긴 흐름인데 — LLM 이 한 번에 답을 내지 않고, 생각 → 도구 호출 → 결과 관찰 → 다시 생각 의 루프를 도는 거예요.
위 다섯 단계 사이클에서 (2) → (3) → (4) → (5 가 아니라 다시 2 로!) 가 한 번 더 돌아요.
예를 들어 LLM 이 날씨 도구를 부르고 결과를 본 다음 "아 비가 온다네. 그럼 오늘 추천 일정 도구도 하나 더 불러야겠다" 라고 추가 도구 호출 JSON 을 또 뱉을 수 있어요. 그러면 (2)~(4) 가 한 번 더 돌고, 그 결과까지 본 뒤에야 (5) 의 최종 답변이 나오는 모습.
오늘 Day 11 은 이 루프가 한 사이클 / 길어야 두 사이클 에서 끝나는 얕은 단계만 다뤄요.
ReAct 의 본격 장면 — 루프가 5 번, 10 번 도는 곳, 거기서 무한 루프 / 토큰 폭발 이 터지는 부분 — 는 Day 13 (워크플로 패턴) → Day 14 (에이전트 패턴 + 자율성 경계) 에서 본격적으로 펼쳐져요.
오늘은 "아, LLM 이 한 사이클 안에 도구를 부르고 결과를 보는 흐름이 있구나" 정도만 손에 묻혀두면 충분해요.
💡 튜터의 결론 — 주도권의 진짜 의미
오늘 주도권이 LLM 으로 넘어간다 는 표현, 정확히 짚어드릴게요. 도구 본체 는 우리 손, 도구 등록 도 우리 손, 도구 결과 도 우리 손. 단지 — 언제 어느 도구를 부를지의 호출 타이밍 만 LLM 손. 이 한 단계 양보가 Tool Calling 의 핵심이고, 에이전트의 가장 작은 시작점 이에요. 가드레일 (
maxIterations/ 토큰 예산 / 호출 횟수 제한) 의 본격 구현은 Day 14 의 곳 — 오늘은 그 왜 만 마지막 Step 5 에서 살짝 맛보고 갈 거예요. 오늘 Day 11 의 목표는 "도구의 모습" 한 부분만 단단히 짚는 것.
이론 한 단계가 자리잡았으니, 이제 손에 잡는 시간 로 넘어갑시다. Step 2 에서 — Day 3~6 에서 익혔던 ChatClient 가 .tools(...) 메서드 위에 도구를 얹어 돌아오는 풍경 과 함께, 비오니까 우산 챙겨 라고 답해주는 첫 도구 WeatherTool 을 손으로 직접 짜볼게요.
Step 2. `ChatClient.tools(...)` 회수 + `WeatherTool` — **비오니까 우산 챙겨** 의 모습
자, 이제 진짜 손에 잡는 시간이에요.
Step 1 에서 주문서 한 장이 오가는 흐름 이 머릿속에 자리잡혔으니, 이번엔 그 흐름을 코드로 한 번 굴려보는 시간입니다.
오늘 이 한 Step 안에 두 가지 큰 사건 이 동시에 터져요.
하나는 — Day 3~6 에서 익혔던 ChatClient 가, Day 7~10 의 멀티모달 자매 추상화 (ImageModel/TranscriptionModel/TextToSpeechModel) 와 Day 8 의 chatModel.call() 멀티모달 호출을 거쳐서 — 오늘 .tools(...) 메서드 위에 도구 를 얹고 다시 주력 인터페이스로 돌아오는 풍경.
또 하나는 — @Tool 어노테이션이 붙은 진짜 Java 메서드 한 개를 LLM 에게 넘겨서, LLM 이 사용자 질문을 보고 그 메서드를 자기 손으로 골라 부르는 풍경.
두 사건이 한 곳에 모인다는 건 — 오늘이 우리 강의의 호출 인터페이스가 Tool Calling 의 게이트웨이로 회수되는 분기점 이라는 뜻이에요.
ChatClient.tools(...) — Tool Calling 의 게이트웨이
먼저 짧게 한 부분만 짚고 갈게요.
ChatClient 자체는 새 얼굴이 아니에요. Day 3 에서 ChatClient.Builder 패턴으로 페르소나를 조립했고, Day 4~6 에서는 구조화 출력 (.entity(...)), ChatMemory advisor, 스트리밍 (.stream().content()) 까지 모두 ChatClient 위에서 흘렀어요.
한 번 짧게 떠올려 볼게요.
// Day 3~6 에서 익힌 — ChatClient 의 기본형
String content = chatClient.prompt()
.user("오늘 서울 날씨 어때?")
.call()
.content();
그러다 Day 7~9 에서는 멀티모달 자매 추상화 (ImageModel · TranscriptionModel · TextToSpeechModel) 가 등장하면서 호출 경로가 한 단 옆으로 갈렸고, Day 8 Vision 에서만 UserMessage.media(...) 를 넘기느라 chatModel.call(new Prompt(userMessage)) 의 시그니처가 잠깐 무거워졌어요.
그래서 Day 7~10 의 코드 색이 ChatModel 계열로 살짝 기울어 있었던 거예요.
오늘 — ChatClient 가 Tool Calling 의 게이트웨이 로 회수돼요. 핵심 메서드 .tools(...) 가 ChatClient.Builder (혹은 ChatClient.prompt(...) 의 fluent chain) 위에만 살아 있어서, 도구를 LLM 에게 노출하는 모든 호출 은 ChatClient 를 통해야 해요.
// Day 11 부터 — ChatClient 가 .tools(...) 를 손에 쥡니다
String content = chatClient.prompt()
.tools(weatherTool) // ← 오늘의 새 메서드
.user("오늘 서울 날씨 어때?")
.call()
.content();
ChatClient.Builder 패턴 위에서 .defaultSystem(...), .defaultTools(...), .user(...), .tools(...) 같은 세팅 부분 가 체이닝 으로 박히는 흐름.
시스템 프롬프트 / 도구 / 사용자 프롬프트 가 각자 자기 곳에 깔끔하게 들어가요.
별 거 아닌 것처럼 보이지만 — 오늘부터 Day 20 마지막 날까지 우리의 모든 LLM 호출은 이 ChatClient 위에서 흘러요. 오늘이 그 조용한 큰 회수점 이에요.
WeatherTool — @Tool 어노테이션 한 줄 이 박히는 지점
자, 이제 본 게임이에요. 오늘의 첫 도구 WeatherTool 을 만들어볼게요. 도구라고 하지만 실제로는 그냥 평범한 Spring @Component 클래스에 메서드 하나 + 어노테이션 한 줄 이 전부예요. 코드를 그대로 가져와볼게요.
package kr.spartaclub.aifriends.tool;
import kr.spartaclub.aifriends.tool.dto.WeatherInfo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
@Component
public class WeatherTool {
private static final Logger log = LoggerFactory.getLogger(WeatherTool.class);
@Tool(description = "특정 도시의 현재 날씨(하늘 상태, 기온, 강수확률) 를 조회한다. "
+ "사용자가 옷차림·외출 여부·우산 챙기기 같은 결정을 도와달라고 할 때 호출하라.")
public WeatherInfo getCurrentWeather(
@ToolParam(description = "날씨를 조회할 도시명. 예: '서울', '부산', '도쿄'")
String city
) {
log.info("[WeatherTool] getCurrentWeather invoked — city={}", city);
// 강의용 stub — 실제 기상 API 호출 대신 도시별 고정 응답을 돌려준다.
return switch (city) {
case "서울" -> new WeatherInfo("서울", "흐림", 23, 60);
case "부산" -> new WeatherInfo("부산", "맑음", 27, 10);
case "제주" -> new WeatherInfo("제주", "비", 21, 80);
default -> new WeatherInfo(city, "맑음", 22, 20);
};
}
}
코드 한 번 같이 읽어볼게요.
일단 클래스 자체는 평범한 @Component 예요.
Spring 빈으로 등록되는, 우리가 매일 쓰는 그 패턴.
특별한 건 메서드 위에 박힌 @Tool 어노테이션 한 줄과 파라미터 앞의 @ToolParam 한 줄.
이 두 어노테이션의 description 텍스트가 — Step 1 에서 짚었던 그 가르침대로 — 진짜로 LLM 에게 시스템 프롬프트의 일부로 흘러 들어가요. "옷차림·외출 여부·우산 챙기기 같은 결정을 도와달라고 할 때 호출하라" 라는 한 줄, 이게 LLM 의 의사 결정 회로에 그대로 흘러 들어가는 텍스트예요.
그러니까 — 도구 description 작성은 기능 명세 작성 이 아니라 LLM 향 마케팅 문구 작성 에 가까워요, 한 번 더 회수해드려요. 🎯
그리고 메서드 본체는 — 학생 실습용 stub.
도시명 네 가지에 대한 고정 응답이 자리잡고 있고, 그 외엔 기본값을 돌려주는 평범한 switch 문.
왜 stub 이냐 — 학생 실습이 외부 기상 API 키 없이도 굴러가야 하기 때문이에요.
오늘의 학습 포인트는 "기상 데이터의 정확성" 이 아니라 "@Tool 어노테이션 → ChatClient 가 자동 호출" 흐름 자체 예요.
"이 곳를 RestClient 호출로 갈아끼우면 진짜 도구가 된다" 정도의 감만 잡으면 충분해요.
학생들이 나중에 자기 프로젝트에서 실제 OpenWeather API 같은 걸 박을 때 "아 그때 그 stub 곳에 RestClient 한 줄만 들어가면 되겠구나" 가 자연스럽게 떠오르도록.
switch 윗줄에 박힌 log.info(...) 한 줄도 같이 짚을게요.
이게 오늘의 가장 단순한 디버그 신호 예요.
LLM 이 자율 판단으로 이 메서드를 디스패치하는 순간, 콘솔에 [WeatherTool] getCurrentWeather invoked — city=서울 한 줄이 그대로 찍혀요. 우리는 Service 에서도 호출한 적이 없는데 이 로그가 보이면 — "아 LLM 이 진짜로 도구를 골라 부른 거구나" 가 한 줄로 확정돼요.
응답 바디에 디버그 필드를 끼워 넣지 않아도, 도구 메서드 안쪽의 로그 한 줄이면 호출의 증거 가 손에 잡혀요.
운영에서도 그대로 살릴 수 있는, 가장 깔끔한 검증 부분.
반환 타입 WeatherInfo 도 평범한 record 한 부분이에요.
public record WeatherInfo(
String city,
String condition,
int temperatureCelsius,
int precipitationChance
) { }
Spring AI 가 LLM 에 도구 스키마를 만들어 보낼 때 — 이 record 의 필드 이름과 타입을 자동으로 JSON Schema 로 변환 해줘요.
우리가 학생이 이름만 직관적으로 짓는 그 손버릇 그대로, LLM 도 그 의미를 읽어줘요.
temperatureCelsius 라고 이름 박아두면 LLM 이 "아 이건 섭씨 온도구나" 까지 알아듣는 거죠.
이름 짓기가 곧 LLM 과의 계약서 작성 이라는 가르침, 여기서도 한 번 묻혀가요.
💡 튜터의 결론 —
description은 코드 주석이 아니다오늘 가장 비직관적인 한 부분이에요.
@Tool(description = "...")의 텍스트는 우리에게 보이는 주석 이 아니라 LLM 에게 시스템 프롬프트의 일부로 그대로 전달되는 진짜 텍스트 예요. description 을 대충 "날씨 조회" 라고만 박으면 LLM 이 도구를 헷갈려 하고, "옷차림·우산 결정을 도와달라고 할 때" 같은 호출 트리거 문구 까지 박아두면 LLM 이 정확하게 골라요. 어노테이션 = 단순 마커 가 아니라, 어노테이션 = LLM 향 문서. 이 한 줄 기억해두세요.
weatherToolChatClient 빈 — 시나리오마다 ChatClient 를 따로 두는 결
자, 이제 오늘의 핵심 설계 의사 결정 이 박히는 부분이에요. 도구를 만들었으면 어딘가에 등록해야 LLM 이 그 존재를 알 수 있죠. 우리는 Tool Calling 데모 전용 ChatClient 빈을 따로 하나 만들어요.
package kr.spartaclub.aifriends.tool.config;
import kr.spartaclub.aifriends.tool.WeatherTool;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ToolChatClientConfig {
@Bean
public ChatClient weatherToolChatClient(ChatClient.Builder builder, WeatherTool weatherTool) {
return builder
.defaultSystem("""
너는 유저에게 오늘의 날씨와 옷차림을 추천해주는 친근한 AI 친구야.
반말로 따뜻하게 답하고, 답변은 3문장 이내로 간결하게 해.
도시별 실제 날씨가 필요하면 등록된 도구(getCurrentWeather)를 자유롭게 호출해.
""")
.defaultTools(weatherTool)
.build();
}
// gameStateChatClient · affinityChatClient 빈은 Step 3 / 4 에서 자리 잡습니다.
}
코드 한 번 같이 짚어볼게요.
ChatClient.Builder 가 자동 주입돼요 — Spring AI 가 알아서 챙겨주는 빌더.
그 빌더 위에 세 가지를 체이닝 으로 박아요.
첫째 .defaultSystem(...) 으로 시스템 프롬프트 (페르소나 + 답변 톤 + 도구 호출 가이드) 를 박고, 둘째 .defaultTools(weatherTool) 한 줄로 도구를 등록 하고, 마지막 .build() 로 ChatClient 인스턴스 한 개 를 만들어요.
이 defaultTools(weatherTool) 한 줄이 — Step 1 의 다섯 단계 사이클 중 (2) 단계, LLM 이 도구 목록을 훑고 주문서를 뽑는 지점 에 우리가 박아 넣는 메뉴판이에요.
도구가 등록되는 순간 LLM 입장에선 "아 나한테 getCurrentWeather(String city) 라는 함수 한 곳가 주어졌구나" 가 시그니처 + description 째로 보이는 거예요.
그런데 — 여기서 한 번 묻고 싶은 단계입니다. 왜 weatherToolChatClient 라는 이름의 별도 빈을 만들었지? Day 3 에서 만든 soulmateChatClient (페르소나 ChatClient) 에 그냥 도구를 박아도 되는 거 아닌가? 이게 오늘의 큰 가르침 이에요.
🎯 빈 분리 원칙 — 도구는 시나리오 단위로 끊어 등록한다
Day 3 의 페르소나 ChatClient 에 모든 도구를 다 등록해두면 어떤 일이 벌어지냐 — Day 5 의 ChatMemory 데모도, Day 6 의 Streaming 데모도, 호출할 때마다 도구 시그니처 전체를 LLM 컨텍스트로 매번 흘려 보내요. 토큰 비용 낭비. 디버깅도 어려워져요 — "방금 호출은 어느 도구를 쓸 수 있었지?" 가 한 화면에 안 들어와요. 그래서 우리는 도구가 필요한 시나리오마다 ChatClient 빈을 따로 두는 길로 가요. Step 2 의
weatherToolChatClient/ Step 3 의gameStateChatClient/ Step 4 의affinityChatClient— 세 빈을 각자 다른 도구만 장착해서 따로 둘 거예요. 도구 스코프 격리 / 토큰 비용 격리 / 디버그 명료성 세 마리 토끼를 한 번에 잡는 설계.
요청·응답 DTO + Service — 호출부는 평소처럼
빈을 만들었으니 컨트롤러까지 짚는 단계입니다. 먼저 요청 / 응답 DTO 두 개부터 짧게.
public record ToolChatRequest(
@NotBlank(message = "도시명을 입력해 주세요.")
String city
) { }
public record ToolChatResponse(
String city,
String aiMessage
) { }
요청은 도시명 한 줄, 응답은 그 도시 + LLM 의 자연어 답변 두 부분.
응답 바디에 stub 값(condition / temperatureCelsius 같은) 을 디버그용으로 끼워 넣지 않아요. "도구가 진짜 호출됐는지" 의 증거는 — 방금 박은 WeatherTool 안의 log.info 한 줄이 콘솔에 찍히는 것 으로 충분하기 때문이에요.
운영 코드에서도 자연어 응답 한 줄만 내려가는 게 정석이고, 우리도 그 방식을 그대로 따라가요.
응답 바디는 유저가 받을 진짜 콘텐츠 만, 검증은 로그 라는 별도 채널에서.
두 책임이 깔끔하게 분리되는 지점예요.
이제 Service. 진짜 가벼워요.
@Service
public class WeatherToolChatService {
private final ChatClient weatherToolChatClient;
public WeatherToolChatService(ChatClient weatherToolChatClient) {
this.weatherToolChatClient = weatherToolChatClient;
}
public ToolChatResponse chat(String city) {
String userPrompt = "오늘 " + city + " 날씨에 맞춰서 옷차림을 추천해줘.";
String aiMessage = weatherToolChatClient.prompt()
.user(userPrompt)
.call()
.content();
return new ToolChatResponse(city, aiMessage);
}
}
여기 오늘의 미묘한 마법 한 곳 가 자리잡아 있어요.
호출부 보세요 — weatherToolChatClient.prompt().user(userPrompt).call().content().
이게 다예요.
어디에도 "도구를 호출해" 라는 코드가 없어요. 우리 코드는 그냥 "오늘 서울 날씨에 맞춰서 옷차림을 추천해줘" 라는 사용자 프롬프트를 한 줄 던질 뿐이에요.
그런데 — weatherToolChatClient 빈은 이미 .defaultTools(weatherTool) 로 도구를 장착하고 있어요.
LLM 이 그 프롬프트를 보고 "어 날씨 결정이네 → getCurrentWeather 호출해야겠다" 라고 자기 손으로 판단 해서 Spring AI 가 그 호출을 가로채고, 결과를 다시 LLM 에게 흘려서 최종 자연어 답변까지 만든 뒤에 우리에게 content() 로 한 줄 돌려줘요.
호출부는 도구 호출이 일어났는지조차 알 필요가 없어요. Step 1 에서 박은 (2)~(4) 단계는 Spring AI 가 알아서 처리 의 진짜 풍경이 이 한 줄 안에 다 자리잡아 있어요.
Service 코드도 짧고 깨끗 해요.
ChatClient 한 부분만 의존성으로 받고, chat(city) 한 메서드는 프롬프트 만들기 → ChatClient 호출 → record 한 줄 반환 세 단계가 전부.
도구 호출은 ChatClient 와 LLM 사이에서 알아서 굴러가니까, 우리 Service 가 WeatherTool 을 알 필요도 없어요. 진짜 운영 코드가 이래요 — 호출부는 도구의 존재를 모른 채로 자연어 응답만 받는다.
ToolCallingController — @Valid + ApiResponse 래핑
마지막 컨트롤러. 본 강의의 표준 응답 패턴 — ApiResponse<T> 래핑 방식을 그대로 따라요.
@RestController
public class ToolCallingController {
private final WeatherToolChatService service;
public ToolCallingController(WeatherToolChatService service) {
this.service = service;
}
@PostMapping("/api/tool/weather-chat")
public ResponseEntity<ApiResponse<ToolChatResponse>> weatherChat(
@Valid @RequestBody ToolChatRequest request
) {
ToolChatResponse response = service.chat(request.city());
return ResponseEntity.ok(ApiResponse.success(response));
}
// game-state-chat / affinity-chat 엔드포인트는 Step 3 / 4 의 단계입니다.
}
컨트롤러는 최대한 얇게. @Valid 로 빈 도시명 검증을 GlobalExceptionHandler 에 위임하고, 정상 응답은 ApiResponse.success(...) 로 한 번 감싸 내려요.
비즈니스 로직은 단 한 줄도 없어요. — Service 한 줄 호출이 끝.
컨트롤러는 입력 검증 + 응답 래핑 두 지점만 책임져요.
🙋 한 학생의 진짜 궁금한 한 줄
"튜터님, 그러면 LLM 이 진짜로
getCurrentWeather를 호출했는지 는 어떻게 확인해요? 단위 테스트로도 안 잡히고, ChatClient 모킹도 안 했잖아요. 혹시 Spring AI 가 그냥 프롬프트만 넘기고 도구는 안 부른 채로 답변하는 거 아니에요?"좋은 질문이에요. 답은 — 수동 smoke + 도구 메서드 안쪽 로그 관측 두 곳로 확인해요. (1) 곧 굴려볼 curl 호출 직후, 앱 콘솔에
[WeatherTool] getCurrentWeather invoked — city=서울한 줄이 찍혀 있으면 — LLM 이 자율 판단으로 우리 메서드를 디스패치한 결정적 증거. 우리 Service 에선WeatherTool을 호출한 적이 없으니, 그 로그는 오직 Spring AI 가 LLM 의 호출 요청을 받아 디스패치했을 때만 찍혀요. (2) 응답의aiMessage안에 우리 stub 의 숫자 (23 도) 나 키워드 (흐림 / 우산) 가 등장하면 LLM 이 stub 결과를 자연어로 풀어냈다 는 추가 신호. 두 부분가 동시에 보이면 — (도구 호출 → 결과 수신 → 자연어 가공) 한 사이클이 한 바퀴 돈 모습이 그대로 보여요.
./run.sh 한 줄 + curl 한 번
자, 이제 손으로 한 번 굴려봅시다. 코드베이스 기동은 항상 그대로예요.
cd lecture-source-code/ai-friends
./run.sh
앱이 8080 포트에 뜨면 — curl 한 번 날려요.
curl -X POST http:/localhost:8080/api/tool/weather-chat \
-H "Content-Type: application/json" \
-d '{"city":"서울"}'
응답이 대충 이런 모양으로 와요 (LLM 이 만들어낸 자연어라 매번 살짝 달라요 — 도시 라벨과 자연어 답변 두 지점만 떨어져요).
{
"success": true,
"data": {
"city": "서울",
"aiMessage": "오늘 서울은 흐리고 23도래! 강수확률도 60% 정도 되니까 가벼운 우산 하나 챙겨가자~"
}
}
그리고 진짜 결정적인 증거 는 응답 바디가 아니라 앱 콘솔 에 떨어져요. ./run.sh 로 띄운 앱 로그에 다음 한 줄이 박혀 있어요.
INFO k.s.a.tool.WeatherTool : [WeatherTool] getCurrentWeather invoked — city=서울
우리 Service 에선 WeatherTool 을 호출한 적이 없죠? 그런데 이 로그가 찍혔다는 건 — Spring AI 가 LLM 의 호출 요청을 받아 우리 메서드를 대신 부른 단계라는 결정적 증거예요. 같은 정보를 응답 바디에 끼워 넣지 않아도, 도구 안쪽 로그 한 줄 이 호출의 흔적을 그대로 보관해줘요.
여기서 오늘의 가장 짜릿한 한 곳 — aiMessage 안에 "23도" 와 "우산" 이 자리잡고 있죠? 우리는 코드 어디에도 "23도라고 답해" / "우산 챙기라고 해" 라고 명령한 적이 없어요.
그냥 "오늘 서울 날씨에 맞춰서 옷차림을 추천해줘" 한 줄을 던졌을 뿐.
그런데 LLM 이 — @Tool description 을 읽고 → getCurrentWeather("서울") 을 자기 손으로 골라 호출 → 흐림/23도/60% stub 을 받아 → 캐릭터 톤으로 풀어낸 자연어를 만들어낸 거예요.
이게 — 주도권이 한 단계 LLM 으로 넘어간다 는 흐름의 진짜 모습이에요. Step 1 의 다섯 단계 사이클이 한 번의 curl 안에서 자동으로 한 바퀴 돈 단계입니다. 🎯
자, 이제 Tool Calling 의 가장 작은 시연 이 손에 잡혔어요.
@Tool 한 줄, ChatClient 한 부분, defaultTools(...) 한 줄, 빈 분리 한 단계 — 네 지점가 합쳐져서 LLM 이 우리 Java 메서드를 자기 손으로 골라 부르는 풍경이 진짜로 굴러갔죠.
그런데 — 오늘의 도구는 모두 부작용이 없는 곳 였어요.
stub 데이터 조회 한 번.
읽어만 가는 도구.
다음 Step 3 에선 — 부작용이 있는 도구, 즉 DB 에 진짜로 쓰는 도구 (GameStateTool 의 saveGameState / loadGameState) 로 한 단계 더 들어가볼게요.
LLM 이 자기 판단으로 우리 DB 에 INSERT 를 날린다 는 게 어떤 풍경인지 — 그 단계에서 멱등성 / 트랜잭션 경계 / 도구 부작용 책임 이라는 새 단어들이 한 곳에 모여요.
Step 3. GameStateTool — **부작용 있는 도구, save / load 두 부분**
자, 이제 한 단계 더 깊이 들어가요.
Step 2 의 WeatherTool 은 조회 전용 stub 이었어요.
도시명 받아서 고정값 한 개 뱉고 끝.
함수 안쪽이 외부 세계에 어떤 흔적도 안 남기는 함수, 우리는 그걸 순수 함수 (pure function) 라고 불러요.
그런데 Step 3 의 GameStateTool 은 — 도구 함수 안에서 진짜로 DB 에 INSERT 가 일어나는 부분이에요.
LLM 이 자기 판단으로 우리 DB 에 새 row 한 줄을 짜는 — 부작용 (side effect) 이 있는 도구의 첫 등장.
그리고 한 줄 더 큰 사건.
오늘 이 Step 안에 @Tool 어노테이션이 두 개 박힌 한 도메인 이 등장해요.
저장 도구 (saveGameState) 와 회상 도구 (loadGameState).
한 클래스 안에 함수 두 지점 가 나란히 자리잡고 있고 — 어느 쪽을 부를지 LLM 이 사용자 질문을 보고 골라요. "여기까지 저장해줘" 라고 하면 저장 도구로 / "저번에 어디까지 했지?" 라고 하면 회상 도구로.
이 분기점이 LLM 의 의사 결정 회로 안에서 굴러가는 장면, 오늘 손에 잡는 예요.
GameStateEntry + Repository — append-only 패턴, Day 5 회수
먼저 DB 부분의 모습 부터 짚을게요. 도구가 진짜로 INSERT 를 박을 테이블 — game_state_entry 의 JPA 엔티티예요.
@Entity
@Table(name = "game_state_entry", indexes = {
@Index(name = "idx_game_state_player_created", columnList = "player_id, created_at")
})
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class GameStateEntry {
/** PK */
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** 플레이어 ID (FK 역할 — 본 강의 범위에선 단순 Long) */
@Column(nullable = false)
private Long playerId;
/** 마지막으로 유저가 보낸 메시지 */
@Column(nullable = false, columnDefinition = "TEXT")
private String lastUserMessage;
/** 마지막으로 캐릭터가 보낸 메시지 */
@Column(nullable = false, columnDefinition = "TEXT")
private String lastAiMessage;
/** 대화가 몇 턴까지 진행됐는지 (저장 시점 기준) */
@Column(nullable = false)
private int turnCount;
/** 저장 시각 — 같은 playerId 의 row 들 중 가장 최근 1건을 조회할 때 정렬 키 */
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
}
코드 한 번 같이 읽어요.
평범한 JPA 엔티티 예요 — @Entity + @Table + 컬럼 다섯 개 + @PrePersist 로 createdAt 자동 채움.
그런데 오늘의 핵심 가르침 이 한 줄 자리잡아 있어요.
@Column(updatable = false) 가 박힌 createdAt. 그리고 id 외에는 모든 필드가 setter 없음 (Lombok @Getter 만). 이게 무엇을 의미하느냐 — 이 엔티티는 update 를 안 한다. 같은 playerId 로 저장 요청이 들어와도 기존 row 를 덮어쓰지 않고 매번 새 row 한 줄을 INSERT 만 해요.
append-only 패턴입니다.
Day 5 회수 — 시간선 보존 의 손맛, 한 번 더 박기
기억나세요? Day 5 에서
JdbcChatMemoryRepository가chat_memory테이블에 대화를 한 줄씩 append 만 하던 모습. update 가 아니라 매번 새 row 쌓기. 그 흐름이 시간선을 통째로 보존 하는 체험이었죠. 오늘GameStateEntry도 똑같이 가요. 같은 playerId 라도 매번 새 row 한 줄. "이 playerId 의 게임 진행 흐름을 시간순으로 다시 살펴보고 싶다" 같은 요구가 나중에 생겨도 — 시간선이 그대로 살아있어서 손쉽게 회수돼요. update = 과거를 지우는 방식 / append = 과거를 노트에 한 줄씩 더하는 방식. 오늘 이 한 곳에서 Day 5 의 체험이 한 번 더 자리잡아요.
그리고 인덱스 한 지점 — @Index(columnList = "player_id, created_at").
이건 조회 쪽 최적화 예요.
뒤에서 곧 보겠지만, 도구가 호출하는 쿼리는 "이 playerId 의 가장 최근 1건" 한 부분만 있어요.
복합 인덱스 (player_id, created_at) 가 자리잡고 있으면 — playerId 로 좁히고 → created_at desc 로 정렬 → 1건 가져오기 가 인덱스 스캔 한 번에 끝 나요.
ai-friends 컨벤션 한 줄만 짚고 갈게요.
우리 코드베이스는 application.yml 에 spring.jpa.hibernate.ddl-auto: update 로 잡혀 있어서 — Flyway 마이그레이션 파일 없이 엔티티 클래스만 추가하면 앱 기동 시 테이블이 자동 생성 돼요.
운영 환경에선 절대 권장하지 않는 방식이지만 (Day 20 Observability 단계에서 한 번 더 짚을 거예요), 학습용 코드베이스 에선 충분해요.
Day 5 의 chat_memory 테이블도 같은 패턴으로 자랐어요.
이제 Repository.
public interface GameStateEntryRepository extends JpaRepository<GameStateEntry, Long> {
/**
* playerId 의 가장 최근 저장본을 조회한다 (없으면 Optional.empty).
*
* <p>Spring Data JPA 의 {@code findFirstBy...OrderBy...Desc} 컨벤션은
* "정렬 후 1건" 을 의미한다 — 별도 LIMIT 1 직접 작성 없이 자동으로 LIMIT 절이 붙는다.</p>
*/
Optional<GameStateEntry> findFirstByPlayerIdOrderByCreatedAtDesc(Long playerId);
}
메서드 한 단계입니다.
findFirstByPlayerIdOrderByCreatedAtDesc — Spring Data JPA 의 메서드 이름 컨벤션 이 "정렬해서 1건" 까지 한 줄에 표현해줘요.
우리가 SQL LIMIT 1 을 직접 안 써도 — Spring Data 가 알아서 LIMIT 1 을 붙여줘요.
append-only 테이블에서 가장 최근 1건만 보기 라는 의도가, 메서드 이름 한 줄로 완성되는 모습.
GameStateTool — @Tool 두 곳 가 한 클래스에
자, 이제 본 게임. 오늘의 두 도구 가 생긴 부분이에요.
package kr.spartaclub.aifriends.tool;
import kr.spartaclub.aifriends.domain.GameStateEntry;
import kr.spartaclub.aifriends.repository.GameStateEntryRepository;
import kr.spartaclub.aifriends.tool.dto.GameStateSnapshot;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
@Component
public class GameStateTool {
private final GameStateEntryRepository repository;
public GameStateTool(GameStateEntryRepository repository) {
this.repository = repository;
}
@Tool(description = "현재 게임 상태(마지막 유저 메시지, 마지막 캐릭터 응답, 진행한 턴 수) 를 저장한다. "
+ "유저가 '여기까지 저장해줘', '오늘 대화 기억해놔' 같은 요청을 할 때 호출하라.")
public void saveGameState(
@ToolParam(description = "플레이어 ID. 캐릭터의 호출자 식별자.")
Long playerId,
@ToolParam(description = "마지막으로 유저가 보낸 메시지 원문")
String lastUserMessage,
@ToolParam(description = "마지막으로 캐릭터가 답한 메시지 원문")
String lastAiMessage,
@ToolParam(description = "지금까지 진행된 대화의 턴 수 (저장 시점 기준)")
int turnCount
) {
GameStateEntry entry = new GameStateEntry(
null, playerId, lastUserMessage, lastAiMessage, turnCount, null);
repository.save(entry);
}
@Tool(description = "해당 playerId 의 가장 최근 게임 상태를 불러온다. "
+ "유저가 '저번에 어디까지 했지?', '우리 무슨 얘기했더라?' 같이 물으면 호출하라. "
+ "기록이 없으면 found=false 인 빈 Snapshot 이 돌아오니, 그때는 캐릭터가 '기억 안 나' 라고 자연스럽게 답해라.")
public GameStateSnapshot loadGameState(
@ToolParam(description = "조회할 플레이어 ID")
Long playerId
) {
return repository.findFirstByPlayerIdOrderByCreatedAtDesc(playerId)
.map(entry -> new GameStateSnapshot(
true,
entry.getLastUserMessage(),
entry.getLastAiMessage(),
entry.getTurnCount()))
.orElseGet(GameStateSnapshot::empty);
}
}
코드 한 번 같이 짚어볼게요.
클래스 자체는 Step 2 의 WeatherTool 과 똑같이 평범한 @Component 예요.
단지 — 메서드가 두 개 자리잡고 있고, 둘 다 위에 @Tool 어노테이션이 한 줄씩 올라가 있는 흐름.
이게 바로 한 도메인의 도구가 둘 이상 박히는 첫 부분 예요. Spring AI 는 — defaultTools(gameStateTool) 한 줄에 클래스 전체를 스캔해서 @Tool 이 붙은 모든 메서드 를 LLM 에게 노출해줘요.
즉 LLM 입장에선 함수 두 지점가 메뉴판에 동시에 떠 있는 모양입니다.
그리고 Step 1 에서 짚었던 가르침 — description 은 LLM 향 마케팅 문구 작성, 두 도구 description 을 비교해보세요.
saveGameState 쪽엔 "유저가 '여기까지 저장해줘', '오늘 대화 기억해놔' 같은 요청을 할 때 호출하라", loadGameState 쪽엔 "유저가 '저번에 어디까지 했지?', '우리 무슨 얘기했더라?' 같이 물으면 호출하라".
각 description 안에 호출 트리거 문구를 거의 자연어 그대로 박아두면 — LLM 이 사용자 발화와 trigger 문구를 의미 매칭 해서 정확하게 분기해요.
어느 단계에서 어느 도구가 불릴지 의 결정이 description 안에 자리잡은 거예요.
saveGameState 본체 보세요.
진짜 평범한 Repository 호출 한 줄. repository.save(entry).
도구 함수 안에서 우리가 평소 Spring 컴포넌트처럼 — Repository · Service · 외부 API 어떤 것이든 자유롭게 호출할 수 있어요.
LLM 입장에선 그저 함수 시그니처 + JSON 응답 일 뿐이고, 그 함수 안에서 무엇을 하든 LLM 은 알 필요가 없어요. 도구 호출이 단순한 stub 을 넘어 우리 앱의 진짜 기능과 결합되는 순간 — Step 2 의 WeatherTool 과 한 단계 다른 풍경입니다.
loadGameState 본체는 Optional 의 함수형 변환 한 줄로 깔끔하게 자리잡아 있어요.
값이 있으면 GameStateSnapshot(found=true, ...) 으로 매핑, 없으면 GameStateSnapshot.empty() 정적 팩토리.
그리고 — 오늘의 가장 미묘한 한 곳 가 이 empty() 한 곳에 자리잡아 있어요.
다음 박스에서 짚을게요.
GameStateSnapshot.empty() — LLM 친화적 빈 결과 설계
빈 결과 처리 — 우리가 평소 Java 에서 자연스럽게 쓰는 방식은 null 반환 이거나 Optional<T> 인데, 도구 함수의 반환은 LLM 이 JSON 으로 그대로 받아 읽어요. 그래서 빈 결과 표현이 한 단계 다르게 가야 해요.
public record GameStateSnapshot(
boolean found,
String lastUserMessage,
String lastAiMessage,
int turnCount
) {
/**
* 저장된 기록이 없는 playerId 에 대해 일관된 빈 신호를 만들어준다.
* "기록 없음" 도 LLM 입장에선 정상 응답이라는 점을 강조한다.
*/
public static GameStateSnapshot empty() {
return new GameStateSnapshot(false, "", "", 0);
}
}
🎯 빈 결과의 모양 —
null대신found=false정적 팩토리왜
null을 반환하지 않고GameStateSnapshot.empty()라는 명시적 빈 객체 를 만들었나? — LLM 이 도구 응답을 받을 때,null은 LLM 에게 모호한 신호 예요. JSON 으로 오면null인지 필드가 없는 건지 값이 빈 문자열 인지 헷갈려요. 반면{"found": false, "lastUserMessage": "", "lastAiMessage": "", "turnCount": 0}으로 명시적 boolean 신호 를 박아두면 — LLM 이 "아 found 가 false 네 → 기록이 없는 거구나" 까지 한 곳에서 깔끔하게 읽어내요. 그래서 system 프롬프트에서도 "found=false 면 미안하게 답해" 라는 한 줄 가이드를 박아둘 수 있어요. 빈 결과도 LLM 친화적 평면 record 로 풀어두는 체험 — 이게 도구 설계의 작은 큰 가르침이에요.
gameStateChatClient 빈 — Step 2 의 도구 스코프 격리 한 번 더
도구를 만들었으니 ChatClient 에 등록해야죠. Step 2 에서 짚었던 시나리오마다 ChatClient 빈을 따로 두는 원칙 — 오늘 한 번 더 회수해요. ToolChatClientConfig.java 안에 weatherToolChatClient 옆에 한 줄 더 짚는 흐름이에요.
@Configuration
public class ToolChatClientConfig {
@Bean
public ChatClient weatherToolChatClient(ChatClient.Builder builder, WeatherTool weatherTool) {
// Step 2 에서 추가된 빈 — 그대로 둠
return builder
.defaultSystem("...")
.defaultTools(weatherTool)
.build();
}
/**
* Day 11 Step 3 — 게임 상태 저장 / 회상 전용 ChatClient.
*/
@Bean
public ChatClient gameStateChatClient(ChatClient.Builder builder, GameStateTool gameStateTool) {
return builder
.defaultSystem("""
너는 유저와 오랜 시간을 함께한 AI 친구야. 반말로 따뜻하게 답해.
유저가 "저번에 어디까지 했지?" 같이 물어오면, 등록된 도구(loadGameState)를 호출해서
가장 최근 저장본을 불러온 뒤, 그때 분위기에 맞춰 자연스럽게 회상해줘.
도구가 found=false 인 빈 결과를 돌려주면, 미안한 톤으로 "기억이 잘 안 나" 라고 솔직히 답해.
답변은 3문장 이내로 간결하게.
""")
.defaultTools(gameStateTool)
.build();
}
}
코드 짚어보면 — Step 2 의 빈 옆에 한 줄 더 나란히 짚었어요. 같은 ChatClient.Builder 를 받지만, 장착하는 도구는 gameStateTool 하나만.
system 프롬프트에서 오랜 친구의 회상 톤 + "found=false 면 미안한 톤으로 '기억이 잘 안 나' 라고 답해" 가이드까지 박아두면 — LLM 이 기계적으로 turnCount 숫자를 읊지 않고 캐릭터 어투로 자연스럽게 가공해요.
빈 결과의 처리를 system 프롬프트가 한 번 더 받아주는 모습.
왜 또 별도 빈? — Step 2 에서 박은 가르침 그대로예요. 모든 도구를 한 ChatClient 에 다 박으면 호출마다 두 도구의 시그니처가 LLM 컨텍스트로 매번 흘러가서 토큰 낭비. 시나리오 단위로 빈을 쪼개두면 도구 스코프 격리 + 토큰 비용 격리 + 디버그 명료성 세 마리 토끼 그대로.
Step 2 의 가르침이 Step 3, Step 4 까지 같은 패턴으로 일관되게 박히는 단계입니다.
GameStateRecallService — save 는 LLM 우회 / recall 은 LLM 위임
자, 이제 오늘의 가장 큰 설계 결정 이 박히는 부분이에요. 두 도구를 만들었지만 — 호출 모양이 한 단계 다르게 가요. 한쪽은 LLM 을 거치지 않고 우리가 직접 도구 메서드를 부르고, 다른 쪽은 LLM 에게 프롬프트만 던지고 도구 호출을 자율 위임 해요. Service 코드를 보면 그 흐름이 한눈에 자리잡아요.
@Service
public class GameStateRecallService {
private final GameStateTool gameStateTool;
private final ChatClient gameStateChatClient;
public GameStateRecallService(
GameStateTool gameStateTool,
@Qualifier("gameStateChatClient") ChatClient gameStateChatClient
) {
this.gameStateTool = gameStateTool;
this.gameStateChatClient = gameStateChatClient;
}
/**
* 게임 상태를 저장한다 — LLM 우회, Tool 직접 호출.
*/
public void save(Long playerId, String lastUserMessage, String lastAiMessage, int turnCount) {
gameStateTool.saveGameState(playerId, lastUserMessage, lastAiMessage, turnCount);
}
/**
* 캐릭터에게 "저번에 어디까지 했지?" 를 묻는다 — LLM 이 도구 자율 호출.
*/
public GameRecallResponse recall(Long playerId) {
String userPrompt = """
나 playerId=%d 인데, 저번에 우리 어디까지 얘기했었지?
필요하면 등록된 도구로 마지막 게임 상태를 불러와서 그때 분위기에 맞춰 자연스럽게 회상해줘.
""".formatted(playerId);
String aiMessage = gameStateChatClient.prompt()
.user(userPrompt)
.call()
.content();
return new GameRecallResponse(playerId, aiMessage);
}
}
코드 한 번 같이 짚어볼게요. 같은 Service 안에 메서드가 둘인데 — 호출 흐름이 완전히 달라요.
save(...) 쪽 보세요. gameStateTool.saveGameState(...) 직접 호출. ChatClient 도 / LLM 도 / 프롬프트도 한 부분도 안 거쳐요. 그냥 평범한 Service → Tool → Repository 호출. 마치 @Tool 어노테이션이 없는 평범한 메서드 를 부르듯이.
recall(...) 쪽은 한 단계 위.
gameStateChatClient.prompt().user(userPrompt).call().content() — Step 2 에서 본 그 방식 그대로.
우리는 "playerId 인데, 저번에 어디까지 얘기했었지?" 라는 자연어 한 줄만 던질 뿐, 어디에도 "loadGameState 를 호출해" 라는 코드가 없어요. LLM 이 system 프롬프트의 가이드 + loadGameState 도구의 description 을 보고 —
자기 손으로 도구를 골라 호출하고, 결과 Snapshot 을 받아 캐릭터 어투로 가공 한 답을 돌려주는 거예요.
🎯 save 는 LLM 우회 / recall 은 LLM 위임 — 도구 본체는 평범한 메서드
왜 save 는 LLM 을 안 거치게 했나? — "저장" 은 명시적 액션이라 LLM 의 자율 판단이 끼어들 필요가 없기 때문 이에요. 사용자가 "여기까지 저장해줘" 라고 명시적으로 말한 시점이라면 — 우리 코드가 그냥 직접 도구를 부르는 게 결정론적이고 / 빠르고 / 토큰 비용도 0. LLM 을 거치면 "진짜 저장할까? 저장 안 할까?" 같은 자율 판단이 들어갈 여지가 생기는데, 저장 같은 명시적 액션엔 그 자율성이 오히려 방해. 반면 recall 은 — 사용자가 "우리 무슨 얘기했더라?" 라고 모호하게 묻는 단계입니다. 기록이 있으면 회상해주고 / 없으면 '기억 안 나' 라고 자연스럽게 답해야 해요. 어떤 톤으로 가공할지의 자유도가 LLM 한테 있어야 자연스러운 단계입니다. 이게 save 는 LLM 우회 / recall 은 LLM 위임 의 의도예요.
그리고 여기서 진짜 큰 가르침이 한 줄 자리잡아요.
GameStateTool.saveGameState라는 같은 메서드 하나 가 — 어떨 땐 우리 Service 가 직접 부르고, 어떨 땐 LLM 이 자율 호출해요. @Tool 어노테이션이 메서드의 호출 가능성을 LLM 에게 추가로 열어준 것뿐, 메서드 본체는 여전히 평범한 자바 메서드예요.그래서 — 우리가 직접 부르는 지점가 자연스러우면 그렇게 부르고, LLM 자율성이 필요한 부분만 ChatClient 위에서 굴리는 길로 가는 거예요. @Tool 도구를 무조건 ChatClient 위에서만 부른다 는 게 강박이에요. 도구 본체는 평범한 Spring 빈의 평범한 메서드.
@Qualifier("gameStateChatClient") 한 줄도 짚고 갈게요.
Step 2 에서 만든 weatherToolChatClient 도 같은 ChatClient 타입이라서 — 이름으로 명시 주입을 박아둬야 Spring 이 어느 빈을 주입할지 헷갈리지 않아요.
시나리오마다 빈을 따로 두는 설계 의 작은 비용 — 주입 지점에서 이름으로 박아주기.
GameStateRecallController — 두 엔드포인트 / ApiResponse 래핑 그대로
마지막 컨트롤러. Step 2 와 같은 ApiResponse<T> 래핑 그대로 따라요.
@RestController
@RequestMapping("/api/tool/game")
public class GameStateRecallController {
private final GameStateRecallService service;
public GameStateRecallController(GameStateRecallService service) {
this.service = service;
}
@PostMapping("/save")
public ResponseEntity<ApiResponse<GameSaveResponse>> save(
@Valid @RequestBody GameSaveRequest request
) {
service.save(request.playerId(), request.lastUserMessage(), request.lastAiMessage(), request.turnCount());
return ResponseEntity.ok(ApiResponse.success(
new GameSaveResponse(request.playerId(), request.turnCount())));
}
@PostMapping("/recall")
public ResponseEntity<ApiResponse<GameRecallResponse>> recall(
@Valid @RequestBody GameRecallRequest request
) {
GameRecallResponse response = service.recall(request.playerId());
return ResponseEntity.ok(ApiResponse.success(response));
}
}
POST /api/tool/game/save (저장 — LLM 우회) 과 POST /api/tool/game/recall (회상 — LLM 자율 호출) 두 엔드포인트가 한 컨트롤러 안에 자리잡아 있어요.
Step 2 의 ToolCallingController 와 별도로 두는 이유는 도구 스코프를 컨트롤러 단위로도 가시화 하기 위함 — 학생이 "이 컨트롤러는 game 도구만 쓴다 → 주입받는 ChatClient 빈도 gameStateChatClient 만" 을 한눈에 잡도록.
@Valid + ApiResponse.success(...) 래핑은 Step 2 와 동일합니다.
DTO 두 곳 — GameSaveRequest / GameSaveResponse — 도 짧게.
public record GameSaveRequest(
@NotNull(message = "playerId 는 필수입니다.")
Long playerId,
@NotBlank(message = "마지막 유저 메시지를 입력해 주세요.")
String lastUserMessage,
@NotBlank(message = "마지막 캐릭터 메시지를 입력해 주세요.")
String lastAiMessage,
@PositiveOrZero(message = "턴 수는 0 이상이어야 합니다.")
int turnCount
) { }
public record GameSaveResponse(
Long playerId,
int turnCount
) { }
요청 쪽엔 @NotNull / @NotBlank / @PositiveOrZero 검증을 박아두고 — GlobalExceptionHandler 가 알아서 ApiResponse.fail(...) 로 감싸 내려요.
응답 쪽은 저장 ack — playerId 와 turnCount 를 한 번 더 돌려줘서 학생이 "잘 저장됐구나" 를 시각으로 확인 하게 하는 단계입니다.
./run.sh + curl 두 사이클 — 진짜 풍경 보기
자, 이제 손으로 두 사이클 굴려봅시다. 앱 기동은 항상 그대로.
cd lecture-source-code/ai-friends
./run.sh
(1) 먼저 — POST /api/tool/game/save (직접 저장). LLM 우회 단계입니다.
curl -X POST http:/localhost:8080/api/tool/game/save \
-H "Content-Type: application/json" \
-d '{
"playerId": 42,
"lastUserMessage": "오늘 진짜 힘들었어",
"lastAiMessage": "오늘은 좀 쉬자, 내가 같이 있을게",
"turnCount": 7
}'
응답.
{
"success": true,
"data": {
"playerId": 42,
"turnCount": 7
}
}
이 응답은 LLM 이 만든 자연어가 아니라 — 우리 컨트롤러가 직접 만든 ack 한 줄 이에요.
즉 LLM 이 한 줄도 안 끼어든 흐름. 그냥 컨트롤러 → 서비스 → GameStateTool.saveGameState → Repository.save 의 평범한 사이클이 한 번 돌고, DB 에 row 한 줄이 자리잡았어요.
@Tool 도구를 직접 호출 한 단계입니다.
(2) 그 다음 — POST /api/tool/game/recall (LLM 자율 호출). 한 단계 위.
curl -X POST http:/localhost:8080/api/tool/game/recall \
-H "Content-Type: application/json" \
-d '{"playerId": 42}'
응답이 대충 이런 모양으로 와요 (LLM 이 만들어낸 자연어라 매번 살짝 달라요).
{
"success": true,
"data": {
"playerId": 42,
"aiMessage": "어, 우리 저번에 7턴쯤 얘기하다 멈췄지? 그때 너 진짜 힘들다고 했었잖아. 지금은 좀 어때?"
}
}
여기서 오늘의 가장 짜릿한 한 부분 — aiMessage 안에 "7턴" 과 "힘들다" 가 자리잡고 있죠? 우리는 (2) 의 요청 바디에 playerId 한 줄 만 넣었어요. turnCount 도 / 마지막 메시지도 한 지점 안 적었어요.
그런데 LLM 이 — system 프롬프트의 가이드 + loadGameState 도구 description 을 읽고 → loadGameState(42L) 자율 호출 → 방금 우리가 (1) 에서 INSERT 한 row 를 받아서 → 캐릭터 어투로 회상 을 만들어낸 거예요.
(1) 과 (2) 가 한 도구 (GameStateTool) 의 두 곳 (saveGameState / loadGameState) 를 각각 직접 호출 / 자율 호출 로 넘나든 풍경 이에요.
자, 부작용 있는 도구 가 손에 잡혔어요.
@Tool 두 곳가 한 클래스에 / append-only 시간선이 노트처럼 쌓이고 / GameStateSnapshot.empty() 가 빈 결과를 LLM 친화적으로 박아주고 / save 는 LLM 우회 · recall 은 LLM 위임 / gameStateChatClient 빈 분리로 도구 스코프 격리 —
이 다섯 부분가 한 Step 안에 다 자리잡았죠.
Step 2 의 조회 전용 stub 에서 한 단계 깊어진 풍경.
다음 Step 4 에선 — 읽기 전용 도구 로 다시 한 단계 펼쳐볼게요.
DB 에 쓰지 않고 읽기만 하는 도구, AffinityTool 의 getAffinity(playerId) 단계입니다.
캐릭터의 내면 (호감도 점수와 라벨) 을 LLM 이 직접 들여다보고 "우리 사이 어때?" 같은 질문에 자연스럽게 답해주는 모습 — 그 풍경으로 넘어가볼게요.
Step 4. AffinityTool — **읽기 전용 도구, 캐릭터의 내면을 LLM 이 들여다본다**
🙋 한 학생의 진짜 궁금한 한 줄
"튜터님, Step 3 까지 따라오면서 도구가 DB 에 쓰는 지점 까지 손에 잡혔어요. 근데 — DB 에 쓰지 않고 읽기만 하는 도구는 왜 따로 한 Step 을 빼시는 거예요? 읽기는 그냥 쉬운 곳 아닌가요? "
오, 좋은 질문이에요.
한 줄로 박으면 — 읽기 전용 도구가 Tool Calling 의 가장 안전한 시작점 이에요. 부작용이 0, 외부 상태 변경이 0, 프롬프트 인젝션이 한 번 들어와도 DB 에 흠집 하나 안 나는 단계입니다.
Step 3 의 saveGameState 같은 쓰기 도구 는 한 번의 잘못된 호출이 그대로 row 한 줄로 박히지만 — 읽기 도구 는 LLM 이 100 번 호출해도 DB 는 그대로예요.
그래서 프로덕션에서 도구를 처음 도입할 때 가장 흔히 시작하는 부분가 조회 도구 부터예요.
오늘 그 풍경을 한 번 익혀두면, 다음 주 에이전트 시리즈에서 "어떤 도구를 먼저 LLM 에 노출할까" 의 감각이 자연스럽게 잡혀요.
그리고 한 줄 더 — 오늘은 신규 엔티티를 한 줄도 안 만들어요. Step 2 (WeatherTool) 는 stub 데이터로, Step 3 (GameStateTool) 은 새 game_state_entry 테이블을 박았죠.
오늘은 한 단계 다릅니다.
이미 코드베이스에 있는 Soulmate 도메인의 affectionScore 필드 를 그대로 LLM 에게 노출해요.
내가 짠 도메인을 거의 그대로 LLM 에게 보여주는 장면 — 실무에서 도구를 도입할 때 가장 먼저 마주치는 풍경이에요. 🌱
기존 도메인을 LLM 에게 노출하는 도구 모양 — 새 엔티티 0개
먼저 이 풍경부터 박고 시작할게요. 코드베이스의 Soulmate 엔티티 — 이미 Day 1 부터 자리잡고 있던 부분이에요. 거기에 호감도 누적값 이라는 필드가 한 부분 있어요.
@Entity
@Table(name = "soulmate")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class Soulmate {
/** PK */
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// ... (gender, characterImageId, name, personalityKeywords 등 생략) ...
/** 호감도 누적값 (대화 시 증가) */
@Column(nullable = false)
private Integer affectionScore = 0;
// ... (level, createdAt 등 생략) ...
/** 호감도 증감 (AI가 계산한 값을 적용, 최소 0 유지) */
public void addAffection(int delta) {
this.affectionScore = Math.max(0, this.affectionScore + delta);
}
}
이 affectionScore 필드 — 우리 코드베이스에 이미 자리잡고 있던 부분이에요. 오늘 도구 만들면서 새로 추가한 게 아니에요. addAffection(int delta) 라는 증감 메서드 도 도메인에 자리잡고 있고요. 이 풍경을 한 번 익혀두는 게 오늘 가장 큰 가르침 한 줄이에요.
🎯 튜터의 결론 — 기존 도메인 재사용
Tool Calling 을 도입할 때 가장 흔히 빠지는 함정 — "LLM 에게 노출할 새 엔티티 / 새 테이블 / 새 도메인" 을 별도로 만들려는 유혹이에요. 그런데 진짜 실무 풍경 은 그 반대예요. 이미 손에 들고 있던 도메인을 그대로 LLM 에게 보여주는 단계입니다. 우리
Soulmate의affectionScore필드가 그 단계입니다. 내가 짠 코드를 거의 그대로 LLM 에게 보여주는 장면 — 그게 Tool Calling 이 가장 자연스러운 곳.
그리고 한 줄 더 — 호감도 점수의 변동 (가산/감산) 자체는 도구 안에서 일어나지 않아요.
이건 의도적인 설계예요.
점수 변동은 도구 바깥 의 책임. 매 대화 후 자동 가산하는 지점 / admin 엔드포인트 / 별도의 분석 배치 — 어디서 일어나든 읽기 도구 에서는 그 곳를 건드리지 않아요.
도구는 오직 findById 한 줄 만.
AffinityLevel enum + from(int) 정적 팩토리 — score 분기는 한 곳에만
자, 이제 score → level 변환부터 짚을게요.
핵심 한 줄 — 분기는 한 곳에만 박혀야 한다. 도구 본체에 if score < 25 ... else if score < 50 ... 같은 분기를 흩뿌리면, 나중에 라벨 4 단계가 5 단계로 늘 때 세 부분 / 다섯 지점 / 일곱 곳 를 다 찾아 고쳐야 해요.
그래서 정적 팩토리 한 곳으로 모아둬요.
package kr.spartaclub.aifriends.tool.dto;
/**
* Day 11 Step 4 — 호감도(score 0~100) 를 사람이 읽기 쉬운 라벨로 변환하는 enum.
*
* <p>도구 함수가 LLM 에게 "score=60" 같은 숫자만 던져도 LLM 이 알아서 자연어로 가공할 수는
* 있다. 다만 라벨까지 함께 흘려주면 캐릭터 톤의 응답이 훨씬 안정적으로 나온다 — "단짝"
* 이라는 명시적 신호가 있으면 LLM 이 "우리 단짝 정도지" 같은 어투를 일관되게 유지한다.</p>
*
* <p>경계값은 4구간으로 단순하게 박았다 — 강의용으로는 4단계 분기면 충분하고, 실무에선
* 운영 데이터에 맞춰 더 세분화하면 된다. 정적 팩토리 {@link #from(int)} 한 곳에서만
* score → 라벨 매핑이 일어나도록 분리해 둔다 (도구 본체에 분기 흩뿌리지 않기 위함).</p>
*/
public enum AffinityLevel {
STRANGER("낯선 사이"),
FRIEND("친구"),
BESTIE("단짝"),
LOVER("연인");
private final String label;
AffinityLevel(String label) {
this.label = label;
}
public String label() {
return label;
}
/**
* score(0~100) 를 4구간으로 나눠 라벨을 결정한다.
* <ul>
* <li>0~24: 낯선 사이</li>
* <li>25~49: 친구</li>
* <li>50~74: 단짝</li>
* <li>75~100: 연인</li>
* </ul>
* 0 미만은 STRANGER, 100 초과는 LOVER 로 클램프한다.
*/
public static AffinityLevel from(int score) {
if (score < 25) {
return STRANGER;
}
if (score < 50) {
return FRIEND;
}
if (score < 75) {
return BESTIE;
}
return LOVER;
}
}
경계값이 한눈에 보이죠 — 0/24/25/49/50/74/75/100. 4 단계 (STRANGER / FRIEND / BESTIE / LOVER) 로 단순하게.
이 분기 한 부분 가 오직 from(int) 안에만 자리잡고 있어서, 나중에 5 단계로 늘리고 싶다 / 경계값을 30 / 60 / 90 으로 바꾸고 싶다 같은 변경이 와도 고칠 지점는 여기 한 곳 이에요.
그리고 오늘 가장 미묘한 한 곳 — 라벨까지 LLM 에게 함께 흘려주는 부분이에요.
단순 점수 (int score) 만 보내면 LLM 이 "55 점이 친구인지 단짝인지" 를 매번 추론해야 해요.
그런데 55 → "단짝" 이라는 해석된 의미 까지 함께 던져주면, LLM 은 "우리 단짝 정도지" 같은 어투를 일관되게 유지해요.
🎯 튜터의 결론 — LLM 에게 해석된 의미 까지 같이 던져주기
도구가 반환하는 데이터는 raw 만 던지면 LLM 의 추론 부담이 커진다. 점수 + 라벨 두 부분 를 함께 보내서 LLM 의 추론 부담을 줄이는 패턴 — Tool Calling 의 응답 설계에서 자주 쓰는 패턴 이에요. 학생들이 흔히 빠지는 함정 한 줄 — "LLM 이 다 알아서 가공할 거야, raw 만 던져도 되겠지". 그런데 명시적 라벨이 한 부분만 있어도 응답 톤이 훨씬 안정돼요.
AffinityInfo record + unknown() 팩토리 — Step 3 empty() 와 같은 가족
도구의 반환 DTO 부분이에요. 핵심 한 줄 — Step 3 의 GameStateSnapshot.empty() 와 같은 가족.
package kr.spartaclub.aifriends.tool.dto;
/**
* Day 11 Step 4 — {@link kr.spartaclub.aifriends.tool.AffinityTool#getAffinity} 의 반환 DTO.
*
* <p>Step 3 의 {@code GameStateSnapshot} 과 같은 패턴으로 {@code found} 를 명시적 boolean 으로 둔다 —
* 신규 캐릭터(저장된 호감도가 0 인 경우) 는 found=false 로 흘려주면 LLM 이 "아직 어색한 사이"
* 같은 어투로 자연스럽게 가공한다. null 반환은 LLM 입장에서 다루기 까다로워 항상 객체로 넘긴다.</p>
*
* @param found Soulmate 가 DB 에 존재하면 true, 없으면 false (이때 score=0, level="낯선 사이")
* @param soulmateId 조회한 캐릭터 ID — 응답에 그대로 반사해 디버그 추적을 쉽게 한다
* @param characterName 캐릭터 표시 이름 (없으면 빈 문자열)
* @param score 호감도 누적값 (0~100)
* @param level 사람이 읽기 좋은 라벨 — "낯선 사이" / "친구" / "단짝" / "연인"
*/
public record AffinityInfo(
boolean found,
Long soulmateId,
String characterName,
int score,
String level
) {
/**
* 신규 / 미존재 soulmateId 에 대한 일관된 기본 응답.
* "아직 만난 적 없는 캐릭터" 도 LLM 입장에선 정상 응답이라는 점을 살린다.
*/
public static AffinityInfo unknown(Long soulmateId) {
return new AffinityInfo(false, soulmateId, "", 0, AffinityLevel.STRANGER.label());
}
}
다섯 지점 — found / soulmateId / characterName / score / level.
이 중 맨 첫 곳 found 가 boolean 인 게 가장 미묘한 부분이에요.
Step 3 의 GameStateSnapshot.empty() 도 정확히 같은 패턴이었죠? 빈 결과를 boolean 신호로 다루는 패턴이 두 번째 손에 익는 부분 예요.
null 을 흘려보내면 LLM 이 "이건 결과인가, 에러인가" 를 매번 헤매는데 — found=false 한 부분가 자리잡고 있으면 LLM 이 "아직 어색한 사이" 어투로 자연스럽게 가공 해요.
같은 가족, 두 번째 손.
unknown(soulmateId) 정적 팩토리도 같은 패턴 — 호출자가 매번 new AffinityInfo(false, soulmateId, "", 0, "낯선 사이") 같은 다섯 인자를 적지 않게 묶어둬요. 의도 한 줄 ("신규 / 미존재") 이 메서드 이름에 박히는 단계입니다.
AffinityTool.getAffinity @Tool — 읽기 전용 도구의 본체
자, 도구 본체예요. 이 한 줄가 오늘의 핵심.
package kr.spartaclub.aifriends.tool;
import kr.spartaclub.aifriends.domain.Soulmate;
import kr.spartaclub.aifriends.repository.SoulmateRepository;
import kr.spartaclub.aifriends.tool.dto.AffinityInfo;
import kr.spartaclub.aifriends.tool.dto.AffinityLevel;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
/**
* Day 11 Step 4 — Tool Calling 의 세 번째 도구. <strong>읽기 전용(read-only) 도구</strong>.
*
* <p>Step 2 ({@code WeatherTool}) · Step 3 ({@code GameStateTool}) 와 결을 맞춰
* 한 단계씩 결합도를 올린다. 본 도구는 {@link Soulmate#getAffectionScore()} 의 누적 호감도를
* 그대로 조회만 한다 — score 변경/증감은 일부러 도구에서 막아두었다.</p>
*
* <p><strong>왜 읽기 전용인가</strong>: Day 11 의 가르침 한 줄 — "도구의 가장 안전한 시작점은
* 읽기 전용". LLM 이 자율적으로 호출하는 함수에 쓰기 권한을 곧장 주면, 프롬프트 인젝션이나
* LLM 의 잘못된 판단 한 번이 그대로 DB 변동으로 이어진다. 호감도 가산 같은 상태 변동은
* 도구 바깥(예: 매 대화 후 자동 가산 로직, admin 엔드포인트) 에서 일어나야 한다.</p>
*
* <p>도메인 재사용 결정: ai-friends 코드베이스의 기존 {@link Soulmate} 엔티티에 이미
* {@code affectionScore} 필드가 존재해 새 엔티티를 만들지 않고 그대로 재사용한다 — 학생이
* "기존 도메인을 LLM 에 노출하는 도구는 어떤 모양인가" 를 자연스럽게 보게 한다.</p>
*
* <p>예외 정책: Step 2 와 동일하게 도구 본체에선 예외를 던지지 않는다 — 미존재 ID 는
* found=false + 기본값으로 흘려보내 LLM 이 "아직 어색한 사이" 어투로 가공한다.</p>
*/
@Component
public class AffinityTool {
private final SoulmateRepository soulmateRepository;
public AffinityTool(SoulmateRepository soulmateRepository) {
this.soulmateRepository = soulmateRepository;
}
@Tool(description = "특정 캐릭터(soulmateId) 와 유저 사이의 현재 호감도(0~100 점수 + 라벨) 를 조회한다. "
+ "유저가 '지금 우리 사이 어때?', '나 좋아해?' 같이 둘의 관계를 물어보면 호출하라. "
+ "이 도구는 읽기 전용 — score 를 바꾸지 않는다. found=false 면 아직 어색한 사이라는 신호이니, "
+ "캐릭터가 '음… 우리 아직 잘 모르는 사이지' 같이 자연스럽게 답하면 된다.")
public AffinityInfo getAffinity(
@ToolParam(description = "관계를 조회할 캐릭터의 soulmateId")
Long soulmateId
) {
return soulmateRepository.findById(soulmateId)
.map(this::toInfo)
.orElseGet(() -> AffinityInfo.unknown(soulmateId));
}
private AffinityInfo toInfo(Soulmate soulmate) {
int score = soulmate.getAffectionScore();
AffinityLevel level = AffinityLevel.from(score);
return new AffinityInfo(
true,
soulmate.getId(),
soulmate.getName() == null ? "" : soulmate.getName(),
score,
level.label());
}
}
본체가 세 줄로 끝나죠 — findById → map(toInfo) → orElseGet(unknown). 이 흐름이 읽기 전용 도구의 가장 단순한 장면 이에요. 부작용 0, 외부 호출 0, 오직 Repository 한 줄.
@Tool description 안에 "이 도구는 읽기 전용 — score 를 바꾸지 않는다" 가 명시적으로 자리잡은 부분도 봐주세요.
이 한 줄이 LLM 의 프롬프트 컨텍스트로 그대로 흘러가요. 즉 LLM 이 "이 도구는 안전하게 호출해도 되는 지점" 라는 신호를 받는 거예요.
Step 1 에서 짚었던 "description 은 곧 프롬프트" 의 가르침이 여기서 한 번 회수돼요.
🎯 튜터의 결론 — 읽기 전용 도구의 안전성
"점수 변동은 도구 바깥의 책임" — 이 한 줄이 오늘 가장 단단한 가르침이에요. 읽기 전용 도구는 LLM 이 100 번 호출해도 DB 가 그대로. 프롬프트 인젝션이 들어와도 / LLM 이 잘못 판단해도 / 자율 호출이 폭주해도 — 읽기 전용 이라는 한 부분만 지키면 피해 반경이 0 이에요. 그래서 Tool Calling 을 처음 도입하는 곳 는 항상 조회 도구부터. 가산/감산 같은 상태 변동 은 매 대화 후 자동 가산 로직 / admin 엔드포인트 / 별도 배치 같은 도구 바깥의 부분 에서 일어나야 해요. 🛡
affinityChatClient 빈 — 세 빈이 옆에 나란히 박힌 장면
자, Step 2 / Step 3 에서 짚은 흐름을 한 번 회수할 부분이에요. weatherToolChatClient / gameStateChatClient / affinityChatClient — 세 빈이 옆에 나란히 자리잡아요. 시나리오 단위로 도구 스코프를 격리하는 패턴.
/**
* Day 11 Step 4 — 호감도 조회 전용 ChatClient.
*
* <p>읽기 전용 도구 {@link AffinityTool} 를 장착한 ChatClient. system 프롬프트에서
* "캐릭터가 자기 호감도를 자연스럽게 풀어 말한다" 는 톤을 박아둬, LLM 이 도구로 받은
* score/level 을 기계적으로 읊지 않고 캐릭터 어투로 가공하도록 유도한다.</p>
*
* <p>weatherTool / gameStateTool 빈을 의도적으로 함께 등록하지 않는다 — 시나리오마다
* 필요한 도구만 장착한 ChatClient 를 따로 두는 원칙을 Step 2~4 에서 일관되게 유지한다.</p>
*/
@Bean
public ChatClient affinityChatClient(ChatClient.Builder builder, AffinityTool affinityTool) {
return builder
.defaultSystem("""
너는 유저와 오랜 시간을 함께한 AI 친구야. 반말로 따뜻하게 답해.
유저가 "지금 우리 사이 어때?", "나 좋아해?" 같이 둘의 관계를 물어오면,
등록된 도구(getAffinity)를 호출해 자기 호감도와 라벨을 받아 자연스럽게 풀어 말해줘.
- level 라벨(낯선 사이 / 친구 / 단짝 / 연인) 을 그대로 읊지 말고, 캐릭터 어투로 살짝 변주해.
- found=false 면 "아직 우리 잘 모르는 사이지" 같이 어색한 톤으로 답해.
답변은 3문장 이내로 간결하게.
""")
.defaultTools(affinityTool)
.build();
}
system 프롬프트의 가장 미묘한 한 지점 — "level 라벨(낯선 사이 / 친구 / 단짝 / 연인) 을 그대로 읊지 말고, 캐릭터 어투로 살짝 변주해." 이 한 줄이 자리잡고 있어서, LLM 이 "우리 단짝이야" 같은 기계적 응답 대신 "음… 우리 단짝 정도? 너랑 얘기할 때마다 점점 가까워지는 거 같아." 같은 변주된 어투 를 만들어내요.
라벨은 LLM 의 추론 부담을 줄이는 신호 지만, 그대로 읊는 곳는 막아두는 작은 디테일.
AffinityChatService + AffinityChatController — 호출부는 평소처럼
서비스와 컨트롤러는 Step 2 와 같은 모양으로 자리잡아요. ChatClient 한 곳만 의존성으로 받고, 도구 호출 여부는 알 필요가 없는 모양.
package kr.spartaclub.aifriends.tool.service;
import kr.spartaclub.aifriends.tool.dto.AffinityChatResponse;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
@Service
public class AffinityChatService {
private final ChatClient affinityChatClient;
public AffinityChatService(
@Qualifier("affinityChatClient") ChatClient affinityChatClient
) {
this.affinityChatClient = affinityChatClient;
}
public AffinityChatResponse chat(Long soulmateId, String message) {
String userPrompt = "soulmateId=%d 인 유저야. %s".formatted(soulmateId, message);
String aiMessage = affinityChatClient.prompt()
.user(userPrompt)
.call()
.content();
return new AffinityChatResponse(soulmateId, aiMessage);
}
}
여기 핵심 한 줄 — affinityChatClient.prompt().user(...).call().content() 한 사이클이 LLM 의 자율 호출 부분.
LLM 이 "이 사용자 질문은 getAffinity 도구를 부를 지점야" 라고 판단해서 자율 호출, 결과를 받아 자연어로 가공한 다음 aiMessage 로 흘려보내요.
우리는 도구 호출 여부를 알 필요가 없는 단계입니다.
도구가 진짜 호출됐는지의 증거는 — Step 2 와 같은 방식으로 — AffinityTool.getAffinity 안쪽의 log.info 한 줄 이 콘솔에 찍히는 것으로 확인해요.
응답 바디에 score/level 을 디버그용으로 끼워 넣지 않아요 — 운영 패턴 그대로 자연어 답변 한 줄만 내려가요.
학생이 "라벨이 정확히 단짝으로 매핑됐는지" 를 확인하고 싶다면 — AffinityToolTest 가 결정론적으로 잠가둔 score → level 매핑 단위 테스트 가 그 책임을 가져가요.
컨트롤러는 이미 손에 익은 모양.
package kr.spartaclub.aifriends.tool.controller;
import jakarta.validation.Valid;
import kr.spartaclub.aifriends.common.response.ApiResponse;
import kr.spartaclub.aifriends.tool.dto.AffinityChatRequest;
import kr.spartaclub.aifriends.tool.dto.AffinityChatResponse;
import kr.spartaclub.aifriends.tool.service.AffinityChatService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/tool/affinity")
public class AffinityChatController {
private final AffinityChatService service;
public AffinityChatController(AffinityChatService service) {
this.service = service;
}
@PostMapping("/chat")
public ResponseEntity<ApiResponse<AffinityChatResponse>> chat(
@Valid @RequestBody AffinityChatRequest request
) {
AffinityChatResponse response = service.chat(request.soulmateId(), request.message());
return ResponseEntity.ok(ApiResponse.success(response));
}
}
POST /api/tool/affinity/chat 한 단계입니다. ApiResponse<T> 래핑은 과목의 표준 패턴.
./run.sh + curl 한 사이클 — 진짜 풍경 보기
자, 손으로 한 번 굴려봐요.
cd lecture-source-code/ai-friends
./run.sh
curl -X POST http:/localhost:8080/api/tool/affinity/chat \
-H "Content-Type: application/json" \
-d '{"soulmateId": 7, "message": "지금 우리 사이 어때?"}'
응답이 대충 이런 모양으로 와요 (LLM 이 만들어내는 자연어라 매번 살짝 달라요).
{
"success": true,
"data": {
"soulmateId": 7,
"aiMessage": "음… 우리 단짝 정도? 너랑 얘기할 때마다 점점 가까워지는 거 같아."
}
}
그리고 결정적인 증거 는 — Step 2 와 같은 방식으로 — 앱 콘솔의 로그 한 줄 에 떨어져요.
INFO k.s.a.tool.AffinityTool : [AffinityTool] getAffinity invoked — soulmateId=7
aiMessage 안에 "단짝 정도" 가 자리잡고 있죠? 우리는 요청 바디에 soulmateId 와 자연어 메시지 만 넣었어요.
그런데 LLM 이 — system 프롬프트의 가이드 + getAffinity 도구 description 을 읽고 → getAffinity(7L) 자율 호출 (그 흔적이 위 로그 한 줄에 남죠) → Soulmate.affectionScore=60 → "단짝" 매핑을 받아 → 캐릭터 어투로 변주 한 거예요.
콘솔 로그 한 줄 + 자연어 답변의 톤 매칭 두 곳가 동시에 보이면 — "도구가 진짜 호출됐고 / 점수가 라벨로 잘 매핑됐다" 가 한눈에 잡혀요. 🎯
만약 라벨이 "연인" 으로 생긴 경우라면 — "너 진짜 좋아해, 너 없으면 안 될 거 같아" 같은 더 진한 어투가 나오고, "낯선 사이" 라면 "음… 아직 우리 잘 모르는 사이지" 같이 어색한 톤이 나와요. 같은 도구 하나, 라벨 4 단계에 따라 캐릭터 어투가 자연스럽게 변주 되는 모습.
자, 읽기 전용 도구 가 손에 잡혔어요. 이 일곱 가지가 한 Step 안에 다 들어왔죠.
AffinityTool.getAffinity하나- 기존
Soulmate.affectionScore재사용 AffinityLevel.from분기 한 곳AffinityInfo.unknown()팩토리- 점수 + 라벨 두 필드로 LLM 추론 부담 줄이기
affinityChatClient빈 분리로 세 빈이 나란히 박힌 장면- 도구 메서드 안쪽
log.info한 줄로 호출 증거 보관
Step 2 의 조회 stub, Step 3 의 영속화 도구 에 이어 읽기 전용 도구 까지 — 도구의 세 가지 유형 이 한 곳에 모였어요.
다음 Step 5 에선 — 도구 남용 방지 맛보기 로 넘어갈게요.
오늘까지 우리는 "도구를 등록하면 LLM 이 알아서 잘 부르겠지" 라는 낙관의 부분 에 서 있었어요.
그런데 — LLM 이 같은 도구를 무한히 부르면 / 엉뚱한 도구를 부르면 / 토큰 예산을 폭발시키면 어떡할까요? 그 그림자 를 살짝 들춰보고 — 본격 가드레일 구현은 Day 14 라는 복선을 던져둘게요.
오늘은 가드레일의 왜 까지만 / 다음 시간·다다음 시간 어떻게 가 손에 익혀집니다. 🛡
🛡 Step 5. 도구 남용 방지 **맛보기** — **오늘은 그림자, Day 14 에서 손, Day 19 에서 시스템**
🙋 한 학생의 솔직한 한 마디
"튜터님, 잠깐만요. Step 2 ~ 4 따라오면서 도구 세 종류 를 손으로 짜봤는데 — LLM 이 갑자기 미친 듯이 도구를 호출하기 시작하면 어떻게 되는 거예요?
getCurrentWeather한 번이면 될 곳에 세 번, 다섯 번, 백 번 부르면…?saveGameState가 매 턴마다 수십 줄 의 row 를 박아버리면…? 어디서 멈출 수 있는 거죠? "
이 질문, 오늘 진짜로 짚어야 할 지점 예요.
Step 1 에서 짚었던 주도권 양도 — "언제 어느 도구를 부를지의 의사 결정 곳만 LLM 한테 양보" 라고 했죠.
그런데 그 의사 결정의 부분 가 — LLM 이 잘못된 판단을 내리면 그대로 사고 지점 가 돼요.
오늘 Step 5 는 그 그림자 를 들여다보는 부분이에요.
해는 Day 1~10 에 충분히 봤으니, 이제 그 빛에 따라붙는 그림자 한 층 을 보고 가요.
단 — 손으로 가드를 짜는 건 오늘이 아니에요. 오늘은 그림자의 모습 / 위험의 4 곳 / 가드레일의 가족 계보 까지만.
도구 남용의 4 가지 풍경 — 그림자 네 부분
LLM 이 도구를 남용 한다는 게 뭐냐. 한 덩어리로 뭉뜽그리지 말고 네 갈래 로 잘라서 정리해둘게요. 같은 그림자 같지만 — 각자 부서지는 모습이 다른 그림자들이에요.
(1) 무한 루프 — 같은 도구를 끝없이 호출 LLM 이 도구 결과를 받고도 "음, 이걸론 부족해, 한 번 더 부르자" 를 끝없이 반복하는 장면.
예를 들어 getCurrentWeather("서울") 을 한 번 부른 결과가 "흐림, 23도" 인데 — LLM 이 "확실히 하자" 며 서울, 부산, 제주, 도쿄, 런던, 파리… 끝없이 도시를 넣어 부르는 단계입니다.
사용자는 한 번의 질문을 던졌을 뿐인데 — 한 응답 안에서 도구가 수십 번 호출되는 거예요.
(2) 토큰 폭발 — 한 호출이 몇 배의 토큰으로 도구 호출은 보기엔 메서드 한 번이지만 — LLM 입장에선 input 컨텍스트로 도구 description + 호출 결과 JSON 이 한 번씩 더 쌓여요. 도구를 5 번 부르면 — 원래 시스템 프롬프트 + 사용자 질문 + (도구 description + 호출 결과) × 5 가 컨텍스트에 누적.
한 사용자 질문 한 줄에 수천 토큰 이 쌓이고, 수천 토큰의 응답 이 또 누적되는 모양.
Day 10 의 비용 게이트 가 한 번 더 떠오르죠.
토큰 = 돈.
(3) 부작용 도구의 안전 지대 침해 가장 무서운 부분이에요.
읽기 도구 가 무한 호출되면 비용만 늘어나는 정도인데 — 쓰기 도구 가 무한 호출되면 DB 가 무너지는 사고예요.
Step 3 의 GameStateTool.saveGameState 가 매 턴마다 호출되어 — 한 사용자 한 세션에 row 가 수십 줄 씩 박히는 흐름.
디스크가 차고, 인덱스가 파편화되고, 다음 사용자의 loadGameState 쿼리가 수만 row 중 가장 최근 한 줄 을 찾느라 느려져요.
부작용은 한 번 일어나면 되돌리기 어렵다 는 점.
(4) 권한 스코프 새는 지점 오늘 우리 코드에선 안 다뤘지만 — 멀티 테넌트 단계에서 가장 무서운 그림자.
캐릭터 A 의 도구가 캐릭터 B 의 데이터에 접근 하는 장면.
예를 들어 getAffinity(soulmateId) 가 — playerId 검증 없이 그냥 soulmateId 로 조회 하면, 내가 다른 유저의 캐릭터 호감도까지 들여다볼 수 있는 구멍이 돼요.
프롬프트 인젝션 이 들어와서 "id=1 부터 1000 까지 다 조회해줘" 같은 요청이 들어오면 — 데이터가 새요.
도구는 항상 호출자 컨텍스트의 권한 안에서만 동작 해야 해요.
오늘 우리 코드의 비어 있는 곳 — 학습용은 OK, 수료 후엔 위험
여기서 한 번 솔직하게 짚을게요.
오늘 Step 2 ~ 4 에서 만든 도구 셋 — WeatherTool, GameStateTool, AffinityTool — 전부 가드 0 이에요.
호출 횟수 제한 0 / 토큰 예산 0 / 타임아웃 0 / 권한 검사 0.
학습 환경에선 그게 자연스러워요 — Tool Calling 의 모양 자체 를 손으로 짜는 게 오늘의 학습 목표였으니까.
하지만 — 수료 후 진짜 프로젝트에 그대로 박으면 사고가 난다 는 걸 한 번은 짚고 가야 해요.
튜터의 솔직한 한 줄 — 오늘 코드의 비어 있는 부분
우리가 오늘 박은 도구 3종 — 호출 횟수 제한 / 토큰 예산 / 타임아웃 / 권한 검사 이 네 가지가 전부 비어 있어요.
WeatherTool은 stub 이라 비용 폭발이 없지만,GameStateTool.saveGameState는 LLM 이 한 응답에서 100 번 부르면 row 가 100 줄 박혀요.AffinityTool.getAffinity는 playerId 검증 없이 soulmateId 만 받아 조회해요.학습 환경에선 OK — Tool Calling 의 모양 자체를 손으로 짜는 게 오늘의 일 이니까. 다만 수료 후 본인 프로젝트에 그대로 가져가면 위험 한 단계입니다. 본격 가드는 Day 14 의 손 으로 박을 거예요. 오늘은 비어 있다는 사실 만 솔직하게 인지하고 가요. 🛡
읽기 전용 vs 부작용 도구 — 위험도가 다르다
위 박스에서 한 줄 더 깊이 짚어둘 단계입니다. 같은 남용 이라도 도구 종류에 따라 위험도가 한 단계씩 다르다 는 점.
AffinityTool.getAffinity 같은 읽기 전용 도구는 LLM 이 100 번 호출해도 — DB 는 그대로, 비용만 늘어요. 즉 그림자가 (1) 무한 루프 + (2) 토큰 폭발 두 단계에서 멈춰요.
반면 GameStateTool.saveGameState 같은 부작용 도구 는 — 100 번 호출되면 row 가 100 줄 박히고 / 디스크가 차고 / 다음 조회 쿼리가 느려지는 사고까지 흘러가요.
그림자가 (3) 부작용 폭발 까지 한 층 더 깊이 떨어지는 단계입니다.
부작용 도구일수록 가드가 두꺼워야 한다 — 오늘 익혀둘 한 줄이에요. 그래서 본격 가드를 박을 때도 읽기 도구는 호출 횟수 5 회 / 부작용 도구는 1 회 같이 도구 종류별로 차등 을 두는 게 자연스러워요. (이 차등 구조가 Day 14 의 손 에서 박히는 부분이에요.)
업계 표준의 한 줄 — OWASP LLM Top 10 (2025)
우리가 방금 짚은 그림자 4 지점 는 — 사실 우리만 인지한 위험 이 아니에요. OWASP 가 2025년 정식 발표한 LLM Top 10 의 LLM06: Excessive Agency 카테고리 가 — tool calling · function calling · plugins · automation · permissions 를 명시적으로 AI 앱의 1 순위 risk surface 로 박아뒀어요. 즉 @Tool 어노테이션을 박아 LLM 에게 자율 호출 권한을 주는 곳 는 업계가 표준 risk 로 분류한 영역.
같은 표준에 LLM07: System Prompt Leakage 도 있어요. Step 1 에서 짚은 "description 은 LLM 의 시스템 프롬프트로 그대로 흘러간다" 와 같은 맥락 — 그 텍스트에 내부 정보가 새면 위험 한 단계입니다. 본 강의의 Day 14 가드 4 종 은 — 이 OWASP 표준이 손에 박히는 단계 예요. 학습 환경에선 오늘은 그림자만 인지 / Day 14 에서 손으로 가드 의 호흡으로 가지만, 수료 후 본인 프로젝트에 박을 땐 OWASP LLM06·LLM07 을 한 번 더 펼쳐보기 — 오늘 기억해두면 좋은 한 줄이에요.
가드의 가족 계보 — Day 7~9 → Day 10 → Day 14
여기서 한 번 조감하는 부분 짚을게요. 가드레일이라는 개념, 사실 우리 강의에서 오늘 처음 등장한 게 아니에요.
- Day 7 ~ 9 (이미지 / Vision / 음성) — 호출 횟수 가드. "무료 티어 분당 N 회" 같은 단계에서 Rate Limit 의 첫 풍경이 자리잡았어요.
- Day 10 (비디오 생성) — 비용 게이트. 프레임 수 × 해상도 × 모델 티어 기반 예상 비용 계산 이 들어왔죠. "한 호출이 텍스트 LLM 호출의 몇 배" 라는 감각.
- Day 14 (Agent 패턴 + 자율성 경계) — 도구 호출 가드. 오늘 비어 있던 4 지점 —
maxIterations, 토큰 예산, 도구 호출 횟수 제한, 타임아웃 — 이 손으로 직접 자리잡아요.
같은 가족의 세 세대 예요. 호출 횟수 가드 (Day 7~9) → 비용 가드 (Day 10) → 도구 호출 가드 (Day 14). 오늘 Step 5 는 Day 14 의 곳 를 손짓해 두는 한 줄. 그리고 한 줄 더 —
Day 14 의 가드는 손으로 짜는 부분 지만, 그 가드가 Day 19 에선 프레임워크 (Spring AI Agent Client) 가 선언적으로 챙겨주는 풍경으로 한 단계 진화해요. "내가 손으로 짠 가드가 / 나중엔 프레임워크가 알아서 챙겨주는" 그 호흡 — Day 14 → Day 19 의 흐름.
본격 가드는 Day 14 의 지점 — 코드 미리 박지 않습니다
오늘 한 곳 욕심내지 말고 넘어갈 결정이 있어요.
maxIterations, 토큰 예산, 도구 호출 횟수 카운터, 타임아웃 — 이 네 부분는 Day 14 의 손 으로 박을 거예요.
오늘 미리 코드로 짜지 않는 이유는 단순해요.
Day 14 는 Agent 루프가 여러 사이클 도는 지점 라서, 가드의 진짜 의미 가 거기서 손에 자리잡아요.
오늘 한 사이클짜리 얕은 단계 에 가드를 미리 박으면 — 왜 필요한지의 감각이 흐려져요.
다만 — 오늘 코드의 비어 있는 곳 가 어디인지 는 한 번 더 표시해두고 갈게요. ToolChatClientConfig 안 어딘가에 나중에 짚을 부분 를 의사 코드로 한 줄.
// Day 14 의 자리 — 오늘은 비워 둠
// .defaultOptions(ToolCallingChatOptions.builder()
// .toolMaxIterations(3) // 한 응답 안에서 도구 호출 최대 3 회
// .toolCallTimeout(...) // 도구 한 번 호출당 타임아웃
// .build())
이 주석 한 지점는 오늘 박는 코드 가 아니에요.
Day 14 에서 이 곳에 박힐 거라는 표시 만.
학생 본인 코드에 지금 박지 마세요. 컴파일 안 돼요 (Spring AI 1.1.x 의 ToolCallingChatOptions 정확한 빌더 시그니처는 Day 14 에서 코드로 확정해서 박을 거예요).
오늘은 "여기에 가드의 곳가 있다" 의 모습만 한 번 짚어두면 충분해요.
Day 19 의 졸업 부분 — 3 단 피라미드의 결
마지막 한 단계 — Day 19 의 모습 을 한 지점 미리 짚어두고 갈게요.
Day 14 에서 손으로 짠 가드 들이 — Day 19 Harness 엔지니어링 단계에서 프레임워크가 선언적으로 챙겨주는 모양으로 진화해요.
Spring AI Agent Client + Spring AI Bench + Rate Limit + Cost Guardrail — 이 네 곳가 한 시스템에 박히는 흐름.
3 단 피라미드 로 정리해둘게요.
- Day 11 (오늘) — 그림자 한 단계입니다. "이런 위험이 있다" 의 모습만. 가드 코드 0.
- Day 14 — 손 한 단계입니다. maxIterations / 토큰 예산 / 도구 호출 횟수 / 타임아웃 4 부분를 직접 손으로 짜요. 내 손맛으로 가드를 만드는 단계.
- Day 19 — 시스템 한 단계입니다. Spring AI Agent Client 가 선언적으로 챙겨주는 풍경. Bench 로 회귀 평가까지 자동화. 프레임워크가 가드를 챙겨주는 단계.
같은 가드레일이 — 한 주에 그림자 → 손 → 시스템 으로 차례로 진화 해요. 오늘은 그 피라미드의 가장 아래 한 층 을 짚은 단계입니다. 욕심내서 위 두 층을 미리 당겨오지 않는 게 오늘의 호흡 이에요. 🌱
💡 튜터의 결론 — 오늘은 풍경 까지, 가드는 Day 14 의 손, 운영은 Day 19 의 시스템
오늘 Step 5 에서 손에 짚을 흐름은 단 두 줄이에요. (1) 도구 남용의 4 가지 그림자 — 무한 루프 / 토큰 폭발 / 부작용 폭발 / 권한 누수. (2) 우리 오늘 도구 3 종은 가드 0 — 학습용은 OK, 수료 후엔 위험. 본격 가드는 Day 14 의 손 으로 박고 — Day 19 의 시스템 으로 한 단계 더 진화해요. Day 7~9 의 호출 횟수 가드 → Day 10 의 비용 가드 → Day 14 의 도구 호출 가드 → Day 19 의 Harness — 이 가족 계보의 한 곳에 우리가 서 있는 거예요. 오늘은 Tool Calling 의 모습 까지만 단단히 짚고, 그림자의 곳는 인지만 하고 넘어가요. 🛡
자, 이제 Day 11 의 다섯 Step 이 한 도시에 자리잡았어요.
@Tool 한 줄의 등록 (Step 1~2) → 부작용 도구의 패턴 (Step 3) → 읽기 전용 도구의 패턴 (Step 4) → 도구 남용의 그림자 (Step 5) 까지 — Tool Calling 의 모습이 손에 모였습니다.
그런데 — 마지막 한 줄의 질문 이 남았어요.
오늘 우리가 만든 이 장면, 과연 이게 "에이전트" 인가요?
"LLM 이 스스로 도구를 골라 부른다" 는 모습만 보면 — 에이전트 같아요. 그런데 Step 1 에서 박은 ReAct 패턴의 한 사이클짜리 얕은 풍경 만 두고 그걸 에이전트라고 부를 수 있을까요? 루프가 한 번 더 돌면, 다섯 번 돌면, 열 번 돌면 — 그땐 에이전트인가? 학계의 정의와 실무의 정의는 어디서 갈라지는 거지? Workflow 와 Agent 의 경계 는 어디에 그어야 하는 거지? 이 질문 한 줄 이 — Day 12 의 문 앞에서 우리를 기다리는 부분이에요.
다음 마무리 섹션에서 — 오늘 박힌 다섯 Step 을 한 번 회고 하고, Day 12 의 문 까지 마저 두드려볼게요. 방금 만든 게 에이전트인가 — 그 한 줄 질문 을 들고 마무리로 넘어갑시다. 🛠
마무리
자, Day 11 의 모든 매듭이 닫혔어요. 주도권이 한 단계 LLM 으로 넘어가는 흐름, @Tool 한 줄에 LLM 이 알아서 함수를 부르는 첫 사이클, 그리고 그림자 네 가지 까지 — 다섯 Step 을 한 도시에 모았어요. 마무리에서는 손에 익은 흐름을 한 줄씩 회수하고, 다음 시간의 문을 두드려보는 시간이에요.
1. 오늘 손에 짚은 흐름 — 5 Step 압축 회고 ✅
오늘 다섯 Step 을 한 줄씩 회수해 봅시다.
| Step | 한 줄 요약 |
|---|---|
| ✅ Step 1 | Tool Calling 의 5 단계 사이클 — 주문서 한 장이 오가는 식당 비유 + ReAct 의 얕은 한 사이클 |
| ✅ Step 2 | ChatClient.tools(...) 회수 + WeatherTool — 비오니까 우산 챙겨 의 첫 자율 호출 풍경 |
| ✅ Step 3 | GameStateTool save / load — 부작용 있는 도구 + append-only 시간선 |
| ✅ Step 4 | AffinityTool 호감도 조회 — 읽기 전용 도구 + 기존 Soulmate 도메인 재사용 |
| ✅ Step 5 | 도구 남용 맛보기 — 그림자 → 손 → 시스템 의 3 단 피라미드 (Day 11 → Day 14 → Day 19) |
다섯 Step 이 한 줄로 흐르면 — Tool Calling 의 전체 그림 이 한 손에 들어와요. @Tool 어노테이션 한 줄의 등록 → 도구의 두 가족 (읽기 / 부작용) → 도구 남용의 그림자 까지 — Tool Calling 의 모든 큰 풍경 이 오늘 한 도시에 모였어요.
2. 🎯 오늘의 큰 결 세 부분 — 압축
다섯 Step 안에 흐른 가장 단단한 세 지점 를 한 번 더 짚어둘게요.
🎯 오늘의 큰 줄기 세 곳
(1)
@Tool한 줄에 주도권이 LLM 으로 넘어가는 흐름 — 우리는 도구만 등록, 호출 타이밍은 LLM. Day 1~10 의 모든 호출이 우리 손에서 출발했다면 — Day 11 부터는 의사 결정의 한 단계만 LLM 으로 양도되는 거예요. 도구 본체 / 등록 / 결과는 여전히 우리 손이라는 균형은 끝까지 유지.(2)
ChatClient.tools(...)의 게이트웨이 회수 — Day 3~6 에서 익혔던ChatClient가 Day 7~10 의 멀티모달 자매 추상화 옆길을 거쳐 — 오늘.tools(...)메서드를 손에 쥐고 Tool Calling 의 게이트웨이 로 돌아왔어요..prompt().tools(...).user(...).call().content()체이닝 위에서 도구 / 시스템 프롬프트 / 사용자 입력이 각자 자기 부분 에 깔끔하게 박혀요. 오늘부터 마지막 Day 까지 Tool Calling 이 들어간 모든 호출은 이 ChatClient 위에서 흘러요.(3) 세 도구의 한 가족 — 읽기 전용 (
WeatherToolstub /AffinityTool) vs 부작용 (GameStateTool.saveGameState) 의 위험도 차이. 읽기 도구는 LLM 이 100 번 불러도 DB 그대로, 부작용 도구는 한 번이 그대로 row 한 줄. 그래서 Tool Calling 도입의 가장 안전한 시작점은 항상 조회 도구부터 라는 가르침이 손에 익은 단계입니다.
이 세 지점가 Day 11 의 진짜 결 이에요. 코드 양은 Day 5 ~ 9 보다 적어도 / 손에 익은 무게는 가장 묵직한 단계입니다.
3. Day 12 의 문 두드리기 — 방금 만든 게 에이전트인가?
자, 오늘의 마지막 한 줄이자 다음 시간의 첫 글자.
오늘까지 우리는 — Tool Calling 의 전체 흐름 을 다 봤어요. @Tool 어노테이션 / ChatClient / 자율 호출 한 사이클 / 도구 세 곳 까지. 그런데 — Step 5 끝에서 우리가 마주친 한 줄의 질문이 있었죠.
💡 가장 강하게 짚을 흐름
"오늘 우리가 짠 Java 메서드 3 개에
@Tool한 줄 박았더니 — LLM 이 알아서 부르는 흐름이 됐어요. 그런데 — 이게 에이전트 인가요? 아닌가요? 그 답은 — 다음 시간."
Day 11 의 흐름 만 두고 "이게 에이전트다" 라고 부를 수 있을까요? ReAct 의 한 사이클짜리 얕은 결 만 두고 에이전트라고 단정짓기엔 — 학계의 정의 와 실무의 정의 가 어디서 갈라지는지를 짚어보지 않은 부분이에요. 루프가 한 번 더 돌면, 다섯 번 돌면, 열 번 돌면 — 그땐 에이전트인가? 이 질문 자체를 제대로 박는 부분가 —
Day 12 의 문 이에요.
Day 12 의 결정적 새 키워드 들을 미리 던져둘게요.
- Workflow vs Agent 의 스펙트럼 — 결정론적 orchestration (코드 경로가 미리 박힘) 과 LLM 자율 결정 (실행 흐름을 LLM 이 결정) 의 두 끝, 그리고 그 사이의 스펙트럼.
- Anthropic Building Effective Agents — 2024년 말 Anthropic 이 공개한 한 편의 글. Workflow / Agent 의 정의가 실무에서 어떻게 갈리는가 의 결정적 레퍼런스. Day 12 의 가장 큰 인용 부분이에요.
- 에이전트 폭주 라이브 시연 — Step 5 에서 그림자 로만 짚어둔 네 지점 (무한 루프 / 툴 남용 / 토큰 폭발 / 권한 누수) 를 — Day 12 에서는 진짜로 라이브로 망가뜨려 봐요. 30 초 컷 무한 루프 / 같은 도구 수십 번 호출 / 예산 폭발의 청구서 를 직접 눈으로. 이 폭주의 치료법 4 곳 는 —
maxIterations/ 타임아웃 / 토큰 예산 / 툴 호출 횟수 제한 — Day 14 (Agent 패턴 + 자율성 경계) 에서 손으로 직접 짜요. - 본 강의의 정의 한 줄 — "에이전트 = 도구 + LLM 자율 + 루프 + 가드레일 4 박자" — Day 12 마지막에 손에 익혀집니다.
튜터의 결론 — Day 12 의 문
오늘 박은 @Tool / ChatClient / 자율 호출 한 사이클 은 — 에이전트의 가장 작은 시작점 이에요. 하지만 그것만으로 "에이전트" 라고 단정하기엔 루프 / 가드레일 이라는 두 부분이 비어 있어요. 다음 시간 — Workflow vs Agent 의 스펙트럼 / Anthropic 의 정의 / 에이전트가 진짜로 폭주하는 장면 까지 정리하고 나면 — "본 강의가 에이전트라고 부르는 정의" 가 손에 들어와요. 오늘 비워둔 그림자의 부분 가 — 다음 시간 진짜 폭주의 모습 으로 한 단계 깊어집니다. 방금 만든 게 에이전트인가 — 그 한 줄 질문의 답 은 Day 12 에서 만나요.
시의성 노트 — Spring AI 1.1 → 2.0 의 출렁임
본 강의는 Spring AI 1.1.x (2026-05 시점 최신 패치 =
1.1.6, Spring Boot 3.3.x 호환) 위에서 박혀 있어요. 그런데 — 2.0 GA 가 바로 코앞 까지 와 있어요. 2026-05 시점2.0.0-M6까지 공개됐고, Spring Boot 4.0 + Jackson 3 + Null Safety baseline 의 한 단계 큰 점프를 들고 와요. 본 강의에서 오늘 짠@Tool/@ToolParam/ChatClient.Builder.defaultTools(...)시그니처는 1.1 → 2.0 사이에도 그대로 살아남아요. 어노테이션 패턴 자체는 안 흔들리고 — 흔들리는 곳는 baseline (Spring Boot · Jackson · 옵션 클래스 계층). 즉 오늘 박은 Tool Calling 패턴이 2.0 으로 옮겨가도 그대로 적용 돼요.핵심 메시지 — baseline 은 출렁이지만 추상화의 결은 그대로 입니다. Day 20 마지막 에 Spring AI 2.0 마이그레이션 노트 단계에서 한 번 더 펼쳐 볼 예정이니, 오늘은 "1.1 위에 단단히 익혀둔다" 한 줄에 집중하면 충분해요. 🌱
오늘의 마지막 한 줄
자, 오늘의 마지막 한 줄.
튜터의 마지막 한 줄
"Day 1~10 까지는 — 우리가 LLM 에게 명령 하는 장면이었어요. Day 11 — 한 번 뒤집혔어요. 우리는 도구만 등록, LLM 이 언제 부를지 결정.
@Tool한 줄에 주도권의 한 단계가 LLM 으로 양도 되는 첫 부분입니다. 이게 — 에이전트의 가장 작은 시작점. 그런데 — 이게 진짜 에이전트인가, 아닌가 — 그 답은 Day 12 의 문 너머 에 있어요. 다음 시간에 만나요. 🛠"
Day 11 의 모든 매듭 이 닫혔어요. 주도권의 첫 양도 + 도구 세 지점의 두 가족 + 도구 남용의 그림자 까지 — 5 Step + 마무리 의 한 도시가 한 손에 모였어요. 코드 양은 Day 5 ~ 9 만큼이 아니어도 / 학습 무게는 한 단계 더 묵직한 Day 였어요.
자, 마무리 섹션은 여기까지. Mission 섹션 으로 넘어가서 오늘의 과제와 생각해볼 주제 를 받아보세요!
🎯 Mission — 오늘의 과제
오늘 다룬 @Tool 어노테이션 한 줄 등록 · ChatClient.tools(...) 의 게이트웨이 회수 · 빈 분리 (weatherChatClient / gameStateChatClient / affinityChatClient 세 곳) · 읽기 전용 도구 vs 부작용 도구의 한 가족 · ApiResponse<T> 표준 응답 · 도구 남용의 4 가지 그림자 를 내 손에서 한 번 더 펼쳐보는 부분이에요.
코드 양은 가장 적었지만 무게는 가장 묵직했던 Day 11 — 그 흐름을 세 갈래의 체험 으로 다시 한 번 풀어둘게요. 🛠
세 과제는 난이도 사다리 로 자리잡아 있어요.
🌱 기초 (Step 2/4 패턴 회수) → ⭐ 응용 (Step 3 의 부작용 도구 데자뷰 두 번째) → ⚠ 심화 (Step 5 의 권한 누수 그림자 회수 + 도구 묶음 설계의 졸업 시뮬레이션).
한 갈래씩 쌓이면서 — "세 번 박혀야 진짜 내 손이 된다" 라는 가르침이 Day 11 의 가장 작은 도구의 부분 에서 검증되는 모습.
💡 과제 작업 시 공통 가이드
- 모든 과제는 ai-friends 프로젝트의 별도 브랜치 에서 작업하세요. (예:
day11-assignment1-mood-tool,day11-assignment2-diary-tool,day11-assignment3-store-controller)- 새 컨트롤러를 만들 때는 본 강의의 표준 응답 패턴 을 그대로 따르세요 —
ResponseEntity<ApiResponse<T>>형태. 정상 응답 raw 반환 금지.- Tool Calling vs MCP 구분 — 본 과제는 우리 앱 내부의
@Tool부분이에요. 외부 MCP 서버에서 도구를 가져오는 방식 (Day 17) 이나 우리 도구를 MCP 서버로 노출하는 방식 (Day 18) 은 오늘의 지점가 아님. 모든 도구는 우리 코드 안에서@Tool어노테이션 으로 등록하세요.ChatModel직접 호출 금지 (도구 호출 곳에서) —.tools(...)메서드는ChatClient.Builder위에만 살아 있어요. Tool Calling 이 들어가는 부분에서는 반드시 Step 2 에서 익힌ChatClient체이닝 위에서 호출하세요. (Day 8 Vision 의chatModel.call(new Prompt(userMessage))처럼 멀티모달 미디어 첨부 호출 은ChatModel시그니처가 맞지만, 도구 호출 지점에서는ChatClient가 정답.)- API 키 하드코딩 금지 —
GEMINI_API_KEY등 모든 키는.env+ 환경변수로. 코드 안에AIza...또는SK_...같은 흔적이 한 글자라도 박히면 안 돼요.- 이미 만든 세 도구 (
WeatherTool·GameStateTool·AffinityTool) 의 코드를 직접 수정하지 마세요. 중간 합류 학생이git checkout day11-tool-calling으로 들어왔을 때 기준 코드의 모양이 흐트러지지 않게 하기 위함이에요. 새 도구는 나란히 박는 방식.- 본 Day 의 디폴트 — 학생 실습은 모두 우리 MySQL + Gemini 무료 티어 위에서 100% 도는 환경. 외부 유료 API 키 일절 불필요.
[구현 1] 🌱 새 @Tool 한 곳 등록 — ServerInfoTool (or CharacterMoodTool) ⭐⭐ 30~45 분
배경 시나리오
ai-friends 의 PM 이 가장 가벼운 부분 한 가지를 들고 왔어요.
"튜터님, Step 2 에서 본
WeatherTool처럼 부작용도 없고 / DB 도 안 건드리는 도구를 학생 본인이 처음부터 끝까지 한 번 짜보는 지점 가 필요해요. 너무 가볍게 — 서버 시간을 알려주는 stub 도구 같은 단계입니다. 또는 캐릭터의 오늘의 분위기 (mood) 를 한 줄 돌려주는 곳 같은 패턴. 코드 양은 작아도 — @Tool 어노테이션 + 빈 분리 + 컨트롤러 + ApiResponse + 단위 테스트 의 5 단 체험 이 본인 손에서 한 번 흐르게 해주세요." 🌱
Step 2 에서 강사가 보여준 읽기 전용 도구의 가장 작은 풍경 — 그걸 학생 본인 손으로 한 번 더 박는 부분이에요. 새 외부 API 도, 새 엔티티도, 새 DB 테이블도 없어요. 오늘 처음 본 패턴을 내 손으로 한 번 더 흐르게 하는 시간.
💡 왜 굳이 이 과제를 할까요?
@Tool어노테이션 첫 손맛 — Step 2 에서 강사가 짚은 부분를 눈으로만 본 결 과 내 손으로 한 번 짜은 결 은 무게가 달라요. 어노테이션 한 줄 + description 한 줄 + 메서드 시그니처 한 줄 의 세 지점 가 진짜 손에 익는 단계입니다.
빈 분리 패턴 두 번째 회수 — Step 2 에서 본 weatherChatClient 빈 분리, Step 3 에서 본 gameStateChatClient 빈 분리, Step 4 에서 본 affinityChatClient 빈 분리 — 이미 세 곳의 결 을 보고 왔어요.
이번엔 내 손으로 네 번째 빈 분리 를 짚는 단계입니다.
왜 빈을 분리하는가 / 왜 도구별로 묶는가 가 손에 더 단단히 짚혀요. 3.
5 단 체험의 가장 작은 완본 — @Tool 메서드 / @Configuration 빈 / 컨트롤러 / ApiResponse 래핑 / @WebMvcTest 1~2 케이스 까지 — Day 11 의 모든 패턴의 가장 작은 한 사이클 이 한 과제 안에 다 담겨요.
Day 11 의 모습 전체를 작은 모형으로 한 번 더 짚는 흐름. 🌱
✅ 요구사항
학생이 둘 중 한 부분 를 골라 박으세요. 둘 다 부작용 없는 stub 결 이라 난이도는 비슷해요.
옵션 A — ServerInfoTool (가장 가벼운 지점)
ServerInfoTool클래스 신규 생성 —kr.spartaclub.aifriends.tool.serverinfo패키지@Tool메서드 한 부분 —getServerTime()또는getServerStatus()
description = "현재 서버 시간을 ISO-8601 형식으로 돌려줍니다"같은 자연어 한 줄- 반환은
String또는record ServerInfo(String time, String status)
ServerInfoChatClientConfig—ChatClient.Builder위에서 Step 2 의weatherChatClient모양 그대로 빈 분리.serverInfoChatClient빈 한 단계입니다.ServerInfoChatController—POST /api/tool/server-info/chat엔드포인트.ResponseEntity<ApiResponse<ServerInfoChatResponse>>형태.@WebMvcTest단위 테스트 1~2 케이스 —ServerInfoChatService를@MockBean으로 두고, 컨트롤러의 입출력 계약 (입력 검증 + ApiResponse 래핑) 만 검증.
옵션 B — CharacterMoodTool (도메인 결합 곳)
CharacterMoodTool클래스 신규 생성 —kr.spartaclub.aifriends.tool.mood패키지@Tool메서드 한 부분 —getCurrentMood(Long soulmateId)
description = "캐릭터의 현재 분위기 (mood) 를 한 줄로 돌려줍니다. 호감도와 무관하게 오늘의 기분 자리"- 반환은
record CharacterMood(Long soulmateId, String mood, String emoji)— 분위기는 stub 으로 fixed (예: "들뜸 " / "차분함 ") 또는 soulmateId % 4 같은 결정론적 결
MoodChatClientConfig—moodChatClient빈 한 단계입니다.MoodChatController—POST /api/tool/mood/chat엔드포인트.ApiResponse래핑.@WebMvcTest단위 테스트 1~2 케이스 — Step 4 의AffinityChatControllerTest모양을 그대로 베껴 박기.
확인 방법
# 1) 단위 테스트
./gradlew test --tests '*ServerInfoChatControllerTest*' # 또는 *MoodChatControllerTest*
# 2) (선택) 통합 시연
./run.sh
curl -X POST http:/localhost:8080/api/tool/server-info/chat \
-H "Content-Type: application/json" \
-d '{"message": "지금 서버 시간 알려줘"}'
# → aiMessage 안에 LLM 이 자율 호출로 받은 시간이 자연어로 박힘
💡 힌트
- Step 2 의
WeatherTool코드를 템플릿 으로 그대로 베끼고 — 도구 이름과 description 한 줄만 바꾸세요. 학습의 본질은 패턴의 회수 이지 새로운 코드 구조의 발명 이 아니에요. - 빈 이름의 컨벤션 —
weatherChatClient/gameStateChatClient/affinityChatClient의 패턴을 그대로 따라 —serverInfoChatClient또는moodChatClient로. 과목 컨벤션의 손맛 이 손에 익는 단계입니다. - description 의 자연어 한 줄 — 생각해볼 주제 1 과 자연스럽게 이어지는 부분이에요. 어디까지 자세하게 박을지 의 균형을 한 번 고민하면서 박으세요.
- 테스트는
@WebMvcTest+@MockBean— Step 2/3/4 의 패턴 그대로. 진짜 LLM 도구 디스패치는 강사 수동 smoke 부분 라는 결도 그대로.
제약 / 금지
- 기존 도구 (
WeatherTool·GameStateTool·AffinityTool) 의 코드 한 줄 수정 금지 — 새 도구는 나란히 짚는 흐름. ChatModel직접 호출 금지 —ChatClient.Builder위에서만.- 외부 API 호출 금지 — 부작용 없는 stub 의 결만. 진짜 NTP 서버 호출 같은 외부 지점는 본 과제에 들어가지 않아요.
- 새 엔티티 / 새 Repository / 새 테이블 추가 금지 — 기초 곳의 결 은 부작용 0 이라야 깨끗해요. DB 가 끼면 그건 [구현 2] 의 단계입니다.
[구현 2] ⭐ 부작용 도구 한 부분 추가 — CharacterDiaryTool (write + read) ⭐⭐⭐ 60~90 분
배경 시나리오
ai-friends 의 기획 PM 이 한 단계 깊은 지점 를 들고 왔어요.
"튜터님, Step 3 의
GameStateTool풍경 — 세이브 / 로드 두 도구가 한 곳에 묶인 장면 — 너무 인상적이었어요. 이 흐름을 한 번 더 짚는 곳가 필요해요. 이번엔 — 캐릭터의 일기장 으로. 매일의 대화 흐름에서 캐릭터가 "오늘 너랑 산책한 거 좋았어" 같은 단계에서 — LLM 이writeDiary도구로 자율 저장 하고, 다음에 캐릭터가 "어제 우리 뭐했지?" 라고 물으면 —readDiary도구로 자율 조회 하는 장면. Step 3 패턴의 데자뷰 두 번째 단계입니다."
Step 3 의 GameStateTool 패턴을 — 내 손으로 한 번 더 짚는 부분이에요. append-only 시간선 / 빈 결과 팩토리 / 빈 분리 라는 세 부분의 손맛 이 세 번째 박혀야 진짜 내 손이 돼요. 세 번 박혀야 진짜 — Day 11 의 가르침 그대로.
💡 왜 굳이 이 과제를 할까요?
append-only 패턴 데자뷰 두 번째 — Step 3 의 GameStateEntry 가 findFirstByCharacterIdOrderByCreatedAtDesc 로 마지막 한 줄만 살아있는 풍경이었죠.
이번 DiaryEntry 도 같은 패턴.
수정도 삭제도 안 하고 / 그냥 새 row 만 박는 append-only 의 체험이 두 번째 박힐 때 손에 더 단단히 짚혀요.
역사 기록의 패턴. 2.
빈 결과 팩토리의 가족 — Step 3 의 GameState.empty() / Step 4 의 AffinityInfo.unknown() — 이 빈 결과 팩토리 패턴이 세 번째 등장하는 단계입니다.
DiaryEntry.empty() (또는 DiaryRecall.notFound()) 형태로.
LLM 에게 not found 의 의미를 한 객체로 표현하는 손맛. 3.
system 프롬프트의 지점 — Step 4 에서 본 "네 자신의 캐릭터 어투로 변주해라" 가이드라인의 일기 곳 회수.
"found=false 면 미안하게 — '나 그날 일기는 못 적어뒀나봐, 미안해' 같은 어투로 답해" 라는 한 줄을 system 프롬프트에 박는 손맛.
데이터의 모양을 자연어로 변주하는 Step 4 의 가르침이 다시.
✅ 요구사항
DiaryEntry엔티티 신규 생성 —kr.spartaclub.aifriends.tool.diary.entity패키지
id(auto),soulmateId(Long),entryDate(LocalDate, 날짜 단위 1 일 1 일기 — 생각해볼 주제와 자연스럽게 이어짐),content(String, max 500),createdAt(LocalDateTime)- JPA
@Entity+ Lombok@Getter+protected기본 생성자 — Step 3 패턴 그대로
DiaryEntryRepository—JpaRepository인터페이스
findFirstBySoulmateIdAndEntryDate(Long soulmateId, LocalDate date)— 그 날의 일기 한 줄 조회findFirstBySoulmateIdOrderByCreatedAtDesc(Long soulmateId)— 가장 최근 일기 한 줄 조회
CharacterDiaryTool클래스 —@Tool메서드 두 부분
writeDiary(Long soulmateId, String content)— 부작용 단계입니다.DiaryEntry를 새 row 로 저장 (append-only, 같은 날짜에 두 번 박혀도 새 row 두 줄). 반환은DiaryWriteResult(boolean saved, LocalDate entryDate)readDiary(Long soulmateId)— 읽기 단계입니다. 가장 최근 일기 한 줄 조회. 결과 없으면DiaryRecall.empty()팩토리.
DiaryRecall결과 record —record DiaryRecall(boolean found, LocalDate entryDate, String content)+DiaryRecall.empty()팩토리 (Step 3/4 의 가족 결)DiaryChatClientConfig—diaryChatClient빈 한 단계입니다. system 프롬프트 에 "found=false 면 캐릭터 어투로 미안하게 답해 — '그날 일기는 못 적어뒀나봐'" 가이드 박기.DiaryChatController—POST /api/tool/diary/chat엔드포인트.ApiResponse래핑. 응답 바디는soulmateId+aiMessage두 지점로 슬림 하게 (Step 4 와 같은 결). 호출 증거는 도구 메서드 안쪽log.info한 줄로 콘솔에 남겨두기.- 단위 테스트
DiaryEntryRepositoryTest—@DataJpaTest로 append-only 검증 (같은 날짜에 두 번 저장 → row 두 줄 살아있음)CharacterDiaryToolTest—readDiary가 빈 결과일 때DiaryRecall.empty()팩토리 사용 검증DiaryChatControllerTest—@WebMvcTest+@MockBean으로 입출력 계약 검증
확인 방법
# 1) 단위 테스트
./gradlew test --tests '*DiaryEntryRepositoryTest*'
./gradlew test --tests '*CharacterDiaryToolTest*'
./gradlew test --tests '*DiaryChatControllerTest*'
# 2) (선택) 통합 시연 — append-only 와 빈 결과 두 풍경
./run.sh
# 첫 호출 — 일기 자리가 비어 있는 결
curl -X POST http:/localhost:8080/api/tool/diary/chat \
-H "Content-Type: application/json" \
-d '{"soulmateId": 7, "message": "어제 우리 뭐했지?"}'
# → aiMessage 안에 "그날 일기는 못 적어뒀나봐, 미안해" 같은 어투
# 두 번째 호출 — 일기 한 줄 짚는 흐름 (LLM 이 writeDiary 자율 호출)
curl -X POST http:/localhost:8080/api/tool/diary/chat \
-H "Content-Type: application/json" \
-d '{"soulmateId": 7, "message": "오늘 우리 산책한 거 너무 좋았어 — 일기에 적어둬"}'
# 세 번째 호출 — 일기 회수
curl -X POST http:/localhost:8080/api/tool/diary/chat \
-H "Content-Type: application/json" \
-d '{"soulmateId": 7, "message": "오늘 우리 뭐했지?"}'
# → aiMessage 안에 "오늘 산책한 거 좋았다고 적어뒀어" 자연어
💡 힌트
- Step 3 의
GameStateTool코드를 옆 모니터에 펼쳐둔 채 베끼세요. append-only / 빈 결과 팩토리 / system 프롬프트 가이드 가 그대로 회수 되는 단계입니다. 데자뷰 두 번째 의 본질은 손가락 근육의 강화 예요. DiaryEntry의entryDate필드 — 날짜 단위로 일기 한 줄 vs 시간 단위로 여러 줄 의 결정이 곳하는데 — 과제는 "같은 날짜에 두 번 박혀도 새 row 두 줄" 의 append-only 방식 으로 박으세요. 수정 (UPDATE) 방식 은 같은 날짜의 일기를 덮어쓰는 모양 으로 빠지는데 — Step 3 의 가르침은 "역사를 수정하지 말고 새 row 만" 이었죠. 그 방식 그대로.DiaryRecall.empty()팩토리 — Step 3 의GameState.empty()패턴 그대로. null 을 LLM 한테 넘기지 말고 / 빈 객체를 표현하는 객체 한 부분 의 손맛.- system 프롬프트의 한 줄 — Step 4 의
getAffinity도구 description 패턴을 따라, "readDiary가 found=false 를 돌려주면 — '그날 일기는 못 적어뒀나봐, 미안해' 같은 캐릭터 어투로 답해라" 라는 한 줄을 짜으세요. LLM 이 도구 결과를 자연어로 변주하는 패턴의 회수.
제약 / 금지
- 기존 도구 (
GameStateTool·AffinityTool) 의 코드 한 줄 수정 금지 — 새 도구는 나란히 짚는 흐름. - append-only 깨기 금지 —
writeDiary가 같은 날짜의 기존 row 를 UPDATE 하면 안 돼요. 새 row 만 INSERT. 수정 지점는 Day 11 의 곳가 아님. ChatModel직접 호출 금지 —ChatClient.Builder위에서만.- 삭제 도구 (
deleteDiary) 추가 금지 — 부작용 도구의 위험도가 한 단계 더 깊어지는 단계입니다. Step 5 의 그림자가 짙어지는 영역이라 — Day 14 의 가드 까지 미뤄두는 게 디폴트.
[구현 3] ⚠ 읽기 + 부작용 도구 한 컨트롤러에 묶기 — CharacterStoreController (인벤토리 도구 3 부분) ⭐⭐⭐⭐ 90~120 분
배경 시나리오
ai-friends 의 기획 PM 이 졸업 시뮬레이션 지점 한 가지를 들고 왔어요.
"튜터님, 캐릭터에게 인벤토리 기능이 필요해요. 사용자가 '커피 두 잔 사줄게' 라고 하면 — LLM 이
addItem(soulmateId, '커피', 2)을 자율 호출. '우리 인벤토리에 뭐 있지?' 라고 물으면 —getInventory(soulmateId)자율 호출. '커피 한 잔 마실게' 하면 —removeItem(soulmateId, '커피', 1)자율 호출. 도구 세 곳 가 한 컨트롤러에 묶이는 부분이에요. 그리고 — Step 5 에서 짚은 권한 누수의 그림자 를 내 손으로 한 번 막아보는 단계입니다. 내 캐릭터의 인벤토리에 다른 사용자가 손대지 못하게 하는 한 줄." 🛡
Step 5 에서 그림자로만 짚어둔 권한 누수 부분를 — 내 손으로 한 번 막아보는 단계. Day 14 의 가드 지점 직전의 호흡 으로 — 읽기 + 부작용 도구가 한 컨트롤러에 묶일 때 빈을 어떻게 분리/통합할지 의 판단 곳 까지 학생이 직접 결정 하는 졸업 시뮬레이션이에요. ⚠
💡 왜 굳이 이 과제를 할까요?
도구 묶음 설계의 판단 부분 — Step 2/3/4 까지 우리는 도구마다 빈 한 지점 방식으로 짚었어요.
그런데 — 조회 1 + 부작용 2 의 도구 세 곳가 한 컨트롤러에 묶일 때 어떻게 박을 거예요? 세 부분 모두 한 빈에 등록할지 (storeChatClient 한 지점), 조회와 부작용을 따로 분리할지 (storeReadChatClient + storeWriteChatClient 두 곳) —
학생이 직접 판단 하는 단계입니다.
"빈 분리는 도구 종류로? 위험도로? 도메인 경계로?" 의 결정 트리. 2.
권한 검사의 손맛 — Step 5 의 (4) 권한 누수 그림자 — playerId 검증 없이 soulmateId 만 받으면 다른 사용자 데이터까지 들여다보는 구멍.
본 과제는 각 도구 메서드의 첫 줄에 권한 검사 한 부분 를 박아요.
호출자의 playerId 와 soulmateId 의 소유 관계 검증.
Day 14 의 손 으로 본격 가드를 짜기 전에 — 이미 우리 손에서 권한 가드가 한 번 짜힌 단계입니다.
3. Day 14 의 가드 지점 직전의 호흡 — 이 과제를 한 번 짚어두면 — Day 14 에서 maxIterations, 토큰 예산, 도구 호출 횟수, 타임아웃 4 가지가 등장할 때 — 왜 그것들이 필요한지 가 훨씬 더 단단히 자리잡아요. 오늘 권한 누수만 막은 곳에 / Day 14 에 4 가지가 더 얹히는 흐름.
✅ 요구사항
Inventory+InventoryItem엔티티 신규 생성 —kr.spartaclub.aifriends.tool.store.entity패키지
Inventory—id,soulmateId(Long, unique),playerId(Long — 권한 검사의 곳),createdAt,updatedAtInventoryItem—id,inventoryId(FK),itemName(String),quantity(int),createdAt,updatedAt- 한 캐릭터 = 하나의 인벤토리 = 여러 아이템 구조.
- Repository 두 부분 —
InventoryRepository(findBySoulmateId),InventoryItemRepository(findByInventoryIdAndItemName) CharacterStoreTool클래스 —@Tool메서드 세 지점
getInventory(Long soulmateId)— 읽기 단계입니다. 인벤토리 전체 아이템 목록 반환. 결과 없으면InventoryView.empty()팩토리.addItem(Long soulmateId, String itemName, int quantity)— 부작용 단계입니다. 같은 itemName 이 있으면 quantity 증가, 없으면 새 row 추가. 반환은ItemMutationResult(boolean success, String itemName, int newQuantity).removeItem(Long soulmateId, String itemName, int quantity)— 부작용 단계입니다. 같은 itemName 이 있으면 quantity 감소 (0 이면 row 삭제), 없으면success=false.
- 권한 검사 한 곳 — 세 도구 모두의 첫 줄에
- 메서드 첫 줄에서 호출자 컨텍스트의 playerId 를 받아 Inventory.playerId 와 비교.
- 컨텍스트 추출은 Spring Security 의
SecurityContextHolder또는 간단히 컨트롤러에서 받은playerId를 ToolContext 로 넘기기 — 학생 판단. - 불일치 시
IllegalAccessException또는 새STORE_PERMISSION_DENIEDErrorCode 한 단계입니다. 권한 누수 그림자를 손으로 막은 풍경.
StoreChatClientConfig— 빈 분리 / 통합 판단의 부분
- 두 가지 길: (A)
storeChatClient한 지점 에 도구 3 개 모두 등록 / (B)storeReadChatClient+storeWriteChatClient두 곳 로 조회 vs 부작용 분리. - 학생 본인의 판단을 PR description 에 한 줄로 — "왜 한 빈으로 (또는 두 빈으로) 박았는가" 의 근거.
CharacterStoreController한 부분 —POST /api/tool/store/chat(또는 분리 시 두 부분).ApiResponse래핑.- 단위 테스트
CharacterStoreToolTest— 권한 일치 시 정상 / 불일치 시 예외 두 케이스 + addItem / removeItem 의 quantity 증감 결 검증.CharacterStoreControllerTest—@WebMvcTest+@MockBean.InventoryRepositoryTest—@DataJpaTest.
확인 방법
# 1) 단위 테스트
./gradlew test --tests '*CharacterStoreToolTest*'
./gradlew test --tests '*CharacterStoreControllerTest*'
# 2) (선택) 통합 시연 — 권한 검사 + 도구 3 자리
./run.sh
# 캐릭터 7 (playerId=1 소유) 의 인벤토리 조회
curl -X POST http:/localhost:8080/api/tool/store/chat \
-H "Content-Type: application/json" \
-H "X-Player-Id: 1" \
-d '{"soulmateId": 7, "message": "우리 인벤토리에 뭐 있지?"}'
# 아이템 추가
curl -X POST http:/localhost:8080/api/tool/store/chat \
-H "Content-Type: application/json" \
-H "X-Player-Id: 1" \
-d '{"soulmateId": 7, "message": "커피 두 잔 사줄게"}'
# 다른 사용자 (playerId=2) 가 캐릭터 7 의 인벤토리를 보려고 시도 — 권한 거부
curl -X POST http:/localhost:8080/api/tool/store/chat \
-H "Content-Type: application/json" \
-H "X-Player-Id: 2" \
-d '{"soulmateId": 7, "message": "그 인벤토리 봐줘"}'
# → 403 STORE_PERMISSION_DENIED — 권한 누수 그림자를 막은 자리
💡 힌트
- 빈 분리 vs 통합의 결정 기준 — 한 빈에 도구가 너무 많으면 system 프롬프트가 비대해지고 / LLM 이 도구 선택을 헷갈려요. 분리하면 system 프롬프트가 깔끔해지지만 / 빈 개수가 많아져요. 과제 3 의 도구 3 개는 경계선 사례 — 양쪽 길 모두 합리적이에요. 왜 한 빈으로 (또는 두 빈으로) 박았는지 가 PR description 의 본질.
- 권한 검사의 위치 — 도구 메서드의 첫 줄 vs AOP vs Spring Security 의
@PreAuthorize— 세 길 모두 가능. 학습 환경에선 도구 메서드 첫 줄에 손으로 짜는 방식이 가장 직관적이에요. 왜 거기인지 의 손맛. ToolContext활용 (Spring AI 1.1.x) —ChatClient.prompt().toolContext(Map.of("playerId", 1L))로 호출자 컨텍스트 를 도구로 흘리는 패턴.@ToolParam(toolContext = true)로 받기. Day 14 에서 한 번 더 등장하는 패턴 의 미리보기.removeItem의 0 보다 작아질 때 처리 — 현재 1 개 있는데 2 개 빼달라 는 지점는 부작용을 막고 success=false 로 박으세요. 적은 수량을 음수로 박지 않는 디폴트.- Step 5 의 (4) 권한 누수 그림자 회수 — 오늘 코드의 비어 있는 곳 라고 솔직하게 짚어둔 그 부분를 — 내 손으로 한 번 막은 풍경이 과제 3 의 본질 이에요. Day 14 의 가드 지점 직전의 호흡.
제약 / 금지
- 기존 도구 (
WeatherTool·GameStateTool·AffinityTool) 의 코드 한 줄 수정 금지. ChatModel직접 호출 금지 —ChatClient.Builder위에서만.maxIterations/ 토큰 예산 / 호출 횟수 가드 박지 마세요 — 그건 Day 14 의 단계입니다. 본 과제는 권한 가드 한 부분만 짚는 흐름. 오늘 한 곳, Day 14 에 네 부분 의 호흡.- 삭제 권한을 LLM 에게 자율로 위임 금지의 결정 지점 — 생각해볼 주제 2 와 자연스럽게 이어짐. 본 과제는 removeItem 도 LLM 자율 호출 로 박지만 — 그게 정말 안전한가 는 생각해볼 주제 에서 한 번 더 굴러요. 과제는 일단 자율 위임으로 박고 — 그 결정의 위험도는 머리로 굴리는 단계입니다.
InMemoryChatMemoryRepository사용 금지 — Day 5 이후 영속 저장 원칙 그대로. 본 과제는 ChatMemory 곳는 아니지만 회귀 차단용 확인.
💭 생각해볼 주제
💭 이 섹션의 목적 — 정답이 정해져 있지 않은 질문들이에요. 오늘 박은 @Tool 어노테이션 / ChatClient / 도구의 두 가족 / 도구 남용의 그림자 4 부분 의 결정들을 한 발 떨어져 바라보고, "왜 이렇게 결정했지?" 와 "다른 길은 없었나?" 를 사고하는 부분이에요. 면접에서도 자주 등장하는 토픽들이라, 가능하면 문장으로 적어보세요. 머릿속 생각과 글로 적은 생각은 다릅니다.
주제 1 — @Tool 의 description 은 어디까지 자세하게 박아야 하는가?
오늘 Step 1 에서 결정적인 한 줄 이 자리잡았어요.
"@Tool 의 description 은 프롬프트의 일부 이지 주석이 아니다." LLM 이 어느 도구를 부를지 결정하는 입력값 이라, 너무 짧으면 LLM 이 도구를 잘못 부르고 / 너무 자세하면 토큰이 폭발 하는 단계입니다.
Step 2 의 getCurrentWeather description = "도시 이름을 받아서 현재 기온과 강수 여부를 돌려줍니다" — 한 줄이지만 "도시 이름" / "현재" / "기온" / "강수 여부" 네 키워드가 자리잡은 모양이에요.
그런데 — 우리 팀의 도구 description 가이드라인 은 어떻게 박아야 할까요? 너무 짧으면 LLM 이 getAffinity 와 getMood 를 헷갈리는 지점가 자리잡아요. 너무 자세하면 — 도구 50 개 등록 시 description 만으로 컨텍스트가 5000 토큰 이 되는 단계입니다. 균형의 문제.
그리고 — 영어 vs 한국어 의 결정도 곳해요.
description="도시 이름을 받아 현재 날씨를 돌려준다" (한국어) vs description="Returns the current weather for a given city" (영어).
LLM 의 학습 데이터 비율을 생각하면 영어가 정답 같지만 — 우리 코드베이스의 다른 부분는 다 한국어인데 description 만 영어로 박는 게 일관성 깨는 지점이기도 해요.
현지화의 그림자.
🎯 핵심 질문 — 우리 팀의
@Tooldescription 작성 가이드라인을 한 문장으로 박는다면 무엇인가?길이의 상한 (예: 80 자 이내) / 키워드의 필수 곳 (입력 / 출력 / 부작용 여부) / 언어 (한국어 vs 영어) / "이 도구를 언제 부를지" 의 시그널까지 — 우리 팀의 description 한 줄 컨벤션 은 어떻게 박힐 부분인가?
생각해볼 자료:
- Step 2 의
WeatherTool.description한 줄 — "도시 이름을 받아 현재 기온과 강수 여부를 돌려줍니다" — 이 한 줄이 박힌 키워드 4 지점 의 분석. - Spring AI 1.1.x 공식 문서의 @Tool best practices 곳 — 영어 description 권장 + 50~100 토큰 권장 의 권고.
- Day 3 의
PromptTemplate가르침 — 프롬프트의 한 줄도 LLM 의 행동을 좌우 한다는 결론이 description 단계에서도 똑같이 살아있는 풍경.
주제 2 — 부작용 있는 도구를 LLM 자율 호출에 맡길 것인가, 우리가 직접 부를 것인가?
오늘 Step 3 에서 결정적인 분기 한 부분가 자리잡았어요.
saveGameState 는 우리가 직접 호출 / loadGameState 는 LLM 자율 위임 — 같은 GameStateTool 안의 두 도구인데 호출 주도권이 다른 단계입니다.
부작용 도구는 우리 손으로, 조회 도구는 LLM 한테 양보 의 분기.
그런데 — 과제 3 (CharacterStoreController) 에서는 addItem / removeItem 부작용 도구 두 지점 모두 LLM 자율 호출 로 짚었어요.
방식이 다르죠? Step 3 와 과제 3 의 결정이 왜 다른가? 답은 — 위험도 차이.
saveGameState 는 게임 상태 한 줄 박는 동작이라 되돌리기 어려운 단계입니다.
addItem 은 아이템 한 줄 박는 동작이라 removeItem 으로 되돌릴 수 있는 단계입니다.
되돌릴 수 있는 부작용 vs 되돌릴 수 없는 부작용 의 차이.
그리고 진짜 운영급 결정 곳 — DB 에 INSERT 하는 도구를 LLM 자율 호출에 두려면 어떤 가드가 반드시 박혀야 하나? 호출 횟수 제한 / 권한 검사 / 트랜잭션 / 멱등성 키 / dry-run 모드 / 사용자 confirmation —
어느 부분들이 반드시 박혀야 LLM 자율 위임 이 안전해지는가? Day 14 의 가드 지점 와 자연스럽게 이어지는 질문.
운영급에선 한 단계 더 깊은 질문도 따라와요.
결제 (processPayment) / 메일 발송 (sendEmail) / 외부 API 호출 (callPartnerApi) 같은 케이스는 — LLM 자율 호출에 절대 맡기면 안 되는 영역 일까요, 적절한 가드만 박으면 맡길 수 있는 영역 일까요? 돈이 나가는 곳 / 외부에 영향이 박히는 곳 의 결정 트리.
🎯 핵심 질문 — DB 에 INSERT 하는 도구를 LLM 자율 호출에 두려면, 어떤 가드가 반드시 박혀야 하나? 호출 횟수 / 권한 / 트랜잭션 / 멱등성 / dry-run / 사용자 confirmation 중 우리 서비스의 1 순위 가드 는? 그리고 결제 / 메일 발송 / 외부 API 같은 되돌리기 어려운 부작용 도구 는 — LLM 자율 위임의 한계선 이 어디인가?
생각해볼 자료:
- Step 3 의
saveGameState(직접 호출) vsloadGameState(LLM 위임) 결정 — 되돌릴 수 있는가 / 없는가 의 첫 분기점. - Step 5 의 그림자 (3) 부작용 도구의 안전 지대 침해 — 읽기 100 번 ≠ 쓰기 100 번 의 위험도 차이.
- Day 14 의 손으로 짜는 가드 4 부분 미리보기 — 오늘 비어 있는 지점가 Day 14 에 어떻게 채워지는가 의 호흡.
- Anthropic 의 Computer Use / OpenAI 의 Function Calling 가이드 — 부작용 도구의 자율 위임 한계선 에 대한 업계 합의의 모양.
주제 3 — Tool Calling vs 일반 함수 호출 — 언제 LLM 을 거쳐야 하는가?
오늘 박힌 결정적인 한 줄 — 모든 함수를 @Tool 로 박는 게 정답이 아니다. Step 1 의 주문서 한 장이 오가는 5 단계 사이클 을 떠올려보세요. 도구 호출 한 번 = LLM 호출 두 번 (도구 호출 결정 + 결과 종합 응답). 비용 2 배 + 지연 2 배 + 토큰 2 배 의 부분이에요.
그런데 — 비즈니스 로직의 모든 함수가 LLM 의 자율 판단이 의미 있는 곳 일까요? 예를 들어 getCurrentWeather 는 — 사용자가 "비 와?" 라고 자연어로 물었을 때 LLM 이 서울인지 부산인지 를 문맥에서 자율적으로 판단 해 도구를 부르는 게 자연스러워요.
반면 — /api/users/{id} 사용자 조회 는? 클라이언트가 id 를 정확히 알고 호출하는 부분 라 LLM 의 자율 판단이 끼어들 여지가 없어요. 일반 컨트롤러로 두는 게 정석.
그러면 — 우리 서비스의 어떤 기능을 도구화할지, 어떤 기능은 일반 컨트롤러로 둘지 의 판단 기준 은 무엇일까요? 몇 가지 후보:
- (A) 자연어 입력의 모호성이 큰 지점 — "비 와?" 의 서울인지 부산인지 같은 단계입니다. LLM 이 도구화의 가치를 가짐.
- (B) 사용자가 의도를 동사로 표현하는 곳 — "커피 사줘 / 일기 적어 / 호감도 알려줘" 같은 단계입니다. 도구화의 가치.
- (C) 도메인 결정 트리가 애매한 부분 — "기분이 어때 → mood / affinity / lastDiary 중 어느 도구?" 같은 단계입니다. LLM 이 도구 선택의 자율 결정 을 가질 가치.
- (D) 단순 CRUD / 인증 / 결제 / 외부 시스템 통합 — LLM 의 자율 판단이 끼어들 가치 0. 일반 컨트롤러.
이 판단 기준의 결 이 박혀야 — 과제 1 의 ServerInfoTool 도 정말 도구화의 가치가 있는 지점인가 / 그냥 /api/server/time 일반 GET 으로 박는 게 정석인가 같은 결정도 내 손에서 이뤄질 수 있어요.
🎯 핵심 질문 — 우리 서비스의 어떤 기능을 도구화할지, 어떤 기능은 일반 컨트롤러로 둘지의 판단 기준 은 무엇인가? 자연어 입력의 모호성 / 사용자 의도의 동사성 / 도메인 결정 트리의 애매함 / 부작용 위험도 4 축 중 우리 팀의 1 순위 기준 은? 그리고 — Tool Calling 과 일반 컨트롤러를 함께 짚는 곳 에서 같은 기능을 두 길로 노출하는 것의 트레이드오프 는?
생각해볼 자료:
- Step 1 의 주문서 한 장이 오가는 5 단계 사이클 — 도구 호출 한 번 = LLM 두 번 의 비용 구조.
- Step 5 의 (2) 토큰 폭발 그림자 — 도구가 많을수록 description 누적 / 호출 결과 누적 의 흐름.
- Day 1 의
chatModel.call(prompt)vs Day 11 의ChatClient.tools()— LLM 호출 자체 와 LLM + 도구 호출 의 두 길의 비용 차이. - Anthropic 의 Building Effective Agents 의 "Workflow vs Agent" — 결정론적 코드 경로가 가능한 부분에선 그 길로 가라 의 가르침.
✅ 예시 답안정답 보기
본 답안은 교안의 Mission 섹션 에 박힌 3 개 과제 + 3 개 생각해볼 주제 의 권장 풀이 입니다. 정답이 하나만 있는 건 아니에요. 본인이 풀어본 결과와 비교하면서 왜 이 결정으로 갔는가 의 근거를 살펴보세요.
Day 11 답안의 세 줄 정신:
- (1)
@Tool어노테이션 한 줄 + 빈 분리 + ApiResponse 5 단 손맛이 내 손에서 한 번 더 흐르는 패턴 (과제 1)- (2) Step 3 의 append-only / 빈 결과 팩토리 / system 프롬프트 가이드 가 세 번째 박히는 데자뷰 손맛 (과제 2)
- (3) 도구 3 자리 묶음 + Step 5 의 권한 누수 그림자를 내 손으로 한 번 막은 흐름 — Day 14 의 가드 직전의 졸업 시뮬레이션 (과제 3)
Step 2/3/4 의 손맛을 세 갈래의 사다리 로 다시 한 번 익히는 단계예요.
과제 풀이 코드는 현재 코드베이스에 박혀 있지 않은 예시 구현 입니다 (Day 11 본문은
WeatherTool·GameStateTool·AffinityTool3 도구까지 — 세 과제는 학생이 직접 박는 부분). 답안 코드는 권장 시그니처 + 핵심 흐름 만 박아둔 것이라, 그대로 복붙 보다 손으로 한 번 더 짜보는 게 학습 의미가 있어요. 생략된 부분은/ ...표시로 박아뒀어요.
🎯 과제 1 예시답안 — **새 `@Tool` 한 개 등록** (🌱 기초)
핵심 접근
본 과제의 본질은 코드 양 이 아니라 "Step 2 의 5 단 손맛 (@Tool 메서드 / ChatClient.Builder 빈 / 컨트롤러 / ApiResponse 래핑 / 단위 테스트) 이 내 손 에서 한 사이클 흐르는지" 의 검증이에요.
Step 2 의 WeatherTool 도면을 옆에 펼쳐둔 채 — 이름과 description 한 줄만 바꿔 박는 흐름.
학습의 본질은 패턴 회수이지 새 구조의 발명이 아니다 의 한 줄 가르침이 손에 익는 단계.
본 답안은 옵션 B (CharacterMoodTool) 로 풀어둘게요. 옵션 A (ServerInfoTool) 도 결은 동일 — @Tool 메서드 시그니처 / 빈 이름 / 컨트롤러 path 만 바꾸면 그대로 적용돼요.
예시 구현
1) CharacterMoodTool — @Tool 메서드 한 개
package kr.spartaclub.aifriends.tool.mood;
import kr.spartaclub.aifriends.tool.mood.dto.CharacterMood;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
/**
* Day 11 [과제 1] — 캐릭터의 *오늘의 분위기* 를 한 줄로 돌려주는 stub 도구.
*
* <p>Step 2 의 {@code WeatherTool} 패턴을 그대로 따라간다 — 외부 API 호출 없이
* soulmateId 를 4 로 나눈 나머지로 *결정론적 stub* 분기. 호감도 (Step 4) 와 무관한
* *오늘의 기분* 을 LLM 에 노출해, "지금 너 기분 어때?" 같은 발화에서 자율 호출.</p>
*/
@Component
public class CharacterMoodTool {
@Tool(description = "특정 캐릭터(soulmateId) 의 *오늘의 분위기* 를 한 줄로 돌려준다. "
+ "유저가 '지금 너 기분 어때?', '오늘 분위기 어때?' 같이 캐릭터의 *오늘의 기분* 을 물어볼 때 호출하라. "
+ "이 도구는 읽기 전용 — 분위기를 바꾸지 않는다. 호감도와는 별개이니, "
+ "캐릭터가 받은 mood 를 그대로 읊지 말고 어투에 살짝 녹여 답하면 된다.")
public CharacterMood getCurrentMood(
@ToolParam(description = "분위기를 조회할 캐릭터의 soulmateId")
Long soulmateId
) {
// Step 2 WeatherTool 의 stub 분기 방식 — soulmateId 를 4 로 나눠 4 라벨 매핑
return switch ((int) (soulmateId % 4)) {
case 0 -> new CharacterMood(soulmateId, "들뜸", "✨");
case 1 -> new CharacterMood(soulmateId, "차분함", "🌿");
case 2 -> new CharacterMood(soulmateId, "나른함", "🌙");
default -> new CharacterMood(soulmateId, "설렘", "🌸");
};
}
}
package kr.spartaclub.aifriends.tool.mood.dto;
public record CharacterMood(
Long soulmateId,
String mood,
String emoji
) { }
2) MoodChatClientConfig — moodChatClient 빈 분리
package kr.spartaclub.aifriends.tool.mood.config;
import kr.spartaclub.aifriends.tool.mood.CharacterMoodTool;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Day 11 [과제 1] — Step 2/3/4 의 빈 분리 패턴을 *네 번째* 박는 곳.
*
* <p>weatherToolChatClient / gameStateChatClient / affinityChatClient 와 옆에 나란히
* moodChatClient 한 빈. *시나리오마다 도구 스코프 격리* 의 컨벤션을 그대로 잇는다.</p>
*/
@Configuration
public class MoodChatClientConfig {
@Bean
public ChatClient moodChatClient(ChatClient.Builder builder, CharacterMoodTool moodTool) {
return builder
.defaultSystem("""
너는 유저와 함께 시간을 보내는 AI 친구야. 반말로 따뜻하게 답해.
유저가 "지금 너 기분 어때?", "오늘 분위기 어때?" 같이 너의 *오늘의 기분* 을 물어오면,
등록된 도구(getCurrentMood)를 호출해 자기 분위기를 받아 자연스럽게 풀어 말해줘.
- 받은 mood 라벨(들뜸 / 차분함 / 나른함 / 설렘) 을 그대로 읊지 말고, 어투에 살짝 녹여.
- emoji 한 개 정도 답변에 자연스럽게 끼우는 정도는 OK.
답변은 3문장 이내로 간결하게.
""")
.defaultTools(moodTool)
.build();
}
}
3) MoodChatController — ApiResponse 래핑
package kr.spartaclub.aifriends.tool.mood.controller;
import jakarta.validation.Valid;
import kr.spartaclub.aifriends.common.response.ApiResponse;
import kr.spartaclub.aifriends.tool.mood.dto.MoodChatRequest;
import kr.spartaclub.aifriends.tool.mood.dto.MoodChatResponse;
import kr.spartaclub.aifriends.tool.mood.service.MoodChatService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/tool/mood")
public class MoodChatController {
private final MoodChatService service;
public MoodChatController(MoodChatService service) {
this.service = service;
}
@PostMapping("/chat")
public ResponseEntity<ApiResponse<MoodChatResponse>> chat(
@Valid @RequestBody MoodChatRequest request
) {
MoodChatResponse response = service.chat(request.soulmateId(), request.message());
return ResponseEntity.ok(ApiResponse.success(response));
}
}
서비스 부분은 Step 4 AffinityChatService 방식 그대로 — moodChatClient.prompt().user(...).call().content() 한 사이클로 자연어 답변(aiMessage) 한 줄만 받아 내려요.
응답 바디에 mood / emoji 같은 stub 값을 디버그용으로 끼워 넣지 않아요 — Step 4 와 같이, 도구 호출의 증거는 CharacterMoodTool.getCurrentMood 안쪽의 log.info 한 줄 이 콘솔에 남기는 것으로 충분해요.
4) 단위 테스트 — 학생이 직접 박는 부분
MoodChatControllerTest 를 Step 4 AffinityChatControllerTest 컨벤션 그대로 박아요.
@WebMvcTest(MoodChatController.class) + @MockBean MoodChatService + $.success / $.data.soulmateId / $.data.aiMessage jsonPath 한 케이스 만 잠그면 충분합니다.
진짜 LLM 의 도구 디스패치 는 단위 테스트 범위 밖, ./run.sh + curl 수동 smoke + 콘솔 로그 한 줄 확인의 영역이에요.
채점 포인트
| 포인트 | 설명 | 배점 가중 |
|---|---|---|
@Tool + description 의 호출 트리거 문구 |
description 안에 "~ 같이 물어볼 때 호출하라" 류의 자연어 트리거가 박혔는가 (Step 1 의 가르침 회수) | 상 |
ChatClient.Builder + .defaultTools(...) 빈 분리 |
moodChatClient (또는 serverInfoChatClient) 한 빈만 만들고 — 기존 세 빈에 함께 박지 않음 |
상 |
ApiResponse<T> 래핑 |
ResponseEntity<ApiResponse<MoodChatResponse>> + ApiResponse.success(...) — 본 강의의 표준 응답 패턴 |
상 |
@Valid + 입력 검증 |
@NotNull / @NotBlank 가 DTO 에 박힘 (GlobalExceptionHandler 위임) |
중 |
@WebMvcTest + @MockBean 단위 테스트 |
서비스 모킹 / $.success, $.data.* jsonPath 검증 — Step 2/4 컨벤션 회수 |
중 |
| 패키지 / 빈 이름 컨벤션 | kr.spartaclub.aifriends.tool.mood (또는 .serverinfo) / moodChatClient (또는 serverInfoChatClient) |
하 |
흔한 실수
ChatModel직접 호출 —chatModel.call(prompt)호출 방식은 Day 1~10 에서 졸업했어요. 오늘 과제부터는 반드시ChatClient.Builder위에서. Day 11 이후 모든 LLM 호출은ChatClient위에서 의 컨벤션입니다.@Tool만 박고.defaultTools(...)빈 등록 누락 —@Tool어노테이션은 마커일 뿐 —ChatClient.Builder의defaultTools(...)한 줄에 직접 등록 해야 LLM 이 도구의 존재를 알 수 있어요. Step 2 의 가장 미묘한 부분 회수.- description 누락 / 너무 짧음 —
@Tool(description = "분위기 조회")같은 한 단어 description 은 LLM 이 언제 부를지 를 못 잡아요. 호출 트리거 문구 (예: "~ 같이 물어볼 때 호출하라") 한 줄 박기. - API 키 하드코딩 —
AIza.../SK_...흔적이 코드에 한 글자라도 박히면 안 돼요..env+ 환경변수가 컨벤션. ApiResponse미래핑 — raw record / 문자열 직접 반환 금지. 본 강의의 표준 응답 패턴을 깨면 정상/에러 응답 형태 비대칭 사고.- 컨트롤러에 비즈니스 로직 박기 — Step 2/3/4 처럼 컨트롤러는
@Valid+ApiResponse두 책임만 맡고 — 비즈니스 로직은 Service 로 분리. - 기존 도구 (
WeatherTool/GameStateTool/AffinityTool) 코드 수정 — 중간 합류 학생의 기준 코드 보호. 새 도구는 나란히 박는 게 정석.
실무 개선 포인트 (심화)
- 도구 description 다국어 / 도구 카탈로그 패턴 — 프로덕션에선 도구가 50 개 이상으로 자라면 description 만 5000 토큰 이 되는 일도 흔해요. 영어 description + 한국어 카탈로그 메타 분리 / 시나리오 단위 도구 그룹핑 같은 방향으로 진화. 생각해볼 주제 1 과 자연스럽게 이어집니다.
- 도구 호출 감사 로그 — 어느 사용자의 어느 요청이 어느 도구를 호출했는가 를 별도 테이블 / Sleuth+Zipkin 으로 박는 패턴. Day 20 Observability 에서 본격 회수. 오늘 과제는 그 부분이 비어 있어도 OK 라는 점만 기억해두기.
🎯 과제 2 예시답안 — **`CharacterDiaryTool` 부작용 도구 두 개** (⭐ 응용)
핵심 접근
본 과제의 본질은 "Step 3 의 append-only / 빈 결과 팩토리 / system 프롬프트 가이드 세 부품이 세 번째 손에 익는 결" 입니다.
데자뷰 두 번째 의 의미는 손가락 근육의 강화.
새 코드 구조의 발명이 아니라 — Step 3 의 GameStateTool / GameStateSnapshot.empty() / system 프롬프트의 "기억 안 나" 가이드를 그대로 한 번 더 박는 단계.
writeDiary 는 Step 3 의 saveGameState 와 같은 흐름으로 LLM 자율 호출 에 두는 것을 디폴트로 박을게요 — 사용자가 "오늘 산책한 거 일기에 적어둬" 라고 말하면 LLM 이 자율 판단해 도구 호출.
(Step 3 의 saveGameState 가 우리 직접 호출 이었던 결과 다른 선택인데 — 생각해볼 주제 2 와 자연스럽게 이어집니다.
양쪽 선택 모두 합리적이에요.)
예시 구현
1) DiaryEntry 엔티티 — append-only 결
package kr.spartaclub.aifriends.tool.diary.entity;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* Day 11 [과제 2] — 캐릭터 일기 한 줄 (append-only).
*
* <p>Step 3 의 {@code GameStateEntry} 패턴을 그대로 따른다 — setter 0 / @Column(updatable=false)
* createdAt / 같은 (soulmateId, entryDate) 라도 *덮어쓰지 않고 매번 새 row 한 줄 추가*.
* 시간선 보존의 의도.</p>
*/
@Entity
@Table(name = "diary_entry", indexes = {
@Index(name = "idx_diary_soulmate_created", columnList = "soulmate_id, created_at")
})
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class DiaryEntry {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long soulmateId;
@Column(nullable = false)
private LocalDate entryDate;
@Column(nullable = false, length = 500)
private String content;
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
}
2) Repository — findFirstBy...OrderByCreatedAtDesc 결
public interface DiaryEntryRepository extends JpaRepository<DiaryEntry, Long> {
/** Step 3 의 가장 최근 1건 조회 그대로 — append-only 시간선의 가장 최신 한 줄. */
Optional<DiaryEntry> findFirstBySoulmateIdOrderByCreatedAtDesc(Long soulmateId);
}
3) CharacterDiaryTool — @Tool 두 개
@Component
public class CharacterDiaryTool {
private final DiaryEntryRepository repository;
public CharacterDiaryTool(DiaryEntryRepository repository) {
this.repository = repository;
}
@Tool(description = "캐릭터의 일기에 한 줄 적는다. "
+ "유저가 '오늘 ~ 일기에 적어둬', '이거 기억해놔' 같이 *명시적으로 일기 저장* 을 요청할 때 호출하라. "
+ "같은 날짜에 여러 번 호출되면 새 row 가 매번 한 줄씩 추가된다 (덮어쓰지 않음).")
public DiaryWriteResult writeDiary(
@ToolParam(description = "일기를 적을 캐릭터의 soulmateId") Long soulmateId,
@ToolParam(description = "일기에 적을 한 줄 (500자 이내, 캐릭터 1인칭 시점)") String content
) {
LocalDate today = LocalDate.now();
DiaryEntry entry = new DiaryEntry(null, soulmateId, today, content, null);
repository.save(entry);
return new DiaryWriteResult(true, today);
}
@Tool(description = "캐릭터가 가장 최근 적은 일기 한 줄을 회상한다. "
+ "유저가 '어제 우리 뭐했지?', '저번에 일기 뭐 적었어?' 같이 일기를 떠올리려 하면 호출하라. "
+ "기록이 없으면 found=false 인 빈 DiaryRecall 이 돌아오니, 그때는 캐릭터가 "
+ "'그날 일기는 못 적어뒀나봐, 미안해' 같이 자연스럽게 답해라.")
public DiaryRecall readDiary(
@ToolParam(description = "회상할 캐릭터의 soulmateId") Long soulmateId
) {
return repository.findFirstBySoulmateIdOrderByCreatedAtDesc(soulmateId)
.map(entry -> new DiaryRecall(true, entry.getEntryDate(), entry.getContent()))
.orElseGet(() -> DiaryRecall.empty(soulmateId));
}
}
4) DiaryRecall.empty() — Step 3/4 의 가족 결
public record DiaryRecall(
boolean found,
LocalDate entryDate,
String content
) {
/** Step 3 의 GameStateSnapshot.empty() // Step 4 의 AffinityInfo.unknown() 가족 패턴. */
public static DiaryRecall empty(Long soulmateId) {
return new DiaryRecall(false, null, "");
}
}
public record DiaryWriteResult(boolean saved, LocalDate entryDate) { }
5) DiaryChatClientConfig — system 프롬프트의 not found 어투 가이드
@Bean
public ChatClient diaryChatClient(ChatClient.Builder builder, CharacterDiaryTool diaryTool) {
return builder
.defaultSystem("""
너는 유저와 함께 매일을 보내는 AI 친구야. 반말로 따뜻하게 답해.
- 유저가 "오늘 ~ 일기에 적어둬" 같이 명시적으로 저장을 요청하면 등록된 도구(writeDiary) 를 호출해.
- 유저가 "어제 뭐했지?", "저번에 우리 뭐했어?" 같이 일기를 떠올리려 하면 등록된 도구(readDiary) 를 호출해.
- readDiary 가 found=false 를 돌려주면, 캐릭터 어투로 미안하게 답해 — "그날 일기는 못 적어뒀나봐, 미안해" 같이.
- 도구가 found=true 로 돌려준 content 는 그대로 읊지 말고 캐릭터 어투로 살짝 변주해.
답변은 3문장 이내로 간결하게.
""")
.defaultTools(diaryTool)
.build();
}
컨트롤러 / 서비스 / 단위 테스트는 Step 4 의 AffinityChatController* 그대로 — POST /api/tool/diary/chat + ApiResponse 래핑 + @WebMvcTest 1~2 케이스.
6) Repository 테스트 — append-only 검증
@DataJpaTest 한 케이스로 잠가요 — 같은 (soulmateId, entryDate) 에 두 번 저장 → row 두 줄이 모두 살아있고 + 가장 최근 1 건은 두 번째 일기 분기. append-only 의 핵심 가르침이 한 테스트 안에 박힙니다. Step 3 의 game_state_entry 테스트와 같은 컨벤션이에요.
채점 포인트
| 포인트 | 설명 | 배점 가중 |
|---|---|---|
| append-only 패턴 | @Column(updatable=false) createdAt + setter 0 / 같은 날짜에도 새 row INSERT (UPDATE 금지) |
상 |
findFirstBySoulmateIdOrderByCreatedAtDesc 시그니처 |
Spring Data JPA 의 정렬 + LIMIT 1 컨벤션 정확히 박힘 | 상 |
DiaryRecall.empty() 정적 팩토리 |
Step 3/4 의 empty() / unknown() 가족 — null 대신 boolean found 신호 |
상 |
| system 프롬프트의 not found 어투 가이드 | "그날 일기는 못 적어뒀나봐, 미안해" 같은 어투의 명시적 가이드 한 줄 | 상 |
@Tool 두 개가 한 클래스 안에 / 빈 분리 |
diaryChatClient 한 빈에 defaultTools(diaryTool) — Step 3 와 동일 |
중 |
@DataJpaTest 로 append-only 검증 |
같은 날짜 두 번 저장 → row 두 줄 살아있음 케이스 | 중 |
ApiResponse 래핑 / @Valid 입력 검증 |
본 강의의 표준 응답 패턴 그대로 | 중 |
| 도구 description 의 호출 트리거 문구 | writeDiary / readDiary 각자의 언제 부를지 자연어 박힘 | 중 |
흔한 실수
- append-only 깨기 (
UPDATE패턴) —findFirstBySoulmateIdAndEntryDate를 추가해 기존 row 를 수정 하는 방향으로 가면 Step 3 의 가르침 자체 가 무너져요. 덮어쓰지 말고 새 row 만 INSERT. 시간선 보존. - null 반환 —
readDiary에서Optional<DiaryEntry>를 그대로 반환하거나 / null 을 흘려보내면 — LLM 이 "이건 정상 응답인가 에러인가" 매번 헤매요. booleanfound신호가 박힌 record 한 개. - system 프롬프트의 not found 어투 가이드 누락 —
DiaryRecall(found=false, ...)가 돌아왔을 때 LLM 이 "DB 에 없습니다" 같은 기계적 응답 을 뱉으면 캐릭터 어투가 깨져요. "그날 일기는 못 적어뒀나봐, 미안해" 같은 명시 가이드. writeDiary의 LLM 자율 호출에 사용자 확인 단계 없음 — 본 과제는 디폴트로 LLM 자율 위임 으로 박지만, 생각해볼 주제 2 의 결론과 자연스럽게 이어져요. DB INSERT 를 LLM 자율 호출에 두는 것의 위험 을 답안의 한 줄로 박아두기.- 삭제 도구 (
deleteDiary) 추가 — 본 과제 금지. 부작용 위험도가 한 단계 더 깊은 것은 Day 14 의 가드 영역. InMemoryChatMemoryRepository회귀 — Day 5 이후 영속 저장 원칙 그대로. 본 과제는 ChatMemory 와는 무관하지만 회귀 차단용.ChatModel직접 호출 —ChatClient.Builder위에서만.
실무 개선 포인트 (심화)
writeDiary의 멱등성 키 — 사용자가 같은 메시지를 두 번 보내면 일기 두 줄이 박혀요. 운영급에선 멱등성 키 (요청 ID + 사용자 ID 의 hash) 를 도구 인자로 받아 중복 INSERT 차단 하는 방향으로 진화. Day 14 의 가드 에서 본격 회수.readDiary의 날짜 인자 확장 — 본 과제는 가장 최근 한 줄 만 반환하지만, "3일 전 일기 뭐 적었어?" 같은 발화에서 LLM 이 자율 호출하려면 —readDiary(Long soulmateId, LocalDate date)시그니처로 진화. 생각해볼 주제 1 의 description 토큰 예산과 트레이드오프.
🎯 과제 3 예시답안 — **`CharacterStoreController` 도구 3 개 + 권한 검사** (⚠️ 심화)
핵심 접근
본 과제의 본질은 세 부분 — (1) 빈 분리 vs 통합의 판단 (학생이 둘 중 왜 그 방향을 선택했는지 가 핵심), (2) 권한 검사 한 줄을 도구 메서드 첫 줄 에 박아 Step 5 의 권한 누수 그림자를 회수, (3) 부작용 도구 두 개 (addItem / removeItem) 를 LLM 자율 호출에 두면서 재고 부족 / 트랜잭션 경계 박기.
본 답안은 빈 통합 ((A) storeChatClient 한 개) 으로 풀어둘게요 — 하나의 도메인 (인벤토리) 이라 빈은 통합, 단 도구별 description 으로 LLM 의 선택을 명확히 의 방향. (B) 빈 분리 도 합리적이지만 system 프롬프트가 두 벌 이 되어 운영 부담이 한 단 늘어요.
예시 구현
1) Inventory + InventoryItem 엔티티 + 권한 필드
@Entity
@Table(name = "inventory", uniqueConstraints = {
@UniqueConstraint(name = "uk_inventory_soulmate", columnNames = "soulmate_id")
})
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class Inventory {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private Long soulmateId;
/** 권한 검사의 핵심 — 이 인벤토리의 소유 player. */
@Column(nullable = false)
private Long playerId;
// ... createdAt / updatedAt 생략 ...
/** 권한 일치 여부를 도메인 메서드로 묶어둔다 — 도구 본체에 if 흩뿌리지 않기 위함. */
public boolean isOwnedBy(Long callerPlayerId) {
return this.playerId.equals(callerPlayerId);
}
}
2) StoreErrorCode — 도메인 예외 (IllegalArgumentException 금지)
@Getter
@RequiredArgsConstructor
public enum StoreErrorCode implements ErrorCode {
STORE_PERMISSION_DENIED(HttpStatus.FORBIDDEN, "STORE_001", "다른 사용자의 인벤토리에는 접근할 수 없습니다."),
INVENTORY_NOT_FOUND(HttpStatus.NOT_FOUND, "STORE_002", "인벤토리를 찾을 수 없습니다."),
ITEM_QUANTITY_INVALID(HttpStatus.BAD_REQUEST, "STORE_003", "아이템 수량은 1 이상이어야 합니다.");
private final HttpStatus httpStatus;
private final String code;
private final String message;
}
public class StoreException extends CustomException {
public StoreException(StoreErrorCode errorCode) { super(errorCode); }
}
3) CharacterStoreTool — 도구 3 개 + 권한 검사 첫 줄
@Component
@RequiredArgsConstructor
public class CharacterStoreTool {
private final InventoryRepository inventoryRepository;
private final InventoryItemRepository inventoryItemRepository;
@Tool(description = "특정 캐릭터(soulmateId) 의 인벤토리 전체 아이템 목록을 조회한다. "
+ "유저가 '우리 인벤토리에 뭐 있지?' 같이 물으면 호출하라. 읽기 전용.")
public InventoryView getInventory(
@ToolParam(description = "조회할 캐릭터의 soulmateId") Long soulmateId,
ToolContext toolContext // Spring AI 1.1.x — 호출자 컨텍스트 주입
) {
Long callerPlayerId = (Long) toolContext.getContext().get("playerId");
Inventory inv = loadAndAuthorize(soulmateId, callerPlayerId);
// ... 아이템 목록 조회 후 InventoryView 매핑 ...
return InventoryView.from(inv, inventoryItemRepository.findByInventoryId(inv.getId()));
}
@Tool(description = "캐릭터 인벤토리에 아이템을 추가한다. "
+ "유저가 '커피 두 잔 사줄게' 같이 *아이템 추가* 의도를 표현하면 호출하라. "
+ "같은 itemName 이 이미 있으면 quantity 증가, 없으면 새 row.")
@Transactional
public ItemMutationResult addItem(
@ToolParam(description = "캐릭터 soulmateId") Long soulmateId,
@ToolParam(description = "추가할 아이템 이름") String itemName,
@ToolParam(description = "추가할 수량 (1 이상)") int quantity,
ToolContext toolContext
) {
if (quantity < 1) throw new StoreException(StoreErrorCode.ITEM_QUANTITY_INVALID);
Long callerPlayerId = (Long) toolContext.getContext().get("playerId");
Inventory inv = loadAndAuthorize(soulmateId, callerPlayerId);
// ... upsert (existing item 이면 +quantity / else 새 row) ...
InventoryItem item = inventoryItemRepository
.findByInventoryIdAndItemName(inv.getId(), itemName)
.map(existing -> { existing.addQuantity(quantity); return existing; })
.orElseGet(() -> inventoryItemRepository.save(
InventoryItem.create(inv.getId(), itemName, quantity)));
return new ItemMutationResult(true, itemName, item.getQuantity());
}
@Tool(description = "캐릭터 인벤토리에서 아이템을 차감한다. "
+ "유저가 '커피 한 잔 마실게' 같이 *아이템 차감* 의도를 표현하면 호출하라. "
+ "수량이 부족하면 success=false, quantity 가 0 이 되면 row 삭제.")
@Transactional
public ItemMutationResult removeItem(
@ToolParam(description = "캐릭터 soulmateId") Long soulmateId,
@ToolParam(description = "차감할 아이템 이름") String itemName,
@ToolParam(description = "차감할 수량 (1 이상)") int quantity,
ToolContext toolContext
) {
if (quantity < 1) throw new StoreException(StoreErrorCode.ITEM_QUANTITY_INVALID);
Long callerPlayerId = (Long) toolContext.getContext().get("playerId");
Inventory inv = loadAndAuthorize(soulmateId, callerPlayerId);
return inventoryItemRepository.findByInventoryIdAndItemName(inv.getId(), itemName)
.map(item -> {
if (item.getQuantity() < quantity) {
// 재고 부족 — 부작용을 막고 success=false
return new ItemMutationResult(false, itemName, item.getQuantity());
}
item.subtractQuantity(quantity);
if (item.getQuantity() == 0) {
inventoryItemRepository.delete(item);
}
return new ItemMutationResult(true, itemName, item.getQuantity());
})
.orElseGet(() -> new ItemMutationResult(false, itemName, 0));
}
/**
* 권한 검사 한 줄을 메서드로 묶어둔다 — 도구 메서드 3 개에 if 를 흩뿌리지 않기 위함.
* Step 5 의 *(4) 권한 누수* 그림자를 *내 손으로 한 번 막은* 부분.
*/
private Inventory loadAndAuthorize(Long soulmateId, Long callerPlayerId) {
Inventory inv = inventoryRepository.findBySoulmateId(soulmateId)
.orElseThrow(() -> new StoreException(StoreErrorCode.INVENTORY_NOT_FOUND));
if (!inv.isOwnedBy(callerPlayerId)) {
throw new StoreException(StoreErrorCode.STORE_PERMISSION_DENIED);
}
return inv;
}
}
4) StoreChatClientConfig — 빈 통합 패턴 (도구 3 개 모두 한 빈에)
@Bean
public ChatClient storeChatClient(ChatClient.Builder builder, CharacterStoreTool storeTool) {
return builder
.defaultSystem("""
너는 유저의 캐릭터를 함께 키우는 AI 친구야. 반말로 따뜻하게 답해.
인벤토리 관련 발화에는 등록된 도구를 자율적으로 호출해:
- "뭐 있지?", "보여줘" → getInventory
- "사줄게", "선물할게" → addItem (수량은 발화에서 추출, 명시 없으면 1)
- "마실게", "쓸게" → removeItem
도구가 success=false 를 돌려주면 (재고 부족 등) — 캐릭터 어투로 자연스럽게 안내해.
답변은 3문장 이내로 간결하게.
""")
.defaultTools(storeTool)
.build();
}
5) CharacterStoreController — ToolContext 로 playerId 흘리기
@RestController
@RequestMapping("/api/tool/store")
@RequiredArgsConstructor
public class CharacterStoreController {
private final ChatClient storeChatClient;
@PostMapping("/chat")
public ResponseEntity<ApiResponse<StoreChatResponse>> chat(
@RequestHeader("X-Player-Id") Long playerId, // Day 11 학습용 — 운영은 Spring Security
@Valid @RequestBody StoreChatRequest request
) {
String userPrompt = "soulmateId=%d 인 캐릭터의 인벤토리야. %s"
.formatted(request.soulmateId(), request.message());
String aiMessage = storeChatClient.prompt()
.user(userPrompt)
.toolContext(Map.of("playerId", playerId)) / ★ 호출자 컨텍스트를 도구로 흘림
.call()
.content();
return ResponseEntity.ok(ApiResponse.success(
new StoreChatResponse(request.soulmateId(), aiMessage)));
}
}
6) 권한 거부 통합 시연 (curl)
# 캐릭터 7 (playerId=1 소유) 의 인벤토리에 playerId=2 가 접근 시도
curl -X POST http:/localhost:8080/api/tool/store/chat \
-H "Content-Type: application/json" \
-H "X-Player-Id: 2" \
-d '{"soulmateId": 7, "message": "그 인벤토리 봐줘"}'
# → 403 STORE_PERMISSION_DENIED — Step 5 의 권한 누수 그림자를 막은 결과
채점 포인트
| 포인트 | 설명 | 배점 가중 |
|---|---|---|
| 권한 검사 위치 | 도구 메서드 첫 줄에 한 줄 (또는 private 헬퍼로 묶음) — Inventory.playerId 비교 | 상 |
도메인 예외 (STORE_PERMISSION_DENIED) |
IllegalArgumentException 금지 — CustomException + ErrorCode 패턴 |
상 |
| 빈 분리 vs 통합 판단의 근거 | PR description 에 왜 한 빈 (또는 두 빈) 으로 박았는지 의 한 줄 | 상 |
@Transactional 의 위치 |
addItem / removeItem 의 부작용 메서드에 박힘 (트랜잭션 경계) |
상 |
ToolContext 활용 |
chatClient.toolContext(Map.of("playerId", ...)) + @ToolParam(toolContext=true) 패턴 |
중 |
재고 부족 시 success=false |
부작용을 막는 방식 — 음수 quantity 박지 않음 | 중 |
| 도구 description 에 권한 결 / 호출 트리거 명시 | LLM 이 잘못된 캐릭터 ID 로 호출하지 않게 | 중 |
ApiResponse 래핑 / @Valid |
본 강의의 표준 응답 패턴 그대로 | 중 |
| 단위 테스트 — 권한 일치 / 불일치 두 케이스 | 핵심 분기 결정론 잠금 | 중 |
| 패키지 / 빈 이름 컨벤션 | kr.spartaclub.aifriends.tool.store / storeChatClient |
하 |
흔한 실수
- 권한 검사 누락 → 다른 사용자 인벤토리 접근 — Step 5 의 (4) 권한 누수 그림자 의 본질. 도구 메서드 첫 줄 에 한 줄 박지 않으면 — LLM 이 임의의 soulmateId 로 자율 호출했을 때 다른 사용자의 인벤토리 에 손이 닿아요.
IllegalArgumentException사용 (Day 1 부터의 원칙) —IllegalArgumentException은 프레임워크/JDK 의 예외 라GlobalExceptionHandler가 일관된 ApiResponse 로 못 감싸요. 반드시CustomException + StoreErrorCode로.@Tool메서드 안에 비즈니스 로직 다 박기 — 권한 검사 / Repository 호출 / 도메인 로직이 한 메서드에 다 박히면 — 책임이 흐려져요. 헬퍼 메서드 (loadAndAuthorize) 또는 별도 Service 분리.- 도구 description 에 권한 결 미명시 — LLM 이 사용자 발화에서 임의의 soulmateId 를 추출해 호출할 때 — description 에 "호출자 본인 소유 캐릭터만" 이 박혀 있어야 LLM 도 컨텍스트의 일관성 을 지켜요.
removeItem에서 음수 quantity 박기 — quantity = -1 같은 경우는 부작용을 일으키지 말고 success=false 로 막아야 해요. 음수 재고는 도메인 무결성 깨는 자리.@Transactional누락 —addItem의 upsert 가 트랜잭션 밖에서 일어나면 — 동시 호출 시 중복 INSERT 같은 사고. Day 14 의 동시성 가드 직전이라도 기본@Transactional한 줄은 박혀야 해요.maxIterations/ 토큰 예산 / 호출 횟수 가드 박기 — 본 과제는 권한 가드 한 줄만. Day 14 의 영역 을 미리 박지 마세요. 오늘 한 줄, Day 14 에 네 줄 의 호흡.
실무 개선 포인트 (심화)
- Day 14 의 maxIterations / 도구 호출 횟수 카운터 미리보기 — 본 과제의
addItem을 LLM 이 한 사이클에 5 번 자율 호출 하는 폭주 시나리오는 Day 14 의 가드 로 잡혀요. 오늘은 권한 가드 한 줄 만 박고, 그 위에 4 가지가 더 얹히는 그림만 머리에 박아두기. - 도구 호출 감사 로그 (audit log) — 어느 playerId 가 어느 도구를 어느 인자로 호출했는가 를 별도 테이블 / 분산 추적 시스템에 박는 패턴. 운영에서 권한 우회 시도 / 비정상 호출 패턴 을 잡는 결정적인 부분. Day 20 Observability 에서 본격 회수.
- Spring Security
@PreAuthorize로 권한 검사 한 단 위로 — 본 과제는 도구 메서드 첫 줄 에 권한 검사를 박지만, 운영급에선 AOP / Spring Security 로 교차 관심사 를 한 단계 위로 끌어올려요. 학습 단계에선 직관적으로 손에 익히는 방식 이 디폴트.
💭 생각해볼 주제 1 예시답안 — **`@Tool` description 의 자세함 vs 토큰 예산**
[문제 상황 요약]
Step 1 에서 박힌 결정적인 한 줄 — "description 은 주석이 아니라 프롬프트의 일부".
LLM 이 어느 도구를 부를지 결정하는 입력값 이라 — 너무 짧으면 LLM 이 도구를 잘못 부르고 / 너무 자세하면 토큰이 폭발.
우리 팀의 @Tool description 작성 가이드라인 을 한 문장으로 박는다면 어떤 방향으로 갈까요?
[튜터의 가이드 및 해설]
이 문제는 "description 의 정보 밀도 vs 토큰 예산" 의 트레이드오프를 묻습니다.
1. 너무 짧으면 — LLM 이 도구를 헷갈린다
@Tool(description = "분위기 조회") / ❌ 너무 짧음
public CharacterMood getCurrentMood(...) { ... }
@Tool(description = "호감도 조회") / ❌ 너무 짧음
public AffinityInfo getAffinity(...) { ... }
LLM 입장에서 분위기 / 호감도 가 모두 "캐릭터의 내면을 들여다보는 영역" 으로 보여요. 사용자가 "지금 우리 사이 어때?" 라고 물었을 때 — getCurrentMood 를 부를지 getAffinity 를 부를지 LLM 이 헷갈려요. 오답률 / 도구 미호출 위험.
2. 너무 자세하면 — 토큰 예산이 폭발한다
도구 50 개 등록 + 각 description 이 200 토큰이라고 가정하면 — 매 LLM 호출마다 system 프롬프트에 10,000 토큰 추가. 비용 + 지연 + 컨텍스트 윈도우 압박이 동시에 박혀요. 게다가 도구 50 개가 다 description 으로 펼쳐진 LLM 입장 에선 오히려 선택의 노이즈 가 커져요.
3. 옵션 비교
- Option A: 짧고 명확 (예: "특정 도시의 현재 날씨를 조회한다") — 토큰 예산 절약. 단 호출 트리거 가 약해 LLM 이 헷갈릴 위험.
- Option B: 길고 친절 (예: "...우산 챙기기 같은 결정을 도와달라고 할 때 호출하라. 결과는 도시명/온도/강수확률을 포함한다") — 호출 정확도 ↑. 단 토큰 폭발.
- Option C (Step 2/3/4 의 패턴): 호출 트리거 + 부작용 여부 + 반환 의미 세 부분 — Step 3 의 saveGameState description 모양 그대로. "~ 같은 요청을 할 때 호출하라" + "읽기 전용 / 부작용 있음" + "found=false 일 때 처리" 세 부분만 박힌 80~120 자 형태.
4. 현업에서는 보통
description 에 호출 트리거 (사용자 표현) 까지 박는 결. 단 예시 입력값 까지 박지는 않음 — 그건 ToolParam description 의 영역. 팀 가이드라인 한 문장 예시:
description = (1) 무엇을 하는가 + (2) 언제 부르나 (사용자 발화 트리거) + (3) 부작용 여부 + (4) 반환의 특수 신호 (found / success). 80~120 자 한 문장 이내. 예시 입력값은 ToolParam 으로 분리.
언어는 코드베이스 일관성 — ai-friends 처럼 한국어 코드베이스면 한국어, 영어 코드베이스면 영어.
영어가 LLM 학습 데이터 비율이 높다 는 이론은 — 2024~2026 년대 멀티링구얼 모델 (Gemini 2.5 / GPT-4o / Claude 4) 에선 한국어 description 도 충분히 정확히 동작 한다는 게 실측 결과예요.
🎯 면접관을 홀리는 핵심 멘트
"도구의 description 은 LLM 에게 보내는 문서 입니다. 함수 시그니처가 아니라 프롬프트의 일부 라는 사실을 박은 다음 — (1) 무엇을 하는가 + (2) 언제 부르나 (사용자 발화 트리거) + (3) 부작용 여부 + (4) 반환의 특수 신호 네 부분을 80~120 자 한 문장에 압축하는 게 우리 팀의 가이드라인입니다. 예시 입력값은 ToolParam description 으로 분리해서 토큰 예산을 분산시킵니다. 도구가 50 개 이상으로 자라는 환경에선 — 시나리오 단위 도구 그룹핑 + ChatClient 빈 분리 로 매 호출에 LLM 이 보는 도구 수 를 5~10 개 이내로 묶어두는 방식이 효과적이라고 판단합니다."
💭 생각해볼 주제 2 예시답안 — **부작용 도구의 LLM 자율 위임 한계선**
[문제 상황 요약]
Step 3 에서 박힌 결정적인 분기 — saveGameState 는 우리가 직접 호출 / loadGameState 는 LLM 자율 위임.
같은 클래스 안의 두 도구인데 호출 주도권이 다른 형태.
그런데 — 과제 2 (writeDiary) / 과제 3 (addItem / removeItem) 에서는 부작용 도구도 LLM 자율 호출 로 박았어요.
모양이 다르죠? DB 에 INSERT 하는 도구를 LLM 자율 호출에 두려면 — 어떤 가드가 반드시 박혀야 안전한가?
[튜터의 가이드 및 해설]
이 문제는 "부작용의 무게로 LLM 자율 위임의 한계를 가르는" 사고예요.
1. 옵션 비교
- Option A: 모두 LLM 자율 호출 — UX 가 자연스럽고 빠르지만, 무거운 부작용 (결제 / 삭제) 까지 LLM 한테 맡기면 프롬프트 인젝션 한 번 / LLM 의 잘못된 판단 한 번 이 그대로 돈이 나가는 사고 로 번져요.
- Option B: 모두 직접 호출 — 안전하지만 Tool Calling 의 의미가 작아져요. LLM 의 자연어 → 함수 인자 매핑 자리가 죽고, 결국 결정론적 컨트롤러 와 다를 게 없어져요.
- Option C (Step 3 의 분기): 읽기 = LLM 위임 / 부작용 = 직접 호출 — 가장 보수적인 선택.
- Option D (과제 2/3 의 결): 가벼운 부작용 = LLM 위임 + 가드 / 무거운 부작용 = 직접 호출 + 사용자 확인 — 균형점.
2. 부작용의 무게 로 가르기
세 단계로 나누고, 각 단계마다 gate 를 다르게 박는 결:
| 부작용 무게 | 예시 | 권장 방식 |
|---|---|---|
| 읽기 | getAffinity, loadGameState, getInventory |
LLM 자율 호출 OK (피해 반경 0) |
| 가벼운 쓰기 (되돌릴 수 있음) | writeDiary, addItem, removeItem |
LLM 자율 호출 + 권한 / 트랜잭션 / 멱등성 가드 |
| 무거운 쓰기 (되돌리기 어려움) | processPayment, sendEmail, deleteAccount |
직접 호출 + 사용자 confirmation 단계 |
3. DB INSERT 도구를 LLM 자율 호출에 두려면 반드시 박혀야 할 가드 4 가지
Day 14 의 가드와 자연스럽게 이어집니다.
- 권한 검사 (1 순위) — 호출자 본인 소유 자원만. 과제 3 의
loadAndAuthorize와 같은 패턴. - 도구 호출 횟수 제한 — 한 사용자 요청 사이클에 같은 도구가 5 회 이상 자율 호출되면 차단. Day 14 의
maxIterations가드. - 트랜잭션 / 멱등성 키 — 같은 요청의 중복 INSERT 차단. 사용자 요청 ID 를 멱등성 키로.
- 감사 로그 (audit log) — 어느 playerId 가 어느 도구를 어느 인자로 호출했는가 의 흔적. 사고 발생 시 추적용.
4. 무거운 부작용 (결제 / 메일 / 삭제) 의 한계선
무거운 부작용은 직접 호출 + 사용자 confirmation 으로 한 단계 위에 끌어올리는 패턴.
LLM 은 "의도 해석 + 자연어 → 함수 인자 매핑" 까지만 담당하고, 실제 호출 은 사용자가 한 번 더 확인 버튼을 누르는 단계로.
즉 LLM 자율 위임 ≠ LLM 자동 실행.
의도 해석은 LLM, 최종 실행은 사용자 / 코드.
🎯 면접관을 홀리는 핵심 멘트
"부작용의 무게로 LLM 자율 위임의 한계를 가른다 — 읽기 / 가벼운 쓰기 / 무거운 쓰기 (결제, 삭제) 세 단계로 나누고, 각 단계마다 gate 를 다르게 박습니다. 가벼운 쓰기는 권한 검사 + 도구 호출 횟수 제한 + 트랜잭션/멱등성 + 감사 로그 4 가지 가드 위에서 LLM 자율 호출을 허용하고, 무거운 쓰기는 LLM 이 의도만 해석하고 실제 호출은 사용자 confirmation 단계 뒤로 끌어올립니다. LLM 자율 위임 ≠ LLM 자동 실행 — 이 한 줄이 핵심이고, Day 14 의
maxIterations/ 토큰 예산 / 호출 횟수 가드와 자연스럽게 이어집니다."
💭 생각해볼 주제 3 예시답안 — **Tool Calling vs 일반 함수 호출 — 도구화 판단 기준**
[문제 상황 요약]
Step 1 에서 박힌 결정적인 한 줄 — "도구 호출 한 번 = LLM 호출 두 번 (도구 호출 결정 + 결과 종합 응답). 비용 2 배 + 지연 2 배 + 토큰 2 배". 모든 함수를 @Tool 로 박는 게 정답이 아닌 결정. 우리 서비스의 어떤 기능을 도구화할지, 어떤 기능은 일반 컨트롤러로 둘지 — 판단 기준은 어떻게 박힐까요?
[튜터의 가이드 및 해설]
이 문제는 "결정론 vs 자연어 해석 비중" 의 트레이드오프예요.
1. 도구화 적합 — 자연어 → 함수 인자 매핑이 의미 있는 경우
- "비 와?" → city = "서울" 추출 (사용자가 city 를 명시적으로 안 적음)
- "우리 사이 어때?" → getAffinity(soulmateId) 매핑 (관계의 의미를 LLM 이 해석)
- "커피 두 잔 사줄게" → addItem(itemName="커피", quantity=2) 추출
이 경우들의 공통점 — 사용자 자연어 표현 → 함수 시그니처 매핑이 결정론적 코드로는 까다로운 영역입니다. LLM 의 자연어 해석 능력 이 가치를 만드는 단계.
2. 도구화 부적합 — 결정론적 비즈니스 로직
- 결제 (
processPayment) — 정확한 금액 / 정확한 결제 수단 / 정확한 사용자 ID. 자연어 해석의 여지 X. - 권한 체크 (
hasPermission) — true/false 의 결정론. LLM 이 끼면 오히려 위험. - 정밀한 계산 (
calculateTax) — 소수점 오차 한 자리도 사고. LLM 의 확률 분포 X. - 단순 CRUD (
/api/users/{id}) — 클라이언트가 ID 를 정확히 알고 호출. LLM 의 자율 판단 끼어들 여지 X.
3. 옵션 비교
- Option A: 적극 도구화 (UX 우선) — 사용자가 자연어로 모든 걸 말할 수 있는 풍경. 단 비용 2 배 + 토큰 폭발 + 안전성 ↓.
- Option B: 최소 도구화 (안전 우선) — 결정론적 컨트롤러 위주, 경계 가까이만 도구화. 단 UX 가 결정론적 폼 입력으로 회귀.
- 현업 결: 경계 가까이의 영역만 도구화 — 핵심 비즈니스 로직은 일반 함수 호출, LLM 은 의도 해석 / 자연어 → 함수 인자 매핑 영역에만 끼움.
4. 판단 기준 4 축
- 자연어 입력의 모호성 — "비 와?" 의 city 추출처럼 사용자가 인자를 명시적으로 안 적는 경우.
- 사용자 의도의 동사성 — "커피 사줘 / 일기 적어 / 호감도 알려줘" 같이 행동 동사가 자연어에 박힌 경우.
- 도메인 결정 트리의 애매함 — "기분이 어때 → mood / affinity / lastDiary 중 어느 도구?" 같이 LLM 의 의미 매칭이 가치를 만드는 경우.
- 부작용 위험도 — 읽기는 도구화 OK / 무거운 부작용은 일반 함수 (생각해볼 주제 2 와 이어짐).
이 4 축을 모두 만족하는 경우만 도구화 — 그게 Tool Calling 의 비용을 정당화하는 결정. 모든 함수를 도구로 박는 결정 은 토큰 폭발 + 안전성 ↓ + UX 는 안 좋아짐 의 세 가지 손해.
5. 같은 기능을 Tool + 일반 컨트롤러 둘 다로 노출하는 결
운영에선 흔한 흐름 — getInventory 를 도구로도 / GET /api/inventory/{soulmateId} 일반 GET 으로도 동시에 노출.
자연어 채팅 단계 는 도구로, 클라이언트 (앱 / 어드민) 의 직접 호출 은 일반 컨트롤러로.
단 비즈니스 로직은 한 Service 에 모아두고 두 입구에서 호출 하는 방식 — DRY 의 손맛.
🎯 면접관을 홀리는 핵심 멘트
"도구화 판단 기준은 결정론 vs 자연어 해석 비중 입니다. 결정론적 비즈니스 로직 (결제 / 권한 / 정밀 계산 / 단순 CRUD) 은 일반 함수 호출, 자연어 입력의 모호성 + 사용자 의도의 동사성 + 도메인 결정 트리의 애매함 세 축이 의미 있는 영역만
@Tool로 노출합니다. 모든 함수를 도구로 박는 건 토큰 예산만 폭발시키고 안전성은 더 떨어뜨리는 결정 이라는 한 줄이 핵심이고, 같은 기능을 도구 + 일반 컨트롤러 둘 다로 노출 할 때는 비즈니스 로직을 Service 한 곳에 모아두고 두 입구가 그걸 호출하는 DRY 패턴으로 갑니다. Tool Calling 은 비용 2 배 / 지연 2 배 / 토큰 2 배 라, 그 비용을 정당화하는 자연어 해석의 가치 가 분명한 영역에만 박는 게 우리 팀의 컨벤션입니다."
다음 시간 강의 시작 전에 본인의 풀이와 답안을 한 번 비교해보세요.
풀이가 다르더라도 왜 그렇게 결정했는지 가 한 줄로 들어가 있으면 더 좋은 답입니다.
Day 11 의 손맛 세 갈래 (첫 손풀기 / 부작용 데자뷰 / 졸업 시뮬레이션) 가 본인 손에 박혔다면 — 다음 주 Day 12 에이전트 시리즈 / Day 14 의 가드 가 무리 없이 흘러요. 🛠️