지난 글에서 Spring AI의 기본 구조와 ChatClient를 통해 LLM에 질문을 던지는 방법을 살펴봤다.
그런데 막상 챗봇을 만들려고 하면 이런 의문이 생긴다.
이번 글에서는 이 네 가지 질문을 중심으로, LLM과의 대화를 설계하는 핵심 개념들을 정리한다.
가장 먼저 이 사실을 받아들여야 한다. LLM은 요청을 받고 응답을 돌려주는 순간, 그 내용을 전혀 기억하지 않는다. 다음 요청이 들어오면 완전히 새로운 대화로 인식한다.
그렇다면 ChatGPT나 Claude가 이전 대화를 기억하는 것처럼 느껴지는 이유는 무엇일까? 답은 간단하다. 매 요청마다 이전 대화 내용을 통째로 함께 보내기 때문이다.
LLM과의 대화에는 세 가지 메시지 타입이 존재한다.
System : AI의 역할과 규칙 정의 (사용자에게 보이지 않음)
User : 사용자의 질문
Assistant: AI의 이전 응답 (다음 요청 시 히스토리로 포함)
대화가 쌓이면 요청 구조는 다음과 같이 눈덩이처럼 불어난다.
1회차: [System] + [User 1]
2회차: [System] + [User 1] + [Assistant 1] + [User 2]
3회차: [System] + [User 1] + [Assistant 1] + [User 2] + [Assistant 2] + [User 3]
코드로 표현하면 이렇다.
// AI는 history 전체를 읽고 나서 User 3에 답변한다
ChatResponse response = chatClient.prompt()
.messages(history) // 지금까지의 모든 대화
.call()
.chatResponse();
// AI 응답을 다시 히스토리에 추가
history.add(new AssistantMessage(response.getResult().getOutput().getText()));
대화가 100번, 1000번 쌓이면 매 요청마다 엄청난 양의 텍스트를 AI에게 읽어줘야 한다. 문제는 세 가지다.
비용 폭주: LLM API는 토큰 단위로 과금한다. 누적된 히스토리가 길수록 입력 토큰이 늘어나고, 비용이 기하급수적으로 증가한다.
응답 지연: AI가 읽어야 할 내용이 많을수록 응답 생성 시간이 길어진다.
주의력 분산: Context가 너무 길면 AI가 중간에 있는 정보를 무시하는 경향이 생긴다. 오히려 답변 품질이 떨어질 수 있다.
가장 최근 N개의 메시지만 AI에게 전달하는 방식이다. 전체 대화는 DB에 저장하되, AI에게는 최근 것만 보여준다.
전체 히스토리: [1][2][3]...[98][99][100]
AI에게 전달: [91][92]...[99][100] + [새 질문]
private List<Message> getRecentMessages(List<Message> history, int max) {
if (history.size() <= max) return history;
return history.subList(history.size() - max, history.size()); // 최근 N개만
}
구현이 단순하고 토큰 사용량이 일정하게 유지된다는 장점이 있다. 다만 오래된 맥락이 잘릴 수 있다.
오래된 대화를 AI를 통해 짧게 요약하고, 최근 대화만 원본으로 유지하는 방식이다.
[오래된 대화 20개] → AI가 요약 → "유저는 Spring AI에 대해 물어봤고, 감정 분석 API 구현을 논의했다."
[최근 대화 10개] → 원본 유지
String summary = chatClient.prompt()
.user("다음 대화를 200자 이내로 요약해: " + convertToText(oldHistory))
.call().content();
// 요약본을 System Message로 교체
optimized.add(new SystemMessage("이전 대화 요약: " + summary));
optimized.addAll(recentHistory);
토큰 절약 효과가 크지만, 요약 과정에서 중요한 세부 정보가 손실될 수 있다.
모든 메시지를 동등하게 취급하지 않고, 비즈니스적으로 중요한 메시지를 선별하여 보내는 방식이다.
private boolean isImportant(Message msg) {
String content = msg.getText().toLowerCase();
// 도메인에 따라 중요 키워드가 달라진다
return content.contains("결제") || content.contains("주소") || content.length() > 100;
}
이 전략의 핵심 난이도는 구현 자체가 아니다. "무엇이 중요한가"를 정의하는 것이 어렵다. 쇼핑몰이라면 결제/배송이 중요하고, 의료 챗봇이라면 증상/약 이름이 중요하다. 도메인마다 기준이 완전히 달라지기 때문에, 중요도 정의 설계가 곧 이 전략의 성패를 가른다.
| 전략 | 장점 | 단점 |
|---|---|---|
| 슬라이딩 윈도우 | 구현 단순, 비용 예측 가능 | 오래된 맥락 손실 |
| 요약 기반 | 토큰 절약 효과 큼 | 세부 정보 손실 가능 |
| 중요도 기반 | 핵심 정보 보존 | 중요도 정의가 어려움 |
LLM API는 토큰 단위로 과금한다. 토큰은 AI가 텍스트를 처리하는 기본 단위로, 영어는 약 0.75단어당 1토큰, 한글은 한 글자당 약 1~2토큰이다.
과금 구조는 입력과 출력으로 나뉜다.
총 비용 = (입력 토큰 수 × 입력 단가) + (출력 토큰 수 × 출력 단가)
주의할 점은 출력 단가가 입력 단가보다 약 3~5배 비싸다는 것이다. 짧고 명확한 답변을 유도하는 것이 비용 측면에서도 중요하다.
Spring AI에서는 ChatResponse에서 토큰 사용량을 확인할 수 있다.
ChatResponse response = chatClient.prompt()
.user(question)
.call()
.chatResponse();
var usage = response.getMetadata().getUsage();
log.info("입력: {}, 출력: {}, 합계: {}",
usage.getPromptTokens(),
usage.getCompletionTokens(),
usage.getTotalTokens());
System Message는 모든 요청마다 포함된다. 한 번 줄이면 호출 횟수만큼 곱해서 절약된다.
// Before: 약 150 토큰
"당신은 매우 친절하고 상냥하며 사용자를 배려하는 AI 어시스턴트입니다.
사용자의 질문에 항상 정중하고 예의바르게 답변해야 하며..."
// After: 약 15 토큰
"친절한 AI 어시스턴트. 명확하고 간결하게 답변."
1000회 호출 기준으로 135,000 토큰 절약, 약 90% 감소 효과다.
모든 질문에 동일한 maxTokens를 설정하면 낭비가 생긴다. 질문의 의도에 따라 필요한 만큼만 할당한다.
private int determineMaxTokens(String question) {
String q = question.toLowerCase();
if (q.contains("코드") || q.contains("구현")) return 2000; // 코드 생성
if (q.contains("설명") || q.contains("알려줘")) return 500; // 정보 요약
if (question.length() < 30) return 100; // 단답형
return 1000; // 기본값
}
단답형 질문 100개 처리 시, 고정 방식(maxTokens=2000) 대비 동적 방식(maxTokens=100)은 약 95% 비용 절감 효과를 기대할 수 있다.
AI가 답변을 생성하는 데 10초가 걸린다고 가정해보자.
call() 방식: 10초가 지난 뒤 완성된 답변을 한 번에 전달한다. 사용자는 10초 동안 아무것도 볼 수 없다.stream() 방식: 단어가 생성되는 즉시 실시간으로 전달한다. 사용자는 첫 단어부터 바로 읽기 시작한다.실제 처리 시간은 동일하지만 체감이 완전히 달라진다. ChatGPT나 Claude가 타이핑하듯 답변을 보여주는 것이 바로 이 방식이다.
Spring AI에서 스트리밍은 Flux<String>과 SSE를 조합해서 구현한다.
// Controller
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChat(@RequestBody ContextChatRequest request) {
return chatService.chatStream(request.getMessage());
}
// Service
public Flux<String> chatStream(String question) {
return chatClient.prompt()
.user(question)
.stream() // call() 대신 stream() 사용
.content();
}
여기서 두 가지 핵심 개념이 등장한다.
Flux\<String>
Project Reactor 라이브러리의 타입으로, 데이터 조각이 시간 순서대로 흘러오는 스트림을 표현한다. String이 완성된 답변 하나를 담는다면, Flux<String>은 조각들이 컨베이어 벨트처럼 계속 흘러오는 구조다.
String : [완성된 전체 답변] → 한 번에 전달
Flux<String> : [안] [녕] [하] [세] [요] → 생성될 때마다 즉시 전달
Reactor에는 두 가지 핵심 타입이 있다.
Mono<T> : 0개 또는 1개의 데이터를 비동기로 처리 (call() 방식에 대응)
Flux<T> : 0개 ~ N개의 데이터를 비동기로 처리 (stream() 방식에 대응)
SSE (Server-Sent Events)
TEXT_EVENT_STREAM_VALUE는 HTTP 통신 방식 중 SSE를 의미한다. 일반 HTTP와의 차이는 연결 유지 방식에 있다.
일반 HTTP: 클라이언트 요청 → 서버 응답 → 연결 끊김 (1회성)
SSE: 클라이언트 요청 → 서버가 연결을 유지하며 데이터를 계속 흘려보냄
비슷한 기술로 WebSocket이 있지만, AI 스트리밍에는 SSE가 더 적합하다. AI 응답은 서버에서 클라이언트로 단방향으로만 흐르면 충분하기 때문이다. WebSocket은 양방향 통신이 필요한 경우(채팅, 게임 등)에 사용한다.
| 방식 | 방향 | 연결 유지 | 사용 사례 |
|---|---|---|---|
| 일반 HTTP | 단방향 | X | 일반 API |
| SSE | 단방향 (서버 → 클라이언트) | O | AI 스트리밍, 알림 |
| WebSocket | 양방향 | O | 실시간 채팅, 게임 |
감정 분석 API를 만든다고 가정해보자. System Message로 이렇게 지시할 수 있다.
return chatClient.prompt()
.system("positive, neutral, negative 중 하나로만 답변하세요.")
.user(text)
.call()
.content();
문제는 아무리 강하게 지시해도 AI가 다른 형태로 응답할 가능성이 항상 존재한다는 것이다.
"positive" <- 정상
"This is positive." <- 문장으로 옴
"긍정적입니다" <- 한국어로 옴
"POSITIVE" <- 대소문자가 다름
이를 문자열 파싱으로 방어하면 예외 케이스가 생길 때마다 방어 코드를 계속 추가해야 한다.
// 문자열 파싱 방식 - 방어 로직이 계속 늘어난다
String result = response.toLowerCase().trim();
if (result.contains("positive")) return "positive";
if (result.contains("negative")) return "negative";
return "neutral";
더 나은 방법은 Structured Output이다. AI가 처음부터 Java 객체 형태로 응답하게 만드는 방식으로, 응답 구조가 항상 동일할 때 특히 효과적이다.
// 응답 구조를 클래스로 정의
public record SentimentResult(
String sentiment, // positive / neutral / negative
double confidence // 0.0 ~ 1.0
) {}
// Before: String으로 받아서 파싱
String raw = chatClient.prompt().user(text).call().content();
// 파싱 로직 필요...
// After: Java 객체로 바로 받기
SentimentResult result = chatClient.prompt()
.user(text)
.call()
.entity(SentimentResult.class); // 구조를 강제
result.sentiment(); // "positive"
result.confidence(); // 0.95
entity()를 사용하면 Spring AI가 내부적으로 AI에게 JSON 형식으로 응답하도록 지시하고, 응답을 자동으로 지정한 클래스로 변환해준다. 파싱 로직도, 방어 코드도 필요 없다.
LLM은 기억이 없고, 히스토리 관리와 토큰 최적화는 실무에서 반드시 고려해야 할 설계 문제다. 스트리밍으로 사용자 체감 속도를 높이고, Structured Output으로 응답 형식을 안정적으로 제어하면 완성도 높은 AI 서비스를 만들 수 있다.