Day 6. Streaming — "답변이 흘러 도착하는 형태"
안녕하세요! 여러분의 백엔드 가이드, 홍순구 튜터입니다.
Day 5, 정말 단단하게 마무리하셨어요.
지난 시간 우리는 stateless LLM 한테 대화의 기억 을 입혔죠.
JdbcChatMemoryRepository 로 MySQL 에 영속화하고, MessageWindowChatMemory 로 sliding window 정책을 깔고, MessageChatMemoryAdvisor 한 줄로 호출 직전·직후 자동 끼워넣기까지 끝냈어요.
conversationId 를 키로 세션을 갈라두니 같은 사용자가 두 캐릭터랑 동시에 떠들어도 대화가 안 섞였고요.
그런데 지난 시간 마무리에서 제가 또 슬쩍 미루고 도망간 게 하나 있었어요.
"오늘 만든
SoulmateChatService.chat(...)은 답변이 몰아서 한 번에 도착하니 답답한 부분이에요. ChatGPT · Claude · Gemini 의 웹 UI 처럼 답변이 글자 단위로 흘러 도착하게 만들 수 있는데... 그건 다음 시간 (Day 6) Streaming 의 모습 으로 만나요."
오늘이 그 약속을 펼치는 날입니다.
지난 시간 우리가 정리한 .call().entity(AiReply.class) 한 줄을 한 번 더 떠올려 봅시다.
public AiReply chat(String conversationId, String anonymizedUserName, String mood, String userMessage) {
return soulmateChatClient.prompt()
.system(...)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
.user(userMessage)
.call()
.entity(AiReply.class);
}
이 호출을 사용자 입장에서 한 번 그려볼게요.
사용자가 "오늘 진짜 별로였어" 라고 입력하면, 모델이 답변을 전부 만들 때까지 빈 화면을 멍하니 보고 있어야 해요. Gemini 2.5 Flash 면 ≈ 1.5~3 초, Ollama 로컬 모델이면 더 길게 5~10 초까지도 걸려요. 답변이 한 번에 떨어지니, 그 시간 동안 사용자는 "앱이 멈춘 건가?" 를 의심해요.
💡 오늘 수업의 핵심 — ".call() 을 .stream() 으로, 답변을 흘려보내는 한 줄"
오늘 수업은 한 문장으로 요약돼요.
"같은 총 응답 시간이라도, 체감 대기 시간 은 절반 이하로 줄일 수 있다.
.call()을.stream()으로 바꾸는 한 줄, 그리고Flux<String>이 떨어지는 원리만 익히면."
여기서 네 가지 도구가 등장해요. 지난 시간 Day 5 에서 익힌 Advisor 위에 흐름의 채널 을 얹는 작업이에요.
.stream().content()—.call().entity(...)의 형제. 응답이 한 번에 떨어지는 대신Flux<String>으로 청크 단위로 흘러나와요. Spring AI 의 ChatClient 는 동기 / 스트리밍 두 모양을 같은 fluent API 위에서 깔끔히 갈라놨어요.- Reactor
Flux<String>— "한 번에 안 오고 흘러 오는 데이터" 를 받는 컨테이너예요. 비동기를 정복하는 도구가 아니라, 받는 모양 만 잡으면 충분한 컨테이너로 우선 보시면 돼요. 깊은 내부 동작 (스케줄러·백프레셔) 은 Step 2 에서 필요한 만큼만 짚을 거예요. - SSE (Server-Sent Events) — 별도 의존성 없이 HTTP 응답을 끊어 보내는 표준 미디어 타입 (
text/event-stream). 신규 프로토콜이 아니라 그냥 HTTP 에 가깝다는 점이 핵심이에요. Spring MVC 에선 컨트롤러가Flux<String>을 직접 반환하면 끝이에요. ChatClientMessageAggregator(내부) —MessageChatMemoryAdvisor가 스트리밍 종료 시점에 한 번 청크를 모아 ChatMemory 에 저장하는 비밀 장치예요. Step 5 에서 지난 시간 advisor 의after(...)훅이 스트리밍에선 어떻게 동작하는지 풀어봅니다.
이 넷이 들어오면, 지난 시간 만든 SoulmateChatService 가 타이핑 효과로 흘러나오는 캐릭터 로 진화해요. 그리고 미연시 게임의 UX 가 한 단계 올라갑니다 — 같은 모델, 같은 모델 비용, 같은 ChatMemory. 클라이언트에 흘려보내는 채널만 바꿨을 뿐인데요.
🙋 한 학생의 걱정
"튜터님, 솔직히 지난 시간 ChatMemory 까지 따라온 것도 머리 터지기 직전이었어요. 그런데 오늘 또 Reactor
Flux라는 이름이 나오고, SSE 라는 새 프로토콜도 나온다고요? 저 비동기 어려워해요... 그리고 결국 다 배워도 나중에 WebSocket 도 써야 한다면서요? 머리에 안 들어와요."
그 걱정 너무 잘 알아요. 세 가지를 짧게 풀어드릴게요.
첫째, Flux 는 "비동기를 정복하는 도구" 가 아니에요. 오늘 우리는 Flux 를 "한 번에 안 오고 흘러오는 데이터를 받는 컨테이너" 정도로만 쓸 거예요. 컨트롤러에서 Flux<String> 을 그대로 반환 만 하면 Spring MVC 가 알아서 흘려보내요. .subscribe(...) · .flatMap(...) 같은 깊은 연산자는 오늘 안 씁니다. 받는 모양 만 잡으면 끝이에요.
둘째, SSE 는 신규 프로토콜이 아니에요. 그냥 HTTP 응답을 끊어 보내는 표준 미디어 타입 (text/event-stream) 이고, 별도 의존성도 안 받아요. WebSocket 처럼 핸드셰이크 코드를 따로 짜지 않아도 돼요.
Spring MVC 에서 produces = MediaType.TEXT_EVENT_STREAM_VALUE 한 줄이면 SSE 응답이 나가요.
셋째, 우리는 오늘 SSE 로만 갑니다. WebSocket 은 Step 6 에서 비교 만 해요 (트레이드오프 표 한 장). 양쪽을 다 손으로 만질 필요는 없어요. 우리 도메인 (캐릭터가 사용자한테 답변을 흘려주기만 하는 단방향 흐름) 에는 SSE 가 더 잘 맞고, 의존성도 더 가볍거든요. WebSocket 은 언제 SSE 로는 부족하고 양방향이 필요한가 를 판단할 수 있는 감각만 잡고 갑니다.
요약하자면 오늘 새로 외울 건 세 가지의 단어 예요 — .stream().content() / Flux<String> / text/event-stream. 나머지는 그게 어디서 어떻게 만나는지 만 익히면 돼요.
학습 목표
- 블로킹 응답의 UX 문제 를 curl 로 직접 체감하고, 스트리밍이 왜 답인지 직관으로 이해합니다.
- Spring AI 의
.stream().content()가 떨어뜨리는Flux<String>을 받는 모양 으로 익힙니다 (깊은 Reactor 연산자 학습 X). - Spring MVC 에서
@GetMapping(produces = TEXT_EVENT_STREAM_VALUE)+Flux<String>직접 반환 패턴으로 SSE 응답을 만듭니다. - ApiResponse 표준 패턴의 정당한 예외 를 결정하는 근거를 잡습니다 —
text/event-stream미디어 타입과 JSON 래핑이 비호환이라는 기술적 사정. MessageChatMemoryAdvisor가 스트리밍에서도 동작하는 비밀 —ChatClientMessageAggregator가 스트림 종료 시점에 한 번 청크를 모아 저장하는 메커니즘을 이해합니다.- WebSocket vs SSE 트레이드오프 를 표 한 장으로 정리하고, 왜 우리 도메인에는 SSE 가 맞는지 설명할 수 있습니다.
- ai-friends 의 캐릭터 대사가 타이핑되듯 흘러나오는 형태를 직접 만들어 봅니다.
Step 1: "답변이 다 올 때까지 빈 화면을 본 적 있죠?" — 블로킹 UX 의 답답함 재점검
자, 본격적으로 새 도구 (.stream().content()) 를 익히기 전에 — 지난 시간 만든 /api/chat/soulmate 엔드포인트가 왜 답답한지 부터 몸으로 한 번 느끼고 가야 해요. 그래야 오늘 도구가 들어왔을 때 "와, 진짜 살았다" 라는 감각이 옵니다.
이 Step 은 코드를 새로 짜지 않아요. 지난 시간까지의 코드베이스를 그대로 띄워두고, curl 로 응답 시간을 측정 해서 blocking 의 모습 을 직접 확인할 거예요. 시뮬레이션 위주의 Step 입니다.
1. 먼저 지난 시간까지의 베이스라인 띄우기
Day 5 까지의 코드베이스 상태로 앱을 띄워봅시다. Day 5 마무리에서 우리는 day05-chat-memory 브랜치에 박제해뒀죠.
cd lecture-source-code/ai-friends
git status # working tree clean 확인
git checkout day05-chat-memory # Day 5 마지막 시점
./run.sh up # docker compose 로 앱 + MySQL 기동
앱이 8080 으로 떠 있으면 준비 완료입니다. 헬스체크로 한 번 확인하고 갈게요.
curl http://localhost:8080/actuator/health
# {"status":"UP"}
좋아요, Day 5 베이스라인 살아있어요. 지난 시간 만든 SoulmateChatService.chat(...) 의 시그니처를 한 번만 더 떠올려 봅시다 (이게 오늘 바꿀 대상 이에요).
return soulmateChatClient.prompt()
.system(...)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
.user(userMessage)
.call()
.entity(AiReply.class);
여기서 우리 눈에 띄는 부분은 두 줄이에요 — .call() 그리고 .entity(AiReply.class). 이 두 줄이 왜 답답한 응답을 만드는지 가 오늘의 출발점입니다.
2. 첫 번째 호출 — 시간 측정과 함께
자, 이제 진짜 실험 시간이에요. time 명령으로 응답 시간을 측정해 볼 거예요. 환경에 따라 숫자는 달라요 — 하지만 모습 은 비슷할 거예요.
time curl -s "http://localhost:8080/api/chat/soulmate?userId=1&mood=우울&message=오늘%20진짜%20별로였어&conversationId=demo-streaming-1"
응답이 떨어지기까지 시간 측정 결과 는 대략 이런 식이에요 (Gemini 2.5 Flash 기준).
{
"success": true,
"data": {
"aiMessage": "에이, 무슨 일 있어? 오늘 하루 힘들었구나... 천천히 얘기해줄래?",
"choices": ["회사에서 일이 좀 있었어", "그냥 별 이유 없이 가라앉아", "괜찮아, 들어줘서 고마워"],
"affectionDelta": 1
}
}
real 0m2.341s
user 0m0.011s
sys 0m0.009s
약 2.3 초. 빠른 편이에요. 그런데 이 2.3 초의을 한 번 더 보세요.
| 시점 | 사용자 화면 | 서버 → 클라이언트로 흐른 데이터 |
|---|---|---|
| 0.0 초 | 메시지 전송, 빈 말풍선 + 로딩 스피너 | 0 byte |
| 0.5 초 | 빈 말풍선 + 로딩 스피너 | 0 byte |
| 1.0 초 | 빈 말풍선 + 로딩 스피너 | 0 byte |
| 1.5 초 | 빈 말풍선 + 로딩 스피너 | 0 byte |
| 2.0 초 | 빈 말풍선 + 로딩 스피너 | 0 byte |
| 2.3 초 | 답변 한 방에 도착 | 응답 전체 (≈ 200 bytes) |
0 ~ 2.3 초 사이에 클라이언트는 0 byte 를 받고 있었어요. 사용자는 그 시간 동안 "앱이 멈춘 건가?" 를 의심하고, 빠른 사용자는 새로고침 버튼을 누르거나 메시지를 다시 한 번 보내요 (그러면 ChatMemory 가 두 번 누적되는 부작용까지 따라오고요).
3. ChatGPT · Claude · Gemini 의 모습과 비교
자, 같은 2.3 초를 흘려보내는 방식으로 그려보면 어떻게 될까요?
| 시점 | 사용자 화면 | 서버 → 클라이언트로 흐른 데이터 |
|---|---|---|
| 0.0 초 | 메시지 전송 | 0 byte |
| 0.3 초 | "에이," | 첫 청크 |
| 0.6 초 | "에이, 무슨 일" | 두 번째 청크 |
| 1.0 초 | "에이, 무슨 일 있어? 오늘" | 세 번째 청크 |
| 1.5 초 | "에이, 무슨 일 있어? 오늘 하루 힘들었" | 네 번째 청크 |
| 2.0 초 | "에이, 무슨 일 있어? 오늘 하루 힘들었구나... 천천히" | 다섯 번째 청크 |
| 2.3 초 | "에이, 무슨 일 있어? 오늘 하루 힘들었구나... 천천히 얘기해줄래?" | 마지막 청크 |
총 응답 시간은 같은 2.3 초 이지만, 사용자가 응답을 인식한 첫 시점 은 0.3 초 예요. 7 배 가까이 빨라진 거죠. 이 차이가 ChatGPT · Claude · Gemini 의 웹 UI 가 모두 같은 형태 으로 갈아탄 이유예요.
UX 연구에선 이걸 체감 대기 시간 (perceived latency) 라고 부르는데, 깊이 들어갈 필욘 없고 — 직관으로만 잡으면 충분해요. "같은 5 초라도, 0 byte 의 5 초 와 흘러 도착하는 5 초 는 사용자에게 완전히 다른 시간이다" 정도로요.
4. 왜 한 번에 떨어지나? — .call().entity(...) 의 사정
이쯤에서 "왜 한 번에 떨어지냐" 가 궁금해야 정상이에요. 답은 지난 시간 우리가 쓴 두 줄에 그대로 들어있어요.
.call()
.entity(AiReply.class);
.call() 은 "호출의 마지막 토큰까지 전부 받아서 한 번에 돌려달라" 라는 의미예요. .entity(AiReply.class) 는 그 완성된 응답 전체 를 ObjectMapper 로 AiReply 객체에 매핑하라는 거고요. 두 줄 다 응답이 완전체 가 되어야 동작하는 구조예요. JSON 매핑은 일부 객체 로는 못 하잖아요 — } 가 도착할 때까지 기다려야 파싱이 시작되니까요.
게다가 우리 서버 ↔ LLM 사이도 마찬가지예요.
지난 시간 우리가 쓴 Spring AI 의 ChatModel 구현체 (Gemini, Ollama 등) 는 .call() 호출 시 모델 서버의 응답이 완성될 때까지 그 응답 본문을 버퍼에 쌓아둬요.
그래서 우리 서버가 모델 응답의 첫 토큰을 0.3 초에 받았더라도, 클라이언트한테 흘려보낼 채널이 없으니 그 시점엔 아무것도 안 일어나는 거예요.
5. ai-friends 도메인 적합성 — 캐릭터 대사는 흘러야 한다
우리 도메인을 떠올려 봅시다. ai-friends 는 미연시 게임 부분이에요. 캐릭터가 사용자한테 답변을 건네는 부분이에요. 미연시 게임에서 캐릭터의 대사가 어떻게 화면에 나오는지 — 떠올려 보세요.
거의 모든 미연시 게임이 타이핑 효과 로 대사를 흘려요. 대사 박스에 글자가 한 글자씩 또륵또륵 떨어지죠. 빠른 사용자를 위해 "전체 출력" 버튼이 따로 있을 정도로, 흘러나오는 그 자체가 게임의 일부 예요. 이게 정적인 답답한 응답보다 캐릭터에 생동감을 입혀주거든요.
지금 우리 ai-friends 의 응답은 캐릭터의 대사를 한 방에 떨어뜨리는 부분이에요. 이게 도메인적으로 맞지 않아요. 캐릭터가 생각하는 호흡 이 사라져 있고, 말을 건네는 호흡 이 사라져 있어요.
오늘 Day 6 의 동기 부여는 단순히 "UX 가 좋아진다" 가 아니에요. "우리 도메인에 맞는 응답 방식으로 갈아타자"가 본질이에요. 같은 2.3 초의 응답을 캐릭터답게 흘려보내자는 거죠.
🙋 날카로운 질문 타임
"튜터님, 그냥 프론트엔드에서 받은 답변을 토큰 단위로 잘라서 화면에 타이핑 효과 입혀주면 안 되나요? 굳이 백엔드에서 스트리밍을 만들 필요 있어요?"
좋은 감각이에요. 사실 오래된 챗봇 UI 들이 그 방식으로 눈속임을 했어요. 그런데 우리 도메인에선 두 가지가 걸려요.
- 가짜 streaming 은 총 응답 시간 자체 를 못 줄여요. 클라이언트가 응답을 다 받기 전엔 타이핑 효과를 시작도 못 하니까요. 결국 0 ~ 2.3 초 사이의 0 byte 침묵 은 그대로예요. 사용자가 새로고침 버튼을 누를 그 답답함 자체는 해결이 안 돼요.
- 모델의 첫 토큰 도착 시점 은 전체 완성 시점 보다 훨씬 빨라요. Gemini 2.5 Flash 의 경우 첫 토큰까지 ≈ 0.3 초, 전체 완성까지 ≈ 2.3 초. 이 시간차를 클라이언트한테 그대로 흘려주는 게 진짜 streaming 부분이에요. 가짜로는 못 만드는 7 배 차이죠.
요약하자면 진짜 streaming 은 모델이 토큰을 만들기 시작한 그 순간부터 클라이언트 화면에 글자가 도착하기 시작 하이에요. 프론트의 타이핑 효과로는 절대 못 만들어요.
"튜터님, 2.3 초가 그렇게 답답해요? 그냥 좀 기다리면 되는 거 아닌가요?"
직관으로 답하자면 — 맞는 호흡과 안 맞는 호흡 의 차이예요. 사람한테 메시지 보내고 2.3 초 동안 입력 중... 이 보이면 자연스러워요. 그런데 2.3 초 동안 그냥 침묵 이면 어색하죠? 사람 사이의 카톡에서도 입력 중... 이라는 신호를 굳이 보여주는 이유예요. 응답이 시작 됐다는 신호가 있어야 사람의 호흡이 맞아요.
미연시 도메인에선 입력 중... 의 등가물이 타이핑 효과 예요. 그래서 진짜 streaming 으로 캐릭터가 답변을 흘려주기 시작하는 0.3 초의 신호 가 있어야, 사용자가 "AI 가 응답하고 있다" 를 인식하고 기다리는 게 자연스러워져요.
Step 1 의 한 문장 요약은 이래요.
"
.call().entity(...)는 완성된 응답을 한 번에 매핑 하는 구조라, 0 byte 의 침묵 시간을 만들 수밖에 없다. 우리 도메인엔 안 맞는 응답 방식이다."
오늘의 출발점은 명확해요. 지난 시간까지의 /api/chat/soulmate 는 답변이 한 번에 떨어지는 캐릭터예요. 사용자가 빈 말풍선을 2 ~ 5 초 멍하니 보고 있어야 하이죠. 우리는 오늘 이 캐릭터한테 "흘러나오는 호흡" 을 입혀줄 거예요.
다음 Step 에서는 그 흘려보내는 채널 을 어떻게 만드는지 — Spring AI 가 제공하는 .stream().content() 한 줄로 .call() 이 어떻게 Flux<String> 으로 변신 하는지, 그리고 그 Flux 를 우리가 왜 어렵게 다루지 않아도 되는지 를 풀어볼 거예요.
지난 시간 advisor 한 줄로 30 줄을 흡수했던 그 마법, 오늘도 비슷한 장면이 한 번 더 펼쳐집니다.
💡 살짝 흘리는 복선 — 스트리밍으로 갈아타면 지난 시간의
MessageChatMemoryAdvisor가 청크를 언제 ChatMemory 에 저장할지의 미묘한 타이밍 문제가 따라와요. 청크가 흩어져 도착하는데, 우리가 저장해야 할 건 완성된 한 메시지 거든요. 그 학습 포인트은 Step 5 에서 streaming + ChatMemory 의 만남 으로 풀어봅니다.
Step 2: `.call()` 의 형제 `.stream()` — `Flux` 이 떨어지는 원리
자, Step 1 에서 우리는 ".call().entity(...) 는 완성된 응답을 한 번에 매핑하는 구조라 0 byte 의 침묵을 만든다" 까지을 펼쳤어요. 그리고 마지막에 한 문장 약속을 던졌죠.
"
.call()을.stream()으로 바꾸는 한 줄, 그리고Flux<String>이 떨어지는 원리만 익히면 된다."
이번 Step 에서 그 한 줄 을 진짜로 펼쳐볼 거예요. 지난 시간 advisor 한 줄로 30 줄을 흡수했던 그 형태이 오늘도 한 번 더 와요 — Spring AI 의 ChatClient 는 동기 / 스트리밍 두 모양을 같은 fluent API 위에 형제 메서드 로 깔끔하게 갈라놨거든요.
이 Step 에선 Service 메서드만 만들어요. 이걸 컨트롤러로 어떻게 흘려보내는지 (= SSE 응답) 는 다음 Step 3 에서 이어집니다. 받는 모양 이 들어와야 흘려보내는 모양 도 자연스러우니까요.
1. .call() ↔ .stream() — 형제 관계의 분기점
먼저 지난 시간 우리가 정리한 .call() 호출의 체인 트리 를 머리에 그려봅시다. ChatClient 의 fluent API 는 이렇게 생겼어요.
soulmateChatClient.prompt()
.system(...)
.user(...)
.call() // ← 여기서 한 가지가 갈라진다
.entity(AiReply.class);
여기서 핵심 포인트는 — .call() 직전까지의 체인 (prompt() → system() → user()) 은 동기 / 스트리밍 두 모양에서 완전히 동일 하다 는 거예요. 같은 시스템 메시지, 같은 사용자 메시지, 같은 advisor (있다면) 를 그대로 쌓아둬요. 갈라지는 건 마지막 두 줄 뿐이에요.
스트리밍 모드는 이래요.
soulmateChatClient.prompt()
.system(...)
.user(...)
.stream() // ← .call() 의 형제
.content(); // ← .entity(...) 의 형제
.call()에 .stream() 이 들어가고, .entity(...)에 .content() 가 들어가요. 두 줄 차이예요. 그런데 이 두 줄이 만드는 결과는 완전히 달라요 — 반환 타입부터가 다르거든요.
| 모드 | 마지막 두 줄 | 반환 타입 |
|---|---|---|
동기 (.call()) |
.call().entity(AiReply.class) |
AiReply (단일 객체) |
스트리밍 (.stream()) |
.stream().content() |
Flux<String> (흐름) |
여기서 .entity(...) 가 .content() 로 바뀐 이유도 자연스러워요.
완성된 응답 전체 를 객체로 매핑하려면 } 가 도착할 때까지 기다려야 하잖아요? 그런데 스트리밍은 완성을 기다리지 않는 부분이에요. 그러니 매핑할 완성된 객체 자체가 아직 없어요. 대신 토큰이 도착하는 그대로의 텍스트 청크 를 흘려주는 거죠. .content() 는 "매핑 없이 텍스트 청크 그대로 흘려달라" 라는 의미예요.
짧은 메모 —
.stream()도 사실.entity(...)의 스트리밍 버전을 가지고 있긴 해요 (.stream().entity(BeanOutputConverter)). 다만 스트리밍 + 구조화 출력 은 호흡이 한 단계 더 까다로워서 (record 의}가 도착하기 전엔 부분 객체를 못 만들거든요) 본 강의 범위에선 다루지 않아요. 우리는 오늘 평문 텍스트 만 흘립니다.
2. Flux<String> 의 — 양동이 vs 강물
자, 가장 낯선 단어가 등장했어요. Flux<String>. 이걸 어떻게 받아들여야 할지 — 그림 한 장으로 잡고 갈게요.
List<String> 과 Flux<String> 의 차이를 한 문장으로 잡으면 이래요.
List<String>은 공간의 컨테이너 다 — "여기 글자 5 개 있어, 한 번에 다 줄게."
Flux<String>은 시간의 컨테이너 다 — "글자가 시간 순 으로 흘러올 거야. 첫 글자는 0.3 초에, 다음은 0.6 초에, 마지막은 2.3 초에."
List 는 받는 시점 에 이미 모든 데이터가 손에 있어요. Flux 는 받는 시점 엔 흐를 약속 만 있고, 데이터는 시간이 방식에 따라 도착해요. Step 1 에서 본 표 — 0.3 초에 첫 청크, 0.6 초에 두 번째 청크가 떨어지던 그렇게 — 이 그대로 Flux<String> 의 의미예요.
Reactor 라이브러리 (Spring AI 가 의존하는) 에는 두 가지 흐름의 컨테이너가 있어요.
| 타입 | 의미 | 비유 |
|---|---|---|
Mono<T> |
0 또는 1 개 의 데이터를 시간 위에 흘려보내는 컨테이너 | 택배 한 박스 (배송 완료 시점에 한 번에 도착) |
Flux<T> |
0 또는 N 개 의 데이터를 시간 순으로 흘려보내는 컨테이너 | 강물 (계속 흘러오다가 어느 순간 끝남) |
스트리밍은 청크가 여러 개 시간 순으로 흘러오니까 Flux 가 맞고요, 각 청크는 텍스트 니까 Flux<String> 이 되는 거예요.
학생분들 안심 메시지 한 번 더 — 우리는 오늘
Flux의 받는 모양 만 잡으면 돼요.subscribe(...)·flatMap(...)·map(...)같은 연산자는 깊이 들어가지 않아요. 그냥 Service 가Flux<String>을 반환 하고, 컨트롤러가 그걸 그대로 또 반환 하는 형태만 익히면 끝이에요. 깊은 Reactor 학습은 본 강의의 범위 밖입니다. (정복 욕심이 나신다면 프로젝트 Reactor 공식 가이드 를 따로 권장드려요.)
3. 검증된 코드 — chatStream(...) 메서드 등장
자, 도구의을 다 잡았으니 코드를 정리할 시간이에요. SoulmateChatService 에 지난 시간 만든 chat(...) 옆에 새 메서드 chatStream(...) 을 한 개 추가합니다.
import reactor.core.publisher.Flux;
/**
* Day 6 Step 2~3 — 토큰 단위 스트리밍 응답.
*
* <p>{@code .call()} 대신 {@code .stream().content()} 를 호출하면
* Spring AI 가 LLM 의 토큰을 받자마자 {@code Flux<String>} 으로 흘려준다.
* 컨트롤러는 이 Flux 를 그대로 반환하고, Spring MVC 의 {@code ReactiveTypeHandler}
* 가 SSE({@code text/event-stream}) 응답으로 자동 변환한다.</p>
*
* <p>이번 Step 에서는 구조화 응답({@link AiReply}) 대신 평문 토큰만 흘린다 —
* 스트리밍은 본질적으로 "끝나기 전에 보여주기" 인데 record 직렬화는 응답이 끝나야 검증할 수 있어
* 두 모드가 섞이면 학습 포인트가 흐려진다. 둘을 동시에 잡는 패턴(스트리밍 + 구조화) 은
* Day 6 Step 5~6 에서 ChatMemory 통합과 함께 다룬다.</p>
*/
public Flux<String> chatStream(String anonymizedUserName, String mood, String userMessage) {
return soulmateChatClient.prompt()
.system(system -> system
.text("""
너는 {userName} 님의 AI 친구야.
유저의 현재 기분은 '{mood}' 이야.
답변은 3문장 이내로, 반말로 친근하게 해.
""")
.param("userName", anonymizedUserName)
.param("mood", mood))
.user(userMessage)
.stream()
.content();
}
코드를 정리했으니 지난 시간 메서드와 정확히 어디가 다른지 비교해 볼게요. 지난 시간 만든 chat(...) 메서드는 이 모양이었죠.
// Day 5 의 chat() — 동기 모드
public AiReply chat(String conversationId, String anonymizedUserName, String mood, String userMessage) {
return soulmateChatClient.prompt()
.system(...)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
.user(userMessage)
.call()
.entity(AiReply.class);
}
오늘 정리한 chatStream(...) 과 시그니처 부터 마지막 두 줄 까지 한 번에 비교해 봅시다.
| 비교 항목 | Day 5 의 chat(...) (동기) |
Day 6 Step 2 의 chatStream(...) (스트리밍) |
|---|---|---|
| 반환 타입 | AiReply (단일 record) |
Flux<String> (시간) |
| 첫 인자 | conversationId 있음 |
(없음) — Step 5 에서 다시 추가 |
| advisor 라인 | .advisors(a -> a.param(...)) 있음 |
(없음) — Step 5 에서 다시 추가 |
| 마지막 두 줄 | .call().entity(AiReply.class) |
.stream().content() |
핵심 차이는 마지막 두 줄 부분이에요. 그 외의 시스템 메시지 작성, 사용자 메시지 주입, 파라미터 바인딩까지 — prompt() 부터 .user(userMessage) 까지이 완전히 동일 해요. 지난 시간 익힌 ChatClient 의 호흡이 그대로 살아 있어요.
이게 바로 Spring AI 가 동기 / 스트리밍 을 형제로 설계 한 이점이에요. 학생 입장에선 "또 새 API 학습이네" 가 아니라 "마지막 두 줄만 갈아끼우면 되네" 라는으로 갈아탈 수 있는 거죠.
⚠️ 눈썰미 좋은 분이 발견했을 차이 — 지난 시간의
chat(...)에 있던conversationId인자와.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))라인이 오늘chatStream(...)에는 빠져 있어요. 이건 실수가 아니라 고의적인 누락 부분이에요. 이유와 복구 시점은 잠시 뒤 질문 타임 에서 풀어드릴게요.
4. Reactor 의존성 — build.gradle 변경이 없다 는 사실
자, 여기서 학생분들이 한 번쯤 의심해봐야 할 포인트가 있어요. "Reactor 의 Flux 가 등장했는데... build.gradle 에 의존성 추가 안 했나요? 의존성 충돌 같은 거 안 나요?"
답: build.gradle 변경이 완전히 없어요. 우리가 추가한 건 import 한 줄뿐이에요.
import reactor.core.publisher.Flux;
이게 가능한 이유는 spring-ai-client-chat 이 transitive 의존성 으로 이미 reactor-core 를 끌어와뒀기 때문 이에요. Spring AI 의 ChatClient 자체가 내부적으로 Reactor 를 쓰거든요 (스트리밍 모드를 제공 하려면 Reactor 가 필수니까요). 그래서 우리가 Day 1 에 spring-ai-starter-model-... 의존성을 정리한 그 순간부터 사실 Flux 는 이미 우리 클래스패스 안에 있었어요. 단지 우리가 호출하지 않았을 뿐이죠.
확인하고 싶다면 IntelliJ 에서 Flux 를 클릭하고 Go to Declaration (⌘B / Ctrl+B) 를 누르면 reactor-core-3.7.4.jar 안의 클래스로 이동할 거예요. 또는 터미널에서 한 줄로 확인할 수도 있어요.
./gradlew dependencyInsight --dependency reactor-core
# spring-ai-client-chat -> reactor-core 의 transitive 경로가 출력됨
요약하자면 — Reactor 도입에 대한 의존성 걱정은 없어도 돼요. build.gradle 한 줄 안 건드리고 import 한 줄로 들어갑니다. 안심하시고 진도 따라오세요.
🙋 날카로운 질문 타임
"튜터님, Reactor 지옥에 빠지는 거 아닌가요?
subscribe,map,flatMap같은 연산자 다 배워야 하나요? 어디서 들었는데 그거 배우는 데 한 달 넘게 걸린대요... "
그 걱정 너무 잘 알아요. 결론부터 말하면 — 오늘 우리는 그 연산자들 하나도 안 씁니다.
우리 코드에서 Flux<String> 이 등장하는 부분은 딱 두 군데예요.
- Service 메서드의 반환 타입 —
public Flux<String> chatStream(...) - 컨트롤러 메서드의 반환 타입 (Step 3 에서 등장) —
public Flux<String> streamChat(...)
둘 다 반환만 해요. subscribe(...) 로 데이터를 끌어내거나, map(...) 으로 변환하거나, flatMap(...) 으로 합성하지 않아요. 그 일은 Spring MVC 가 알아서 해줘요 — 컨트롤러가 Flux<String> 을 반환하면, Spring MVC 의 ReactiveTypeHandler 가 그 Flux 를 구독 해서 청크가 흘러올 때마다 SSE 응답 본문에 "data:..." 를 써주는 진행이 자동으로 돌아요.
(이 부분은 Step 3 에서 풀어요.)
비유하자면 — 우리는 강물을 만들어서 Spring MVC 한테 건네주기만 하면, Spring MVC 가 그 강물을 떠서 손님 (= 클라이언트) 한테 한 컵씩 따라줘요. Flux 의 깊은 연산자들은 강물을 합치거나, 거르거나, 가공하는 도구예요. 우리는 강물을 만들고 → 건넨다 만 하니까, 그 도구들이 필요하지 않은 거예요.
오늘 외울 단어는 두 개뿐이에요 — Flux<String> (받는 모양) 과 .stream().content() (만드는 한 줄). 이게 다입니다.
"튜터님, 그런데 지난 시간
chat(...)에는 있던conversationId와.advisors(...)라인이 왜chatStream(...)에선 빠졌어요? 스트리밍에선 ChatMemory 못 쓰는 거예요?"
날카로운 질문이에요. 고의로 미뤘어요. 정확히 말하면 — 이번 Step 2 에선 빠져 있고, Step 5 에서 다시 합칠 거예요.
이유는 학습 호흡 때문이에요. 한 Step 에 너무 많은 변화 가 동시에 들어가면 어떤 변화가 어떤 효과를 만드는지 가 흐려져요. 만약 이번 Step 에 .stream().content() 도입 + advisor 유지 + ChatMemory 의 스트리밍 관계까지 한 번에 정리하면 — 학생 입장에선 "어디부터 봐야 하지?" 가 돼버려요.
그래서 호흡을 이렇게 잘랐어요.
| Step | 새로 들어오는 변화 | 빠진 채로 두는 것 |
|---|---|---|
| Step 2 (지금) | .stream().content() → Flux<String> 도입 |
conversationId, advisor (= ChatMemory 통합) |
| Step 3 | 컨트롤러 → SSE 응답 채널 | 〃 |
| Step 4 | text/event-stream 과 ApiResponse 의 관계 |
〃 |
| Step 5 | ChatMemory 다시 통합 — conversationId 와 advisor 복귀 |
(모두 합쳐짐) |
스트리밍에서 ChatMemory 가 어떻게 동작하는지 (특히 advisor 의 after(...) 훅이 언제 청크를 모아 저장하는지) 는 그 자체로 재미있는 주제 예요. 청크가 5 ~ 10 번에 걸쳐 흩어져 도착하는데, 우리가 ChatMemory 에 저장해야 할 건 완성된 한 메시지 거든요. 이 주제를 풀려면 ChatClientMessageAggregator 라는 비밀 장치가 등장해요. 그 형태을 Step 5 에서 오롯이 펼치려고, 이번 Step 엔 일부러 ChatMemory 를 빼두는 거예요.
요약하자면 — 지금 의 chatStream(...) 은 임시 버전 부분이에요. 대화 맥락 없이 매 호출이 독립적으로 답변해요. 시스템 메시지의 userName, mood 는 살아있지만, 어제 무슨 얘기 했는지 는 캐릭터가 기억하지 못해요. Step 5 에서 다시 기억하는 캐릭터 로 돌아옵니다.
Step 2 의 한 문장 요약은 이래요.
"
.call().entity(...)를.stream().content()로 갈아끼우면 반환 타입이AiReply에서Flux<String>으로 바뀐다. 이게 시간으로 흘러오는 컨테이너 다. 우리는 이 Flux 를 반환만 하면 된다."
이제 우리 손엔 Flux<String> 이 떨어지는 Service 메서드가 들어왔어요. 이걸 어떻게 클라이언트한테 흘려보낼지 — 그게 다음 Step 의 일이에요.
다음 Step 에서는 컨트롤러를 만듭니다.
그런데 평범한 @GetMapping 으로는 안 돼요.
흘려보내는 채널 인 SSE (Server-Sent Events) 를 미디어 타입으로 잡아줘야 하거든요. produces = MediaType.TEXT_EVENT_STREAM_VALUE 한 줄과 Flux<String> 직접 반환 — 이 두 도구만으로 컨트롤러가 진짜 streaming 응답 을 흘려보내는, 다음 Step 에서 펼쳐봅니다.
Step 3: Spring MVC 가 `Flux` 를 SSE 로 흘려보내는 한 줄 — `produces = TEXT_EVENT_STREAM_VALUE`
자, Step 2 에서 우리는 받는 모양 을 익히셨어요. SoulmateChatService.chatStream(...) 이 Flux<String> 을 떨어뜨리고, 우리는 .stream().content() 두 줄로 그 방식을 받기만 했죠. 그런데 Service 메서드는 애플리케이션 내부의 손 부분이에요. 사용자 화면까지 그 방식을 흘려보내려면 마지막 한 단계가 남았어요 — 컨트롤러가 그 Flux 를 클라이언트의 화면 까지 끌어내려주는 채널 이 필요해요.
그 채널의 이름이 SSE (Server-Sent Events) 예요. 이름이 거창해 보이지만 — Step 1 에서 한 약속을 떠올려 보세요. "신규 프로토콜이 아니라, 그냥 HTTP 응답을 끊어 보내는 표준 미디어 타입" 이라고 미리 짚어드렸죠. 이 약속을 이번 Step 에서 풀어드릴 거예요.
1. SSE 가 무엇인가 — 그냥 HTTP 의 풀이
먼저 SSE 의 정체부터 짧게 잡고 갈게요. SSE 는 Server-Sent Events 의 약자로, HTTP/1.1 위에서 동작하는 단방향 스트리밍 표준 이에요. 핵심 사실을 단어 단위로 짚어드릴게요.
| 항목 | SSE 의 |
|---|---|
| 프로토콜 베이스 | HTTP/1.1 — 새 프로토콜이 아니다 |
| 의존성 추가 | 없음 — Spring Boot 기본 의존성으로 끝 |
| 핸드셰이크 | 없음 — 평범한 GET 요청 한 번이면 된다 |
| 미디어 타입 | text/event-stream |
| 본문 포맷 | data: <내용>\n\n (각 청크는 빈 줄로 구분) |
| 방향 | 단방향 (서버 → 클라이언트만) |
WebSocket 과 비교해 보면 차이가 선명해요.
WebSocket 은 별도 프로토콜 (ws:// / wss://) 이고, 핸드셰이크 (HTTP Upgrade) 가 따로 필요하고, 양방향 통신이 가능해요. 반면 SSE 는 그냥 HTTP 위에서 서버가 응답 본문을 끊어 보내기만 해요.
클라이언트 쪽에선 평범한 EventSource API (브라우저 빌트인) 로 받거나, curl 로도 그대로 받아져요.
본문 포맷을 한 줄 더 풀어볼게요. 이런 식으로 흘러가요.
HTTP/1.1 200 OK
Content-Type: text/event-stream
Transfer-Encoding: chunked
data: 오늘
data: 많이
data: 힘들었구나
각 데이터 청크 는 data: 로 시작하고 \n\n (빈 줄) 으로 구분돼요. 이 포맷을 Spring MVC 가 알아서 만들어주니까 우리가 손으로 data: 를 쓸 일은 없어요. 우리는 문자열 청크 만 흘려주면, MVC 가 SSE 포맷으로 감싸서 흘려보내요.
우리 도메인 (캐릭터가 사용자한테 답변을 흘려주기만 하는 단방향 흐름) 에는 SSE 가 완벽하게 맞아요. 사용자가 말풍선에 쓰는 메시지는 별도 HTTP 요청 (POST/GET) 으로 보내고, 캐릭터의 답변은 SSE 로 흘러나오는. 이게 ChatGPT · Claude · Gemini 의 웹 UI 가 모두 채택한 모양이에요.
2. Spring MVC 의 자동 변환 — ReactiveTypeHandler 의 마법
자, SSE 의 포맷을 손으로 짤 필요가 없다는 약속을 펼쳐볼게요. 이 마법의 이름이 ReactiveTypeHandler 예요. 이름이 무서워 보이지만 — 우리는 호출하지 않아요. 단지 그게 거기 있어서 알아서 동작한다 는 사실만 알면 충분해요.
Spring MVC 는 컨트롤러 메서드의 반환 타입을 보고 어떻게 응답을 흘려보낼지 를 결정해요. 반환 타입이 평범한 String · MyDto · record 면 완성된 본문을 한 번에 JSON 으로 직렬화해서 흘려요. 그런데 반환 타입이 Flux<T> 거나 Mono<T> 면 — ReactiveTypeHandler 가 깨어나서 "아, 이건 흐르는 데이터구나" 를 알아채고, 내부적으로 ResponseBodyEmitter 라는 청크 단위 흘려보내는 장치 로 변환해요.
내부적으로 어떤 일이 벌어지는지 한 번에 그려볼게요.
| 단계 | 동작 주체 | 일어나는 일 |
|---|---|---|
| 1 | 클라이언트 | GET /api/chat/soulmate/stream 요청 보냄 |
| 2 | 우리 컨트롤러 | chatStream(...) 호출 → Flux<String> 반환 |
| 3 | Spring MVC | 반환 타입이 Flux → ReactiveTypeHandler 활성화 |
| 4 | ReactiveTypeHandler |
Flux 를 ResponseBodyEmitter 로 변환 + 자동 구독 |
| 5 | Reactor | 청크가 도착할 때마다 emitter 의 send(...) 호출 |
| 6 | Spring MVC | 각 청크를 data: <내용>\n\n 으로 감싸 응답에 흘림 |
우리는 이 방식에서 2번만 책임져요. 나머지 5 단계는 Spring MVC + Reactor 가 알아서 돌아요. 이게 지난 시간 advisor 한 줄로 30 줄을 흡수한 장면 — 그 형제 모습 부분이에요. Spring 이 이렇게까지 알아서 해주니까, 우리가 짤 코드는 정말 적어요.
3. produces 명시 — 빠뜨리면 함정 에 빠진다 ⚠️
자, 여기서 함정 한 줄 을 박고 갈게요. Spring MVC 가 Flux<String> 을 SSE 로 흘려보내려면 — 응답의 미디어 타입을 명시 해줘야 해요. 이게 빠지면이 완전히 다르게 망가져요.
// ❌ 잘못된 모양 — produces 가 빠져 있다
@GetMapping("/api/chat/soulmate/stream")
public Flux<String> streamChat(...) { ... }
위 코드는 컴파일도 되고, 호출도 되고, 응답도 떨어져요. 그런데 응답이 우리가 원하는 흘러오는 형태 이 아니에요. Spring MVC 가 어떤 미디어 타입으로 응답할지를 결정 못 해서 — Flux 를 모아서 단일 JSON 으로 응결시켜 한 번에 떨어뜨려버려요. 결국 Step 1 의 .call()으로 돌아간 거죠. 가짜 streaming 보다 더 나쁜 디버깅 함정 부분이에요.
올바른 모양은 이래요.
// ✅ 올바른 모양 — produces = TEXT_EVENT_STREAM_VALUE 명시
@GetMapping(value = "/api/chat/soulmate/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChat(...) { ... }
produces = MediaType.TEXT_EVENT_STREAM_VALUE 한 줄이 "이 응답은 SSE 로 흘려보낸다" 라는 신호예요. 이 신호가 있어야 ReactiveTypeHandler 가 Flux 를 응결시키지 않고 청크 단위로 흘려보내는 모드로 갈아타요. 이 한 줄이 오늘의 핵심이에요. 잊지 마세요.
왜 이게 함정이 되는가 — Spring MVC 의 응답 디스패처는 클라이언트의 Accept 헤더 와 컨트롤러의 produces 선언 을 매칭해서 미디어 타입을 결정해요. produces 가 없으면 기본값으로 컨버터가 처리할 수 있는 첫 미디어 타입 을 고르는데, 그게 보통
application/json이에요. JSON 컨버터는Flux를 완성을 기다려서 배열로 직렬화 하니까, 결국.call()모드와 같은 형태이 되는 거죠. 이건 TDD 검증 단계에서 직접 발견된 함정이에요 — 처음엔 produces 빠뜨려서 테스트가 완성된 JSON 으로 떨어졌고, Content-Type 단언 에서 빨갛게 빨갰거든요.
4. SseEmitter 대안 — 짧게만 짚고 가자
Spring MVC 에서 SSE 를 만드는 또 다른 방법이 있어요. SseEmitter 라는 클래스를 직접 들고 와서 emitter.send(data) 로 청크를 손수 보내는 방식이에요. Spring AI 가 등장하기 전엔 이 방식이 표준이었죠.
// 참고용 — 우리는 *안 쓰는* 대안
@GetMapping("/api/something")
public SseEmitter someStream() {
SseEmitter emitter = new SseEmitter();
// 별도 스레드에서 emitter.send(...) 를 손으로 호출
// 끝나면 emitter.complete() 도 손으로 호출
return emitter;
}
우리는 이 방식을 안 쓰기로 결정했어요. 이유는 두 가지예요.
Spring AI 가 이미 Flux 를 떨어뜨려요. chatStream(...) 의 반환이 Flux<String> 이잖아요. 이걸 SseEmitter 로 변환하는 코드 (구독해서 emitter 에 send 하기) 를 우리가 손으로 짜야 해요. 그건 불필요한 변환 비용 부분이에요.
Flux 를 그대로 반환하면 0 줄로 끝나는데, SseEmitter 면 10 ~ 20 줄을 더 짜야 해요.
2.
완료 시점을 명시 호출 해야 해요. SseEmitter 는 emitter.complete() 를 손으로 호출해야 응답이 닫혀요. 까먹으면 클라이언트 연결이 영원히 열린 채로 매달려 있어요. 반면 Flux 는 흐름이 끝나는 시점이 자체적으로 정의 돼 있어서 (마지막 청크 후 onComplete 신호) MVC 가 알아서 응답을 닫아줘요.
그래서 우리는 Flux 직접 반환 방식만 채택해요. SseEmitter 는 Reactor 와 친하지 않은 환경 (예: 동기 블로킹 코드에서 SSE 가 필요한 레거시 통합) 에 대안 이 있다는 정도만 머릿속에 두시면 돼요.
5. 검증된 컨트롤러 코드 — streamChat(...) 등장
자, 도구의을 다 잡았으니 코드를 정리할 시간이에요. SoulmateChatController 에 지난 시간 만든 soulmate(...) 옆에 새 메서드 streamChat(...) 을 한 개 추가합니다.
import org.springframework.http.MediaType;
import reactor.core.publisher.Flux;
/**
* Day 6 Step 2~3 — 토큰 단위 스트리밍 응답 엔드포인트.
*
* <p>{@code produces = MediaType.TEXT_EVENT_STREAM_VALUE} 로 SSE 임을 명시하면
* Spring MVC 의 {@code ReactiveTypeHandler} 가 컨트롤러가 반환한 {@code Flux<String>}
* 을 자동으로 {@code ResponseBodyEmitter} 로 변환해 토큰을 흘려준다.</p>
*
* <p>SSE 응답은 ApiResponse 래핑 규약의 정당한 예외다. 청크 단위로 흐르는
* {@code text/event-stream} 본문에 JSON wrapper 를 끼워 넣으면 스트리밍 의미가 깨진다.
* 에러 처리는 {@code Flux.onErrorResume(...)} 같은 Reactor 연산으로 흐름 안에서 처리한다.</p>
*
* <p>Day 6 Step 5 에서 ChatMemory 통합이 들어오면 {@code conversationId} 파라미터가 추가되며,
* {@code MessageChatMemoryAdvisor.adviseStream} 이 내부적으로 {@code ChatClientMessageAggregator}
* 를 거쳐 스트림 종료 시점에 *완성된 한 메시지* 를 자동 저장한다 — 컨트롤러/서비스에 별도의
* {@code .doOnComplete} 보정이 필요 없다.</p>
*/
@GetMapping(value = "/api/chat/soulmate/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChat(
@RequestParam Long userId,
@RequestParam String mood,
@RequestParam String message
) {
String anonymizedName = userAnonymizer.anonymize(userId);
return service.chatStream(anonymizedName, mood, message);
}
코드를 정리했으니 지난 시간 메서드와 정확히 어디가 다른지 비교해 볼게요.
| 비교 항목 | Day 5 의 soulmate(...) (동기) |
Day 6 Step 3 의 streamChat(...) (스트리밍) |
|---|---|---|
| HTTP 메서드 | @GetMapping (URL 파라미터) |
@GetMapping (URL 파라미터) |
produces |
기본값 (application/json) |
TEXT_EVENT_STREAM_VALUE |
| 반환 타입 | ResponseEntity<ApiResponse<SoulmateChatResponse>> |
Flux<String> |
| Service 호출 | service.chat(...) (단일 record 반환) |
service.chatStream(...) (Flux 반환) |
| 반환 처리 | ApiResponse.success(...) 로 래핑 |
반환만 함 (래핑 없음) |
핵심 차이는 반환 타입 과 produces 두 줄이에요. 그리고 한 가지 눈에 띄는 차이 가 더 있죠 — ApiResponse 래핑이 없어요. 이건 ApiResponse 표준 패턴을 위반한 게 아니라, 정당한 예외 예요. 이유는 다음 Step 4 에서 풀어드려요.
🤔 왜 GET 인가 — 단일 GET 으로 묶은 건 SSE 의 표준 사용 패턴 때문이에요. 브라우저의
EventSourceAPI 는 GET 만 지원해요 (POST 로 SSE 를 받으려면fetch+ 직접 파싱이 필요). 메시지가 URL 에 길게 들어가는 게 신경 쓰이면 — 실무에선 POST 로 메시지 등록 → 서버가 conversationId 응답 → GET 으로 SSE 구독 이라는 2 단계 분리 패턴 도 자주 써요. 본 강의에선 학습 호흡을 위해 단일 GET 으로 가요.
🙋 날카로운 질문 타임
"튜터님,
produces빠뜨리면 어떻게 되나요? 그냥 토큰 안 흘러요? 에러 떨어져요?"
이게 진짜 함정 부분이에요. 에러는 안 떨어져요. 그게 더 무서운 거죠. Spring MVC 는 컴파일 에러도, 런타임 에러도 안 던지고 — Flux 를 모아서 단일 JSON 배열로 응결시켜 흘려요. 응답 코드는 200 OK, 응답 본문은 ["오늘"," 많이"," 힘들었구나"] 같은 완성된 JSON 배열. 결국 Step 1 에서 본 .call()이랑 똑같아져요 — 0 byte 침묵 2.3 초 뒤 한 방에 도착하는.
이 함정에 빠지면 "분명 .stream().content() 썼는데 왜 streaming 이 안 되지?" 라고 디버깅 미궁에 빠져요. 응답을 눈으로 봐도 청크가 다 들어 있으니까 차이를 못 찾는 거예요. 정답은 응답 헤더의 Content-Type 을 확인 하는 거예요.
application/json 이면 함정에 빠진 거고, text/event-stream 이면 정상이에요.
curl -i "http://localhost:8080/api/chat/soulmate/stream?userId=1&mood=우울&message=힘들어"
# HTTP/1.1 200 OK
# Content-Type: text/event-stream;charset=UTF-8 ← 이게 보여야 정상
"튜터님,
SseEmitter가 인터넷에 더 많이 나오던데 왜 안 쓰나요? 더 자세한 통제가 가능하다고 하던데..."
좋은 감각이에요. SseEmitter 가 자료가 더 많은 건 Spring AI 등장 전에 표준이었던 시기의 흔적이에요. 그 시절엔 백그라운드 스레드에서 토큰을 만들고, emitter 에 손수 send 하는 모양이 주류였거든요. 지금 우리 도메인에서 안 쓰는 이유는 정확히 두 가지예요.
Spring AI 가 이미 Flux 를 떨어뜨려요. 우리가 만든 chatStream(...) 의 반환이 Flux<String> 이잖아요. 이걸 SseEmitter 로 변환하려면 별도 스레드 풀에서 구독 + 손수 send + 손수 complete 까지 10~20 줄을 더 짜야 해요. 변환 비용 0 vs 변환 비용 20 줄 — 우리는 0 을 고른 거죠.
2.
Reactor 연산자와 친화도가 떨어져요. 다음 Step 5 에서 보겠지만 — Flux.onErrorResume(...), Flux.doOnComplete(...) 같은 흐름 안에서 처리하는 연산자가 ChatMemory 통합·에러 처리에 자연스럽게 어울려요. SseEmitter 면 콜백 지옥이 되거든요. 흐름 안의 연산 vs 콜백 안의 연산 — 코드 가독성이 완전히 달라요.
요약하자면 — 우리 도메인에선 Flux 직접 반환이 압도적으로 좋아요. SseEmitter 는 레거시 환경 또는 Reactor 가 없는 워크플로 에 대안 으로만 머릿속에 두시면 됩니다.
Step 3 의 한 문장 요약은 이래요.
"Spring MVC + Spring AI 의 결합이 너무 자연스러워서, 우리가 짤 코드는
produces = TEXT_EVENT_STREAM_VALUE한 줄과Flux<String>반환 한 줄이 전부다. 나머지는ReactiveTypeHandler가 알아서 한다."
이제 우리 컨트롤러는 진짜 streaming 응답 을 흘려보내요. Step 1 에서 봤던 0 byte 침묵 2.3 초 의이 — 0.3 초에 첫 청크, 0.6 초에 두 번째 청크 의으로 갈아탔어요. 같은 모델, 같은 비용, 같은 호출. 흘려보내는 채널 만 바꾼 한 줄로요.
다음 Step 4 에서는 눈에 띄는 차이 로 짚어둔 그 부분 — ApiResponse 래핑이 없는 이유 를 풀어볼 거예요. 우리 과목의 ApiResponse 표준 패턴은 모든 컨트롤러 응답을 ApiResponse 로 감싼다 인데, SSE 응답은 예외 라고 했죠. 왜 그게 정당한 예외 인지 — text/event-stream 미디어 타입과 JSON 래핑이 기술적으로 비호환 이라는 사정을 한 번 짚고 갑니다.
Step 4: `ApiResponse` 래핑의 **정당한 예외** — 왜 SSE 만 raw `Flux` 인가
자, Step 3 에서 우리는 흘려보내는 채널 을 익히셨어요.
produces = TEXT_EVENT_STREAM_VALUE 한 줄과 Flux<String> 직접 반환 — 이 두 줄로 컨트롤러가 진짜 streaming 응답을 흘려보내는 형태까지 펼쳤죠.
그런데 Step 3 의 컨트롤러 코드, 다시 한 번 눈으로 훑어볼게요.
한 줄 이상한 점 못 보셨어요? 🤔
@GetMapping(value = "/api/chat/soulmate/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChat(...) {
...
return service.chatStream(anonymizedName, mood, message);
}
눈썰미 좋은 분은 잡아내셨을 거예요 — 반환 타입이 Flux<String> 이에요. ResponseEntity<ApiResponse<...>> 가 아니에요. 지난 시간 (Day 5) 만든 soulmate() 메서드와 일관성이 깨져 보여요.
이번 Step 은 코드를 새로 짜지 않아요. 지난 시간 ApiResponse 표준 패턴과 오늘 raw Flux 의 충돌처럼 보이는 부분이 — 사실은 정당한 예외 라는 걸 한 번 짚고 가는 결정 문서화 Step 이에요. 왜 SSE 만 표준 패턴의 예외인지, 그 예외의 원칙 은 무엇인지 — 이걸 정리해두지 않으면 다음 과목에서 또 같은 부분 에서 학생이 혼란스러워해요.
1. 우리는 ApiResponse로 모든 컨트롤러 응답을 감싼다
이 약속은 지난 시간 Day 5 에서 만든 세 메서드가 완벽히 따르고 있어요. 짧게 한 줄씩 떠올려 볼게요.
// Day 5 의 soulmate() — GET 응답 (블로킹 chat 호출)
@GetMapping("/api/chat/soulmate")
public ResponseEntity<ApiResponse<SoulmateChatResponse>> soulmate(...) { ... }
// Day 5 의 getSession() — GET 세션 조회
@GetMapping("/api/chat/soulmate/sessions/{conversationId}")
public ResponseEntity<ApiResponse<List<SoulmateSessionMessageView>>> getSession(...) { ... }
// Day 5 의 deleteSession() — DELETE 세션 초기화
@DeleteMapping("/api/chat/soulmate/sessions/{conversationId}")
public ResponseEntity<ApiResponse<Void>> deleteSession(...) { ... }
세 메서드가 셋 다 ResponseEntity<ApiResponse<T>> 로 감싸져 있어요. 정상 응답은 ApiResponse.success(data) 로 흘려보내고, 에러는 GlobalExceptionHandler 가 평소처럼 가로채서 ApiResponse.fail(...) 로 흘려보내요. 정상 / 에러 응답이 같은 wire 형태 라서 — 클라이언트가 어떤 응답이든 같은 파싱 코드 로 받을 수 있어요.
그런데 Step 3 의 streamChat(...) 은 이 패턴을 따르지 않아요.
// Day 6 Step 3 의 streamChat() — raw Flux 반환
@GetMapping(value = "/api/chat/soulmate/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChat(...) { ... }
ResponseEntity<ApiResponse<Flux<String>>> 같은 모양이 아니에요. raw Flux<String> 그 자체죠. 이게 룰 위반 인지, 정당한 예외 인지 — 그 판단의 근거를 세 가지로 풀어드릴게요.
2. 근거 ①: 미디어타입 비호환 — JSON wrapper 가 낄 부분이 없다
첫 번째 근거가 가장 본질적이에요. text/event-stream 은 청크 단위 본문이라 JSON wrapper 를 끼울 부분이 없어요.
상상해 봅시다. 만약 우리가 표준 패턴을 문자 그대로 지키려고 SSE 응답도 ApiResponse 로 감싸기로 했다면 — 응답 본문이 어떤 모양으로 흐를까요? 머릿속에 그려보면 이래요.
data: {"success":true,"data":"오늘"}
data: {"success":true,"data":" 많이"}
data: {"success":true,"data":" 힘들었구나"}
이건 각 청크마다 JSON wrapper 가 붙은 모양이에요. 청크가 5 ~ 10 번에 걸쳐 흩어져 도착하는데 그때마다 {"success":true,"data":"...} 이 따라붙으면 — 같은 wrapper 가 청크 수만큼 반복되거든요. 토큰이 두 번 직렬화 되는 비용이 들고, 본문 크기는 두 배 가까이 커져요.
더 나쁜 시나리오도 있어요. 만약 전체 응답을 한 wrapper 로 감싸려고 했다면 — 본문이 이렇게 시작하다가...
{"success":true,"data":"
...첫 청크가 {"data": 라는 JSON 시작 토큰만 깔아놓고 끝나지 않는 본문이 흐르는이 돼요. 청크가 다 도착할 때까지 JSON 파서가 완성된 객체 를 못 만들어요. 그러면 클라이언트는 흘러오는 토큰을 실시간으로 못 받고 끝까지 모아둬야 해요. 이게 정확히 Step 1 에서 거부한 블로킹 으로 회귀하는 거죠.
핵심은 — JSON wrapper 의 본질이 완성된 객체 위에 깔리는 껍데기 라는 거예요. 청크가 시간 위에서 흐르는 SSE 와 근본적으로 호흡이 다른 도구예요. 한쪽은 공간의 컨테이너, 한쪽은 시간의 컨테이너 라는 Step 2 의 비유가 여기서도 살아있어요.
3. 근거 ②: 스트리밍 의미 자체가 깨진다
첫 번째 근거가 기술적 사정 이라면, 두 번째 근거는 의미론적 사정 부분이에요.
ApiResponse 래핑은 전체 응답이 모인 뒤 한 번에 직렬화하는 게 자연스러운 도구예요. 응답의 success 필드, data 필드, error 필드가 완성된 결과물 위에서 의미를 가지거든요. 흘러오는 도중 의 응답 위에 ApiResponse 를 얹으려면 — 흐름을 모두 모아서 완성된 객체를 만든 뒤 wrapper 를 씌워야 해요.
그런데 그 순간, 우리가 지난 시간과 오늘 3 시간 가까이 풀어온 streaming 의 의도가 완전히 무너져요. Step 1 에서 본 표를 떠올려 봅시다.
| 시점 | 사용자 화면 (streaming) | 사용자 화면 (ApiResponse 감싸면?) |
|---|---|---|
| 0.3 초 | "에이," 첫 청크 도착 | 빈 말풍선 (모으는 중) |
| 1.0 초 | "에이, 무슨 일 있어? 오늘" | 빈 말풍선 (여전히 모으는 중) |
| 2.0 초 | "에이, 무슨 일 있어? 오늘 하루 힘들었" | 빈 말풍선 (모으는 중) |
| 2.3 초 | "에이,... 얘기해줄래?" 완성 | 응답 한 방에 도착 |
ApiResponse 로 감싸면 결국 0 byte 침묵 2.3 초 의이 부활해요. Step 1 에서 왜 답답한지 풀어내고, Step 2 에서 흘려보내는 모양 을 익히고, Step 3 에서 흘려보내는 채널 을 만들었는데 — Step 4 에서 그 방식을 다시 모아 한 방에 떨어뜨리는 코드를 짠다면, 우리는 자기 발등을 찍는 거예요.
요약하자면 — ApiResponse wrapper 는 흐름을 모으는 도구라서, 흐름을 흘리는 SSE 와 의도 자체가 충돌해요. 이건 코드 스타일의 일관성 으로 강제할 만한 부분가 아니에요. 일관성을 강제하는 순간 streaming 자체가 죽으니까요.
4. 근거 ③: 에러 채널의 분리 — 정상은 raw, 에러는 두 가지
세 번째 근거가 학생들이 가장 헷갈리는 부분예요. "ApiResponse 안 쓰면 에러는 어떻게 처리해요?" 라는 질문이 자연스럽게 따라오거든요. 결론부터 말하면 — 에러 처리도 raw 텍스트와 충분히 잘 어울려요. 단지 두 가지로 나뉜다 는 게 핵심이에요.
에러가 발생할 수 있는 부분를 시간축 위에서 그려보면 두 케이스로 나뉘어요.
케이스 A — 스트림 시작 전 에러 (예: 잘못된 mood, userId 없음)
이 케이스는 우리가 이미 익숙한 장면 부분이에요. 아직 첫 청크가 흘러나가기 전이니까 — 응답 본문 자체가 시작도 안 했어요. 이 시점에 IllegalArgumentException 이나 EntityNotFoundException 같은 예외가 던져지면, 우리 GlobalExceptionHandler 가 평소처럼 가로채서 ResponseEntity<ApiResponse<ErrorResponse>> JSON 응답으로 응대해요.
HTTP/1.1 400 Bad Request
Content-Type: application/json
{"success":false,"error":{"code":"INVALID_PARAM","message":"mood 값이 비어 있습니다"}}
여기서 응답의 Content-Type 이 application/json 이라는 점이 핵심이에요.
스트림이 시작하기 전이니까 미디어타입 협상이 다시 일어나서, 정상 케이스의 text/event-stream 이 아니라 에러 케이스의 application/json 으로 갈아탔거든요.
클라이언트의 EventSource 도 이걸 비정상 응답 으로 인식하고 onerror 핸들러를 트리거해요.
이 케이스에선 에러 응답은 여전히 ApiResponse.fail(...) 형태로 감싸져 흘러요. 우리가 raw 로 바꾼 건 정상 응답 채널 뿐이에요.
케이스 B — 스트림 도중 에러 (예: LLM 일시 장애, 토큰 한도 초과)
이 케이스가 새로워요. 첫 청크가 이미 흘러나간 상태에서 LLM 측 장애가 발생하면 — 이미 클라이언트 화면에 도착한 토큰 은 그대로 살아있고, 그 뒤에 도착할 토큰이 없어요. 이때 클라이언트한테 "이 응답은 망가졌습니다" 라는 신호를 어떻게 보내야 할까요?
답은 Reactor 의 흐름 안에서 처리하는 연산자예요. Flux.onErrorResume(...) 같은 연산자로 — 에러를 흐름의 일부 로 흡수해서 대체 토큰 을 마지막에 흘려요.
// 의사 코드 — Step 5 에서 더 정교하게 다룸
return service.chatStream(anonymizedName, mood, message)
.onErrorResume(e -> Flux.just("\n\n[연결이 잠시 흔들렸어요. 다시 시도해줘]"));
위 의사 코드 형태은 이래요.
정상적으로 흐르다가 LLM 측에서 에러가 발생하면 — 이미 흘러간 토큰 (예: "에이, 무슨 일 있어? 오늘") 은 살리고, 마지막에 대체 메시지 (예: "[연결이 잠시 흔들렸어요. 다시 시도해줘]") 를 한 번 더 흘려서 응답을 우아하게 마무리해요. 클라이언트 입장에선 이미 받은 글자 는 화면에 살아있고, 마지막 줄에 에러 신호가 한 줄 붙이죠.
이 케이스에선 도중 에 ApiResponse JSON 으로 갈아탈 수가 없어요 (Content-Type 은 응답 시작 시점에 이미 결정됐고, 변경 불가능해요). 그래서 흐름 안에서 처리하는 Reactor 연산이 더 자연스러운 도구가 되는 거예요.
요약하자면 — 에러 채널은 두 가지로 분리 된다. 스트림 시작 전 은 ApiResponse 로 평소처럼, 스트림 도중 은 안에서 Reactor 연산으로. 두 가지가 함께 있어서 정상 응답 채널의 raw 화 가 정당하게 받쳐져요.
5. 예외 원칙 — 미디어타입이 본질적으로 JSON 과 비호환인 경우만
세 가지 근거를 정리해보면 — SSE 가 정당한 예외 인 이유는 결국 한 줄이에요.
"미디어타입의 본질이 근본적으로 JSON 과 비호환인 경우, ApiResponse 래핑은 정당한 예외다."
이 원칙을 명시해두는 이유는 — 예외의 범위 가 무한히 커지지 않게 하기 위함이에요. 불편하다 거나 코드가 짧아진다 는 이유로는 ApiResponse 표준 패턴을 풀어주지 않아요. 기술적으로 호환이 안 되는 경우만 정당한 예외로 인정해요.
본 강의 안에서 이 원칙에 해당하는 부분을 표로 정리해볼게요.
| 미디어타입 | 정당한 예외? | 이유 |
|---|---|---|
text/event-stream (SSE) |
✅ 예 | 청크 단위 본문 — JSON wrapper 가 낄 부분이 없음 |
text/plain (디버그 평문) |
✅ 예 | Day 4 의 format-debug 같은 raw 텍스트 그대로 보여주기 가 학습 의도 — wrapper 로 감싸면 의도가 흐려짐 |
application/octet-stream (파일 다운로드) |
✅ 예 | 바이너리 본문 — JSON 직렬화 자체가 의미 없음 |
application/json (평범한 REST) |
❌ 아니오 | 호환 자체가 문제 없는 미디어타입. 표준 패턴 그대로 적용 |
| "코드가 짧아져요" | ❌ 아니오 | 정당한 사유가 아님 — 일관성 우선 |
| "제가 ApiResponse 가 불편해요" | ❌ 아니오 | 정당한 사유가 아님 |
이 표가 예외 원칙 의 전체 이에요. 새 컨트롤러를 만들 때 예외인지 아닌지 헷갈리면 — 이 표를 한 번 펼쳐보세요. 미디어타입의 본질이 JSON 과 비호환 이면 정당한 예외, 그 외엔 그대로 래퍼 적용이에요.
짧은 메모 — Day 4 에서 만든
/api/structured/quote/format-debug엔드포인트가text/plain으로 raw 응답을 흘렸던 거 기억나시죠? 거기가 SSE 와 같은 종류의 정당한 예외 예요. 학습 의도를 위해 raw format 텍스트를 그대로 보여줘야 해서 ApiResponse 로 감싸지 않은.
🙋 날카로운 질문 타임
"튜터님, 그러면 표준이 두 가지가 된 셈이잖아요? 정상 / 에러 응답 형태가 비대칭 이 되는 건 어떻게 합리화하나요?"
날카로운 질문이에요. 정확하게 짚으셨어요 — 비대칭은 발생해요. 정상 응답은 raw SSE 로 흐르고, 에러 응답 (스트림 시작 전) 은 ApiResponse JSON 으로 떨어지죠. 같은 엔드포인트의 두 응답이 완전히 다른 모양 부분이에요.
답은 — 이 비대칭은 어쩔 수 없는 트레이드오프 예요. 그리고 큰 문제가 아니에요. 두 가지로 풀어드릴게요.
첫째, 본질적으로 다른 미디어타입을 한 형태로 강제 하면 그게 더 큰 비용이에요. JSON wrapper 를 SSE 에 끼우면 streaming 자체가 죽잖아요. 모양의 일관성 을 위해 기능의 본질 을 죽이는 건 손해 보는 트레이드예요.
둘째, 우리가 잡아야 할 일관성은 클라이언트가 응답 형태를 예측 가능 한 수준이지, 형태 자체가 동일 할 필요는 없어요. 클라이언트는 Accept: text/event-stream 헤더로 SSE 를 명시 했기 때문에, 정상 응답이 SSE 로 흐를 것 을 이미 알고 있어요. 비정상 응답이 JSON 으로 와도 이건 에러구나 라고 자연스럽게 인식해요. 표준 EventSource API 도 비정상 응답이 JSON 으로 오는 케이스 를 정상적으로 핸들링해요 (onerror 트리거).
요약하자면 — 비대칭은 예측 가능한 비대칭 이라서 괜찮다. 클라이언트가 Accept 헤더로 명시한 응답 형태 와 비정상 응답 형태 가 다른 건 표준이에요. 우리만 그러는 게 아니라 ChatGPT API · Claude API 도 다 그렇거든요.
"튜터님,
SseEmitter안에 ApiResponse 형태로 감싸 보낼 수도 있지 않나요?"
가능은 해요. 그런데 각 청크마다 JSON wrapper 가 붙어 — 토큰이 두 번 직렬화 되는 비용이 든다는 게 함정이에요. SSE 의 data: 프리픽스만으로도 이미 청크 식별이 가능 해요. 그 위에 또 wrapper 를 얹는 건 양치질하면서 양칫물에 또 양칫물 부어 헹구는 부분이에요.
게다가 받는 쪽 (클라이언트) 에서도 두 단계 파싱이 필요해져요. 첫 단계에서 SSE data: 를 벗기고, 두 번째 단계에서 JSON {"data":...} 를 또 벗기고요. 이게 클라이언트 코드의 불필요한 복잡도 가 되거든요. 결국 서버 비용 + 클라이언트 비용 이 둘 다 늘어나는 패턴이에요.
요약하자면 — 기술적으로 가능 하지만 모든 면에서 손해 라서 우리는 안 해요. 토큰을 그대로 흘리고, 에러는 Reactor 연산으로 안에서 잡는 게 가장 깔끔해요.
Step 4 의 한 문장 요약은 이래요.
"ApiResponse 래핑은 원칙 이고, 정당한 예외는 미디어타입의 본질이 JSON 과 비호환인 경우 만이다. SSE · 디버그 평문 · 파일 다운로드가 그 예외에 속하고, 불편하다 / 짧아진다 는 이유는 예외가 아니다."
오늘 우리는 지난 시간 정리한 표준 패턴을 깨뜨린 게 아니라, 예외 원칙을 정의한 거예요. 표준의 정확한 윤곽이 한 단계 더 또렷해졌어요.
자, Step 4 까지 정리하면 우리 손엔 완벽하게 흘러가는 streaming 컨트롤러가 들어왔어요. 그런데 한 가지 고의적으로 미뤄둔 부분이 있죠 — Step 2 에서 잠깐 지적했던 그 부분. chatStream(...) 메서드엔 지난 시간의 conversationId 와 .advisors(...) 라인이 빠져 있어요. 다음 Step 5 에서 그걸 다시 합칠 거예요. 그런데 합치는 과정이 간단하지 않아요 — 청크가 5 ~ 10 번에 걸쳐 흩어져 도착하는데, 우리가 ChatMemory 에 저장해야 할 건 완성된 한 메시지 거든요. 이 주제를 풀려면 ChatClientMessageAggregator 라는 비밀 장치가 등장해요. 다음 Step 에서 streaming + ChatMemory 의 만남 으로 펼쳐봅니다.
Step 5: ChatMemory 와 스트리밍의 만남 — `conversationId` 재등장 + `ChatClientMessageAggregator` 의 마법
자, 드디어 도착했어요. 오늘 Day 6 의 큰 학습 포인트 을 푸는 부분이에요.
이번 Step 은 그동안 고의로 미뤄둔 두 개의 매듭을 한 번에 풀어요.
- Step 2 에서 비워둔
conversationId부분 —chatStream(...)시그니처가 3 인자였잖아요? 지난 시간의chat(...)처럼 4 인자로 완성 시킬 거예요.
Step 1 마지막에 살짝 흘린 복선 — "streaming 으로 갈아타면 지난 시간의 MessageChatMemoryAdvisor 가 청크를 언제 ChatMemory 에 저장할지의 미묘한 타이밍 문제가 따라온다" 는 그 매듭. 청크가 5 ~ 10 번에 걸쳐 흩어져 도착하는데, 우리가 저장해야 할 건 완성된 한 메시지 거든요.
그 부분을 풀어요.
3. Day 5 마무리에서 흘린 핵심 복선 — "MessageChatMemoryAdvisor.after(...) 훅이 동기에선 깔끔하지만 스트리밍에선 청크가 흩어져 도착하니 완성된 메시지 를 어디서 잡아야 할지가 미묘해진다" 는 그 약속. 그것까지 같이 다시 다뤄요.
그런데 이 풀이의 반전 이 하나 있어요. 우리가 손으로 풀어야 할 줄 알았던 부분을 — Spring AI 가 이미 풀어놨어요. MessageChatMemoryAdvisor.adviseStream(...) 이라는 형제 메서드 가 내부적으로 청크를 다 모은 뒤에 after(...) 를 딱 한 번만 호출하거든요. 우리가 짤 코드는 한 줄 부분이에요.
그 형태, 풀어볼게요.
1. 고민 펼치기 — 언제 저장해야 할까? 🤔
자, 본격적으로 코드를 박기 전에 고민거리를 한 번 펼쳐볼게요. 내가 이 코드를 손으로 짠다면 어떤 부분에서 막힐지를 미리 그려보면, Spring AI 가 왜 그 주제를 풀어줬는지가 또렷해져요.
상황을 다시 떠올려볼게요. 우리는 지난 시간 MessageChatMemoryAdvisor 한 줄로 두 가지 자동화를 입혔어요.
before(...)훅 — 호출 직전 에 ChatMemory 에서 과거 대화를 꺼내 prompt 에 끼워 넣기after(...)훅 — 호출 직후 에 응답을 ChatMemory 에 저장하기
동기 모드에선 after(...) 가 깔끔했어요. 완성된 응답 한 개 가 한 번에 도착하니까, 그걸 그대로 ChatMemory 에 던져 넣으면 됐죠. 그런데 스트리밍은 완성을 기다리지 않는이잖아요. 청크가 흩어져 도착하는데, 언제 after(...) 를 트리거해야 할까요? 두 가지 선택지가 있어요.
시나리오 A — 토큰마다 저장 (망가지는 형태)
상상해 봅시다. 각 청크가 도착할 때마다 chatMemory.add(...) 를 호출하는 모양이에요.
// 의사 코드 — 시나리오 A (절대 안 짭니다)
return service.chatStream(...)
.doOnNext(chunk -> chatMemory.add(conversationId,
new AssistantMessage(chunk))); // ← 청크마다 add
이 형태이 어떻게 망가지는지 한 번에 그려보면 — 5 개 청크 가 흘러왔다고 했을 때 ChatMemory 엔 이런 모양이 누적돼요.
| 청크 도착 | ChatMemory 에 저장된 메시지들 |
|---|---|
| "에이," | 1 개: AssistantMessage("에이,") |
| ", 무슨" | 2 개: AssistantMessage("에이,"), AssistantMessage(" 무슨") |
| ", 일 있어?" | 3 개:..., AssistantMessage(" 일 있어?") |
| " 오늘" | 4 개:..., AssistantMessage(" 오늘") |
| " 힘들었구나" | 5 개:..., AssistantMessage(" 힘들었구나") |
망가졌어요. 두 가지가 한꺼번에 어긋났거든요.
- DB INSERT 쿼리가 토큰 수만큼 발생 — 한 번의 응답에 5 ~ 10 번의
INSERT INTO ai_chat_messages ...가 떨어져요. 한 번 쓰고 말 메시지인데 5 ~ 10 번을 쪼개서 쓰는 거죠.
다음 호출의 컨텍스트가 반쪽 짜리 로 누적 — Day 5 의 MessageWindowChatMemory 는 최근 N 개 메시지를 prompt 에 끼워 넣는다고 했죠. 그런데 ChatMemory 에 반쪽 짜리 청크 5 개 가 누적돼 있으면, 다음 호출의 prompt 에 부서진 메시지 조각들 이 들어가요. 캐릭터가 자기가 어제 한 말 을 조각난 채로 다시 보게 되는 거예요.
요약하면 — 토큰마다 저장 은 DB 비용 + 컨텍스트 의미 둘 다 깨뜨려요. 절대 답이 아니에요.
시나리오 B — 스트림 종료 시 한 번 저장 (정답) ✅
자연스러운 정답은 — 청크를 다 모은 뒤 한 번에 완성된 메시지 로 저장하는 거예요.
// 의사 코드 — 시나리오 B (이게 우리가 원하는 풍경)
StringBuilder buffer = new StringBuilder();
return service.chatStream(...)
.doOnNext(chunk -> buffer.append(chunk)) // ← 청크는 일단 버퍼에
.doOnComplete(() -> chatMemory.add( // ← 흐름이 끝나면 한 번에
conversationId,
new AssistantMessage(buffer.toString())));
이 형태이 우리가 원하는 모양이에요. INSERT 는 한 번만, ChatMemory 에 누적되는 메시지도 완성된 한 개. 다음 호출의 컨텍스트도 깔끔한 메시지 가 들어가요.
그런데 — 이 코드를 직접 짤 필요가 없어요. Spring AI 가 이미 이걸 우리 대신 짜놨거든요.
2. MessageChatMemoryAdvisor.adviseStream — Spring AI 가 이미 풀어놨다
지난 시간 우리가 등록한 advisor 빈을 한 번 떠올려봅시다 (Day 5 Step 4).
@Bean
ChatClient soulmateChatClient(ChatClient.Builder builder, ChatMemory chatMemory) {
return builder
.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
.build();
}
이 MessageChatMemoryAdvisor 는 동기 / 스트리밍 두 모양 다 지원 하는 클래스예요. 정확히 말하면 — 두 개의 메서드를 형제 처럼 가지고 있어요.
| 메서드 | 언제 호출되나 | 무엇을 하나 |
|---|---|---|
adviseCall(...) |
chatClient.prompt()...call() 호출 시 |
before/after 훅 동기 버전 — 응답 1 개에 대해 한 번씩 |
adviseStream(...) |
chatClient.prompt()...stream() 호출 시 |
before/after 훅 스트리밍 버전 — Flux 를 가로채서 처리 |
우리가 어떤 걸 부를지 수동으로 선택 하지 않아요. .call() 을 부르면 adviseCall 이, .stream() 을 부르면 adviseStream 이 자동 라우팅 돼요. 같은 빈 한 개 (Day 5 에서 이미 등록한 그것) 가 동기 / 스트리밍 두 방식에서 그대로 재사용 되는 거죠.
그런데 진짜 마법은 adviseStream 의 내부 에 있어요. 이 메서드가 내부적으로 ChatClientMessageAggregator 라는 비밀 장치를 거쳐요. 이 aggregator 의 역할이 정확히 시나리오 B 그대로예요.
ChatClientMessageAggregator 의 라이프사이클을 한 번에 그려볼게요.
| 단계 | 동작 주체 | 일어나는 일 |
|---|---|---|
| 1 | LLM | 첫 토큰 도착 |
| 2 | ChatClientMessageAggregator |
청크를 내부 버퍼 에 누적 (ChatMemory 에 아직 안 씀) |
| 3 | LLM | 두 번째 ~ N 번째 토큰 도착 → 계속 누적 |
| 4 | LLM | 마지막 토큰 + onComplete 신호 도착 |
| 5 | ChatClientMessageAggregator |
누적된 청크를 합쳐서 AssistantMessage 한 개 생성 |
| 6 | MessageChatMemoryAdvisor.after(...) |
완성된 메시지 한 개 로 chatMemory.add(...) 딱 한 번 호출 |
| 7 | 클라이언트 | 평소처럼 모든 청크를 받음 (저장은 투명 하게 백그라운드) |
핵심은 — 우리가 짤 거라고 위에서 의사 코드로 그렸던 StringBuilder buffer + doOnNext + doOnComplete 의, 그게 그대로 ChatClientMessageAggregator 안에 들어있어요. 우리가 직접 짜는 건 불필요한 재발명 부분이에요. 🚫
Day 5 마무리의 약속 재등장 — 지난 시간 마무리에서 제가 "
after(...)훅이 스트리밍에선 청크가 흩어져 도착하니 완성된 메시지를 어디서 잡아야 할지가 미묘해진다" 라고 흘려뒀잖아요. 그 매듭의 답이 바로 이거예요.adviseStream이ChatClientMessageAggregator를 거쳐 청크를 다 모은 뒤 완성된 메시지 한 개 로after()를 호출하니까, 우리는 그 부분을 손으로 풀 필요가 없다. Spring AI 의 설계자들이 이미 그 미묘한 부분를 우리 대신 풀어놨어요.
3. 그러면 우리가 짤 코드는 — 한 줄 ️
자, 이쯤에서 학생분들 머릿속이 "그럼 도대체 우리가 뭘 추가하는데요?" 가 될 것 같아요. 답은 정말 한 줄 부분이에요.
.advisors(advisor -> advisor.param(ChatMemory.CONVERSATION_ID, conversationId))
지난 시간 chat(...) 에 정리해뒀던 그 한 줄. 그게 다예요. 이 줄이 어떤 conversationId 의 ChatMemory 를 쓸지 를 advisor 에게 알려주는 역할이고, 나머지 (청크 누적 → 완성 메시지 합성 → ChatMemory 저장) 은 advisor 가 알아서 해줘요.
이게 지난 시간 advisor 한 줄로 30 줄을 흡수했던 모습의 형제 예요. 오늘 Day 6 에서 또 한 번, 우리가 한 줄을 정리하면 Spring AI 가 30 줄어치 매듭을 풀어주는 패턴이 펼쳐지는 거죠.
4. chatStream(...) 시그니처 변천 — 비어있던 부분 가 채워진다
자, 이제 비어있던 부분 를 채울 시간이에요. Step 2 에서 정리한 chatStream(...) 의 시그니처는 3개의 파라미터였죠.
// Step 2 의 chatStream — 3개의 파라미터 (conversationId 자리 비어있음)
public Flux<String> chatStream(String anonymizedUserName, String mood, String userMessage) { ... }
이걸 지난 시간 chat(...) 의 모양과 같이 4 파라미터 로 확장해요. conversationId 가 첫 부분 로 들어와요. 지난 시간 정한 파라미터 순서 (conversationId, anonymizedUserName, mood, userMessage) 그대로요.
5. 검증된 코드 — chatStream(...) 4 파라미터 버전
자, 이론의을 다 잡았으니 코드를 정리할 시간이에요. SoulmateChatService.chatStream(...) 의 완성된 모양이 이래요.
/**
* Day 6 Step 2~3 — 토큰 단위 스트리밍 응답.
* Day 6 Step 5 — {@code conversationId} 를 받아 ChatMemory 와 통합.
*
* <p>{@code .call()} 대신 {@code .stream().content()} 를 호출하면
* Spring AI 가 LLM 의 토큰을 받자마자 {@code Flux<String>} 으로 흘려준다.
* 컨트롤러는 이 Flux 를 그대로 반환하고, Spring MVC 의 {@code ReactiveTypeHandler}
* 가 SSE({@code text/event-stream}) 응답으로 자동 변환한다.</p>
*
* <p>Day 5 의 블로킹 {@link #chat(String, String, String, String)} 과 동일한
* conversationId 정책을 사용한다 — 사용자 × 무드 단위로 세션이 갈리도록 컨트롤러가
* conversationId 를 발급하거나 클라이언트가 넘긴 값을 그대로 들고 와야 한다.</p>
*
* <p>스트리밍 + ChatMemory 의 저장 시점은 Spring AI 가 자동 처리한다.
* {@code MessageChatMemoryAdvisor.adviseStream(...)} 은 내부적으로
* {@code ChatClientMessageAggregator} 를 거쳐 스트림이 끝난 시점에 단 한 번
* {@code after()} 를 호출한다 → 토큰이 모두 도착한 뒤 합쳐진 AssistantMessage 가
* ChatMemory 에 저장된다. 우리 코드는 conversationId 를 advisor 컨텍스트로
* 정확히 흘려보내기만 하면 된다 — {@code Flux.doOnComplete()} 보정 불필요.</p>
*
* <p>이번 Step 에서는 구조화 응답({@link AiReply}) 대신 평문 토큰만 흘린다 —
* 스트리밍은 본질적으로 "끝나기 전에 보여주기" 인데 record 직렬화는 응답이 끝나야 검증할 수 있어
* 두 모드가 섞이면 학습 포인트가 흐려진다.</p>
*/
public Flux<String> chatStream(String conversationId,
String anonymizedUserName,
String mood,
String userMessage) {
return soulmateChatClient.prompt()
.system(system -> system
.text("""
너는 {userName} 님의 AI 친구야.
유저의 현재 기분은 '{mood}' 이야.
답변은 3문장 이내로, 반말로 친근하게 해.
""")
.param("userName", anonymizedUserName)
.param("mood", mood))
.user(userMessage)
.advisors(advisor -> advisor.param(ChatMemory.CONVERSATION_ID, conversationId))
.stream()
.content();
}
코드를 정리했으니 Step 2 의 버전과 정확히 어디가 달라졌는지 비교해 볼게요.
| 비교 항목 | Step 2 의 chatStream(...) |
Step 5 의 chatStream(...) |
|---|---|---|
| 파라미터 개수 | 3 개 | 4 개 (conversationId 첫 부분 추가) |
| advisor 라인 | (없음) | .advisors(advisor -> advisor.param(ChatMemory.CONVERSATION_ID, conversationId)) |
| 마지막 두 줄 | .stream().content() |
.stream().content() (그대로) |
| ChatMemory 저장 | 없음 (대화 맥락 휘발) | 자동 (ChatClientMessageAggregator 가 처리) |
추가된 줄은 advisor 한 줄 뿐이에요. 그런데 이 한 줄로 캐릭터가 어제까지 무슨 얘기 했는지 를 다시 기억하기 시작해요. Step 2 에서 임시로 휘발됐던 캐릭터의 기억이, Step 5 에서 돌아온 거예요.
6. 컨트롤러도 같은 패턴 — conversationId 회수
서비스가 4개의 파라미터가 됐으니 컨트롤러도 따라가야 해요. Step 3 에서 정리한 streamChat(...) 도 4 번째 파라미터 가 추가돼요. 정책은 지난 시간 (Day 5) 의 블로킹 엔드포인트와 완전히 동일 해요.
conversationId 가 비어 있으면 서버가
UUID.randomUUID()로 새로 발급, 있으면 그대로 흘려보낸다.
이 정책이 왜 자연스러운지는 지난 시간 Day 5 Step 5 에서 풀었던 그대로예요. 사용자 × 무드 단위로 세션이 갈리니까, 같은 사용자가 여러 캐릭터/여러 분위기로 동시에 떠들어도 대화가 안 섞여요.
/**
* Day 6 Step 2~3 — 토큰 단위 스트리밍 응답 엔드포인트.
* Day 6 Step 5 — {@code conversationId} 파라미터로 ChatMemory 와 통합.
*
* <p>{@code produces = MediaType.TEXT_EVENT_STREAM_VALUE} 로 SSE 임을 명시하면
* Spring MVC 의 {@code ReactiveTypeHandler} 가 컨트롤러가 반환한 {@code Flux<String>}
* 을 자동으로 {@code ResponseBodyEmitter} 로 변환해 토큰을 흘려준다.</p>
*
* <p>SSE 응답은 ApiResponse 래핑 규약의 정당한 예외다. 청크 단위로 흐르는
* {@code text/event-stream} 본문에 JSON wrapper 를 끼워 넣으면 스트리밍 의미가 깨진다.
* 에러 처리는 {@code Flux.onErrorResume(...)} 같은 Reactor 연산으로 흐름 안에서 처리한다.</p>
*
* <p>conversationId 정책은 블로킹 엔드포인트({@link #soulmate}) 와 동일하다 —
* 비어 있으면 서버가 UUID 를 발급, 있으면 그대로 흘려보낸다. 스트리밍은 응답 본문에
* conversationId 를 함께 끼워 넣을 자리가 없어서, 새로 발급된 ID 는 응답 헤더
* {@code X-Conversation-Id} 로 클라이언트에게 알려주는 것이 일반적인 패턴이다.</p>
*
* <p>실제 ChatMemory 저장 타이밍은 {@code MessageChatMemoryAdvisor.adviseStream}
* 이 자동 처리한다 — Spring AI 의 {@code ChatClientMessageAggregator} 가 토큰을
* 모두 모은 뒤 한 번에 assistant 메시지를 ChatMemory 에 저장하므로 컨트롤러/서비스에
* 별도의 {@code .doOnComplete} 보정이 필요 없다.</p>
*/
@GetMapping(value = "/api/chat/soulmate/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChat(
@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;
return service.chatStream(convId, anonymizedName, mood, message);
}
추가된 부분은 세 가지예요.
@RequestParam(required = false) String conversationId— 새 파라미터. 없어도 되는 (optional) 부분이에요. 첫 호출엔 클라이언트가 안 보내도 돼요.- UUID fallback 한 줄 —
conversationId == null || isBlank()면UUID.randomUUID().toString()으로 새 ID 발급, 있으면 그대로 사용. - service 호출 시 첫 인자로 전달 —
service.chatStream(convId, anonymizedName, mood, message).
이 패턴은 지난 시간 Day 5 의 블로킹 soulmate(...) 컨트롤러와 완전히 똑같아요. 지난 시간 익힌 호흡이 오늘 그대로 살아있어요.
7. 미해결 트레이드오프 ① — X-Conversation-Id 응답 헤더가 없다
자, 여기서 눈썰미 좋은 학생 이 한 가지를 잡아냈을 거예요. 위 컨트롤러 코드, 한 가지 부족한 부분이 있어요.
"클라이언트가 처음 호출할 땐
conversationId를 안 보내잖아요. 그러면 서버가 UUID 를 새로 발급 하는데... 클라이언트는 그 발급된 UUID 를 어떻게 알아내요? 다음 호출 때 같은 conversationId 를 다시 보내려면 새로 발급된 ID 를 알아야 하잖아요?"
정확히 짚으셨어요. 우리 코드엔 그 답이 없어요. 🤔
지난 시간 Day 5 의 블로킹 엔드포인트는 응답 본문에 conversationId 를 함께 실어 보내는 모양이었어요 (ApiResponse JSON 안에 같이 들어갔죠). 그런데 SSE 응답은 응답 본문이 청크로 흘러나가는이라, 그 안에 conversationId 같은 메타데이터 를 끼워 넣을 부분이 없어요.
일반적인 패턴은 — 응답 헤더로 알려주기 예요.
// 의사 코드 — 본 강의 코드엔 아직 없음
return ResponseEntity.ok()
.header("X-Conversation-Id", convId)
.contentType(MediaType.TEXT_EVENT_STREAM)
.body(service.chatStream(convId, anonymizedName, mood, message));
X-Conversation-Id 같은 커스텀 응답 헤더 로 새로 발급된 UUID 를 클라이언트한테 알려주는 거예요. 클라이언트는 첫 응답에서 헤더를 읽어 보관하고, 두 번째 호출부터 그 ID 를 다시 보내요. 표준 EventSource API 는 응답 헤더를 직접 못 읽어서 fetch 기반 SSE 구현으로 갈아타야 하는 부분이긴 한데, 어쨌든 서버 측에서는 헤더로 내려주는 게 정석이에요.
그러면 왜 본 강의 코드엔 그게 없냐 — 본 강의의 학습 호흡 때문이에요. 이번 Step 의 핵심 학습 포인트 가
adviseStream+ChatClientMessageAggregator의 자동 합성 이거든요. 거기에ResponseEntity빌더 +X-Conversation-Id헤더 정책까지 한 번에 정리하면 학습의 초점이 흐려져요. 이 부분은 Step 7 의 ai-friends 통합 단계 또는 심화 과제에서 풀어볼 만한 보정 포인트예요. 본 Step 에선 "이런 미해결 부분이 있다" 정도만 짚고 갑니다.
이 트레이드오프는 실무에선 반드시 풀어야 할 부분 예요. 머릿속에 체크포인트 하나 정리해두세요.
8. 미해결 트레이드오프 ② — 스트리밍 도중 disconnect 의 비대칭 누적 ⚠️
두 번째 트레이드오프가 더 미묘해요. ChatClientMessageAggregator 의 맹점 한 가지를 짚어드릴게요.
aggregator 는 정상 onComplete 시점에서만 동작해요. 즉, 스트림이 정상적으로 끝나야 after(...) 가 호출돼서 ChatMemory 에 저장돼요. 그런데 사용자가 스트리밍 도중 페이지를 닫거나, 네트워크가 끊기거나, 브라우저가 강제 종료 되면 어떻게 될까요?
순서로 그려보면 이렇게 돼요.
| 시점 | 일어나는 일 | ChatMemory 상태 |
|---|---|---|
| 0.0 초 | 사용자 호출 → before() 가 user 메시지를 ChatMemory 에 저장 |
user: "오늘 진짜 별로였어" 1 개 |
| 0.3 초 | 첫 청크 도착 → 클라이언트가 받기 시작 | user 1 개 (assistant 아직 없음) |
| 1.5 초 | 사용자가 페이지 닫음 | user 1 개 (assistant 아직 없음) |
| 1.6 초 | LLM 은 계속 토큰 만들지만 클라이언트는 받지 않음 | user 1 개 |
| ?? | aggregator 가 onComplete 받음? — 케이스에 따라 다름 | 정상 onComplete 면 assistant 저장, 아니면 누락 |
정상 onComplete 가 도달하면 (서버는 사용자가 끊긴 줄 모를 수 있어요) assistant 메시지가 저장되긴 해요. 그런데 연결 자체가 cancel 된 케이스에선 aggregator 의 콜백이 불려지지 않아서 assistant 메시지가 누락 돼요.
결과는 ChatMemory 의 비대칭 누적 부분이에요. user 메시지만 남고 assistant 메시지가 빈 상태죠. 사용자가 거기서 다시 호출 하면, 다음 호출의 컨텍스트가 반쪽 으로 들어가요. 내가 한 말 — (응답 없음) — 새로 한 말 의 모양이 prompt 에 끼어들거든요. 그러면 LLM 이 "왜 자기 답이 없지?" 를 의심하면서 어색한 응답을 만들 수 있어요.
그러면 어떻게 푸나 — 정답은 부분 저장 + 일관성 보정 정책 의 영역이에요. (1) 일정 시간 이상 흐른 후 disconnect 면 받은 만큼만 assistant 메시지로 ChatMemory 에 남기는 옵션, (2)
before()와after()를 트랜잭션처럼 묶어 disconnect 시 user 메시지를 롤백 하는 옵션 등이 있어요. 본 강의의 범위 밖이고, 심화 과제 로 던질 만한 부분예요. 우리 ai-friends 도메인은 반쪽 응답이 더 답답한 세계라 (B 시나리오) 의 단점을 감수 하고 가요 — 적어도 토큰마다 저장 (A) 의 부서진 메시지 누적 보다는 훨씬 나은 트레이드오프거든요.
이 부분도 머릿속에 체크포인트 정리해두세요. 실무에서 대화 일관성이 중요한 도메인이라면 (예: 상담 봇, 의료 봇) 이 부분를 반드시 풀어야 해요.
🙋 날카로운 질문 타임
"튜터님, 그러면
MessageChatMemoryAdvisor가adviseCall과adviseStream두 메서드를 다 가진다는 거잖아요? 우리가 어느 걸 부를지 어떻게 정해지나요?chat()에선adviseCall,chatStream()에선adviseStream을 명시적으로 호출해야 하는 건가요?"
좋은 질문이에요. 결론부터 말하면 — 우리는 둘 다 직접 호출하지 않아요. .call() / .stream() 만 골라 쓰면 advisor 가 알아서 라우팅해요.
ChatClient fluent API 의 호흡을 다시 떠올려봅시다.
soulmateChatClient.prompt()
.system(...)
.user(...)
.advisors(a -> a.param(...)) // ← 같은 빈, 같은 한 줄
.call() // ← 이걸 호출하면 → adviseCall
// ← 또는 .stream() → adviseStream
.call() 호출 시 — Spring AI 가 등록된 advisor 들의 adviseCall(...) 메서드를 동기 체인 으로 엮어 실행해요. .stream() 호출 시 — 같은 advisor 들의 adviseStream(...) 메서드를 Reactor 체인 으로 엮어 실행해요. 우리는 .call() / .stream() 만 마지막에 갈아끼우면, advisor 가 어느 메서드로 동작할지를 자동으로 결정해요.
핵심은 — 같은 빈 한 개 (Day 5 Step 4 에서 등록한 그 한 줄) 가 동기 / 스트리밍 두 방식에서 그대로 재사용 된다는 거예요. advisor 빈을 두 개 만들 필요 없고, 둘 사이를 우리가 분기할 필요 없어요. 지난 시간 한 줄로 시작한 advisor 등록이 오늘 형제 흐름 에 그대로 살아있어요.
"튜터님, 토큰마다 저장 (시나리오 A) 가 안 좋은 건 알겠는데, 스트림 종료 시 한 번 저장 (시나리오 B) 도 클라이언트가 끊으면 저장이 안 되는 거잖아요? 그럼 어떻게 해요? 답이 없는 거 아닌가요?"
직관 너무 정확해요. 그리고 답은 — 완벽한 답은 없어요. 정답은 트레이드오프의 영역 부분이에요.
위에서 짚은 부분 저장 + 일관성 보정 정책 이 그 부분예요. 두 가지 주제가 있어요.
부분 저장 옵션 — 일정 시간 이상 (예: 1 초 이상) 스트림 후 disconnect 면 받은 만큼만 assistant 메시지로 ChatMemory 에 남기기. 반쪽 짜리 응답이라도 안 빈 게 낫다 는 정책이에요. 단점은 부분 응답이 어색한 도메인 (예: 코드 생성 봇) 에선 망가진 코드가 ChatMemory 에 남아 다음 호출에 끼어드는 부작용.
2. 롤백 옵션 — before() 와 after() 를 트랜잭션처럼 묶어, disconnect 시 user 메시지를 롤백. 비대칭은 만들지 않겠다 는 정책이에요. 단점은 사용자 메시지가 통째로 사라져 사용자가 "내가 한 말이 안 보낸 건가?" 를 의심할 수 있음.
도메인마다 답이 달라져요. ai-friends (미연시 캐릭터) 는 반쪽 응답이 더 답답한 도메인이라 — 시나리오 B 의 단점 (assistant 메시지 누락 → 비대칭 누적) 을 감수 하기로 결정했어요. 적어도 부서진 청크 누적 (시나리오 A) 보다는 훨씬 나으니까요.
이 결정을 명시적으로 하는 게 중요해요. 그래야 나중에 왜 여기에 보정이 없냐 라는 질문이 나왔을 때, "이게 우리 도메인의 트레이드오프 결정이다" 라고 답할 수 있거든요. 결정을 문서화 하는 게 엔지니어링이에요.
Step 5 의 한 문장 요약은 이래요.
"스트리밍과 ChatMemory 의 만남은 의외로 짧다 —
advisor.param(ChatMemory.CONVERSATION_ID, ...)한 줄. Spring AI 의ChatClientMessageAggregator가 완성된 메시지를 잡는 매듭을 자동으로 풀어준 덕분이다. 우리는 conversationId 를 advisor 컨텍스트로 흘려보내기만 하면 된다 —Flux.doOnComplete()보정 불필요."
오늘 Day 6 의 큰 주제 가 풀렸어요. Step 1 에서 흘린 "streaming + ChatMemory 의 만남" 의 복선, 지난 시간 Day 5 마무리에서 흘린 "after(...) 훅의 미묘함" 의 복선 — 둘 다 회수됐어요. 그리고 그 답은 "우리가 풀 게 아니라 Spring AI 가 이미 풀어놨다" 는이었죠. 한 줄짜리 정리가 30 줄어치 청크 누적 + 메시지 합성 + ChatMemory 저장을 대신 흡수해요.
다음 Step 에선 — Step 1 에서 언젠가 짚어보겠다 라고 약속한 그 비교를 풀어요. WebSocket vs SSE 트레이드오프. 우리가 오늘 SSE 로 갔으니, 언제 SSE 로는 부족하고 WebSocket 이 필요한가 를 표 한 장으로 정리하고 갈게요. 우리 도메인 (단방향 흘려주기) 에는 SSE 가 왜 더 잘 맞는지, 양방향이 진짜 필요할 때 (예: 멀티플레이어 채팅, 실시간 협업) 는 어떤 형태이 펼쳐지는지 — 한 번 짚고 갑니다.
Step 6: SSE vs WebSocket — 우리는 왜 SSE 를 골랐을까
자, Step 5 에서 streaming + ChatMemory 의 학습 포인트이 한 줄로 풀리는을 봤어요. advisor.param(ChatMemory.CONVERSATION_ID, ...) 한 줄, ChatClientMessageAggregator 의 자동 합성 — 두 가지가 들어왔죠.
그런데 Step 1 의 한 학생 걱정 박스 를 한 번 더 떠올려 봅시다. 거기서 우리는 약속을 하나 했었어요.
"WebSocket 은 Step 6 에서 비교 만 해요 (트레이드오프 표 한 장). 양쪽을 다 손으로 만질 필요는 없어요. 우리 도메인 (캐릭터가 사용자한테 답변을 흘려주기만 하는 단방향 흐름) 에는 SSE 가 더 잘 맞고, 의존성도 더 가볍거든요."
이번 Step 이 그 약속을 펼치는 부분이에요. 코드를 새로 짜지 않습니다. Spring Boot 과정에서 한 번 만나본 WebSocket 의 모습과 오늘 우리가 만든 SSE 의을 5 축 비교 표 한 장으로 정리해요. 그리고 왜 우리 도메인엔 SSE 가 자연스러운지, 언제 WebSocket 이 더 자연스러운지 의 감각을 잡고 갑니다.
1. WebSocket 짧게 복기 — Spring Boot 과정에서 한 번 짜보셨죠?
여러분 대부분은 Spring Boot 과정에서 WebSocket 기반 실시간 채팅 을 한 번쯤 짜보셨을 거예요.
STOMP 프로토콜로 메시지를 라우팅하고, @MessageMapping 으로 들어오는 메시지를 처리하고, SimpMessagingTemplate.convertAndSend(...) 로 구독자한테 뿌리고 — 그 형태이 손에 남아있을 겁니다.
WebSocket 의 핵심을 두 줄로 요약하면:
- HTTP Upgrade 핸드셰이크 — 처음엔 일반 HTTP 요청으로 시작하지만,
Upgrade: websocket헤더로 프로토콜을 갈아탑니다. 그 뒤로는ws://(또는wss://) 라는 별도 프로토콜 위에서 양방향 메시지가 오가요. HTTP 의 요청-응답 모양이 아니라, 양쪽이 언제든 메시지를 보내는 모양이에요. - 양방향 채널 — 클라이언트도 서버에 메시지를 언제든 보낼 수 있고, 서버도 클라이언트한테 언제든 메시지를 밀어줄 수 있어요. 한 번 핸드셰이크가 끝나면 지속적인 채널 이 열리는 거죠.
Spring Boot 과정에서 짜본 채팅방을 떠올려 보세요. 사용자가 메시지를 보내면 서버가 받아서 방의 모든 구독자한테 뿌리고, 다른 사용자가 응답하면 또 그 메시지가 모든 구독자한테 뿌려지고 — 양방향이 동시에 흐르는이었죠. 그게 WebSocket 의 결정적 강점이에요.
2. SSE vs WebSocket — 5 축 비교 표
이제 우리가 오늘 익힌 SSE 와 Spring Boot 과정에서 만난 WebSocket 을 5 축 으로 비교해봅시다. 외울 게 아니라 감각으로 보는 표예요.
| 비교 축 | SSE (Server-Sent Events) | WebSocket |
|---|---|---|
| 방향성 | 단방향 (서버 → 클라이언트만) | 양방향 (양쪽이 언제든 메시지) |
| 프로토콜 계층 | HTTP/1.1 응답 청크 (그냥 HTTP) | HTTP Upgrade 후 ws:// (별도 프로토콜) |
| 재연결 / 복구 | 표준 Last-Event-Id 헤더 → 브라우저가 자동 재연결 + 마지막 이벤트 이후부터 |
수동 재연결 (어플리케이션이 직접 구현) |
| 프록시·인프라 호환성 | HTTP 인프라 그대로 (CDN · L7 LB · 방화벽 통과) | 별도 설정 필요 (예: nginx proxy_set_header Upgrade) |
| 클라이언트 구현 복잡도 | EventSource 한 줄 (브라우저 표준 API) |
STOMP / SockJS / raw 핸드셰이크 + 메시지 라우팅 |
표 한 장만 잘 새겨두면 80% 끝났어요. 한 줄씩 좀 더 풀어볼게요.
방향성 — 가장 본질적인 차이예요. SSE 는 서버가 클라이언트한테 흘려주기만 하는 채널이에요. 클라이언트는 새 메시지를 보낼 때 별도 HTTP 요청 을 써요. WebSocket 은 양쪽이 동시에 메시지를 주고받는 채널이고요. 우리 도메인 (LLM 응답이 한 방향으로만 흘러나오는) 에 어느 쪽이 더 맞을지는 Step 3 에서 자세히 풀게요.
프로토콜 계층 — SSE 는 별도 프로토콜이 아니에요. Content-Type: text/event-stream 으로 응답하는 그냥 HTTP/1.1 응답 이고, 본문이 청크로 끊어 흐를 뿐 부분이에요. WebSocket 은 HTTP Upgrade 핸드셰이크 후 ws:// 라는 별도 프로토콜 로 갈아타요. 이 차이가 인프라 호환성 차이로 직접 이어져요.
재연결 / 복구 — SSE 의 결정적 장점 중 하나예요. 브라우저의 EventSource 가 연결이 끊기면 자동으로 재연결을 시도하고, 서버가 보낸 마지막 이벤트 ID 를 Last-Event-Id 헤더로 다시 보내줘요. 서버는 그 ID 이후의 이벤트만 다시 흘려주면 되죠. WebSocket 은 재연결 로직을 어플리케이션이 직접 짜야 해요.
프록시·인프라 호환성 — SSE 는 그냥 HTTP 라 모든 HTTP 인프라가 그대로 통과 시켜요. CDN, L7 로드밸런서, 사내 방화벽, 회사 프록시 — 별도 설정 없이 그냥 흘러가요. WebSocket 은 Upgrade 헤더를 통과시키도록 nginx · ALB · 방화벽에 별도 설정 이 필요하고, 일부 회사 망에선 아예 차단당해서 fallback (long-polling) 까지 준비해야 하이에요.
클라이언트 구현 복잡도 — 클라이언트 측 코드 양으로 직접 비교해보면 차이가 한눈에 들어와요.
// SSE — 브라우저 표준 EventSource API
const es = new EventSource('/api/chat/soulmate/stream?userId=42&mood=...&message=...');
es.onmessage = (ev) => console.log(ev.data); // 토큰이 흘러올 때마다 호출
// WebSocket (raw) — 핸드셰이크 + 메시지 라우팅 직접
const ws = new WebSocket('ws://localhost:8080/chat');
ws.onopen = () => ws.send(JSON.stringify({ type: 'subscribe', topic: 'soulmate' }));
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === 'token') { /* 토큰 처리 */ }
else if (msg.type === 'error') { /* 에러 처리 */ }
// ... 메시지 타입별 분기
};
SSE 는 한 줄. WebSocket 은 메시지 타입별 분기 가 누적되는 부분이에요. STOMP 를 얹으면 좀 더 정돈되긴 하지만, 학습·운영 비용이 함께 올라가요.
3. 우리 도메인에 SSE 가 자연스러운 이유 3 가지
표를 봤으니 이제 우리 도메인 (ai-friends 의 LLM 토큰 스트리밍) 에 SSE 가 왜 자연스러웠는지 풀어볼게요. 세 가지가 결정적이었어요.
① LLM 응답은 본질적으로 서버 → 클라이언트 단방향 — 토큰이 서버에서 클라이언트로 흘러요. 클라이언트가 흘러오는 도중에 서버한테 토큰을 다시 보내는 일은 없어요. 이건 도메인의 본질적 단방향성 부분이에요. 양방향 채널 (WebSocket) 을 깔면 클라이언트 → 서버 채널이 그대로 놀아요. 도메인이 단방향인데 양방향 채널을 쓰는 건 낭비 인 거죠.
② 사용자 메시지 전송은 별도 HTTP 요청으로 충분 — 사용자가 새 메시지를 보내는 건 별도의 POST 요청 한 번이면 끝이에요. 실시간 양방향 채널이 필요한 게 아니라, 반-실시간 단방향 흐름 부분이에요. ai-friends 의 실제 엔드포인트 두 개를 떠올려 보세요.
POST /api/chat/soulmate ← 사용자 메시지 전송 (블로킹, 블로킹 전체 응답 한 번)
GET /api/chat/soulmate/stream ← 토큰 단위 SSE 스트림 (단방향 흘려주기)
이 두 엔드포인트의 분리 가 우리 도메인의 통신 모양에 정확히 들어맞아요. 하나의 양방향 채널을 끌어다 메시지 타입을 분기시키는 것보다, 책임이 다른 두 엔드포인트로 나누는 게 더 깔끔해요.
③ HTTP 인프라 그대로 — 운영 부담이 거의 0 — ./run.sh (docker compose) 한 번에 앱이 뜨고, 별도 nginx 설정 / Upgrade 헤더 정책 / fallback 분기 코드 가 전혀 필요 없어요. 학생 입장에서도, 실무 배포 입장에서도, 부담이 작아요. SSE 는 HTTP 의 친척 이라 HTTP 가 통과하는 곳이면 어디든 그대로 통과해요.
️
이 셋이 합쳐지면 우리 도메인엔 SSE 가 자연스럽다 라는 결론이 나와요. 기술이 우월해서 가 아니라 — 도메인 모양과의 결합도 가 SSE 쪽이 훨씬 컸기 때문이에요.
4. 그러면 WebSocket 은 언제 더 자연스러운가
비교를 위해 반대 도 짧게 짚고 갈게요. 다음 같은 도메인은 SSE 로는 부족하고, WebSocket 이 훨씬 자연스러워요.
① 채팅방 실시간 멀티 사용자 — Spring Boot 과정에서 만들어 본 그대로예요. 여러 사용자가 동시에 메시지를 보내고, 서버가 모든 구독자한테 즉시 뿌려주는 모양. 클라이언트 → 서버 / 서버 → 클라이언트가 동시에 활발히 흐르는 도메인이라 SSE + 별도 POST 의 분리가 오히려 어색해져요. 양방향 단일 채널이 자연스러워요.
② 협업 편집 (Google Docs 모습) — 여러 사용자가 동시에 같은 문서를 편집 하면서, 누군가의 키 입력이 밀리초 단위로 다른 사용자한테 전파되는 부분이에요. 양쪽이 서로 영향을 주는 도메인이라 양방향 동시 채널이 본질이에요. ️
③ 게임 실시간 위치 업데이트 — 멀티플레이어 게임에서 캐릭터 위치 / 액션이 모든 클라이언트 사이로 밀리초 단위로 흐르는. 또한 바이너리 메시지 가 자주 오가는 (텍스트보다 효율적) 도메인이라 바이너리 프레임을 지원하는 WebSocket 이 자연스러워요.
세 도메인의 공통점은 양방향성이 본질 이라는 거예요. 단방향으로 줄여서 표현하면 도메인이 부서지는 모양이죠. 그럴 땐 WebSocket 이 정답이에요.
5. 혼합 전략 — 실무에서 흔한 패턴
여기서 실무 인사이트 하나. 실제 현업에선 SSE + 별도 POST 엔드포인트 조합이 흔한 선택지 예요. 그리고 우리 ai-friends 의 두 엔드포인트가 정확히 그 패턴 부분이에요.
POST /api/chat/soulmate ← 사용자 메시지 (요청 → 응답)
GET /api/chat/soulmate/stream ← 서버 푸시 (단방향 토큰 흐름)
양방향이 필요한 도메인이 아닌데 양방향 채널을 깔면 — 운영 / 인프라 / 클라이언트 코드 / fallback 분기 / 재연결 로직이 모두 무거워져요. 반대로 단방향을 SSE 로 깔고, 클라이언트 → 서버 메시지는 그냥 POST 로 풀면 — 책임이 명확히 갈라지고 각 엔드포인트의 역할이 한 줄로 설명 돼요.
이 조합은 LLM 챗봇 도메인의 표준 패턴 으로 굳어져 가고 있어요. ChatGPT, Claude, Gemini 의 웹 UI 도 모두 비슷한 모양 이에요 (정확한 내부 구현은 회사마다 다르지만, 단방향 토큰 푸시 + 별도 메시지 전송이라는 모양 은 공통이에요). 그래서 우리 ai-friends 의 두 엔드포인트 는 학습용 단순화 가 아니라 실무 패턴 그대로 인 거예요.
🙋 날카로운 질문 타임
"튜터님, 그러면 WebSocket 은 우리 강의에선 다시 만날 일 없어요? Day 6 이 마지막인가요?"
좋은 질문이에요. 결론부터 말하면 — 다시 만나긴 합니다, 그런데 완전히 다른 맥락 으로요.
본 강의에서 WebSocket 을 깊이 다루는 별도 Day 는 없어요 (이미 Spring Boot 과정에서 한 번 짜본 가정이거든요). 다만 Day 18 (MCP Server + A2A) 에서 SSE transport 가 다시 등장해요. MCP (Model Context Protocol) 라는 외부 통신 표준에서 transport 선택지로 stdio 와 SSE 두 가지가 있는데, 그땐 MCP 프로토콜의 transport 선택지 라는 완전히 다른 맥락 으로 SSE 가 등장해요. 오늘 깔아둔 SSE 의 본질 — HTTP 응답 청크 흐름 의 감각이 그때 다시 살아날 거예요.
WebSocket 은 — 본 강의의 별도 Day 는 없어요. 수료 후 실시간 채팅 / 양방향 협업 / 게임 도메인 을 만나면 그때 본격적으로 만날 부분이에요. 본 강의의 학습 호흡은 LLM 도메인의 자연스러운 통신 모양 에 집중하는 거라, 양방향 채널이 낭비 가 되는 부분에선 의도적으로 SSE 만 다뤘습니다.
"튜터님,
Last-Event-Id자동 재연결이 SSE 의 결정적 장점이라고 하셨는데, 우리 도메인에선 재연결 시 마지막 토큰 이후 를 다시 받아야 의미가 있잖아요? LLM 응답이 다시 시작되면 토큰이 처음부터 흐를 텐데요? 자동 재연결이 우리한테 진짜 유용한 거 맞아요?"
날카로운 질문이에요. 솔직히 답하면 — 우리 도메인에선 Last-Event-Id 의 진짜 가치는 약해요. ️
Last-Event-Id 의 진짜 가치는 연속적 이벤트 스트림 도메인에서 빛나요. 예를 들면:
- 주식 호가 스트림 — 재연결 시 놓친 호가만 다시 받으면 됨. 처음부터 다시 받을 이유 없음.
- 라이브 스코어 스트림 — 재연결 시 놓친 골 이벤트만 다시 받으면 됨.
- 로그 tailing — 재연결 시 놓친 로그 라인만 다시 받으면 됨.
이런 도메인은 각 이벤트가 독립적이고 누적되는 모양이라 마지막 ID 이후만 의 의미가 명확해요.
반면 우리 LLM 도메인은 완성된 한 번의 응답이 토큰으로 쪼개진 모양이에요. 중간에 끊기면 처음부터 다시 받는 게 일반적이에요 — 토큰 절반만 받고 그 다음 토큰부터 이어붙이는 게 문맥적으로 어색하고 (LLM 이 같은 답을 정확히 같은 토큰 시퀀스 로 다시 만든다는 보장도 없고요), 사용자 입장에서도 다시 처음부터 보는 게 더 자연스러워요.
그래서 우리는 Last-Event-Id 의 풀 파워 를 쓰진 않아요. 다만 EventSource 의 자동 재연결만 빌리는 정도로도 충분해요. 네트워크가 잠깐 끊겼다가 돌아왔을 때 클라이언트 코드 한 줄 안 짜고 자동 재연결이 시도되는 그 편의 — 그게 우리한테 SSE 의 진짜 이득 부분이에요. 부분적 가치 회수 라고 보시면 돼요.
Step 6 의 한 문장 요약은 이래요.
"기술 선택의 정답은 기술의 우월함 이 아니라 도메인과의 결합도 다. 우리 ai-friends 는 LLM 토큰 단방향 스트리밍 + 사용자 메시지는 별도 HTTP 라는 도메인 모양이라 SSE 가 자연스러웠다. WebSocket 은 양방향이 본질적으로 필요한 도메인 (실시간 채팅, 협업 편집, 게임) 에서 빛나는 도구다."
오늘 우리는 SSE 와 WebSocket 을 5 축으로 비교 하고, 우리 도메인에 SSE 가 왜 자연스러운지 세 가지 이유 로 풀었어요. 그리고 언제 WebSocket 이 더 자연스러운지 의 감각도 잡았어요. 핵심은 — 어느 도구가 더 우월한가 가 아니라 어느 도구가 도메인 모양에 잘 맞는가 라는 시각이에요.
엔지니어로서 가장 중요한 감각 중 하나죠.
다음 Step 에선 — 드디어 다 들어간 코드를 들고 실제로 한 번 띄워봅니다. Step 2~5 에서 만든 service.chatStream(...) + streamChat(...) 컨트롤러를 ./run.sh up 으로 띄우고, curl 로 SSE 응답이 진짜 흘러나오는 형태를 직접 봐요. 그리고 프론트엔드의 캐릭터 대사가 타이핑되듯 흘러나오는 효과 까지 — Day 6 의 결실을 익히고 마무리합니다.
Step 7: 들고 있던 코드를 **진짜** 띄워보기 — 캐릭터가 타이핑되듯 흘러나오는 형태
자, Step 6 까지 트레이드오프 표 를 그렸으니 이제 손으로 만져볼 시간 부분이에요.
이번 Step 은 코드를 새로 짜지 않습니다. Step 2~5 에서 정리한 코드 (day06-streaming 브랜치) 를 ./run.sh up 으로 띄우고, curl 두 번 + 세션 조회 한 번 으로 멀티턴 + 타이핑 효과 를 직접 봅니다. Step 1 에서 빈 화면을 멍하니 보던 그 2.3 초의 답답함 이 — 이번 Step 의 0.6 초 첫 토큰 도착 으로 풀리는을 익히고 Day 6 을 마무리할 거예요.
1. day06-streaming 브랜치 띄우기
도커 컴포즈로 앱 + MySQL 을 띄워요.
./run.sh up
8080 으로 떠 있는지 헬스체크 한 번.
curl http://localhost:8080/actuator/health
# {"status":"UP"}
좋아요, 준비 완료입니다.
2. 첫 SSE 호출 — 토큰이 흘러나오는 직접 보기
이제 curl -N 으로 SSE 응답을 받아볼 거예요. -N 이 핵심 인데, 잠시 후 질문 타임에서 자세히 풀게요. 일단 명령부터.
time curl -N -G "http://localhost:8080/api/chat/soulmate/stream" \
--data-urlencode "userId=1" \
--data-urlencode "mood=우울" \
--data-urlencode "message=오늘 진짜 별로였어"
엔터 누르고 터미널을 가만히 보세요. Step 1 에서 본 2.3 초의 침묵 과 다른 형태이 펼쳐질 거예요.
data:에이,
data: 무슨 일
data: 있어?
data: 오늘
data: 하루
data: 힘들었구나...
data: 천천히
data: 얘기해
data:줄래?
real 0m2.398s
user 0m0.014s
sys 0m0.011s
총 응답 시간은 2.39 초 — Step 1 의 2.3 초와 거의 같아요. 그런데 첫 청크 (data:에이,) 가 도착한 시점은 약 0.6 초. Step 1 에선 0 byte 였던 그 시점에 우리는 첫 토큰을 받고 있어요.
| 항목 | Step 1 (blocking) | Step 7 (streaming) |
|---|---|---|
| 첫 토큰 도착까지 | 2.3 초 (응답 전체) | 0.6 초 |
| 전체 완료까지 | 2.3 초 | 2.4 초 |
| 클라이언트가 본 0 byte 의 시간 | 2.3 초 | 0.6 초 |
| 체감 대기 시간 | 2.3 초 | 0.6 초 |
총 응답 시간은 비슷한데 체감 대기 시간이 약 4 배 짧아진 거예요. 그리고 0.6 초 이후로는 문장이 한 글자씩 흘러 도착하니, 사용자는 "앱이 멈췄나?" 를 의심할 틈이 없어요. 이게 Step 1 에서 "답답하다" 라고 손으로 만져본이 풀리는 부분이에요.
3. 두 번째 호출 — 같은 conversationId 로 멀티턴 검증
자, 이제 Step 5 의 ChatMemory 통합 이 진짜로 동작하는지 확인할 차례예요. 위 호출에서 conversationId 를 비워서 보냈으니, 서버가 새 UUID 를 발급했을 거예요.
⚠️ Step 5 에서 미해결로 짚어둔 트레이드오프 ① —
X-Conversation-Id응답 헤더가 없어서, 클라이언트가 발급된 ID 를 공식 채널로 알아낼 부분이 없어요. 본 강의 단계에선 — 서버 로그 또는 DB 직접 조회 로 ID 를 꺼내 쓸 거예요. 실무에선 응답 헤더 보정이 필수라는 점, 다시 한 번 짚어둡니다.
DB 에서 직접 conversationId 를 꺼내볼게요. Day 5 에서 정리한 SPRING_AI_CHAT_MEMORY 테이블에서 가장 최근 1 건만.
docker exec -it ai-friends-mysql mysql -uaifriends -paifriends1234 aifriends \
-e "SELECT conversation_id FROM SPRING_AI_CHAT_MEMORY ORDER BY \`timestamp\` DESC LIMIT 1;"
# +--------------------------------------+
# | conversation_id |
# +--------------------------------------+
# | 7f3a1b2c-9d4e-4f5a-8b6c-1234567890ab |
# +--------------------------------------+
(여러분 환경에선 다른 UUID 가 나올 거예요.) 이 ID 를 들고 두 번째 호출 을 합니다. 내가 좀 전에 뭐라고 했지? 라는 후속 메시지로요.
CID="7f3a1b2c-9d4e-4f5a-8b6c-1234567890ab"
time curl -N -G "http://localhost:8080/api/chat/soulmate/stream" \
--data-urlencode "userId=1" \
--data-urlencode "mood=우울" \
--data-urlencode "message=내가 좀 전에 뭐라고 했지?" \
--data-urlencode "conversationId=$CID"
응답이 흘러올 거예요. 만약 ChatMemory 가 진짜로 동작하고 있다면, 캐릭터의 답이 첫 호출의 맥락 (오늘 진짜 별로였어) 을 기억하고 흘러나와야 해요.
data:방금
data: 오늘
data: 하루가
data: 진짜
data: 별로였다
data:고
data: 했잖아.
data: 무슨 일
data: 있었던
data:거야?
"방금... 별로였다고 했잖아" — 첫 호출의 메시지를 기억하고 응답에 반영 했어요. Step 5 의 MessageChatMemoryAdvisor + ChatClientMessageAggregator 통합이 실제로 동작한 거예요. 지난 시간 Day 5 에서 정리한 ChatMemory 가 오늘의 스트리밍 위에서 그대로 살아있다는 것의 마지막 검증이에요.
✅
4. 세션 조회로 ChatMemory 사후 검증
한 단계 더 가요. aggregator 가 assistant 메시지를 제대로 누적했는지 를 직접 눈으로 확인할 시간이에요. Day 5 Step 5 에서 정리한 세션 조회 엔드포인트 (GET /api/chat/soulmate/sessions/{conversationId}) 를 호출해요.
curl -s "http://localhost:8080/api/chat/soulmate/sessions/$CID" | jq
응답이 이렇게 흘러올 거예요. (Day 5 의 ApiResponse 포맷 그대로 — role 은 MessageType.name().toLowerCase() 의 결과라 소문자 로 떨어집니다.)
{
"success": true,
"data": [
{ "role": "user", "content": "오늘 진짜 별로였어" },
{ "role": "assistant", "content": "에이, 무슨 일 있어? 오늘 하루 힘들었구나... 천천히 얘기해줄래?" },
{ "role": "user", "content": "내가 좀 전에 뭐라고 했지?" },
{ "role": "assistant", "content": "방금 오늘 하루가 진짜 별로였다고 했잖아. 무슨 일 있었던거야?" }
]
}
4 개의 메시지가 시간 순서대로 잘 누적됐어요. 보세요 — user 와 assistant 가 번갈아 정확히 두 쌍. 이게 Step 5 의 자동 합성 의 사후 증거예요.
MessageChatMemoryAdvisor.before()가 매 호출마다user메시지를 저장MessageChatMemoryAdvisor.after()(aggregator 위에서) 가 스트림 종료 시점에assistant메시지를 완성된 형태로 저장
assistant 메시지의 내용을 보면 — 조각난 청크 (에이,, 무슨 일, 있어?) 가 아니라 완성된 한 문장 (에이, 무슨 일 있어? 오늘 하루 힘들었구나...) 으로 저장돼 있어요. aggregator 가 청크들을 합쳐서 한 번에 넣어준 결과죠.
5. 프론트엔드 측 의사 코드 — EventSource 한 줄로 받기
여기서 백엔드 학생이 "그래서 프론트엔드는 이걸 어떻게 받는가" 의 감 만 잡고 갈 거예요. Step 1 의 학생 걱정 박스 에서 약속한 받는 모양만 의 마지막 마무리예요. 실제 프론트 코드 작성은 본 강의 범위 밖이고, 우리는 어떤 모양으로 받겠구나 정도만 봅니다.
브라우저 표준 API 인 EventSource 의 의사 코드 3 줄.
const es = new EventSource('/api/chat/soulmate/stream?userId=1&mood=우울&message=오늘%20진짜%20별로였어');
es.onmessage = (ev) => appendToken(ev.data); // 토큰이 흘러올 때마다 호출
es.onerror = () => es.close(); // 스트림 종료 시 정리
세 줄이 끝 부분이에요. onmessage 콜백이 서버가 흘려보낸 청크 하나 마다 호출돼요. 위에서 우리가 curl 로 본 data:에이,, data: 무슨 일, data: 있어? 가 — 각각 한 번씩 콜백을 트리거해요.
프론트엔드에서 appendToken(ev.data) 가 캐릭터 말풍선 DOM 에 글자를 이어 붙이기만 하면, 그게 바로 타이핑 효과 예요. 별도의 인위적 setTimeout / setInterval 없이, 서버가 흘려보내는 자연스러운 속도 가 곧 캐릭터의 타이핑 속도 가 되는 거죠.
6. 캐릭터 대사 타이핑 효과의 UX — Day 6 의 결실
여기서 우리가 익힌 결실 을 한 번 짚고 갈게요. 미연시 게임의 전통적인 형태를 떠올려 보세요. 캐릭터의 대사가 말풍선에 한 번에 통째로 떨어지는 게임 vs 한 글자씩 타이핑되듯 흐르는 게임 — 어느 쪽이 더 몰입감이 있나요?
미연시 / 비주얼 노벨 / 어드벤처 게임의 거의 모든 명작 이 타이핑 효과 를 채택해요. 이유는 두 가지예요.
첫째 — 말이 흘러오는 모습이 캐릭터에게 생명을 부여해요. 한 번에 떨어지는 텍스트는 AI 가 생성한 결과 라는 인공적 감각을 주지만, 한 글자씩 흐르는 텍스트는 캐릭터가 지금 이 순간 입을 떼는 감각을 줘요. 사용자가 내 캐릭터와 대화하고 있다 는 몰입 이 깊어지는 거죠.
둘째 — 읽는 호흡이 자연스러워져요. 한 번에 떨어진 긴 대사는 사용자가 어디부터 읽을지 잠시 헷갈리지만, 흘러오는 대사는 읽는 속도와 흐르는 속도가 자연스럽게 맞아 들어가요. 사용자가 대사를 따라 읽는 경험이 매끄럽죠.
Step 1 의 2.3 초 빈 화면 이 답답한 장면 이었던 이유가 여기서 다시 풀려요. 단순히 기다림이 길었던 것 이 아니라 — 캐릭터가 입을 다물고 있는 형태 이었던 거예요. 그리고 오늘 Step 7 의 0.6 초 첫 토큰 + 흘러나오는 대사 는 — 캐릭터가 입을 떼고 천천히 말을 잇는 형태 으로 바뀌었어요. 같은 모델, 같은 비용, 같은 ChatMemory. 흘려보내는 채널 만 바꿨을 뿐인데 ai-friends 의 UX 가 한 단계 진화한 거예요.
7. 미해결 이슈 정리 — 다음 Day 또는 과제로
자, 마무리 전에 Step 5 에서 짚어둔 두 가지 미해결 트레이드오프 를 다시 한 번 압축할게요. 두 부분 모두 알고 있는 상태로 감수 또는 보정 예약 해둡니다.
① X-Conversation-Id 응답 헤더 누락 — 첫 호출에서 서버가 새 UUID 를 발급하지만, 클라이언트가 그 ID 를 알 채널이 없는 상태. 실무에선 ResponseEntity.ok().header("X-Conversation-Id", convId).body(...) 로 응답 헤더에 실어 보내는 게 정석이에요. 본 강의에선 과제 또는 Day 7 이후의 보정 부분 로 미뤄둡니다.
② 스트리밍 도중 disconnect 시 ChatMemory 비대칭 누적 — 사용자가 페이지를 닫거나 네트워크가 끊기면 user 메시지만 남고 assistant 메시지가 누락 되는. 부분 저장 정책 / 롤백 정책 두 가지가 있지만, ai-friends 도메인은 반쪽 응답이 더 답답한 세계라 현재는 감수 합니다. 상담 봇 / 의료 봇처럼 일관성이 중요한 도메인이라면 반드시 풀어야 하는 부분이에요.
⚠️
③ chatStream 은 학습용 메서드 — 실제 게임로직에 미적용은 의도적 결정 — 지난 시간 Day 5 Step 6 에서 chat(convId, name, mood, msg) 학습용 PoC 가 chat(Long soulmateId, String userMessage) prod sig 로 자라며 AiChatController (POST /api/chat) 를 흡수했죠. 오늘 만든 chatStream(...) 는 그 길을 가지 않아요. chatStream(Long, String) prod sig 로의 진화도, AiChatController 의 streaming 흡수도 — 의도적으로 안 합니다. 이유는 ai-friends 는 미연시 게임이지 챗 어시스턴트가 아니거든요. 게임의 핵심 루프가 "AI 대사 → 선택지 칩 클릭 → 분기 + 호감도 갱신" 인데, choices · affectionDelta · 뱃지는 텍스트 청크 본문에 낄 부분이 없어요. 게다가 프론트는 이미 blocking 응답 + 클라가 한 글자씩 렌더링 하는 시뮬 타이핑 으로 체감 typing UX 를 확보 했고, 미연시 사용자는 대사가 끝까지 도착해야 선택지 클릭 으로 넘어가니 부분 텍스트의 가치 자체가 작아요. 그래서 streaming 은 Spring AI 의 capability 를 알아두는 학습 부분 로 박제 (/api/chat/soulmate/stream 은 curl 로 만져보는 학습용 엔드포인트 로 코드베이스에 보존, 프론트엔드엔 안 붙임), ai-friends 의 prod UX 는 blocking POST /api/chat 를 그대로 갑니다.
세 부분 모두 — 몰라서 감수하는 게 아니라 알면서 감수한다는 게 핵심이에요. 결정을 명시적으로 문서화 하는 게 엔지니어링이라고 Step 5 에서 짚었죠. 그 호흡을 Step 7 까지 가져와 마무리합니다.
🙋 날카로운 질문 타임
"튜터님,
curl -N의-N이 뭐예요? 그냥curl만 쓰면 안 돼요?"
좋은 질문이에요. -N 은 no buffer 의 약자예요.
일반 curl 은 효율을 위해 응답을 일정량 모아서 한 번에 출력 해요. 이게 stdout 버퍼링이라는 건데, 보통은 출력 효율을 높이려는 목적이에요. 그런데 SSE 토큰을 받을 땐 이 버퍼링이 방해가 돼요. 서버가 0.3 초에 첫 청크, 0.6 초에 두 번째 청크 를 보냈는데, curl 이 모아서 한 번에 출력 하면 우리 눈엔 덩어리째 보여요 — 흘러오는이 사라지는 거죠.
-N 옵션은 그 버퍼를 끄는 옵션이에요. 받는 즉시 그대로 화면에 출력. 그래서 SSE 디버깅의 표준 옵션 으로 굳어져 있어요. 매번 SSE 엔드포인트를 curl 로 테스트할 때는 -N 을 빼먹지 않는 걸 익혀두세요. ️
"튜터님, 세션 조회 엔드포인트로 ChatMemory 의 메시지를 보면 역순 으로 보이거나 빠진 메시지가 있을 수 있나요?"
날카로운 질문이에요. 결론부터 — 정상 동작에선 시간 순 누적이에요 (USER 먼저 → ASSISTANT). ⏰
MessageChatMemoryAdvisor 의 before() 가 호출 직전에 USER 메시지를 저장하고, 그 다음 LLM 호출이 일어나고, 마지막에 after() (스트리밍에선 aggregator 의 onComplete) 가 ASSISTANT 메시지를 저장해요. 그러니까 항상 USER 먼저, ASSISTANT 가 그 다음 의 시간 순서로 들어가요.
다만 누락 가능성 이 한 부분 있어요 — 위에서 짚은 미해결 트레이드오프 ② (스트리밍 도중 disconnect). 그 케이스에선 USER 만 남고 ASSISTANT 가 빈 비대칭이 생겨요. 다음에 세션 조회를 해보면 USER 메시지 옆에 짝이 없는 부분이 보일 수 있어요. 정상 종료 시점이라면 항상 짝수 개 (USER N + ASSISTANT N) 로 누적된다는 점만 머릿속에 정리해두세요.
Step 7 의 한 문장 요약은 이래요.
"Day 6 의 처음과 끝을 이어보면 — Step 1 에서 빈 화면을 2.3 초 보던이, Step 7 에서 0.6 초 만에 첫 토큰 이 흐르는으로 바뀌었다. 사용자 체감 대기 시간은 약 4 배 짧아졌고, 캐릭터 대사가 타이핑되듯 흘러나오는 미연시 게임 UX 가 들어왔다."
오늘 Day 6 의 모든 주제 가 풀렸어요. .call() → .stream() 한 줄, Flux<String> 의 받는 모양, text/event-stream 미디어 타입, MessageChatMemoryAdvisor 의 자동 라우팅, ChatClientMessageAggregator 의 완성된 메시지 합성, SSE vs WebSocket 의 도메인 결합도 — 여섯 가지 도구가 익히셨어요. 그리고 그 결실은 ai-friends 의 캐릭터가 입을 떼고 말을 잇는 부분이에요.
다음 Day (Day 7) 는 — 이미지 생성 입니다. 텍스트 스트리밍과는 완전히 다른 패턴 이 기다리고 있어요. 텍스트는 작은 토큰이 빠르게 흘러 도착하지만, 이미지는 큰 payload 한 방 이 한참 걸려 도착해요. 응답 시간이 수 초~수십 초, 비용은 텍스트 호출의 수십 배. text/event-stream 의 흘려보내는 방식이 안 통하는 부분이에요.
다른 호흡, 다른 비용 감각, 다른 UX 패턴 — 모두 다음 Day 에서 만나요.
마무리 — 오늘 배운 것 · Day 7 예고
1. 오늘의 여정 한눈에
Day 6 의 3 시간을 한 문장으로 요약하면 — "답변이 흘러 도착하는을 익힌 하루" 였어요.
지난 시간 Day 5 에서 대화의 기억 을 입혔던 SoulmateChatService 가, 오늘 글자가 흘러나오는 캐릭터 로 진화했어요. 같은 모델, 같은 비용, 같은 ChatMemory — 흘려보내는 채널 만 갈았을 뿐인데 사용자 체감 대기 시간이 4 배 짧아졌고요.
Day 5 에서 그랬듯, 오늘 만진 7 개의 도구·결정·감각을 한 줄씩 묶어볼게요.
| Step | 도구 / 결정 | 한 줄 요약 |
|---|---|---|
| Step 1 | blocking UX 의 답답함 | "2.3 초 빈 화면 — 사용자가 앱이 멈춘 건가 의심하는 형태" |
| Step 2 | .stream().content() → Flux<String> |
"비동기를 정복 이 아니라 받는 모양 만 잡으면 충분" |
| Step 3 | produces = TEXT_EVENT_STREAM_VALUE + Flux 직접 반환 |
"Spring MVC 가 SSE 포맷으로 자동 변환 — 컨트롤러는 한 줄" |
| Step 4 | ApiResponse 표준 패턴의 정당한 예외 | "미디어타입 본질 비호환 이라 표준 패턴이 열리는 부분 — 일반 패턴이 표준이고 이건 예외" |
| Step 5 | MessageChatMemoryAdvisor.adviseStream + ChatClientMessageAggregator |
"advisor 한 줄 + param 한 줄 = 청크 누적 + 스트림 종료 시 한 번만 저장" |
| Step 6 | SSE vs WebSocket 5 축 | "단방향 / HTTP 친화 / 자동 재연결 / 인프라 호환 / 구현 단순 — 5 축 모두 SSE 가 우세한 도메인이라 골랐다" |
| Step 7 | 첫 토큰 0.6 초 + ChatMemory 사후 검증 | "체감 대기 시간 4 배 단축 + aggregator 가 완성된 메시지 로 누적 확인" |
이 7 개를 다 외우라는 게 아니에요.
"스트리밍은 LLM 응답을 토큰 단위로 흘려서 사용자 체감 대기 시간을 줄인다" 와 "Spring AI 의 MessageChatMemoryAdvisor.adviseStream 은 ChatClientMessageAggregator 로 스트림 종료 시점에 한 번만 저장한다 — 우리는 advisor + param 한 줄만 추가" 두 문장만 3 개월 뒤에도 기억하시면 오늘 수업은 성공이에요.
2. 실제 게임에 미적용 결정 — streaming 은 capability 학습, 게임 prod 는 blocking 그대로
지난 시간 Day 5 마무리에서 수렴 로드맵 한 줄을 정리해뒀던 거 기억나시죠? SoulmateChatService.chat(convId, name, mood, msg) 학습용 PoC 가 Day 5 Step 6 에서 prod sig (chat(Long, String)) 로 자라며 AiChatController 를 흡수 했던. 이 수렴 의식 이 본 강의의 약속이에요.
오늘 Day 6 의 streaming 도 같은 의식을 거치는데, 답이 완전히 다릅니다. 결론부터 박을게요.
오늘 만든 streaming (
chatStream(...)+GET /api/chat/soulmate/stream) 은 실 게임에 적용하지 않기로 의도적으로 결정. ai-friends 의 게임 도메인과 근본적으로 안 맞기 때문. streaming 은 Spring AI 의 capability 를 학습한 부분 로 박제 (curl 로 만져보는 학습용 엔드포인트로 코드베이스에 보존), prod UX 는 blocking POST/api/chat(Day 5 Step 6 흡수 완료) 를 그대로 유지.
세 가지로 나눠 정리할게요.
① 왜 prod 적용을 안 하는가 — 게임 도메인의 근본적 부정합
ai-friends 는 미연시 게임 부분이에요. 챗 어시스턴트가 아니에요. 게임의 핵심 루프를 한 줄로 그리면 — "AI 대사 → 선택지 칩 클릭 → 분기 + 호감도 갱신 + 뱃지 획득" 이거든요. 이 루프에서 선택지 칩 과 호감도 게이지 갱신 은 대사와 동시에 도착해야 게임의 박자가 살아요.
그런데 SSE 채널의 본문은 순수 텍스트 청크 만 흘러요. aiMessage 는 흐를 수 있지만, choices · affectionDelta · 뱃지는 텍스트 본문에 낄 부분이 없어요. 그러면 streaming 으로 가면 게임의 메인 루프가 깨져요 — 캐릭터 대사는 흐르듯 도착하는데 선택지 칩이 안 보이거나, 응답 끝나고 추가 이벤트로 따로 도착 하는. 미연시의 "대사 + 선택지 + 호감도가 한 박자에 떨어지는" 약속이 무너집니다.
② 게다가 체감 typing UX 는 이미 확보됨
ai-friends 프론트엔드는 이미 blocking 응답을 받은 뒤 클라이언트가 한 글자씩 렌더링 하는 시뮬 타이핑 을 깔아놨어요. 학생이 게임 화면을 띄워보면 캐릭터가 진짜로 글을 쓰는 것 같은 감각이 이미 손에 잡혀 있죠. 진짜 SSE 가 추가로 주는 가치는 첫 토큰 시간 단축 (2.3 초 → 0.6 초) 인데 — 미연시 사용자는 대사가 끝까지 도착해야 다음 행동 (선택지 클릭) 으로 넘어가요. 읽다 만 상태 의 텍스트는 클릭할 거리가 없는 상태라 가치가 작아요. 챗 어시스턴트 (예: ChatGPT) 와 미연시 게임의 대기 시간 가치 가 서로 다른 거예요.
③ 그러면 오늘 배운 streaming 은 어디서 살아있는가 — capability 학습 부분
그래도 오늘 배운 streaming 이 버려진 건 아니에요. 두에 살아있어요.
- 코드베이스의 학습용 엔드포인트 —
GET /api/chat/soulmate/stream은 코드베이스에 그대로 박제. 학생이 curl 로 직접 만져보며 Spring AI 의 streaming capability 를 익히는 부분이에요. 프론트엔드에는 안 붙입니다 (게임 prod UX 는 blocking 그대로니까). - 다른 도메인의 prod 적용 후보 — 챗 어시스턴트 (선택지/호감도 없음, 텍스트만 흐르면 충분), 코드 생성 봇 (긴 응답 → 첫 토큰 시간 단축의 가치 큼), Agent 의 thinking step streaming (LLM 의 생각의 진행 자체가 컨텐츠) 같은 도메인에선 prod 채택이 자연스러워요. 그래서 Day 14 (Agent 가드레일) / Day 19 (Harness 엔지니어링) 에서 streaming 이 그 도메인에 맞는 부분에서 다시 등장합니다. ai-friends 의 게임 채널이 아니라 Agent thinking 채널로요.
결정을 명시적으로 하는 게 엔지니어링이에요 (Step 5 에서 짚었죠). 오늘 "기술이 화려하다고 도메인에 들이는 게 아니다" 라는 감각을 한 번 손에 잡아두세요. streaming 은 도메인이 받아주는 곳에서만 prod 가 된다 — 미연시 게임은 받아주지 않는 도메인 부분이에요.
3. Day 7 예고 — "텍스트는 흘러 도착했다, 이젠 이미지가 한 번에 도착할 차례"
오늘 Step 1 에서 우리가 본 — 답변이 토큰 단위로 흘러와 빈 화면 시간을 0.6 초로 줄이는. 이 흘려보내는 패턴이 LLM 응답의 기본 호흡 인 것 같죠? 🤔
그런데 Day 7 에서 만날 건 정반대 호흡이에요. 이미지 생성 입니다.
Day 5 에서 비교한 두 호출을 한 번 더 떠올려 볼게요.
// Day 5: blocking — 한 번에 도착 (답답)
.call().entity(AiReply.class);
// Day 6: streaming — 흘러서 도착 (체감 4배 빠름)
.stream().content(); // Flux<String>
Day 7 의 이미지 생성은 — 어느 쪽도 아닌 제 3 의 호흡이에요. 흘려보낼 것이 없거든요.
| 응답 | 모양 | 패턴 |
|---|---|---|
| Day 5 텍스트 (blocking) | 완성된 한 문장 | .call().entity(...) |
| Day 6 텍스트 (streaming) | 작은 청크 × 다수 | .stream().content() → SSE |
| Day 7 이미지 | 큰 payload 한 방 | JSON ApiResponse 로 회귀 |
이미지는 부분이 흘러오면 의미가 없어요. 픽셀의 절반만 받으면 반쪽 그림이 아니라 깨진 그림 이에요. 그래서 패턴이 다시 일반 JSON 응답으로 돌아와요.
Day 7 의 키워드 몇 개만 미리 던져 둘게요.
ImageModel—ChatModel과 자매 추상화. Spring AI 가 생성 종류별 로 모델을 추상화한 또 하나의 자매 추상화.- 비용 감각 — 이미지 생성 호출 한 번 이 텍스트 LLM 호출 수십 번 분량의 비용. 무료 옵션(Pollinations.ai, Hugging Face 무료 티어, Gemini Imagen 무료 할당) 위주로 가지만, 원가 감각 은 처음부터 머리에 정리해둬요.
- 응답 패턴 회귀 —
text/event-stream안 씁니다. 다시application/json+ApiResponse<T>로 돌아와요. - 다른 UX 호흡 — 텍스트 스트리밍은 0.6 초 첫 토큰 으로 체감 대기 를 줄였지만, 이미지 생성은 수 초~수십 초 동안 진행 중 표시 (스피너 / 프로그레스 바) 를 보여주는 게 정석. 같은 대기 모습 의 다른 답.
⚠️ 이미지 생성은 비용이 비싸서 학생 실습은 선택 부분이에요. 비용 경고를 미리 드릴게요. 무료 옵션 위주로 시연을 하지만, 유료 모델(DALL-E, Midjourney API 등) 은 원가 감각만 짚고 실습엔 안 써요. 화면 한 번 그릴 때마다 청구서 가 어떻게 쌓이는지 비용 가이드 섹션이 따로 들어갈 거예요. 🚨
오늘 흘려보내는에 익숙해진 손이, 다음 시간 한 방에 도착하는 앞에서 왜 이건 못 흘려보내는가 를 자연스럽게 묻게 될 거예요. 그 질문이 Day 7 의 첫 주제예요.
오늘의 도전 과제 (Homework)
[구현 1] 체감 대기 시간 측정 — blocking vs streaming 의 첫 토큰까지 시간 직접 재기
배경 시나리오
ai-friends 의 PM 이 출시 회의에서 묻습니다.
"튜터님, 오늘 streaming 으로 바꿨는데 얼마나 빨라진 거예요? 체감 대기 시간이 4 배 짧아졌다 고 하셨는데 — 그 4 배 가 어디서 나온 숫자예요? 우리 환경에서도 진짜 그 정도인가요?"
전형적으로 PM 보고서에 들어갈 측정값 이 필요한 상황이에요. Step 7 의 0.6 초 첫 토큰 은 튜터의 환경에서 측정한 예시값 이고, 여러분의 환경(모델 프로바이더 / 네트워크 / 메시지 길이) 에서는 다를 수 있어요.
이번 과제에선 직접 blocking 과 streaming 두 엔드포인트의 체감 대기 시간 을 측정해서 비교 표를 측정값 으로 채워봅니다. Day 4 과제 2 의 추측을 측정으로 바꾸는 정신 그대로요.
💡 왜 굳이 이 과제를 할까요?
- PM 보고서의 4 배 는 측정값이어야 한다 — Step 7 에서 짚은 체감 대기 시간 4 배 단축 은 예시 환경의 숫자 였어요. 운영 의사결정의 무게는 내 환경에서 측정한 숫자에서 나옵니다. 추측이 아닌 수치 로 PM 에게 답할 수 있어야 해요.
- 스트리밍의 진짜 효과는 첫 토큰까지의 시간 이다 — 전체 응답 완료 시간 은 blocking 과 streaming 이 비슷할 수 있어요. 그런데 체감 의 핵심은 언제 첫 글자가 떨어지는가 예요. 두 지표를 분리해서 측정하는 감각을 익혀요.
✅ 요구사항
- 두 엔드포인트의 응답 시간을 각각 5 회 이상 측정 — Day 6 Step 1 의 blocking 엔드포인트(
/api/chat/soulmate) 와 Step 3 의 streaming 엔드포인트(/api/chat/soulmate/stream)
- blocking:
time_total측정 — 응답 완료까지 걸린 전체 시간 - streaming: 첫 토큰 도착 시점 측정 —
curl -N의 첫 출력까지의 시간
- 측정 환경 명시 — 모델 프로바이더(Gemini Flash / Ollama 로컬 등) · 모델명 · 메시지 길이 · 네트워크 환경(WiFi / 유선) 를 한눈에 보이게 적기
- 모델 / 메시지 길이는 고정 — 한 모델 한 문장으로 통일해서 변수를 줄이세요. 예: Gemini Flash + "오늘 진짜 별로였어" (15 byte 내외) 한 문장만으로 5 회씩
- 비교 표 작성 —
blocking 평균/최대/최소vsstreaming 첫 토큰 / 전체 완료 - 체감 대기 시간 단축 비율 계산 —
(blocking 평균) / (streaming 첫 토큰 평균)의 배수
확인 방법
./run.sh up
# 1) blocking 엔드포인트 — time_total 측정 (5회)
for i in 1 2 3 4 5; do
curl -s -o /dev/null -w "blocking #$i total=%{time_total}s\n" \
"http://localhost:8080/api/chat/soulmate?userId=1&mood=우울&message=오늘%20진짜%20별로였어"
done
# 2) streaming 엔드포인트 — 첫 토큰 도착까지 (5회)
# 예시 1: time + head -c 1 로 첫 1 byte 도착까지의 시간
for i in 1 2 3 4 5; do
start=$(date +%s.%N)
curl -sN "http://localhost:8080/api/chat/soulmate/stream?userId=1&mood=우울&message=오늘%20진짜%20별로였어" \
| head -c 1 > /dev/null
end=$(date +%s.%N)
echo "streaming #$i first-token=$(echo "$end - $start" | bc)s"
done
# 3) (선택) streaming 의 전체 완료 시간도 같이 측정
for i in 1 2 3 4 5; do
curl -sN -o /dev/null -w "streaming-total #$i total=%{time_total}s\n" \
"http://localhost:8080/api/chat/soulmate/stream?userId=1&mood=우울&message=오늘%20진짜%20별로였어"
done
응답에서 다음 표를 손으로 옮겨 정리하세요.
| 지표 | blocking (/api/chat/soulmate) |
streaming 첫 토큰 (/stream) |
streaming 전체 완료 |
|---|---|---|---|
| 평균 | ? s | ? s | ? s |
| 최대 | ? s | ? s | ? s |
| 최소 | ? s | ? s | ? s |
표 아래에 "체감 대기 시간 단축 비율 = blocking 평균 / streaming 첫 토큰 평균 = ?? 배" 와 "같은 모델·같은 문장·같은 ChatMemory 인데 왜 이만큼 차이가 나는가?" 한두 줄을 적으세요.
🚫 제약 / 금지
- 유료 모델 사용 금지 — Gemini Flash 무료 티어 또는 Ollama 로컬 중 하나로 고정. 비용 청구를 측정으로 사겠다 는 시도는 안 해요.
- 모델을 섞지 말 것 — Gemini Flash 와 Ollama 로컬은 first-token-latency 가 극단적으로 달라요. 한 모델에 고정해서 측정해야 우리 환경의 한 결론 이 나옵니다.
- 문장 길이를 늘려가며 측정 금지 — 응답 길이가 전체 완료 시간 에 영향을 주니, 같은 한 문장으로 5 회 통일.
[구현 2] X-Conversation-Id 응답 헤더 보정 — 클라이언트가 새 UUID 를 알 수 있게
배경 시나리오
Step 5 와 Step 7 에서 우리는 미해결 트레이드오프 ① 을 짚어뒀어요.
"첫 호출에서 서버가 새 UUID 를 발급하지만, 클라이언트가 그 ID 를 알 채널이 없는 상태."
이 부분은 알면서 감수 해두기로 했지만, 실무에선 거의 항상 응답 헤더 로 보정해요. 클라이언트가 이후 호출에서 같은 conversationId 를 재사용하려면 서버가 그 ID 를 어디든 넘겨줘야 하거든요.
이번 과제에선 그 부분를 직접 보정합니다. 응답 헤더 X-Conversation-Id 한 줄로 — Spring MVC + Flux<T> + 응답 헤더 의 호환성을 손으로 검증하는 기회이기도 해요.
💡 왜 굳이 이 과제를 할까요?
- 트레이드오프를 감수 하다 해소 로 옮기는 손길 — Step 7 에서 알면서 감수 한다고 정리한 부분는, 실무에선 결국 언젠가는 풀게 돼요. 그 풀이의 모양 이 어떤지 한 번 손으로 그려보면, 다음에 비슷한 알면서 감수 부분이 나왔을 때 언제 풀어야 할지 의 감이 잡혀요.
Flux<String>+ 응답 헤더의 호환성 실험 — Spring MVC 1.1.x 에서ResponseEntity<Flux<String>>가 SSE 자동 변환을 깨뜨리는지 안 깨뜨리는지 는 문서만 읽어선 모르는 부분이에요. 직접 짜고 curl 로 응답 헤더가 들어가 오는지 확인하는 게 백엔드 엔지니어의 손 감각 부분이에요.
✅ 요구사항
streamChat(...)응답에X-Conversation-Id헤더 추가 — 클라이언트가 보낸 conversationId 든 서버가 새로 발급한 UUID 든 항상 헤더에 들어가야 함- 두 패턴 중 하나 시도 — 학생 자유
- 패턴 A:
ResponseEntity<Flux<String>>반환 —ResponseEntity.ok().header("X-Conversation-Id", convId).body(flux) - 패턴 B:
HttpServletResponse직접 주입 —response.setHeader("X-Conversation-Id", convId)호출 후Flux<String>반환
- 슬라이스 테스트 추가 — 헤더 검증 (
mvcResult.getResponse().getHeader("X-Conversation-Id")또는 WebMvcTest 의header().exists("X-Conversation-Id")) - 두 케이스 모두 검증 — 클라이언트가 conversationId 를 보낸 경우 그 값 그대로, 안 보낸 경우 새 UUID 가 들어가야 함
- curl 로 응답 헤더 확인 —
curl -N -i ...(-i가 응답 헤더까지 출력) 의 첫 줄에X-Conversation-Id: ...가 보여야 함
확인 방법
# 1) 새 UUID 발급 케이스 — conversationId 파라미터 없이 호출
curl -N -i "http://localhost:8080/api/chat/soulmate/stream?userId=1&mood=우울&message=오늘%20진짜%20별로였어"
# HTTP/1.1 200
# Content-Type: text/event-stream
# X-Conversation-Id: 8f4e2c1a-... ← 이 줄이 박혀 있어야 OK
# 2) 클라이언트 conversationId 사용 케이스 — 1) 의 응답 헤더에서 받은 UUID 를 다시 사용
CID="8f4e2c1a-..."
curl -N -i "http://localhost:8080/api/chat/soulmate/stream?userId=1&mood=우울&message=좀%20전에%20뭐라고%20했지?&conversationId=$CID"
# X-Conversation-Id: 8f4e2c1a-... ← 같은 UUID 가 박혀 있어야 OK
💡 힌트
- 패턴 A가 가장 Spring 다운 모양이에요.
ResponseEntity.ok().header(...).body(flux)한 줄로 정리됨. 단 Spring MVC 가Flux<String>본문을 SSE 로 자동 변환 하는 진행을 깨지 않는지 직접 확인 이 필요해요 — 깰 수도, 안 깰 수도 있어서 여기가 본 과제의 실험 포인트 예요. - 패턴 B 는 fallback 같은 모양.
HttpServletResponse response를 컨트롤러 메서드 파라미터로 받아서response.setHeader(...)호출. 패턴 A 가 동작 안 하면 자연스럽게 패턴 B 로 떨어집니다. - 슬라이스 테스트 시그니처는 다음 모양이에요 (의사 코드).
mockMvc.perform(get("/api/chat/soulmate/stream").param("userId", "1").param("mood", "우울").param("message", "안녕"))
.andExpect(status().isOk())
.andExpect(header().exists("X-Conversation-Id"))
.andExpect(header().string("X-Conversation-Id", matchesPattern(UUID_REGEX)));
🚫 제약 / 금지
Flux가 아닌String으로 반환 회귀 금지 — 헤더만 박으려고 streaming 을 포기 하는 시도는 본 과제의 본질을 어긋나요. 어떤 패턴이든 응답 본문은 SSE 스트림 그대로 유지.MessageChatMemoryAdvisor동작 깨뜨리지 말 것 — 헤더 추가가 advisor 의 ChatMemory 누적을 깨면 안 됨. Step 7 의 사후 검증 (GET /api/chat/soulmate/sessions/{conversationId}) 으로user+assistant가 정상 누적되는지 한 번 더 확인.
[구현 3] 스트리밍 도중 disconnect 일관성 보정 — 부분 응답 저장 정책 직접 구현 ⚠️
배경 시나리오
Step 5 와 Step 7 에서 짚은 미해결 트레이드오프 ② — 사용자가 페이지를 닫거나 네트워크가 끊기면 USER 메시지만 남고 ASSISTANT 메시지가 누락 되는 비대칭 누적.
ai-friends 도메인에선 반쪽 응답이 더 답답한 세계라 현재는 감수 한다고 결정했지만, 의료 봇 / 상담 봇 / 컴플라이언스가 중요한 도메인이라면 그대로 둘 수 없는 부분예요.
이번 과제에선 부분 응답을 ChatMemory 에 저장하는 정책 을 직접 구현해봅니다. 그때까지 흘러간 토큰 을 모아서 ASSISTANT 메시지로 정리하는 패턴 — Flux 의 lifecycle 훅 (doOnCancel, doOnError, doOnComplete) 을 익히는 부분이기도 해요.
💡 왜 굳이 이 과제를 할까요?
- lifecycle 훅의 손 감각 —
Flux가 어떻게 끝났는지 (정상 완료 / 취소 / 에러) 에 따라 다른 코드 가 돌아야 하는 부분은 실무에서 자주 등장해요. 이번 과제가 그 첫 번째 손길. - 도메인별 정책 결정의 질감 — 너무 짧은 부분 응답은 저장 안 함 / 완성된 응답만 저장 / 부분이라도 저장 세 가지 중 우리 도메인에 맞는 결정을 내려보는 경험. 정답은 도메인마다 달라요.
✅ 요구사항
Flux<String>에 lifecycle 훅 추가 —doOnCancel(...)(클라이언트 disconnect) 와doOnError(...)(예외) 시점 감지- 그 시점까지 흘러간 토큰을 수동으로 합쳐서
ChatMemory.add(...)호출 —MessageChatMemoryAdvisor가 자동 처리해주지 않는 부분이므로 직접 호출 - 저장 정책 — 너무 짧은 부분 응답은 저장 안 함 — 학생 자유 (예: 토큰 3 개 이하 / 합쳐진 텍스트 10 자 이하 등 — 왜 그 임계값을 골랐는지 한 줄 코멘트 필수)
- 통합 테스트 —
Flux.error(...)/Flux.take(2)/Flux.timeout(...)같은 인위적 disconnect 로 부분 저장이 동작하는지 검증 - 세션 조회로 사후 검증 —
GET /api/chat/soulmate/sessions/{conversationId}호출 시assistant메시지가 부분 텍스트 로 잘 들어갔는지 확인
확인 방법
# 1) 인위적 disconnect 시뮬레이션 — curl 의 --max-time 으로 1 초 만에 끊기
CID=$(uuidgen)
curl -N --max-time 1 "http://localhost:8080/api/chat/soulmate/stream?userId=1&mood=우울&message=오늘%20진짜%20별로였어&conversationId=$CID"
# 출력 도중에 강제 종료됨
# 2) 세션 조회로 부분 저장 확인
curl -s "http://localhost:8080/api/chat/soulmate/sessions/$CID" | jq
# {
# "data": [
# { "role": "user", "content": "오늘 진짜 별로였어" },
# { "role": "assistant", "content": "에이, 무슨 일" } ← 부분 텍스트가 박혀 있어야 OK
# ]
# }
💡 힌트
- 토큰 누적은
Flux.scan(...)또는 외부 누적 변수 (StringBuilder/List<String>) 두 가지로 가능해요. 외부 변수가 익으면 더 단순합니다 —Flux의 부수효과 로 합치는 모양.
// 의사 코드 — 외부 누적 변수 패턴
StringBuilder accumulator = new StringBuilder();
return chatClient.prompt()...stream().content()
.doOnNext(token -> accumulator.append(token))
.doOnCancel(() -> savePartialIfLongEnough(conversationId, accumulator.toString()))
.doOnError(e -> savePartialIfLongEnough(conversationId, accumulator.toString()));
ChatMemory.add(...)직접 호출 시 다음 모양이에요 (의사 코드).
// 의사 코드
chatMemory.add(conversationId, new AssistantMessage(accumulator.toString()));
MessageChatMemoryAdvisor의 advisor.param 컨텍스트에 직접 접근 하는 방법 (AdvisorContext) 도 있지만 본 강의 범위 외 라 더 어려워요. 위 직접 호출 패턴이 단순합니다.- 통합 테스트에선 진짜 LLM 호출 대신
ChatModel을 모킹하거나Flux.just(...)로 가짜 스트림을 만든 뒤.take(2)로 인위적 disconnect 시뮬레이션. 실제 Gemini / Ollama 호출은 느리고 불안정 해서 통합 테스트엔 적합하지 않아요.
🚫 제약 / 금지
InMemoryChatMemoryRepository로 회귀 금지 — 본 강의 5 번 규약.JdbcChatMemoryRepository그대로 사용 (테스트 코드 한정 허용).- 부분 저장 정책 없이 모든 disconnect 를 무조건 저장 금지 — 의미 없는 짧은 부분 까지 저장하면 ChatMemory 가 쓰레기 데이터로 오염 돼요. 임계값 한 줄 은 반드시 들어가야 합니다.
MessageChatMemoryAdvisor코드 직접 수정 금지 — Spring AI 의 내장 advisor 를 건드리면 다음 Day 의 다른 예제가 깨질 수 있어요. 외부에서 추가 lifecycle 훅을 얹는 방향으로만 푸세요.
🤔 생각해볼 주제
이 섹션의 목적 — 정답이 정해져 있지 않은 질문들이에요. 오늘 배운 .stream().content() · SSE · ChatClientMessageAggregator 의 결정들을 한 발 떨어져 바라보고, "왜 이렇게 결정했지?" 와 "다른 길은 없었나?" 를 사고하는 부분이에요. 면접에서도 자주 등장하는 토픽들이라, 가능하면 문장으로 적어보세요. 머릿속 생각과 글로 적은 생각은 다릅니다. ️
주제 1 — SSE 만으로 충분한가? 양방향 채널 이 필수가 되는 전환점은 어디인가
Step 6 에서 SSE 와 WebSocket 의 5 축 비교 표를 정리하면서 "우리 ai-friends 처럼 LLM 단방향 응답은 SSE 가 자연스럽다" 고 결론을 내렸어요. 그런데 실제 도메인 에서는 어느 한쪽이 정답 이 되는 일이 드뭅니다. 요구사항이 한 줄 추가되는 순간 프로토콜 결정이 뒤집어질 수도 있거든요. 예를 들어 대화 중 사용자가 실시간으로 캐릭터 표정을 바꿀 수 있다 같은 새 시나리오가 들어오면, 그 부분은 더 이상 서버 → 클라이언트 단방향 이 아니에요.
ai-friends 의 다음 단계 기능 으로 다음 셋을 더한다고 가정해보세요.
- ① 캐릭터 표정 실시간 변화 — 사용자가 채팅 중 "기뻐해 줘" 같은 입력을 던지면 캐릭터의 표정 / 모션이 즉시 반응 (LLM 응답과 별개의 실시간 채널)
- ② 멀티 사용자 단체 채팅 — 한 캐릭터에 여러 사용자가 동시에 접속해서 같이 떠드는 시나리오 (다른 사용자의 입력이 내 화면에도 흘러와야 함)
- ③ 음성 통화 — Day 9 (voice) 이후, 캐릭터와 실시간 음성 대화 (낮은 지연 + 양방향 오디오 스트림)
🎯 핵심 질문 — 위 셋 중 SSE 만으로 충분한 것은 어디까지이고, 어디서부터 WebSocket 또는 WebRTC 같은 양방향 채널이 필수 가 되는가? 그 전환점을 어떤 기준 (지연 · 양방향성 · 동시 채널 수 · 미디어 종류) 으로 판단할 것인가?
생각해볼 자료:
- Step 6 의 5 축 비교 표 (양방향성 · 미디어 · 지연 · 운영 복잡도 · 인프라)
- Day 9 (voice) 에서 펼칠 양방향 음성 스트리밍 의 프로토콜 선택 — 여기가 왜 SSE 로는 안 되는지 의 가장 명확한 사례
- Spring Boot 과정에서 만든 실시간 채팅 도메인 의 양방향 요구 — 그 도메인은 왜 SSE 가 아니라 WebSocket 이 정답이었는지 떠올려보기
주제 2 — ApiResponse<T> 정당한 예외 의 근거 — 표준의 일관성 vs 미디어타입의 본질
Step 4 에서 우리는 ApiResponse 표준 패턴의 정당한 예외 라는 표현을 정리했어요. SSE 응답이 그 예외에 해당한다고 결론을 냈죠 — "미디어타입의 본질이 JSON 과 비호환인 경우만 예외" 라는 원칙으로요. 그런데 이 원칙은 실무에서 논쟁의 여지 가 있어요. 어떤 팀은 모든 응답을 ApiResponse 로 강제 해서 일관성을 우선하고 (예: SSE 페이로드를 JSON 으로 직렬화한 뒤 그 안에 ApiResponse 를 넣는 식), 어떤 팀은 미디어타입 본질을 따라 분기를 허용해요 (우리처럼).
면접에서 "왜 그 엔드포인트만 ApiResponse 를 안 씌웠나요?" 라는 질문이 들어오면 30 초 안에 정리할 수 있어야 해요. 그리고 반대 입장 도 합리화 할 줄 알아야 진짜로 그 결정의 트레이드오프를 이해한 거예요.
🎯 핵심 질문 — 본 강의 코드베이스에서 SSE 가 ApiResponse 의 정당한 예외 인 근거 3 가지를 면접관에게 30 초 안에 설명한다면 어떻게 정리할까? 그리고 반대 의견 — 모든 응답을 ApiResponse 로 강제하는 팀의 입장 — 도 합리화한다면 어떤 근거가 가능한가? 두 입장의 트레이드오프는 무엇인가?
생각해볼 자료:
- Step 4 복습 — "미디어타입의 본질이 JSON 과 비호환" + "프레임당 직렬화/역직렬화 비용" + "표준 SSE 클라이언트 호환성" 세 축
- Day 4 의
GlobalExceptionHandler에러 직렬화 패턴 — 정상은 raw, 에러는 어떻게 직렬화되는지의 비대칭 이 클라이언트에서 어떻게 보이는지 text/plain디버그 엔드포인트 (Day 4 의/api/structured/quote/format-debug) 와의 비교 — 이 부분도 예외 인데 SSE 와 근거의 결 이 같은가 다른가
주제 3 — ChatClientMessageAggregator 프레임워크 마법 을 신뢰하는 비용
Step 5 에서 우리는 "우리 코드는 Flux.doOnComplete() 같은 보정을 짤 필요가 없다" 는 결론에 도달했어요. Spring AI 의 MessageChatMemoryAdvisor 가 내부적으로 ChatClientMessageAggregator 를 써서 완성된 ASSISTANT 메시지를 자동으로 잡아 ChatMemory 에 저장 해주거든요. 한 줄도 안 짜고 동작이 보장되니 추상화의 단맛 이 진하죠.
그런데 추상화를 신뢰한다 는 결정엔 항상 비용이 따라요. 라이브러리 버전 업그레이드 시 동작 변경 (Spring AI 1.1 → 1.2 → 2.0 사이의 시그니처 변화), 디버깅 난이도 상승 ("왜 ChatMemory 에 저장이 안 되지?" 가 우리 코드 잘못인지 라이브러리 버그인지 모호함), 도메인 특화 요구가 추가됐을 때의 우회 비용 (과제 3 의 부분 응답 저장 시나리오가 정확히 그 부분 — 라이브러리가 자동 처리해주지 않는 부분은 우리가 직접 짜야 함) 같은 비용들이에요.
🎯 핵심 질문 —
ChatClientMessageAggregator같은 프레임워크 마법 을 신뢰할 때의 비용을 3 가지 이상 들어보고, 그 비용을 방어 하기 위해 우리 코드베이스에 어떤 최소한의 검증/관찰성 (observability) 을 정리해둘 수 있을지 설계해보자. 만약 라이브러리 동작이 의도와 다르게 바뀐다면 우리는 어떻게 조기에 발견할 수 있는가?
생각해볼 자료:
- Spring AI 의
MessageChatMemoryAdvisorjar 소스 — IntelliJ 에서Ctrl/Cmd + 클릭으로 직접 열어보면ChatClientMessageAggregator가 어떻게 호출되는지 눈으로 확인 할 수 있어요. 추상화가 어떻게 동작하는지 본 사람과 안 본 사람의 차이는 면접에서 갈립니다. - Day 5 의
JdbcChatMemoryRepository통합 테스트 — USER + ASSISTANT 누적 을 실제 DB 에 들어간 row 로 검증하는 그 패턴이, 사실 라이브러리 동작이 의도와 일치하는지 의 가장 단순한 가드 예요. 본 강의에서 이미 그 가드를 깔아둔 셈. - Day 20 (observability) 으로 흐를 관찰성 키워드 — 메트릭 / 로그 / 트레이스 셋 중 어느에 "ChatMemory 에 ASSISTANT 가 정상 누적되었는가" 시그널을 박을지
✅ 예시 답안정답 보기
Day 6 의 답안은 세 줄 정신 으로 갑니다 — (1) 측정으로 추측을 대체 (과제 1), (2) 알면서 감수한 자리를 손으로 풀어보기 (과제 2 · 3), (3) 프로토콜 · 미디어타입 · 프레임워크 마법의 트레이드오프를 면접 30 초 안에 정리 (생각해볼 주제 1~3). Day 5 답안과 같은 호흡이에요 — 예시답안은 유일 정답이 아니라 모범 사례 한 갈래 입니다. 본인의 측정값·결정·근거가 다르더라도 왜 그렇게 결정했는지 가 한 줄로 들어가 있다면 그게 더 좋은 답이에요.
특히 과제 2·3 의 코드는 현재 코드베이스에 박혀 있지 않은 예시 구현 입니다 (Day 6 본문은 SoulmateChatService.chatStream / SoulmateChatController.streamChat 까지 — X-Conversation-Id 헤더와 부분 저장은 이번 과제에서 학생이 직접 구현하는 자리).
검증된 코드는 Day 6 Step 5 / Step 7 의 코드뿐이라는 점을 먼저 기억하고 시작합니다.
🔥 도전 과제 예시답안
과제 1 예시답안: 체감 대기 시간 측정 — blocking vs streaming 의 **첫 토큰까지 시간**
이 과제의 본질은 측정 표를 채우는 것 이 아니라 "체감 대기 시간 = 첫 토큰까지의 시간" 이라는 지표 분리 감각 을 손에 쥐는 거예요. 전체 완료 시간 만 보면 blocking 과 streaming 이 비슷할 수 있어요. 그런데 사용자가 답답하다 고 느끼는 자리는 첫 글자가 떨어질 때까지 의 시간이거든요. 같은 모델, 같은 ChatMemory, 같은 한 문장 — 받는 모양만 바꿨을 뿐인데 체감 이 4 배 빨라지는 이유가 여기 있어요.
채점 포인트
| # | 항목 | 배점 | 핵심 |
|---|---|---|---|
| 1 | 두 엔드포인트 5 회 이상 측정 | 상 | blocking · streaming 모두 최소 5 회 측정해야 평균이 의미 있음 |
| 2 | 측정 환경 명시 | 상 | 모델 프로바이더 / 모델명 / 메시지 길이 / 네트워크 — 한 줄로 적기 |
| 3 | 첫 토큰까지 시간 분리 측정 | 상 | streaming 의 전체 완료 가 아닌 첫 토큰 을 별도 컬럼으로 |
| 4 | 비교 표가 측정값 으로 채워짐 | 상 | ? 가 남아 있으면 안 됨 — 본인 환경의 숫자가 들어가야 |
| 5 | 체감 대기 시간 단축 비율 계산 | 상 | blocking 평균 / streaming 첫 토큰 평균 = 배수 |
| 6 | 변수 통제 (한 모델·한 문장) | 중 | 모델 섞기 / 문장 길이 늘리기 — 실험의 변수가 두 개 가 되면 결론이 흐려짐 |
| 7 | 분석 한두 줄 | 중 | 왜 이만큼 차이가 나는가 — 추측이 아니라 측정 근거 로 한 줄 |
5 번이 자주 빠지는 포인트예요. 측정만 하고 "streaming 이 빠르네요" 같은 모호한 결론이면 점수 절반 — 측정은 의사결정의 근거 가 되어야 의미가 있어요.
측정 셸 스크립트 (학생이 직접 돌릴 수 있는 형태)
본인 환경에서 그대로 복사해서 돌리면 돼요. 결과 파싱은 jq 와 awk 두 갈래로 갈 수 있는데, 셸 호환성을 위해 bash 의 산술 연산 으로 통일했습니다.
#!/usr/bin/env bash
# day06-assignment1-measure.sh — blocking vs streaming first-token latency
set -e
ENDPOINT="http://localhost:8080/api/chat/soulmate"
PARAMS="userId=1&mood=우울&message=오늘%20진짜%20별로였어"
N=5 # 시도 횟수
echo "── 환경 ──────────────────────────────────────────────"
echo "Provider : Gemini Flash (free tier)"
echo "Model : gemini-2.5-flash-lite"
echo "Network : WiFi (home)"
echo "Message : '오늘 진짜 별로였어' (≈ 15 byte)"
echo "Trials : ${N}"
echo "─────────────────────────────────────────────────────"
# 1) blocking — total 응답 시간
echo ""
echo "[1] blocking (/api/chat/soulmate) — total time"
sum_b=0
for i in $(seq 1 $N); do
t=$(curl -s -o /dev/null -w "%{time_total}" "${ENDPOINT}?${PARAMS}")
echo " blocking #${i} total=${t}s"
sum_b=$(echo "$sum_b + $t" | bc -l)
done
avg_b=$(echo "scale=3; $sum_b / $N" | bc -l)
echo " → avg = ${avg_b}s"
# 2) streaming — first-token latency
echo ""
echo "[2] streaming (/api/chat/soulmate/stream) — first token"
sum_s=0
for i in $(seq 1 $N); do
start=$(date +%s.%N)
# head -c 1 = 첫 1 byte 가 도착하면 즉시 종료
curl -sN "${ENDPOINT}/stream?${PARAMS}" | head -c 1 > /dev/null
end=$(date +%s.%N)
t=$(echo "$end - $start" | bc -l)
printf " streaming #%d first-token=%.3fs\n" $i $t
sum_s=$(echo "$sum_s + $t" | bc -l)
done
avg_s=$(echo "scale=3; $sum_s / $N" | bc -l)
echo " → avg = ${avg_s}s"
# 3) 단축 비율
echo ""
echo "── 결과 ──────────────────────────────────────────────"
ratio=$(echo "scale=2; $avg_b / $avg_s" | bc -l)
echo "blocking 평균 : ${avg_b}s"
echo "streaming 첫 토큰 : ${avg_s}s"
echo "체감 대기 단축 비율 : ${ratio} 배"
echo "─────────────────────────────────────────────────────"
결과 표 예시 (튜터 환경에서의 측정값 — 본인 환경에서 다시 측정)
⚠️ 아래 숫자는 예시. 본인의 모델·네트워크·시간대에 따라 다를 수 있어요. 표를 그대로 옮기는 게 아니라 본인 환경에서 재측정 한 값으로 채우는 게 과제의 본질입니다.
| 지표 | blocking (/api/chat/soulmate) |
streaming 첫 토큰 (/stream) |
streaming 전체 완료 |
|---|---|---|---|
| 평균 | 2.31 s | 0.58 s | 2.42 s |
| 최대 | 2.74 s | 0.71 s | 2.81 s |
| 최소 | 1.92 s | 0.49 s | 2.05 s |
체감 대기 시간 단축 비율 = 2.31 / 0.58 ≈ 약 4.0 배
왜 이만큼 차이가 나는가? 같은 모델·같은 문장·같은 ChatMemory 에서 전체 완료 는 거의 비슷한데 (2.31 vs 2.42 — streaming 이 살짝 더 길어요. SSE 프레임 간 약간의 오버헤드), 첫 토큰 은 0.58 초로 떨어집니다. 이유는 단순해요. blocking 은 LLM 이 전체 응답을 다 만들 때까지 기다린 뒤 한 번에 응답을 보내요. streaming 은 첫 토큰이 만들어지는 즉시 클라이언트로 흘리거든요. 모델 입장에서 첫 토큰을 만드는 시간 (TTFT — Time To First Token) 은 대부분의 LLM 에서 전체 응답 생성 시간 의 1/4~1/3 수준이에요.
🎯 면접관을 홀리는 핵심 멘트
"체감 대기 시간은 완료까지의 시간 이 아니라 첫 토큰까지의 시간 (TTFT) 으로 측정해야 합니다. blocking 과 streaming 의 전체 완료 시간 은 거의 같지만, 첫 토큰 은 streaming 이 약 4 배 빠릅니다 — 우리 환경에서 측정한 0.58 초 대 2.31 초 가 그 근거입니다. 사용자가 답답하다고 느끼는 자리는 전체 완료가 아니라 첫 글자가 떨어질 때까지 입니다. PM 보고서의 체감 4 배 단축 은 이 측정 표에서 떨어진 숫자이지 추측이 아닙니다."
💼 실무 개선 포인트
(1) p50/p95/p99 분포 + 시간대별 회귀 측정
5 회 측정의 평균 만 보면 outlier 가 보이지 않아요. 운영에선 100~1,000 회 측정으로 p50 / p95 / p99 분포를 그려요. 특히 p95 가 SLA (예: p95 < 1 초) 를 넘기면 간헐적 느림 의 신호이고, 그 자리는 모델 프로바이더 측 부하 변동 인 경우가 많아요. 시간대별로 (오전 / 오후 / 새벽) 분리 측정하면 프로바이더의 시간대별 지연 도 회귀 감지 가능합니다.
(2) 메시지 길이별 / 모델별 회귀 측정
본 과제는 한 모델 한 문장 으로 통제했지만, 운영에선 입력 길이 (10 / 100 / 500 토큰) × 모델 (Flash / Pro / Ollama 로컬) 의 매트릭스로 회귀 측정해서 어떤 조합이 SLA 위반 가능성이 높은가 를 미리 잡아둡니다. 신규 모델 도입 시 우리 워크로드에 적합한지 의 의사결정도 이 매트릭스가 답합니다 — 추측이 아니라 수치 로요.
과제 2 예시답안: `X-Conversation-Id` 응답 헤더 보정
핵심 접근은 두 가지 — 패턴 A (ResponseEntity<Flux<String>>) 와 패턴 B (HttpServletResponse 직접 주입) 의 실험 이에요. 본 강의 1.1.x 환경에서는 둘 다 동작 하는데 — Spring MVC 가 ResponseEntity 의 body 가 Flux<String> 이고 Content-Type 이 text/event-stream 이면 SSE 자동 변환을 그대로 유지해줘요. 그래도 패턴 B 가 더 안전한 fallback 인 이유 한 줄을 답안에 박아두면 채점 가중이 올라갑니다 — 미래 라이브러리 버전에서 ResponseEntity<Flux<T>> 의 SSE 자동 변환 동작이 달라질 가능성이 제로가 아니거든요.
채점 포인트
| # | 항목 | 배점 | 핵심 |
|---|---|---|---|
| 1 | 두 케이스 모두 헤더 박힘 검증 | 상 | 클라가 conversationId 보낸 경우 + 안 보낸 경우 두 케이스 |
| 2 | curl -i 의 헤더 캡처 첨부 |
상 | X-Conversation-Id: <UUID> 가 응답 헤더에 박혀 있는 증거 |
| 3 | 슬라이스 테스트 통과 | 상 | header().exists("X-Conversation-Id") + UUID 패턴 검증 |
| 4 | 두 패턴 중 하나 + 선택 근거 | 상 | A·B 중 어느 쪽을 골랐는지와 왜 한 줄 |
| 5 | SSE 본문은 그대로 유지 | 상 | Flux<String> → String 회귀 금지 |
| 6 | ChatMemory 누적 깨지지 않음 | 중 | /api/chat/soulmate/sessions/{conversationId} 호출 시 user + assistant 정상 누적 |
| 7 | RFC 6648 (X- prefix 비권장) 인지 | 하 | 헤더 명에 X- 를 붙이는 건 비권장 임을 알면서 감수 |
4 번이 자주 빠져요. 어느 패턴을 골랐는지 만 적고 왜 가 빠지면 카피 코드 처럼 읽혀요.
예시 구현 (학생이 직접 짜야 하는 자리 — 코드베이스에 박혀 있지 않음)
패턴 A — ResponseEntity<Flux<String>> (Spring 다운 모양, 권장)
// 예시 구현 — 코드베이스에 없는 자리, 학생이 직접 작성
// src/main/java/kr/spartaclub/aifriends/chat/controller/SoulmateChatController.java
@GetMapping(
value = "/api/chat/soulmate/stream",
produces = MediaType.TEXT_EVENT_STREAM_VALUE
)
public ResponseEntity<Flux<String>> streamChat(
@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;
Flux<String> tokenStream = service.chatStream(convId, anonymizedName, mood, message);
// X-Conversation-Id: 클라가 다음 호출에서 같은 자루로 재진입할 수 있도록 헤더로 알림
return ResponseEntity.ok()
.header("X-Conversation-Id", convId)
.contentType(MediaType.TEXT_EVENT_STREAM)
.body(tokenStream);
}
패턴 B — HttpServletResponse 직접 주입 (fallback, 패턴 A 가 동작 안 할 때)
// 예시 구현 — 패턴 A 가 깨지는 미래 버전 대비 fallback
// src/main/java/kr/spartaclub/aifriends/chat/controller/SoulmateChatController.java
@GetMapping(
value = "/api/chat/soulmate/stream",
produces = MediaType.TEXT_EVENT_STREAM_VALUE
)
public Flux<String> streamChat(
@RequestParam Long userId,
@RequestParam String mood,
@RequestParam String message,
@RequestParam(required = false) String conversationId,
HttpServletResponse response
) {
String anonymizedName = userAnonymizer.anonymize(userId);
String convId = (conversationId == null || conversationId.isBlank())
? UUID.randomUUID().toString()
: conversationId;
// 컨트롤러가 Flux 를 반환하기 *전에* 헤더 세팅
response.setHeader("X-Conversation-Id", convId);
return service.chatStream(convId, anonymizedName, mood, message);
}
슬라이스 테스트 (예시 코드 — 학생이 직접 작성)
// 예시 구현 — 코드베이스에 없는 자리
// src/test/java/kr/spartaclub/aifriends/chat/controller/SoulmateChatControllerStreamHeaderTest.java
@WebMvcTest(SoulmateChatController.class)
class SoulmateChatControllerStreamHeaderTest {
private static final String UUID_REGEX =
"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$";
@Autowired MockMvc mockMvc;
@MockBean SoulmateChatService service;
@MockBean UserAnonymizer userAnonymizer;
@Test
@DisplayName("conversationId 미전송 시 — 새 UUID 가 X-Conversation-Id 헤더로 발급된다")
void newUuidWhenAbsent() throws Exception {
given(userAnonymizer.anonymize(anyLong())).willReturn("익명_사자");
given(service.chatStream(anyString(), anyString(), anyString(), anyString()))
.willReturn(Flux.just("hello"));
mockMvc.perform(get("/api/chat/soulmate/stream")
.param("userId", "1")
.param("mood", "우울")
.param("message", "오늘 별로야"))
.andExpect(status().isOk())
.andExpect(header().exists("X-Conversation-Id"))
.andExpect(header().string("X-Conversation-Id",
matchesPattern(UUID_REGEX)));
}
@Test
@DisplayName("conversationId 전송 시 — 그 값이 그대로 X-Conversation-Id 헤더에 박힌다")
void echoWhenProvided() throws Exception {
String given = "8f4e2c1a-1234-5678-9abc-def012345678";
given(userAnonymizer.anonymize(anyLong())).willReturn("익명_사자");
given(service.chatStream(eq(given), anyString(), anyString(), anyString()))
.willReturn(Flux.just("hello"));
mockMvc.perform(get("/api/chat/soulmate/stream")
.param("userId", "1")
.param("mood", "우울")
.param("message", "안녕")
.param("conversationId", given))
.andExpect(status().isOk())
.andExpect(header().string("X-Conversation-Id", given));
}
}
curl 검증
# 1) 새 UUID 발급 케이스
curl -N -i "http://localhost:8080/api/chat/soulmate/stream?userId=1&mood=우울&message=오늘%20진짜%20별로였어"
# HTTP/1.1 200
# Content-Type: text/event-stream
# X-Conversation-Id: 8f4e2c1a-... ← 박혀 있어야 OK
#
# data: 에이,
# data: 무슨 일
# ...
# 2) 같은 UUID 재사용 케이스
CID="8f4e2c1a-..."
curl -N -i "http://localhost:8080/api/chat/soulmate/stream?userId=1&mood=우울&message=좀%20전에%20뭐라고%20했지?&conversationId=$CID"
# X-Conversation-Id: 8f4e2c1a-... ← 같은 UUID 가 박혀 있어야 OK
🎯 면접관을 홀리는 핵심 멘트
"새 식별자 발급 시 클라이언트가 알아챌 수 있게 응답 헤더로 던져주는 패턴은 SSE 만이 아니라 모든 서버 발급 식별자 에 일반화됩니다. POST 후
Location헤더, 인증 후Authorization갱신 토큰도 같은 가족입니다. 헤더로 보내면 body 의 미디어타입 과 식별자 채널 이 분리되어 — SSE 처럼 body 가 JSON 이 아니어도 식별자 전달이 깨지지 않습니다. 헤더 명은 RFC 6648 권고를 따라X-접두 없이Conversation-Id로 가는 게 표준에 더 가깝지만, 본 강의에선 학습 가독성 우선으로X-Conversation-Id를 알면서 채택했습니다."
💼 실무 개선 포인트
(1) 헤더 명을 프로젝트 표준 으로 박아두기 — RFC 6648 권고 vs 학습 가독성
X- 접두는 RFC 6648 (2012) 에서 비권장 으로 지정됐어요. 새 헤더는 그냥 Conversation-Id 같은 단순 명칭이 표준에 더 맞습니다. 다만 학습용 강의에선 X- 접두가 커스텀 헤더임을 한눈에 보여주는 가독성 효과가 있어 본 강의는 알면서 채택.
운영에선 팀 표준에 따라 결정 — Conversation-Id 로 가는 팀이 점점 늘고 있어요.
(2) 클라이언트 SDK 에 헤더 추출 로직 표준화
매 호출마다 클라가 헤더에서 conversationId 를 꺼내 다음 호출에 쿼리 파라미터로 다시 넣는 흐름은 매번 짜기 번거로워요. 클라이언트 SDK (예: TypeScript SDK) 의 fetch 래퍼에 X-Conversation-Id 응답 헤더를 자동 보관 + 다음 요청에 자동 부착 하는 미들웨어를 한 번 박아두면 여러 엔드포인트에서 같은 패턴이 재사용돼요.
JWT 토큰 자동 갱신 미들웨어와 같은 가족.
과제 3 예시답안: 스트리밍 도중 disconnect 일관성 보정
핵심은 두 가지예요.
(1) Flux.doOnCancel(...) + doOnError(...) 두 lifecycle 훅에 외부 누적 변수 를 합치는 패턴, (2) 너무 짧은 부분 응답은 저장 안 함 의 임계값 결정 + 그 임계값을 한 줄 코멘트 로 정당화. ai-friends 도메인은 반쪽 응답이 더 답답한 세계라 짧은 부분은 버리고 긴 부분만 마커와 함께 저장 의 절충이 답이에요. 의료/상담 봇이라면 모든 부분을 저장 하되 비대칭 마커 를 명확히 박는 정책이 답이고요.
채점 포인트
| # | 항목 | 배점 | 핵심 |
|---|---|---|---|
| 1 | doOnCancel + doOnError 둘 다 처리 |
상 | 하나만 처리하면 예외 vs 취소 중 한쪽이 빈다 |
| 2 | 외부 누적 변수 + 부수효과로 합치기 | 상 | StringBuilder / List<String> — Flux.scan 도 가능하지만 가독성 ↓ |
| 3 | 임계값 정책 + 왜 그 값인가 코멘트 | 상 | "토큰 3 개 이하면 저장 안 함" 같은 정책의 근거 한 줄 |
| 4 | ChatMemory.add(...) 직접 호출 |
상 | advisor 가 자동 처리 안 해주는 자리 |
| 5 | 통합 테스트 — 인위적 disconnect | 상 | Flux.take(2) 또는 Flux.error(...) 로 시뮬레이션 |
| 6 | 세션 조회 사후 검증 | 상 | /api/chat/soulmate/sessions/{conversationId} 응답에 부분 텍스트 박힘 |
| 7 | 부분 응답 마커 박기 (선택) | 중 | [중단됨] suffix — 다음 호출의 LLM 이 비대칭임을 인지 |
7 번은 선택이지만 실무 감각 의 차이가 갈리는 자리예요. 부분 응답을 그대로 ChatMemory 에 박으면 다음 호출에서 LLM 이 비대칭 컨텍스트 임을 모르고 완성된 응답인 양 이어가버려요 — 마커 한 줄이 그 사고를 막아줍니다.
예시 구현 (학생이 직접 짜야 하는 자리)
// 예시 구현 — 코드베이스에 없는 자리, 학생이 직접 작성
// src/main/java/kr/spartaclub/aifriends/chat/service/SoulmateChatService.java
//
// 핵심 정책:
// - 임계값: 합쳐진 텍스트가 10 자 이하면 *의미 없는 부분 응답* 으로 보고 저장 안 함
// (ai-friends 의 캐릭터 대사는 보통 30 자 이상 — 10 자 미만은 인사말 시작 정도라
// ChatMemory 가 *쓰레기 데이터로 오염* 되는 비용이 더 큼)
// - 마커: 부분 저장 시 끝에 " [중단됨]" suffix — 다음 호출에서 LLM 이 비대칭임을 인지
private static final int MIN_PARTIAL_LENGTH = 10;
private static final String PARTIAL_MARKER = " [중단됨]";
public Flux<String> chatStreamWithPartialSave(
String conversationId,
String anonymizedUserName,
String mood,
String userMessage
) {
StringBuilder accumulator = new StringBuilder();
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))
.stream()
.content()
// 매 토큰마다 외부 누적 변수에 합치기 (부수효과)
.doOnNext(token -> accumulator.append(token))
// 클라이언트 disconnect — Flux 가 *취소* 됐을 때
.doOnCancel(() -> savePartialIfLongEnough(conversationId, accumulator.toString()))
// 예외 발생 — Flux 가 *에러* 로 끝났을 때
.doOnError(e -> savePartialIfLongEnough(conversationId, accumulator.toString()));
// doOnComplete 는 *불필요* — 정상 완료 시 ChatClientMessageAggregator 가 자동 저장
}
private void savePartialIfLongEnough(String conversationId, String partial) {
// 임계값 정책: 10 자 미만이면 의미 없는 부분 — ChatMemory 오염 방지
if (partial.length() < MIN_PARTIAL_LENGTH) {
log.info("[partial-save] skipped — length={} < {}",
partial.length(), MIN_PARTIAL_LENGTH);
return;
}
String marked = partial + PARTIAL_MARKER;
chatMemory.add(conversationId, new AssistantMessage(marked));
log.info("[partial-save] saved — length={}, conversationId={}",
marked.length(), conversationId);
}
통합 테스트 (예시 코드 — Flux.take(2) 로 인위적 disconnect)
// 예시 구현 — 학생이 직접 작성
// src/test/java/kr/spartaclub/aifriends/chat/service/SoulmateChatServicePartialSaveTest.java
@SpringBootTest
@Testcontainers
class SoulmateChatServicePartialSaveTest {
@Autowired SoulmateChatService service;
@Autowired ChatMemory chatMemory;
@MockBean ChatModel chatModel; // 실제 LLM 호출 대신 Flux.just(...) 가짜 스트림
@Test
@DisplayName("disconnect — 부분 응답이 10 자 이상이면 [중단됨] 마커와 함께 저장된다")
void partialSaveAboveThreshold() {
String convId = UUID.randomUUID().toString();
// 가짜 스트림: 모델이 5 토큰 흘리는 시뮬레이션
given(chatModel.stream(any(Prompt.class)))
.willReturn(Flux.just(
chunk("에이,"), chunk(" 무슨 일"), chunk(" 있어?"),
chunk(" 오늘"), chunk(" 힘들었구나")
));
// .take(2) — 두 청크만 받고 인위적 종료 → doOnCancel 트리거
service.chatStreamWithPartialSave(convId, "익명_사자", "우울", "오늘 별로야")
.take(2)
.blockLast();
List<Message> messages = chatMemory.get(convId);
Optional<Message> assistant = messages.stream()
.filter(m -> m.getMessageType() == MessageType.ASSISTANT)
.findFirst();
assertThat(assistant).isPresent();
assertThat(assistant.get().getText())
.startsWith("에이, 무슨 일")
.endsWith("[중단됨]");
}
@Test
@DisplayName("disconnect — 부분 응답이 10 자 미만이면 저장하지 않는다")
void noSaveBelowThreshold() {
String convId = UUID.randomUUID().toString();
given(chatModel.stream(any(Prompt.class)))
.willReturn(Flux.just(chunk("에"), chunk("이,"))); // 합쳐도 3 자
service.chatStreamWithPartialSave(convId, "익명_사자", "우울", "오늘 별로야")
.take(1)
.blockLast();
List<Message> messages = chatMemory.get(convId);
long assistantCount = messages.stream()
.filter(m -> m.getMessageType() == MessageType.ASSISTANT)
.count();
assertThat(assistantCount).isZero();
}
private ChatResponse chunk(String token) {
return new ChatResponse(List.of(
new Generation(new AssistantMessage(token))));
}
}
🎯 면접관을 홀리는 핵심 멘트
"완벽한 일관성 은 비싸고 — 실무는 허용 가능한 비대칭 의 정의에서 시작합니다. ai-friends 도메인은 짧은 부분 응답은 버리고, 긴 부분 응답은 마커와 함께 저장 이 답이었어요. 임계값 10 자는 우리 캐릭터 대사 평균 길이가 30 자 이상 이라는 도메인 측정에서 떨어진 숫자입니다. 의료·상담 봇처럼 컴플라이언스가 중요한 도메인이면 임계값을 낮추고 마커를 더 명시적으로 박는 정책으로 갑니다 — 본질은 임계값과 마커가 도메인 요구의 함수 라는 점입니다."
💼 실무 개선 포인트
(1) 토큰 수 임계치를 도메인별 측정으로 튜닝
본 답안은 10 자 를 임계값으로 박았지만, 운영에선 우리 도메인 대사의 길이 분포 를 한 번 측정해서 결정해요. p10 길이가 25 자 라면 임계값은 15 자 정도가 자연스럽고 (의미 있는 응답의 하단 컷오프), p50 이 80 자 라면 25 자 가 더 적절. 추측이 아니라 분포 측정 으로 결정하는 게 과제 1 의 정신과 같은 가족입니다.
(2) 재시도 시 컨텍스트 정리 — 비대칭 응답 제거 후 재호출
부분 응답이 ChatMemory 에 저장된 다음 사용자가 "다시 답해줘" 라고 요청하면, 그대로 호출하면 LLM 이 비대칭 응답을 본 채로 또 답을 만들게 돼요. 운영에선 "마지막 ASSISTANT 메시지가 [중단됨] 마커를 포함하면 ChatMemory 에서 제거 후 재호출" 같은 재시도 정책 을 advisor 한 줄로 박아두는 게 자연스러워요.
Day 11 (tool calling) 이후 advisor 커스터마이징 손에 익으면 바로 손에 들어옵니다.
주제 1 예시답안: SSE 만으로 충분한가? — **양방향 채널** 이 필수가 되는 전환점
[문제 상황 요약]
Day 6 Step 6 에서 SSE 가 우리 도메인에 5 축 모두 우세 하다는 결론을 내렸어요. 그런데 요구사항이 한 줄 추가되는 순간 그 결론이 뒤집힐 수 있어요. 캐릭터 표정 실시간 변화 / 멀티 사용자 단체 채팅 / 음성 통화 세 시나리오 중 어디까지 SSE 만으로 풀고, 어디서부터 양방향 채널이 필수 가 되는가 — 그 전환점을 어떤 기준 으로 판단할 것인가가 본 주제예요.
[튜터의 가이드 및 해설]
세 시나리오를 프로토콜 결정의 기준 3 축 으로 풀어볼게요. 그 기준이 명확해지면 새 시나리오가 들어왔을 때도 자동으로 결정이 떨어집니다.
판단 기준 3 축
- (1) 클라이언트 → 서버 실시간 빈도 — 분당 5 회 이하면 별도 POST 로 충분, 그 이상이면 양방향 채널이 필요. POST 매번 새 TCP 핸드셰이크 (TLS 까지 포함) 를 하면 분당 10+ 회부터 오버헤드가 사용자 체감을 깎기 시작.
- (2) 메시지 간 동기 의존성 — 한 메시지가 직전 응답에 동기적으로 의존하면 양방향 채널이 자연스러움. 서버가 보낸 데이터를 보고 클라가 즉시 다음 동작을 결정 해야 한다면 단방향 SSE + 별도 POST 의 지연 이 누적돼서 UX 가 깨져요.
- (3) 지연 민감도 — ms 단위 지연이 치명적인 도메인 (음성 / 게임 / 트레이딩) 은 WebSocket 도 부족, 진정한 P2P + 미디어 처리 가 필요한 WebRTC 가 답.
세 시나리오 별 적용
-
Option A — 캐릭터 표정 실시간: SSE 단방향 (LLM 응답) + 별도 POST (사용자 입력 → 표정 변경) 로 충분.
- 장점: 기존 SSE 인프라 그대로 재사용, 의존성 추가 없음
- 단점: 분당 표정 변경 빈도가 낮을 때만 성립. 빈도가 분당 30 회 이상이라면 (예: 캐릭터가 사용자 텍스트를 실시간 분석 해서 글자 단위로 표정 변화) WebSocket 으로 옮기는 게 자연스러움
- 판단 근거: 기준 (1) — 빈도가 낮음. 기준 (2) — 표정 변화는 별개 채널 이라 LLM 응답과 동기 의존성 없음
-
Option B — 멀티 사용자 단체 채팅: WebSocket 이 자연스러움.
- 장점: 다른 사용자의 메시지가 내 화면에도 흘러와야 하는 자리 — 서버가 능동적으로 여러 클라이언트에 push 하는 시나리오라 WebSocket 의 서버 push 가 정확히 맞음
- 단점: 인프라 복잡도 ↑ (sticky session / 메시지 브로커 / 재연결 정책)
- 판단 근거: 기준 (1) — N 명이 동시에 메시지를 던지면 분당 빈도가 급증. 기준 (2) — 다른 사용자 메시지 + 내 입력 을 동기적으로 묶어야 하는 자리
-
Option C — 음성 통화: WebRTC 가 정답, WebSocket 도 약함.
- 장점: P2P 양방향 미디어 스트림, ms 단위 지연
- 단점: 인프라 복잡도 최상 (STUN/TURN 서버 / SDP 협상 / NAT traversal)
- 판단 근거: 기준 (3) — 음성 지연이 100ms 넘으면 사용자가 어색함 을 즉시 감지. 기준 (1) — 오디오 프레임이 초당 50 회 흘러야 함
현업에서는 보통
| 도메인 | 선택 | 근거 |
|---|---|---|
| ai-friends 의 LLM 채팅 | SSE | 단방향 + 분당 빈도 낮음 |
| ai-friends + 표정 변화 (저빈도) | SSE + POST | 별도 채널 분리로 SSE 인프라 유지 |
| 단체 채팅 / 협업 도구 | WebSocket | 양방향 + 서버 push 필요 |
| 음성 / 영상 통화 | WebRTC | 지연 민감 + 미디어 P2P |
기술 선택은 기술의 우월함 이 아니라 도메인 요구의 매트릭스 에서 떨어지는 결정이에요. SSE 가 항상 좋다 도, WebSocket 이 항상 좋다 도 아닙니다.
🎯 면접관을 홀리는 핵심 멘트
"기술 선택의 정답은 기술의 우월함 이 아니라 도메인 요구의 매트릭스 입니다. 우리는 분당 양방향 빈도·메시지 간 동기 의존성·지연 민감도 세 축으로 결정합니다. ai-friends 의 LLM 단방향 응답은 SSE, 멀티 사용자 단체 채팅은 WebSocket, 음성 통화는 WebRTC 가 자연스럽게 떨어집니다. SSE 가 충분한지 답하려면 분당 클라이언트 → 서버 빈도와 지연 민감도 두 숫자를 먼저 보세요 — 그 두 숫자가 5 회 미만 + 100ms 허용 이면 SSE, 둘 중 하나라도 넘기면 양방향 채널 검토 시작점입니다."
주제 2 예시답안: `ApiResponse` **정당한 예외** 의 근거 — 표준의 일관성 vs 미디어타입의 본질
[문제 상황 요약]
Day 6 Step 4 에서 SSE 가 §4-1 ApiResponse 게이트의 정당한 예외 라고 결론을 내렸어요 — "미디어타입의 본질이 JSON 과 비호환인 경우만 예외" 라는 원칙으로요. 그런데 이 원칙은 실무에서 논쟁의 여지 가 있어요. 어떤 팀은 모든 응답을 ApiResponse 로 강제 해서 일관성을 우선하고, 어떤 팀은 미디어타입 본질을 따라 분기를 허용해요.
면접관에게 30 초 안에 정리할 수 있어야 하는 자리.
[튜터의 가이드 및 해설]
본 강의의 입장과 반대 입장 을 모두 합리화할 수 있어야 진짜로 그 결정의 트레이드오프를 이해한 거예요.
본 강의의 입장 — 미디어타입 본질 분기 허용 (3 가지 근거)
- (1) 미디어타입 본질 비호환 —
text/event-stream은 프레임 단위로 끊어 흐르는 미디어타입이고, JSON 은 완성된 객체 한 덩어리 의 미디어타입. 이 둘을 한 응답 안에 묶으면 SSE 의 흐름 의미 자체가 깨져요. - (2) 스트리밍 의미 파괴 — SSE 페이로드를 JSON 으로 직렬화한 뒤 그 안에 ApiResponse 를 넣으면, 프레임마다 JSON 직렬화 비용 + 클라가 매 프레임마다 JSON 파싱 의 오버헤드가 누적. 0.6 초 첫 토큰의 가치를 프레임 당 직렬화 비용 이 깎아먹어요.
- (3) 에러 채널 분리 — SSE 는 정상 흐름 중 에러가 발생하면 별도 이벤트 타입 (
event: error\ndata: ...) 으로 분리해서 보내는 게 표준. ApiResponse.fail 같은 body 안의 에러 와는 에러 채널의 결 자체가 달라요.
반대 입장 — 모든 응답을 ApiResponse 로 강제 (3 가지 합리화)
- (1) 클라이언트 SDK 의 응답 파싱 로직 단일화 — 모든 엔드포인트가 같은 wrapper 라 클라 SDK 의
parseResponse(response)가 if/else 분기 없이 한 줄로. SSE 만 예외라는 예외 케이스 가 SDK 코드에서 사라짐 → 신규 개발자 학습 곡선 ↓ - (2) 자동 직렬화 검증 도구의 모든 엔드포인트 적용 — 응답이 항상
ApiResponse<T>면 스키마 자동 검증 도구 (예: Pact / OpenAPI 검증) 가 모든 엔드포인트에 예외 없이 적용 가능. 하나라도 예외가 있으면 테스트 게이트 가 약해짐 - (3) 미디어타입 결정의 단일 게이트 — 어떤 엔드포인트가 SSE 고 어떤 게 JSON 인지 의 분기 로직이 컨트롤러에 흩어지지 않고 클라가 한 wrapper 안에서 결정. 미디어타입을 body field 로 표현하면 (예:
{"contentType": "stream", "events": [...]}) 한 채널로 통합 가능
두 입장의 트레이드오프 정리
| 축 | 본 강의 입장 (미디어타입 본질) | 반대 입장 (ApiResponse 강제) |
|---|---|---|
| 우선순위 | 미디어타입의 프레임 의미 보존 | 응답 wrapper 의 형태 일관성 |
| 비용 | 예외 케이스 (SSE) 학습 비용 | 프레임당 직렬화/파싱 비용 + 표준 SSE 클라 호환성 손실 |
| 강점 | 표준 SSE 클라이언트 (EventSource) 즉시 호환 |
SDK 코드 단순화, 자동 검증 도구 일관 적용 |
| 약점 | 클라 SDK 분기 한 줄 필요 | 첫 토큰 latency 가치 일부 깎임 |
현업에서는 보통
- ai-friends 같은 사용자 체감이 핵심인 도메인 — 본 강의 입장 (미디어타입 본질 우선)
- B2B SaaS / 내부 API 도구 — 반대 입장 (SDK 표준화 우선)
- 양쪽 입장이 맞다 가 아니라 팀의 우선순위 가 답. ai-friends 는 체감 latency 자산이 SDK 일관성 자산보다 더 비싸기 때문에 본 강의 입장이 합리적.
🎯 면접관을 홀리는 핵심 멘트
"§4-1 의 정당한 예외 결정은 원칙의 정합성 이 아니라 예외 비용 vs 일관성 비용 의 도메인 매트릭스입니다. 본 강의는 미디어타입의 프레임 의미 + 첫 토큰 latency 자산 을 우선해서 SSE 를 예외로 두었습니다. 클라이언트 SDK 표준화가 더 비싼 자산인 팀이라면 모든 응답을 ApiResponse 로 강제 하는 결정도 정당합니다 — 그 팀은 프레임당 직렬화 비용을 SDK 단순성과 맞바꾸는 거래를 한 거예요. 핵심은 어느 자산이 더 비싼지 의 판단이지, 원칙의 절대성이 아닙니다."
주제 3 예시답안: `ChatClientMessageAggregator` **프레임워크 마법** 을 신뢰하는 비용
[문제 상황 요약]
Day 6 Step 5 에서 우리는 "우리 코드는 Flux.doOnComplete() 같은 보정을 짤 필요가 없다" 는 결론에 도달했어요. Spring AI 의 MessageChatMemoryAdvisor 가 내부적으로 ChatClientMessageAggregator 를 써서 완성된 ASSISTANT 메시지를 자동으로 잡아 ChatMemory 에 저장 해주거든요. 한 줄도 안 짜고 동작이 보장되니 추상화의 단맛 이 진하죠. 그런데 그 신뢰의 비용 은 무엇이고, 어떻게 방어 할까가 본 주제예요.
[튜터의 가이드 및 해설]
추상화를 신뢰한다는 결정엔 항상 비용이 따라요. 그 비용을 3 가지 + α 로 짚고, 방어 전략 3 줄 로 분할 납부 하는 패턴을 정리합니다.
프레임워크 마법 신뢰의 비용
- (1) 라이브러리 버전 업그레이드 시 동작 변경 — Spring AI 1.1 → 1.2 → 2.0 사이의 시그니처 변화, aggregation 정책 변경 (예: 청크 합치기 기준이 달라짐), 메시지 타입 분리 변경 (예: ToolMessage 가 별도 타입으로 분리되면서 aggregation 흐름 갈라짐) 등. 우리 코드는 그대로인데 업그레이드 한 줄로 동작이 다르게 떨어질 수 있어요.
- (2) 디버깅 난이도 상승 — "왜 ChatMemory 에 저장이 안 되지?" 가 발생했을 때 우리 코드 잘못인지 라이브러리 버그인지 모호함. 디버거를 jar 안으로 들여보내서
ChatClientMessageAggregator의 onComplete 가 실제로 호출되는지 확인해야 답이 나오는 자리가 생겨요. - (3) 도메인 특화 요구 시 우회 비용 — 과제 3 의 부분 응답 저장 이 정확히 그 자리. 라이브러리가 자동 처리해주지 않는 자리는 우리가 직접 짜야 함. 우회 비용이 도메인 요구마다 누적.
- (+ α) 라이브러리 deprecation 시 마이그레이션 — Spring AI 가 어느 시점에
ChatClientMessageAggregator를 다른 추상화 (예:ChatMemoryAdvisor통합) 로 교체하면 우리 코드의 가정 자체 가 깨짐. 깊은 추상화 신뢰일수록 마이그레이션 시 깨질 자리가 많아져요.
방어 전략 — 분할 납부 3 줄
- (가) 계측 (Observability) — ChatMemory 저장 시점에 우리 의 메트릭 (Micrometer Counter) 으로 "chat_memory_saved_total" 같은 카운터 박기. 라이브러리 동작이 의도대로 돌면 카운터가 매 호출마다 1 씩 증가해야 함. 만약 어느 날 카운터 증가 패턴이 갑자기 깨지면 라이브러리 동작 변경 신호 — Day 20 (observability) 으로 회수될 자리.
- (나) 회귀 테스트 — Day 5 의
JdbcChatMemoryRepository통합 테스트가 정확히 그 자리. 동일 conversationId 로 두 번 호출했을 때 컨텍스트가 누적되는지 를 실제 DB 에 박힌 row 로 검증. 라이브러리 업그레이드 후 이 테스트가 Red 가 되면 곧장 동작 변경 알림. 본 강의에서 이미 그 가드를 깔아둔 셈. - (다) 계약 명시 — 우리 코드의 기대 를 javadoc / README / ADR (Architecture Decision Record) 에 명시. "이 코드는
ChatClientMessageAggregator가 onComplete 시점에 한 번 저장한다는 가정 위에 동작" 같은 한 줄. 다음 사람 (또는 6 개월 뒤의 나) 이 왜 이렇게 짰는지 를 1 분 안에 이해할 수 있어야 함.
라이브러리 동작이 의도와 다르게 바뀌면 어떻게 조기에 발견하는가
세 줄을 종합하면 조기 발견의 3 단계 게이트 가 만들어져요.
| 단계 | 게이트 | 발견 시점 |
|---|---|---|
| 1 단계 | 회귀 테스트 (JdbcChatMemoryRepository 통합 테스트) |
라이브러리 업그레이드 직후 CI 단계 — 몇 분 안에 |
| 2 단계 | 메트릭 알림 (chat_memory_saved_total 의 패턴 변화) | 운영 배포 후 몇 시간~며칠 안에 |
| 3 단계 | 사용자 신고 (CS 문의) | 운영 배포 후 며칠~몇 주 뒤 |
세 단계 모두 박아두면 1 단계에서 잡히면 운영 영향 0, 2 단계에서 잡히면 사용자 영향 최소화, 3 단계는 최후 보루. 이게 프레임워크 마법의 비용을 분할 납부 하는 모양입니다.
현업에서는 보통
- 작은 팀 / 빠른 MVP — (가) 계측 + (다) 계약 명시 두 줄로 시작. (나) 는 핵심 경로만
- 운영 안정성 우선 도메인 — 세 줄 모두 풀 도입. 특히 (나) 회귀 테스트가 라이브러리 메이저 업그레이드의 게이트키퍼
- 프레임워크를 깊이 신뢰하지 않는 보수적 팀 — 추상화를 우회 해서 직접 구현. 우리 입장에선 과제 3 의 부분 저장이 그 부분 우회 의 사례
🎯 면접관을 홀리는 핵심 멘트
"프레임워크 마법 의 비용은 지금 이 아니라 6 개월 뒤 라이브러리 업그레이드 와 도메인 특화 요구 에서 청구됩니다. 우리는 그 청구서를 계측·회귀·계약 명시 세 줄로 분할 납부합니다 — 메트릭으로 동작 변경을 조기에 알고, 회귀 테스트로 업그레이드 직후 CI 게이트에서 잡고, 계약 명시로 왜 이렇게 짰는지 의 의도를 다음 사람에게 박아둡니다. 이 셋이 깔린 추상화 신뢰는 단맛만 가져오고, 셋 중 하나라도 빠진 신뢰는 6 개월 뒤 청구서 로 돌아옵니다."