문서 읽는 데 244분 · day05

Day 5. ChatMemory — "LLM 한테 어제 무슨 얘길 했는지 기억하게 만들기"

전체 26강 중 5강 · 스프링 AI
난이도 · 심화선수지식자바 기초스프링 부트

ℹ️스프링 부트로 만든 ai-friends 프로젝트 위에 얹어 진행해요. 자바·스프링이 처음이라면 먼저 “서버 만들기” 트랙부터 권해요.

안녕하세요! 여러분의 백엔드 가이드, 홍순구 튜터입니다.

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(...) 의 시그니처를 StringAiReply 로 갈아엎으면서 한 가지 찝찝함을 남겨뒀어요.

호출 1) 사용자: "오늘 진짜 별로였어"
모델:  "에이, 무슨 일 있었어? 천천히 얘기해봐."

호출 2) 사용자: "사실 회사에서 일이 있었어"
모델:  ???

한 번 답을 받고 끝이에요. 두 번째 호출이 들어오면 모델은 어제(아니, 1 초 전) 무슨 얘길 했는지 모릅니다. 미연시 게임 도메인에서 이건 치명적이에요. 캐릭터가 매 턴마다 "처음 뵙는 분이세요?" 라고 말하면 게임이 성립이 안 되거든요.

💡 오늘 수업의 핵심 "stateless LLM 에 '대화의 기억' 을 입힌다" 🎯

오늘 수업은 한 문장으로 요약돼요.

"LLM 호출은 stateless 다. 그러므로 '대화의 흐름' 은 우리 서버가 만들어서 매 호출마다 다시 넣어줘야 한다."

여기서 세 가지 도구가 등장해요. 지난 시간 Day 4 마무리에서 슬쩍 보여드렸던 Advisor 라는 큰 추상화의 첫 적용이기도 해요.

  1. ChatMemory 인터페이스 — 대화 이력을 어디에 어떻게 보관할지의 정책. "메시지를 N 개까지만 들고 갈래" (sliding window), "오래된 건 요약해서 들고 갈래" (summarization) 같은 전략이 들어가는 곳이에요.
  2. JdbcChatMemoryRepository — 보관 저장소. 서버가 꺼져도 대화가 살아남도록 우리는 MySQL 에 영속화할 거예요. 메모리에 들고 있다가 재시작하면 싹 날아가는 InMemoryChatMemoryRepository 는 이 강의의 프로덕션 예제에서는 안 씁니다 (테스트 코드 한정 허용).
  3. MessageChatMemoryAdvisor주입기. ChatClient 호출 직전에 저장된 대화 이력을 시스템 프롬프트 옆에 자동으로 끼워넣어주는 역할이에요. 우리가 매번 "이전 대화를 끌어와서 프롬프트에 합쳐서..." 같은 코드를 손으로 짤 필요가 없어요. Advisor 한 줄 이면 끝납니다.

이 셋이 손에 들어오면, 지난 시간 Day 4 의 그 찝찝함 — "두 번째 호출에서 모델이 첫 호출을 못 알아본다" — 이 한 방에 풀려요. 그리고 추가로, ai-friends 의 게임 세션이 회차를 넘나들면서도 대화가 이어지게 됩니다 (사용자가 앱을 껐다 켜도 캐릭터가 어제 대화를 기억).

🙋 한 학생의 걱정

"튜터님, 솔직히 말씀드리면요. 지난 시간 Day 4 끝나고 AiReply 받는 데까지는 따라왔는데, 오늘 들어가면 갑자기 ChatMemory, JdbcChatMemoryRepository, MessageChatMemoryAdvisor 세 개가 한꺼번에 등장하네요. 머리가 좀 어지러워요... 이거 다 외워야 하나요? 그리고 우리 코드베이스에 이미 ChatLog 라는 도메인이 있던데, 그건 그럼 왜 만들었던 거예요? ChatMemory 랑 어떻게 다른 거죠? "

