최근 /posts 페이지에서 카드/리스트 뷰 토글과 BGM 플레이어 최소화 상태를 저장하는 작업을 하다가, 새로고침 시 UI가 잠깐 기본값으로 보였다가 나중에 저장된 상태로 바뀌는 문제를 만났다.
예를 들면 이런 식이다.
- 카드 뷰를 사용 중이었는데 새로고침하면 잠깐 카드가 보였다가 리스트로 바뀜
- BGM 플레이어를 최소화해뒀는데 새로고침하면 잠깐 기본 크기로 보였다가 최소화 상태로 바뀜
처음에는 localStorage를 잘 읽고 있으니 큰 문제는 아니라고 생각했는데, 실제로 보면 이 전환이 꽤 눈에 띄었다. 결국 원인은 localStorage가 아니라 SSR과 hydration 타이밍에 있었다.
문제 상황
초기 구현은 대략 이런 구조였다.
플레이어도 비슷했다.
이 코드는 얼핏 보면 문제 없어 보인다.
하지만 Next.js의 서버 렌더링 환경에서는 첫 HTML이 서버에서 먼저 생성된다.
즉 서버는 window.localStorage를 읽을 수 없기 때문에:
- posts 뷰는 일단
card - player 모드는 일단
default
이 기본값으로 HTML을 먼저 렌더링한다.
그 뒤 브라우저가 hydrate 되면서 useEffect가 실행되고, 그제서야 localStorage에서 실제 값을 읽어서 상태를 바꾼다. 그래서 사용자는 기본 UI에서 저장된 UI로 바뀌는 짧은 전환을 보게 된다.
원인 정리
핵심은 이것이다.
localStorage는 브라우저에서만 읽을 수 있다.- 서버 렌더 시점에는
localStorage를 읽을 수 없다. - 그래서 서버는 항상 기본 상태로 먼저 그린다.
- hydrate 이후
useEffect가 실행되면서 저장된 상태로 바뀐다. - 결과적으로 플래시가 발생한다.
즉 이 문제는 단순히 useEffect 타이밍 문제가 아니라, 서버가 첫 렌더부터 올바른 상태를 알 수 없다는 구조 문제다.
처음 시도했던 우회 방법
처음에는 뷰 상태가 준비되기 전까지 결과 영역을 렌더하지 않는 방식도 시도했다.
예를 들면:
그리고 isViewReady 전에는 placeholder를 보여주게 했다.
이 방식은 잘못된 UI가 잠깐 보였다가 바뀌는 현상은 줄일 수 있지만, 결국 또 다른 의미의 깜빡임이 남는다. 그리고 플레이어처럼 페이지 전역에 붙는 UI에는 적용하기도 애매하다.
그래서 근본 해결책은 아니었다.
해결 방법
결론은 간단했다.
상태를 localStorage에만 저장하지 말고 cookie에도 같이 저장한다.
그리고 서버가 cookie를 읽어서 첫 렌더부터 올바른 상태를 사용하게 만든다.
즉 구조를 이렇게 바꿨다.
- 클라이언트
localStorage저장cookie저장
- 서버
cookie읽기- 초기 상태를 props로 내려주기
이렇게 하면 서버가 첫 HTML을 그릴 때 이미 사용자의 마지막 상태를 알고 있게 된다.
상태 키 정리
먼저 상태 키와 파서를 공용 파일로 뺐다.
posts 페이지에서 초기 뷰 상태 복원
/posts는 서버 컴포넌트 페이지이므로 cookies()를 사용해서 초기 뷰 상태를 읽었다.
그리고 이 값을 클라이언트 컴포넌트에 props로 전달했다.
클라이언트에서 localStorage와 cookie 동시 저장
이제 클라이언트 컴포넌트는 기본값을 하드코딩하지 않고, 서버가 내려준 initialViewMode를 그대로 사용한다.
그리고 상태가 바뀔 때마다 localStorage와 cookie를 함께 갱신한다.
이제 새로고침해도 서버가 첫 렌더부터 card인지 list인지 알고 있기 때문에 화면이 튀지 않는다.
BGM 플레이어에도 같은 방식 적용
플레이어도 동일한 문제였기 때문에 같은 패턴을 적용했다.
app/layout.tsx에서 cookie를 읽는다.
그리고 플레이어 셸에 초기 상태를 props로 넘긴다.
플레이어 컴포넌트에서는:
상태 변경 시 저장:
이제 최소화 상태도 새로고침 후 바로 맞게 렌더된다.
왜 cookie가 필요한가
이 문제에서 cookie가 중요한 이유는 서버가 읽을 수 있기 때문이다.
localStorage: 브라우저 전용cookie: 브라우저에도 있고 서버 요청에도 같이 전달됨
SSR 환경에서 첫 렌더부터 사용자의 마지막 UI 상태를 반영하고 싶다면, 서버가 읽을 수 있는 저장소가 필요하다. 이때 cookie가 가장 단순하고 현실적인 선택지였다.
정리
이번 문제에서 얻은 결론은 이렇다.
localStorage만으로는 SSR 초기 렌더 상태를 맞출 수 없다.useEffect로 복원하면 기본값에서 저장값으로 바뀌는 플래시가 생긴다.- 이 문제는 렌더링 타이밍 문제가 아니라 서버가 상태를 모른다는 구조 문제다.
- UI 상태가 첫 페인트부터 중요하다면 cookie를 같이 써야 한다.
- 서버는 cookie로 초기 상태를 읽고, 클라이언트는 localStorage와 cookie를 함께 갱신하는 방식이 안정적이다.
특히 다음 같은 상태에 잘 맞는다.
- 카드/리스트 뷰 토글
- 사이드 패널 열림/닫힘
- 플레이어 최소화/확장 상태
- 테마/밀도/레이아웃 선택 상태
반대로 서버가 굳이 알 필요 없는 아주 사소한 상태라면 localStorage만으로도 충분할 수 있다. 하지만 새로고침 직후 UI가 틀어져 보이는 것이 문제라면, localStorage만으로는 한계가 있다는 점을 꼭 기억해야 한다.