
사이드바를 접어둔 사용자가 새로고침을 했는데, 화면이 처음에는 펼쳐진 상태로 보였다가 잠깐 뒤에 다시 접힌다면 어떨까. 기능은 정상적으로 동작하지만, 사용자는 화면이 한 번 흔들린 것처럼 느낀다. 사이드바의 토글 상태를 유지한다는 것은 단순히 localStorage에 값을 저장하는 문제가 아니라, 사용자가 화면을 보는 첫 순간부터 이전 상태가 자연스럽게 반영되도록 만드는 문제다.
상태를 어디에 저장하고, 언제 그 상태를 복원해야할까?
이 문제를 해결하려면 '상태를 어디에 저장할까?'보다 먼저 '언제 그 상태를 복원할 수 있는가?'를 생각해야 한다. 사이드바 상태를 클라이언트 State, URL, 웹 스토리지, 쿠키 중 어디에 둘지 고민하게 된 이유도 바로 여기에 있다.
클라이언트측 복원
사이드바의 토글 상태를 유지하려고 하면 가장 먼저 떠오르는 방법은, 클라이언트에서 상태를 저장하고 다시 읽어오는 방식이다. 사용자가 사이드바를 접으면 그 값을 localStorage 같은 웹 스토리지에 저장하고, 다음에 페이지가 열릴 때 저장된 값을 읽어 다시 적용하는 식이다. 구현은 단순하고 직관적이다. 하지만 이 방식에는 한 가지 문제가 있다. 브라우저가 화면을 먼저 그린 뒤에야 저장된 값을 읽을 수 있다면, 사용자는 기본 상태의 사이드바가 잠깐 보였다가 나중에 접히는 장면을 보게 된다.
신입 시절에는 이런 상황들 때문에 골머리를 앓았다. React로 개발하면 보통 다음과 같이 구현을 하게 된다. 사용자가 토글 버튼을 누르면 현재 상태를 저장하고, 컴포넌트가 마운트된 뒤 저장된 값을 읽어 UI에 반영한다. 이는 구현이 쉽고, 브라우저 안에서만 의미 있는 상태를 다루기에 적합하다.
하지만, 복원 시점이 늦다. 서버가 만든 HTML이나 초기 렌더링 결과에는 아직 저장된 상태가 반영되어 있지 않기 때문이다. 그래서 기본값으로 사이드바가 먼저 그려진 뒤, 클라이언트에서 저장된 값을 읽고 다시 렌더링하면서 깜빡임이 발생할 수 있다.
서버측 복원
그렇다면 문제의 핵심은 '상태를 저장했는지'가 아니다. '화면이 처음 그려지기 전에, 그 상태를 알 수 있는지'가 핵심이다. 클라이언트측 복원은 값을 저장할 수는 있지만, 브라우저가 실행되기 전의 렌더링에는 개입하기가 어렵다. 사이드바가 깜빡이지 않게 하려면 초기 화면을 만들 때부터 이전 토글 상태가 반영되어야 한다. 이때 필요한 방식이 서버측 복원이다.
이전 상태를 서버가 렌더링 시점에 미리 알 수 있도록 만드는 방식에는 대표적으로 쿠키를 사용할 수 있다. 사용자가 사이드바를 접으면 그 값을 쿠키에 저장하고, 다음 요청에서 서버는 쿠키를 읽어 사이드바의 초기 상태를 결정한다. 이렇게 하면 클라이언트가 마운트된 뒤 상태를 다시 덮어쓰는 것이 아닌, 처음부터 접힌 상태의 HTML을 만들 수 있다.
이러면 사용자는 기본(초기) 상태가 잠깐 보였다가 바뀌는 과정을 보지 않는다. 결국 핵심은 값을 어디에 저장하느냐보다, 그 값을 언제 읽을 수 있느냐다. 즉, 복원 시점의 차이로 사용자 경험이 달라진다.
URL도 서버측에서 먼저 읽어서 복원할 수 있지 않나?
URL도 쿠키처럼 서버가 초기 렌더링 전에 읽을 수 있다. 따라서 깜빡임을 줄이는 관점에서는 둘 다 서버측 복원이 가능하다. 다만, URL은 공유 가능한 화면 상태에 어울리고, 쿠키는 사용자 개인의 UI 선호를 저장하는 데 더 어울린다. 사이드바 토글 상태가 페이지의 의미라기보다 사용자의 보기 설정에 가깝다면, URL보다 쿠키가 더 자연스러운 선택일 수 있다.
URL - 이 페이지를 누가 열든 같은 결과를 보장하고 싶은 상태
검색 키워드, 페이지 번호, 정렬 등
쿠키 - 내 디바이스에서만 유지되어야 하는 상태
다크 모드, 사이드바 펼침, 언어 설정, 오늘 하루 열지 않음 등
Sprinters에서는..
Sprinters에서는 사이드바 토글 상태에 쿠키를 사용했다. 사용자가 사이드바 토글 버튼을 클릭하면 먼저 클라이언트 상태를 변경한다. 그리고 변경된 상태를 쿠키에 저장한다.
// 실제 코드가 아닌, 전체 흐름을 보여주기 위해 단순화한 예시입니다.
setStoredOpen((prev) => !prev);
useEffect(() => {
document.cookie = `site-sidebar=${storedOpen ? "open" : "closed"}`;
}, [storedOpen]);이렇게 저장된 쿠키는 새로고침하거나 다른 페이지에 진입할 때, 브라우저가 Cookie 헤더에 담아 자동으로 서버에 전송한다. React Router의 root.tsx loader에서는 이 쿠키 값을 읽어 사이드바의 초기 상태를 결정한다.
export async function loader({ request }) {
const sidebarOpen = parseSidebarCookie(
request.headers.get("Cookie"),
);
return {
sidebarOpen,
};
}그리고 이 값을 SidebarProvider의 초기값으로 넘긴다.
export default function App({ loaderData }) {
return (
<SidebarProvider initialOpen={loaderData.sidebarOpen}>
...
</SidebarProvider>
);
}이렇게 구현하면 서버가 요청 시점에 쿠키를 먼저 읽고, 그 값을 기준으로 초기 HTML을 만든다. 사용자가 사이드바를 펼쳐둔 상태라면, 서버 HTML도 이미 펼쳐진 상태로 렌더링되는 것이다. 이후 hydration 과정에서도 같은 초기값을 사용하기 때문에, 깜빡임이 발생하지 않는다.
사이드바처럼 레이아웃에 바로 영향을 주는 UI 상태에서는 단순히 저장만 해서는 부족하다. 사용자가 화면을 보는 첫 순간부터 그 상태가 반영되어야 한다.