Remix 2로 옮기기: Vite 기반 빌드, Future Flags, Flat Routes, 타입 개선과 배포

이 글의 핵심

Remix 2.x는 Classic Compiler 대신 Vite를 중심으로 한 개발·빌드 경로를 권장하며, v3 Future Flags로 React Router v7로의 이행을 준비합니다. v1에서 올라오는 경우의 v2 플래그와 Flat Routes 규칙, vite.config.ts 전환, CSS·타입·라우트 설정까지 실무에서 바로 쓰는 마이그레이션 순서로 정리합니다.

이 글의 핵심

Remix 마이그레이션은 버전 번호보다 단계로 이해하는 편이 정확합니다. Remix 2.0(2023년 9월)은 v1에서 v2 Future Flags로 미리 켜 두던 동작(flat routes 파일 규칙, 새 meta API, ErrorBoundary 통합 등)을 기본값으로 만든 릴리스였습니다. 이후 2.x 마이너 버전에서 Vite 플러그인이 추가되어 2.7에서 안정화되었고, 2.x 후반부터는 React Router v7로 넘어가기 위한 v3 Future Flags가 추가되었습니다. Remix의 다음 메이저 버전은 React Router v7로 합쳐졌으므로, Remix 2는 사실상 React Router v7로 가는 중간 지점입니다.

이 글은 v1 앱을 v2로 올리고, Vite로 전환하고, v3 플래그를 하나씩 켜는 순서로 정리합니다. loader·action·중첩 라우트에 익숙하다는 전제입니다. 패키지 버전은 시점마다 다르므로 실제 작업 전에 npm info @remix-run/dev version으로 최신 2.x를 확인하세요.


v1에서 v2로: v2 Future Flags

v1 앱은 바로 2.0으로 올리지 말고, v1 마지막 버전에서 v2 Future Flags를 하나씩 켜서 v2 동작에 미리 맞추는 것이 공식 권장 경로입니다. 모든 플래그를 켠 상태에서 문제가 없으면 2.0으로 올렸을 때 바뀌는 것이 거의 없습니다.

플래그바뀌는 동작
v2_routeConvention라우트 파일 규칙이 폴더 중첩에서 점(.) 구분 flat routes로 바뀜
v2_errorBoundaryCatchBoundary가 없어지고 ErrorBoundary 하나에서 useRouteError로 처리
v2_metameta 함수가 객체 대신 메타 태그 배열을 반환하고, 부모 meta와 자동 병합되지 않음
v2_normalizeFormMethoduseNavigation().formMethod가 대문자("POST")로 정규화
v2_headers자식 라우트에 headers가 없으면 부모의 headers를 사용
v2_dev새 개발 서버(remix dev)와 HMR

가장 큰 작업은 보통 라우트 규칙입니다. v2 기본 규칙에서는 app/routes/concerts.$city.tsx처럼 파일 이름의 점이 URL 구분자가 되고, 레이아웃 공유는 같은 접두사로 표현합니다. 기존 폴더 구조를 당장 바꾸기 어렵다면 @remix-run/v1-route-convention 패키지로 v1 규칙을 유지한 채 v2로 올린 뒤, 라우트 이름 변경은 나중에 따로 진행할 수 있습니다.


Vite로 전환하기

무엇이 바뀌나

Classic Remix Compiler는 esbuild 기반의 자체 빌드 파이프라인이었습니다. Vite 플러그인(@remix-run/dev의 vitePlugin)으로 바꾸면 개발 서버와 HMR은 Vite가, 프로덕션 빌드는 Rollup 기반 Vite 빌드가 맡고, Vite 플러그인 생태계를 그대로 쓸 수 있습니다.

최소 vite.config.ts

// vite.config.ts
import { vitePlugin as remix } from '@remix-run/dev';
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';

export default defineConfig({
  plugins: [
    remix({
      // remix.config.js의 옵션 대부분을 여기로 옮김
      ignoredRouteFiles: ['**/.*'],
      future: {
        // v3 플래그는 하나씩 켭니다 (아래 절 참고)
      },
    }),
    tsconfigPaths(),
  ],
});

