전체 검색

글, 태그와 카테고리를 검색하고 바로 이동해요

사이드바 메뉴를 데이터가 아니라 배포 아티팩트로 다루기

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

2026. 07. 14. 18:20:326분 읽기프로젝트/데이터 플랫폼
#사이드바#UI 설계#마이크로프론트엔드
조회 35
사이드바 아티팩트 썸네일

"사이드바가 왜 한 편씩이나 필요해요?" — 네, 저도 압니다. 그런데 멀티 제품 플랫폼에서 사이드바는 모든 제품이 만나는 유일한 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년을 돌아본 가장 큰 수확입니다.

(사이드바 한 편에 아키텍처 원칙이 이렇게 많이 나온다는 게, 이 컴포넌트가 왜 어려운지를 보여주는 것 같기도 해요.)

그리고 이 집착은 결국 배포까지 갔어요. 고객사가 늘어도 빌드는 한 번만 도는 이야기 — 다음 편입니다.

시리즈 이어읽기

재빌드 없는 플랫폼

4 / 5

이야기 나누기

반응을 남기고 새 글 소식을 받아보세요.

댓글0
서로를 존중하는 대화를 남겨주세요.

첫 댓글을 남겨 대화를 시작해보세요.

새 글 구독
새 글이 공개되면 이메일로 알려드려요.