perf-ception을 통해 400% 더 빠른 성능 패널

Andrés Olivares
Andrés Olivares
Nancy Li
Nancy Li

개발 중인 애플리케이션 유형에 관계없이 성능을 최적화하고 빠르게 로드되며 원활한 상호작용을 제공하도록 하는 것은 사용자 환경과 애플리케이션의 성공을 위해 매우 중요합니다. 이를 수행하는 한 가지 방법은 프로파일링 도구를 사용하여 애플리케이션의 활동을 검사하여 특정 기간 동안 실행되는 동안 내부에서 어떤 일이 일어나는지 확인하는 것입니다. DevTools의 Performance 패널은 웹 애플리케이션의 성능을 분석하고 최적화하는 데 유용한 프로파일링 도구입니다. 앱이 Chrome에서 실행 중인 경우 애플리케이션이 실행되는 동안 브라우저가 수행하는 작업에 대한 자세한 시각적 개요를 제공합니다. 이 활동을 이해하면 패턴, 병목 현상, 성능 핫스팟을 파악하여 성능을 개선할 수 있습니다.

다음 예에서는 Performance(성능) 패널을 사용하는 방법을 설명합니다.

프로파일링 시나리오 설정 및 재생성

최근에는 Performance(성능) 패널의 성능을 향상한다는 목표를 세웠습니다. 특히 대량의 성능 데이터를 더 빠르게 로드하고자 했습니다. 예를 들어 장기 실행되거나 복잡한 프로세스를 프로파일링하거나 고도로 세분화된 데이터를 캡처하는 경우가 여기에 해당합니다. 이를 위해서는 애플리케이션의 성능 방식과 애플리케이션 작동 이유를 먼저 이해해야 했으며, 이를 위해 프로파일링 도구를 사용했습니다.

아시다시피 DevTools 자체는 웹 애플리케이션입니다. 따라서 Performance 패널을 사용하여 프로파일링할 수 있습니다. 이 패널 자체를 프로파일링하려면 DevTools를 연 다음 연결된 다른 DevTools 인스턴스를 열면 됩니다. Google에서는 이 설정을 DevTools-on-DevTools라고 합니다.

설정이 준비되면 프로파일링할 시나리오를 재생성하고 기록해야 합니다. 혼동을 피하기 위해 원래 DevTools 창을 '첫 번째 DevTools 인스턴스'라고 하며 첫 번째 인스턴스를 검사하는 창을 '두 번째 DevTools 인스턴스'라고 합니다.

DevTools 자체에서 요소를 검사하는 DevTools 인스턴스의 스크린샷
DevTools-on-DevTools: DevTools로 DevTools 검사

두 번째 DevTools 인스턴스에서 Performance 패널(여기서부터 perf 패널이라고 함)은 첫 번째 DevTools 인스턴스를 관찰하여 시나리오를 다시 만들고, 프로필을 로드합니다.

두 번째 DevTools 인스턴스에서 실시간 기록이 시작되고 첫 번째 인스턴스에서는 디스크의 파일에서 프로필이 로드됩니다. 큰 입력 처리 성능을 정확하게 프로파일링하기 위해 대용량 파일이 로드됩니다. 두 인스턴스 모두 로드를 완료하면 프로필을 로드하는 perf 패널의 두 번째 DevTools 인스턴스에 일반적으로 trace라고 하는 성능 프로파일링 데이터가 표시됩니다.

초기 상태: 개선 기회 식별

로드가 완료되면 다음 스크린샷에서 두 번째 perf 패널 인스턴스에서 다음이 관찰되었습니다. Main 라벨이 지정된 트랙 아래에 표시되는 기본 스레드의 활동에 포커스를 둡니다. Flame Chart에는 5개의 큰 활동 그룹이 있는 것을 볼 수 있습니다. 이러한 작업은 로드에 가장 많은 시간이 걸리는 작업으로 구성됩니다. 이러한 작업의 총시간은 약 10초였습니다. 다음 스크린샷에서 실적 패널을 사용하여 이러한 각 활동 그룹에 초점을 맞추고 무엇을 찾을 수 있는지 확인합니다.

다른 DevTools 인스턴스의 성능 패널에서 성능 트레이스의 로드를 검사하는 DevTools의 성능 패널 스크린샷 프로필을 로드하는 데 10초 정도 걸립니다. 이 시간은 주로 다섯 가지 주요 활동 그룹으로 나뉩니다.

첫 번째 활동 그룹: 불필요한 작업

첫 번째 활동 그룹은 여전히 실행은 되지만 실제로는 필요하지 않은 레거시 코드라는 것이 분명해졌습니다. 기본적으로 processThreadEvents라는 라벨이 지정된 녹색 블록 아래의 모든 작업은 노력의 낭비였습니다. 바로 승리입니다. 이 함수 호출을 삭제하면 약 1.5초의 시간이 절약되었습니다. 아주 잘 분석하죠!

두 번째 활동 그룹

두 번째 활동 그룹에서는 해결 방법이 첫 번째 활동에서처럼 간단하지 않았습니다. buildProfileCalls는 약 0.5초가 걸렸으며, 이 작업은 피할 수 없는 작업이 아니었습니다.

DevTools에서 다른 성능 패널 인스턴스를 검사하는 성능 패널의 스크린샷 buildProfileCalls 함수와 연관된 작업에는 약 0.5초가 걸립니다.

더 자세히 조사하기 위해 perf 패널의 Memory 옵션을 사용 설정했으며 buildProfileCalls 활동도 많은 메모리를 사용하고 있음을 확인했습니다. 여기에서 buildProfileCalls가 실행되는 시간에서 파란색 선 그래프가 갑자기 어떻게 이동하는지 볼 수 있습니다. 이는 메모리 누수 가능성을 시사합니다.

성능 패널의 메모리 소비를 평가하는 DevTools의 메모리 프로파일러 스크린샷 검사기는 buildProfileCalls 함수가 메모리 누수의 원인임을 보여줍니다.

이 의심에 대한 후속 조치를 취하기 위해 우리는 Memory 패널 (DevTools의 또 다른 패널, perf 패널의 Memory 창과 다른 패널)을 사용하여 조사했습니다. Memory 패널에서 'Allocation 샘플링' 프로파일링 유형이 선택되어 있으며, 이 유형은 CPU 프로필을 로드하는 perf 패널의 힙 스냅샷을 기록합니다.

메모리 프로파일러의 초기 상태 스크린샷 '할당 샘플링' 옵션이 빨간색 상자로 강조표시되어 있으며 이 옵션이 JavaScript 메모리 프로파일링에 가장 적합하다는 것을 나타냅니다.

다음 스크린샷은 수집된 힙 스냅샷을 보여줍니다.

메모리를 많이 사용하는 Set 기반 작업이 선택된 메모리 프로파일러의 스크린샷

이 힙 스냅샷에서 Set 클래스가 많은 메모리를 소비하고 있는 것이 관찰되었습니다. 호출 포인트를 확인한 결과, 대량으로 생성된 객체에 Set 유형의 속성을 불필요하게 할당하는 것으로 확인되었습니다. 이 비용이 증가하고 메모리를 많이 소비하여 큰 입력에서 애플리케이션이 다운되는 경우가 흔했습니다.

세트는 고유 항목을 저장하는 데 유용하며, 중복 데이터 세트 삭제, 더 효율적인 조회 제공과 같이 콘텐츠의 고유성을 사용하는 작업을 제공합니다. 그러나 저장된 데이터가 소스에서 고유하다는 것이 보장되었으므로 이러한 특성은 필요하지 않았습니다. 따라서 애초에 세트가 필요하지 않았습니다. 메모리 할당을 개선하기 위해 속성 유형을 Set에서 일반 배열로 변경했습니다. 이 변경 사항을 적용한 후에 또 다른 힙 스냅샷이 만들어졌고 메모리 할당이 줄어든 것이 관찰되었습니다. 이 변경으로 인해 속도가 상당히 개선되지 않았음에도 불구하고, 부수적인 이점은 애플리케이션의 비정상 종료 빈도가 줄었다는 점입니다.

메모리 프로파일러의 스크린샷 이전의 메모리 집약적인 Set 기반 작업은 일반 배열을 사용하도록 변경되어 메모리 비용이 크게 절감되었습니다.

세 번째 활동 그룹: 데이터 구조 장단점 측정

세 번째 섹션은 특이합니다. Flame Chart는 좁지만 긴 열(딥 함수 호출을 나타내는 열과 이 경우 딥 재귀)으로 구성되어 있는 것을 볼 수 있습니다. 이 섹션은 총 1.4초 동안 지속되었습니다. 이 섹션의 하단을 보면 이러한 열의 너비는 함수 appendEventAtLevel의 지속 시간에 따라 결정되었음을 알 수 있습니다. 이는 병목 현상일 수 있음을 시사했습니다.

appendEventAtLevel 함수 구현 내에서 한 가지 눈에 띄는 점이 있습니다. 입력의 모든 단일 데이터 항목 (코드에서는 '이벤트'라고 함)에 대해 타임라인 항목의 세로 위치를 추적하는 항목이 지도에 추가되었습니다. 저장된 항목의 양이 매우 많았기 때문에 문제가 발생했습니다. 지도의 키 기반 조회는 빠르지만 이러한 이점은 무료로 제공되지 않습니다. 예를 들어 지도가 커지면 데이터를 추가하면 다시 해싱하여 비용이 많이 들 수 있습니다. 많은 양의 항목이 연속해서 지도에 추가되는 경우 이 비용이 현저하게 증가합니다.

/**
 * Adds an event to the flame chart data at a defined vertical level.
 */
function appendEventAtLevel (event, level) {
  // ...

  const index = data.length;
  data.push(event);
  this.indexForEventMap.set(event, index);

  // ...
}

Flame Chart의 모든 항목에 대해 지도에 항목을 추가할 필요가 없는 또 다른 접근 방식을 실험했습니다. 상당한 개선이 있었으며, 병목 현상이 실제로 모든 데이터를 지도에 추가할 때 발생하는 오버헤드와 관련되어 있다는 것을 확인할 수 있었습니다. 활동 그룹이 약 1.4초에서 약 200밀리초로 줄어든 시간입니다.

변경 전:

addEventAtLevel 함수를 최적화하기 전의 성능 패널 스크린샷 함수가 실행되는 데 걸린 총 시간은 1,372.51밀리초였습니다.

변경 후:

addEventAtLevel 함수에 대해 최적화를 수행한 후 성능 패널의 스크린샷 함수가 실행되는 데 걸린 총 시간은 207.2밀리초였습니다.

네 번째 활동 그룹: 중복 작업 방지를 위해 중요하지 않은 작업 및 캐시 데이터 지연

이 창을 확대하면 거의 동일한 두 개의 함수 호출 블록이 있음을 확인할 수 있습니다. 호출된 함수의 이름을 보면 이러한 블록이 트리를 구축하는 코드 (예: refreshTree 또는 buildChildren와 같은 이름)로 구성되었음을 추론할 수 있습니다. 실제로 관련 코드는 패널의 하단 창에 트리 뷰를 만드는 코드입니다. 흥미로운 점은 이러한 트리 뷰가 로드 직후에 표시되지 않는다는 것입니다. 대신 사용자는 트리를 표시할 트리 뷰 (창의 'Bottom-up', 'Call Tree' 및 'Event Log' 탭)를 선택해야 합니다. 또한 스크린샷에서 알 수 있듯이 트리 빌딩 프로세스가 두 번 실행되었습니다.

필요하지 않은 경우에도 실행되는 여러 반복 작업을 보여주는 성능 패널의 스크린샷 이러한 작업은 지연되어 미리가 아니라 온디맨드 방식으로 실행할 수 있습니다.

