프론트엔드 성능 측정, 이제는 자동화하자! - Playwright와 Chrome Trace로 시작하기

타락한스벨트전도사·2024년 11월 10일
39

"성능이 좋아졌어요!" 정말 그걸 증명할 수 있나요?

프론트엔드 개발자라면 누구나 겪는 상황입니다.
"이 부분 최적화했는데 확실히 빨라진 것 같아요!"
"음... 제 환경에선 별로 차이를 못 느끼겠는데요?"

Chrome DevTools를 열고 성능 측정을 시도해보지만:

  • 매번 도구를 열고 Record 버튼을 눌러야 하는 번거로움
  • 브라우저 캐시, CPU 상태, 네트워크 상황 등 측정 환경이 매번 달라짐
  • 동료들과 성능 개선 결과를 공유하기 위해 스크린샷을 찍어 공유
  • 시간이 지나면서 성능이 나빠지고 있는지 추적이 어려움

결과적으로, 우리는 "느낌상" 성능이 좋아졌다고 이야기할 수밖에 없습니다.
하지만 진정한 성능 최적화는 객관적인 수치로 입증될 수 있어야 합니다.

이 글에서는 자동화된 성능 측정 방법을 통해:
1. 일관된 환경에서의 객관적인 성능 데이터 수집
2. 시간에 따른 성능 변화의 추적
3. 팀 전체가 공유할 수 있는 성능 지표 확보

방법을 알아보겠습니다.

💡 실전 팁: Chrome DevTools의 Performance 패널은 강력한 도구이지만, 수동 측정의 한계가 있습니다. 자동화된 측정을 통해 이러한 한계를 극복하면서도, DevTools의 분석 기능은 계속 활용할 수 있습니다.

실험은 과학적으로: 신뢰할 수 있는 성능 측정 방법

Chrome DevTools의 Performance 패널을 코드로 제어할 수 있다는 사실, 알고 계셨나요?
Playwright를 사용하면 브라우저 자동화를 통해 성능 측정을 자동화할 수 있습니다.

Playwright로 시작하는 자동화된 성능 측정

test('Capture performance traces', async ({ page, browser }) => {
    // Chrome trace 기록 시작
    await browser.startTracing(page, {
        path: `./traces/${formatDate(new Date())}.json`
    });
    
    // 페이지 로드 및 액션 수행
    await page.goto('http://localhost:4173/performance-test');
    await page.click('button#start');
    
    // 성능 측정 포인트 기록
    await page.evaluate(() => {
        window.performance.mark('Perf:Started');
        window.performance.mark('Perf:Ended');
        window.performance.measure('overall', 'Perf:Started', 'Perf:Ended');
    });
    
    // 측정 종료 및 결과 저장
    await browser.stopTracing();
});

하지만 이렇게 한 번 측정하는 것으로는 충분하지 않습니다.

💡 물리교육과 시절의 실험실 습관
"자, 여러분! 한 번 측정은 측정이 아닙니다!"

물리실험 레포트를 작성하면서 몸에 밴 습관입니다. 😄
측정값의 오차는 반복 측정 횟수의 제곱근에 반비례한다는 사실,
아직도 생생하게 기억납니다.

성능 측정의 과학적 접근

물리 실험에서 배운 원칙을 웹 성능 측정에 적용해보면:

  1. 반복 측정의 중요성

    • 측정 횟수(n)가 증가하면 표준오차는 1/√n 만큼 감소
    • 50회 반복 시 오차율은 단일 측정 대비 약 1/7로 감소
  2. 변인 통제의 철저함

    • 실험실에서 온도, 습도를 통제하듯
    • 브라우저 캐시, 메모리 상태, CPU 부하 통제가 필수
    • V8 엔진의 JIT 컴파일러 최적화도 고려해야 함

신뢰할 수 있는 측정을 위한 완성된 코드

