React의 보안 라우터 및 조건부 렌더링 (feat. vue-router)

Jeonghun·2023년 12월 6일
2

React

목록 보기
20/21


프론트엔드 개발을 하다보면 라우팅에 관한 고민을 한 번쯤 하게 될 것이다.

필자가 가장 많이 고민했던 부분은 "사용자의 인증 상태에 따른 라우팅 처리" 였다.

예를 들어, 사용자가 마이페이지에 접근했을 때 로그인한 사용자의 user 데이터를 불러와 해당 정보를 렌더링 해주는게 일반적인 방식일 것이다.

이 때 비로그인 사용자가 마이페이지에 접근하게 된다면 어떨까? 당연히 데이터가 비어있는 껍데기 페이지가 렌더링 될 것이다.

이런 상황을 대비하기 위해 보안 라우터 의 적용을 고안해야 했으며, 이번 포스팅에서는 필자가 해당 문제를 해결한 방식에 대해 다룬다.

ProtectedRoute

검색 엔진에 React의 보안 라우터에 대해 검색해보면 필자와 같은 고민을 한 개발자 분들의 포스팅을 많이 접할 수 있다.

여러 포스팅을 전전하던 중 아래 게시글을 보게 되었고, 마침 react-router v6를 사용하던 나에게 가장 적합한 방법이라고 생각해 해당 포스트를 토대로 ProtectedRoute 를 작성했다.

https://cbi-analytics.nl/react-js-create-protected-routes-in-react-router-6/

위 포스팅에서는 react-router-dom 에서 제공하는 OutletNavigate 컴포넌트를 사용하여 보안 라우터를 작성하는 방법에 대해 설명한다.

우선 이 두 컴포넌트에 대해 간단히 알아보자.

Outlet?

Outlet 컴포넌트는 중첩 라우트 (Nested Routes)를 다루기 위해 주로 사용되는 컴포넌트로, 부모 라우트 컴포넌트 내에서 Outlet 을 사용하면, 해당 위치에 자식 라우트 컴포넌트가 렌더링 된다.

예를 들어, /project 경로에 대한 라우트가 있고, 이 경로에 /project/home, /project/mypage 등의 자식 라우트가 있다고 가정해 보자.

/project 경로에 대한 컴포넌트 내에서 Outlet 을 사용하면, /project/home 이나 /project/mypage 에 접근했을 때 해당 컴포넌트가 Outlet 이 있는 위치에 렌더링된다.

Navigate 컴포넌트는 페이지 리다이렉션을 위해 사용된다. 특정 조건이 충족됐을 때 사용자를 다른 경로로 이동시키고 싶을 경우 Navigate 를 사용할 수 있다.

예를 들어, 사용자가 인증되지 않은 상태에서 인증이 필요한 특정 경로에 접근하려고 할 때 로그인 페이지로 리다이렉트하고 싶다면, Navigate 컴포넌트를 사용하여 이를 구현할 수 있다.

코드 작성

OutletNavigate 를 이용해 보안 라우터 파일을 작성해보자.

// ProtectedRoute.tsx

import React from 'react';
import { LINK } from '@/constants/links';
import { Navigate, Outlet } from 'react-router-dom';
import { useAuthToken } from '@/store/authStore';

const ProtectedRoute = () => {
  // zustand store의 token을 가져옴
  // token은 로그인시 storage에 저장되므로, token이 존재하면 로그인 한 유저라고 판단
  const isAuth = useAuthToken();
  
  // isAuth의 값에 따른 조건부 렌더링
  return isAuth ? <Outlet /> : <Navigate to={LINK.LOGIN} replace />;
};

export default ProtectedRoute;

위 컴포넌트에서는 사용자의 인증 유무를 storage에 저장된 token 의 유무로 판단한다.

프로젝트 내에서 zustand persist를 이용하여 storage를 관리하고 있기에 이를 통해 token 을 불러와 인증 유무를 판별하고, isAuth의 값이 true 즉, 인증된 사용자의 경우 Outlet 을 이용해 자식 라우트를 렌더링, 미인증 사용자의 경우 Navigate 를 통해 login 페이지로 리다이렉션한다.

이 때 Navigate 컴포넌트에 replace props를 true로 설정하면 브라우저의 뒤로가기를 방지해 이전 페이지로 재접근을 방지할 수 있다.

라우트 적용하기

이제 작성한 보안 라우트 파일을 프로젝트의 라우터에 적용해보자.

우선 프로젝트의 요구사항을 정리해보면

  1. 프로젝트의 모든 페이지에서 적용될 공통 컴포넌트인Layout 컴포넌트가 렌더링 되어야한다.

  2. 사용자가 서비스에 접근하면 맨 처음 Layout 컴포넌트가 렌더링 되고, 이후 Layout 의 children 요소인 ProtectedRoute 가 렌더링되며 인증 유무를 판별한다.

  3. 인증 유무에 따라 children을 렌더링하거나, Navigate 로 지정된 url로 리다이렉션 해야하며, 메인페이지의 경우 미인증 사용자와 인증된 사용자 모두 접근이 가능하여야 한다.

이를 간단한 순서도로 그려보면 다음과 같다.

이제 순서도에 따라 코드로 작성해보자.

필자의 경우 프로젝트 내에서 react-router-dom v6.4 이상의 버전에서 제공하는 createBrowserRouter 를 사용했다.

createBrowserRouter 의 간단한 사용 방법에 대해서는 링크에 첨부된 포스팅과 공식 문서를 참고하길 바란다.

// router.tsx

import React from 'react';
import { LINK } from '@/constants/links';
import Layout from '@/layout/Layout';
import ProtectedRoute from './ProtectedRoute';
import MainPage from '@/pages/main/MainPage';
import MyPage from '@/pages/mypage/MyPage';
// import ...

const routes = [
  {
    path: '/',
    element: <Layout />,
    children: [
      {
        path: '',
        element: <ProtectedRoute />,
        children: [
          { path: LINK.MYPAGE, element: <MyPage /> },
        ],
      },
      { path: LINK.MAIN, element: <MainPage /> },
    ],
  },
];

export default routes;

이렇게 라우터를 적용해보면

비로그인 상태에서 마이페이지에 접근했을 때 보안 라우트에 걸려 로그인 페이지로 리다이렉션 되는걸 확인할 수 있다.

위와 같은 방식을 사용하여 로그인 사용자는 접근이 불가능한 라우터를 만들거나, 어드민 계정만 접근이 가능한 라우터를 만드는것도 간단하다.

이미 로그인된 사용자라면 로그인 페이지나 회원가입 페이지에 다시 접근할 필요가 없을것이고, 어드민이 아닌 사용자의 어드민 페이지 접근은 방지할 필요가 있다.

// AuthenticatedRoute.tsx

import React from 'react';
import { LINK } from '@/constants/links';
import { Navigate, Outlet } from 'react-router-dom';
import { useAuthToken } from '@/store/authStore';

const AuthenticatedRoute = () => {
  const isAuth = useAuthToken();

  return isAuth ? <Navigate to={LINK.MAIN} replace /> : <Outlet />;
};

export default AuthenticatedRoute;
// AdminRoute.tsx

import React from 'react';
import useUser from '@/hooks/useUser';
import { Navigate, Outlet } from 'react-router-dom';
import { LINK } from '@/constants/links';

const AdminRoute = () => {
  const { user } = useUser();
  const isAdmin = user?.roles.includes('ROLE_ADMIN');

  return isAdmin === false ? <Navigate to={LINK.MAIN} replace /> : <Outlet />;
};

export default AdminRoute;

AuthenticatedRoute 에서는 ProtectedRoute 와 같이 토큰의 유무로 인증 여부를 판단하고, 인증된 사용자가 접근할 경우 메인페이지로 리다이렉션한다.

AdminRoute 의 경우 user 데이터에 포함된 roles 값에 따라 어드민 계정임을 판별, 이에 따라 조건부로 렌더링해준다.

라우터 파일은 아래와 같이 수정해준다.

// router.tsx

// import ...

const routes = [
  {
    path: '/',
    element: <Layout />,
    children: [
      {
        path: '',
        element: <AuthenticatedRoute />,
        children: [
          {
            path: LINK.SIGNUP,
            element: <SignUpPage />,
          },
          {
            path: LINK.LOGIN,
            element: <LoginPage />,
          },
        ],
      },
      {
        path: '',
        element: <ProtectedRoute />,
        children: [{ path: LINK.MYPAGE, element: <MyPage /> }],
      },
      {
        path: '',
        element: <AdminRoute />,
        children: [
          {
            path: LINK.ADMIN_ACCOUNT,
            element: <AccountPage />,
          },
        ],
      },
      { path: LINK.MAIN, element: <MainPage /> },
    ],
  },
];

export default routes;

Layout 컴포넌트의 children 요소에 각 라우터에 대한 페이지를 추가해주면 된다.

로그인 후 /login 경로로 접근했을 때 메인페이지로 리다이렉션 되는것을 볼 수 있다.

조건부로 렌더링 되는 요소

보안 라우터와 더불어 고민거리 중 하나였던 것은 "페이지에 따라 조건부로 렌더링 되는 요소는 어떻게 처리해야하나?" 였다.

