수영장(Sooyoung Archive)
홈개발일지이번캠App소개
전체보기
프로젝트
기술 & 개발

© 2026 수영장 (Sooyoung Archive). All rights reserved.

개인정보처리방침연락처
개발일지 목록
기술 & 개발

Next.js 16과 Tailwind CSS에서 다크/라이트 테마 깜빡임(FOUC) 없이 구현하기

2026년 9월 4일약 10분 소요12회 조회

웹사이트를 개발할 때 다크 모드(Dark Mode)는 이제 선택이 아닌 필수가 되었습니다. 특히 개발 관련 글이나 긴 코드를 읽는 개발자 블로그에서는 눈의 피로를 덜어주는 어두운 테마에 대한 선호도가 매우 높습니다.

하지만 서버 사이드 렌더링(SSR)을 기본으로 제공하는 Next.js 환경에서 다크 모드를 잘못 구현하면, **페이지를 새로고침할 때마다 하얀 화면이 순간적으로 번쩍거리는 현상(FOUC: Flash of Unstyled Content)**이 발생합니다.

이번 글에서는 외부 무거운 서드파티 라이브러리 없이, 순수 React Context와 인라인 부트스트랩 스크립트, 그리고 Tailwind CSS를 조합하여 새로고침 깜빡임이 전혀 없는 100% 깔끔한 다크/라이트 테마 시스템을 구축한 과정을 공유합니다.


1. 다크 모드 구현 시 마주치는 깜빡임(FOUC)의 원인

Next.js의 App Router는 기본적으로 서버에서 HTML을 먼저 생성하여 브라우저로 내려보냅니다.

  1. 서버 사이드 렌더링 단계: 서버는 방문자의 로컬 스토리지(localStorage)에 접근할 수 없기 때문에, 기본값(보통 라이트 모드)으로 HTML을 렌더링합니다.
  2. 브라우저 로드 단계: 브라우저가 화면을 라이트 모드로 먼저 그립니다.
  3. 클라이언트 하이드레이션 단계: React 코드가 실행되면서 useEffect 안에서 localStorage의 테마를 확인하고, 다크 모드 클래스(.dark)를 <html> 태그에 추가합니다.

결과적으로, 브라우저는 **[라이트 모드 화면 출력] -> [0.1초 후 다크 모드로 변경]**의 과정을 거치게 되며, 이 순간 유저의 눈에는 불쾌한 화면 번쩍임이 고스란히 노출됩니다.


2. 해결 전략: 렌더링 블로킹 인라인 부트스트랩 스크립트

이 문제를 해결하는 가장 확실한 방법은, 브라우저가 첫 픽셀을 화면에 그리기(Paint) 전에 <html> 요소에 .dark 클래스를 확정 짓는 것입니다.

브라우저는 HTML 문서의 <head> 안에 위치한 동기식(Inline) <script> 태그를 만나면, 해당 스크립트의 실행이 끝날 때까지 렌더링 파이프라인을 잠시 멈춥니다(Render-blocking). 이 특성을 역이용하면 깜빡임을 완전히 없앨 수 있습니다.

인라인 스크립트 코드 (src/app/layout.tsx)

typescript
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko" suppressHydrationWarning>
      <head>
        <script
          dangerouslySetInnerHTML={{
            __html: `(function(){try{
              var t = localStorage.getItem('theme');
              var d = window.matchMedia('(prefers-color-scheme: dark)').matches;
              if (t === 'dark' || (!t && d) || (!t)) {
                document.documentElement.classList.add('dark');
              } else {
                document.documentElement.classList.remove('dark');
              }
            }catch(e){}})()`,
          }}
        />
      </head>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}
  • localStorage에 저장된 테마 값이 있다면 최우선으로 반영합니다.
  • 저장된 값이 없다면 OS 시스템 설정(prefers-color-scheme: dark)을 감지합니다.
  • <html> 태그에 suppressHydrationWarning을 추가하여, 서버 렌더링된 클래스와 클라이언트 첫 렌더 클래스 간의 불일치 경고를 방지합니다.

3. 전역 테마 상태 관리 (ThemeProvider)

클라이언트 런타임에서는 React 컴포넌트들이 언제든 테마를 변경하고 현재 상태를 구독할 수 있어야 합니다.

typescript
// src/components/theme-provider.tsx
"use client";

import { createContext, useContext, useEffect, useState } from "react";

type Theme = "dark" | "light";

interface ThemeContextType {
  theme: Theme;
  setTheme: (theme: Theme) => void;
  toggleTheme: () => void;
}