이 그림에서 확인된 두 가지 문제가 있습니다.

  1. 중요하지 않은 작업으로 인해 로드 시간 성능이 저하되고 있었습니다. 사용자에게 항상 출력이 필요하지는 않습니다. 따라서 이 작업은 프로필 로드에 중요하지 않습니다.
  2. 이러한 태스크의 결과는 캐시되지 않았습니다. 따라서 데이터는 변경되지 않았음에도 불구하고 수목을 두 번 계산한 것입니다.

사용자가 트리 보기를 수동으로 열었을 때까지 트리 계산을 지연하는 것부터 시작했습니다. 그래야만 이 나무를 만드는 대가를 지불할 가치가 있습니다. 이를 두 번 실행하는 총 시간이 약 3.4초였으므로 지연하면 로드 시간에 상당한 차이가 있었습니다. 이러한 유형의 작업을 캐시하는 방법도 아직 검토 중입니다.

다섯 번째 활동 그룹: 가능하면 복잡한 호출 계층 구조 지양

이 그룹을 자세히 살펴보면 특정 호출 체인이 반복적으로 호출된 것이 분명했습니다. 동일한 패턴이 Flame Chart의 여러 위치에서 6번 나타났으며, 이 기간의 총 시간은 약 2.4초였습니다.

동일한 트레이스 미니맵을 생성하기 위한 6개의 개별 함수 호출을 보여주는 성능 패널의 스크린샷(각각 딥 호출 스택이 있음)

여러 번 호출되는 관련 코드는 '미니맵' (패널 상단의 타임라인 활동 개요)에서 렌더링될 데이터를 처리하는 부분입니다. 왜 여러 번 발생했는지 불분명했지만 분명히 6번만 할 필요는 없었습니다. 실제로, 다른 프로필이 로드되지 않는 경우 코드 출력은 최신 상태로 유지되어야 합니다. 이론적으로 코드는 한 번만 실행해야 합니다.

조사 결과, 로드 파이프라인의 여러 부분이 미니맵을 계산하는 함수를 직간접적으로 호출한 결과로 관련 코드가 호출된 것으로 확인되었습니다. 이는 프로그램 호출 그래프의 복잡성이 시간이 지남에 따라 진화하고 이 코드에 대한 더 많은 종속 항목이 자신도 모르게 추가되었기 때문입니다. 이 문제에 대한 빠른 해결 방법은 없습니다. 이 문제를 해결하는 방법은 해당 코드베이스의 아키텍처에 따라 다릅니다. 여기서는 호출 계층 구조의 복잡성을 약간 줄이고 입력 데이터가 변경되지 않은 상태로 유지되면 코드 실행을 방지하는 검사를 추가해야 했습니다. 이를 구현한 후 다음과 같이 타임라인을 살펴봤습니다.

2회로만 줄어든 동일한 트레이스 미니맵을 생성하는 6개의 개별 함수 호출을 보여주는 성능 패널의 스크린샷

미니맵 렌더링 실행은 한 번이 아니라 두 번 실행됩니다. 이는 모든 프로필에 두 개의 미니맵이 그려져 있기 때문입니다. 하나는 패널 상단의 개요용이고 다른 하나는 기록에서 현재 표시된 프로필을 선택하는 드롭다운 메뉴용입니다 (이 메뉴의 모든 항목에는 선택한 프로필의 개요가 포함됨). 그럼에도 불구하고 이 둘은 콘텐츠가 완전히 같으므로 하나를 다른 것에 재사용할 수 있어야 합니다.

이러한 미니맵은 모두 캔버스에 그려지는 이미지이므로 drawImage 캔버스 유틸리티를 사용한 후 추가 시간을 절약하기 위해 코드를 한 번만 실행하면 됩니다. 이러한 노력의 결과, 그룹의 시간이 2.4초에서 140밀리초로 단축되었습니다.

결론

이러한 수정사항 (및 여기 저기의 몇 가지 작은 수정사항 포함)을 적용한 후 프로필 로드 타임라인의 변경사항은 다음과 같습니다.

변경 전:

최적화 전 트레이스 로드를 보여주는 성능 패널 스크린샷 이 프로세스는 약 10초가 소요되었습니다.

변경 후:

최적화 후 트레이스 로드를 보여주는 성능 패널 스크린샷 이제 이 프로세스에 약 2초가 걸립니다.

