예시와 함께 이해하는 Chrome 개발자 도구 성능 탭 활용 가이드

웹앱을 사용하면서 성능 저하가 느껴진다면 브라우저 개발자 도구의 "성능" 기능을 이용해 어떤 부분에서 성능 문제가 발생하는지 알 수 있다. 성능(Performance) 탭에서 녹화를 시작하고 성능 저하가 발생하는 동작을 한 후 녹화를 종료해보자. 그러면 아래와 같은 플레임 차트가 렌더링된다. 지금부터 성능 탭의 결과 화면에서 각각이 어떠한 것을 나타내는지, 그것을 성능 문제 분석에 어떻게 활용할 수 있는지 살펴보도록 하자.

 

 

 

1. 프레임

1. Idle Frame(흰색): 변경사항이 없습니다.
2. Frame(녹색): 예상대로 제때 렌더링됩니다.
3. Partially Presented Frame(노란색): Chrome에서는 적어도 일부 시각적 업데이트를 제때 렌더링하기 위해 최선을 다했습니다. 예를 들어 렌더러 프로세스의 기본 스레드 작업 (캔버스 애니메이션)이 늦었지만 컴포지터 스레드 (스크롤)는 시간 내에 있는 경우입니다.
4. Dropped Frame(빨간색): Chrome이 적절한 시간 내에 프레임을 렌더링할 수 없습니다.

기록의 상단에는 Frame 섹션이 존재하는데 이 섹션은 브라우저 화면 프레임이 정상적으로 렌더링되었는지 확인하는데 사용될 수 있다. 초당 60프레임의 화면을 프레임 드랍 없이 렌더링하기 위해선 개별 Task가 16.6ms를 초과하면 안 된다. 소요시간이 16.6ms가 넘어가는 Task가 있는 경우, 해당 Task를 수행하는 도중에는 화면을 다시 그릴 수 없어 프레임 드랍이 생기게 된다. 그럴 경우 성능 분석 결과에서 위와 같이 Frame 섹션에 노란색, 빨간색의 표시가 뜬다. 본격적으로 어떤 Task가 성능 문제를 유발하는지 찾아내기 전에 어느 동작에서 성능 문제가 발생하는지 파악하는데 유용하다.

 

 

 

2. Flame Chart

A. Flame Chart 분석 방법(기본)

플레임 차트는 해당 Task가 어떤 원인에 의해, 어떤 스크립트가 실행되었는지 파악하는데 사용할 수 있다. 맨 위부터 콜스택의 밑바닥이라고 생각할 수 있는데 위 사진에서는 표시된 Task는 wheel 이벤트로서 실행되었고, 이벤트 핸들러로 `addEventListener.passive`가 실행되어 `_wheel` 함수가  `_fire`를 호출하고,  `_fire` 가 `handleZoom`를 호출하는 식으로 함수들이 연쇄적으로 실행된 것으로 이해할 수 있다. 또한 `handleZoom` 함수 부분을 보면 `(anonymous)`가 연이어 두 개가 배치되어 있는데 `handleZoom` 함수 내부에서 두 익명 함수가 순차적으로 호출되었다는 것을 의미한다.

 

 

B. Summary

Flame Chart에서 특정 항목을 클릭하면 해당 Task 또는 함수에 대한 상세정보를 얻을 수 있다.

또한, 하단 Summary 탭에서 @ Layer.js:248:1 링크를 클릭하면 해당 스크립트 파일로 이동해서 살펴볼 수도 있는데 아래와 같이 왼쪽 사이드바 영역을 보면 Layer.js 파일은 konva/lib 폴더 즉, Konva.js에서 정의된 스크립트임을 알 수 있다.

Summary 탭은 플레임 차트에서 특정 항목이 `(anonymous)`일 때 정확히 어떤 함수인지를 파악하거나, 특정 라이브러리에서 호출되는 함수인지 아닌지 파악할 때 유용하다. 예를 들어, 플레임 차트에서 렌더링 시간을 많이 잡아먹는 항목이 @ react-dom, @ react-reconciler이라면 브라우저 성능 탭보다는 React DevTools에서 성능 저하의 원인을 찾는 것이 좋다.  

 

 

C. Bottom-Up

해당 액티비티에 대해 가장 말단 함수(콜스택의 맨 위) 중에서도 Self Time이 0보다 큰 항목들을 보여준다. Self Time과 Total Time의 정의는 아래와 같다. 

Self time
How long it took to complete the current invocation of the function, including only the statements in the function itself, not including any functions that it called.
Total time
The time it took to complete the current invocation of this function and any functions that it called

Self Time과 Total Time의 정의는 이해했는데 그 둘이 플레임 차트에서 어떻게 보여지는 값일까? 플레임 차트에서 특정 액티비티의 하단에 아무것도 없는 부분Self Time, 해당 액티비티의 전체 길이Total Time이라고 생각할 수 있다.

만약, 선택된 액티비티 아래에 동일 액티비티(ex. `fillStrokeShape`)가 둘 이상 존재한다면 Bottom-Up 탭에서는 그 항목들의 총합으로 계산되어 보여진다. 예를 들어, 위 사진에서 `fillStrokeShape`는 왼쪽과 오른쪽에 2개 항목이 존재하는데 `fillStrokeShape`의 Total Time은 왼쪽 0.75ms와 오른쪽 1.20ms의 합인 1.95ms로 계산된다. 반면에 Self Time은 아래처럼 계산되는데 그 둘의 합인 0.57ms(~ 0.6ms)가 된다. 

 

A. 첫번째  `fillStrokeShape`

왼쪽의 `fillStrokeShape`는 자식 액티비티로 `strokeShape`가 있는데 `fillStrokeShape`의 크기와 동일하고 이 함수를 실행하는데 0.75ms(~ 0.8ms)가 소요되었다. `fillStrokeShape` 하단에는 동일한 크기의 `strokeShape`가 존재하고, `strokeShape` 하단에는 어떠한 항목도 없기 때문에 0.75ms는 온전히 `strokeShape`의 Self Time으로 계산된다. 반면에, `fillStrokeShape`의 하단에 빈 영역이 없기 때문에 Self Time은 0ms로 계산된다.

 

B. 두번째  `fillStrokeShape`

오른쪽의 `fillStrokeShape`를 보면 1.20ms에서 앞쪽 절반(0.57ms)은 비어있고 뒤쪽 절반(0.63ms)은 자식 액티비티 `fillShape`가 호출되었음을 볼 수 있는데 이 앞쪽 절반은 `fillStrokeShape`의 Self Time으로 계산된다. 

한편, 이와 비슷한 방식으로 `fillShape`의 Self Time은 0, `_fill`의 Self Time도 0, ... 가장 아래에 하위 액티비티가 없는 `setAttr`의 Self Time과 Total Time은 0.63ms이다.

 

 

 

3. 예시1: 성능 분석 기초

마우스 휠로 캔버스를 확대/축소하는 코드를 작성했다고 해보자. 처음에는 괜찮았지만 캔버스에 그려진 도형이 많아질수록 캔버스를 확대/축소할 때 프레임이 떨어지는 듯한 느낌을 받았다. 이렇게 성능 문제를 느꼈다면 개발자 도구 성능 탭에서 마우스 휠 동작을 몇번 해보고 녹화를 종료해보면 아래와 같은 플레임 차트가 생성된다. 이제 플레임 차트를 살펴보면서 성능 문제의 원인을 찾아보자.

우리가 예상했던 대로 Wheel 이벤트 Task에서 Long Task 경고가 보인다. 콜스택을 쭉 내려다보면 우리에게 익숙한 `handleZoom`이라는 함수가 보인다. 지금 예시처럼 성능 문제의 원인이 유명 함수로 특정된다면 문제가 쉽겠지만 그렇지 않고 `(anonymous)`로 보인다고 해서 당황하지 말자. `(anonymous)` 항목을 클릭해 하단 Summary 탭의 소스 코드 링크를 클릭하면 그 익명 함수가 어떤 함수인지 특정해내는데 도움이 될 수 있다.

