티스토리 뷰

서론

이 글은 SSR 프레임워크인 Next.js를 커스텀해서 성능을 향상시킨 라이브러리를 구현한 내용을 담은 글을 요약, 번역한것입니다. 이해를 돕기 위해 많은 의역이 들어갈 수 있으니 참고해주세요.

 

 

웹에서의 성능

고성능을 유지하려면 클라이언트에게 적은 양의 코드를 보내야 한다. 우리는 우리가 hydrate해야 하는(static 하지 않고 유저와 상호작용 해야 하는 리액트 컴포넌트) 컴포넌트만 클라이언트에게 보내는 방식으로 성능을 끌어올렸다. 나머지 부분은 서버에서 렌더링한 그대로 놔둔다.

 

The Cost Of JavaScript In 2018

 

위 내용에서 가장 중요한것은 다음과 같다.

 

자바스크립트의 비용은 로드할때 뿐만아니라, 파싱하고 실행하는것까지 포함된다.

 

우리가 최적화 하려고 하는것은 클라이언트에게 보내는 코드 자체가 아니라, 코드의 양이다. 이걸 하기 위해선 SSR이 필수이다. 서버에서 할 수 있는 일들은 서버에서 해야한다. 클라이언트는 클라이언트에서 필요한것들만 서버에서 받아와야 한다.

 

SSR과 hydration

고성능의 웹사이트를 만들기 위해서 우리는 Next.js의 수정버전을 사용할것이다. next는 기본적으로 SSR과 함께 성능 최적화 기능이 들어있다. next는 react를 다음과 같은 순서로 사용한다.

 

1. 서버에서 리액트 컴포넌트를 HTML string 형태로 렌더링한다.

2. 렌더링된 HTML을 클라이언트에게 보낸다.

3. javascript로 된 리액트 코드를 클라이언트에게 보낸다.

4. html을 리액트로 hydrate한다.

 

next가 HTML 위에 돌돌 말려진 리액트 코드를 푸는것을 hydrate 라고 이해하면 된다. 그 후에 Next는 React에게 다음과 같이 말한다.

Next

리액트야, 여기 HTML이 있는데 만약에 내가 비어있는 노드에 렌더링 하라고 시켜도 절대 렌더링 하지마. 그냥 내가 만들어준 HTML을 그냥 사용하면돼. 마치 너가 렌더링한것처럼.

 

React

그래 나는 그냥 너가 전달해준 HTML을 보기만 할게. 보니까 내가 렌더링 하려던거랑 똑같네. 나는 그럼 너가 전달해준 HTML에 이벤트 핸들러만 붙일게.(hydrate) 그러면 너가 만든 페이지는 내가 혼자 만든 SPA랑 똑같이 동작할거야.

 

 

이런 방식으로 웹페이지를 로드하게되면, 클라이언트는 웹사이트에 접속했을때 완벽하게 렌더링 된 페이지를 볼 수 있다. 그리고 react가 컴포넌트를 hydrate하면 드디어 유저와 상호작용 할 수 있게 된다. 브라우저는 리페인트를 다시 할 필요가 없기 때문에 성능 향상이 이뤄질 수 있다. (리액트로 리렌더링하지 않아도 된다는 뜻.)

 

 

오버헤드가 너무 커.

이렇게 넥스트가 페이지를 최적화 하는 방법은 페이지 전체가 자바스크립트에 의해서 컨트롤되는 웹 앱의 경우에는 굉장히 효율적으로 동작한다. 

 

하지만 모든 웹사이트가 그런것은 아니다. 대부분의 웹사이트는 페이지 대부분이 static한 영역이고 일부만 유저와 상호작용한다. 넥스트가 HTML을 만들고 나서 모든 리액트의 코드를 클라이언트로 보내게 되는데 문제는 이 자바스크립트 번들의 크기가 너무 크다는것이다. 자바스크립트 파일의 크기가 커질수록 로드, 파싱, 실행이 더 오래걸리게 된다. 

 

부분적으로 hydrate하기

위에서 말한 문제점을 해결하기 위해 우리는 partial hydration이라는 기법을 사용했다. 그전에 progressive hydration이나 lazy hydration이라는 용어를 들어봤을것이다. partial hydration은 이것들과 완벽히 똑같진 않지만 겹치는 부분이 있다.

 

우리가 구현한 라이브러리의 기본적인 아이디어는, 정적인 사이트는 그대로 next가 SSR하게 놔두고 hydrate가 필요한 컴포넌트만 클라이언트로 보내자는것이다. 이런 방식으로 웹사이트를 만들게 되면 마치 여러개의 작은 리액트 앱을 여러개의 DOM에 렌더링 한 것과 비슷해진다.

 

