Next.JS hydration 스타일 이슈 파악하기

HyeonSeok Yang
7 min readJan 24, 2021

--

Next.JS를 사용해 웹을 만들어가다보면, 어느 순간 Hydration 이슈를 마주치게 된다. 이번엔 그 상황이 언제, 왜 생겨나는지를 파악해보고, 이걸 피해갈 방법을 알아보자.

Problem of Hydration

하이드레이션이란, 리엑트에서 서버사이드 렌더링 혹은 SSG(스태틱 사이트 제네레이션)을 실행한 HTML 결과물을 받아온 뒤, 브라우저에서 이것을 다시 리액트 트리에 맞게 파싱하는 행위이다. 이 단계에서,

  1. 렌더링한 결과물이 어떤 컴포넌트인지 확인하고,
  2. 각 컴포넌트에 걸린 이벤트리스너를 실제 DOM에 걸어주는 동작을 하게 된다.

하이드레이션이 잘못되었을 때, 우리가 마주하는 문제들은 거의 1번 과정이 잘못되어서 일어난다. 긴 말 하지 않고, 코드를 보자.

SSR 시에만 렌러링되는 어떤 컴포넌트가 있고

process.browser는 deprecated 되었다. typeof window !== ‘undefined’ 를 쓰자

CSR 시에만 렌더링되는 다른 컴포넌트가 있다

그리고 항상 렌더링되는 또다른 컴포넌트가 있다.

이 페이지에 접속하면 어떻게 보일까?

이게 대체 무슨 일이람!

Why this happened?

문제를 파악하기 위해, 서버사이드에서 렌더링되어서 내려온 html을 살펴보자. 브라우저에서 자바스크립트를 비활성화 시키거나, 네트워크 탭으로 서버에서 온 응답을 확인해보자.

서버사이드 렌더링은 잘 된것 같다.

대체 무슨 일이 발생한걸까?

Next.JS에서 내부적으로 사용하는 ReactDOM.hydrate 함수는 다음과 같은 일을 한다.

  1. 서버에서 받아온 DOM tree와 자체적으로 렌더링한 tree를 비교한다.
  2. 두 tree 사이의 diff를 얻어낸 뒤, 자체적으로(클라이언트사이드) 렌더링 한 tree에 비교하면서 어떤 DOM이 어떻게 매칭되는지 이해한다.
  3. 이해한 내용에 따라, CSR 동작을 실행한다.

이 스텝을 따라가보자.

Digging — part 1.

리액트가 두 개의 트리를 어떻게 비교하는걸까? 일단 렌더링된 결과물을 비교한다면 아래 두 트리를 비교하는게 맞을 것 같다.

땡!

Hydrate는 HTML파일을 렌더링 된 트리가 아닌, 버츄얼 돔과 비교한다.
그러니까, 훅을 사용한다면, useEffect가 실행되기 전의 상태와 비교한다.
아마도 서버사이드 렌더링 결과와 환경을 맞춰 비교하기 위해서- 인 듯 하다.
때문에, 실제 비교가 일어나는 환경은 다음과 같다.

땡!

하이드레이트 함수의 설명을 잘 읽어보면 경고문이 있다.

There are no guarantees that attribute differences will be patched up in case of mismatches.

그러니까 저 경고의 함의는, 리액트 하이드레이션은 텍스트나 속성값은 비교하지 않는다. 리액트가 비교하는 트리는 아래와 같다. 결국 렌더링된 엘리먼트 타입과 순서만 비교한다는 뜻이다.

이제 조금 느낌이 오는가? 여기에 추가적인 논의를 이어가기 위한 표지를 좀 추가해보자.

그러니까 리액트의 입장에서는, 아래와 같은 판단을 하게 된다.

  1. $C0 는 <Always />가 렌더링 된 결과물이지.
  2. $S0 는 $C0와 매칭되는군.
  3. $S1 는 제거되었군.

Digging — part 2.

