지난 글에서는 RAG(Retrieval-Augmented Generation)를 통해 LLM이 외부 문서를 참조해서 답변하는 방법을 배웠다. 이번 글에서는 한 단계 더 나아간다.
이런 질문들을 생각해보자.
이 두 가지를 해결하는 것이 Advisor 패턴과 Function Calling이다.
서비스가 10개 있고, 모든 서비스에 로깅 코드를 직접 넣었다고 가정해보자. 로깅 형식을 바꿔야 한다면 10개 서비스를 전부 열어서 수정해야 한다. 한 곳이라도 빠뜨리면 로그 형식이 제각각이 된다.
@Transactional이 트랜잭션 관리 코드를 Service에서 분리했던 것처럼, Advisor는 LLM 호출과 관련된 공통 관심사(대화 기록, 필터링, 로깅 등)를 ChatClient 외부로 분리한다.
// ❌ Advisor 없이 — 모든 관심사가 Service에 섞여 있다
public String chat(String message) {
List<Message> history = chatMemory.get(conversationId); // 관심사 1
List<Document> docs = vectorStore.similaritySearch(message); // 관심사 2
String context = docs.stream()...collect(Collectors.joining());
String fullPrompt = "Context: " + context + "\nHistory: " + history + "\nQuestion: " + message;
String response = chatClient.call(fullPrompt);
chatMemory.add(conversationId, message, response); // 관심사 1 마무리
return response;
}
// ✅ Advisor 사용 — Service는 오직 "질문하고 답 받기"에만 집중
public String chat(String message) {
return chatClient.prompt()
.user(message)
.call()
.content();
}
Advisor는 LLM 호출 과정을 양파 껍질처럼 감싼다. 요청이 나갈 때는 바깥에서 안으로(before), 응답이 돌아올 때는 안에서 밖으로(after) 순서로 실행된다.
사용자 질문
↓
[Advisor1] before() ← 등록 순서대로
↓
[Advisor2] before()
↓
[Advisor3] before()
↓
LLM
↓
[Advisor3] after() ← 역순으로
↓
[Advisor2] after()
↓
[Advisor1] after()
↓
최종 응답
들어갈 때 순서의 반대로 나온다. @Transactional Proxy가 메서드를 감쌌던 것과 같은 구조다.
Advisor들이 서로 데이터를 주고받아야 할 때 사용하는 공유 바구니다. 단순한 Map<String, Object> 형태로, 체인을 따라 흘러다닌다.
// RAG Advisor가 찾은 문서를 Context에 담아두면
request.mutate()
.context("retrieved_docs", docs)
.build();
// 다음 로깅 Advisor가 꺼내서 기록할 수 있다
List<Document> docs = (List<Document>) response.context().get("retrieved_docs");
| 단계 | 활용 예시 |
|---|---|
before() | 이전 대화 기록을 프롬프트에 추가, 벡터 DB 검색 결과 삽입, 욕설 입력 차단 |
after() | 대화 기록 DB 저장, 토큰 사용량 로깅, 부적절한 응답 교체 |
PromptChatMemoryAdvisor
대화 기록을 하나의 텍스트 덩어리로 이어 붙여서 프롬프트에 포함시킨다. 구현이 단순하지만, 이미지 같은 미디어 데이터를 다루기 어렵다.
MessageChatMemoryAdvisor
대화 기록을 Message 객체 단위로 구조화해서 전달한다. 각 메시지의 역할(USER / ASSISTANT / SYSTEM)과 메타데이터가 보존된다. 멀티모달 대응이 가능하고, Prompt Injection 방어에도 유리하다.
// 두 Advisor의 차이
// PromptChatMemoryAdvisor — 텍스트를 이어 붙임
"User: 안녕 / Assistant: 안녕하세요 / User: 내 이름이 뭐야?"
// MessageChatMemoryAdvisor — 메시지 객체를 구조화해서 전달
[UserMessage("안녕"), AssistantMessage("안녕하세요"), UserMessage("내 이름이 뭐야?")]
SafeGuardAdvisor
입력과 출력을 두 번 검사한다. before()에서 유해한 질문을 LLM에 전달하기 전에 차단하고, after()에서 부적절한 응답을 안전한 문구로 교체한다.
유해한 질문 → [SafeGuardAdvisor before()] → 차단 → LLM 호출 안 함
정상 질문 → [SafeGuardAdvisor before()] → 통과 → LLM → [SafeGuardAdvisor after()] → 검증 후 전달
BaseAdvisor 인터페이스를 구현하면 된다. before()와 after() 두 메서드만 작성하면 Spring AI가 나머지 흐름을 자동으로 처리한다.
public class ChatMemoryAdvisor implements BaseAdvisor {
private final Map<String, List<Message>> conversationStore = new ConcurrentHashMap<>();
private final String conversationId;
private final int maxMessages;
@Override
public ChatClientRequest before(ChatClientRequest request, AdvisorChain chain) {
// 1. 이전 대화 기록 조회
List<Message> history = conversationStore.getOrDefault(conversationId, new CopyOnWriteArrayList<>());
// 2. 이전 기록 + 현재 질문을 합쳐서 프롬프트 재구성
List<Message> fullMessages = new ArrayList<>(history);
fullMessages.addAll(request.prompt().getInstructions());
// 3. 현재 질문을 Context에 저장 (after에서 꺼내 쓰기 위해)
String userText = request.prompt().getInstructions().stream()
.filter(m -> m.getMessageType() == MessageType.USER)
.map(Message::getText)
.findFirst().orElse("");
return request.mutate()
.prompt(new Prompt(fullMessages))
.context("user_text", userText)
.build();
}
@Override
public ChatClientResponse after(ChatClientResponse response, AdvisorChain chain) {
List<Message> history = conversationStore.computeIfAbsent(conversationId, k -> new CopyOnWriteArrayList<>());
// 1. before에서 저장한 질문 복구 후 저장
Optional.ofNullable(response.context().get("user_text"))
.map(Object::toString)
.filter(text -> !text.isBlank())
.ifPresent(text -> history.add(new UserMessage(text)));
// 2. LLM 응답 저장
if (response.chatResponse() != null && response.chatResponse().getResult() != null) {
var output = response.chatResponse().getResult().getOutput();
history.add(new AssistantMessage(output.getText(), output.getMetadata()));
}
// 3. 용량 초과 시 오래된 메시지 제거 (FIFO)
while (history.size() > maxMessages && !history.isEmpty()) {
history.remove(0);
}
return response;
}
}
before()에서 현재 질문을 Context에 저장하는 이유가 있다. after()가 실행될 시점에는 원본 요청 객체에 접근하기 어렵기 때문에, 미리 Context라는 공유 바구니에 담아두는 것이다.
LLM은 학습된 데이터를 바탕으로 텍스트를 생성한다. 그래서 다음 세 가지를 혼자서는 할 수 없다.
Function Calling은 이 한계를 뚫는다. LLM이 외부 도구(API, DB, 계산기 등)를 직접 호출할 수 있게 해주는 기술이다.
LLM이 직접 코드를 실행하는 것이 아니다. 어떤 함수를 어떤 파라미터로 실행해야 할지 판단하고, 실제 실행은 Spring(개발자 서버)이 담당한다.
사용자: "서울 날씨 알려줘"
↓
LLM: "getWeather("서울")을 호출해야겠다" (판단만)
↓
Spring: 실제 함수 실행
↓
Spring: 결과값을 다시 LLM에게 전달
↓
LLM: "서울은 현재 15도이고 맑습니다" (자연어로 변환)
↓
사용자
| 비교 | 일반 API 호출 | Function Calling |
|---|---|---|
| 호출 주체 | 개발자가 if-else로 명시 | LLM이 의도를 보고 자동 판단 |
| 유연성 | 정해진 키워드에서만 작동 | 다양한 표현을 문맥으로 이해 |
| 복잡한 의도 | 처리 어려움 | "비 오면 우산 챙길지 알려줘" 같은 복합 질문 처리 가능 |
1단계 — @Tool로 함수 선언
일반 Java 메서드에 @Tool을 붙이면 LLM이 쓸 수 있는 도구가 된다. description이 LLM의 사용 설명서 역할을 하므로 구체적으로 작성해야 한다. 설명이 애매하면 LLM이 엉뚱한 함수를 호출하거나 필요한 함수를 찾지 못할 수 있다.
@Service
public class FunctionTools {
@Tool(description = "특정 도시의 현재 날씨 정보를 조회합니다")
public WeatherResponse getWeather(WeatherRequest request) {
// 실제 날씨 API 호출
return WeatherResponse.builder()
.city(request.getCity())
.temperature(15)
.condition("맑음")
.build();
}
@Tool(description = "두 숫자의 사칙연산을 수행합니다")
public CalculatorResponse calculator(CalculatorRequest request) {
double result = switch (request.getOperation()) {
case "add" -> request.getA() + request.getB();
case "subtract" -> request.getA() - request.getB();
case "multiply" -> request.getA() * request.getB();
case "divide" -> request.getA() / request.getB();
default -> throw new IllegalArgumentException("지원하지 않는 연산");
};
return CalculatorResponse.builder().result(result).build();
}
@Tool(description = "현재 날짜와 시간을 반환합니다")
public CurrentTimeResponse getCurrentTime() {
LocalDateTime now = LocalDateTime.now();
return CurrentTimeResponse.builder()
.readableFormat(now.format(DateTimeFormatter.ofPattern("yyyy년 MM월 dd일 HH시 mm분")))
.build();
}
}
2단계 — .tools()로 ChatClient에 전달
@Service
@RequiredArgsConstructor
public class FunctionCallingService {
private final ChatClient.Builder clientBuilder;
private final FunctionTools functionTools;
public String chat(String userMessage) {
return clientBuilder.build()
.prompt()
.user(userMessage)
.tools(functionTools) // 도구 상자 전달
.call()
.content();
}
}
특정 도구만 활성화하기
// 날씨 도구만 사용하도록 제한
chatClient.prompt()
.toolNames("getWeather")
.call();
LLM은 사용자의 자연어 문장에서 함수에 필요한 인자를 뽑아낸다. DTO 필드명과 타입을 명확하게 정의하는 것이 중요하다.
// 날씨 요청 DTO — LLM이 "서울 날씨"에서 city = "서울"을 추출
@Getter
@NoArgsConstructor
public class WeatherRequest {
private String city;
}
// 계산기 요청 DTO — LLM이 "253 곱하기 47"에서 a=253, operation="multiply", b=47을 추출
@Getter
public class CalculatorRequest {
private double a;
private double b;
private String operation;
}
Advisor 패턴은 LLM 호출의 공통 관심사를 분리해서 Service 코드를 깔끔하게 유지한다. Function Calling은 LLM이 판단하고 Spring이 실행하는 구조로, LLM의 실시간 정보 부재와 외부 시스템 고립 문제를 해결한다.
두 개념의 관계를 정리하면: