프로젝트 기록

[Waffle-market] react-router에서 Private Route 구현을 통한 권한별 접근제어 설정

lerrybe 2023. 2. 19. 20:35

공부한 내용을 기록합니다. 잘못된 부분은 댓글로 알려주시면 감사하겠습니다! 🙇


(react-router-dom v6 환경에서 구현된 코드입니다.)

Custom Private route 작성의 계기

보통 웹사이트를 돌아다니다 보면 유저의 인증 및 권한 등급에 따라 접근이 가능한 페이지가 있고, 그렇지 않은 페이지가 있습니다. 예를 들어 카페의 경우 준회원, 정회원, 우수회원... 등급에 따라 접근 가능한 카테고리가 있고 특정 등급 미만이면 접근할 수 없는 부분이 있죠.

또한 로그인이 되어 있는 상황에서는 보통 로그인이나 회원가입 페이지로 이동할 수 있는 버튼이 보이지 않게 되는데, 유저 정보가 있는 경우에 로그인 페이지나 회원가입 페이지에 접근하는게 흐름 상 자연스럽지 않습니다. 

 

이렇게 유저의 상황에 맞게 페이지 별로 접근을 제어해야 할 상황이 생기게 됩니다.

각각 페이지 자체에서 로그인 유무나 유저의 grade를 체크해서 접근이 불가한 경우 돌려보낼 수도 있겠지만 프로젝트 전반적으로 통일성 있게 적용하기 위해서, 같은 역할을 하는 중복된 코드 유지보수를 위해 라우터 차원에서 제어하는 것도 괜찮을 것 같다는 생각이 들었습니다. 

 

이를 위해서 기존 Route에는 직접 작성한 PrivateRoute를 넣어주고, 그 서브 라우트에 실제 보여주고 싶은 페이지 컴포넌트를 넣어줌으로써 Private한 Route를 제공하는 방법을 생각해봤습니다. react-router-dom에서 라우팅 관리를 할 때, 페이지 접근 권한을 위해 Private Route를 구현해보도록 하려 합니다. 

 


중첩된 routing과 Outlet

우선 react-router-dom에서는 중첩 라우팅을 이용할 수 있습니다.

path가 '/parent'인 페이지와 '/parent/children'인 페이지가 있을 때, 아래와 같이 각각 사용해줄 수도 있지만

<Route path="/parent" element={<ParentPage />} />
<Route path="/parent/children" element={<ChildrenPage />} />

 

아래와 같이 중첩해서 사용하게 할 수도 있습니다.

<Route path="/parent" element={<ParentPage />}>
  <Route path="children" element={<ChildrenPage />} />
</Route>

하위 라우팅에 걸려있는 컴포넌트에서는 path 시작시 '/'를 생략하면 되고, 상위 라우팅에 걸려있는 컴포넌트에서는 Outlet으로 하위 라우트 컴포넌트의 위치를 지정해주면 됩니다. 

 

(참고) Docs를 살펴보니 Outlet 컴포넌트는 이런 타입으로 구성되어 있습니다.

interface OutletProps {
  context?: unknown;
}
declare function Outlet(
  props: OutletProps
): React.ReactElement | null;

 

parent/index.tsx

import { Outlet } from 'react-router-dom';

const ParentPage = () => {
  return (
    <>
      <div>상위 페이지</div>
      <Outlet /> {/* 하위 페이지 자리 잡아주기 */}
    </>
  );
};

export default ParentPage;

 

children/index.tsx

const ChildrenPage = () => {
  return (
    <div>하위 페이지</div>
  );
};

export default ChildrenPage;

 

서버를 켜고 결과를 확인해봅시다.

ParentPage
ParentPage + Outlet (ChildrenPage)

 

 

 


private route 호출부

import { BrowserRouter, Route, Routes } from 'react-router-dom';

import PrivateRoute from './PrivateRoute';

// ... import ... 

// DESC: 라우팅 관리를 위한 EntryRoute
function EntryRoute() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<HomePage />} />
        <Route path="/*" element={<ErrorPage />} />
        <Route element={<PrivateRoute authentication={false} />}>
          <Route path="/login" element={<LoginPage />} />
        </Route>
        <Route element={<PrivateRoute authentication />}>
          <Route path="/profile/me" element={<ProfilePage />} />
        </Route>
        {/* ..생략.. */}
      </Routes>
    </BrowserRouter>
  );
}