개선 후 로드 시간은 2초였습니다. 즉, 대부분의 작업이 간단한 수정으로 이루어졌기 때문에 비교적 적은 노력으로 약 80%의 개선을 달성했습니다. 물론 처음에 해야 할 작업을 제대로 파악하는 것이 중요했으며 perf 패널이 이 작업에 적합한 도구였습니다.

또한 이러한 수치는 연구 대상으로 사용되는 프로필과 관련이 있다는 점을 강조하는 것이 중요합니다. 프로필은 크기가 매우 크다는 점이 흥미로웠습니다. 그럼에도 불구하고, 처리 파이프라인은 모든 프로필에 대해 동일하기 때문에 달성한 상당한 개선은 perf 패널에 로드된 모든 프로필에 적용됩니다.

테이크어웨이

애플리케이션의 성능 최적화라는 측면에서 이러한 결과에서 얻어야 할 몇 가지 교훈이 있습니다.

1. 프로파일링 도구를 활용하여 런타임 성능 패턴 파악

프로파일링 도구는 애플리케이션이 실행되는 동안 어떤 일이 일어나는지 이해하고, 특히 성능을 개선할 기회를 식별하는 데 매우 유용합니다. Chrome DevTools의 Performance 패널은 브라우저의 기본 웹 프로파일링 도구이며 최신 웹 플랫폼 기능으로 최신 상태를 유지하도록 활발히 유지되고 있으므로 웹 애플리케이션을 위한 훌륭한 옵션입니다. 또한 속도가 훨씬 빨라졌습니다. 😉

대표적인 워크로드로 사용할 수 있는 샘플을 사용해 보고 결과를 확인해 보세요.

2. 복잡한 호출 계층 구조 피하기

가능하면 호출 그래프를 너무 복잡하게 만들지 않는 것이 좋습니다. 호출 계층 구조가 복잡하면 성능 회귀를 도입하기가 쉽지만 코드가 제대로 실행되는 이유를 이해하기 어려우므로 개선이 어렵습니다.

3. 불필요한 작업 식별

오래된 코드베이스에는 더 이상 필요하지 않은 코드가 포함되는 것이 일반적입니다. 여기서는 기존 코드와 불필요한 코드가 총 로드 시간의 상당 부분을 차지했습니다. 그것을 제거하는 것이 가장 쉬운 열매였습니다.

4. 적절한 데이터 구조 사용

데이터 구조를 사용하여 성능을 최적화하되, 사용할 데이터 구조를 결정할 때 각 데이터 구조 유형의 비용과 장단점을 파악하세요. 이는 데이터 구조 자체의 공간 복잡도뿐만 아니라 관련 연산의 시간 복잡도이기도 합니다.

5. 복잡하거나 반복적인 작업의 중복 작업을 피하기 위해 결과를 캐시합니다.

이 작업을 실행하는 데 비용이 많이 드는 경우 다음에 필요할 때를 위해 결과를 저장하는 것이 좋습니다. 또한, 각 개별 시간에 특별히 큰 비용이 들지 않더라도 작업이 여러 번 실행되는 경우에도 이 방법을 사용하는 것이 좋습니다.

6. 중요하지 않은 작업 연기

작업 출력이 즉시 필요하지 않고 작업 실행이 주요 경로를 확장하는 경우 출력이 실제로 필요할 때 작업을 느리게 호출하여 작업을 연기하는 것이 좋습니다.

7. 대량 입력에 효율적인 알고리즘 사용

대규모 입력의 경우 최적의 시간 복잡도 알고리즘이 매우 중요합니다. 이 예에서는 이 카테고리를 살펴보지 않았지만 중요성을 아무리 강조해도 지나치지 않습니다.

8. 보너스: 파이프라인 벤치마크

진화하는 코드를 빠르게 유지하려면 동작을 모니터링하고 표준과 비교하는 것이 현명합니다. 이를 통해 회귀를 사전에 식별하고 전반적인 안정성을 개선하여 장기적인 성공을 거둘 수 있습니다.