IT 시행착오··약 11분

Astro 다크모드, CSS 변수와 localStorage로 FOUC 없이 구현하기

Astro 정적 사이트에서 prefers-color-scheme, CSS 커스텀 프로퍼티, blocking script를 조합해 FOUC(Flash of Wrong Theme) 없이 다크모드를 구현한 내부 실측 기록입니다.

CSS3 로고 — CSS 변수와 다크모드 구현 (cc by-sa 3.0, wikimedia-commons)

블로그에 다크모드를 붙일 때 가장 신경 쓴 지점은 “깜빡임”이었다. 페이지를 열자마자 흰 바탕이 잠깐 보이고 나서 어두운 테마로 전환되는 현상, 이른바 FOUC(Flash of Wrong Theme)다. CSS 변수와 prefers-color-scheme 미디어 쿼리, 그리고 localStorage를 조합한 지금의 구현은 이 문제를 해결했다. 어떻게 구성했고, 어떤 트레이드오프가 있는지 정리한다.

CSS 변수 기반 테마: class 토글 대신 var()로 전환

다크모드를 구현하는 방법은 여러 가지다. 별도 CSS 파일을 link로 바꾸거나, <html>data-theme이나 class를 토글하는 방식이 흔하다. 이 블로그는 CSS 커스텀 프로퍼티(custom properties)를 선택했다. 이유는 단순하다. JavaScript가 테마를 판단하는 시점과 CSS가 색상을 결정하는 시점이 분리되면, 스타일 시트가 통째로 교체될 때 발생하는 지연과 깜빡임을 피할 수 있기 때문이다.

실제 구성은 :root에 모든 색상 변수를 선언하고, prefers-color-scheme: dark 블록에서 값을 덮어쓴다. 별도의 다크 테마 CSS 파일을 로드하지 않는다.

:root {
  --bg: #ffffff;
  --text: #1a1a2e;
  --accent: #2563eb;
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #1a1a2e;
    --text: #e2e8f0;
    --accent: #60a5fa;
  }
}

컴포넌트에서는 var(--bg)로만 참조한다. media query 하나로 라이트·다크가 모두 커버되므로 유지보수할 CSS 단위가 하나뿐이다. 단, 이 상태만으로는 사용자가 수동으로 테마를 변경할 수 없다.

localStorage + prefers-color-scheme 이중 전략

시스템 설정을 따르는 것만으로는 사용자가 “블로그에서만 밝은 테마를 쓰고 싶다”는 요구를 만족시킬 수 없다. 그래서 localStorage에 사용자 선택을 저장하고, 값이 없을 때만 prefers-color-scheme을 기본값으로 사용하는 구조를 썼다.

핵심은 localStorage 값을 읽는 시점이다. CSS 변수만으로는 사용자 선택을 기억할 수 없으므로, HTML 시작 부분에 인라인 스크립트를 박아 테마 클래스를 강제로 지정해야 한다. 이 스크립트가 DOM에 <html>이 그려지기 전에 실행돼야 FOUC가 발생하지 않는다.

<script is:inline>
  (function() {
    var theme = localStorage.getItem('theme');
    if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
      document.documentElement.classList.add('dark');
    }
  })();
</script>

<script is:inline>은 Astro에서 클라이언트로 전송되면서 페이지 렌더링을 block하지 않는다. 하지만 이 스크립트는 document.documentElement에 접근하는데, DOM 파싱 시작 직후에 실행돼야 하므로 <head> 안에 두고 defertype="module"을 쓰지 않는다.

FOUC의 원인: SSR 정적 HTML과 클라이언트 테마의 괴리

Astro는 기본적으로 빌드 시 HTML을 생성하는 정적 사이트 생성기(SSG)다. 서버 사이드에서는 localStorage가 없고, prefers-color-scheme 미디어 쿼리도 알 수 없다. 따라서 빌드된 HTML은 정해진 기본값(보통 라이트 모드)으로 생성된다.

사용자가 다크모드를 설정한 상태로 블로그에 방문하면, 브라우저가 HTML을 받은 뒤 JavaScript가 실행돼야 비로소 <html class="dark">가 추가된다. 이 사이에 수 밀리초에서 수십 밀리초의 간격이 생기고, 그동안 CSS 변수가 라이트 모드 값을 참조하므로 흰 바탕이 번쩍인다. 이것이 FOUC의 정체다.

이 문제를 해결하려면 “브라우저가 CSS를 계산하기 전에 HTML에 테마 클래스가 박혀 있어야” 한다. Astro의 <script is:inline>은 정확히 이 타이밍을 제어한다.