날카로운 질문이에요. 두 가지를 미리 짧게 풀어드릴게요.

첫째, 세 도구는 역할이 정확히 셋으로 나뉘어 있어서 한 번 그림을 그려두면 안 헷갈려요. Step 2 에서 Repository(저장소)·Memory(정책)·Advisor(주입기) 의 3 각 구조를 그림 한 장으로 정리할 거예요. "외우는 게 아니라 역할 세 개를 분리해서 보면 된다" 는 감각이 잡힐 거예요.

둘째, ChatLogChatMemory이름은 비슷해도 역할이 완전히 달라요.

구분 ChatLog (우리 비즈니스 로그) ChatMemory (Spring AI 컨텍스트)
누가 보는가 사람 (운영자·CS·분석팀) LLM (다음 호출의 컨텍스트로)
용도 감사·통계·CS 응대 대화의 흐름 유지
보관 형태 우리 도메인 모델 (작성자·시간·메타) Spring AI 의 Message 객체 (role + content)

같은 대화를 두 번 저장하는 셈이라 처음엔 어색해요. 그런데 LLM 한테 넘겨줄 컨텍스트사람이 나중에 들춰볼 비즈니스 로그 는 보관 포맷·삭제 정책·프라이버시 처리가 다 달라서 분리하는 게 정석이에요. Step 3 에서 이 분리를 한 번 더 짚을게요.

🎯 학습 목표

  • stateless LLM 호출의 본질 을 HTTP 의 무상태성과 같은 맥락으로 이해하고, "기억" 을 만들려면 서버가 무엇을 해야 하는지 손으로 그립니다.
  • Spring AI 의 ChatMemory 3 각 구조 (Repository · Memory · Advisor) 를 한 그림으로 정리하고, 셋의 책임이 어떻게 분리되어 있는지 체득합니다.
  • JdbcChatMemoryRepository 로 대화를 MySQL 에 영속화해서, 서버 재시작·세션 끊김에도 살아남는 저장 흐름을 만듭니다.
  • MessageChatMemoryAdvisor + MessageWindowChatMemory 조합으로 슬라이딩 윈도우 기반 컨텍스트 자동 주입을 한 줄짜리 advisor 등록으로 끝냅니다.
  • ai-friends 의 SoulmateChatServiceconversationId 를 키로 세션을 세이브/로드 하는 흐름까지 통합해서, 같은 사용자가 다시 들어왔을 때 어제 대화가 이어지는 풍경을 직접 만듭니다.
  • 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 까지 만들 필요 있나요?"

좋은 감각이에요. 실제로 "이전 메시지 리스트를 클라이언트가 들고 다닌다" 는 패턴은 짧은 데모 에선 충분히 동작해요. 그런데 우리 도메인에선 세 가지가 걸려요.

  1. 모바일 / 다중 디바이스 동기화 — 사용자가 폰에서 시작한 대화를 노트북에서 이어가려면 클라이언트 측에 들고 있던 이력은 무용지물이에요. 서버가 진실의 원본(source of truth) 이어야 해요.
  2. 토큰 비용 ≠ 클라이언트 비용 — 클라이언트가 들고 다니더라도 결국 서버가 그 이력을 LLM 에 다시 보내야 해요. 그 시점에 토큰 윈도우(maxMessages) 를 적용하려면 서버가 정책을 쥐고 있어야 해요. 클라이언트한테 맡기면 디버깅·운영이 어지러워져요.
  3. 프라이버시 정책 — "이력 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 등록 (한 줄)
