LLM 호출을 Spring 서비스처럼 다루기
M0에서는 아무 기능도 없는 Spring Boot 애플리케이션을 만들었다.
이번 M1에서는 아주 작은 기능을 하나 붙여본다.
/api/chat
이 엔드포인트 하나만 만든다.
아직 AgentRuntime도 없고, Tool도 없고, Session도 없다.
MCP도 없고, 파일 시스템을 읽거나 bash를 실행하지도 않는다.
HTTP 요청을 받는다
→ Spring Service에서 LLM을 호출한다
→ 문자열 응답을 돌려준다
정말 단순한 Chat API다.
하지만 이 단계를 따로 나누는 이유가 있다.
나중에 에이전트가 커지더라도, 결국 가장 안쪽에는 “모델을 호출하는 코드”가 있어야 하기 때문이다.
LLM 호출도 결국 외부 시스템 호출이다
Spring AI의 ChatClient는 LLM을 호출하기 위한 클라이언트다.
Spring 개발자 입장에서 보면 WebClient와 비슷하게 생각해도 된다.
WebClient가 외부 HTTP API를 호출한다면, ChatClient는 외부 AI 모델을 호출한다.
String answer = chatClient.prompt()
.user("이 프로젝트를 한 문장으로 설명해줘")
.call()
.content();
겉으로는 자연어를 다루기 때문에 낯설어 보인다.
하지만 구조만 보면 외부 시스템에 요청을 보내고 응답을 받는 코드다.
기존 백엔드에서 외부 API를 호출할 때도 비슷한 고민을 한다.
타임아웃은 어떻게 둘 것인가.
실패하면 어떻게 처리할 것인가.
로그를 어디까지 남길 것인가.
테스트에서는 실제 외부 API를 호출하지 않게 만들 수 있는가.
API key 같은 민감한 설정은 어떻게 분리할 것인가.
LLM 호출도 똑같다.
오히려 더 조심해야 한다.
응답이 매번 조금씩 달라질 수 있고, 비용도 들고, 네트워크나 모델 상태에 영향을 받기 때문이다.
그래서 M1에서 가장 먼저 정하고 싶은 원칙은 이것이다.
LLM 호출을 Controller에 직접 넣지 않는다.
Spring Service 뒤에 숨긴다.
컨트롤러는 HTTP 요청과 응답만 담당한다.
실제 모델 호출은 서비스에서 처리한다.
이렇게 해두면 나중에 실제 ChatClient를 쓰든, 테스트용 fake를 쓰든, 구조가 덜 흔들린다.
이번에 만들 API
이번 단계에서 만들 API는 단순하다.
POST /api/chat
Content-Type: application/json
{
"text": "Spring AI가 뭔지 쉽게 설명해줘"
}
응답도 단순하게 간다.
{
"content": "Spring AI는 Spring 애플리케이션에서 AI 모델을 쉽게 호출하게 도와주는 프레임워크입니다."
}
지금은 대화 기록도 저장하지 않는다.
사용자별 세션도 없다.
이전 질문을 기억하지도 않는다.
그냥 요청 하나가 들어오면, 모델에게 전달하고, 응답 하나를 돌려주는 구조다.
패키지는 이 정도면 충분하다.
api/
AiChatController.java
ChatRequest.java
ChatResponse.java
model/
AiChatService.java
SpringAiChatService.java
ChatClientConfig.java
컨트롤러는 HTTP 요청과 응답만 다룬다.
실제 `ChatClient` 호출은 `AiChatService` 뒤로 숨긴다.
테스트에서는 이 서비스를 fake bean으로 바꿔서 외부 LLM API를 호출하지 않는다.
테스트는 실제 모델을 부르지 않는다
M1에서 제일 중요하게 지키고 싶은 원칙은 M0와 같다.
API key 없이 테스트가 통과해야 한다.
실제로 OpenAI나 Gemini API를 호출해서 응답이 오는 걸 보면 뭔가 만든 것 같은 느낌이 든다.
그런데 테스트에서 실제 모델을 호출하기 시작하면 금방 문제가 생긴다.
테스트를 돌릴 때마다 비용이 든다.
네트워크가 불안하면 실패한다.
CI 환경에서는 API key가 없어서 깨질 수 있다.
모델 응답이 달라져서 테스트 결과가 흔들릴 수 있다.
백엔드 개발에서 결제 테스트를 할 때 실제 카드 결제를 하지 않는 것과 같다.
외부 배송사 API 테스트를 할 때 실제 송장을 계속 만들지 않는 것과도 비슷하다.
LLM도 외부 시스템이다.
테스트에서는 fake나 stub을 쓰는 편이 안전하다.
M1에서는 이런 구조를 생각한다.
실제 실행
→ 진짜 ChatClient 사용
테스트
→ 고정 응답을 돌려주는 fake 사용
예를 들어 테스트에서는 사용자가 어떤 메시지를 보내든 고정된 응답을 돌려줘도 된다.
"fake response"
지금 검증해야 하는 것은 Spring API 구조다.
요청이 들어오는지.
Controller가 Service를 호출하는지.
응답 DTO가 원하는 형태로 내려가는지.
API key 없이도 테스트가 통과하는지.
M1의 테스트는 여기까지만 보면 된다.
아직 Agent가 아니다
이 단계에서 만든 /api/chat은 에이전트가 아니다.
그냥 Chat API다.
사용자 질문
→ 모델 응답
이 정도 흐름이다.
하지만 Pi 스타일 코딩 에이전트는 이보다 훨씬 더 많은 일을 해야 한다.
예를 들면 이런 흐름이 필요하다.
사용자 요청
→ 모델이 다음 행동을 판단
→ 필요한 도구 선택
→ 도구 실행
→ 실행 결과 저장
→ 다시 모델 판단
→ 최종 응답
여기에는 Tool, Session, Runtime, 권한, 실행 로그, 파일 접근, 실패 처리 같은 것들이 들어간다.
M1은 정확히는 에이전트가 나중에 사용할 “모델 호출 계층의 가장 작은 시작점”에 가깝다.
이번 단계의 완료 기준
M1이 끝났다고 말하려면 이 정도가 되어야 한다.
- POST /api/chat 엔드포인트가 있다.
- AiChatController와 AiChatService가 분리되어 있다.
- Spring AI ChatClient Bean 구성이 있다.
- Google GenAI model starter가 추가되어 있다.
- OpenAI model starter가 추가되어 있다.
- GEMINI_API_KEY 또는 GOOGLE_API_KEY로 Gemini 실행이 가능하다.
- AGENT_MODEL_PROVIDER=openai로 OpenAI 전환이 가능하다.
- API key 없이 테스트가 통과한다.
- 아직 AgentRuntime, Tool, Session 코드는 없다.
여기서도 마지막 항목이 중요하다.
아직 AgentRuntime, Tool, Session을 만들지 않는다.
이름을 먼저 만들고 싶은 유혹이 있지만, 지금은 참는다.
M1은 모델 호출을 Spring Service처럼 감싸는 단계다.
역할을 작게 유지하는 것이 더 중요하다.
마무리
이번 단계에서 만든 것은 Agent가 아니라 Chat이다.
하지만 이 작은 Chat API가 나중에 AgentRuntime 안으로 들어갈 모델 호출 계층이 된다.
Spring 애플리케이션 안에서 LLM 호출을 특별한 마법처럼 다루지 않고, 평범한 Service 호출처럼 다루는 것.
M1의 목표는 그 감각을 잡는 것이다.
다음 단계부터는 이 모델 호출을 바탕으로, 조금씩 에이전트에 가까운 구조를 만들어볼 예정이다.
[codex] Add ChatClient hello endpoint by dd3ok · Pull Request #2 · dd3ok/pi-spring-ai
'개발 > AI' 카테고리의 다른 글
| Spring AI로 Pi 스타일 에이전트 하네스 만들기 (3) - 작업 일지 기록 (0) | 2026.06.29 |
|---|---|
| Spring AI로 Pi 스타일 에이전트 하네스 만들기 (2) - 상태 전이를 위한 타입 정의 (0) | 2026.06.26 |
| Spring AI로 Pi 스타일 에이전트 하네스 만들기 (0) - init (0) | 2026.06.26 |
| 에이전트 토큰을 줄이는 방법 (Caveman vs RTK) (0) | 2026.05.26 |
| 하네스의 다음 -> 실패 루프를 설계하기 (1) | 2026.05.15 |