어드민에 다크모드 토큰 시스템과 모바일 사이드바 도입
목차
다크모드 토큰 시스템과 모바일 햄버거 사이드바를 한 번에 붙인 커밋이었다.
규모 자체가 크진 않지만, 건드린 파일들이 전부 "앱의 뼈대"에 해당하는 것들이라 함부로 손댔다가는 전체 레이아웃이 무너질 수 있는 작업이었음. globals.css, 루트 layout.tsx, 어드민 protected 레이아웃, 헤더 3종 세트(Header, HeaderClient, MobileNav). 이 조합이면 한 군데 실수해도 어드민 전체가 날아간다.
왜 지금 다크모드였나
사실 다크모드 요구사항은 꽤 오래 쌓여 있었다. 근데 "언제 할 거야?" 했을 때 항상 뒤로 밀렸던 이유가 있었음. 토큰 시스템 없이 그냥 dark:text-white 식으로 때려박으면 나중에 수백 군데를 일일이 뒤집어야 하는데, 그 기술부채를 팀이 감당하기 싫었던 거다.
그래서 이번에 CSS 커스텀 프로퍼티 기반 토큰 구조로 가기로 결정했다. 핵심은 컴포넌트가 색상 값을 직접 아는 게 아니라 토큰 이름만 알면 된다는 것. globals.css에서 :root와 .dark 셀렉터로 토큰을 정의하고, 나머지 컴포넌트들은 그 변수를 그냥 참조한다.
/* globals.css */
:root {
--color-bg: #ffffff;
--color-surface: #f5f5f5;
--color-text-primary: #111111;
--color-text-secondary: #555555;
--color-border: #e0e0e0;
}
.dark {
--color-bg: #0f0f0f;
--color-surface: #1a1a1a;
--color-text-primary: #f0f0f0;
--color-text-secondary: #aaaaaa;
--color-border: #2e2e2e;
}
이 패턴을 쓰면 나중에 테마 색상을 바꿔야 할 때도 globals.css 한 파일만 건드리면 된다. 팀원한테 "여기 한 파일만 수정하면 전체 테마 바뀌어" 라고 설명할 수 있는 구조가 됨. 코드리뷰 때도 이 부분을 강조했다.
토글과 Client 경계 설계
Next.js App Router 환경이라 layout.tsx는 기본적으로 서버 컴포넌트다. 다크모드 토글은 사용자 인터랙션이 필요하니까 클라이언트 쪽에 올려야 하는데, 이 경계를 어떻게 그을지가 제일 고민이었음.
결국 HeaderClient.tsx를 분리한 이유가 여기 있다. 서버 컴포넌트인 Header.tsx가 레이아웃 뼈대와 서버 데이터를 담당하고, 토글·인터랙션은 HeaderClient.tsx로 위임하는 구조.
| 파일 | 역할 | 컴포넌트 성격 |
|---|---|---|
Header.tsx |
헤더 레이아웃, 서버 데이터 | 서버 컴포넌트 |
HeaderClient.tsx |
다크모드 토글, 클라이언트 상태 | 클라이언트 컴포넌트 |
MobileNav.tsx |
햄버거 메뉴, 사이드바 오픈/클로즈 | 클라이언트 컴포넌트 |
이 분리가 없으면 Header.tsx 전체를 'use client'로 내려야 하는데, 그러면 서버 렌더링 이점을 포기하게 된다. 어드민 레이아웃 특성상 헤더에 붙는 정보가 서버에서 가져오는 것들이라 이 트레이드오프는 명확했음.
모바일 햄버거 사이드바 — 동시에 들어간 이유
다크모드랑 햄버거 메뉴가 같은 커밋에 들어간 게 이상해 보일 수 있는데, 사실 자연스러운 흐름이었다. MobileNav.tsx 자체가 새로 만들어진 파일이고, 어드민 헤더를 다크모드 대응하면서 모바일 반응형도 같이 정리하려다 보니 묶이게 됐음.
모바일 햄버거 사이드바의 경우 상태 관리 포인트가 중요하다. 사이드바 열림/닫힘 상태를 어디서 들고 있을지.
// MobileNav.tsx 개념적 패턴
'use client';
const [isOpen, setIsOpen] = useState(false);
// 사이드바 바깥 클릭 시 닫기
useEffect(() => {
const handleOutsideClick = (e: MouseEvent) => {
if (isOpen && !sidebarRef.current?.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleOutsideClick);
return () => document.removeEventListener('mousedown', handleOutsideClick);
}, [isOpen]);
이런 패턴 안 쓰면 사이드바 열린 채로 다른 메뉴 클릭했을 때 그냥 남아있는 버그가 반드시 생긴다. 팀원이 비슷한 실수를 과거에 한 번 겪었던 터라 이번엔 처음부터 챙겼음.
회고
이번 작업에서 배운 점을 정리하면:
- 토큰 먼저, 컴포넌트 나중 — 색상 시스템 없이 컴포넌트부터 만들면 반드시 나중에 다 걷어내야 함
- 서버/클라이언트 경계는 명시적으로 — App Router에서 이 경계 흐릿하게 두면 팀 전체가 혼란을 겪음
- 레이아웃 파일 건드릴 때는 PR 단위 조심 — 영향 범위가 전역이라 최소 두 사람이 리뷰 보는 걸 원칙으로 잡음
결국 UI 인프라에 해당하는 작업은 빠르게 치고 나가는 것보다 설계 한 번 제대로 잡는 게 팀 전체의 속도에 기여한다. 나중에 팀원이 새 컴포넌트 만들 때 "다크모드는 어떻게 하면 돼요?" 물어보면 "토큰 쓰면 자동이야"라고 답할 수 있는 상태가 됐음. 그게 이 커밋의 실질적인 성과라고 본다.
끝.
🛒 이 글과 어울리는 추천 상품
*위 링크는 쿠팡파트너스 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.
댓글 0
첫 댓글 달아줘.