AirSHIFT에서 React 앱의 런타임 성능을 개선한 5가지 방법

React SPA 성능 최적화에 관한 실제 사례 연구입니다.

Kento Tsuji
Kento Tsuji
Satoshi Arai
Satoshi Arai
Yusuke Utsunomiya
Yusuke Utsunomiya
Yosuke Furukawa
Yosuke Furukawa

웹사이트 성능은 로드 시간만의 문제가 아닙니다. 특히 사람들이 매일 사용하는 생산성 데스크톱 앱의 경우 사용자에게 빠르고 반응이 빠른 환경을 제공하는 것이 중요합니다. Recruit Technologies의 엔지니어링팀은 웹 앱 중 하나인 AirSHIFT를 개선하여 사용자 입력 성능을 향상하기 위한 리팩터링 프로젝트를 진행했습니다. 방법은 다음과 같습니다.

느린 응답, 낮은 생산성

AirSHIFT는 레스토랑이나 카페와 같은 매장 소유자가 직원의 교대 근무를 관리할 수 있도록 도와주는 데스크톱 웹 애플리케이션입니다. React로 빌드된 단일 페이지 애플리케이션은 일, 주, 월 등으로 구성된 교대 근무 일정의 다양한 그리드 표를 비롯하여 풍부한 클라이언트 기능을 제공합니다.

AirSHIFT 웹 앱의 스크린샷

Recruit Technologies 엔지니어링팀이 AirSHIFT 앱에 새로운 기능을 추가하면서 느린 성능에 대한 더 많은 피드백을 받기 시작했습니다. AirSHIFT의 엔지니어링 관리자인 후루카와 요스케는 다음과 같이 말했습니다.

사용자 연구 조사에서 매장 주인 중 한 명이 시프트 테이블이 로드될 때까지 시간을 허비하기 위해 버튼을 클릭한 후 자리에서 커피를 내려 놓겠다고 말하자 충격을 받았습니다.

연구를 진행한 후 엔지니어링 팀은 많은 사용자들이 10년 전 출시된 1GHz Celeron M 노트북과 같은 저사양 컴퓨터에 대규모 시프트 테이블을 로드하려고 한다는 것을 알게 되었습니다.

저사양 기기에 무한 스피너가 있습니다.

AirSHIFT 앱은 비용이 많이 드는 스크립트로 기본 스레드를 차단하고 있었지만 엔지니어링팀은 빠른 Wi-Fi 연결을 갖춘 다양한 사양의 컴퓨터에서 개발 및 테스트를 하고 있었기 때문에 스크립트의 비용이 얼마나 많이 드는지 깨닫지 못했습니다.

앱의 런타임 활동을 보여주는 차트
Shift 테이블을 로드할 때 스크립트 실행으로 로드 시간의 약 80% 가 사용되었습니다.

CPU 및 네트워크 제한을 사용 설정한 상태로 Chrome DevTools에서 성능을 프로파일링한 후 성능 최적화가 필요하다는 사실이 분명해졌습니다. AirSHIFT는 이 문제를 해결하기 위해 태스크 포스를 구성했습니다. 앱에서 사용자 입력에 더 잘 반응하도록 하기 위해 주력한 5가지 사항은 다음과 같습니다.

1. 대규모 테이블 가상화

시프트 테이블을 표시하려면 가상 DOM을 구성하고 교직원 수와 시간대에 비례하여 화면에 렌더링하는 등 비용이 많이 드는 여러 단계가 필요했습니다. 예를 들어 식당의 직원이 50명이고 월간 교대 근무 일정을 확인하려는 경우 50명 (구성원)에 30 (일)을 곱한 테이블로 렌더링해야 할 셀 구성요소가 1,500개가 됩니다. 이는 특히 저사양 기기의 경우 비용이 많이 드는 작업입니다. 하지만 실제로는 상황이 더 안 좋았어요. 연구 결과, 200명의 직원을 관리하는 매장이 있었으며 한 달에 6,000개 정도의 셀 구성 요소가 필요하다는 사실을 알게 되었습니다.

