Function calling / tool use 예제로 배우기 (Spring AI)

1. 개요

LLM은 학습 데이터에만 있는 정보만 안다. 당신의 ORD-1001 주문이 지금 어디 있는지는 모른다. Function calling(또는 tool use라고도 함)이 정확히 이 문제를 해결한다. 모델에 몇 가지 함수를 설명한다. 모델은 언제, 어느 함수를 호출할지, 그리고 어떤 매개변수로 호출할지 판단한다. 당신의 애플리케이션이 함수를 실행한 다음 결과를 모델에 다시 돌려준다.

이 글에서는 JSON 수준의 메커니즘을 먼저 살펴본 다음, Spring AI로 고객 지원 어시스턴트를 구축한다. 이 어시스턴트는 주문 상태를 조회하고 주문을 취소할 수 있다. 이 글을 읽은 후에는 tool을 선언하고, 안전한 컨텍스트를 전달하며, 에러를 처리하고, 실제 모델 호출 없이 테스트를 작성할 수 있을 것이다.

2. Function calling은 어떻게 작동하나

2.1. 질문 – 호출 – 답변 루프

가장 헷갈리기 쉬운 부분: 모델은 당신의 코드를 직접 실행하지 않는다. 단지 구조화된 JSON 형태의 함수 호출 요청을 반환할 뿐이다. tool을 포함한 한 번의 대화는 다음과 같이 진행된다:

  1. 애플리케이션이 사용자의 질문과 함께 tool 목록(이름, 설명, 매개변수의 JSON Schema)을 전송한다.
  2. 모델이 최종 답변 대신 tool_calls를 반환한다.
  3. 애플리케이션이 해당 함수를 실행한 다음 결과를 tool 역할의 메시지로 다시 전송한다.
  4. 모델이 결과를 읽는다. 다른 tool을 추가로 호출할 수도 있고, 최종 답변을 작성할 수도 있다.

2.2. 원본 JSON 들여다보기

다음 형식은 OpenAI 방식을 따른다. 다른 공급자도 유사한 구조를 사용하지만 필드 이름만 다르다. 위로 보내는 요청에는 tools 섹션이 있다:

{
  "model": "gpt-4o-mini",
  "messages": [
    { "role": "user", "content": "내 ORD-1001 주문이 어디에 있나요?" }
  ],
  "tools": [{
    "type": "function",
    "function": {
      "name": "getOrderStatus",
      "description": "주문의 상태와 예상 배송 날짜를 조회한다",
      "parameters": {
        "type": "object",
        "properties": { "orderId": { "type": "string" } },
        "required": ["orderId"]
      }
    }
  }]
}

모델이 즉시 답변하지 않는다. 대신 함수 호출을 요청한다:

{
  "role": "assistant",
  "tool_calls": [{
    "id": "call_abc123",
    "type": "function",
    "function": { "name": "getOrderStatus", "arguments": "{\"orderId\":\"ORD-1001\"}" }
  }]
}

애플리케이션이 함수를 실행하고 올바른 tool_call_id와 함께 결과를 다시 전송한다:

{ "role": "tool", "tool_call_id": "call_abc123",
  "content": "{\"orderId\":\"ORD-1001\",\"status\":\"SHIPPED\",\"eta\":\"2026-10-30\"}" }

이제 모델이 최종 답변을 작성할 수 있다. 예를 들어: “주문 ORD-1001이 배송업체에 전달되었으며, 10월 30일에 도착할 예정입니다.”

두 가지를 기억해야 한다. 첫째, arguments는 모델이 생성한 문자열 JSON이다. 이 문자열은 타입이 잘못되거나 필드가 없을 수 있다. 둘째, description의 품질이 모델이 올바른 tool을 선택하는지를 결정한다.

이 루프를 직접 작성하는 것은 어렵지 않지만 상당히 길다. Spring AI가 이미 이 부분을 당신을 위해 처리한다.

3. Spring AI 설정

OpenAI용 BOM과 starter를 추가한다. 다른 공급자를 사용하는 경우, starter만 바꾸면 되고 tool 코드는 그대로 유지된다.

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>1.0.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-openai</artifactId>
    </dependency>
</dependencies>

API 키와 모델을 구성한다:

spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.options.model=gpt-4o-mini
spring.ai.openai.chat.options.temperature=0.2

낮은 temperature는 모델이 tool을 더 안정적으로 선택하도록 도와준다. 조회 작업의 경우, ’창의성’이 필요하지 않다.

4. @Tool로 tool 선언하기

간단하게 하기 위해 메모리 내 주문 저장소를 사용한다:

public record Order(String id, String customerId, String status, String eta) {}

public record OrderStatusReply(String orderId, String status, String eta) {}