통합 적용 SoulmateChatServiceconversationId 로 세션 세이브/로드 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_id VARCHAR(36)대화방 식별자. 한 사용자의 한 회차 대화가 한 conversation_id 로 묶여요. 36 이라는 숫자가 결정적이에요. UUID 한 개가 정확히 36 자 (32 자 hex + 4 개 하이픈) 거든요. 즉 "이 컬럼은 UUID 받기로 약속하고 만든 자리" 라는 신호예요. 우리도 ai-friends 의 세션 식별자를 UUID 형식으로 만들어 넣어야 들어맞아요. (Step 5 에서 본격 사용)
  • content TEXT — 메시지 본문. user 메시지·assistant 응답 모두 이 한 컬럼에 들어가요.
  • type ENUM — 메시지의 역할. USER, ASSISTANT, SYSTEM, TOOL 네 종류만 받아요. Step 2 에서 본 MessageType enum 과 1:1 매칭이에요.
  • timestamp TIMESTAMP — 저장 시각. 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. ChatLogSPRING_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.javachatMemory(...) 메서드를 추가합니다. 이게 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_idVARCHAR(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 외부 프롬프트가 진짜로 사용되기 시작 하며, GeminiService 30 줄을 들어낸다."

세 약속을 다시 펼치면 이래요.

회수할 약속 출처 오늘 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.generateReplysoulmateChatService.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)
사용자 식별 userIdUserAnonymizer 없음 (싱글플레이)
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

그리고 RestClientConfiggeminiRestClient 빈도 같이 들어내요. 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();

실험 절차.

  1. maxMessages 값을 3 으로 바꾸고 ./run.sh 로 앱 재기동
  2. 첫 호출에서 conversationId 를 받아두기 (Step 5 에서 봤듯 응답에 함께 내려옴)
  3. 같은 conversationId 로 짧은 메시지 5 번 보내기 (각 호출이 user 1 개 + assistant 1 개를 적재 → 5 호출 후 누적 메시지는 최대 10 개)
  4. GET /api/chat/soulmate/sessions/{conversationId}지금 advisor 가 LLM 한테 넘기는 메시지가 몇 개인지 확인
  5. maxMessages10 으로 바꾸고 (앱 재기동) — 새 conversationId 로 같은 5 호출 반복 → 다시 조회
  6. maxMessages30 으로 바꾸고 — 새 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 의 ChatLog vs ChatMemory 표 — ChatLog우리 비즈니스 로그 (운영팀이 들춰보는, 통계 / 감사 / 디버깅 용도), ChatMemoryLLM 의 단기 작업 기억 (모델 컨텍스트 주입용).

지금 풍경이 정확히 그 표의 결과 같아요.

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 만 누적된다" — 기억나시죠? 이게 바로 이 한 끗입니다.

MessageChatMemoryAdvisorafter(...) 은 매 호출마다 방금 사용자가 보낸 메시지 + 모델이 응답한 메시지 두 개를 repository.saveAll(...) 로 INSERT 해요.

deleteByConversationId(...) 는 우리가 명시적으로 호출하지 않으면 영원히 안 불립니다. 즉 한 사용자가 100 턴 대화하면 row 200 개가 그 conversationId 아래 영원히 남아요.

운영에선 두 갈래로 갈라요.

  • (a) 세션 종료 시 즉시 삭제 — 사용자가 "이 대화 다시 시작" 을 누를 때 Step 5 의 DELETE /api/chat/soulmate/sessions/{conversationId} 호출. 자루가 통째로 비워지죠. 본 강의는 여기까지 훅 한 줄 을 박아뒀어요.
  • (b) 주기적 batch 정리 — 스케줄러 (Spring 의 @Scheduled) 로 매일 새벽 N 일 이상 손대지 않은 conversationfindConversationIds() 로 끌어와 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_idfindByConversationId(conv) 의 핵심 키. 인덱스 트리를 세션별로 좁혀주는 역할.
  • 두 번째 컬럼 timestamp — 같은 세션 안에서 시간 순으로 정렬 해서 LLM 한테 넘기기 위한 키. ORDER BY timestamp 가 인덱스만으로 해결돼요.

