Nextjs + Jotai의 SSR 이슈

Nextjs에서 jotai를 사용하면서 겪은 이슈 트러블 슈팅 경험을 공유합니다.
(본문에서는 Nextjs와 jotai의 기본 구조와 사용법에 대해서는 설명하지 않습니다)

이슈 발견

SSR시 결과가 간헐적으로 다름

// case 1
<body>
  <div>
    <span class="banner">배너 컨텐츠</span>
  </div>
  <div>hello world</div>
  ...
  <body></body>
</body>
// case 2
<body>
  <div>
    <!-- 이 부분에 있어야 할 배너가 없음 -->
  </div>
  <div>hello world</div>
  ...
  <body></body>
</body>

SSR된 html이 case1과 case2가 번갈아가면서 나타남

이슈가 발생한 코드

import { useHydrateAtoms } from 'jotai/utils';

export const global_store = createStore();

const banner_visible_atom = atom(false);

const HydrateAtoms = ({ initial_values, store, children }) => {
  useHydrateAtoms(initial_values, { store });

  return children;
};

// 초기화
<HydrateAtoms
  initial_values={[[banner_visible_atom, true]]}
  store={global_store}
/>;

// 사용
const banner_visible = useAtomValue(banner_visible_atom, { global_store });

위와 같이 jotai document의 SSR 가이드대로 useHydrateAtoms 를 통해 banner_visible_atom을 true로 초기화 했으나 banner_visible_atom의 초기값이 true일때도 있고 false일때도 있어서 SSR의 결과가 다른 이슈

원인 파악

모든 페이지에서 공유해야하는 atom이어서 global store을 사용한 것이 주된 이유였음.

  1. 기존에 global_store 를 한곳에 정의하고 export해서 사용하는 구조였음
export const global_store = createStore();
  1. CSR상황에서는 각 유저별로 각각 코드를 서빙 받으니 당연히 scope가 달라 문제 없음
  2. 하지만 SSR에서는 하나의 요청당 하나의 global_store 를 사용하는게 아님. 여러 유저가 같은 메모리의 global_store 를 공유하고 있던것.
  3. jotai의 useHydrateAtoms 은 설계상 atom이 “초기화 되어 있지 않은” 상황에서만 초기화를 수행함 관련 discussions
// jotai의 useHydrateAtoms 코드

const hydratedMap = new WeakMap();

export function useHydrateAtoms(values: T, options?: Options) {
  const store = useStore(options);

  const hydratedSet = getHydratedSet(store);
  for (const [atom, value] of values) {
    if (
      !hydratedSet.has(atom) || // 이미 hydratedSet에 추가된 atom은 초기화를 수행하지 않음
      options?.dangerouslyForceHydrate
    ) {
      hydratedSet.add(atom);
      store.set(atom, value); // atom을 초기화하는 코드
    }
  }
}
  1. 각각 다른 유저의 요청a, 요청b, 요청c가 있다고 하면, 3과 4로 인해 요청a에서 global_store의 banner_visible_atom 을 한번 초기화하고 요청b, 요청c, 요청d… 에서는 이미 atom이 초기화 되어있기 때문에 초기화가 수행되지 않음. 그래서 대부분 요청의 경우 hydrate가 수행되지않아서 의도치 않은 초기값으로 SSR을 하다가 어떤 이유로 서버에서 글로벌 영역에 저장된 global_store가 파괴된 직후 요청은 초기화가 정상 수행되어 기대한 HTML이 내려오는것.
  2. SSR은 초기화 되지 않은 값으로 실행하지만 CSR은 초기화가 수행된 값으로 실행 되어 수화 과정에서 server side와 다르다는 에러 메시지(Hydration failed because the initial UI does not match what was rendered on the server)가 계속 발생했었음

수정

서버사이드라면 useHydrateAtoms 에 추가된 dangerouslyForceHydrate 옵션을 true로 해서 global_store의 초기화 여부에 관계없이 항상 수화 하도록 변경.
=> 메인테이너가 생각하기에 useHydrateAtoms 를 통한 초기화는 store.set과 같은 결과이므로 좋은 해결책은 아니나 현재로선 제일 나아보임
참고 - 관련PR

교훈

간헐적으로 재현이 되는 이슈는 항상 디버깅이 어려움.

그 동안 이슈를 발견하지 못한 이유는 서비스 코드내에서 global store 값 초기화를 통해 SSR에 영향을 주는 케이스가 없었고 에러 메시지(서버와 클라이언트가 다르다는)를 영향도 낮은 기술부채로 취급하여 따로 보고있지 않았기 때문.

이 에러메시지에 유의했으면 더 빨리찾을 수 있었을텐데 평소 유저에게 큰 영향이 없는 경우가 많은 에러라 무시하고 있었어서 비용을 치뤘다. 하지만 더 크리티컬한 이슈를 겪기 전에 발견해서 다행이라고 생각.