[Spring AI] Spring AI Advisor 패턴과 Function Calling

Raha·2026년 4월 10일

Spring AI

목록 보기
7/8

들어가며

지난 글에서는 RAG(Retrieval-Augmented Generation)를 통해 LLM이 외부 문서를 참조해서 답변하는 방법을 배웠다. 이번 글에서는 한 단계 더 나아간다.

이런 질문들을 생각해보자.

  • 대화 기록 저장, 욕설 필터링 같은 공통 로직을 매번 Service에 직접 넣어야 할까?
  • LLM은 "지금 서울 날씨"처럼 실시간 정보를 어떻게 가져올 수 있을까?
  • LLM이 직접 외부 API를 호출할 수 있을까?

이 두 가지를 해결하는 것이 Advisor 패턴Function Calling이다.


1. Advisor 패턴

왜 필요한가

서비스가 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가 메서드를 감쌌던 것과 같은 구조다.

Context — Advisor 간 공유 저장소

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()와 after()에서 할 수 있는 일

단계활용 예시
before()이전 대화 기록을 프롬프트에 추가, 벡터 DB 검색 결과 삽입, 욕설 입력 차단
after()대화 기록 DB 저장, 토큰 사용량 로깅, 부적절한 응답 교체

내장 Advisor 3가지

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()] → 검증 후 전달

커스텀 Advisor 구현

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라는 공유 바구니에 담아두는 것이다.


2. Function Calling

LLM의 한계

LLM은 학습된 데이터를 바탕으로 텍스트를 생성한다. 그래서 다음 세 가지를 혼자서는 할 수 없다.

  • 실시간 정보 — 날씨, 주가, 뉴스 (학습 데이터 커트라인 이후)
  • 사내 데이터 — 회사 내부 DB는 학습된 적이 없다
  • 실제 행동 — 이메일 발송, 결제 처리, DB 저장

Function Calling은 이 한계를 뚫는다. LLM이 외부 도구(API, DB, 계산기 등)를 직접 호출할 수 있게 해주는 기술이다.

중요한 포인트 — LLM은 판단만 한다

LLM이 직접 코드를 실행하는 것이 아니다. 어떤 함수를 어떤 파라미터로 실행해야 할지 판단하고, 실제 실행은 Spring(개발자 서버)이 담당한다.

사용자: "서울 날씨 알려줘"
    ↓
LLM: "getWeather("서울")을 호출해야겠다" (판단만)
    ↓
Spring: 실제 함수 실행
    ↓
Spring: 결과값을 다시 LLM에게 전달
    ↓
LLM: "서울은 현재 15도이고 맑습니다" (자연어로 변환)
    ↓
사용자
비교일반 API 호출Function Calling
호출 주체개발자가 if-else로 명시LLM이 의도를 보고 자동 판단
유연성정해진 키워드에서만 작동다양한 표현을 문맥으로 이해
복잡한 의도처리 어려움"비 오면 우산 챙길지 알려줘" 같은 복합 질문 처리 가능

Spring AI 구현

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();

DTO 설계 — LLM이 파라미터를 추출하는 방식

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의 실시간 정보 부재와 외부 시스템 고립 문제를 해결한다.

두 개념의 관계를 정리하면:

  • Advisor — LLM 호출 흐름을 감싸서 앞뒤를 제어
  • Function Calling — LLM이 외부 세계와 상호작용할 수 있도록 손과 발을 달아줌
profile
Backend Developer | Aspiring Full-Stack Enthusiast

0개의 댓글