React

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

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

  • 사용자 포털 사이드 메뉴: 상수 배열로 하드코딩

  • 두 그룹의 메뉴가 파일 안에 박혀 있음
  • 각 항목에 permission 문자열 부착
// 권한 코드와 경로는 가명. 구조만 그대로다.
const GROUP_A_MENU_ITEMS = {
  ALPHA: { label: "알파 관리", icon: AlphaIcon, href: "/group-a/alpha", permission: "ALPHA_VIEW" },
  BETA:  { label: "베타",     icon: BetaIcon,  href: "/group-a/beta",  permission: "BETA_VIEW" },
  // ...
} as const;

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

const shouldShowGroupBMenu = (permissions: string[] | undefined): boolean => {
  return hasPermission(permissions, "GAMMA_VIEW") || hasPermission(permissions, "DELTA_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: "ALPHA_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 부여
  • onDragEndarrayMove로 배열 순서 변경 후 로컬 상태 반영
  • 저장 시 서버로 전송
  • arrayMove를 쓰는 이유: 순서 계산 로직을 직접 짤 필요가 없음

  • 여기서 구조 완성
  관리자 포털 사용자 포털
하는 일 메뉴 생성·수정·순서 편집 받은 메뉴 렌더링
권한 판정 없음 (백엔드) 없음 (백엔드)
프론트가 가진 상수 없음 ICON_MAP만
  • 관리자가 메뉴 순서 변경 → 사용자 사이드바 순서도 프론트 배포 없이 반영

  • 이전 구조: 상수 배열의 원소 순서를 고쳐서 배포해야 했음

남는 교훈

“서버에서 이미 내려주는 데이터를 프론트 상수로 다시 정의하고 있다”는 신호가 보이면, 그 상수는 대체로 지워질 수 있다.

  • 이 경우의 신호: 그룹 이름을 API에서 찾아 덮어쓰는 코드
  • 그 의미: 서버 데이터를 부분적으로만 신뢰

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