본문 바로가기

Project/프리온보딩

[프리온보딩 프론트엔드 챌린지 3월] - ROUTE 수정 (feat. Route Object)

 

프리온보딩 3월 사전 과제였던 로그인 구현. 과제의 세부 사항으로 여러 개의 페이지가 언제든 추가될 수 있다고 가정하고 페이지 별로 로그인 여부를 판단하고 구조를 확장이 있었다.

 

이번 게시글에서는 멘토님의 1회 차 강의를 기반으로 사전 과제를 리팩터링 하고자 한다.

 


로그인 구현을 위한 개념

 

코드 리팩토링 전 로그인 구현을 위한 개념을 살펴보자.

 

로그인의 간략한 정의는 사용자가 시스템에 접근하거나 동작을 수행하는 것을 제어하고 기록하기 위한 컴퓨터 보안 절차이다. 이러한 정의를 서비스 관점에서 살펴보면 어떨까?

 

쇼핑몰 애플리케이션을 이용하는 사용자가 있다 가정해 보자. 이 사용자는 상품을 사고 상품이 너무 마음에 들어 리뷰를 남기고 싶어 한다.

 

누구요..?

 

이 경우 사용자를 식별하지 않고 상품에 대한 리뷰를 남기게 해도 괜찮을까?

 

당연하게 상품에 대한 리뷰를 남기기 위해서는 리뷰를 작성하는 사용자가 누구인지 확인해야 한다. 이는 리뷰에 국한되지 않고 주문, 상품 관리, 쿠폰 관리 등등의 여러 서비스에서 사용자가 누구인지 식별한 뒤 해당 서비스를 제공해야 할 것이다.

 

이렇게 로그인을 통한 사용자 식별에 따라 BE에서는 API 접근에 제한을 두는 등의 역할을 수행할 수 있으며, FE에서는 권한 없이 접근하려는 사용자에게 해당 서비스에 접근하지 못하도록 구조를 만들 수 있을 것이다.

 

 

한 가지 예시를 살펴보자. github는 사용자의 권한을 확인하고, 권한이 있는 사용자라면 settings 기능을 View로 노출시켜 준다. 반대로 권한이 없는 사용자에게는 View를 노출시키지 않는다.

 

 

만약 권한이 없는 사용자가 URL에 settings를 직접 입력하고 들어갈 경우 404 페이지를 띄워 해당 기능의 존재를 모르도록 한다.

 

 


로그인 기능 구현하기 (👨🏻‍🔧 리팩토링)

 

[ 사용자 정보 ]

// type
export type UserInfo = {
  name: string;
};

export type User = {
  id: string;
  password: string;
  userInfo: UserInfo;
};

// data
export const users: User[] = [
  {
    id: "test1111",
    password: "qweqwe123123!",
    userInfo: { name: "이름1" },
  },
  {
    id: "test2222",
    password: "qweqwe123123!",
    userInfo: { name: "이름2" },
  },
  {
    id: "test3333",
    password: "qweqwe123123!",
    userInfo: { name: "이름3" },
  },
];

 

우선 위와 같은 더미 사용자 데이터를 만들어 주었다. 해당 더미 데이터를 추후 사용자 식별에 이용할 것이다.

 

 

[ API ]

 

로그인 시 사용자의 입력 값은 사용자 데이터와 비교하는 login api와 login api가 반환한 token을 사용하여 사용자 정보를 반환하는 getUserInfo api를 살펴보자.

 

// type
type LoginSuccessMessage = "SUCCESS";
type LoginFailMessage = "FAIL";

export type LoginResponse = {
  message: LoginSuccessMessage | LoginFailMessage;
  token: string;
};

// api
export const loginAPI = {
  login: async (
    id: string,
    password: string
  ): Promise<LoginResponse | null> => {
    const user: User | undefined = users.find((user: User) => {
      return user.id === id && user.password === password;
    });

    return user
      ? {
          message: "SUCCESS",
          token: JSON.stringify({ user: user.userInfo, secret: "123qwe!@#" }),
        }
      : null;
  },
};

 