handleZoom 함수는 Stage.tsx 77번째 줄에서 확인할 수 있다

익명 함수로 선언된 함수인 경우에 보통 `(anonymous)`로 표시되는데 고차 함수가 반환한 익명 함수이거나, React에서는 아래 `changeScale`처럼 `useCallback`으로 감싼 화살표 함수인 경우일 수 있다. `(anonymous)`가 정확히 어떤 함수인지는 해당 함수의 구현부를 직접 살펴보면서 찾아내는 것이 바람직하다.

const changeScale = useCallback(({ evt }: KonvaEventObject<WheelEvent>) => {
  /* ... */
}, [])

const handleZoom = (e: KonvaEventObject<WheelEvent>) => {
  // Wheel 이벤트가 마우스 휠, 트랙 패드 중 어디에서 발생한 것인지 체크
  const shouldScale = /* ... */
  const shouldTranslate = /* ... */
  
  if (shouldScale) {
    changeScale(e)
  }
}

또한, 어떤 함수가 익명 함수만을 호출하는 게 전부가 아니더라도 콜스택에는 두 함수의 길이가 동일하게 표현될 수 있다. 예를 들어, `handleZoom`처럼 `changeScale`를 호출하기 전에 마우스 휠, 트랙패드 중에서 어떤 타입의 Wheel 이벤트인지 체크하는 코드가 있다고 해보자. 만약, 이 코드의 실행시간이 매우 짧다면 `handleZoom`의 Self Time이 0으로 보일 수 있다. `handleZoom`와 `(anonymous)`로 표시되는 `changeScale` 항목의 크기가 동일한 것은 이 때문이다. 

1. `(anonymous)`는 해당 함수가 호출하는 다른 익명 함수를 나타낸다.
2. 함수 A 내부에 함수 B를 호출하는 코드 외에 다른 코드가 존재하더라도 A, B 항목의 크기(Total Time)은 동일할 수 있다. 

`(anonymous)`의 하위 액티비티로 `setAttrs`와 또다른 `(anonymous)`가 있는데 `setAttrs` 소요시간이 2.66ms로 성능 문제를 유발할 수준은 아니기 때문에 `setAttrs`와 그 하위 액티비티 부분은 무시하고 넘어가도 괜찮을 것 같다. `(anonymous)`의 소요시간이 부모 액티비티 소요시간의 95%를 차지하므로 바로 이 함수가 성능 저하의 원인이다.   

const changeScale = useCallback(({ evt }: KonvaEventObject<WheelEvent>) => {
  // ...
  stage.setAttrs({ /* ... */ })
  utils.viewAttributes([nextOx, nextOy], nextScale)
}, [])

`(anonymous)`이 성능 저하의 원인임을 밝혀낸 것과 동일한 방식으로 문제의 원인을 계속 좁혀나가보자. 앞서 부모 액티비티인 `(anonymous)`가 `changeScale` 함수임을 알았으므로 `setAttrs`와 하위 `(anonymous)`가 무엇인지 파악해보자. `changeScale`에서는 `stage.setAttrs`와 `utils.viewAttributes`를 호출하는데 `stage.setAttrs`는 Konva.js의 `<Stage />`의 속성을 변경하는 함수로 `setAttrs`이다. 따라서 `(anonymous)`는 `utils.viewAttributes`이다.

// utils.viewAttributes의 원본 함수
const setViewAttributes = useCallback((origin: Point2D, scale: number) => {
  const [x, y] = origin

  // 전역 Store를 업데이트하는 함수
  update({ origin: { x, y }, scale })
}, [])

하위 `(anonymous)`인 `utils.viewAttributes`의 자식 액티비티는 `handleStoreChange`, `forceStoreRerender`인데 두 항목을 하단의 Detail 탭을 통해 살펴보면 React 관련 함수임을 알 수 있다. 그 하위 액티비티도 모두 React 관련 함수이기 때문에 플레임 차트를 통해 더 얻을 수 있는 정보는 없어 보인다. 

 

