TL;DR

  • 하이드레이션(Hydration)이란 이미 렌더링 된 마크업(HTML)에 화면의 동작 코드(JS)를 연결하는 과정이다.
  • 서버로부터 받은 렌더링 결과와 클라이언트의 하이드레이션 결과가 다를 경우 하이드레이션 불일치(Hydration Mismatch)가 발생한다
  • 하이드레이션 불일치는 다음과 같은 상황에서 발생한다.
    • if (typeof window !== 'undefined')처럼 서버/클라이언트 분기 처리를 할 때
    • Date.now(), Math.random()처럼 실행 시점마다 달라지는 값을 사용했을 때
    • 사용자의 날짜 표시 형식이 서버와 다를 때
    • HTML과 함께 스냅샷이 전달되지 않은 외부 데이터
    • 유효하지 않은 HTML 태그 중첩
    • 브라우저 확장 프로그램
  • 단순 텍스트 차이로 인해 하이드레이션 불일치가 발생했다면 suppressHydrationWarning 속성을 추가해 해결할 수 있다.

이하 그리 중요하진 않은 내용들

호기롭게 시작을 세상에 공표했던 개인 프로젝트는 아직 지지부진해 첫 삽도 뜨지 못한 가운데, 아무런 새 글도 없이 블로그가 죽은 것처럼 보이면 신세가 참 처량해 보이겠다 싶어 이번에도 자잘한 도움말을 써보려 시도하고 있다. 그런고로 이번에도 주제는 블로그 리모델링 중 겪은 일이다. 아니 블로그 재개발도 어느덧 벌써 반년 전의 일이 되어버렸는데 언제까지 이걸 울궈먹을런지.

SSR과 CSR

나는 이 블로그를 React와 Next.js 프레임워크를 사용해 구축했다. 얼마 전 알게 된 사실인데 CRA(create-react-app)이 deprecated 처리된 이후 React 공식 문서에선 새 React 앱을 만들 땐 Next.js를 사용할 것을 권장하고 있더라고. 아니 왜 Vite 놔두고 그런담.

nextjs쓰세요
심지어 프레임워크 없이 구축하는 방법 소개 페이지에 가도 프레임워크 좀 쓰라고 꽤 질척거린다

물론 이 블로그를 Next.js로 만든 주제에 몽니를 부리고 있지만, 블로그엔 Next.js의 장점이 꼭 필요했기 때문이었다. 그렇담 Next.js의 그 장점이란 무엇일까. Next.js와 같은 프레임워크를 사용하지 않고 리액트 라이브러리와 Vite나 Rollup 같은 번들러로 사이트를 만들면 일반적으로 클라이언트에서 자바스크립트를 실행해 HTML을 생성하는 CSR(Client Side Rendering) 방식의 SPA(Single Page Application)로 결과물이 나올 것이다. SPA는 수많은 현대 웹 서비스들의 가능성을 넓혀준 패러다임이지만, 근본적인 문제가 하나 있다. 클라이언트에서 자바스크립트를 실행해 HTML을 생성한다는 것은 다시 말해 클라이언트가 그럴 능력을 갖추고 있어야 한다는 것이다. 일반 사용자들이 사용하는 웹 브라우저에겐 그 능력이 기본적으로 내장되어 있지만, 웹 서핑은 웹 브라우저만 하는 것은 아니다.

웹 크롤러는 자동화된 방법으로 웹을 탐색하는 컴퓨터 프로그램이다. 그러니까 사람이 조작해서 웹 페이지를 탐색하게끔 하는 웹 브라우저와 달리 웹 크롤러란 물건은 자기들이 알아서 웹 페이지들을 기어 다니며 목적에 따라 필요한 내용을 읽어야 한다. 그리고 그 목적의 대표적인 사례가 바로 구글과 같은 검색 엔진의 인덱싱이다. 효율성을 위해 크롤러는 그 동작이 웹 브라우저에 비해 가벼울 수밖에 없다. 무슨 말이냐면, 웹 크롤러는 일반적으로 자바스크립트를 실행할 능력, 다시 말해 자바스크립트 엔진이 없다. 따라서 웹 크롤러는 일반적으로 CSR을 수행하지 못한다. 자바스크립트를 해석하지 못하는 웹 크롤러가 SPA 사이트에 접속하면 검색 엔진에 등록할 만한 정보가 하나도 들어 있지 않은 빈 화면으로 사이트를 인식할 것이다. 그래서 SPA 방식으로 만든 웹 서비스는 검색 엔진의 검색 결과에 제대로 노출되기가 어렵다는 치명적인 단점을 가지고 있다.

