[SpringAI] LLM과 제대로 대화하기: Context, History, Token, Streaming

Raha·5일 전

SpringAI

목록 보기
2/5

들어가며

지난 글에서 Spring AI의 기본 구조와 ChatClient를 통해 LLM에 질문을 던지는 방법을 살펴봤다.
그런데 막상 챗봇을 만들려고 하면 이런 의문이 생긴다.

  • AI는 내가 아까 뭐라고 했는지 기억하는가?
  • 대화가 길어질수록 비용이 얼마나 늘어나는가?
  • AI 응답을 실시간으로 보여주려면 어떻게 해야 하는가?
  • AI가 항상 정해진 형식으로만 응답하게 만들 수 있는가?

이번 글에서는 이 네 가지 질문을 중심으로, LLM과의 대화를 설계하는 핵심 개념들을 정리한다.


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

2. 히스토리가 쌓이면 생기는 문제

대화가 100번, 1000번 쌓이면 매 요청마다 엄청난 양의 텍스트를 AI에게 읽어줘야 한다. 문제는 세 가지다.

비용 폭주: LLM API는 토큰 단위로 과금한다. 누적된 히스토리가 길수록 입력 토큰이 늘어나고, 비용이 기하급수적으로 증가한다.

응답 지연: AI가 읽어야 할 내용이 많을수록 응답 생성 시간이 길어진다.

주의력 분산: Context가 너무 길면 AI가 중간에 있는 정보를 무시하는 경향이 생긴다. 오히려 답변 품질이 떨어질 수 있다.


3. 히스토리 관리 전략 3가지

전략 A: 슬라이딩 윈도우 (Sliding Window)

가장 최근 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개만
}

구현이 단순하고 토큰 사용량이 일정하게 유지된다는 장점이 있다. 다만 오래된 맥락이 잘릴 수 있다.

전략 B: 요약 기반 압축 (Summarization)

오래된 대화를 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);

토큰 절약 효과가 크지만, 요약 과정에서 중요한 세부 정보가 손실될 수 있다.

전략 C: 중요도 기반 필터링 (Smart Filtering)

모든 메시지를 동등하게 취급하지 않고, 비즈니스적으로 중요한 메시지를 선별하여 보내는 방식이다.

private boolean isImportant(Message msg) {
    String content = msg.getText().toLowerCase();
    // 도메인에 따라 중요 키워드가 달라진다
    return content.contains("결제") || content.contains("주소") || content.length() > 100;
}

이 전략의 핵심 난이도는 구현 자체가 아니다. "무엇이 중요한가"를 정의하는 것이 어렵다. 쇼핑몰이라면 결제/배송이 중요하고, 의료 챗봇이라면 증상/약 이름이 중요하다. 도메인마다 기준이 완전히 달라지기 때문에, 중요도 정의 설계가 곧 이 전략의 성패를 가른다.

전략장점단점
슬라이딩 윈도우구현 단순, 비용 예측 가능오래된 맥락 손실
요약 기반토큰 절약 효과 큼세부 정보 손실 가능
중요도 기반핵심 정보 보존중요도 정의가 어려움

4. 토큰은 곧 비용이다

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

5. 토큰 최적화 전략

System Message를 짧게

System Message는 모든 요청마다 포함된다. 한 번 줄이면 호출 횟수만큼 곱해서 절약된다.

// Before: 약 150 토큰
"당신은 매우 친절하고 상냥하며 사용자를 배려하는 AI 어시스턴트입니다.
 사용자의 질문에 항상 정중하고 예의바르게 답변해야 하며..."

// After: 약 15 토큰
"친절한 AI 어시스턴트. 명확하고 간결하게 답변."

1000회 호출 기준으로 135,000 토큰 절약, 약 90% 감소 효과다.

maxTokens를 동적으로 설정

모든 질문에 동일한 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% 비용 절감 효과를 기대할 수 있다.


6. 스트리밍 응답 (Streaming)

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단방향 (서버 → 클라이언트)OAI 스트리밍, 알림
WebSocket양방향O실시간 채팅, 게임

7. Structured Output으로 응답 형식 강제하기

감정 분석 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 서비스를 만들 수 있다.

profile
Backend Developer | Aspiring Full-Stack Enthusiast

0개의 댓글