이 작업의 비용을 줄이기 위해 AirSHIFT는 시프트 테이블을 가상화했습니다. 이제 앱이 표시 영역 내에서만 구성요소를 마운트하고 화면 밖에 있는 구성요소를 마운트 해제합니다.

AirSHIFT가 표시 영역 밖의 콘텐츠를 렌더링하는 데 사용되었음을 보여주는 주석이 달린 스크린샷
이전: 모든 Shift 테이블 셀을 렌더링합니다.
이제 AirSHIFT가 표시 영역에 보이는 콘텐츠만 렌더링한다는 것을 보여주는 주석이 달린 스크린샷
이후: 표시 영역 내에서만 셀을 렌더링합니다.

이 경우 복잡한 2차원 그리드 테이블을 사용 설정하는 요구사항이 있으므로 AirSHIFT는 반응 가상화를 사용했습니다. 또한 앞으로 경량의 react-window를 사용하도록 구현을 변환하는 방법도 모색하고 있습니다.

결과

테이블 가상화만으로 스크립팅 시간이 6초 단축되었습니다 (CPU 4배 속도 저하 + Fast 3G 제한 Macbook Pro 환경). 리팩터링 프로젝트에서 가장 영향력이 큰 성능 개선이었습니다.

Chrome DevTools Performance 패널 기록을 보여주는 주석이 달린 스크린샷
전: 사용자 입력 후 약 10초 동안의 스크립트 작성
Chrome DevTools Performance 패널 기록을 보여주는 주석이 추가된 다른 스크린샷
후: 사용자 입력 후 스크립트 4초

2. User Timing API를 사용한 감사

그 다음 AirSHIFT 팀은 사용자 입력에서 실행되는 스크립트를 리팩터링했습니다. Chrome DevTools플레임 차트를 사용하면 기본 스레드에서 실제로 발생하는 상황을 분석할 수 있습니다. 하지만 AirSHIFT팀은 React의 수명 주기를 기반으로 애플리케이션 활동을 분석하기가 더 쉬웠다는 것을 알게 되었습니다.

React 16은 Chrome DevTools의 타이밍 섹션에서 시각화할 수 있는 User Timing API를 통해 성능 트레이스를 제공합니다. AirSHIFT는 Timings 섹션을 사용하여 React 수명 주기 이벤트에서 실행되는 불필요한 로직을 찾았습니다.

Chrome DevTools Performance 패널의 Timings 섹션
React의 User Timing 이벤트입니다.

결과

AirSHIFT팀은 모든 경로 탐색 직전에 불필요한 React Tree Reconciliation이 이루어지고 있음을 발견했습니다. 즉, React는 탐색을 시작하기 전에 불필요하게 시프트 표를 업데이트하고 있었습니다. 불필요한 Redux 상태 업데이트로 인해 이 문제가 발생했습니다. 이를 수정하여 약 750ms의 스크립팅 시간을 절약했습니다. AirSHIFT는 다른 마이크로 최적화도 진행했으며, 그 결과 스크립팅 시간이 총 1초 단축되었습니다.

3. 구성요소 지연 로드 및 비용이 많이 드는 로직을 웹 워커로 이동

AirSHIFT에는 채팅 애플리케이션이 내장되어 있습니다. 많은 매장 소유자가 시프트 테이블을 보면서 채팅을 통해 직원과 소통합니다. 즉, 사용자가 테이블이 로드되는 동안 메시지를 입력하고 있을 수 있습니다. 기본 스레드에 테이블을 렌더링하는 스크립트가 포함되어 있으면 사용자 입력에 버벅거림이 발생할 수 있습니다.

