Skip to content

ProtectedRoute

이토 edited this page May 29, 2025 · 1 revision

문제 상황

서비스 내 각 페이지마다 로그인 여부, 사용자 권한에 따라 접근을 제한하는 요구사항이 있었습니다.

하지만 초기 구현은 각 페이지 컴포넌트마다 useEffect로 직접 조건을 검사하고, 조건 불일치 시 모달을 띄우거나 리다이렉트하는 방식이었습니다.

이 방식은 다음과 같은 문제를 낳았습니다:

  • 같은 조건 분기 로직이 페이지마다 반복적으로 중복되고,
  • 어떤 페이지가 어떤 권한 조건을 갖는지 한눈에 파악하기 어려우며,
  • 조건 변경 시 모든 페이지를 직접 수정해야 하는 비효율이 발생했습니다.

더 나아가, 신규 조건이 추가될수록 각 페이지의 useEffect 로직은 점점 더 길어지고 불안정해질 수 있는 구조였습니다.

export default function Page() {
  ...
  useEffect(() => {
    if (!user) {
      openModal({
        type: "alert",
        iconType: "warning",
        message: "로그인 후에 이용 가능한 기능입니다.",
        onClose: () => navigate(ROUTES.AUTH.SIGNIN),
      });
      return;
    }
    if (user.type === "employer") {
      openModal({
        type: "alert",
        iconType: "warning",
        message: "알바생 계정으로만 이용 가능한 기능입니다.",
        onClose: () => navigate(ROUTES.SHOP.ROOT),
      });
      return;
    }
  }, []);

  useEffect(() => {
    const fetchUser = async () => {
      if (!user?.id) return;
      const res = await getUser(user.id);

      if (!res.data.item?.name) {
        navigate(ROUTES.PROFILE.REGISTER);
        return;
      }
      const { name, phone, address, bio } = res.data.item;
      setForm({
        name: name ?? "",
        phone: phone ?? "",
        address,
        bio: bio ?? "",
      });
    };
    fetchUser();
  }, [user?.id, navigate]);

  return ( ... );
}

어떻게 해결했을까?

저는 이 문제를 근본적으로 해결하기 위해, 조건 기반의 보호 라우트 시스템(ProtectedRoute)을 설계했습니다.

핵심 전략은 다음과 같습니다:

  1. 조건 배열 기반 구조화 - 각 페이지에 필요한 접근 조건을 ProtectedRoute의 conditions props로 선언하도록 만들고, 조건은 { isPass, redirectPath, message } 형태의 객체 배열로 전달됩니다.
  2. 권한 검사 로직을 컴포넌트 외부로 이동 - 기존처럼 페이지 내부에서 검사하지 않고, 라우터 선언부에서 모든 권한 조건을 선언형으로 작성할 수 있도록 했습니다.
  3. 모달 표시 및 리다이렉트도 추상화 - 조건 불충족 시 메시지와 경로가 있으면 자동으로 모달 → 리다이렉트를 트리거하도록 구성하여, 반복되는 UI 처리도 제거했습니다.
  4. 향후 확장 문제까지 고려한 설계 방향 마련 - 프로젝트가 커짐에 따라 라우터 파일이 방대해질 수 있는 점을 우려했고, 실제로 별도의 파일로 분리하지는 않았지만, 도메인 단위로 라우터 파일을 분리하는 전략을 병렬적으로 고려했습니다. 이는 향후 확장성과 유지보수 편의를 높이기 위한 설계적 대비였습니다.
// Router.tsx 중 일부
<ProtectedRoute
  conditions={({ isLoggedIn, user }) => [
    loginProtectCondition(isLoggedIn),
    {
      isPass: user?.type === "employer",
      redirectPath: ROUTES.PROFILE.ROOT,
      message: "사장님 계정으로만 이용 가능한 기능입니다.",
    },
  ]}
>
  <NoticeEmployerPage />
</ProtectedRoute>
// ProtectedRoute.tsx
interface ProtectedRouteProps {
  children: ReactNode;
  conditions: (
    params: ProtectedRouteParamType,
  ) => ProtectedRouteConditionType[];
}

function ProtectedRoute({ children, conditions }: ProtectedRouteProps) {
  const navigate = useNavigate();
  const user = useUserStore((state) => state.user);
  const isLoggedIn = useUserStore((state) => state.isLoggedIn);
  const openModal = useModalStore((state) => state.openModal);

  useLayoutEffect(() => {
    if (user === undefined) return;
    const receivedConditions = conditions({ user, isLoggedIn });

    for (const { isPass, redirectPath, message } of receivedConditions) {
      if (!isPass) {
        if (message) {
          openModal({
            type: "alert",
            iconType: "warning",
            message,
            onClose: () => navigate(redirectPath),
          });
        } else {
          navigate(redirectPath);
        }
        break;
      }
    }
  }, [user, isLoggedIn, conditions, openModal, navigate]);

  if (user === undefined) {
    return null;
  }

  return children;
}

export default ProtectedRoute;

결과

  • 조건 로직의 중복 제거: 각 페이지마다 중복되던 useEffect 기반 조건 검사 코드가 완전히 제거되었습니다.
  • 권한 구조의 가시성 확보: 모든 조건이 라우터 파일 상에서 한눈에 드러나, 유지보수와 리뷰 시에도 명확히 파악 가능해졌습니다.
  • 확장성과 재사용성 강화: 새로운 조건 추가 또는 수정 시, 선언만으로 적용 가능하여 빠르게 대응할 수 있는 구조가 되었습니다.
  • 모달과 리다이렉트 로직 일관화: 메시지 출력과 이동 처리까지 구조 내에서 자동화되어 사용자 경험도 안정적으로 유지되었습니다.

트러블 슈팅

컴포넌트

커스텀 훅

Clone this wiki locally