export default EntryRoute;

아무 권한이 필요하지 않은 Route는 그대로 사용하고, 로그인 되었을 때 혹은 로그인 되지 않았을 때 접근 제한을 받아야하는 경로에 한해서 PrivateRoute로 감싸줬습니다. 

 

로그인이 되어야지만 접근할 수 있는 페이지 (cf. 프로필 페이지)는 authentication={true}로, 로그인이 되지 않아야지만 접근할 수 있는 페이지 (cf. 로그인 페이지)는 authentication={false}로 내려줬습니다.

 


PrivateRoute 구현, useAuth Hook 구현

먼저 로그인된 유저인지 아닌지 판별하는 로직을 따로 관리하면 좋을 것 같아 useAuth 커스텀 훅을 만들었습니다. 

(상태관리로는 Redux-toolkit을 사용하였고, session관련 비동기 함수와 정보는 sessionSlice, users 관련 함수와 정보는 usersSlice에서 관리하고 있습니다.)

 

👨‍🍳 useAuth.ts

import { useState, useEffect } from 'react';
import Cookies from 'js-cookie';

import { getLoggedInUser } from '../store/slices/usersSlice';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import { refresh, sessionActions } from '../store/slices/sessionSlice';

export const useAuth = () => {
  const dispatch = useAppDispatch();
  const { loggedInUser } = useAppSelector(state => state.users);
  const [isLoggedIn, setIsLoggedIn] = useState(false);
  const [sessionLoading, setSessionLoading] = useState(true);

  // DESC: 로그인 된 유저 정보로 로그인 유무 판별
  const setLoggedInUser = () => {
    dispatch(getLoggedInUser(Cookies.get('accessToken')))
      .unwrap()
      .then(() => {
        setIsLoggedIn(true);
        setSessionLoading(false);
      })
      .catch(() => {
        dispatch(refresh(Cookies.get('refreshToken')))
          .unwrap()
          .then(() => {
            dispatch(getLoggedInUser(Cookies.get('accessToken')))
              .unwrap()
              .then(() => {
                setIsLoggedIn(true);
                setSessionLoading(false);
              });
          })
          .catch(() => {
            setIsLoggedIn(false);
            setSessionLoading(false);
            dispatch(sessionActions.logout());
          });
      });
  };

  useEffect(() => {
    setLoggedInUser();
  }, []);

  return { isLoggedIn, sessionLoading };
};

 

useAuth 훅은 타입 정보를 포함한 useAppDispatch와 useAppSelector Hook을 사용하여 Redux store에 접근하고 있는, 사용자 인증 정보를 처리하기 위해 작성한 Custom Hook입니다.

 

useAuth는 다음과 같은 값을 반환합니다.

  • isLoggedIn: 현재 사용자가 로그인 되어 있는지 여부
  • sessionLoading: 세션 정보가 로드 중인지 여부

여기서 isLoggedIn은 초기값이 false로 설정되어 있고, sessionLoading은 유저 정보를 가져오는 동안 true로 설정되어 로딩 상태를 나타내고 있습니다. 

 

setLoggedInUser 함수는 현재 로그인 한 사용자의 정보를 세팅하는 함수입니다. 이 함수 내부에서는 getLoggedInUser 함수를 사용하여 현재 사용자의 정보를 가져오고, 이에 대한 처리 결과에 따라 로그인 여부를 결정(isLoggedIn)하고, sessionLoading 상태를 갱신해줍니다. 만약 getLoggedInUser 함수가 실패하면, 토큰 refresh 후 (refresh 토큰과 accessToken은 쿠키에서 관리되고 있습니다.) 다시 getLoggedInUser 함수를 호출하여 로그인 여부를 결정합니다. 이 과정에서 문제가 발생하면 로그아웃시킵니다. 

 

 

🌵 PrivateRoute.tsx

이제 만든 useAuth 훅을 가지고 PrivateRoute를 구현해봅시다. Route 컴포넌트를 감싸는 PrivateRoute 구현부입니다. 

import { ReactElement } from 'react';
import { Navigate, Outlet } from 'react-router-dom';

import { useAuth } from '../hooks/useAuth';
import Spinner from '../components/spinner';
import { normalToast } from '../utils/basic-toast-modal';

interface PrivateRouteProps {
  children?: ReactElement;
  authentication: boolean;
}

