Spring AI 기초 정리 — ChatClient, Advisor, 대화 기억까지

한소연·2026년 3월 22일

내일배움캠프

목록 보기
12/15
post-thumbnail

다음 주 프로젝트 시작을 앞두고 Spring AI 강의를 들으면서 정리한 내용이에요.
아직 예제를 직접 다 만들어보진 않았지만, 개념을 확실히 잡고 가려고 정리했어요.


목차

  1. ChatModel vs ChatClient
  2. ChatClient 생성 방법
  3. 동기 vs 스트리밍 응답
  4. 메시지 3종류
  5. Advisor
  6. 대화 기억 (ChatMemory)
  7. 구조화된 출력 (record, entity)
  8. final 개념 정리

1. ChatModel vs ChatClient

결론부터 말하면 실무에서는 ChatClient를 써요.

ChatModel은 저수준 API고, ChatClient는 그걸 감싼 고수준 API예요.
ChatModel을 직접 쓰면 응답 객체를 직접 꺼내야 해서 코드가 길어져요.

// ChatModel 방식 — 저수준, 잘 안 씀
ChatResponse response = chatModel.call(prompt);
String text = response.getResult().getOutput().getText();

// ChatClient 방식 — 실무에서 이걸 써요
String answer = chatClient.prompt()
    .system("역할 지정")
    .user("사용자 질문")
    .call()
    .content();
구분ChatModelChatClient
레벨저수준 (Low-level)고수준 (High-level)
주요 용도단순 질문-답변챗봇, RAG, 에이전트
대화 기억직접 구현해야 함Advisor로 자동 처리
권장 여부특별한 경우만일반적으로 이걸 써요

2. ChatClient 생성 방법

서비스 클래스 생성자에서 한 번만 만들어두고 재사용해요.

@Service
public class MyService {
    private final ChatClient chatClient;

    public MyService(ChatClient.Builder builder) {
        this.chatClient = builder
            .defaultSystem("기본 역할 지정")   // 모든 요청에 공통 적용
            .defaultOptions(ChatOptions.builder()
                .model("gpt-4o-mini")
                .temperature(0.7)
                .maxTokens(1000)
                .build())
            .build();
    }
}

defaultSystem()defaultOptions()는 모든 요청에 공통으로 적용돼요.
요청마다 다른 설정이 필요하면 .prompt() 체인에서 따로 지정하면 돼요.


3. 동기 vs 스트리밍 응답

구분메서드반환 타입언제 쓰나
동기.call().content()String분석, 분류, 백엔드 로직
스트리밍.stream().content()Flux<String>챗봇 UI, 실시간 출력
// 동기 방식
String answer = chatClient.prompt()
    .user("질문")
    .call()
    .content();

// 스트리밍 방식
Flux<String> stream = chatClient.prompt()
    .user("질문")
    .stream()
    .content();

챗봇 UI처럼 실시간으로 글자가 나오는 걸 구현할 때는 스트리밍을 써요.
Controller에서 반환 타입을 Flux<String>으로 하고, producesAPPLICATION_NDJSON_VALUE를 지정하는 패턴을 기억해두면 좋아요.


4. 메시지 3종류

LLM에 전달되는 메시지는 역할에 따라 3가지로 나뉘어요.

new SystemMessage("AI의 역할, 말투, 제약 정의")  // 페르소나
new UserMessage("사용자 질문")                    // 입력
new AssistantMessage("이전 AI 답변")              // 대화 기억에 사용
메시지 종류역할생성 시점
SystemMessageAI 역할, 말투, 제약 정의LLM 요청 전
UserMessage사용자 입력LLM 요청 전
AssistantMessageAI 답변, 대화 기억 유지LLM 요청 후

AssistantMessage는 대화 기억을 구현할 때 이전 답변을 프롬프트에 포함시키는 용도로 써요.
Few-shot 예시를 줄 때도 UserMessage → AssistantMessage 쌍으로 넣어요.

// Few-shot 예시 패턴
chatClient.prompt()
    .messages(
        new SystemMessage("당신은 리뷰 감정 분석가입니다."),
        new UserMessage("이 제품 정말 최고예요!"),
        new AssistantMessage("{\"sentiment\": \"positive\"}"),  // 예시 답변
        new UserMessage("별로네요. 품질이 떨어집니다."),
        new AssistantMessage("{\"sentiment\": \"negative\"}"),  // 예시 답변
        new UserMessage("실제 분석할 리뷰")                    // 진짜 질문
    )
    .call()
    .content();

5. Advisor

Advisor는 LLM 요청 전후에 끼어드는 인터셉터예요.
Spring의 AOP나 Web 인터셉터와 비슷한 개념이에요.

주요 역할

  • 전처리: 프롬프트에 대화 기억, 검색 결과 등을 자동으로 추가
  • 후처리: 응답 로깅, 필터링, 저장

자주 쓰는 Advisor 3가지

1. SimpleLoggerAdvisor — 개발할 때 항상 달아두세요

// 실제로 LLM에 뭐가 넘어가는지 볼 수 있어요
chatClient = builder
    .defaultAdvisors(
        new SimpleLoggerAdvisor(Ordered.LOWEST_PRECEDENCE - 1) // 항상 마지막에
    )
    .build();

로그 레벨 설정도 필요해요.

# application.yaml
logging:
  level:
    org.springframework.ai.chat.client.advisor: DEBUG

2. MessageChatMemoryAdvisor — 대화 기억의 핵심