플레임 차트는 성능 저하의 원인을 찾는데 힌트를 줄 뿐, 그 원인까지 정확하게 찾아내주는 은탄환(silver bullet)이 아니다. 정확한 원인은 플레임 차트에서 좁혀낸 원인과 내가 작성한 코드를 같이 들여다보면서 찾아내는 수밖에 없다. 문제의 원인이었던 `utils.viewAttributes`는 내부적으로 전역 Store의 `origin`과 `scale`을 업데이트하는 간단한 함수이다. 이제 이 코드의 역할과 자식 액티비티인 `handleStoreChange`, `forceStoreRerender`라는 퍼즐 조각을 엮어보자. 전역 Store의 `origin`과 `scale`을 업데이트하면 `origin`과 `scale`을 구독하는 컴포넌트들이 재렌더링되는데 그 과정에서 성능 문제가 발생했다는 사실을 추론해낼 수 있다.

 

성능 저하의 원인을 파악했기 때문에 메모이제이션 기법을 통해 전역 Store의 `origin`과 `scale`을 구독하는 컴포넌트를 최소화하는 방법으로 성능 개선이 가능할 것이다. 실질적인 최적화 방법은 사용하는 라이브러리에 따라 달라질 수 있기 때문에 여기서는 다루지 않겠다. 

`handleZoom`이라는 익숙한 함수에서부터 시작해 실행 시간이 오래 걸리는 함수를 소거법으로 찾아내는 것이 원인 파악의 핵심이다.  

 

 

 

4. 예시2: React와 Konva.js

파이프라인 캔버스에 그려진 노드가 50개가 넘어가면 캔버스 이동 미약하게나마 신경 쓰일 정도의 프레임 저하가 느껴졌다. 해당 동작에 대한 프로파일링 결과는 아래와 같다. 

우선 캔버스를 이동시킬 때 발생하는 하나의 Task에 대해 큰 그림을 파악해보자. 하나의 Task는 평균적으로 35~50ms가 소요되는데 그 하위 액티비티로 `Event: mousemove`와 `Animation Frame Fired`가 있다. 두 액티비티의 실행 시간이 거의 동일하기 때문에 성능 문제를 해소하려면 두 액티비티의 실행 시간을 모두 줄여야 한다. 우선 `Event: mousemove`는 캔버스 이동에 관여하는 MouseMove 이벤트임을 쉽게 짐작할 수 있다.

다음 액티비티인 `Animation Frame Fired`가 문제인데 액티비티 이름만 봐서는 무엇을 하는 액티비티인지, 어떤 이벤트에 의해 실행되었는지 알기 어렵다. 대신 그 하위 액티비티를 통해 어떤 항목인지 파악해보도록 하자. `Function Call`을 클릭해보면 Summary 탭에 그 소스가 @ Util.js:358라고 나온다. 

 

이 링크를 클릭하면 아래와 같이 개발자 도구의 Sources 탭으로 이동해서 그 출처를 확인할 수 있는데 왼쪽 탭을 보면 이 스크립트가 konva/lib에 존재하는 파일임을 알 수 있다. 따라서 `Animation Frame Fired`는 Konva.js 라이브러리에서 업데이트된 캔버스를 새로 그릴 때 실행되는 코드라고 추측해볼 수 있다.

 

상황을 정리해보면 우리가 성능 최적화를 진행해야 할 부분은 아래와 같이 크게 두 부문으로 분류해볼 수 있다. 

1. 캔버스를 이동시킬 때 React 상태를 업데이트하고 컴포넌트 트리를 다시 그리는데 소요되는 Task
2. Konva.js에서 캔버스를 다시 그리는데 소요되는 Task

 

 

A. 캔버스 이동 시 React 상태 업데이트 최적화

handleDragMove는 19ms 정도 소요된다
handleDragMove 하단에 Html 액티비티가 여럿 보인다

