문서 읽는 데 250분 · day06

Day 6. Streaming — "답변이 흘러 도착하는 형태"

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

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

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

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 위에 흐름의 채널 을 얹는 작업이에요.

  1. .stream().content().call().entity(...) 의 형제. 응답이 한 번에 떨어지는 대신 Flux<String> 으로 청크 단위로 흘러나와요. Spring AI 의 ChatClient 는 동기 / 스트리밍 두 모양을 같은 fluent API 위에서 깔끔히 갈라놨어요.
  2. Reactor Flux<String> — "한 번에 안 오고 흘러 오는 데이터" 를 받는 컨테이너예요. 비동기를 정복하는 도구가 아니라, 받는 모양 만 잡으면 충분한 컨테이너로 우선 보시면 돼요. 깊은 내부 동작 (스케줄러·백프레셔) 은 Step 2 에서 필요한 만큼만 짚을 거예요.
  3. SSE (Server-Sent Events) — 별도 의존성 없이 HTTP 응답을 끊어 보내는 표준 미디어 타입 (text/event-stream). 신규 프로토콜이 아니라 그냥 HTTP 에 가깝다는 점이 핵심이에요. Spring MVC 에선 컨트롤러가 Flux<String> 을 직접 반환하면 끝이에요.
  4. 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 들이 그 방식으로 눈속임을 했어요. 그런데 우리 도메인에선 두 가지가 걸려요.

  1. 가짜 streaming 은 총 응답 시간 자체 를 못 줄여요. 클라이언트가 응답을 다 받기 전엔 타이핑 효과를 시작도 못 하니까요. 결국 0 ~ 2.3 초 사이의 0 byte 침묵 은 그대로예요. 사용자가 새로고침 버튼을 누를 그 답답함 자체는 해결이 안 돼요.
  2. 모델의 첫 토큰 도착 시점 은 전체 완성 시점 보다 훨씬 빨라요. Gemini 2.5 Flash 의 경우 첫 토큰까지 ≈ 0.3 초, 전체 완성까지 ≈ 2.3 초. 이 시간차를 클라이언트한테 그대로 흘려주는 게 진짜 streaming 부분이에요. 가짜로는 못 만드는 7 배 차이죠.

요약하자면 진짜 streaming모델이 토큰을 만들기 시작한 그 순간부터 클라이언트 화면에 글자가 도착하기 시작 하이에요. 프론트의 타이핑 효과로는 절대 못 만들어요.

"튜터님, 2.3 초가 그렇게 답답해요? 그냥 좀 기다리면 되는 거 아닌가요?"

직관으로 답하자면 — 맞는 호흡과 안 맞는 호흡 의 차이예요. 사람한테 메시지 보내고 2.3 초 동안 입력 중... 이 보이면 자연스러워요. 그런데 2.3 초 동안 그냥 침묵 이면 어색하죠? 사람 사이의 카톡에서도 입력 중... 이라는 신호를 굳이 보여주는 이유예요. 응답이 시작 됐다는 신호가 있어야 사람의 호흡이 맞아요.

미연시 도메인에선 입력 중... 의 등가물이 타이핑 효과 예요. 그래서 진짜 streaming 으로 캐릭터가 답변을 흘려주기 시작하는 0.3 초의 신호 가 있어야, 사용자가 "AI 가 응답하고 있다" 를 인식하고 기다리는 게 자연스러워져요.

### 7. 💡 튜터의 결론

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> 이 등장하는 부분은 딱 두 군데예요.

  1. Service 메서드의 반환 타입public Flux<String> chatStream(...)
  2. 컨트롤러 메서드의 반환 타입 (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 에서 다시 기억하는 캐릭터 로 돌아옵니다.

### 6. 💡 튜터의 결론

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 반환 타입이 FluxReactiveTypeHandler 활성화
4 ReactiveTypeHandler FluxResponseBodyEmitter 로 변환 + 자동 구독
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.

완료 시점을 명시 호출 해야 해요. SseEmitteremitter.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 의 표준 사용 패턴 때문이에요. 브라우저의 EventSource API 는 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 가 없는 워크플로 에 대안 으로만 머릿속에 두시면 됩니다.

### 7. 💡 튜터의 결론

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-Typeapplication/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 연산으로 안에서 잡는 게 가장 깔끔해요.

### 7. 💡 튜터의 결론

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 은 그동안 고의로 미뤄둔 두 개의 매듭을 한 번에 풀어요.

  1. 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(" 힘들었구나")

망가졌어요. 두 가지가 한꺼번에 어긋났거든요.

  1. 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(...) 훅이 스트리밍에선 청크가 흩어져 도착하니 완성된 메시지를 어디서 잡아야 할지가 미묘해진다" 라고 흘려뒀잖아요. 그 매듭의 답이 바로 이거예요. adviseStreamChatClientMessageAggregator 를 거쳐 청크를 다 모은 뒤 완성된 메시지 한 개 로 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);
}

