React SPA 성능 최적화에 관한 실제 사례 연구
웹사이트 성능은 로드 시간뿐만이 아닙니다. 특히 사용자가 매일 사용하는 생산성 데스크톱 앱의 경우 사용자에게 빠르고 반응이 좋은 환경을 제공하는 것이 중요합니다. Recruit Technologies의 엔지니어링팀은 사용자 입력 성능을 개선하기 위해 웹 앱 중 하나인 AirSHIFT를 개선하기 위한 리팩터링 프로젝트를 진행했습니다. 그 비법을 소개해 드리겠습니다.
느린 응답, 낮은 생산성
AirSHIFT는 음식점, 카페와 같은 매장 소유자가 직원의 교대 근무를 관리하는 데 도움이 되는 데스크톱 웹 애플리케이션입니다. React를 사용해 구축된 단일 페이지 애플리케이션은 일, 주, 월 등으로 구성된 다양한 교대 근무 일정 그리드 표를 비롯한 풍부한 클라이언트 기능을 제공합니다.
Recruit Technologies 엔지니어링팀이 AirSHIFT 앱에 새로운 기능을 추가하면서 성능 저하에 대한 의견이 점점 더 많이 접수되기 시작했습니다. AirSHIFT의 엔지니어링 관리자인 후루카와 요스케는 다음과 같이 말했습니다.
사용자 연구 조사에 따르면 매장 주인 중 한 명이 교대 근무 테이블이 로드되기를 기다리면서 시간을 낭비하기 위해 버튼을 클릭한 후 자리에서 커피를 내리겠다고 말했을 때 충격을 받았습니다.
엔지니어링 팀은 조사를 마친 후 많은 사용자가 10년 전 1GHz Celeron M 노트북과 같은 저사양 컴퓨터에 대규모 시프트 테이블을 로드하려고 했다는 사실을 알게 되었습니다.
AirSHIFT 앱은 값비싼 스크립트로 기본 스레드를 차단했지만 엔지니어링팀은 스크립트가 얼마나 비싼지 알지 못했습니다. 스크립트가 빠른 Wi-Fi 연결을 갖춘 풍부한 사양의 컴퓨터에서 개발하고 테스트했기 때문입니다.
CPU 및 네트워크 제한을 사용 설정한 상태로 Chrome DevTools에서 성능을 프로파일링한 후 성능 최적화가 필요하다는 사실을 명확하게 알 수 있었습니다. AirSHIFT는 이 문제를 해결하기 위해 태스크포스를 구성했습니다. 다음은 앱이 사용자 입력에 더 빠르게 반응하도록 하기 위해 중점을 둔 5가지 사항입니다.
1. 대형 테이블 가상화
교대표를 표시하려면 가상 DOM을 구성하고 직원 수와 시간 슬롯에 비례하여 화면에 렌더링하는 등 여러 번의 비용이 많이 드는 단계가 필요했습니다. 예를 들어 식당에 근무하는 직원이 50명 있고 월별 교대 근무 일정을 확인하려는 경우 50(직원) x 30(일)의 표가 되며 렌더링할 셀 구성요소는 1,500개가 됩니다. 이는 특히 사양이 낮은 기기에서 매우 비용이 많이 드는 작업입니다. 실제로는 더 나빴습니다. 조사 결과, 200명의 직원을 관리하는 매장이 있으며, 매월 하나의 표에 약 6,000개의 셀 구성요소가 필요하다는 사실을 알게 되었습니다.
이 작업의 비용을 줄이기 위해 AirSHIFT는 교대 테이블을 가상화했습니다. 이제 앱은 뷰포트 내에 있는 구성요소만 마운트하고 화면 밖 구성요소는 마운트 해제합니다.
이 경우 AirSHIFT는 복잡한 2차원 그리드 테이블을 사용 설정해야 하는 요구사항이 있었으므로 react-virtualized를 사용했습니다. 또한 향후 경량 react-window를 사용하도록 구현을 변환하는 방법도 모색하고 있습니다.
결과
테이블을 가상화하는 것만으로 스크립트 실행 시간이 6초 단축되었습니다(CPU 속도 저하 4배 + 제한된 고속 3G MacBook Pro 환경). 이는 리팩터링 프로젝트에서 가장 큰 영향을 미친 성능 개선사항입니다.
2. User Timing API로 감사
그런 다음 AirSHIFT팀은 사용자 입력으로 실행되는 스크립트를 리팩터링했습니다. Chrome DevTools의 화염 차트를 사용하면 기본 스레드에서 실제로 어떤 일이 일어나고 있는지 분석할 수 있습니다. 하지만 AirSHIFT팀은 React의 수명 주기를 기반으로 애플리케이션 활동을 더 쉽게 분석할 수 있다는 것을 알게 되었습니다.
React 16은 User Timing API를 통해 성능 트레이스를 제공하며, 이를 Chrome DevTools의 Timings 섹션에서 시각화할 수 있습니다. AirSHIFT는 타이밍 섹션을 사용하여 React 수명 주기 이벤트에서 실행되는 불필요한 로직을 찾았습니다.
결과
AirSHIFT팀은 모든 경로 탐색 직전에 불필요한 React 트리 조정이 발생한다는 사실을 발견했습니다. 즉, React가 탐색 전에 불필요하게 시프트 테이블을 업데이트하고 있었습니다. 불필요한 Redux 상태 업데이트로 인해 이 문제가 발생했습니다. 이 문제를 수정하여 스크립팅 시간을 약 750ms 절약했습니다. AirSHIFT는 다른 마이크로 최적화도 수행하여 스크립트 실행 시간을 총 1초 단축했습니다.
3. 구성요소를 지연 로드하고 비용이 많이 드는 로직을 웹 워커로 이동
AirSHIFT에는 채팅 애플리케이션이 내장되어 있습니다. 많은 매장 소유자가 교대 근무표를 보면서 채팅을 통해 직원과 소통하므로 사용자가 테이블이 로드되는 동안 메시지를 입력할 수 있습니다. 기본 스레드가 테이블을 렌더링하는 스크립트로 가득 차 있으면 사용자 입력이 버벅거릴 수 있습니다.
이러한 경험을 개선하기 위해 AirSHIFT는 이제 React.latency 및 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>
)
}
Cost 구성요소에서 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>;
}
작업자에서 실행되는 계산 로직을 구현하고 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는 JavaScript의 약 100ms를 메인 스레드에서 워커 스레드로 전환했습니다(4배 CPU 제한으로 시뮬레이션됨).
AirSHIFT는 현재 다른 구성요소를 지연 로드하고 더 많은 로직을 웹 워커로 오프로드하여 버벅거림을 더욱 줄일 수 있는지 모색하고 있습니다.
4. 실적 예산 설정
이러한 최적화를 모두 구현한 후에는 시간이 지남에 따라 앱의 성능이 유지되는지 확인하는 것이 중요했습니다. 이제 AirSHIFT는 bundlesize를 사용하여 현재 JavaScript 및 CSS 파일 크기를 초과하지 않습니다. 이러한 기본 예산을 설정하는 것 외에도, 전환 테이블 로드 시간의 다양한 백분율을 표시하는 대시보드를 빌드하여 이상적이지 않은 조건에서도 애플리케이션의 성능이 우수한지 확인했습니다.
- 이제 모든 Redux 이벤트의 스크립트 완료 시간이 측정됩니다.
- 성능 데이터가 Elasticsearch에서 수집됩니다.
- 각 이벤트의 10번째, 25번째, 50번째, 75번째 백분위수 성능이 Kibana로 시각화됩니다.
이제 AirSHIFT에서 시프트 테이블 로드 이벤트를 모니터링하여 75번째 백분위수 사용자의 경우 3초 이내에 완료되도록 합니다. 현재는 시행되지 않는 예산이지만 예산을 초과할 경우 Elasticsearch를 통한 자동 알림을 고려하고 있습니다.
결과
위 그래프에서 AirSHIFT가 이제 대부분 75번째 백분위수 사용자의 경우 3초 예산을 사용하고 25번째 백분위수 사용자의 경우 1초 이내에 시프트 표를 로드하고 있음을 알 수 있습니다. 이제 AirSHIFT는 다양한 조건과 기기에서 RUM 성능 데이터를 캡처하여 새 기능 출시가 실제로 애플리케이션의 성능에 영향을 미치는지 여부를 확인할 수 있습니다.
5. 성능 해커톤
이러한 모든 성능 최적화 작업이 중요하고 영향력이 있었음에도 불구하고 엔지니어링팀과 비즈니스팀이 비기능적 개발에 우선순위를 두도록 하기가 항상 쉽지만은 않습니다. 문제의 일부는 이러한 성능 최적화 중 일부를 계획할 수 없다는 점입니다. 실험과 시행착오를 마다치 않는 자세가 필요합니다.
AirSHIFT는 엔지니어가 성능 관련 작업에만 집중할 수 있도록 내부 1일 성능 해커톤을 진행하고 있습니다. 이러한 해커톤에서는 모든 제약 조건을 없애고 엔지니어의 창의성을 존중합니다. 즉, 속도 향상에 기여하는 모든 구현은 고려할 가치가 있습니다. 해커톤에 박차를 가하기 위해 AirSHIFT에서는 그룹을 소규모 팀으로 나누고 각 팀은 Lighthouse 성능 점수를 가장 크게 높일 수 있는 팀을 두고 경쟁을 벌입니다. 팀 간의 경쟁이 치열해집니다. 🔥
결과
이러한 해커톤 접근 방식이 효과적이었습니다.
- 해커톤이 진행되는 동안 여러 접근 방식을 실제로 시도하고 Lighthouse로 측정하여 성능 병목 현상을 쉽게 감지할 수 있습니다.
- 해커톤이 끝나면 프로덕션 출시에 우선순위를 두어야 하는 최적화를 팀에 설득하기가 훨씬 쉽습니다.
- 속도의 중요성을 옹호하는 데도 효과적입니다. 모든 참여자는 코딩 방식과 성능 결과 간의 상관관계를 파악할 수 있습니다.
좋은 부수적인 효과는 Recruit 내의 다른 많은 엔지니어링팀이 이 실습 접근 방식에 관심을 갖게 되었고 AirSHIFT팀이 현재 회사 내에서 여러 속도 해커톤을 진행하고 있다는 점입니다.
요약
AirSHIFT에서 이러한 최적화를 수행하는 것이 가장 쉬운 여정은 아니었지만 확실히 효과가 있었습니다. 이제 AirSHIFT는 평균 1.5초 이내에 교대 근무 테이블을 로드하고 있어 프로젝트 전 성과가 6배 향상되었습니다.
성능 최적화가 출시된 후 한 사용자는 다음과 같이 말했습니다.
Shift 테이블 로드 속도를 높여 주셔서 감사합니다. 이제 교대 근무를 정리하는 것이 훨씬 더 효율적입니다.