test('Scientifically measure performance', async () => {
    const results = [];
    const REPEAT_COUNT = 50;  // 신뢰도를 위한 충분한 반복
    
    for(let i = 0; i < REPEAT_COUNT; i++) {
        // 변인 통제: 매번 새로운 브라우저 환경
        const browser = await chromium.launch({
            args: ['--no-sandbox', '--disable-dev-shm-usage']
        });
        const page = await browser.newPage();
        
        await browser.startTracing(page, {
            path: `./traces/trace_${i}.json`
        });
        
        // 성능 측정
        const result = await measurePerformance(page);
        results.push(result);
        
        await browser.stopTracing();
        await browser.close();  // 브라우저 상태 초기화
    }
    
    // 통계 처리
    const avg = calculateAverage(results);
    const standardError = calculateStandardError(results);
    
    console.log(`실행 시간: ${avg}ms ± ${standardError}ms`);
});

JIT 컴파일러의 함정: 실행할수록 빨라지는 코드

다음은 제가 실제로 경험한 흥미로운 사례입니다:

// ❌ 잘못된 측정 방법: 브라우저 재시작 없이 반복 측정
test('Without browser restart', async ({ page }) => {
    const results = [];
    for(let i = 0; i < 50; i++) {
        const startTime = performance.now();
        await runPerformanceTest(page);
        results.push(performance.now() - startTime);
    }
    console.log('실행시간 변화:', results);
});

이 코드로 측정한 결과:

  • 첫 10회 평균: 450ms
  • 중간 10회 평균: 380ms
  • 마지막 10회 평균: 320ms

왜 이런 현상이 발생할까요?
V8 엔진의 JIT(Just-In-Time) 컴파일러가 실행 패턴을 학습하면서 코드를 최적화하기 때문입니다. 즉, 같은 코드가 반복될수록 실행 속도가 점점 빨라지는 거죠.

// ✅ 올바른 측정 방법: 매번 브라우저 재시작
test('With browser restart', async () => {
    const results = [];
    for(let i = 0; i < 50; i++) {
        const browser = await chromium.launch();
        const page = await browser.newPage();
        
        const startTime = performance.now();
        await runPerformanceTest(page);
        results.push(performance.now() - startTime);
        
        await browser.close();
    }
    console.log('실행시간 변화:', results);
});

이 방식으로 측정한 결과:

  • 첫 10회 평균: 445ms
  • 중간 10회 평균: 442ms
  • 마지막 10회 평균: 448ms

💡 실전 팁:
JIT 컴파일러의 최적화는 프로덕션 환경에서 실제로 발생하는 현상입니다.
하지만 성능 측정의 목적은 "기준값" 을 얻는 것이므로,
이러한 최적화의 영향을 받지 않는 환경에서 측정해야 합니다.

물리교육과 출신이 프론트엔드 개발자가 되어 실험 방법론을 적용하게 될 줄이야...
세상일이란 참 알 수 없네요. 😄 이제 이 기반 위에서 더 깊이 있는 성능 분석을 위해
Chrome Trace를 살펴보도록 하겠습니다.

Chrome Trace Report를 분석해보자

문제 상황: Flitter의 성능 최적화 도전

저는 시각화 프레임워크 Flitter를 개발하는 중에, 성능 최적화에 대한 필요성을 절실히 느끼게 되었습니다. Flitter는 SVG, Canvas를 활용해 동적 데이터를 시각화하는 라이브러리로, 다양한 애니메이션과 상호작용을 제공합니다. 이러한 기능들은 데이터 표현을 풍부하게 해주지만, 성능 부하를 일으키는 원인이 되기도 했습니다.

예를 들어, 데이터가 바뀔 때마다 새롭게 SVG 요소를 그리기 위해 requestFrameAnimation이 반복 호출되는 상황이 있었습니다. setTimeout의 콜백함수는 최적화로 한번만 트리거가 되도록 했으나,requestFrameAnimation을 호출하는 자체도 비용이 있을줄은 몰랐습니다. 평소에는 티가나지 않았으나 매우 복잡한 UI를 반복적으로(마우스 드래깅) 랜더링할때 성능저하가 체감되었습니다.