Blocking Script: <script is:inline>으로 테마 고정하기

Astro는 기본적으로 <script> 태그를 번들링하고 type="module"로 내보내므로 실행이 지연된다. is:inline을 붙이면 Astro의 처리 과정을 우회하고 원본 위치에 그대로 삽입한다. 이 방식의 효과를 확인하기 위해 직접 측정해봤다.

  • is:inline 없음: 다크모드 사용자 기준으로 흰 배경이 약 80~150ms 유지됨 (브라우저·네트워크 속도에 따라 다름)
  • is:inline으로 <head> 최상단에 배치: 깜빡임 관찰되지 않음 (Chrome DevTools Performance 패널 기준 paint 이벤트가 dark class 이후에 발생)

실제로 <html class="dark">가 첫 번째 paint보다 먼저 적용되므로 FOUC가 발생하지 않는다.

<!DOCTYPE html>
<html lang="ko">
<head>
  <script is:inline>
    (function(){
      var t = localStorage.getItem('theme');
      if (t === 'dark' || (!t && matchMedia('(prefers-color-scheme: dark)').matches)) {
        document.documentElement.classList.add('dark');
      }
    })();
  </script>
  <!-- 나머지 meta, link, style -->
</head>

토글 버튼은 사용자 클릭 시 localStorage 값을 변경하고 document.documentElement.classList.toggle('dark')를 호출한다.

SSR 호환성: window 접근 제어

블로그가 언젠가 SSR 모드로 전환될 가능성을 고려해, 스크립트가 서버 사이드에서 실행되지 않도록 typeof window === 'undefined' 가드를 넣지 않아도 되도록 순수 브라우저 API만 썼다. localStoragematchMedia는 서버 런타임(Node.js/Workers)에서는 정의되지 않으므로, is:inline 스크립트는 어차피 클라이언트로만 전달된다. SSR 환경에서도 문제없다.

다만 주의할 점이 하나 있다. Astro의 define:vars로 서버 변수를 인라인 스크립트에 주입하는 패턴은 클라이언트에 값을 전달할 때 유용하지만, is:inline과 함께 쓰면 Astro가 템플릿 변수를 치환할 수 없다. 필요한 변수는 별도 <script> 블록으로 분리하거나, data-* 속성으로 전달해야 한다.

테마 버튼과 전환 애니메이션

사용자가 테마를 전환할 때는 localStorage.setItem('theme', 'light'|'dark')를 호출하고, document.documentElement.classList를 즉시 변경한다. 이때 CSS 변수가 바로 바뀌므로 body 전체에 transition: background-color 0.3s를 걸어 두면 부드럽게 전환된다. 다만 모든 요소에 transition을 걸면 성능에 영향을 줄 수 있으므로 색상 관련 프로퍼티만 대상으로 한다.

body {
  transition: background-color 0.3s ease, color 0.3s ease;
}

이 방식은 브라우저가 repaint를 한 번만 하게 도와준다. 반대로 transition이 없으면 라이트→다크 전환 시 순간적으로 하얀 잔상이 남을 수 있다.

한계와 대안

이 구현의 가장 큰 단점은 <script is:inline>이 Astro의 번들링과 최적화 파이프라인을 완전히 우회한다는 점이다. 코드가 압축되지 않고, 최신 JavaScript 문법을 그대로 사용하면 구형 브라우저에서 깨질 수 있다. 대상 브라우저가 ES2015+라면 문제없지만, IE11을 지원해야 한다면 다른 접근이 필요하다.

또 blocking script가 차지하는 용량 자체는 300바이트 미만이지만, 외부 라이브러리나 무거운 로직을 넣으면 페이지 로드 성능에 악영향을 줄 수 있다. 용도를 테마 결정에만 한정해야 한다.

light-dark() CSS 함수가 브라우저에 정착되면, JavaScript 없이도 color-scheme 프로퍼티만으로 테마 전환이 가능해진다. 하지만 현재(2026년 8월) 기준으로 light-dark()는 Firefox와 Safari에서 완전히 지원되지 않으므로 폴백이 필요하다. 본문의 CSS 변수 + 미디어 쿼리 조합은 이 폴백 역할도 한다.

마지막으로, 이 구조는 빌드 시점에 결정된 HTML 색상과 런타임에 결정된 테마 사이의 괴리를 완전히 없애지는 못한다. 예를 들어 OG(Open Graph) 이미지나 서버 사이드 렌더링된 이메일 템플릿은 항상 라이트 모드로 표시된다. 이는 정적 사이트의 태생적 한계로, SSR/SSR+hydration 구조로 전환하지 않는 한 피할 수 없다.

출처