추가된 부분은 세 가지예요.

  1. @RequestParam(required = false) String conversationId — 새 파라미터. 없어도 되는 (optional) 부분이에요. 첫 호출엔 클라이언트가 안 보내도 돼요.
  2. UUID fallback 한 줄conversationId == null || isBlank()UUID.randomUUID().toString() 으로 새 ID 발급, 있으면 그대로 사용.
  3. 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) 의 부서진 메시지 누적 보다는 훨씬 나은 트레이드오프거든요.

이 부분도 머릿속에 체크포인트 정리해두세요. 실무에서 대화 일관성이 중요한 도메인이라면 (예: 상담 봇, 의료 봇) 이 부분를 반드시 풀어야 해요.

🙋 날카로운 질문 타임

"튜터님, 그러면 MessageChatMemoryAdvisoradviseCalladviseStream 두 메서드를 다 가진다는 거잖아요? 우리가 어느 걸 부를지 어떻게 정해지나요? 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) 보다는 훨씬 나으니까요.

이 결정을 명시적으로 하는 게 중요해요. 그래야 나중에 왜 여기에 보정이 없냐 라는 질문이 나왔을 때, "이게 우리 도메인의 트레이드오프 결정이다" 라고 답할 수 있거든요. 결정을 문서화 하는 게 엔지니어링이에요.

### 10. 💡 튜터의 결론

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 의 핵심을 두 줄로 요약하면:

  1. HTTP Upgrade 핸드셰이크 — 처음엔 일반 HTTP 요청으로 시작하지만, Upgrade: websocket 헤더로 프로토콜을 갈아탑니다. 그 뒤로는 ws:// (또는 wss://) 라는 별도 프로토콜 위에서 양방향 메시지가 오가요. HTTP 의 요청-응답 모양이 아니라, 양쪽이 언제든 메시지를 보내는 모양이에요.
  2. 양방향 채널 — 클라이언트도 서버에 메시지를 언제든 보낼 수 있고, 서버도 클라이언트한테 언제든 메시지를 밀어줄 수 있어요. 한 번 핸드셰이크가 끝나면 지속적인 채널 이 열리는 거죠.

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 의 진짜 이득 부분이에요. 부분적 가치 회수 라고 보시면 돼요.

### 7. 💡 튜터의 결론

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 포맷 그대로 — roleMessageType.name().toLowerCase() 의 결과라 소문자 로 떨어집니다.)

{
  "success": true,
  "data": [
    { "role": "user",      "content": "오늘 진짜 별로였어" },
    { "role": "assistant", "content": "에이, 무슨 일 있어? 오늘 하루 힘들었구나... 천천히 얘기해줄래?" },
    { "role": "user",      "content": "내가 좀 전에 뭐라고 했지?" },
    { "role": "assistant", "content": "방금 오늘 하루가 진짜 별로였다고 했잖아. 무슨 일 있었던거야?" }
  ]
}

4 개의 메시지가 시간 순서대로 잘 누적됐어요. 보세요 — userassistant 가 번갈아 정확히 두 쌍. 이게 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). ⏰

MessageChatMemoryAdvisorbefore() 가 호출 직전에 USER 메시지를 저장하고, 그 다음 LLM 호출이 일어나고, 마지막에 after() (스트리밍에선 aggregator 의 onComplete) 가 ASSISTANT 메시지를 저장해요. 그러니까 항상 USER 먼저, ASSISTANT 가 그 다음 의 시간 순서로 들어가요.

다만 누락 가능성 이 한 부분 있어요 — 위에서 짚은 미해결 트레이드오프 ② (스트리밍 도중 disconnect). 그 케이스에선 USER 만 남고 ASSISTANT 가 빈 비대칭이 생겨요. 다음에 세션 조회를 해보면 USER 메시지 옆에 짝이 없는 부분이 보일 수 있어요. 정상 종료 시점이라면 항상 짝수 개 (USER N + ASSISTANT N) 로 누적된다는 점만 머릿속에 정리해두세요.

### 9. 💡 튜터의 결론

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.adviseStreamChatClientMessageAggregator 로 스트림 종료 시점에 한 번만 저장한다 — 우리는 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 의 키워드 몇 개만 미리 던져 둘게요.

  • ImageModelChatModel 과 자매 추상화. 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 의 추측을 측정으로 바꾸는 정신 그대로요.

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

  1. PM 보고서의 4 배 는 측정값이어야 한다 — Step 7 에서 짚은 체감 대기 시간 4 배 단축 은 예시 환경의 숫자 였어요. 운영 의사결정의 무게는 내 환경에서 측정한 숫자에서 나옵니다. 추측이 아닌 수치 로 PM 에게 답할 수 있어야 해요.
  2. 스트리밍의 진짜 효과는 첫 토큰까지의 시간 이다 — 전체 응답 완료 시간 은 blocking 과 streaming 이 비슷할 수 있어요. 그런데 체감 의 핵심은 언제 첫 글자가 떨어지는가 예요. 두 지표를 분리해서 측정하는 감각을 익혀요.