복합 인덱스의 첫 컬럼이 conversation_id 라는 게 특히 중요해요. 세션 단위 조회 가 우리의 99% 쿼리거든요 (advisor 의 before(...) 가 매 호출마다 호출하는 그 자리). 만약 첫 컬럼을 timestamp 로 잡았다면? 인덱스가 세션별 로 작동을 못 해서 풀스캔에 가까운 비용이 나왔을 거예요.

운영급으로 가도 추가로 박을 만한 인덱스가 거의 없다 는 결론이에요. 이 한 줄이 우리 도메인의 거의 모든 조회 패턴을 커버합니다.

5. 운영 스케일 시 고민할 점 — 한 줄씩

본 강의에선 구현하지 않지만, 시야 로만 짚고 갑니다.

  • 멀티 인스턴스 환경JdbcChatMemoryRepositoryDB 트랜잭션 으로 동시성을 잡으니, 인스턴스가 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-friendsDay 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 패턴과 직접 만나는 지점이에요.

MessageChatMemoryAdvisorafter(...) 훅이 동기 호출 에선 깔끔히 동작했지만, 스트리밍 에선 청크가 흩어져 도착하니 완성된 메시지 를 어디서 잡아야 할지가 미묘해져요.

그리고 사용자 입장에서 보면 — 다음 시간엔 ai-friends 의 소꿉친구가 답변을 글자 단위로 타이핑하듯 흘려주는 풍경 을 직접 만들 거예요.

빈 화면을 5 초 동안 보고 있던 1.5 초로 체감 대기 시간이 줄어드는, 그 결정적인 UX 차이를 직접 체감해보는 시간입니다.

Day 6 에서 그 학습 지점을 풀어봅니다.


🔥 오늘의 도전 과제 (Homework)

[구현 1] 한 사용자가 두 캐릭터와 동시에 멀티턴 대화하는 게임 화면 만들기

배경 시나리오

ai-friends 미연시 게임에서 한 사용자가 여러 캐릭터 (소꿉친구 A, 선배 B 등) 와 동시에 친밀도를 쌓아가는 흔한 시나리오. 각 캐릭터마다 conversationId 가 분리 되어야 한 캐릭터의 대화가 다른 캐릭터로 새지 않아요.

PM 이 슬랙을 던집니다.

"튜터님, 사용자가 한 캐릭터랑 얘기하다가 다른 캐릭터로 넘어가면, 이전 캐릭터의 대화가 새 캐릭터한테 섞여 보이는 버그가 있다는 제보가 있어요. 한 사용자가 동시에 두 세션을 관리하는 시나리오를 한 번 시뮬레이션해볼 수 있을까요?"

전형적인 세션 격리 검증 요구예요. Step 5 의 conversationId 격리가 한 사용자 ↔ 여러 캐릭터 풍경에서 정확히 동작하는지 손으로 굴려봅시다.

💡 왜 굳이 이 과제를 할까요?

  1. 격리의 시각적 확인 — Step 5 의 한 학생의 걱정 ("conversationId 를 클라이언트가 들고 다니는 게 번거로워 보여요") 에 대한 실측 답 이에요. 두 자루가 정말 안 섞이는지 직접 봐야 합니다.
  2. 클라이언트 측 conversationId 보관 패턴 체득 — JWT 처럼 서버 발급 → 클라이언트 보관 → 매 호출 제출 의 호흡이 실제 UX 에 어떻게 녹는지 손으로 그려보는 시간.

✅ 요구사항

  1. 두 캐릭터 (A, B) 와 각각 5 턴씩 대화 시뮬레이션 — curl 또는 간단한 HTML 폼
    • 캐릭터 A: mood=기쁨 으로 5 턴 진행 (예: "안녕! 오늘 어땠어?" → "별일 없었어" → ...)
    • 캐릭터 B: mood=설렘 으로 5 턴 진행 (다른 주제로 — 예: "주말에 뭐 할까?" → ...)
  2. 각 캐릭터의 conversationId 를 클라이언트 측에서 보관 — sessionStorage 또는 그냥 shell 변수 (CONV_A, CONV_B) 로 OK
  3. GET /api/chat/soulmate/sessions/{convA}{convB} 가 서로 격리된 메시지 리스트를 돌려주는지 확인 + 스크린샷
  4. 두 세션의 메시지 개수 · 내용이 섞이지 않음 을 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 인지, 더 작아도 되는지, 더 커야 되는지 의 감각을 숫자로 잡아요.