login api의 경우 사용자 입력 id와 password를 파라미터로 전달받아 LoginResponse 타입이나 null을 반환한다.

 

login api 내부에서 앞서 생성한 사용자 더미 데이터에서 id와 password와 일치하는 사용자를 찾는다. 일치하는 사용자가 있다면 "SUCCESS" message와 token을, 일치하는 사용자가 없다면 null을 반환하다.

 

// type
export type UserInfo = {
  id: string;
};

// api
export const userInfoAPI = {
  getUserInfo: async (token: string): Promise<UserInfo | null> => {
    const parsedToken = JSON.parse(token);

    if (!parsedToken?.secret || parsedToken.secret !== "123qwe!@#") return null;

    const loggedUser: User | undefined = users.find((user: User) => {
      if (user.userInfo.id === parsedToken.id) return user;
    });

    return loggedUser ? loggedUser.userInfo : null;
  },
};
getUserInfo api의 경우 token을 파라미터로 전달받아 UserInfo 타입이나 null을 반환한다.

 

getUserInfo api 내부에서는  전달받은 token의 secret의 일치 여부를 판별한다. 그 후 token에 포함된 사용자 id와 일치하는 사용자를 찾는다. 일치하는 사용자가 있다면 로그인한 사용자의 정보를, 일치하는 사용자가 없다면 null을 반환하다.

 

 

[ API 연결 ]

 

앞서 만든 api를 form handler 함수에서 사용해 보자.

 

*기존 form handler 함수

  const handleLoginFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    dispatch(login());
    resetIdInputValue();
    resetPasswordInputValue();
    navigate("/");
  };

 

*api 사용

  const handleLoginFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    const loginResponse = await loginAPI.login(
      idInputValue,
      passwordInputValue
    );
    if (!loginResponse) return;

    const userInfo = userInfoAPI.getUserInfo(loginResponse.token);
    if (!userInfo) return;

    // --------
    // 사용자 정보 저장 로직 ...
    // --------

    dispatch(login());
    resetIdInputValue();
    resetPasswordInputValue();
    navigate("/");
  };

 

기존 form handler 함수의 경우 id와 password의 유효성이 통과하면 로그인 버튼이 활성화되어 바로 로그인이 되도록 매우 간단하게 함수를 만들어주었었다.

 

하지만 기존의 함수는 로그인의 기능을 제대로 수행하지 못하고 있다. 때문에 login api를 사용해 사용자 입력과 일치하는 사용자가 있는지를 확인하고 getUserInfo api를 사용해 token에 저장된 사용자 정보와 일치하는 사용자 정보를 받아 전역 상태에 저장하거나 캐시 해줄 필요가 있다.

 

추가로 login api와 getUserInfo api가 반환하는 값이 없을 경우 return 되도록 해주었다.

 

🔥 멘토님이 알려주신 Early Return 패턴을 적용해 보았다. Early Return 패턴을 사용하니 조건이 보다 명시적이고 가독성 또한 올라간 것을 느낄 수 있었다.

 

 


레이아웃 컴포넌트 만들기

 

앞서 만든  login api와 getUserInfo api를 사용해 로그인 기능을 리팩토링 해보았다. 하지만 여전히 앞서 언급했던 권한이 없는 사용자가 접근을 시도할 경우 접근할 수 없는 구조는 여전히 만들어져있지 않다.

 

* 기존 route

// type
export enum ROUTE {
  PageA = "/",
  PageB = "/page-b",
  PageC = "/page-c",
  Login = "/login",
}

// route
const AppRoute = () => {
  const isLogin = useSelector(selectIsLogin);

  return (
    <BrowserRouter>
      <Routes>
          <Route path={"/"} element={<APage />} />
          
        {/* 로그인이 된 경우 해당 route 활성화 */}
        {isLogin && (
          <Route element={<PageLayout />}>

            <Route path={ROUTE.PageB} element={<BPage />} />
            <Route path={ROUTE.PageC} element={<CPage />} />
          </Route>
        )}

        <Route path={ROUTE.Login} element={<LoginPage />} />

        {/* 위의 path를 제외한 모든 path Login 페이지로 이동  */}
        <Route path="*" element={<Navigate to={ROUTE.Login} />} />
      </Routes>
    </BrowserRouter>
  );
};

 