const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setThemeState] = useState<Theme>("dark");

  useEffect(() => {
    const isDark = document.documentElement.classList.contains("dark");
    setThemeState(isDark ? "dark" : "light");
  }, []);

  const setTheme = (newTheme: Theme) => {
    setThemeState(newTheme);
    localStorage.setItem("theme", newTheme);
    if (newTheme === "dark") {
      document.documentElement.classList.add("dark");
    } else {
      document.documentElement.classList.remove("dark");
    }
  };

  const toggleTheme = () => {
    setTheme(theme === "dark" ? "light" : "dark");
  };

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

4. 부드러운 회전 인터랙션 토글 버튼 (ThemeToggle)

테마 전환 버튼은 시각적인 피드백이 가장 중요한 UI 요소 중 하나입니다. 해(Sun)와 달(Moon) 아이콘이 360도 회전하며 교차 페이드아웃되는 인터랙션을 구현했습니다.

typescript
// src/components/theme-toggle.tsx
"use client";

import { useTheme } from "./theme-provider";
import { Sun, Moon } from "lucide-react";

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

  return (
    <button
      onClick={toggleTheme}
      aria-label="테마 전환"
      className="relative flex size-9 items-center justify-center rounded-lg border border-border/80 bg-background/80 hover:bg-accent transition-colors"
    >
      <Sun className="size-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0 text-amber-500" />
      <Moon className="absolute size-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100 text-indigo-400" />
    </button>
  );
}

5. 도입 후 얻은 결론과 장점

  1. 완벽한 제로 플리커(Zero Flicker): 네트워크 속도가 느리거나 저사양 기기에서도 새로고침 시 화면 깜빡임이 완전히 사라졌습니다.
  2. 번들 사이즈 최적화: next-themes 같은 무거운 외부 패키지에 의존하지 않고 단 40줄의 순수 코드로 구현하여 번들 오버헤드를 0으로 유지했습니다.
  3. 시스템 테마 완벽 연동: 사용자가 OS 다크 모드를 켜거나 껐을 때의 기본값 또한 매끄럽게 처리되었습니다.

사용자 경험(UX)은 눈에 띄는 화려한 기능뿐만 아니라, 이러한 디테일한 깜빡임 방지에서 결정된다는 점을 다시 한번 체감할 수 있었습니다.

클릭할 때마다 작가에게 따뜻한 응원이 전달됩니다 ❤️

이 글이 유익하셨나요?
동료 개발자들과 경험과 노하우를 공유해 보세요.
이전 글기술 & 개발
개인 도메인 도입기: DNS 연결부터 사이트맵과 로그인 설정 점검까지
기술 & 개발다음 글
Next.js App Router canonical 점검: Search Console 대체 페이지를 구분하는 방법

함께 읽으면 좋은 다른 개발일지

'기술 & 개발' 및 추천 아카이브 글 모음

Next.js App Router canonical 점검: Search Console 대체 페이지를 구분하는 방법
기술 & 개발
Next.js App Router canonical 점검: Search Console 대체 페이지를 구분하는 방법
이 블로그의 실제 메타데이터 구성을 기준으로 canonical 상속, 한글 주소와 Search Console 확인 절차를 정리했다. 색인 생성과 광고 승인은 별도 판단임을 구분한다.
2026. 9. 7.

댓글

댓글 개인정보 안내
목차
1. 다크 모드 구현 시 마주치는 깜빡임(FOUC)의 원인2. 해결 전략: 렌더링 블로킹 인라인 부트스트랩 스크립트인라인 스크립트 코드 (src/app/layout.tsx)3. 전역 테마 상태 관리 (ThemeProvider)4. 부드러운 회전 인터랙션 토글 버튼 (ThemeToggle)5. 도입 후 얻은 결론과 장점
13회
🏊‍♂️
기술 & 개발
React 마크다운 렌더러에 스마트 목차(TOC)와 스크롤 스파이(Scroll-Spy) 적용기
긴 기술 블로그 아티클을 읽기 편하게 만들기 위해 마크다운 헤딩(#, ##)을 자동 추출하여 동적 목차(TOC)를 생성하고 IntersectionObserver로 실시간 스크롤 스파이를 구현한 경험.
2026. 9. 4.8회
개인 도메인 도입기: DNS 연결부터 사이트맵과 로그인 설정 점검까지
기술 & 개발
개인 도메인 도입기: DNS 연결부터 사이트맵과 로그인 설정 점검까지
sooyoung.pe.kr을 대표 도메인으로 연결하며 점검한 DNS, HTTPS, GitHub 로그인과 사이트맵 설정. 도메인 변경과 색인·애드센스 승인의 관계를 구분한다.
2026. 7. 10.22회