PrivateRoute, PublicRoute 만들기

김용현·2024년 3월 28일

LESSER 개발일지

목록 보기
5/7
post-thumbnail

0. 개발 배경

인증 상태에 따라 사용자들에게 페이지에 접근 가능하는지 확인하자!

사용자가 로그인을 했는지, 토큰이 만료되었는지와 같은 상황에 따라 접근할 수 있는 페이지가 달라야 합니다. 예를 들어 로그인을 완료한 사용자가 로그인 페이지에 접근해서는 안되고, 반대로 로그인하지 않은 사람이 프로젝트 페이지에 함부로 들어 들어가서는 안됩니다. 이렇게 각 페이지에 접근을 허용할지를 파악하기 위해서, 이를 점검하는 PrivateRoute, PublicRoute를 개발해야 했습니다.

이전 버전의 LESSER에는 간단한 PrivateRouter를 개발해서 사용했지만, 이를 그대로 사용하기에는 다음의 문제점이 있었습니다.

문제 상황

1. PrivateRoute의 적용 방식에 오류가 있었습니다.
인증이 필요한 각 페이지에 PrivateRouter를 적용한 것이 아니라, 하나의 PrivateRoute의 자식에 모든 인증이 필요한 페이지를 넣어두었습니다. 이러한 방식으로 설계될 경우, 인증된 페이지에서 다른 인증된 페이지로 이동할 때 accessToken이 (refreshToken도 같이) 만료가 된 경우를 잡아내지 못하게 되는 문제가 발생합니다.

2. PublicRoute의 개발이 되어있지 않았습니다.
앞서 말한 것과 같이, 인증되지 않은 사람에게 페이지를 보여주지 않는 것 만큼 인증을 완료한 사람에게 로그인, 회원가입과 같은 불필요한 페이지로의 이동을 막는 것 또한 중요합니다. 하지만 해당 기능의 개발이 되어 있지 않아 추가적인 개발이 필요했습니다.

3. refreshToken을 저장하는 방식이 바뀌었습니다.
이전 버전에서 페이지의 허용을 확인하기 위해서 메모리의 accessToken 혹은 sessionStorage의 refreshToken이 존재하는지의 여부만으로 판단했지만, 이는 XSS 공격에 취약한 문제가 있습니다. 보안 관점의 결점을 보완하기 위해 http onlycookie에 저장하는 방식으로 변경되었고, 이에 따라 이전 방식처럼 refreshToken이 실제 존재하는지의 여부를 Javscript에서는 파악할 수 없고, 새로운 방식으로의 점검하는 방식이 필요했습니다.

1. 사용자의 상황에 따른 동작 방식 고려하기

본격적인 개발에 앞서, 사용자가 인증과 관련하여 마주하게 될 상황을 가정해보았습니다. 사용자의 accessToken과 refreshToken의 소지여부, 만료 여부에 대한 모든 케이스를 꼼꼼하게 따져보는 과정을 통해, 개발해야할 Router의 역할과 구조를 더 섬세하고 구체적으로 설계할 수 있었습니다.

WHO, 누가 해당 상황을 마주했는가?

이 코드는 어떤 사람을 대상으로 시나리오를 고민해야 하는가?

  • 일반적인 사용자

WHEN, 언제 해당 상황을 마주했는가?

이 코드는 어떤 상황에 동작 해야 하는가?

상황 1

  • 인증이 필요한 페이지를 외부에서 접근하거나 특정 동작을 했을 때

상황 2

  • 인증이 필요하지 않은 페이지에 인증한 인원이 접근했을 때

WHAT, 어떤 상황을 마주했는가?

이 코드가 각 상황에서 어떤 결과를 사용자들에게 제공해야 하는가?

상황 1) 인증이 필요한 페이지를 외부에서 접근하거나 특정 동작을 했을 때

  • 인증이 필요한 페이지 대신 "로그인이 필요합니다"의 메세지가 송출
  • 로그인 페이지로 이동할 수 있는 버튼

상황 2) 인증이 필요하지 않은 페이지에 인증한 인원이 접근했을 때

  • 해당 인원의 project 페이지로 강제 리다이렉트

WHY, 왜 이러한 상황을 마주했는가?

앞서 정의한 상황이 발동하는 조건을 어떻게 설계해야 하는가?

