React Hydration 문제에 대해서

7분

4/22/2022

Thumbnail

아래 글과 tweet을 읽고 다시 한 번 생각하기 좋은 글이라고 생각하여 이렇게 옮겨봅니다. https://www.builder.io/blog/hydration-is-pure-overhead > https://twitter.com/sebmarkbage/status/1516907614566854659

CSR이 TTI와 FCP 메트릭에 안 좋은 영향을 미친다는 것을 알고 SSR로 개발을 하게 되는 경우가 많았던 것 같습니다.

사용자 경험을 위해 SSR + hydration을 사용하는 경우가 많은데, 오히려 개념을 이해하지 못하고 개발한다면 Overhead를 증가시키고 나쁜 경험을 초래할 수도 있다는 인식을 하게 되었습니다.

살펴보았던 2개의 글은 hydration에 대해 부정적 측면과 역사적 측면을 설명해주고 있습니다.

그리고 그것을 극복하기 위한 다양한 기술들이 연구중이며, 최근에 React 18에 릴리즈된 Suspense를 통한 selective hydration도 그 중 하나입니다.

어떤 점에서 주의깊게 개발해야되는지, 어떻게 hydration을 사용해야되는지에 대해 생각해볼 만한 글인 것 같습니다.

들어가면서

웹 개발에서 hydration 또는 rehydration은 정적 hosting이나 SSR서버에서 받은 정적 HTML 웹 페이지를 동적 HTML 웹 페이지(이벤트 헨들러를 연결함으로써)로 바꿔주는 기술을 말합니다. _- hydration_wiki*

hydration

Hydration은 서버에서 렌더링된 HTML에 interactivity를 추가하기 위한 솔루션입니다.

위키에 설명대로라면 정적 HTML에 이벤트 핸들러를 붙이는 것을 hydration이라고 설명하고 있습니다. 그러나 DOM에 이벤트 핸들러를 붙이는 것은 hydration 영역에서 도전적이거나 값 비싼 부분은 아닙니다. 위키 설명에는 hydration이 overhead한 포인트가 빠져있습니다.

이 글에서 overhead는 해당 부분을 피할 수 있고, 그 부분을 제거한다고 해도 동일한 결과가 나오는 것을 의미합니다.

hydration을 더 깊게 살펴보자

hydration의 어려운 부분은 WHERE(어디에)WHAT(어떤) 이벤트 핸들러를 붙여야 하는지를 알아내는 것입니다.

  • WHAT: 이벤트 핸들러는 이벤트 핸들러 행동을 포함하고 있는 클로져입니다. 사용자가 이벤트를 트리거하면 일어나야만 하는 것입니다.
  • WHERE: WHAT이 필요한 DOM 요소 위치입니다.

여기서 WHATAPP_STATEFRAMEWORK_STATE를 Close-over(가리는) 클로져라는 것이 복잡성을 추가합니다.

  • APP_STATE: 어플리케이션의 상태입니다. 대부분 사람들이 생각하는 상태가 APP_STATE입니다. 이 상태가 없다면 사용자에게 동적으로 보여줄 수 없습니다.
  • FRAMEWORK_STATE: 프레임워크 내부 상태입니다. 이 상태가 없다면 프레임워크는 어떤 DOM 노드가 업데이트 되었는지, 언제 DOM 노드가 업데이트 되어야하는지를 알 수 없습니다. component-tree, references to render functions가 대표적인 예입니다.

클로져(이벤트 핸들러) 특성으로 인해 APP_STATE, FRAMEWORK_STATE를 알 수 없는 상태에서 어떻게 WHATWHERE를 복구할 수 있을까요? 현재 HTML 내에 component를 다운로드하고 실행을 통해서 알아내야합니다. HTML에서 렌더링된 component를 다운로드하고 실행하는 것은 매우 값 비싼 부분입니다.

다시말해, hydration은 브라우저에서 열심히 app 코드를 실행하면서 APP_STATEFRMAEWORK_STATE를 회복하는 hack 입니다. 더 자세한 실행은 다음과 같습니다.

  1. component code 다운로드
  2. component code 실행
  3. 이벤트핸들러 클로져를 얻기 위해 WHAT(APP_STATE,FRAMEWORK_STATE)와 WHERE를 복구하는 것
  4. WHAT(이벤트핸들러)을 WHERE(DOM 요소)에 붙히는 것

recover

위 그림에서 3번째 단계까지 RECOVERY 단계라고 하겠습니다. RECOVERY는 프레임워크가 어플리케이션을 rebuild하는 단계입니다.

