! This post is also available in the following language. 일어

requestIdleCallback으로 초기 렌더링 시간 14% 단축하기

안녕하세요. LINE 증권의 프런트 엔드를 담당하고 있는 파이낸셜 개발 센터의 Suzuki입니다. 최근 웹 바이탈이 대두되면서 LINE 증권 프런트 엔드에서도 성능 개선에 힘쓰기 시작했습니다. 약 2주 정도 개선 작업을 진행한 뒤 개발 환경에서 계측한 결과, Lighthouse 성능 점수가 기존보다 30점가량 상승했습니다. 성능을 개선하기 위해 다양한 방안을 실시했는데요. 이번 글에서는 그중에서도 흥미로웠던 사례로 requestIdleCallback을 활용해서 레이지 로딩(lazy loading) 가능한 컴포넌트의 로딩을 지연시켜 초기 렌더링 시간을 약 14% 단축할 수 있었던 이야기를 공유하겠습니다.

 

작업 환경

LINE 증권의 프런트 엔드는 TypeScript와 React로 구성했고 JavaScript 번들은 webpack으로 생성합니다. 이번 글의 내용은 React와 webpack과 관련이 깊습니다. 글의 내용은 React 16과 webpack 4 기반으로 작성했지만 현재(2021년 2월) 기준으로 최신 버전인 React 17과 webpack 5에서도 적용되는 것을 확인했습니다. 또한 이번 성능 개선은 프런트 엔드 성능의 전형적인 지표인 Core Web Vitals 개선을 목표로 삼았고 결과 측정 수단으로는 Google Chrome 확장 프로그램인 Lighthouse를 사용했습니다. 

 

성능 개선 실시

먼저 기본적인 성능 개선으로 CLS(Cumulative Layout Shift)를 없애고 콘텐츠가 표시될 때까지 API 서버를 3번 왕복하던 부분을 수정하는 등의 로직을 개선했고, 이후 초기 렌더링과의 싸움이 남았습니다. LINE 증권에서는 서버 사이드 렌더링 방식을 채택하지 않았기 때문에 콘텐츠를 표시하려면 JavaScript 번들을 읽고 실행해야 합니다. 여기에 걸리는 시간을 단축하여 성능, 특히 LCP(Largest Contentful Paint) 개선을 기대했습니다.

이를 위한 전형적인 방안으로는 레이지 로딩을 이용한 번들 분할이 있습니다. 예를 들어 처음 로딩하는 번들에서 톱 페이지를 표시할 때는 필요하지 않는 코드를 제외하는 것인데요. 이 방안을 적용해도 LINE 증권은 톱 페이지에 여러 콘텐츠가 표시되기 때문에 여전히 코드의 양이 많아서 톱 페이지 아래쪽 콘텐츠의 로딩도 지연시키기로 했습니다. 즉, 아래쪽 콘텐츠를 표시하는데 필요한 JavaScript를 다른 파일로 분할해서 나중에 로딩한 후 실행하도록 한 것입니다. 이렇게 하면 처음 로딩하는 JavaScript 번들의 크기가 감소하고 첫 렌더링에 필요한 JavaScript 코드의 양(JavaScript 실행 시간) 또한 감소합니다. 구체적으로는 React.lazy와 import()를 이용한 webpack의 청크 분할 기능(참고)을 사용했습니다. 간략하게 예를 들면 다음 코드와 같습니다.

import { lazy, Suspense } from "react";
import MainContents from "./mainContents";
  
const OtherContents = lazy(() => import('./otherContents'));
  
const Home: React.VFC = () => {
  return (
    <div>
      <MainContents />
      <Suspense fallback={<p>Loading...</p>}>
        <OtherContents />
      </Suspense>
    </div>
  );
};

위와 같이 코딩하면 Home 컴포넌트를 초기 렌더링할 때 MainContents만 렌더링하고 OtherContents의 내용은 제외합니다(Suspense 컴포넌트 효과로 Loading…이라고 표시됩니다). 그 후 OtherContents를 로딩한 시점에서 Home을 다시 렌더링하고 OtherContents의 내용을 표시합니다. CLS 발생에 주의를 기울여야 하지만 간단한 방식입니다. 초기 화면에서 보이지 않는 곳에 위치한 콘텐츠뿐만 아니라 로딩이 오래 걸리는 그래프 생성 영역 등도 같은 방법으로 지연시킬 수 있습니다.

 

수상한 appendChild

앞서 말씀드린 부분을 구현한 후 성능을 측정해보니 렌더링을 처리하는 중에 왠지 모르게 시간이 걸리는 appendChild가 나열된 것을 발견했습니다. React는 렌더링 결과를 최종적으로 DOM에 반영하므로 언뜻 봤을 때 appendChild의 존재 자체가 이상한 것은 아니라고 생각할 수 있지만 이 부분은 DOM에 반영 처리하기 전에 컴포넌트 트리를 생성하는 단계였습니다. 

