Skip to main content

Command Palette

Search for a command to run...

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

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

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

More from this blog

AI 에이전트에서 작업 맥락을 관리하기 위해 사용한 방법

AI 에이전트의 불편함 Codex CLI와 같은 AI 코딩 에이전트를 통해서 프로젝트 개발 속도를 높일 수 있다. 그리고 혼자 작업했을 때는 놓칠 수 있는 부분도 발생할 수 있는데 AI 에이전트를 통해 보완할 수 있었다. 컴포넌트에 대해서 접근성 태그 작성 API 호출하는 비동기 코드 작성할 때 에러 처리 그리고 AI 에이전트를 잘 활용하면 불가능하

Mar 8, 202610 min read

지시사항을 분할하여 Codex로 효율적인 작업하기

이슈 Codex와 같은 AI 기반 코드 에이전트는 출력 토큰의 수가 제한되어 있다. 그래서 다양한 작업을 하나의 프롬프트에 전부 몰아서 작성하면 원하는 결과가 나오지 않을 수 있다. Codex와 같은 LLM 기반 서비스는 출력 토큰 수를 초과하게 되면 일부 단계가 누락되거나 특정 요소를 과하게 요약할 수 있고 이는 전체적인 답변 퀄리티를 낮출 수 있기 때문이다. 그래서 다양한 AI 공식문서는 특정 작업을 더 작은 단위로 분할하는 방식을 강조한다...

Nov 3, 20255 min read

Key를 활용하여 컴포넌트 상태를 초기화하기

개요 컴포넌트의 상태가 업데이트되어도 컴포넌트 내부에 배치된 컴포넌트의 상태는 그대로 유지되는 경우가 있었다. 예전에는 Props 전달 및 내부에서 useEffect를 통해서 상태를 초기 상태로 업데이트하도록 했는데 useEffect를 남용하게 되면 상태 변화 추적이 어려워지는 문제가 있었다. 그런데 useEffect 외에도 Key를 통해서 컴포넌트 상태를 초기화할 수 있다는 사실을 알게 되었다. 리스트 렌더링 React 프론트엔드에서 목록 형...

Sep 21, 20253 min read

내가 AGENTS.md를 작성하는 방법

Codex CLI 요즘 프로젝트 개발에 Codex CLI를 활용하는 이유는 다음과 같다. 특정 프로젝트를 작업할 때 Claude Code는 종종 사용량을 초과한 것과 다르게 Codex CLI는 ChatGPT Plus 요금제만으로도 토큰 사용량 초과 걱정 없이 충분하게 활용할 수 있었다. 타 CLI 기반 AI 개발 도구에 비해서 요구사항을 더 정확하게 구현하고 꼭 필요한 작업만 진행하기 때문에 코드를 검토하는 시간을 줄일 수 있다. 반복적인 코드...

Sep 21, 20254 min read
N

Nowon Lee

22 posts