rebuild는 어플리케이션 코드를 다운로드하고 실행하는 아주 비싼 단계입니다.

RECOVERY가 매우 비싼 단계이기 때문에 대부분 어플리케이션은 최적이 아닌 차선의 시작 성능을 가집니다. 특히 모바일에서 더 그렇습니다.

RECOVERY는 순수한 overhead입니다. overhead라는 것은 직접적으로 가치를 제공하지 않는 작업입니다. hydration 맥락에서, 이미 서버에서 SSR/SSG의 일부에서 얻은 정보를 rebuild하기 때문에 overhead입니다. 서버에서 클라이언트로 정보를 보냈지만, 그 정보가 버려졌습니다. 그 결과, 클라이언트는 서버에서 이미 했던 것을 rebuild하기 위한 값 비싼 RECOVERY를 해야합니다. 만약 서버가 정보만 serialize하고 클라이언트에게 HTML을 통해서 보낸다면, RECOVERY를 피할 수 있습니다. serialize된 정보는 클라이언트를 HTML 내에 component를 열심히 다운로드하고 실행하는 것으로부터 구해낼 수 있습니다.

서버에서 이미 SSR/SSG를 통해서 실행했던 것을 클라이언트에서 재실행하는 것은 hydration에서 순수한 overhead를 만듭니다. 즉, 클라이언트와 서버에서 똑같은 일을 중복해서 한다는 것입니다. 프레임워크는 서버에서 클라이언트로 정보를 전송하여 비용을 줄일 수 있었지만 대신에 정보를 버렸습니다.

정리하자면, hydration은 SSR/SSG를 통해 렌더링된 HTML의 모든 component를 다운로드하고 재실행하여 이벤트핸들러를 복구하는 것입니다. 사이트는 클라이언트에 2번 보내집니다. 한번은 HTML, 또다른 한번은 JavaScript를 통해서. 추가적으로, 프레임워크는 WHAT, WHERE를 복구하기 위해 열심히 JavaScript를 실행해야합니다. 이 모든일들이 단지 서버에서 이미 실행 되었지만 버려진 것들을 얻기위한 일인 것입니다.

Hydration이 클라이언트에서 중복작업을 강제하는 이유를 이해하기 위해 몇 가지 간단한 component가 있는 예시를 살펴보겠습니다.

많은 사람들이 이해하기 위해 잘 알려진 syntax를 사용하는 것이고, 어느 특정 프레임워크에 한정된 것은 아닙니다.

export const Main = () => <>
   <Greeter />
   <Counter value={10}/>
</>

export const Greeter = () => {
  return (
    <button onClick={() => alert('Hello World!'))}>
      Greet
    </button>
  )
}

export const Counter = (props: { value: number }) => {
  const store = useStore({ count: props.number || 0 });
  return (
    <button onClick={() => store.count++)}>
      {count}
    </button>
  )
}

위 코드를 SSR/SSG 실행한 결과는 아래와 같습니다.

<button>Greet</button>
<button>10</button>

HTML은 이벤트 핸들러나 component 경계가 어디에 있는지 표시하지 않습니다. 렌더링된 HTML은 WHAT이나 WHERE를 포함하고 있지 않습니다. 서버에서 HTML을 생성할 때 존재했던 정보를 server가 serialize하지 않았습니다. 클라이언트에서 어플리케이션을 interactive하도록 유일하게 할 수 있는 것은 코드를 다운로드하고 실행하여 정보를 복구하는 것 뿐입니다. 상태를 가린 이벤트핸들러 클로저를 복구하기 위한 것입니다.

여기서 요점은 이벤트 핸들러를 연결하고 실행하기 전에 다운로드하고 실행해야 한다는 것입니다. 코드 실행은 component를 초기화하고 상태(WHAT, WHERE)를 재생성 합니다.

hydration이 완료되면 어플리케이션은 동작할 수 있습니다. 버튼을 클릭하면 기대한대로 UI를 업데이트 하게 됩니다.

Resumability: overhead가 없는 hydration 대안

대안 중 하나가 이런게 있구나 가볍게 보면 될 것 같습니다. builder.io에서 만든 Qwik 프레임워크

그러면 어떻게 hydration없는(overhead없는) 시스템을 디자인해야될까요?

overhead를 제거하기 위해 프레임워크는 RECOVERY를 피해야할 뿐만 아니라 4번째 단계 또한 피해야 합니다. 4번째 단계가 WHATWHERE에 연결하는 것이고 피하게 된다면 해당 비용을 줄일 수 있습니다.

