본문으로 건너뛰기
Previous
Next
Remix 완전 가이드 | 웹 표준 기반 풀스택 React 프레임워크

Remix 완전 가이드 | 웹 표준 기반 풀스택 React 프레임워크

Remix 완전 가이드 | 웹 표준 기반 풀스택 React 프레임워크

이 글의 핵심

Next.js를 대체하는 웹 표준 기반 풀스택 React 프레임워크 Remix. 중첩 라우팅, Loader/Action, Optimistic UI, Progressive Enhancement로 빠르고 탄력적인 웹 앱을 구축합니다. Shopify가 인수했습니다.

이 글의 핵심

Remix는 웹 표준을 밀어붙이는 풀스택 React 프레임워크고, 저는 이걸 쓰면서 “아, 이전엔 왜 이렇게 삽질했지?” 같은 느낌을 자주 받았어요. 아래 코드는 그대로 두고, 설명만 사람 말로 풀어볼게요.

Remix 도입 이야기 (제가 본 채택 흐름)

Remix는 2021년에 공개됐다가 곧바로 Shopify 품으로 들어갔죠. 당시만 해도 “또 하나의 React 프레임워크?”라는 반응이 많았는데, 시간이 지나면서 패턴이 정리됐어요. 특히 2024년쯤 Remix랑 React Router가 합쳐져서 React Router v7 한 벌로 가는 그림이 되면서, “레거시 Remix”랑 “순수 RR” 사이의 간극이 줄었습니다. 즉 팀이 Remix 쪽 생태계를 택하는 이유가 예전처럼 “낯선 별도 세계”가 아니라, 라우터·데이터 로딩 스토리가 한 줄로 이어진다는 쪽으로 바뀐 거죠.

제 경험으로 말하면, 사이드 프로젝트 하나를 Next에서 Remix로 옮겨볼 때 가장 크게 느낀 건 “페이지 단위로 데이터가 박혀 있다”는 감각이었어요. 백엔드 API를 따로 짜고 useEffect로 붙이는 횟수가 확 줄었습니다. 물론 Next가 못 하는 건 아니고, Remix가 그걸 기본값으로 밀어준다는 차이예요.

Remix란?

Remix는 2021년 Ryan Florence와 Michael Jackson이 만든 React 풀스택 프레임워크입니다. 위에서 말한 것처럼 지금은 React Router 쪽이랑도 합쳐진 덕에, 문서를 볼 때 “Remix 옛날 글”과 “RR v7 가이드”를 같이 봐야 할 때가 있습니다. 헷갈리면 공식 문서 최신 트랙을 기준으로 보면 됩니다.

핵심 특징 (제 기준으로 짧게)

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
└── remix.config.js

라우팅

파일 기반 라우팅

// 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>;
}

Loader (데이터 로딩)

솔직히 말해서 loader가 마법이에요. (과장 아님; “마법”이란 표현이 정확히 맞는 순간이 있음.) 라우트 파일 하나에 loader를 박아두면, 그 페이지에 필요한 데이터가 서버에서 먼저 준비되고, 클라이언트로 넘어가도 useLoaderData로 타입이 따라옵니다. 예전에 제가 했던 패턴은 “페이지 컴포넌트 마운트 → useEffect → fetch → setState → 로딩 스피너 지옥”이었는데, 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>
  );
}

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>
  );
}

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>
  );
}

Remix vs Next.js (표 말고 그냥 제 소견)

여기서 표로 갈라 놓으면 깔끔하긴 한데, 실제로 고를 때는 그렇게 단칸으로 떨어지지 않아서요. 제가 쓰는 기준만 말하면 이렇습니다.

웹 표준에 가깝게 가고 싶다 → Remix 쪽이 손이 덜 가요. Response, FormData, 리다이렉트 같은 게 프레임워크가 아니라 웹 API 그 자체에 가깝게 남습니다. Next는 App Router·RSC·Server Actions 쪽으로 자기만의 이야기가 두꺼워졌죠. 나쁘다는 뜻은 아니고, 멘탈 모델이 다릅니다.

중첩 라우팅은 둘 다 할 수 있는데, Remix·RR 쪽은 예전부터 그게 DNA에 박혀 있었고, Next는 App Router 들어오면서 비슷한 풍경이 됐어요. 여기서 싸울 문제는 아니고, 팀이 이미 Next에 수억 줄 투자했다면 굳이 갈아엎을 이유는 없습니다.

Progressive enhancement(JS 꺼져도 폼은 돌아간다)는 Remix가 이야기하기 좋아하는 파트고, 저는 “우리 서비스 사용자가 구형 브라우저/느린망이 얼마나 되지?”를 같이 봅니다. 마케팅 랜딩이면 체감이 크고, 실시간 대시보드면 솔직히 덜요.

Optimistic UI는 Remix에서 useFetcher 패턴이 정말 잘 묶여 있어요. Next에서도 못 하는 건 아닌데, 기본 예제가 그렇게 이어지는지가 다릅니다.

생태계·채용은 Next가 압도적이에요. 그건 인정해야 합니다. 저는 “스타트업 초기에 한 명이 풀스택으로 갈긴다” 같은 상황에서는 Remix가 빨리 결과 내기 좋았고, 대기업 표준 스택을 맞춰야 한다면 Next 쪽 자료가 더 많죠.

배포는 둘 다 어디든 올릴 수 있는데, Next·Vercel 조합이 편한 건 사실입니다. Remix도 Cloudflare·Node·다른 어댑터 잘 돼요.

SSG만 미친 듯이 필요하면 Next가 유리합니다. Remix는 그걸 메인으로 밀지 않았고, 저도 블로그처럼 전부 정적으로 뽑을 거면 Astro나 Next를 먼저 떠올려요.

정리하면, “loader·action이 체질에 맞는지” 한번 스파이크 해보는 게 제일 빠릅니다. 표로 읽는 것보다 거기서 감이 옵니다.


마무리 (제가 다시 고른다면)

Remix를 찬양만 하고 싶진 않아요. 다만 데이터를 라우트에 붙이는 경험은 한 번 맛보면 돌아가기 싫을 수 있어요—특히 저처럼 예전에 useEffect 지옥을 겪은 사람한테는요. 웹 표준을 지키자는 말이 슬로건으로 들리기 쉬운데, 실제로는 “프레임워크가 덜 끼어든다”에 가깝습니다.

다음은 공식 쪽으로 파고들기 좋아요.

시작은 npx create-remix@latest 한 방이면 충분하고, 막히면 loader부터 다시 보면 절반은 풀립니다. loader가 마법이에요. 한 번 써보시죠.