Next.js의 rewrites 사용 시 SSE 스트리밍이 동작하지 않는 이슈 (feat. streamedQuery)


최근 프로젝트에서 OpenAI API를 활용한 실시간 채팅 기능을 구현하면서 예상치 못한 문제를 만났습니다. 백엔드에서는 SSE(Server-Sent Events)로 응답을 한 글자씩 스트리밍하는데, 브라우저에서만 모든 내용이 한꺼번에 도착하는 현상이었습니다. (curl이나 Postman을 통한 요청에서는 정상적으로 스트리밍)

이 문제를 해결하는 과정에서 Next.js의 기본 압축 설정에 대해 알게 되었고, 최종적으로 BFF(Backend for Frontend) 패턴으로 해결할 수 있었습니다. 혹시 비슷한 문제를 겪고 계신 분들께 도움이 되길 바라며 경험을 공유해보겠습니다.

프로젝트 구조 및 문제 상황

우리 프로젝트의 구조는 다음과 같았습니다.

  • 프론트엔드 - 사용자 질문 입력
  • 백엔드 - OpenAI API 호출 후 SSE로 스트리밍 응답
  • 기대 동작 - 한 글자씩 실시간으로 화면에 표시
  • 우리 프로젝트는 기본적으로 CORS 문제를 해결하기 위해 next.config.js에서 rewrites를 설정하여 사용하고 있습니다.

    // next.config.js
    module.exports = {
    async rewrites() {
    return [
    {
    source: '/.api/:path*',
    destination: 'https://mock-api-endpoint.co.kr/:path*'
    }
    ];
    }
    };

    문제는 여기서 시작되었습니다.

  • curl 요청 - 한 글자씩 실시간으로 응답이 도착
  • Postman - 한 글자씩 실시간으로 응답이 도착
  • 브라우저 (rewrites 경유) - 모든 내용이 한꺼번에 도착
  • 처음에는 브라우저의 문제인 줄 알았는데, rewrites를 거치지 않고 백엔드 엔드포인트로 직접 요청하면 정상적으로 스트리밍이 되는 것을 확인했습니다. 그제서야 Next.js rewrites가 문제일 수 있다는 가설을 세웠습니다.

    원인 탐구: Next.js의 gzip 압축

    문제의 원인을 찾기 위해 Next.js의 내부 동작을 살펴보았습니다. Next.js 공식 문서에 따르면 아래와 같은 내용이 있습니다.

    Next.js는 기본적으로 next start 또는 커스텀 서버를 사용할 때 렌더링된 콘텐츠와 정적 파일을 압축하기 위해 gzip을 사용합니다.

    압축이 활성화되면 충분한 데이터가 모일 때까지 응답을 버퍼링하게 됩니다. 이는 일반적인 HTTP 응답에서는 성능상 이점이 있지만, SSE 스트리밍에서는 실시간성을 해치는 문제가 됩니다.

    실제로 next.config.js에 다음 설정을 추가하니 문제가 해결되었습니다.

    // next.config.js
    module.exports = {
    compress: false, // 압축 비활성화
    };

    하지만 이 방법에는 고려해야 할 점들이 있었습니다.

    전체 압축 비활성화에 대한 고민

    Next.js 공식 문서에는 다음과 같은 내용이 있었습니다.

    We do not recommend disabling compression unless you have compression configured on your server, as compression reduces bandwidth usage and improves the performance of your application.

    공식 문서에서는 별도의 압축 서버(nginx 등)가 없다면 압축을 비활성화하지 않기를 권장하고 있었습니다. 실제로 압축을 비활성화하면 모든 정적 파일과 API 응답이 압축되지 않은 상태로 전송되어 대역폭 사용량이 증가하고, 특히 JavaScript, CSS 파일의 크기가 커질 수 있습니다.

    우리 프로젝트에서는 nginx 같은 별도의 압축 서버를 사용하지 않고 있었고, SSE 스트리밍이 필요한 엔드포인트는 일부에 불과했습니다. 전체 애플리케이션의 압축을 비활성화하는 것보다는, 해당 엔드포인트만 우회할 수 있는 방법을 찾고 싶었습니다.

    BFF(Backend for Frontend) 패턴으로 해결하기

    더 나은 해결책을 찾던 중, 특정 엔드포인트만 우회할 수 있는 BFF 패턴을 적용하기로 했습니다. Next.js API 라우트를 중간 프록시로 활용하여 백엔드와 직접 통신하는 방식입니다.

    Next.js API Routes에는 현재 gzip 압축이 기본적으로 적용되지 않는 것 같습니다. 관련 내용은 다음 GitHub 이슈에서 찾았습니다.

    Hey! 👋 You're absolutely right—Next.js doesn’t gzip API route responses out of the box. Static files are gzipped in production, but for API routes, compression isn’t built-in. Instead, Next.js expects you to handle this at the server or proxy level (like with Nginx, Vercel, or Cloudflare).

    BFF API 라우트 구현

    import { NextRequest } from 'next/server';
    export async function POST(request: NextRequest) {
    const body = await request.json();
    // 백엔드 API 호출
    const response = await fetch(`${BACKEND_URL}/chat-stream`, {
    method: 'POST',
    headers: {
    'Content-Type': 'application/json',
    Accept: 'text/event-stream',
    },
    body: JSON.stringify(body),
    });
    // 백엔드 응답을 그대로 클라이언트에 전달
    return new Response(response.body, {
    status: response.status,
    statusText: response.statusText,
    headers: response.headers,
    });
    }

    핵심은 백엔드의 스트림 응답을 그대로 클라이언트에 전달하는 것입니다. response.body를 직접 반환함으로써 중간에 버퍼링 없이 스트리밍을 유지할 수 있었습니다.

    클라이언트에서 스트림 처리

    const LAST_LINE = '[DONE]';
    const decodeResponseValues = (values?: Uint8Array): string[] => {
    if (!values) {
    return [];
    }
    const decoder = new TextDecoder('utf-8');
    const decoded = decoder.decode(values, { stream: true });
    return decoded.split('\n\n').map((line) => line.replace('data: ', '').trim());
    };
    export async function* chatStream({ input }: { input: string }): AsyncGenerator<string> {
    try {
    const response = await fetch('/api/chat-stream', {
    method: 'POST',
    headers: {
    'Content-Type': 'application/json',
    },
    body: JSON.stringify({ input }),
    });
    if (!response.body) {
    yield '[ERROR] 서버와의 통신 중 문제가 발생했습니다.';
    return;
    }
    const reader = response.body.getReader();
    while (true) {
    const { value, done } = await reader.read();
    if (done) {
    break;
    }
    for (const line of decodeResponseValues(value)) {
    if (line === LAST_LINE) {
    return;
    }
    try {
    const parsed = JSON.parse(line);
    yield parsed.content;
    } catch {
    yield line;
    }
    }
    }
    } catch (error) {
    yield '[ERROR] 서버와의 통신 중 문제가 발생했습니다.';
    console.error(error);
    }
    }

    Generator 함수를 사용하여 스트림 데이터를 하나씩 처리할 수 있도록 구현했습니다.

    streamedQuery로 더 편리하게 처리하기

    TanStack Query의 실험적 기능인 streamedQuery를 활용하면 스트리밍 데이터를 더 쉽게 관리할 수 있습니다. TanStack Query 문서에서 해당 기능이 experimental인 이유는 작동하지 않는다는 것이 아니라 의견을 받고 요구에 맞게 조정하기 위해서라고 명시하고 있어서 사용해보기로 하였습니다.

    import { queryOptions } from '@tanstack/react-query';
    import { experimental_streamedQuery as streamedQuery } from '@tanstack/react-query';
    export const chatQueries = {
    stream: (question: string | null) =>
    queryOptions({
    queryKey: ['chat-stream', question],
    queryFn: question
    ? streamedQuery({
    queryFn: () => chatStream({ input: question }),
    })
    : async () => [],
    enabled: !!question,
    }),
    };

    컴포넌트에서는 다음과 같이 사용합니다.

    const { data: chatStreamData = [], isFetching } = useQuery(
    chatQueries.stream(currentQuestion)
    );
    return (
    <div>
    {chatStreamData.map((message, index) => (
    <p key={index}>{message}</p>
    ))}
    {isFetching && <p>응답 중...</p>}
    </div>
    );

    streamedQuery는 Generator에서 yield되는 값들을 자동으로 배열에 누적해주어, 실시간으로 업데이트되는 UI를 쉽게 구현할 수 있게 해줍니다.

    마치며

    이번 문제를 해결하면서 가장 어려웠던 점은 관련 정보가 많지 않다는 것이었습니다. Next.js rewrites와 SSE 스트리밍의 조합에서 발생하는 압축 이슈에 대한 명확한 해결책을 찾기 어려웠고, 비슷한 상황을 겪은 사례도 드물었습니다. 하지만 포기하지 않고 끊임없이 파고들어서 공식 문서를 꼼꼼히 읽어보고, GitHub 이슈들을 살펴보면서 실마리를 조금씩 찾았습니다. 이 과정이 저는 꽤 즐거웠는데, 이런 면에서 확실히 개발이 적성에 맞다고 느낍니다 :)

    그리고 이러한 문제들을 해결하려 할 때마다 느끼는 것이 결국 기본기가 중요하다는 점입니다. 내가 작업하고 있는 프로젝트의 아키텍처를 이해하고, 각 구성 요소가 어떤 역할을 하는지 파악해야 이런 문제들의 근본 원인을 더 쉽게 찾을 수 있다고 생각합니다.

    혹시 비슷한 문제로 고민하고 계신 분들께 이 글이 조금이라도 도움이 되었으면 좋겠습니다.


    참고 자료:

  • Next.js 공식 문서 - Compress
  • TanStack Query - Experimental StreamedQuery