Spring AI Tool Calling 설계: Tool과 DDD 계층의 책임 분리
Spring AI를 활용하면 @Tool 메서드를 ChatClient에 등록해 LLM이 애플리케이션의 기능을 호출하게 만들 수 있다. 하지만 @Tool은 어느 계층의 책임인지 바로 드러나지 않아, 기존 Application Service나 AI 설정 클래스에 함께 두기 쉽다.
문제는 Tool이 늘어나면서 발생한다. LLM에 어떤 기능을 노출할지 결정해야 하고, 인증된 사용자 정보를 Tool까지 전달해야 하며, Tool에서도 기존 비즈니스 로직을 동일하게 사용해야 한다. 이 과정에서 @Tool, ChatClient, ToolCallback이 여러 계층으로 퍼지면 Application 계층이 Spring AI에 직접 의존하게 된다.
이번 글에서는 Spring AI Tool Calling의 실행 흐름을 살펴보고, LLM Tool과 DDD 계층의 책임을 분리하는 방법을 정리한다.
I. 문제 상황
1. Application Service에 @Tool 선언
주문 환불 기능을 LLM에 제공한다고 가정해보자. 가장 간단한 방법은 기존 Application Service에 @Tool을 바로 선언하는 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// application/order/RefundOrderService.java
@Service
@RequiredArgsConstructor
public class RefundOrderService {
private final OrderRepository orderRepository;
@Tool(description = "주문을 환불한다") // ⚠️ LLM 계약이 Application에 포함됨
@Transactional
public String refund(
@ToolParam(description = "주문 ID") String orderId) {
Order order = orderRepository.findById(new OrderId(orderId))
.orElseThrow(OrderNotFoundException::new);
order.refund();
orderRepository.save(order);
return order.id().value();
}
}
코드는 짧지만 환불 UseCase와 LLM 계약을 한 클래스가 함께 책임한다. @Tool의 이름, 설명과 파라미터는 비즈니스 규칙이 아니라 모델에 전달할 외부 계약이다. 결국 Spring AI를 다른 프레임워크로 교체하거나 Tool 설명만 변경해도 Application 계층이 함께 변경된다.
2. Tool 선택 정책의 프레임워크 의존
사용자 권한에 따라 노출할 Tool을 다르게 만들려 하면 문제는 더 커진다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// application/agent/AgentToolset.java
public interface AgentToolset {
ToolCallback[] resolve(Set<AgentCapability> capabilities); // ⚠️ Spring AI 타입 노출
}
// application/agent/ChatAgentService.java
@Service
public class ChatAgentService {
private final ChatClient chatClient; // ⚠️ Spring AI 타입
private final AgentToolset agentToolset;
public String chat(ChatCommand command) {
ToolCallback[] tools = agentToolset.resolve(command.capabilities());
return chatClient.prompt()
.user(command.message())
.toolCallbacks(tools)
.call()
.content();
}
}
Application에 Port를 만들었지만 반환 타입이 ToolCallback[]이라면 의존성은 분리되지 않는다. 인터페이스만 추가했을 뿐, Application은 여전히 Spring AI의 타입과 실행 방식을 알고 있다.
문제를 정리하면 다음과 같다.
| 문제 | 결과 |
|---|---|
Application Service에 @Tool 선언 | 비즈니스 로직과 LLM 계약이 결합됨 |
Application에서 ChatClient 사용 | 모델 호출 방식이 UseCase에 노출됨 |
Port가 ToolCallback 반환 | 추상화 내부로 Spring AI 타입이 침투함 |
| 모든 Tool을 기본 등록 | 사용자에게 필요하지 않은 기능까지 모델에 노출될 수 있음 |
| Tool 노출 여부만으로 권한 판단 | Prompt Injection이나 중복 호출 시 비즈니스 권한을 보장할 수 없음 |
II. Spring AI Tool Calling 실행 흐름
계층을 나누기 전에 Tool Calling이 실제로 어떻게 실행되는지 확인해보자.
1. LLM은 Java 메서드를 직접 호출하지 않는다
Spring AI의 @Tool은 메서드의 이름, 설명과 파라미터를 Tool Definition으로 변환한다. 이 정보는 요청과 함께 모델에 전달되지만, 모델이 애플리케이션의 Java 메서드에 직접 접근하는 것은 아니다.
Spring AI 1.1.6에서 ChatClient를 사용하면 다음 순서로 실행된다.
1
2
3
4
5
6
7
8
[1] @Tool 메서드에서 Tool Definition과 JSON Schema 생성
[2] ChatClient가 메시지와 ToolCallback을 Prompt로 구성해 ChatModel 호출
[3] ChatModel이 사용자 메시지와 Tool Definition을 LLM에 전달
[4] LLM이 Tool 이름과 인자를 포함한 Tool Call 요청을 응답
[5] ChatModel이 ToolCallingManager에 Tool 실행을 위임
[6] ToolCallingManager가 대응하는 ToolCallback 실행
[7] ChatModel이 Tool 실행 결과를 ToolResponseMessage로 LLM에 다시 전달
[8] LLM이 Tool 결과를 바탕으로 최종 응답 생성
Spring AI 1.1.6의 ChatModel은 기본적으로 내부 Tool 실행이 활성화되어 있다. 때문에 일반적인 Tool Calling은 아래 코드만으로 전체 실행 반복을 처리할 수 있다.
1
2
3
4
5
String response = chatClient.prompt()
.user("주문 ORD-100을 환불해줘")
.tools(refundOrderTool)
.call()
.content();
ChatModel을 직접 호출해도 기본값에서는 동일하게 Tool을 자동 실행한다. 승인이나 별도 감사 절차가 필요하면 internalToolExecutionEnabled(false)를 지정하고, ToolCallingManager를 이용해 실행 반복을 직접 제어해야 한다.
2. Tool은 프로토콜을 변환하는 경계
Tool Calling의 흐름을 기준으로 보면 @Tool 클래스가 담당하는 일은 명확하다.
1
2
3
4
5
LLM이 생성한 Tool 이름 + JSON 인자
↓
@Tool 메서드에서 변환
↓
Application Command + UseCase
이는 HTTP 요청을 Command로 변환하는 Controller와 유사하다.
| 진입 어댑터 | 외부 클라이언트 | 외부 계약 | 호출 대상 |
|---|---|---|---|
@RestController | 웹·모바일 클라이언트 | URL, HTTP, JSON | Application UseCase |
@KafkaListener | 메시지 브로커 | Topic, Message | Application UseCase |
@Tool | LLM Tool Calling Runtime | Tool 이름, 설명, JSON Schema | Application UseCase |
비즈니스 기능을 노출하는 @Tool은 LLM 요청을 Application 입력 포트로 변환하는 Inbound Adapter로 볼 수 있다. 반면 단순 계산처럼 UseCase와 무관한 기술 Tool은 AI 어댑터 내부에 둘 수 있다. 기준은 어노테이션이 아니라 어떤 외부 요청을 어떤 UseCase로 변환하는가이다.
III. LLM Tool과 Application UseCase 분리
1. Application 입력 포트 정의
먼저 Spring AI를 전혀 알지 못하는 주문 환불 UseCase를 정의한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// application/order/port/in/RefundOrderUseCase.java
public interface RefundOrderUseCase {
RefundOrderResult execute(RefundOrderCommand command);
}
public record RefundOrderCommand(
ActorContext actor,
OrderId orderId
) {
}
public record ActorContext(
String userId,
String tenantId
) {
}
public record RefundOrderResult(
String orderId,
String status
) {
}
Application Service는 기존과 동일하게 비즈니스 권한과 트랜잭션을 책임진다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// application/order/RefundOrderService.java
@Service
@RequiredArgsConstructor
public class RefundOrderService implements RefundOrderUseCase {
private final OrderRepository orderRepository;
private final OrderAuthorizationService authorizationService;
@Override
@Transactional
public RefundOrderResult execute(RefundOrderCommand command) {
Order order = orderRepository.findById(command.orderId())
.orElseThrow(OrderNotFoundException::new);
// ✅ Tool 노출 여부와 별개로 실제 비즈니스 권한을 다시 검증
authorizationService.checkRefundPermission(command.actor(), order);
order.refund();
orderRepository.save(order);
return new RefundOrderResult(
order.id().value(),
order.status().name()
);
}
}
이 UseCase는 요청이 HTTP에서 왔는지, LLM Tool Calling에서 왔는지 알 필요가 없다.
2. LLM Inbound Adapter 구현
@Tool 클래스는 AI Adapter에 배치하고, 모델의 인자를 Application Command로 변환한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// adapter/ai/tool/RefundOrderTool.java
@Component
@RequiredArgsConstructor
public class RefundOrderTool {
private final RefundOrderUseCase refundOrderUseCase;
@Tool(
name = "refund_order",
description = "인증된 사용자의 주문을 환불한다"
)
public RefundToolResponse refund(
@ToolParam(description = "환불할 주문 ID") String orderId,
ToolContext toolContext) {
Object contextValue = toolContext.getContext().get("actor");
if (!(contextValue instanceof ActorContext actor)) {
throw new AccessDeniedException("인증 정보가 필요하다");
}
RefundOrderResult result = refundOrderUseCase.execute(
new RefundOrderCommand(actor, new OrderId(orderId))
);
return new RefundToolResponse(result.orderId(), result.status());
}
}
public record RefundToolResponse(
String orderId,
String status
) {
}
Tool 반환 타입은 반드시 String일 필요가 없다. Spring AI는 직렬화할 수 있는 POJO나 record를 Tool 실행 결과로 사용할 수 있다.
ToolContext는 인증된 사용자나 Tenant처럼 모델이 결정해서는 안 되는 값을 전달할 때 사용한다. ToolContext의 데이터는 모델의 Tool Schema나 Prompt로 전달되지 않는다.
userId,role,tenantId를@ToolParam으로 받으면 모델이 해당 값을 생성할 수 있다. 인증·인가에 사용되는 값은 서버가 검증한 뒤ToolContext로 전달해야 한다.
3. REST Controller와 동일한 UseCase 공유
HTTP 요청도 같은 RefundOrderUseCase를 호출한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// adapter/http/OrderController.java
@RestController
@RequiredArgsConstructor
public class OrderController {
private final RefundOrderUseCase refundOrderUseCase;
@PostMapping("/api/v1/orders/{orderId}/refund")
public RefundOrderResponse refund(
@AuthenticationPrincipal ActorContext actor,
@PathVariable String orderId) {
RefundOrderResult result = refundOrderUseCase.execute(
new RefundOrderCommand(actor, new OrderId(orderId))
);
return RefundOrderResponse.from(result);
}
}
두 어댑터는 서로를 알지 못한다. 각자 외부 프로토콜을 변환한 후 동일한 Application 입력 포트를 호출한다.
1
2
3
HTTP Client ──→ OrderController ───┐
├──→ RefundOrderUseCase ──→ Domain
LLM Tool Call ─→ RefundOrderTool ──┘
이를 통해 REST와 Tool에서 환불 규칙, 권한 검증, 트랜잭션을 중복 구현하지 않아도 된다.
IV. ChatClient와 ToolCallback 격리
Tool Adapter를 분리해도 Application에서 ChatClient를 직접 사용하면 Spring AI 의존은 남아 있다. 모델 호출도 Outbound Port를 통해 분리해보자.
1. 프레임워크 중립적인 Agent Port
Application은 어떤 모델과 Tool Callback이 사용되는지 알 필요가 없다. 사용 가능한 Capability를 결정하고, 중립적인 요청을 AgentGateway에 전달한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// application/agent/port/out/AgentGateway.java
public interface AgentGateway {
AgentAnswer ask(AgentRequest request);
}
public record AgentRequest(
String message,
ActorContext actor,
Set<AgentCapability> allowedCapabilities
) {
}
public record AgentAnswer(String content) {
}
Capability는 Tool의 이름이 아니라 애플리케이션이 제공하는 능력을 표현한다.
1
2
3
4
5
// application/agent/AgentCapability.java
public enum AgentCapability {
SEARCH_ORDER,
REFUND_ORDER
}
Application Service는 사용자와 Agent의 정책을 기준으로 Capability를 결정한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// application/agent/ChatAgentService.java
@Service
@RequiredArgsConstructor
public class ChatAgentService {
private final AgentCapabilityPolicy capabilityPolicy;
private final AgentGateway agentGateway;
public AgentAnswer chat(ChatCommand command) {
Set<AgentCapability> allowed = capabilityPolicy.resolve(
command.agentType(),
command.actor()
);
return agentGateway.ask(new AgentRequest(
command.message(),
command.actor(),
allowed
));
}
}
이제 Application에는 ChatClient, ToolCallback, ToolContext가 존재하지 않는다.
2. Capability와 ToolCallback 매핑
Capability를 실제 Spring AI Tool로 변환하는 책임은 바깥쪽 Adapter에 둔다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// adapter/ai/config/SpringAiToolRegistry.java
@Component
public class SpringAiToolRegistry {
private final Map<AgentCapability, ToolCallback[]> callbacks;
public SpringAiToolRegistry(
SearchOrderTool searchOrderTool,
RefundOrderTool refundOrderTool) {
this.callbacks = Map.of(
AgentCapability.SEARCH_ORDER,
ToolCallbacks.from(searchOrderTool),
AgentCapability.REFUND_ORDER,
ToolCallbacks.from(refundOrderTool)
);
}
public ToolCallback[] resolve(Set<AgentCapability> capabilities) {
return capabilities.stream()
.flatMap(capability -> Arrays.stream(
callbacks.getOrDefault(capability, new ToolCallback[0])
))
.toArray(ToolCallback[]::new);
}
}
Tool 수가 많지 않은 단계에서는 Reflection이나 별도의 커스텀 어노테이션보다 명시적인 Map이 이해하기 쉽다. Capability가 추가될 때 어떤 Tool이 노출되는지도 코드에서 바로 확인할 수 있다.
3. 요청 범위 Tool만 사용하는 ChatModel과 ChatClient 구성
권한별 Tool 목록을 보안 경계로 사용하려면 기본 Tool이 없는 전용 ChatClient를 구성하는 것이 안전하다. 또한 ToolCallingManager가 요청에 없는 Tool 이름을 전역 ToolCallbackResolver에서 다시 찾지 못하도록 제한한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// adapter/ai/config/SpringAiAgentConfig.java
@Configuration
public class SpringAiAgentConfig {
@Bean
public ToolCallingManager toolCallingManager(
ToolExecutionExceptionProcessor exceptionProcessor) {
return ToolCallingManager.builder()
// 요청에 포함된 ToolCallback만 실행한다.
.toolCallbackResolver(toolName -> null)
.toolExecutionExceptionProcessor(exceptionProcessor)
.build();
}
@Bean
public ChatClient agentChatClient(ChatModel chatModel) {
return ChatClient.builder(chatModel)
// defaultTools는 등록하지 않는다.
.build();
}
}
Spring AI 1.1.6에서는 자동 구성된 ChatModel이 애플리케이션의 ToolCallingManager를 사용해 Tool 실행 반복을 처리한다. 기본 실행기는 요청의 Callback에서 Tool을 찾지 못하면 ToolCallbackResolver를 조회한다. 따라서 전역 ToolCallback Bean을 함께 사용하는 애플리케이션에서는 .toolCallbacks(...)만 호출했다고 해서 요청 범위 Allowlist가 자동으로 보장되는 것은 아니다.
위 설정은 Resolver 제한을 해당 ChatModel을 공유하는 모든 ChatClient에 적용한다. 다른 흐름에서 이름 기반 Tool 탐색이 필요하다면, 제한된 ToolCallingManager를 사용하는 전용 ChatModel을 별도로 구성해야 한다.
4. Spring AI Outbound Adapter
AgentGateway 구현체에서 ChatClient와 ToolCallback을 사용한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// adapter/ai/client/SpringAiAgentGateway.java
@Component
public class SpringAiAgentGateway implements AgentGateway {
private final ChatClient chatClient;
private final SpringAiToolRegistry toolRegistry;
public SpringAiAgentGateway(
@Qualifier("agentChatClient") ChatClient chatClient,
SpringAiToolRegistry toolRegistry) {
this.chatClient = chatClient;
this.toolRegistry = toolRegistry;
}
@Override
public AgentAnswer ask(AgentRequest request) {
ToolCallback[] tools = toolRegistry.resolve(
request.allowedCapabilities()
);
String content = chatClient.prompt()
.user(request.message())
.toolCallbacks(tools)
.toolContext(Map.of("actor", request.actor()))
.call()
.content();
return new AgentAnswer(content);
}
}
전용 ChatClient에는 기본 Tool이 없고 전역 Resolver의 Fallback도 차단했다. 때문에 모델에는 허용된 Capability의 Tool Definition만 전달되고, 실행기도 요청에 포함된 ToolCallback만 사용한다.
반면 .defaultTools(...)는 동일한 ChatClient의 모든 요청에서 공유된다. 모든 사용자와 Agent가 같은 Tool을 사용할 때는 편리하지만, 권한별로 Tool이 달라지는 구조에서는 의도하지 않은 기능이 노출될 수 있다.
V. 변경된 계층 구조
1. 패키지 구조
최종 구조는 다음과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
order/
├── domain/
│ └── Order.java
├── application/
│ ├── order/
│ │ ├── port/in/RefundOrderUseCase.java
│ │ └── RefundOrderService.java
│ └── agent/
│ ├── AgentCapability.java
│ ├── AgentCapabilityPolicy.java
│ ├── ChatAgentService.java
│ └── port/out/AgentGateway.java
└── adapter/
├── http/
│ └── OrderController.java
└── ai/
├── tool/
│ └── RefundOrderTool.java
├── client/
│ └── SpringAiAgentGateway.java
└── config/
├── SpringAiAgentConfig.java
└── SpringAiToolRegistry.java
계층별 책임도 명확해졌다.
| 계층 | 책임 | 포함되는 타입 |
|---|---|---|
| Domain | 주문 상태와 환불 규칙 | Order |
| Application | UseCase, 권한 검증, Capability 정책 | RefundOrderUseCase, AgentGateway |
| HTTP Adapter | HTTP 요청을 Application Command로 변환 | OrderController |
| AI Integration Adapter | LLM 요청 변환, 모델 호출과 Tool 조합 | RefundOrderTool, ChatClient, SpringAiToolRegistry |
RefundOrderTool은 Inbound 역할을, SpringAiAgentGateway는 Outbound 역할을 담당한다. 다만 SpringAiToolRegistry가 두 객체를 조합하므로 물리적으로 완전히 분리된 Adapter라기보다 하나의 AI Integration Adapter 내부에서 방향별 책임을 나눈 구조다.
2. 런타임 흐름
전체 실행 흐름을 도식화하면 다음과 같다.
sequenceDiagram
actor User
participant HTTP as ChatController
participant App as ChatAgentService
participant AI as SpringAiAgentGateway
participant Client as ChatClient
participant Model as ChatModel
participant LLM
participant Manager as ToolCallingManager
participant Tool as RefundOrderTool
participant UseCase as RefundOrderUseCase
User->>HTTP: 주문 환불 요청
HTTP->>App: ChatCommand
App->>App: Capability 결정
App->>AI: AgentRequest
AI->>Client: Prompt + 허용된 ToolCallback
Client->>Model: Prompt
Model->>LLM: Prompt + Tool Definition
LLM-->>Model: refund_order Tool Call
Model->>Manager: executeToolCalls
Manager->>Tool: ToolCallback 실행
Tool->>UseCase: RefundOrderCommand
UseCase-->>Tool: RefundOrderResult
Tool-->>Manager: RefundToolResponse
Manager-->>Model: ToolExecutionResult
Model->>LLM: ToolResponseMessage
LLM-->>Model: 최종 응답
Model-->>Client: ChatResponse
Client-->>AI: 응답 본문
AI-->>App: AgentAnswer
App-->>HTTP: AgentAnswer
HTTP-->>User: HTTP Response
LLM이 애플리케이션에 별도의 네트워크 요청을 보내는 것은 아니다. 하나의 외부 Chat 요청을 처리하는 동안 여러 번의 LLM 왕복이 발생하고, 그 실행 흐름에서 Application 입력 포트가 다시 호출된다.
이는 순환 의존성이 아니다. Application은 AgentGateway와 RefundOrderUseCase라는 Port만 정의한다. 바깥쪽 AI Integration Adapter가 두 Port의 구현과 호출을 조합하고, 실제 객체 연결은 Spring의 Composition Root에서 이루어진다.
VI. Tool 노출과 권한 검증
1. Capability는 권한 검증을 대체하지 않는다
Capability Policy는 모델에 어떤 Tool Schema를 보여줄지 결정한다. 이는 모델의 선택지를 줄여 공격 면을 축소하지만, 최종 인가 경계는 아니다.
1
2
3
4
5
1차: Capability Policy
→ 이 요청의 모델에 refund_order Tool을 보여줄 것인가?
2차: RefundOrderUseCase
→ 인증된 사용자가 이 주문을 실제로 환불할 권한이 있는가?
모델이 Tool을 호출하지 못하게 숨기는 것과, 사용자가 비즈니스 기능을 실행할 권한이 있는지는 별개의 문제다. 상태를 변경하는 Tool은 반드시 UseCase에서 권한을 다시 확인해야 한다.
2. 트랜잭션 경계
LLM 호출 전체를 하나의 트랜잭션으로 묶으면 트랜잭션 자원을 오래 유지할 수 있다. 이미 SQL이 실행되거나 락을 획득한 상태라면 모델 응답을 기다리는 동안 DB 커넥션과 락의 점유 시간도 함께 늘어난다. 또한 한 번의 Chat 요청에서 Tool이 여러 번 호출될 수 있으므로 긴 트랜잭션은 재시도와 오류 복구를 어렵게 만든다.
1
2
3
4
5
6
7
8
9
10
11
// ❌ LLM 왕복 전체를 하나의 트랜잭션으로 관리
@Transactional
public AgentAnswer chat(ChatCommand command) {
return agentGateway.ask(...);
}
// ✅ 상태 변경이 발생하는 Command UseCase만 트랜잭션으로 관리
@Transactional
public RefundOrderResult execute(RefundOrderCommand command) {
// 권한 확인 → 상태 변경 → 저장
}
Tool이나 Controller는 트랜잭션을 시작하지 않고, 실제 상태를 변경하는 UseCase가 트랜잭션 경계를 가진다.
환불, 결제, 메시지 발송처럼 부작용이 있는 Tool에는 다음 항목도 함께 고려해야 한다.
- 동일 Tool Call의 중복 실행을 막는 Idempotency Key
- 누가 어떤 Tool을 실행했는지 남기는 Audit Log
- 외부 API 타임아웃과 재시도 정책
- 부분 실패 시 보상 또는 Outbox 처리
3. Tool 실행 오류 처리
기본 DefaultToolExecutionExceptionProcessor는 RuntimeException 메시지를 모델에 전달하고, Checked Exception과 Error는 호출자에게 던진다. 내부 식별자나 민감한 오류가 그대로 노출되지 않도록 예외 메시지를 정제해야 한다.
RuntimeException도 모델에 전달하지 않고 애플리케이션에서 직접 처리하려면 다음 설정을 사용할 수 있다.
1
2
3
4
5
# application.yml
spring:
ai:
tools:
throw-exception-on-error: true
이 설정은 오류를 정제하는 것이 아니라 호출자에게 다시 던지도록 동작을 변경한다. 모델에 안전한 오류를 전달하려면 ToolExecutionExceptionProcessor를 구현하여 공개할 메시지와 호출자에게 던질 오류를 직접 구분해야 한다.
4. 승인과 실행 반복 제어
조회 Tool은 자동 실행이 편리하지만, 결제·삭제처럼 위험한 Tool은 사람의 승인이 필요할 수 있다. Spring AI 1.1.6에서는 ToolCallingChatOptions의 내부 실행을 비활성화하고 ChatModel을 직접 호출하면 된다. 응답의 Tool Call을 승인한 뒤 동일한 ToolCallingManager로 실행 반복을 이어간다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// ChatModel과 ToolCallingManager를 주입받아 사용한다.
ToolCallingChatOptions options = ToolCallingChatOptions.builder()
.toolCallbacks(tools)
.toolContext(Map.of("actor", command.actor()))
.internalToolExecutionEnabled(false)
.build();
Prompt prompt = new Prompt(
List.of(new UserMessage(command.message())),
options
);
ChatResponse response = chatModel.call(prompt);
int iteration = 0;
while (response.hasToolCalls()) {
if (++iteration > MAX_TOOL_ITERATIONS) {
throw new IllegalStateException("Tool 호출 횟수를 초과했다");
}
// 한 응답에 여러 Tool Call이 포함될 수 있으므로 모두 확인한다.
approvalService.approveAll(
response.getResult().getOutput().getToolCalls(),
command.actor()
);
ToolExecutionResult execution = toolCallingManager.executeToolCalls(
prompt,
response
);
prompt = new Prompt(execution.conversationHistory(), options);
response = chatModel.call(prompt);
}
수동 실행에서는 내부 Tool 실행 비활성화와 Callback, ToolContext가 포함된 동일한 ToolCallingChatOptions를 매 반복에 유지해야 한다. 또한 ToolCallingManager는 한 응답에 포함된 Tool Call을 함께 실행하므로, 실행 전에 각 항목을 모두 승인해야 한다. 위 예제는 기본값인 returnDirect(false)를 전제로 하며, 결과를 즉시 반환하는 Tool을 사용한다면 execution.returnDirect() 분기도 추가해야 한다.
VII. 설계 적용 기준
모든 Agent에 Capability와 별도의 Registry가 필요한 것은 아니다. Tool 개수보다는 Tool 집합의 변동성과 실행 위험도를 기준으로 구조를 확장하는 것이 좋다.
| 상황 | 권장 구조 |
|---|---|
| 한 Agent가 고정된 읽기 Tool 사용 | .tools(toolObject) 직접 등록 |
| 사용자·Tenant·Agent별 Tool 노출이 다름 | AgentCapability와 Policy 도입 |
| Spring AI와 Application을 독립적으로 테스트해야 함 | AgentGateway Port 도입 |
| 여러 AI 프레임워크를 함께 지원 | 중립적인 AgentRequest, AgentAnswer 정의 |
| MCP·플러그인에서 Tool을 동적으로 공급 | Tool Registry 또는 Catalog 도입 |
| 결제·삭제처럼 위험한 작업 수행 | 승인, Audit, Idempotency 정책 도입 |
Tool이 적더라도 결제나 환불처럼 위험한 기능이면 Capability와 승인 정책이 필요하다. 반대로 읽기 전용 Tool이 많아도 모든 요청에 동일한 집합을 사용한다면 별도 Capability는 과도한 설계일 수 있다.
최소한 다음 경계는 유지하는 것이 좋다.
- Domain과 일반 UseCase에
@Tool,ToolContext를 선언하지 않는다. ChatClient,ToolCallback은 AI Adapter 내부에 둔다.- 인증 정보는 Tool 인자가 아니라
ToolContext로 전달한다. - Tool 노출과 실제 비즈니스 권한 검증을 분리한다.
- 상태 변경 Tool은 트랜잭션 경계, 중복 실행 방지와 감사 정책을 정의한다.
VIII. 결과
Spring AI의 @Tool을 Application Service에 바로 선언하면 코드는 빠르게 완성할 수 있다. 하지만 Tool이 늘어나고 사용자별 노출 정책이 생기면 LLM 계약과 Spring AI 타입이 Application 계층에 퍼지기 시작한다.
이번 구조의 핵심은 다음과 같다.
1
2
3
4
5
@Tool = LLM 요청을 번역하는 Inbound Adapter
RefundOrderUseCase = 채널과 무관한 Application 입력 포트
AgentCapability = Application이 이해하는 기능 단위
AgentGateway = 모델 호출을 추상화한 Outbound Port
ChatClient/Callback = Spring AI Adapter 내부 구현
HTTP Client와 LLM은 서로 다른 방식으로 요청하지만 동일한 UseCase를 호출한다. Application은 Tool 이름이나 JSON Schema를 알지 못하고, Adapter는 비즈니스 규칙을 직접 구현하지 않는다.
결국 중요한 것은 @Tool을 어느 폴더에 둘지 자체가 아니다. LLM 프로토콜, Tool 노출 정책과 비즈니스 규칙이 각각 어느 계층의 변경 이유인지 분리하는 것이 핵심이다.