chatClient = builder
    .defaultAdvisors(
        MessageChatMemoryAdvisor.builder(chatMemory).build()
    )
    .build();

이걸 붙이면 이전 대화를 자동으로 프롬프트에 포함시켜줘요.

3. SafeGuardAdvisor — 민감한 단어 차단

SafeGuardAdvisor safeGuardAdvisor = new SafeGuardAdvisor(
    List.of("욕설", "폭력", "폭탄"),
    "해당 질문은 답변할 수 없습니다.",
    Ordered.HIGHEST_PRECEDENCE
);

Advisor 체이닝

여러 Advisor를 순서대로 실행할 수 있어요.
getOrder() 값이 낮을수록 먼저 실행돼요.

chatClient = builder
    .defaultAdvisors(
        new SafeGuardAdvisor(...),           // 1순위 — 먼저 차단 검사
        MessageChatMemoryAdvisor.builder(chatMemory).build(), // 2순위 — 대화 기억 추가
        new SimpleLoggerAdvisor(Ordered.LOWEST_PRECEDENCE - 1) // 마지막 — 로깅
    )
    .build();

6. 대화 기억 (ChatMemory)

LLM은 기본적으로 이전 대화를 기억하지 못해요.
ChatMemoryAdvisor를 조합해서 대화 기억을 구현해요.

conversationId 패턴

사용자별로 대화를 분리하려면 conversationId가 필요해요.
보통 HTTP 세션 ID를 써요.

// Controller
@PostMapping
public String chat(@RequestBody String question, HttpSession session) {
    return service.chat(question, session.getId());
}

// Service
public String chat(String question, String conversationId) {
    return chatClient.prompt()
        .user(question)
        .advisors(spec -> spec.param(ChatMemory.CONVERSATION_ID, conversationId))
        .call()
        .content();
}

저장소 종류

저장소특징언제 쓰나
InMemoryChatMemoryRepository서버 메모리에 저장개발 초반, 프로토타입
JdbcChatMemoryRepositoryRDBMS에 영구 저장실서비스
CassandraChatMemoryRepositoryNoSQL, TTL 지원대규모 서비스
VectorStoreChatMemoryAdvisor벡터DB, 유사도 검색방대한 대화 기록

처음엔 InMemory로 시작하고 나중에 JDBC로 교체하면 돼요. 코드 변경이 거의 없어요.


7. 구조화된 출력 (record, entity)

LLM 응답을 Java 객체로 바로 받을 수 있어요.

record란?

데이터를 담는 용도로만 쓰는 클래스의 축약 버전이에요.
생성자, getter를 자바가 자동으로 만들어줘요.

// 일반 클래스로 쓰면 이렇게 길어요
public class University {
    private String city;
    private List<String> names;
    
    public University(String city, List<String> names) {
        this.city = city;
        this.names = names;
    }
    public String getCity() { return city; }
    public List<String> getNames() { return names; }
}

// record로 쓰면 한 줄이에요
public record University(String city, List<String> names) {}

entity()로 객체 바로 받기

// 단일 객체
University university = chatClient.prompt()
    .user("인천 대학교 5개 알려줘")
    .call()
    .entity(University.class);

System.out.println(university.city());  // "인천"
System.out.println(university.names()); // ["인하대", "인천대" ...]

// 리스트로 받기
List<University> universities = chatClient.prompt()
    .user("서울, 인천, 부산 대학교 5개씩 알려줘")
    .call()
    .entity(new ParameterizedTypeReference<List<University>>() {});

8. final 개념 정리

Spring AI 강의 코드를 보다가 final이 왜 붙는지 궁금해서 정리했어요.

final이란?

변수에 값을 딱 한 번만 할당할 수 있게 하는 키워드예요.

@Service
public class MyService {
    private final ChatClient chatClient; // final O
    
    public MyService(ChatClient.Builder builder) {
        this.chatClient = builder.build(); // 딱 한 번만 할당 가능
    }
    
    public void someMethod() {
        this.chatClient = null; // 컴파일 에러 — 실행도 안 됨
        // "Cannot assign a value to final variable 'chatClient'"
    }
}

왜 쓰냐

@Service를 붙이면 스프링이 그 클래스의 객체를 딱 하나만 만들어요 (싱글톤).
앱이 시작될 때 생성자가 한 번 호출되고, chatClient가 세팅돼요.
이 값은 앱이 꺼질 때까지 절대 바뀌면 안 돼요.

// final 없으면 — 실수로 덮어써도 자바가 못 잡아줌
public String chat(String question) {
    this.chatClient = null; // 실수! 근데 에러 안 남
    return chatClient.prompt()... // 여기서 NullPointerException 터짐
}

// final 있으면 — 코드 짜는 순간에 바로 잡아줌
public String chat(String question) {
    this.chatClient = null; // 빨간줄 — 빌드 자체가 안 됨
}

에러가 터지는 시점

시점설명
코드 작성할 때IDE에서 빨간줄이 바로 떠요
컴파일할 때빌드 자체가 안 돼요
실행할 때컴파일이 안 됐으니 실행도 안 돼요

final은 스프링이 잡아주는 게 아니라 자바 문법 자체가 잡아주는 거예요.


참고 자료

  • 팀스파르타 Spring AI 강의 (ChatModel, ChatOptions, Prompt, ChatClient / 프롬프트 엔지니어링 / Advisor / 대화기억)
profile
안 되면 될 때까지

0개의 댓글