이 비용을 줄이기 위해 다음 3가지가 필요합니다:

  1. HTML의 요소에 필요한 모든 정보를 serialize 해야합니다. serialize된 정보는 WHAT, WHERE, APP_STATE, FRAMEWORK_STATE가 포함되어야 합니다.
  2. 모든 이벤트를 intercept하기 위해 이벤트 버블링에 의존하는 전역 이벤트 핸들러가 필요합니다. 이 이벤트 핸들러는 DOM 요소 각각에 등록하지 않게 하기 위해 전역에 있어야 합니다.
  3. 이벤트 핸들러(WHAT)를 lazy 복구할 수 있는 팩토리 함수가 필요합니다.

resumability

팩토리 함수가 key 입니다. hydration은 WHEREWHAT을 연결할 필요가 있기 때문에 열심히 WHAT을 생성합니다. 대신에 팩토리 함수를 사용하면 사용자 이벤트에 대한 응답인 WHAT을 lazy 생성하여 불필요한 작업을 피할 수 있습니다.

위 설정은 서버에서 이미 했던 작업을 다시 수행하지 않고 서버가 중단한 실행을 재개할(resumable) 수 있기 때문에 RECOVERY를 피할 수 있습니다. 더 중요한 것은, 해당 설정은 overhead가 없습니다. 모든 작업이 필요한 작업이고, 서버가 이미 했던 작업을 다시 수행하는 작업이 없기 때문에 overhead가 없다는 것입니다.

push, pull 시스템으로 확인해봄으로써 차이점을 좀 더 생각해보겠습니다.

  • Push(hydration): 사용자 상호작용가 있는 경우에만 다운로드하고 코드를 실행하여 이벤트 핸들러를 등록합니다.
  • Pull(resumablitiy): 아무 작업도 하지 않고 사용자가 이벤트를 트리거할 때까지 기다립니다. 그 후에 이벤트를 처리할 때 핸들러를 lazy 생성합니다.

hydration 경우, 이벤트 핸들러 생성은 이벤트가 트리거 되기전에 일어납니다. 또한 사용자가 이벤트를 트리거한 경우(잠재적으로 불필요한 작업)에 대비하여 가능한 모든 이벤트 핸들러를 생성하고 등록해야 합니다. 따라서 이벤트 핸들러 생성은 speculative(추측적)입니다. 필요없는 작업이 될 수 있습니다. (이벤트 핸들러는 서버에서 작업한 것과 동일하게 재수행하여 생성되기에 overhead입니다.)

resumable 시스템에서는 이벤트 핸들러 생성은 lazy 합니다. 그래서 그 생성은 이벤트가 트리거된 이후에 일어나고 필요에 따라 엄격하게 이루어집니다. 프레임워크는 deserialize하여 이벤트 핸들러를 생성합니다. 그래서 클라이언트는 서버에서 이미 작업했던 것을 재수행하지 않습니다.

이벤트 핸들러의 lazy 생성은 Qwik이 작동하는 방식이며 이를 통해 빠른 어플리케이션 시작을 할 수 있습니다.

Resumability는 WHAT, WHERE를 serialize 해야합니다. resumable 시스템은 WHAT, WHERE를 저장하는 솔루션으로 아래와 같은 HTML을 생성합니다. (정확한 디테일은 중요하지않습니다. 모든 정보가 존재한다는 것이 중요합니다.)

<div q:host>
  <div q:host>
    <button on:click="./chunk-a.js#greet">Greet</button>
  </div>
  <div q:host>
    <button q:obj="1" on:click="./chunk-b.js#count[0]">10</button>
  </div>
</div>
<script>
  /* code that sets up global listeners */
</script>
<script type="text/qwik">
  /* JSON representing APP_STATE, FRAMEWORK_STATE */
</script>

브라우저에 위의 HTML이 로드될 때 전역 리스너를 설정하는 인라인 script가 즉시 실행됩니다. 어플리케이션은 이벤트를 수행할 준비가 되었지만 브라우저는 어플리케이션 코드를 실행하지는 않습니다. 거의 zero-JS에 가깝습니다.

how-resumability-works

HTML은 element에 속성으로 인코딩된 WHERE을 가집니다. 사용자가 이벤트를 트리거하면 프레임워크는 이벤트 핸들러를 lazy 생성하기 위해 DOM 속성 정보를 사용할 수 있습니다. 해당 생성은 WHAT을 완료하기 위해 APP_STATEFRAMEWORK_STATE lazy deserialize을 포함합니다. 프레임워크가 이벤트 핸들러를 lazy 생성하고 나면, 해당 이벤트 핸들러는 이벤트를 수행합니다. 서버에서 이미 했던 작업을 클라이언트에서 재수행하지 않는다는 점을 확인하세요.

