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 인스턴스'로 지칭합니다.

<ph type="x-smartling-placeholder">
</ph> DevTools 자체에서 요소를 검사하는 DevTools 인스턴스의 스크린샷 <ph type="x-smartling-placeholder">
</ph> DevTools-on-DevTools: DevTools로 DevTools 검사

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

<ph type="x-smartling-placeholder">

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

초기 상태: 개선 기회 파악

로드가 완료되면 두 번째 성능 패널 인스턴스에서 다음 스크린샷이 관찰되었습니다. 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 패널 (perf 패널의 Memory 창과는 다른 DevTools의 다른 패널)을 사용하여 조사했습니다. 메모리 패널 내의 '할당 샘플링' 프로파일링 유형이 선택되어 CPU 프로필을 로드하는 성능 패널의 힙 스냅샷이 기록되었습니다.

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

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

<ph type="x-smartling-placeholder">
메모리를 많이 사용하는 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);

  // ...
}

플레임 차트의 모든 항목에 대해 지도에 항목을 추가할 필요가 없는 또 다른 접근 방식을 실험했습니다. 이러한 개선 결과는 병목 현상 문제가 실제로 모든 데이터를 지도에 추가할 때 발생하는 오버헤드와 관련이 있음을 확인할 수 있는 중요한 결과였습니다. 액티비티 그룹이 약 1.4초에서 약 200밀리초로 단축된 시간입니다.

변경 전:

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

변경 후:

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

네 번째 활동 그룹: 중요하지 않은 작업을 연기하고 데이터를 캐시하여 중복 작업 방지

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

필요하지 않더라도 실행되는 여러 반복적인 작업을 보여주는 성능 패널의 스크린샷 이러한 작업은 미리가 아니라 필요에 따라 실행되도록 지연될 수 있습니다.

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

  1. 중요하지 않은 작업이 로드 시간의 성능을 저하시켰습니다. 사용자에게 항상 출력이 필요한 것은 아닙니다. 따라서 이 작업은 프로필 로드에 중요하지 않습니다.
  2. 이러한 작업의 결과가 캐시되지 않았습니다. 데이터가 변경되지 않았음에도 불구하고 나무 수를 두 번 계산한 것입니다.

사용자가 트리 보기를 수동으로 연 시점까지 트리 계산을 연기하는 것으로 시작했습니다. 그래야만 이 나무를 만드는 대가를 지불할 가치가 있습니다. 이를 두 번 실행하는 데 걸린 총 시간이 약 3.4초였으므로 지연하면 로드 시간이 크게 달라집니다. 이러한 유형의 작업도 캐시하는 방법을 아직 찾고 있습니다.

다섯 번째 액티비티 그룹: 가능하면 복잡한 호출 계층 구조를 사용하지 마세요.

이 그룹을 자세히 살펴보면 특정 호출 체인이 반복적으로 호출되고 있음이 분명했습니다. 플레임 차트의 여러 위치에서 동일한 패턴이 6번 나타났으며, 이 기간의 총 지속 시간은 약 2.4초였습니다.

동일한 트레이스 미니맵을 생성하기 위한 6개의 개별 함수 호출을 보여주는 성능 패널의 스크린샷. 각 함수 호출에는 깊은 호출 스택이 있습니다.

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

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

동일한 트레이스 미니맵을 생성하기 위한 6개의 개별 함수 호출이 단 2회로 축소된 성능 패널의 스크린샷

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

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

결론

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

변경 전:

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

변경 후:

<ph type="x-smartling-placeholder">
</ph> 최적화 후 트레이스 로드를 보여주는 성능 패널의 스크린샷 이제 이 프로세스는 약 2초가 소요됩니다.
<ph type="x-smartling-placeholder">

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

또한 이러한 수치는 연구의 과목으로 사용되는 프로필에 국한된다는 점을 강조하는 것이 중요합니다. 이 프로필은 특히 규모가 크기 때문에 매력적이었습니다. 그럼에도 불구하고, 처리 파이프라인은 모든 프로필에 대해 동일하기 때문에, 상당한 개선이 이루어졌다면 성능 패널에 로드된 모든 프로필에 적용됩니다.

요약

애플리케이션의 성능 최적화 측면에서 다음과 같은 몇 가지 교훈을 얻을 수 있습니다.

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

프로파일링 도구는 애플리케이션이 실행되는 동안 발생하는 상황을 이해하고, 특히 성능 개선 기회를 파악하는 데 매우 유용합니다. Chrome DevTools의 Performance 패널은 브라우저의 네이티브 웹 프로파일링 도구이므로 웹 애플리케이션에 매우 적합한 옵션입니다. 이 패널은 최신 웹 플랫폼 기능을 최신 상태로 유지하기 위해 활발히 관리되고 있습니다. 속도가 훨씬 빨라졌습니다. 😉

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

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

가능하면 호출 그래프를 너무 복잡하게 만들지 마세요. 호출 계층 구조가 복잡하면 성능 회귀가 발생하기 쉽고 코드가 제대로 실행되는 이유를 이해하기 어렵기 때문에 개선사항을 적용하기가 어렵습니다.

3. 불필요한 작업 식별

오래된 코드베이스에는 일반적으로 더 이상 필요하지 않은 코드가 포함됩니다. 이 사례에서는 레거시 및 불필요한 코드가 총 로드 시간의 상당 부분을 차지했습니다. 그것을 없애는 것이 가장 쉬운 과일이었습니다.

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

데이터 구조를 사용하여 성능을 최적화할 수 있을 뿐만 아니라, 사용할 데이터 구조를 결정할 때 각 유형의 데이터 구조로 인해 발생하는 비용과 장단점을 파악하세요. 이는 데이터 구조 자체의 공간 복잡성뿐만 아니라 적용 가능한 작업의 시간 복잡성이기도 합니다.

5. 결과를 캐시하여 복잡하거나 반복적인 작업을 위한 중복 작업을 방지합니다.

작업을 실행하는 데 비용이 많이 드는 경우 다음에 필요할 때 사용할 수 있도록 결과를 저장하는 것이 합리적입니다. 각 개별 시간에 특별히 많은 비용이 들지 않더라도 작업이 여러 번 수행되는 경우에도 이 작업을 수행하는 것이 합리적입니다.

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

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

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

대용량 입력의 경우 최적의 시간 복잡도 알고리즘이 중요합니다. 이 예에서는 이 카테고리를 고려하지 않았지만 중요성은 아무리 강조해도 지나치지 않습니다.

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

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