React Router 7과 Remix 통합: 풀스택 라우팅 완전 가이드

프론트엔드

React Router 7Remix라우팅풀스택SSR

이 글은 누구를 위한 것인가

  • Remix 앱을 React Router 7로 마이그레이션하려는 팀
  • loader/action 패턴으로 풀스택 라우팅을 구현하려는 개발자
  • Next.js 대신 Remix/React Router 7을 고려하는 팀

들어가며

React Router 7은 Remix의 서버 기능을 완전히 흡수했다. loader로 서버 데이터 로딩, action으로 폼 제출, 중첩 라우트로 레이아웃 공유, defer로 Suspense 통합까지 Remix의 강점이 모두 React Router 7에 통합됐다.

이 글은 bluefoxdev.kr의 React Router 7 완전 가이드 를 참고하여 작성했습니다.


1. React Router 7 아키텍처

[React Router 7 = Remix 통합]

기존 분리:
  Remix: 풀스택 (loader, action, SSR)
  React Router 6: 클라이언트 라우팅만

React Router 7:
  두 기능 완전 통합
  Remix 앱 → React Router 7 마이그레이션 단순화

[핵심 개념]

loader (서버 사이드 데이터 로딩):
  GET 요청 시 실행
  컴포넌트 렌더링 전 데이터 준비
  useLoaderData()로 접근

action (폼 제출/뮤테이션):
  POST/PUT/DELETE 요청 시 실행
  폼 제출 후 처리
  useActionData()로 결과 접근

중첩 라우트 (Nested Routes):
  부모 라우트가 자식 렌더링 위치 제공 (<Outlet />)
  각 라우트가 독립적으로 데이터 로딩
  레이아웃 재사용

Deferred Data:
  defer()로 느린 데이터를 Suspense로 스트리밍
  빠른 데이터 먼저 표시, 느린 건 나중에

2. React Router 7 풀스택 구현

// app/routes/products._index.tsx
import {
  type LoaderFunctionArgs,
  type ActionFunctionArgs,
  json,
  redirect,
} from 'react-router';
import { Form, useLoaderData, useNavigation, useActionData } from 'react-router';

// 서버에서 실행 - 데이터 로딩
export async function loader({ request }: LoaderFunctionArgs) {
  const url = new URL(request.url);
  const category = url.searchParams.get('category');
  const page = Number(url.searchParams.get('page') ?? '1');
  
  const [products, total] = await Promise.all([
    db.product.findMany({
      where: category ? { category } : undefined,
      skip: (page - 1) * 20,
      take: 20,
    }),
    db.product.count({ where: category ? { category } : undefined }),
  ]);
  
  // json() 헬퍼로 직렬화 + 타입 안전
  return json({ products, total, page, category });
}

// 서버에서 실행 - 폼 제출 처리
export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const intent = formData.get('intent') as string;
  
  if (intent === 'delete') {
    const id = formData.get('id') as string;
    await db.product.delete({ where: { id } });
    return redirect('/products');
  }
  
  const name = formData.get('name') as string;
  const price = Number(formData.get('price'));
  
  if (!name || price <= 0) {
    return json({ error: '입력값을 확인해주세요' }, { status: 400 });
  }
  
  await db.product.create({ data: { name, price } });
  return redirect('/products');
}

// 컴포넌트
export default function ProductsPage() {
  const { products, total, page } = useLoaderData<typeof loader>();
  const actionData = useActionData<typeof action>();
  const navigation = useNavigation();
  
  const isSubmitting = navigation.state === 'submitting';
  
  return (
    <div>
      {/* React Router Form: action 자동 연결 */}
      <Form method="post">
        <input name="name" placeholder="상품명" />
        <input name="price" type="number" placeholder="가격" />
        {actionData?.error && <p>{actionData.error}</p>}
        <button type="submit" disabled={isSubmitting}>
          {isSubmitting ? '추가 중...' : '상품 추가'}
        </button>
      </Form>
      
      <ul>
        {products.map(product => (
          <li key={product.id}>
            {product.name}
            {/* 삭제 버튼 - 별도 Form */}
            <Form method="post">
              <input type="hidden" name="intent" value="delete" />
              <input type="hidden" name="id" value={product.id} />
              <button type="submit">삭제</button>
            </Form>
          </li>
        ))}
      </ul>
      
      <p>총 {total}개 상품</p>
    </div>
  );
}

