Remix 프레임워크: Loader·Action 중심 데이터 흐름과 Next.js 비교
이 글의 핵심
클라이언트 상태 라이브러리와 API 호출 코드가 계속 불어나는 React 앱에서, Remix는 데이터 읽기와 쓰기를 라우트 단위의 loader·action으로 모으고 웹 표준 Form과 Response를 그대로 씁니다. 중첩 라우트로 레이아웃별 데이터를 나눠 불러오는 구조를 따라가 보고, Next.js 대신 Remix를 고를 만한 상황에 대한 필자의 판단도 담았습니다.
이 글의 핵심
Remix는 웹 표준(Request, Response, FormData)을 최대한 그대로 쓰는 풀스택 React 프레임워크입니다. 데이터를 읽는 loader와 데이터를 바꾸는 action을 라우트 파일에 함께 두고, 폼 제출 뒤에는 관련 데이터를 자동으로 다시 불러오는 구조가 핵심입니다. 이 글은 그 데이터 흐름을 예제로 따라가고, Next.js와 비교해 어떤 상황에서 Remix가 맞는지 정리합니다.
Remix의 흐름: 공개에서 React Router v7까지
Remix는 2021년에 공개되었고 2022년에 Shopify가 개발팀을 인수했습니다. 처음에는 “또 하나의 React 프레임워크”라는 반응이 많았지만, Remix의 라우팅과 데이터 로딩은 원래 같은 팀이 만든 React Router 위에 있었기 때문에 두 프로젝트는 점점 가까워졌습니다. 2024년에 팀은 Remix의 다음 버전을 별도로 내지 않고 React Router v7에 합치기로 발표했고, 그 결과 현재는 React Router v7의 “프레임워크 모드”가 사실상 Remix 2의 후속입니다. 그래서 문서를 찾을 때 “Remix 2 기준 글”과 “React Router v7 가이드”를 같이 보게 되는데, 이 글의 예제는 Remix 2 문법(@remix-run/* 패키지)으로 작성했고, v7로 옮길 때 달라지는 점은 본문에서 따로 짚습니다.
Next.js에서 Remix 방식으로 옮겨 보면 가장 크게 달라지는 것은 “데이터가 페이지 컴포넌트가 아니라 라우트에 붙어 있다”는 감각입니다. 컴포넌트가 마운트된 뒤 useEffect로 API를 호출하고, 로딩·에러 상태를 useState로 관리하던 코드가 라우트의 loader 하나로 줄어듭니다. Next.js로도 같은 구조를 만들 수는 있지만, Remix는 이것을 기본값으로 강제한다는 점이 다릅니다.
Remix란?
Remix는 React Router를 만든 Ryan Florence와 Michael Jackson이 만든 React 풀스택 프레임워크입니다. 서버에서 실행되는 loader·action과 브라우저에서 실행되는 컴포넌트를 한 파일에 두고, 서버 렌더링된 HTML이 먼저 도착한 뒤 JavaScript가 로드되면 클라이언트 라우팅으로 전환됩니다.
핵심 특징
1. 웹 표준 우선
// Remix: 웹 표준 Response
export async function loader() {
return new Response(JSON.stringify({ message: 'Hello' }), {
headers: { 'Content-Type': 'application/json' },
});
}
// Next.js: 자체 API
export async function getServerSideProps() {
return { props: { message: 'Hello' } };
}
2. 중첩 라우팅
/dashboard
├─ Layout (공통)
├─ /dashboard/settings
│ ├─ Settings Layout
│ └─ /dashboard/settings/profile
└─ /dashboard/analytics
3. Progressive Enhancement
// JavaScript 없이도 작동
<form method="post">
<input name="email" />
<button type="submit">Submit</button>
</form>
// JavaScript 로드 후 자동으로 AJAX로 전환
4. Optimistic UI
// 서버 응답 전에 UI 즉시 업데이트
const fetcher = useFetcher();
const optimisticData = fetcher.formData
? { ...data, name: fetcher.formData.get('name') }
: data;
Remix 시작하기
프로젝트 생성
# Remix 프로젝트 생성
npx create-remix@latest my-remix-app
# 옵션 선택:
# - Template: Remix App Server
# - TypeScript: Yes
# - Install dependencies: Yes
cd my-remix-app
npm run dev
프로젝트 구조
my-remix-app/
├── app/
│ ├── routes/
│ │ ├── _index.tsx # 홈페이지
│ │ ├── about.tsx # /about
│ │ └── blog.$slug.tsx # /blog/:slug
│ ├── root.tsx # 루트 레이아웃
│ └── entry.server.tsx
├── public/
├── package.json
└── vite.config.ts
Remix 2.x 후반부터는 번들러가 Vite로 바뀌어 설정 파일이 remix.config.js 대신 vite.config.ts가 되었고, 오래된 튜토리얼의 “Remix App Server” 템플릿 선택지도 사라졌습니다. 새로 시작한다면 React Router v7 템플릿(npx create-react-router@latest)으로 만드는 것이 현재 공식 권장 경로이며, 파일 이름 규칙과 loader·action 개념은 그대로 이어집니다. 라우트 파일 이름의 .은 URL의 /가 되고, $slug는 동적 파라미터, _index는 해당 경로의 인덱스 페이지를 뜻합니다.
라우팅
파일 기반 라우팅
// app/routes/_index.tsx (홈페이지)
export default function Index() {
return <h1>Home Page</h1>;
}
// app/routes/about.tsx (/about)
export default function About() {
return <h1>About Page</h1>;
}
// app/routes/blog.$slug.tsx (/blog/hello-world)
import { useParams } from '@remix-run/react';
export default function BlogPost() {
const { slug } = useParams();
return <h1>Post: {slug}</h1>;
}
중첩 라우팅
// app/routes/dashboard.tsx (Layout)
import { Outlet } from '@remix-run/react';
export default function DashboardLayout() {
return (
<div>
<nav>Dashboard Navigation</nav>
<main>
<Outlet /> {/* 자식 라우트가 여기에 렌더링됨 */}
</main>
</div>
);
}
// app/routes/dashboard._index.tsx (/dashboard)
export default function DashboardIndex() {
return <h1>Dashboard Home</h1>;
}
// app/routes/dashboard.settings.tsx (/dashboard/settings)
export default function Settings() {
return <h1>Settings</h1>;
}
중첩 라우팅이 중요한 이유는 데이터 로딩과 직결되기 때문입니다. /dashboard/settings로 이동하면 Remix는 dashboard.tsx와 dashboard.settings.tsx 두 라우트의 loader를 병렬로 실행합니다. 레이아웃이 사용자 정보를, 자식이 설정 데이터를 따로 불러오더라도 부모 요청이 끝나기를 기다렸다가 자식 요청을 시작하는 워터폴이 생기지 않습니다. 또 /dashboard/settings에서 /dashboard/analytics로 이동할 때는 바뀐 자식 라우트의 loader만 다시 실행되고 레이아웃 데이터는 재사용됩니다. 자식 라우트가 화면에 나타나지 않는 경우는 대부분 부모 레이아웃에 <Outlet />을 빠뜨린 것입니다.
Loader (데이터 로딩)
라우트 파일에 loader를 내보내면 그 페이지에 필요한 데이터가 서버에서 먼저 준비되고, 컴포넌트는 useLoaderData<typeof loader>()로 타입까지 추론된 데이터를 받습니다. “컴포넌트 마운트 → useEffect → fetch → setState → 로딩 스피너”로 이어지던 흐름이 라우트 단위의 계약으로 바뀌는 셈이라, 디버깅할 때도 “이 데이터가 어디서 왔나”를 파일 경로만 보고 찾을 수 있습니다.
loader는 항상 서버에서만 실행됩니다. 첫 요청에서는 서버 렌더링 중에, 이후 클라이언트 내비게이션에서는 브라우저가 해당 라우트의 loader를 fetch 요청으로 호출합니다. 그래서 loader 안에서는 DB 클라이언트나 비밀 키를 안전하게 쓸 수 있지만, 반환값은 그대로 브라우저로 전송된다는 점을 잊으면 안 됩니다. 사용자 레코드를 통째로 반환하면 비밀번호 해시 같은 필드까지 네트워크 응답에 실리므로, 화면에 필요한 필드만 골라 반환해야 합니다. 서버 전용 모듈은 파일 이름을 *.server.ts로 지으면 Remix가 클라이언트 번들에서 제외해 줍니다.
기본 Loader
// app/routes/users.tsx
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
// 서버에서 실행 (SSR)
export async function loader() {
const users = await db.users.findMany();
return json({ users });
}
// 클라이언트 컴포넌트
export default function Users() {
const { users } = useLoaderData<typeof loader>();
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
Dynamic Params
// app/routes/users.$id.tsx
import { LoaderFunctionArgs, json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
export async function loader({ params }: LoaderFunctionArgs) {
const user = await db.users.findUnique({
where: { id: parseInt(params.id!) },
});
if (!user) {
throw new Response('Not Found', { status: 404 });
}
return json({ user });
}
export default function UserProfile() {
const { user } = useLoaderData<typeof loader>();
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
throw new Response('Not Found', { status: 404 })는 Remix다운 에러 처리 방식입니다. loader에서 Response를 던지면 컴포넌트 렌더링이 중단되고 가장 가까운 ErrorBoundary가 useRouteError()로 그 응답을 받아 404 화면을 그립니다. 라우트마다 ErrorBoundary를 둘 수 있으므로 자식 라우트의 에러가 대시보드 레이아웃 전체를 무너뜨리지 않고 해당 영역에만 표시됩니다. parseInt(params.id!)에서 id가 숫자가 아니면 NaN이 되어 DB 조회가 예외를 던질 수 있으므로, 실무에서는 파라미터를 먼저 검증하고 잘못된 값이면 400이나 404를 던지는 편이 좋습니다.
예제의 json() 헬퍼는 Remix 2에서 일반적이던 방식입니다. Remix 2.9부터 도입된 Single Fetch와 React Router v7에서는 json()이 폐기 예정이 되었고 loader에서 평범한 객체를 그대로 반환하면 됩니다. 이 방식에서는 Date나 Map 같은 타입도 직렬화되어 그대로 전달되지만, json()을 쓰던 시절에는 Date가 문자열로 바뀌어 타입과 실제 값이 어긋나는 문제가 흔했습니다.
Action (데이터 변경)
Form Submission
// app/routes/users.new.tsx
import { ActionFunctionArgs, redirect } from '@remix-run/node';
import { Form } from '@remix-run/react';
// POST 요청 처리 (서버)
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const user = await db.users.create({
data: {
name: formData.get('name') as string,
email: formData.get('email') as string,
},
});
return redirect(`/users/${user.id}`);
}
// 클라이언트 컴포넌트
export default function NewUser() {
return (
<Form method="post">
<input name="name" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<button type="submit">Create User</button>
</Form>
);
}
Form 검증
// app/routes/users.new.tsx
import { json } from '@remix-run/node';
import { useActionData } from '@remix-run/react';
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const name = formData.get('name') as string;
const email = formData.get('email') as string;
// 검증
const errors: { name?: string; email?: string } = {};
if (!name || name.length < 2) {
errors.name = 'Name must be at least 2 characters';
}
if (!email || !email.includes('@')) {
errors.email = 'Email must be valid';
}
if (Object.keys(errors).length > 0) {
return json({ errors }, { status: 400 });
}
// 저장
const user = await db.users.create({ data: { name, email } });
return redirect(`/users/${user.id}`);
}
export default function NewUser() {
const actionData = useActionData<typeof action>();
return (
<Form method="post">
<div>
<input name="name" />
{actionData?.errors?.name && <p>{actionData.errors.name}</p>}
</div>
<div>
<input name="email" />
{actionData?.errors?.email && <p>{actionData.errors.email}</p>}
</div>
<button type="submit">Create</button>
</Form>
);
}
검증 실패 시 redirect가 아니라 상태 코드 400과 함께 데이터를 반환하면, 페이지는 그대로 남고 useActionData로 에러 메시지를 받아 폼 옆에 표시할 수 있습니다. 성공했을 때는 redirect로 다른 페이지로 보내는데, 이는 POST 뒤 새로고침 시 폼이 다시 제출되는 문제를 막는 Post/Redirect/Get 패턴입니다. action이 끝나면 Remix는 현재 화면의 모든 loader를 자동으로 다시 실행하므로, 목록 데이터를 수동으로 갱신하거나 캐시를 무효화하는 코드가 필요 없습니다. 이 “자동 재검증”이 Remix 데이터 흐름의 핵심이고, 반대로 loader가 무겁다면 폼 제출마다 모두 다시 실행된다는 비용이 있으므로 shouldRevalidate로 재실행 범위를 조정할 수 있습니다.
주의할 점은 formData.get('name') as string의 타입 단언입니다. get의 실제 반환 타입은 FormDataEntryValue | null이라, 필드가 빠진 요청(누군가 curl로 직접 보낸 요청 등)에서는 null이 들어오고 .length에서 “Cannot read properties of null” 에러가 납니다. 예제처럼 !name 검사를 먼저 하면 막을 수 있지만, 필드가 많아지면 Zod 같은 스키마 검증 라이브러리로 Object.fromEntries(formData)를 한 번에 검증하는 편이 안전합니다. 클라이언트의 required나 type="email"은 사용자 편의일 뿐이고, 서버 검증을 대신하지 못합니다.
Optimistic UI
// app/routes/todos.tsx
import { useFetcher } from '@remix-run/react';
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
await db.todos.update({
where: { id: formData.get('id') as string },
data: { completed: formData.get('completed') === 'on' },
});
return json({ success: true });
}
export default function Todos() {
const { todos } = useLoaderData<typeof loader>();
const fetcher = useFetcher();
return (
<ul>
{todos.map((todo) => {
// Optimistic Update
const optimisticCompleted =
fetcher.formData?.get('id') === todo.id
? fetcher.formData.get('completed') === 'on'
: todo.completed;
return (
<li key={todo.id}>
<fetcher.Form method="post">
<input type="hidden" name="id" value={todo.id} />
<input
type="checkbox"
name="completed"
checked={optimisticCompleted}
onChange={(e) => fetcher.submit(e.currentTarget.form)}
/>
{todo.title}
</fetcher.Form>
</li>
);
})}
</ul>
);
}
useFetcher는 페이지 이동 없이 action을 호출하는 도구입니다. 제출이 진행 중인 동안 fetcher.formData에 보낸 값이 들어 있으므로, 서버 응답을 기다리지 않고 그 값으로 체크박스를 먼저 그리는 것이 이 예제의 낙관적 업데이트입니다. 요청이 끝나면 loader가 다시 실행되어 실제 서버 값으로 화면이 맞춰지고, 서버에서 실패하면 원래 값으로 되돌아갑니다.
이 예제를 그대로 쓰면 드러나는 함정이 두 가지 있습니다. 첫째, useFetcher()를 목록 전체에서 하나만 만들었기 때문에 여러 항목을 빠르게 연달아 체크하면 이전 제출이 새 제출로 대체되어, 먼저 누른 항목의 낙관적 상태가 순간적으로 되돌아가 보입니다. 항목마다 독립적인 상태가 필요하면 <TodoItem> 컴포넌트를 분리해 각자 useFetcher()를 갖게 하는 것이 정석입니다. 둘째, 체크를 해제한 체크박스는 HTML 폼 규칙상 값이 아예 전송되지 않으므로 formData.get('completed')가 null이 되고, 예제는 이를 false로 해석해 우연히 맞게 동작합니다. 이 규칙을 모르고 'off' 값을 기대하는 코드를 쓰면 체크 해제가 저장되지 않는 버그가 생깁니다. 또 todo.id가 숫자라면 fetcher.formData.get('id')는 항상 문자열이라 === 비교가 실패하므로 String(todo.id)로 맞춰야 합니다.
Remix vs Next.js
두 프레임워크의 차이는 기능 표로 나누기보다 몇 가지 판단 기준으로 보는 편이 실제 선택에 도움이 됩니다.
웹 표준과의 거리: Remix는 Response, FormData, 리다이렉트를 프레임워크 고유 API가 아니라 웹 API 그대로 다룹니다. 그래서 MDN 문서의 지식이 그대로 통하고, 다른 런타임(Cloudflare Workers, Deno 등)으로 옮기기도 쉽습니다. Next.js는 App Router, React Server Components, Server Actions를 중심으로 고유한 개념이 많아졌습니다. 어느 쪽이 나쁘다기보다 요구하는 멘탈 모델이 다릅니다.
중첩 라우팅: Remix는 처음부터 중첩 라우트와 병렬 데이터 로딩을 중심에 두었고, Next.js도 App Router의 레이아웃으로 비슷한 구조를 갖추게 되었습니다. 이미 Next.js로 큰 코드베이스를 운영하고 있다면 이 차이만으로 옮길 이유는 없습니다.
점진적 향상: JavaScript가 로드되기 전에도 폼이 동작한다는 장점은 느린 모바일 네트워크 사용자가 많은 서비스나, JS 번들이 늦게 도착하는 첫 방문 경험에서 체감됩니다. 로그인 후에만 쓰는 실시간 대시보드라면 상대적으로 중요도가 낮습니다.
생태계와 채용: 자료, 서드파티 통합, 채용 시장은 Next.js가 훨씬 넓습니다. 소규모 팀이 빠르게 풀스택 기능을 만드는 상황에서는 Remix의 단순한 데이터 흐름이 유리할 수 있고, 조직 표준 스택을 따라야 하는 상황에서는 Next.js가 무난합니다.
배포와 정적 생성: 둘 다 Node 서버, 서버리스, 엣지 환경에 배포할 수 있으며 Next.js는 Vercel과의 통합이 가장 매끄럽습니다. 블로그나 문서처럼 대부분을 정적으로 생성하는 사이트라면 Remix보다 Astro나 Next.js의 정적 생성이 더 잘 맞습니다. React Router v7에서 프리렌더링이 추가되었지만 여전히 주력은 서버 렌더링입니다.
결국 가장 빠른 판단 방법은 실제 화면 하나를 loader·action 구조로 만들어 보고 팀에 맞는지 확인하는 것입니다.
마무리
Remix의 가장 큰 가치는 데이터를 라우트에 붙이고, 변경 후 재검증을 프레임워크가 맡는다는 데 있습니다. useEffect와 전역 상태 라이브러리로 서버 데이터를 동기화하던 코드가 많을수록 이 구조의 효과가 큽니다. 웹 표준을 따른다는 말은 슬로건처럼 들리기 쉽지만, 실제로는 “프레임워크 고유 API를 덜 배워도 된다”는 뜻에 가깝습니다. 다만 Remix와 React Router의 합병으로 문서와 패키지 이름이 바뀌는 과도기이므로, 새 프로젝트라면 React Router v7 문서를 기준으로 삼는 것이 좋습니다.
더 깊이 보려면 다음 자료를 참고하세요.
- Remix 공식 문서에서 심화
- Remix GitHub에서 구현 훑기
- Discord에서 커뮤니티
같이 보면 좋은 글
- tRPC: End-to-End 타입 안전 API, 미들웨어·Zod·React Query 통합과 REST 비교
- Fresh 프레임워크
- Qwik 프레임워크
- Astro Islands 아키텍처