본문 바로가기

JavaScript

101 Javascript Critical Rendering Path (한글) (2)

이 글은 아래 원문을 번역한 글로 의역이 있을 수 있습니다. 정확한 의미를 파악하고 싶으신 분은 원문을 참고해주시기 바랍니다.

예제 코드에서 오타로 보이는 부분을 일부 수정했습니다. 필요한 경우, 원문을 확인해주세요.

 

원문: https://indepth.dev/posts/1498/101-javascript-critical-rendering-path

저작권 정보: https://indepth.dev/


렌더 차단 자원 CSS 줄이는 방법

어떤 웹 페이지이든, 첫 스크롤 지점(fold) 이전에 있는 콘텐츠와 스크롤 지점 이후에 있는 콘텐츠가 있습니다. fold 이전에 있는 콘텐츠는 신중하게 선정해야 합니다. fold 전에 있는 모든 콘텐츠는 스타일을 로드해야만 합니다. 즉, 이것들은 Critical Styles입니다. 나머지 스타일은 나중에 로드할 수 있습니다. 이렇게 하면 웹 페이지의 속도를 높일 수 있습니다. 또한, 불필요한 렌더 차단 스타일을 제거할 수 있습니다.

실제 예제와 함께 렌더 차단 자원을 알아봅시다. 그리고, 작은 변화가 당신의 코드를 얼마나 더 좋게 만들 수 있는지 확인해봅시다.

파서 차단 자원을 줄이는 방법

Lazy Loading

로딩에서의 중요 포인트는 "Lazy Loading"일 것입니다. 아마존과 페이스북 같은 웹사이트들은 아주 많은 콘텐츠를 가지고 있는데, 어떻게 로드하는 걸까요? 당신이 스크롤을 할 때, 콘텐츠가 로드되는데 이것은 그리 느리게 느껴지지 않습니다. 이는 Lazy Loading이라는 개념을 사용하기 때문입니다. 모든 미디어, CSS, 자바스크립트, 이미지, 심지어 HTML까지 게으르게(lazily) 로드됩니다. 한 번에 페이지에 로드되는 콘텐츠의 양은 제한됩니다. 이렇게 하면 주요 렌더링 경로 점수가 향상됩니다.

  1. 당신의 페이지에 오버레이가 있다고 상상해보세요.
  2. 페이지를 로드하는 동안 이 오버레이의 CSS, 자바스크립트, HTML을 로드하지 마세요.
  3. 대신, 버튼에 이벤트 리스너를 추가하고, 사용자가 버튼을 누를 때만 스크립트를 로드하세요.
  4. 이 기능을 사용하려면 웹팩을 사용하세요.

순수 자바스크립트로 Lazy Loading을 구현하는 몇 가지 기술도 있습니다.

 

이미지와 iframe부터 시작하겠습니다. 중요하지 않은 이미지를 게으르게 로드하려면 어떻게 해야 할까요? 또는 사용자의 상호 작용 후에만 표시되어야 하는 이미지는 어떻게 로드할까요? 이런 경우, <img>와 <iframe>의 기본 로드 속성을 사용할 수 있습니다. 브라우저가 이 태그를 보면, 이미지와 iframe의 로드를 연기합니다. 이 동작을 수행하기 위한 구문은 아래와 같습니다.

<img src="image.png" loading="lazy">
<iframe src="tutorial.html" loading="lazy"></iframe>

참고: loading=lazy로 Lazy Loading 한 이미지는 절대로 첫 번째 뷰포트에 사용하면 안 됩니다. 반드시 fold 이후에 있는 이미지에 사용해야 합니다.

 

loading=lazy를 사용할 수 없는 브라우저에서는 IntersectionObserver를 사용할 수 있습니다. Intersection Observer API를 사용할 수 있는 인터페이스입니다. 이 API는 root를 설정하고, 루트에서 모든 요소의 가시성 비율을 구성합니다. 뷰포트에 요소가 보이면, 그것은 로드됩니다. 아래는 이 API를 이해하는 데 도움이 되는 간단한 코드입니다.

  1. 클래스가 '. lazy'인 모든 요소를 관찰합니다.
  2. 클래스가 '. lazy'인 요소들이 뷰포트에 있으면, intersection 비율이 0 아래로 떨어집니다. intersection 비율이 0이거나 0 이하이면, 대상이 화면에 있지 않습니다. 그리고 아무것도 할 필요가 없습니다.
  3. 이제, 이 요소들에 대해 기정의 된 작업들이 수행됩니다.
