개발 slecs

어드민에 다크모드 토큰 시스템과 모바일 사이드바 도입

목차

다크모드 토큰 시스템과 모바일 햄버거 사이드바를 한 번에 붙인 커밋이었다.

규모 자체가 크진 않지만, 건드린 파일들이 전부 "앱의 뼈대"에 해당하는 것들이라 함부로 손댔다가는 전체 레이아웃이 무너질 수 있는 작업이었음. 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

첫 댓글 달아줘.