@Res와 Response 객체로 챗봇 스트리밍 구현하기

Dorong·2026년 5월 10일

AI

목록 보기
4/4

LLM 챗봇을 만들다 보면, 챗봇에서는 표준에 가깝지만 막상 평소에 자주 쓰지는 않았던 방식이 있음. 바로 답변이 한 글자씩 타이핑되듯 나오는 스트리밍 응답임. NestJS에서 이걸 구현하려면 평소 쓰던 return 방식을 버리고, @Res() 데코레이터로 Response 객체를 직접 제어해야 함.


Response 객체란

  • Response는 HTTP 응답 자체를 나타내는 객체임
  • 브라우저(클라이언트)가 서버에 요청을 보내면, 서버는 이 Response 객체를 통해 응답을 "쓰고" "보냄"
  • NestJS에서 쓰는 Response는 Express가 Node.js의 http.ServerResponse를 래핑한 것임
  • 즉, TCP 연결의 서버 쪽 끝단을 추상화한 객체라고 이해하면 됨
브라우저 ─────────── TCP 연결 ─────────── 서버
          POST /chat/stream →
          ← HTTP 헤더 + 바디 (응답)
  • res는 이 화살표의 오른쪽(서버)에서 왼쪽(브라우저)으로 데이터를 보내는 파이프의 제어권

일반적인 NestJS 방식 vs @Res() 직접 제어

return 방식 (일반적)

@Post("chat")
async chat(@Body() data: ChatDto) {
  const result = await this.chatService.chat(data);
  return result; // NestJS가 알아서 res.json(result) 처리해줌
}
  • NestJS 프레임워크가 리턴값을 받아서 JSON 직렬화 → 헤더 설정 → 응답 전송까지 전부 대신 해줌
  • 편리하지만, 응답이 한 번에 완성되어야 함
  • 응답을 보내는 순간 연결이 닫힘

@Res() 방식 (직접 제어)

@Post("stream")
async stream(@Body() data: ChatStreamDto, @Res() res: Response) {
  // 직접 컨트롤
}
  • @Res()를 붙이는 순간, NestJS에 "내가 직접 응답 관리할게" 라고 선언하는 것임
  • NestJS는 손을 뗌. 인터셉터, 직렬화 같은 기본 동작이 적용되지 않음
  • 대신 res.write(), res.end() 등을 직접 호출해서 응답을 제어함

비교 정리

항목return 방식@Res() 직접 제어
응답 방식return valueres.write() + res.end()
연결응답 즉시 닫힘원하는 시점에 닫음
NestJS 개입자동 JSON 직렬화, 인터셉터 적용프레임워크 개입 없음
사용 시점단순 JSON 응답SSE, 파일 다운로드, 청크 스트리밍

LLM 챗봇 스트리밍에서 왜 @Res()가 필요한가

LLM API(OpenAI, Anthropic 등)는 토큰을 하나씩 생성하는데, 전체 응답이 완성될 때까지 기다리면 사용자는 수 초간 빈 화면을 보게 됨. 따라서 일반적인 AI 채팅 UI처럼 한 글자씩 출력하도록 만들려면:

  • 서버가 LLM으로부터 토큰을 받을 때마다 즉시 클라이언트에 전달해야 함
  • 이를 위해 SSE(Server-Sent Events) 프로토콜을 사용함
  • SSE는 HTTP 연결을 열어둔 채로 서버가 일방적으로 데이터를 계속 보내는 방식임
  • 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()로 즉시 클라이언트에 전송함. 어디에서도 전체 응답을 메모리에 쌓지 않음.

  • SSE 포맷 규칙: data: {내용}\n\n — 반드시 data: 접두사와 빈 줄(\n\n)로 끝나야 함

3단계: 완료 신호

  • 모든 토큰 전송 후 { type: "done" } 이벤트를 보냄
  • 클라이언트는 이 이벤트를 받고 "스트리밍 끝났구나" 판단 → UI에서 로딩 상태 해제 등 처리

4단계: 에러 핸들링

  • LLM API 호출 중 에러가 발생해도, HTTP 연결은 이미 200으로 열려 있음
  • HTTP 상태 코드로 에러를 알릴 수 없으니, SSE 데이터 자체에 에러 정보를 실어 보냄
  • 클라이언트 측에서 type: "error" 이벤트를 파싱해서 에러 처리해야 함

5단계: 연결 종료

  • finally 블록에서 res.end() 호출 → 어떤 상황이든 TCP 연결을 확실히 닫음
  • 이걸 빠뜨리면 연결이 좀비 상태로 남아 서버 리소스를 잡아먹음

Response 객체의 주요 메서드 정리

메서드역할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() → 연결 종료

요약하자면,

  • Response 객체는 서버→클라이언트 TCP 연결의 제어권을 추상화한 것임
  • @Res()를 쓰면 NestJS의 자동 응답 처리를 끄고, 직접 응답 라이프사이클을 관리함
  • LLM 챗봇 스트리밍은 SSE + @Res() 직접 제어 조합이 사실상 표준 패턴임
  • flushHeaders()로 연결을 열고, write()로 토큰을 쏘고, end()로 닫는 흐름을 기억하면 됨
profile
AI R&D와 웹/앱개발 욕심쟁이 멀티 플레이🐖

0개의 댓글