var intersectionObserver = new IntersectionObserver(function(entries) {
  if (entries[0].intersectionRatio <= 0) return;

  //intersection ratio is above zero
  console.log('Loading Lazy Items');
});
// start observing
intersectionObserver.observe(document.querySelector('.lazy'));

Async, Defer, Preload

참고: Async와 Defer는 외부 스크립트에 사용되는 속성입니다.

 

Async를 사용하면 자바스크립트 자원이 다운로드되는 동안 브라우저가 다른 무언가를 할 수 있습니다. 다운로드된 자바스크립트는 다운로드가 완료되는 즉시 실행됩니다.

  1. 자바스크립트가 비동기식으로 다운로드됩니다.
  2. 다른 모든 스크립트의 실행이 중단됩니다.
  3. DOM 렌더링이 동시에 수행됩니다.
  4. DOM 렌더링은 스크립트가 실행될 때에만 중단됩니다.
  5. 렌더링 차단 자바스크립트 문제는 async 속성을 사용하여 해결할 수 있습니다.

"만약 중요한 리소스가 중요하지 않다면, async도 사용하지 말고 완벽히 제거하라."

<p>...content before scripts...</p>

<script>
  document.addEventListener('DOMContentLoaded', () => alert("DOM ready!"));
</script>

<script async src=""></script>

<!-- will be visible after the above script is completely executed –>
<p>...content after scripts...</p>

Defer를 사용하면 HTML을 렌더링 하는 동안 자바스크립트가 다운로드됩니다. 그러나 다운로드가 완료된 즉시 실행되지는 않습니다. 대신, HTML 파일이 완벽히 렌더링 될 때까지 기다립니다.

  1. Defer는 Async를 한 단계 넘어섭니다.
  2. 스크립트는 렌더링이 완료된 이후에 실행됩니다.
  3. Defer는 자바스크립트 자원을 완벽하게 렌더링을 차단하지 않는 자원(non-render-blocking)으로 만듭니다.

 

<p>...content before script...</p>

<script defer src=""></script>

<!-- this content will be visible immediately -->
<p>...content after script...</p>

Preload는 자바스크립트나 CSS 파일에 사용됩니다. Preload를 사용하면 브라우저가 파일을 다운로드하고, 사용할 수 있는 시점에 바로 실행됩니다.

  • Preload를 현명하게 사용하세요. 브라우저는 페이지에서 필요 없는 파일이어도 다운로드합니다.
  • preload가 너무 많으면 페이지 속도가 느려집니다.
  • preload 된 파일이 너무 많으면 내재된 우선순위가 영향을 받습니다.
  • fold 콘텐츠 위에 필요한 경우에만 preload를 사용합니다. 그러면 구글에서 평가하는 페이지 속도 점수가 높아집니다.
  • 다른 파일이 렌더링 될 때, preload 된 파일들만 검색이 됩니다. 예를 들어, CSS 파일에 글꼴의 링크를 넣었다고 가정해봅시다. 새로운 글꼴의 필요성은 CSS 파일이 구문 분석될 때까지 알 수 없습니다. 이전에 글꼴을 다운로드한 경우, 사이트의 속도가 향상됩니다.
  • Preload는 <link> 태그에서만 사용할 수 있습니다.
Examples of Preload
<link rel="preload" href="style.css" as="style">
<link rel="preload" href="main.js" as="script">

서드 파티 스크립트를 없이 바닐라 자바스크립트 작성하기

바닐라 자바스크립트는 성능과 접근성을 번역합니다. 주어진 상황에서 꼭 서드 파티를 사용할 필요는 없습니다. 라이브러리는 종종 많은 문제를 해결합니다. 간단한 문제를 해결하기 위해 무거운 라이브러리를 사용하는 것은 코드에 성능 저하를 일으킵니다.

웹 AIM 팀이 실시한 설문 조사에서 프레임워크를 사용하는 거의 백만 개의 상위 웹 사이트들이 심각한 접근성 문제를 갖고 있다고 나왔습니다. 당신의 사용자를 고려한다면, 바닐라 자바스크립트를 쓰세요.

 

요점은 프레임워크를 피하고 100% 새로운 코드를 작성하라는 것이 아닙니다. helper 함수와 작은 크기의 플러그인을 사용하라는 것입니다.

콘텐츠 캐싱 및 만료