메모리 사용

DOM 요소는 life-cycle 동안 이벤트 핸들러를 유지합니다. hydration은 모든 요소에 이벤트 핸들러를 유지합니다. 따라서 hydration을 위해서는 시작할 때 메모리에 할당해야 합니다.

resumable 프레임워크는 이벤트가 트리거될 때까지 이벤트 핸들러를 생성하지 않습니다. 그러므로, resumable 프레임워크는 hydration보다 메모리를 덜 사용합니다. 게다가 resumable 접근 방식은 실행 이후에 이벤트 핸들러를 유지하지 않습니다. 이벤트 핸들러는 실행 이후에 release되고 메모리를 반환합니다.

어떤 면에서 메모리를 release하는 방법은 hydration과 반대입니다. 마치 프레임워크가 특정 WHAT을 lazy hydrate하고 실행한 뒤 de-hydrate하는 것과 같습니다. 핸들러의 첫 번째 실행과 n번째 실행 사이에는 큰 차이가 없습니다. 이벤트 핸들러의 lazy 생성과 release는 hydration 멘탈 모델과 맞지 않습니다.

hydration !== resumability

hydration은 서버와 클라이언트에서 동일한 작업을 하는 부분 때문에 overhead를 가집니다. 서버는 WHEREWHAT을 만듭니다. 그러나 해당 정보가 클라이언트를 위하 serialize되지 않고 버려집니다. 클라이언트는 어플리케이션을 rebuild할 수 있는 충분한 정보를 가지지 못한 HTML을 받습니다. 그 부족한 정보는 어플리케이션 다운로드를 강제하고 WHEREWHAT을 복구하기 위해 어플리케이션 실행을 강제합니다.

resumability는 서버에서 클라이언트로 정보를 전달하는데 초점이 맞춰져 있습니다. 그 정보는 WHEREWHAT을 포함합니다. 그 정보는 어플리케이션 코드를 다운로드하지 않고 어플리케이션에 대해 추론할 수 있습니다. 사용자 상호작용만이 클라이언트에게 그것을 다루기 위한 코드를 다운로드 하도록 합니다. 클라이언트는 서버에서 한 작업을 중복해서 수행하지 않으므로 그것은 overhead가 아닙니다.

React에서 hydration

https://twitter.com/sebmarkbage/status/1516907614566854659 > - Sebastian Markbåge Meta(facebook)에 최근까지 근무했으면 React maintainer로 활동하며 올 초부터 Vercel에 근무중인 react 개발자

React의 Hydration은 처음에는 원래 SSR용으로 구축되지 않았습니다.

IE6에서 DOM API가 너무 느렸고 그에 비해 JS string이 빨랐기 때문에 Hydration을 처음 썼습니다.

클라이언트에서 HTML 문자열을 연결한 다음 "hydrate" 하는 것이 더 빨랐습니다.

이것은 나중에 더 많은 해결방식이 사용 되고, DOM이 더 빨라짐에 따라 변경되었습니다.

처음 SSR은 단순하게 기존 알고리즘을 사용하여 클라이언트에서 이미 사용한 것과 동일한 HTML을 서버에서 생성했습니다.

나중에 서버를 위한 서버 전용 HTML generator가 되었습니다.

일종의 hack 과 같은 것처럼 느껴졌고 그래서 hydration 아키텍쳐에 의문을 가졌습니다.

몇년 지나고... hydration이 얼마나 잘 작동하는지 나중에서야 깨달았습니다. 다양한 컴파일러 출력을 실험하면서 다른 모델을 찾고 있었지만 전반적으로 항상 더 나쁜 결과로 끝나는 것처럼 느껴졌습니다.

음...만약 first paint를 불필요하게 지연시키는 "snapshot"을 제공하는 것 대신에(비록 전반적으로 더 적은 byte로 보낼지라도) 완전히 interactive한 컨텐츠를 렌더링하도록 최적화한다면?

그래서 렌더 함수와 이벤트 핸들러를 별도로 보내지 않는 실험을 했습니다.

코드만 사용하여 어떤 context에서 복구할 수 있는지 알려면 입력의 meta data를 보내야 했습니다.

만약 parent "render"도 필요한 경우 추가적인 비용이 듭니다.

어떤 것과 상호작용 하자마자 새로운 결과를 생성하기 위해 "render" 함수의 일부가 필요합니다.(상호작용 하자마자 화면에 렌더링되는 것을 기대하기 때문에)