조사해보니 appendChild를 실행하는 것은 React가 아니라 webpack의 런타임이었습니다. webpack에서는 청크를 로딩할 때 JSONP를 사용하기 때문에 초기 청크에서 분리된 청크를 로딩하고 싶은 경우에는 해당 청크를 로딩하기 위한 스크립트 요소를 생성해서 문서에 추가합니다. 이때 사용되는 appendChild가 나타났던 것입니다. 렌더링에 직접 영향을 주지는 않더라도 DOM을 조작하는 작업이기에 이를 처리하는 데 걸리는 시간이 눈에 띄었습니다.

초기 렌더링에 appendChild가 나타나는 현상을 설명할 수 있는 요인은 두 가지입니다. 첫 번째는 import()에 해당하는 코드를 실행할 때 webpack 런타임이 그 자리에서 동기적으로 스크립트 요소를 생성하고 임베딩을 실시한다는 점입니다. 또 하나는 React의 lazy()로 생성한 컴포넌트를 렌더링한 시점에서 역시 동기적으로 콜백 함수를 호출한다는 점입니다. 이 두 가지 처리가 모두 동기적으로 실행된 결과, appendChild가 초기 렌더링 처리에 섞이게 되었습니다. 

 

requestIdleCallback 함수 도입

이번 글의 제목에도 들어간 requestIdleCallback은 브라우저의 메인 스레드(JavaScript 실행 등을 담당하는 스레드)가 비어 있으면 지정한 콜백 함수를 실행하도록 지시할 수 있는 함수입니다. 3년 이상 ‘Proposed Recommendation(참고)’인 채로 남아 있는 점이 다소 불안하긴 하지만 Google에서는 활용할 것을 권장하고 있습니다(참고).

초기 렌더링하는 동안에는 메인 스레드가 콘텐츠를 최대한 빨리 표시하는 것에 집중하기를 바라기 때문에 레이지 로딩 관련 작업은 뒤로 미루고 싶습니다. 초기 렌더링을 방해하지 않는 선에서 뒤로 미루다가 여유가 생겼을 때 실행하길 바라는 경우에 requestIdleCallback이 적합합니다. 다음은 React의 lazy()와 같은 방식으로 사용할 수 있는 lazyIdle()을 구현한 코드입니다.

import { lazy } from 'react'
  
export const lazyIdle: typeof lazy = (factory) => {
  return lazy(
    () =>
      new Promise((resolve) => {
        window.requestIdleCallback(() => resolve(factory()), {
          timeout: 3000
        })
      })
  )
}
  
// 사용법
const OtherContents = lazyIdle(() => import('./otherContents'));

위와 같이 lazyIdle()에 전달된 팩토리 함수(() => import(‘./otherContents’)와 같은 함수)의 실행을 requestIdleCallback으로 지연시킵니다. 이를 통해 결과적으로 스크립트 요소 삽입을 초기 렌더링 완료 후로 지연시킵니다. 앞서 소개한 Google에서 작성한 글에서 requestIdleCallback의 콜백 내에서 DOM을 조작하는 것을 권장하지 않고 있는데요. 스크립트 요소이므로(레이아웃 등에 영향을 주지 않음) 문제없습니다.

iOS에서는 아직 requestIdleCallback을 지원하지 않기 때문에 저희는 requestidlecallback-polyfill을 이용했습니다. requestidlecallback-polyfill는 내부에서 setTimeout을 사용하고 requestIdleCallback의 본래 동작을 구현하지 않기 때문에 엄밀히 말하면 Polyfill은 아니지만 Progressive Enhancement 사고방식에 따라 이런 식으로 접근했습니다. 

 

성능 개선 결과

지금까지 설명드린 requestIdleCallback을 이용한 초기 렌더링 실행 시간 개선 방안을 실제로 LINE 증권의 톱 페이지에 적용해 봤습니다. 다섯 군데 정도 lazy()를 lazyIdle()로 교체했는데요. 그 결과 약 14% 정도 개선됐습니다. 개선을 진행하기 전에는 아래와 같이 초기 렌더링 전체에 약 700밀리 초가 걸렸습니다(MacBook Pro, Google Chrome, CPU 6x slowdown으로 계측).

requestIdleCallback을 이용해 개선을 진행한 후에는 아래와 같이 약 600밀리 초로 실행 시간이 단축됐습니다.

드라마틱한 차이는 아니지만 그렇다고 14%가 결코 작은 수치는 아닙니다. React와 webpack 조합 앱의 속도를 최대한 높이고 싶을 때는 성능 측정 결과 중에 나타난 이상한 appendChild를 찾아보면 어떨까요?

 

마치며

이번 글에서는 LINE 증권 프런트 엔드에서 실시한 성능 개선 사례를 소개했습니다. requestIdleCallback은 앞서 말씀드린 것처럼 이미 2015년에 Google에서 활용을 권장하는 등 생각보다 오래된 기술이지만 한편으로는 구체적인 활용 사례가 많이 알려지지 않은 기술이기도 합니다. 나중으로 미뤄도 괜찮은 처리가 메인 스레드에 있는 것을 발견했다면 requestIdleCallback 사용을 검토해 보세요. 저희는 앞으로도 꾸준히 성능을 개선해 더 편리한 서비스를 만드는 데 기여하겠습니다.