💡 왜 굳이 이 과제를 할까요?

  1. 추측을 측정으로 바꾸기 — Day 4 과제 2 와 같은 정신. 운영 의사결정의 무게는 측정에서 나와요. "우리 도메인엔 20 이 적당하다" 라는 PM 보고서를 숫자 표 와 함께 낼 수 있어야 합니다.
  2. 첫 호출 키워드 회수 여부 — sliding window 의 완전 망각 이 어느 maxMessages 에서 시작되는지 직접 봅니다. 우리 도메인의 손익분기점이 어디인지가 체감 으로 잡혀요.

✅ 요구사항

  1. maxMessages 를 3, 10, 20, 30 으로 바꿔가며 각각 동일한 5 턴 대화 시뮬레이션
    • MemoryConfig (Step 4 에서 만든) 의 maxMessages(...) 값을 4 단계로 바꿔가며 앱 재기동
    • 매번 같은 첫 메시지 사용 — 예: "오늘 진짜 우울해" (키워드 우울 이 회수 가능한지 검증할 거예요)
  2. 각 설정에서 5 번째 호출 시 모델이 첫 호출의 키워드 (예: "우울") 를 기억하는지 확인
    • 5 번째 메시지로 "내가 처음에 어떤 기분이었지?" 같은 회수 질문을 던지기
    • 모델 응답에 "우울" 또는 그 의미 단어가 등장하는지 체크
  3. 결과 markdown 표maxMessages / 누적 메시지 개수 / 첫 호출 키워드 기억 여부 / (선택) 응답 지연
  4. 분석 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 — ChatLogSPRING_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. 분석 — 우리 도메인의 적정선

위 표에서 두 가지가 보여요.

  1. maxMessages = 3 은 명백히 부족 — 한 턴이 (user, assistant) 두 메시지를 만드니, 3 으로 두면 직전 한 턴 반 만 컨텍스트에 들어가요. 5 턴째에 첫 호출 키워드가 회수될 길이 없어요.
  2. 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일별 메시지 수·호감도 추이 같은 집계

🎯 면접관을 홀리는 핵심 멘트

"추상화의 결 을 따라가는 것이 디스크 비용보다 운영 비용을 낮춥니다. ChatLogSPRING_AI_CHAT_MEMORY 를 분리하는 이유는 두 데이터의 보관 기간·삭제 트리거·프라이버시 정책이 완전히 다르기 때문입니다. 합치면 디스크 1GB 정도의 절감이 있지만, 부분 삭제의 비대칭성·비즈니스 컬럼이 ChatMemory 추상화로 새어드는 역방향 의존·Spring AI 마이그레이션 시 자동 schema 혜택 상실 같은 운영 비용이 그 절감보다 훨씬 비쌉니다. 디스크 비용이 심각하게 큰 도메인 (수억 메시지) 이라면 분리 유지 + 한 쪽을 cold storage 로 가 정답이지, 한 테이블로 합치는 게 아닙니다. 핵심 원칙은 추상화의 결을 따라가는 것이 결국 가장 싸다 — Spring AI 가 제공하는 JdbcChatMemoryRepository 표준을 그대로 쓰고, 비즈니스 로그는 별도 자루에 두는 게 운영 안정성과 마이그레이션 자유도를 모두 가져갑니다."

더 배우려면

실무 프로젝트까지 가고 싶다면

팀스파르타 백엔드 부트캠프에서 인스타그램 클론을 풀스택으로 완성합니다.