개발하는 애플리케이션의 유형과 관계없이 성능을 최적화하고 빠르게 로드되며 원활한 상호작용을 제공하는 것은 사용자 환경과 애플리케이션의 성공에 매우 중요합니다. 이를 수행하는 한 가지 방법은 프로파일링 도구를 사용하여 시간 창 동안 실행될 때 내부적으로 어떤 일이 발생하는지 확인하여 애플리케이션의 활동을 검사하는 것입니다. DevTools의 성능 패널은 웹 애플리케이션의 성능을 분석하고 최적화하는 데 유용한 프로파일링 도구입니다. 앱이 Chrome에서 실행되면 애플리케이션이 실행되는 동안 브라우저에서 실행되는 작업을 시각적으로 자세히 보여줍니다. 이 활동을 이해하면 성능을 개선하기 위해 조치를 취할 수 있는 패턴, 병목 현상, 성능 핫스팟을 파악하는 데 도움이 됩니다.
다음 예에서는 성능 패널 사용 방법을 안내합니다.
프로파일링 시나리오 설정 및 재현
최근에는 성능 패널의 성능을 개선하는 목표를 설정했습니다. 특히 많은 양의 실적 데이터를 더 빠르게 로드할 수 있기를 바랐습니다. 예를 들어 장기 실행되거나 복잡한 프로세스를 프로파일링하거나 세부적인 데이터를 캡처하는 경우에 그렇습니다. 이를 달성하기 위해서는 먼저 애플리케이션이 어떻게 실행되었는지, 왜 그렇게 실행되었는지 이해해야 했으며, 이는 프로파일링 도구를 사용하여 달성했습니다.
아시다시피 DevTools 자체는 웹 애플리케이션입니다. 따라서 성능 패널을 사용하여 프로파일링할 수 있습니다. 이 패널 자체를 프로파일링하려면 DevTools를 열고 여기에 연결된 다른 DevTools 인스턴스를 열면 됩니다. Google에서는 이 설정을 DevTools-on-DevTools라고 합니다.
설정이 준비되면 프로파일링할 시나리오를 다시 만들어 녹화해야 합니다. 혼동을 방지하기 위해 원래 DevTools 창을 '첫 번째 DevTools 인스턴스'라고 하고 첫 번째 인스턴스를 검사하는 창을 '두 번째 DevTools 인스턴스'라고 합니다.

두 번째 DevTools 인스턴스에서 성능 패널(이후 성능 패널이라고 함)은 첫 번째 DevTools 인스턴스를 관찰하여 프로필을 로드하는 시나리오를 다시 만듭니다.
두 번째 DevTools 인스턴스에서는 실시간 녹화가 시작되고 첫 번째 인스턴스에서는 디스크의 파일에서 프로필이 로드됩니다. 대규모 입력을 처리하는 성능을 정확하게 프로파일링하기 위해 대용량 파일이 로드됩니다. 두 인스턴스의 로드가 완료되면 성능 프로파일링 데이터(일반적으로 트레이스라고 함)가 프로필을 로드하는 perf 패널의 두 번째 DevTools 인스턴스에 표시됩니다.
초기 상태: 개선 기회 파악
로드가 완료된 후 다음 스크린샷에서 두 번째 성능 패널 인스턴스에 다음이 관찰되었습니다. Main이라는 트랙 아래에 표시되는 기본 스레드의 활동에 집중합니다. 프레임 차트에는 5개의 큰 활동 그룹이 있습니다. 여기에는 로드에 가장 많은 시간이 걸리는 작업이 포함됩니다. 이러한 작업의 총 시간은 약 10초였습니다. 다음 스크린샷에서는 성능 패널을 사용하여 이러한 각 활동 그룹에 집중하여 무엇을 찾을 수 있는지 확인합니다.

첫 번째 활동 그룹: 불필요한 작업
첫 번째 활동 그룹은 여전히 실행되지만 실제로 필요하지 않은 기존 코드인 것으로 드러났습니다. 기본적으로 processThreadEvents
이라는 녹색 블록 아래의 모든 것은 낭비된 노력입니다. 이건 금방 해결할 수 있었어. 이 함수 호출을 삭제하면 약 1.5초의 시간을 절약할 수 있습니다. 아주 잘 분석하죠!
두 번째 활동 그룹
두 번째 활동 그룹에서는 첫 번째 활동 그룹만큼 간단하지 않았습니다. buildProfileCalls
는 약 0.5초가 걸렸으며 이 작업은 피할 수 없는 작업이었습니다.