이러한 환경을 개선하기 위해 AirSHIFT에서는 이제 React.lazy 및 Suspense를 사용하여 실제 구성요소를 느리게 로드하는 동안 테이블 콘텐츠의 자리표시자를 표시합니다.

또한 AirSHIFT팀은 지연 로드되는 구성요소 내의 비용이 많이 드는 비즈니스 로직 중 일부를 웹 작업자로 마이그레이션했습니다. 이를 통해 기본 스레드의 여유가 확보되어 사용자 입력에 응답하는 데 집중할 수 있게 되어 사용자 입력 버벅거림 문제가 해결되었습니다.

일반적으로 개발자는 작업자를 사용할 때 복잡성에 직면하지만 이번에는 Comlink가 어려운 작업을 완료했습니다. 다음은 AirSHIFT가 가장 비용이 많이 드는 작업 중 하나인 총 인건비를 계산한 방법을 보여주는 의사 코드입니다.

App.js에서 로드 중 React.lazy 및 Suspense를 사용하여 대체 콘텐츠 표시

/** App.js */
import React, { lazy, Suspense } from 'react'

// Lazily loading the Cost component with React.lazy
const Hello = lazy(() => import('./Cost'))

const Loading = () => (
  <div>Some fallback content to show while loading</div>
)

// Showing the fallback content while loading the Cost component by Suspense
export default function App({ userInfo }) {
   return (
    <div>
      <Suspense fallback={<Loading />}>
        <Cost />
      </Suspense>
    </div>
  )
}

비용 구성요소에서 comlink를 사용하여 계산 로직 실행

/** Cost.js */
import React from 'react';
import { proxy } from 'comlink';

// import the workerlized calc function with comlink
const WorkerlizedCostCalc = proxy(new Worker('./WorkerlizedCostCalc.js'));
export default async function Cost({ userInfo }) {
  // execute the calculation in the worker
  const instance = await new WorkerlizedCostCalc();
  const cost = await instance.calc(userInfo);
  return <p>{cost}</p>;
}

worker에서 실행되는 계산 로직을 구현하고 comlink를 사용하여 노출

// WorkerlizedCostCalc.js
import { expose } from 'comlink'
import { someExpensiveCalculation } from './CostCalc.js'

// Expose the new workerlized calc function with comlink
expose({
  calc(userInfo) {
    // run existing (expensive) function in the worker
    return someExpensiveCalculation(userInfo);
  }
}, self);

결과

체험판으로 작업자화한 로직의 양은 제한적이지만 AirSHIFT는 약 100ms의 자바스크립트를 기본 스레드에서 작업자 스레드로 이동했습니다 (4배 CPU 제한으로 시뮬레이션).

스크립팅이 기본 스레드가 아닌 웹 워커에서 발생하고 있음을 보여주는 Chrome DevTools Performance 패널 기록의 스크린샷

AirSHIFT는 현재 버벅거림을 더 줄이기 위해 다른 구성요소를 지연 로드하고 더 많은 로직을 웹 작업자에게 오프로드할 수 있는지 살펴보고 있습니다.

4. 성능 예산 설정

이러한 최적화를 모두 구현한 후에는 시간이 지나도 앱의 성능을 유지하는 것이 중요했습니다. 이제 AirSHIFT는 bundlesize를 사용하여 현재 JavaScript 및 CSS 파일 크기를 초과하지 않습니다. 이러한 기본 예산을 설정하는 것 외에도 시프트 테이블 로드 시간의 다양한 백분위수를 보여주는 대시보드를 구축하여 이상적인 조건에서 벗어난 애플리케이션 성능이 우수한지 확인합니다.

  • 이제 모든 Redux 이벤트의 스크립트 완료 시간이 측정됩니다.
  • 성능 데이터는 Elasticsearch에서 수집됩니다.
  • Kibana로 각 이벤트의 10번째, 25번째, 50번째, 75번째 백분위수 성능을 시각화합니다.