Classic 컴파일러는 tsconfig.json의 paths(예: ~/* → ./app/*)를 자동으로 반영했지만 Vite는 그렇지 않습니다. 기존 코드가 ~/ import에 의존한다면 vite-tsconfig-paths 플러그인이 필요합니다. remix.config.js는 옵션을 옮긴 뒤 삭제합니다.

package.json 스크립트

{
  "type": "module",
  "scripts": {
    "dev": "remix vite:dev",
    "build": "remix vite:build",
    "start": "remix-serve ./build/server/index.js"
  }
}

빌드 산출물이 build/server와 build/client로 나뉘므로 Dockerfile, PM2 설정, CI의 시작 명령을 함께 고쳐야 합니다. 로컬에서는 되는데 서버에서 모듈을 찾지 못하는 문제는 대부분 이 경로 불일치에서 생깁니다. Vite 설정 파일은 ESM이어야 하므로 "type": "module"이 필요하고, 이 때문에 CommonJS로 작성된 다른 설정 파일(예: postcss.config.js)은 .cjs로 이름을 바꿔야 할 수 있습니다.

코드에서 고칠 것

루트 레이아웃(app/root.tsx)의 <LiveReload />는 Vite HMR이 대신하므로 제거합니다. @remix-run/css-bundle과 cssBundleHref도 필요 없어집니다. Vite는 컴포넌트에서 import './styles.css'처럼 부수 효과로 import한 CSS를 자동으로 번들에 넣습니다. 반대로 links 함수에서 CSS 파일의 URL이 필요할 때는 import stylesUrl from './styles.css?url'처럼 ?url 접미사를 붙여야 URL 문자열을 받습니다. 이것을 빠뜨리면 개발 모드에서는 동작하는 것처럼 보여도 프로덕션에서 스타일이 깨집니다.

tsconfig.json은 "types": ["@remix-run/node", "vite/client"], "module": "ESNext", "moduleResolution": "Bundler"로 맞춥니다. moduleResolution이 예전 값(node)으로 남아 있으면 package.json의 exports 필드를 해석하지 못해 타입 오류가 나거나, 타입 검사는 통과하는데 실제 해석 결과가 달라지는 경우가 생깁니다. 내용이 Remix 타입 참조뿐인 remix.env.d.ts는 삭제해도 됩니다.

서버 진입점과 installGlobals

Remix 2의 Node 어댑터는 Node 18을 지원하며 fetch 등을 위해 @remix-run/node의 installGlobals()로 undici 기반 폴리필을 설치했습니다. 커스텀 서버(Express 등)를 쓰고 있다면 Vite 개발 서버를 미들웨어로 붙이도록 서버 코드를 바꿔야 합니다. 개발 중에는 vite.createServer({ server: { middlewareMode: true } })로 만든 서버의 ssrLoadModule로 빌드를 불러오고, 프로덕션에서는 빌드된 build/server/index.js를 import하는 구조입니다. 공식 Vite 마이그레이션 문서의 커스텀 서버 예제를 그대로 따르는 편이 안전합니다.

installGlobals() 제거는 Single Fetch(v3_singleFetch)를 켤 때 함께 다룹니다. Single Fetch는 Node 20 이상의 네이티브 fetch를 전제로 하므로, 이 단계에서 installGlobals()를 제거하거나 installGlobals({ nativeFetch: true })로 바꿉니다. React Router v7은 Node 20 이상만 지원합니다.


v3 Future Flags: React Router v7 준비

v3 플래그는 React Router v7에서 기본값이 될 동작을 Remix 2에서 미리 켜 보는 장치입니다. 성격이 서로 다르므로 한 번에 켜지 말고 하나씩 켜서 테스트와 배포를 마친 뒤 다음으로 넘어갑니다.

플래그바뀌는 동작
v3_fetcherPersistfetcher가 컴포넌트 언마운트 시 바로 사라지지 않고 요청이 끝날 때까지 유지
v3_relativeSplatPath스플랫(*) 라우트 안에서 상대 경로 링크의 기준이 바뀜
v3_throwAbortReason요청이 중단되면 request.signal.reason을 던짐
v3_singleFetch내비게이션마다 loader별 요청 대신 한 번의 요청으로 데이터를 가져오고, turbo-stream으로 직렬화
v3_lazyRouteDiscovery라우트 매니페스트를 처음에 전부 내려보내지 않고 필요할 때 가져옴
v3_routeConfigapp/routes.ts로 라우트를 정의

v3_relativeSplatPath는 routes/files.$.tsx 같은 스플랫 라우트 안에서 <Link to="../x"> 같은 상대 링크를 쓰는 앱에서 링크 대상이 바뀌므로, 켜기 전에 해당 링크를 모두 찾아 확인해야 합니다.

v3_singleFetch는 영향이 가장 큽니다. loader가 반환한 값을 JSON이 아니라 turbo-stream으로 직렬화하므로 Date, Map, Promise 같은 값도 그대로 전달되고, 그 결과 json()과 defer() 유틸리티가 필요 없어져 deprecated 됩니다. 일반 객체를 그대로 반환하고, 상태 코드나 헤더를 지정해야 할 때는 data() 유틸리티를 씁니다. 헤더 처리 방식과 캐시 동작도 바뀌므로 공식 Single Fetch 문서의 마이그레이션 절을 따라야 합니다.

// v3_singleFetch 이전
import { json } from '@remix-run/node';
export async function loader() {
  return json({ user }, { headers: { 'Cache-Control': 'max-age=60' } });
}

// v3_singleFetch 이후
import { data } from '@remix-run/node';
export async function loader() {
  return data({ user }, { headers: { 'Cache-Control': 'max-age=60' } });
  // 상태·헤더가 필요 없으면 그냥 return { user };
}

타입 안정성

useLoaderData<typeof loader>

loader의 반환 타입을 컴포넌트에서 따로 선언하지 말고 useLoaderData<typeof loader>()로 추론하게 하면, loader가 바뀔 때 컴포넌트 쪽 타입도 함께 바뀝니다.

// app/routes/example.tsx
import type { LoaderFunctionArgs } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';

export async function loader({ request }: LoaderFunctionArgs) {
  const userId = new URL(request.url).searchParams.get('uid') ?? 'guest';
  return { userId, fetchedAt: new Date() };
}

export default function Example() {
  const { userId, fetchedAt } = useLoaderData<typeof loader>();
  // Single Fetch 타입 보강을 켜면 fetchedAt은 Date, 아니면 직렬화된 string으로 추론됨
  return <p>{userId}</p>;
}

Single Fetch를 켰다면 @remix-run/node의 Future 인터페이스에 v3_singleFetch: true를 선언하는 타입 보강(module augmentation)을 추가해야 useLoaderData가 turbo-stream 직렬화 기준으로 타입을 추론합니다. 이것을 빠뜨리면 실제 값은 Date인데 타입은 string으로 나오는 불일치가 생깁니다.

SerializeFrom으로 loader 데이터 타입을 꺼내 쓰던 코드는 Single Fetch에서 deprecated 되었으므로 typeof loader 기반 추론으로 바꿉니다. loader가 외부 API나 DB 데이터를 그대로 반환한다면, Zod 같은 도구로 런타임 검증을 붙여 타입과 실제 데이터가 어긋나는 상황을 막을 수 있습니다.


routes.ts와 라우트 설정

v3_routeConfig를 켜면 app/routes.ts가 라우트 정의의 진입점이 됩니다. 기존 파일 기반 규칙을 그대로 쓰려면 @remix-run/fs-routes의 flatRoutes()를 반환합니다.

// app/routes.ts
import type { RouteConfig } from '@remix-run/route-config';
import { flatRoutes } from '@remix-run/fs-routes';

export default flatRoutes() satisfies RouteConfig;

이미 Vite 플러그인의 routes 옵션으로 remix-flat-routes 같은 서드파티 규칙이나 v1-route-convention을 쓰고 있었다면, @remix-run/routes-option-adapter로 그 설정을 routes.ts 안에 그대로 연결할 수 있습니다. 파일 기반 라우트와 route() 함수로 정의한 코드 기반 라우트는 배열로 합칠 수 있으므로, 대규모 앱에서는 일부만 먼저 코드 기반으로 옮기는 점진적 이전도 가능합니다. routes.ts 형식은 React Router v7의 라우트 설정과 같으므로, 이 단계를 마치면 v7 업그레이드가 크게 단순해집니다.


마이그레이션 순서

운영 중인 서비스라면 다음 순서로 진행하고, 각 단계를 별도 커밋과 배포로 나누는 것이 안전합니다. v1이라면 먼저 v1 마지막 버전에서 v2 플래그를 하나씩 켜고 2.0으로 올립니다. 그다음 @remix-run/* 패키지를 같은 2.x 최신 버전으로 맞추는데, 이때 출력되는 deprecation 경고가 이후 작업 목록이 됩니다. 이어서 Node 버전을 개발·CI·프로덕션에서 같은 LTS로 통일하고, Vite로 전환해 개발 서버와 프로덕션 빌드가 모두 동작하는지 확인합니다. 그 뒤 v3 플래그를 하나씩 켜고, 마지막으로 routes.ts를 도입합니다.

이렇게 나누면 단계마다의 커밋이 그대로 롤백 지점이 되고, 문제가 생겼을 때 어느 변경이 원인인지 좁히기 쉽습니다. 각 단계는 개발 서버와 빌드 로그에서 해당 경고가 사라진 것을 확인하고 다음으로 넘어갑니다.


프로젝트 구조

project/
├── app/
│   ├── routes/                 # 파일 기반 라우트
│   ├── routes.ts               # v3_routeConfig 사용 시
│   ├── root.tsx
│   ├── entry.client.tsx
│   └── entry.server.tsx
├── public/
├── vite.config.ts
├── tsconfig.json
└── package.json

도메인 경계가 뚜렷한 팀은 app/features/<도메인> 아래에 컴포넌트, 서버 유틸리티, 테스트를 모으고 라우트 파일은 loader·action과 화면 조립만 남기는 구조를 많이 씁니다. 서버 전용 코드는 파일 이름을 *.server.ts로 지으면 클라이언트 번들에 섞여 들어가는 것을 빌드 단계에서 막을 수 있습니다.


트러블슈팅

개발 서버는 뜨는데 HMR만 이상하다면 브라우저 캐시, 개발 서버 앞의 프록시, Vite server.hmr 설정을 확인합니다. 프로덕션에서만 스타일이 깨진다면 links에서 쓰는 CSS import에 ?url이 붙어 있는지, 빌드 결과물에 해당 CSS 파일이 실제로 들어 있는지 확인합니다. remix-serve가 파일을 찾지 못하면 시작 스크립트의 경로와 작업 디렉터리가 CI 산출물 구조와 맞는지 봅니다. 클라이언트 번들 빌드에서 fs나 DB 드라이버 관련 오류가 나면 서버 전용 모듈이 라우트 컴포넌트 쪽에서 import된 것이므로 .server.ts로 분리합니다.


배포와 운영

빌드 산출물과 실행

remix vite:build는 서버 번들(build/server)과 클라이언트 자산(build/client)을 따로 만듭니다. remix-serve는 둘을 함께 서빙하는 가장 단순한 Node 서버이고, 실서비스에서는 이를 systemd·PM2·컨테이너·PaaS 위에서 실행합니다. 정적 자산은 CDN에 올리고 긴 캐시 기간을 주면 좋은데, Vite가 파일 이름에 해시를 붙이므로 build/client/assets 아래 파일은 immutable로 캐시해도 안전합니다.

어댑터

Cloudflare Workers, Deno, AWS Lambda 같은 대상은 파일 시스템, 스트리밍, 타이머 동작이 Node와 다릅니다. loader에서 Node 전용 모듈을 import하면 빌드는 통과해도 해당 런타임에서 실행할 때만 실패하는 경우가 있어, CI에서 대상 런타임(예: wrangler dev)으로 실제 요청을 보내 보는 단계를 두는 편이 안전합니다.

스트리밍과 프록시

defer나 Single Fetch의 Promise 스트리밍을 쓰는 경우, Nginx나 CDN이 응답을 버퍼링하면 데이터가 준비되는 대로 흘려보내지 못하고 마지막에 한꺼번에 도착합니다. Nginx라면 해당 경로에 proxy_buffering off를 설정하거나 응답에 X-Accel-Buffering: no 헤더를 붙이고, 압축과 타임아웃 설정도 함께 확인합니다.

관측

서버에서는 요청 ID, 라우트 ID, loader 실행 시간을 기록하고, 클라이언트에서는 Web Vitals와 하이드레이션 오류를 수집합니다. 프로덕션 빌드에서는 ErrorBoundary에 스택이 노출되지 않으므로, entry.server.tsx의 handleError export에서 서버 오류를 로깅 시스템으로 보내야 원인을 추적할 수 있습니다.


같이 보면 좋은 글