LLM 챗봇을 만들다 보면, 챗봇에서는 표준에 가깝지만 막상 평소에 자주 쓰지는 않았던 방식이 있음. 바로 답변이 한 글자씩 타이핑되듯 나오는 스트리밍 응답임. NestJS에서 이걸 구현하려면 평소 쓰던 return 방식을 버리고, @Res() 데코레이터로 Response 객체를 직접 제어해야 함.
http.ServerResponse를 래핑한 것임브라우저 ─────────── TCP 연결 ─────────── 서버
POST /chat/stream →
← HTTP 헤더 + 바디 (응답)
res는 이 화살표의 오른쪽(서버)에서 왼쪽(브라우저)으로 데이터를 보내는 파이프의 제어권임@Post("chat")
async chat(@Body() data: ChatDto) {
const result = await this.chatService.chat(data);
return result; // NestJS가 알아서 res.json(result) 처리해줌
}
@Post("stream")
async stream(@Body() data: ChatStreamDto, @Res() res: Response) {
// 직접 컨트롤
}
@Res()를 붙이는 순간, NestJS에 "내가 직접 응답 관리할게" 라고 선언하는 것임res.write(), res.end() 등을 직접 호출해서 응답을 제어함| 항목 | return 방식 | @Res() 직접 제어 |
|---|---|---|
| 응답 방식 | return value | res.write() + res.end() |
| 연결 | 응답 즉시 닫힘 | 원하는 시점에 닫음 |
| NestJS 개입 | 자동 JSON 직렬화, 인터셉터 적용 | 프레임워크 개입 없음 |
| 사용 시점 | 단순 JSON 응답 | SSE, 파일 다운로드, 청크 스트리밍 |
LLM API(OpenAI, Anthropic 등)는 토큰을 하나씩 생성하는데, 전체 응답이 완성될 때까지 기다리면 사용자는 수 초간 빈 화면을 보게 됨. 따라서 일반적인 AI 채팅 UI처럼 한 글자씩 출력하도록 만들려면:
return으로는 "응답을 조금씩 나눠서 보내는 것"이 불가능함res 객체를 직접 잡고, write()로 토큰 단위로 쏴야 함@Post("stream")
async stream(@Body() data: ChatStreamDto, @Res() res: Response) {
// 1) SSE 헤더 설정
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
res.flushHeaders();
try {
// 2) LLM에서 토큰이 올 때마다 클라이언트로 전송
for await (const token of this.chatService.stream(data)) {
res.write(`data: ${JSON.stringify({ token })}\n\n`);
}
// 3) 스트림 완료 신호
res.write(`data: ${JSON.stringify({ type: "done" })}\n\n`);
} catch (error) {
// 4) 에러 발생 시 클라이언트에 에러 전달
const message =
error instanceof Error ? error.message : "Unexpected stream error";
res.write(`data: ${JSON.stringify({ type: "error", message })}\n\n`);
} finally {
// 5) 연결 종료
res.end();
}
}
1단계: SSE 헤더 설정 + flushHeaders()
Content-Type: text/event-stream → 브라우저에게 "이건 SSE야"라고 알려줌Cache-Control: no-cache, no-transform → 중간 프록시가 응답을 캐싱하거나 변환하지 못하게 함Connection: keep-alive → 연결을 끊지 말라는 신호flushHeaders() → 헤더만 먼저 클라이언트로 보냄. 바디는 아직 안 보냄. 이 시점에서 연결이 "열린 상태"가 됨flushHeaders()가 핵심임. 이걸 호출해야 클라이언트가 "아, 서버가 연결을 열었구나" 하고 데이터를 받을 준비를 함.
2단계: 토큰 스트리밍
컨트롤러에서 this.chatService.stream(data)를 호출하고 있음. 이 서비스 메서드의 구현은 다음과 같음:
async *stream(dto: ChatStreamDto): AsyncGenerator<string> {
const model = this.createModel();
const messages = this.buildMessages(dto);
const stream = await model.stream(messages);
for await (const chunk of stream) {
const token = this.extractToken(chunk);
if (token) {
yield token;
}
}
}
이 코드에서 핵심은 async *와 yield임:
async *stream()은 AsyncGenerator 함수를 선언하는 문법으로, 일반 함수처럼 값을 한 번에 리턴하는 게 아니라, yield로 값을 하나씩 "내보내는" 함수임model.stream(messages)로 LLM API에 스트리밍 요청을 보내고, LLM은 토큰을 하나씩 생성해서 돌려줌for await로 LLM이 보내주는 chunk를 하나씩 받고, extractToken()으로 실제 텍스트 토큰을 추출함yield token으로 호출한 쪽(컨트롤러)에 즉시 전달함컨트롤러의 for await (const token of this.chatService.stream(data))가 이 yield된 값을 하나씩 받는 것임. 정리하면 이런 체인이 만들어짐:
LLM API → chunk → service(yield token) → controller(res.write) → 브라우저
LLM에서 토큰이 나올 때마다 서비스가 yield하고, 컨트롤러가 받아서 res.write()로 즉시 클라이언트에 전송함. 어디에서도 전체 응답을 메모리에 쌓지 않음.
data: {내용}\n\n — 반드시 data: 접두사와 빈 줄(\n\n)로 끝나야 함3단계: 완료 신호
{ type: "done" } 이벤트를 보냄4단계: 에러 핸들링
type: "error" 이벤트를 파싱해서 에러 처리해야 함5단계: 연결 종료
finally 블록에서 res.end() 호출 → 어떤 상황이든 TCP 연결을 확실히 닫음| 메서드 | 역할 | SSE에서의 용도 |
|---|---|---|
res.setHeader(key, value) | HTTP 헤더 작성 | SSE 관련 헤더 설정 |
res.flushHeaders() | 헤더를 즉시 전송 (바디 없이) | 연결을 열고 클라이언트 대기 상태로 만듦 |
res.write(chunk) | 바디 일부를 전송 (연결 유지) | 토큰 하나씩 전송 |
res.end() | 응답 완료, 연결 종료 | 스트리밍 끝난 후 정리 |
사용자 입력
↓
브라우저 → POST /chat/stream → NestJS Controller
↓
Controller: res.flushHeaders() → 헤더 전송, 연결 열림
↓
Controller → chatService.stream() → LLM API (토큰 생성)
↓
토큰 1 → res.write("data: {token: '안'}") → 브라우저
토큰 2 → res.write("data: {token: '녕'}") → 브라우저
토큰 3 → res.write("data: {token: '하'}") → 브라우저
...
↓
res.write("data: {type: 'done'}") → 브라우저
res.end() → 연결 종료
@Res()를 쓰면 NestJS의 자동 응답 처리를 끄고, 직접 응답 라이프사이클을 관리함flushHeaders()로 연결을 열고, write()로 토큰을 쏘고, end()로 닫는 흐름을 기억하면 됨