
여러 개발 커뮤니티에서 진행되는 세션을 살펴보면 간혹 성능 개선 이라는 주제로 진행되는 세션을 볼 수 있다.
하지만, 그러한 세션들은 성능 개선 이라는 주제로 발표를 진행하기 때문에, 어떻게 성능 측정을 진행했는지는 간단하게 소개하거나 그냥 넘어가는 경우가 많다.
필자는 이번 프로젝트를 진행하면서 고민했던 성능 측정 방법 을 이번 포스팅을 통해 다뤄보고자 한다.
우리팀의 프론트엔드 인원은 총 3명으로 구성되어 있으며, 각자 서로 다른 페이지에 대해서 성능 개선을 진행해보고 결과를 공유하고 싶었다.
따라서 우리는 최소한 두 가지 조건을 만족해야 했다.
환경 일관성
- 프론트엔드 팀원들이 서로 다른 개발환경에서 측정을 진행해도 동일한 결과가 나와야 한다
측정 신뢰성
- 여러 번 실행해도 코드를 변경하지 않았다면 당연히 동일한 결과가 나와야 한다
이 조건을 만족하면서 의미 있는 성능 측정을 하기 위해서는 단순히 개발자 도구의 Network 탭 을 살펴보거나 Lighthouse 를 한 번 실행하는 것으로는 한계가 있다고 생각했다.
(여기서 환경 일관성이나 측정 신뢰성은 편의를 위해 나름대로 정의한 부분이다)
(이후에도 이 단어들을 재사용해서 포스팅을 진행할 예정이다!)
정리하자면, 서로 다른 환경에서 테스트를 진행하더라도 코드가 동일하다면 동일한 결과가 나오는 신뢰성 있는 지표를 원했던 상황이다.
이 요구를 만족하기 위해 여러가지로 찾아봤던 부분을 공유해보려고 한다.
WebPageTest는 일관된 측정 환경을 제공하는 온라인 서비스이다.
전 세계 여러 지역의 실제 디바이스와 네트워크 환경에서 테스트할 수 있어서, 이전에 소개했던 환경 일관성 측면과 측정 신뢰성 측면 모두에서 제일 괜찮은 방법이라고 생각했다.
하지만 우리 프로젝트에는 치명적인 문제가 있었다.
로그인을 진행한 이후 AccessToken을 헤더에 추가해야 하는 페이지들이 대부분이었는데, 이를 위해서는 해당 사이트에서 별도의 스크립트를 작성해야 했다.
더 큰 문제는 실시간 피드백이 어렵다는 점이었다.
코드를 수정하고 바로바로 성능 변화를 확인하기에는 워크플로우가 너무 복잡했으며, 이전에 말한 스크립트를 작성하기 위해 요구하는 허들이 너무 높다고 판단했다.
두 번째로 고려했던 방법은 AWS EC2에 전용 성능 측정 환경을 구축하는 것이었다.
CPU 코어와 메모리를 제한하고, Docker에 Chrome을 설치해서 Lighthouse를 실행하는 방식을 고민해봤다.
이 방법의 장점은 완전히 통제 가능한 환경에서 측정할 수 있다는 것이었다.
네트워크 대역폭, CPU 성능, 메모리 등을 일정하게 유지할 수 있어서 가장 일관된 결과를 얻을 수 있을 것 같았다.
하지만 현실적인 문제들이 많았는데, 코드가 바뀔 때마다 CloudFront에 배포를 다시하고 EC2 내부에서는 측정만 진행하거나, 아니면 EC2 내부에 VSCode를 설치하여 EC2에서 개발을 진행할 수 있도록 구축해야 했다.
팀원 3명이 각자 다른 페이지를 담당하는 상황에서, 모든 사람이 EC2 환경에 접근해서 작업하기에는 복잡도가 너무 높다고 판단했다.
마지막으로 검토한 방법이 GitHub Actions를 활용하는 것이었다.
GitHub에서 제공하는 서버 리소스를 사용해서, 매번 동일한 Ubuntu 환경에서 Chrome을 설치하고 Lighthouse CI를 실행하는 방식이다.
이 방법이 가장 괜찮다고 판단한 이유는 다음과 같다.
1. 환경 일관성
- 매번 새로운 Ubuntu 컨테이너에서 실행되기 때문에, 이전 테스트의 캐시나 상태가 영향을 주지 않는다.
- GitHub Actions의 runner 환경은 표준화되어 있어서 실행할 때마다 동일한 조건을 보장한다.
2. 자동화
- PR을 올리거나 특정 브랜치에 푸시할 때 자동으로 성능 테스트가 실행된다.
- 별도로 테스트 사이트에 접속하거나 명령어를 실행할 필요가 없다.
3. 비용
- GitHub의 public 저장소에서는 Actions를 무료로 사용할 수 있다.
- 성능 테스트가 몇 분 정도밖에 걸리지 않아서 사용량 제한에도 걸리지 않는다.
4. 접근성
- 팀원 누구나 PR만 올리면 성능 테스트 결과를 확인할 수 있어서 편하다!
- Lighthouse CI를 추가하는건 상대적으로 별로 어렵지 않은 상황이었다.
따라서, 우리팀은 GitHub Actions를 사용하는 것으로 결정하고 설정을 추가하여 Lighthouse CI를 도입했다!
Lighthouse CI를 적용하기로 결정했지만, 여기서 다시 마주한 문제는 우리의 운영자 페이지에서는 로그인을 진행해야만 메인 페이지로 접근할 수 있다는 것이었다.
Lighthouse CI는 각 URL을 독립적으로 측정하기 때문에, 매번 새로운 브라우저 세션에서 실행된다.
인증이 필요한 페이지의 성능 측정 문제
1. Lighthouse CI 시작
2. 새 브라우저 세션
3. 로그인 페이지 접근
4. 인증 X
5. 메인 페이지 접근 불가
즉, 로그인이 필요한 페이지들을 측정하려면 각 측정마다 인증 과정을 거쳐야 한다는 의미이다.
이러한 문제를 해결하기 위해 여러 방법을 찾아본 결과, Puppeteer 를 사용하여 성능 측정 이전에 인증 과정을 자동화하는 방법을 선택했다.
Puppeteer vs Puppeteer-Core
처음에는 Headless 환경에서 puppeteer-core를 사용해야 하는지 고민했다
간단하게 Puppeteer와 Puppeteer-core를 비교하면 다음과 같다.
- puppeteer: Chrome 브라우저 번들 포함 (~300MB)
- puppeteer-core: 브라우저 없이 API만 제공 (~13MB)
하지만 CI 환경에서 Chrome을 별도로 설치하기로 했기 때문에, 필자는 puppeteer를 그대로 사용하기로 결정했다.
관련 내용은 이후에 조금 더 자세히 설명하려고 한다.
우선 운영자 서비스는 로그인을 진행하면 AccessToken 이 발급된다.
필자는 로그인을 진행한 이후, AccessToken과 관련된 인증 정보를 auth-token.json 이라는 이름으로 파일을 저장하도록 auth-setup.js 를 작성했다.
1. 인증 정보 생성
// auth-setup.js async function setupAuth() { try { const response = await fetch("https://dev-api.ceo.popi.today/auth/login", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ username: process.env.TEST_EMAIL, password: process.env.TEST_PASSWORD, }), }); if (!response.ok) { throw new Error(`로그인 실패: ${response.status} ${response.statusText}`); } const data = await response.json(); console.log("로그인 응답:", data); if (!data.success || !data.data || !data.data.accessToken) { console.log("전체 응답:", JSON.stringify(data, null, 2)); throw new Error("로그인 실패 또는 토큰이 없습니다"); } const accessToken = data.data.accessToken; console.log("=========== JWT 토큰 획득 성공 ==========="); const authData = { accessToken, timestamp: new Date().toISOString(), expiresIn: data.data.expiresIn || 3600, }; const authFilePath = path.join(__dirname, "auth-token.json"); fs.writeFileSync(authFilePath, JSON.stringify(authData, null, 2)); console.log(`토큰 저장 완료: ${authFilePath}`); return accessToken; } catch (error) { console.error("인증 설정 실패:", error.message); throw error; } }
코드를 보면 알겠지만 로그인 API 호출 -> 응답에서 AccessToken 추출 -> auth-token.json 파일에 JSON 형태로 저장 순서로 동작한다.
이 부분은 CI 과정에서 하나의 Step으로 동작하여 미리 실행되도록 할 생각이다.
2. Puppeteer 스크립트 실행
module.exports = async (browser, context) => { console.log("=========== Puppeteer 스크립트 시작 ==========="); // 토큰 파일에서 accessToken 로드 const authFilePath = path.join(__dirname, "auth-token.json"); let accessToken = ""; if (fs.existsSync(authFilePath)) { const authData = JSON.parse(fs.readFileSync(authFilePath, "utf8")); accessToken = authData.accessToken; console.log("토큰 로드 완료"); } else { throw new Error("토큰 파일이 없습니다. auth-setup.js를 먼저 실행하세요"); } const page = await browser.newPage(); try { // 먼저 onboarding에 가서 인증 상태 설정 console.log("onboarding 페이지로 이동"); await page.goto("http://localhost:4173/onboarding", { waitUntil: "domcontentloaded", timeout: 30000, }); // localStorage에 인증 정보 설정 console.log("localStorage에 인증 정보 설정"); await page.evaluate(token => { const authStore = { state: { accessToken: token, isLogin: true, }, version: 0, }; localStorage.setItem("auth-store", JSON.stringify(authStore)); console.log( "localStorage 설정 완료:", localStorage.getItem("auth-store"), ); }, accessToken); await new Promise(resolve => setTimeout(resolve, 2000)); await page.waitForSelector("body", { timeout: 10000 }); console.log("=========== Puppeteer 스크립트 완료 ==========="); return page; } catch (error) { console.error("Puppeteer 스크립트 실행 중 오류:", error); await page.close(); throw error; } };
다음으로 Puppeteer 스크립트를 실행시킬 예정이다.
이전 auth-setup.js 단계에서 생성된 auth-token.json 을 읽어와서 토큰 정보를 로컬 스토리지에 주입하는 과정이 포함되어있다.
여기서 주의할 점은 우리 서비스가 AccessToken을 auth-store 라는 이름으로 localStorage에서 관리하기 때문에, 동일한 방식으로 토큰을 설정해야 한다는 것이다.
당연히 AccessToken 관리 방식이 달라진다면 그에 맞춰서 코드를 수정해야한다!
Q. Puppeteer를 어디서 사용했나요?
위 코드를 언뜻보면 그냥 Javascript로 코드를 작성한것처럼 보일 수 있다.
필자도 처음에는 Puppeteer를 어떻게 사용해야하는지 많이 찾아봤다.
여기서는 이 두가지를 기억하면 될 것 같다.
1. Lighthouse는 내부적으로 Puppeteer를 지원한다.
2. 위 스크립트는 Lighthouse에게 넘겨줄 내용이다.즉, puppeteer 관련 코드를 별도로 import를 하지 않아도 Lighthouse가 알아서 제공해줄거기 때문에 별도의 import가 필요없다.
여기서 puppeteer를 사용한 곳은
async (browser, context)에서 browser이다.이는 Lighthouse가 puppeteer.launch()로 만든 객체이며, 이후
const page = await browser.newPage();처럼 browser를 사용한 부분이 모두 puppeteer의 인스턴스를 사용한 부분이라고 이해하면 된다.
3. Lighthouse Config 생성
... const lighthouseConfig = { ci: { collect: { url: ["http://localhost:4173/dashboard"], puppeteerScript: "./scripts/lighthouse-puppeteer.js", puppeteerLaunchOptions: { executablePath: chromePath, args: chromeFlags, headless: true, }, settings: { preset: "desktop", throttlingMethod: "provided", throttling: { rttMs: 0, throughputKbps: 0, cpuSlowdownMultiplier: 1, requestLatencyMs: 0, downloadThroughputKbps: 0, uploadThroughputKbps: 0, }, }, numberOfRuns: 3, }, assert: { assertions: { "categories:performance": ["warn", { minScore: 0.8 }], "categories:accessibility": ["error", { minScore: 0.8 }], "categories:best-practices": ["warn", { minScore: 0.8 }], "categories:seo": ["warn", { minScore: 0.8 }], }, }, upload: { target: "temporary-public-storage", }, }, }; const configPath = path.join(process.cwd(), "lighthouserc.json"); fs.writeFileSync(configPath, JSON.stringify(lighthouseConfig, null, 2)); ...
마지막으로, 이전에 만든 스크립트들을 조합하여 lighthouserc.json 을 만드는 과정이다.
lighthouserc.json 이 실제로 Lighthouse CI를 동작할때 사용하는 설정 파일이며, 우리는 Puppeteer를 통해 인증 과정을 미리 세팅해야했기 때문에 javascript로 설정 파일을 생성하는 코드를 작성한거라고 이해하면 된다.
여기서 주목할 부분은 lighthouserc.json 속성에 puppeteerScript , puppeteerLaunchOptions 과 같이 puppeteer관련 속성이 있다는 것이다.
이를 통해 위에서 작성한 Puppeteer 스크립트가 실행된다.
4. Lighthouse CI
- name: 인증 설정 run: node scripts/auth-setup.js env: TEST_EMAIL: ${{ secrets.TEST_EMAIL }} TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }} - name: Lighthouse 설정 생성 run: node scripts/generate-lighthouse-config.js env: CHROME_PATH: /usr/bin/google-chrome-stable - name: Lighthouse CI 실행 run: lhci autorun env: LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }} CHROME_PATH: /usr/bin/google-chrome-stable
CI 단계의 일부를 가져와봤는데 이전에 설정한 auth-setup.js 실행 -> lighthouse config 생성 -> lighthouse CI 실행 순서로 동작한다.
위와 같은 방법으로 Lighthouse CI를 우리 프로젝트에 도입할 수 있었다.
하지만, 여러가지 문제가 있었는데 이제부터 한번 다뤄보려고 한다.