// 에러 경계
export function ErrorBoundary() {
  // 이 라우트에서 발생한 에러 처리
  return <div>상품 목록을 불러오는 중 오류가 발생했습니다</div>;
}
// 중첩 라우트 - 레이아웃 공유
// app/routes/dashboard.tsx (레이아웃)
import { Outlet, NavLink } from 'react-router';
import { useLoaderData } from 'react-router';

export async function loader() {
  const user = await getCurrentUser();
  return json({ user });
}

export default function DashboardLayout() {
  const { user } = useLoaderData<typeof loader>();
  
  return (
    <div>
      <nav>
        <span>안녕하세요, {user.name}</span>
        <NavLink to="/dashboard" end>홈</NavLink>
        <NavLink to="/dashboard/products">상품</NavLink>
        <NavLink to="/dashboard/orders">주문</NavLink>
      </nav>
      {/* 자식 라우트가 여기 렌더링 */}
      <main><Outlet /></main>
    </div>
  );
}

// app/routes/dashboard.products.tsx
// → /dashboard/products URL, 부모 레이아웃 유지
export default function DashboardProducts() {
  return <div>상품 관리</div>;
}
// Deferred Data - 스트리밍으로 빠른 응답
import { defer } from 'react-router';
import { useLoaderData, Await } from 'react-router';
import { Suspense } from 'react';

export async function loader() {
  // 빠른 데이터: 즉시 반환
  const user = await getCurrentUser();
  
  // 느린 데이터: Promise로 전달 (await 없음)
  const recentOrdersPromise = fetchRecentOrders(user.id);
  const recommendationsPromise = fetchRecommendations(user.id);
  
  return defer({
    user,                               // 즉시 데이터
    recentOrders: recentOrdersPromise,  // 스트리밍 데이터
    recommendations: recommendationsPromise,
  });
}

export default function HomePage() {
  const { user, recentOrders, recommendations } = useLoaderData<typeof loader>();
  
  return (
    <div>
      {/* 즉시 표시 */}
      <h1>안녕하세요, {user.name}</h1>
      
      {/* Suspense로 스트리밍 데이터 */}
      <Suspense fallback={<OrdersSkeleton />}>
        <Await resolve={recentOrders} errorElement={<p>주문 로딩 실패</p>}>
          {(orders) => (
            <ul>
              {orders.map(order => (
                <li key={order.id}>{order.total}원</li>
              ))}
            </ul>
          )}
        </Await>
      </Suspense>
    </div>
  );
}

// fetcher - 페이지 전환 없이 액션 실행
import { useFetcher } from 'react-router';

function LikeButton({ productId }: { productId: string }) {
  const fetcher = useFetcher<{ liked: boolean }>();
  const liked = fetcher.data?.liked ?? false;
  
  return (
    <fetcher.Form method="post" action="/api/likes">
      <input type="hidden" name="productId" value={productId} />
      <button type="submit">
        {fetcher.state !== 'idle' ? '처리 중...' : (liked ? '♥' : '♡')}
      </button>
    </fetcher.Form>
  );
}

async function getCurrentUser() { return { id: '1', name: '홍길동' }; }
async function fetchRecentOrders(userId: string) { return []; }
async function fetchRecommendations(userId: string) { return []; }
function OrdersSkeleton() { return null; }

마무리

React Router 7의 loader/action 패턴은 MVC와 유사한 명확한 분리를 제공한다. loader는 GET 데이터, action은 뮤테이션, 컴포넌트는 UI만 담당한다. defer는 느린 데이터를 기다리지 않고 스트리밍으로 전달해 TTI를 단축한다. useFetcher는 페이지 전환 없이 좋아요, 읽음 표시 같은 부분 업데이트에 최적화돼 있다. Next.js의 App Router와 비교하면 라우팅 로직이 훨씬 명시적이고 예측 가능하다.