이 문제를 해결하고자 처음에는 특정 함수의 실행 시간을 추적하고, 애니메이션 루틴을 최적화하려고 시도했습니다. 하지만 주관적인 느낌만으로는 성능 개선의 효과를 확신할 수 없었습니다. 성능 데이터를 수치화해 측정해야 했고, 코드의 변경 사항이 실제로 긍정적인 영향을 미치는지 확인할 필요가 있었습니다.

“자바스크립트의 실행 시간을 체계적으로 기록하고, 최적화 결과를 과학적으로 검증할 방법이 필요하다!”

이 결론에 이르러 Chrome Trace와 Playwright를 활용한 자동화된 성능 측정 방식을 도입하게 되었습니다. 이 글에서는 Chrome Trace Report를 활용해 자바스크립트의 각 함수가 얼마나 많은 리소스를 사용하는지 확인하고, 최적화가 필요한 부분을 찾아가는 과정을 소개하려고 합니다.

1. Chrome Trace Report로 성능 데이터 수집하기

Chrome Trace Report는 브라우저가 자바스크립트 코드를 실행하는 동안 발생하는 모든 주요 이벤트를 추적하여, 각 이벤트가 CPU와 메모리를 얼마나 소모하는지를 확인할 수 있게 해줍니다. 특히, 자바스크립트 함수 호출과 실행 시간을 포함한 다양한 성능 지표를 기록하기 때문에, 코드의 성능을 면밀히 분석하고 병목 현상을 정확하게 파악하는 데 매우 유용합니다.

Chrome Trace Report를 활용하는 이유

Chrome Trace Report의 가장 큰 장점은 전체 함수 호출 스택과 각 함수의 실행 시간을 한눈에 볼 수 있다는 점입니다. 이를 통해 성능에 부담을 주는 특정 함수나 반복 호출되는 코드가 무엇인지 빠르게 파악할 수 있습니다. 특히 Flitter와 같이 여러 요소와 상호작용하는 시각화 프레임워크에서는 성능 부하를 일으키는 특정 루틴을 찾는 것이 중요한데, Chrome Trace는 이러한 문제를 해결하는 데 큰 도움을 줍니다.

Playwright로 Chrome Trace 자동화하기

Playwright는 브라우저 환경에서 성능 측정을 자동화하는 데 유용한 도구로, Chrome Trace Report와 연동하여 성능 데이터를 수집할 수 있습니다. Playwright를 사용하면 브라우저에서 발생하는 이벤트들을 추적하는 Trace Report를 손쉽게 저장할 수 있습니다. 아래 예제 코드는 특정 시점부터 성능 데이터를 기록하여 JSON 파일로 저장하는 방법을 보여줍니다.

test('Capture performance traces and save JSON file when diagram is rendered', async ({ page, browser }) => {
    // Chrome trace 기록 시작
    await browser.startTracing(page, {
        path: `./performance-history/${formatDate(new Date())}.json`
    });

    // 성능 측정하려는 페이지로 이동
    await page.goto('http://localhost:4173/performance/diagram');

    // 성능 측정 시작 지점 표시
    await page.evaluate(() => window.performance.mark('Perf:Started'));
    await page.click('button');
    await page.waitForSelector('svg');
    await page.evaluate(() => window.performance.mark('Perf:Ended'));
    await page.evaluate(() => window.performance.measure('overall', 'Perf:Started', 'Perf:Ended'));

    // 성능 측정 종료 및 결과 저장
    await browser.stopTracing();
});

2. 주요 이벤트 분석: CpuProfile Event

