logo

Photo: AI Generated by @luasenvy © CC BY 4.0

스트림으로 응답하기

Fetch API Response 활용 ReadableStream으로 응답하기

#nextjs #FetchAPI #StreamResponse

34 조회

0 추천

1,564 단어

8분 예상

2025. 08. 03. 게시

2025. 08. 03. 수정

luasenvy 작성

CC BY-SA 4.0 국제규약

App Router API Route

next.js에서 App Router는 지정된 파일명 route.ts 파일을 구현하는 것으로 API 라우트를 웹 어플리케이션에 탑재할 수 있는 인터페이스를 제공한다.

Fetch API Response

route.ts는 Fetch API Response를 반환하는 것으로 어떤 데이터를 클라이언트에게로 전송할지 결정할 수 있다.

app/api/something/route.ts
export async function GET() {
  // return Response.json({ hello: "world" });
  return new Response(null, { status: 204 });
}

Response 객체를 활용하면 위처럼 쉽게 어떤 데이터를 전송할지 구현할 수 있다.

Stream Response

JSON, 이미지, 텍스트, 버퍼 등 FetchAPI를 활용하면 쉽게 정의할 수 있지만 점점 관리할 파일들이 늘어나면 서버의 메모리 효율에 신경이 갈 수 밖에 없다. 최근 이미지 관련 서비스를 준비하면서 자연스럽게 스트림을 응답하는 방법에 대해 찾게 되었다.

http 패키지나 expressjs를 통하여 서버를 구현할 때에는 stream.pipe(res)와 같은 내장함수를 활용하여 간단하게 구현할 수 있었다. 그러나 FetchAPI의 경우 Response 객체에 스트림을 파이핑해줄 수 있는 기능이 없기 때문에 ReadableStream을 통하여 chunk를 전송하는 방법을 활용해야 한다.

StreamResponse 구현

lib/stream.ts
import type { ReadStream } from "fs";

export class ReadableStreamResponse extends Response {
  constructor(stream: ReadStream, init?: ResponseInit) {
    const readable = new ReadableStream({
      async start(controller) {
        const encoder = new TextEncoder();

        stream
          .on("data", (chunk) => {
            if (chunk instanceof Buffer) controller.enqueue(chunk);
            else controller.enqueue(encoder.encode(chunk.toString()));
          })
          .on("error", (err) => controller.error(err))
          .on("end", () => controller.close());
      },
    });

    super(readable, init);
  }
}

Response 클래스를 확장하여 API Route에서 스트림을 응답할 수 있도록 구현한다. ReadableStream의 생성자 파라미터로 start 함수를 정의할 수 있는데 여기서 생성자로 전달받은 stream을 활용할 수 있다.

스트림의 data 이벤트로 전달되는 chunk의 경우 string | Buffer<ArrayBufferLike> 타입형을 가지고 있으므로 그에 맞게 controller.enqueue() 함수로 넘겨주면 된다. 위 코드에서는 Buffer의 경우 굳이 TextEncoder를 통하여 다시 Unit8Array로 바꿀 필요가 없기 때문에 분기하도록 하였지만 조금 더 생각해볼만한 구간이다.

app/api/something/route.ts
import { createReadStream } from "fs";
import type { NextRequest } from "next/server";
import { ReadableStreamResponse } from "@/lib/stream";

export async function GET(req: NextRequest) {
  const filepath = req.nextUrl.searchParams.get("filepath");
  const stream = createReadStream(filepath);
  return new ReadableStreamResponse(stream);
}

다시 route.ts로 돌아와서 구현된 ReadableStreamResponse 객체를 응답하도록 하고 정상적으로 작동하는지 확인하면 된다.

성능 개선

Stream으로 응답하는 것을 확인하였다면 가장 큰 작업은 끝났으니 예외처리와 성능을 개선할 수 있는 작업들을 조금만 진행해보자.

app/api/something/route.ts
import { createReadStream, existsSync } from "fs";
import type { NextRequest } from "next/server";
import { ReadableStreamResponse } from "@/lib/stream";