위 사진은 이전에 소개한 Lighthouse CI의 결과이다.
여기서 측정된 페이지를 살펴보면 localhost:4173/dashboard 인데, 해당 페이지는 로그인을 진행해야만 들어갈 수 있는 페이지이다.
첨부된 사진을 살펴보면 분명 대시보드 페이지에 대한 성능 측정이 완료된 것으로 보인다.
하지만, 실제 제공된 레포트를 들어가면 다음과 같다.

사진과 같이 /onboarding 페이지로 라우팅이 되어있는 모습을 볼 수 있으며, 캡처된 화면도 onboarding 화면이다.

하지만, 트리맵에 들어가보면 다음과 같이 타겟팅한 dashboard 페이지로 제대로 되어있는 모습을 볼 수 있다.
따라서, 필자가 생각하기에는 세션 정보에 저장된 인증 관련 정보가 성능 측정이 끝나면 사라지기 때문에, onboarding 페이지로 자동 라우팅되어 결과 레포트 주소는 onboarding 으로 보이는게 아닐까하고 추측해본다.
Lighthouse CI를 선택하면서 고려했던 가장 큰 주안점은 환경 일관성 과 측정 신뢰성 이다.
즉, 서로 다른 팀원이 Github Action을 통해 성능 측정을 진행하더라도 동일한 결과가 측정되어야만 의미가 있는 상황이라는 것이다.
하지만, 결과는 다음 사진과 같았다.