예를 들어, BottomNavBar와 FloatModal의 경우 메인페이지에서는 렌더링이 필요했고, 마이페이지에서는 렌더링이 되지 않아야 했는데, 이걸 조건부 렌더링을 어떻게 처리해야할지에 대한 레퍼런스는 검색을 통해서도 제대로 찾지 못했다.

필자가 생각했던 방법으로는

  1. 각 페이지 컴포넌트에서 react의 useLocation 을 사용해 현재 페이지의 pathname을 불러와 이를 이용한 렌더링

  2. NavBar와 같은 공용 컴포넌트를 렌더링하는 WrapperComponent를 만들어 렌더링이 필요한 페이지 컴포넌트를 감싸주기

    예를 들면 이런 식으로 코드를 작성할 수 있겠다.

    // example
    
    <RenderBottomNav>
    	<MainPage />
    </RenderBottmNav>

이 정도가 있었는데, 두 가지 방법의 단점은 모두 여러 페이지 컴포넌트에 동일한 작업이 반복되어야 한다는 것이다.

하나의 파일에서 모두 관리하고 싶다는 욕심이 생겨 이것저것 찾아보던 중 Vue 에서 사용하는 vue-router 에 대해 알게 되었다.

Vue-router

Vue-Router 공식문서

위에 첨부된 vue-router의 공식문서를 보면, vue-router 에서는 meta 라는 속성 필드를 이용해 라우트에 임의의 정보를 제공하고, 접근할 수 있도록 한다.

// example 출처 : vue router 공식문서

const routes = [
  {
    path: '/posts',
    component: PostsLayout,
    children: [
      {
        path: 'new',
        component: PostsNew,
        // 유저 인증 필수
        meta: { requiresAuth: true },
      },
      {
        path: ':id',
        component: PostsDetail,
        // 유저 인증 없어도 됨
        meta: { requiresAuth: false },
      },
    ],
  },
]

위 코드는 vue-router 공식문서에서 제공하는 meta 속성에 대한 간단한 예시 코드이다. 코드를 보면 meta 속성에 제공하는 값에 따라 유저 인증이 필요한 페이지와 불필요한 페이지를 구분해주는걸 볼 수 있다.

그럼 이제 react-router 를 위의 vue-router 를 참고해 조정해보자.

meta 속성 추가하기

위에서 작성한 라우터 파일에 vue-router 에서 사용하는 방식처럼 meta 속성을 추가했다.

// router.tsx

// import...

const routes = [
  {
    path: '/',
    element: <Layout />,
    children: [
      {
        path: '',
        element: <AuthenticatedRoute />,
        children: [
          {
            path: LINK.SIGNUP,
            element: <SignUpPage />,
            meta: { hideNavBar: true, hideFloatNav: true },
          },
          {
            path: LINK.LOGIN,
            element: <LoginPage />,
            meta: { hideNavBar: true, hideFloatNav: true },
          },
        ],
      },
      {
        path: '',
        element: <ProtectedRoute />,
        children: [{ path: LINK.MYPAGE, element: <MyPage />, meta: { hideFloatNav: true } }],
      },
      {
        path: '',
        element: <AdminRoute />,
        children: [
          {
            path: LINK.ADMIN_ACCOUNT,
            element: <AccountPage />,
            meta: { hideFloatNav: true },
          },
        ],
      },
      { path: LINK.MAIN, element: <MainPage /> },
    ],
  },
];

export default routes;

프로젝트에서 조건부로 렌더링 되어야 하는 요소는 BottomNavBar와 FloatNav 두 가지가 있었고, 이들을 관리하기 위해 hideNavBar, hideFloatNav 라는 속성을 meta 필드에 추가했다.

로그인과 회원가입 페이지에서는 두 요소 모두 렌더링될 필요가 없었기에, 두가지 속성에 true 값을 주었고, 메인페이지의 경우 meta 필드를 추가하지 않아 조건부를 주지 않았다.

조건부 렌더링 적용

이제 추가한 meta 필드를 이용해 조건부 렌더링을 적용해보자.

라우터에서 가장 첫번째로 렌더링 되는 컴포넌트는 모든 페이지의 레이아웃을 관리하는 컴포넌트인 Layout 이다. 따라서 Layout 컴포넌트 내에서 아래와 같이 meta 태그를 이용해 요소를 렌더링하는 로직을 추가했다.

// Layout.tsx

import React from 'react';
import { RouteObject, useLocation, Outlet } from 'react-router-dom';
import routes from '@/router/router';
import styled from 'styled-components';
import BottomNavBar from './BottomNavBar';
import FloatingNav from './FloatingNav';
import GlobalModal from '@/components/public/modal/GlobalModal';

