ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Next.js Lazy Hydration으로 웹 성능 향상시키기(HTML은 유지하고 Script는 걸러내자)
    개발 블로깅/Improving Performance 2021. 12. 2. 12:24

     

    Next.js의 Hydrate개념을 아직 모르신다면,이전에 작성했던 Next.js의 Hydrate 글을 먼저 참고하시는 것을 추천드립니다.

    https://scriptedalchemy.medium.com/next-js-and-lazy-hydration-keep-the-html-but-drop-the-javascript-846feb2da1f

     

    Next.js에서는 첫 페이지를 로드하면 Hydrate과정을 거치게 된다.

    💡 Hydrate란?
    화면에 보여줄 document 페이지를 서버 단에서 먼저 렌더링 후 브라우저로 전송한 뒤, 이후에 해당 DOM 요소에 필요로 한 Script 코드들을 바로 브라우저로 전송한다. 그리고 각 DOM 요소와 Script 코드가 매칭이 되면서 정상적으로 웹 페이지가 동작하게 된다.

     

    그러나 이러한 Hydrate 방식은 아직 약간의 아쉬운 점이 존재하는데, 그것은 'PreRendering 된 DOM 요소의 모든 Script는 무조건 모두 가져온다는 '이다.

     

    이 말이 무슨 뜻인지 아래에서 정확히 알아보도록 하자.

    qanda.ai

     

    여기 Next.js 환경에서 동작하는 웹 페이지가 하나 있다.
    첫 페이지 요청 시 예상대로 document를 먼저 가져오고, 이후에 관련된 Script Chunk 파일들을 가져온다.

    Event Handler와 같은 유저 인터렉션을 위해 Chunk File들이 필요한 것은 알겠는데, 문제는 이것이다.

     

    '현재 화면에 보이지 않는 Footer 영역에 대한 Chunk File도 바로 전송받을 필요가 있냐'이다.

     

    Document에 포함된 모든 DOM 요소의 Script가 전부 요청돼서 가져오다 보니, 현재 뷰 포트에서 벗어나 있는 컨텐츠 영역에 대한 Chunk File들도 Server에게 요청하고 불필요한 Hydration 작업을 하게 된다.

     

    우리는 이러한 문제를 어떻게 해결해 볼 수 있을까? 그냥 Dynamic Import로 Lazy 하게 불러오면 해결이 될까?

     

    Dynamic Import의 한계

    우선 Dynamic Import에 대해 잠깐 알아보자.
    Dynamic Import 역시, 첫 렌더링 시에 바로 보여줄 필요가 없는 요소들을 걷어내어 성능을 향상시키는 방식이다.

    const PopupComponent = dynamic(() => '@/components/organisms/Popup');
    
    const Home = () => {
      const [openPopup, setOpenPopup] = useState(false);
      ...
      return (
        <>
          { openPopup && <PopupComponent /> }
        </>
      )
    }
    
    export default Home;

     

    초기 Preredering 시 Popup에 대한 컴포넌트는 완전히 제외된 상태로 렌더링을 한 뒤, Client Side 단에서 실제 popup을 열게 되면, 서버 단에 해당 Popup 관련 Chunk File을 요청하여 별도 렌더링을 진행하게 된다.

    그러나 이 Dynamic Import의 한계는 무엇일까.

    여러 가지가 있겠지만, 여기서 다루는 Hydration 관점에서는 딱 한 가지로 정리할 수 있겠다.

     

    'Dynamic Import는 PreRendering 된 Document에서도 보이지 않게 된다.'

     

    Dynamic Import로 해당 컴포넌트의 Chunk File을 걷어내 웹 성능은 향상시킬 수 있겠지만, 그 이전의 서버단의 PreRendering 과정에서도 해당 컴포넌트가 초기 로딩 때 바로 보여질 상태가 아니라면 렌더링 요소 대상에서 제외된다.

     

    '어차피 해당 영역이 뷰 포트로 접근했을 때 유저에게 정상적으로 보여주면 아무 문제가 없지 않겠느냐'라는 생각이 들었을 수도 있다.

    일반적인 유저 방문에 대한 관점으로는 아무 문제가 없겠지만, SEO관점으로 보면 Google Bot 등의 크롤러가 웹 페이지에 방문했을  때 접속한 document 페이지에 크롤링할만한 정보가 없게 될 것이다. Dynamic Import로 모두 제외해 버렸기 때문에.

     

    추가로, 그렇게까지 SEO를 신경 써야 하는지 의문이 든다면, 이전에 작성했던 블로그 글의 일부인 '굳이 이렇게까지 성능을 개선시킬 필요가 있을까요? 실제로 유저가 느끼기에는 큰 차이 없을 건데..' 파트를 읽어보면 좋을 것 같다.

     

     

    따라서 위에서 다뤘던 Hydration 문제에 대해 Dynamic Import만으로는 해결하기에는 한계가 있다는 것을 알 수 있다.
    우리는 Prerendering은 그대로 보존하고, 해당 컴포넌트 Chunk File이 필요한 순간에 Lazy 하게 요청하여 가져올 수 있는 방안이 필요하다.

     

     

    Next.js Lazy Hydration

    최근에 사내 채널에서 'Next.js Hydration 흑마술 🧙' 이라며 올려주신 링크가 있었는데, 바로 Document는 유지하고 Script 코드는 Lazy 하게 가져와서 Hydration 문제를 해결할 수 있는 방안이었다.

    어떻게 HTML을 유지하고 Script 코드만 Lazy하게 불러올 수 있을까? 아래 세 가지 핵심 내용만 알면 된다.

    1. Webpack Plugin 설정
    2. Next.js 내부 설정 재정의
    3. react-lazy-hydration 모듈

     

    아래에서 하나씩 알아보자.

     

    Webpack Plugin 설정

    HTML Document는 브라우저에 응답받자마자 자기한테 필요한 Script 코드를 서버로 요청하게 되는데, 우리는 이러한 Script 요청을 제어할 수 있는 작은 Webpack Plugin을 구현해야 한다.

    빌드 시 스크립트 파일 단위로 해당 webpack이 컴파일을 시작할 때, lazy 하게 가져오려는 Chunk File에 별도의 표시를 해놔야 한다.

     

    // next.config.js
    
    // webpkac Compiler 생성
    const mapModuleIds = fn => (compiler) => {
      const { context } = compiler.options;
    
      compiler.hooks.compilation.tap('ChangeModuleIdsPlugin', (compilation) => {
        compilation.hooks.beforeModuleIds.tap('ChangeModuleIdsPlugin', (modules) => {
          const { chunkGraph } = compilation;
          
          for (const module of modules) {
            if (module.libIdent) {
              const origId = module.libIdent({ context });
              
              if (!origId) continue;
              
              const namedModuleId = fn(origId, module.debugId);
              
              if (namedModuleId) {
                  chunkGraph.setModuleId(module, namedModuleId);
              }
            }
          }
        });
      });
    };
    
    
    const moduleExports = {  
      ...
      webpack: (config, options) => {
      
        const lazyTargets = [
          '/components/organisms/Footer/index.tsx',
          '/components/organisms/BelowContents/index.tsx',
           ...
        ];
      
        config.plugins.push(
          mapModuleIds((id, debugId) => {
            const isTarget = lazyTargets.some((target) => target.includes(id);
            
            if (isTarget) {
              return `lazy-${debugId}`;
            }
            
            return false;
          }),
        );
    
        return config;
      },
    });
    
    module.exports = moduleExports;

     

    Webpack 컴파일 중, 모든 Script File을 전체적으로 돌면서 lazy hydration 할 파일에만 Chunk modeule ID 앞에 임의로 'lazy-'를 붙이도록 한다. 이는 향후에 lazy 하게 받아올 Script를 식별하는 용도로 사용하게 될 것이다.
    ('lazy-'라는 용어는 여기서 임의로 쓴 것이라 다른 것으로 변경해도 된다.)

     

    Next.js 설정 재정의

    여기서는 Next.js의 두 가지 설정이 필요하다.

    첫 번째는, Prerendering 후 document와 관련된 Script코드를 불러오는 중, 컴파일 시 'lazy-'로 표시한 Dynamic Import Chunk File들은 제외하고 응답할 수 있도록 Next.js 내부 설정을 해주어야 한다. 해당 설정을 하기 위해서 _document.tsx에서 <script> 태그를 제어할 수 있도록 next/head를 확장한다.

    두 번째는 Next.Script를 확장해서, 기존의 모든 Chunk Script를 Next Script 자체에서 관리했는데, Lazy Hudration 할 Chunk Script는 별도로 Webpack에서 관리될 것이기 때문에, Next Script Data에서 'lazy-'로 표시한 Dynamic Import Chunk File들을 제외시키도록 한다.

     

    //_document.tsx
    
    class LazyHead extends Head {
      getDynamicChunks(files) {
        const dynamicScripts = super.getDynamicChunks(files);
        try {
          // get chunk manifest from loadable
          const loadableManifest = __non_webpack_require__(
            '../../react-loadable-manifest.json',
          );
          // search and filter modules based on marker ID
          const chunksToExclude = Object.values(loadableManifest).filter(
            manifestModule => manifestModule?.id?.startsWith?.('lazy') || false,
          );
          const excludeMap = chunksToExclude?.reduce?.((acc, chunks) => {
            if (chunks.files) {
              acc.push(...chunks.files);
            }
            return acc;
          }, []);
          const filteredChunks = dynamicScripts?.filter?.(
            script => !excludeMap?.includes(script?.key),
          );
    
          return filteredChunks;
        } catch (e) {
          // if it fails, return the dynamic scripts that were originally sent in
          return dynamicScripts;
        }
      }
    }
    
    const backupScript = NextScript.getInlineScriptSource;
    
    NextScript.getInlineScriptSource = (props) => {
      // dont let next load all dynamic IDS, let webpack manage it
      if (props?.__NEXT_DATA__?.dynamicIds) {
        const filteredDynamicModuleIds = props?.__NEXT_DATA__?.dynamicIds?.filter?.(
          moduleID => !moduleID?.startsWith?.('lazy'),
        );
        if (filteredDynamicModuleIds) {
          // mutate dynamicIds from next data
          props.__NEXT_DATA__.dynamicIds = filteredDynamicModuleIds;
        }
      }
      return backupScript(props);
    };
    
    export default class MyDocument extends Document {
      render ():JSX.Element {
        return (
          <Html>
            <LazyHead />
            <body>
              <Main />
              <NextScript />
            </body>
          </Html>
        )
      }
    }

     

    이쪽 설정을 하면서 내 개인적으로 알아본 내용들을 정리해본다.

    react-loadable-manifest.json은 무엇인가요?
    lazy import를 이용해 별도로 Chunk File로 분리되는 파일들을 정보를 담고 있는 파일이다. 프로젝트 빌드 시에 .next 디렉터리 하단에 생성된다.

    __non_webpack_require__is not defined 에러가 발생합니다.
    해당 webpack관련 types 모듈을 설치하지 않아서다. 아래 모듈을 설치해주면 해결된다.

    # yarn add -D @types/webpack-env

     

    (오잉 뭔가 알아본 게 많았던 것 같은데, 막상 정리해보니 두 개밖에 없네.. 😅)

     

     

    react-lazy-hydration 모듈

    마지막으로 HTML Document가 보존된 상태에서 Hydration이 일어날 때 깜박임이 발생하지 않도록 하게 위해 react-lazy-hydration이란 모듈을 활용한다.

    import LazyHydrate from "react-lazy-hydration";
    
    const Footer = dynamic(() => import('@/components/organisms/Footer'));
    
    const Home = () => (
        <>
          ... 
          <LazyHydrate whenVisible>
            <Footer />
          </LazyHydrate>
        </>
    );
    
    export default Home;

     

    내부 interactive Observer와 whenVisible 속성을 통해 해당 컴포넌트가 뷰 포트에 들어와 유저 인터렉션이 필요해지는 순간에Hydration을 수행한다.
     
    따라서 HTML은 그대로 남아있고, Hydration이 필요해지는 순간에 웹팩이 동작해서 동적으로 해당 Chunk Script를 요청 후 hydration하여 정상적으로 해당 컴포넌트가 동작하도록 한다.

     

    적용 결과

     

     

    (GIF 파일이 흐려서, 별도 스크린샷도 함께 올렸다.)
    보다시피 lazy Hydration을 적용했던 Footer 영역이 뷰포트에 들어가는 순간, 해당 Chunk File을 요청해서 받아오는 것을 확인할 수 있다.

     

    "Document를 먼저 응답받고 해당 Script 코드들을  이후에 요청하기 때문에, 처음 웹 페이지를 빠르게 보여주기 위해서라면 이미 Document를 전송 받았으니 Hydration 최적화는 의미가 없지 않나요?"

     

    응답은 말 그대로 해당 document라는 리소스를 요청해서 응답은 받은 것이고, 실제 브라우저 화면에 paint 되는 것은 다른 Thread Task 이다. 아래 예시를 보자.

    qanda.ai Chrome Devtools Performance Tab 측정 결과

    위 이미지는 Next.js 프로젝트의 처음 페이지 접근 시 측정한 Performance 탭 수치이다.
    왼쪽 상단에 표시한 파란색 막대가 document 페이지를 응답받은 시간이고, 중간 하단에 표시한부분이 Paint가 일어난 시간이다.

    이미 document 응답은 예전에 받고나서, 이후 스크립트를 전송 받는 중에 document가 화면에 나타나기 시작했다.
    따라서 Hydration에 필요한 Script를 줄이는 만큼, Thread가 document Paint를 처리하는데 더 집중할 수 있게된다.


    이처럼 브라우저가 절차적으로 Task를 처리하는 것이 아니다 보니, 빠른 웹 화면 표시를 위해서는 document 응답 후 처리할 요소들도 모두 신경 써주어야 한다.

    lazy Hydration으로 Next.js 웹 페이지를 최적화 시켜보자!

     

    Reference

     

    Next.js and Lazy Hydration. Keep the HTML but drop the Javascript

    Code splitting can only get us so far, and how it works means you end up loading a lot of JS you don't really need right then and there…

    scriptedalchemy.medium.com

     

     

    반응형

    댓글

Designed by Tistory.