• 모닥위키모닥위키
  • 모닥위키
위키
  • 임의문서
  • 주간인기
  • 문서
  • 시리즈
    AAAdddvvveeerrrtttiiissseeemmmeeennntttAdvertisement

    © 2025 modak.wiki All rights reserved.

      API Route 스트림으로 응답하기

      Chapter 4 - API Route 활용법

      컴퓨터/IT학습, 작업기록
      lu

      luasenvy (luasenvy)

      CC BY 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 패키지를 통해 파일의 앞부분 4100(4KB) 청크를 읽어 파일 유형을 특정하는데 read-chunk의 경우 그렇게 어려운 코드가 아니어서 lib/stream.ts에 포함시키고 stream을 생성하기전에 콘텐츠 유형을 알아내도록 하였다. 이 값은 file-type의 기본값으로 다른 예제인 stream sample size의 경우 1024를 사용한다. 많은 파일들이 시그니쳐 사인을 최초 몇 바이트 내에 위치시키기는 것을 생각한다면 필요시 더 줄이거나 늘이는 등 더 최적화할 수 있다.

      이렇게 구현한 이유는 응답 전 생성되는 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 헤더를 제외해야 한다.


      연관 링크

      • How to stream data over HTTP using NextJS
      • file-type stream sample size
      • file-type read chunk
      • read-chunk
      • MDN Transfer-Encoding

      초판: 2025. 08. 03. 00:31:34

      © 2025 이 문서는 "CC BY 4.0 국제규약" 라이선스로 배포 되었습니다. 모든 권리는 저자에게 있습니다.

      API Route 스트림으로 응답하기

      App Router API Route
      Fetch API Response
      Stream Response
      StreamResponse 구현
      성능 개선
      연관 링크