Remix 완벽 가이드 | Full Stack·Loader·Action·Nested Routes·Progressive Enhancement
이 글의 핵심
Remix로 풀스택 웹 앱을 구축하는 완벽 가이드입니다. Loader, Action, Nested Routes, Form, Error Boundary, Progressive Enhancement까지 실전 예제로 정리했습니다.
실무 경험 공유: 이커머스 플랫폼을 Next.js에서 Remix로 마이그레이션하면서, 초기 로딩 속도를 40% 향상시키고 사용자 경험을 크게 개선한 경험을 공유합니다.
들어가며: “Next.js가 복잡해요”
실무 문제 시나리오
시나리오 1: 데이터 페칭이 복잡해요
getServerSideProps, getStaticProps가 헷갈립니다. Remix는 Loader 하나로 해결합니다.
시나리오 2: Form 처리가 번거로워요
API Route를 따로 만들어야 합니다. Remix는 Action으로 간단합니다.
시나리오 3: JavaScript 없이 동작 안 해요
JS가 로드되기 전까지 아무것도 안 됩니다. Remix는 Progressive Enhancement를 지원합니다.
1. Remix란?
핵심 특징
Remix는 React Router 팀이 만든 풀스택 웹 프레임워크입니다.
주요 장점:
- Loader: 서버 데이터 페칭
- Action: Form 처리 내장
- Nested Routes: 중첩 라우팅
- Progressive Enhancement: JS 없이도 동작
- Web Standards: 표준 Web API 사용
2. 프로젝트 생성
설치
npx create-remix@latest my-remix-app
cd my-remix-app
npm run dev
디렉터리 구조
my-remix-app/
├── app/
│ ├── routes/ # 라우트 파일
│ ├── root.tsx # 루트 레이아웃
│ └── entry.client.tsx # 클라이언트 엔트리
├── public/ # 정적 파일
└── remix.config.js # 설정 파일
3. Loader (데이터 페칭)
기본 사용
// app/routes/posts.tsx
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
export async function loader() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
return json({ posts });
}
export default function Posts() {
const { posts } = useLoaderData<typeof loader>();
return (
<div>
<h1>Posts</h1>
<ul>
{posts.map((post) => (
<li key={post.id}>
<a href={`/posts/${post.id}`}>{post.title}</a>
</li>
))}
</ul>
</div>
);
}
동적 라우트
// app/routes/posts.$postId.tsx
import { json, LoaderFunctionArgs } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
export async function loader({ params }: LoaderFunctionArgs) {
const post = await fetch(`https://api.example.com/posts/${params.postId}`)
.then(r => r.json());
if (!post) {
throw new Response('Not Found', { status: 404 });
}
return json({ post });
}
export default function Post() {
const { post } = useLoaderData<typeof loader>();
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
4. Action (Form 처리)
기본 Form
// app/routes/posts.new.tsx
import { json, redirect, ActionFunctionArgs } from '@remix-run/node';
import { Form, useActionData } from '@remix-run/react';
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const title = formData.get('title');
const content = formData.get('content');
// 유효성 검증
if (!title || !content) {
return json({ error: 'Title and content are required' }, { status: 400 });
}
// 데이터 저장
const post = await fetch('https://api.example.com/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content }),
}).then(r => r.json());
return redirect(`/posts/${post.id}`);
}
export default function NewPost() {
const actionData = useActionData<typeof action>();
return (
<Form method="post">
<div>
<label htmlFor="title">Title</label>
<input id="title" name="title" type="text" required />
</div>
<div>
<label htmlFor="content">Content</label>
<textarea id="content" name="content" required />
</div>
{actionData?.error && <p style={{ color: 'red' }}>{actionData.error}</p>}
<button type="submit">Create Post</button>
</Form>
);
}
Optimistic UI
import { useFetcher } from '@remix-run/react';
export function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
const fetcher = useFetcher();
const likes = fetcher.formData
? Number(fetcher.formData.get('likes'))
: initialLikes;
return (
<fetcher.Form method="post" action={`/posts/${postId}/like`}>
<input type="hidden" name="likes" value={likes + 1} />
<button type="submit">
❤️ {likes}
</button>
</fetcher.Form>
);
}
5. Nested Routes
레이아웃 공유
// app/routes/dashboard.tsx (부모 레이아웃)
import { Outlet } from '@remix-run/react';
export default function Dashboard() {
return (
<div>
<nav>
<a href="/dashboard">Overview</a>
<a href="/dashboard/settings">Settings</a>
<a href="/dashboard/profile">Profile</a>
</nav>
<main>
<Outlet />
</main>
</div>
);
}
// app/routes/dashboard._index.tsx
export default function DashboardIndex() {
return <h1>Dashboard Overview</h1>;
}
// app/routes/dashboard.settings.tsx
export default function Settings() {
return <h1>Settings</h1>;
}
// app/routes/dashboard.profile.tsx
export default function Profile() {
return <h1>Profile</h1>;
}
6. Error Boundary
에러 처리
// app/routes/posts.$postId.tsx
import { isRouteErrorResponse, useRouteError } from '@remix-run/react';
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
if (error.status === 404) {
return (
<div>
<h1>404 - Post Not Found</h1>
<p>The post you're looking for doesn't exist.</p>
</div>
);
}
return (
<div>
<h1>Error {error.status}</h1>
<p>{error.statusText}</p>
</div>
);
}
return (
<div>
<h1>Unexpected Error</h1>
<p>Something went wrong.</p>
</div>
);
}
7. 인증
세션 관리
// app/sessions.ts
import { createCookieSessionStorage } from '@remix-run/node';
export const sessionStorage = createCookieSessionStorage({
cookie: {
name: '__session',
httpOnly: true,
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/',
sameSite: 'lax',
secrets: [process.env.SESSION_SECRET!],
secure: process.env.NODE_ENV === 'production',
},
});
로그인
// app/routes/login.tsx
import { json, redirect, ActionFunctionArgs } from '@remix-run/node';
import { Form, useActionData } from '@remix-run/react';
import { sessionStorage } from '~/sessions';
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const email = formData.get('email');
const password = formData.get('password');
// 인증 로직
const user = await authenticateUser(email, password);
if (!user) {
return json({ error: 'Invalid credentials' }, { status: 401 });
}
// 세션 생성
const session = await sessionStorage.getSession();
session.set('userId', user.id);
return redirect('/dashboard', {
headers: {
'Set-Cookie': await sessionStorage.commitSession(session),
},
});
}
export default function Login() {
const actionData = useActionData<typeof action>();
return (
<Form method="post">
<input name="email" type="email" required />
<input name="password" type="password" required />
{actionData?.error && <p>{actionData.error}</p>}
<button type="submit">Login</button>
</Form>
);
}
Protected Route
// app/routes/dashboard.tsx
import { json, LoaderFunctionArgs, redirect } from '@remix-run/node';
import { sessionStorage } from '~/sessions';
export async function loader({ request }: LoaderFunctionArgs) {
const session = await sessionStorage.getSession(
request.headers.get('Cookie')
);
if (!session.has('userId')) {
return redirect('/login');
}
return json({ userId: session.get('userId') });
}
8. 실전 예제: Todo 앱
// app/routes/todos._index.tsx
import { json, redirect, ActionFunctionArgs } from '@remix-run/node';
import { Form, useLoaderData, useFetcher } from '@remix-run/react';
interface Todo {
id: string;
text: string;
done: boolean;
}
let todos: Todo[] = [
{ id: '1', text: 'Learn Remix', done: false },
{ id: '2', text: 'Build an app', done: false },
];
export async function loader() {
return json({ todos });
}
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const intent = formData.get('intent');
if (intent === 'create') {
const text = formData.get('text') as string;
todos.push({
id: Date.now().toString(),
text,
done: false,
});
} else if (intent === 'toggle') {
const id = formData.get('id') as string;
const todo = todos.find(t => t.id === id);
if (todo) {
todo.done = !todo.done;
}
} else if (intent === 'delete') {
const id = formData.get('id') as string;
todos = todos.filter(t => t.id !== id);
}
return json({ success: true });
}
export default function Todos() {
const { todos } = useLoaderData<typeof loader>();
return (
<div>
<h1>Todo App</h1>
<Form method="post">
<input type="hidden" name="intent" value="create" />
<input name="text" placeholder="What needs to be done?" required />
<button type="submit">Add</button>
</Form>
<ul>
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
</div>
);
}
function TodoItem({ todo }: { todo: Todo }) {
const fetcher = useFetcher();
return (
<li>
<fetcher.Form method="post" style={{ display: 'inline' }}>
<input type="hidden" name="intent" value="toggle" />
<input type="hidden" name="id" value={todo.id} />
<button type="submit">
{todo.done ? '✓' : '○'}
</button>
</fetcher.Form>
<span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
{todo.text}
</span>
<fetcher.Form method="post" style={{ display: 'inline' }}>
<input type="hidden" name="intent" value="delete" />
<input type="hidden" name="id" value={todo.id} />
<button type="submit">Delete</button>
</fetcher.Form>
</li>
);
}
9. 배포
Vercel
npm install -g vercel
vercel
Cloudflare Pages
npm run build
npx wrangler pages deploy ./public
Docker
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
정리 및 체크리스트
핵심 요약
- Remix: React Router 기반 풀스택 프레임워크
- Loader: 서버 데이터 페칭
- Action: Form 처리 내장
- Nested Routes: 중첩 라우팅
- Progressive Enhancement: JS 없이도 동작
- Web Standards: 표준 API 사용
구현 체크리스트
- Remix 프로젝트 생성
- Loader로 데이터 페칭
- Action으로 Form 처리
- Nested Routes 구성
- Error Boundary 구현
- 인증 구현
- 배포
같이 보면 좋은 글
- Next.js 15 완벽 가이드
- React 18 심화 가이드
- tRPC 완벽 가이드
이 글에서 다루는 키워드
Remix, React, Full Stack, SSR, Web Framework, Progressive Enhancement
자주 묻는 질문 (FAQ)
Q. Remix vs Next.js, 어떤 게 나은가요?
A. Remix는 Web Standards를 따르고 Progressive Enhancement를 지원합니다. Next.js는 더 많은 기능과 생태계를 제공합니다. 간단한 앱은 Remix, 복잡한 앱은 Next.js를 권장합니다.
Q. Loader와 Action의 차이는?
A. Loader는 GET 요청 시 데이터를 가져옵니다. Action은 POST/PUT/DELETE 등 Form 제출을 처리합니다.
Q. Progressive Enhancement가 뭔가요?
A. JavaScript가 없어도 기본 기능이 동작하는 것입니다. Remix는 HTML Form을 사용하여 JS 없이도 동작합니다.
Q. 프로덕션에서 사용해도 되나요?
A. 네, Remix는 안정적이며 많은 기업에서 프로덕션 환경에서 사용하고 있습니다.