`handleDragMove` 액티비티의 하단을 쭉 내려다보면 `<Html />` 관련 항목이 20개 정도 있음을 확인할 수 있었다. 참고로 위 사진에서 하단에 보이는 보라색, 분홍색 액티비티가 모두 `<Html />` 항목이다. 이 액티비티들의 Total Time을 모두 더해 보면 2~3ms정도가 되는데 `handleDragMove` Total Time인 19ms의 15% 정도에 해당한다. `<Html />`로 표시되는 항목만 15%이고, 이것과 연관된 액티비티까지 포함한다면 15%보다 더 클 것이라고 생각할 수 있다. 하나의 노드는 3~4개의 `<Html />`를 사용하고 있기 때문에 노드의 수가 늘어날수록 성능 문제는 더 심각해질 것이다.

문제의 원인이 `<Html />`인지 확실히 하기 위해 노드에서 사용하는 `<Html />`를 모두 제거해봤을 때 `handleDragMove`의 Total Time이 19ms에서 0.5ms 수준으로 줄어든 것을 볼 수 있다. 캔버스에 그려진 도형이 줄어든 것도 렌더링 시간이 줄어든 이유 중 하나겠지만 `handleDragMove` 하위에 `handleStoreChange` 액티비티가 더이상 보이지 않는 점으로부터 추론해보면 Store를 구독하는 `<Html />`가 제거된 것도 상당한 지분을 차지할 것으로 볼 수 있다.

 

한편, 예시2는 예시1과 동일하게 `handleStoreChange` 액티비티의 Total Time이 큰데 Store를 구독하는 컴포넌트가 많아서 발생한 문제이다. 하지만 예시1과 달리 플레임 차트를 위에서부터 하나하나 살펴보지 않은 점도 눈여겨보자. 때로는 이렇게 플레임 차트의 하단부터 살펴봄으로써 어느 부분을 수정해야 할지 힌트를 얻을 수 있다.

플레임 차트의 상단부를 분석해 큰 그림을 파악하고, 하단부에 눈여겨볼 만한 특이 사항이 있는지 살펴보는 것이 좋다.  

이제 `<Html />`이 Store 구독을 최소화할 수 있도록 해결책을 생각해보자. `<Html />`의 prop인 `children`은 캔버스 상에서 항상 보이는 HTML 요소가 아니라 특정 이벤트가 발생했을 때만 보이는 요소이다. 지금처럼 해당 요소가 화면에 보이지 않는데도 항상 Store의 `origin`, `scale`을 구독하는 것은 불필요하기 때문에 이렇게 성능 문제를 유발하고 있다. 대신 `<Html />`에 `listening`이라는 prop을 추가해 `listening = true`일 때만 `origin`, `scale`을 구독할 수 있도록 변경해 주었다.

interface HtmlProps {
  listening?: boolean
  // ...
}

const Html = ({ listening = true }: HtmlProps) => {
  // listening = true일 때만 origin, scale 값을 구독하도록 변경
  const origin = useStageOrigin(listening)
  const scale = useStageScale(listening)
  
  /* ... */ 
}

그 결과 `handleDragMove`의 Total Time이 19ms에서 4ms로 렌더링 시간이 80% 개선되었다.  

여기서 더 나아가 `<Html />`에서 Store 구독을 완전히 제거하고, `<Stage />`의 속성으로 `origin`, `scale` 값을 얻어 위치를 계산하는 방법으로 변경해 보았다. 아래와 같이 `<Html />`의 `transformFunc` 함수 본문에서 `getAttributes`를 이용해 `origin`, `scale`를 얻어 다음 HTML 요소를 나타낼 `x`, `y`를 구할 수 있다.

import { HtmlTransformAttrs } from 'react-konva-utils'

const transformRelativeToStage = (a: HtmlTransformAttrs) => {
  if (!listening) {
    return { ...a, ...INITIAL_HTML_TRANSFORM }
  }
  const { origin, gutter, scale } = getAttributes()
  /* ... */

  return { ...a, x, y }
}

