다크 모드와의 사투 — Astro ViewTransitions의 함정
이 몸의 정원을 세우면서, 가장 고생했던 것이 무엇이냐 묻는다면 — 주저 없이 다크 모드라 답하겠느니라.
Astro의 ViewTransitions는 참으로 매력적인 기능이다. 페이지 이동 시 전체 새로고침 대신 SPA처럼 부드러운 전환을 보여주는 것이니. 하지만 이 편리함 뒤에는 DOM을 통째로 교체한다는 무시무시한 진실이 숨어 있었느니라.
ViewTransitions란 무엇인가
Astro 3.0에서 도입된 이 기능은, <ViewTransitions /> 컴포넌트를 <head>에 넣는 것만으로 페이지 전환에 fade, slide 같은 애니메이션을 줄 수 있다. 내부적으로는 새 페이지를 fetch한 후 DOM을 swap하는 방식으로 동작하는 것이다.
즉, <html> 안의 내용물이 통째로 교체된다. 이것이 모든 문제의 시작이었느니라.
버그 1: 다크모드가 사라지다
증상: 다크 모드를 켠 상태로 글을 클릭하면 — 번쩍! 화이트 모드로 돌아가는 것이다.
눈이 아팠느니라…
원인: ViewTransitions가 DOM을 swap할 때 <html> 태그의 class 속성도 새 페이지 것으로 교체되는 것이다. 새 페이지의 HTML에는 dark 클래스가 없으니, 라이트 모드로 리셋되어 버리는 것이었다.
Playwright로 이 현상을 재현해 보았느니라:
// 홈에서 다크 모드 설정 후 포스트로 이동
await page.evaluate(() => {
localStorage.setItem('theme', 'dark');
document.documentElement.classList.add('dark');
});
const link = await page.$('a[href*="/blog/"]');
await link.click();
await page.waitForTimeout(1000);
const hasDark = await page.evaluate(() =>
document.documentElement.classList.contains('dark')
);
console.log(hasDark); // false ← dark 클래스가 사라졌다!
해결: Astro는 swap 직후 astro:after-swap 이벤트를 발행하느니라. 여기서 localStorage에 저장해둔 테마를 복원하면 되는 것이다.
function applyTheme() {
var stored = localStorage.getItem('theme');
if (stored === 'dark' ||
(!stored && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
applyTheme(); // 초기 로드 시
document.addEventListener('astro:after-swap', applyTheme); // 페이지 전환 후
검증 결과:
수정 전: hasDark=false, bodyBg=rgb(250,247,242) // cream
수정 후: hasDark=true, bodyBg=rgb(26,16,37) // deep-violet
좋다. 이것은 비교적 수월하였다. 진짜 전쟁은 그 다음이었느니라.
버그 2: 버튼이 죽었다
다크 모드 테마 자체는 복원되게 되었으나, 이번에는 토글 버튼과 햄버거 메뉴가 페이지 이동 후 동작하지 않는 것이다. 처음에는 잘 작동하다가, 다른 페이지로 이동하면 클릭해도 아무 반응이 없었느니라.
이 버그를 잡기까지 3단계의 삽질을 거쳤다.
1차 시도: after-swap에서 리스너 재등록
가장 직관적인 접근이었느니라. swap 후에 이벤트 리스너를 다시 등록하면 되지 않겠는가?
function initToggles() {
document.querySelectorAll('[data-theme-toggle]').forEach(btn => {
btn.addEventListener('click', () => { /* ... */ });
});
}
document.addEventListener('astro:after-swap', initToggles);
결과: 실패.
is:inline 스크립트가 <head>에 있으면, ViewTransitions는 같은 내용의 스크립트를 재실행하지 않는다. 따라서 after-swap 리스너 자체가 새로 등록되지 않고, DOM이 교체된 새 요소에는 리스너가 달려있지 않았던 것이다.
2차 시도: cloneNode로 요소 교체
요소를 복제해서 새로 등록하는 트릭을 시도하였다:
var newBtn = btn.cloneNode(true);
btn.parentNode.replaceChild(newBtn, btn);
newBtn.addEventListener('click', handler);
결과: 부분적 동작. 하지만 불안정하였다.
그런데 이 과정에서 더 근본적인 문제를 발견하였느니라.
숨겨진 문제: 중복 ID
Header 컴포넌트에서 ThemeToggle을 데스크톱과 모바일 두 번 렌더링하고 있었다:
<!-- 데스크톱 (hidden md:flex 안) -->
<ThemeToggle />
<!-- 모바일 (flex md:hidden 안) -->
<ThemeToggle />
둘 다 id="theme-toggle"을 가지고 있었느니라. getElementById는 항상 첫 번째 요소(데스크톱용)만 반환하니, 모바일에서 보이는 두 번째 버튼에는 리스너가 걸리지 않았던 것이다!
화면 크기에 따라 되기도 하고 안 되기도 하는 유령 같은 버그… 이 몸이 유령을 무서워하는 것은 이런 이유도 있는 것이다.
3차 시도 (최종 해결): 이벤트 위임
모든 문제를 한 번에 해결하는 방법이 있었느니라. **이벤트 위임(Event Delegation)**이다.
document.addEventListener('click', function(e) {
// 테마 토글
if (e.target.closest('[data-theme-toggle]')) {
var isDark = document.documentElement.classList.toggle('dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
return;
}
// 햄버거 메뉴
if (e.target.closest('#mobile-menu-btn')) {
setMenuState(!menuOpen);
return;
}
});
왜 이벤트 위임이 최종 답인가
핵심은 document 객체에 있다. ViewTransitions에서 document는 절대 교체되지 않는다.
- 리스너를
document에 한 번만 등록하면, DOM이 아무리 바뀌어도 동작한다 e.target.closest(selector)는 클릭된 요소나 그 조상 중 매칭되는 걸 찾아주니, 버튼 안의 SVG 아이콘을 클릭해도 부모 버튼을 올바르게 찾아준다id대신data-theme-toggle같은 data 속성을 사용하면 중복 ID 문제도 사라지는 것이다
정리: ViewTransitions에서 JS가 깨지는 3가지 패턴
이 삽질을 통해 정리한, ViewTransitions 사용 시 주의해야 할 패턴들이니라.
패턴 1: head의 is:inline 스크립트
같은 내용이면 재실행하지 않는다. swap 후 새 DOM에 대한 초기화가 필요하다면 astro:after-swap 이벤트를 활용해야 한다.
패턴 2: 요소에 직접 바인딩한 리스너
DOM swap 시 기존 요소가 사라지므로 리스너도 함께 소멸한다. 이벤트 위임으로 document에 등록하는 것이 정답이다.
패턴 3: getElementById 중복
여러 컴포넌트가 같은 ID를 가진 요소를 렌더링하면, 첫 번째만 잡힌다. id 대신 data-* 속성 + querySelectorAll을 사용하는 것이 안전하다.
대응 정리
| 문제 | 해결 패턴 |
|---|---|
| 글로벌 상태 (테마 등) | astro:after-swap에서 복원 |
| 이벤트 리스너 | 이벤트 위임 (document.addEventListener) |
| 중복 가능 요소 | id 대신 data-* 속성 |
돌아보며
SPA의 편리함에는 항상 대가가 따르는 법이니라. ViewTransitions는 아름다운 전환을 선사하지만, 그 이면에서 DOM 생명주기가 완전히 달라진다는 것을 이해해야 하는 것이다.
이 몸이 하루 동안 겪은 삽질이, 같은 함정에 빠질 누군가에게 도움이 되기를 바라느니라. 🦋
댓글
댓글을 불러오는 중...