보다시피 LCP와 CLS의 차이는 거의 무의미 할정도로 비슷하다.
(여기서 Lighthouse CI는 3번의 측정 이후, 중앙값을 레포트로 제공해준다.)
하지만, 이 Performance 점수를 통해 알 수 있는건 Github 서버의 네트워크 상황에 따라서 성능 측정 결과가 달라질 수 있음을 의미한다.
만약, 그렇다면 Github 서버의 네트워크 환경은 우리가 제어할 수 없기 때문에 성능 측정에 대한 일관성을 보장하기 어렵다고 판단했다.
그래서 우리팀은 로컬 환경에서 연속으로 5번 Lighthouse를 통해 성능을 측정한 이후, 그 결과의 평균값을 사용하기로 결정했다.
실험적으로 진행해보니 이 방법이 가장 측정 신뢰성 이 높다고 판단했다.
물론 이러한 방식으로 테스트를 진행하면서 얻은 결과를 다른 팀원들과 비교하면서 Performance 점수를 논하기는 어렵다.
왜냐하면 결국 테스트 환경이 다르기 때문에 A 팀원이 측정한 Performance 점수 90점과 B 팀원이 측정한 80점은 네트워크 환경이 같았다면 동일한 점수가 될 가능성이 있기 때문이다.
대안책
상대적 비교
- 동일한 환경에서 개선 전후를 비교하여 상대적인 성능 향상을 측정
일관된 측정 환경
- 팀 내에서 측정 방법과 환경을 표준화하여 최대한 일관성 확보
결국, 동일한 와이파이를 사용하는 환경에서 동일한 노트북으로 성능 측정을 진행한 이후 팀원끼리 논의하는 방식이 가장 적합하다고 생각한다!
성능 개선 측정이라는 주제로 여러가지를 시도해본 결과, 각각의 방법에는 장단점이 있으며, 프로젝트의 상황과 팀의 리소스를 고려하여 가장 적합한 방법을 선택하는 것이 중요하다는걸 몸소 깨달았다.
우리가 경험한 것처럼 이론적으로 완벽해 보이는 방법도 실제 적용해보면 예상치 못한 문제들이 발생할 수 있다는걸 오랜만에 느껴본 것 같다.
튜닝의 끝은 순정이다 라는 말이 참 적절한 것 같다..
나중에 다른 프로젝트를 진행한다면, Lighthouse CI는 퍼포먼스가 너무 떨어지지 않는지 검사하는 용도로만 사용해볼 것 같다! 👊