만약 페이지에서 같은 자산이 반복적으로 사용된다면, 그것들을 매번 로드하는 것은 치명적일 것입니다. 매번 사이트를 로드하는 것과 비슷합니다. 캐싱을 이 순환을 방지합니다. 콘텐츠의 헤더에서 만료에 관한 정보를 제공합니다. 콘텐츠가 만료됐을 때만, 캐시를 지우고 다시 로드합니다.

 

프런트엔드 코드에서 캐싱을 하기 위해, 브라우저는 HTTP 응답(response)의 헤더에서 다음 4가지 중요한 속성을 찾습니다.

  1. ETag
  2. Cache-Control
  3. Last-Modified
  4. Expires

ETag는 엔티티 태그라고도 불립니다. 이것은 캐시 토큰의 유효성을 검증하는 문자열에 불과합니다. 이 토큰은 브라우저가 해당 요청(request)이 캐시의 복사본으로 충족될 수 있는지 없는지를 결정하는 데 사용됩니다. 만약 자원이 변경되지 않았으면, 서버는 같은 해시 토큰과 빈 body를 반환합니다. 이게 응답 코드 304의 경우입니다. 자원이 만료되었으면, body는 최신 데이터로 채워집니다.

 

Cache-Control을 통해 애플리케이션은 지정된 요청에 대해 브라우저의 캐싱 정책을 설정할 수 있습니다. 선택지로는 no-cache, no-store, private 또는 public이 있습니다.

 

Last-Modified는 ETag와 유사하지만, 이는 요청 헤더의 Last-Modified에 따라 결정됩니다. 마지막으로 수정된 날짜와 시간은 새 요청이 필요한지 결정하는 데 도움이 됩니다.

 

Expires는 데이터의 유효성을 검증할 때 사용되는 태그 중 하나입니다. 애플리케이션은 항상 만료되지 않은 데이터를 사용해야 합니다. 레더 필드에 있는 만료 날짜가 되면, 자원이 유효하지 않다고 간주할 수 있습니다.

 

순수 자바스크립트에서는 service workers를 사용해서 데이터가 로드되어야 하는지 여부를 결정할 수 있습니다. 예를 들어, styles.css와 script.js 파일이 있다고 가정합시다. 이 파일들을 로드해야 할 때, service worker를 통해 자원을 새로 로드해야 하는지, 캐시를 사용할 수 있는지 결정할 수 있습니다. 앞으로 며칠 동안, 진보적인 웹 페이지와 Service Workers에 대한 더 넓고 깊은 내용을 담은 게시물을 연재할 것입니다. 지켜봐 주세요.

/* Install gets executed when the user launches the single page application for *the first time */

self.addEventListener('install', function (event) {
  event.waitUntil(
    caches.open(cacheName).then(function (cache) {
      return cache.addAll(['styles.css', 'script.js']);
    })
  );
});

// When a user performs an operation
document.querySelector('.lazy').addEventListener('click', function (event) {
  event.preventDefault();
  caches.open('lazy_posts').then(function (cache) {
    fetch('/get-article')
      .then(function (response) {
        return response;
      })
      .then(function (urls) {
        cache.addAll(urls);
      });
  });
});

// When there is a network response
self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.open('lazy_posts').then(function (cache) {
      return cache.match(event.request).then(function (response) {
        return response;
      });
    })
  );
});

리액트 관점에서 알아보기

휴, 이론이 너무 많았지요. 그래도 여기까지 왔네요. 지금까지 읽으셨다면, 주요 렌더링 경로가 무엇인지, 그리고 왜 코드가 웹 애플리케이션의 성능에 중요한 역할을 하는지 알게 되었을 것입니다. 다음 섹션에서는 성능을 다루고, 주요 렌더링 경로를 최대한 짧게 만드는 방법을 알아보겠습니다. 예제로 선택한 프레임워크는 리액트입니다. 최적화 기술은 두 단계로 나뉩니다. 첫 번째는 응용 프로그램이 로드되기 전 과정이고, 그리고 두 번째는 응용 프로그램이 로드된 후 최적화를 하려는 사용자를 위한 것입니다.

1단계

간단한 애플리케이션을 구축해봅시다.

  1. Header
  2. Sidebar
  3. Footer

이 애플리케이션에서는 사용자가 로그인한 경우에만 사이드바가 보여야 합니다. 웹팩은 코드 분할(code splitting)을 도와주는 좋은 툴입니다. 만약 코드 분할을 활성화하면, App.js나 리액트 컴포넌트에서 바로 React Lazy Loading을 사용할 수 있습니다.

 

