Ayden's journal

Hydration Error

─리액트로 CSR 페이지를 만들다가 NextJs로 넘어오면, 마주했을 때 가장 당황스러운 개념 중 하나가 바로 Hydration Error(이하 HE)일 것이다. 일반적으로 이 HE를 설명할 때 "서버에서 랜더링 된 HTML과 클라이언트에서 React가 다시 렌더링할 때의 HTML이 다를 경우" 발생한다는 식으로 이야기하곤 한다.

그런데 여기서 말하는 '다를 경우'라는 게 구체적으로 어떻게 다른 걸 의미하는지 오랫동안 제대로 이해하지 못했다. 정확히는 이해할 생각조차 하지 않았다고 하는 게 맞겠다. 서버와 클라이언트가 동일하면 HE는 발생하지 않을 거고, 그 외에는 크게 중요하지 않은 것처럼 느껴졌다.

 

이번 주에 나는 엑세스토큰과 리프레시토큰을 어떻게 관리하고, 어떻게 사용하는 게 best practice일지 고민했다. 그리고 주변 개발자들의 경험담과 스택오버플로우 등을 참고하여 대강의 결론을 내렸다. 클라이언트에서는 토큰을 제어하지 않은 채 서버에서 httpOnly secure 쿠키로 구워주거나, 리프레시토큰만 쿠키로 굽고 엑세스 토큰은 클라이언트에서 로컬 변수로 들고 있는 방식을 나는 잠정적으로 best practice라 여기고 있다.

그런데 한 가지 의문이 생겼다. 쿠키나 로컬변수 모두 클라이언트에 귀속되어있는 값이다. 물론 쿠키를 통해 SSR 환경에서도 엑세스토큰 값을 사용할 수 있지만, 페이지 라우터 환경에서는 getServerSideProps에서 리턴한 엑세스토큰 값을 각 컴포넌트에서 사용하기 위해 또다른 대책을 필요로 한다(가령 전역 상태 관리 도구 Caro-Kann의 도움을 받는다거나...).

 

서버에서는 엑세스토큰이 없어서 데이터를 못 받아오고, 클라이언트에서는 받아오면 HE가 발생할 것 같았다. 'HE가 발생한다'가 아니라 'HE가 발생할 것 같았다'고 표현할 수 밖에 없는 상황이, 그동안 제대로 알아보지도 않고 어림짐작으로 코드를 짜왔다는 사실이 나는 썩 달갑잖았다. 그러나 곧 생각을 고쳐먹었다. 어쨌든 앞으로도 모르는 것보다야 이제부터라도 아는 게 낫다.

 

 

기본적인 검증

window 객체는 브라우저에만 존재하고 node 환경에는 없다는 것 정도는 알고있다. 그리하여 우선 아래와 같은 코드를 작성해보았다. 환경에 따라 변수가 서로 다른 값을 가지면 어떻게 될까, 를 검증해보고 싶었다.

export default function Home() {
  let bar = "";

  if (typeof window === "undefined") bar = "A";
  else bar = "B";

  return <></>;
}

다행히 아무런 문제 없이 빈 화면이 랜더링되었다. 앞서 서버와 클라이언트의 랜더링 불일치가 HE를 발생시킨다고 했다. 여기서 알 수 있는 사실 한 가지는 컴포넌트가 리턴하는 영역에 대해서만 HE가 발생한다는 것이다. bar 변수에 어떤 불일치를 발생시킨다고 해도, 리턴되어 랜더링에 포함되지 않는다면 HE 걱정 없다.

 

단순 문자열의 불일치는 어떤 결과를 불러일으킬까. 아쉽게도 아래의 코드는 HE를 일으킨다. 이로부터 서버와 클라이언트의 랜더링을 비교할 때 innerText 수준까지 비교한다는 사실을 알 수 있었다.

export default function Home() {
  let bar = "";

  if (typeof window === "undefined") bar = "node";
  else bar = "client";

  return <div>{bar}</div>;
}

 

리액트에서 자주 사용하는 조건부 랜더링 기법에서도 아래와 같은 경우라면 클라이언트와 서버의 랜더링에 차이가 있기 때문에 에러가 발생한다. 나는 은연중에 '서버에서 존재하지 않았던 노드가 클라이언트에서 생기는 경우 HE가 발생하지 않을 것'이라 예상했지만 보기 좋게 빗나갔다.