✅ 요구사항

  1. 두 엔드포인트의 응답 시간을 각각 5 회 이상 측정 — Day 6 Step 1 의 blocking 엔드포인트(/api/chat/soulmate) 와 Step 3 의 streaming 엔드포인트(/api/chat/soulmate/stream)
  • blocking: time_total 측정 — 응답 완료까지 걸린 전체 시간
  • streaming: 첫 토큰 도착 시점 측정 — curl -N 의 첫 출력까지의 시간
  1. 측정 환경 명시 — 모델 프로바이더(Gemini Flash / Ollama 로컬 등) · 모델명 · 메시지 길이 · 네트워크 환경(WiFi / 유선) 를 한눈에 보이게 적기
  2. 모델 / 메시지 길이는 고정 — 한 모델 한 문장으로 통일해서 변수를 줄이세요. 예: Gemini Flash + "오늘 진짜 별로였어" (15 byte 내외) 한 문장만으로 5 회씩
  3. 비교 표 작성blocking 평균/최대/최소 vs streaming 첫 토큰 / 전체 완료
  4. 체감 대기 시간 단축 비율 계산(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> + 응답 헤더 의 호환성을 손으로 검증하는 기회이기도 해요.

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

  1. 트레이드오프를 감수 하다 해소 로 옮기는 손길 — Step 7 에서 알면서 감수 한다고 정리한 부분는, 실무에선 결국 언젠가는 풀게 돼요. 그 풀이의 모양 이 어떤지 한 번 손으로 그려보면, 다음에 비슷한 알면서 감수 부분이 나왔을 때 언제 풀어야 할지 의 감이 잡혀요.
  2. Flux<String> + 응답 헤더의 호환성 실험 — Spring MVC 1.1.x 에서 ResponseEntity<Flux<String>> 가 SSE 자동 변환을 깨뜨리는지 안 깨뜨리는지 는 문서만 읽어선 모르는 부분이에요. 직접 짜고 curl 로 응답 헤더가 들어가 오는지 확인하는 게 백엔드 엔지니어의 손 감각 부분이에요.

✅ 요구사항

  1. streamChat(...) 응답에 X-Conversation-Id 헤더 추가 — 클라이언트가 보낸 conversationId 든 서버가 새로 발급한 UUID 든 항상 헤더에 들어가야 함
  2. 두 패턴 중 하나 시도 — 학생 자유
  • 패턴 A: ResponseEntity<Flux<String>> 반환 — ResponseEntity.ok().header("X-Conversation-Id", convId).body(flux)
  • 패턴 B: HttpServletResponse 직접 주입 — response.setHeader("X-Conversation-Id", convId) 호출 후 Flux<String> 반환
  1. 슬라이스 테스트 추가 — 헤더 검증 (mvcResult.getResponse().getHeader("X-Conversation-Id") 또는 WebMvcTest 의 header().exists("X-Conversation-Id"))
  2. 두 케이스 모두 검증 — 클라이언트가 conversationId 를 보낸 경우 그 값 그대로, 안 보낸 경우 새 UUID 가 들어가야 함
  3. 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 로 자동 변환 하는 진행을 깨지 않는지 직접 확인 이 필요해요 — 깰 수도, 안 깰 수도 있어서 여기가 본 과제의 실험 포인트 예요.
  • 패턴 Bfallback 같은 모양. 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) 을 익히는 부분이기도 해요.

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

  1. lifecycle 훅의 손 감각 — Flux 가 어떻게 끝났는지 (정상 완료 / 취소 / 에러) 에 따라 다른 코드 가 돌아야 하는 부분은 실무에서 자주 등장해요. 이번 과제가 그 첫 번째 손길.
  2. 도메인별 정책 결정의 질감 — 너무 짧은 부분 응답은 저장 안 함 / 완성된 응답만 저장 / 부분이라도 저장 세 가지 중 우리 도메인에 맞는 결정을 내려보는 경험. 정답은 도메인마다 달라요.

✅ 요구사항

  1. Flux<String> 에 lifecycle 훅 추가doOnCancel(...) (클라이언트 disconnect) 와 doOnError(...) (예외) 시점 감지
  2. 그 시점까지 흘러간 토큰을 수동으로 합쳐서 ChatMemory.add(...) 호출 — MessageChatMemoryAdvisor 가 자동 처리해주지 않는 부분이므로 직접 호출
  3. 저장 정책 — 너무 짧은 부분 응답은 저장 안 함 — 학생 자유 (예: 토큰 3 개 이하 / 합쳐진 텍스트 10 자 이하 등 — 왜 그 임계값을 골랐는지 한 줄 코멘트 필수)
  4. 통합 테스트Flux.error(...) / Flux.take(2) / Flux.timeout(...) 같은 인위적 disconnect 로 부분 저장이 동작하는지 검증
  5. 세션 조회로 사후 검증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 의 MessageChatMemoryAdvisor jar 소스 — 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 개월 뒤 청구서 로 돌아옵니다."

더 배우려면

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

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