Vercel 배포 환경에서 브라우저 뒤로가기 시 다크모드가 해제되는 문제

Category
Project
Status
Published
Tags
Trouble Shooting
Style
Description
Published
Slug

문제 상황

Next.js로 구축한 포트폴리오 사이트를 Vercel에 배포한 후, 특정 상황에서 다크모드가 의도치 않게 해제되는 문제 발생
 

재현 단계

  1. 포트폴리오 페이지에서 다크모드 활성화
  1. Notion 기반 블로그 포스트 페이지로 이동
  1. 브라우저 뒤로가기 버튼 클릭
  1. 포트폴리오 페이지로 돌아오면 다크모드가 해제됨
 

추가 증상

  • 다크모드 토글 버튼을 2번 클릭해야 테마가 변경됨
  • 개발 환경(pnpm run dev)에서는 문제가 발생하지 않음
  • Vercel 프로덕션 환경에서만 재현됨
 

원인 분석

시도 1: bfcache (Back/Forward Cache) 문제

처음에는 브라우저의 bfcache가 원인이라고 판단했습니다.
bfcache란?
  • 브라우저가 페이지를 메모리에 저장했다가, 뒤로가기 시 빠르게 복원하는 최적화 기술
  • JavaScript 실행 상태는 일시정지되지만, DOM 스냅샷은 복원됨
  • 프로덕션 빌드에서만 활성화되어 개발 환경에서는 발생하지 않음
// bfcache 복원을 감지하는 코드 window.addEventListener('pageshow', (event) => { if (event.persisted) { // bfcache에서 복원됨 } })
 
이 가설에 따라 _app.tsxpageshow 이벤트 핸들러를 추가했습니다:
// _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' />

디버깅 팁

문제 진단 체크리스트

  1. localStorage 확인
    1. // 개발자 도구 콘솔에서 localStorage.getItem('darkMode')
  1. body 클래스 확인
    1. document.body.className
  1. React DevTools로 상태 확인
      • ThemeProvider의 isDarkMode 값
      • 각 페이지 컴포넌트의 props
  1. bfcache 복원 여부 확인
    1. window.addEventListener('pageshow', (event) => { console.log('bfcache 복원:', event.persisted) })
  1. BodyClassName 충돌 확인
      • 여러 컴포넌트가 동시에 사용하는지 체크
      • 조건부 렌더링으로 인한 깜빡임 확인