개발 중인 애플리케이션 유형과 관계없이 성능을 최적화하고 빠르게 로드되며 원활한 상호작용을 제공하는 것은 사용자 환경과 애플리케이션의 성공에 매우 중요합니다. 이를 수행하는 한 가지 방법은 프로파일링 도구를 사용하여 애플리케이션의 활동을 검사하고 시간 범위 동안 애플리케이션이 실행될 때 내부에서 어떤 일이 일어나고 있는지 확인하는 것입니다. DevTools의 성능 패널은 웹 애플리케이션의 성능을 분석하고 최적화하는 데 유용한 프로파일링 도구입니다. 앱이 Chrome에서 실행 중인 경우 애플리케이션이 실행될 때 브라우저에서 실행 중인 작업을 시각적으로 자세히 파악할 수 있습니다. 이 활동을 이해하면 성능을 개선하기 위해 조치를 취할 수 있는 패턴, 병목 현상, 성능 핫스팟을 파악하는 데 도움이 됩니다.
다음 예에서는 실적 패널을 사용하는 방법을 안내합니다.
프로파일링 시나리오 설정 및 다시 만들기
최근 YouTube는 실적 패널의 성능을 개선하기 위한 목표를 세웠습니다. 특히 대량의 성능 데이터를 더 빠르게 로드하고자 했습니다. 예를 들어 장기 실행 또는 복잡한 프로세스를 프로파일링하거나 고정밀도 데이터를 캡처하는 경우 이러한 문제가 발생합니다. 이를 위해 먼저 애플리케이션이 실행되는 방식과 이러한 방식으로 실행되는 이유를 이해해야 했습니다. 이는 프로파일링 도구를 사용하여 달성했습니다.
아시다시피 DevTools 자체는 웹 애플리케이션입니다. 따라서 성능 패널을 사용하여 프로파일링할 수 있습니다. 이 패널 자체를 프로파일링하려면 DevTools를 연 다음 DevTools에 연결된 다른 DevTools 인스턴스를 열면 됩니다. Google에서는 이러한 구성을 DevTools-on-DevTools라고 합니다.
설정이 완료되면 프로파일링할 시나리오를 다시 만들어 기록해야 합니다. 혼란을 피하기 위해 원래 DevTools 창은 '첫 번째 DevTools 인스턴스'라고 하고 첫 번째 인스턴스를 검사하는 창은 '두 번째 DevTools 인스턴스'라고 합니다.
두 번째 DevTools 인스턴스에서 성능 패널(이후 성능 패널이라고 함)은 첫 번째 DevTools 인스턴스를 관찰하여 프로필을 로드하는 시나리오를 다시 만듭니다.
두 번째 DevTools 인스턴스에서는 실시간 녹화가 시작되고 첫 번째 인스턴스에서는 디스크의 파일에서 프로필이 로드됩니다. 대용량 입력 처리의 성능을 정확하게 프로파일링하기 위해 대용량 파일이 로드됩니다. 두 인스턴스의 로드가 완료되면 일반적으로 트레이스라고 하는 성능 프로파일링 데이터가 프로필을 로드하는 성능 패널의 두 번째 DevTools 인스턴스에 표시됩니다.
초기 상태: 개선 기회 파악
로드가 완료된 후 다음 스크린샷에서 두 번째 성능 패널 인스턴스에 다음과 같은 문제가 관찰되었습니다. 기본 라벨이 지정된 트랙 아래에 표시되는 기본 스레드의 활동에 집중합니다. 플레임 차트에는 5개의 큰 활동 그룹이 있습니다. 로드하는 데 가장 많은 시간이 걸리는 태스크로 구성됩니다. 이러한 태스크의 총 시간은 약 10초였습니다. 다음 스크린샷에서는 성능 패널을 사용하여 이러한 각 활동 그룹에 초점을 맞춰 확인할 수 있는 항목을 확인합니다.
첫 번째 활동 그룹: 불필요한 작업
첫 번째 활동 그룹은 여전히 실행되지만 실제로는 필요하지 않은 기존 코드인 것으로 확인되었습니다. 기본적으로 processThreadEvents
로 라벨이 지정된 녹색 블록 아래의 모든 항목은 낭비된 노력이었습니다. 그건 빠른 해결이었습니다. 이 함수 호출을 삭제하여 약 1.5초의 시간을 절약했습니다. 아주 잘 분석하죠!
두 번째 활동 그룹
두 번째 활동 그룹에서는 첫 번째 그룹만큼 간단한 해결 방법이 없었습니다. buildProfileCalls
는 약 0.5초가 소요되었으며 이 작업은 피할 수 없는 작업이었습니다.
궁금해서 성능 패널에서 메모리 옵션을 사용 설정하여 자세히 살펴본 결과 buildProfileCalls
활동도 많은 메모리를 사용하고 있는 것을 확인했습니다. 여기에서 buildProfileCalls
가 실행될 때 파란색 선 그래프가 갑자기 점프하는 것을 볼 수 있습니다. 이는 잠재적인 메모리 누수를 나타냅니다.
이 의심을 확인하기 위해 메모리 패널 (DevTools의 다른 패널로, 성능 패널의 메모리 창과는 다름)을 사용하여 조사했습니다. 메모리 패널 내에서 '할당 샘플링' 프로파일링 유형이 선택되어 CPU 프로필을 로드하는 성능 패널의 힙 스냅샷을 기록했습니다.
다음 스크린샷은 수집된 힙 스냅샷을 보여줍니다.
이 힙 스냅샷에서 Set
클래스가 많은 메모리를 사용하고 있는 것으로 확인되었습니다. 호출 지점을 확인한 결과 대량으로 생성된 객체에 Set
유형의 속성이 불필요하게 할당되고 있는 것으로 확인되었습니다. 이 비용이 누적되고 많은 메모리가 소비되어 대규모 입력 시 애플리케이션이 비정상 종료되는 경우가 많았습니다.
세트는 고유한 항목을 저장하는 데 유용하며 데이터 세트 중복 삭제, 더 효율적인 조회 제공과 같이 콘텐츠의 고유성을 사용하는 작업을 제공합니다. 그러나 저장된 데이터가 소스에서 고유한 것으로 보장되므로 이러한 기능은 필요하지 않았습니다. 따라서 처음부터 세트는 필요하지 않았습니다. 메모리 할당을 개선하기 위해 속성 유형이 Set
에서 일반 배열로 변경되었습니다. 이 변경사항을 적용한 후 힙 스냅샷을 다시 찍어보니 메모리 할당이 줄어든 것을 확인할 수 있었습니다. 이 변경사항으로 속도가 크게 개선되지는 않았지만 애플리케이션이 비정상 종료되는 빈도가 줄어드는 부수적인 이점이 있었습니다.
세 번째 활동 그룹: 데이터 구조 절충사항 고려
세 번째 섹션은 특이합니다. 플레임 차트에서 이 섹션은 좁지만 긴 열로 구성되어 있으며, 이는 깊은 함수 호출과 깊은 재귀를 나타냅니다. 이 섹션은 총 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밀리초로 줄었습니다.
변경 전:
변경 후:
네 번째 활동 그룹: 중복 작업을 방지하기 위해 중요하지 않은 작업 및 캐시 데이터 지연
이 창을 확대하면 거의 동일한 함수 호출 블록이 두 개 있는 것을 볼 수 있습니다. 호출된 함수의 이름을 보면 이러한 블록이 트리를 빌드하는 코드 (예: refreshTree
또는 buildChildren
와 같은 이름)로 구성되어 있음을 알 수 있습니다. 실제로 관련 코드는 패널 하단 창에 트리 뷰를 만드는 코드입니다. 흥미로운 점은 이러한 트리 보기가 로드된 직후에 표시되지 않는다는 것입니다. 대신 사용자가 트리가 표시되도록 트리 뷰 (서랍의 '하향식', '호출 트리', '이벤트 로그' 탭)를 선택해야 합니다. 또한 스크린샷에서 알 수 있듯이 트리 빌드 프로세스가 두 번 실행되었습니다.
이 사진에는 다음과 같은 두 가지 문제가 있습니다.
- 중요한 작업이 아니면서 로드 시간의 성능을 저해하는 작업이 있었습니다. 사용자는 항상 출력이 필요하지는 않습니다. 따라서 이 태스크는 프로필 로드에 중요하지 않습니다.
- 이러한 태스크의 결과가 캐시되지 않았습니다. 따라서 데이터가 변경되지 않았음에도 불구하고 트리가 두 번 계산되었습니다.
먼저 사용자가 트리 뷰를 수동으로 열 때까지 트리 계산을 연기했습니다. 그래야만 이러한 트리를 만드는 데 드는 비용을 지불할 가치가 있습니다. 이 작업을 두 번 실행하는 데 걸린 총 시간은 약 3.4초였으므로 지연하면 로드 시간이 크게 달라졌습니다. 이러한 유형의 작업도 캐시하는 기능을 연구하고 있습니다.
다섯 번째 활동 그룹: 가능한 경우 복잡한 호출 계층 구조 피하기
이 그룹을 자세히 살펴보니 특정 호출 체인이 반복적으로 호출되고 있었습니다. 동일한 패턴이 Flame 그래프의 여러 위치에 6번 표시되었으며 이 기간의 총 길이는 약 2.4초였습니다.
여러 번 호출되는 관련 코드는 '미니맵' (패널 상단의 타임라인 활동 개요)에 렌더링할 데이터를 처리하는 부분입니다. 왜 여러 번 발생하는지는 명확하지 않았지만 6번이나 발생할 필요는 없었습니다. 실제로 다른 프로필이 로드되지 않으면 코드의 출력이 현재 상태로 유지되어야 합니다. 이론적으로 코드는 한 번만 실행되어야 합니다.
조사 결과, 로드 파이프라인의 여러 부분에서 미니맵을 계산하는 함수를 직접 또는 간접적으로 호출하여 관련 코드가 호출된 것으로 확인되었습니다. 이는 프로그램의 호출 그래프 복잡성이 시간이 지남에 따라 진화하고 이 코드에 더 많은 종속 항목이 알지 못하게 추가되었기 때문입니다. 이 문제에 대한 빠른 해결 방법은 없습니다. 해결 방법은 문제의 코드베이스 아키텍처에 따라 다릅니다. 이 경우 호출 계층 구조의 복잡성을 약간 줄이고 입력 데이터가 변경되지 않은 경우 코드 실행을 방지하는 검사를 추가해야 했습니다. 이를 구현한 후 타임라인에 대한 전망은 다음과 같습니다.
미니맵 렌더링 실행은 한 번이 아니라 두 번 발생합니다. 이는 모든 프로필에 두 개의 미니맵이 그려지기 때문입니다. 하나는 패널 상단의 개요용이고 다른 하나는 기록에서 현재 표시되는 프로필을 선택하는 드롭다운 메뉴용입니다. 이 메뉴의 모든 항목에는 선택한 프로필의 개요가 포함되어 있습니다. 하지만 두 템플릿의 콘텐츠는 동일하므로 하나의 템플릿을 다른 템플릿에 재사용할 수 있습니다.
이러한 미니맵은 모두 캔버스에 그려진 이미지이므로 drawImage
캔버스 유틸리티를 사용하고 나서 코드를 한 번만 실행하여 시간을 절약하면 됩니다. 그 결과 그룹의 길이가 2.4초에서 140밀리초로 줄었습니다.
결론
이러한 수정사항과 여기저기 있는 몇 가지 다른 소규모 수정사항을 모두 적용한 후 프로필 로드 타임라인의 변경사항은 다음과 같았습니다.
변경 전:
변경 후:
개선 후 로드 시간은 2초였습니다. 즉, 대부분의 작업이 빠른 수정으로 이루어졌기 때문에 비교적 적은 노력으로 약 80%의 개선이 이루어졌습니다. 물론 처음에 해야 할 작업을 올바르게 식별하는 것이 중요하며, 성능 패널이 이에 적합한 도구였습니다.
또한 이러한 수치는 연구 대상으로 사용되는 프로필에만 해당한다는 점을 강조해야 합니다. 이 프로필은 특히 크기가 컸기 때문에 Google의 관심을 끌었습니다. 하지만 처리 파이프라인은 모든 프로필에서 동일하므로 실적 패널에 로드된 모든 프로필에 상당한 개선사항이 적용됩니다.
요약
애플리케이션의 성능 최적화 측면에서 이 결과를 통해 얻을 수 있는 몇 가지 교훈이 있습니다.
1. 프로파일링 도구를 사용하여 런타임 성능 패턴 식별
프로파일링 도구는 애플리케이션이 실행되는 동안 애플리케이션에서 발생하는 상황을 파악하는 데 특히 유용하며, 성능을 개선할 기회를 파악하는 데도 유용합니다. Chrome DevTools의 Performance 패널은 브라우저의 네이티브 웹 프로파일링 도구이며 최신 웹 플랫폼 기능을 사용하도록 적극적으로 유지관리되므로 웹 애플리케이션에 적합한 옵션입니다. 또한 속도가 훨씬 빨라졌습니다. 😉
대표적인 워크로드로 사용할 수 있는 샘플을 사용해 보고 어떤 결과를 얻을 수 있는지 확인해 보세요.
2. 복잡한 호출 계층 구조 피하기
가능하면 호출 그래프를 너무 복잡하게 만들지 마세요. 호출 계층 구조가 복잡하면 성능 회귀가 쉽게 발생하고 코드가 현재와 같은 방식으로 실행되는 이유를 파악하기 어려워 개선사항을 적용하기가 어렵습니다.
3. 불필요한 작업 식별
오래된 코드베이스에는 더 이상 필요하지 않은 코드가 포함되는 경우가 많습니다. 이 경우 기존의 불필요한 코드가 총 로드 시간의 상당 부분을 차지했습니다. 삭제는 가장 쉽게 구현할 수 있는 방법이었습니다.
4. 데이터 구조 적절히 사용
데이터 구조를 사용하여 성능을 최적화하되, 사용할 데이터 구조를 결정할 때 각 유형의 데이터 구조가 가져오는 비용과 절충점을 이해해야 합니다. 이는 데이터 구조 자체의 공간 복잡성뿐만 아니라 관련 작업의 시간 복잡성도 고려합니다.
5. 복잡하거나 반복적인 작업의 중복을 방지하기 위해 결과를 캐시합니다.
실행 비용이 많이 드는 작업인 경우 다음에 필요할 때 사용할 수 있도록 결과를 저장하는 것이 좋습니다. 또한 작업이 여러 번 실행되는 경우에도 각 개별 작업의 비용이 크지 않더라도 이렇게 하는 것이 좋습니다.
6. 중요하지 않은 작업 지연
작업의 출력이 즉시 필요하지 않고 작업 실행이 중요 경로를 연장하는 경우 출력이 실제로 필요할 때 지연 호출하여 연기하는 것이 좋습니다.
7. 대규모 입력에 효율적인 알고리즘 사용
입력이 클 경우 최적의 시간 복잡도 알고리즘이 중요해집니다. 이 예에서는 이 카테고리를 살펴보지 않았지만, 이 카테고리의 중요성은 아무리 강조해도 지나치지 않습니다.
8. 보너스: 파이프라인 벤치마킹
진화하는 코드가 빠르게 유지되도록 하려면 동작을 모니터링하고 표준과 비교하는 것이 좋습니다. 이렇게 하면 회귀를 사전에 식별하고 전반적인 안정성을 개선하여 장기적인 성공을 거둘 수 있습니다.