선언적 메뉴 설정
사이드바, GNB, 모바일 내비게이션 같은 메뉴 UI를 만들다 보면 거의 똑같은 패턴이 반복된다. 아이콘, 라벨, 경로, 권한 조건... 이런 것들이 JSX 안에 하드코딩되면서 메뉴 항목을 추가하거나 순서를 바꿀 때마다 컴포넌트를 직접 수정해야 한다. 메뉴가 10개, 20개로 늘어나면 JSX가 거대해지고, "어드민만 보이는 메뉴"같은 조건 분기가 중간중간 끼어들면서 읽기도 힘들어진다.
선언적 메뉴 설정은 이 문제를 데이터와 렌더러를 분리하는 방식으로 해결한다. 메뉴 항목을 배열(또는 객체)로 정의하고, 렌더링은 그 배열을 순회하는 범용 컴포넌트가 담당한다. "무엇을 보여줄지"와 "어떻게 그릴지"가 분리되는 것이다.
하드코딩 방식의 문제
전형적인 사이드바 코드를 보자.
function Sidebar() {
const { user } = useAuth();
return (
<nav>
<SidebarItem icon={<HomeIcon />} label="홈" to="/" />
<SidebarItem icon={<DashboardIcon />} label="대시보드" to="/dashboard" />
{user.role === "admin" && (
<SidebarItem icon={<UsersIcon />} label="사용자 관리" to="/admin/users" />
)}
{user.role === "admin" && (
<SidebarItem icon={<SettingsIcon />} label="시스템 설정" to="/admin/settings" />
)}
<SidebarItem icon={<ProfileIcon />} label="내 프로필" to="/profile" />
<SidebarItem icon={<LogoutIcon />} label="로그아웃" to="/logout" />
</nav>
);
}
메뉴가 6개밖에 안 되는데도 이미 지저분하다. 여기서 발생하는 문제들:
- 메뉴 항목 추가/삭제/순서 변경이 JSX 수정을 요구한다. 데이터 변경인데 UI 코드를 건드려야 한다.
- 조건부 렌더링이 흩어진다.
{user.role === "admin" && ...}같은 분기가 여기저기 끼어들면서 전체 구조를 한눈에 파악하기 어렵다. - 재사용이 안 된다. 모바일 내비게이션에서 같은 메뉴를 다른 레이아웃으로 보여주려면 메뉴 목록을 또 하드코딩해야 한다.
- 테스트가 번거롭다. 메뉴 구성을 검증하려면 컴포넌트를 렌더링해야 한다.
데이터-렌더러 분리
핵심 아이디어는 간단하다. 메뉴 항목을 설정 데이터(configuration data)로 정의하고, 렌더링 로직은 이 데이터를 소비하는 범용 렌더러가 처리한다.
1단계: 메뉴 설정 타입 정의
먼저 메뉴 항목이 가질 수 있는 속성을 타입으로 정의한다.
interface MenuItem {
key: string;
label: string;
icon: React.ReactNode;
to: string;
roles?: string[]; // 이 메뉴를 볼 수 있는 역할. 생략하면 모든 사용자에게 노출
badge?: number | string; // 알림 뱃지
children?: MenuItem[]; // 서브메뉴
external?: boolean; // 외부 링크 여부
dividerAfter?: boolean; // 이 항목 뒤에 구분선 추가
}
roles가 있으면 해당 역할을 가진 사용자만 메뉴를 볼 수 있고, 없으면 모든 사용자에게 보인다. children으로 서브메뉴를 재귀적으로 표현할 수 있다. 여기서 중요한 건 이 타입이 UI에 대한 어떤 가정도 하지 않는다는 점이다. 사이드바든 탑바든 모바일 드로어든, 같은 설정 데이터를 다른 렌더러에 넘기면 된다.
2단계: 메뉴 설정 배열
타입을 기반으로 실제 메뉴를 배열로 선언한다.
import {
Home, LayoutDashboard, Users,
Settings, UserCircle, LogOut
} from "lucide-react";
const menuConfig: MenuItem[] = [
{
key: "home",
label: "홈",
icon: <Home size={20} />,
to: "/",
},
{
key: "dashboard",
label: "대시보드",
icon: <LayoutDashboard size={20} />,
to: "/dashboard",
},
{
key: "admin-users",
label: "사용자 관리",
icon: <Users size={20} />,
to: "/admin/users",
roles: ["admin"],
},
{
key: "admin-settings",
label: "시스템 설정",
icon: <Settings size={20} />,
to: "/admin/settings",
roles: ["admin"],
dividerAfter: true,
},
{
key: "profile",
label: "내 프로필",
icon: <UserCircle size={20} />,
to: "/profile",
},
{
key: "logout",
label: "로그아웃",
icon: <LogOut size={20} />,
to: "/logout",
},
];
이제 메뉴 구성이 순수 데이터다. 컴포넌트 밖에 있으므로 별도 파일로 분리할 수 있고, 테스트에서 직접 import해서 검증할 수도 있다. "어드민 메뉴가 3개 있어야 한다" 같은 테스트를 컴포넌트 렌더링 없이 작성할 수 있다.
3단계: 필터링 유틸리티
설정 데이터에서 현재 사용자가 볼 수 있는 항목만 걸러내는 함수를 만든다.
function filterMenuByRole(items: MenuItem[], userRole: string): MenuItem[] {
return items
.filter((item) => {
if (!item.roles) return true;
return item.roles.includes(userRole);
})
.map((item) => ({
...item,
children: item.children
? filterMenuByRole(item.children, userRole)
: undefined,
}));
}
children이 있는 경우 재귀적으로 필터링한다. 이 함수는 순수 함수이므로 테스트가 쉽다.
// 테스트
const filtered = filterMenuByRole(menuConfig, "user");
expect(filtered.find((m) => m.key === "admin-users")).toBeUndefined();
expect(filtered.find((m) => m.key === "home")).toBeDefined();
4단계: 범용 렌더러
필터링된 메뉴 배열을 받아서 렌더링하는 컴포넌트를 만든다.
interface MenuRendererProps {
items: MenuItem[];
activePath: string;
}
function SidebarMenu({ items, activePath }: MenuRendererProps) {
return (
<nav>
{items.map((item) => (
<Fragment key={item.key}>
<SidebarItem
icon={item.icon}
label={item.label}
to={item.to}
isActive={activePath === item.to}
badge={item.badge}
external={item.external}
>
{item.children && (
<SidebarMenu items={item.children} activePath={activePath} />
)}
</SidebarItem>
{item.dividerAfter && <Divider />}
</Fragment>
))}
</nav>
);
}
이 렌더러는 메뉴의 내용에 대해 아무것도 모른다. 어떤 메뉴 항목이 있는지, 어드민 전용인지, 몇 개인지 전혀 관심 없다. 그저 받은 배열을 순회하면서 그릴 뿐이다.
조합
function Sidebar() {
const { user } = useAuth();
const { pathname } = useLocation();
const visibleItems = useMemo(
() => filterMenuByRole(menuConfig, user.role),
[user.role]
);
return <SidebarMenu items={visibleItems} activePath={pathname} />;
}
Sidebar 컴포넌트가 하는 일이 명확해졌다. 설정을 필터링하고 렌더러에 넘긴다. 메뉴 항목을 추가하고 싶으면 menuConfig 배열에 객체 하나를 추가하면 끝이다. 컴포넌트 코드는 건드릴 필요가 없다.
왜 이 패턴이 효과적인가
단일 변경 지점
메뉴 구성을 바꿔야 할 때 수정할 파일이 하나뿐이다. 설정 배열만 수정하면 사이드바, 모바일 내비게이션, 브레드크럼 등 이 설정을 소비하는 모든 UI가 자동으로 반영된다.
관심사 분리
- 설정 파일: 메뉴에 무엇이 있는지 (what)
- 필터 함수: 누가 무엇을 볼 수 있는지 (who)
- 렌더러 컴포넌트: 어떻게 그리는지 (how)
각각 독립적으로 변경하고 테스트할 수 있다.
다중 렌더러 지원
같은 설정 데이터를 다른 렌더러에 넘겨서 완전히 다른 UI를 만들 수 있다.
// 데스크톱 사이드바
<SidebarMenu items={visibleItems} activePath={pathname} />
// 모바일 하단 내비게이션 (상위 4개만)
<BottomNav items={visibleItems.slice(0, 4)} activePath={pathname} />
// 모바일 드로어
<DrawerMenu items={visibleItems} activePath={pathname} onClose={closeDrawer} />
// 커맨드 팔레트 (검색용)
<CommandPalette items={visibleItems} onSelect={navigate} />
menuConfig는 하나인데 UI 표현은 넷이다. 하드코딩 방식에서는 이 각각에 메뉴 목록을 복붙해야 하고, 항목이 추가될 때마다 네 곳을 모두 수정해야 한다.
고급 패턴
동적 뱃지
메뉴 설정에서 뱃지 값을 정적으로 넣는 대신, 런타임에 주입할 수 있다.
interface MenuItem {
key: string;
label: string;
icon: React.ReactNode;
to: string;
roles?: string[];
badgeKey?: string; // 뱃지 값을 주입받을 키
children?: MenuItem[];
}
function SidebarMenu({ items, activePath, badges }: MenuRendererProps) {
return (
<nav>
{items.map((item) => (
<SidebarItem
key={item.key}
icon={item.icon}
label={item.label}
to={item.to}
isActive={activePath === item.to}
badge={item.badgeKey ? badges[item.badgeKey] : undefined}
/>
))}
</nav>
);
}
// 사용
const badges = {
notifications: unreadCount, // 3
messages: newMessageCount, // 12
};
<SidebarMenu items={visibleItems} activePath={pathname} badges={badges} />
설정 데이터는 badgeKey: "notifications"만 알고, 실제 숫자 값은 렌더링 시점에 주입된다. 메뉴 설정이 API 상태에 의존하지 않으므로 순수 데이터로 유지된다.
서브메뉴와 그룹화
메뉴가 복잡해지면 항목을 그룹으로 나누거나 접이식 서브메뉴가 필요해진다. 설정 데이터의 children 필드로 자연스럽게 표현할 수 있다.
const menuConfig: MenuItem[] = [
{
key: "dashboard",
label: "대시보드",
icon: <LayoutDashboard size={20} />,
to: "/dashboard",
},
{
key: "content",
label: "콘텐츠 관리",
icon: <FileText size={20} />,
to: "/content",
children: [
{ key: "posts", label: "게시글", icon: <Edit size={18} />, to: "/content/posts" },
{ key: "media", label: "미디어", icon: <Image size={18} />, to: "/content/media" },
{ key: "categories", label: "카테고리", icon: <Tag size={18} />, to: "/content/categories" },
],
},
{
key: "analytics",
label: "분석",
icon: <BarChart size={20} />,
to: "/analytics",
roles: ["admin", "analyst"],
},
];
렌더러에서는 children이 있으면 재귀적으로 렌더링하면 된다.
function SidebarItem({ item, activePath, depth = 0 }: SidebarItemProps) {
const [isOpen, setIsOpen] = useState(false);
const hasChildren = item.children && item.children.length > 0;
return (
<div>
<button
onClick={() => hasChildren ? setIsOpen(!isOpen) : navigate(item.to)}
style={{ paddingLeft: `${16 + depth * 16}px` }}
className={activePath.startsWith(item.to) ? "active" : ""}
>
{item.icon}
<span>{item.label}</span>
{hasChildren && <ChevronIcon rotated={isOpen} />}
</button>
{hasChildren && isOpen && (
<div>
{item.children!.map((child) => (
<SidebarItem
key={child.key}
item={child}
activePath={activePath}
depth={depth + 1}
/>
))}
</div>
)}
</div>
);
}
depth를 넘기면서 들여쓰기를 자동으로 처리한다. 3단계든 5단계든 설정 데이터가 깊어지면 렌더러가 알아서 따라간다.
조건부 가시성 확장
roles만으로는 부족한 경우가 있다. "유료 플랜 사용자만", "특정 기능 플래그가 켜져 있을 때만" 같은 조건이 필요할 수 있다.
interface MenuItem {
key: string;
label: string;
icon: React.ReactNode;
to: string;
visible?: (ctx: MenuContext) => boolean; // 함수 기반 가시성
children?: MenuItem[];
}
interface MenuContext {
role: string;
plan: "free" | "pro" | "enterprise";
features: Record<string, boolean>;
}
const menuConfig: MenuItem[] = [
{
key: "ai-tools",
label: "AI 도구",
icon: <Sparkles size={20} />,
to: "/ai-tools",
visible: (ctx) => ctx.plan !== "free" && ctx.features["ai-enabled"],
},
{
key: "admin",
label: "관리자",
icon: <Shield size={20} />,
to: "/admin",
visible: (ctx) => ctx.role === "admin",
},
];
필터 함수도 업데이트한다.
function filterMenu(items: MenuItem[], ctx: MenuContext): MenuItem[] {
return items
.filter((item) => {
if (!item.visible) return true;
return item.visible(ctx);
})
.map((item) => ({
...item,
children: item.children ? filterMenu(item.children, ctx) : undefined,
}));
}
이렇게 하면 가시성 로직이 각 메뉴 항목에 코로케이션(colocation)된다. 필터 함수는 범용적이고, 개별 메뉴의 조건은 설정 데이터 안에서 자체적으로 관리된다.
설정을 외부에서 불러오기
설정 데이터가 순수 객체이기 때문에 JSON이나 API에서 불러오는 것도 가능하다.
[
{ "key": "home", "label": "홈", "to": "/", "iconName": "home" },
{ "key": "products", "label": "상품", "to": "/products", "iconName": "package" }
]
// 아이콘 매핑
const iconMap: Record<string, React.ReactNode> = {
home: <Home size={20} />,
package: <Package size={20} />,
// ...
};
function toMenuItem(raw: RawMenuItem): MenuItem {
return {
...raw,
icon: iconMap[raw.iconName] ?? <Circle size={20} />,
};
}
CMS에서 메뉴를 관리하는 경우에 유용하다. 다만 visible 같은 함수 기반 필드는 JSON으로 직렬화할 수 없으므로, 이 경우에는 roles: ["admin"] 같은 선언적 방식을 사용해야 한다.
active 상태 판별
현재 경로가 메뉴 항목의 경로와 일치하는지 판별하는 로직도 설정에 포함시킬 수 있다.
interface MenuItem {
key: string;
label: string;
icon: React.ReactNode;
to: string;
/** true면 pathname.startsWith(to) 로 판별. false(기본)면 exact match */
matchPrefix?: boolean;
}
function isActive(item: MenuItem, pathname: string): boolean {
if (item.matchPrefix) {
return pathname.startsWith(item.to);
}
return pathname === item.to;
}
/admin/users, /admin/settings 같은 하위 경로에서도 "관리자" 메뉴가 활성화되어야 한다면 matchPrefix: true를 설정하면 된다. React Router의 NavLink도 end prop으로 비슷한 기능을 제공하지만, 설정 데이터에 이 정보를 포함시키면 NavLink 없이도 어떤 렌더러에서든 일관된 active 판별이 가능하다.
테스트 전략
이 패턴의 큰 장점 중 하나가 테스트다. 각 레이어를 독립적으로 검증할 수 있다.
설정 데이터 테스트
describe("menuConfig", () => {
it("모든 항목이 고유한 key를 가진다", () => {
const keys = getAllKeys(menuConfig); // 재귀적으로 key 수집
const uniqueKeys = new Set(keys);
expect(keys.length).toBe(uniqueKeys.size);
});
it("모든 경로가 /로 시작한다", () => {
const allItems = flattenMenu(menuConfig);
allItems.forEach((item) => {
expect(item.to).toMatch(/^\//);
});
});
});
필터링 로직 테스트
describe("filterMenu", () => {
it("일반 사용자에게 admin 메뉴를 숨긴다", () => {
const result = filterMenu(menuConfig, {
role: "user",
plan: "free",
features: {},
});
const keys = result.map((r) => r.key);
expect(keys).not.toContain("admin-users");
expect(keys).toContain("home");
});
it("admin에게 모든 메뉴를 보여준다", () => {
const result = filterMenu(menuConfig, {
role: "admin",
plan: "enterprise",
features: { "ai-enabled": true },
});
expect(result.length).toBeGreaterThanOrEqual(menuConfig.length - 1);
});
});
렌더링을 하지 않고도 메뉴 구성을 검증할 수 있다. 이건 하드코딩 방식에서는 불가능하다.
이 패턴이 적합한 경우와 아닌 경우
적합한 경우:
- 메뉴 항목이 5개 이상이고 계속 늘어날 가능성이 있을 때
- 역할/권한에 따라 메뉴 구성이 달라질 때
- 같은 메뉴를 여러 형태(사이드바, 모바일, 커맨드 팔레트)로 보여줘야 할 때
- CMS나 API에서 메뉴를 동적으로 불러와야 할 때
과할 수 있는 경우:
- 메뉴가 3~4개이고 변경 가능성이 낮을 때
- 단일 UI에서만 쓰이고 재사용 계획이 없을 때
간단한 경우에는 하드코딩이 오히려 직관적이다. 패턴을 적용할 때는 항상 현재 복잡도와 미래 변경 가능성을 함께 고려해야 한다.
정리
선언적 메뉴 설정의 핵심은 데이터와 렌더링의 분리다.
| 레이어 | 역할 | 변경 시점 |
|---|---|---|
| 설정 데이터 (배열) | 메뉴에 무엇이 있는가 | 메뉴 항목 추가/삭제/순서 변경 |
| 필터 함수 | 누가 무엇을 보는가 | 권한 로직 변경 |
| 렌더러 컴포넌트 | 어떻게 그리는가 | UI 디자인 변경 |
각 레이어가 독립적으로 변경 가능하고, 독립적으로 테스트 가능하다. 이 분리 원칙은 메뉴뿐 아니라 폼 필드 설정, 테이블 컬럼 정의, 라우트 설정 등 "반복 구조를 가진 UI"에 폭넓게 적용할 수 있다.