Remix
Remix Framework
웹 표준 기반 React 프레임워크. 서버 렌더링과 데이터 로딩에 중점.
Remix Framework
웹 표준 기반 React 프레임워크. 서버 렌더링과 데이터 로딩에 중점.
Remix는 React Router 팀(Ryan Florence, Michael Jackson)이 개발한 풀스택 React 프레임워크로, 2022년 Shopify에 인수되었습니다. "웹의 기본으로 돌아가자"는 철학으로, 브라우저의 기본 동작(HTML form, HTTP 캐싱, 점진적 향상)을 최대한 활용합니다. JavaScript가 없어도 기본 기능이 동작하는 Progressive Enhancement를 지향합니다.
Remix의 핵심은 loader와 action입니다. loader는 GET 요청 시 서버에서 데이터를 가져오고, action은 POST/PUT/DELETE 요청을 처리합니다. 이는 전통적인 MVC 패턴과 유사하지만, React 컴포넌트와 긴밀하게 통합됩니다. useLoaderData, useActionData 훅으로 서버 데이터에 접근하며, 별도의 API 엔드포인트 없이 같은 파일에서 서버/클라이언트 코드를 작성합니다.
Remix의 폼 처리는 HTML <form>의 기본 동작을 활용합니다. Form 컴포넌트는 JavaScript가 로드되기 전에도 동작하며, JS 로드 후에는 fetch로 향상됩니다. useFetcher로 여러 mutation을 동시에 처리할 수 있고, 낙관적 업데이트(Optimistic UI)도 쉽게 구현됩니다. 에러 발생 시 폼 상태가 유지되어 사용자 경험이 좋습니다.
Remix v2(2023)에서는 React Router v6.4와 통합되어, 라우팅 패턴이 더욱 강화되었습니다. Nested Routes로 레이아웃 중첩이 자연스럽고, 각 라우트가 독립적으로 데이터를 로드하여 Waterfall 문제를 방지합니다. Cloudflare Workers, Deno, Node.js 등 다양한 서버 런타임을 지원하며, Vite 기반으로 개발 경험이 개선되었습니다.
// app/routes/posts._index.tsx
import type { LoaderFunctionArgs, ActionFunctionArgs } from '@remix-run/node';
import { json, redirect } from '@remix-run/node';
import { useLoaderData, useActionData, Form, useNavigation } from '@remix-run/react';
import { prisma } from '~/lib/prisma.server';
// loader - GET 요청 처리 (데이터 로드)
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const search = url.searchParams.get('q') || '';
const posts = await prisma.post.findMany({
where: search ? {
OR: [
{ title: { contains: search } },
{ content: { contains: search } },
]
} : undefined,
include: { author: { select: { name: true } } },
orderBy: { createdAt: 'desc' },
});
return json({ posts, search });
}
// action - POST/PUT/DELETE 처리 (mutation)
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const intent = formData.get('intent');
if (intent === 'delete') {
const id = formData.get('id') as string;
await prisma.post.delete({ where: { id } });
return json({ success: true });
}
if (intent === 'create') {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// 유효성 검사
const errors: Record<string, string> = {};
if (!title) errors.title = '제목을 입력하세요';
if (!content) errors.content = '내용을 입력하세요';
if (Object.keys(errors).length) {
return json({ errors }, { status: 400 });
}
await prisma.post.create({
data: { title, content, authorId: 1 }
});
return redirect('/posts');
}
return json({ error: 'Invalid intent' }, { status: 400 });
}
// 컴포넌트
export default function PostsIndex() {
const { posts, search } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isCreating = navigation.formData?.get('intent') === 'create';
return (
<div className="max-w-4xl mx-auto py-8">
{/* 검색 폼 - GET 요청 */}
<Form method="get" className="mb-6">
<input
type="search"
name="q"
defaultValue={search}
placeholder="검색어 입력..."
className="border p-2 rounded w-full"
/>
</Form>
{/* 새 글 작성 폼 - POST 요청 */}
<Form method="post" className="mb-8 p-4 border rounded">
<input type="hidden" name="intent" value="create" />
<input
name="title"
placeholder="제목"
className="w-full border p-2 rounded mb-2"
/>
{actionData?.errors?.title && (
<p className="text-red-500 text-sm">{actionData.errors.title}</p>
)}
<textarea
name="content"
placeholder="내용"
rows={4}
className="w-full border p-2 rounded mb-2"
/>
{actionData?.errors?.content && (
<p className="text-red-500 text-sm">{actionData.errors.content}</p>
)}
<button
type="submit"
disabled={isCreating}
className="bg-blue-500 text-white px-4 py-2 rounded disabled:opacity-50"
>
{isCreating ? '저장 중...' : '게시글 작성'}
</button>
</Form>
{/* 게시글 목록 */}
<ul className="space-y-4">
{posts.map(post => (
<li key={post.id} className="p-4 border rounded flex justify-between">
<div>
<h3 className="font-bold">{post.title}</h3>
<p className="text-gray-600">{post.author.name}</p>
</div>
<Form method="post">
<input type="hidden" name="intent" value="delete" />
<input type="hidden" name="id" value={post.id} />
<button type="submit" className="text-red-500">
삭제
</button>
</Form>
</li>
))}
</ul>
</div>
);
}
// app/routes/posts.$id.tsx
import { useFetcher } from '@remix-run/react';
// 좋아요 버튼 - Optimistic UI
function LikeButton({ postId, likes, isLiked }: {
postId: string;
likes: number;
isLiked: boolean;
}) {
const fetcher = useFetcher();
// 낙관적 업데이트: 요청 중이면 예상 결과 표시
const optimisticLikes = fetcher.formData
? (isLiked ? likes - 1 : likes + 1)
: likes;
const optimisticIsLiked = fetcher.formData
? !isLiked
: isLiked;
return (
<fetcher.Form method="post" action={`/posts/${postId}/like`}>
<button
type="submit"
className={`flex items-center gap-1 ${
optimisticIsLiked ? 'text-red-500' : 'text-gray-500'
}`}
disabled={fetcher.state !== 'idle'}
>
{optimisticIsLiked ? '❤️' : '🤍'}
<span>{optimisticLikes}</span>
</button>
</fetcher.Form>
);
}
// 여러 fetcher 동시 사용 예시
function CommentSection({ postId }: { postId: string }) {
const fetcher = useFetcher();
const isSubmitting = fetcher.state === 'submitting';
return (
<div>
<fetcher.Form method="post" action={`/posts/${postId}/comments`}>
<textarea
name="content"
placeholder="댓글을 입력하세요..."
className="w-full border p-2 rounded"
disabled={isSubmitting}
/>
<button
type="submit"
disabled={isSubmitting}
className="mt-2 bg-blue-500 text-white px-4 py-2 rounded"
>
{isSubmitting ? '등록 중...' : '댓글 등록'}
</button>
</fetcher.Form>
</div>
);
}