기존의 route 코드를 살펴보면 권한이 없는 사용자의 접근에 대한 가드가 없지는 않다. route guard를 사용하여 로그인이 된 경우 페이지의 path를 활성화시켜 주었다. 이 경우 로그인을 하지 않은 사용자가 페이지 path를 직접 입력하여 접근을 시도할 경우 로그인 페이지로 이동하도록 해주었다.

 

위의 방법의 경우 로그인을 하지 않은 사용자가 페이지 path가 아닌 존재하지 않는 path로 접근한 경우 404 페이지가 아닌 로그인 페이지로 이동한다는 문제가 있다. 또한 과제의 세부 사항인 여러 개의 페이지가 추가될 수 있는 상황을 고려한 route가 아니다.

 

아래와 같은 상황을 가정하고 살펴본 후 route 관련 코드를 리팩토링 해보자.

현재는 page A, page B, page C 세 개의 페이지만 모두 로그인이 필요한 페이지이다. 이때 로그인이 필요하지 않은 페이지가 추가되었고, 기존에 사용한 route guard를 사용하지 않는다고 가정해 보자. 

 

삽질은 이제 멈추자..

🤔 로그인이 필요한 페이지 내부에서 로그인 여부를 확인하는 로직을 각각의 페이지에 모두 작성해야 할까? 중복되는 코드를 모듈화 해서 분리한다면 중복된 코드를 작성하는 삽질은 더 이상 하지 않아도 될 것이다.

 

// types
export interface IAuthorizationProps {
  children: React.ReactNode;
}

// 로그인이 필요한 페이지를 감싸줄 컴포넌트
const Authorization: React.FC<IAuthorizationProps> = ({ children }) => {
  const navigate = useNavigate();
  const isLogin = useSelector(selectIsLogin); // 로그인 상태(전역)

  // login을 하지 않은 경우 login 페이지로 이동
  useEffect(() => {
    if (!isLogin) {
      navigate("/login");
    }
  }, [isLogin, navigate]);

  return <div>{children}</div>;
};

export default Authorization;

 

위의 Authorization 컴포넌트처럼 공통된 사용자의 로그인 여부 검사 로직을 분리해 줄 수 있다.

 

Authorization 컴포넌트를 로그인이 필요한 페이지를 감싸준다면 해당 페이지는 로그인을 하지 않은 경우 로그인 페이지로 리다이렉트 될 것이다. 

 

위의 방법으로 로그인 여부를 확인하는 코드를 중복으로 작성하는 삽질은 피했다. Authorization 컴포넌트를 로그인이 필요한 페이지르 ㄹ감싸주기만 하면 되는데 페이지를 일일이 감싸주는 삽질이 남아있다. 페이지가 추가될수록 이 삽질은 필수 삽질이 될 것이다.

 

export const routerInfo: RouterItem[] = [
  {
    path: ROUTE.Home,
    element: <Home />,
    withAuthorization: false,
    label: "Home",
  },
  {
    path: ROUTE.PageA,
    element: <APage />,
    withAuthorization: true,
    label: "Page A",
  },
  {
    path: ROUTE.PageB,
    element: <BPage />,
    withAuthorization: true,
    label: "Page B",
  },
  {
    path: ROUTE.PageC,
    element: <CPage />,
    withAuthorization: true,
    label: "Page C",
  },
  {
    path: ROUTE.Login,
    element: <LoginPage />,
    withAuthorization: false,
    label: "Login",
  },
];
router object의 withAuthorization 속성은 사용자 식별의 필요 여부를 나타낸다. 이를 이용해 로그인이 필요한 페이지인지 아닌지 구별할 수 있게 된다.

 

이때 삽질을 피하기 위해서는 router 객체를 생성해 주게 되면 문제를 해결할 수 있게 된다. 위의 route 객체를 이용하여 사전 과제의 route 코드를 수정해 보자.

 

