React 다크 모드 완벽 구현 가이드

24. 04. 30. (1년 4개월 전)

TL;DR: React에서 useSyncExternalStore와 zustand를 활용해 깜빡임 없는 다크 모드를 구현하는 방법을 알아봅니다. 시스템 테마 감지부터 접근성 고려사항까지, 실무에서 바로 사용할 수 있는 완성도 높은 다크 모드를 만들어보세요.

다크 모드를 제대로 구현하려면 생각할 게 많아요. 사용자 시스템 설정을 감지하고, 깜빡임 없이 테마를 전환하고, 접근성까지 고려해야 하죠. 하지만 차근차근 따라하다 보면 생각보다 어렵지 않답니다!

💡

이 가이드에서는 실무에서 바로 사용할 수 있는 완성도 높은 다크 모드 구현 방법을 다룹니다. 복잡한 이론보다는 실제 동작하는 코드에 집중해서 설명할게요.

시스템 테마 설정 감지하기

사용자가 OS에서 다크 모드로 설정해 놓았다면, 우리 웹앱도 다크 모드로 표시되는 게 자연스럽겠죠? prefers-color-scheme 미디어 쿼리로 사용자의 시스템 테마 설정을 감지할 수 있어요.

핵심은 useSyncExternalStore을 사용하는 거예요. 이 훅이 하는 일을 간단히 설명하면:

  1. 외부 저장소(여기서는 시스템 테마 설정)의 변경사항을 실시간으로 감지
  2. React 18의 동시성 렌더링에서 UI 일관성 보장 (Tearing 방지)
시스템 다크 모드 감지 훅
const MEDIA = '(prefers-color-scheme: dark)';
 
const subscribeSystemDark = (onStoreChange: () => void) => {
  const mediaQuery = window.matchMedia(MEDIA);
  mediaQuery.addEventListener('change', onStoreChange);
 
  return () => mediaQuery.removeEventListener('change', onStoreChange);
};
 
const getSystemDarkSnapshot = () => {
  return window.matchMedia(MEDIA).matches;
};
 
const getServerSnapshot = () => {
  return undefined;
};
 
export const useSystemDark = () => {
  return useSyncExternalStore(
    subscribeSystemDark,
    getSystemDarkSnapshot,
    getServerSnapshot
  );
};
💡

useSyncExternalStore에 대해 더 자세히 알고 싶다면 JSer.dev의 블로그를 참고해보세요!

다크 모드 설정 관리하기

이제 시스템 테마를 감지했으니, 사용자가 직접 테마를 선택할 수 있는 기능도 만들어야겠죠. 사용자는 다음 세 가지 중 하나를 선택할 수 있어야 해요:

  • 시스템 설정 따르기 (기본값)
  • 라이트 모드 고정
  • 다크 모드 고정
다크 모드 설정 훅
import {
  isDarkMode,
  mergeDefaultOptions,
  type Options,
  type Theme,
} from '~/entities/theme/lib';
import { useSystemDark } from '~/entities/theme/model/use-system-dark';
 
export const useDark = (options?: Options) => {
  const { storageKey } = mergeDefaultOptions(options);
 
  const [theme, setTheme] = useLocalStorageState<Theme>(storageKey, {
    defaultValue: 'system',
  });
  const isSystemDark = useSystemDark();
 
  const isDark = useMemo(
    () => isDarkMode(theme, isSystemDark),
    [theme, isSystemDark]
  );
 
  const toggleDark = () => {
    theme === 'system'
      ? setTheme(isSystemDark ? 'light' : 'dark')
      : setTheme('system');
  };
 
  useEffect(() => {
    document.documentElement.classList.toggle('dark', isDark);
 
    if (
      (theme === 'dark' && isSystemDark) ||
      (theme === 'light' && !isSystemDark)
    ) {
      setTheme('system');
    }
  }, [isDark, isSystemDark, setTheme, theme]);
 
  return { isDark, toggleDark };
};
⚠️

이 훅은 한 가지 문제가 있어요. 여러 컴포넌트에서 동시에 사용하면 상태가 동기화되지 않는 현상이 발생할 수 있거든요. 이 문제는 zustand 같은 전역 상태 관리 라이브러리로 해결할 수 있어요!

테마 커스터마이징

