RAG 챗봇을 운영하다 보면 한 가지 한계에 부딪힌다. 매 대화가 완전히 독립적으로 처리된다는 점이다.
여행사 챗봇을 예로들어보자.
어제 항공권 환불 문제로 3번 문의한 사용자가 오늘 또 같은 주제로 질문해도, 챗봇은 그 이력을 전혀 모른다. "지난번 이슈와 연관지어 안내해드리겠습니다"는 불가능한 응답이다.
이에 사용자 문의 이력을 Redis에 구조화된 프로파일로 누적하고, LLM 시스템 프롬프트에 주입해 응답 품질을 개선한 방법을 공유한다.
예제는 예시로 든 여행사 관련 로직으로 한다.
챗봇이 질문을 받으면 아래 순서로 동작한다.
[사용자 질문 수신]
│
▼
[① 프로파일 조회] Redis에서 사용자 프로파일 조회 → 없으면 빈 상태로 진행
│
▼
[② 의도 분석] LLM이 질문 키워드·시그널 유형 분류
│
├──────────────────────────────────────┐
▼ ▼ (비동기, non-await)
[③ 답변 생성] 시스템 프롬프트에 [④ 프로파일 갱신]
프로파일 내용 주입 후 LLM이 새 문의에서 신호 추출
최종 답변 생성 → 기존 프로파일에 merge → Redis 저장
③ 답변 생성과 ④ 프로파일 갱신은 동시에 진행된다. 갱신이 실패해도 답변에는 영향이 없다.
해당 개인화 정보는 Redis 에 저장한다.
DB 나 S3 에 파일로 저장하는것도 고민했었다.
DB 의 경우 장기 데이터를 보관하는 용도로 사용해야 하는데 아직 어떤 정보를 장기 데이터로 쓸지를 모르는 상황이라 DB 는 제외하고, S3 의 경우 내구성이 높아서 데이터 유실을 막을수 있지만 아무래도 고객이 사용하는 챗봇이다보니 속도가 중요하다고 생각되어 결국 Redis 로 선택하게 되었다.
Redis에는 자연어 목록이 아닌 구조화된 JSON으로 저장한다.
이부분에서 자연어와 JSON 포멧중 어떤형식으로 저장하는게 좋을지 고민을 했었는데,
예를 들어 GPT 의 경우 저장된 메모리를 보면 자연어로 되어있기 때문에 자연어로 저장을 할까 하다가 결국 이 작업의 목적은 챗봇의 응답개선 이기 때문에 응답 개선에 필요한 항목들을 고정해서 정규화해 저장하는게 더 맞는 방향인것같아 JSON 포멧으로 저장하는것으로 결정했다.
{
"travel_profile": {
"preferred_destinations": ["유럽", "동남아"],
"travel_style": "자유여행",
"budget_range": "1인 150만원 이하"
},
"user_level": {
"level": "중급"
},
"preferences": {
"answer_style": "간단"
},
"task_interests": [
{ "name": "항공권 최저가 조회", "count": 4, "lastSeenAt": "2026-06-15T..." }
],
"needed_features": [
{ "name": "얼리버드 가격 알림", "count": 2, "lastSeenAt": "2026-06-14T..." }
],
"issue_patterns": [
{ "name": "예약 취소 환불 지연", "count": 2, "lastSeenAt": "2026-06-15T..." }
],
"notes": "혼자 여행, 숙소보다 항공 비용 중시",
"updated_at": "2026-06-15T..."
}
Redis 키: user_profile:{사용자ID} (String 타입, TTL 30일)
구조화 저장의 장점은 카운팅과 최신성 관리다. 같은 이슈를 3번 문의하면 count: 3으로 누적되고, 답변 시 우선순위를 높일 수 있다.
Redis에서 꺼낸 JSON을 바로 프롬프트에 넣으면 쓸데 없이 토큰이 낭비되기 때문에, 정의된 항목이 있는경우에만 LLM이 읽기 쉬운 문자열 목록으로 변환한 뒤 시스템 프롬프트에 주입한다.
// JSON → string[] 변환
private formatProfileForPrompt(profile: UserProfile): string[] {
const lines: string[] = [];
if (profile.travel_profile.preferred_destinations.length > 0)
lines.push(`선호 여행지: ${profile.travel_profile.preferred_destinations.join(', ')}`);
if (profile.issue_patterns.length > 0)
lines.push(`최근 문제/오류 패턴: ${this.formatSignalList(profile.issue_patterns)}`);
if (profile.needed_features.length > 0)
lines.push(`필요 기능 요청: ${this.formatSignalList(profile.needed_features)}`);
// ... 나머지 필드
return lines;
}
// { name, count } → "예약 취소 환불 지연(2회), 항공권 최저가 조회"
private formatSignalList(items: ProfileSignal[]): string {
return items
.map(item => `${item.name}${item.count > 1 ? `(${item.count}회)` : ''}`)
.join(', ');
}
변환 결과를 시스템 프롬프트에 붙인다.
if (userProfile.length > 0) {
systemPrompt += `\n\n# 사용자 이력 (참고용)\n`;
systemPrompt += userProfile.map(line => `- ${line}`).join('\n');
}
LLM이 받는 실제 프롬프트 예시:
# 사용자 이력 (참고용)
- 선호 여행지: 유럽, 동남아
- 여행 스타일: 자유여행
- 최근 문제/오류 패턴: 예약 취소 환불 지연(2회)
- 필요 기능 요청: 얼리버드 가격 알림(2회)
프로파일 갱신에 앞서, 의도 분석 단계에서 질문의 시그널 유형을 함께 분류한다.
| signalType | 분류 기준 | 갱신 대상 필드 |
|---|---|---|
featureRequest | "~기능 있나요?", "~할 수 있나요?" | needed_features |
troubleshooting | 오류, 버그, 작동 안 됨 | issue_patterns |
actionRequest | 특정 작업 수행 요청 | task_interests |
general | 일반 사용법·정책 문의 | task_interests 또는 travel_profile |
signalType이 없거나 hasProfileSignal=false이면 LLM 호출 없이 갱신을 스킵한다. 가벼운 인사나 단순 재질문은 프로파일에 남길 신호가 없다.
갱신은 단순 덮어쓰기가 아니다. 프로파일 분석 LLM이 새로운 사용자 질문에서 신호를 추출하고, 기존 프로파일에 merge한다.
const userMessage = `기존 프로파일:
${JSON.stringify(existingProfile, null, 2)}
새 문의 정보:
- 원문 질문: ${question}
- 질문 키워드: ${intentAnalysis.keyword}
- 시그널 유형: ${intentAnalysis.signalType}`;
{
"observations": {
"preferred_destinations": [],
"issue_patterns": ["예약 취소 환불 지연"],
"needed_features": [],
"task_interests": [],
"notes": null
}
}
추측하지 않는다. 이번 문의에서 명시적으로 확인된 것만 반환하도록 프롬프트에 못 박는다.
private mergeSignalList(existing: ProfileSignal[], incoming: string[]): ProfileSignal[] {
const merged = existing.map(item => ({ ...item }));
for (const name of incoming) {
const found = merged.find(item => isSameText(item.name, name));
if (found) {
found.count += 1; // 기존 항목이면 카운트 증가
found.lastSeenAt = now;
} else {
merged.push({ name, count: 1, lastSeenAt: now }); // 신규 항목 추가
}
}
// 빈도 높은 순 → 최근 순 정렬, 상위 5개만 유지
return merged
.sort((a, b) => b.count - a.count || b.lastSeenAt.localeCompare(a.lastSeenAt))
.slice(0, 5);
}
merge 후 JSON.stringify로 Redis에 저장한다. 리스트 타입이 아닌 String 타입으로 단일 키에 저장하는 구조다.
await this.redisService.set(key, JSON.stringify(updatedProfile), TTL_30_DAYS);
updateProfile은 non-await 비동기로 호출된다. 같은 사용자가 연속으로 질문하면 갱신이 동시에 두 번 실행될 수 있다. 이를 막기 위해 setNx 기반 분산 락을 사용한다.
const locked = await this.redisService.setNx(lockKey, lockValue, LOCK_TTL_15S);
if (!locked) return; // 다른 프로세스가 갱신 중 → 스킵
try {
// 갱신 로직
} finally {
await this.redisService.delIfValue(lockKey, lockValue); // 내 락만 해제
}
락 TTL은 15초. 예외 발생으로 finally가 실행 안 돼도 자동 만료된다.
개인화는 보조 기능이다. 핵심 답변 생성에 영향을 줘서는 안 된다.
| 결정 | 선택 | 이유 |
|---|---|---|
| 저장 포맷 | 구조화 JSON | 카운팅·최신성 관리, 항목별 우선순위 정렬 가능 |
| 신호 추출 | LLM | 추측 없이 명시된 사실만 추출, 필드별 매핑 지시 가능 |
| 갱신 방식 | merge (덮어쓰기 X) | 기존 이력 보존, 반복 문의 누적 |
| 갱신 타이밍 | 비동기 (non-await) | 응답 지연 없음 |
| 동시성 제어 | setNx 분산 락 | 연속 질문 시 중복 갱신 방지 |
| 저장 범위 | 문의 내용만 | 이름·이메일 등 개인식별정보 저장 금지 |
hasProfileSignal 플래그: 의도 분석 결과에 함께 포함. false이면 LLM 호출 자체를 스킵해 비용 절감Date.now() + random으로 생성해 내 락만 정확히 해제 (delIfValue)Before:
사용자: "항공권 환불이 아직도 안 됐어요"
챗봇: "항공권 환불 절차를 안내해드리겠습니다. 예약 번호를 확인해주세요..."
After:
사용자: "항공권 환불이 아직도 안 됐어요"
챗봇: "환불 관련으로 최근 자주 문의하고 계시네요. 이전에 접수하신 건이 아직 처리 중일 수 있는데, 해당 건과 연관된 내용인지 먼저 확인해드리겠습니다..."
아직 개인화에 대한 결과가 충분히 쌓이지 않아 어느정도의 사용자에게 어느정도의 효과가 있는지는 미지수다.
하지만 당장 유효한 답변을 얻지 못했다 하더라도 사용자가 어느 부분에서 어려움을 겪고 있는지, 어떤부분에 흥미가 있는지에 대해 모다 명확한 해답을 제시할 수 있는 근거를 마련한다는점이 가치가 있다고 본다.
스택: NestJS · LangGraph · AWS Bedrock · Redis · TypeScript