글 신나게 썼는데 구글 네이버 다음 등등 검색 서비스에게 외면당하면 안 되잖아. 따라서 블로그 개발에는 웹 크롤러도 인식할 수 있는 형태로 서버가 일차적인 렌더링을 해주는 방식, SSR(Server Side Rendering)을 지원하는 프레임워크 Next.js를 사용했다. 정리하자면 이렇게 웹 어플리케이션의 구현을 CSR과 SSR이라는 큰 두 방식의 분류로 나눌 수 있겠다. 근데 사실 엄밀히 따지면 하나를 쓴다고 다른 한쪽은 완전히 사장되는 것은 아니긴 하다. 워낙 각자의 장단점이 뚜렷하기 때문. 게다가 어떤 사이트를 SSR 방식으로 만들었다고 그 사이트엔 CSR이란 개념이 아예 존재하지 않는 것도 아니다. 그렇기 때문에 두 방식은 서로 얼마나 다르고, 어떻게 융화되는지 알아둘 필요가 있다.

Hydration

조금 억지스럽게 느껴질 수도 있겠지만, 사용자가 완성된 웹 페이지를 이용하는 것을 식사에 비유해 보자. 먼저 고전적인 웹 서버는 이미 다 만들어져 바로 먹을 수 있는 완제품을 파는 편의점, WAS는 주문을 받았을 때 주방장이 요리를 시작하는 식당이라고 볼 수 있다.

이 비유를 현대적인 웹 페이지 구현 방식들에도 적용해 본다면, CSR과 SSR은 사용자에게 재료와 레시피를 쥐여주고 직접 요리하길 요구하는 방식이라고 말할 수 있겠다. CSR은 사용자가 직접 원재료 손질부터 시작한다는 느낌이라면, SSR은 마치 밀키트를 구입하는 것과 비슷하다. 고기도 이미 양념에 재워져 있고 감자 껍질도 벗겨져 있고 물만 붓고 끓이기만 하면 된다. 이 물을 붓고 끓이는 행위를 SSR에선 하이드레이션(Hydration, 수분공급)이라고 부른다.

CSR에선 두 동작이 결합되어 있었기에 구분이 확실하지 않았지만, 웹 페이지 생성 과정에서 프로그래밍 언어의 동작은 다음 둘로 나눌 수 있다.

  1. 렌더링(Rendering) : 화면 구성요소(마크업, HTML)를 생성한다.
  2. 하이드레이션(Hydration) : 화면의 동작 코드(스크립트, JS)를 앞서 생성한 마크업과 연결한다

SSR은 위 두 과정 중 렌더링만 서버에서 담당한다. SSR 서버에 접속한 클라이언트는 이벤트 핸들러, 리액트 훅(Hook) 등 동작이 정의되어 있지 않은 메마른 웹 페이지의 최초 소스를 받게 된다. 따라서 웹 페이지를 동적으로 만드는 과정을 뿌리(Root)에 물을 줘(Hydrate) 나무(DOM Tree)를 생생하게 만든다는 의미에서 하이드레이션이라고 부르는 것이다.

hydration
엔지니어들의 비유력이란.

리액트는 하이드레이션을 위한 API를 제공한다. 기존엔 hydrate란 함수를 사용했었고, 리액트 18 버전부터는 hydrateRoot를 사용한다. 공식 문서에서 설명하길, "서버 환경에서 먼저 리액트로 렌더링 된 HTML에 리액트를 부착(attach)하려면 hydrateRoot를 호출하면 된다." 여기서 알 수 있는 점은 하이드레이션이란 클라이언트에서 수행해야 하는 작업이란 것이다.

// 서버 측
import { renderToString } from 'react-dom/server'
 
const html = renderToString(<App />); // Server Side Rendering
 
// (렌더링 결과인 html이 클라이언트에게 전달된다.)
 
// 클라이언트 측
import { hydrateRoot } from 'react-dom/client';
 
const domNode = document.getElementById('root');
const root = hydrateRoot(domNode, reactNode); // Hydration

따라서 실제로 Next.js 프로젝트를 SSG로 설정해 빌드한 결과물에 다음과 같은 코드가 포함된 것을 볼 수 있다.

ssgbuild
이 코드는 클라이언트에서 실행된다.

Hydration Mismatch

조리예