Playwright와 Chrome Trace를 활용하여 JSON 형식의 Trace Report를 생성했지만, 이 파일을 바로 분석하는 데는 몇 가지 어려움이 있습니다. Trace Report에는 실행 중 발생한 이벤트들이 모두 기록되지만, 각 함수의 실행 시간이나 호출 횟수를 직관적으로 확인하기 어렵기 때문입니다. 수많은 Trace Event가 원시 형태로 기록되어 있어 원하는 데이터를 빠르게 파악할 수 없었고, 모든 데이터를 직접 확인하는 데 한계가 있었습니다.

CpuProfile Event와 전처리의 필요성

Trace Report에서 CpuProfile Eventdisabled-by-default-v8.cpu_profiler라는 카테고리로 기록되며, 자바스크립트 함수의 CPU 사용 시간을 추적하는 중요한 데이터입니다. 각 이벤트는 시간 간격으로 기록되고, samplestimeDeltas라는 필드를 포함하고 있어 각 함수의 실행 시간을 유추할 수 있습니다. 이를 통해 각 함수의 총 실행 시간을 구하기 위해서는 전처리 작업이 필요합니다.

전처리를 통해 필요한 데이터 추출하기

각 함수의 총 실행 시간을 구하기 위해서는 samplestimeDeltas 필드를 활용해 다음과 같은 과정으로 전처리 작업을 수행해야 합니다.

  • samplestimeDeltas를 순회하면서 각 함수에 해당하는 노드 ID의 총 실행 시간을 계산합니다.
  • 노드 간의 부모-자식 관계를 기반으로 자식 노드의 실행 시간을 부모 노드에 합산하여, 전체적인 실행 시간을 추출합니다.
  • 함수별로 정리된 데이터를 기반으로 실행 시간을 시각화하거나, 최적화 포인트를 식별합니다.

이 전처리 과정은 각 함수가 전체 실행 시간에서 얼마나 많은 자원을 사용하는지 명확하게 보여주며, 어떤 함수가 성능 병목 지점인지 구체적으로 파악할 수 있게 해줍니다.

3. ChromeTraceAnalyzer 클래스 구현: 실행 시간 분석 자동화

Trace Report는 다양한 이벤트와 호출 정보를 담고 있지만, 이를 바로 분석하는 것은 어렵습니다. 그래서 ChromeTraceAnalyzer라는 클래스를 구현하여, Trace 데이터에서 각 함수별 실행 시간을 자동으로 계산하고, 성능 분석에 필요한 정보를 손쉽게 추출할 수 있도록 전처리를 수행하려 합니다.

ChromeTraceAnalyzer 클래스의 주요 기능

ChromeTraceAnalyzer 클래스는 Trace 데이터를 받아 각 함수별 실행 시간을 계산하고, 부모-자식 노드 관계를 구성하여 전체 실행 시간을 정확히 파악할 수 있도록 합니다. 이 클래스는 주로 다음과 같은 기능을 제공합니다.

  • Trace 데이터를 읽고 CpuProfile Event의 데이터를 정리
  • 각 함수별 총 실행 시간을 계산하고, 성능 분석에 필요한 주요 함수별 실행 시간 제공
  • 분석된 데이터를 토대로 성능 병목 구간을 식별
class ChromeTraceAnalyzer {
    nodes: any[];

    constructor(trace) {
        this.setConfig(trace);
    }
    
    // 특정 함수의 실행 시간을 반환 (단위: ms)
    getDurationMs(name: string): number {
        if (!this.nodes) throw new Error('nodes is not initialized');
        const result = this.nodes.find((node) => node.callFrame.functionName === name);
        return result ? result.duration / 1000 : 0; // 밀리초 단위로 변환
    }