export async function GET(req: NextRequest) {
  const filepath = req.nextUrl.searchParams.get("filepath");
  const stream = createReadStream(filepath);

  // 존재하지 않는다면 404
  if (!existsSync(filepath)) return new Response("Not Found", { status: 404 });

  const chunk = readChunk(filepath, 1024);
  const ftype = await fileTypeFromBuffer(chunk);

  return new ReadableStreamResponse(createReadStream(filepath), {
    headers: {
      "Content-Type": ftype?.mime || "application/octet-stream",
    },
  });
}

패키지 file-type은 여러가지 방법으로 파일의 유형을 확인할 수 있는 함수를 제공한다. 가장 자원 효율적으로 파일의 유형을 특정하기 위해 file-type 패키지의 fileTypeFromBuffer를 활용하였다. 예제에서 read-chunk 패키지를 통해 파일의 앞부분 41004KB 청크를 읽어 파일 유형을 특정1하는데, read-chunk의 경우 그렇게 어려운 코드가 아니어서 lib/stream.ts에 포함시키고 stream을 생성하기전에 콘텐츠 유형을 알아내도록 하였다.

이렇게 구현한 이유는 응답 전 생성되는 ReadStream을 fileTypeFromStream에 재활용할 수 없기 때문이기도 하다. 스트림에는 커서가 있어서 조금이라도 읽으면 이동하여 그만큼 전달할 수 없기 때문이다.

또한, ReadableStreamResponse의 생성자에서 일련의 작업을 포함시켜 route내에서의 가독성을 높이는 것 또한 실패하였는데 file-type이 제공하는 함수들은 모두 비동기방식이기 때문에 생성자 내부에서 활용하기에는 적절치 않기 때문이다.

lib/stream.ts
import type { ReadStream } from "fs";
import { closeSync, openSync, PathLike, readSync, statSync } from "fs";
import { lookup } from "mime-types";
import { extname } from "path";

export function readChunk(filepath: PathLike, length: number) {
  let buffer = new Uint8Array(length);
  const descriptor = openSync(filepath, "r");

  try {
    const bytesRead = readSync(descriptor, buffer, { length });

    if (bytesRead < length) buffer = buffer.subarray(0, bytesRead);

    return buffer;
  } finally {
    closeSync(descriptor);
  }
}

export class ReadableStreamResponse extends Response {
  constructor(stream: ReadStream, init?: ResponseInit) {
    const filepath = stream.path as string;
    const { mtime } = statSync(filepath);

    const readable = new ReadableStream({
      async start(controller) {
        const encoder = new TextEncoder();

        stream
          .on("data", (chunk) => {
            if (chunk instanceof Buffer) controller.enqueue(chunk);
            else controller.enqueue(encoder.encode(chunk.toString()));
          })
          .on("error", (err) => controller.error(err))
          .on("end", () => controller.close());
      },
    });

    const headers = init?.headers instanceof Headers ? init?.headers : new Headers(init?.headers);

    if (!headers.get("Content-Type"))
      headers.set("Content-Type", lookup(extname(filepath)) || "application/octet-stream");

    headers.set("Last-Modified", mtime.toUTCString());
    headers.set("Transfer-Encoding", "chunked");

    super(readable, {
      ...init,
      headers,
    });
  }
}

파일 스트림의 속성을 통하여 ReadableStreamResponse의 생성자에서 콘텐츠의 유형, 마지막 변경일, 그리고 Transfer-Encoding을 자동으로 헤더에 포함하도록 수정하였다. 앞서 소개했듯이 file-type은 모두 비동기 함수이기 때문에 최대한 route.ts내에서 유형을 특정하도록 하고, 그렇지 못한 경우 파일의 확장자를 통해 콘텐츠 유형을 유추할 수 있도록 mime-types 패키지도 활용하였다. 이도저도 안된다면 바이너리로 파일을 전송할 수 있는 'application/octet-stream' 유형이 지정되도록 하였다. Transfer-Encoding헤더는 사이즈가 큰 데이터를 청킹하여 통신할 때 활용할 수 있는데 기억할 점으로는 반드시 Content-Length 헤더를 제외해야 한다.


참고 링크

Footnotes

  1. 이 값은 file-type의 기본값으로, 다른 예제인 stream sample size의 경우 1024를 사용한다. 많은 파일들이 시그니쳐 사인을 최초 몇 바이트 내에 위치시키기는 것을 생각한다면 필요시 더 줄이거나 늘이는 등 더 최적화할 수 있다.

Next.js 15 활용 시리즈