Astro 다크모드에서 뒤로가기 시 라이트 테마가 깜빡이는 이슈 해결
Astro + View Transitions 환경에서 브라우저 뒤로가기 시 다크모드가 풀리며 라이트 테마가 순간적으로 노출되는 문제의 원인과 해결 방법을 정리합니다.
증상
Astro + View Transitions 환경에서 다크모드를 사용할 때, 브라우저 뒤로가기를 하면 라이트 테마(흰 배경)가 순간적으로 보였다가 다크 테마로 전환되는 깜빡임이 발생했다.
일반적인 페이지 이동(링크 클릭)에서는 문제가 없었고, 오직 뒤로가기에서만 재현되었다.
기존 구현
다크모드 초기화 스크립트는 <head> 안에 is:inline으로 작성되어 있었다. 렌더링 전에 dark 클래스를 적용하고, View Transitions의 astro:after-swap 이벤트에서 테마를 복원하는 구조다.
const applyTheme = () => {
const theme = localStorage.getItem('theme');
if (theme === 'dark' || (!theme && matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
};
applyTheme();
document.addEventListener('astro:after-swap', applyTheme);
<body>에는 테마 전환 시 부드러운 색상 변화를 위한 transition이 적용되어 있었다.
<body class="bg-white dark:bg-slate-900 transition-colors duration-200">
이 구현은 일반적인 페이지 이동에서는 완벽하게 동작한다. 문제는 브라우저 뒤로가기에서 발생했다.
원인: bfcache
bfcache란
bfcache(Back-Forward Cache)는 브라우저가 뒤로가기/앞으로가기 시 이전 페이지를 메모리에서 그대로 복원하는 최적화 기법이다. 네트워크 요청 없이 페이지를 즉시 보여줄 수 있어 체감 성능이 크게 향상된다.
핵심은, bfcache에서 복원된 페이지는 HTML을 다시 파싱하지 않는다는 점이다. 즉, <head> 안의 스크립트가 재실행되지 않는다.
왜 깜빡이는가
bfcache에서 페이지가 복원되는 흐름은 다음과 같다.
뒤로가기 발생
│
├─ bfcache에서 페이지 복원
│ └─ <html>의 dark 클래스가 빠진 상태로 복원될 수 있음
│
├─ 화면 렌더링 (라이트 테마로 보임) ← 깜빡임 발생
│
└─ astro:after-swap? → 발생하지 않음 (View Transitions를 거치지 않음)
bfcache 복원 시에는 astro:after-swap 이벤트가 발생하지 않는다. 이 이벤트는 View Transitions가 DOM을 swap할 때만 발생하는데, bfcache는 Astro의 라우팅을 거치지 않고 브라우저가 직접 페이지를 복원하기 때문이다.
결과적으로 applyTheme()이 실행되지 않아 dark 클래스가 복원되지 않고, 라이트 테마가 그대로 렌더링된다.
transition이 문제를 악화시킴
여기에 <body>의 transition-colors duration-200이 상황을 더 나쁘게 만든다. 뒤늦게 dark 클래스가 적용되더라도, transition 때문에 흰색에서 어두운색으로 서서히 변하는 애니메이션이 보인다. 깜빡임이 아니라 눈에 띄는 색상 전환이 되는 것이다.
해결
두 가지를 동시에 적용했다.
1. pageshow 이벤트로 bfcache 복원 감지
bfcache에서 페이지가 복원될 때는 pageshow 이벤트가 발생한다. 이 이벤트의 persisted 속성이 true이면 bfcache에서 복원된 것이다.
applyTheme();
document.addEventListener('astro:after-swap', applyTheme);
window.addEventListener('pageshow', (e) => {
if (e.persisted) applyTheme();
});
이제 세 가지 시나리오를 모두 커버한다.
| 시나리오 | 실행되는 코드 |
|---|---|
| 최초 페이지 로드 | applyTheme() 직접 호출 |
| View Transitions 페이지 전환 | astro:after-swap 리스너 |
| bfcache 복원 (뒤로가기) | pageshow 리스너 |
2. 테마 적용 후에만 transition 활성화
pageshow에서 테마를 복원하더라도, transition-colors가 걸려있으면 색상 전환 애니메이션이 보인다. 이를 방지하기 위해 테마가 완전히 적용된 후에만 transition을 활성화하도록 변경했다.
<body class="bg-white dark:bg-slate-900" data-theme-ready="false">
body[data-theme-ready='true'] {
transition: color 200ms ease, background-color 200ms ease;
}
<body>에서 transition-colors duration-200을 제거하고, data-theme-ready 속성으로 transition 활성화 시점을 제어한다.
테마 적용 후 requestAnimationFrame으로 한 프레임 뒤에 data-theme-ready를 true로 변경한다. 이렇게 하면 테마가 적용된 상태에서 첫 렌더링이 완료된 후에야 transition이 켜진다.
applyTheme();
requestAnimationFrame(() => {
document.body.dataset.themeReady = 'true';
});
astro:after-swap에서도 동일하게 처리한다.
document.addEventListener('astro:after-swap', () => {
applyTheme();
requestAnimationFrame(() => {
document.body.dataset.themeReady = 'true';
});
});
최종 코드
(() => {
const applyTheme = () => {
const theme = localStorage.getItem('theme');
if (theme === 'dark' || (!theme && matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
};
applyTheme();
requestAnimationFrame(() => {
document.body.dataset.themeReady = 'true';
});
document.addEventListener('astro:after-swap', () => {
applyTheme();
requestAnimationFrame(() => {
document.body.dataset.themeReady = 'true';
});
});
window.addEventListener('pageshow', (e) => {
if (e.persisted) applyTheme();
});
})();
body[data-theme-ready='true'] {
transition: color 200ms ease, background-color 200ms ease;
}
다른 접근 방법과 비교
이 문제를 해결하는 방법이 하나만 있는 것은 아니다. 검토했던 다른 대안들과 각각을 선택하지 않은 이유를 정리한다.
대안 1: color-scheme meta 태그
<meta name="color-scheme" content="dark">
브라우저에게 “이 페이지는 다크”라고 선언하는 방식이다. bfcache 복원 시에도 브라우저가 기본 배경색을 다크로 유지한다.
불채택 이유: 이 방식은 브라우저 기본 색상만 제어할 수 있다. Tailwind의 dark: 유틸리티나 커스텀 CSS 변수와는 별개로 동작하기 때문에, 배경색은 다크로 유지되더라도 나머지 요소의 색상이 불일치하는 문제가 발생한다. 클래스 기반 다크모드와 조합하기 어렵다.
대안 2: 쿠키 기반 테마 저장 + SSR
서버 사이드에서 쿠키를 읽어 HTML 자체에 dark 클래스를 포함시켜 내려보내는 방식이다. Next.js나 Nuxt 같은 SSR 프레임워크에서 자주 사용한다.
---
const theme = Astro.cookies.get('theme')?.value;
const isDark = theme === 'dark';
---
<html class={isDark ? 'dark' : ''}>
HTML 자체에 이미 dark가 포함되어 있으므로 어떤 상황에서도 깜빡임이 없다. 가장 근본적인 해결책이다.
불채택 이유: 이 프로젝트는 Astro를 정적 사이트(SSG)로 빌드하고 있어서, 요청 시점에 쿠키를 읽는 것이 불가능하다. SSR 모드로 전환하면 가능하지만, 포트폴리오 사이트의 특성상 정적 빌드가 더 적합하다.
대안 3: bfcache 비활성화
<meta http-equiv="Cache-Control" content="no-store">
bfcache 자체를 비활성화하여 근본 원인을 제거하는 방법이다. 뒤로가기 시에도 페이지를 처음부터 로드하므로 is:inline 스크립트가 정상적으로 실행된다.
불채택 이유: 뒤로가기 시 페이지를 매번 새로 로드해야 하므로 성능이 저하된다. 다크모드 깜빡임 하나를 해결하기 위해 모든 페이지에서 bfcache의 성능 이점을 포기하는 것은 과한 트레이드오프다.
대안 4: transition 자체를 사용하지 않기
<body>의 transition-colors를 아예 제거하는 방법이다. 깜빡임이 “서서히 전환되는 애니메이션”이 아니라 “즉시 전환”이 되므로 덜 눈에 띈다.
불채택 이유: 사용자가 테마 토글 버튼을 클릭했을 때도 색상이 즉시 바뀌어서 다소 딱딱한 UX가 된다. 그리고 pageshow 처리 없이는 여전히 한 프레임 정도 라이트 테마가 노출될 수 있어 근본적인 해결이 아니다.
비교 요약
| 대안 | 장점 | 불채택 이유 |
|---|---|---|
color-scheme meta | 브라우저 레벨 대응 | 클래스 기반 다크모드와 색상 불일치 |
| 쿠키 + SSR | 근본적 해결 | 정적 빌드(SSG) 환경에서 불가 |
| bfcache 비활성화 | 확실한 해결 | 성능 이점 포기 |
| transition 제거 | 간단함 | 토글 UX 저하, 깜빡임 완전 제거 아님 |
| pageshow + transition 지연 | bfcache 유지, 토글 UX 유지, 깜빡임 제거 | — |
최종적으로 pageshow + transition 지연 방식을 선택한 이유는, bfcache의 성능 이점을 유지하면서 토글 시 부드러운 전환 UX도 그대로 살릴 수 있는 가장 균형 잡힌 방법이었기 때문이다.
정리
| 원인 | 해결 |
|---|---|
bfcache 복원 시 astro:after-swap이 발생하지 않아 테마가 복원되지 않음 | pageshow 이벤트의 persisted 속성으로 bfcache 복원을 감지하여 테마 재적용 |
transition-colors가 테마 복원 시에도 동작하여 색상 전환 애니메이션이 보임 | data-theme-ready 속성으로 테마 적용 완료 후에만 transition 활성화 |
bfcache는 성능 최적화에 중요한 브라우저 기능이지만, SPA형 클라이언트 라우팅과 함께 사용할 때 이런 엣지 케이스가 발생할 수 있다. pageshow 이벤트는 bfcache 관련 이슈를 다룰 때 가장 기본적인 도구이므로 알아두면 유용하다.