두번째. zustand + Hydration (건조한 내 코드에 물주기)

유원근·2022년 12월 27일
9
post-thumbnail

이전 글에서 Nextjs에 대한 내용을 간단하게 살펴보았다면 이번글에서는 그래서 어떻게 zustandnextjs에서 hydration을 진행하는지

reduxreact-redux를 이용한 hydrate 방법은 많은 정보들이 있는 반면에 zustand를 이용한 케이스에서는 쉽게 알아볼 수 있는 자료가 많이 않기 때문에 ( + typescript)

Hydration?

hydrate는 수분을 공급한다는 사전적 의미를 가지고 있습니다.

이 부분을 이해하기 위해서는 이전 글에서 본 SSR과정을 조금더 자세하게 살펴 볼 필요가 있습니다.

CSR에서는 빈 문서를 서버에서 받은뒤 JS를 통해 페이지를 그려주게 됩니다.

그러나 SSR에서는 서버에서 리액트 컴포넌트들을 렌더링한뒤 사용자에게 보내주게 됩니다.

이때 중요한 부분은 서버에서 html을 받았을 당시에 javascript파일은 아직 로드되지 않았다는 부분입니다.
즉, Javascript가 불러와지지 않았기 때문에 서버에서 받아온 완성된 html을 통해 사용자가 화면을 볼 수는 있지만, 이벤트와 같은 상호작용은 이용할 수 없습니다.

  • 서버에서 받아온 Document를 이벤트와 같은 상호작용이 없는 건조한 문서라고 할 수 있습니다.

Hydration는 이러한 건조한 문서에 수분을 넣어주는 즉, JS를 입혀주고 React를 이용해 문서를 관리할 수 있게 하기 위한 과정이라고 볼 수 있습니다. 그리고 그림에서 알 수 있듯이 React가 Rehydration을 진행하기 바로 직전 store에 대한 hydrate가 발생하게 되는데요,
결국 여기서 가장 중요한것은 순서입니다. store가 먼저 hydrate가 진행되고, React의 Rehydration이 진행되는 순서요!

이때 React는 서버에서 전달받은 문서의 Text값과 같은 컨텐츠가 클라이언트에서 리액트로 Hydration될때 동일한 컨텐츠가 렌더링 될 것으로 예상합니다. 만약 컨텐츠가 일치하지 않는 경우에는 개발모드에서 경고를 출력해 주게 됩니다.

위 문제가 바로 store hydrate당시 store의 상태와, 서버에서 문서가 만들어질때의 store의 상태달라서 다른 컨텐츠가 그려졌기 때문에 생기는 문제인데, 왜 그런 문제가 발생하게 되는지에 대하여는 다음 글에서 살펴보겠습니다.
관련글 -> react-hydration-error | Next.js

Zustand store SSR과 함께 사용하기.

이제부터는 기존에 create store와 다른 방법으로 접근을 해주어야 합니다.

기존에는 create를 통해 만들어진 useStore를 단순하게 컴포넌트에서 불러와 사용해 주었다면, SSR시에 좀더 효과적으로 사용하기 위해 Context를 이용한 Provider와 그에 Provider로 공급할 store를 만들어 주어야 합니다.

  • 타입스크립트를 기준으로 코드를 살펴보겠습니다.

1. 먼저 아래와 같이 타입과 zustandContext를 이용해 Provider,Store를 만들어줍니다.

// initialState의 타입을 만들어주는 타입
export type InitialState<M extends (...args: any) => any> = ReturnType<M>;
// store를 사용할때 어떤 타입의 store인지 만들어주는 타입
export type UseStoreState<M> = M extends (...args: never) => UseBoundStore<infer T>
  ? T
  : never;

//스토어를 let으로 선언해두고 뒤에 store가 이미 생성되어 있는지 확인할 것입니다.
let store: any;
//initializeUserStore는 아래에서 store를 만들어주는 함수입니다.
const userContext = createContext<UseStoreState<typeof initializeUserStore>>();
export const UserProvider = userContext.Provider;
export const useUserStore = userContext.useStore;

2. 그리고 스토어에 있는 State의 기본값을 만들어주세요.

