Day 5. ChatMemory — "LLM 한테 어제 무슨 얘길 했는지 기억하게 만들기"
안녕하세요! 여러분의 백엔드 가이드, 홍순구 튜터입니다.
Day 4, 정말 단단하게 마무리하셨어요.
지난 시간 우리는 LLM 의 답을 더 이상 String 으로 받지 않기로 약속했죠.
record AiReply(String aiMessage, List<String> choices, int affectionDelta) 한 줄을 정의하고 .call().entity(AiReply.class) 로 받으니까, ObjectMapper.readValue(...) 와 try-catch 30 줄이 한꺼번에 사라졌어요.
그리고 Day 4 Step 6 에선 retry → fallback 정책까지 손으로 펴봤죠.
그런데 지난 시간 마무리에서 제가 또 슬쩍 미루고 도망간 게 하나 있었어요.
"오늘 박아둔 도구 7 개와 이 토론 주제 3 개를 들고 가셔서 다음 시간 (Day 5) ChatMemory 의 풍경 으로 만나요."
오늘이 그 약속을 펼치는 날입니다.
그리고 Day 4 Step 5 에서 SoulmateChatService.chat(...) 의 시그니처를 String → AiReply 로 갈아엎으면서 한 가지 찝찝함을 남겨뒀어요.
호출 1) 사용자: "오늘 진짜 별로였어"
모델: "에이, 무슨 일 있었어? 천천히 얘기해봐."
호출 2) 사용자: "사실 회사에서 일이 있었어"
모델: ???
한 번 답을 받고 끝이에요. 두 번째 호출이 들어오면 모델은 어제(아니, 1 초 전) 무슨 얘길 했는지 모릅니다. 미연시 게임 도메인에서 이건 치명적이에요. 캐릭터가 매 턴마다 "처음 뵙는 분이세요?" 라고 말하면 게임이 성립이 안 되거든요.
💡 오늘 수업의 핵심 "stateless LLM 에 '대화의 기억' 을 입힌다" 🎯
오늘 수업은 한 문장으로 요약돼요.
"LLM 호출은 stateless 다. 그러므로 '대화의 흐름' 은 우리 서버가 만들어서 매 호출마다 다시 넣어줘야 한다."
여기서 세 가지 도구가 등장해요. 지난 시간 Day 4 마무리에서 슬쩍 보여드렸던 Advisor 라는 큰 추상화의 첫 적용이기도 해요.
ChatMemory인터페이스 — 대화 이력을 어디에 어떻게 보관할지의 정책. "메시지를 N 개까지만 들고 갈래" (sliding window), "오래된 건 요약해서 들고 갈래" (summarization) 같은 전략이 들어가는 곳이에요.JdbcChatMemoryRepository— 보관 저장소. 서버가 꺼져도 대화가 살아남도록 우리는 MySQL 에 영속화할 거예요. 메모리에 들고 있다가 재시작하면 싹 날아가는InMemoryChatMemoryRepository는 이 강의의 프로덕션 예제에서는 안 씁니다 (테스트 코드 한정 허용).MessageChatMemoryAdvisor— 주입기. ChatClient 호출 직전에 저장된 대화 이력을 시스템 프롬프트 옆에 자동으로 끼워넣어주는 역할이에요. 우리가 매번 "이전 대화를 끌어와서 프롬프트에 합쳐서..." 같은 코드를 손으로 짤 필요가 없어요. Advisor 한 줄 이면 끝납니다.
이 셋이 손에 들어오면, 지난 시간 Day 4 의 그 찝찝함 — "두 번째 호출에서 모델이 첫 호출을 못 알아본다" — 이 한 방에 풀려요. 그리고 추가로, ai-friends 의 게임 세션이 회차를 넘나들면서도 대화가 이어지게 됩니다 (사용자가 앱을 껐다 켜도 캐릭터가 어제 대화를 기억).
🙋 한 학생의 걱정
"튜터님, 솔직히 말씀드리면요. 지난 시간 Day 4 끝나고
AiReply받는 데까지는 따라왔는데, 오늘 들어가면 갑자기ChatMemory,JdbcChatMemoryRepository,MessageChatMemoryAdvisor세 개가 한꺼번에 등장하네요. 머리가 좀 어지러워요... 이거 다 외워야 하나요? 그리고 우리 코드베이스에 이미ChatLog라는 도메인이 있던데, 그건 그럼 왜 만들었던 거예요? ChatMemory 랑 어떻게 다른 거죠? "
날카로운 질문이에요. 두 가지를 미리 짧게 풀어드릴게요.
첫째, 세 도구는 역할이 정확히 셋으로 나뉘어 있어서 한 번 그림을 그려두면 안 헷갈려요. Step 2 에서 Repository(저장소)·Memory(정책)·Advisor(주입기) 의 3 각 구조를 그림 한 장으로 정리할 거예요. "외우는 게 아니라 역할 세 개를 분리해서 보면 된다" 는 감각이 잡힐 거예요.
둘째, ChatLog 와 ChatMemory 는 이름은 비슷해도 역할이 완전히 달라요.
| 구분 | ChatLog (우리 비즈니스 로그) |
ChatMemory (Spring AI 컨텍스트) |
|---|---|---|
| 누가 보는가 | 사람 (운영자·CS·분석팀) | LLM (다음 호출의 컨텍스트로) |
| 용도 | 감사·통계·CS 응대 | 대화의 흐름 유지 |
| 보관 형태 | 우리 도메인 모델 (작성자·시간·메타) | Spring AI 의 Message 객체 (role + content) |
같은 대화를 두 번 저장하는 셈이라 처음엔 어색해요. 그런데 LLM 한테 넘겨줄 컨텍스트 와 사람이 나중에 들춰볼 비즈니스 로그 는 보관 포맷·삭제 정책·프라이버시 처리가 다 달라서 분리하는 게 정석이에요. Step 3 에서 이 분리를 한 번 더 짚을게요.
🎯 학습 목표
- stateless LLM 호출의 본질 을 HTTP 의 무상태성과 같은 맥락으로 이해하고, "기억" 을 만들려면 서버가 무엇을 해야 하는지 손으로 그립니다.
- Spring AI 의
ChatMemory3 각 구조 (Repository · Memory · Advisor) 를 한 그림으로 정리하고, 셋의 책임이 어떻게 분리되어 있는지 체득합니다. JdbcChatMemoryRepository로 대화를 MySQL 에 영속화해서, 서버 재시작·세션 끊김에도 살아남는 저장 흐름을 만듭니다.MessageChatMemoryAdvisor+MessageWindowChatMemory조합으로 슬라이딩 윈도우 기반 컨텍스트 자동 주입을 한 줄짜리 advisor 등록으로 끝냅니다.- ai-friends 의
SoulmateChatService가conversationId를 키로 세션을 세이브/로드 하는 흐름까지 통합해서, 같은 사용자가 다시 들어왔을 때 어제 대화가 이어지는 풍경을 직접 만듭니다. - sliding window vs summarization 트레이드오프와
maxMessages의 실측 감각을 잡고, 운영에서 메시지 정리·프라이버시를 어떻게 다룰지 한 줄짜리 정책으로 정리합니다.
Step 1: "지난 시간의 AI 는 금붕어였다" — stateless 호출의 한계 재점검
자, 본격적으로 새 도구 (ChatMemory) 를 손에 쥐기 전에 — 지난 시간 만든 /api/chat/soulmate 엔드포인트가 왜 기억을 못 하는지 부터 몸으로 한 번 느끼고 가야 해요. 그래야 오늘 도구가 들어왔을 때 "와, 진짜 살았다" 라는 감각이 옵니다.
이 Step 은 코드를 새로 짜지 않아요. 지난 시간까지의 코드베이스를 그대로 띄워두고, curl 두 번 으로 직접 금붕어 현상을 확인할 거예요. 시뮬레이션 위주의 Step 입니다.
1. 먼저 지난 시간까지의 베이스라인 띄우기
Day 4 까지의 코드베이스 상태로 앱을 띄워봅시다. Day 4 마무리에서 우리는 day04-structured-output 브랜치에 박제해뒀죠.
cd lecture-source-code/ai-friends
git status # working tree clean 확인
git checkout day04-structured-output # Day 4 마지막 시점
./run.sh up # docker compose 로 앱 + MySQL 기동
앱이 8080 으로 떠 있으면 준비 완료입니다. (떠있는지 확인하고 싶으시면 curl http://localhost:8080/actuator/health — Day 1 에서 켜둔 엔드포인트예요.)
2. 첫 번째 호출 — "안녕, 나 오늘 좀 우울해"
자, 지난 시간 Day 4 Step 5 에서 우리는 SoulmateChatService.chat(...) 이 AiReply 를 반환하도록 갈아엎었어요. 그 응답이 어떻게 떨어지는지 먼저 한 번 보고 갈게요.
curl "http://localhost:8080/api/chat/soulmate?userId=1&mood=우울&message=안녕,%20나%20오늘%20좀%20우울해"
응답 (모델 호출이라 매번 똑같진 않지만, 흐름은 비슷해요) 은 대략 이런 형태로 떨어져요.
{
"success": true,
"data": {
"aiMessage": "에이, 무슨 일 있어? 오늘 하루 힘들었구나... 천천히 얘기해줄래?",
"choices": [
"회사에서 일이 좀 있었어",
"그냥 별 이유 없이 기분이 가라앉아",
"괜찮아, 들어줘서 고마워"
],
"affectionDelta": 1
}
}
좋아요. 모델이 잘 공감해주고 있어요. choices 도 적절하고, 호감도도 +1 올라갔네요. 지난 시간 우리가 만든 AiReply 가 정확히 이 모양으로 떨어지죠.
3. 두 번째 호출 — "내가 좀 전에 뭐라고 했지?"
자, 이제 진짜 실험 시간이에요. 같은 userId=1 로 두 번째 호출을 보내볼게요. 사람 사이의 대화라면 당연히 "조금 전에 우울하다고 했지?" 정도로 받아칠 텐데, 우리 LLM 은 어떨까요?
curl "http://localhost:8080/api/chat/soulmate?userId=1&mood=우울&message=내가%20좀%20전에%20뭐라고%20했지?"
응답은 이런 식으로 떨어집니다.
{
"success": true,
"data": {
"aiMessage": "음, 미안해 — 좀 전에 뭐라고 했는지 잘 모르겠어. 무슨 얘기 하고 싶었어?",
"choices": [
"아무것도 아니야",
"그냥 잡담하고 싶었어",
"처음부터 다시 얘기해도 돼?"
],
"affectionDelta": 0
}
}
보셨죠? 모델이 1 초 전 대화를 전혀 기억하지 못해요. 정확히 지난 시간 마무리에서 짚었던 그 풍경입니다.
두 응답을 표로 나란히 펼치면 이래요.
| 호출 | 사용자 메시지 | 모델 응답 요지 | 모델이 알고 있는 정보 |
|---|---|---|---|
| 1 | "안녕, 나 오늘 좀 우울해" | "무슨 일 있어? 천천히 얘기해줘" | 시스템 프롬프트 + 이번 메시지 |
| 2 | "내가 좀 전에 뭐라고 했지?" | "미안, 모르겠어 — 무슨 얘기 하고 싶었어?" | 시스템 프롬프트 + 이번 메시지만 |
핵심은 마지막 열이에요. 호출 2 시점의 모델은 호출 1 의 존재 자체를 모릅니다. 우리 서버가 두 호출을 같은 userId=1 로 받았다는 것도, "우울해" 라는 단어가 1 초 전에 오갔다는 것도, 모두 모델 입장에선 존재하지 않는 사실이에요.
4. 왜 이렇게 동작하나? — HTTP 의 무상태성
이쯤에서 "왜?" 가 궁금해야 정상이에요. 답은 의외로 익숙한 데서 나옵니다.
LLM 호출은 본질적으로 HTTP 호출이에요. 그리고 HTTP 는 stateless 프로토콜 이죠. 매 요청은 독립적이고, 서버는 이전 요청을 기억할 의무가 없어요. 우리는 이미 Spring Boot 에서 이 전제 위에서 살고 있어요. 세션을 쓰려면 쿠키나 토큰을 매 요청에 다시 실어야 했죠?
LLM 도 똑같아요. 우리가 매번 새로 POST /v1beta/.../generateContent (또는 OpenAI / Ollama 의 /v1/chat/completions) 를 때리는 거예요. 모델 서버 입장에서 보면:
요청 1) [system prompt + user: "안녕, 나 오늘 좀 우울해"]
→ 응답 만들고 → 컨텍스트 전체 폐기
요청 2) [system prompt + user: "내가 좀 전에 뭐라고 했지?"]
→ 응답 만들고 → 컨텍스트 전체 폐기
요청 2 의 모델은 요청 1 의 존재를 알 길이 없어요. 모델 서버 쪽에 우리만의 세션을 따로 만들어주는 API 도 없어요 (Gemini / OpenAI 모두 클라이언트가 컨텍스트를 들고 다녀라 가 표준이에요).
5. "기억" 의 두 축 — 한 세션 vs 세션 너머
자 이제 우리가 만들어야 할 "기억" 이 정확히 뭔지를 두 축으로 나눠봅시다. 미연시 게임을 떠올리면 직관적이에요.
(a) 한 세션 안의 멀티턴 기억 — 사용자가 앱을 켜놓고 5 분 동안 캐릭터랑 대화하는 동안의 흐름입니다. "조금 전에 회사 얘기했지?" 같은 짧은 호흡의 회상. 우리 게임에선 한 회차의 대화 전체가 여기에 해당해요.
(b) 세션을 넘나드는 영속 기억 — 사용자가 앱을 껐다가 다음 날 다시 들어왔을 때, 캐릭터가 "어제 회사에서 일 있었다고 했잖아, 오늘은 좀 어때?" 라고 받아주는 호흡. 즉 서버 재시작·세션 끊김을 견뎌야 하는 기억이에요.
| 축 | 보관 위치 후보 | 라이프사이클 | 우리 도메인 예시 |
|---|---|---|---|
| (a) 한 세션 안 | 메모리(InMemory...) 도 가능 |
앱 살아있는 동안 | 한 회차의 대화 흐름 |
| (b) 세션 너머 | DB(MySQL) 필수 | 서버 재시작도 견딤 | 회차를 넘나드는 캐릭터 관계 |
Day 5 는 둘 다 다룹니다. Step 2~4 에서 (a) 까지 깔고, Step 5 에서 (b) 로 확장해요. 그래서 우리는 처음부터 JdbcChatMemoryRepository (MySQL 영속화) 로 갑니다. 메모리 기반은 학습용 디버그·테스트 코드에서만 잠깐 등장할 거예요.
6. 잠깐, 그럼 우리 ChatLog 도메인은 뭐였더라?
위에서 한 학생이 던진 질문을 한 번 더 짚고 갈게요. 우리 코드베이스에 이미 있는 ChatLog (kr.spartaclub.aifriends.domain.ChatLog, kr.spartaclub.aifriends.service.ChatLogService) — 이건 그럼 뭐예요?
ChatLog 는 우리 비즈니스용 대화 로그 예요. 운영자가 나중에 "어떤 사용자가 어떤 캐릭터랑 어떤 대화를 했나" 를 들춰보거나, CS 응대 시 근거 자료로 쓰거나, 통계 (DAU 별 평균 대화 길이 같은 것) 를 뽑을 때 쓰는 용도죠. 사람이 보는 데이터예요.
반면 오늘 만들 ChatMemory 의 보관 데이터는 LLM 한테 넘겨줄 컨텍스트 예요.
사람이 들춰보는 게 아니라, 다음 호출의 입력 토큰으로 들어갈 메시지 시퀀스죠.
보관 포맷도 다르고 (ChatMemory 는 Spring AI 의 Message 객체 형태 — role + content), 삭제 정책도 다르고 (오래된 메시지를 sliding window 로 잘라낸다거나), 프라이버시 처리도 다를 수 있어요 (PII 마스킹 정책이 다름).
당장은 "둘이 별개구나" 정도만 짚어두시고, Step 3 에서 JdbcChatMemoryRepository 가 어떤 테이블을 만드는지 확인할 때 다시 한 번 비교할게요. (스포일러: 두 시스템은 서로 다른 테이블을 쓰고, 서로의 데이터를 모릅니다.)
🙋 날카로운 질문 타임 — "튜터님, 그럼 클라이언트(프론트엔드) 가 매번 이전 대화를 다 들고 와서 보내면 되는 거 아니에요? 굳이 서..."
"튜터님, 그럼 클라이언트(프론트엔드) 가 매번 이전 대화를 다 들고 와서 보내면 되는 거 아니에요? 굳이 서버에서 ChatMemory 까지 만들 필요 있나요?"
좋은 감각이에요. 실제로 "이전 메시지 리스트를 클라이언트가 들고 다닌다" 는 패턴은 짧은 데모 에선 충분히 동작해요. 그런데 우리 도메인에선 세 가지가 걸려요.
- 모바일 / 다중 디바이스 동기화 — 사용자가 폰에서 시작한 대화를 노트북에서 이어가려면 클라이언트 측에 들고 있던 이력은 무용지물이에요. 서버가 진실의 원본(source of truth) 이어야 해요.
- 토큰 비용 ≠ 클라이언트 비용 — 클라이언트가 들고 다니더라도 결국 서버가 그 이력을 LLM 에 다시 보내야 해요. 그 시점에 토큰 윈도우(
maxMessages) 를 적용하려면 서버가 정책을 쥐고 있어야 해요. 클라이언트한테 맡기면 디버깅·운영이 어지러워져요. - 프라이버시 정책 — "이력 30 일 후 삭제" 같은 운영 정책을 클라이언트 측에서 관철하기는 거의 불가능해요. 서버가 보관 주체여야 일관된 정책이 가능해요.
요약하자면 "기억의 책임은 서버가 진다" 가 표준이에요. Spring AI 가 ChatMemory 추상화를 굳이 만든 것도 같은 이유예요.
"튜터님, 그럼 ChatMemory 안 쓰고 그냥 매번 시스템 프롬프트에 손으로 이전 대화 붙여서 보내면 안 되나요?"
물론 가능해요! 사실 추상화가 없던 시절엔 모두가 그렇게 짰어요. 우리 레거시 GeminiService 에도 비슷한 흔적이 있죠 (Day 4 에서 본 그 무거운 클래스 — "들어내기로 약속한" 그 친구). 그런데 손으로 짜다 보면 곧 4 가지가 같이 따라옵니다.
| 손으로 짤 때 | 따라오는 비용 |
|---|---|
| 이력 조회 | 매번 SELECT 쿼리 손코딩 |
| 메시지 정렬 | createdAt 기준 정렬 + 형식 변환 |
| 윈도우 자르기 | 마지막 N 개만 잘라서 들고 가는 로직 |
| 새 메시지 저장 | 응답 받으면 user / assistant 메시지를 따로 INSERT |
이걸 컨트롤러마다 반복하면 코드가 빠르게 무거워져요. Spring AI 의 ChatMemory + Advisor 는 정확히 이 4 가지를 자동으로 해주는 추상화 예요. Step 4 에서 advisor 한 줄로 위 네 가지가 다 사라지는 풍경을 직접 볼 거예요.
8. 💡 튜터의 결론
Step 1 의 한 문장 요약은 이래요.
"LLM 호출은 stateless 다. '대화의 흐름' 은 모델이 만드는 게 아니라 우리 서버가 매 호출마다 다시 넣어줘야 한다."
오늘의 출발점은 명확해요. 지난 시간까지의 /api/chat/soulmate 는 한 호출짜리 캐릭터예요. 두 번째 호출이 들어오면 첫 호출을 모르는 금붕어죠. 우리는 오늘 이 캐릭터한테 "기억의 끈" 을 입혀줄 거예요.
다음 Step 에서는 그 "끈" 을 어떻게 만들지 — Spring AI 가 제공하는 ChatMemory 인터페이스의 3 각 구조 (Repository · Memory · Advisor) 를 그림 한 장으로 정리하고, 셋의 책임이 어떻게 나뉘는지 손에 익혀볼 거예요.
Step 2: `ChatMemory` 3 각 구조 — Repository · Memory · Advisor
자, Step 1 에서 우리는 "기억의 책임은 서버가 진다" 까지 합의했어요. 그럼 이제 진짜 질문이 따라와요. "서버가 어떻게 기억하지?" 🤔
이 Step 은 코드를 새로 짜지 않아요.
대신 Spring AI 가 우리한테 던져주는 세 개의 추상화 를 한 그림으로 정리할 거예요.
이 그림이 머리에 박히면 Step 3·4·5 에서 코드를 손으로 짤 때 "내가 지금 어느 층을 만지고 있구나" 가 또렷해져요.
반대로 이 그림이 흐릿하면 메서드 하나 부를 때마다 "이게 어디 메서드였더라" 하게 돼요.
1. 한 그림으로 보기 — Repository · Memory · Advisor 의 3 각
먼저 큰 그림부터 한 장으로 펼쳐 볼게요. ChatClient 가 모델을 호출할 때 우리 서버 안에서 일어나는 흐름은 대략 이래요.
사용자 메시지 → ChatClient.prompt(...).user(message).call()
│
▼ (호출 직전)
MessageChatMemoryAdvisor.before(...)
│
▼ (대화 이력 좀 가져와줘)
ChatMemory.get(conversationId)
│
▼ (그 이력은 어디 보관돼 있지?)
ChatMemoryRepository.findByConversationId(...)
│
▼ (예: MySQL에서 SELECT)
[Message 들 반환]
│
▼
ChatMemory 가 "마지막 N 개만" 잘라서 반환
│
▼
Advisor 가 그 메시지들을 프롬프트에 끼워넣고 모델 호출
│
▼ (응답 받음)
MessageChatMemoryAdvisor.after(...)
│
▼ (방금 user / assistant 메시지 저장해줘)
ChatMemory.add(conversationId, [user, assistant])
│
▼ (DB 에 INSERT)
ChatMemoryRepository.saveAll(...)
세 추상화의 역할을 한 줄씩 정리하면 이래요.
| 추상화 | 역할 | 비유 |
|---|---|---|
ChatMemoryRepository |
"어디에 보관할지" — 저장 매체 (메모리·DB·NoSQL) | 창고지기 |
ChatMemory |
"얼마나 들고 갈지" — 보관 정책 (윈도우 자르기·요약·압축) | 매니저 |
MessageChatMemoryAdvisor |
"ChatClient 호출에 자동으로 끼워넣기" | 전령 |
이 셋이 서로 모르는 척 일을 합니다. Repository 는 어떤 정책으로 자르는지 모르고, Memory 는 어디에 저장되는지 모르고, Advisor 는 세부 구현이 뭔지 몰라요. 각자 자기 일만 하면 되는 구조예요. (왜 이렇게 나눠놨는지는 3 절에서 다시 파볼게요.)
2. 첫 번째 층 — ChatMemoryRepository (창고지기)
가장 아래층부터 가볼게요. ChatMemoryRepository 는 "메시지를 어디에 보관할 것인가" 의 추상화 예요. 메모리에 들고 있을지, MySQL 에 박을지, Cassandra·Neo4j 같은 NoSQL 에 박을지 — 그 결정의 자리죠.
이 인터페이스가 정의한 메서드는 딱 네 개예요. "창고지기한테 시킬 수 있는 일이 네 가지 밖에 없다" 라고 생각하면 편해요.
interface ChatMemoryRepository {
List<String> findConversationIds();
List<Message> findByConversationId(String conversationId);
void saveAll(String conversationId, List<Message> messages);
void deleteByConversationId(String conversationId);
}
이 네 메서드가 왜 이 모양인지 한 줄씩 풀어볼게요.
findConversationIds()— "지금 우리 시스템에 살아있는 대화방이 뭐가 있어?" — 운영·디버깅용 조회 (실제 LLM 호출 흐름엔 거의 안 쓰여요).findByConversationId(...)— "이 대화방의 메시지 다 꺼내줘" — Advisor 가 호출 직전에 가장 자주 부르는 메서드예요.saveAll(...)— "이 메시지 묶음을 보관해줘" — 응답 받은 직후 user 메시지 + assistant 메시지 를 묶어서 한 번에 저장.deleteByConversationId(...)— "이 대화방 통째로 지워줘" — 사용자가 "이전 대화 다 지워주세요" 같은 액션을 했을 때.
구현체는 누가 있느냐. 세 가지 정도 를 손에 쥐고 가시면 돼요.
| 구현체 | 저장 매체 | 언제 쓰나 |
|---|---|---|
InMemoryChatMemoryRepository |
JVM 메모리 (HashMap) | 테스트·단발 데모 한정. 서버 재시작하면 증발함. 프로덕션 금지 |
JdbcChatMemoryRepository |
JDBC + 관계형 DB | 우리는 이걸 MySQL 로 갈 거예요 (Step 3 에서 본격 도입) ✅ |
| 기타 (Cassandra·Neo4j 등) | NoSQL | 별도 starter 로 존재. 대규모·그래프 도메인이라면 검토. 본 강의는 다루지 않음 |
이 강의에서
InMemoryChatMemoryRepository는 테스트 코드 외엔 등장하지 않아요. Day 5 이후 모든 대화형 예제는JdbcChatMemoryRepository기반이에요. 메모리 기반은 학습용 디버그 정도로만 잠깐 짚고 넘어갈 거예요.
3. 두 번째 층 — ChatMemory (매니저)
자, 창고에 메시지가 전부 들어 있다고 칩시다. 한 사용자가 6 개월 동안 5,000 줄을 쌓았다고요. 다음 LLM 호출에서 5,000 줄을 다 보내면 어떻게 될까요?
- 토큰이 폭발해서 모델이 컨텍스트 길이 초과 로 거절하거나 잘라먹음
- 비용이 폭발 (입력 토큰은 그대로 돈)
- 응답 속도가 느려짐
그래서 "전부 다 보내지 말고, 뭘 얼마만큼 보낼지" 를 결정해야 해요. 그게 ChatMemory 인터페이스의 책임 이에요. 창고에서 물건을 다 꺼내오는 게 아니라, "오늘 손님한테 보여줄 것만 골라오라" 고 시키는 매니저예요.
인터페이스 시그니처는 이래요.
interface ChatMemory {
void add(String conversationId, Message message);
void add(String conversationId, List<Message> messages);
List<Message> get(String conversationId);
void clear(String conversationId);
}
get(...) 한 줄에 정책이 다 녹아 있어요. "이 대화방의 이력을 가져와줘" 라고 부르면, 구현체가 내부 정책에 따라 골라서 반환합니다. 5,000 줄을 다 주는 게 아니라, "마지막 20 개만 주겠다" 거나 "오래된 건 요약해서 주겠다" 거나 — 그건 구현체가 결정해요.
Spring AI 1.1.x 가 표준으로 제공하는 구현체는 한 개예요.
MessageWindowChatMemory — 슬라이딩 윈도우 기반. 마지막 N 개의 메시지만 유지 하는 가장 단순하고 효과적인 정책이에요. 기본값은 DEFAULT_MAX_MESSAGES = 20 이고, Builder 로 만들어요.
MessageWindowChatMemory.builder()
.chatMemoryRepository(repo)
.maxMessages(20)
.build();
여기서 보세요.
MessageWindowChatMemory 가 자기 안에 ChatMemoryRepository 를 들고 있어요. 즉 "정책 (얼마나 잘라낼지) 은 내가 알아서 결정하지만, 실제 보관 매체 (DB? 메모리?) 는 너한테 위임할게" 라는 구조예요.
이 분리 덕분에 MessageWindow 의 정책은 그대로 두고 저장소만 메모리 → MySQL 로 갈아끼우는 게 가능 해져요.
(Step 4 에서 이 두 줄을 직접 조립할 거예요.)
"summarization 전략은요?" — 슬라이딩 윈도우 말고, 오래된 대화는 LLM 으로 요약해서 들고 가는 전략이에요. 1.1.x 표준 라이브러리에는 내장 구현체가 없어요. 직접
ChatMemory인터페이스를 구현하거나 별도 커뮤니티 라이브러리를 가져다 써야 해요. 이 전략의 트레이드오프 (요약 호출 비용 vs 토큰 절약) 는 Step 7 에서 본격 비교할 거예요. 오늘 Step 2 에선 "표준 슬라이딩 윈도우 하나만 손에 쥐고 가자" 가 메인이에요.
4. 세 번째 층 — MessageChatMemoryAdvisor (전령)
자, Repository 와 Memory 가 손에 들어왔다고 쳐도 — 우리가 매번 컨트롤러에서 이런 코드를 짜야 한다면 어떨까요?
1. ChatClient 호출 직전 → ChatMemory.get(conversationId) 로 이력 끌어옴
2. 끌어온 이력을 프롬프트에 합쳐서 모델 호출
3. 응답 받음
4. ChatMemory.add(conversationId, [user 메시지, assistant 메시지]) 로 저장
이걸 컨트롤러 / 서비스마다 반복하면 Step 1 의 표 에서 봤던 4 가지 손코딩이 다시 등장해요. Day 4 에서 retry 정책을 손으로 펴봤을 때처럼, 추상화 없이 짜면 코드가 무거워지죠.
여기서 등장하는 게 MessageChatMemoryAdvisor 예요. Spring AI 의 Advisor 체인 의 첫 적용 사례죠.
Advisor 가 뭐냐면, "ChatClient 호출 전후로 끼워넣을 수 있는 중간 추상화" 예요. AOP 의 around advice 같은 거라고 생각하시면 편해요.
MessageChatMemoryAdvisor.builder(chatMemory).build();
빌더 한 줄이 끝이에요. 그리고 이걸 ChatClient 에 한 번 등록해두면 (Step 4 에서 직접 등록할 거예요) — 위 4 단계가 자동 으로 일어나요.
- ChatClient 가 모델로 요청 보내기 직전 → Advisor 의
before(...)호출 → ChatMemory 에서get(conversationId)결과를 메시지 시퀀스에 끼워넣음 - 모델 응답 받은 뒤 → Advisor 의
after(...)호출 →add(conversationId, [user message, assistant message])로 저장
우리는 "conversationId 만 넘겨줘" 만 신경 쓰면 됩니다. 이력 조회·정렬·윈도우 자르기·새 메시지 저장 — 네 가지가 한 번에 사라져요.
💡 지난 시간 Day 4 Step 6 에서 우리가 retry → fallback 을 손으로 짠 게 기억나시죠? 그때 짠 try-catch 와
attempt < 3루프를 떠올려보세요. 만약 retry 전용 advisor 같은 게 있다면 그 로직도 똑같은 방식으로 한 줄짜리 advisor 로 단순화될 수 있어요. (1.1.x 표준엔 내장 retry advisor 가 박혀있진 않지만, Advisor 라는 추상화의 큰 그림 이 그래요 — 횡단 관심사를 ChatClient 호출 전후로 끼워넣는 패턴.) 본 도메인의 retry 정책 회수는 즉시 적용분 =spring.ai.retry.*자동 RetryTemplate · 운영 묶음 = Day 19 Harness 엔지니어링 두 길로 미뤄둔 그 약속이고, 오늘은 Memory 용 Advisor 가 그 추상화의 첫 적용이에요.
5. 왜 셋으로 나눴는가 — SRP 와 갈아끼우기
여기서 한 번 멈추고 왜 이렇게 굳이 셋으로 갈라놨는지 짚고 가야 해요. 처음 보면 "한 클래스에 다 담으면 안 돼?" 라는 의문이 들 수 있거든요.
이유는 두 가지예요. 둘 다 백엔드 설계의 단골 원칙 이에요.
(a) 단일 책임 원칙 (SRP) — 한 클래스가 너무 많은 변경 이유 를 갖지 않아야 해요. 만약 정책과 저장소가 한 클래스에 묶여 있다면:
- "윈도우 사이즈를 20 → 30 으로 바꾸자" → 그 클래스 변경
- "메모리 → MySQL 로 옮기자" → 그 클래스 변경
- "Cassandra 로 옮기자" → 또 그 클래스 변경
변경 이유가 두 개 이상 같은 클래스에 박히면, 한쪽 수정하다가 다른 쪽이 깨져요. 갈라놓으면 정책만 손대거나 저장소만 손대거나 가 가능해져요.
(b) 갈아끼우기 (Strategy Pattern 의 결) — 인터페이스를 분리해두면 조합 이 가능해져요. 우리 강의 흐름을 보면:
| 시점 | Repository | Memory |
|---|---|---|
| Step 3 | JdbcChatMemoryRepository (MySQL) |
(아직 미정) |
| Step 4 | JdbcChatMemoryRepository |
MessageWindowChatMemory(maxMessages=20) |
| Step 7 | JdbcChatMemoryRepository |
MessageWindowChatMemory(maxMessages=10) 으로 조정 |
Step 7 에서 정책만 바뀌어요. 저장소는 그대로예요. 갈라놨기 때문에 가능한 갈아끼우기죠. 만약 한 클래스였다면 정책만 바꾸려고 해도 저장소 코드까지 손대게 됐을 거예요.
6. 우리는 이 셋을 어떻게 쓸 것인가 — 미리 보기
자, 그래서 Day 5 의 흐름 안에서 이 3 각 구조가 어떻게 순서대로 손에 들어오는지 미리 펼쳐드릴게요. 지금 다 외우라는 게 아니에요. "아 이런 순서로 쌓아가는구나" 정도만 머리에 박아두시면 돼요.
| 위치 | 컴포넌트 | 어디서 등장 |
|---|---|---|
| 저장소 (Repository) | JdbcChatMemoryRepository |
Step 3 — MySQL 영속화 도입 |
| 정책 (Memory) | MessageWindowChatMemory(maxMessages=20) |
Step 4 등록 → Step 7 에서 수치 조정 |
| 주입기 (Advisor) | MessageChatMemoryAdvisor |
Step 4 등록 (한 줄) |
| 통합 적용 | SoulmateChatService 가 conversationId 로 세션 세이브/로드 |
Step 5 에서 게임 도메인에 본격 통합 |
🙋 한 학생의 걱정 — "튜터님, 왜 정책(`ChatMemory`)과 저장소(`ChatMemoryRepository`)를 분리했어요?..."
"튜터님, 왜 정책(
ChatMemory)과 저장소(ChatMemoryRepository)를 분리했어요? 한 클래스에 다 담으면 코드도 짧고 좋잖아요.MessageWindowChatMemoryWithJdbc같은 클래스 하나로 박으면 안 돼요? "
날카로워요! 실제로 짧은 데모 만 짜는 거라면 한 클래스에 다 담는 게 코드는 짧아요. 그런데 두 가지가 따라옵니다.
첫째, 5 절에서 짚은 SRP — 변경 이유가 두 개 라 한 클래스에 박으면 어느 한쪽만 수정하기 가 어려워져요. 우리 강의에서 Step 7 에 정책만 바꾸는 풍경이 등장하는데, 분리되어 있어야 그 갈아끼우기가 한 줄로 끝나요.
둘째, 조합 폭발 — 만약 한 클래스에 다 담는다고 치면, 우리가 만들 수 있는 조합이 (정책 N 개) × (저장소 M 개) = N×M 개의 클래스 가 돼요. MessageWindowJdbc, MessageWindowInMemory, MessageWindowCassandra, SummarizationJdbc, SummarizationInMemory... 끝이 없어요.
분리해두면 N + M 개 만 만들면 자유롭게 조합돼요. (스프링 데이터의 JpaRepository 가 엔티티 × 저장소 를 분리해둔 것과 같은 패턴이에요.)
셋째 (이건 보너스), Spring AI 입장에서도 추상화를 잘게 쪼개두면 써드파티가 새 저장소 구현체만 추가해도 기존 정책 코드를 그대로 쓸 수 있어요. Cassandra·Neo4j starter 가 정책 코드는 건드리지 않고 ChatMemoryRepository 만 구현해서 끼울 수 있는 게 그 덕분이에요.
요약하자면, 지금 코드는 한 줄 길어 보여도 6 개월 뒤 코드는 짧아진다 는 게 분리의 가치예요. 🎯
8. 💡 튜터의 결론
Step 2 의 한 문장 요약은 이래요.
"ChatMemory 는 셋으로 갈라져 있다. 어디에 보관할지 (Repository) · 얼마나 들고 갈지 (Memory) · 호출에 자동으로 끼워넣기 (Advisor) — 셋의 책임이 분리되어 있어서 갈아끼우기가 쉽다."
이론은 여기까지예요. 다음 Step 3 부터는 진짜 손으로 만들어요. 가장 아래층 — JdbcChatMemoryRepository 를 도입해서 우리 MySQL 에 대화 이력 테이블을 박는 풍경부터 시작할게요. "메시지가 진짜로 DB 에 INSERT 되는 걸 눈으로 본다" 는 게 Step 3 의 목표예요.
Step 3: `JdbcChatMemoryRepository` 도입 — MySQL 에 대화 영속화
자, Step 2 에서 그림으로 펼쳐둔 3 각 구조의 가장 아래층 부터 손에 쥡니다. 오늘 Step 3 의 목표는 한 줄이에요.
"MySQL 에 대화를 저장하는 저장소 빈을 자동설정으로 등록한다."
Step 1 에서 우리가 "두 번째 호출에 모델이 첫 호출을 못 알아본다" 는 풍경을 봤죠.
그 메시지를 어디에 보관할 거냐 는 질문에 답하는 단계예요.
다행히 우리가 ChatMemoryRepository 를 손으로 짜는 게 아니에요.
Spring AI 가 starter 한 줄로 빈 등록 + 자동설정 + schema sql 까지 다 묶어서 던져줘요.
1. build.gradle 에 starter 한 줄 추가
가장 먼저 의존성 한 줄. 이게 오늘 Step 3 의 물리적인 변화 의 거의 전부예요.
// 🧠 Spring AI ChatMemory — JDBC 기반 영속화 (Day 5 Step 3 에서 추가)
implementation 'org.springframework.ai:spring-ai-starter-model-chat-memory-repository-jdbc'
이 한 줄을 비유로 표현하면 "가구 풀세트가 박힌 이케아 박스" 예요. 박스 하나 풀었더니 책상·의자·조명까지 다 한 번에 딸려나오는 풍경이죠. 이 starter 가 풀어주는 게 정확히 세 가지예요.
| starter 가 가져오는 것 | 우리 코드에 미치는 영향 |
|---|---|
(a) JdbcChatMemoryRepository 빈 자동 등록 |
@Autowired ChatMemoryRepository 한 줄로 주입받을 수 있게 됨 |
| (b) MySQL · H2 · Postgres · Oracle 등 dialect 자동 인식 | 우리는 어떤 DB 인지 신경 안 써도 됨. JDBC URL 보고 알아서 결정 |
| (c) 각 DB 별 schema sql 파일을 클래스패스에 동봉 | schema-mysql.sql, schema-h2.sql 등이 starter jar 내부 에 들어 있음 |
여기서 (c) 가 살짝 헷갈릴 수 있는데, schema sql 파일은 우리 코드베이스에 새로 추가되는 파일이 아니에요. starter jar 안에 박혀서 함께 배포되는 리소스고, 우리 앱이 기동할 때 그 jar 내부의 sql 을 그대로 실행 합니다. src/main/resources/ 어디에도 새 파일을 만들 필요가 없어요.
2. application.yml 에 한 영역 추가
이제 "그 schema sql 을 진짜 실행해줘" 라고 한 줄 켜주기만 하면 돼요.
# ⓓ ChatMemory JDBC schema (Day 5 Step 3) — SPRING_AI_CHAT_MEMORY 테이블을 앱 기동 시 자동 생성
# 기본값(embedded)은 H2 같은 임베디드 DB 만 초기화하므로, MySQL(docker) 도 자동 초기화하려면 always.
# 운영 전환 시엔 never 로 두고 별도 마이그레이션 도구(Flyway 등) 로 관리한다.
chat:
memory:
repository:
jdbc:
initialize-schema: always
(파일: src/main/resources/application.yml, spring.ai 블록 안. 들여쓰기는 4-space 그대로예요.)
initialize-schema 의 옵션 세 가지가 의미하는 바를 한 줄씩 잡고 가요.
| 값 | 의미 | 우리 강의에서의 용도 |
|---|---|---|
embedded (기본값) |
H2 같은 임베디드 DB 만 자동 초기화. MySQL 같은 외부 DB 는 건드리지 않음 | 우리는 docker 프로파일에서 MySQL 을 쓰니까 이 기본값으론 부족 |
always |
어떤 DB 든 무조건 자동 초기화 | 우리가 채택 — 학생 실습 환경에서 마찰 없이 바로 돌리기 위해 |
never |
자동 초기화 안 함. 사람이 수동으로 sql 실행하거나 마이그레이션 도구 사용 | 운영 — Flyway / Liquibase 가 정석. 우리 강의는 Day 20 에서 짧게 짚을 거예요 |
학생 실습 단계에선 always 가 마찰을 줄여줘요. 그런데 운영 으로 넘어갈 때는 반드시 never + Flyway / Liquibase 조합으로 갈아끼워야 해요. "DB 스키마는 앱 기동의 부수효과로 만들지 말고, 의도된 마이그레이션으로 만들어라" 가 운영 원칙이에요. (이 한 줄만 머리에 박아두시면 돼요.)
3. starter 가 만들어주는 테이블 구경하기
자, 그럼 그 jar 안에 들어있다는 schema sql 이 실제로 어떤 테이블을 만들길래 우리가 한 줄로 끝낼 수 있는 걸까요? Spring AI 1.1.0 의 schema-mysql.sql 은 정확히 이 모양이에요.
CREATE TABLE IF NOT EXISTS SPRING_AI_CHAT_MEMORY (
`conversation_id` VARCHAR(36) NOT NULL,
`content` TEXT NOT NULL,
`type` ENUM('USER', 'ASSISTANT', 'SYSTEM', 'TOOL') NOT NULL,
`timestamp` TIMESTAMP NOT NULL,
INDEX `SPRING_AI_CHAT_MEMORY_CONVERSATION_ID_TIMESTAMP_IDX` (`conversation_id`, `timestamp`)
);
컬럼 네 개 + 인덱스 한 개. "이게 다야?" 싶을 만큼 단순하죠. 한 컬럼씩 의미를 짚어볼게요.
conversation_idVARCHAR(36) — 대화방 식별자. 한 사용자의 한 회차 대화가 한conversation_id로 묶여요. 36 이라는 숫자가 결정적이에요. UUID 한 개가 정확히 36 자 (32 자 hex + 4 개 하이픈) 거든요. 즉 "이 컬럼은 UUID 받기로 약속하고 만든 자리" 라는 신호예요. 우리도 ai-friends 의 세션 식별자를 UUID 형식으로 만들어 넣어야 들어맞아요. (Step 5 에서 본격 사용)contentTEXT — 메시지 본문. user 메시지·assistant 응답 모두 이 한 컬럼에 들어가요.typeENUM — 메시지의 역할.USER,ASSISTANT,SYSTEM,TOOL네 종류만 받아요. Step 2 에서 본MessageTypeenum 과 1:1 매칭이에요.timestampTIMESTAMP — 저장 시각. PK 가 따로 없고 timestamp + conversation_id 조합으로 인덱스만 걸려있어요. 즉 "같은 ms 에 두 메시지가 들어오면 순서가 흔들릴 수 있다" 는 의미인데, 실무에선 LLM 응답이 ms 단위로 두 번 동시에 떨어질 일이 거의 없어서 문제가 안 돼요. 부담스러우면nanoTime으로 바꿔치는 변형을 직접 만들 수 있는 자리이기도 해요.
학생들이 직접 확인하고 싶으면 docker 로 띄운 뒤 한 줄이면 돼요.
docker compose exec mysql mysql -uroot -p aifriends -e 'desc SPRING_AI_CHAT_MEMORY;'
(또는 H2 콘솔로 띄웠다면 http://localhost:8080/h2-console 접속 → SHOW COLUMNS FROM SPRING_AI_CHAT_MEMORY;)
4. ChatLog 와 SPRING_AI_CHAT_MEMORY — 두 테이블의 역할 분담
자, Step 1 의 6 절에서 한 학생이 이렇게 물었어요. "우리 코드베이스에 이미 ChatLog 도메인이 있는데 또 새 테이블을 만든다고요?" 그때 "포맷·용도가 다르다" 라고만 짧게 풀었는데, 이제 두 테이블의 컬럼이 손에 잡혔으니 표로 다시 비교해볼게요.
| 구분 | chat_log (우리 비즈니스 로그) |
SPRING_AI_CHAT_MEMORY (LLM 컨텍스트) |
|---|---|---|
| 컬럼 | id (PK auto), soulmate_id, speaker (USER/AI), message (TEXT), created_at |
conversation_id, content, type (USER/ASSISTANT/SYSTEM/TOOL), timestamp |
| 키 / 인덱스 | id PK + (soulmate_id, created_at) 인덱스 |
(conversation_id, timestamp) 인덱스 (PK 없음) |
| 한 행에 들어가는 것 | 한 발화 (user 한 줄, ai 한 줄이 별행) | 한 메시지 (user 와 assistant 가 별행) |
| 도메인 키 | soulmate_id (캐릭터 단위 조회) |
conversation_id (UUID — 세션 단위 조회) |
| 트랜잭션 | 우리 도메인 서비스의 @Transactional |
Spring AI 가 자체 관리 |
| 누가 보는가 | 사람 (운영자·CS·분석팀) | LLM (다음 호출의 컨텍스트로) |
같은 대화 가 두 테이블에 각자의 포맷으로 저장돼요. 처음엔 "중복 아닌가?" 싶지만, 위 표를 보면 행 단위는 비슷해도 키 구조와 컬럼 의미가 달라요. chat_log 는 캐릭터(soulmate_id) 축으로 조회하는 비즈니스 로그 고, SPRING_AI_CHAT_MEMORY 는 세션(conversation_id) 축으로 조회하는 LLM 컨텍스트 죠.
또 speaker 컬럼은 사람이 읽는 USER/AI 두 종 라벨이지만, type 컬럼은 LLM 이 해석하는 USER/ASSISTANT/SYSTEM/TOOL 네 종 role 이에요 —
system 프롬프트 / tool 호출 결과까지 한 흐름에 박혀야 LLM 이 컨텍스트로 받아낼 수 있거든요. 같은 대화의 서로 다른 두 시각 이 그래서 두 테이블로 갈라집니다. 🎯
🙋 한 학생의 걱정 — "튜터님, 그런데 진짜 의문이 하나 있어요. 우리가 이미 만든 `ChatLog` 테이블을 그대로 활용해서 LL..."
"튜터님, 그런데 진짜 의문이 하나 있어요. 우리가 이미 만든
ChatLog테이블을 그대로 활용해서 LLM 컨텍스트로 넘기면 안 돼요? 굳이 새 테이블을 또 만드는 게... 저장 공간도 두 배로 들고, 같은 대화를 두 번 INSERT 하는 거잖아요. 중복 아닌가요? "
진짜 합리적인 의문이에요. 이건 두 갈래로 풀어드릴게요.
첫째, Spring AI 가 이 추상화를 만든 이유 를 보세요. SPRING_AI_CHAT_MEMORY 는 우리 도메인 (미연시 게임) 에 맞춘 테이블이 아니에요. 모든 모델·언어·도메인에서 공통으로 쓸 수 있는 표준 포맷 이에요. 그래서 컬럼이 4 개 (conversation_id, content, type, timestamp) 로 미니멀해요.
우리가 ChatLog 를 LLM 컨텍스트로 직접 매핑하려면 매번 ChatLog → Message 변환 코드를 짜야 하고, 그 코드가 컨트롤러마다 흩어지죠. Step 1 에서 짚었던 "손코딩 4 가지 비용" 이 그대로 따라와요.
둘째, 트레이드오프 의 결론. 저장공간이 약간 늘어나는 건 맞아요. 그런데 그 대가로 우리는 — Step 4 에서 보겠지만 — MessageChatMemoryAdvisor 한 줄로 이력 조회 + 윈도우 자르기 + 새 메시지 저장 이 모두 자동화되는 이점을 얻어요.
저장공간 vs 자동화 — 이 강의 규모에선 자동화 쪽이 압도적으로 큰 가치예요. 운영 비용 감각으로도, 메시지 한 줄 = 보통 수백 바이트 안쪽이라 사용자당 수만 메시지가 쌓여도 GB 단위는 아니에요.
요약: ChatLog 는 사람이 보는 비즈니스 로그, SPRING_AI_CHAT_MEMORY 는 LLM 이 보는 컨텍스트. 같은 대화의 서로 다른 두 시각 이라고 받아들이시면 돼요. 두 테이블이 서로의 데이터를 모르는 채로 각자 자기 책임만 다하는 게 정석이에요. 🎯
6. 💡 튜터의 결론
Step 3 의 한 문장 요약은 이래요.
"
spring-ai-starter-model-chat-memory-repository-jdbc한 줄 +initialize-schema: always한 영역 — 이 두 줄이JdbcChatMemoryRepository빈 자동 등록 · MySQL/H2 dialect 인식 ·SPRING_AI_CHAT_MEMORY테이블 자동 생성까지 다 묶어서 가져온다."
이제 우리 ai-friends 는 대화를 영속적으로 저장할 수 있는 저장소 를 손에 쥐었어요. 그런데 한 가지 빠진 게 있죠. "메시지를 진짜로 거기에 어떻게 쌓고 어떻게 꺼내쓸지" — 그 흐름은 아직 우리가 손코딩으로 짜야 하는 풍경이에요.
다음 Step 4 에서는 그 마지막 한 조각 — MessageChatMemoryAdvisor 등록 — 을 박을 거예요. ChatClient 빌더에 advisor 한 줄 추가하면 "이력 조회 → 프롬프트에 끼워넣기 → 응답 후 저장" 의 4 단계가 자동으로 도는 풍경을 직접 보게 될 거예요.
Step 4: `MessageChatMemoryAdvisor` 등록 — 빈 한 군데로 멀티턴 살리기
자, 드디어 오늘의 하이라이트 입니다.
Step 3 에서 우리는 "메시지를 어디에 보관할 거냐" 의 답 (JdbcChatMemoryRepository) 을 손에 쥐었어요.
하지만 그 저장소는 그냥 거기에 가만히 있을 뿐 이에요.
누군가가 "새 호출 직전에 이력을 꺼내서 프롬프트에 합쳐주고, 응답이 오면 그걸 다시 저장해주는" 일을 해줘야 비로소 멀티턴이 살아나요.
그 누군가 가 바로 오늘 등장하는 MessageChatMemoryAdvisor 입니다.
1. 🎯 Step 4 의 목표 — Service · Controller 한 줄도 안 건드린다
Step 4 의 목표를 한 줄로 박을게요.
"빈 등록 두 군데 (
ChatMemory+MessageChatMemoryAdvisor) 만 손대면,SoulmateChatService와 컨트롤러 코드는 한 줄도 바뀌지 않은 채로 멀티턴이 살아난다."
이 문장이 핵심이에요.
Day 4 에서 우리가 그렇게 공들여 짜둔 SoulmateChatService.chat(...) — .entity(AiReply.class) 받아오는 그 메서드 — 거기에 손도 안 댑니다.
컨트롤러도 마찬가지.
그저 ChatClientConfig.java 한 파일 안에 빈 두 개를 더 박을 뿐인데, 다음 Step 에서 같은 사용자가 "오늘 좀 우울해" → "사실 회사에서 일이 있었어" 를 연달아 보내면 모델이 첫 발화를 이어받아서 자연스럽게 답하게 돼요.
2. Day 4 retry 손코딩의 회수 — Advisor 라는 추상화의 정체
여기서 한 번 멈추고 지난 시간에 뭘 했는지 떠올려보면 좋아요.
Day 4 Step 6 에서 우리는 retry → fallback 정책을 손으로 짰죠.
기억나시죠? attempt < 3 루프에 try-catch 를 둘러서 JsonProcessingException 이 나면 한 번 더 호출하고, 마지막엔 fallback AiReply 를 만들어 돌려주는...
그 코드가 ChatClient 호출 직전·직후로 둘러싸는 형태였어요.
자, 이제 그 풍경에 이름표를 붙여드릴게요.
그게 바로 Advisor 패턴이 끼어드는 지점입니다. Advisor 는 "ChatClient 호출 직전·직후로 일관된 횡단 관심사 (cross-cutting concern) 를 끼워넣는 추상화" 거든요.
지난 시간 Day 4 마무리에서 "손코딩한 retry 도 ChatMemory 도 같은 추상화의 패턴을 따른다" 라고 슬쩍 비춰드렸던 — 바로 그 추상화가 이거예요.
비유로 풀어볼게요.
Advisor 는 무대 뒤 스태프 예요. 배우(ChatClient) 가 무대 위에서 대사를 칠 때, 배우는 자기 대사에만 집중하면 돼요.
조명을 맞추고 음향을 켜고 자막을 띄우는 건 무대 뒤 스태프 의 일이죠.
호출부 (SoulmateChatService) 가 배우라면, "이전 대화 이력을 꺼내서 프롬프트에 합치는" 일은 무대 뒤 스태프 (MessageChatMemoryAdvisor) 가 알아서 해줘요.
배우는 스태프의 존재를 굳이 알 필요가 없어요.
그저 무대 위에서 자기 한 줄 (.user(message).call().entity(AiReply.class)) 만 치면 됩니다.
💡 한 가지 짚고 갈게요. Spring AI 1.1.x 표준 라이브러리에 retry 전용 advisor 가 그대로 들어있는 건 아니에요. 즉 우리가 지난 시간 짠 retry 손코딩을 오늘 한 줄로 갈아치울 수 있는 단계는 아닙니다. 다만 추상화의 자리 가 거기에 마련되어 있다는 게 핵심이에요. ChatMemoryAdvisor 가 그 첫 번째 적용 사례인 거고, 추후 retry · 로깅 · 토큰 가드 · 프롬프트 검열 등이 모두 같은 패턴으로 끼어들 수 있는 풍경이에요. retry 정책 자체의 회수는 즉시 적용분 =
spring.ai.retry.*(application.yml 한 영역 추가) · 운영 정책 묶음 = Day 19 Harness 엔지니어링 으로 두 길이 박혀있고, 오늘 이 한 번을 잘 익혀두면 그 뒤로 등장하는 advisor 들이 모두 같은 패턴으로 익숙해져요.
3. 첫 번째 빈 — ChatMemory 등록
자, 이제 코드로 들어가요. ChatClientConfig.java 에 chatMemory(...) 메서드를 추가합니다. 이게 3 각 구조의 두 번째 층 — 정책 (Memory) 이에요.
/**
* Day 5 Step 4 — 슬라이딩 윈도우 기반 ChatMemory 빈.
*
* <p>{@link ChatMemoryRepository} 는 Day 5 Step 3 에서 등록한
* {@code JdbcChatMemoryRepository} 가 자동 주입된다 (MySQL/H2 영속화).
* maxMessages = 20 은 sliding window 의 상한 — 가장 최근 20 개 메시지만 LLM 컨텍스트로 흘러간다.
* Step 7 에서 토큰 비용·기억 길이 트레이드오프를 따라 이 값을 조정한다.</p>
*/
@Bean
public ChatMemory chatMemory(ChatMemoryRepository chatMemoryRepository) {
return MessageWindowChatMemory.builder()
.chatMemoryRepository(chatMemoryRepository)
.maxMessages(20)
.build();
}
(파일: src/main/java/kr/spartaclub/aifriends/chat/config/ChatClientConfig.java)
이 메서드 한 덩어리에서 손에 잡고 가야 할 게 두 줄이에요.
첫 줄 — ChatMemoryRepository chatMemoryRepository 파라미터. 이 파라미터가 어디서 오는지 보이시죠? Step 3 에서 starter 가 자동 등록해준 JdbcChatMemoryRepository 빈이 그대로 흘러들어오는 자리 예요. 우리가 @Autowired 도 안 박았어요. 메서드 파라미터로 선언만 하면 스프링이 알아서 주입해줍니다.
이게 Step 3 의 starter 한 줄이 진짜로 살아나는 첫 순간 이에요.
두 번째 줄 — .maxMessages(20). 이 숫자가 sliding window 의 상한 이에요. 즉 대화가 100 턴이 넘어가도 LLM 한테 보내지는 건 가장 최근 20 개 메시지 뿐 이라는 약속이에요. 왜 자르냐 면, LLM 의 토큰 비용은 입력 길이에 비례하기 때문이에요. 모든 이력을 매번 다 넣으면 100 번째 호출에서 청구서가 100 배가 돼요.
적정선이 바로 이 sliding window 의 가치예요. (Step 7 에서 이 값을 조정 하는 풍경이 등장합니다 — 정책만 바꾸고 저장소는 그대로 두는 그 갈아끼우기.)
참고로
MessageWindowChatMemory.builder()가 빌더 패턴 으로 되어있는 이유는, 나중에 새 정책을 추가할 여지 를 비워두기 위해서예요. 가령.minRetainedMessages(5)(요약 시 최소 보관 수) 같은 옵션이 추가될 수 있어요. 우리는 1.1.x 의 표준 옵션 두 개 (chatMemoryRepository,maxMessages) 만 쓸 거예요.
4. 두 번째 빈 — defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
자, 이제 주입기 (Advisor) 차례예요. 이건 별도 @Bean 메서드를 만드는 게 아니에요. 우리가 Day 3 에서 만든 soulmateChatClient(...) 빈 안에 한 줄 을 추가하는 형태로 들어옵니다.
/**
* 소꿉친구 페르소나가 기본으로 장착된 ChatClient.
*
* <p>defaultSystem 으로 페르소나를 박아두면, 이 빈을 주입받는 서비스는
* .user(...) 만 호출해도 시스템 프롬프트가 자동으로 앞에 붙는다.
* 페르소나가 늘어나면 @Bean 메서드 이름이 그대로 Qualifier 가 된다 (senpaiChatClient 등).</p>
*
* <p>Day 5 Step 4 — defaultAdvisors 에 {@link MessageChatMemoryAdvisor} 를 등록해서
* 모든 호출 직전에 ChatMemory 의 이력이 자동 주입되고, 응답 직후에 새 메시지가 자동 저장되도록 한다.
* 호출부(Service)는 advisor 의 존재를 모른 채 한 줄로 호출한다.</p>
*/
@Bean
public ChatClient soulmateChatClient(ChatClient.Builder builder, ChatMemory chatMemory) {
return builder
.defaultSystem("""
너는 유저의 오랜 소꿉친구 역할을 하는 AI 친구야.
반말로 편하고 따뜻하게 답하되, 답변은 3문장 이내로 간결하게 해.
유저의 감정이 드러나는 말에는 먼저 공감한 뒤 대화를 이어가.
""")
.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
.build();
}
여기서 시선을 박아둘 곳은 정확히 한 줄이에요.
.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
한 줄 이에요. 진짜로 한 줄 입니다. 이 한 줄이 매 호출마다 다음 네 가지 일을 자동으로 해줘요.
| 시점 | Advisor 가 하는 일 |
|---|---|
호출 직전 (before) |
ChatMemory.get(conversationId) 호출 → 이력 메시지 시퀀스를 새 prompt 에 끼워넣음 |
| 모델 호출 | (advisor 가 손대지 않음, ChatModel 이 그대로 처리) |
응답 직후 (after) |
user 메시지 + assistant 메시지를 묶어서 ChatMemory.add(conversationId, [user, assistant]) 로 저장 |
| 다음 호출 | 다시 before 로 돌아가 방금 저장한 이력까지 포함된 시퀀스를 끼워넣음 |
MessageChatMemoryAdvisor 의 빌더가 만들어내는 advisor 객체는 이 네 시점을 처리하는 before(...) / after(...) / adviseStream(...) 세 개의 훅 메서드를 구현하고 있어요.
우리는 그 메서드 본문을 손으로 짤 필요가 없어요.
빌더에 ChatMemory 한 개만 넘겨주면 Spring AI 가 표준 구현을 그대로 끼워줍니다.
defaultAdvisors(...) 의 default 라는 단어도 의미가 있어요.
"이 ChatClient 빈을 주입받는 모든 호출에 기본으로 이 advisor 를 적용해라" 라는 뜻이에요.
즉 SoulmateChatService.chat(...) 에서 ChatClient 를 한 번 호출하든 백 번 호출하든, 매번 이 advisor 가 자동으로 끼어듭니다.
한 군데서 정책을 박아두면 전체 호출의 행동이 일괄 변경 되는 — 이게 바로 빈 등록이라는 행위가 가지는 강력함이에요.
5. 여전히 남은 한 가지 — conversationId 정책
자, 빈 두 개 등록은 끝났어요. 그런데 세심한 학생 이라면 한 가지가 걸릴 거예요. "그래서 그 conversationId 는 누가 정해줘?" — 이게 Step 4 가 의도적으로 미뤄둔 마지막 한 조각 이에요.
지금 우리가 등록한 advisor 는 ChatClient 호출 시 conversationId 를 따로 안 넘기면 내부 default conversation id 로 모든 호출을 묶어요. 즉 모든 사용자 · 모든 세션의 대화 가 한 자루에 섞이는 풍경이 돼요.
ai-friends 가 나 혼자 쓰는 데모 라면 일단 동작은 해요.
하지만 진짜 운영 으로 가면 이건 큰 사고예요.
사용자 A 의 "오늘 좀 우울해" 가 사용자 B 의 다음 호출에 컨텍스트로 끼어들 수 있거든요.
게다가 한 사용자 안에서도 어제 대화 와 오늘 대화 를 분리하고 싶을 때 (회차 / 세션) 는 더 잘게 쪼갠 ID 가 필요해요.
이 마지막 한 조각 이 다음 Step 5 의 메인 테마예요. 거기서 우리는 conversationId 를 사용자 + 페르소나 + 세션 단위로 만들어서 ChatClient 호출 시 명시적으로 넘기는 풍경을 박을 거예요.
🙋 한 학생의 걱정 — "튜터님... 잠깐만요. 진짜로요? `SoulmateChatService` 와 컨트롤러를 **한 줄도** 안 ..."
"튜터님... 잠깐만요. 진짜로요?
SoulmateChatService와 컨트롤러를 한 줄도 안 고쳤다고요? Step 5 에서chat(...)메서드 안에ChatMemory.get(...)같은 호출을 짜야 할 줄 알았는데, 그게 진짜 빈 등록 한 군데서 다 처리된다고요? 어떻게 호출부 코드는 그대로인데 행동만 바뀔 수가 있어요? 마법인가요? "
마법처럼 보이지만 마법은 아니에요. 구조 의 결과예요. 두 갈래로 풀어드릴게요.
첫째, defaultAdvisors(...) 의 정체. 우리가 이 한 줄을 박는 순간 — ChatClient.Builder 는 그 빌더가 만들어내는 ChatClient 객체의 내부 호출 체인 에 advisor 를 끼워넣어요.
즉 SoulmateChatService 가 호출하는 soulmateChatClient.prompt().user(...).call() 의 내부 에서 ChatClient 자기가 알아서 "호출 시작! advisor.before() 부르기 → 모델 호출 → advisor.after() 부르기 → 결과 돌려주기" 를 해요.
호출부는 그 체인의 존재를 알 필요가 없어요.
체인이 하는 일은 ChatClient 의 관심사이지, Service 의 관심사가 아니에요. 이게 캡슐화의 가치죠.
둘째, 빈 등록의 강력함. 같은 soulmateChatClient 빈을 주입받는 곳이 한 군데든 열 군데든, 그 빈의 행동 을 한 군데서 정의하면 모든 호출의 행동이 일괄 변경돼요. 이게 스프링이 우리에게 주는 큰 약속 이에요. 만약 우리가 advisor 를 호출부 (Service) 에 박았다면? 호출부가 늘어날 때마다 복붙 해야 했을 거고, 어느 한 곳에서 빠뜨리면 그 호출만 멀티턴이 안 살았겠죠.
빈 등록 한 군데로 모든 호출의 횡단 행동 을 갈아끼우는 — 이게 Spring DI 와 Advisor 패턴이 만나는 지점이에요.
요약: 마법이 아니라 구조 예요.
ChatClient 의 내부 호출 체인 + 빈 등록의 일괄 적용 — 이 두 가지가 만나면 호출부 코드는 그대로인데 행동은 바뀌는 풍경이 만들어져요.
그리고 이 풍경은 앞으로 등장할 모든 advisor (RAG advisor · safety advisor · token guard advisor 등) 에 동일하게 적용돼요.
오늘의 ChatMemoryAdvisor 가 바로 그 첫 적용 사례입니다. 🎯
7. 💡 튜터의 결론
Step 4 의 한 문장 요약은 이래요.
"빈 두 개 (
ChatMemory+defaultAdvisors(MessageChatMemoryAdvisor)) 한 군데 등록만으로, 호출부 코드는 한 줄도 바뀌지 않은 채로 멀티턴이 살아난다. — Advisor 는 무대 뒤 스태프, 호출부는 그 존재를 모른다."
이제 우리 ai-friends 는 이론상 멀티턴 대화가 가능해진 상태예요.
다만 한 가지 진짜 실전 문제 가 남아있어요.
모든 호출이 default conversation 한 자루에 섞이는 구조라, 사용자별·세션별 분리가 안 돼있어요.
다음 Step 5 에서는 conversationId 를 발급·전달하는 정책을 세우고 ChatClient 호출 시 명시적으로 넘기는 풍경을 박아서, 진짜 ai-friends 운영급 으로 한 단계 더 올라갑니다.
Step 5: `conversationId` 로 세션 분리 + 세이브/로드 엔드포인트
자, Step 4 마무리에서 우리가 의도적으로 미뤄둔 그 한 조각을 드디어 펼칩니다.
지금 ChatClient 호출은 advisor 가 자동으로 이력을 꺼내고 저장해주긴 해요.
그런데 그 이력이 어느 자루에 담기는지 를 호출부에서 안 정해주면, advisor 는 내부 default conversation id 한 자루에 모든 사용자 · 모든 세션 을 통째로 섞어요.
데모 단계에선 동작하는 척 보이지만, 진짜 사용자 두 명만 들어와도 사용자 A 의 "오늘 좀 우울해" 가 사용자 B 의 다음 호출 컨텍스트로 새는 사고가 납니다.
오늘 Step 5 가 이걸 풀어요.
1. 🎯 Step 5 의 목표
한 줄로 박을게요.
"
conversationId를 호출 단위로 명시적으로 넘겨서 사용자 / 세션별로 자루를 분리하고, 그 자루를 외부에서 조회 / 삭제 할 수 있는 엔드포인트를 같이 연다."
Step 4 가 "메모리 자체" 를 살린 단계였다면, Step 5 는 "그 메모리를 누구의 것으로 묶을 거냐" 의 단계예요. 그리고 그 묶음을 외부에서 들춰보고 비울 수 있는 두 개의 엔드포인트 (GET /sessions/{id}, DELETE /sessions/{id}) 도 같이 박습니다.
2. conversationId 정책 — 두 후보 비교
먼저 누가 그 conversationId 를 만들 거냐 를 결정해야 해요. 두 갈래의 후보가 있어요.
| 정책 | 누가 ID 를 만드나 | 어떻게 동작하나 |
|---|---|---|
| (A) 서버 발급 UUID + 클라이언트가 들고 다님 | 첫 호출 때 서버 가 UUID 발급 | 응답에 conversationId 같이 내려줌 → 다음 호출부터 클라이언트가 그대로 들고 옴 |
| (B) 도메인 키 조합 | 서버가 (userId, soulmateId) 같은 키로 결정 |
클라이언트가 의식할 필요 없음, 서버가 매번 같은 키로 잡음 |
본 강의는 (A) 를 채택 합니다. 이유 두 가지예요.
첫째, ai-friends 도메인은 한 사용자가 여러 세션 을 가질 수 있어요. 같은 캐릭터(소꿉친구) 와도 오늘 대화 와 내일 대화 를 분리하고 싶을 수 있고, 사용자가 "이 세션은 새로 시작할게" 라고 누르면 새 자루로 갈아주고 싶을 수도 있어요.
(B) 의 도메인 키 방식은 한 사용자 = 한 자루 로 고정되어 이런 자유도를 못 줘요.
둘째, 우리가 Step 3 에서 본 SPRING_AI_CHAT_MEMORY.conversation_id 가 VARCHAR(36) 이었던 거 기억하시죠? 그 36 이라는 숫자가 UUID 한 개의 표준 길이 와 정확히 맞아떨어집니다.
"이 컬럼은 UUID 받기로 약속하고 만든 자리" 라는 starter 의 신호를 그대로 따라가는 게 (A) 예요.
💡 운영 단계에서 (B) 같은 도메인 키 조합도 한 옵션이에요. 가령 "사용자별로 캐릭터당 하나의 자루만" 같은 단순 정책이 도메인에 맞는 서비스라면
userId:soulmateId형태로 conversationId 를 결정해서 그대로 박는 것도 가능해요. 다만 그때도 길이 / 포맷이 36 자 안쪽이어야 schema 와 충돌이 안 나요. 이 강의에선 (A) 의 자유도가 미연시 도메인에 더 잘 맞아서 (A) 로 갑니다.
3. SoulmateChatService.chat(...) 시그니처 확장
자, 코드로 들어가요. 가장 먼저 손대는 자리는 서비스 계층 이에요. Day 4 까지 chat(name, mood, message) 였던 시그니처에 conversationId 한 자리를 추가 합니다.
public AiReply chat(String conversationId, String anonymizedUserName, String mood, String userMessage) {
return soulmateChatClient.prompt()
.system(system -> system
.text("""
너는 {userName} 님의 AI 친구야.
유저의 현재 기분은 '{mood}' 이야.
답변은 3문장 이내로, 반말로 친근하게 해.
유저가 이어서 보낼 만한 짧은 답장 후보(choices) 를 2~3개 함께 제안해.
이번 한 턴으로 너에 대한 호감도(affectionDelta) 가 -5~+5 사이에서 얼마나 변할지 정수로 추정해.
""")
.param("userName", anonymizedUserName)
.param("mood", mood))
.user(userMessage)
.advisors(advisor -> advisor.param(ChatMemory.CONVERSATION_ID, conversationId))
.call()
.entity(AiReply.class);
}
전체 메서드 본문 중 진짜로 새로 박힌 줄 은 정확히 한 줄이에요.
.advisors(advisor -> advisor.param(ChatMemory.CONVERSATION_ID, conversationId))
이 한 줄이 Step 4 의 default conversation 풍경을 호출 단위로 갈라주는 핵심입니다. .advisors(Consumer) 는 ChatClient 호출 빌더의 advisor 파라미터를 그 호출에 한해서 채워주는 메서드예요.
우리가 넘기는 키 ChatMemory.CONVERSATION_ID 는 Spring AI 가 표준으로 제공하는 상수 ("chat_memory_conversation_id" 라는 문자열) 예요.
거기에 우리 conversationId 값을 박아두면 advisor 의 before(...) 가 그 ID 로 ChatMemory.get(...) 을 부르고, after(...) 가 같은 ID 로 add(...) 를 부르게 돼요.
호출 단위 격리가 이 한 줄로 끝납니다.
다른 부분은 문자 단위로 그대로 예요. system 프롬프트 · user 메시지 · .entity(AiReply.class) 까지 — Day 4 까지 만들어둔 흐름은 한 줄도 안 건드렸어요. 이게 바로 advisor 패턴 위에서 conversationId 만 갈아끼우는 방식입니다.
4. SoulmateChatController — UUID 발급 + 응답 래핑
서비스가 conversationId 를 받기로 약속했으니, 이번엔 컨트롤러가 그 ID 를 어디서 만들어서 어떻게 흘려줄 것인가 의 풍경이에요.
@RestController
public class SoulmateChatController {
private final SoulmateChatService service;
private final UserAnonymizer userAnonymizer;
private final ChatMemory chatMemory;
public SoulmateChatController(SoulmateChatService service,
UserAnonymizer userAnonymizer,
ChatMemory chatMemory) {
this.service = service;
this.userAnonymizer = userAnonymizer;
this.chatMemory = chatMemory;
}
@GetMapping("/api/chat/soulmate")
public ResponseEntity<ApiResponse<SoulmateChatResponse>> soulmate(
@RequestParam Long userId,
@RequestParam String mood,
@RequestParam String message,
@RequestParam(required = false) String conversationId
) {
String anonymizedName = userAnonymizer.anonymize(userId);
String convId = (conversationId == null || conversationId.isBlank())
? UUID.randomUUID().toString()
: conversationId;
AiReply reply = service.chat(convId, anonymizedName, mood, message);
return ResponseEntity.ok(ApiResponse.success(new SoulmateChatResponse(convId, reply)));
}
핵심을 세 가지로 쪼개볼게요.
(a) @RequestParam(required = false) String conversationId — Day 4 까지 컨트롤러는 userId · mood · message 를 전부 필수 로 받았죠. conversationId 만 required = false 로 풀어준 게 정책 (A) 의 첫 신호예요.
"첫 호출 때는 비워서 와도 돼, 서버가 만들어줄게" 라는 약속이 이 한 줄에 박혀있어요.
(b) UUID 발급 분기 — conversationId 가 null 이거나 blank 면 UUID.randomUUID().toString() 으로 새로 발급, 그렇지 않으면 그대로 사용. 이 if-else 한 덩어리가 정책 (A) 의 본체예요. 이렇게 만든 convId 를 서비스에 넘기고 + 응답에 함께 내려줍니다. 응답에 같이 내려주는 게 결정적이에요.
클라이언트가 그 UUID 를 다음 호출에 다시 들고 와야 멀티턴이 이어지거든요.
(c) ChatMemory chatMemory 생성자 주입 — Step 4 에서 등록한 ChatMemory 빈을 컨트롤러가 직접 들고 있는 게 보이실 거예요. 왜냐고요? 다음 5 절·6 절에서 세션 조회 / 삭제 엔드포인트가 등장하는데, 그 두 자리가 chatMemory.get(...) · chatMemory.clear(...) 를 직접 호출하거든요.
채팅 호출 (/api/chat/soulmate) 은 advisor 가 알아서 처리하지만, 세션 자루를 들춰보거나 비우는 행동은 advisor 의 영역이 아니라 우리 컨트롤러가 명시적으로 해야 하는 일 이에요.
응답에 쓰이는 record 두 개도 같이 박았어요. 한 줄짜리 record 두 개라 같이 보고 가요.
public record SoulmateChatResponse(String conversationId, AiReply reply) {
}
public record SoulmateSessionMessageView(String role, String content) {
}
SoulmateChatResponse 는 채팅 응답에 conversationId 를 함께 내려주려고 만든 record 예요.
SoulmateSessionMessageView 는 다음 5 절 세션 조회 에서 쓰이는데, 굳이 Message 를 그대로 안 흘리고 role/content 두 필드 뷰 로 갈아주는 역할입니다.
그 이유는 5 절에서 펼칩니다.
5. 세션 조회 엔드포인트 — GET /sessions/{conversationId}
자, 그 자루를 들춰보는 첫 번째 엔드포인트.
@GetMapping("/api/chat/soulmate/sessions/{conversationId}")
public ResponseEntity<ApiResponse<List<SoulmateSessionMessageView>>> getSession(
@PathVariable String conversationId
) {
List<Message> messages = chatMemory.get(conversationId);
List<SoulmateSessionMessageView> views = messages.stream()
.map(m -> new SoulmateSessionMessageView(
m.getMessageType().name().toLowerCase(),
m.getText()))
.toList();
return ResponseEntity.ok(ApiResponse.success(views));
}
chatMemory.get(conversationId) 한 줄이 advisor 가 매 호출마다 부르던 그 메서드 와 정확히 같은 메서드라는 점이 중요해요.
즉 우리가 LLM 한테 흘리는 컨텍스트와 동일한 데이터 를 외부에 노출하는 패턴이에요.
사용자에게 "네가 나랑 했던 대화 다시 보여줄게" 라고 보여줄 때, 그게 LLM 이 보고 있는 것과 정확히 같은 것 이어야 자연스럽거든요.
여기서 왜 Message 를 그대로 안 내리고 굳이 SoulmateSessionMessageView 로 변환하는지 를 짚고 가야 해요. 두 가지 이유예요.
첫째, Message 는 Spring AI 의 내부 타입 계층 이에요. UserMessage · AssistantMessage · SystemMessage · ToolResponseMessage 같은 구체 타입이 들어 있고, 각 타입은 프레임워크가 내부적으로 들고 있는 메타데이터 (toolCalls 등) 를 노출해요.
이걸 그대로 응답으로 흘려보내면 우리 응답 스키마가 Spring AI 의 내부 구조에 묶여버려요. Spring AI 1.1.x 에서 1.2.x 로 올라갈 때 Message 의 어떤 필드가 추가되거나 사라지면 우리 클라이언트 (앱 / 웹) 가 예고 없이 깨질 수 있어요.
둘째, 클라이언트가 진짜로 필요로 하는 것 만 노출하는 게 SOLID 의 ISP (Interface Segregation Principle) 의 본뜻이에요. 클라이언트는 role 과 content 만 알면 충분해요. "누가 한 말이고, 뭐라고 했는지" 두 가지면 화면에 그릴 수 있죠. 내부 타입을 외부 응답 스키마로 새는 걸 막는 한 겹의 얇은 뷰 —
그게 SoulmateSessionMessageView 의 정체예요.
m.getMessageType().name().toLowerCase() 가 "user" / "assistant" / "system" / "tool" 중 하나를 돌려주고, m.getText() 가 본문을 돌려줘요. 이 두 줄로 내부 → 외부 변환이 끝납니다.
6. 세션 삭제 엔드포인트 — DELETE /sessions/{conversationId}
다음은 그 자루를 비우는 엔드포인트.
@DeleteMapping("/api/chat/soulmate/sessions/{conversationId}")
public ResponseEntity<ApiResponse<Void>> deleteSession(@PathVariable String conversationId) {
chatMemory.clear(conversationId);
return ResponseEntity.ok(ApiResponse.success(null));
}
본체는 한 줄 — chatMemory.clear(conversationId).
그 한 줄이 ChatMemory 인터페이스의 clear(String) 을 통해 결국 JdbcChatMemoryRepository.deleteByConversationId(...) 까지 흘러가서 MySQL 에서 해당 conversationId 의 모든 메시지 row 를 삭제 합니다.
다른 사용자의 자루는 건드리지 않는다 는 보장도 Step 4 케이스 ④ (chatMemory_clear_isolatedPerConversation) 에서 이미 못 박았던 풍경이에요.
이 엔드포인트가 열리는 도메인 이유는 세 갈래예요.
- 회차 리셋 — 사용자가 "이 캐릭터랑 새로 시작하고 싶어" 를 누를 때
- 운영 — CS 팀이 문제가 생긴 세션 을 비우거나 디버깅용으로 들춰볼 때
- GDPR / 개인정보 — 사용자가 "나에 대한 모든 대화 데이터 삭제해주세요" 를 요청할 때 (right to erasure)
세 번째 케이스의 자세한 운영 정책 (감사 로그 / 보존 기간 / 일괄 삭제 배치 등) 은 Step 8 에서 본격적으로 풀 거예요. 오늘은 엔드포인트와 호출 한 줄이 살아있다 는 사실 자체가 의미예요.
🙋 한 학생의 걱정 — "튜터님... 한 가지 진짜 의문이 있어요. 왜 굳이 conversationId 를 클라이언트한테 넘겨서 들고 다니게 해요? ..."
"튜터님... 한 가지 진짜 의문이 있어요. 왜 굳이 conversationId 를 클라이언트한테 넘겨서 들고 다니게 해요? 서버에서 세션 시작 같은 별도 API 한 번 호출하면 서버가 메모리에 들고 있다가 알아서 잡으면 되잖아요. 클라이언트가 UUID 를 매번 들고 다니는 게 좀 번거로워 보여요. "
좋은 질문이에요. 두 갈래로 풀어드릴게요.
첫째, REST 의 무상태성 (statelessness) 이라는 기둥. REST 의 큰 약속 중 하나가 "서버는 클라이언트의 세션을 들고 있지 않는다" 예요. 들고 있는 순간 서버 인스턴스가 늘어나면 어느 인스턴스가 그 세션을 들고 있느냐 가 문제가 되고, 로드 밸런서가 sticky session 으로 묶어줘야 하고, 그 인스턴스가 죽으면 세션이 증발하죠.
세션 식별자를 클라이언트가 들고 다니게 하면 어느 인스턴스로 요청이 떨어져도 같은 자루를 정확히 찾을 수 있어요. 이게 우리가 수평 확장 할 때 무너지지 않는 구조예요.
둘째, 학생들이 익숙한 풍경 — JWT 토큰 이에요. 로그인 후 서버가 발급한 JWT 를 클라이언트가 매 요청마다 Authorization: Bearer ... 헤더로 들고 오죠? 그 패턴을 그대로 따라가는 거예요. "서버가 발급, 클라이언트가 보관, 매 호출마다 제출" 의 패턴이 conversationId 에도 동일하게 적용돼요.
다만 JWT 가 "이 사람이 누구냐" 의 식별자라면, conversationId 는 "이 대화가 어느 자루냐" 의 식별자예요. 같은 모양입니다.
💡 만약 (B) 도메인 키 방식 —
userId:soulmateId— 을 썼다면? 클라이언트가 conversationId 를 들고 다닐 필요가 없어서 번거로움 은 사라져요. 다만 그 대가로 한 사용자가 한 캐릭터당 자루 하나 로 고정돼요. 미연시 도메인에서 "이 캐릭터랑 새로 처음부터 시작하고 싶어" 같은 액션을 줄 수 없어져요. 트레이드오프가 명확하죠 — 클라이언트의 번거로움 vs 도메인의 자유도. 우리는 후자를 택한 거예요.
요약: 클라이언트가 들고 다니는 게 번거로워 보여도 그게 REST 의 정석이고, 수평 확장의 안전망이고, 도메인 자유도까지 같이 따라옵니다. 🎯
8. 💡 튜터의 결론
Step 5 의 한 문장 요약은 이래요.
"
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))한 줄 + 컨트롤러의 UUID 발급 분기 — 이 두 자리가 만나면 모든 사용자의 대화가 default 자루에 섞이던 풍경 이 사용자 / 세션별로 분리된 자루 의 풍경으로 바뀐다."
이제 학습용 lab 으로서의 멀티턴은 살아났어요. 두 사용자가 동시에 들어와도 자루가 안 섞이고, 한 사용자도 회차별로 자루를 새로 열 수 있고, 세션을 들춰보거나 비울 수도 있어요. Day 4 마지막에 남겼던 그 찝찝함이 다 풀렸죠.
다만 한 가지 큰 약속 이 아직 안 닫혔어요.
우리가 Day 3 부터 들고 다닌 AiChatController(프론트가 진짜로 호출하는 prod 진입점) — 이 친구는 여전히 레거시 GeminiService 를 호출하고 있어요.
Day 3·4 마무리에서 "Day 5 마무리 시점에 이 백엔드를 갈아끼우고 GeminiService 를 들어낸다" 약속했죠.
그게 다음 Step 6 의 풍경이에요.
이번엔 학습용 lab 의 시그니처(chat(convId, name, mood, msg))가 도메인에 안 맞는다는 걸 한 번 더 직시해요.
그리고 도메인 정합 시그니처 (chat(soulmateId, msg))로 자라면서 system-v1.st·fewshot-v1.st 같은 Day 3 의 외부 자산들이 드디어 살아나는 풍경을 만들 거예요.
Step 6: prod 수렴 — `AiChatController` 가 `SoulmateChatService` 를 흡수, `GeminiService` 제거
Day 3 마무리에서 우리가 약속한 게 두 개였어요.
하나는 "외부 프롬프트 파일(system-v1.st)에 페르소나를 빼두면, 코드 배포 없이 프롬프트만 갈아끼울 수 있다".
다른 하나는 "오늘 만든 lab SoulmateChatService 가 Day 4·5 를 거치며 자라서, Day 5 마무리 시점에 prod 진입점인 AiChatController 의 백엔드를 흡수한다".
오늘 Step 6 이 그 두 약속을 한꺼번에 닫는 단계예요. 그리고 부수적으로 — Day 4 에서 "GeminiService 30 줄 들어내기는 Day 5 의 일" 이라고 미뤄둔 그 약속까지 같이 회수돼요. 약속 셋이 한 Step 에 모입니다.
1. 🎯 Step 6 의 목표 — 약속 셋을 한 Step 에 닫는다
한 줄 요약은 이래요.
"
AiChatController의 백엔드(AiChatService) 가GeminiService대신SoulmateChatService를 호출하도록 갈아끼우고, system-v1.st + fewshot-v1.st 외부 프롬프트가 진짜로 사용되기 시작 하며,GeminiService30 줄을 들어낸다."
세 약속을 다시 펼치면 이래요.
| 회수할 약속 | 출처 | 오늘 Step 6 에서 |
|---|---|---|
| 외부 프롬프트 파일이 진짜로 사용된다 | Day 3 Step 7 | SoulmateChatService.chat(soulmateId, msg) 가 system-v1.st + fewshot-v1.st 를 ClassPathResource 로 로딩 |
| lab 이 prod 를 흡수한다 | Day 3·4 마무리 | AiChatService.processChat 의 LLM 호출이 geminiService.generateReply → soulmateChatService.chat 으로 갈아끼워짐 |
GeminiService 30 줄 들어내기 |
Day 4 Step 1·5 | GeminiService.java · GeminiServiceTest.java · GeminiRequest/Response/ParsedResponse.java 다섯 파일 삭제 |
세 약속이 같이 닫히면 AiChatService 156 줄 → 약 88 줄 로 절반 가까이 줄어요. Spring AI 추상화의 진짜 가치가 코드 감량 으로 증명되는 자리 입니다.
2. 도메인 정합성 — 한 이성친구당 기억공간 하나
수렴 형태 를 결정하기 전에 도메인을 한 번 더 짚어요. ai-friends 의 도메인 사실은 두 가지예요.
- 로그인 없는 싱글플레이 게임 —
userId같은 사용자 식별자가 애초에 없어요. system 프롬프트에{userName}슬롯이 없는 게 자연스러워요. - 한 이성친구 캐릭터당 conversation 하나 — 사용자가 "이 캐릭터랑 새로 시작" 같은 액션을 누를 일이 없어요. soulmateId 가 곧 자루 식별자입니다.
이 두 사실이 Step 5 의 lab 시그니처 와 묘하게 안 맞아요.
Step 5 는 "클라이언트가 conversationId(UUID) 를 들고 다닌다" + "anonymizedUserName 을 system 프롬프트에 박는다" 의 학습용 자유도 정책 을 시연했죠.
이건 "인증이 있고 한 사용자가 한 캐릭터당 자루 여러 개를 갖는" 가상 시나리오에선 잘 작동해요.
그런데 우리 도메인은 그게 아니에요. 🤔
| 축 | 학습용 lab (Step 5) | prod 도메인 (Step 6) |
|---|---|---|
| 사용자 식별 | userId → UserAnonymizer |
없음 (싱글플레이) |
| conversationId | UUID 자유 발급, 클라이언트 보관 | String.valueOf(soulmateId) 도메인 키 |
| system 프롬프트 슬롯 | {userName} · {mood} (깡통) |
{gender} · {characterName} · {personality} · {hobbies} · {speechStyles} (Soulmate 도메인) |
| 시그니처 | chat(convId, name, mood, msg) |
chat(soulmateId, msg) |
💡 학습용 lab 시그니처는 제거하지 않고
@Deprecated로 보존해요. Step 5 의SoulmateChatController가 그 lab 진입점을 호출하는 자리고, 학습 시연용으로는 여전히 의미가 있어요 (REST 무상태성·JWT 비교의 모양을 직접 만져볼 수 있는 시나리오니까요). 다만 prod (AiChatController) 는 절대 lab 시그니처를 쓰지 않는다 는 원칙이 박힙니다.
3. SoulmateChatService 진화 — 새 prod 시그니처 추가
자, 코드로 들어가요. SoulmateChatService 에 새 메서드 를 추가하고, 기존 시그니처는 @Deprecated 로 보존해요. 새 메서드의 핵심은 세 가지입니다.
@Service
public class SoulmateChatService {
private final ChatClient soulmateChatClient;
private final SoulmateRepository soulmateRepository;
private final Resource systemV1Resource;
private final Resource fewshotV1Resource;
public SoulmateChatService(ChatClient soulmateChatClient,
SoulmateRepository soulmateRepository,
@Value("classpath:prompts/soulmate/system-v1.st") Resource systemV1Resource,
@Value("classpath:prompts/soulmate/fewshot-v1.st") Resource fewshotV1Resource) {
this.soulmateChatClient = soulmateChatClient;
this.soulmateRepository = soulmateRepository;
this.systemV1Resource = systemV1Resource;
this.fewshotV1Resource = fewshotV1Resource;
}
/**
* Day 5 Step 6 — prod 진입점.
*
* <p>도메인 정책: 한 이성친구당 기억공간 하나 → conversationId = {@code String.valueOf(soulmateId)}.
* system 프롬프트는 Day 3 에서 도입한 외부 파일(system-v1.st + fewshot-v1.st)을 로딩해
* Soulmate 엔티티의 페르소나 컬럼을 슬롯에 박는다. 응답 포맷(JSON Schema)은 Day 4 의
* BeanOutputConverter 가 자동 주입하므로 system-v1.st 에는 # Format 섹션이 없다.</p>
*/
public AiReply chat(Long soulmateId, String userMessage) {
Soulmate soulmate = soulmateRepository.findById(soulmateId)
.orElseThrow(() -> new BusinessException(ErrorCode.SOULMATE_NOT_FOUND));
String conversationId = String.valueOf(soulmateId);
String systemText = readResource(systemV1Resource) + "\n\n" + readResource(fewshotV1Resource);
return soulmateChatClient.prompt()
.system(system -> system
.text(systemText)
.param("gender", soulmate.getGender())
.param("characterName", soulmate.getName())
.param("personality", soulmate.getPersonalityKeywords())
.param("hobbies", soulmate.getHobbies())
.param("speechStyles", soulmate.getSpeechStyles()))
.user(userMessage)
.advisors(advisor -> advisor.param(ChatMemory.CONVERSATION_ID, conversationId))
.call()
.entity(AiReply.class);
}
// ... readResource(Resource) 헬퍼는 try-with-resources 로 ClassPathResource 의 텍스트를 읽음
}
세 자리만 짚어볼게요.
(a) SoulmateRepository + 두 Resource 직접 주입 — Day 3·4 의 lab 시그니처는 호출자가 anonymizedUserName · mood 를 만들어 넘겨줘야 했어요. 이번엔 SoulmateChatService 자체가 Soulmate 도메인을 들고 있는 책임자 가 됩니다. 호출자(AiChatService) 는 "soulmateId 와 메시지만 줘" 한 줄로 끝나요.
이게 흡수 의 코드적 정의예요.
(b) system-v1.st + fewshot-v1.st 동시 로딩 + 슬롯 5 개 — Day 3 에서 외부 파일로 빼놨던 페르소나가 오늘 진짜로 사용되기 시작 해요. system-v1.st 는 # Role / # Context / # Task 만 들고 있고 (# Format 섹션은 Day 4 BeanOutputConverter 와 중복 이라 들어냈어요 —
이 정리는 system-v1.st 파일에서도 6 줄을 같이 들어냈죠). fewshot-v1.st 는 대화 예시 2 개 로 모델한테 "이런 식으로 답해라" 의 톤을 잡아주고요.
슬롯 다섯 개 ({gender} · {characterName} · {personality} · {hobbies} · {speechStyles}) 는 모두 Soulmate 엔티티 컬럼과 1:1 매핑.
(c) String.valueOf(soulmateId) 가 conversationId — Step 5 의 lab 정책 (UUID) 와 결정적으로 다른 방식입니다. ai-friends 도메인은 한 이성친구 = 한 자루 라 클라이언트가 자루 식별자를 들고 다닐 이유가 없어요.
SPRING_AI_CHAT_MEMORY 의 conversation_id 컬럼에 그대로 "1", "2", "3" 같은 문자열이 박혀요 (VARCHAR 36 충돌 없음).
4. ChatClientConfig — defaultSystem 깡통 들어내기
Day 3 Step 2 부터 우리가 박아뒀던 defaultSystem 깡통 페르소나 를 이제 들어내요. 진짜 페르소나는 system-v1.st 에 있고, SoulmateChatService 가 호출 시점에 슬롯을 박으니까요.
// Before (Day 5 Step 4 까지)
@Bean
public ChatClient soulmateChatClient(ChatClient.Builder builder, ChatMemory chatMemory) {
return builder
.defaultSystem("""
너는 유저의 오랜 소꿉친구 역할을 하는 AI 친구야.
반말로 편하고 따뜻하게 답하되, 답변은 3문장 이내로 간결하게 해.
유저의 감정이 드러나는 말에는 먼저 공감한 뒤 대화를 이어가.
""")
.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
.build();
}
// After (Day 5 Step 6)
@Bean
public ChatClient soulmateChatClient(ChatClient.Builder builder, ChatMemory chatMemory) {
return builder
.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
.build();
}
defaultSystem 이 사라지면, ChatClient 호출 시 반드시 호출 시점에 .system(...) 을 넘겨야 시스템 프롬프트가 박혀요.
이게 정확히 Step 5 의 lab 시그니처와 Step 6 의 prod 시그니처 둘 다 가 하는 일이에요.
defaultSystem 을 들어내도 동작에 손해는 없고, 대신 "진짜 system 프롬프트는 system-v1.st 에 산다" 는 학습 메시지가 또렷해져요.
5. AiChatService — Before/After 코드 감량
이제 prod 진입점인 AiChatService.processChat 를 갈아끼워요. Soulmate 조회 + ChatLog 저장 + 호감도/레벨/뱃지 같은 비즈니스 로직은 그대로 유지하고, LLM 호출 부분 한 자리만 갈아끼우는 풍경입니다.
// Before (Day 5 Step 5 까지) — 156 줄
@Slf4j
@Service
@RequiredArgsConstructor
public class AiChatService {
private final SoulmateRepository soulmateRepository;
private final ChatLogRepository chatLogRepository;
private final SoulmateAchievementRepository achievementRepository;
private final GeminiService geminiService; // ← 들어낼 자리
private static final int RECENT_LOGS_LIMIT = 20; // ← ChatMemory 가 대체, 들어냄
private final Map<Long, Integer> consecutiveAffectionMissingBySoulmate = new ConcurrentHashMap<>(); // ← 들어냄
private final Map<Long, Integer> consecutiveChoicesShownBySoulmate = new ConcurrentHashMap<>(); // ← 들어냄
@Transactional
public AiChatResponse processChat(AiChatRequest request) {
Soulmate soulmate = soulmateRepository.findById(...).orElseThrow(...);
// ChatLog 최근 N건 조회 + Asc 정렬 (8 줄) // ← 들어냄
List<ChatLog> recentLogsDesc = chatLogRepository.findBy...
List<ChatLog> recentLogsAsc = new ArrayList<>(recentLogsDesc);
Collections.reverse(recentLogsAsc);
// 호감도 미제공 / 연속 선택지 보정 플래그 계산 (12 줄) // ← 들어냄
int missingCount = consecutiveAffectionMissingBySoulmate.getOrDefault(soulmateId, 0);
boolean requireAffectionInResponse = missingCount >= 2;
// ...
GeminiParsedResponse parsedResponse = geminiService.generateReply(
soulmate, recentLogsAsc, userMessage,
requireAffectionInResponse, forceNoChoices); // ← LLM 호출 자리
// 보정 카운터 갱신 (10 줄) // ← 들어냄
// ChatLog 저장, 호감도/레벨 갱신, 뱃지 체크 ... // ← 유지
}
}
// After (Day 5 Step 6) — 약 88 줄
@Service
@RequiredArgsConstructor
public class AiChatService {
private final SoulmateRepository soulmateRepository;
private final ChatLogRepository chatLogRepository;
private final SoulmateAchievementRepository achievementRepository;
private final SoulmateChatService soulmateChatService; // ← 갈아끼운 자리
@Transactional
public AiChatResponse processChat(AiChatRequest request) {
Long soulmateId = request.soulmateId();
String userMessage = request.userMessage();
// 1. 대화 상대 조회
Soulmate soulmate = soulmateRepository.findById(soulmateId)
.orElseThrow(() -> new BusinessException(ErrorCode.SOULMATE_NOT_FOUND));
// 2. LLM 호출 — Spring AI ChatClient + ChatMemory + system-v1.st 페르소나
AiReply reply = soulmateChatService.chat(soulmateId, userMessage);
// 3. 비즈니스 로그(ChatLog) 저장
chatLogRepository.save(new ChatLog(null, soulmateId, "USER", userMessage, null));
chatLogRepository.save(new ChatLog(null, soulmateId, "AI", reply.aiMessage(), null));
// 4. 호감도 / 레벨 갱신
soulmate.addAffection(reply.affectionDelta());
soulmate.setLevel(1 + (soulmate.getAffectionScore() / 10));
// 5. 뱃지 + 응답
List<String> newBadges = checkAndGrantBadges(soulmate);
return new AiChatResponse(userMessage, reply.aiMessage(), reply.choices(),
soulmate.getId(), soulmate.getAffectionScore(), soulmate.getLevel(), newBadges);
}
}
(파일: src/main/java/kr/spartaclub/aifriends/service/AiChatService.java)
들어낸 자리 셋을 한 줄로 짚어요.
| 들어낸 코드 | 사라진 이유 | 대체자 |
|---|---|---|
GeminiService 의존 |
RestClient + 수동 JSON Schema + ObjectMapper 30 줄 | SoulmateChatService (Spring AI ChatClient) |
recentLogsAsc 컨텍스트 주입 |
ChatLog 에서 손으로 끌어와 LLM 한테 다시 박던 풍경 | MessageChatMemoryAdvisor (advisor 한 줄이 자동) |
| 호감도·선택지 보정 카운터 | 모델이 호감도 0 으로 응답할 때 재호출 보정 하던 13 줄 + 카운터 두 개 | system-v1.st 의 # Task 룰 + ChatMemory 가 지난 턴의 자기 답변을 보면서 일관성 유지 |
특히 세 번째 가 인상적이에요. 멀티턴 컨텍스트가 자동으로 주입되니까 모델이 지난 응답 스타일 을 보고 "아, 호감도 정수 형태로 줘야지" 를 알아서 따라요. 손으로 짠 runtime 보정 코드가 외부 프롬프트의 안정된 룰 + ChatMemory 라는 추상화 두 겹 에 흡수되는 풍경입니다.
6. GeminiService 와 다섯 파일 삭제
코드 갈아끼움이 끝났으니 이제 GeminiService 와 그 동행자들을 깔끔하게 들어내요.
$ git rm \
src/main/java/kr/spartaclub/aifriends/service/GeminiService.java \
src/main/java/kr/spartaclub/aifriends/dto/GeminiRequest.java \
src/main/java/kr/spartaclub/aifriends/dto/GeminiResponse.java \
src/main/java/kr/spartaclub/aifriends/dto/GeminiParsedResponse.java \
src/test/java/kr/spartaclub/aifriends/service/GeminiServiceTest.java
그리고 RestClientConfig 의 geminiRestClient 빈도 같이 들어내요. Day 1 의 jsonPlaceholderRestClient·boredRestClient 같은 RestClient 학습용 빈은 보존 (이건 학습 자산이지 prod 자산이 아니에요).
// Before — RestClientConfig (geminiRestClient 빈 + @Value 두 개)
@Value("${gemini.base-url}") private String geminiBaseUrl;
@Value("${gemini.api-key}") private String geminiApiKey;
@Bean("geminiRestClient")
public RestClient geminiRestClient() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(5000);
factory.setReadTimeout(30000);
return RestClient.builder()
.requestFactory(factory)
.baseUrl(geminiBaseUrl)
.defaultHeader("x-goog-api-key", geminiApiKey)
.defaultHeader("Content-Type", "application/json")
.build();
}
// After — geminiRestClient 빈 통째로 사라짐. JSON Placeholder · Bored API RestClient 만 남음.
7. 통합 테스트 — 갈아끼움이 깨지지 않았다는 증명 ✅
수렴 후 ./gradlew test 한 번 돌려서 모든 흐름이 살아있는지 확인해요.
./gradlew test
BUILD SUCCESSFUL
70 tests completed, 0 failed
70 케이스가 한 번에 Green. 그중 결정적인 두 케이스를 짚어볼게요.
AiChatServiceTest.processChat_success_noBadge — 갈아끼움의 핵심 검증. 이 테스트는 SoulmateChatService 를 mock 으로 stub 하고, AiChatService 가 그 mock 의 결과를 받아 ChatLog 저장 + 호감도 갱신을 정확히 수행 하는지 검증해요.
지난 시간까지 GeminiService mock 이었던 자리가 SoulmateChatService mock 으로 갈아끼워진 게 자연스럽게 통과하면, 비즈니스 로직은 LLM 호출자가 누구든 무관 하다는 사실이 증명됩니다.
ApiIntegrationTest.chatPost — 더미 OpenAI 키 환경에서 Spring AI 가 401 반환 → GlobalExceptionHandler 가 500 응답으로 변환 → 컨트롤러 경로 자체 는 살아있음을 검증. 외부 키가 없는 환경 에서도 Bean 와이어링은 깨지지 않는다 는 안전망이에요.
8. ⚠️ 함정 — 외부 프롬프트 부활 시 만난 ST brace 충돌
자, 70 테스트 Green 까지 받고 수렴 끝 인 줄 알았는데, 실은 한 가지 런타임 사고 가 한 번 더 있었어요.
./run.sh up 으로 진짜 환경에서 채팅을 한 번 때려봤더니 — 아래 같은 에러가 떨어졌어요.
java.lang.IllegalArgumentException: The template string is not valid.
at org.springframework.ai.template.st.StTemplateRenderer.createST(...)
at org.springframework.ai.template.st.StTemplateRenderer.apply(...)
at org.springframework.ai.chat.prompt.PromptTemplate.render(...)
at SoulmateChatService.chat(SoulmateChatService.java:115)
Caused by: org.stringtemplate.v4.compiler.STException
원인을 추적해보면 fewshot-v1.st 의 예시 응답 에 박힌 JSON 때문이에요.
## 예시 1 — 일상 대화 (choices 는 빈 배열)
User: "오늘 점심 뭐 먹었어?"
Assistant:
{
"aiMessage": "*책상 위 커피잔을 살짝 밀며 미소* 오늘은 파스타. ...",
"choices": [],
"affectionDelta": 1
}
Spring AI 의 StTemplateRenderer 는 system 텍스트의 {변수} 를 PromptTemplate 슬롯으로 컴파일해요. 우리는 system-v1.st 의 {gender} · {characterName} 등을 슬롯으로 의도 했지만, fewshot 의 {aiMessage} · {choices} · {affectionDelta} 도 함께 슬롯으로 인식되어버렸어요.
aiMessage 는 valid identifier 라 컴파일은 진행됐는데, JSON 의 따옴표·콜론·공백·괄호가 ST 문법에 어긋나 invalid template string 으로 터져버린 거예요.
해결 두 길이 있어요.
| 옵션 | 동작 | 본 강의 채택? |
|---|---|---|
(A) brace escape — fewshot 텍스트의 { → \{, } → \} 로 코드에서 치환 |
ST 가 \{ 를 literal brace 로 해석 |
✅ 본 강의 채택 |
(B) .messages(List<Message>) 분리 — fewshot 의 User/Assistant 페어를 파싱해서 UserMessage · AssistantMessage 시퀀스로 inject |
system 은 system-v1.st 만, fewshot 은 Message 객체 라 ST 처리 우회 | Spring AI 표준 패턴이지만 파싱 코드 추가 부담 |
본 강의는 (A) 로 갑니다. 코드 한 줄 헬퍼 추가로 끝나요.
private String escapeStBraces(String text) {
return text.replace("{", "\\{").replace("}", "\\}");
}
호출부에서는 String fewshotText = escapeStBraces(readResource(fewshotV1Resource)); 한 줄로 fewshot 만 escape 하고 system-v1.st 은 그대로 슬롯 처리. 두 텍스트의 운명이 다른 게 코드에 명시 됩니다.
그런데 — 이 사고가 어디서 새어 들어왔나?
여기서 더 중요한 학습이 있어요. 이 사고는 70 단위 테스트 + 더미 키 통합 테스트가 한 번도 잡지 못한 자리 였어요. 왜?
| 테스트 종류 | 어디까지 검증하나 | ST 충돌 잡았나 |
|---|---|---|
AiChatServiceTest (단위) |
SoulmateChatService 를 mock 으로 stub → mock 결과를 받는 흐름만 |
❌ mock 이라 실제 prompt render 안 일어남 |
SoulmateChatServiceTest (단위) |
ChatClient 를 deep stub mock → .entity(AiReply.class) 결과만 stub |
❌ 같은 이유 |
ApiIntegrationTest.chatPost (통합) |
OPENAI_API_KEY=test-dummy → OpenAI 가 401 → GlobalExceptionHandler 가 500 변환 |
❌ ST 컴파일이 401 응답 받기 직전 에 일어나는데, 우리는 500 도 OK 로 통과 처리 |
자가검증의 세 그물 이 모두 PromptTemplate.render() 의 ST 컴파일 단계 를 우회한 거예요.
이 사각지대는 ChatClient 추상화의 builder 패턴 과 Spring AI 의 internal lazy compile 의 자연스러운 부작용이라, 완벽히 매끄러운 자가검증 은 어렵습니다.
결국 "진짜 환경에서 한 번 띄워서 실제 호출까지 흘려보는 예행 실행" 이 최종 자가검증 이 됩니다.
💡 실무 일반화 — Spring AI · LangChain · 그 어느 LLM 프레임워크든, 프롬프트가 외부 텍스트 자산 이면서 런타임 템플릿 엔진을 거쳐 모델로 흘러가는 구조라면, 외부 텍스트 안의 우연한 템플릿 메타문자가 컴파일 충돌 을 일으킬 수 있어요. 우리 ST 충돌은 그 패턴의 한 사례. 운영 단계에선 "어떤 외부 텍스트 자산을 추가할 때마다 한 번은 진짜 호출로 예행 실행" 의 룰을 박아둬야 해요.
본 사고의 hotfix 커밋 (d918543) 도 그대로 보존해뒀어요. 코드를 펼쳐 보면 escape 한 줄과 그 위 주석 이 이 함정의 흔적이에요.
🙋 한 학생의 걱정 — "튜터님, 그럼 Step 5 에서 만들었던 `SoulmateChatController` 의 `/api/chat..."
"튜터님, 그럼 Step 5 에서 만들었던
SoulmateChatController의/api/chat/soulmate엔드포인트 — 그건 이제 죽은 코드 아니에요? prod 진입점이AiChatController라면SoulmateChatController는 누가 호출해요? "
좋은 질문이에요. 두 갈래로 풀어드릴게요.
첫째, SoulmateChatController 는 학습용 진입점 으로 의도적으로 보존 해요. ai-friends 의 프론트엔드 게임 화면 은 안 호출하지만, 학생이 curl 또는 Postman 으로 직접 두드려보면서 conversationId UUID 정책 · 세션 조회 / 삭제 같은 풍경을 만져볼 수 있어요.
학습 시연 자리 로서의 가치는 유지돼요.
둘째, 언젠가 ai-friends 가 인증을 도입하고 한 사용자가 한 캐릭터당 채팅 세션 여러 개를 갖는 도메인으로 자라면, SoulmateChatController 의 lab 정책이 prod 정책의 후보 가 될 수도 있어요. 그때를 위해 learning artifact 로 보존해두는 거예요.
코드에 @Deprecated 어노테이션이 박혀있는 게 그 신호.
💡 deprecated 가 죽은 코드 인가요? 라고 물으면 — 아니에요, 보존된 학습 자료에요. IDE 에서 노란색으로 보이는 자리가 "이 시그니처는 Day 5 Step 6 에서 갈라졌다" 는 시간 표시예요. 코드의 역사 가 코드 자체에 박혀있는 풍경입니다.
10. 💡 튜터의 결론
Step 6 의 한 문장 요약은 이래요.
"Day 3·4 의 약속 셋(외부 프롬프트 사용·lab 의 prod 흡수·GeminiService 제거)이 한 Step 에서 모두 닫히고, AiChatService 156 줄이 약 88 줄로 추상화 도입의 가치를 코드 감량으로 증명 한다."
오늘 우리는 ai-friends 의 진짜 prod 진입점(AiChatController)이 Spring AI ChatClient 위에서 동작 하기 시작한 첫 날을 만들었어요.
Day 0 시점의 RestClient + 수동 JSON 파싱 덩어리 가 Spring AI ChatClient + Advisors + 외부 프롬프트 의 깔끔한 레이어드 구조로 자란 풍경. 5 주 강의의 수렴 이라는 단어가 진짜 의미를 갖는 순간이에요.
이제 멀티턴 + 도메인 정합 + 외부 프롬프트 까지 다 갖춘 prod 진입점이 살아있어요. 다음 Step 에서는 한 발짝 떨어져서 얼마나 들고 갈지의 정책 — sliding window 의 maxMessages 손익분기점 — 을 손에 두고 비교해 볼 거예요.
Step 7: 토큰 윈도우 전략 (sliding window vs summarization) + `maxMessages` 실측
자, Step 6 까지로 prod 까지 흡수된 멀티턴 대화 가 살아났어요. conversationId 별로 자루도 분리됐고, system-v1.st·fewshot-v1.st 도 진짜로 사용되기 시작했죠. 이제 한 발짝 떨어져서 진짜 실전 사고 를 한 번 더 떠올려볼게요.
같은 conversationId 로 사용자가 50 턴, 100 턴, 1000 턴 이 누적되면 어떤 일이 벌어질까요? 모든 메시지를 매 호출마다 LLM 한테 넘기면 — 입력 토큰 비용이 호출당 100 배가 돼요.
거기에 응답 지연도 길어지고, attention 도 분산돼서 "네가 100 턴 전에 한 그 한 마디" 같은 결정적 정보를 모델이 오히려 못 집어내요.
메모리는 가지고만 있다고 끝 이 아니라 얼마나 들고 갈지의 정책 이 따로 있어야 해요.
그래서 오늘 Step 7 의 풍경이에요.
1. 🎯 Step 7 의 목표
한 줄로 박을게요.
"sliding window 와 summarization 의 두 전략을 비교하고, 우리는 sliding window 를 왜 채택했는지 의 근거를 손에 들고,
maxMessages의 효과를 직접 실측 으로 확인한다."
코드 변경은 거의 없어요.
Step 4 에서 박아둔 ChatClientConfig.chatMemory(...) 의 maxMessages(20) 한 자리만 살짝 만져가며 시뮬레이션 합니다.
그리고 Step 5 에서 만든 GET /api/chat/soulmate/sessions/{id} 가 그 시뮬레이션의 자 (ruler) 역할을 해줘요.
두 자원을 그대로 들고 가서 직접 손으로 측정해볼 거예요.
2. 두 전략의 큰 그림 — sliding window vs summarization
먼저 두 전략의 모양을 한 표로 비교하고 시작할게요.
| 축 | sliding window | summarization |
|---|---|---|
| 정책 | 마지막 N 개 메시지만 들고 감 | 오래된 메시지를 요약문 으로 압축해서 들고 감 |
| 구현 비용 | 매우 낮음 (Spring AI 1.1.x 표준 MessageWindowChatMemory) |
높음 (요약 LLM 호출 추가, ChatMemory 직접 구현) |
| 토큰 비용 | 안정적 (상한 보장) | 변동적 (요약 호출 ↑, 컨텍스트 토큰 ↓) |
| 정보 손실 | 윈도우 밖은 완전 망각 | 요점은 유지, 디테일은 손실 |
| 호출 지연 | 호출 한 번 | 요약 호출 + 본 호출 → 약 2 배 |
| 적합 도메인 | 캐주얼 대화 · 미연시 · 게임 | 장기 메모 · CS 응대 · 의료 / 법률 상담 |
비유로 풀어볼게요.
sliding window 는 카페 카운터 위의 받침대 예요.
바리스타가 최근 주문 영수증 N 장 만 클립으로 꽂아두고, 새 주문이 들어오면 가장 오래된 한 장을 빼서 휴지통에 던져요.
단순하고 빠르고 상한이 보장 돼요.
카운터 위에 영수증이 3 장이든 10 장이든 공간 사용량은 일정.
다만 휴지통에 던진 한 장은 영영 사라져요.
summarization 은 노련한 비서 예요. 한 시간마다 한 번씩 오래된 영수증 더미를 책상 위에서 한 줄짜리 요약 ("오늘 오전엔 카페라떼 5 잔 · 아메리카노 3 잔 팔림") 으로 압축해서 들고 다녀요. 디테일은 사라지지만 전체 흐름 은 살아있죠.
다만 요약하는 행위 자체에 추가 인력 (= 별도 LLM 호출) 이 필요해요.
3. 본 강의는 sliding window — 그 이유 네 가지
본 강의에선 sliding window 를 채택해서 Day 5 ~ Day 20 전체를 가져갑니다. 결정 근거는 네 가지예요.
(a) Spring AI 1.1.x 표준 구현체 — MessageWindowChatMemory 가 starter 한 줄에 따라오는 기본 정책 이에요. 우리가 직접 구현할 게 한 줄도 없어요. 반면 summarization 은 1.1.x 표준 라이브러리에 따로 박혀있는 구현체가 없어요.
직접 ChatMemory 인터페이스를 구현하면서 요약 호출 로직까지 짜야 합니다.
(b) 무료 티어 친화 — summarization 은 원래의 호출 + 요약 호출 두 번이 일어나요. ai-friends 는 Gemini 2.5 Flash 무료 티어 / Ollama 로컬을 기본 프로바이더로 가는 강의라, 요약 호출이 한 번 더 추가되는 비용이 적지 않아요. sliding window 는 호출 한 번에 끝납니다.
(c) 도메인 적합성 — ai-friends 는 캐주얼한 미연시 대화 예요. 어제 무슨 메뉴를 먹었는지 같은 건 잊어도 자연스럽고, 최근 몇 턴의 감정 흐름 만 살아있어도 캐릭터의 일관성이 유지돼요. 반대로 의료 상담 챗봇이라면 환자가 3 개월 전에 언급한 알레르기 가 결정적이라 summarization 쪽이 적합하겠죠.
도메인이 얼마나 긴 기억을 요구하는가 가 갈림길이에요.
(d) 학습 호흡 — 학생들이 ChatMemory 의 흐름을 처음 익히는 단계에서 기본 구현체를 그대로 갖다 쓰는 풍경 이 먼저 들어와야 해요. 직접 구현 (SummarizingChatMemory 같은 것) 은 심화 트랙 의 영역이에요. 본 강의에선 짚고만 가고 구현은 안 합니다.
4. maxMessages 의 실측 — 직접 따라하기
자, 이론은 끝. 이제 손으로 만져볼 차례예요.
준비물. Step 4 에서 박아둔 ChatClientConfig.chatMemory(...) 의 maxMessages 가 그 값입니다. 이걸 3, 10, 30 으로 바꿔가며 행동을 관찰합니다.
// ChatClientConfig.java — Step 4 에서 박은 빈
return MessageWindowChatMemory.builder()
.chatMemoryRepository(chatMemoryRepository)
.maxMessages(20) // ← 3, 10, 30 으로 바꿔가며 실험할 자리
.build();
실험 절차.
maxMessages값을 3 으로 바꾸고./run.sh로 앱 재기동- 첫 호출에서 conversationId 를 받아두기 (Step 5 에서 봤듯 응답에 함께 내려옴)
- 같은 conversationId 로 짧은 메시지 5 번 보내기 (각 호출이 user 1 개 + assistant 1 개를 적재 → 5 호출 후 누적 메시지는 최대 10 개)
GET /api/chat/soulmate/sessions/{conversationId}로 지금 advisor 가 LLM 한테 넘기는 메시지가 몇 개인지 확인maxMessages를 10 으로 바꾸고 (앱 재기동) — 새 conversationId 로 같은 5 호출 반복 → 다시 조회maxMessages를 30 으로 바꾸고 — 새 conversationId 로 같은 5 호출 반복 → 다시 조회
curl 한 줄짜리 셸 명령으로 그대로 따라할 수 있어요.
# 1) 첫 호출 — conversationId 발급받기
curl "http://localhost:8080/api/chat/soulmate?userId=1&mood=평온&message=안녕"
# 2) 응답에서 conversationId 를 복사한 뒤, 같은 ID 로 4 번 더 호출
CONV="응답에서_받은_uuid"
for i in 1 2 3 4; do
curl "http://localhost:8080/api/chat/soulmate?userId=1&mood=평온&message=메시지${i}&conversationId=${CONV}"
done
# 3) 누적 메시지 확인
curl "http://localhost:8080/api/chat/soulmate/sessions/${CONV}" | jq '.data | length'
예상 결과 표.
maxMessages |
5 호출 후 누적 메시지 | 첫 호출 ("안녕") 기억 여부 |
|---|---|---|
| 3 | 3 (가장 최근만) | ❌ 망각 (window 밖) |
| 10 | 10 (전부) | ✅ 기억 |
| 30 | 10 (아직 한도 안 침) | ✅ 기억 |
maxMessages = 3 일 때 누적이 3 으로 잘려있는 게 핵심이에요. 5 호출 × 2 메시지 = 10 개를 적재했지만 advisor 의 before(...) 가 LLM 에게 넘기는 건 마지막 3 개만.
그러니 첫 호출에서 한 "안녕" 은 컨텍스트에 없어서, 다음 호출에서 "아까 인사했지?" 같은 회수가 안 됩니다.
maxMessages = 10 은 한도가 정확히 누적과 같아서 전부 보존 이에요. 30 은 한도가 누적보다 커서 역시 전부 보존되는데, 아직 자르기가 일어나지 않은 상태일 뿐 50 턴, 100 턴 가면 결국 30 에서 잘립니다.
5. maxMessages 의 손익분기점 — 우리 도메인은 얼마가 적당한가
자, 실측을 손에 들고 우리는 왜 20 으로 박았나 의 답을 정리할게요.
너무 작으면. 대화 흐름이 끊겨요. 사용자가 "아까 그 얘기" 라고 했을 때 캐릭터가 "무슨 얘기?" 라고 되묻는 사고가 자주 납니다. 미연시 도메인에서 이건 몰입을 깨는 결정적 사고예요. 감정 누적 (호감도 변화의 결) 도 사라지죠.
너무 크면. 토큰 비용 · 지연 · attention 분산이 커져요. 한 호출당 입력 토큰이 비례해서 늘어나고, 모델이 어디에 집중해야 할지 가 흐려져요. 무료 티어라도 일일 호출 한도 에 빠르게 도달해서 사용자 경험이 끊겨요.
ai-friends 의 적정선. 캐주얼 대화는 최근 10 ~ 20 턴 이 컨텍스트 가치 vs 비용 이 가장 균형잡힌 구간이에요. 한 턴 평균 토큰을 (system + user + assistant) ≈ 200 ~ 400 토큰으로 잡으면, 20 턴 × 평균 토큰 ≈ 4,000 ~ 8,000 토큰.
Gemini 2.5 Flash 의 1M 컨텍스트 윈도우 안에 여유롭게 들어가고, Ollama 로컬도 부담 없이 처리할 수 있는 수준이에요. 그래서 maxMessages(20) 으로 박았어요. ✅
6. summarization 을 직접 구현하려면? — 한 단락만 짚기
본 강의에선 구현하지 않지만, 학습 호흡상 "그럼 summarization 은 어떻게 만드는데?" 의 윤곽만 한 단락 짚고 갑니다.
Spring AI 1.1.x 표준에는 summarization 구현체가 박혀있지 않아요.
직접 만들려면 다음과 같은 단계를 손으로 짜야 해요 —
ChatMemory인터페이스 (add,get,clear) 를 구현하는 클래스를 한 개 만든다.add(...)호출 시 누적 메시지가 임계치 (예: 50 개) 를 넘으면 별도의 ChatClient 한 개를 따로 주입받아 요약 호출 을 부른다.- 그 요약 결과를 시스템 메시지로 압축본 한 개 만들어 저장하고, 오래된 메시지들은 비운다.
이런 흐름이에요.
요약 호출의 비용 · 실패 시 fallback · 요약 품질 유지 등 진짜 운영 난이도 가 거기에 다 들어있어요.
심화 트랙에서 풀 가능성이 있는 주제로 박아두고, 본 강의에선 sliding window 의 단순함이 이긴다 의 방향으로 갑니다.
🙋 한 학생의 걱정 — "튜터님... 잠깐만요. 그럼 `maxMessages = 20` 일 때 21 번째로 밀려난 메시지는 영영 잃어버리는 거예요? ..."
"튜터님... 잠깐만요. 그럼
maxMessages = 20일 때 21 번째로 밀려난 메시지는 영영 잃어버리는 거예요? 우리SPRING_AI_CHAT_MEMORY테이블에는 다 남아있는 거 아니에요? 자루를 통째로 비우지도 않았는데 LLM 만 못 본다는 게 좀 이상해요... "
날카로운 지적이에요. DB 와 LLM 컨텍스트는 다른 층 이라는 사실을 짚고 가야 해요.
테이블엔 남아있어요. SPRING_AI_CHAT_MEMORY 의 row 들은 advisor 가 sliding window 를 적용하든 말든 그대로 누적돼요. 21 번째 row, 50 번째 row, 100 번째 row 가 conversationId 별로 차곡차곡 쌓입니다. 즉 데이터는 살아있어요.
다만 LLM 한테 넘기는 컨텍스트엔 안 들어가요. MessageWindowChatMemory.get(...) 의 내부 동작이 "마지막 N 개만 잘라서 돌려준다" 거든요.
advisor 의 before(...) 가 이 메서드를 부르고 그 결과를 prompt 에 합치니까, LLM 이 보는 컨텍스트 는 마지막 N 개로 한정되는 거예요.
Step 1 에서 박아둔 그 표 회수해 볼게요. 기억나시죠?
Step 1 의
ChatLogvsChatMemory표 —ChatLog는 우리 비즈니스 로그 (운영팀이 들춰보는, 통계 / 감사 / 디버깅 용도),ChatMemory는 LLM 의 단기 작업 기억 (모델 컨텍스트 주입용).
지금 풍경이 정확히 그 표의 결과 같아요.
SPRING_AI_CHAT_MEMORY 테이블 = 우리 비즈니스 로그처럼 운영팀이 들춰볼 수 있는 데이터.
MessageWindowChatMemory.get() 으로 잘라낸 마지막 N 개 = 모델 컨텍스트로 흘러가는 단기 기억.
같은 데이터 위에서 어디까지 쓸지의 정책 (window) 이 다르게 적용되는 거예요.
그럼 밀려난 옛날 메시지들은 그냥 영원히 DB 에 남아있나요? 아니에요. 거기에는 정리 정책 이 따로 필요해요. 보존 기간 · 회차 리셋 · 사용자 탈퇴 시 일괄 삭제 · GDPR 대응 — 이런 운영의 한 끗을 다음 Step 8 에서 슬림하게 짚고 마무리로 갑니다.
8. 💡 튜터의 결론
Step 7 의 한 문장 요약은 이래요.
"sliding window 의 단순함이 ai-friends 의 캐주얼 도메인엔 이긴다.
maxMessages(20)은 대화 일관성 vs 토큰 비용 의 손익분기점에서 우리가 박은 좌표이고, 이 숫자는 도메인이 바뀌면 함께 옮겨야 한다."
이제 멀티턴은 살아났고, 얼마나 들고 갈지 의 정책도 손에 잡혔어요. 마지막으로 한 가지가 남아있어요. 밀려난 옛 메시지들 · 사용자가 탈퇴할 때의 자루 정리 · 프라이버시 — 운영의 한 끗이에요. 다음 Step 8 에서 그 운영 정책을 슬림하게 짚고 Day 5 를 마무리합니다.
Step 8: 운영 한 끗 — 메시지 정리 정책 & 프라이버시 한 줄
자, 마지막 Step 입니다. 지금까지 우리는 대화의 기억을 만드는 도구 에 집중했어요. JdbcChatMemoryRepository 로 영속화하고, MessageWindowChatMemory 로 윈도우를 정하고, MessageChatMemoryAdvisor 로 자동 주입하고, conversationId 로 자루를 분리했죠.
그런데 이 도구들이 운영 으로 들어가는 순간 따라붙는 한 끗이 있어요.
DB 가 무한 증가하고, 사용자 자유 텍스트엔 PII 가 섞여 있고, 인덱스 한 줄이 없으면 조회가 느려지고, 인스턴스가 늘어나면 캐시가 필요해질 수 있어요.
이 Step 은 그 네 가지를 짧게 짚고 갑니다.
학습 호흡상 자세한 구현 보다는 시야 확보 가 목적이에요.
1. 🎯 Step 8 의 목표
운영에서 마주칠 4 가지 한 끗 을 짧게 인지하고, 그중 우리 강의 코드베이스에 이미 박힌 것 과 시야로만 갖고 갈 것 을 분리합니다.
| 한 끗 | 본 강의에서 | 시야로만 |
|---|---|---|
| 메시지 정리 정책 | DELETE 엔드포인트 (Step 5) | 주기적 batch 삭제 |
| 프라이버시 마스킹 | 사용자 요청 시 즉시 삭제 (DELETE) | 입력 측 마스킹 (regex/NER) · 보관 정책 N일 |
| 인덱스 | (conversation_id, timestamp) 복합 인덱스 |
(이미 schema 에 박혀있음) |
| 스케일 (멀티 인스턴스 · 캐시 · 압축) | — | Redis 캐시 (Day 19) · 압축 |
이 표만 손에 들고 가시면 Step 8 은 거의 다 끝난 거예요. 한 줄씩 살을 붙여볼게요.
2. 메시지 정리 정책 — DB 의 SPRING_AI_CHAT_MEMORY 가 무한 증가하면
Step 7 에서 우리가 짚었던 풍경 — "sliding window 는 LLM 한테 안 넘기지만, 테이블 자체는 INSERT 만 누적된다" — 기억나시죠? 이게 바로 이 한 끗입니다.
MessageChatMemoryAdvisor 의 after(...) 훅 은 매 호출마다 방금 사용자가 보낸 메시지 + 모델이 응답한 메시지 두 개를 repository.saveAll(...) 로 INSERT 해요.
deleteByConversationId(...) 는 우리가 명시적으로 호출하지 않으면 영원히 안 불립니다. 즉 한 사용자가 100 턴 대화하면 row 200 개가 그 conversationId 아래 영원히 남아요.
운영에선 두 갈래로 갈라요.
- (a) 세션 종료 시 즉시 삭제 — 사용자가 "이 대화 다시 시작" 을 누를 때 Step 5 의
DELETE /api/chat/soulmate/sessions/{conversationId}호출. 자루가 통째로 비워지죠. 본 강의는 여기까지 훅 한 줄 을 박아뒀어요. - (b) 주기적 batch 정리 — 스케줄러 (Spring 의
@Scheduled) 로 매일 새벽 N 일 이상 손대지 않은 conversation 을findConversationIds()로 끌어와deleteByConversationId(...)를 일괄 호출. 본 강의에선 구현하지 않지만, 운영급 ai-friends 에선 거의 필수 작업이에요.
학생 실습 차원에선 (a) 한 줄 — 게임 회차가 끝났을 때 DELETE 한 번 호출 — 정도면 충분해요. (b) 는 도메인이 커졌을 때 추가하는 운영 레이어입니다.
3. 프라이버시 한 줄 — PII 가 컨텍스트로 새는 경로
Day 3 에서 우리는 UserAnonymizer 로 사용자 이름 을 user_1, user_2 같은 익명 슬롯으로 바꿔서 LLM 에 넘겼죠.
그런데 한 가지 빈 자리가 있어요.
사용자가 자유 텍스트로 입력한 내용 — 예를 들어 "내 친구 김철수가 그러는데..." 같은 문장은 그대로 LLM 으로 흘러갑니다.
그리고 그게 SPRING_AI_CHAT_MEMORY 테이블에도 그대로 남아요.
운영에서 이걸 다루는 정책은 세 갈래예요.
- (a) 입력 측 마스킹 — regex 로 전화번호 / 이메일 / 주민번호 패턴을 잡거나, NER (Named Entity Recognition) 모델로 사람 이름·기관명·주소를 자동 마스킹. 입력 단계에서 PII 를 못 들어가게 막는 가장 강한 정책. 본 강의 범위 밖.
- (b) 보관 정책 N 일 — 위 (b) batch 정리 정책과 직결. "최대 30 일까지만 보관" 같은 규칙을 박아두면 노출 창이 시간으로 닫혀요.
- (c) 사용자 요청 시 즉시 삭제 — Right to Erasure (GDPR 17 조). 사용자가 "내 데이터 다 지워주세요" 를 요청하면 즉시 응답할 수 있어야 함. 본 강의는 Step 5 의
DELETE /sessions/{id}가 그 자리 예요.
학습 호흡상 (c) 만 구현하고 (a)·(b) 는 시야 로 갖고 갑시다. 다만 한 가지는 분명히 — 사용자 자유 텍스트엔 PII 가 섞여 들어올 수 있다 는 사실 자체는 인지하고 있어야 해요. "어, 이건 익명화 못 했네?" 를 깨닫는 순간이 운영 사고를 막습니다.
4. 인덱스 한 줄 — (conversation_id, timestamp)
Step 3 에서 우리는 schema-mysql.sql 을 한 번 읽고 지나갔죠. 그 파일 안에 복합 인덱스 한 줄 이 박혀 있었어요.
CREATE INDEX SPRING_AI_CHAT_MEMORY_CONVERSATION_ID_TIMESTAMP_IDX
ON SPRING_AI_CHAT_MEMORY (conversation_id, timestamp);
이 한 줄이 우리 시스템의 조회 성능 을 잡고 있어요. 학생 여러분이 이미 학습한 MySQL 인덱스 감각을 잠깐 회수해 볼게요.
- 첫 번째 컬럼
conversation_id—findByConversationId(conv)의 핵심 키. 인덱스 트리를 세션별로 좁혀주는 역할. - 두 번째 컬럼
timestamp— 같은 세션 안에서 시간 순으로 정렬 해서 LLM 한테 넘기기 위한 키.ORDER BY timestamp가 인덱스만으로 해결돼요.
복합 인덱스의 첫 컬럼이 conversation_id 라는 게 특히 중요해요. 세션 단위 조회 가 우리의 99% 쿼리거든요 (advisor 의 before(...) 가 매 호출마다 호출하는 그 자리). 만약 첫 컬럼을 timestamp 로 잡았다면? 인덱스가 세션별 로 작동을 못 해서 풀스캔에 가까운 비용이 나왔을 거예요.
운영급으로 가도 추가로 박을 만한 인덱스가 거의 없다 는 결론이에요. 이 한 줄이 우리 도메인의 거의 모든 조회 패턴을 커버합니다.
5. 운영 스케일 시 고민할 점 — 한 줄씩
본 강의에선 구현하지 않지만, 시야 로만 짚고 갑니다.
- 멀티 인스턴스 환경 —
JdbcChatMemoryRepository는 DB 트랜잭션 으로 동시성을 잡으니, 인스턴스가 N 개로 늘어나도 별도 동기화 없이 그대로 작동해요. Step 5 의 "클라이언트가 conversationId 를 들고 다닌다" 패턴이 여기서도 살아납니다. 어느 인스턴스로 요청이 떨어져도 같은 자루를 정확히 찾을 수 있죠. - 캐시 — 자주 호출되는 핫 conversation (예: 활성 세션의 마지막 N 개 메시지) 을 Redis 에 캐시해두면 매 호출마다 DB 를 때릴 필요가 없어져요. 다만 캐시 일관성 (write-through · cache-invalidation) 의 한 끗이 따라붙죠. Day 19 harness 트랙 에서 비슷한 류의 고민을 풀어볼 거예요.
- 압축 — 오래된 메시지 텍스트를 압축해서 보관? 도메인이 거대해졌을 때 고려할 만한 옵션이지만, 텍스트 데이터 1 row 의 크기가 그렇게 크지 않아 현실적인 손익분기점이 멀어요. 본 강의 외 영역.
세 가지 모두 언젠가 만날 수도 있는 풍경 이에요. 지금 손으로 짜지는 않더라도 어느 단계에 등장할지 의 좌표만 머리에 박아두세요.
6. 💡 튜터의 결론
Step 8 의 한 문장 요약은 이래요.
"운영의 큰 그림은 4 가지 — 정리 · 프라이버시 · 인덱스 · 스케일. 본 강의는 그중 정리 (DELETE 엔드포인트) 한 자리만 구현하고, 나머지는 시야로만 챙긴다."
수료 후 실무에서 AI 대화 시스템 을 운영할 때 이 4 가지 좌표가 손에 있는 학생과 없는 학생의 차이는 큽니다. 지금 다 구현하지 않아도, 언제 등장하는지 만 알아두면 그때 가서 한 끗씩 추가할 수 있어요.
자, 이제 정말 마무리로 갑니다. 오늘 박은 도구 5 개와 정책 한 끗을 회고하고, Day 6 의 스트리밍 풍경을 살짝 흘려두겠습니다.
🎯 마무리 — 오늘 배운 것 · Day 6 예고
1. 오늘의 여정 한눈에
Day 5 시간을 한 문장으로 요약하면 — "stateless LLM 한테 대화의 기억 을 입힌 하루" 였어요.
| 도구 / 정책 | 한 줄 요약 | 실무에서 기억해야 할 감각 |
|---|---|---|
JdbcChatMemoryRepository (Step 3) |
MySQL 영속화 | "starter 한 줄 + yml 한 영역 추가 = 빈 자동 등록 + schema 자동 초기화" |
MessageWindowChatMemory (Step 4) |
sliding window 정책 | "윈도우 밖은 완전 망각 — DB 엔 남아있어도 LLM 컨텍스트엔 안 들어감" |
MessageChatMemoryAdvisor (Step 4) |
호출 직전·직후 자동 끼워넣기 | "advisor 한 줄이 손 코딩 30 줄을 흡수한다" |
conversationId 정책 + 세션 엔드포인트 (Step 5) |
학습용 lab — 사용자 / 세션 격리 자유도 | "REST 무상태성의 약속 그대로 — 클라이언트가 들고 다니는 식별자가 자루를 가른다" |
| prod 수렴 (Step 6) | AiChatController 백엔드 흡수 + system-v1.st 부활 + GeminiService 제거 |
"Day 3·4 의 약속 셋이 한 Step 에 닫힘 — 156 줄 → 88 줄로 추상화 가치 증명" |
maxMessages 실측 감각 (Step 7) |
손익분기점 | "20 은 대화 일관성 vs 토큰 비용 의 균형 좌표 — 도메인 바뀌면 같이 옮긴다" |
이 6 개를 다 외우라는 게 아니에요. "stateless LLM 의 기억은 서버가 만들어 매 호출마다 다시 넣어준다" 와 "DB 와 LLM 컨텍스트는 다른 층" 두 문장만 3 개월 뒤에도 기억하시면 오늘 수업은 성공이에요.
Day 5 시작 vs 종료 — ai-friends 코드 구조도
Day 5 가 수렴 Day 라는 사실을 한 박스로 박아두고 갑니다. 5 주 강의의 수렴 이라는 단어가 진짜 의미를 가지는 분기점입니다.
[ Day 5 시작 시점 — Day 4 마지막 ]
AiChatController ──→ AiChatService ──→ GeminiService
(POST /api/chat) (퍼사드 156 줄) (RestClient + 수동 JSON Schema +
+ 호감도 보정 ObjectMapper + try-catch 30 줄)
+ 연속 선택지 차단
+ ChatLog 컨텍스트 주입
SoulmateChatController ──→ SoulmateChatService(chat(convId, name, mood, msg))
(학습용 lab) (system 프롬프트 깡통 람다 — system-v1.st 안 씀)
prompts/soulmate/system-v1.st ← 작성됐지만 GeminiService 만 사용
prompts/soulmate/fewshot-v1.st ← 작성됐지만 GeminiService 만 사용
[ Day 5 종료 시점 — Step 6 수렴 직후 ]
AiChatController ──→ AiChatService ──→ SoulmateChatService(chat(soulmateId, msg))
(POST /api/chat) (퍼사드 88 줄) │
(호감도/레벨/뱃지) ├──→ ChatClient (defaultAdvisors=MessageChatMemoryAdvisor)
│ │
│ └──→ ChatMemory(MessageWindowChatMemory, max=20)
│ │
│ └──→ JdbcChatMemoryRepository → MySQL
│
├──→ system-v1.st (Soulmate 도메인 슬롯) ← 진짜로 사용
└──→ fewshot-v1.st (예시 2개) ← 진짜로 사용
SoulmateChatController ──→ SoulmateChatService(chat(convId, name, mood, msg) @Deprecated)
(학습용 lab — 자루 자유도 정책 시연용으로 보존)
❌ GeminiService.java ← 삭제
❌ GeminiRequest/Response/ParsedResponse ← 삭제
❌ geminiRestClient @Bean ← 삭제
한 줄로: RestClient + 수동 파싱 덩어리 가 Spring AI ChatClient + Advisors + 외부 프롬프트 + ChatMemory 의 깔끔한 레이어드 구조로 자랐어요. 같은 풍경을 코드 줄 수로 보면 156 → 88 줄 (44% 감량).
5 주 후 Day 20 의 회고 시점에 우리는 Day 1 의 ai-friends 와 Day 20 의 ai-friends 를 비교할 텐데, 오늘이 그 비교의 결정적 변곡점 입니다.
2. Day 6 예고 — "Streaming: 답변이 흘러 도착한다"
오늘 Step 6 에서 prod 진입점이 된 SoulmateChatService.chat(soulmateId, userMessage) 를 한 번만 더 떠올려 봅시다.
public AiReply chat(Long soulmateId, String userMessage) {
Soulmate soulmate = soulmateRepository.findById(soulmateId).orElseThrow(...);
String conversationId = String.valueOf(soulmateId);
String systemText = readResource(systemV1Resource) + "\n\n" + readResource(fewshotV1Resource);
return soulmateChatClient.prompt()
.system(s -> s.text(systemText)
.param("gender", soulmate.getGender())
.param("characterName", soulmate.getName())
/* ...persona slots... */)
.user(userMessage)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
.call()
.entity(AiReply.class);
}
이 호출을 사용자 입장에서 한 번 그려볼게요.
사용자가 "오늘 진짜 별로였어" 라고 입력하면, 사용자는 모델이 답변을 전부 만들 때까지 빈 화면을 멍하니 보고 있어야 해요.
Gemini 2.5 Flash 면 ≈ 1.5~3 초, Ollama 로컬 모델이면 더 길게 5~10 초까지도 걸려요.
답변이 몰아서 한 번에 도착하니 답답한 거예요.
ChatGPT · Claude · Gemini 의 웹 UI 를 떠올려 보세요. 거기선 답변이 글자 단위로 흘러 와요. 그래서 1 초 안에 "AI 가 응답을 시작했다" 는 신호가 사용자한테 도착하죠. 같은 총 응답 시간이라도 체감 대기 시간 이 절반 이하로 줄어들어요.
Day 6 의 주제가 정확히 이거예요 — Streaming.
// Day 6 에서 만날 모양 (오늘은 코드에 넣지 않습니다 — 개념 예고)
chatClient.prompt()
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
.user(userMessage)
.stream() // .call() 이 .stream() 으로
.content(); // Flux<String> 이 떨어진다
.call() 이 .stream() 으로 바뀌면서 응답 타입이 Mono<...> / 동기 객체 → Flux<String> 으로 변해요. 클라이언트로 흘러가는 채널도 일반 HTTP → SSE (Server-Sent Events) 또는 WebSocket 으로 갈리고요.
Day 6 에서 다룰 것:
- Reactor
Flux<String>— Spring AI 의.stream().content()가 떨어뜨리는 청크 스트림 - SSE vs WebSocket 트레이드오프 — 단방향 vs 양방향, HTTP/2 호환성, 재연결 전략
- ChatMemory 와 스트리밍의 미묘한 타이밍 문제 — 스트리밍 응답을 ChatMemory 에 언제 저장해야 할까? 청크가 도착할 때마다? 아니면 스트림이 끝났을 때?
- 백프레셔 · 클라이언트 끊김 처리 — 사용자가 답변 도중 페이지를 닫으면 스트림은 어떻게 정리되나
특히 "스트리밍 응답을 ChatMemory 에 언제 쓸지의 미묘한 타이밍 문제" — 이게 오늘의 advisor 패턴과 직접 만나는 지점이에요.
MessageChatMemoryAdvisor 의 after(...) 훅이 동기 호출 에선 깔끔히 동작했지만, 스트리밍 에선 청크가 흩어져 도착하니 완성된 메시지 를 어디서 잡아야 할지가 미묘해져요.
그리고 사용자 입장에서 보면 — 다음 시간엔 ai-friends 의 소꿉친구가 답변을 글자 단위로 타이핑하듯 흘려주는 풍경 을 직접 만들 거예요.
빈 화면을 5 초 동안 보고 있던 1.5 초로 체감 대기 시간이 줄어드는, 그 결정적인 UX 차이를 직접 체감해보는 시간입니다.
Day 6 에서 그 학습 지점을 풀어봅니다.
🔥 오늘의 도전 과제 (Homework)
[구현 1] 한 사용자가 두 캐릭터와 동시에 멀티턴 대화하는 게임 화면 만들기
배경 시나리오
ai-friends 미연시 게임에서 한 사용자가 여러 캐릭터 (소꿉친구 A, 선배 B 등) 와 동시에 친밀도를 쌓아가는 흔한 시나리오. 각 캐릭터마다 conversationId 가 분리 되어야 한 캐릭터의 대화가 다른 캐릭터로 새지 않아요.
PM 이 슬랙을 던집니다.
"튜터님, 사용자가 한 캐릭터랑 얘기하다가 다른 캐릭터로 넘어가면, 이전 캐릭터의 대화가 새 캐릭터한테 섞여 보이는 버그가 있다는 제보가 있어요. 한 사용자가 동시에 두 세션을 관리하는 시나리오를 한 번 시뮬레이션해볼 수 있을까요?"
전형적인 세션 격리 검증 요구예요. Step 5 의 conversationId 격리가 한 사용자 ↔ 여러 캐릭터 풍경에서 정확히 동작하는지 손으로 굴려봅시다.
💡 왜 굳이 이 과제를 할까요?
- 격리의 시각적 확인 — Step 5 의 한 학생의 걱정 ("conversationId 를 클라이언트가 들고 다니는 게 번거로워 보여요") 에 대한 실측 답 이에요. 두 자루가 정말 안 섞이는지 직접 봐야 합니다.
- 클라이언트 측 conversationId 보관 패턴 체득 — JWT 처럼 서버 발급 → 클라이언트 보관 → 매 호출 제출 의 호흡이 실제 UX 에 어떻게 녹는지 손으로 그려보는 시간.
✅ 요구사항
- 두 캐릭터 (A, B) 와 각각 5 턴씩 대화 시뮬레이션 — curl 또는 간단한 HTML 폼
- 캐릭터 A:
mood=기쁨으로 5 턴 진행 (예: "안녕! 오늘 어땠어?" → "별일 없었어" → ...) - 캐릭터 B:
mood=설렘으로 5 턴 진행 (다른 주제로 — 예: "주말에 뭐 할까?" → ...)
- 캐릭터 A:
- 각 캐릭터의 conversationId 를 클라이언트 측에서 보관 — sessionStorage 또는 그냥 shell 변수 (
CONV_A,CONV_B) 로 OK GET /api/chat/soulmate/sessions/{convA}와{convB}가 서로 격리된 메시지 리스트를 돌려주는지 확인 + 스크린샷- 두 세션의 메시지 개수 · 내용이 섞이지 않음 을 markdown 표로 정리
확인 방법
./run.sh up
# 첫 호출로 conversationId 발급받기
CONV_A=$(curl -s "http://localhost:8080/api/chat/soulmate?userId=1&mood=기쁨&message=A에게%20인사" | jq -r '.data.conversationId')
CONV_B=$(curl -s "http://localhost:8080/api/chat/soulmate?userId=1&mood=설렘&message=B에게%20인사" | jq -r '.data.conversationId')
# 각각 4 턴씩 추가 진행 (총 5 턴)
curl "http://localhost:8080/api/chat/soulmate?userId=1&mood=기쁨&message=A%20두번째&conversationId=$CONV_A"
curl "http://localhost:8080/api/chat/soulmate?userId=1&mood=설렘&message=B%20두번째&conversationId=$CONV_B"
# ... 5 턴까지
# 세션 격리 확인
curl "http://localhost:8080/api/chat/soulmate/sessions/$CONV_A" | jq '.data | length'
curl "http://localhost:8080/api/chat/soulmate/sessions/$CONV_B" | jq '.data | length'
응답에서 다음 표를 손으로 옮겨 정리하세요.
| 캐릭터 | conversationId | 누적 메시지 개수 | 첫 메시지 내용 | 마지막 메시지 내용 |
|---|---|---|---|---|
| A (기쁨) | ? |
10 (5 user + 5 assistant) | "A에게 인사" | ? |
| B (설렘) | ? |
10 | "B에게 인사" | ? |
표 아래에 "두 세션이 정말 섞이지 않았는가? 한 캐릭터의 응답에 다른 캐릭터의 단서가 보였다면 어디서?" 를 한두 줄로 적으세요.
제약 / 금지
InMemoryChatMemoryRepository로 회귀 금지 — 본 강의 5 번 규약. 테스트 코드 한정 허용이고, 본 과제는 production path 라서JdbcChatMemoryRepository그대로.- 두 세션의 conversationId 를 서버측에서 자동 생성 — 사용자가 임의 문자열을 넘겨 충돌시키는 시도는 하지 않습니다. UUID 발급은 서버에 맡깁니다.
[구현 2] maxMessages 손익분기점 직접 측정해보기 📏
배경 시나리오
Step 7 에서 우리는 maxMessages = 3 / 10 / 30 의 풍경을 예상 표 로 그렸어요. 그 표는 "3 이면 첫 호출 망각, 10 이면 전부 보존, 30 이면 한도 안 침" 같은 정성적인 결론이었죠.
이번 과제에선 직접 LLM 을 호출해서 그 표를 측정값 으로 채워봅니다. 그리고 우리 도메인의 적정선이 정말 20 인지, 더 작아도 되는지, 더 커야 되는지 의 감각을 숫자로 잡아요.
💡 왜 굳이 이 과제를 할까요?
- 추측을 측정으로 바꾸기 — Day 4 과제 2 와 같은 정신. 운영 의사결정의 무게는 측정에서 나와요. "우리 도메인엔 20 이 적당하다" 라는 PM 보고서를 숫자 표 와 함께 낼 수 있어야 합니다.
- 첫 호출 키워드 회수 여부 — sliding window 의 완전 망각 이 어느 maxMessages 에서 시작되는지 직접 봅니다. 우리 도메인의 손익분기점이 어디인지가 체감 으로 잡혀요.
✅ 요구사항
maxMessages를 3, 10, 20, 30 으로 바꿔가며 각각 동일한 5 턴 대화 시뮬레이션MemoryConfig(Step 4 에서 만든) 의maxMessages(...)값을 4 단계로 바꿔가며 앱 재기동- 매번 같은 첫 메시지 사용 — 예: "오늘 진짜 우울해" (키워드 우울 이 회수 가능한지 검증할 거예요)
- 각 설정에서 5 번째 호출 시 모델이 첫 호출의 키워드 (예: "우울") 를 기억하는지 확인
- 5 번째 메시지로 "내가 처음에 어떤 기분이었지?" 같은 회수 질문을 던지기
- 모델 응답에 "우울" 또는 그 의미 단어가 등장하는지 체크
- 결과 markdown 표 —
maxMessages/ 누적 메시지 개수 / 첫 호출 키워드 기억 여부 / (선택) 응답 지연 - 분석 3 줄 — "우리 도메인에 적정한
maxMessages는 얼마인가? 그 결정의 근거는?"
확인 방법
# MemoryConfig.java 의 maxMessages 값을 3 / 10 / 20 / 30 으로 4 번 바꿔가며 ./run.sh restart
# 매번 5 턴 대화 후 마지막 회수 질문 → 응답 확인
# 5 턴째 회수 질문 예시
curl "http://localhost:8080/api/chat/soulmate?userId=1&mood=우울&message=내가%20처음에%20어떤%20기분이었지?&conversationId=$CONV"
응답에서 다음 표를 손으로 옮겨 정리하세요.
maxMessages |
5 턴 후 누적 메시지 | 첫 호출 키워드 ("우울") 기억 여부 | (선택) 평균 응답 지연 |
|---|---|---|---|
| 3 | ? | ? | ? ms |
| 10 | ? | ? | ? ms |
| 20 | ? | ? | ? ms |
| 30 | ? | ? | ? ms |
표 아래에 "우리 도메인에 적정한 maxMessages 는 얼마인가? 왜?" 를 한두 문장으로 적으세요.
제약 / 금지
- 유료 모델 사용 금지 — Day 4 과제 2 와 동일. Gemini 2.5 Flash 무료 티어로 충분합니다.
- 토크나이저 직접 측정 금지 — 응답 토큰 수까지 측정하려면 별도 라이브러리가 필요해서 학습 호흡이 깨집니다. 키워드 회수 여부 한 가지에만 집중.
🤔 생각해볼 주제
이 섹션의 목적 — 정답이 정해져 있지 않은 질문들이에요. 오늘 배운 내용에서 한 발 떨어져 "만약 나라면 어떻게 할까?" 를 스스로 정리해보는 시간. 각 주제마다 5~10 분씩, 가능하면 문장으로 적어보세요. 머릿속 생각과 글로 적은 생각은 다릅니다. ✍️
주제 1 — 클라이언트가 conversationId 를 들고 다니는 패턴, JWT 와 어디까지 같고 어디서 갈라지는가?
Step 5 의 한 학생의 걱정에서 우리는 conversationId 가 클라이언트 측 책임 이라고 짚었고, JWT 와 비슷한 방식이라고 잠깐 비교했죠. 그런데 실제로 둘은 보안 모델 · 만료 정책 · 서명 검증 측면에서 결정적으로 다른 점이 있어요.
- 같은 점 — 서버가 발급, 클라이언트가 보관, 매 호출마다 제출. 서버는 stateless.
- 다른 점:
- JWT 는 서명 (signature) 으로 위변조를 막아요. conversationId 는 단순 UUID — 누구나 들고 오면 그 자루가 열려요.
- JWT 는 exp (expiration) claim 으로 만료 시점을 박아요. conversationId 는 만료가 없어요.
- JWT 는 페이로드에 식별자 (sub) + 권한 (roles) 같은 메타가 들어가요. conversationId 는 그냥 식별자 한 개.
🎯 핵심 질문 — JWT 와 conversationId 패턴은 무엇이 같고 무엇이 다른가? 보안 모델 · 만료 정책 · 서명 검증 측면에서 ai-friends 의 conversationId 는 어떻게 강화될 수 있을까?
생각해볼 시나리오:
- "공격자가 다른 사용자의 conversationId 를 알아낸다면?" — Step 5 에선 검증이 없어서 그 자루의 모든 메시지가 노출돼요. 의도적인 단순화 (강의 호흡상) 지만 운영 시엔 어떻게 막을까?
- conversationId 에 서명 을 박는다면? — 예를 들어
userId:soulmateId:uuid의 HMAC. 어떤 단계에서 검증해야 할까? - 만료 정책 을 박는다면? — 24 시간 무활동 conversation 은 자동 삭제? 사용자 신뢰엔 어떻게 영향?
여러분 도메인 (ai-friends 든, 본인 사이드 프로젝트든) 에서 이 conversationId 의 강화 지점 이 어디인지 한 번 적어보세요. 적어보지 않으면 "기본값" 으로 묻혀버려요.
주제 2 — sliding window 의 완전 망각 vs summarization 의 손실 압축, 어느 쪽이 사용자 신뢰에 더 안전한가?
Step 7 에서 우리는 sliding window 가 윈도우 밖을 완전히 망각 한다고 했어요. summarization 은 요점은 유지하지만 디테일을 손실 하죠. 두 정책 모두 사용자 입장에선 "AI 가 내 말을 잊었다" 는 경험을 만들 수 있어요. 망각의 방식 이 다를 뿐이에요.
- 완전 망각 (sliding window) — "네가 그런 말 했었어?" 의 풍경. 모델이 모른다는 사실 자체를 명확히 드러냄. 사용자가 "아, AI 가 옛날 얘기는 까먹는구나" 를 빠르게 학습.
- 손실 압축 (summarization) — "네가 우울하다고 했었지" 같은 대략적 회수 가 되지만, "우울한 이유가 회사 일이었던 것 까지는 기억 하는 척 하다가 디테일 한 부분에서 어긋남. 사용자가 "AI 가 내 얘기를 들은 척만 하네" 같은 의심을 가질 위험.
🎯 핵심 질문 — 사용자 신뢰의 관점에서 완전 망각 (sliding window) 과 손실 압축 (summarization) 중 어느 쪽이 더 정직한 UX 인가? 도메인별로 답이 어떻게 달라질까?
도메인별 시나리오:
- 캐주얼 게임 (ai-friends) — 완전 망각이 자연스러움. "어제 일은 어제 일" 같은 톤. summarization 은 디테일 어긋남이 "AI 가 거짓말한다" 는 인상으로 번지기 쉬움.
- 의료 / 법률 상담 — 손실 압축은 위험. 디테일 손실이 사고로 직결. 차라리 완전 망각 + "그 부분은 기록을 다시 봐주세요" 같은 정직한 안내가 안전.
- 교육 / 튜터링 — 절충 — 핵심 학습 진도는 summarization 으로 유지, 디테일 답변은 sliding window. 두 정책의 하이브리드 가 가장 정교.
또 하나의 축 — Day 4 의 fallback 정직성 주제와도 연결돼요. "윈도우 밖이라 답할 수 없어요" 를 사용자에게 명시적으로 알릴 것인가, 아니면 자연스러운 톤으로 "새로 시작해볼까요?" 같은 우회를 쓸 것인가? 여러분 도메인에선 어느 쪽이 더 신뢰를 만드는지 적어보세요.
주제 3 — ChatLog 와 SPRING_AI_CHAT_MEMORY 두 저장소를 두는 이중 저장의 비용은 정당한가?
Step 1 / Step 3 에서 우리는 ChatLog (우리 비즈니스 로그) 와 SPRING_AI_CHAT_MEMORY (LLM 컨텍스트) 를 별개 테이블 로 분리했어요. 같은 대화가 두 번 저장되니 디스크 비용이 ≈ 2 배. 처음 보면 "왜 굳이?" 같은 어색함이 있었죠.
- 분리의 이점 — 보관 정책 · 삭제 트리거 · 프라이버시 처리가 완전히 다름.
ChatLog는 영구 보관 (감사 / 통계),SPRING_AI_CHAT_MEMORY는 N 일 후 정리 (Step 8 의 batch 정책). - 합치는 시도 — 예를 들어
chat_log컬럼에role한 줄 추가하고 LLM 호출 시 그 테이블을 읽게 하면? 그러려면 Spring AI 의JdbcChatMemoryRepositoryDialect를 직접 구현 해야 해요. 우리 테이블 스키마에 맞게 SELECT / INSERT / DELETE 쿼리를 다 손으로 짜야 함. - 충돌 지점 —
ChatLog의 컬럼 (작성자 ID,세션 메타) 와SPRING_AI_CHAT_MEMORY의 컬럼 (role,content,timestamp) 을 한 테이블에 합치면 어느 쪽 의미 로 쓸지가 매번 흐려져요. 또 보관 정책이 다른 두 데이터가 같은 row 에 있으니 부분 삭제 (LLM 컨텍스트만 비우고 비즈니스 로그는 유지) 가 비대칭으로 어려워짐.
🎯 핵심 질문 — 같은 대화를 두 번 저장하는 이중 저장의 비용은 정당한가? 합치는 게 더 단순할 수도 있는데, 왜 굳이 분리하는가? 합치는 시도를 해본다면 어디서 충돌이 발생할까?
생각해볼 시나리오:
- 디스크 비용이 심각하게 큰 도메인 (수백만 사용자, 일일 100 만 턴 대화) 이라면? 그땐 합치는 게 경제적 일 수 있어요. 다만 운영 자유도는 한 번 잃어요.
- 반대로 프라이버시 규제가 강한 도메인 (의료 · 금융) 은 분리가 거의 필수. 두 데이터의 접근 권한 이 다르거든요.
- 절충안 —
SPRING_AI_CHAT_MEMORY만 정식으로 두고,ChatLog는 집계 (일별 메시지 수, 호감도 변화 추이) 만 별도 테이블에 저장. 원본 텍스트는 한 곳에만.
여러분 도메인의 데이터 볼륨 · 규제 · 운영 자유도 의 좌표 위에서 이 세 옵션 중 어느 게 가장 자연스러운지 적어보세요. "의식적으로 결정한 분리/합치기 기준선" 이 있는 시스템은 그렇지 않은 시스템보다 운영 안정성이 항상 더 높습니다.
자, 손도 머리도 함께 굳히는 마무리 시간이었어요. 오늘 박아둔 도구 5 개와 정책 한 끗, 그리고 이 토론 주제 3 개를 들고 가셔서 다음 시간 (Day 6) Streaming 의 풍경 — 답변이 흘러 도착하는 그 호흡 으로 만나요. 수고 많으셨습니다!
✅ 예시 답안정답 보기
Day 5 Step 5 에서 우리는 conversationId 한 줄로 세션 자루 를 가르는 풍경을 만들었어요. 이번 과제는 그 자루가 정말 서로 안 섞이는지 — 한 사용자가 두 캐릭터(A: 기쁨, B: 설렘) 와 동시에 5 턴씩 대화하는 시나리오로 손에 굳히는 게 목적이에요.
핵심 포인트 한 가지부터 짚을게요.
이 과제는 새 코드를 짜는 과제가 아닙니다. Step 5 에서 이미 만든 SoulmateChatController·SoulmateChatService 가 그대로 통과하는지 — 즉 클라이언트 측 conversationId 보관 + 두 자루 격리 를 시뮬레이션으로 확인하는 과제예요.
학생이 추가로 짤 건 클라이언트 측 보관 흐름과 검증 표 두 가지뿐.
Step 1. 서버 코드는 그대로 — Day 5 Step 5 상태가 충분
확인할 코드는 코드베이스에 이미 있어요. 이 과제에서 수정 하지 않습니다.
// src/main/java/kr/spartaclub/aifriends/chat/controller/SoulmateChatController.java
@GetMapping("/api/chat/soulmate")
public ResponseEntity<ApiResponse<SoulmateChatResponse>> soulmate(
@RequestParam Long userId,
@RequestParam String mood,
@RequestParam String message,
@RequestParam(required = false) String conversationId
) {
String anonymizedName = userAnonymizer.anonymize(userId);
String convId = (conversationId == null || conversationId.isBlank())
? UUID.randomUUID().toString()
: conversationId;
AiReply reply = service.chat(convId, anonymizedName, mood, message);
return ResponseEntity.ok(ApiResponse.success(new SoulmateChatResponse(convId, reply)));
}
@GetMapping("/api/chat/soulmate/sessions/{conversationId}")
public ResponseEntity<ApiResponse<List<SoulmateSessionMessageView>>> getSession(
@PathVariable String conversationId
) {
List<Message> messages = chatMemory.get(conversationId);
List<SoulmateSessionMessageView> views = messages.stream()
.map(m -> new SoulmateSessionMessageView(
m.getMessageType().name().toLowerCase(),
m.getText()))
.toList();
return ResponseEntity.ok(ApiResponse.success(views));
}
서비스 시그니처도 그대로.
// src/main/java/kr/spartaclub/aifriends/chat/service/SoulmateChatService.java
public AiReply chat(String conversationId, String anonymizedUserName, String mood, String userMessage) {
return soulmateChatClient.prompt()
.system(system -> system
.text("""
너는 {userName} 님의 AI 친구야.
유저의 현재 기분은 '{mood}' 이야.
...
""")
.param("userName", anonymizedUserName)
.param("mood", mood))
.user(userMessage)
.advisors(advisor -> advisor.param(ChatMemory.CONVERSATION_ID, conversationId))
.call()
.entity(AiReply.class);
}
이 두 클래스가 학생 코드베이스에 이미 박혀 있어야 정상이에요. 아직이라면 Day 5 Step 5 부터 다시 따라오세요.
Step 2. 두 자루 발급 + 5 턴 진행 — shell 시나리오
./run.sh up 으로 앱을 띄운 뒤, 클라이언트가 conversationId 를 들고 다니는 패턴을 shell 변수 두 개로 시뮬레이션해요.
./run.sh up
# ── 첫 호출로 두 자루 발급 ────────────────────────────────────────────
CONV_A=$(curl -s "http://localhost:8080/api/chat/soulmate?userId=1&mood=기쁨&message=안녕!%20오늘%20좋은%20일%20있었어" \
| jq -r '.data.conversationId')
CONV_B=$(curl -s "http://localhost:8080/api/chat/soulmate?userId=1&mood=설렘&message=주말에%20뭐%20할까?%20같이%20영화%20볼래?" \
| jq -r '.data.conversationId')
echo "CONV_A=$CONV_A"
echo "CONV_B=$CONV_B"
# ── 캐릭터 A 와 4 턴 추가 (총 5 턴, 기쁨 톤 일관) ──────────────────────
curl "http://localhost:8080/api/chat/soulmate?userId=1&mood=기쁨&message=시험%20성적이%20잘%20나왔거든&conversationId=$CONV_A"
curl "http://localhost:8080/api/chat/soulmate?userId=1&mood=기쁨&message=고마워!%20너도%20요즘%20어때?&conversationId=$CONV_A"
curl "http://localhost:8080/api/chat/soulmate?userId=1&mood=기쁨&message=오늘%20저녁%20뭐%20먹지?&conversationId=$CONV_A"
curl "http://localhost:8080/api/chat/soulmate?userId=1&mood=기쁨&message=내가%20처음에%20어떤%20기분이라고%20했지?&conversationId=$CONV_A"
# ── 캐릭터 B 와 4 턴 추가 (총 5 턴, 설렘 톤 일관) ──────────────────────
curl "http://localhost:8080/api/chat/soulmate?userId=1&mood=설렘&message=로맨스%20장르%20좋아해?&conversationId=$CONV_B"
curl "http://localhost:8080/api/chat/soulmate?userId=1&mood=설렘&message=토요일%20저녁%207%20시%20어때?&conversationId=$CONV_B"
curl "http://localhost:8080/api/chat/soulmate?userId=1&mood=설렘&message=떨려서%20잠도%20안%20와&conversationId=$CONV_B"
curl "http://localhost:8080/api/chat/soulmate?userId=1&mood=설렘&message=아까%20내가%20뭐%20같이%20하자고%20했더라?&conversationId=$CONV_B"
# ── 두 세션 격리 검증 ─────────────────────────────────────────────────
curl -s "http://localhost:8080/api/chat/soulmate/sessions/$CONV_A" | jq '.data | length'
curl -s "http://localhost:8080/api/chat/soulmate/sessions/$CONV_B" | jq '.data | length'
curl -s "http://localhost:8080/api/chat/soulmate/sessions/$CONV_A" | jq '.data[].content' | head
curl -s "http://localhost:8080/api/chat/soulmate/sessions/$CONV_B" | jq '.data[].content' | head
각 자루의 마지막 호출은 회수 질문 이에요 — "내가 처음에 어떤 기분이라고 했지?" (CONV_A) 와 "아까 내가 뭐 같이 하자고 했더라?" (CONV_B).
ChatMemory 가 잘 작동하면 모델이 각 자루의 첫 메시지 만 회수해야 해요.
만약 CONV_A 의 회수 답변에 "영화" 라는 단어가 등장하면 — 자루가 새고 있는 거예요.
(이번 과제 코드 그대로면 그런 일은 없어요.)
Step 3. 격리 검증 결과 — 표로 정리
호출이 끝난 뒤 GET /sessions/{convA} 와 {convB} 를 비교해 다음 표를 채웁니다. 아래는 예시 측정값 (실제 LLM 응답은 호출마다 약간 달라질 수 있음 — 본인 측정값으로 채우세요).
| 캐릭터 | conversationId | 누적 메시지 개수 | 첫 메시지 내용 (user) | 마지막 회수 응답 (assistant) |
|---|---|---|---|---|
| A (기쁨) | 7e2f-...-a91 |
10 (5 user + 5 assistant) | "안녕! 오늘 좋은 일 있었어" | "처음엔 기쁘다 고 했었지! 시험 잘 봤다고." |
| B (설렘) | 4c0d-...-b73 |
10 (5 user + 5 assistant) | "주말에 뭐 할까? 같이 영화 볼래?" | "영화 같이 보자고 했었지! 토요일 7 시." |
검증 포인트 두 줄.
A 의 회수 응답엔 "영화" / "토요일" 이 나오면 안 됨 — 나오면 자루가 샌 것. B 의 회수 응답엔 "시험" / "성적" 이 나오면 안 됨 — 나오면 자루가 샌 것.
SPRING_AI_CHAT_MEMORY 테이블을 직접 들여다봐도 돼요.
docker exec -it ai-friends-mysql-1 mysql -uroot -p${MYSQL_ROOT_PASSWORD} ai_friends \
-e "SELECT conversation_id, type, LEFT(content, 30) AS preview, timestamp \
FROM SPRING_AI_CHAT_MEMORY \
WHERE conversation_id IN ('$CONV_A', '$CONV_B') \
ORDER BY conversation_id, timestamp;"
두 conversation_id 의 row 가 완전히 분리 되어 있어야 정답이에요.
Step 4. 트레이드오프 짚기 — 클라이언트 보관 vs 도메인 키
이번 과제의 conversationId 발급 정책은 서버 발급 + 클라이언트 보관 이에요. 다른 옵션도 있어요.
| 옵션 | 형태 | 장점 | 단점 |
|---|---|---|---|
| (A) 서버 발급 UUID + 클라 보관 (Step 5 채택) | UUID.randomUUID().toString() |
단순·범용·도메인 결합 0 | 서버는 어느 사용자의 자루 인지 검증 못 함 (생각해볼 주제 1) |
| (B) 도메인 키 | userId + ":" + soulmateId |
서버가 자루의 주인을 즉시 식별 | 도메인 변경에 취약, 한 사용자 ↔ 한 캐릭터 1 자루로 강제됨 |
| (C) UUID + 서버 매핑 테이블 | UUID + (userId, soulmateId) 매핑 |
둘 다 가짐 — 외부 노출은 UUID, 서버는 매핑 테이블로 owner 검증 | 테이블 한 개 추가, 운영 복잡도 ↑ |
ai-friends 같은 캐주얼 도메인은 (A) 가 학습 호흡상 자연스러워요. 다만 공격자가 다른 사용자의 conversationId 를 알아내면 그 자루가 그대로 노출 되는 한계가 있어요 — 이게 생각해볼 주제 1 의 진짜 출발점입니다.
💡 채점 포인트 요약
| # | 포인트 | 설명 | 배점 가중 |
|---|---|---|---|
| 1 | conversationId 두 자루 발급 | 첫 호출 응답의 data.conversationId 를 shell 변수로 보관 |
상 |
| 2 | 5 턴씩 진행 | 두 번째 이후 호출에 &conversationId=$CONV_A 식으로 동일 자루 명시 |
상 |
| 3 | 회수 질문 한 번 포함 | 5 턴째 "내가 처음에 ~" 같은 질문으로 ChatMemory 동작 검증 | 상 |
| 4 | 격리 검증 표 | A·B 두 행, 누적 개수 + 첫 메시지 + 마지막 회수 응답 네 컬럼 채워짐 | 상 |
| 5 | 자루 누수 검증 문장 | "A 의 응답에 B 의 키워드가 등장했는가?" 한두 줄 분석 | 중 |
| 6 | DB 직접 검증 (선택) | SPRING_AI_CHAT_MEMORY 테이블에서 conversation_id 분리 SELECT |
중 |
| 7 | 트레이드오프 한 줄 | 클라 보관 vs 도메인 키의 장단을 한 줄 로 적기 | 하 |
5 번이 가장 자주 빠지는 포인트예요. 표만 채우고 "왜 격리가 보장되는가" 를 한 줄도 안 적으면 검증의 의미가 절반입니다.
🚫 흔한 실수
&conversationId=$CONV_A를 빼먹는 실수 → 매 호출마다 새 자루가 발급돼서 "왜 모델이 매번 처음부터냐" 라는 디버깅 시간을 한 시간 잡아먹어요. 첫 호출만 빼고 반드시 자루를 들고 다니세요.- 두 자루를 같은 변수 (
CONV) 로 덮어쓰기 → 두 번째 발급 호출이 첫 자루를 덮어버려서 격리 검증 자체가 불가능해져요.CONV_A,CONV_B처럼 별도 변수 를 써야 합니다. - 서버 재기동 후 옛 conversationId 재사용 시도 →
JdbcChatMemoryRepository는 영속화되니 사실 가능해요. 다만chat_memory테이블이 비어 있는 상태로 재기동했다면 (예: docker volume reset) 자루도 사라져요. 재기동 후 빈 응답이 떨어지면 첫 발급부터 다시 하세요. - mood 를 매 호출마다 바꾸기 → 페르소나가 흔들려서 회수 검증이 어려워져요. 한 자루 안에선 mood 일관성을 유지하세요.
🚀 실무 개선 포인트 (심화)
(1) conversationId 의 owner 검증
현재 GET /api/chat/soulmate/sessions/{conversationId} 는 누구나 그 자루의 메시지를 가져갈 수 있어요.
운영에선 서버측에서 (loginUserId, conversationId) 의 owner 매핑을 검증 해서 다른 사용자의 자루를 차단해야 합니다.
가장 단순한 형태는 conversation_owner (conversation_id PK, user_id, soulmate_id, created_at) 테이블 한 개 추가 + 컨트롤러 첫 줄에서 owner 체크.
이게 생각해볼 주제 1 의 강화 방향과 연결돼요.
(2) 한 사용자 ↔ 한 캐릭터의 활성 자루 정책
현재는 같은 (userId, soulmateId) 로 호출해도 매번 새 conversationId 가 발급돼요.
운영에선 "한 캐릭터당 활성 자루 하나" 정책이 자연스러울 때가 많아요 — 사용자가 앱을 껐다 켜도 어제 이어가던 자루 로 자동 복귀.
find_or_create 패턴을 컨트롤러에 끼워두면 클라이언트가 자루를 보관할 의무에서 해방돼요.
단 한 캐릭터와 여러 회차 (이전 게임 세션 보관) 같은 시나리오는 또 갈라지는 결정이에요 — 도메인 결정.
🎯 면접관을 홀리는 핵심 멘트
"ChatMemory 의
conversationId는 세션 단위 격리 의 책임을 클라이언트와 서버가 분담하는 자리입니다. 서버는 발급 만 책임지고, 보관 은 클라이언트가, 동일 자루로 다시 들고 오기 도 클라이언트의 의무입니다 — REST 의 무상태성 결과 정확히 같습니다. 이 패턴의 강점은 서버가 N 대로 늘어나도 별도 동기화 없이 어느 인스턴스로 요청이 와도 같은 자루를 정확히 찾을 수 있다는 점이고, 약점은 자루의 주인 을 서버가 검증하지 않으면 다른 사용자의 자루가 식별자만 알면 노출된다는 점입니다. ai-friends 같은 캐주얼 도메인은 단순 UUID 가 학습 호흡상 적절하지만, 운영급으로 가려면 (loginUserId, conversationId) owner 매핑 검증 한 줄이 컨트롤러 첫 줄에 박혀야 합니다."
🎯 [과제 2 예시 답안] maxMessages 손익분기점 — 추측을 측정으로
Step 6 에서 우리는 maxMessages = 3 / 10 / 30 의 풍경을 예상 표 로 그렸어요. 이번 과제는 그 표를 측정값 으로 채우는 과제예요. Day 4 과제 2 (schema 비대화의 손익분기점) 와 같은 정신 — 추측을 측정으로 바꾸는 운영 의사결정 을 연습합니다.
Step 1. 측정 절차 — 한 군데만 바꾸고 재기동
이번 과제에서 손대는 자리는 딱 한 군데예요.
// src/main/java/kr/spartaclub/aifriends/chat/config/ChatClientConfig.java
@Bean
public ChatMemory chatMemory(ChatMemoryRepository chatMemoryRepository) {
return MessageWindowChatMemory.builder()
.chatMemoryRepository(chatMemoryRepository)
.maxMessages(20) // ← 이 숫자만 3 → 10 → 20 → 30 으로 4 회 바꾼다
.build();
}
중요: maxMessages 는 빈이 만들어질 때 한 번 평가되는 상수예요. 값을 바꿨으면 반드시 앱 재기동 — ./run.sh restart. 단순 코드 hot-reload 로는 안 바뀌어요. 학생들이 가장 자주 빠뜨리는 함정이라 빨간색으로 박아둘 가치가 있어요.
또 하나 — 각 시도마다 conversationId 를 새로 발급하세요. 같은 자루를 4 번 재사용하면 누적 메시지가 윈도우를 이미 넘어버려서 비교가 깨져요. 깨끗한 자루로 시작해야 5 턴 후 윈도우 적용 결과 를 정직하게 측정할 수 있습니다.
Step 2. 측정 시나리오 — 같은 첫 메시지, 같은 회수 질문
# ── 시도 1: maxMessages = 3 ────────────────────────────────────────
# (ChatClientConfig.java 수정 → ./run.sh restart)
CONV=$(curl -s "http://localhost:8080/api/chat/soulmate?userId=1&mood=우울&message=오늘%20진짜%20우울해" | jq -r '.data.conversationId')
curl "http://localhost:8080/api/chat/soulmate?userId=1&mood=우울&message=회사에서%20일이%20좀%20있었어&conversationId=$CONV"
curl "http://localhost:8080/api/chat/soulmate?userId=1&mood=우울&message=계속%20야근이라%20지쳤어&conversationId=$CONV"
curl "http://localhost:8080/api/chat/soulmate?userId=1&mood=우울&message=다음주에%20휴가라도%20쓸까&conversationId=$CONV"
# 5 턴째 — 첫 호출 키워드("우울") 회수 검증
curl "http://localhost:8080/api/chat/soulmate?userId=1&mood=우울&message=내가%20처음에%20어떤%20기분이었지?&conversationId=$CONV"
# 누적 메시지 개수 확인
curl -s "http://localhost:8080/api/chat/soulmate/sessions/$CONV" | jq '.data | length'
# ── 시도 2~4: maxMessages 를 10 / 20 / 30 으로 바꿔가며 위 시나리오 반복 ─
# (매번 ChatClientConfig.java 수정 → ./run.sh restart → 새 conversationId 발급)
5 턴째의 응답 본문에 "우울" / "슬프다" / "힘들다" 같은 단어가 등장하면 회수 성공 (✅), 모델이 "어떤 기분이었는지 잘 모르겠어" 식으로 답하면 회수 실패 (❌).
Step 3. 예시 측정 결과 — 본인 환경에서 다시 측정
아래는 예시 측정값 (Gemini 2.5 Flash 무료 + ai-friends 코드베이스 기준 — 본인 측정과 다를 수 있어요. 본인 표를 채우는 게 과제의 본질입니다).
maxMessages |
5 턴 후 누적 메시지 (응답 size) | 5 턴째 응답에서 첫 호출 키워드 회수 | (선택) 평균 응답 지연 |
|---|---|---|---|
| 3 | 3 (윈도우 밖 7 개 잘림) | ❌ ("기분이 어땠는지 잘 모르겠어, 다시 알려줄래?") | ~1.3 초 |
| 10 | 10 (5 user + 5 assistant 모두 포함) | ✅ ("처음엔 우울 하다고 했었지") | ~1.6 초 |
| 20 | 10 (한도 안 침) | ✅ (동일) | ~1.7 초 |
| 30 | 10 (한도 안 침) | ✅ (동일) | ~1.7 초 |
⚠️ 위 숫자는 예시 — 본인 측정값으로 채우세요. 특히 "5 턴 후 누적 메시지" 는 코드베이스의 advisor 흐름상 결정적으로 떨어지는 값이라 측정마다 거의 동일해야 합니다. 회수 여부는 LLM 응답이라 확률적이니 시도 2 회 정도 평균 보세요.
Step 4. 분석 — 우리 도메인의 적정선
위 표에서 두 가지가 보여요.
maxMessages = 3은 명백히 부족 — 한 턴이 (user, assistant) 두 메시지를 만드니, 3 으로 두면 직전 한 턴 반 만 컨텍스트에 들어가요. 5 턴째에 첫 호출 키워드가 회수될 길이 없어요.maxMessages = 10부터는 5 턴 시나리오가 충분히 들어감 — 그 위로 늘려봐야 추가 보호 마진 일 뿐 회수율 변화는 없어요. 그런데 매 호출마다 컨텍스트로 들어가는 토큰은 선형으로 늘어나서 비용 ↑.
ai-friends 의 한 회차 평균 턴 수가 10~15 턴이라고 가정하면 (평균 30 메시지), maxMessages = 20 이 우리 도메인의 적정선 이라는 결론이 그려져요. 이유 세 가지.
- 회차 일관성 — 한 회차 안에선 거의 완전 회수 가 보장돼요 (윈도우 안에 모든 메시지 들어감).
- 회차 간 자연스러운 전환 — 회차가 끝나고 새 회차가 시작되면 윈도우 밖으로 지난 회차 가 자연스럽게 흘러나가요. 미연시 게임의 어제 일은 어제 일 결과 정합.
- 토큰 비용 + attention 분산 — 30 으로 올리면 매 호출마다 평균 1.5 배 토큰. 비용도 ↑, 모델 attention 도 분산되어 응답 품질 미세 저하.
요약하면 — "우리 도메인 (ai-friends 캐주얼 미연시) 의 적정 maxMessages 는 20. 한 회차의 메시지 수를 충분히 담으면서, 회차 간 자연 전환을 방해하지 않는 좌표." 이런 1~2 줄 결론이 PM 보고서에 들어갈 자리입니다.
✍️ 본인 도메인은 다를 수 있어요 — 한 세션이 50 턴 넘게 길어지는 도메인 (CS 챗봇, 장시간 학습 튜터) 은 적정선이 50~80 까지 올라갈 수 있고, 1~3 턴짜리 짧은 Q&A 도메인은 6~10 으로도 충분해요. 측정 한 번이 6 개월의 의사결정을 가른다는 게 이 과제의 진짜 의미예요.
💡 채점 포인트 요약
| # | 포인트 | 설명 | 배점 가중 |
|---|---|---|---|
| 1 | 4 단계 측정 | maxMessages 3·10·20·30 모두 측정 (한 단계 빠지면 손익분기점 그래프 깨짐) |
상 |
| 2 | 매번 앱 재기동 | ./run.sh restart 또는 동일 효과의 절차 명시 |
상 |
| 3 | 매번 새 conversationId | 4 단계 모두 깨끗한 자루로 시작 — 누적 자루 재사용 금지 | 상 |
| 4 | 같은 첫 메시지 + 같은 회수 질문 | 4 단계 모두 동일 시나리오 (변수는 maxMessages 한 가지뿐) |
상 |
| 5 | 결과 표 4 행 채워짐 | 누적 메시지 + 회수 여부 두 컬럼은 필수 | 상 |
| 6 | 분석 3 줄 | "왜 X 가 적정선인가" 의 근거 가 숫자에 기반 | 상 |
| 7 | (선택) 응답 지연 측정 | bash 의 time 또는 jq 로 latency 도 함께 |
중 |
6 번이 핵심이에요. 측정만 하고 "적정선이 20 인 것 같다" 같은 모호한 결론이면 점수 절반 — 측정은 의사결정의 근거 가 되어야 의미가 있어요.
🚫 흔한 실수
maxMessages변경 후 앱 재기동 안 함 → 빈은 한 번 만들어지면 안 바뀌니, 코드만 수정하고 다음 시도로 넘어가면 4 회 측정이 모두 같은 결과. 가장 흔한 함정.- conversationId 를 4 단계 동안 재사용 → maxMessages = 3 시도에서 누적된 메시지가 maxMessages = 10 시도에 영향을 줘요. 깨끗한 자루 가 매 시도의 출발점.
- 회수 질문에 첫 호출 키워드를 그대로 넣기 → 예: "우울했다고 한 거 기억해?" — 키워드를 질문에 박아 넣으면 모델이 그걸 그대로 인용해서 회수 검증이 무의미해져요. "내가 처음에 어떤 기분이었지?" 처럼 모델이 컨텍스트에서 끌어와야 답할 수 있는 질문이어야 합니다.
- 유료 모델로 측정 — 본 강의 비용 정책 위반. Gemini 2.5 Flash 무료 티어로 충분합니다.
🚀 실무 개선 포인트 (심화)
(1) 응답 토큰 비용까지 측정
본 과제는 회수 여부 한 축만 봤어요. 운영에선 매 호출의 입력 토큰 수 도 측정 가치가 큽니다. maxMessages 가 늘어날수록 입력 토큰이 거의 선형으로 늘어요. jtokkit 같은 토크나이저 라이브러리로 한 호출의 토큰 수를 찍어보면 비용 곡선 도 그려져서 의사결정이 더 정확해집니다.
(2) 도메인별 손익분기점을 PM 보고서로
이번 과제 결과를 PM 보고서 1 장 으로 정리해 보세요.
(1) 측정 시나리오, (2) 결과 표, (3) 적정선 결론, (4) 적정선이 도메인 변경 (예: 한 회차 길이 30 턴) 에 어떻게 반응하는가) 4 가지 항목.
운영에서 "이 숫자를 왜 20 으로 잡았어?" 라는 질문이 6 개월 뒤에 반드시 옵니다 — 그때 측정 표 한 장 으로 답할 수 있는 시스템과 "그냥 그렇게 잡았어요" 라고 답하는 시스템의 차이가 운영 신뢰의 차이예요.
(3) summarization 보조 정책으로 윈도우 늘리지 않고 회수율 ↑
maxMessages 를 무한정 늘리는 대신, 윈도우 밖으로 흘러나간 메시지를 요약 해서 시스템 프롬프트 끝에 한 줄로 끼워두는 하이브리드 정책이 있어요 (생각해볼 주제 2 의 손실 압축). 본 강의 범위는 아니지만 운영에선 자주 쓰이는 패턴 — Day 19 harness 트랙에서 변형으로 다시 만나요.
🎯 면접관을 홀리는 핵심 멘트
"
maxMessages같은 운영 상수는 추측이 아닌 측정 으로 결정해야 합니다. 우리 도메인의 한 회차 평균 메시지 수와 토큰 비용·attention 분산·회수율을 4 단계로 측정해서 손익분기점을 그리면, ai-friends 같은 캐주얼 미연시 도메인의 적정선은 20 이라는 숫자가 떨어집니다 — 한 회차를 충분히 담되 회차 간 자연스러운 전환을 방해하지 않는 좌표예요. 핵심은 숫자보다 측정 표 자체입니다. 6 개월 뒤 PM 이 왜 20 인가요? 라고 물었을 때 측정 표 로 답할 수 있는 시스템이 운영 신뢰를 만들어요. 같은 질문에 그냥 그렇게 잡았어요 라고 답하는 시스템과는 운영 의사결정의 무게가 다릅니다."
🤔 [생각해볼 거리] 면접관을 사로잡는 ChatMemory 설계의 깊이 🛡️
혼자 고민해도 좋고, 스터디 팀원들과 치열하게 논쟁해도 좋습니다. 모두 현업 기술 면접에서 "이 지원자, 단순 ChatMemory 호출이 아니라 세션 격리·기억 정책·저장소 분리 의 트레이드오프를 깊게 고민해봤구나?" 하고 면접관을 감탄하게 만드는 질문들입니다.
🚨 주제 1. JWT vs conversationId — 어디까지 같고 어디서 갈라지는가?
🔑 [문제 상황 요약]
Step 5 에서 우리는 conversationId 를 서버 발급 + 클라이언트 보관 + 매 호출 제출 의 형태로 만들었어요. JWT 와 형태가 비슷하죠. 하지만 서명·만료·페이로드 측면에서 결정적으로 다른 점이 있어요. 이 차이가 ai-friends 의 세션 노출 위험 을 결정합니다.
💡 [튜터의 가이드 및 해설]
이 문제는 인증 토큰과 세션 식별자의 책임 경계 를 묻는 질문이에요.
1. 같은 점 — 클라이언트 보관 패턴
- 서버가 발급, 클라이언트가 보관, 매 호출마다 제출
- 서버는 stateless 의 결을 유지 — 어느 인스턴스로 요청이 떨어져도 토큰만 들고 있으면 처리 가능
- N 인스턴스 환경에서 별도 동기화 없이 자연스럽게 작동
2. 다른 점 — 서명·만료·페이로드의 부재
| 측면 | JWT | conversationId (Step 5) |
|---|---|---|
| 위변조 방지 | ✅ HMAC/RSA 서명 | ❌ 단순 UUID — 누구나 들고 오면 통과 |
| 만료 정책 | ✅ exp claim |
❌ 만료 없음 — 자루는 영구 |
| 식별자 + 권한 | ✅ sub + roles 페이로드 |
❌ 자루 식별자 한 개뿐 |
| owner 검증 | ✅ 서명 검증으로 자동 | ❌ 서버측 별도 매핑 필요 |
3. 보안 침해 시나리오
- 공격자가 다른 사용자의 conversationId 를 알아내면? →
GET /sessions/{convId}한 번 호출로 그 자루의 모든 메시지가 노출. Step 5 에선 owner 검증을 의도적으로 생략했어요 (강의 호흡상 단순화). - 공격자가 그 자루로 대화를 이어가면? → 우리 서비스가 다른 사용자 세션에 공격자의 메시지를 추가 하게 됩니다. 사용자 입장에선 캐릭터가 갑자기 이상한 답을 하는 풍경.
- 공격자가 그 자루를 삭제 하면? →
DELETE /sessions/{convId}호출로 자루 전체 소실. 사용자 데이터 손실.
4. 강화 방향 — 세 가지 옵션
- (A) owner 매핑 테이블 —
conversation_owner (conversation_id PK, user_id, soulmate_id, created_at)추가 + 컨트롤러 첫 줄에서loginUserId와 매핑 검증. 가장 단순하고 효과적. - (B) 서명된 conversationId — 예:
userId:soulmateId:uuid:HMAC형태로 서명을 박아 페이로드 자체에 owner 정보 + 위변조 방어. 검증은 컨트롤러에서 한 줄로. - (C) JWT 의 sub 와 conversationId 매핑 — 인증 시점의 JWT subject 와 conversationId 의 owner 가 일치하는지 검증. 별도 매핑 테이블 없이 토큰 자체로 검증 — 단 JWT 만료 후 자루 접근이 차단되는 정책 결정 필요.
5. 도메인별 권장
| 도메인 | 권장 강화 수준 |
|---|---|
| 캐주얼 챗봇·ai-friends | (A) owner 매핑 — 운영 단순성 우선 |
| 의료·법률·금융 상담 | (B) + (C) 둘 다 — 위변조·만료 양쪽 방어 |
| 익명 챗봇 (로그인 없음) | conversationId 만 — 다만 공격 시 손실 = 자루 1 개 인 점을 사용자에게 명시 |
🎯 면접관을 홀리는 핵심 멘트
"conversationId 와 JWT 의 공통점은 클라이언트가 상태를 들고 다니는 stateless 토큰 패턴입니다. 차이는 서명 검증의 유무 — JWT 는 위변조·만료·페이로드를 모두 토큰 자체에 박아두지만, 단순 UUID conversationId 는 식별자 한 개뿐이라 공격자가 다른 사용자의 자루 ID 를 알아내면 그대로 노출됩니다. ai-friends 같은 캐주얼 도메인은 학습 호흡상 단순 UUID 가 자연스럽지만, 운영급으로 가려면 (loginUserId, conversationId) owner 매핑 검증 을 컨트롤러 첫 줄에 박는 게 최소 강화선입니다. 의료·법률·금융처럼 자루의 노출 비용이 큰 도메인은 conversationId 자체에 HMAC 서명을 박아 위변조 + 만료 까지 방어하는 게 맞습니다. 핵심 판단 기준은 자루가 노출됐을 때 사용자한테 얼마나 큰 피해가 가는가 — JWT 와 같은 결의 질문입니다."
🚨 주제 2. sliding window 의 완전 망각 vs summarization 의 손실 압축
🔑 [문제 상황 요약]
Step 6 에서 우리는 sliding window 가 윈도우 밖을 완전 망각 한다고 했어요. summarization 은 요점을 손실 압축 으로 유지하죠. 두 정책 모두 사용자 입장에선 "AI 가 내 말을 잊었다" 는 경험을 만들어요. 망각의 방식 이 다를 뿐. 어느 쪽이 사용자 신뢰에 더 안전한지 도메인별로 갈라집니다.
💡 [튜터의 가이드 및 해설]
이 문제는 정직성과 사용성의 트레이드오프 를 묻는 질문이에요.
1. 두 정책이 만드는 사용자 경험의 차이
- 완전 망각 (sliding window): 모델이 모르는 사실 자체를 명확히 드러내요. "네가 그런 말 했었어?" 의 풍경. 사용자가 "아, AI 가 옛날 얘기는 까먹는구나" 를 빠르게 학습. 신뢰는 깎이지만 예측 가능 한 한계.
- 손실 압축 (summarization): 대략적 회수가 되지만 디테일이 어긋나요. "네가 우울하다고 했지" 까지는 맞는데 "우울한 이유가 회사 일이었다" 같은 디테일이 잘못 회수되면 사용자는 "AI 가 내 얘기를 들은 척한다" 는 의심을 가져요. 신뢰가 불규칙하게 깎임.
2. 도메인별 결정
- Option A — 완전 망각 (sliding window): 캐주얼 챗봇·게임·짧은 Q&A. 사용자가 한계를 빨리 학습하고, 디테일 오류가 없다는 점 이 신뢰를 받쳐줘요.
- Option B — 손실 압축 (summarization): 장기 메모·CS 응대·학습 튜터. 디테일 일부 손실은 감수하되 대략적 흐름 을 보존해야 가치가 큰 도메인.
- Option C — 하이브리드 (window + summary 보조): 의료·법률·금융 의 일부. 핵심 정보 (진단명·계약 조건) 는 별도 영구 저장 + 대화 흐름은 sliding window. 디테일 손실 위험을 시스템 분리 로 회피.
3. ai-friends 도메인의 결정 — 완전 망각
ai-friends 같은 캐주얼 미연시는 (A) 완전 망각 이 자연스러워요. 이유 두 가지.
- 회차 단위 자연 전환 — 한 회차가 끝나고 새 회차로 넘어가면 어제 일은 어제 일 같은 흐름이 게임 내러티브에도 자연스러움.
- summarization 의 디테일 어긋남이 치명적 — 캐릭터가 "네가 그때 데이트 하자고 했지" 같은 잘못된 회수를 하면 "AI 가 거짓말한다" 는 인상이 한 번에 박힘. 캐주얼 도메인이라 회복이 어려워요.
4. 사용자 명시적 알림 — 정직성 옵션
윈도우 밖으로 흘러나간 메시지가 있을 때 "앞쪽 N 개 메시지가 컨텍스트 밖이에요" 같은 안내를 띄울지 결정해야 해요. Day 4 의 fallback 정직성 주제와 같은 가족.
| 옵션 | 표면 (사용자) | 운영 측 (메트릭) |
|---|---|---|
| 완전 정직 | "윈도우 밖 N 개" 표시 | flag 기록 |
| 부분 위장 | 표면엔 자연스러운 톤 | 메타데이터에만 flag |
| 완전 위장 | 표시 없음 | 메트릭 없음 |
현업에서는 보통: 캐주얼 도메인 (ai-friends) 은 부분 위장 — 사용자에겐 자연스럽게, 운영 측엔 메트릭으로. 의료·법률은 완전 정직 — 디테일 손실의 위험이 사용자 피해로 직결.
🎯 면접관을 홀리는 핵심 멘트
"기억의 두 정책은 망각의 방식 이 다른 것이지 망각이 사라지는 게 아닙니다. sliding window 는 완전 망각 으로 한계를 정직하게 드러내고, summarization 은 손실 압축 으로 흐름은 유지하지만 디테일 어긋남의 위험을 안습니다. 핵심 트레이드오프는 정직성과 사용성 입니다. 캐주얼 챗봇·미연시 같은 도메인은 sliding window 가 자연스럽습니다 — 한계가 예측 가능하고 디테일 어긋남이 치명적인 신뢰 손상을 만들 수 있기 때문입니다. 반대로 장기 메모·학습 튜터는 summarization 의 가치가 커요. 의료·법률·금융처럼 디테일 손실이 사용자 피해로 직결되는 도메인은 완전 망각 + 핵심 정보 별도 영구 저장 의 하이브리드가 맞습니다. ai-friends 는 sliding window 채택, 윈도우 밖 알림은 표시하지 않는 부분 위장 이 적절하다고 판단합니다 — 캐주얼 게임의 흐름을 끊지 않으면서 운영 측 메트릭으로는 한계를 추적할 수 있기 때문입니다."
🚨 주제 3. `ChatLog` ↔ `SPRING_AI_CHAT_MEMORY` 이중 저장의 정당성
🔑 [문제 상황 요약]
Step 1·Step 3 에서 우리는 ChatLog (비즈니스 로그) 와 SPRING_AI_CHAT_MEMORY (LLM 컨텍스트) 를 별개 테이블 로 분리했어요. 같은 대화가 두 번 저장되니 디스크 비용이 ≈ 2 배. 처음 보면 "왜 굳이?" 싶죠. 합치는 시도가 가능한데 왜 안 합치는지가 본 주제예요.
💡 [튜터의 가이드 및 해설]
이 문제는 추상화의 결과 운영 비용의 트레이드오프 를 묻는 질문이에요.
1. 분리의 이점 — 정책이 다르므로 자루도 달라야
| 축 | ChatLog |
SPRING_AI_CHAT_MEMORY |
|---|---|---|
| 보관 기간 | 영구 (감사·통계) | N 일 후 정리 (Step 7) |
| 누가 보는가 | 사람 (운영자·CS·분석팀) | LLM (다음 호출의 컨텍스트) |
| 삭제 트리거 | GDPR 요청·법무 정책 | 사용자 대화 초기화 버튼 / TTL |
| 컬럼 구성 | 작성자 ID, 세션 메타, 소꿉친구 ID |
conversation_id, type, content, timestamp |
| 프라이버시 | 마스킹·익명화 가능 | 원문 유지 (모델한테 마스킹된 텍스트 주면 답 품질 저하) |
다섯 축이 완전히 다른 데이터를 한 테이블에 욱여넣으면 — 부분 삭제 (LLM 컨텍스트만 비우고 비즈니스 로그는 유지) 가 비대칭으로 어려워져요. 같은 row 의 어느 컬럼은 비우고 어느 컬럼은 남기는 식의 운영이 매번 발생해요.
2. 합치는 시도 — JdbcChatMemoryRepositoryDialect 커스텀 구현
기술적으론 가능해요.
Spring AI 의 JdbcChatMemoryRepositoryDialect 인터페이스를 우리가 직접 구현하면, chat_log 테이블에 role 컬럼 한 줄 추가하고 LLM 호출 시 그 테이블을 바로 읽게 만들 수 있어요.
SELECT / INSERT / DELETE 쿼리를 우리 스키마에 맞게 다 손으로 짜야 함.
3. 합치기의 함정 — 추상화의 역방향 의존
| 합칠 때 발생하는 문제 | 설명 |
|---|---|
| 비즈니스 컬럼이 ChatMemory 추상화로 새어 들어옴 | soulmate_id 같은 도메인 컬럼이 ChatMemory 가 몰라도 되는 자리에 등장. Spring AI 의 표준 결을 깸 |
| 보관 정책 충돌 | 사용자가 "대화 초기화" 누르면 LLM 컨텍스트는 비워야 하지만 비즈니스 로그는 살아남아야 — 한 row 에 두 정책이 동시에 존재 |
| 마이그레이션 비용 | Spring AI 1.1 → 2.0 으로 가면서 표준 schema 가 바뀌면 우리 dialect 도 같이 바꿔야. 표준 추상화의 자동 마이그레이션 혜택을 못 받음 |
| 부분 삭제의 비대칭성 | "이 메시지의 LLM 컨텍스트 부분만 비우고 비즈니스 로그는 유지" 같은 운영이 복잡한 UPDATE 로 변함 |
4. 디스크 비용 정량화
이중 저장의 실제 비용을 한 번 계산해 보세요.
- 메시지당 평균 크기: 약 1KB (텍스트 + 메타)
- 사용자 1 만 명 × 평균 메시지 100 개 = 100 만 메시지
- 한쪽 저장: 약 1GB
- 이중 저장: 약 2GB
운영 인프라 비용으로 환산하면 1GB 추가는 유의미 하지만 운영 자유도 손실 비용 에 비하면 작아요. 분리 정책에서 발생하는 부분 삭제·다른 보관 기간·다른 마스킹 정책 의 운영 자유도가 디스크 1GB 의 가격보다 월등히 비쌉니다.
5. 우리 도메인 결정 — 두 테이블 유지
ai-friends 는 분리 유지 가 맞아요. 이유 두 줄.
- Spring AI 의 표준 추상화 결을 따라가는 게 마이그레이션·운영 비용을 낮춤 — 1.1 에서 2.0 으로 갈 때, 표준 schema 자동 마이그레이션 혜택을 받을 수 있음.
- 부분 삭제 / 다른 보관 정책의 운영 자유도가 디스크 1GB 의 가격보다 비쌈 — 디스크는 싸지만 복잡한 UPDATE 로직 디버깅 시간 은 비싸요.
현업에서는 보통:
| 도메인 | 권장 |
|---|---|
| 캐주얼 챗봇·ai-friends | 분리 유지 (Spring AI 표준) |
| 데이터 볼륨 거대 (수억 메시지) | 분리 유지 + 한 쪽을 cold storage 로 |
| 프라이버시 규제 강한 도메인 | 분리 유지 (접근 권한이 다름) |
| 절충 — 원본은 한 곳, 다른 쪽은 집계만 | SPRING_AI_CHAT_MEMORY 만 원본 보관, ChatLog 는 일별 메시지 수·호감도 추이 같은 집계 만 |
🎯 면접관을 홀리는 핵심 멘트
"추상화의 결 을 따라가는 것이 디스크 비용보다 운영 비용을 낮춥니다.
ChatLog와SPRING_AI_CHAT_MEMORY를 분리하는 이유는 두 데이터의 보관 기간·삭제 트리거·프라이버시 정책이 완전히 다르기 때문입니다. 합치면 디스크 1GB 정도의 절감이 있지만, 부분 삭제의 비대칭성·비즈니스 컬럼이 ChatMemory 추상화로 새어드는 역방향 의존·Spring AI 마이그레이션 시 자동 schema 혜택 상실 같은 운영 비용이 그 절감보다 훨씬 비쌉니다. 디스크 비용이 심각하게 큰 도메인 (수억 메시지) 이라면 분리 유지 + 한 쪽을 cold storage 로 가 정답이지, 한 테이블로 합치는 게 아닙니다. 핵심 원칙은 추상화의 결을 따라가는 것이 결국 가장 싸다 — Spring AI 가 제공하는JdbcChatMemoryRepository표준을 그대로 쓰고, 비즈니스 로그는 별도 자루에 두는 게 운영 안정성과 마이그레이션 자유도를 모두 가져갑니다."