상황 1) 인증이 필요한 페이지를 외부에서 접근하거나 특정 동작을 했을 때

  • accessToken이 클라이언트 메모리에 정상적으로 들어있지 않는다.
    • acccessToken이 없는 상황(시간 만료, 새로고침 등)에서 정상적으로 refresh를 통해 acccessToken을 전달 받지 못했다.
    • 인증(로그인)하지 않고 바로 인증이 필요한 페이지에 접근했다.

상황 2) 인증이 필요하지 않은 페이지에 접근했을 때

  • 이미 인증이 완료되어 accessToken이 존재하는 상황에서 해당 페이지에 접근했다.

분석을 통한 코드 설계

1. 사용자의 인증 상태를 점검하는 함수 checkAuthentication 개발

  • 사용자가 accessToken을 가지고 있을 경우 true를 반환
  • 사용자가 accessToken을 가지고 있지 않을 경우 refreshToken을 이용하여 accessToken을 새롭게 저장하는 refresh 동작을 수행하고, 정상적으로 수행하면 true를 반환
  • refresh 동작이 정상적으로 수행되지 않을 경우 error를 반환

2. 인증하지 않은 사용자의 인증 페이지 접근을 막기 위한 PrivateRoute를 개발

  • checkAuthentication이 동작하는 동안, 인증 페이지의 보안을 위해 loading 화면을 띄운다
  • checkAuthenticationtrue를 반환하면 정상적으로 인증 페이지를 띄운다
  • checkAuthenticationerror를 반환하면 에러를 던지고, Error Boundary가 던져진 에러를 확인하고 AuthErrorPage를 화면에 띄운다

3. 인증한 사용자의 미인증 페이지 접근을 막기 위한 PublicRoute를 개발

  • checkAuthentication이 동작하는 동안, 인증 페이지의 보안을 위해 loading 화면을 띄운다
  • checkAuthenticationtrue를 반환하면 인증이 완료된 사용자이므로, project 페이지로 강제 리다이렉트
  • checkAuthenticationerror를 반환하면 인증되지 않은 사용자이므로, 정상적으로 미인증 페이지를 띄운다.

2. 설계 내용을 바탕으로 개발

checkAuthentication

PrivateRoute, PublicRoute에서 사용자의 인증 여부를 확인하기 위해서 사용하는 util 함수

accessToken을 저장하는 위치에서 accessToken 가지고 있는지를 확인하고 있을 경우 return을 반환하도록 코드를 설계했습니다.

// checkAuthentication.ts

if (checkAccessToken()) return true; // accessToken이 메모리에 저장되어 있는지 확인

accessToken이 메모리에 저장되어 있기 때문에, 새로 고침 혹은 브라우저의 재실행과 같은 문제로 휘발되어 있을 수 있습니다. 이 상황을 확인하기 위해 refreshToken을 통해 accessToken을 새롭게 받아오도록 하고, 성공했을 경우 true를 실패했을 경우 error를 반환하도록 코드를 설계했습니다.

  try {
    await postRefresh(); // cookie에 저장된 refreshToken을 통해 accessToken을 새롭게 받아오는 함수
    return true; // 만일 정상적으로 성공할 경우 true 를 반환
  } catch (error) {
    return error; // 실패할 경우 error를 반환
  }

이제 앞으로 개발할 PrivateRoute와 PublicRoute는 checkAuthentication 함수를 통해 사용자의 인증여부를 확인할 수 있습니다!

PrivateRoute

인증이 필요한 페이지에 사용자가 접근 했을 때 해당 자격이 있는지 확인하는 코드

PrivateRoute는 해당 페이지가 로딩 중인지 아닌지를 확인하기 위한 isLoading 상태를 가지고 있습니다. isLoading 상태는 최초 true의 값을 가지고 있으며, useEffect에서 checkAuthentication을 실행이 정상적으로 끝났을 경우 isLoading은 로딩이 끝났다는 의미로서 false로 값이 변하게 됩니다.

만약 checkAuthentication이 정상적으로 처리될 경우, isLoading이 false로 상태가 바뀌며, 인증이 필요한 페이지의 내용을 정상적으로 보여주게 됩니다.

// PrivateRoute.tsx
const [isLoading, setIsLoading] = useState<Boolean>(true);
useEffect(() => {
    checkAuthentication().then((result: Boolean | unknown) => {
      if (result === true) {
        setIsLoading(false);
      } else {
        showBoundary(result);
      }
    });
  }, [checkAuthentication]);

반대로 인증에 실패하게 된다면 showBoundary 함수를 통해 ErrorBoundary에 에러를 던지게 되고, ErrorBoundary는 error의 내용에 맞는 AuthErrorPage를 대신 표시하게 됩니다.

