이번에 새 프로젝트를 시작하면서 SEO를 어느정도 고려해야했기 때문에 next.js
를 도입하게 되었습니다. 클라이언트 상태 관리는 직관적인 Zustand
를 활용했고 API 통신은 익숙한 axios
를 이용해서 호출했습니다.
개발을 진행하면서 서버와 클라이언트 컴포넌트 간의 상태 불일치로 인한 헤더 깜빡임 이슈를 겪게 되었고 이를 해결 해나간 과정을 정리해보려고 합니다!
처음 문제를 인식한건 로그인 상태에서 새로고침 했을 때 였습니다.
로그인 상태임에도 불구하고,
브라우저 새로고침 시에 항상 발생하는 문제였고, CSR 환경에서는 문제가 없던 문제였기 때문에 SSR 동작 과정에서의 상태 처리 문제라고 생각하게 되었습니다.
많은 사람들이 착각하는 부분인데, Next.js ≠ SSR입니다.
Next.js는 CSR, SSR, SSG를 자유롭게 혼합할 수 있는 하이브리드 프레임워크입니다.
app/
디렉토리를 기준으로 컴포넌트는 아래처럼 나뉩니다.
"use client"
필요): 상태관리, 이벤트 핸들링 등 클라이언트 환경에서만 동작이런 구조로 인해 서버와 클라이언트에서의 상태가 일치하지 않으면 Hydration
시 깜빡임 또는 UI 불일치가 발생할 수 있습니다.
처음에는 클라이언트에서 Zustand 상태를 동기화하는 sessionHydration
컴포넌트를 만들었습니다.
"use client"
import { useEffect } from "react"
import { useUserStore } from "@/store/user"
import { getSession } from "@/lib/auth"
export function SessionHydration() {
const setUser = useUserStore((state) => state.setUser);
useEffect(() => {
getSession().then((session) => {
if (session) {
setUser(session.user);
}
});
}, []);
return null;
}
Header
컴포넌트는 이 상태를 바라보고 렌더링 하도록 처리해보았지만,
서버에서 세션 상태를 먼저 가져오고 그 결과를 Header에 props로 넘기면 어떨까 생각해서 시도해보았습니다.
// layout.tsx
import { fetchSession } from "@/lib/auth"
export default async function Layout({ children }: PropsWithChildren) {
const session = await fetchSession();
return (
<>
<Header session={session} />
{children}
</>
);
}
이렇게 하면 SSR 단계에서 로그인 상태를 미리 알고 있기 때문에 상태 불일치에 따른 깜빡임이 더이상 일어나지 않았습니다. 👏🏻
그렇지만....
실제로 서버에 배포 후 사내에서 테스트 해보니 로그인 호출은 정상적으로 동작했는데, 로그인 상태로 전환되지 않는 문제가 발생했습니다.
왜 그런지 원인을 찾는데 헤매던 와중, 백엔드 개발자 동료의 조언이 있었습니다.
Next 서버에서 API를 호출하는 방식이면, 다중 사용자 환경에서 세션이 꼬일 수도 있습니다.
예를 들면, 쇼핑몰에서 내 계정으로 로그인했는데 다른 사람 장바구니가 보이는 문제와 비슷해요.
세션 상태는 항상 클라이언트에서 판단하는 게 안전합니다.
이런 구조에서는 사용자 A의 세션 정보가 사용자 B에게 잘못 전달될 위험이 있습니다.
또한, layout.tsx에서 세션 상태를 확인하면 모든 페이지 요청마다 API가 호출되어
위와 같은 문제들이 발생할 수 있었습니다.
그래서 다시 구조를 변경해보았습니다.
layout.tsx
에서 Next.js의 cookies()
를 사용해 sid
쿠키가 있는지만 확인isLoggedIn
props만 Header에 내려줌import { cookies } from "next/headers"
export default function Layout({ children }: PropsWithChildren) {
const hasSid = cookies().has("sid");
return (
<>
<Header isLoggedIn={hasSid} />
{children}
</>
);
}
store에서 skipHydration
옵션을 설정하거나 서버에서 초기화 되지 않도록 설정해주었습니다.
persist(
(set) => ({
user: null,
setUser: (u) => set({ user: u }),
}),
{
name: "user-store",
skipHydration: true,
}
)
이번 문제 해결을 통해서
혹시 더 좋은 해결 방법이나 다른 접근법을 알고 계시다면 언제든지 알려주세요.
잘못된 내용이나 개선할 점이 있다면 언제든 피드백 부탁드립니다. 감사합니다. 🙇🏻♀️
이전의 hydration 이슈 경험에서 고민했던 흐름과 유사해서 공감하며 읽었습니다. 그땐 적당히 정리하고 넘어갔었는데, 이렇게 배경과 적용했던 방법들을 자세히 남겨놨으면 좋았을 것 같네요. 잘 보고 갑니다!
next 는 참.. 좋으면서도 이런 어려운점이 많은거 같습니다. 저도 최근에 hydration 관련 부분 해결하는 발표를 들은적이 있는데요. 그 내용에서는 hook 관련 회피방법이였는데, 라이브러리 사용하면서 이렇게도 해결을 할 수 있군요.
저도 이번주 부터는 예림님처럼 유익한 글을 쓰도록 노력해보겠습니다. !
저도 이번에 사이드 프로젝트로 Next를 사용하는데, 이 글이 너무 도움이 될 것 같아요. 원인 파악에만 오랜 시간을 쏟을 뻔했는데 유익한 글 감사합니다~!