그런데 밀키트의 포장지에 그려진 조리예와 고객이 조리한 결과가 너무 달라버리면 좀 곤란하겠지. 뉴스도 나고 소보원에서 나서고 책임자는 재판에 넘겨지고 가정 불화가 생기고 애는 굶고... 그렇기 때문에 리액트에선 하이드레이션 과정의 결과물이 서버에서 렌더링 한 결과물과 너무 달라지는 상황, 즉 하이드레이션 불일치(Hydration Mismatch) 상황을 경고한다.

그래서 이게 얼마나 나쁜가. 그냥 불일치 나면 그 부분만 수정해 주면 되는 거 아닌가? 하지만 그게 말처럼 쉬운 일은 아니다. 바뀐 부분을 수정한다는 것은 재렌더링이 발생한다는 것을 의미한다. 서버 측에서 일차적으로 렌더링을 한다는 장점을 갖춘 SSR에서 페이지를 로드하자마자 다시 렌더링해버리면 그 장점이 퇴색된다고 볼 수 있다.


출처 (그냥 막 가져온 거라 언제 사라져도 모름)

또 다른 문제는 사용자 경험에 나쁜 영향을 줄 수 있다는 점이다. 구글에선 Core Web Vital이라고 부르는 웹 페이지의 성능에 대한 지표들을 제시하고 있고 이를 검색 엔진 인덱싱의 기준으로 사용한다. 그중 CLS(Cumulative Layout Shift, 누적 레이아웃 이동)라는 지표는 웹 페이지가 로드된 후 추가로 발생하는 화면 레이아웃 상의 변경 횟수를 의미한다. 하이드레이션 불일치의 결과로 추가 렌더링이 발생하면서 위 영상과 같은 상황이 발생한다면 우리 서비스의 고객 센터는 원성의 메시지로 불이 날 것이다.

export default function DateString() {
  return <p>{Date.now()}</p>;
}

하이드레이션 불일치는 위 코드로 쉽게 재현할 수 있다. 현재 시각을 반환하는 Date.now() 함수는 실행할 때마다 다른 값을 반환한다. 따라서 서버 측 렌더링 시점의 결과값과 클라이언트 측 하이드레이션 시점의 결과값이 달라질 수밖에 없다. Next.js 프로젝트를 만들고 위 컴포넌트를 구현해 보면 친절히 에러 메시지로 안내해 줄 것이다.

재현

하이드레이션 불일치의 결과로 트리가 클라이언트에서 다시 생성된다고 안내하고 있다. 에러 메시지에 따르면 하이드레이션 불일치를 유발하는 상황은 다음과 같다.

  • if (typeof window !== 'undefined') 처럼 서버/클라이언트 분기 처리를 할 때
  • Date.now(), Math.random() 처럼 실행 시점마다 달라지는 값을 사용했을 때
  • 사용자의 날짜 표시 형식이 서버와 다를 때
  • HTML과 함께 스냅샷이 전달되지 않은 외부 데이터
  • 유효하지 않은 HTML 태그 중첩
  • 브라우저 확장 프로그램

되도록 위 상황을 최대한 피하되, 꼭 필요하다면 몇 가지 편법이 있다. 만약 서버/클라이언트 분기 처리가 필요하다면 이렇게 구현해 보자.

const [isClient, setIsClient] = useState(false);
 
useEffect(() => {
  setIsClient(true);
}, []);

엘레먼트 내부 단순 텍스트로 인해 발생하는 하이드레이션 불일치는 suppressHydrationWarning 옵션을 사용해 경고를 무시하도록 설정할 수도 있다.

export default function DateString() {
  return <p suppressHydrationWarning>{Date.now()}</p>;
}

내가 배운 것

  • 하이드레이션이란 무엇인가
  • 하이드레이션 불일치란 무엇인가

이 글을 거의 2달 동안, 하루에 한 글자씩 써왔다. 이래가지고 앞으로의 블로그 생활이 심히 걱정된다. 벌써 올해 5월이 다가오고 있으나 한 해 동안 쓴 글이 고작 2개째라는 것에 통탄하고 있다. 물리적인 시간이 부족한 것은 어쩔 수 없는 걸까. 그나마 다행인 점은 그래도 마냥 정체 중인 건 아니라서 따로 별도의 스터디는 하고 있다는 점이고, 거기서 이번 글의 주제와 관련된 내용을 배워서 도움이 되기도 했었다. 아무튼 더 잘 알고 싶다면 아래 자료들을 참고해 보자.