remix-fastify 패키지를 참고하면서 Fastify로 Remix 실행하기

remix-fastify 패키지를 참고하면서 Fastify로 Remix 실행하기

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

·

5 min read

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 플러그인으로 정적 파일을 제공할 수 있다.
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"
  }
}