다크/라이트 모드 외에도 사용자가 원하는 대로 테마를 꾸밀 수 있다면 더 좋겠죠? 색상 팔레트나 모서리 둥긂 정도 같은 것들을 커스터마이징할 수 있게 해봅시다.

테마 커스터마이저
export const ThemeCustomizer = () => {
  const [config, setConfig] = useLocalStorageState<Config>('config', {
    defaultValue: DEFAULT_CONFIG,
  });
 
  const handleConfigChange = (key: keyof Config, value: Config[keyof Config]) => {
    if (typeof value === 'string' && key === 'theme') {
      document.body.classList.remove(`theme-${config?.theme}`);
      document.body.classList.add(`theme-${value}`);
    } else if (typeof value === 'number' && key === 'radius') {
      document.body.style.setProperty('--radius', `${value}rem`);
    }
 
    if (config === undefined) {
      return setConfig(DEFAULT_CONFIG);
    }
    setConfig({ ...config, [key]: value });
  };
  // ... 나머지 렌더링 로직
};

설정이 변경될 때마다 DOM에 직접 클래스나 스타일을 적용해서 즉시 반영되도록 했어요. 사용자가 실시간으로 변화를 볼 수 있어서 훨씬 좋은 경험을 제공할 수 있답니다!

깜빡임 없는 테마 로딩

다크 모드를 구현할 때 가장 신경 쓰이는 부분이 바로 페이지 로딩 시 깜빡임 문제예요. 사용자가 다크 모드로 설정해 놓았는데, 페이지가 처음엔 라이트 모드로 잠깐 보였다가 다크 모드로 바뀐다면… 상당히 어색하겠죠? 😅

이 문제를 해결하는 핵심은 React가 렌더링되기 전에 미리 테마를 설정하는 것이에요.

1 / 3

1단계: 기본 HTML 구조

아직 깜빡임 문제가 있는 상태

🎯

핵심 아이디어: 브라우저가 스크립트 태그를 만나면 실행이 완료될 때까지 렌더링을 중단해요. 이 특성을 이용해서 테마를 미리 설정하고 나서 렌더링하도록 하는 거죠!

이제 사용자는 깜빡임 없이 처음부터 올바른 테마로 설정된 화면을 볼 수 있어요! 🎉

테마 전환 애니메이션과 접근성

테마가 바뀔 때 부드러운 애니메이션이 있으면 더 좋은 사용자 경험을 만들 수 있어요. 하지만 접근성도 함께 고려해야 합니다!

테마 전환 애니메이션
<Button className="transition-colors">
  테마 변경 버튼
</Button>
 
<button type="button" onClick={toggleTheme} className="flex">
  <div className="i-lucide-sun scale-100 dark:scale-0 transition-transform duration-300 rotate-0 dark:-rotate-90" />
  <div className="i-lucide-moon absolute scale-0 dark:scale-100 transition-transform duration-300 rotate-90 dark:rotate-0" />
  <span className="sr-only">Toggle theme</span>
</button>
⚠️

접근성 주의사항: 애니메이션은 광과민성 간질이나 전정기관 장애가 있는 사용자에게 문제가 될 수 있어요. prefers-reduced-motion 미디어 쿼리로 사용자의 “애니메이션 줄이기” 설정을 꼭 확인해주세요!

애니메이션 줄이기 설정 감지

사용자의 OS 설정을 존중하는 것이 중요해요. 다행히 prefers-reduced-motion 미디어 쿼리로 쉽게 확인할 수 있습니다.

애니메이션 줄이기 감지 훅
const QUERY = '(prefers-reduced-motion: reduce)';
 
const subscribePrefersReducedMotion = (onStoreChange: () => void) => {
  const mediaQuery = window.matchMedia(QUERY);
  mediaQuery.addEventListener('change', onStoreChange);
 
  return () => mediaQuery.removeEventListener('change', onStoreChange);
};
 
const getPrefersReducedMotionSnapshot = () => {
  return window.matchMedia(QUERY).matches;
};
 
const getServerSnapshot = () => {
  return undefined;
};
 
export const usePrefersReducedMotion = () => {
  return useSyncExternalStore(
    subscribePrefersReducedMotion,
    getPrefersReducedMotionSnapshot,
    getServerSnapshot
  );
};