2차로 성능 개선을 한 결과 `handleDragMove`의 Total Time은 0.5ms 정도로 `<Html />`을 모두 제거했을 때와 비슷한 결과를 얻을 수 있었다. 최종적으로 `Event: mousemove`의 Total Time은 25~30ms에서 4~5ms로 70~80%만큼 성능이 대폭 개선되었다. 

 

 

B. Konva.js에서 캔버스를 그리는 작업 최적화

Konva.js에서는 성능 개선을 위한 다양한 팁을 소개하고 있다. 그중에서 이벤트 구독이 필요 없는 도형들에 대해 `listening = false`로 Hit Detection을 끄는 방법과 편집 중인 도형의 레이어를 분리해 나머지 도형들은 다시 그려지지 않도록 하는 방법은 이미 프로젝트에 적용되어 있다. 더 적용해볼 만한 방법은 도형 캐싱과 화면에 보이지 않는 도형이 캔버스에서 보이지 않도록 하는 방법이 있다.

 

1. 캐싱

도형 캐싱을 한번 적용해보기로 했다. 캐싱을 단순하게 적용하기에는 캔버스 확대/축소가 가능하기 때문에 노드 컴포넌트 마운트 후에 적절한 시점에 노드의 캐시를 제거해주는 과정도 필요하다. 그래서, 노드 마운트 후 `useEffect`에서 캐시를 생성하고, 노드의 `MouseEnter` 이벤트 핸들러에서 캐시를 제거, `MouseLeave` 이벤트 핸들러에서 다시 캐시를 생성해주는 방식을 적용해봤다. 프로파일링 결과는 아래와 같다.

`Animation Frame Fired`의 Total Time은 10~12ms에서 3~4ms로 60% 가까이 개선되었다. 하지만 파이프라인 캔버스에서 노드 컴포넌트는 해당 노드가 포커스되어 있지 않더라도 노드에서 보여줄 정보가 업데이트되어야 하는 경우가 많았기 때문에 캐싱을 적용하고, 해제하는 것이 까다로웠다. 따라서 캐싱은 적용하지 않기로 했다.

 

2. 화면에 보이지 않는 도형 감추기

두 번째로 화면에 보이지 않는 도형에 대해 `isVisible` 속성으로 캔버스에서 보이지 않도록 하는 방법을 고려해봤다. 현재 파이프라인 캔버스에서는 캔버스 이동, 확대/축소 동작으로 노드가 화면에 보이고 사라지기 때문에 개별 노드 컴포넌트에서 `origin`, `scale`을 구독해야 한다. 하지만 앞서 `<Html />`에서 `origin`, `scale` 구독을 제거했는데 노드에서 다시 `origin`, `scale`를 구독하는 건 말이 안 되기 때문에 다른 방법을 고민해보기로 했다. 

대신 노드가 그려진 레이어의 `draw` 이벤트에서 노드의 위치와 Stage 원점의 위치를 비교해서 화면 안에 있으면 `node.visible(true)`로 화면에 보여주고, 아니라면 `node.visible(false)`로 감추는 방법은 가능할 것이다. 하지만 이 방법은 캔버스를 전부 그리고 나서 캔버스 외부로 나간 요소를 감추는 연산이 추가적으로 필요하기 때문에 성능 개선을 보기는 어려울 것 같다. 캐싱을 적용하는 방법보다는 간단하겠지만 실제 적용은 조금 더 고민이 필요할 것으로 보인다.

 


React 렌더링 최적화만 진행하고 Konva.js 캔버스 렌더링 최적화는 적용하지 못한 점은 아쉽다. 하지만 최종적으로 캔버스 이동 시마다 실행되는 하나의 Task가 소요되는 시간을 35~50ms에서 15~20ms로 줄여 45~60% 성능 개선 효과를 얻을 수 있었다. 

 

 

 

 

 

 

 

 

[참고]

Difference between 'self' and 'total' in Chrome CPU Profile of JS