기록

React Router의 <Link> 클릭 시 페이지 이동이 안 되던 현상을 임시로 해결

als982001 2025. 6. 1. 19:24

1. 개요

<Button variant="link" asChild className="text-lg self-center">
  <Link to="/products/leaderboards/daily">Explore all products &rarr;</Link>
</Button>;

 

// routes.ts
import {
  type RouteConfig,
  index,
  prefix,
  route,
} from "@react-router/dev/routes";

export default [
  index("common/pages/home-page.tsx"),
  ...prefix("products", [
    index("features/products/pages/products-page.tsx"),
    ...prefix("leaderboards", [
      index("features/products/pages/leaderboard-page.tsx"),
      route(
        "/daily/:year/:month/:day",
        "features/products/pages/daily-leaderboard-page.tsx"
      ),
      route(
        "/:period",
        "features/products/pages/leaderboards-redirection-page.tsx"
      ),
    ]),
    ...prefix("categories", [
      index("features/products/pages/categories-page.tsx"),
      route("/:category", "features/products/pages/category-page.tsx"),
    ]),
  ]),
] satisfies RouteConfig;

 

 노마드 코더의 Maker 마스터클래스 강의를 듣던 중 발생한 에러이다. 컴포넌트 중 위와 같은 버튼이 있었는데, 이 버튼을 클릭하면 /products/leaderboards/daily로 이동하게 된다. 그리고 routes.ts에 의해 /features/products/pages/leaderboards-redirection-page.tsx에서 우선 loader 함수를 실행한다.

 

// leaderboards-redirection-page.tsx
import { data, redirect } from "react-router";
import { DateTime } from "luxon";

import type { Route } from "./+types/leaderboards-redirection-page";

export function loader({ params }: Route.LoaderArgs) {
  const { period } = params;

  let url: string;

  const today = DateTime.now().setZone("Asia/Seoul");

  if (period === "daily") {
    url = `/products/leaderboards/daily/${today.year}/${today.month}/${today.day}`;
  } else if (period === "weekly") {
    url = `/products/leaderboards/weekly/${today.year}/${today.weekNumber}`;
  } else if (period === "monthly") {
    url = `/products/leaderboards/monthly/${today.year}/${today.month}`;
  } else if (period === "yearly") {
    url = `/products/leaderboards/yearly/${today.year}`;
  } else {
    return data(null, { status: 404 });
  }

  console.log({ period, url });

  return redirect(url);
}

 

이어서 period가 daily이기 때문에, "/products/leaderboards/daily/2025/6/1"로 이동(2025년 6월 1일 기준)하게 되고, routes.ts에 의해 features/products/pages/daily-leaderboard-page.tsx를 실행하게 된다.

 

// daily-leaderboard-page.tsx
import { data, isRouteErrorResponse, Link } from "react-router";

import { DateTime } from "luxon";
import { z } from "zod";

import type { Route } from "./+types/daily-leaderboard-page";

import { Hero } from "~/common/components/hero";
import { ProductCard } from "../components/product-card";
import { Button } from "~/common/components/ui/button";
import ProductPagination from "~/common/components/product-pagination";

const paramsSchema = z.object({
  year: z.coerce.number(),
  month: z.coerce.number(),
  day: z.coerce.number(),
});

export const loader = ({ params }: Route.LoaderArgs) => {
  const { success, data: parsedData } = paramsSchema.safeParse(params);

  console.log({ success, parsedData, params });

  if (!success) {
    throw data(
      { error_code: "invalid_praams", message: "Invalid params" },
      { status: 400 }
    );
  }

  const date = DateTime.fromObject(parsedData).setZone("Asia/Seoul");

  console.log("date", date);

  if (!date.isValid) {
    throw data(
      { error_code: "invalid_date", message: "Invalid date" },
      { status: 400 }
    );
  }

  const today = DateTime.now().setZone("Asia/Seoul").startOf("day");

  console.log("today", today);

  if (date > today) {
    throw data(
      { error_code: "future_date", message: "Future date" },
      { status: 400 }
    );
  }

  return { ...parsedData };
};

export default function DailyLeaderboardPage({
  loaderData,
}: Route.ComponentProps) {
  console.log("loaderData", loaderData);
  const urlDate = DateTime.fromObject({
    year: loaderData.year,
    month: loaderData.month,
    day: loaderData.day,
  });
 // 생략
}

export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
  if (isRouteErrorResponse(error)) {
    return (
      <div>
        {error.data.message} / {error.data.error_code}
      </div>
    );
  }

  if (error instanceof Error) {
    return <div>{error.message}</div>;
  }

  return <div>Unknown error</div>;
}

 

 

 

그런데 위 움짤처럼 제대로 이동이 되지 않는 현상이 있었다. 너무 빠르게 /products/leaderboards 로 리다이렉트 되어서 잠깐 보이는 저 에러 문구는 아래와 같았다.

 

Error loading route module '/app/features/products/pages/daily-leaderboard-page.tsx', reloading page...

 

2. 원인 (추측)

 여러 방법으로 해결해보려고 했는데 계속 실패했다. 그런데 주소창에서 http://localhost:3000/products/leaderboards/daily/2025/6/1를 직접 입력해서 들어가거나, Link를 아래처럼 window.location.href로 변경했을 때는 제대로 이동되는 것을 확인했다.

 

<button
  className="text-lg self-center"
  onClick={() => {
    window.location.href = "/products/leaderboards/daily";
  }}
>
  Explore all products &rarr;
</button>;

 

그리고 React Router 공식 문서랑 같이 확인해 보았는데, 이 현상은 아무래도 React Router v7의 redirect 처리 버그 혹은 history stack 관리 문제가 원인일 수도 있다는 거 같다. React Router v7은 서버 우선 아키텍처를 기반으로 동작하며, 클라이언트 사이드에서의 redirect 처리에는 아직 완벽하지 않은 부분이 있는데, 특히 loader 내부의 redirect()는 서버에선 잘 작동하지만 클라이언트에선 히스토리 또는 모듈 로딩 이슈로 인해 무시될 수 있다. 

 

3. 임시 해결

<Button variant="link" asChild className="text-lg self-center">
  <Link to="/products/leaderboards/daily" reloadDocument>
    Explore all products &rarr;
  </Link>
</Button>

 

 조금 더 찾아보니 reloadDocument라는 것을 알게 되었다. reloadDocument 속성은 페이지 이동 시, SPA의 클라이언트 라우팅을 우회하고 브라우저의 기본 동작인 전체 페이지 리로드를 수행하도록 강제한다. 즉, 클라이언트에서 JavaScript로 라우트를 전환하는 것이 아니라, 새로고침을 한 것처럼 HTML 전체를 서버에서 다시 요청하게 됩니다. 이로 인해, 모듈 로딩 실패나 클라이언트 라우터의 동적 경로 처리 문제와 관계없이 정상적으로 페이지가 로딩될 수 있습니다.

 

 

하지만 이는 임시방편으로 수정한 것이라, 근본적으로는 route 모듈 로딩 문제를 추적해서 해결하는 것이 바람직하다고 생각한다.

 


참고 문서

- Resource Routes

- stack overflow의 글