React

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

최근 웹 바이탈이 대두되면서 성능 개선에 힘쓰기 시작했다.
개발 환경에서 계측한 결과, Lighthouse 성능 점수가 기존보다 30점 상승했다.
성능 개선 사례 중 requestIdleCallback을 활용해 lazy loading 가능한 컴포넌트의 로딩을 지연시켜 초기 렌더링 시간을 약 14% 단축한 사례를 공유한다.

작업 환경

TypeScript, React, webpack
React, webpack과 관련이 깊은 사례다.
Core Web Vitals 개선을 목표 ← Chrome 확장인 Lighthouse 사용

성능 개선 실시

Cumulative Layout Shift를 없애고 콘텐츠가 표시될 때까지 API 서버를 3번 왕복하는 부분을 수정했다.
이후 초기 렌더링을 개선해야 했다. SSR이 아니므로 콘텐츠를 표시하기 위해 JavaScript 번들을 읽고 실행해야 했으므로 이 시간을 단축하여 Largest Contentful Paint 개선을 기대했다.
이는 lazy loading을 이용한 번들 분할이 있다.
예를 들어 처음 로딩하는 번들에 톱페이지를 표시할 때 필요하지 않은 코드를 제외하는 것이다.
톱페이지에 여러 콘텐츠가 표시되어야 해서 코드양이 많아서 아래쪽 콘텐츠의 로딩을 지연시키는 것도 필요했다.(이 부분을 다른 파일로 분할해서 나중에 로딩하는 것, React.lazy와 import()를 이용한 webpack의 청크 분할 기능(참고))
const OtherContents = lazy(() => import('./otherContents')); const Home: React.VFC = () => { return ( <div> <MainContents /> <Suspense fallback={<p>Loading...</p>}> <OtherContents /> </Suspense> </div> ); };
JavaScript
복사
Suspense 컴포넌트 효과로 Loading이 표시된다.
OtherContents를 로딩한 시점에 Home을 리렌더링해서 OtherContents의 내용을 표시한다.
CLS 발생에 주의를 기울여야 하지만 간단하다.
로딩이 오래 걸리는 그래프 생성 영역에 쓰일 수 있다.

수상한 appendChild

성능 측정 시 appendChild가 나열된 것을 발견했다.
React는 최종적으로 DOM에 반영하긴 하지만 DOM 반영 전에 컴포넌트 트리를 생성하는 단계에 나타났다.
조사?해보니 webpack의 런타임이 appendChild를 실행했다.
webpack은 청크를 로딩할 때 JSONP를 사용하므로 초기 청크에서 분리된 청크를 로딩할 경우 해당 청크를 로딩하기 위한 스크립트 요소를 생성해서 문서에 추가한다.
초기 렌더링에 appendChild가 나타나는 요인은 2가지다.
1.
import()를 실행할 때 webpack 런타임이 그 자리에서 동기적으로 스크립트 요소를 생성하고 임베딩을 실시한다.
2.
React.lazy로 생성한 컴포를 렌더링한 시점에 동기적으로 콜백 함수를 호출한다.
→ 둘다 동기적이어서 appendChild가 초기 렌더링에 나타났다.

requestIdleCallback 함수 도입

브라우저의 메인 스레드(js 실행 등 담당)가 비어 있으면 지정한 콜백 함수를 실행하도록 지시할 수 있는 함수다.
3년 이상 Proposed Recommendation이지만 Google에서는 권장한다.
초기 렌더링 동안은 메인 스레드가 콘텐츠를 최대한 빨리 표시해야 하므로 lazy loading을 뒤로 미루고 싶다.(초기 렌더링을 방해하지 않게)
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'));
JavaScript
복사
lazyIdle()에 전달된 팩토리 함수?(() ⇒ import(’./otherContents’)와 같은 함수)의 실행을 requestIdleCallback으로 지연시킨다.
결과적으로 스크립트 요소 삽입을 초기 렌더링 완료 후로 지연시킨다.
Google은 requestIdleCallback 콜백 내에서 DOM 조작을 비권장하지만 이는 스크립트 요소(레이아웃 영향 없음)라서 괜찮다.
iOS에는 지원하지 않는 함수이므로 requestidlecallback-polyfill을 이용
이는 본래 동작을 구현하지는 않아서 엄밀히 말하면 Polyfill은 아니지만 Progressive Enhancement 사고방식에 따라 이런 식으로 접근

성능 개선 결과

실제 톱페이지에 5군데에 교체한 결과 14% 정도 개선되었다.
React와 webpack 조합 앱의 속도를 최대한 높이고 싶을 때는 성능 측정 결과 중에 나타난 이상한 appendChild를 찾아보면 어떨까

마치며

구체적인 활용 사례가 많지 않은 기술이었다.
메인스레드에 미뤄도 괜찮은 처리가 있는 경우 검토해보자.