App Router API Route
next.js에서 App Router는 지정된 파일명 route.ts
파일을 구현하는 것으로 API 라우트를 웹 어플리케이션에 탑재할 수 있는 인터페이스를 제공한다.
Fetch API Response
route.ts는 Fetch API Response를 반환하는 것으로 어떤 데이터를 클라이언트에게로 전송할지 결정할 수 있다.
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 구현
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로 바꿀 필요가 없기 때문에 분기하도록 하였지만 조금 더 생각해볼만한 구간이다.
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으로 응답하는 것을 확인하였다면 가장 큰 작업은 끝났으니 예외처리와 성능을 개선할 수 있는 작업들을 조금만 진행해보자.
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
이 제공하는 함수들은 모두 비동기방식이기 때문에 생성자 내부에서 활용하기에는 적절치 않기 때문이다.
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
Footnotes
-
이 값은 file-type의 기본값으로, 다른 예제인 stream sample size의 경우 1024를 사용한다. 많은 파일들이 시그니쳐 사인을 최초 몇 바이트 내에 위치시키기는 것을 생각한다면 필요시 더 줄이거나 늘이는 등 더 최적화할 수 있다. ↩