interface ExtendedRouteObject extends Omit<RouteObject, 'children'> {
  meta?: {
    hideNavBar?: boolean;
    hideFloatNav?: boolean;
  };
  children?: ExtendedRouteObject[];
}

const findRouteByPath = (
  path: string,
  routes: ExtendedRouteObject[],
): ExtendedRouteObject | null => {
  for (const route of routes) {
    // 일치하는 경로 찾기
    if (route.path === path) {
      return route;
    }
    // 중첩된 라우트 (children 요소) 가 있는 경우
    if (route.children) {
      const nestedRoute = findRouteByPath(path, route.children);
      if (nestedRoute) {
        return nestedRoute;
      }
    }
  }
  return null;
};

const Layout = () => {
  const location = useLocation();
  
  // location을 이용해 현재 페이지 path를 가져와 인자로 전달
  const currentRoute = findRouteByPath(location.pathname, routes);
  
  // meta 필드를 이용한 true || false 값 설정
  const hideBottomNav = currentRoute?.meta?.hideNavBar || false;
  const hideFloatNav = currentRoute?.meta?.hideFloatNav || false;

  return (
    <PageLayout>
      <PageContentLayout $hideBottomNav={hideBottomNav}>
        <Outlet />
        
        // 조건부 렌더링
        {hideBottomNav === false && <BottomNavBar />}
        {hideFloatNav === false && <FloatingNav />}
      </PageContentLayout>
      <GlobalModal />
    </PageLayout>
  );
};

export default Layout;

// styled-components ...

Layout 컴포넌트에 findRouteByPath 라는 함수를 작성했다.

이 함수는 현재 페이지의 경로인 path와 routes 배열을 인자로 받아, for of 를 이용해 routes 배열을 순환하며 각 route의 path 속성이 인자로 받은 path와 일치하는지 찾고, 일치할 경우 해당 rotue 객체를 반환한다.

만약 route 객체가 중첩 라우트 (children) 를 가질경우 본 함수를 재호출하여 재귀적으로 중첩 라우트 내에서 path가 일치하는 route 객체를 찾아 return 하는 방식을 사용하여 중첩 라우트에 대한 처리를 해줬다.

그 후 반환된 route 객체의 meta 필드를 이용해 BottmNavBar와 FloatNav를 조건부 렌더링하면,

로그인 페이지에서는 BottomNav가 렌더링 되지 않고, 메인페이지에서는 두 요소가 모두 렌더링 되는것을 확인할 수 있다.

동적 라우터에 대한 이슈

라우팅을 나름 깔끔하게 작성한 것 같이 마음에 들었는데, 동적 라우팅을 하는데에 있어서 문제가 발생했다.

react 에서 동적 경로를 지정할 때에는 /post/:id 와 같이 :id를 붙여 동적 id를 할당하게 되는데, 위에서 작성한 findRouteByPath 함수를 보면

// Layout.tsx의 fineRouteByPath 중

for (const route of routes) {
    // 일치하는 경로 찾기
    if (route.path === path) {
      return route;
    }

route.path === path 부분에서 route.path 값은 /post/:id 를 가지고, location을 통해 가져온 실제 path는 /post/1 과 같은 값을 가지기 때문에 두 path가 일치하지 않아 라우팅이 제대로 처리되지 않았다.

이 부분을 아래와 같이 정규표현식을 사용해 동적 라우트에 대한 처리를 해주도록했다.

for (const route of routes) {
    const routePathRegex = new RegExp('^' + route.path?.replace(/:\w+/g, '\\w+') + '$');
    if (routePathRegex.test(path)) {
      return route;
    }

위 정규표현식은 동적으로 변경될 수 있는 부분을 \w+ 로 변환한다. 예를 들어, /post/:id^/post/\w+$ 의 형태로 변환되며 '\w' 는 한 글자 이상의 문자를 허용한다는 의미가 되고, ^와 $는 각각 문자열의 시작과 끝을 나타내 path가 정확히 일치하는지를 판단한다.

정규표현식을 반영하고 라우팅 작동을 확인해보면,

게시글의 고유 id에 따라 동적으로 변하는 path에 대해서도 라우팅이 제대로 적용된다.


구현 방식에 대해 많은 고민과 시간을 투자했던 만큼 원하는 대로 구현했을 때 뿌듯함이 컸던 라우팅 작업이었다.

이상 길었던 라우팅 포스팅을 마친다!

profile
안녕하세요, 프론트엔드 개발자 임정훈입니다.

0개의 댓글