페이지마다 색이 튀는 게 신경 쓰여서 — 테마 시스템 도입기
페이지마다 다른 컬러 컨셉을 입히고 싶었는데 네비게이션과 모달이 자꾸 따로 놀았던 이야기. CSS 변수 지역 재정의와 Portal 격리 보완으로 온전한 테마 팔레트 시스템을 만든 기록.
시작 — 이질감을 느끼다
블로그 배포 후 어느 날 스타쉽 미션 페이지를 열어봤더니 상단 버튼들은 시안색(cyan) 테마인데 햄버거 메뉴를 열자 여전히 보라색이 가득했습니다. 묘하게 어긋나는 기분이 들었습니다.
처음엔 "햄버거 메뉴 색만 맞춰주면 되겠네" 하고 가볍게 봤지만, 파고들어 보니 이야기가 훨씬 깊었습니다.
상황 파악
당시 상단 SystemButtons 컴포넌트는 이미 간단한 테마 스위치를 갖고 있었습니다. theme: 'starship' 을 prop 으로 받으면 cyan 톤으로 바뀌는 정도. 그런데 이 스위치가 SystemButtons 내부에서만 유효했습니다.
- 상단 아이콘 버튼들 → cyan ✓
- NavMenu (햄버거 메뉴) → 보라 그대로
- InfoModal (정보 팝업) → 보라 그대로
- SearchPalette (⌘K) → 보라 그대로
- 페이지 본문 → 보라 그대로
"한 페이지에서 색을 통일하고 싶다" 는 관점에선 시작 지점 외에는 아무것도 안 바뀌고 있었던 셈입니다.
왜 이렇게 된 걸까
두 가지 이유였습니다.
1. 전역 토큰을 그대로 참조
프로젝트 전체 CSS 는 이런 패턴을 쓰고 있었습니다.
.button {
border: 1px solid rgba(var(--js2-primary), 0.4);
}
--js2-primary 는 globals.css 에 139, 92, 246 (purple) 으로 박혀 있는 전역 변수. 모든 요소가 이 값을 직접 참조하기 때문에, 단 한 곳을 바꾸는 게 거의 불가능 했습니다. 바꾸려면 전역 값을 통째로 교체해야 하는데, 그러면 홈도 다른 페이지도 전부 색이 바뀌어 버립니다.
2. Portal 격리
NavMenu·InfoModal·SearchPalette 는 모두 createPortal(..., document.body) 로 렌더되고 있었습니다. 말하자면 트리를 탈출해서 body 바로 밑에 붙어버리는 구조. 덕분에 z-index 쌓임 순서는 깔끔해졌지만, 대가는 CSS cascade 가 끊긴다는 것이었습니다.
PageLayout 루트에 클래스를 주든 뭘 하든, Portal 로 날아간 요소는 그 영향권 밖이었습니다.
해결 실마리 — CSS 변수의 지역 재정의
CSS 변수의 매력은 cascade 입니다. 특정 요소에 값을 재정의하면 그 하위의 모든 자식이 새 값으로 계산됩니다. 원래 값은 그대로 두고요.
.themeAurora {
--js2-primary: 110, 220, 180;
--js2-primary-mid: 140, 230, 195;
}
이 클래스를 페이지 루트에 붙이면 그 페이지 안의 모든 rgba(var(--js2-primary), ...) 가 민트 톤으로 계산됩니다. 전역 값은 건드리지 않고요. 깔끔했습니다.
다만 Portal 이 문제였습니다. Portal 요소는 페이지 루트 아래에 있지 않으니 이 재정의가 닿지 않습니다. 해결책은 단순했습니다 — Portal 컴포넌트 내부에 같은 클래스를 직접 붙여준다.
테마 팔레트 정하기
색 6가지로 시작했습니다. 정확히는 기본 보라 + 5가지 추가.
| ID | 톤 | 느낌 |
|---|---|---|
default | 보라 | 기본·메타 페이지 |
starship | 하늘 | 우주선·미션 |
inferno | 앰버 | 아케이드·전투 |
aurora | 민트 | 힐링·창작 |
void | 마젠타 | 심연·카오스 |
solar | 골드 | 천문·관측 |
각 테마는 4개 변수를 재정의합니다.
.themeAurora {
--js2-primary: 110, 220, 180;
--js2-primary-light: 180, 240, 215;
--js2-primary-mid: 140, 230, 195;
--js2-primary-pale: 210, 250, 230;
}
4개면 충분했습니다. 기존 CSS 가 --js2-primary-* 계열 외에는 거의 참조하지 않았기 때문입니다.
적용 범위 — 4곳에 동일 블록
PageLayout root → 페이지 본문 + SystemButtons 자동 상속
NavMenu panel → Portal 지역 재정의
InfoModal panel → Portal 지역 재정의
SearchPalette panel → Portal 지역 재정의 (layout.tsx 레벨 렌더)
4곳 모두 같은 RGB 값을 갖는 .themeXxx 블록을 두었습니다. 중복이긴 하지만, CSS Module 스코프가 각각 다르고 Portal 이 격리되어 있어 피할 수 없는 중복이었습니다. 대신 한 군데만 있으면 되는 값들은 철저히 중복 제거했습니다.
자동 매핑 — 페이지가 늘어나면?
초기 5종 테마 설계를 보여드렸더니 사용자(저 자신)가 이런 요구를 했습니다.
"앞으로 페이지가 더 늘어날 수도 있고, 특정 페이지만 다른 테마 쓰고 싶을 때도 있을 것 같은데..."
그래서 중앙 매핑 테이블 을 도입했습니다. pageTheme.ts 한 파일에 "어느 URL 이 어느 테마를 쓰는지" 를 몰아놓았습니다. 카테고리별로 분리해서 눈에 보기 편하게.
const HEALING_THEME = {
'/cosmos': 'default',
'/breathing': 'aurora',
// ...
};
const BATTLE_THEME = {
'/galaga': 'inferno',
'/cosmicBarrage': 'starship',
// ...
};
export const PAGE_THEME = {
...HEALING_THEME,
...BATTLE_THEME,
// ...
};
PageLayout 이 usePathname() 으로 현재 경로를 읽어 이 테이블을 lookup 합니다. 페이지 컨테이너가 직접 theme 을 넘기면 그게 우선, 아니면 테이블의 매핑 사용. 없으면 기본 보라.
덕분에 향후 "개발 동향" 같은 새 영역이 생기면 pageTheme.ts 에 1줄 추가하는 걸로 끝납니다.
작업 중 재밌었던 순간
작업을 마치고 페이지 본문 레이아웃을 확인해 봤더니, 여전히 버튼들이 보라색 이었습니다. 이미 root 에 테마 클래스를 붙여놨는데 왜? 원인은 단순했습니다. 본문의 CSS 몇 곳에 rgba(139, 92, 246, 0.15) 처럼 RGB 값을 직접 하드코딩 해놨던 거죠. 그 시절의 저를 탓했습니다.
전수 grep 해보니 7개 파일 68곳. 일괄 sed 로 rgba(var(--js2-primary), ...) 패턴으로 치환했습니다. 그다음에야 모든 색이 연동되기 시작했습니다.
교훈: 디자인 토큰을 만들었다면 예외를 만들지 말 것. 한 곳이라도 하드코딩이 섞여 있으면 전체 시스템이 무너집니다.
최적화 — 중복 제거
초기에 SystemButtons 안에도 .themeXxx 5개 블록을 따로 넣었었는데, 이게 나중에 불필요했음을 깨달았습니다. PageLayout 루트에 테마 클래스가 붙으면 --js2-primary 가 이미 재정의되고, SystemButtons 는 그 값을 자연히 cascade 로 상속받아 자체 --sys-btn-* 변수에 적용되기 때문입니다.
그 5개 블록을 지우고 이런 한 줄 주석을 남겼습니다.
/* 테마 오버라이드는 PageLayout root 의 .themeXxx 가 --js2-primary 를 재정의하면
위 .root 의 --sys-btn-* 이 자동으로 새 값을 상속받아 적용됨 (cascade). */
코드 50여 줄이 주석 한 줄로 바뀐 순간이었습니다. 이 맛에 리팩토링 합니다.
회고
1. CSS 변수 지역 재정의는 강력합니다
"전역 토큰을 바꾸지 않고 특정 영역만 덮는다" 는 패턴은 생각보다 적용 범위가 넓습니다. 테마뿐 아니라 컴포넌트 variant, 다크 모드, 섹션별 강조 등에 다 쓸 수 있습니다.
2. Portal 은 편리하지만 격리된다는 점을 잊지 말아야 합니다
z-index 쌓임을 깔끔하게 하려고 Portal 을 쓰는 순간 CSS cascade 를 잃습니다. 테마나 다크 모드 같이 하위 트리 전체에 영향을 미쳐야 하는 것 들은 Portal 컴포넌트 내부에 별도 처리가 필요합니다.
3. 확장성은 처음부터 넣어야 합니다
"지금은 5개 테마만 필요해" 라고 하드코딩하면 6번째 테마가 생기는 순간 여러 곳을 건드려야 합니다. 상수 테이블 한 곳만 수정하면 되는 구조로 처음부터 만들어두는 편이 결국 빠릅니다.
블로그 페이지마다 살짝씩 다른 색이 들어가니 훨씬 입체적으로 느껴집니다. 작은 차이지만 이런 게 쌓여서 사이트 전체 인상을 만드는 것 같습니다.
방명록
이 글에 대한 한 줄을 남겨주세요
불러오는 중...