문제 상황
Next.js로 구축한 포트폴리오 사이트를 Vercel에 배포한 후, 특정 상황에서 다크모드가 의도치 않게 해제되는 문제 발생
재현 단계
- 포트폴리오 페이지에서 다크모드 활성화
- Notion 기반 블로그 포스트 페이지로 이동
- 브라우저 뒤로가기 버튼 클릭
- 포트폴리오 페이지로 돌아오면 다크모드가 해제됨
추가 증상
- 다크모드 토글 버튼을 2번 클릭해야 테마가 변경됨
- 개발 환경(
pnpm run dev)에서는 문제가 발생하지 않음
- Vercel 프로덕션 환경에서만 재현됨
원인 분석
시도 1: bfcache (Back/Forward Cache) 문제
처음에는 브라우저의 bfcache가 원인이라고 판단했습니다.
bfcache란?
- 브라우저가 페이지를 메모리에 저장했다가, 뒤로가기 시 빠르게 복원하는 최적화 기술
- JavaScript 실행 상태는 일시정지되지만, DOM 스냅샷은 복원됨
- 프로덕션 빌드에서만 활성화되어 개발 환경에서는 발생하지 않음
// bfcache 복원을 감지하는 코드 window.addEventListener('pageshow', (event) => { if (event.persisted) { // bfcache에서 복원됨 } })
이 가설에 따라
_app.tsx에 pageshow 이벤트 핸들러를 추가했습니다:// _app.tsx에 추가 function handlePageShow(event: PageTransitionEvent) { if (event.persisted) { const darkMode = localStorage.getItem('darkMode') if (darkMode !== null) { const isDark = JSON.parse(darkMode) as boolean document.body.classList.toggle('dark-mode', isDark) document.body.classList.toggle('light-mode', !isDark) } } } window.addEventListener('pageshow', handlePageShow)
하지만 문제는 여전히 발생했습니다.
시도 2
더 깊이 파고든 결과, 커스텀 과정에서 각 페이지 컴포넌트가
BodyClassName을 통해 body의 className을 서로 다르게 설정하고 있다는 것을 발견했습니다문제 코드 패턴
// NotionPage.tsx export const NotionPage = () => { const { isDarkMode } = useTheme() return ( <> {isDarkMode && <BodyClassName className='dark-mode' />} {/* 다크모드일 때만 조건부로 클래스 추가 */} </> ) } // PortfolioPage.tsx export const PortfolioPage = () => { return ( <BodyClassName className='zoom-enabled'> {/* dark-mode 클래스가 없음 */} </BodyClassName> ) } // BlogPage.tsx export const BlogPage = () => { return ( <BodyClassName className='zoom-enabled'> {/* dark-mode 클래스가 없음 */} </BodyClassName> ) }
BodyClassName 라이브러리의 동작 방식
react-body-classname 라이브러리는 body의 className을 완전히 덮어씁니다// 페이지 전환 시 발생하는 일 NotionPage 렌더링 → body.className = "notion-viewport dark-mode" 뒤로가기로 PortfolioPage 렌더링 → body.className = "zoom-enabled" // dark-mode 사라짐!
2번 클릭하면 작동한 이유
// 초기 상태 (뒤로가기 후) localStorage: { darkMode: true } React state: true (초기화되지 않음) DOM: <body class="zoom-enabled"> (dark-mode 없음) // 첫 번째 클릭 toggleDarkMode() 호출 → React state: true → false로 변경 시도 → 하지만 실제로는 undefined → false → DOM: 변화 없음 // 두 번째 클릭 toggleDarkMode() 호출 → React state: false → true → DOM: dark-mode 클래스 추가
해결 방법
→ 모든 페이지에서 일관된 다크모드 처리
핵심 아이디어: 모든 페이지 컴포넌트가
useTheme() 훅을 사용하여 다크모드 상태를 반영하도록 통일1. PortfolioPage.tsx 수정
import { useTheme } from '@/contexts/Theme' export const PortfolioPage = ({ recordMap }: PortfolioPageProps) => { const { isDarkMode } = useTheme() // 추가 return ( <BodyClassName className={isDarkMode ? 'zoom-enabled dark-mode' : 'zoom-enabled'} > {/* 나머지 컴포넌트 */} </BodyClassName> ) }
2. BlogPage.tsx 수정
import { useTheme } from '@/contexts/Theme' export const BlogPage = ({ recordMap }: BlogPageProps) => { const { isDarkMode } = useTheme() // 추가 return ( <BodyClassName className={isDarkMode ? 'zoom-enabled dark-mode' : 'zoom-enabled'} > {/* 나머지 컴포넌트 */} </BodyClassName> ) }
3. MainPage.tsx 수정
import { useTheme } from '@/contexts/Theme' export const MainPage = () => { const { isDarkMode } = useTheme() // 추가 return ( <BodyClassName className={isDarkMode ? 'zoom-enabled dark-mode' : 'zoom-enabled'} > {/* 나머지 컴포넌트 */} </BodyClassName> ) }
4. NotionPage.tsx는 그대로 유지
export const NotionPage = () => { const { isDarkMode } = useTheme() return ( <> {isDarkMode && <BodyClassName className='dark-mode' />} {/* 기존 패턴 유지 */} </> ) }
5. _app.tsx의 bfcache 핸들러 (보험)
React.useEffect(() => { function handlePageShow(event: PageTransitionEvent) { if (event.persisted) { const darkMode = localStorage.getItem('darkMode') if (darkMode !== null) { const isDark = JSON.parse(darkMode) as boolean document.body.classList.toggle('dark-mode', isDark) document.body.classList.toggle('light-mode', !isDark) } } } window.addEventListener('pageshow', handlePageShow) return () => { window.removeEventListener('pageshow', handlePageShow) } }, [])
해결 결과
Before
MainPage → body: "zoom-enabled" Portfolio → body: "zoom-enabled" NotionPage → body: "dark-mode" (다크모드 켜짐) 뒤로가기 → body: "zoom-enabled" (다크모드 꺼짐!)
After
MainPage → body: "zoom-enabled dark-mode" Portfolio → body: "zoom-enabled dark-mode" NotionPage → body: "dark-mode" 뒤로가기 → body: "zoom-enabled dark-mode" (유지)
배운 점
1. bfcache의 동작 원리
브라우저의 bfcache는 성능 최적화를 위한 강력한 기능이지만, SPA에서는 예상치 못한 동작을 유발할 수 있습니다
- JavaScript 실행 컨텍스트가 일시정지됨
- React 컴포넌트가 재마운트되지 않을 수 있음
pageshow이벤트로 감지하고 대응해야 함
2. 라이브러리의 동작 방식 이해의 중요성
react-body-classname이 body의 className을 완전히 덮어쓴다는 사실을 모르고 사용하면, 이런 미묘한 버그가 발생할 수 있습니다.3. 개발 환경과 프로덕션 환경의 차이
- Hot Module Replacement는 bfcache를 비활성화시킴
- 프로덕션 환경에서만 재현되는 버그는 실제 빌드 테스트가 필수
4. 상태 관리의 일관성
모든 페이지가 동일한 패턴으로 전역 상태를 처리해야 예측 가능한 동작을 보장할 수 있습니다.
// Good: 일관된 패턴 const { isDarkMode } = useTheme() <BodyClassName className={isDarkMode ? 'page dark-mode' : 'page'} /> // Bad: 페이지마다 다른 방식 {isDarkMode && <BodyClassName className='dark-mode' />} <BodyClassName className='page' />
디버깅 팁
문제 진단 체크리스트
- localStorage 확인
// 개발자 도구 콘솔에서 localStorage.getItem('darkMode')
- body 클래스 확인
document.body.className
- React DevTools로 상태 확인
- ThemeProvider의 isDarkMode 값
- 각 페이지 컴포넌트의 props
- bfcache 복원 여부 확인
window.addEventListener('pageshow', (event) => { console.log('bfcache 복원:', event.persisted) })
- BodyClassName 충돌 확인
- 여러 컴포넌트가 동시에 사용하는지 체크
- 조건부 렌더링으로 인한 깜빡임 확인