그럼, Lazy Loading이란 무엇일까요? 이는 코드를 논리적인 조각으로 나누는 것입니다. 논리적인 조간은 애플리케이션에서 필요한 경우에만 로드됩니다. 결론적으로, 코드의 전체 양은 더 적어집니다.

 

예를 들어, 사용자가 로그인한 경우에만 사이드바 컴포넌트를 로드해야 하는 경우, 애플리케이션에서 성능을 향상할 수 있는 몇 가지 방법이 있습니다. 우선, 라우트에 lazy loading 개념을 주입할 수 있습니다. 아래 보이는 것처럼 코드는 3개의 논리 조각(logical chunks)으로 나누어집니다. 각 조각은 사용자가 특정 경로를 선택할 때에만 로드됩니다. 즉, DOM은 초기 페인트 시에 Sidebar 코드를 "Critical Bytes"로 고려할 필요가 없습니다. 마찬가지로 상위 App.js에서도 lazy loading을 적용할 수 있습니다. 개발자나 상황에 따라 선택할 수 있습니다.

webpack-demo
|- package.json
|- package-lock.json
|- webpack.config.js
|- /dist
|- /src
 |- index.js
 |- Header.js
 |- Sidebar.js
 |- Footer.js
 |- loader.js
 |- route.js
|- /node_modules
import { Switch, browserHistory, BrowserRouter as Router, Route} from 'react-router-dom';
const Header = React.lazy( () => import('./Header'));
const Footer = React.lazy( () => import('./Footer'));
const Sidebar = React.lazy( () => import('./Sidebar'));

const Routes = (props) => {
    return isServerAvailable ? (
        <Router history={browserHistory}>
            <Switch>
                <Route path="/" exact><Redirect to='/header' /></Route>
                <Route path="/sidebar" exact component={props => <Sidebar {...props} />} />
                <Route path="/footer" exact component={props => <Footer {...props} />} />
            </Switch>
        </Router>
    );
}

그럼, 부모 컴포넌트에서 Lazy Loading을 어떻게 적용하는지 알아보겠습니다. App.js에서 조건부로 컴포넌트를 렌더링 할 수 있습니다. 아래 코드에서 props.user가 없으면 Sidebar를 로드하지 않습니다. Sidebar 렌더링은 조건부입니다. props.user 값이 변경되면, 리액트와 웹팩은 변경을 알릴 것입니다. 그러면 요청된 스크립트 조각(chunk)은 로드될 것입니다. 그러나, 초기 렌더링 중에는 props.user가 없으면, 이 코드를 렌더링 할 필요가 없습니다. Sidebar가 애플리케이션에서 무거운 컴포넌트이면, 초기 로딩이 더 원활하고 빨라질 것입니다. 왜냐고요? 사용자가 처음 페이지를 방문할 때는 로드하지 않으니까요. 조건부 렌더링은 애플리케이션 전체에서 사용할 수 있습니다.

const Header = React.lazy( () => import('./Header'));
const Footer = React.lazy( () => import('./Footer'));
const Sidebar = React.lazy( () => import('./Sidebar'));

function App (props) {
    return (
        <React.Fragment>
           <Header user={props.user} />
           {props.user ? <Sidebar user={props.user} /> : null}
           <Footer/>
        </React.Fragment>
    );
}

조건부 렌더링에 대해 말하자면, 리액트는 심지어 버튼 클릭 한 번으로 컴포넌트를 로드할 수도 있습니다. 예를 들어, 사용자가 로그인 클릭했을 때 헤더 컴포넌트에서 사이드바를 로드해야 한다면, 아래처럼 수정할 수 있습니다.

//Sidebar.js
export default () => {
  console.log('You can return the Sidebar component here!');
};
import _ from 'lodash';

function buildSidebar() {
   const element = document.createElement('div');
   const button = document.createElement('button');
   button.innerHTML = 'Login';
   element.innerHTML = _.join(['Loading Sidebar', 'webpack'], ' ');
   element.appendChild(button);
   button.onclick = e => import(/* webpackChunkName: "sidebar" */ './Sidebar').then(module => {
     const sidebar = module.default;
     sidebar();
   });

   return element;
 }

document.body.appendChild(buildSidebar());

실제로 적용할 때는, lazy loading 하는 모든 라우트와 컴포넌트를 Suspense라고 불리는 컴포넌트 안에 넣는 것이 중요합니다. Suspense의 역할은 lazy load 되는 컴포넌트가 로드될 때, 애플리케이션의 fallback 콘텐츠를 제공하는 것입니다. fallback 콘텐츠는 로더나 메시지 등 사용자에게 페이지가 왜 아직 그려지지 않는지를 알려주는 것이면 뭐든 될 수 있습니다. 라우트 컴포넌트를 Suspense와 함께 사용해봅시다.

