사이드바 메뉴를 데이터가 아니라 배포 아티팩트로 다루기
사이드바가 왜 한 편씩이나 되냐고요? 모든 제품이 만나는 유일한 UI거든요. 메뉴 config를 없애고 배포 아티팩트로 다룬 이야기입니다.

"사이드바가 왜 한 편씩이나 필요해요?" — 네, 저도 압니다. 그런데 멀티 제품 플랫폼에서 사이드바는 모든 제품이 만나는 유일한 UI예요. 그래서 아키텍처의 모든 원칙이 이 작은 컴포넌트에 응축됩니다.
"재빌드 없는 플랫폼" 네 번째 편, 사이드바 이야기입니다.
사이드바가 어려워지는 지점
제품이 하나면 사이드바는 배열 하나예요. 제품이 여럿이고 고객사마다 설치 구성이 다르면 이야기가 달라집니다.
| 요구 | 제품 1개일 때 | 제품 여러 개 · 고객사별 구성일 때 |
|---|---|---|
| 메뉴 목록 | 배열 하나 | 어느 제품이 설치됐는지에 따라 달라짐 |
| 권한 | 항목마다 플래그 | 제품 설치 여부 × 사용자 권한 |
| 순서 | 작성한 순서 | 제품끼리 순서를 협상해야 함 |
| 새 제품 추가 | 배열에 한 줄 | 중앙 파일을 고치면 3편의 문제로 되돌아감 |
마지막 줄이 핵심이에요. 사이드바는 모든 제품이 만나는 유일한 화면이라, 중앙 열거를 없앤 구조가 여기서 시험당합니다. 여기서 무너지면 다른 데서 지킨 게 전부 무의미해져요.
메뉴는 어디서 오는가
config 파일을 없앴습니다
흔한 사이드바는 중앙 어딘가의 메뉴 config 배열에서 시작하죠. 우리는 그 파일을 없앴어요. 메뉴는 각 제품 패키지가 소유한 Contribution이고, codegen된 카탈로그 → 릴리스 바인딩 → Manager 조립 → 읽기 전용 Registry 스냅샷 경로로 셸에 도착합니다(2편의 구조 그대로예요). 셸은 assembly 키로 스냅샷을 읽을 뿐, 어떤 제품이 있는지 모릅니다.
그래서 메뉴가 어디서 오는지를 코드로 보면 이렇습니다.
// 제품 패키지가 자기 메뉴를 소유합니다
export const menuEntry = createMenuContribution({
source: { layer: "product", id: "product-a" },
items: [
{ id: "reports", parentId: null, label: "리포트", order: 20 },
{ id: "reports:detail", parentId: "reports", label: "상세", order: 10 },
],
});
// 셸은 이렇게만 씁니다
const snapshot = menuRegistry.snapshot(); // 읽기 전용
// 중앙 어디에도 제품 목록이 없습니다order를 10, 20처럼 띄운 건 사이에 끼워 넣을 자리를 남긴 거예요. 제품끼리 순서를 협상해야 하는데, 연속된 정수면 하나 끼울 때마다 전부 다시 매겨야 하거든요.
그리고 스냅샷은 읽기 전용입니다. 화면 어딘가에서 메뉴를 슬쩍 고치는 코드가 생기면, 그 순간 "메뉴가 왜 이래요?"를 아무도 못 풀게 되니까요.
실패는 시끄럽게
중요한 건 실패 방식이에요. 조립이 누락되면 사이드바는 기본 메뉴로 조용히 대체하지 않고, 명시적 에러 상태를 렌더합니다. "자동 fallback 0건"이 테스트 기준으로 박혀 있어요. 메뉴가 빠진 채 멀쩡해 보이는 화면이 제일 나쁜 화면이거든요.
그리고 실패 방식이 이 편에서 제일 하고 싶은 이야기예요.
// 이렇게 하고 싶어집니다 — 절대 안 됩니다
const items = snapshot.items ?? DEFAULT_MENU;
// 실제 구현
if (!snapshot.assembled) {
return <MenuAssemblyError detail={snapshot.reason} />;
}| 조립이 실패했을 때 | 기본 메뉴로 대체 | 명시적 에러 |
|---|---|---|
| 화면 | 멀쩡해 보임 | 고장이 보임 |
| 사용자 | 메뉴가 몇 개 없는 걸 모름 | 바로 신고함 |
| 개발자 | 제보가 안 들어옴 | 스택과 원인이 같이 옴 |
| 발견 시점 | 몇 주 뒤 우연히 | 즉시 |
첫 줄의 유혹이 정말 커요. 사용자에게 에러 화면을 보여주는 것보다 기본 메뉴라도 띄우는 게 친절해 보이니까요.
그런데 메뉴가 빠진 채로 멀쩡해 보이는 화면이 제일 나쁜 화면이에요. 사용자는 그 기능이 원래 없는 줄 알고, 우리는 그런 일이 벌어진 줄도 모릅니다.
그래서 "자동 fallback 0건"을 테스트 기준으로 박아 뒀어요. 조용한 대체를 넣는 코드가 들어오면 테스트가 실패합니다.
구현 — 세 개의 층, 하나의 평탄한 트리
레이어 3분할
구현은 세 층이에요. UI primitive(compound component — Item/Children/Header/Search…)는 도메인을 전혀 모르고, 애플리케이션 셸 경계가 Controller/Host로 조립 결과를 UI에 연결하고, 런타임 Registry가 스냅샷을 공급합니다. slot 객체나 render config props로 평탄화하는 건 README 수준에서 금지예요. 개인 컨벤션의 Props CASE 원칙(독립 UI 영역은 compound로)이 회사 코드베이스에서도 같은 형태로 살아 있습니다.
트리는 flat list로 계약한다
메뉴 트리의 데이터 계약은 중첩 객체가 아니라 id/parentId의 평탄한 배열이에요. 중첩 구조는 보기엔 직관적인데, 부분 갱신·검색·직렬화가 전부 재귀가 되거든요.
대신 UI 경계에서 Map 인덱스 4종(부모별 자식, 값별 부모, 값별 항목, 루트 목록)을 한 번 만들어서 렌더링·조상 펼침·선택 경로 복원을 전부 O(1) 조회로 처리했어요. 전송 계약은 평탄하게, 조회 구조는 경계에서 파생. 트리 UI의 반복 패턴입니다.
평탄한 배열로 계약한 이유도 코드로 보면 명확해요.
// 전송 계약 — 평탄합니다
type MenuItem = {
id: string;
parentId: string | null;
label: string;
order: number;
};
// UI 경계에서 조회 구조를 한 번 만듭니다
const childrenOf = groupBy(items, i => i.parentId); // 부모 → 자식들
const parentOf = new Map(items.map(i => [i.id, i.parentId]));
const itemOf = new Map(items.map(i => [i.id, i]));
const roots = items.filter(i => i.parentId === null);
// 선택 경로 복원이 O(깊이) 로 끝납니다
function ancestorsOf(id) {
const path = [];
for (let cur = parentOf.get(id); cur; cur = parentOf.get(cur)) path.push(cur);
return path;
}| 작업 | 중첩 객체 | 평탄 + 인덱스 |
|---|---|---|
| 항목 하나 갱신 | 경로를 따라 재귀 복사 | 해당 항목만 교체 |
| 검색 | 전체 재귀 순회 | 배열 한 번 훑기 |
| 선택 경로 복원 | 루트부터 탐색 | 부모 링크 따라가기 |
| 직렬화 | 깊이에 따라 커짐 | 평탄해서 그대로 |
| 기여 병합 | 트리끼리 합쳐야 함 | 배열 이어붙이기 |
마지막 줄이 이 구조를 고른 진짜 이유예요. 제품마다 메뉴를 따로 기여하는데, 중첩 트리끼리 합치는 건 생각보다 훨씬 어렵습니다. 평탄한 배열은 그냥 이어붙이면 되고요.
전송 계약은 평탄하게, 조회 구조는 경계에서 파생. 트리 UI에서 계속 만나는 패턴이에요.
디테일이 절반이다
- 리사이즈 — document 레벨 mousemove/mouseup, 리사이즈 중 body cursor·userSelect 강제와 transition 제거, 더블클릭으로 기본폭 복원, 스크롤바 유무에 따라 핸들 hit-area 위치 보정.
- 폭 저장 — localStorage에 폭과 함께 버전 문자열을 저장한다. 디자인 개편으로 기본폭이 바뀌면 버전 교체 한 번으로 전 사용자의 저장값이 무효화된다. 읽을 때는 범위 검증 후 clamp, 손상된 값은 조용히 무시하지 않고 fail-fast.
- 타입 — assembly/surface 키는 branded string으로 서로 섞이지 않게, 메뉴 가시성 정책과 활성화 액션(내부 이동/외부 링크/커맨드)은 discriminated union으로. 접근성은 role=tree/treeitem + aria-expanded.
(사이드바 폭 하나 저장하는 데도 버전 키가 붙어요. 진심입니다.)
디테일이 절반이라는 말
아래 목록이 이 컴포넌트의 절반을 차지합니다. 하나씩은 사소한데, 없으면 매일 거슬려요.
| 디테일 | 왜 필요했나 |
|---|---|
| 접힘 상태 유지 | 새로고침할 때마다 다시 펼치는 건 고문이에요 |
| 폭 저장에 버전 키 | 저장 포맷이 바뀌면 옛 값이 이상하게 해석됨 |
| 현재 경로의 조상 자동 펼침 | 깊은 메뉴에서 내가 어디 있는지 알아야 함 |
| 검색 시 조상 표시 | 자식만 나오면 맥락이 사라짐 |
| 키보드 이동 | 마우스 없이도 다닐 수 있어야 함 |
두 번째 줄은 좀 웃긴데, 사이드바 폭 하나 저장하는 데도 버전 키를 붙였어요. 진심입니다. 저장 포맷은 언젠가 반드시 바뀌고, 그때 옛 값이 어떻게 해석될지를 미리 정해 두지 않으면 사용자 화면이 이상해지니까요.
여기서도 같은 원칙이 반복돼요
지금까지의 편들을 관통한 주제는 하나였어요. 구조는 선언이 아니라 강제로 유지된다. 중앙 열거 금지도, 브랜딩의 토큰 경계도, write-once 릴리스도, 사이드바의 fallback 금지도 — 전부 문서가 아니라 실패하는 검사로 존재합니다.
그리고 그 강제 장치들이 있었기에 상용 그리드 롤백이나 중간 아키텍처 폐기 같은 큰 자기부정이 가능했어요. 개인 프로젝트(arcaC 연재)에서 얻은 교훈과 정확히 같은 결론이라는 것 — 그게 1년을 돌아본 가장 큰 수확입니다.
(사이드바 한 편에 아키텍처 원칙이 이렇게 많이 나온다는 게, 이 컴포넌트가 왜 어려운지를 보여주는 것 같기도 해요.)
그리고 이 집착은 결국 배포까지 갔어요. 고객사가 늘어도 빌드는 한 번만 도는 이야기 — 다음 편입니다.

