🌐 웹개발

Remix

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 기반으로 개발 경험이 개선되었습니다.

💻 코드 예제

Remix Route (loader + action)
// 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>
    );
}
useFetcher (Optimistic UI)
// 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>
    );
}

🗣️ 실무 대화 예시

💼 프레임워크 선택
"Next.js랑 Remix 중에 뭘 써야 할까요?"

"Next.js는 생태계가 더 크고, ISR/SSG 같은 정적 생성이 강해요. Remix는 동적 콘텐츠, 폼 처리, 인터랙션이 많은 앱에 좋아요. Progressive Enhancement가 중요하거나, 에러 바운더리/로딩 상태 처리를 세밀하게 하고 싶으면 Remix가 낫습니다."
🔍 코드 리뷰
"useEffect에서 데이터 fetch하는 대신 loader 쓰라고요?"

"네, Remix에서는 loader로 서버에서 데이터를 가져와요. 클라이언트 Waterfall이 없고, SEO도 좋고, 로딩 상태도 자동으로 처리돼요. useEffect fetch는 SPA 패턴이고, Remix는 MPA에 가까운 접근법을 씁니다."
📱 기술 면접
"Remix의 Progressive Enhancement가 뭔가요?"

"JavaScript가 로드되기 전에도 HTML form으로 기본 기능이 동작하고, JS 로드 후에 더 나은 UX(SPA 네비게이션, Optimistic UI)로 향상되는 거예요. 네트워크가 느리거나 JS가 실패해도 사이트가 작동하니까 접근성과 안정성이 높아집니다."

⚠️ 주의사항

  • loader/action은 서버 전용 - 브라우저 API(localStorage, window)를 loader/action에서 사용하면 에러가 납니다. 클라이언트 전용 코드는 useEffect 내에서 실행하세요.
  • Form vs form 구분 - Remix의 <Form> 컴포넌트는 JavaScript 향상을 제공하지만, JS 없이도 동작하는 <form>도 여전히 유효합니다. 외부 URL로 submit할 때는 일반 <form>을 쓰세요.
  • React Router와 통합 - Remix v2는 React Router v6.4와 밀접하게 연결됩니다. React Router 문서도 참고해야 Nested Routes, Outlet 등을 완전히 이해할 수 있습니다.

🔗 관련 용어

📚 더 배우기