그러나 백그라운드(thread)에서 이벤트에 응답할 수 있더라도 사용자에게 의미있는 것을 표시할 수 없습니다. 어플리케이션이 이벤트에 응답을 했지만 아무도 이벤트를 보지 못한다면 실제로 일어난 거라고 할 수 있을까요? 이벤트에 더 빨리 응답할 수 있도록 가능한 많은 렌더링을 preloading 하기를 원할 것입니다.

핵심은 context를 복구하기 위해 상호작용하는 대상의 parent 경로만 필요하다는 것입니다.

이것이 React가 항상 얘기해온 log(n) 수행입니다. parent는 대게 정렬이나 전역 탐색을 제공하기에 그것이 어째뜬 필요합니다.

여기에서 Selective Hydration이 나오게 되었습니다.

"island architecture" 같은 다른 기술들이 있습니다.

selective hydration은 "context" 같은 것을 보장하기 더 쉽습니다.

그리고 같은 프레임워크에 있을 때 여러 island에 걸친 비동기 작업들이 자동으로 동작하도록 보장하기 더 쉽습니다.

마지막 핵심은 렌더의 일부가 매우 드물게 사용되거나 다른 data를 fetch해야할 때만 발생한 경우입니다.

이는 re-render를 위해 코드를 다시 보내는 것을 피할 수 있고 단지 meta data만 보내면 됩니다.

여기서 Server Component(RSC) 가 나오게 되었습니다.

meta data를 사용하여 사용되지 않을 것 같은 "render"를 대체합니다. 그리고 필요하다고 생각한 것들을 preload 합니다. 그 후에 meta data를 보내는 것을 피하기 위해 상호작용이 필요한 것의 하위 집합만 실행합니다.

반면에 특정 웹사이트에서 더 잘 작동할 수 있는 다른 기술(예: jQuery style, Qwik)들이 있습니다. React 18에서 hydration 모델을 2배(selective hydration, RSC)로 늘리는 것은 이전 선택의 hack 한 연장선으로 생각되진 않으면서 tradeoff를 감안할 때 신중하게 고른 선택이었습니다.

또한 더 나은 성능을 보이는 어떤 아이디어의 더 나은 구현이 있을 수 있습니다. 그러나 저는 이 선택 뒤에 있는 이론이 괜찮다는 것을 고수합니다.

React Selective Hydration + Server Components는 당신이 알던 hydration 아키텍쳐가 아닙니다. hydration 특성이 매우 다르다는 것을 깨닫는 데 시간이 걸릴 것입니다.

많은 새로운 React 항목은 먼저 lazy 하게 만드는 것에 관한 것이지만 lazy는 좋지 않다는 것이 알려져있습니다. 또한 여분의 I/O(prefetch)나 CPU cycle(idle time)이 있는 경우 wram up하기를 원할 것입니다. 핵심은 필요로 할 때 우선순위를 증가시키는 것입니다. 그래서 모든 것이 "eager lazy(열심히 게으른)"입니다.

모든 것을 미리 하는 것은 나쁩니다. lazy가 더 좋습니다. 우선순위를 가진 warm up이 가장 좋습니다. (Doing everything upfront is bad. Lazy is better. Prioritized warm up is best.)

읽으면서 해석이 잘 안되고 이해도 안되는 부분이 있는데 이게 맞나 싶긴한데... 의도는 충분히 알겠는데 글로 옮길려니 잘 안되네요...

마무리하며

SSR + Hydration에 대해서 몇 개의 글을 통해서 이해해보려고 노력하고 있습니다.

아직까지 완벽하게 이해하진 못한 것 같지만 조금씩 조금씩은 hydrate 의도나 React의 hydration 기술에 대해서 이해해가고 있는 것 같습니다.

대단한 사람도 많은 것 같고 아직 많이 부족한 것도 느끼게 되는 글입니다.

솔직히 전혀 이해를 못한 부분도 있는데 남겨두고 계속해서 보면서 이해보려고 합니다.

재밌기도 하고 복잡하기도 하고 그렇습니다...

새로운 글을 읽으면서 이전에 비슷한 주제의 글과 비교해보며 이해하는 것도 나름 재밌는 것 같습니다.

다음엔 기존에 남겨두었던 suspense부분을 좀 더 살펴보려고 합니다.

reference

마지막 업데이트

4/22/2022


Avatar

JHSeo

배우는 것을 좋아하고 관심이 많은 웹 엔지니어 입니다. 느리더라도 꾸준하게 성장하려고 노력하는 개발자입니다.