import React, { Suspense } from 'react';
import { Switch, browserHistory, BrowserRouter as Router, Route} from 'react-router-dom';
import Loader from ‘./loader.js’

const Header = React.lazy( () => import('./Header'));
const Footer = React.lazy( () => import('./Footer'));
const Sidebar = React.lazy( () => import('./Sidebar'));

const Routes = (props) => {
    return isServerAvailable ? (
        <Router history={browserHistory}>
            <Suspense fallback={<Loader trigger={true} />}>
                <Switch>
                     <Route path="/" exact><Redirect to='/header' /></Route>
                     <Route path="/sidebar" exact component={props => <Sidebar {...props} />} />
                     <Route path="/footer" exact component={props => <Footer {...props} />} />
                 </Switch>
             </Suspense>
        </Router>
    );
}

2단계

이제 애플리케이션이 안전하게 로드되었으므로, React가 어떻게 동작하는지를 알고 더 최적화할 필요가 있습니다. React는 호스트 트리(Host Tree)와 호스트 인스턴스(Host Instances)가 있는 흥미로운 프레임워크입니다. 호스트 트리는 DOM에 불과합니다. 그리고 호스트 인스턴스는 노드를 나타냅니다. React는 호스트 환경과 애플리케이션 간의 간극을 연결하기 위해 React DOM을 함께 제공합니다. React DOM에서 가장 작은 항목은 자바스크립트 객체입니다. 이 객체들은 새로운 객체가 생성될 때마다 넘겨집니다. 왜일까요? 객체는 불변성이 매우 강합니다. 변경이 일어날 때마다, React는 호스트 트리를 React Dom 트리와 완벽히 일치하도록 업데이트합니다. 이 과정은 재조정(Reconciliation)으로 알려져 있습니다.

 

올바른 상태 관리 방법을 사용하세요.

  • React Dom 트리가 수정될 때마다 브라우저는 리플로우를 강제합니다. 이는 애플리케이션의 성능에 심각한 영향을 미칠 것입니다. 재조정(Reconciliation)은 다시 렌더 하는 횟수를 줄이기 위해 사용됩니다. 마찬가지로, React는 상태 관리를 사용하여 재 렌더(re-render)를 방지합니다. 예를 들면, useState() 훅이 있습니다.
  • 클래스 컴포넌트를 빌드하는 경우, shouldComponentUpdate() 라이프 사이클 메서드를 사용하세요. 항상 PureComponent를 확장하는 클래스를 만드세요. shouldComponentUpdate() 훅은 PureComponent에 구현되어 있습니다. 이 훅이 불릴 때, states와 props 사이에 얕은 비교가 일어납니다. 그러므로 다시 렌더링 될 확률이 급격히 감소합니다.

React.Memo를 사용하세요.

  • React.Memo는 컴포넌트를 받아서 props를 메모라이즈 합니다. 컴포넌트가 다시 그려져야 할 때, 얕은 비교가 수행됩니다. 이 메서드는 성능상의 이유로 널리 사용됩니다.
function MyComponent(props) {
}
function areEqual(prevProps, nextProps) {
  /*
  return true if passing nextProps to render would return
  the same result as passing prevProps to render,
  otherwise return false
  */
}
export default React.memo(MyComponent, areEqual);
  • 함수형 컴포넌트의 경우에는 useCallback()useMemo()를 사용합니다.

결론

이제 주요 렌더링 경로에 대해 알았으니, 작성했던 코드를 분석해보세요. 프로젝트에 포함된 모든 라인, 모든 자원, 모든 파일이 주요 렌더링 경로에 추가됩니다. 또한, 웹 페이지의 fold 콘텐츠에 대해 생각해보세요. 이전에 웹 사이트 성능을 향상하기 위한 팁과 요령을 활용하지 않았다면, 지금이 시작하기 가장 좋은 때일 수 있습니다. 성능은 어떤 웹 애플리케이션이든 중요합니다. 복잡성과 크기가 커지면서 매 밀리 초마다 차이가 발생합니다. 그럼에도 불구하고, 너무 이른 최적화는 좋지 않을 수 있다는 것을 기억하세요. 항상 측정한 다음 최적화 작업을 시도하세요.

 

즐거운 코딩 하세요!