remix-fastify 패키지를 참고하면서 Fastify로 Remix 실행하기
remix-fastify 패키지를 참고하면서 Fastify로 Remix 애플리케이션을 실행시키기 위한 과정들을 기록했습니다.

Remix
Remix 공식 웹 사이트에서는 Remix를 다음과 같이 소개하고 있다.
React 기반 풀스택 웹 프레임워크
웹 표준을 준수하면서 사용자 인터페이스에 집중할 수 있다.
기존에 create-vite 명령어로 구성된 React 프로젝트가 서버 사이드 렌더링이 필요할 때 직접 구현하는 방법도 있었지만 `react-router-dom` 기반으로 작성된 Remix 프레임워크를 사용해서도 SSR을 어렵지 않게 구축할 수 있었다.
create remix 명령어로 구성한 Remix 프로젝트는 빌드된 서버를 실행할 때 @remix-run/serve 패키지의 remix-serve 명령어를 실행시킨다. 패키지는 express 패키지를 의존하고 있는 것을 확인했다.
서버 사이드 렌더링을 적용하면 웹 페이지를 웹 브라우저가 아닌 서버에서 렌더링하기 때문에 서버의 부담이 증가할 수 있다.
Fastify
서버 사이드 렌더링을 하기 때문에 가급적 서버의 부담을 줄이고 싶었다.
Fastify는 공식문서, 여러 자료를 찾아본 결과 벤치마크 결과에서 Fastify가 Express보다 성능이 좋다는 걸 확인할 수 있었다. 벤치마크가 정확한 성능을 보장하는 건 아니지만 라이브러리를 결정하는 기준의 하나가 될 수 있다고 생각했다.
타입스크립트를 지원하며 지속적으로 개발이 되는 점도 Fastify를 선택하게 된 요인으로 작용했디.
따라서 Fastify로 Remix 애플리케이션까지 실행시키는 방법이 있을까 찾아봤다.
참고 문서
Remix 공식 깃허브 저장소에서 @remix-run/express, 그리고 공식 문서에서 Fastify 서버로도 구현한 저장소 페이지가 있어서 해당 코드도 참고할 수 있었다.
코드를 작성하고 보니 mcansh/remix-fastify 저장소에 등록된 코드와 거의 같아서, 이 글은 코드를 직접 작성했다기보다는 이미 작성된 코드를 참고하면서 각각의 함수가 어떻게 동작하는지 이해하는 과정을 중점으로 글을 작성했다.
진행
우선 Remix 애플리케이션 세팅 후 필요한 패키지를 설치한다.
ts-node타입스크립트 파일 실행@fastify/middie개발 환경에서는 Vite가 제공하는 미들웨어를 서버에 등록하기 위해 필요하다.@fastify/static서버 내 정적 파일 제공@fastify/compress데이터 압축
npx create-remix@latest folder_name
npm uninstall @remix-run/serve
npm install -D ts-node @fastify/middie
npm install fastify @fastify/compress @fastify/static
server 폴더에는 우선 요청, 응답을 처리할 수 있는 함수가 작성되어있다.
우선 Remix 애플리케이션에서 GET 또는 HEAD가 아닌 네트워크 요청을 보내려면 duplex가 활성화되어야 해서 RequestInit 타입에 duplex 속성을 추가했다.
createRemixRequestHandler는 서버가 요청을 처리할 수 있도록 핸들러를 생성한다. Remix 애플리케이션의 진입점(Entry Point)이 된다.Remix 핸들러는 실제 서버는 아니고 다음과 같은 작업을 처리한다.
서버의 요청, 응답 API를 Fetch API가 사용할 수 있도록 변환해준다.
Fetch API의 응답을 서버의 응답 API로 바꿔준다.
컨텍스트 객체를 반환하는
getLoadContext는 서버에서 실행된다. 컨텍스트에 추가된 값들은loader함수에서 받아서 사용할 수 있다.
toRemixRequest는 Fastify 요청을 나타내는 객체를 Request 객체로 변환한다.toRemixHeaders는 Fastify 요청의 헤더 부분을 Request 객체의 헤더로 복사하는 역할을 한다.toFastifyResponse는 Fetch API의 응답 객체를 Fastify의 응답으로 바꿔준다.
Express와 다르게 Fastify 에서는 Reply 객체의 raw 객체의 메소드를 호출하여 비슷하게 구현할 수 있었다.
import {
AppLoadContext,
ServerBuild,
createReadableStreamFromReadable,
createRequestHandler as createRemixRequestHandler,
writeReadableStreamToWritable,
} from "@remix-run/node";
import { FastifyReply, FastifyRequest } from "fastify";
declare global {
interface RequestInit {
duplex?: "half";
}
}
export type GetLoadContextFunction = (
req: FastifyRequest,
rep: FastifyReply
) => Promise<AppLoadContext> | AppLoadContext;
export type RequestHandler = (
req: FastifyRequest,
rep: FastifyReply
) => Promise<void>;
export default function createRequestHandler({
build,
getLoadContext,
mode = process.env.NODE_ENV,
}: {
build: ServerBuild | (() => Promise<ServerBuild>);
getLoadContext?: GetLoadContextFunction;
mode?: string;
}) {
const remixHandler = createRemixRequestHandler(build, mode);
return async (req: FastifyRequest, rep: FastifyReply) => {
const request = toRemixRequest(req, rep);
const loadContext = await getLoadContext?.(req, rep);
const response = await remixHandler(request, loadContext);
await toFastifyResponse(rep, response);
};
}
export function toRemixHeaders(requestHeaders: FastifyRequest): Headers {
const headers = new Headers();
for (const [key, values] of Object.entries(requestHeaders.headers)) {
if (values) {
if (Array.isArray(values)) {
for (const value of values) {
headers.append(key, value);
}
} else {
headers.set(key, values);
}
}
}
return headers;
}
export function toRemixRequest(
req: FastifyRequest,
rep: FastifyReply
): Request {
const url = new URL(
`${req.protocol}://${req.headers.host}${req.originalUrl}`
);
const controller = new AbortController();
rep.raw.on("close", () => controller.abort());
const init: RequestInit = {
method: req.method,
headers: toRemixHeaders(req),
signal: controller.signal,
};
if (req.method !== "GET" && req.method !== "HEAD") {
init.body = createReadableStreamFromReadable(req.raw);
init.duplex = "half";
}
return new Request(url.href, init);
}
export async function toFastifyResponse(rep: FastifyReply, response: Response) {
rep.raw.statusMessage = response.statusText;
rep.statusCode = response.status;
for (const [key, value] of response.headers.entries()) {
rep.header(key, value);
}
if (response.headers.get("Content-Type")?.match(/text\/event-stream/i)) {
rep.raw.flushHeaders();
}
if (response.body) {
await writeReadableStreamToWritable(response.body, rep.raw);
} else {
rep.raw.end();
}
}
server/index.ts에서 Fastify 서버를 실행할 수 있다.
Fastify는 정식으로 미들웨어를 제공하지 않아 @fastify/middie 같은 외부 라이브러리를 설치하거나, 플러그인을 적용해야 했다.
- 공식문서에서 플러그인으로 미들웨어를 대체하는 방법을 알려주고 있다.
핸들러는 배포 환경일 때는 빌드된 자바스크립트 파일에서 모듈을 불러오는 방법을 사용해야 했다.
핸들러 생성(createRequestHandler) 함수를 불러올 때 경로를 server.js로 설정한 이유는 다음과 같다.
개발 서버를
node --loader ts-node/esm명령어로 실행하기 위함배포 환경에서도 모듈을 불러올 수 있어야 함
resolve함수는 인자로 주어진 파일 경로를 받아서 작업 디렉터리와 연결시킨다.모듈 동적으로 불러오기
정적 파일 제공
개발 환경
createViteServer로 Vite 개발 서버를 생성한다.- appType을 custom으로 설정해야 네트워크 요청을 Fastify 서버가 처리할 수 있다.
@fastify/middie로 Fastify 서버가 미들웨어와 호환될 수 있도록 한다.
배포 환경
- Remix가 빌드되면
client,client/assets폴더가 생성되어@fastify/static플러그인으로 정적 파일을 제공할 수 있다.
- Remix가 빌드되면
import fastifyCompress from "@fastify/compress";
import fastifyStatic from "@fastify/static";
import { ServerBuild } from "@remix-run/node";
import fastify from "fastify";
import * as path from "node:path";
import * as process from "node:process";
import { ViteDevServer, createServer as createViteServer } from "vite";
import createRequestHandler from "./server.js";
const isProduction = process.env.NODE_ENV === "production";
function resolve(filepath: string): string {
return path.resolve(process.cwd(), filepath);
}
async function createServer() {
let viteApp: ViteDevServer | null = null;
if (!isProduction) {
viteApp = await createViteServer({
server: { middlewareMode: true },
appType: "custom",
});
}
const app = fastify({ logger: true, disableRequestLogging: true });
if (viteApp) {
const fastifyMiddie = await import("@fastify/middie").then(
(module) => module.default
);
await app.register(fastifyMiddie);
await app.use(viteApp.middlewares);
} else {
await app.register(fastifyStatic, {
root: resolve("build/client/assets"),
prefix: "/assets",
wildcard: true,
decorateReply: false,
});
await app.register(fastifyStatic, {
root: resolve("build/client"),
wildcard: false,
prefix: "/",
});
await app.register(fastifyCompress);
}
await app.register(async function (middleServer) {
middleServer.removeAllContentTypeParsers();
middleServer.addContentTypeParser("*", (_, payload, done) => {
done(null, payload);
});
middleServer.all("*", async (req, rep) => {
try {
if (isProduction) {
const handler = createRequestHandler({
build: () => import(resolve("./build/server/index.js"!)),
});
return handler(req, rep);
} else {
const handler = createRequestHandler({
build: () =>
viteApp!.ssrLoadModule(
"virtual:remix/server-build"
) as Promise<ServerBuild>,
});
return handler(req, rep);
}
} catch (error) {
console.error(error);
return rep.status(500).send(error);
}
});
});
const port = process.env.PORT ? Number(process.env.PORT) : 3000;
app.listen({ port }, () => {
console.log(`App listening on http://localhost:${port}`);
});
}
createServer();
package.json에서 개발 서버, 배포 서버를 실행하는 명령어를 업데이트한 모습이다.
빌드된 서버를 실행할 때는 tsc 명령어로 서버 코드를 자바스크립트 파일로 트랜스파일하는 과정을 거쳤다.
{
"scripts": {
"dev": "NODE_ENV=development node --loader ts-node/esm ./server/index.ts",
"start": "tsc ./server/*.ts --module nodenext && NODE_ENV=production node ./server/index.js"
}
}