interface UserState {
  user: User | null;
  updateUser: (name: Partial<User>) => void;
}

// store에서 합쳐줄 기본값입니다 
// 보통 store에 state를 변경하는 함수도 함께 넣지만, 초기화가 필요한 값들로만 만들어줍니다.
const getDefaultUserState = () => ({
  user: null,
});

3. 스토어를 초기화 하는 함수를 만들어 줍니다.

  • 기존에 create를 사용한 스토어 생성과는 다르게 store가 바로 생성되는 것이 아닌 실행시킬때 생성되게하는 함수를 만드는 것입니다.
  • 이를 이용해 위에서 만든 Provider에 스토어를 넣어줄때 스토어를 초기화 할 수 있습니다.
// 서버측에서 persist할때 임시로 사용할 storage
export const dummyStorage = {
  getItem: () => null,
  setItem: () => undefined,
  removeItem: () => undefined,
};

export const initializeUserStore = (preloadedState: Partial<UserState> = {}) => {
  return create(
    persist(
      immer<UserState>(set => ({
        ...getDefaultUserState(),
        ...preloadedState,
        updateUser: payload =>
          set((state: UserState) => {
            if (state.user) state.user = { ...state.user, ...payload };
            else state.user = payload as User;
          }),
      })),
      {
        name: 'user-storage',
				// persist를 이용할 storage를 정합니다. ssr시에는 localstorage가 없기때문에 dummyStorage를 이용합니다.
        getStorage: () => (typeof window !== 'undefined' ? dummyStorage : localstorage),
      }
    )
  );
};

initializeUserStore의 순서는 다음과 같습니다.

  1. 위 코드에서 getDefaultUserState()를 통해 상태값을 기본값으로 초기화를 해주게 됩니다.
  2. 다음 store를 생성할때 인자로 넣어준 값을 이용해 state를 덮어써줍니다. (중요)
  3. 상태를 바꾸는 함수등의 초기화가 필요없는 값들을 만들어 줍니다.

2번이 중요한 이유는 getServerSideProps에서 스토어를 생성할때 미리 API서버에서 받아온 값을 넣어주게 되면 store의 상태가 최신인 상태로 서버에서 문서를 만들 수 있습니다.

4. 마지막으로 Provider에 Store를 공급할 코드를 작성해줍니다.

  • 가장 처음에 만든 let store를 이용해 client단에서 스토어가 이미 생성되어 있는지 체크합니다.
  • getServerSideProps에서 return해준 AppPropsserverInitialState라는 인자로 받아올 것입니다.
export const useCreateUserStore = (
  serverInitialState: InitialState<typeof getDefaultUserState>
) => {
  if (typeof window === 'undefined') {
		// SSR중일경우 serverInitialState라는 값을 이용해 초기화 해줍니다.
    return () => initializeUserStore(serverInitialState);
  }
  const isReusingStore = Boolean(store);
  store = store ?? initializeUserStore(serverInitialState);
  // eslint-disable-next-line react-hooks/rules-of-hooks
  useLayoutEffect(() => {
    if (serverInitialState && isReusingStore) {
      store.setState(
        {
          ...store.getState(),
          ...serverInitialState,
        },
        true
      );
    }
  });

  return () => store;
};

여기서 가장 중요한 부분이 serverInitialState인데요, useCreateUserStore를 어떻게 사용하는지 _App.tsxpages/index.tsx를 한번 살펴보겠습니다.

5. Component _app.tsx, index.tsx에서의 사용

// pages/index.tsx

export default function Home() {
  const { user } = useUserStore();
  return (
    <Layout>
      <Top />
      <span>{Object(user).toString()}</span>
    </Layout>
  );
}
export const getServerSideProps: GetSSP = async ({ req }) => {
	const user = {
		id: 1,
		name:'conrad'
	}
	// user값을 이용해 store를 만들어주고 그 store를 props로 아래에서 넘겨줍니다.
  const store = initializeUserStore(user);

	// 혹은 아래처럼도 store에서 만든 상태변환 함수를 이용해 사용할 수도 있습니다.
	// store.getState().updateUser(user)

  return {
    props: {
      // initialUserStore로 AppProps를 전달해 서버의 _App.tsx에서 UserStore를 만들어줄것
      initialUserStore: JSON.parse(JSON.stringify(u.getState())),
    },
  };
};
// pages/_app.tsx