// GlobalErrorBoundary.tsx
if (error.response.status === 401) return <AuthErrorPage {...{ error, resetErrorBoundary }} />;
// AuthErrorPage.tsx
const AuthErrorPage = ({ resetErrorBoundary }: FallbackProps) => {
  const navigate = useNavigate();
  const redirectLoginPage = () => {
    resetErrorBoundary();
    navigate(ROUTER_URL.LOGIN);
  };
  return (
    <div className="w-[100vw] h-[100vh] flex flex-col justify-center items-center">
      <p>재로그인이 필요합니다.</p>
      <button className="bg-text-gray text-dark-gray w-fit h-fit" onClick={redirectLoginPage}>
        다시 로그인하러 가기
      </button>
    </div>
  );
};

🤔 isLoading을 사용하는 이유
isLoading의 상태에 따라 Loading 페이지 혹은 PrivateRoute가 가진 child 컴포넌트를 표시할지를 결정하게 됩니다. 만약 isLoading을 통해 로딩 화면을 표시하지 않는다면, useEffect의 특성상 화면을 사용자에게 표시한 후, 인증 여부를 확인하게 됩니다.
하지만 지금 표시하는 화면은 인증이 필요한 인원에게만 보여주어야 하는 보안이 중요한 자료일 가능성이 높습니다. 그렇기 때문에 순간적인 깜빡임으로 인해 해당 자료가 보여지는 경우는 보안에 있어 치명적일 수 있다고 판단했습니다.
동시에 로딩창이 아닌 화면이 깜빡이는 현상은 사용자 경험에 있어도 좋지 못하기 때문에 isLoading을 통해 로딩 화면을 보여주기로 결정했습니다.

PublicRoute

인증을 완료한 사람이 사용할 수 없는 페이지에 대한 접근을 제한하는 기능

반대로, 인증을 완료한 인원이 접근할 수 없는 페이지가 있습니다. 예를 들어, 이미 로그인을 완료한 인원이 login, signup 페이지에 접근할 수 있는 것은 좋은 웹페이지의 설계가 아닐 것입니다. 그렇기 때문에 인증을 완료한 인원의 접근을 막기 위한 PublicRoute를 개발해야 했습니다.

PublicRoute는 PrivateRoute와 반대로 동작하도록 설계했습니다. 단 PublicRoute와 다른 점은, checkAuthentication 함수의 실행 결과로 error가 나오면 하위 페이지를, true가 나오면 react-router의 Navigate를 통해 프로젝트 페이지로 강제 이동하도록 설계한 부분입니다.

// PublicRoute.tsx

  useEffect(() => {
    checkAuthentication().then((result) => {
      if (result === true) {
        setIsLoading(true);
        setAuthenticated(true);
      } else {
        setIsLoading(true);
        setAuthenticated(false);
      }
    });
  }, [checkAuthentication]);

  return !isLoading ? (
    <RouteLoading />
  ) : !authenticated ? (
    <Outlet />
  ) : (
    <Navigate to={ROUTER_URL.PROJECTS} />
  );

3. 복잡한 Route 적용법을 개선하자

추가 문제 상황

PublicRoute와 PrivateRoute를 정상적으로 사용하기 위해서는 인증의 점검이 필요한 모든 페이지를 Route로 감싸주어야 했습니다. 특히 React Router를 통해 페이지의 렌더링을 관리하는 상황에서 모든 경로를 PublicRoute, PrivateRoute로 감싼 router 구조는 굉장히 보기에도 어렵고, 사용하기에도 굉장히 번거로웠습니다.

// AppRouter.tsx
// PrivateRoute, PublicRoute로 인해 더욱 복잡한 구조의 BrouwserRouter가 생성됨
const router = createBrowserRouter([
  {
    path: ROUTER_URL.ROOT,
    element: (
      <GlobalErrorBoundary>
        <Outlet />
      </GlobalErrorBoundary>
    ),
    children: [
      {
        index: true,
        element: <TempHomepage />,
      },
      {
        element: <PublicRouter />,
        children: [
          {
            path: ROUTER_URL.LOGIN,
            element: <LoginPage />,
          },
      	]
      },
  	  {
        element: <PublicRouter />,
        children: [
          {
            path: ROUTER_URL.SIGNUP,
            element: <SignupPage />,
          },
      	]
      },
      {
        element: <PrivateRouter />,
        children: [
          {
            path: ROUTER_URL.PROJECT,
            element: <ProjectPage />,
          },
      	]
      },
    ],
  },
]);

