라이트/다크를 넘어 세피아, 하이 콘트라스트 등 다중 테마를 지원하면 접근성(가독성), 브랜드 일관성, 사용자 선호 반영을 동시에 달성할 수 있습니다. 이 글은 CSS 변수와 React Context를 활용해 확장 가능한 다중 테마 토글을 구현하고, 초기 깜빡임 방지, 시스템 테마 연동, 대규모 테마 최적화까지 실무 팁을 정리합니다.
1. 설계 원칙
첫째, CSS 변수 토큰 기반으로 설계합니다. 둘째, DOM 루트에 data-theme 속성으로 테마 상태를 단일 소스로 관리합니다. 셋째, React에서는 테마 이름만 관리하고 스타일은 CSS 변수로 위임해 리렌더 비용을 줄입니다. 넷째, 시스템 테마와 동기화하며 사용자가 선택한 테마는 localStorage로 지속화합니다.
2. 토큰 기반 CSS 변수 정의(다중 테마)
프로덕션에서는 전역 CSS 파일을 권장합니다. 본문에서는 예제를 위해 런타임 주입 형태로 보여드립니다. 각 테마별로 동일한 변수 키를 사용해 스왑합니다.
// themeTokens.js
export function installBaseThemeStyles() {
if (document.getElementById('theme-base')) return;
const style = document.createElement('style');
style.id = 'theme-base';
style.textContent = `
:root,[data-theme="light"]{--bg:#ffffff;--fg:#111111;--primary:#2563eb;--muted:#6b7280}
[data-theme="dark"]{--bg:#0b0b0b;--fg:#f5f5f5;--primary:#60a5fa;--muted:#9ca3af}
[data-theme="sepia"]{--bg:#f4ecd8;--fg:#5b4636;--primary:#a35d2d;--muted:#8b7355}
[data-theme="contrast"]{--bg:#000000;--fg:#ffea00;--primary:#00e5ff;--muted:#cccccc}
`;
document.head.appendChild(style);
}
컴포넌트에서는 스타일을 CSS 변수로 참조합니다. 이렇게 하면 테마 변경 시 리렌더 없이 페인트만 발생합니다.
// 예: 어디서든 사용
export function Panel({ title, children }) {
return (
<section style={{background:'var(--bg)',color:'var(--fg)',border:'1px solid var(--muted)',borderRadius:8,padding:16}}>
<h2 style={{margin:0,color:'var(--fg)'}}>{title}</h2>
<div>{children}</div>
</section>
);
}
3. ThemeContext + Provider 구현
시스템 테마를 포함해 light, dark, sepia, contrast 등 여러 테마를 지원합니다. 사용자 선택은 localStorage로 저장하고, data-theme 속성으로 DOM과 동기화합니다.
// theme.tsx (JS로 사용 가능)
import React, {createContext, useContext, useEffect, useMemo, useState} from 'react';
import { installBaseThemeStyles } from './themeTokens';
const THEME_KEY = 'app-theme';
const THEMES = ['light','dark','sepia','contrast']; // 확장 가능
const isSystemDark = () => {
if (typeof window === 'undefined' || !window.matchMedia) return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
};
const resolveTheme = (mode) => {
if (!mode || mode === 'system') return isSystemDark() ? 'dark' : 'light';
return THEMES.includes(mode) ? mode : 'light';
};
const ThemeContext = createContext({
theme: 'light', // 실제 적용 테마
mode: 'system', // 사용자 선택값(system | 특정 테마)
setMode: (m) => {},
themes: THEMES,
nextTheme: () => {},
});
export function ThemeProvider({ children, defaultMode = 'system' }) {
const [mode, setMode] = useState(() => {
if (typeof window === 'undefined') return defaultMode;
return localStorage.getItem(THEME_KEY) || defaultMode;
});
useEffect(() => {
installBaseThemeStyles();
}, []);
// DOM 동기화 + 시스템 테마 리스너
useEffect(() => {
const apply = () => {
const effective = resolveTheme(mode);
document.documentElement.setAttribute('data-theme', effective);
};
apply();
if (mode === 'system' && window.matchMedia) {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const onChange = () => apply();
if (mq.addEventListener) mq.addEventListener('change', onChange);
else mq.addListener(onChange);
return () => {
if (mq.removeEventListener) mq.removeEventListener('change', onChange);
else mq.removeListener(onChange);
};
}
}, [mode]);
// 지속화
useEffect(() => {
try { localStorage.setItem(THEME_KEY, mode); } catch(e) {}
}, [mode]);
const value = useMemo(() => ({
theme: resolveTheme(mode),
mode,
setMode,
themes: THEMES,
nextTheme: () => {
const current = resolveTheme(mode);
const idx = THEMES.indexOf(current);
const next = THEMES[(idx + 1) % THEMES.length];
setMode(next);
}
}), [mode]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export const useTheme = () => useContext(ThemeContext);
4. 테마 스위처 컴포넌트
버튼으로 순환 전환, 셀렉트로 직접 선택을 제공합니다. 접근성을 위해 aria-pressed, 라벨을 제공합니다.
// ThemeSwitcher.jsx
import React from 'react';
import { useTheme } from './theme';
export function ThemeSwitcher() {
const { theme, mode, setMode, themes, nextTheme } = useTheme();
return (
<div style={{display:'flex',gap:8,alignItems:'center'}}>
<button
type="button"
aria-label={`현재 테마 ${theme}`}
aria-pressed="true"
onClick={nextTheme}
style={{padding:'8px 12px', background:'var(--primary)', color:'var(--bg)', border:'none', borderRadius:6}}
>
Theme: {theme}
</button>
<label>
<span className="sr-only">테마 선택</span>
<select value={mode} onChange={(e) => setMode(e.target.value)}>
<option value="system">system</option>
{themes.map(t => (<option key={t} value={t}>{t}</option>))}
</select>
</label>
</div>
);
}
앱에 적용합니다.
// App.jsx
import React from 'react';
import { ThemeProvider } from './theme';
import { ThemeSwitcher } from './ThemeSwitcher';
import { Panel } from './Panel';
export default function App(){
return (
<ThemeProvider>
<div style={{minHeight:'100vh',background:'var(--bg)',color:'var(--fg)',padding:24}}>
<ThemeSwitcher />
<Panel title="다중 테마 예제">본문 색상이 테마에 따라 바뀝니다.</Panel>
</div>
</ThemeProvider>
);
}
5. 초기 깜빡임(FOUC) 방지 스니펫
SSR 또는 CRA에서도 index.html에 인라인 스크립트를 넣어 React 마운트 전 data-theme을 적용하면 깜빡임을 줄일 수 있습니다.
// index.html <head> 최상단에 삽입 (script defer/async 금지)
<script>(function(){
try {
var key='app-theme';
var saved=localStorage.getItem(key)||'system';
var dark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
var effective = saved==='system' ? (dark?'dark':'light') : saved;
document.documentElement.setAttribute('data-theme', effective);
} catch(e) {}
})();</script>
Next.js의 경우 _document에서 동일 로직을 dangerouslySetInnerHTML로 주입하거나, 서버에서 쿠키를 읽어 초기 data-theme을 세팅해 수화 불일치를 방지합니다.
6. 대규모 테마 최적화: CSS 청크 지연 로드
테마가 많거나 색 토큰이 방대하면 각 테마를 별도 CSS로 분리해 필요할 때만 로드합니다. 번들러 설정에 따라 동적 import 또는 link 교체를 사용합니다.
// lazyThemeCSS.js
export function ensureThemeAsset(name){
const id='theme-chunk';
const href=`/themes/${name}.css`; // 빌드시 생성되는 경로
let link=document.getElementById(id);
if(!link){
link=document.createElement('link');
link.id=id; link.rel='stylesheet';
document.head.appendChild(link);
}
if(link.getAttribute('data-name')!==name){
link.href=href;
link.setAttribute('data-name', name);
}
}
테마 변경 useEffect에서 effective 테마가 바뀔 때 ensureThemeAsset(effective)를 호출해 필요한 CSS만 로드합니다. 라이트/다크는 기본 번들에 포함, 추가 테마만 지연 로드하면 초기 성능을 유지합니다.
7. 접근성, 국제화, 테스트 팁
접근성 측면에서 하이 콘트라스트 테마를 포함하고, 중요한 UI 대비비는 WCAG AA 이상을 목표로 합니다. 버튼에는 aria-label, aria-pressed를 부여하고, 키보드 포커스가 명확하도록 포커스 스타일을 변수로 지정합니다. 국제화에서는 테마 이름 표기를 번역 리소스로 분리하고 키로 관리합니다. 테스트는 resolveTheme, localStorage 지속화, data-theme 반영을 단위 테스트로 검증하고, 스냅샷 테스트는 피하며 시각 리그레션 도구(예: Percy)를 병행합니다.
8. 라이브러리 연동 가이드
Tailwind는 data-attribute 변형을 사용해 다중 테마를 깔끔히 지원할 수 있습니다. 예를 들어 루트에 data-theme를 두고, 구성에서 색 토큰을 CSS 변수로 매핑하면 클래스는 그대로 두고 테마만 교체합니다. MUI/Chakra/Emotion 등 CSS-in-JS를 쓰는 경우에도 디자인 토큰 레벨을 CSS 변수로 유지하면 런타임 오버헤드를 최소화할 수 있습니다.
9. 체크리스트
첫째, 토큰 정의가 모든 컴포넌트에 일관되게 적용되는지 확인합니다. 둘째, 초기 로드 시 FOUC가 없는지 확인합니다. 셋째, 시스템 테마 변경 이벤트가 system 모드에서만 반응하도록 했는지 점검합니다. 넷째, 사용자 선택은 반드시 localStorage로 지속화하고, 개인정보보호 정책에 반영합니다. 다섯째, 성능을 위해 테마 변경 시 React 리렌더를 유발하지 않도록 스타일은 CSS 변수에 위임합니다.
위 패턴만 적용해도 라이트/다크를 넘어 다양한 테마를 손쉽고 성능 좋게 확장할 수 있습니다. 실제 서비스에서는 디자인 토큰을 별도 패키지로 관리하고, 실측 성능과 접근성 점수를 지표화해 운영하는 것을 권장합니다.
'React' 카테고리의 다른 글
| React와 D3.js를 결합한 데이터 시각화 기법 (0) | 2026.05.29 |
|---|---|
| React에서 오디오 플레이어 컴포넌트 만들기 (0) | 2026.05.29 |
| React에서 사용자 프로필 편집 페이지 제작하기 (0) | 2026.05.29 |
| React로 차트 애니메이션 직접 구현하기 (0) | 2026.05.28 |
| React 앱에서 브라우저 Speech Recognition API 사용하기 (0) | 2026.05.28 |