다크 모드 토글 하나 추가하는데, 왜 CSS 파일 전체를 뒤집어엎어야 할까요?

테마 시스템의 핵심은 "스타일의 값만 교체하면 전체 UI가 따라 바뀌는 구조"를 만드는 것입니다. CSS 커스텀 속성(CSS Variables)을 사용하면 JavaScript로 변수 값만 바꾸어 전체 테마를 즉시 전환할 수 있습니다.

CSS 커스텀 속성 기초

CSS
:root {
  --color-primary: #007bff;
  --color-background: #ffffff;
  --color-text: #1a1a1a;
  --color-border: #e2e8f0;
  --spacing-sm: 8px;
  --spacing-md: 16px;
  --radius-md: 8px;
}

.card {
  background: var(--color-background);
  color: var(--color-text);
  border: 1px solid var(--color-border);
  padding: var(--spacing-md);
  border-radius: var(--radius-md);
}

CSS 변수의 특성

  • 런타임 변경 가능: JavaScript로 setProperty를 호출하면 즉시 반영됩니다
  • 상속: 부모 요소에 설정하면 자식 요소에서 사용할 수 있습니다
  • 폴백 값: var(--color, #000)으로 기본값을 지정할 수 있습니다

토큰 기반 디자인 시스템

Primitive 토큰과 Semantic 토큰

CSS
/* Primitive 토큰 — 구체적인 값 */
:root {
  --blue-50: #eff6ff;
  --blue-500: #3b82f6;
  --blue-600: #2563eb;
  --gray-50: #f9fafb;
  --gray-100: #f3f4f6;
  --gray-800: #1f2937;
  --gray-900: #111827;
}

/* Semantic 토큰 — 용도를 나타내는 이름 */
:root {
  --color-primary: var(--blue-500);
  --color-primary-hover: var(--blue-600);
  --color-background: var(--gray-50);
  --color-surface: #ffffff;
  --color-text-primary: var(--gray-900);
  --color-text-secondary: var(--gray-600);
  --color-border: var(--gray-200);
}

/* 다크 테마 — semantic 토큰의 값만 교체 */
.dark {
  --color-primary: var(--blue-400);
  --color-primary-hover: var(--blue-300);
  --color-background: var(--gray-900);
  --color-surface: var(--gray-800);
  --color-text-primary: var(--gray-50);
  --color-text-secondary: var(--gray-400);
  --color-border: var(--gray-700);
}

컴포넌트에서는 항상 semantic 토큰만 사용합니다. 테마가 바뀌어도 컴포넌트의 CSS는 수정할 필요가 없습니다.

CSS
.button-primary {
  background: var(--color-primary);
  color: white;
}

.button-primary:hover {
  background: var(--color-primary-hover);
}

/* 이 CSS는 라이트/다크 모드 모두에서 동일하게 작동 */

React Context로 테마 관리

기본 구현

JSX
const ThemeContext = createContext();

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState(() => {
    // 1. localStorage에 저장된 설정 확인
    const saved = localStorage.getItem('theme');
    if (saved) return saved;

    // 2. OS 설정 감지
    if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
      return 'dark';
    }

    return 'light';
  });

  useEffect(() => {
    // HTML root에 클래스 토글
    const root = document.documentElement;
    root.classList.remove('light', 'dark');
    root.classList.add(theme);

    // localStorage에 저장
    localStorage.setItem('theme', theme);
  }, [theme]);

  // OS 설정 변경 감지
  useEffect(() => {
    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
    const handler = (e) => {
      // 사용자가 직접 설정하지 않았을 때만 OS 설정을 따름
      if (!localStorage.getItem('theme')) {
        setTheme(e.matches ? 'dark' : 'light');
      }
    };

    mediaQuery.addEventListener('change', handler);
    return () => mediaQuery.removeEventListener('change', handler);
  }, []);

  const toggleTheme = () => {
    setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));
  };

  return (
    <ThemeContext.Provider value={{ theme, setTheme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used within ThemeProvider');
  }
  return context;
}

토글 버튼 컴포넌트

JSX
function ThemeToggle() {
  const { theme, toggleTheme } = useTheme();

  return (
    <button
      onClick={toggleTheme}
      className="theme-toggle"
      aria-label={`${theme === 'dark' ? '라이트' : '다크'} 모드로 전환`}
    >
      {theme === 'dark' ? '☀️' : '🌙'}
    </button>
  );
}

FOIT(Flash of Incorrect Theme) 방지

React가 로드되기 전에 테마가 적용되어야 깜빡임이 없습니다.

HTML
<!-- index.html의 <head>에 인라인 스크립트 추가 -->
<script>
  (function () {
    const saved = localStorage.getItem('theme');
    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    const theme = saved || (prefersDark ? 'dark' : 'light');
    document.documentElement.classList.add(theme);
  })();
</script>

이 스크립트는 React보다 먼저 실행되어 HTML에 올바른 클래스를 적용합니다.

Context 최적화

값과 업데이터 분리

JSX
const ThemeValueContext = createContext();
const ThemeActionsContext = createContext();

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');

  // actions는 useMemo로 안정적인 참조 유지
  const actions = useMemo(() => ({
    setTheme,
    toggleTheme: () => setTheme((prev) => (prev === 'dark' ? 'light' : 'dark')),
  }), []);

  useEffect(() => {
    document.documentElement.classList.remove('light', 'dark');
    document.documentElement.classList.add(theme);
    localStorage.setItem('theme', theme);
  }, [theme]);

  return (
    <ThemeActionsContext.Provider value={actions}>
      <ThemeValueContext.Provider value={theme}>
        {children}
      </ThemeValueContext.Provider>
    </ThemeActionsContext.Provider>
  );
}