이제 JS 애니메이션도 쉽게 제어할 수 있어요:

조건부 애니메이션 적용
export const Sidebar = ({ isOpen }) => {
  const shouldReduceMotion = usePrefersReducedMotion();
  const closedX = shouldReduceMotion ? 0 : '-100%';
 
  return (
    <motion.div
      animate={{
        opacity: isOpen ? 1 : 0,
        x: isOpen ? 0 : closedX,
      }}
    />
  );
};

다크모드 설정 훅 리팩토링

앞서 만든 useDark 훅에는 한 가지 문제가 있어요. 여러 컴포넌트에서 동시에 사용하면 상태가 동기화되지 않는 현상이 발생할 수 있거든요.

문제 상황

문제가 되는 사용 패턴
// theme-customizer.tsx
export const ThemeCustomizer = () => {
  const { isDark } = useDark();
  //  컴포넌트의 isDark 상태
};
 
// appearance-switch.tsx  
export const AppearanceSwitch = () => {
  const { toggleTheme } = useDark();
  //  컴포넌트에서 toggleTheme을 호출해도
  // ThemeCustomizer의 isDark가 업데이트되지 않을  있음!
};

Zustand로 해결하기

이런 문제는 전역 상태 관리로 해결할 수 있어요. zustand를 사용하면 간단하면서도 효과적으로 해결할 수 있습니다.

Zustand 테마 스토어
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
 
interface ThemeState {
  theme: Theme;
  isSystemDark: boolean;
  toggleTheme: () => void;
}
 
export const useThemeStore = create<ThemeState>()(
  persist(
    (set, get) => ({
      theme: 'system',
      isSystemDark: false,
      toggleTheme: () => {
        const { theme, isSystemDark } = get();
        if (theme === 'system') {
          set({ theme: isSystemDark ? 'light' : 'dark' });
        } else {
          set({ theme: 'system' });
        }
      },
    }),
    {
      name: 'theme',
    }
  )
);

이제 개선된 useDark 훅을 만들어보세요:

개선된 useDark 훅
export const useDark = () => {
  const theme = useThemeStore(state => state.theme);
  const toggleTheme = useThemeStore(state => state.toggleTheme);
  const isSystemDark = useSystemDark();
 
  useEffect(() => {
    useThemeStore.setState({ isSystemDark });
  }, [isSystemDark]);
 
  const isDark = useMemo(
    () => isDarkMode(theme, isSystemDark),
    [theme, isSystemDark]
  );
 
  useEffect(() => {
    document.documentElement.classList.toggle('dark', isDark);
    if (
      (theme === 'dark' && isSystemDark) ||
      (theme === 'light' && !isSystemDark)
    ) {
      toggleTheme();
    }
  }, [isDark, theme, isSystemDark, toggleTheme]);
 
  return { isDark, toggleTheme };
};

왜 zustand? React Context API보다 더 간결한 코드로 작성할 수 있어요. Context API의 불필요한 리렌더링 문제도 해결됩니다. 성능 비교와 올바른 사용법이 궁금하다면 Zustand vs Context API: 올바른 선택과 사용법을 확인해보세요!

마무리하며

React 다크 모드 구현, 생각보다 신경 쓸 게 많았죠? 하지만 이렇게 차근차근 구현하면 사용자가 정말 만족할 만한 다크 모드를 만들 수 있어요!

구현한 기능들

  • ✅ 시스템 테마 설정 자동 감지
  • ✅ 깜빡임 없는 테마 로딩
  • ✅ 부드러운 테마 전환 애니메이션
  • ✅ 접근성 고려 (prefers-reduced-motion)
  • ✅ 전역 상태 관리로 안정적인 상태 동기화
  • ✅ 테마 커스터마이징 지원

추가로 알아보면 좋은 내용

실무에서 다크 모드를 더 효율적으로 구현하고 싶다면 이런 내용들도 살펴보세요:

📚

사이드 프로젝트에서 이런저런 시도를 해보면서 많은 걸 배우는 것 같아요. 막히더라도 이미 잘 구현되어 있는 라이브러리 코드를 참고하면 되니까 부담 없이 도전할 수 있기도 하니까요! 그런 점이 사이드 프로젝트를 끊을 수 없는 이유인 것 같습니다.

참고 자료