export default function Home() {
  let bar = "";

  if (typeof window === "undefined") bar = "node";
  else bar = "client";

  return (
    <>
      {bar && <div>{bar}</div>}
    </>
  );
}

 

세 번의 테스트를 통해 나는 HE의 발생 원인을 알 수 있었다. 서버에서 코드를 랜더링하고, 그 결과를 캐싱해서 클라이언트로 전달한 뒤, 클라이언트에서의 랜더링 결과와 캐싱된 값을 비교해서 하나라도 불일치한다면 무조건 HE가 발생한다. 여기서 재미있는 점은 '서버에서는 코드를 딱 한 번만 랜더링할 수 있다'는 것이다.

 

 

추가 검증

useState는 특정한 값을 받을 뿐 아니라, 특정한 값을 계산할 수 있는 초기화 함수를 인자로 받을 수 있다. 초기화 함수는 컴포넌트가 처음 렌더링될 때 한 번만 호출되어 초기 상태 값을 생성하는 데 사용되고, 다시는 호출되지 않는다. 따라서 서버에서 랜더링될 때 한 번, 클라이언트에서 랜더링될 때 한 번 호출된다.

만약 서버와 클라이언트에서 초기화 함수의 결과가 다르다면, 그로 인해 랜더링 결과가 달라진다면, 이 역시도 HE가 발생하는 이유가 된다.

export default function Home() {
  const [bar, setBar] = useState<string>(() => {
    if (typeof window !== "undefined") return "client";
    else return "node";
  });

  useEffect(() => {
    setBar("Bar");
  }, [setBar]);

  return <>{bar}</>;
}

 

이 경우는 당연히 문제가 되지 않는다. 서버와 클라이언트 모두에서 최초 랜더링 시 bar는 빈 문자열이기 때문이다.

export default function Home() {
  const [bar, setBar] = useState<string>("");

  useEffect(() => {
    setBar("Bar");
  }, [setBar]);

  return <>{bar}</>;
}

 

 

React-Query

여기서부터가 본론이다. 액세스토큰을 어디에 어떻게 보관하는가와 별개로 클라이언트와 서버의 액세스토큰 값을 일치시키는 일은 대단히 번거롭게 느껴진다. 그렇다고 이를 일치시키지 않으면 리액트쿼리의 결과값이 클라이언트와 서버에서 일치하지 않을 것이다. 그런데 정말 그럴까?

 

이를 테스트하기 위해 간단한 코드를 작성해보았다. 변수 token의 값은 node 환경에서는 빈 문자열이고, brower 환경에서는 "token"이라는 문자열이다. useQuery가 리턴하는 data 값은 비동기 함수인 fetchToken의 결과이고, fetchToken 함수는 사실상 파라미터를 그대로 리턴하고 있다.

인자로 주어지는 token의 값이 클라이언트와 서버에서 다르니까, data 값도 당연히 다르겠거니 했다. 이번에도 내 예상은 보기좋게 빗나갔다.

import { useQuery } from "@tanstack/react-query";

const fetchToken = async (token: string) => {
  return token
};

export default function Home() {
  const token = typeof window !== "undefined" ? "token" : "";

  const { data, isLoading } = useQuery({
    queryKey: ["todo"],
    queryFn: () => fetchToken(token),
  });

  console.log(data, isLoading);

  return <>{data}</>;
}

 

node console
brower console

 

이걸 보자마자 나는 벼락 맞은 듯 단말마의 비명을 질렀다. 생각해보면 아주 당연한 것인데도 전혀 예상하지 못했기 때문이다. 리액트를 접하면 가장 먼저 배우는 게 "비동기 함수를 useEffect 안에서 호출하고 그 결과를 setState하여 사용"하는 것인데, React-Query도 정확히 그러한 로직을 따르고 있었을 줄이야!

몇 번의 테스트를 더 진행해본 결과 React-Query가 내부적으로 useEffect와 state를 기반으로 작동한다고 거의 확신할 수 있었다. useState 대신 state라고 말한 이유는 구현체를 본 것도 아니거니와, useState 뿐 아니라 useReducer나 useSyncExternalStore 훅으로도 상태(state)를 변경할 수 있기 때문이다.

 

 

그리하여 이제는 하이드레이션 에러의 원인과 결과를 이해했다고 ─ 감히 ─ 말할 수 있을 것 같다.

블로그의 정보

Ayden's journal

Beard Weard Ayden

활동하기