
다음 주 프로젝트 시작을 앞두고 Spring AI 강의를 들으면서 정리한 내용이에요.
아직 예제를 직접 다 만들어보진 않았지만, 개념을 확실히 잡고 가려고 정리했어요.
결론부터 말하면 실무에서는 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();
| 구분 | ChatModel | ChatClient |
|---|---|---|
| 레벨 | 저수준 (Low-level) | 고수준 (High-level) |
| 주요 용도 | 단순 질문-답변 | 챗봇, RAG, 에이전트 |
| 대화 기억 | 직접 구현해야 함 | Advisor로 자동 처리 |
| 권장 여부 | 특별한 경우만 | 일반적으로 이걸 써요 |
서비스 클래스 생성자에서 한 번만 만들어두고 재사용해요.
@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()체인에서 따로 지정하면 돼요.
| 구분 | 메서드 | 반환 타입 | 언제 쓰나 |
|---|---|---|---|
| 동기 | .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>으로 하고, produces에 APPLICATION_NDJSON_VALUE를 지정하는 패턴을 기억해두면 좋아요.
LLM에 전달되는 메시지는 역할에 따라 3가지로 나뉘어요.
new SystemMessage("AI의 역할, 말투, 제약 정의") // 페르소나
new UserMessage("사용자 질문") // 입력
new AssistantMessage("이전 AI 답변") // 대화 기억에 사용
| 메시지 종류 | 역할 | 생성 시점 |
|---|---|---|
| SystemMessage | AI 역할, 말투, 제약 정의 | LLM 요청 전 |
| UserMessage | 사용자 입력 | LLM 요청 전 |
| AssistantMessage | AI 답변, 대화 기억 유지 | 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();
Advisor는 LLM 요청 전후에 끼어드는 인터셉터예요.
Spring의 AOP나 Web 인터셉터와 비슷한 개념이에요.
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를 순서대로 실행할 수 있어요.
getOrder() 값이 낮을수록 먼저 실행돼요.
chatClient = builder
.defaultAdvisors(
new SafeGuardAdvisor(...), // 1순위 — 먼저 차단 검사
MessageChatMemoryAdvisor.builder(chatMemory).build(), // 2순위 — 대화 기억 추가
new SimpleLoggerAdvisor(Ordered.LOWEST_PRECEDENCE - 1) // 마지막 — 로깅
)
.build();
LLM은 기본적으로 이전 대화를 기억하지 못해요.
ChatMemory와 Advisor를 조합해서 대화 기억을 구현해요.
사용자별로 대화를 분리하려면 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 | 서버 메모리에 저장 | 개발 초반, 프로토타입 |
| JdbcChatMemoryRepository | RDBMS에 영구 저장 | 실서비스 |
| CassandraChatMemoryRepository | NoSQL, TTL 지원 | 대규모 서비스 |
| VectorStoreChatMemoryAdvisor | 벡터DB, 유사도 검색 | 방대한 대화 기록 |
처음엔 InMemory로 시작하고 나중에 JDBC로 교체하면 돼요. 코드 변경이 거의 없어요.
LLM 응답을 Java 객체로 바로 받을 수 있어요.
데이터를 담는 용도로만 쓰는 클래스의 축약 버전이에요.
생성자, 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) {}
// 단일 객체
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>>() {});
Spring AI 강의 코드를 보다가 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은 스프링이 잡아주는 게 아니라 자바 문법 자체가 잡아주는 거예요.