이렇게 하면 엄청난 성능 향상을 이끌어낼 수 있다. 왜냐면, 사용자가 처음 사이트에 접속할때 서버로 부터 받은것은 html, css, 적은양의 javascript이기 때문이다. 웹사이트의 성능을 측정할때 중요한것은 리소스를 로드하는 시간 뿐 아니라 파싱하고 실행하는 시간 까지 포함한다는것이다. 즉 자바스크립트 번들 크기가 줄어듦으로써 성능 향상을 이끌어낸것이다.

 

이렇게 클라이언트로 내려주는 자바스크립트 번들의 크기를 줄여서 1차 최적화를 해주고, 그 위에 코드 스플리팅과 같은 최적화 기법을 추가로 얹을 수 있다.

 

우리는 이것을 어떻게 구현했는가?

우리는 2개의 패키지로 라이브러리를 구성 했다.

 

1. 부분적으로 hydration하기 위해서 preact의 라이브러리인 pool-attendant-preact 사용

2. next.js 플러그인인 next-super-performance 사용

 

두번째 플러그인은 그냥 preact의 pool-attendant-preact를 사용하는 넥스트 플러그인일뿐이다. 핵심은 pool-attendant-preact 이므로 이부분을 집중적으로 살펴보자.

 

pool-attendant-preact

헤더, 바디, 사이드바와 2개의 반응형 엘리먼트로 구성된 웹페이지

이 사진을 보자. 회색 부분은 static한 영역들이고 보라색부분이 유저와 상호작용해야할 부분이다. 예를들어 왼쪽 회색 바디 부분에는 트위터의 피드가 들어가고(유저와 상호작용X) 오른쪽 조그만 보라색 영역엔 투표 기능이 들어간다고 상상할 수 있다.(유저와 상호작용O)

 

저 보라색 영역들을 유저와 상호작용 할 수 있게 만드려면 자바스크립트를 사용해야한다.

pool-attendant-preact를 사용하여 이 부분을 구현했다.

import { withHydration, HydrationData } from "pool-attendant-preact";

/* 
	static하게 렌더링 될 컴포넌트들이 import되었다.
	이 컴포넌트들은 서버에서 렌더링 되어 
    자바스크립트 없이 html과 css형태로 클라이언트에 응답될것이다.
*/
import Header from "@components/header";
import Content from "@components/content";
import TwitterFeed from "@components/twitterFeed";
import Poll from "@components/poll";
import SmallBox from "@components/smallBox";
import LargeBox from "@components/smallBox";

// 아래 두 컴포넌트는 hydration 할것이라고 표시해둔다.
const HydratedTwitterFeed = withHydration(TwitterFeed);
const HydratedPoll = withHydration(Poll);

export default function Home() {
  return (
    <Layout>
      <Header />
      <Content />
      <HydratedTwitterFeed />
      <HydratedPoll />
      <SmallBox />
      <LargeBox />
      /* 
      	가장 중요한 부분이다.
        이 컴포넌트는 hydration 될 데이터(props와 component name)를 페이지에 inject한다.
      */
      <HydrationData /> 
    </Layout>
  );
}

 

만약에 순수 리액트로 hydration을 구현하면 다음과 같다.

ReactDOM.hydrate(
  <App />,
  document.getElementById('app')
)
<!doctype html>
  <html>
  <body>
    <div id="app">
      <!-- SSR generated code here -->
    </div>
    <script src="/static/client.js"></script>
  </body>
  </html>

Next가 #app에 SSR 해주고나서 ReactDOM으로 hydrate해준다. 이렇게하면 부분적으로 hydration할때 다음 문제가 생긴다.

 

1. ReactDOM의 hydrate 메소드는 루트 노드에만 동작하게 설계 되어 있다. 다시 말해서, 저 #app 아래에 어떤 특정 노드에 hydrate 하고 싶은데 그러기 위해서 하위 엘리먼트들을 굳이 정의해줘야 한다는것이다. 우리는 페이지 내 여러개의 노드에 렌더링을 해야 한다.

2. hydrate를 하기 위해서 DOM을 항상 찾아와야 하는 번거로움이 있다. (getElementById)

3. 만약에 hydrate된 리액트 컴포넌트에 props를 넘겨줘야 할떈 어떻게 해야 할까? 아래 처럼 하면 될까?

ReactDOM.hydrate(<TwitterFeed user="simsimjae" />);

 

그럼 우리는 simsimjae는 어디서 얻을 수 있을까? 우리는 어떤 컴포넌트에 어떤 props를 전달해줄지 서버에서 저장하고 클라이언트에게 내려줘야한다.

해결책

import { h } from "preact";
import HydrationData from "./hydrationData";

