강제 새로고침해도 사이드바가 안 깜빡이게 만들기: Next.js 앱 셸 상태 저장 전략

위 이미지는 Gemini Nano Banana를 통해 제작했습니다.
들어가며
사이드바가 잠깐 사라졌다가 다시 나타나는 현상은 작은 디테일처럼 보이지만,
앱 셸의 완성도를 크게 떨어뜨린다.
이 글은 "사이드바 접힘 상태를 어디에 저장하는 것이 맞는가"를 정리한 기록이다.
문제 상황
ChatGPT나 Claude처럼 사이드바가 앱의 기본 크롬(chrome) 역할을 하는 서비스에서는, 첫 페인트부터 접힘/펼침 상태가 안정적으로 유지되어야 한다.
사이드바를 구현하면서 핵심적으로 다룬 문제는 두 가지였다.
- 강제 새로고침 시 사이드바 상태가 유지되어야 한다.
- 첫 페인트에서 사이드바가 안 보였다가 나타나는 hydration flicker가 없어야 한다.
기존 구현에서 생긴 문제
처음 구현은 클라이언트 상태 저장 관점에서는 자연스러웠다. Zustand persist를 써서 localStorage에 isCollapsed를 저장하고, 앱이 뜨면 이 값을 복원하는 방식이었다.
하지만 이 방식에는 구조적인 한계가 있다.
- 서버는
localStorage를 읽을 수 없다. - 따라서 서버는 항상 기본값으로 HTML을 만든다.
- 브라우저가 JS를 실행한 뒤에야 실제 저장값이 적용된다.
- 그 사이에 서버 HTML과 클라이언트 상태가 어긋나면서 사이드바가 잠깐 숨겨지거나 튀는 현상이 생긴다.
이 문제를 억지로 막으려고 hydration 전까지 사이드바를 visibility: hidden 처리하면, mismatch warning은 피할 수 있어도 UX는 나빠진다. 결국 "안 보였다가 보이는" 이펙트가 생기기 때문이다.
검토했던 선택지
사이드바 상태를 어디에 저장할지 고민하면서 떠올릴 수 있는 선택지는 대체로 세 가지다.
1. localStorage
장점은 구현이 가장 쉽다는 점이다. 서버와 분리된 순수 클라이언트 UI 상태라면 충분히 쓸 만하다.
하지만 이번 요구사항에는 맞지 않았다.
- 서버 첫 렌더에서 값을 모른다.
- 첫 페인트 일관성을 보장할 수 없다.
- 결국 hydration 이후 보정이 필요하다.
"그러면 <head>에서 인라인 스크립트로 localStorage를 미리 읽어 class를 박으면 되지 않느냐"는 생각도 할 수 있다. 실제로 CSR 기반 앱에서는 유효한 방법이다. 하지만 Next.js App Router 환경에서는 몇 가지 이유로 정석이라고 보기 어렵다.
App Router는 React Server Components + Streaming SSR을 기본으로 사용한다. 서버에서 HTML이 청크 단위로 스트리밍되는 구조에서, <head>에 삽입한 인라인 스크립트가 Suspense 바운더리보다 먼저 실행되는지 보장하기 어렵다. next/script의 beforeInteractive 전략을 사용할 수도 있지만, 이 역시 Pages Router 시절의 설계이고 App Router에서는 root layout의 <head>에 직접 넣는 방식과 동작이 미묘하게 다르다. 결과적으로 "DOM이 그려지기 전에 확실히 실행된다"는 보장이 흔들리면, 인라인 스크립트가 flicker를 막을 수도, 못 막을 수도 있는 불안정한 상태가 된다.
가능한 트릭이지만, 서버가 이미 올바른 HTML을 그려주는 편이 훨씬 안정적이다.
2. DB 저장
처음에는 너무 과한 선택처럼 보였다. 그런데 이건 요구사항에 따라 평가가 달라진다.
- 사용자가 어떤 기기에서 접속해도 같은 대시보드 설정을 유지해야 한다.
- 폰트, 테마, 위젯 순서, 숨김 상태처럼 계정 자산에 가까운 설정이다.
이런 종류의 preference는 DB가 맞다. 실제로 대시보드 위젯 배치나 사용자별 설정은 DB 저장이 정당하다.
다만 사이드바 접힘/펼침은 성격이 조금 다르다.
- 계정 자산이라기보다 현재 브라우저의 UI chrome state에 가깝다.
- 제품 가치보다 편의 상태에 가깝다.
- DB까지 끌어들이기엔 비용 대비 이득이 작다.
그래서 이번 요구에는 DB가 1차 해법이 아니었다.
3. Cookie
결국 이번 문제에는 쿠키가 가장 적절했다.
- 서버가 첫 요청에서 바로 읽을 수 있다.
- 서버 HTML을 접힘/펼침 상태에 맞춰 렌더할 수 있다.
- 클라이언트는 같은 초기값으로 hydrate하면 된다.
- 별도 DB나 Redis 같은 서버 자원을 소비하지 않는다.
즉, 쿠키는 "서버가 알아야 하지만 굳이 영구 사용자 자산으로 저장할 필요는 없는 UI 상태"에 가장 잘 맞는 저장소였다.
다만 쿠키도 만능은 아니므로 주의할 점이 있다.
- 모든 HTTP 요청에 딸려간다. 사이드바 하나는 수 바이트라 문제없지만, 이런 UI 상태를 여러 개 쿠키에 담기 시작하면 매 요청의 헤더 크기가 불필요하게 커진다. API 라우트에까지 전송된다면 낭비다.
- 4KB 크기 제한. 단순 boolean 값은 괜찮지만, 복잡한 UI 설정을 JSON으로 담기에는 한계가 있다.
- 보안 속성 설정이 필요하다. UI 상태라도
SameSite=Lax,Path=/정도는 설정해두는 것이 좋다. 사이드바 상태는 민감 정보가 아니므로HttpOnly는 불필요하다 — 클라이언트 JS에서 읽고 써야 하기 때문이다. - 다중 탭 동기화가 안 된다.
localStorage는 값이 바뀔 때storage이벤트를 발생시켜 다른 브라우저 탭의 상태를 즉시 동기화할 수 있지만, 쿠키에는 이런 메커니즘이 없다. 사이드바 접힘/펼침에서 다중 탭 동기화가 크리티컬한 요구사항인 경우는 드물지만, 만약 필요하다면BroadcastChannelAPI를 병행하여 해결할 수 있다.
최종 구현
전체 흐름
[사용자 토글] → cookie 갱신 + state 갱신
↓
[새로고침/재방문] → 서버가 cookie 읽음 → 올바른 HTML 렌더
↓
[클라이언트] → 서버와 같은 초기값으로 hydrate → flicker 없음서버: 쿠키를 읽어 초기값 결정
Next.js App Router에서는 서버 컴포넌트에서 cookies()로 바로 읽을 수 있다.
// app/layout.tsx (Server Component)
import { cookies } from 'next/headers';
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const cookieStore = await cookies();
const sidebarCollapsed = cookieStore.get('sidebar-collapsed')?.value === 'true';
return (
<html lang="ko">
<body>
<AppShell initialCollapsed={sidebarCollapsed}>
{children}
</AppShell>
</body>
</html>
);
}
한 가지 주의할 점이 있다. Next.js App Router에서 cookies() 함수를 호출하는 순간, 해당 라우트는 정적 렌더링(Static Generation)에서 동적 렌더링(Dynamic Rendering)으로 강제 전환된다. 로그인한 사용자만 접근하는 앱 셸이라면 어차피 동적 렌더링이 필요하므로 문제가 없지만, 랜딩 페이지나 블로그처럼 CDN 캐싱(SSG/ISR)이 필수적인 퍼블릭 페이지의 레이아웃에 이 방식을 적용하면 캐싱의 이점이 사라지고 매 요청마다 서버가 HTML을 새로 그려야 한다. 서비스 성격에 따라 이 trade-off를 반드시 고려해야 한다.
클라이언트: 토글 시 state와 cookie를 같이 갱신
// components/AppShell.tsx
'use client';
import { useState, useCallback } from 'react';
interface AppShellProps {
initialCollapsed: boolean;
children: React.ReactNode;
}
export function AppShell({ initialCollapsed, children }: AppShellProps) {
const [isCollapsed, setIsCollapsed] = useState(initialCollapsed);
const toggleSidebar = useCallback(() => {
setIsCollapsed((prev) => {
const next = !prev;
// cookie 갱신 — 다음 SSR에서 서버가 이 값을 읽는다
document.cookie = `sidebar-collapsed=${next}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`;
return next;
});
}, []);
return (
<div className="flex h-screen">
<aside
className={`transition-all duration-200 ${
isCollapsed ? 'w-0 overflow-hidden' : 'w-64'
}`}
>
{/* 사이드바 내용 */}
</aside>
<div className="flex-1 flex flex-col">
<header>
<button onClick={toggleSidebar}>
{isCollapsed ? '☰' : '✕'}
</button>
</header>
<main className="flex-1 overflow-auto">
{children}
</main>
</div>
</div>
);
}
핵심은 initialCollapsed를 서버에서 내려주고, 클라이언트가 이 값을 그대로 초기 state로 사용한다는 점이다. 서버 HTML과 클라이언트 초기값이 동일하므로 hydration mismatch가 발생하지 않는다.
visibility: hidden 같은 우회 로직도 필요 없다. 서버가 처음부터 올바른 HTML을 그리기 때문이다.
왜 Server Actions 대신 document.cookie를 직접 조작했나
Next.js App Router에서는 Server Actions로 서버 측에서 cookies().set()을 호출하는 방식도 가능하다. 쿠키 조작 코드를 캡슐화할 수 있고, 필요시 revalidatePath와 자연스럽게 연계할 수 있다는 장점이 있다.
하지만 사이드바 토글에는 적합하지 않다고 판단했다. Server Action은 서버 왕복이 발생하는데, 사이드바 토글은 사용자가 즉각적인 UI 반응을 기대하는 인터랙션이다. 클릭할 때마다 서버를 다녀오면 체감 latency가 생긴다. 복잡한 데이터 갱신이 필요하거나, 쿠키 변경 후 서버 캐시를 무효화해야 하는 상황이라면 Server Action이 맞지만, 단순한 UI boolean 상태는 클라이언트에서 document.cookie로 즉시 처리하는 것이 UX 측면에서 훨씬 유리하다.
왜 이 방식이 정석에 가깝나
이 패턴이 좋은 이유는 상태의 책임이 분리되기 때문이다.
- 서버 책임: 첫 렌더를 올바르게 그린다.
- 클라이언트 책임: 사용자 인터랙션에 즉시 반응한다.
- 쿠키 책임: 서버와 클라이언트가 공유할 최소한의 preference를 전달한다.
반면 localStorage only 구조는 서버가 모르는 상태를 클라이언트가 나중에 덮어쓰는 방식이라, 앱 셸처럼 레이아웃 크롬이 중요한 영역에는 불리하다.
저장소 선택 기준 정리
이번 작업을 하면서 기준도 더 명확해졌다.
- 사이드바 접힘/펼침: cookie — 서버가 알아야 하지만 계정 자산은 아닌 상태
- 채팅 입력 draft 같은 일시 상태: localStorage — 서버가 몰라도 되는 상태
- 테마, 폰트, 대시보드 위젯 배치/숨김, 기본 워크스페이스: DB — 기기 간 동기화가 필요한 계정 자산
그리고 첫 페인트가 중요한 DB 기반 설정이라면 실무에서는 DB + cookie mirror도 자주 쓴다.
- DB는 source of truth
- cookie는 SSR first paint 최적화
이 조합이 가장 안정적이다.
마무리
이번 사이드바 개선은 단순히 flicker를 없애는 작업이 아니라, "이 상태를 어디에 저장하는 것이 맞는가"를 다시 정리하는 작업이었다.
결론은 명확했다.
localStorage는 이 문제의 정답이 아니다.DB는 지금 요구에는 과하다.cookie가 가장 단순하고, 가장 실용적이며, Next.js 앱 셸 기준으로도 가장 정석적인 선택이다.
사이드바 같은 앱 크롬은 사소해 보여도 첫 인상과 체감 품질을 좌우한다. 이런 부분일수록 서버 첫 렌더와 클라이언트 상태 복원이 정확히 맞물리도록 설계하는 것이 중요하다.