그러면 리액트의 판단 대로 하나씩 적용시켜보자. 서버사이드 렌더링된 결과물에, 클라이언트 사이드 렌더링된 결과물을 하나씩 대입해보는 방식이다.

  • 1, 2에 의해, $S0는 <Always /> 이다.
  • $S0의 children 보다는 <Always />의 children 이 우선이다. 업데이트한다.
  • 그러나 attribute(style)는 손대지 않는다. 왜냐하면, 하이드레이션 스텝은 이미 렌더링된 돔 트리가 어떤 컴포넌트에 해당하는지 파악하고 이벤트를 걸어주기 위한 스텝으로 의도되었기 때문이다. style=“color:red” 값이 그대로 살아있게 된다.
  • componentDidMount() 를 호출한다 — 혹은 useEffect() 를 호출한다.
  • 새로운 렌더링 결과물은
<main>
<CSR /> <- 그러니까, Always 컴포넌트 앞에 CSR 컴포넌트가 추가되는군!
<Always />
</main>
코드는 멍청하지 않다. 그냥 로직을 따랐을 뿐.

사실, 이 상태에서 클라이언트 사이드에서 리렌더링이 발생할 경우, 각 컴포넌트의 props를 다시 제대로 인식하게 되면서 애초에 원했던 바람직한 상태로 렌더링이 된다.

이 문제는 정확하게, 서버사이드 렌더링 후 하이드레이션으로 돔 렌더링이 끝나버리는 (다른말로, state 나 context 에 의한 리렌더링이 일어나지 않는) 페이지에서만 발생한다.

실제로 발생하는 현상

이 현상은 대체로 컴포넌트 단위에서는 발생하지 않고, 페이지의 레이아웃 단위에서 자주 보여진다. 위에서 언급하듯, 리액트는 DOM Element 단위에서만 비교하므로, className이나 id 같은 프로퍼티가 엉뚱한 엘리먼트에 잘못 붙는 현상이 발생하게 되며, 이로 인해 잘못된 스타일이 먹히는 것이 가장 일반적으로 겪을 수 있는 문제이다.

예를 들자면, 모바일에서만 sticky Header 를 렌더링 하도록 하였는데, 데스크탑 클라이언트의 Contents에 sticky Header의 스타일이 먹혀있다거나, 하는 식이다.

SO HOW TO SOLVE IT?

두 가지의 해결 방법을 제시할 수 있다.

정석적인 해결 방법

클라이언트 사이드에서만, 혹은 서버 사이드에서만 렌더링되는 로직을 제거한다. 단순하게 말하자면, return null을 하지 않는다.

여기에도 두 가지 방법이 있다.

  1. return null 대신 return <div />
  2. return null 대신 visibility: hidden; 또는 display: none;

꼼수같은 해결 방법

이 문제가 발생하는 컴포넌트가 비동기로 받아올 특정 데이터를 꼭 필요로 한다거나, return null이 아니면 레이아웃이 깨진다거나, 컴포넌트 로직이 복잡해 위 방법으로 고치기엔 시간이 없다거나 할 때 다음과 같은 방법을 사용할 수 있다.

리액트가 두 컴포넌트를 어떻게든 구분할 수 있게 해준다.

  1. 서로 다른 엘리먼트를 사용한다. 특히 이렇게 렌더링이 되다 말다 하는 컴포넌트의 루트를 div, span, section, p, 같은 대체 가능한 엘리먼트로 변경한다.
  2. 혹은, 사이 사이에 <div />가 아닌 요소를 삽입해준다.

2번은 이런 방식이다.

Conclusion

리액트는 충분히 똑똑하다.
문제는, 내가 짠 코드가 리액트를 햇갈리게 했기 때문이다.

이런 데이터에 오락가락하는것까지 탓할 순 없다.

--

--

HyeonSeok Yang
HyeonSeok Yang

Written by HyeonSeok Yang

Frontend Developer, mainly using React.js

No responses yet