export default function App({ Component, pageProps }: _AppProps) {
	// 위 getServerSideProps에서 return해준 상태값을이용해 store생성 | 위 4번 코드
  const userStore = useCreateUserStore(pageProps.initialUserStore);

  return (
		// 만든 store를 이용해 컴포넌트에 공급
    <UserProvider createStore={userStore}>
      <GlobalStyle />
      <Component {...pageProps} />
    </UserProvider>
  );
}

이렇게되면 getServerSideProps에서 store의 상태를 바꾼뒤 만들어진 store를 이용해 SSR시 서버에서 최신의 상태값을 이용해 html문서를 만들 수 있습니다.

Notwithstanding..

몇가지 문제점들이 아직 남아있습니다.

  • getServerSideProps에서 모든 store의 상태값을 새로고침 이전의 상태값과 똑같이 맞춰주지 않는이상 server의 컨텐츠와 클라이언트의 컨텐츠가 달라지게 됩니다. 예를들어 persist를 사용한다는 가정하에 client에서 사용하는 데이터가 user, post등의 store를 사용한다면 우리는 모든 데이터를 getServerSideProps에서 불러와 주어야 합니다. 그렇지 않으면 아래와 관련된 Content did not match오류를 마주하게 됩니다. react-hydration-error | Next.js

3가지 가정을 두고 localStorage를 이용한 persist미들웨어를 사용한다면 아래 순서로 렌더링이 이루어지게 됩니다.

  • 페이지에서 사용하는 store의 데이터는 user, posts의 데이터가 있다.
  • getServerSideProps에서 posts의 데이터만 불러온다.
  • 유저가 보고있던 페이지에서 새로고침을 하게된다.
  1. 유저가 보고있는 페이지에서는 Client에서 데이터를 서버에서 불러와 Store에 user, posts등의 모든 데이터가 완성되어 있는상태인데, 상태가 변화될때마다 persist는 localstorage에 store의 상태값을 최신화 하게 됩니다. 이때 사용자가 새로고침을 하게 된다면,
  2. getServerSideProps에서 user데이터를 불러와 store를 최신화 하고, 서버에는 localstorage가 없기때문에 persist되지 않아, posts의 데이터는 null인 상태로 html이 그려지게 됩니다.
  3. 클라이언트에서 hydration이 될때에는 localstorage가 있기 때문에 user, posts등의 모든 데이터가 채워진 상태로 리액트가 페이지를 그리게 됩니다. 이때 서버에서 받아온 컨텐츠와 클라이언트에서 그린 컨텐츠가 서로 MissMatch 되는 현상이 발견됩니다.

위와 같이 서로 다른 모습을 띄고 있기 때문에 오류를 만들어 내고 있는 것인데요,

이번글에서는 [3번 코드] 서버에서 스토어를 초기화 할때 dummyStorage를 이용해 persist를 막아주었기 때문에 위와 같은 문제가 발생하게 됩니다. (getItem시 return값이 없음)

하지만, dummyStorage를 이용하지 않는다면, 서버에서 localstorage를 사용할 수 없기 때문에 필연적으로 오류를 만날 수 밖에 없습니다.

만약 서버에서 persist-storage를 이용해 스토어를 초기화 해준다면, 다른 data를 API서버에서 불러오는 작업 없이도 쉽고 빠르게 store를 만들어 줄 수 있을 것입니다.

다음 글에서는 이 문제를 어떻게 해결하였는지에 대하여 알아보겠습니다.

2개의 댓글

comment-user-thumbnail
2022년 12월 28일

저도 이번에 새로 프로젝트를 Next.js를 사용해서 시작해보려고 합니다! 이전에 사용해본 기술 스택이 zustand, react-query였는데, 이번에 react-query 대신 SWR 을 사용해볼까 고민중에 있었는데, 좋은 포스팅이 있어서 많이 참고할 것 같아요!!

1개의 답글