export default function PrivateRoute({
  authentication,
}: PrivateRouteProps): React.ReactElement | null {
  const { isLoggedIn, sessionLoading } = useAuth();

  if (sessionLoading) {
    return <Spinner />;
  }

  if (!authentication && isLoggedIn) {
    return <Navigate to="/" />
  }

  if (authentication && !isLoggedIn) {
    normalToast('로그인이 필요합니다.');
    return (
      <>
        <Navigate to="/" />
        <Navigate to="/login" />
      </>
    );
  }

  return <Outlet />;
}

 

PrivateRoute의 props로는 authentication이라는 boolean 값을 받고 있고, 이 prop이 true일 경우에는 사용자가 로그인되어야만 해당 경로로 접근할 수 있습니다. 반면에 false일 경우 사용자가 로그인되어 있으면 해당 경로에 접근할 수 없습니다.

 

PrivateRoute 컴포넌트의 리턴값을 정리하면 다음과 같습니다. 

  • Spinner 컴포넌트
    • 세션 정보가 로딩 중일 경우 보여주는 로딩 스피너입니다.
    • useAuth Hook으로부터 반환된 sessionLoading 변수에 따라 결정됩니다. 
  • Navigate 컴포넌트
    • authentication prop이 false이고 로그인한 사용자가 경로에 접근할 경우 '/' 경로로 이동합니다.
  • Outlet 컴포넌트
    • authentication prop이 true이며, 로그인한 사용자만 접근할 수 있는 경로인 경우 Outlet 컴포넌트를 반환해 자식 라우트를 렌더링합니다.

먼저 PrivateRoute 컴포넌트는 useAuth Hook을 사용하여 isLoggedIn와 sessionLoading 상태를 가져온 후, 현재 사용자가 로그인되어 있는지, 세션 정보가 로딩 중인지 여부를 확인합니다.

 

authentication prop에 따라 로그인이 되어있어야만 하는지 or 로그인이 되어있지 않아야만 하는 페이지인지 파악하는데,

authentication === true인데 사용자가 로그인하지 않은 경우 '/login' 경로로 이동하고 normalToast 함수를 사용하여 로그인이 필요하다는 모달을 띄웁니다. (모달에는 toastify 라이브러리를 이용했습니다.)

 

반면 authentication === false인데 사용자가 로그인 한 경우에는 홈으로 이동하도록 합니다.

 

그 외의 경우 (접근이 가능한 경우)에는 Outlet 컴포넌트를 사용하여 자식 라우트를 렌더링합니다. (우리가 보고싶은 페이지)

 

정리하면 PrivateRoute 컴포넌트는 authentication prop의 값과 로그인 여부에 따라 적절한 라우팅 처리를 하여, 로그인 유무로 유저가 페이지에 접근할 수 있는지 없는지에 대한 처리를 할 수 있습니다.

 

 


리팩토링 - 권한 단계 확장에 따른 유연성을 위하여

 

현재 코드는 '로그인 된 유저 / 로그인 되지 않은 유저' 이렇게 두 단계로만 권한을 제어하고 있지만, 만약 유저 등급이 늘어날 경우 (필요성에서 언급한 것 처럼 준회원, 정회원, 우수회원...) 에는 적절하지 못한 것 같아 페이지에 접근할 수 있는 최소 등급을 optional props로 내려서 유저가 해당 Grade 이상일 때만 접근할 수 있도록 하는게 좋을거라 생각했습니다. 

 

수정한 코드는 아래와 같습니다!

 

🌵routes/index.tsx

import { BrowserRouter, Route, Routes } from 'react-router-dom';

import PrivateRoute from './PrivateRoute';

// ... import ...

// DESC: 라우팅 관리를 위한 EntryRoute
function EntryRoute() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<HomePage />} />
        <Route path="/*" element={<ErrorPage />} />
        <Route element={<PrivateRoute isAuthNeeded={false} />}>
          <Route path="/login" element={<LoginPage />} />
        </Route>
        <Route element={<PrivateRoute minGrade={1} isAuthNeeded />}> {/* 🎃 minGrade 추가 */}
          <Route path="/profile/me" element={<ProfilePage />} />
        </Route>
        <Route element={<PrivateRoute minGrade={2} isAuthNeeded />}>
          <Route path="/profile/me/review" element={<MyReviewPage />} />
        </Route>
        {/* ..생략.. */}
      </Routes>
    </BrowserRouter>
  );
}

