Spring AI Tool Calling(@Tool)로 LLM이 사내 API를 직접 호출하게 만든 과정
@Tool 어노테이션으로 Java 메서드를 LLM이 호출 가능한 도구로 등록해, LLM이 사내 API와 DB를 직접 조회하도록 만든 함수 호출(Function Calling) 구현 과정을 예제 기반으로 정리했습니다.

이전에 Spring AI로 LLM API를 연동하면서 ChatClient로 상품 설명을 생성하는 기능까지는 만들어봤습니다. 그런데 곧 벽에 부딪혔습니다. "주문번호 20250719-0032의 배송 상태 알려줘"라고 물으면 LLM은 그럴듯한 거짓말만 늘어놓았습니다. 우리 데이터베이스를 모르니 당연한 일이었습니다.
처음엔 "그럼 관련 데이터를 프롬프트에 다 넣어주면 되지 않나" 싶었습니다. 하지만 주문이 몇 건인지도 모르는데 전부 프롬프트에 담을 수는 없었습니다. 필요한 건 반대 방향이었습니다. LLM이 필요할 때 우리 코드를 직접 호출해서 데이터를 가져오게 만드는 것. 그게 Tool Calling(도구 호출, 함수 호출)이었습니다.
이 글은 운영에 넣은 무용담이 아니라, Spring AI의 Tool Calling을 제대로 이해하려고 예제 프로젝트를 직접 구성해 돌려본 기록입니다. 공식 문서(docs.spring.io/spring-ai/reference/api/tools.html)와 실제 동작을 대조하면서 정리했습니다.
LLM은 코드를 실행하지 않는다
가장 먼저 오해를 바로잡아야 했습니다. Tool Calling이라고 해서 LLM이 우리 서버에서 Java 코드를 실행하는 게 아닙니다. 실제 흐름은 이렇습니다.
- 우리가 사용 가능한 도구 목록(이름, 설명, 파라미터 스키마)을 프롬프트와 함께 LLM에 보낸다
- LLM은 "나는
getOrderStatus도구를orderId=20250719-0032로 호출하고 싶다"는 **의도(JSON)**를 응답한다 - Spring AI가 그 의도를 받아 실제 Java 메서드를 실행한다
- 메서드 반환값을 다시 LLM에 전달한다
- LLM은 그 결과를 바탕으로 사람이 읽을 최종 답변을 만든다
핵심은 2번과 3번의 경계입니다. 실행 주체는 어디까지나 우리 애플리케이션이고, LLM은 "무엇을 호출할지"만 결정합니다. 이 왕복(2~4번)을 Spring AI가 자동으로 돌려주기 때문에, 개발자는 도구를 정의하고 등록만 하면 됩니다.
@Tool로 메서드를 도구로 등록하기
도구를 만드는 건 생각보다 허무할 정도로 간단했습니다. 평범한 스프링 빈의 메서드에 @Tool 어노테이션을 붙이면 끝입니다.
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class OrderTools {
private final OrderRepository orderRepository;
@Tool(description = "주문번호로 주문의 배송 상태와 예상 도착일을 조회한다")
public OrderStatus getOrderStatus(
@ToolParam(description = "주문번호. 예: 20250719-0032") String orderId) {
return orderRepository.findByOrderNo(orderId)
.map(o -> new OrderStatus(o.getOrderNo(), o.getShippingState(), o.getEta()))
.orElseThrow(() -> new OrderNotFoundException(orderId));
}
}여기서 가장 중요한 건 description이었습니다. 이 문자열은 주석이 아니라 LLM이 읽는 사용 설명서입니다. LLM은 이 설명만 보고 "지금 이 도구를 써야 하나"를 판단합니다. 설명을 대충 "주문 조회"라고만 적었더니, 배송 질문에도 엉뚱한 도구를 부르거나 아예 도구를 안 부르는 경우가 생겼습니다. "언제, 무엇을 위해 쓰는 도구인지"를 구체적으로 적자 호출 정확도가 눈에 띄게 안정됐습니다.
@ToolParam은 파라미터에 대한 설명과 필수 여부를 지정합니다. description으로 형식(예: 주문번호 포맷)을 알려주면 LLM이 인자를 더 정확히 채웠습니다. 필수가 아닌 값은 @ToolParam(required = false)로 표시합니다.
@Tool(description = "키워드로 상품을 검색한다. 카테고리를 지정하면 해당 카테고리 안에서만 찾는다")
public List<ProductSummary> searchProducts(
@ToolParam(description = "검색 키워드") String keyword,
@ToolParam(description = "카테고리명. 지정하지 않으면 전체 검색", required = false) String category) {
return productService.search(keyword, category);
}JSON 스키마는 어떻게 자동 생성되는가
처음 신기했던 지점은, 내가 JSON 스키마를 한 줄도 쓰지 않았다는 것이었습니다. Spring AI는 메서드 시그니처를 리플렉션으로 훑어서 파라미터 스키마를 자동으로 만들어 LLM에 넘깁니다. 위 searchProducts는 대략 이런 스키마로 변환됩니다.
{
"type": "object",
"properties": {
"keyword": { "type": "string", "description": "검색 키워드" },
"category": { "type": "string", "description": "카테고리명. 지정하지 않으면 전체 검색" }
},
"required": ["keyword"]
}규칙을 정리하면 이렇습니다.
- 파라미터 타입(String, 숫자, enum, record, List 등)이 스키마의
type으로 매핑된다 @ToolParam(description=...)이 각 속성의description이 된다- 기본적으로 모든 파라미터는 required이며,
@ToolParam(required = false)나@Nullable을 붙이면 optional로 빠진다 - enum 타입은
enum제약으로 변환되어, LLM이 정해진 값 중에서만 고르게 강제된다
@ToolParam 외에 Jackson의 @JsonProperty(required=...), @JsonPropertyDescription도 스키마에 반영됩니다. 다만 저는 도구 정의를 한곳에서 읽히게 하려고 @ToolParam으로 통일하는 편이 관리하기 편했습니다.
ChatClient에 도구 등록하기: tools() vs defaultTools()
정의한 도구를 ChatClient에 붙이는 방법은 두 가지입니다. 요청 단위로 붙이거나, 클라이언트 전역에 기본 도구로 붙이거나.
@Service
@RequiredArgsConstructor
public class OrderAssistantService {
private final ChatClient.Builder chatClientBuilder;
private final OrderTools orderTools;
public String ask(String question) {
return chatClientBuilder.build()
.prompt(question)
.tools(orderTools) // 이 요청에서만 사용할 도구
.call()
.content();
}
}여러 요청에서 항상 같은 도구를 쓴다면, 빌더 단계에서 defaultTools로 한 번만 등록하는 게 깔끔했습니다.
@Bean
public ChatClient chatClient(ChatClient.Builder builder,
OrderTools orderTools,
ProductSearchTools productSearchTools) {
return builder
.defaultSystem("너는 쇼핑몰 고객 문의를 돕는 상담원이다. 데이터가 필요하면 제공된 도구를 사용해라.")
.defaultTools(orderTools, productSearchTools)
.build();
}tools()와 defaultTools() 모두 @Tool이 붙은 객체뿐 아니라 ToolCallback 배열도 받습니다. @Tool 어노테이션이 부담스럽거나 도구를 코드로 조립하고 싶을 때는 MethodToolCallback.builder()(메서드 기반)나 FunctionToolCallback.builder()(Function/Supplier 기반)로 프로그래밍 방식 정의도 가능했습니다. 저는 대부분의 경우 @Tool 선언 방식이 가장 읽기 좋았습니다.
이제 "20250719-0032 배송 상태 알려줘"라고 물으면, LLM이 스스로 getOrderStatus를 호출하고, Spring AI가 DB를 조회해 결과를 되돌려주고, LLM이 "해당 주문은 현재 배송 중이며 예상 도착일은 …"이라고 답합니다. 중간의 왕복은 코드에 전혀 드러나지 않습니다.
부작용이 있는 도구는 다르게 다뤄야 했다
여기서부터가 진짜 고민이었습니다. 조회(read-only) 도구는 잘못 호출돼도 데이터를 읽을 뿐이지만, 쓰기·삭제 도구는 얘기가 다릅니다. "주문 취소해줘" 같은 요청을 처리하려고 cancelOrder(orderId) 도구를 만드는 순간, LLM의 판단 실수가 곧바로 실제 부작용(side effect)으로 이어집니다.
실제로 예제에서 취소 도구를 붙여보니 위험이 선명해졌습니다. LLM은 인자를 환각(hallucination) 으로 지어낼 수 있습니다. 사용자가 명확히 지정하지 않은 주문번호를 그럴듯하게 만들어 넣거나, 대화 맥락을 오해해 엉뚱한 주문을 취소 대상으로 고르는 식입니다. "문은 잠갔는데 도둑에게 열쇠 복사본을 쥐여준" 격이었습니다.
그래서 몇 가지 원칙을 세웠습니다.
- 읽기와 쓰기 도구를 분리하고, 쓰기 도구에는 최소 권한만 준다. 조회 봇에는 애초에 쓰기 도구를 등록하지 않았습니다.
- 도구 메서드 안에서 방어적으로 검증한다. LLM이 넘긴 인자를 신뢰하지 않고, 소유권·상태·권한을 서버 로직으로 다시 확인했습니다. 예를 들어 취소는 "본인 주문이면서 아직 발송 전"일 때만 허용했습니다.
- 되돌릴 수 없는 작업은 사람 확인 단계를 끼운다. LLM이 바로 실행하게 두지 않고, "취소 요청 접수" 상태만 만든 뒤 실제 취소는 확인 액션을 거치게 설계했습니다.
@Tool(description = "고객이 명시적으로 요청한 주문의 취소를 '접수'한다. 실제 취소는 별도 확인 후 처리된다")
public CancelReceipt requestCancel(
@ToolParam(description = "취소할 주문번호") String orderId,
ToolContext toolContext) {
Long userId = (Long) toolContext.getContext().get("userId");
// LLM이 넘긴 orderId를 그대로 믿지 않고 소유권과 상태를 서버가 재검증
Order order = orderRepository.findByOrderNo(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
order.assertOwnedBy(userId);
order.assertCancelable();
return cancelService.receive(order.getId()); // 즉시 취소가 아닌 '접수'
}ToolContext로 민감 정보는 모델에 노출하지 않기
위 코드의 ToolContext가 중요한 장치였습니다. 인증된 사용자 ID 같은 값을 LLM에게 인자로 물어보게 하면 절대 안 됩니다. LLM이 임의의 userId를 지어낼 수 있기 때문입니다. ToolContext는 애플리케이션이 도구에 직접 넘기는 데이터로, 프롬프트나 모델에는 전달되지 않습니다.
chatClient.prompt(question)
.tools(orderMutationTools)
.toolContext(Map.of("userId", currentUser.getId())) // 서버가 아는 값만 주입
.call()
.content();도구 메서드는 toolContext.getContext().get("userId")로 이 값을 꺼내 씁니다. "LLM이 결정하는 값(무엇을 조회/취소할지)"과 "서버가 통제하는 값(누구의 요청인지)"을 물리적으로 분리한 셈입니다. 이 경계를 지키는 것이 도구 설계에서 가장 신경 쓴 부분이었습니다.
한편, 결과를 모델에 다시 보내지 않고 호출자에게 바로 돌려주고 싶을 때는 @Tool(returnDirect = true)를 씁니다. 도구 결과 자체가 최종 응답이면 되는 경우(예: 파일 URL 반환) LLM 왕복을 한 번 줄일 수 있었습니다.
도구 남용을 막기 위해 신경 쓴 것들
도구를 많이 등록할수록 좋은 게 아니라는 걸 예제를 돌리며 배웠습니다. 도구가 늘어나면 두 가지 비용이 생겼습니다.
- 도구 정의(이름·설명·스키마)가 매 요청 프롬프트에 실려 입력 토큰이 늘어난다
- 비슷한 도구가 여럿이면 LLM이 어느 걸 부를지 헷갈려 잘못 고르거나 불필요하게 여러 번 호출한다
그래서 한 클라이언트에 붙이는 도구는 그 봇의 역할에 꼭 필요한 것으로 최소화하고, 이름과 설명을 서로 확실히 구분되게 지었습니다. getOrderStatus와 getOrderDetail처럼 경계가 모호한 도구는 하나로 합치거나 설명에서 용도를 뚜렷이 갈랐습니다.
버전 이야기: @Tool은 어디서 왔나 (1.0 vs 2.0)
혼동하기 쉬운 지점이라 짚어둡니다. 일부 자료는 함수 호출을 @AiFunction 같은 이름으로 소개하지만, 공식 API에서 확인한 정식 명칭은 @Tool(org.springframework.ai.tool.annotation.Tool)과 @ToolParam이었습니다.
- Spring AI 1.0 GA 기준으로
@Tool어노테이션 방식이 표준입니다. 그 이전 마일스톤에서 쓰이던FunctionCallback·@Description을 붙인 함수 빈(bean) 등록 방식은 이@Tool/ToolCallbackAPI로 대체됐습니다. - 2.0 레퍼런스 문서에서는 한 걸음 더 나아가,
ChatModel이 내부적으로 도구 실행을 처리하던 방식이 deprecated 됐고ChatClient+ToolCallingAdvisor사용을 권장한다고 명시하고 있었습니다. 이 글의 예제처럼ChatClient기반으로 짜두면 그 방향과 어긋나지 않습니다.
정확한 좌표는 사용 중인 버전의 릴리스 노트로 한 번 더 확인하시길 권합니다. 마일스톤 사이에 패키지나 빌더 시그니처가 조정된 이력이 있어, 저도 예제 의존성 버전을 고정해두고 문서와 대조하며 검증했습니다.
마무리
Tool Calling을 붙이기 전까지 제게 LLM은 "말을 잘하는 텍스트 생성기"였습니다. 도구를 물려주고 나니 성격이 달라졌습니다. 스스로 무엇이 필요한지 판단하고, 우리 시스템을 호출해 실제 데이터를 가져와 답하는 행위자에 가까워졌습니다. 에이전틱 AI 시대에 개발자의 역할이 어떻게 달라지는지 고민하던 것이, 이 도구 하나로 훨씬 구체적으로 다가왔습니다.
동시에 가장 크게 배운 건 기술이 아니라 태도였습니다. LLM이 도구를 부를 수 있다는 건, 우리 시스템의 문을 LLM에게 조금 열어준다는 뜻입니다. 그 문이 조회창인지, 아니면 데이터를 바꾸는 실행 버튼인지에 따라 설계의 무게가 완전히 달라졌습니다. 결국 Tool Calling은 프롬프트 엔지니어링이 아니라 권한과 부작용을 다루는 백엔드 설계 문제였고, 그동안 API를 설계하며 지켜온 원칙들이 여기서도 그대로 통했습니다.