tool 클래스는 일반적인 bean이다. @Tool로 표시된 각 메서드는 tool이 된다:

@Component
public class OrderTools {

    private final Map<String, Order> orders = new ConcurrentHashMap<>(Map.of(
        "ORD-1001", new Order("ORD-1001", "CUS-7", "SHIPPED", "2026-10-30"),
        "ORD-1002", new Order("ORD-1002", "CUS-7", "PENDING", "2026-11-02")
    ));

    @Tool(description = "주문의 상태와 예상 배송 날짜를 조회한다")
    public OrderStatusReply getOrderStatus(
            @ToolParam(description = "주문 코드, ORD-xxxx 형식") String orderId,
            ToolContext context) {
        Order order = findOwnedOrder(orderId, context);
        return new OrderStatusReply(order.id(), order.status(), order.eta());
    }

    @Tool(description = "배송을 위해 아직 전달되지 않은 주문을 취소한다. 고객이 명확하게 취소를 원할 때만 호출한다")
    public String cancelOrder(
            @ToolParam(description = "주문 코드, ORD-xxxx 형식") String orderId,
            @ToolParam(description = "고객이 제공한 취소 사유", required = false) String reason,
            ToolContext context) {
        Order order = findOwnedOrder(orderId, context);
        if (!"PENDING".equals(order.status())) {
            throw new IllegalStateException(
                "주문 " + orderId + "은(는) " + order.status() + " 상태이므로 취소할 수 없습니다");
        }
        orders.put(orderId, new Order(order.id(), order.customerId(), "CANCELLED", null));
        return "주문 " + orderId + " 취소 완료";
    }

    private Order findOwnedOrder(String orderId, ToolContext context) {
        String customerId = (String) context.getContext().get("customerId");
        Order order = orders.get(orderId);
        if (order == null || !order.customerId().equals(customerId)) {
            throw new IllegalArgumentException("주문 " + orderId + "을(를) 찾을 수 없습니다");
        }
        return order;
    }
}

주목할 몇 가지 사항:

  • @Tool(description = …)은 섹션 2.2의 JSON의 description과 동일하다. 기본적으로 tool의 이름은 메서드 이름이다.
  • @ToolParam은 각 매개변수를 설명한다. required = false는 매개변수를 schema의 required 목록에서 제외한다.
  • ToolContext 타입의 매개변수는 모델로 보내지는 schema에 나타나지 않는다. 모델은 이를 볼 수 없고 채울 수 없다. 로그인 세션에서 customerId를 전달하기 위해 사용한다. 덕분에 모델이 다른 고객의 고객 ID를 ‘지어낼’ 수 없다.
  • OrderStatusReply 반환 타입은 Spring AI가 모델로 다시 보내기 전에 JSON으로 직렬화한다.
  • 현재 고객의 주문이 아닌 경우, ‘권한 없음’ 대신 ’찾을 수 없음’을 반환한다. 이렇게 하면 해당 주문이 존재한다는 정보가 노출되지 않는다.

5. ChatClient를 통해 tool 호출하기

@Service
public class SupportAssistant {

    private final ChatClient chatClient;
    private final OrderTools orderTools;

    public SupportAssistant(ChatClient.Builder builder, OrderTools orderTools) {
        this.chatClient = builder
            .defaultSystem("""
                당신은 상점의 고객 지원 어시스턴트입니다.
                tool에서 가져온 데이터만을 기반으로 답변하십시오. 주문 상태를 추측하지 마십시오.
                주문을 취소하기 전에, 고객의 확인을 위해 다시 물어보십시오.""")
            .build();
        this.orderTools = orderTools;
    }

    public String answer(String customerId, String question) {
        return chatClient.prompt()
            .user(question)
            .tools(orderTools)
            .toolContext(Map.of("customerId", customerId))
            .call()
            .content();
    }
}

.tools(orderTools)는 bean 내의 모든 @Tool 메서드를 스캔하고 그 정의를 모델로 보낸다. 모델이 tool_calls를 반환하면, Spring AI는 세 가지를 자동으로 수행한다: arguments를 파싱하고, 올바른 메서드를 호출하고, 결과를 다시 보낸다. 이 루프는 모델이 텍스트로 답변할 때까지 반복된다. .content()는 최종 답변만 반환한다.

작은 controller로 시도해보자:

@RestController
@RequestMapping("/support")
public class SupportController {

    private final SupportAssistant assistant;

    public SupportController(SupportAssistant assistant) {
        this.assistant = assistant;
    }

    @PostMapping
    public String ask(@RequestBody String question, Principal principal) {
        return assistant.answer(principal.getName(), question);
    }
}