export default EntryRoute;

 

 

유저 정보에는 유저의 grade가 포함되어 있습니다.

만약 등급 단계가 더 추가된다면, 아래 GradeScope enum에 추가해주면 됩니다. 

export type User = {
  id: number;
  username: string;
  email: string;
  location: string;
  temperature: number;
  imgUrl: string | null;
  createdAt?: Date;
  updatedAt?: Date;
  searchScope?: SearchScope;
  grade?: GradeScope;
};

export enum GradeScope {
  ETC = 'etc',
  SILVER = 'silver',
  GOLD = 'gold',
  PLATINUM = 'platinum',
}

 

접근 가능한 최소 등급과 유저 등급을 비교해 유저가 접근할 수 있는지 여부를 'validAuthGrade' 함수를 통해 뱉어냅니다. 

 

🌵utils.ts

import { GradeScope } from '../types/users';

export type GetGradeNum = (grade: GradeScope | 'etc') => number;
export type ValidAuthGrade = (minGrade: number, grade?: number) => boolean;

export const getGradeNum: GetGradeNum = grade => {
  return gradeMap[grade];
};

export const validAuthGrade: ValidAuthGrade = (minGrade, grade) => {
  return grade ? grade >= minGrade : false;
};

const gradeMap: { [key in GradeScope]: number } = {
  [GradeScope.ETC]: 0,
  [GradeScope.SILVER]: 1,
  [GradeScope.GOLD]: 2,
  [GradeScope.PLATINUM]: 3,
};

 

 

🌵routes/PrivateRoute.tsx

import { ReactElement } from 'react';
import { Navigate, Outlet, useNavigate } from 'react-router-dom';

import { useAuth } from '../hooks/useAuth';
import { GradeScope } from '../types/users';
import { useAppSelector } from '../store/hooks';
import { normalToast } from '../utils/basic-toast-modal';
import { validAuthGrade, getGradeNum } from './utils';

import Spinner from '../components/spinner';

interface PrivateRouteProps {
  minGrade?: number;
  isAuthNeeded: boolean;
}

export default function PrivateRoute({
  minGrade,
  isAuthNeeded,
}: PrivateRouteProps): ReactElement | null {
  const navigate = useNavigate();
  const { isLoggedIn, sessionLoading } = useAuth();
  const { loggedInUser } = useAppSelector(state => state.users);

  // 🎃 접근 가능한 등급인지에 대한 boolean 값을 isAuthed에 저장
  const isAuthed = validAuthGrade(
    minGrade || 0,
    getGradeNum(loggedInUser?.grade || GradeScope.ETC),
  );

  if (sessionLoading) {
    return <Spinner />;
  }

  if (!isAuthNeeded && isLoggedIn) {
    return <Navigate to="/" />;
  }

  if (isAuthNeeded && !isLoggedIn) {
    normalToast('로그인이 필요합니다.');
    return (
      <>
        <Navigate to="/" />
        <Navigate to="/login" />
      </>
    );
  }

  // 🎃 등급에 의해 접근이 불가능할 때 처리
  if (isAuthNeeded && isLoggedIn && !isAuthed) {
    normalToast('접근 권한이 없습니다.');
    navigate(-1);
  }

  return <Outlet />;
}

 

등급을 추가하고 싶은 경우, GradeScope enum과 gradeMap에만 새로 추가해주면 되고, 권한에 대한 검증을 Route 단에서 해주기 때문에 페이지 컴포넌트 자체에서는 권한 검증이 완료되었다는 전제 하에 로직 + 컴포넌트를 호출하여 작성할 수 있습니다. 


마치며

페이지 별로 권한 검증을 해주는 과정이 중복되는 것 같아 react-router-dom의 Route를 호출하는 시점에 유저 권한별 접근 가능한지 여부를 판단해야겠다고 생각해 Route 컴포넌트를 감싼 PrivateRoute를 만들어 보았습니다. next.js에서는 자동으로 페이지 라우팅을 처리해주기 때문에 <AuthWrapper>와 같은 꼴로 감싸줄 수도 있을 것 같습니다. 

 


Reference

 

Outlet v6.8.1

Type declarationinterface OutletProps { context?: unknown; } declare function Outlet( props: OutletProps ): React.ReactElement | null; An should be used in parent route elements to render their child route elements. This allows nested UI to show up when ch

reactrouter.com