    // Trace 데이터를 설정하고, 함수별 실행 시간을 계산
    setConfig(trace: any) {
        const { traceEvents } = trace;
        
        // 'ProfileChunk' 이벤트 필터링
        const profileChunks = traceEvents.filter((entry) => entry.name === 'ProfileChunk');
        
        // CpuProfile의 노드와 샘플 데이터 가져오기
        const nodes = profileChunks.map((entry) => entry.args.data.cpuProfile.nodes).flat();
        const sampleTimes = {};

        // 각 샘플의 실행 시간을 합산
        profileChunks.forEach((chunk) => {
            const { cpuProfile: { samples }, timeDeltas } = chunk.args.data;

            samples.forEach((id, index) => {
                const delta = timeDeltas[index];
                sampleTimes[id] = (sampleTimes[id] || 0) + delta;
            });
        });

        // 노드를 구성하고 자식 노드의 실행 시간을 부모 노드에 합산
        this.nodes = nodes.map((node) => ({
            id: node.id,
            parent: node.parent,
            callFrame: node.callFrame,
            children: [],
            duration: sampleTimes[node.id] || 0
        }));

        // 부모-자식 관계 설정 및 실행 시간 합산
        const nodesMap = new Map();
        this.nodes.forEach((node) => {
            nodesMap.set(node.id, node);
        });
        
        this.nodes
            .sort((a, b) => b.id - a.id)
            .forEach((node) => {
                if (!node.parent) return;
                const parentNode = nodesMap.get(node.parent);
                if (parentNode) {
                    parentNode.children.push(node);
                    parentNode.duration += node.duration;
                }
            });
    }
}

4. 반복 측정으로 신뢰성 높이기

ChromeTraceAnalyzer를 통해 함수별 실행 시간을 분석할 수 있게 되었지만, 단일 측정만으로 성능 데이터를 평가하기에는 부족합니다. 자바스크립트 실행 성능은 환경 변화에 따라 편차가 발생할 수 있기 때문에, 보다 신뢰도 높은 데이터를 얻으려면 여러 번 반복 측정하여 평균 실행 시간과 표준 편차를 계산하는 것이 중요합니다. 이를 통해 개별 측정 결과의 편차를 줄이고, 최적화의 효과를 더욱 정확히 평가할 수 있습니다.

반복 측정 코드 예시

아래 예제는 주요 함수(runApp, mount, draw, layout, paint)의 실행 시간을 10회 반복하여 측정하고, 각 함수의 평균 실행 시간을 출력하는 코드입니다.

test('Capture analyzed trace when diagram is rendered with multiple runs', async () => {
    const COUNT = 10;
    const duration = {
        timestamp: Date.now(),
        runApp: 0,
        mount: 0,
        draw: 0,
        layout: 0,
        paint: 0,
    };

    for (let i = 0; i < COUNT; i++) {
        const browser = await chromium.launch({ headless: true });
        const page = await browser.newPage();
        await page.goto('http://localhost:4173/performance/diagram');
        
        // Trace 시작
        await browser.startTracing(page, {});
        await page.evaluate(() => window.performance.mark('Perf:Started'));
        await page.click('button');
        await page.waitForSelector('svg');
        await page.evaluate(() => window.performance.mark('Perf:Ended'));
        await page.evaluate(() => window.performance.measure('overall', 'Perf:Started', 'Perf:Ended'));

        // Trace 데이터 추출 및 분석
        const trace = JSON.parse((await browser.stopTracing()).toString('utf8'));
        const analyzer = new ChromeTraceAnalyzer(trace);
        duration.runApp += analyzer.getDurationMs('runApp') / COUNT;
        duration.mount += analyzer.getDurationMs('mount') / COUNT;
        duration.draw += analyzer.getDurationMs('draw') / COUNT;
        duration.layout += analyzer.getDurationMs('layout') / COUNT;
        duration.paint += analyzer.getDurationMs('paint') / COUNT;

        await browser.close();
    }

    console.log('**** Average Execution Time ****');
    console.log(`runApp: ${duration.runApp}ms`);
    console.log(`mount: ${duration.mount}ms`);
    console.log(`draw: ${duration.draw}ms`);
    console.log(`layout: ${duration.layout}ms`);
    console.log(`paint: ${duration.paint}ms`);
    console.log('********************************');
});

5. 성능 데이터 시각화로 최적화 효과 확인하기