이러한 문제를 해결하기 위해, 함수에 내부에 하위 경로를 집어 넣으면 각 경로를 모두 PublicRoute 혹은 PrivateRoute로 감싼 배열을 반환하는 함수로 작성했습니다.

// AppRouter.tsx
const createAuthCheckRouter = (routeType: RouteType, children: RouteObject[]) => {
  const authCheckRouter = children.map((child: RouteObject) => {
    return {
      element: routeType === "PRIVATE" ? <PrivateRoute /> : <PublicRoute />,
      children: [child],
    };
  });
  return authCheckRouter;
};

이 함수를 적용함으로써, 매번 모든 경로에 PrivateRoute, PublicRoute를 작성하지 않아도 되기 때문에 코드 개발에 있어 번거로움을 줄일 수 있었습니다. 또 public, private로 제한을 두어야 하는 페이지끼리 묶어 두어, 해당 구조를 쉽게 파악할 수 있다는 장점이 있었습니다.

// Approuter.tsx
// 구조도 간단해지고, private와 public이 적용된 페이지를 쉽게 파악할 수 있다.
const router = createBrowserRouter([
  {
    path: ROUTER_URL.ROOT,
    element: (
      <GlobalErrorBoundary>
        <Outlet />
      </GlobalErrorBoundary>
    ),
    children: [
      {
        index: true,
        element: <TempHomepage />,
      },
      ...createAuthCheckRouter("PUBLIC", [
        {
          path: ROUTER_URL.LOGIN,
          element: <LoginPage />,
        },
        {
          path: ROUTER_URL.SIGNUP,
          element: <SignupPage />,
        },
      ]),
      ...createAuthCheckRouter("PRIVATE", [
        {
          path: ROUTER_URL.PROJECTS,
          element: <ProjectsPage />,
        },
      ]),
    ],
  },
]);

4. 최종 개발 결과

상황 1) 인증 되지 않은 인원이 Private 페이지에 접근할 경우

로그인하지 않은 인원이 인증이 필요한 projcet 페이지에 접근하려 할 때, 성공적으로 AuthErrorPage를 fallback 컴포넌트로서 보여주고 있다.

PrivateRouter 수행 결과 gif

상황 2) 인증된 인원이 Public 페이지에 접근할 경우

로그인을 수행한 인원이 login 페이지에 접근하려 할 때, 성공적으로 project 페이지로 리다이렉트 해준다.
(다이렉트가 너무 빨리 처리되어서, 변하는게 잘 눈에 띄지 않는다)

PublicRouter 수행 결과 gif

5. 개발 경험 정리

PrivateRoute, PublicRoute를 개발해보면서 이번 기회에 "기능을 설계하고, 이를 기반으로 개발하는 나만의 방법을 체계화하는 방법을 익힌것"이 굉장히 중요한 결론이었습니다. 특히 사용자 입장에서 마주해야하는 상황과 조건들을 문서로 자세하게 작성하고 고민하는 과정 덕분에, 수행해야 하는 기능을 더 체계적으로 분석하고 개발해야 하는 기능들을 확실하게 깨달을 수 있었습니다.
또, 상위 컴포넌트를 통해 페이지를 감싸는 방식을 학습하고 React-Router를 더 효율적으로 개발하기 위한 방법을 고민한 과정도 굉장히 즐거운 경험이었습니다.

6. 참고 문헌

https://velog.io/@roka/React-Private-Router-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-Feat.-useEffect%EC%97%90-%EB%8C%80%ED%95%9C-%EC%9D%B4%ED%95%B4
https://oliviakim.tistory.com/123

profile
함께 일하고 싶은 개발자가 되기위해 노력 중입니다.

4개의 댓글

comment-user-thumbnail
2024년 3월 30일

WHO, WHEN, WHAT, WHY로 상황을 분석하는 부분이 인상적이네요! 저도 다음에 한번 시도해 봐야겠어요 🙂 글 잘 읽었습니다!

답글 달기
comment-user-thumbnail
2024년 3월 31일

정말 공들여 쓰시고 설계도 훌륭하신것 같아요!! 좋은 블로거를 발견한것 같아 기쁘네요!
읽기에 부담없이 잘 읽을 수 있었습니다🤗
한가지 궁금한점이 있는데요! 이미 인증된 유저가 privateRoute를 이동할때도 isLoading이 발생하지는 않을까요?! 만약 발생한다면 조금 부자연 스러운 UX가 되지는 않을까 싶어서요!

1개의 답글