RAG 챗봇 트래픽 대응하기 - 캐싱과 비동기 처리
지난 글에서 Spring Boot에 RAG를 연동하고, 마지막에 트래픽이 몰릴 때 캐싱과 비동기 처리가 필요하다고 예고했습니다. 이번 글에서는 실제로 RAG 챗봇에 Redis 캐싱과 비동기 처리를 적용해서 응답 속도와 비용을 함께 개선하는 방법을 다룹니다.
1. RAG 챗봇에서 병목이 생기는 지점
일반적인 API와 달리, RAG 챗봇은 요청 하나당 시간이 오래 걸리는 외부 호출이 두 번이나 껴 있습니다.
사용자 질문 → [임베딩 API 호출, ~100ms] → [벡터 검색, ~20ms]
→ [LLM 호출, ~1000~3000ms] → 답변 반환
특히 LLM 호출은 응답 시간의 대부분을 차지하면서, 동시에 호출당 비용이 발생하는 구간입니다. 같은 질문이 반복해서 들어오는데도 매번 임베딩과 LLM을 다시 호출한다면 속도와 비용 양쪽에서 손해를 보게 됩니다.
2. 자주 묻는 질문은 Redis로 캐싱하기
동일하거나 매우 유사한 질문이 반복된다면, 이전에 다룬 Redis 캐싱 전략을 그대로 적용할 수 있습니다.
@Service
@RequiredArgsConstructor
public class RagChatService {
private final VectorStore vectorStore;
private final ChatClient chatClient;
private final RedisTemplate<String, String> redisTemplate;
private static final Duration CACHE_TTL = Duration.ofHours(6);
public String answer(String question) {
String cacheKey = "rag:answer:" + DigestUtils.sha256Hex(question.trim().toLowerCase());
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return cached;
}
String answer = generateAnswer(question);
redisTemplate.opsForValue().set(cacheKey, answer, CACHE_TTL);
return answer;
}
private String generateAnswer(String question) {
List<Document> relevantDocs = vectorStore.similaritySearch(
SearchRequest.query(question).withTopK(3).withSimilarityThreshold(0.7)
);
if (relevantDocs.isEmpty()) {
return "관련된 정보를 찾을 수 없습니다.";
}
String context = relevantDocs.stream()
.map(Document::getContent)
.collect(Collectors.joining("\n\n"));
String promptText = "다음 문서를 참고해서 답변해줘.\n[문서]\n%s\n[질문]\n%s"
.formatted(context, question);
return chatClient.prompt().user(promptText).call().content();
}
}
질문 문자열을 그대로 키로 쓰지 않고 정규화(공백 제거, 소문자 변환) 후 해시값으로 캐시 키를 만들면, "재택근무는 몇 회?"와 "재택근무는 몇 회 ?"처럼 사소한 차이는 같은 캐시로 묶을 수 있습니다.
3. 문서가 갱신되면 캐시를 무효화해야 하는 이유
여기서 주의할 점이 하나 있습니다. 원본 문서가 수정되면, 캐싱된 답변은 더 이상 최신 내용을 반영하지 못한 채로 계속 반환됩니다.
@Service
@RequiredArgsConstructor
public class DocumentIngestService {
private final VectorStore vectorStore;
private final RedisTemplate<String, String> redisTemplate;
public void ingest(String content, String source) {
TextSplitter splitter = new TokenTextSplitter(500, 350, 5, 10000, true);
Document document = new Document(content, Map.of("source", source));
List<Document> chunks = splitter.apply(List.of(document));
vectorStore.add(chunks);
Set<String> keys = redisTemplate.keys("rag:answer:*");
if (keys != null && !keys.isEmpty()) {
redisTemplate.delete(keys);
}
}
}
문서 하나가 바뀔 때마다 전체 답변 캐시를 지우는 건 다소 거칠지만, 문서 갱신 빈도가 하루 몇 건 수준이라면 이 정도로도 충분히 실용적입니다. 갱신이 훨씬 잦다면, 캐시에 TTL을 짧게 주거나 문서 출처별로 캐시 키 네임스페이스를 나누는 방식을 검토하면 됩니다.
4. 임베딩 API 호출도 캐싱 대상이다
의외로 놓치기 쉬운 부분이, 검색을 위해 사용자 질문을 임베딩으로 변환하는 과정도 매번 외부 API를 호출한다는 점입니다. 완전히 동일한 질문이 반복된다면 이 호출도 캐싱 대상입니다.
@Service
@RequiredArgsConstructor
public class CachedEmbeddingService {
private final EmbeddingModel embeddingModel;
private final RedisTemplate<String, String> redisTemplate;
public float[] embed(String text) {
String cacheKey = "rag:embedding:" + DigestUtils.sha256Hex(text.trim().toLowerCase());
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return deserialize(cached);
}
float[] embedding = embeddingModel.embed(text);
redisTemplate.opsForValue().set(cacheKey, serialize(embedding), Duration.ofDays(7));
return embedding;
}
}
답변 자체는 캐시가 없어 매번 새로 생성해야 하는 상황(질문은 비슷하지만 완전히 동일하지는 않은 경우)에서도, 임베딩 호출만이라도 캐싱하면 검색 단계의 지연과 비용을 줄일 수 있습니다.
5. 문서 대량 적재는 비동기로 분리하기
관리자가 대용량 문서를 한 번에 업로드하면, 청킹과 임베딩 변환에 시간이 걸려 요청이 오래 대기하게 됩니다. 이런 적재 작업은 응답을 기다리게 할 이유가 없으므로 비동기로 분리합니다.
@Service
@RequiredArgsConstructor
public class DocumentIngestService {
private final VectorStore vectorStore;
@Async("documentIngestExecutor")
public CompletableFuture<Void> ingestAsync(String content, String source) {
TextSplitter splitter = new TokenTextSplitter(500, 350, 5, 10000, true);
Document document = new Document(content, Map.of("source", source));
List<Document> chunks = splitter.apply(List.of(document));
vectorStore.add(chunks);
return CompletableFuture.completedFuture(null);
}
}
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "documentIngestExecutor")
public Executor documentIngestExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(50);
executor.setThreadNamePrefix("doc-ingest-");
executor.initialize();
return executor;
}
}
@PostMapping
public ResponseEntity<Void> uploadDocument(@RequestBody DocumentUploadRequest request) {
documentIngestService.ingestAsync(request.getContent(), request.getSource());
return ResponseEntity.accepted().build();
}
관리자는 업로드 요청 즉시 202 응답을 받고, 실제 청킹·임베딩·저장은 별도 스레드 풀에서 처리됩니다. 문서 수가 많다면 이 안에서 다시 Spring Batch의 청크 단위 처리를 조합할 수도 있습니다.
6. 캐싱과 비동기 적용 전후 비교
| 항목 | 적용 전 | 적용 후 |
|---|---|---|
| 반복 질문 응답 시간 | 매번 1~3초 (LLM 호출) | 캐시 히트 시 수 ms |
| LLM 호출 비용 | 동일 질문도 매번 과금 | 캐시 기간 내 재호출 없음 |
| 문서 대량 업로드 응답 | 처리 완료까지 대기 | 즉시 202 응답, 백그라운드 처리 |
| 문서 갱신 시 정합성 | 신경 쓰지 않으면 오래된 답변 유지 | 적재 시 캐시 무효화로 최신 상태 유지 |
7. 정리
- RAG 챗봇은 임베딩과 LLM이라는 두 번의 외부 호출이 응답 시간의 대부분을 차지하기 때문에, 반복 질문에 대한 Redis 캐싱 효과가 특히 큽니다.
- 문서가 갱신되면 관련 캐시를 함께 무효화해야, 캐싱이 오히려 오래된 답변을 계속 내보내는 부작용을 막을 수 있습니다.
- 대량 문서 적재처럼 응답을 기다릴 필요 없는 작업은
@Async로 분리해서 사용자 요청과 백그라운드 처리를 나누는 것이 좋습니다.
여기까지 RAG를 이론부터 구축, 검색 개선, 평가, Spring Boot 연동, 트래픽 대응까지 한 사이클로 다뤄봤습니다. 다음 글에서는 실제 운영 중 발생하는 이슈, 예를 들어 임베딩 모델이나 LLM 모델을 교체해야 할 때 기존 벡터 데이터를 어떻게 마이그레이션할지 다뤄볼 수 있습니다.
'RAG를 활용한 LLM 서비스 구축' 카테고리의 다른 글
| STEP 16 - Hybrid Search (0) | 2026.07.13 |
|---|---|
| STEP 15 - 임베딩 모델 교체 시 벡터 데이터 마이그레이션하기 (0) | 2026.07.13 |
| STEP 13 - Spring Boot에 RAG 연동하기 (0) | 2026.07.10 |
| STEP 12 - 프롬프트 엔지니어링 (0) | 2026.07.09 |
| STEP 11 - 답변 품질을 측정하는 방법 (0) | 2026.07.09 |