반복 측정을 통해 얻은 평균 실행 시간과 표준 편차 데이터를 바탕으로, Flitter를 활용해 성능 최적화 효과를 시각화했습니다. 이렇게 시각화된 차트를 통해 시간에 따른 성능 변화 패턴을 직관적으로 확인할 수 있었고, 최적화 전후의 성능 변화를 쉽게 파악할 수 있었습니다.

flitter_optimizer

Flitter를 활용한 스택 막대 그래프 시각화

Flitter를 사용해 각 측정 날짜별로 주요 함수의 총 실행 시간을 누적한 스택 막대 그래프(Stacked Bar Chart)를 생성했습니다. 각 날짜마다 해당 날짜의 전체 실행 시간을 하나의 막대그래프로 표시하고, 이 막대그래프를 함수별 시간으로 구분해 성능이 개선된 부분을 시각적으로 확인할 수 있도록 했습니다.

그래프 구성

  • X축: 실행한 날짜 (측정을 반복한 날짜별로 성능 변화 추적)
  • Y축: 각 날짜의 전체 평균 실행 시간 (ms)
  • 스택 막대 구성: 각 함수(mount, layout, paint)가 차지하는 시간을 색상으로 구분하여 누적 표시

최적화 전후의 성능 변화 확인

이 스택 막대 그래프를 통해 시간에 따라 성능이 어떻게 변화했는지 쉽게 파악할 수 있었습니다.

  • 날짜별 성능 변화: 특정 함수의 실행 시간이 최적화 이후 줄어들 뿐만 아니라, 지속적인 개발 과정에서도 일정 수준의 성능이 유지되는 것을 확인할 수 있었습니다. 예를 들어, runApp 함수의 실행 시간이 줄어든 후 안정적으로 유지되고, layoutpaint 함수도 최적화 이후 일관된 성능을 보여주는 것을 시각적으로 확인할 수 있었습니다.
  • 개별 함수의 비율 파악: 누적된 막대그래프 형태로, 각 날짜의 성능 측정 시 특정 함수가 차지하는 비중을 비교할 수 있었습니다. 이를 통해 성능 병목이 발생하는 함수가 무엇인지, 최적화 이후 각 함수의 비율이 어떻게 달라졌는지를 쉽게 알 수 있었습니다.

이처럼 Flitter를 활용해 성능 데이터를 시각화하니, 성능 개선 효과를 팀원들과 공유할 때 직관적으로 전달할 수 있었고, 최적화가 필요한 부분을 쉽게 식별할 수 있었습니다.

마무리

이번 글에서는 Flitter의 성능 최적화를 진행하며, Playwright와 Chrome Trace를 사용해 성능 데이터를 체계적으로 수집하고 분석하는 과정을 공유했습니다. 수작업이 필요했던 성능 측정을 자동화하고, Flitter를 통해 데이터 시각화를 구현하면서 성능 최적화가 이루어졌는지를 수치와 그래프로 직관적으로 확인할 수 있었습니다. 이를 통해 불필요한 추측에 의존하지 않고, 데이터에 기반한 성능 개선이 가능하다는 점에서 큰 성과를 얻을 수 있었습니다.

현재 Flitter 라이브러리는 성능 최적화가 중요한 상황에서 효율적인 시각화 솔루션으로 활용될 수 있습니다. 관련 코드는 GitHub에서 확인하실 수 있으며, 도움이 되셨다면 GitHub에서 Star와 벨로그 글 좋아요를 부탁드립니다! 이 프로젝트에 관심을 보여주신다면 앞으로도 성능 개선과 다양한 기능을 지속적으로 개발하고 공유할 계획입니다.

GitHub: https://github.com/meursyphus/flitter/blob/latest/packages/test/tests/tracking-performance.test.ts

Docs: Flitter Docs

감사합니다!

profile
스벨트쓰고요. 오픈소스 운영합니다

2개의 댓글

comment-user-thumbnail
2024년 11월 15일

와.. 매번 쓰신글 볼때마다 감탄이 절로나오네요

1개의 답글