// 테마 값만 읽는 컴포넌트
function useThemeValue() {
  return useContext(ThemeValueContext);
}

// 테마 변경만 하는 컴포넌트
function useThemeActions() {
  return useContext(ThemeActionsContext);
}

이렇게 분리하면 토글 버튼(actions만 사용)은 테마가 변경될 때 리렌더링되지 않습니다.

전환 애니메이션

CSS
/* 테마 전환 시 부드러운 트랜지션 */
:root {
  --transition-theme: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}

body,
.card,
.button,
.header {
  transition: var(--transition-theme);
}

/* 페이지 로드 시에는 트랜지션 비활성화 (FOIT 방지) */
.no-transition * {
  transition: none !important;
}
JSX
// 페이지 로드 시 트랜지션 비활성화
useEffect(() => {
  document.body.classList.add('no-transition');
  requestAnimationFrame(() => {
    document.body.classList.remove('no-transition');
  });
}, []);

다중 테마 지원

다크/라이트 외에도 여러 테마를 지원할 수 있습니다.

CSS
:root,
.theme-light {
  --color-primary: #3b82f6;
  --color-background: #ffffff;
  --color-text: #1a1a1a;
}

.theme-dark {
  --color-primary: #60a5fa;
  --color-background: #0f172a;
  --color-text: #e2e8f0;
}

.theme-sepia {
  --color-primary: #92400e;
  --color-background: #fefce8;
  --color-text: #422006;
}

.theme-high-contrast {
  --color-primary: #0000ff;
  --color-background: #ffffff;
  --color-text: #000000;
}
JSX
function ThemeSelector() {
  const { theme, setTheme } = useTheme();

  const themes = [
    { id: 'light', label: '라이트' },
    { id: 'dark', label: '다크' },
    { id: 'sepia', label: '세피아' },
    { id: 'high-contrast', label: '고대비' },
  ];

  return (
    <div className="theme-selector">
      {themes.map((t) => (
        <button
          key={t.id}
          onClick={() => setTheme(t.id)}
          className={theme === t.id ? 'active' : ''}
          aria-pressed={theme === t.id}
        >
          {t.label}
        </button>
      ))}
    </div>
  );
}

컴포넌트별 테마 적용

전체 테마와 별개로 특정 영역에만 다른 테마를 적용할 수 있습니다.

JSX
function InvertedSection({ children }) {
  const { theme } = useTheme();
  const invertedTheme = theme === 'dark' ? 'light' : 'dark';

  return (
    <div className={`theme-${invertedTheme}`}>
      {children}
    </div>
  );
}

// 사용
<ThemeProvider>
  <main> {/* 기본 테마 */}
    <Header />
    <InvertedSection> {/* 반전된 테마 */}
      <Banner />
    </InvertedSection>
    <Content />
  </main>
</ThemeProvider>

CSS 변수는 상속을 따르므로, 중간에 다른 클래스를 넣으면 해당 영역만 다른 테마가 적용됩니다.

완성된 테마 시스템 구조

PLAINTEXT
src/
├── styles/
│   ├── tokens/
│   │   ├── colors.css       # primitive 색상 토큰
│   │   ├── spacing.css      # 간격 토큰
│   │   ├── typography.css   # 타이포그래피 토큰
│   │   └── shadows.css      # 그림자 토큰
│   ├── themes/
│   │   ├── light.css        # 라이트 테마 semantic 토큰
│   │   ├── dark.css         # 다크 테마 semantic 토큰
│   │   └── index.css        # 테마 통합
│   └── global.css           # 리셋, 기본 스타일
├── contexts/
│   └── ThemeContext.jsx      # 테마 Context
└── components/
    └── ThemeToggle.jsx       # 테마 전환 UI

정리

CSS Variables + React Context로 구축하는 테마 시스템의 핵심을 요약합니다.

  • CSS 커스텀 속성 은 런타임에 값을 변경할 수 있어 테마 전환에 최적입니다
  • Primitive → Semantic 토큰 계층으로 설계하면 테마 추가가 쉽습니다
  • React Context 로 테마 상태를 관리하고, 값과 액션을 분리하여 최적화합니다
  • FOIT 방지: HTML head에 인라인 스크립트로 테마를 먼저 적용합니다
  • prefers-color-scheme으로 OS 설정을 감지하고, localStorage로 사용자 선택을 저장합니다
  • CSS 변수의 상속 특성을 활용하면 영역별로 다른 테마를 적용할 수 있습니다

주의할 점

FOIT(Flash of Incorrect Theme) 방지

테마 정보를 JavaScript로만 로드하면, 페이지 로드 시 기본 테마가 잠깐 보인 후 전환되는 깜빡임이 발생합니다. HTML <head>에 인라인 스크립트로 localStorage에서 테마를 읽어 data-theme 속성을 먼저 적용해야 합니다.

나중에 테마 시스템을 도입하면 비용이 급증

프로젝트 초기에 설계하지 않으면, 모든 컴포넌트의 하드코딩된 색상 값을 CSS 변수로 교체해야 합니다. Primitive → Semantic 토큰 계층을 프로젝트 시작 시점에 잡아두는 것이 중요합니다.

테마 시스템은 "구조를 먼저 잡고 컴포넌트를 만드는 것"이 핵심입니다. 나중에 도입하면 비용이 기하급수적으로 증가합니다.

댓글 로딩 중...