export default (Component) => (props) => {
  const hid = HydrationData.storeProps(Component, props);
  return (
    <>
      <script type="application/hydration-marker" data-hid={hid} />
      <Component {...props} />
    </>
  );
};

우리는 이 문제를 HOC(High Order Components)로 해결했다. 이 hoc를 자세히 보면 스크립트 태그를 컴포넌트 위에 붙이는걸 볼 수 있다. 이 스크립트 태그의 역할은 이 컴포넌트가 hydrate될 필요가 있다고 페이지에 표시하는것이다. 이렇게 하면 페이지 내에 어떤 컴포넌트를 hydrate할지 찾는 문제를 해결 할 수 있다. (굳이 getElementById로 DOM을 찾지 않아도)

 

이 코드를 next가 SSR 하게 되면 다음 HTML이 만들어진다.

<div>서버에 의해 정적으로 렌더링된 컴포넌트들..</div>

<script type="application/hydration-marker" data-hid={hid} />
<div class="twitter-feed">
  <!-- SSR된 코드들이 들어있습니다. -->
</div>

<div>서버에 의해 정적으로 렌더링된 컴포넌트들..</div>

우리가 HOC를 통해 넣어줬던 <script/>태그가 클라이언트에게 내려주는 HTML에 포함되게 되었다.

이제 클라이언트는 이 페이지내의 <script/>태그를 찾아서 그 바로 아래에 붙어있는 컴포넌트를 hydrate해주면 된다. 이 스크립트 파일도 물론 서버에서 내려준다. (자바스크립트 번들 사이즈가 크게 줄어들었다는것을 의미한다.)

 

이쯤에서 withHydration.js의 8번줄을 다시 살펴보자.

export default (Component) => (props) => {
  const hid = HydrationData.storeProps(Component, props);
  return (
    <>
      <script type="application/hydration-marker" data-hid={hid} />
      <Component {...props} />
    </>
  );
};

이 코드에서 우리는 컴포넌트에 전달된 props를 저장한다. 

 

만약에 <HydratedTwitterFeed user="simsimjae" /> 와 같이 HOC로 래핑된 컴포넌트에 user="simsimjae"라고 하는 props를 내려준다고 한다면, 

{ "name": "TwitterFeed", "props": { "user": "luke_schmuke" } }

이러한 JSON 데이터를 얻게 된다. 이것은 pool-attendant-preactHydrationData 컴포넌트storeProps 메소드이다. 여기서 저장된 JSON 데이터는 HydrationData 컴포넌트 안에 축적된다. 그리고 이 HydrationData 컴포넌트는 아까 위에서도 봤듯이, HTML 하단에 렌더링된다. 다시 말해서 withHydration으로 감싸진 모든 컴포넌트들로 전달된 props들이 Next가 SSR한 HTML 마크업 하단에 JSON 형태로 저장된다는 뜻이다.

 

그리고 이러한 JSON 데이터들은 hid로 구분된다. 이 프로세스는 Next가 NEXT_DATA를 렌더링 하는 과정을 흉내내서 만들었다.

 

실제로 클라이언트에서 부분적 hydration은 이렇게 작성된다.

import { render, createElement } from "preact";
import Teaser from "../../app/components/teaser";

const markers = [
  ...document.querySelectorAll('script[type="application/hydration-marker"]')
];

const data = JSON.parse(
  document.querySelector('script[type="application/hydration-data"]').innerHTML
);

for (const marker of markers) {
  const el = marker.nextElementSibling;
  const id = marker.getAttribute("data-hid");
  const props = data[id].props;
  render(createElement(Teaser, props), el.parentElement, el);
}

아까 우리가 hoc 안에 넣어놨던 <script> 태그는 마커로 동작한다고 했다. <script> 바로 뒤에 나오는 컴포넌트를 hydrate하라는 표시이다. 그것들이 markers에 들어간다.

 

그리고, 컴포넌트 이름과 props 데이터가 들어있는 hydrationData는 마크업 하단에 존재한다. 그것을 찾아서 JSON형태로 만든것이 data이다.

 

markers를 순회하면서 hydration할 DOM을 찾고(el) marker에 저장해둔 hid를 가져온뒤, 그 hid를 활용해서 hydrationData에 저장되어있는 props 데이터를 가져온다.

 

이제 hydration할 DOM 노드와 props를 모두 가져올 수 있게 되었으므로 preact의 render메소드를 통해 hydrate해주기만 하면 끝난다.

 

 

 

 

 

출처

 

 

https://medium.com/@luke_schmuke/how-we-achieved-the-best-web-performance-with-partial-hydration-20fab9c808d5

불러오는 중입니다...

 

댓글
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함