*사전 과제 적용

export const AppRoute: Router = createBrowserRouter(
  routerInfo.map((routerItem: RouterItem) => {
    return routerItem.withAuthorization
      ? {
          path: routerItem.path,
          element: <Authorization>{routerItem.element}</Authorization>,
        }
      : { path: routerItem.path, element: routerItem.element };
  })
);

 

router 객체의 withAuthorization의 값이 true일 경우 element에 연결된 컴포넌트를 감싸고, false일 경우 element에 연결된 컴포넌트를 그대로 사용한다.

 

Page A, Page B와 Page C의 경우 로그인을 하지 않았다면 로그인 페이지로 리다이렉트 되는 결과를 확인할 수 있다.

 

위의 route 코드가 정상적으로 작동은 하지만 페이지 레이아웃(Header, Nav)을 페이지마다 넣어주어야 한다. 사전 과제의 요구 사항으로 '로그인 페이지의 경우 별도의 레이아웃이 없어야 한다.'라는 항목이 있었고, 추가될 수 있는 페이지 중 레이아웃이 필요 없는 페이지가 추가될 수 있다 가정하고 아래와 같이 수정해 주었다.

 

route object에 layout 속성을 추가했다. layout 속성의 값이 true일 경우 PageLayout 컴포넌트로 감싸고 false일 경우 감싸지 않는다.

 

export const ReactRouteObject: Router = createBrowserRouter(
  routerInfo.map((routerItem: RouterItem) => {
    if (routerItem.withAuthorization && routerItem.layout) {
      return {
        path: routerItem.path,
        element: (
          <Authorization layout={routerItem.layout}>
            <PageLayoutView>{routerItem.element}</PageLayoutView>
          </Authorization>
        ),
      };
    }

    if (routerItem.withAuthorization && !routerItem.layout) {
      return {
        path: routerItem.path,
        element: (
          <Authorization layout={routerItem.layout}>
            {routerItem.element}
          </Authorization>
        ),
      };
    }

    if (!routerItem.withAuthorization && routerItem.layout) {
      return {
        path: routerItem.path,
        element: <PageLayoutView>{routerItem.element}</PageLayoutView>,
      };
    }

    return { path: routerItem.path, element: routerItem.element };
  })
);
withAuthorization과 layout의 값에 따라 element가 정해진다.

 

이제 layout 속성의 값에 따라 각각의 페이지가 레이아웃과 함께 route 되게 되었지만 조금 아쉽다. route object를 조금 더 활용할 수 없을까?

 

*Nav 컴포넌트에 활용

const Nav = () => {
  const { onRoute } = useRoute();
  
  // route object의 path, label 활용
  const navContents = routerInfo.reduce((prev: NavContent[], current) => {
    if (current.layout) {
      return [
        ...prev,
        { route: () => onRoute(current.path), label: current.label },
      ];
    }

    return prev;
  }, []);

  const navProps: INavProps = {
    navContents,
  };

  return <NavView {...navProps} />;
};

export default Nav;

 

const NavView = ({ navContents }: INavProps) => {
  return (
    <NavContainer>
      <NavMenu>
        {navContents.map((navContent) => (
          <li onClick={navContent.route}>
            <button>{navContent.label}</button>
          </li>
        ))}
      </NavMenu>
    </NavContainer>
  );
};

 

route object의 path와 label을 활용하여 Nav 컴포넌트를 구성하도록 수정해 주었다. 기존에는 View에서 Route 할 항목을 하드 코딩으로 작성했지만 더 이상 직접 작성하지 않아도 된다.

 

😃 위의 과정을 통해 새로운 페이지를 추가할 때마다 Nav 컴포넌트에 새로운 페이지를 직접 추가하지 않아도 되게 되었다. 새로운 페이지가 추가되면 route object만 수정해 주면 사용자 인증과 Nav 컴포넌트에 항목이 추가된다.

 

👍🏻 이렇게 사전 과제의 세부 사항이었던 여러 개의 페이지가 언제든 추가될 수 있는 상황을 해결할 수 있었다.