고객 CUS-7이 “ORD-1001 주문이 어디에 있나요?“라고 물을 때, 다음과 같은 결과를 받는다:

당신의 주문 ORD-1001이 배송업체에 전달되었으며, 2026년 10월 30일에 도착할 예정입니다.

정확한 표현은 실행할 때마다 달라질 것이다. 데이터는 항상 tool에서 가져온다.

6. 내부 들여다보기: ToolCallback과 schema

Spring AI는 각 @Tool 메서드를 ToolCallback으로 변환한다. 모델이 실제로 무엇을 받는지 확인하기 위해 직접 생성할 수 있다:

ToolCallback[] callbacks = ToolCallbacks.from(new OrderTools());
for (ToolCallback cb : callbacks) {
    System.out.println(cb.getToolDefinition().name());
    System.out.println(cb.getToolDefinition().inputSchema());
}

결과 (축약됨):

cancelOrder
{"type":"object","properties":{"orderId":{"type":"string","description":"주문 코드, ORD-xxxx 형식"},
 "reason":{"type":"string","description":"고객이 제공한 취소 사유"}},"required":["orderId"],...}
getOrderStatus
{"type":"object","properties":{"orderId":{"type":"string","description":"주문 코드, ORD-xxxx 형식"}},
 "required":["orderId"],...}

섹션 4에서 언급했듯이, ToolContext는 schema에 나타나지 않는다. 모델이 잘못된 tool을 선택할 때, 먼저 위와 같이 schema를 출력하여 확인해야 한다.

7. 모델 호출 없이 테스트하기

단위 테스트에서 실제 모델을 호출하는 것은 느리고, 비용이 들고, 결과가 불안정하다. 대신 두 가지를 테스트해야 한다: 모델이 받는 schema와 모델이 보낼 것과 동일한 arguments 문자열을 받을 때 tool의 동작.

class OrderToolsTest {

    private final ToolCallback[] callbacks = ToolCallbacks.from(new OrderTools());
    private final ToolContext customer7 = new ToolContext(Map.of("customerId", "CUS-7"));

    private ToolCallback tool(String name) {
        return Arrays.stream(callbacks)
            .filter(cb -> cb.getToolDefinition().name().equals(name))
            .findFirst().orElseThrow();
    }

    @Test
    void schemaShouldNotExposeToolContext() {
        String schema = tool("getOrderStatus").getToolDefinition().inputSchema();

        assertThat(schema).contains("\"orderId\"").doesNotContain("context");
    }

    @Test
    void shouldReturnStatusAsJson() {
        String result = tool("getOrderStatus")
            .call("{\"orderId\":\"ORD-1001\"}", customer7);

        assertThat(result).contains("\"status\":\"SHIPPED\"", "\"eta\":\"2026-10-30\"");
    }

    @Test
    void shouldHideOrdersOfOtherCustomers() {
        ToolContext stranger = new ToolContext(Map.of("customerId", "CUS-99"));

        assertThatThrownBy(() -> tool("getOrderStatus").call("{\"orderId\":\"ORD-1001\"}", stranger))
            .hasRootCauseInstanceOf(IllegalArgumentException.class)
            .hasRootCauseMessage("주문 ORD-1001을(를) 찾을 수 없습니다");
    }

    @Test
    void shouldRefuseToCancelShippedOrder() {
        assertThatThrownBy(() -> tool("cancelOrder").call("{\"orderId\":\"ORD-1001\"}", customer7))
            .isInstanceOf(ToolExecutionException.class)
            .hasRootCauseMessage("주문 ORD-1001은(는) SHIPPED 상태이므로 취소할 수 없습니다");
    }

    @Test
    void shouldCancelPendingOrder() {
        String result = tool("cancelOrder")
            .call("{\"orderId\":\"ORD-1002\",\"reason\":\"Đặt nhầm\"}", customer7);

        assertThat(result).contains("주문 ORD-1002 취소 완료");
    }
}

MethodToolCallback bọc mọi exception từ method của bạn trong ToolExecutionException. 이 테스트 모음은 몇 밀리초 안에 실행되며 당신이 제어할 수 있는 부분을 정확히 테스트한다.

모델의 동작(올바른 tool을 선택하는지 여부, 취소하기 전에 확인을 요청하는지 여부)은 다른 종류의 테스트에 해당한다. 실제 모델로 별도의 평가 모음으로 실행해야 하며, 단위 테스트에 포함하지 않아야 한다.

8. Tool에서의 오류 처리

cancelOrder가 실제 실행 중에 예외를 던질 때 어떻게 되나? 기본값으로(spring.ai.tools.throw-exception-on-error=false), Spring AI는 요청을 깨뜨리지 않는다. 대신 예외의 메시지를 가져와 tool 결과로 모델로 다시 보낸다. 모델이 메시지를 읽고 고객에게 설명한다:

죄송하지만, 주문 ORD-1001이 배송업체에 전달되었으므로 더 이상 취소할 수 없습니다.
배송 수령 시 교환/반품 절차를 알아드릴까요?

이 동작은 매우 편리하지만, 주의해야 할 두 가지 결과를 초래한다:

  • 예외의 메시지는 모델이 읽고 일반적으로 사용자에게 다시 전달된다. Stack trace, SQL 쿼리 또는 내부 정보가 포함된 예외를 던지지 마십시오.
  • 인프라 오류(예: 데이터베이스 다운)가 바로 위로 올라가도록 하려면 다음을 설정하십시오:
spring.ai.tools.throw-exception-on-error=true

합리적인 접근: 비즈니스 오류(주문이 이미 배송됨, 주문을 찾을 수 없음)는 모델로 반환하고, 인프라 오류는 모니터링이 포착할 수 있도록 올려보낸다.

9. 일반적인 오류 및 사용하면 안 되는 경우

주의: 모델이 생성한 arguments를 신뢰할 수 없는 사용자 입력처럼 취급하십시오. 프롬프트 인젝션으로 인해 모델이 임의의 주문 코드로 cancelOrder를 호출할 수 있다. 모든 권한 확인은 tool 코드 내에 있어야 하며, system prompt의 지시가 아닌 ToolContext 또는 security context를 기반으로 해야 한다.

일반적인 오류:

  • 모호한 설명. “주문 가져오기”라고 쓰면 모델이 “주문 이력 가져오기”와 구분하기 어렵다. tool이 무엇을 하는지, 언제 사용해야 하는지, 언제 사용하면 안 되는지 명확하게 설명하십시오.
  • 한 요청에 너무 많은 tool. 각 tool 정의는 token을 소비하고, 목록이 길수록 모델의 선택이 저하된다. 현재 use case와 관련된 tool만 전달하십시오.
  • 확인 단계 없는 side effect가 있는 tool. 주문 취소, 환불, 이메일 전송 등의 작업의 경우, 두 단계로 분리하는 것을 고려하십시오. 첫 번째 tool은 준비만 하고 요약을 반환한다. 실제 작업은 모델이 ’고객이 동의했다고 생각’한 이후가 아니라 사용자가 UI에서 확인 버튼을 클릭한 후에만 실행되어야 한다.
  • 멱등성 부족. 모델은 같은 tool을 두 번 호출할 수 있다. cancelOrder를 설계할 때 두 번째 호출이 해롭지 않도록 하십시오.
  • 너무 큰 데이터 반환. 500줄의 주문 이력을 전체 context로 반환하면 token을 낭비하고 모델을 ’혼동’시킨다. tool 내에서 필터링하고 요약하십시오.
  • 각 tool 호출이 모델로의 추가 왕복이라는 것을 잊기. 세 개의 tool이 필요한 질문은 몇 초 정도 걸릴 수 있다. 빠른 응답이 필요한 흐름에 추가하기 전에 지연 시간을 측정하십시오.

function calling을 사용하면 안 되는 경우:

  • 처리 흐름이 미리 정해져 있고 알려진 경우(항상 주문을 조회한 후 답변). 이 경우 service를 직접 호출하고 결과를 prompt에 넣으십시오. 이 방법이 더 저렴하고, 더 빠르고, 더 예측 가능하다.
  • 모델이 구조화된 데이터를 반환하기만 하면 된다. tool로 시뮬레이션하는 대신 structured output(예: .call().entity(MyRecord.class))을 사용하십시오.
  • 민감하고 되돌릴 수 없는 작업(송금, 데이터 삭제)인데 사람의 확인 단계가 없는 경우.

10. 결론

Function calling은 마법이 아니다. 모델은 JSON 형태의 함수 호출 요청만 반환하고, 실제 실행은 당신의 애플리케이션이 한다. Spring AI는 실행 부분을 더 간결하게 만들어준다: @Tool과 @ToolParam이 schema를 생성하고, ChatClient.tools(...)이 호출-결과 반환 루프를 처리하며, ToolContext는 모델이 건드릴 수 없는 세션 데이터를 전달한다.

가장 중요한 것은 신뢰 경계를 올바르게 설정하는 것이다. 명확한 설명을 작성하고 tool 내에서 권한을 확인하십시오. Side effect가 있는 작업의 경우, 사용자 확인이 필요하다. 마지막으로, 실제 JSON 문자열로 ToolCallback을 통해 tool을 테스트하여 모델이 무엇을 답변하든 당신의 코드 부분이 제대로 작동하는지 확인하십시오.

llmspring-aifunction-callingtool-usejava