Home > AI > Frontend > 사이드바 권한 분기 100줄을 지우고 ICON_MAP만 남기다

사이드바 권한 분기 100줄을 지우고 ICON_MAP만 남기다
AI Claude Code React Next.js MUI

문제: 사이드바가 권한을 안다

사용자 포털의 사이드 메뉴는 상수 배열로 하드코딩돼 있었다. 두 그룹의 메뉴가 파일 안에 박혀 있고, 각 항목에 permission 문자열이 붙어 있다.

const OAUTH_MENU_ITEMS = {
  IAM: { label: "IAM 관리", icon: IamIcon, href: "/oauth/iam", permission: "IAM_USER_VIEW" },
  API_KEY: { label: "API Key", icon: ApiKeyIcon, href: "/oauth/api-key", permission: "API_KEY" },
  // ...
} as const;

const hasPermission = (permissions: string[] | undefined, required: string): boolean => {
  return permissions?.includes(required) ?? false;
};

const shouldShowBlockchainMenu = (permissions: string[] | undefined): boolean => {
  return hasPermission(permissions, "FABRIC_VIEW") || hasPermission(permissions, "BESU_VIEW");
};

그리고 useMemo 안에서 사용자 정보 API로 받은 권한 배열을 돌려 필터링한다. 여기에 더해, 그룹 이름은 또 API에서 받아온 메뉴 데이터에서 찾아 덮어쓰는 코드가 붙어 있었다.

const blockchainGroupName =
  frontMenuGroups.find((g) => g.menus?.some((m) => m.url?.startsWith("/blockchain")))
    ?.groupName ?? "블록체인 서비스";

메뉴 정보가 이미 서버에서 오고 있는데, 프론트는 그중 이름만 가져다 쓰고 항목 목록과 표시 여부는 자기 상수로 다시 만들고 있었다. 같은 정보를 두 곳에서 관리하는 전형적인 형태다.

증상은 계속 나왔다. 권한 하나가 추가되면 백엔드와 프론트 양쪽을 고쳐야 하고, 한쪽만 배포되면 메뉴가 안 보이거나 눌렀더니 403이 뜨는 화면이 나온다. 그 직전에도 역할 기반 표시 조건을 하나 더 추가하는 커밋이 있었다. 조건이 계속 붙는 구조였다.

어떻게 고쳤나: 다 지웠다

useFrontMenu()가 이미 그룹과 메뉴 목록을 통째로 내려주고 있었다. 그걸 그대로 렌더링하도록 바꿨다.

type MenuInfo = { id: string; name: string; url: string };
type GroupInfo = { groupId: string; groupName: string; menus: MenuInfo[] };

const NavSideMenu = () => {
  const pathname = usePathname();
  const { refetch: refetchFrontMenu, data: frontMenuData } = useFrontMenu();

  const groups: GroupInfo[] = (frontMenuData as any)?.data ?? [];
  // ...

프론트에 남은 건 URL을 아이콘에 매핑하는 상수 하나뿐이다.

const ICON_MAP: Record<string, { icon: any; iconColor?: string }> = {
  "/blockchain": { icon: BlockchainIcon },
  "/blockchain/statistics": { icon: BarChartIcon, iconColor: "white" },
  // ...
};

const getMenuIcon = (url: string) => {
  return ICON_MAP[url] ?? { icon: FeedOutlinedIcon, iconColor: "white" };
};

?? 폴백이 있는 게 중요하다. 백엔드가 새 메뉴를 추가했는데 프론트에 아이콘 매핑이 없어도 기본 아이콘으로 렌더된다. 메뉴 추가가 프론트 배포를 기다리지 않는다. 아이콘이 예쁘지 않을 뿐 기능은 즉시 나간다.

156줄이 지워지고 52줄이 추가됐다. useMemo, useAuthStore, useUserInfo 의존이 전부 사라졌고 컴포넌트는 순수하게 API 응답을 그리는 일만 한다.

왜 권한 필터링을 프론트에서 하면 안 되는가

두 가지 이유가 있고, 둘 다 독립적으로 충분하다.

보안. 프론트의 권한 필터링은 메뉴를 안 보이게 할 뿐이다. URL을 직접 치면 그 페이지는 열린다. 그러니까 어차피 서버가 API 레벨에서 막아야 한다. 프론트 필터링은 UX 편의일 뿐 보안 장치가 아니다. 그런데 코드가 permission: "IAM_USER_VIEW" 같은 문자열을 들고 있으면, 이게 보안 로직인 것처럼 읽힌다. 실제 방어선은 다른 곳에 있는데 여기 있는 것처럼 보이는 코드가 제일 위험하다.

이중 관리 비용. 권한 판정 규칙이 서버와 클라이언트에 각각 있으면 둘은 반드시 어긋난다. 백엔드에서 권한 코드를 하나 나누거나 합치면 프론트 상수도 같이 고쳐야 하는데, 그걸 강제하는 장치가 없다. 어긋나면 “메뉴는 보이는데 403” 또는 “권한은 있는데 메뉴가 없다”가 되고, 두 경우 다 사용자가 신고하기 전에는 아무도 모른다.

권한 판정을 백엔드 한 곳으로 몰면 진실 공급원이 하나가 된다. 프론트는 “받은 걸 그린다”만 하면 되고, 그 규칙에는 어긋날 여지가 없다.

반대편: 관리자 포털에서 이 메뉴를 편집한다

같은 시기에 관리자 포털에는 이 메뉴를 관리하는 화면이 생겼다. 메뉴와 메뉴 그룹의 순서를 드래그로 바꾸는 모달이다. @dnd-kit을 썼다.

import { DndContext, useSortable, arrayMove, SortableContext,
         verticalListSortingStrategy } from "@dnd-kit/...";

<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
  <SortableContext items={orderedItems.map((i) => i.id)} strategy={verticalListSortingStrategy}>
    {/* useSortable({ id: item.id }) 을 쓰는 행 컴포넌트들 */}
  </SortableContext>
</DndContext>

useSortable이 각 행에 드래그 핸들과 transform을 주고, onDragEnd에서 arrayMove로 배열 순서를 바꿔 로컬 상태에 반영한 뒤 저장 시 서버로 보낸다. 순서 계산 로직을 직접 짤 필요가 없다는 게 arrayMove를 쓰는 이유다.

여기서 구조가 완성된다.

  관리자 포털 사용자 포털
하는 일 메뉴 생성·수정·순서 편집 받은 메뉴 렌더링
권한 판정 없음 (백엔드) 없음 (백엔드)
프론트가 가진 상수 없음 ICON_MAP만

관리자가 메뉴 순서를 바꾸면 사용자 사이드바 순서가 바뀐다. 프론트 배포 없이. 이전 구조에서는 순서를 바꾸려면 상수 배열의 원소 순서를 고쳐서 배포해야 했다.

남는 교훈

“서버에서 이미 내려주는 데이터를 프론트 상수로 다시 정의하고 있다”는 신호가 보이면, 그 상수는 대체로 지워질 수 있다. 이 경우 그룹 이름을 API에서 찾아 덮어쓰는 코드가 바로 그 신호였다. 서버 데이터를 부분적으로만 신뢰하고 있다는 뜻이니까.

그리고 삭제 156줄 / 추가 52줄인 커밋에서 실제로 중요한 건 지워진 쪽이다. hasPermission, shouldShowBlockchainMenu 같은 헬퍼는 잘 짜여 있었지만, 애초에 프론트에 있으면 안 되는 판단을 잘 수행하고 있었을 뿐이다.