이제 AirSHIFT는 시프트 테이블 로드 이벤트를 모니터링하여 75번째 백분위수 사용자의 경우 3초 내에 작업이 완료되는지 확인합니다. 현재 시행되지 않은 예산이지만, 예산을 초과할 경우 Elasticsearch를 통한 자동 알림을 받는 것을 고려하고 있습니다.

75번째 백분위수는 약 2500ms 이내에 완료되고, 50번째 백분위수는 약 1250ms 내에 완료되며, 50번째 백분위수는 약 750ms 내에 완료되며, 10번째 백분위수는 약 500ms 내에 완료된다는 것을 보여주는 차트입니다.
백분위수별 일일 성능 데이터를 보여주는 Kibana 대시보드

결과

위의 그래프를 보면 AirSHIFT가 현재 75번째 백분위수의 사용자에게는 3초의 예산을 소진하고 있으며 25번째 백분위수 사용자의 경우 1초 이내에 교대근무 표를 로드하고 있음을 알 수 있습니다. 이제 AirSHIFT는 다양한 조건과 장치에서 RUM 성능 데이터를 캡처하여 새로운 기능 출시가 실제로 애플리케이션의 성능에 영향을 미치는지 여부를 확인할 수 있습니다.

5. 성능 해커톤

이러한 모든 성능 최적화 노력이 중요하고 영향력이 있었음에도 엔지니어링팀과 비즈니스팀이 비기능적 개발에 우선순위를 두도록 하기가 항상 쉽지만은 않습니다. 문제점 중 하나는 이러한 성능 최적화 중 일부를 계획할 수 없다는 것입니다. 실험과 시행착오를 겪는 사고방식이 필요합니다.

AirSHIFT는 엔지니어들이 성능 관련 작업에만 집중할 수 있도록 내부 1일 성능 해커톤을 실시하고 있습니다. 이러한 해커톤에서는 모든 제약 조건을 없애고 엔지니어의 창의성을 존중합니다. 즉, 속도에 기여하는 모든 구현은 고려할 가치가 있습니다. 해커톤을 빠르게 진행하기 위해 AirSHIFT는 그룹을 소규모 팀으로 분할하고, 각 팀은 누가 Lighthouse 성능 점수를 가장 많이 개선할 수 있는지 두고 경쟁합니다. 팀이 치열해지고 있습니다.

해커톤 사진

결과

해커톤 접근 방식이 효과가 있습니다.

  • 해커톤이 진행되는 동안 여러 접근 방식을 실제로 시도하고 Lighthouse로 각 방법을 측정하면 성능 병목 현상을 쉽게 감지할 수 있습니다.
  • 해커톤이 끝난 후에는 프로덕션 출시를 위해 어떤 최적화를 우선시해야 하는지 팀을 설득하기가 다소 쉽습니다.
  • 또한 속도의 중요성을 강조하는 효과적인 방법이기도 합니다. 모든 참가자는 코딩하는 방식과 그 방법이 성능에 미치는 영향 사이의 상관관계를 이해할 수 있습니다.

좋은 부작용으로는 Recruit의 다른 많은 엔지니어링 팀이 이 실습 방식에 관심을 보였고 AirSHIFT 팀은 현재 회사 내에서 여러 스피드 해커톤을 지원하고 있다는 점입니다.

요약

AirSHIFT가 이러한 최적화를 수행하는 것이 쉬운 여정은 아니었지만, 확실히 효과가 있었습니다. 현재 AirSHIFT는 중앙값으로 1.5초 이내에 시프트 테이블을 로드하고 있습니다. 이는 프로젝트 전 성과에 비해 6배 개선된 것입니다.

성능 최적화가 시작된 후 한 사용자는 다음과 같이 말했습니다.

Shift 테이블 로드 속도를 높여 주셔서 감사합니다. 이제 교대 근무를 정리하는 것이 훨씬 더 효율적입니다.