Next.js와 zustand를 사용하고 있는 프로젝트 진행 중, 서버 컴포넌트 하위의 클라이언트 컴포넌트에서 persist가 적용되어있는 auth store의 유저 아이디 정보를 가져오려고 useAuthStore.getState()를 했더니 오류가 났다.
[zustand persist middleware] Unable to update item 'auth-storage', the given storage is currently unavailable.
💦 오류 원인 - 서버 환경에서 로컬 스토리지 접근 시도
서버 사이드 렌더링 중에는 window 객체와 localStorage가 존재하지 않는다.
useAuthStore.getState()를 컴포넌트 최상위 레벨에서 호출하면 서버에서 렌더링될 때 localStorage에 접근하려고 시도하기 때문에 오류가 나는 것이다.
🤔 클라이언트 컴포넌트인데도 서버에서 접근한다?
Next.js의 App Router 환경에서 "use client"를 선언한 클라이언트 컴포넌트도 SSR 과정에서 최초로 서버에서 한 번 렌더링이 이루어진다.
즉, 클라이언트 컴포넌트라고 해도, 실제로는 서버에서 먼저 정적 HTML을 생성한 뒤, 클라이언트에서 하이드레이션 과정을 거쳐 인터랙티브하게 변환되는 것이다.
🤔 왜?
Next.js는 사용자에게 빠른 페이지 콘텐츠(비대화형 HTML 미리보기)를 제공하기 위해, 클라이언트 컴포넌트도 서버에서 먼저 렌더링된다.
🤔 해결 방법은? (로컬스토리지 예)
1. useEffect 훅 사용 (react 훅은 클라이언트에서만 실행된다.)
useEffect(() => {
const value = localStorage.getItem('key');
// ...
}, []);
로컬 스토리지에 직접 접근하는 경우엔 괜찮으나
2. window 객체 확인 (서버는 window 객체를 갖고 있지 않다.)
if (typeof window !== 'undefined') {
localStorage.getItem('key');
}
3. next/dynamic의 ssr: false 옵션 사용
const ChartComponent = dynamic(() => import('./ChartWrapper'), {
ssr: false,
});
차트 라이브러리의 경우 서버에서 실행되면 오류가 발생할 수 있다. 아예 컴포넌트 자체를 CSR로만 적용할 수 있다.
내 프로젝트에서도 이렇게 차트 컴포넌트를 서버에서 렌더링되지 않게 설정해 안정성을 더했다.
그런데 오류는 하나가 아니었다. 로그인을 했는데도 10초도 안가서 인증 오류가 발생했다.
인증 정보를 로컬스토리지에 저장하다보니 서버가 여기에 접근하지 못하고 zustand의 store엔 빈 값만 있어 인증상태를 유지하지 못했던 것이다.
이런 서버와 클라이언트간의 불일치로 인해 로그인 정보가 제대로 유지되지 않았다.
그래서 이 로컬스토리지에 저장된 유저 정보 store에 대한 근본적인 해결책이 필요했다.
그래서 찾은 해결책은 다음과 같다.
1. zustand persist 설정으로 hydrate 스킵하기
// authStore.ts
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
// 스토어 내용
}),
{
name: 'auth-storage',
// 서버 측 렌더링을 위한 추가 설정
skipHydration: true, // 서버에서 hydration 건너뛰기
getStorage: () => {
// 서버 환경에서는 더미 스토리지 반환
if (typeof window === 'undefined') {
return {
getItem: () => null,
setItem: () => null,
removeItem: () => null,
};
}
return localStorage;
},
},
),
);
skipHydration: true 속성을 적용한다.
서버 측에서는 hydration을 skip하고, 더미 스토리지를 반환해 오류가 나지 않도록 한다.
2. rehydrate() 함수를 호출하는 provider 컴포넌트 만들기
'use client';
import { useEffect, useState } from 'react';
import StatusMessage from '@components/common/StatusMessage';
import { useAuthStore } from '@/stores/authStore';
const StoreHydrationProvider = ({ children }: { children: React.ReactNode }) => {
const [isHydrated, setIsHydrated] = useState(false);
useEffect(() => {
(async () => {
try {
// localStorage에서 직접 auth 데이터 가져오기
// 서버 환경인지, 클라이언트 환경인지 확인
if (typeof window !== 'undefined') {
const authData = localStorage.getItem('auth-storage');
if (authData) {
const parsedData = JSON.parse(authData);
if (parsedData.state?.user) {
useAuthStore.getState().setUser(parsedData.state.user);
}
if (parsedData.state?.accessToken) {
useAuthStore.getState().setAccessToken(parsedData.state.accessToken);
}
if (parsedData.state?.isAuthenticated) {
useAuthStore.setState({ isAuthenticated: true });
}
}
}
await useAuthStore.persist.rehydrate();
setIsHydrated(true);
} catch {
setIsHydrated(true);
}
})();
return () => {};
}, []);
if (!isHydrated) {
return (
<StatusMessage status="loading" message="애플리케이션 초기화 중..." className="h-screen" />
);
}
return <>{children}</>;
};
export default StoreHydrationProvider;
이렇게 하면 Zustand의 내부 동작이 바뀌거나 버그가 있을 경우에도 안전하다.
끔 Zustand의 하이드레이션 과정에서 타이밍 이슈가 발생할 수 있다고 한다. 직접 상태를 설정함으로써 어떤 경우에도 인증 상태가 즉시 복원되도록 보장한다.
추가적으로 문제가 발생했을 때 Zustand의 rehydrate() 내부 로직에서 발생한 건지, 아니면 다른 곳에서 발생한 건지 분리해서 파악할 수 있어서 디버깅에 용이하다.
3. 루트 레이아웃을 provider 컴포넌트로 감싸기
끝!
zustand store를 사용하기 위해 이렇게 해결했으나... 직접 로컬스토리지에 접근해서 사용 중이었다면 LocalStorage를 클래스로 모듈화해서 클라이언트에서만 로컬스토리지에 접근할 수 있도록 추가하는 방법도 있다. (실행하기 전 window 객체 확인)
이 방법은 참고 두 번째 링크에 있다.
참고
'트러블 슈팅&리팩토링' 카테고리의 다른 글
[SpringBoot] Comment 도메인 리팩토링 사항 기록 (0) | 2025.10.06 |
---|---|
[Axios] 서버에서 사용자가 업로드한 파일을 받아오지 못하는 에러 핸들링 (0) | 2025.05.03 |
[Node.js] prisma 클라이언트 초기화 오류 해결하기 (1) | 2025.04.16 |
[Next.js] Image 경고(LCP, 종횡비) 해결하기 (0) | 2025.04.10 |
[React/Zustand] 리액트 훅은 함수 컴포넌트 내부에서만 호출될 수 있습니다. (0) | 2025.03.02 |