궁금한 마음에 성능 패널에서 메모리 옵션을 사용 설정하여 자세히 조사한 결과 buildProfileCalls
활동도 메모리를 많이 사용하는 것으로 확인되었습니다. 여기에서 파란색 선 그래프가 buildProfileCalls
이 실행되는 시점에 갑자기 점프하는 것을 확인할 수 있으며, 이는 메모리 누수가 발생할 수 있음을 나타냅니다.

이 의심을 확인하기 위해 메모리 패널 (DevTools의 또 다른 패널로, 성능 패널의 메모리 드로어와는 다름)을 사용하여 조사했습니다. 메모리 패널 내에서 '할당 샘플링' 프로파일링 유형이 선택되었으며, 이는 CPU 프로필을 로드하는 성능 패널의 힙 스냅샷을 기록했습니다.

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

이 힙 스냅샷에서 Set
클래스가 많은 메모리를 소비하는 것으로 확인되었습니다. 호출 지점을 확인한 결과 대량으로 생성된 객체에 Set
유형의 속성을 불필요하게 할당하는 것으로 확인되었습니다. 이 비용이 누적되고 많은 메모리가 소비되어 대규모 입력에서 애플리케이션이 비정상 종료되는 경우가 많았습니다.
세트는 고유한 항목을 저장하는 데 유용하며 데이터 세트 중복 제거, 더 효율적인 조회 등 콘텐츠의 고유성을 사용하는 작업을 제공합니다. 하지만 저장된 데이터가 소스와 고유한 것으로 보장되었기 때문에 이러한 기능은 필요하지 않았습니다. 따라서 세트가 필요하지 않았습니다. 메모리 할당을 개선하기 위해 속성 유형이 Set
에서 일반 배열로 변경되었습니다. 이 변경사항을 적용한 후 다른 힙 스냅샷을 가져왔으며 메모리 할당이 감소한 것으로 확인되었습니다. 이 변경사항으로 상당한 속도 향상을 달성하지는 못했지만 애플리케이션이 더 적은 빈도로 비정상 종료되는 부수적인 이점이 있었습니다.

세 번째 활동 그룹: 데이터 구조 트레이드 오프 평가
세 번째 섹션은 특이합니다. Flame 차트에서 좁지만 높은 열로 구성되어 있으며, 이는 이 경우 깊은 함수 호출과 깊은 재귀를 나타냅니다. 이 섹션은 총 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초이므로 지연하면 로드 시간에 큰 차이가 발생합니다. 이러한 유형의 작업도 캐싱하는 방법을 조사하고 있습니다.
다섯 번째 활동 그룹: 가능한 경우 복잡한 호출 계층 구조 피하기
이 그룹을 자세히 살펴보니 특정 호출 체인이 반복적으로 호출되는 것이 분명했습니다. 동일한 패턴이 플레임 차트의 여러 위치에 6번 표시되었으며 이 창의 총 지속 시간은 약 2.4초였습니다.

여러 번 호출되는 관련 코드는 '미니맵' (패널 상단의 타임라인 활동 개요)에 렌더링될 데이터를 처리하는 부분입니다. 여러 번 발생하는 이유가 명확하지는 않았지만 6번이나 발생할 필요는 없었습니다. 실제로 다른 프로필이 로드되지 않으면 코드의 출력은 현재 상태로 유지되어야 합니다. 이론적으로 코드는 한 번만 실행되어야 합니다.
조사 결과 로드 파이프라인의 여러 부분에서 미니맵을 계산하는 함수를 직접 또는 간접적으로 호출한 결과 관련 코드가 호출된 것으로 확인되었습니다. 이는 프로그램의 호출 그래프 복잡성이 시간이 지남에 따라 진화했고 이 코드에 대한 종속 항목이 모르는 사이에 추가되었기 때문입니다. 이 문제에 대한 빠른 해결 방법은 없습니다. 이 문제를 해결하는 방법은 문제의 코드베이스 아키텍처에 따라 다릅니다. 이 경우 호출 계층 구조 복잡성을 약간 줄이고 입력 데이터가 변경되지 않은 경우 코드 실행을 방지하는 검사를 추가해야 했습니다. 이를 구현한 후 타임라인은 다음과 같이 표시됩니다.

미니맵 렌더링 실행은 한 번이 아니라 두 번 발생합니다. 이는 모든 프로필에 대해 두 개의 미니맵이 그려지기 때문입니다. 하나는 패널 상단의 개요용이고 다른 하나는 기록에서 현재 표시된 프로필을 선택하는 드롭다운 메뉴용입니다. 이 메뉴의 모든 항목에는 선택한 프로필의 개요가 포함되어 있습니다. 하지만 이 두 가지는 내용이 완전히 동일하므로 하나를 다른 하나에 재사용할 수 있습니다.
이러한 미니맵은 모두 캔버스에 그려진 이미지이므로 drawImage
캔버스 유틸리티를 사용한 후 코드를 한 번만 실행하여 시간을 절약할 수 있었습니다. 이러한 노력의 결과로 그룹의 지속 시간이 2.4초에서 140밀리초로 단축되었습니다.
결론
이러한 수정사항 (그리고 여기저기에 있는 몇 가지 다른 작은 수정사항)을 모두 적용한 후 프로필 로드 타임라인의 변경사항은 다음과 같이 표시되었습니다.
변경 전:

변경 후:

개선 후 로드 시간은 2초로, 대부분의 작업이 빠른 수정으로 구성되어 비교적 적은 노력으로 약 80%의 개선을 달성했습니다. 물론 처음에 무엇을 해야 하는지 올바르게 식별하는 것이 중요했으며, 성능 패널이 이를 위한 적절한 도구였습니다.
이러한 수치는 연구 대상으로 사용되는 프로필에만 해당한다는 점도 강조해야 합니다. 이 프로필은 특히 커서 흥미로웠습니다. 하지만 처리 파이프라인은 모든 프로필에서 동일하므로 달성된 상당한 개선사항은 성능 패널에 로드된 모든 프로필에 적용됩니다.
요약
애플리케이션의 성능 최적화와 관련하여 이러한 결과에서 얻을 수 있는 교훈이 있습니다.
1. 프로파일링 도구를 사용하여 런타임 성능 패턴 식별
프로파일링 도구는 애플리케이션이 실행되는 동안 어떤 일이 발생하는지 파악하는 데 매우 유용하며, 특히 성능을 개선할 기회를 식별하는 데 유용합니다. Chrome DevTools의 성능 패널은 브라우저의 기본 웹 프로파일링 도구이며 최신 웹 플랫폼 기능에 맞게 적극적으로 유지관리되므로 웹 애플리케이션에 적합한 옵션입니다. 또한 이제 훨씬 더 빨라졌습니다. 😉
대표 워크로드로 사용할 수 있는 샘플을 사용하여 무엇을 찾을 수 있는지 확인해 보세요.
2. 복잡한 호출 계층 구조 방지
가능하면 호출 그래프가 너무 복잡해지지 않도록 합니다. 복잡한 호출 계층 구조에서는 성능 회귀가 발생하기 쉽고 코드가 실행되는 방식을 이해하기 어려워 개선하기가 어렵습니다.
3. 불필요한 작업 식별
오래된 코드베이스에는 더 이상 필요하지 않은 코드가 포함되어 있는 경우가 많습니다. 이 경우 기존의 불필요한 코드가 전체 로드 시간의 상당 부분을 차지했습니다. 가장 쉽게 해결할 수 있는 문제였습니다.
4. 데이터 구조를 적절하게 사용
데이터 구조를 사용하여 실적을 최적화하되, 사용할 데이터 구조를 결정할 때는 각 데이터 구조 유형이 가져오는 비용과 절충안도 이해해야 합니다. 이는 데이터 구조 자체의 공간 복잡도뿐만 아니라 적용 가능한 작업의 시간 복잡도도 포함합니다.
5. 복잡하거나 반복적인 작업의 중복을 방지하기 위해 결과를 캐시
실행하는 데 비용이 많이 드는 작업이라면 다음에 필요할 때 사용할 수 있도록 결과를 저장하는 것이 좋습니다. 각 개별 시간이 특별히 비용이 많이 들지 않더라도 작업이 여러 번 실행되는 경우에도 이렇게 하는 것이 좋습니다.
6. 중요하지 않은 작업 지연
작업의 출력이 즉시 필요하지 않고 작업 실행이 중요한 경로를 연장하는 경우 출력이 실제로 필요할 때 지연 호출하여 작업을 지연하는 것이 좋습니다.
7. 대규모 입력에 효율적인 알고리즘 사용
입력이 큰 경우 최적의 시간 복잡도 알고리즘이 중요해집니다. 이 예에서는 이 카테고리를 살펴보지 않았지만, 이 카테고리의 중요성은 아무리 강조해도 지나치지 않습니다.
8. 보너스: 파이프라인 벤치마킹
코드가 계속해서 빠르게 유지되도록 동작을 모니터링하고 표준과 비교하는 것이 좋습니다. 이렇게 하면 회귀를 사전에 식별하고 전반적인 안정성을 개선하여 장기적인 성공을 거둘 수 있습니다.