쌈@뽕하게 Lighthouse 자동화하기

­가은·2024년 6월 21일
100
post-thumbnail

오늘은 내가 lighthouse 모니터링 자동화 작업을 한 과정에 대해 설명해보려 한다.

먼저 이러한 작업을 하게 된 배경을 이야기해보자면,
난 현재 인턴 신분으로 특정 페이지의 버전 2.0을 개발하는 프로젝트에 참여하고 있다.
그리고 최근 회사에서 성능, 특히 SEO 점수를 향상시키는 데 심혈을 기울이고 있어서 성능 수준을 파악하는 것이 중요했다.
그런 이유로 나에게 매일 Lighthouse를 측정하고 기록하는 업무가 주어졌다.

난 약 일주일 간 매일 5개 페이지의 Lighthouse를 측정했다.
화면을 캡쳐하고, 결과를 json으로 저장하고, 표에 직접 결과를 작성하고...
이러한 과정들에는 생각보다 많은 시간이 필요했다.

그래도 명색이 개발자라는 사람인데..
이렇게 반복되는 작업을 매일 직접 하고 있다는게 얼마나 짜치는지...
나와 별개로 PM님도 매일 Lighthouse를 측정하고 있는 걸 보면서 인력 낭비가 크다고 느꼈다.

그래서 난 Lighthouse 측정을 자동화하면 장기적으로 회사에 도움이 될 거라 생각했다.
특히 특정 작업으로 인해 성능이 많이 떨어지는 경우를 잡아내고, 그런 자잘한 원인들을 해결함으로써 성능을 끌어올릴 수 있을 거라고 생각했다.

이러한 이유로 나는 Lighthouse 자동화 작업을 해보기로 했다.
팀원들과 논의한 후 정해진 내 작업의 1차 목표는 아래와 같았다.

1. 타겟 브랜치가 master인 PR에 push될 때마다
2. 해당 브랜치의 Lighthouse를 측정하고
3. 측정 결과를 PR Comment로 남기고
4. Google Spreadsheet에도 기록하는 것

이 블로그 글은 여기까지만 설명하지만, 이후에도 다른 분들의 요청에 따라 주기적으로 우리 서비스 프로덕션이나 타 서비스의 Lighthouse를 측정하는 등 몇몇 워크플로우를 더 작성했다.

나는 Github Actions 워크플로우를 작성해본 것이 처음이었다.
그래서 참고할 자료를 검색해봤는데, Lighthouse CI를 알아보고 Github Actions에 적용하기라는 카카오 기술블로그 글이 가장 위에 있었다.
그리고 Lighthouse 자동화 작업에 관한 대부분의 글이 다 저 글을 따라한 내용이었다.
내가 할 작업은 저 글의 내용만으로는 부족한데... 대부분 저 글에서 크게 벗어나지 않는 내용만 작성해두어서 사실 많이 아쉬웠다.

난 위에서 말했듯 워크플로우 작성이 처음이었기도 했고, 처음부터 끝까지 참고할 만한 자료가 잘 없었기 때문에 삽질을 좀 많이 했다. 😅
특히 코드를 한 번 고칠 때마다 직접 워크플로우를 돌려봐야 정상동작 여부를 알 수 있었기 때문에 시간이 많이 걸렸다.

그래서 나중에 누군가 나와 같은 작업을 하게 되었을 때 참고할 수 있는 자료를 남겨두고 싶어서 이 글을 작성하게 되었다.


🍞 Lighthouse란?

먼저 Lighthouse가 무엇인가에 대해서 조금 알아보고 가자.
공식 문서에 따르면 Lighthouse는 웹페이지 품질을 개선하기 위한 오픈소스 자동화 도구라고 한다.

출처: Chrome for Developers

출처: Chrome for Developers

이런 식으로 개발자 도구의 Lighthouse 탭에서 쉽게 측정해볼 수 있다.

같은 페이지에서 Lighthouse를 여러 번 측정해보면 계속 점수가 다르게 나올 때가 있다.
그 이유가 궁금했는데, Lighthouse 공식문서에 따르면 아래와 같은 이유 때문이라고 한다.

  • A/B 테스트 또는 게재되는 광고의 변경사항
  • 인터넷 트래픽 라우팅 변경사항
  • 고성능 데스크톱 및 저성능 노트북과 같은 다양한 기기에서 테스트
  • 자바스크립트를 삽입하고 네트워크 요청을 추가/수정하는 브라우저 확장 프로그램
  • 바이러스 백신 소프트웨어

그러니 Lighthouse를 의심하지는 말자.

그럼 이제 Lighthouse의 지표에 대해 알아보자.
이 글의 주 내용이 Lighthouse 지표는 아니기 때문에 각 지표의 의미에 대해서만 간단히 작성했다.
더 자세히 알고 싶다면 각 타이틀에 걸린 링크에 들어가보는 것을 추천한다.

🍡 Performance

성능을 측정하고, 페이지 로드 속도를 높일 수 있는 방법을 알아볼 수 있다.

  • First Contentful Paint (FCP)
    : 사용자가 페이지로 이동한 후 브라우저에서 첫 번째 DOM 콘텐츠를 렌더링하는 데 걸리는 시간을 측정한다.
  • Speed Index
    : 페이지 로드 중 콘텐츠가 시각적으로 표시되는 속도를 측정한다.
  • Largest Contentful Paint (LCP)
    : 표시 영역에서 가장 큰 콘텐츠 요소가 화면에 렌더링될 때를 측정한다.
  • Total Blocking Time (TBT)
    : 페이지가 마우스 클릭, 화면 탭 또는 키보드 누름과 같은 사용자 입력에 응답하지 못하도록 차단된 총 시간을 측정한다.
  • Cumulative Layout Shift (CLS)
    : 페이지의 전체 수명 주기 동안 발생하는 모든 예상치 못한 레이아웃 변경에 관한 레이아웃 변경 점수의 가장 큰 버스트를 측정한다.

🍡 Accessibility

모든 사용자가 콘텐츠에 액세스하고 사이트를 효과적으로 탐색하는지 확인한다.

🍡 Best Practices

권장사항에 따라 웹페이지의 코드 상태를 개선할 수 있다.

🍡 SEO

페이지가 검색엔진 결과 순위에 최적화되었는지 확인할 수 있다.


🍞 사전 작업

본격적으로 코드를 작성하기 이전에 필요한 것들을 먼저 세팅해보자.
이 구간을 건너뛰고 코드 먼저 작성하다가 필요할 때 다시 돌아와도 괜찮다.

🍡 Actions secrets 설정

외부에 노출하면 안될 데이터는 Actions secrets 변수로 저장할 수 있다.

워크플로우 작성에 익숙하지 않다면 '그게 뭔지 모르겠지만 그냥 env 파일에 넣으면 되지 않나...?' 라고 생각할 수도 있다.

내가 그랬기 때문이다...
난 처음에 상수들을 env 파일에 넣었다.
그리고 잘 동작했다.
env 파일을 .gitignore에 넣기 전까진 말이다...

로컬 환경에서는 env 파일의 환경변수를 사용할 수 있지만, env 파일을 .gitignore에 넣게 되면 배포 시에 환경 변수를 사용할 수 없게 된다.
바보같이 이 사실을 생각하지 못했다 😂

그래서 워크플로우를 작성할 때는 Actions secrets 변수를 사용해야 한다.

레포지토리의 Settings 탭으로 접속해서 Secrets and variables > Actions 메뉴로 들어가보자.

여기서 New repository secret 버튼을 누르면 아래와 같이 변수를 저장할 수 있다.

일단은 방법만 알아두고, 여기 들어갈 변수들은 밑에서 살펴보도록 하자.


🍡 Lighthouse CI Github App 설치

Lighthouse 측정 결과를 PR Comment로 달기 위해서 Lighthouse CI Github App의 도움을 받을 수 있다.
Lighthouse CI Github App에 접속해보자.

여기서 Lighthouse CI를 설치할 곳을 고르면 된다.
나는 이미 회사 레포지토리에 설치한 상태라 아래에 Configure라고 적혀있지만 보통은 아무것도 적혀있지 않을 것이다.

마지막 단계에서 화면에 뜨는 토큰을 복사해둔다.
그리고 위에서 설명했던 방법대로 secrets 변수에 저장하면 된다.
나는 LHCI_GITHUB_APP_TOKEN라는 이름으로 저장했다.


🍡 Google Spreadsheet API 설정

Google Spreadsheet API를 사용하여 Google Spreadsheet를 조작할 수 있다.
PR Comment까지만 한다면 이 단계는 건너뛰어도 괜찮다.
필요하다면 같이 해보도록 하자.

먼저 Google Cound Console로 이동해서 새 프로젝트를 만들어보자.

프로젝트가 생성됐다면 이제 서비스 계정을 만들어보자.

다시 이 페이지로 돌아와서 방금 생성한 프로젝트를 선택한다.

완료 버튼을 누르면 서비스 계정이 생성될 것이다.
이제 키를 생성해보자.

방금 만든 서비스 계정을 클릭한다.

위 과정을 잘 따라와서 키 만들기를 완료하면 json 파일이 다운받아질 것이다.

json 파일은 이렇게 구성되어있다.
우린 여기서 private_keyclient_email값을 사용할 것이다.
두 값 모두 secrets 변수로 저장하자.
난 각각 LHCI_GOOGLE_PRIVATE_KEY, LHCI_GOOGLE_CLIENT_EMAIL로 저장했다.

이제 Google Sheets API를 사용하려면 API를 활성화하는 단계를 거쳐야 한다.

검색창에 Google Sheets API를 검색해서 들어가면 위와 같은 페이지가 나온다.
사용 버튼을 눌러 활성화시키자.

마지막으로 우리의 Spreadsheet에 액세스할 수 있는 권한을 부여해줘야 한다.

Spreadsheet는 그냥 알아서 만들면 된다.
액세스할 수 있는 사용자에 아까 생성한 키값 내부의 client_email값을 추가해준다.

이제 귀찮은 세팅 과정은 모두 끝났다.
코드를 작성하러 가보자.


🍞 상수 정의

🍡 Lighthouse.js

상수 파일부터 작성해보자.
여기서는 상수를 하나의 파일에 모두 담았는데, 실제로는 src/configs/lighthouse/ 하위에 여러 파일로 분리해서 작성했다.

module.exports = {
  
  // Google Spreadsheet에 접근할 때 사용되는 Google Spreadsheet id
  // Google Spreadsheet 링크가 https://docs.google.com/spreadsheets/d/12345/edit?pli=1#gid=499495518 형태라면, 그 중 12345가 Google Spreadsheet id
  LHCI_GOOGLE_SPREAD_SHEET_ID: '12345',
  
  
  // Lighthouse 점수 색상 기준
  // https://developer.chrome.com/docs/lighthouse/performance/performance-scoring?hl=ko#color-coding 참고
  // Lighthouse의 점수 기준을 따름
  // 0 ~ 49 (빨간색): 나쁨
  // 50 ~ 89 (주황색): 개선 필요
  // 90 ~ 100 (녹색): 좋음
  LHCI_GREEN_MIN_SCORE: 90, 
  LHCI_ORANGE_MIN_SCORE: 50, 
  LHCI_RED_MIN_SCORE: 0,
  
  // lighthouse 성능 측정할 페이지 이름 목록
  // PR Comment에 페이지 url이 아닌 페이지 이름을 노출시키기 위해 필요함
  // 페이지 url이 짧다면 괜찮지만, 길다면 가독성이 떨어질 수 있기 때문에 페이지 이름을 보여주는 것을 추천
  LHCI_MONITORING_PAGE_NAMES: [
    '페이지A',
    '페이지B',
    '페이지C',
    '페이지D',
    '페이지E',
  ],

  // lighthouse 성능 측정할 페이지 이름 - url 매핑 
  LHCI_PAGE_NAME_TO_URL: {
    '페이지A': '/page/typeA',
    '페이지B': '/page/typeB',
    '페이지C': '/page/typeC',
    '페이지D': '/page/typeD',
    '페이지E': '/page/typeE',
  },
  
   // lighthouse 성능 측정할 페이지 이름 - 시트 id 매핑
   // Google Spreadsheet 링크가 https://docs.google.com/spreadsheets/d/12345/edit#gid=123123라면, 시트 id는 123123 부분
  LHCI_PAGE_NAME_TO_SHEET_ID: {
    '페이지A': 0,
    '페이지B': 1,
    '페이지C': 2,
    '페이지D': 3,
    '페이지E': 4,
  },

  // 페이지 이름을 받아서 페이지 url을 리턴해주는 함수
  getLhciPageNameFromUrl: (url) => {
    for (const [name, path] of Object.entries(module.exports.LHCI_PAGE_NAME_TO_URL)) {
      if (decodeURIComponent(path) === decodeURIComponent(url)) return name;
    }
  },

  // 페이지 url을 받아서 페이지 이름을 리턴해주는 함수
  getLhciUrlFromPageName: (name) => {
    return module.exports.LHCI_PAGE_NAME_TO_URL[name];
  },
  
  // 페이지 이름을 받아서 페이지 시트 id를 리턴해주는 함수
  getLhciSheetIdFromPageName: (name) => {
    return module.exports.LHCI_PAGE_NAME_TO_SHEET_ID[name];
  },
};

아직은 이 상수들이 왜 필요한지 감이 잘 오지 않을수도 있지만, 밑에서 차차 알게 될 것이다.

노출돼도 괜찮은 정보는 이렇게 일반 상수 파일에 정의해두고,
민감한 정보들은 위에서 말한 것과 같이 secrets 변수에 저장해두었다.


🍞 configuration 설정

이제 config 파일을 구성해보자.
루트 디렉토리에 위치하며 Lighthouse CI 옵션을 관리하는 파일이다.
아래의 네이밍을 사용할 수 있으며, 작성된 우선순위에 따라 자동으로 파일을 찾는다고 한다.

  1. .lighthouserc.js
  2. lighthouserc.js
  3. .lighthouserc.cjs
  4. lighthouserc.cjs
  5. .lighthouserc.json
  6. lighthouserc.json
  7. .lighthouserc.yml
  8. lighthouserc.yml
  9. .lighthouserc.yaml
  10. lighthouserc.yaml

만약 이 파일을 다른 위치에 놓거나 다른 네이밍을 사용하고 싶다면, lhci command에 --config=./path/to/file 옵션을 붙여 config 파일을 명시해주어야 한다.
조금 이따 사용할 예정이다.

lighthouserc.js 파일은 아래와 같은 형식으로 구성할 수 있다.

module.exports = {
  ci: {
    collect: {
      
    },
    assert: {
      
    },
    upload: {
      
    },
    server: {
      
    },
    wizard: {
      
    },
  },
};

나는 이 중에서 collect, upload 속성만 작성했다.

그리고 lighthouserc.js 파일 말고도 lighthouserc-desktop.js, lighthouser-mobile.js 파일을 추가로 작성했다.
desktop과 mobile을 별도로 측정하기 위해서이다.
처음에는 desktop 설정으로만 측정했었는데, PM님이 desktop과 mobile 기록 모두 필요하다고 하셔서 각각 측정할 수 있도록 수정했다.

이해를 돕기 위해 먼저 desktop으로만 측정했을 때의 코드를 살펴보자.

// 상수파일에서 상수 import해오기
const { LHCI_MONITORING_PAGE_NAMES, getLhciUrlFromPageName } = require('./src/configs/lighthouse/Lighthouse.js');

// '/page/typeA'를 'http://localhost:3000/page/typeA' 형식으로 바꿈
const urls = LHCI_MONITORING_PAGE_NAMES.map(
  (name) => `http://localhost:3000${getLhciUrlFromPageName(name)}`
);

module.exports = {
  ci: {
    collect: {
      startServerCommand: 'npm run start',
      url: urls,
      numberOfRuns: 1,
      settings: {
        preset: 'desktop',
      },
    },

    upload: {
      target: 'filesystem',
      outputDir: './lhci_reports',
      reportFilenamePattern: '%%PATHNAME%%-%%DATETIME%%-report.%%EXTENSION%%',
    },
    
    // assert: {
    //  assertions: {
    //.   'first-contentful-paint': 'off',
    //    'categories:accessibility': ['error', { minScore: 1 }],
    //    'categories:performance': ['warn', { minScore: 0.9 }],
    //    'installable-manifest': ['warn', {'minScore': 1}],
    //    'uses-responsive-images': ['error', {'maxLength': 0}]
    //   },
    // },
  },
};

collect.startServerCommand

  • 서버를 시작할 수 있는 명령어이다.
  • 별도의 서버가 필요 없는 정적 웹 사이트일 경우, staticDistDir 옵션에 정적 파일의 경로를 작성하면 된다.
  • startServerCommand를 사용하여 서버를 시작시켜야 하는 프로젝트의 경우 staticDistDir 옵션을 사용하면 안된다.
  • 만약 내 프로젝트와 아예 관련 없는 서비스의 lighthouse를 측정하고 싶다면, startServerCommandstaticDistDir 모두 작성하지 않아도 된다.

collect.url

  • Lighthouse 결과를 수집할 url 배열이다.
  • 여기서는 ['http://localhost:3000/page/typeA', 'http://localhost:3000/page/typeB', ... 'http://localhost:3000/page/typeE'] 형식이 된다.

collect.numbersOfRuns

  • 각 url에서 Lighthouse 결과를 수집하는 횟수이다.
  • 앞에서 말했던 것처럼 Lighthouse 결과는 측정할 때마다 조금씩 차이가 발생하는데, 이러한 문제를 보완하는 데 도움이 되는 옵션이다.

collect.settings.preset

  • Lighthouse의 기본 설정은 mobile이다.
  • desktop 설정으로 Lighthouse를 측정하고 싶다면 preset: 'desktop'으로 별도의 설정을 해주어야 한다.

upload.target

  • Lighthouse 결과를 업로드할 장소이다.
  • target: 'temporary-public-storage'로 설정할 경우 임시 저장소에 결과를 저장할 수 있다. 하지만 링크가 있는 인터넷상의 모든 사람이 기록에 접근할 수 있고, 며칠 후 기록이 삭제된다. 개인이 간단하게 진행하는 사이드 프로젝트라면 이 옵션이 적당하겠지만 회사에서 사용하기에는 적당하지 않다고 생각했다.
  • target: filesystem으로 설정할 경우 결과를 프로젝트 내부에 저장할 수 있다. 난 워크플로우를 실행할 때마다 결과를 프로젝트 내부에 생성한 후, 해당 결과에 접근해서 점수를 뽑아내는 방식을 사용할 예정이기 때문에 이 옵션을 선택했다.

upload.outputDir

  • target: filesystem인 경우에만 사용되는 옵션이다.
  • 결과를 저장할 파일 경로를 작성하는 곳이다.
  • 결과를 저장하면 폴더 내부에 manifest.json 파일이 생성되는데, 이미 manifest.json 파일이 존재할 경우 그것을 덮어쓰게 된다.
  • 어떤 식으로 결과가 저장되는지는 이후에 다시 살펴보자.

upload.reportFilenamePattern

  • target: filesystem인 경우에만 사용되는 옵션이다.
  • 파일시스템에 저장될 결과의 파일명 패턴을 작성하면 된다.
  • %%HOSTNAME%%, %%PATHNAME%%, %%DATETIME%%, %%DATE%%, %%EXTENSION%% 등을 사용할 수 있다.
  • 사실 우리가 할 작업에서는 파일명이 별로 중요하진 않다. 컴퓨터가 식별만 할 수 있으면 된다.

assert.assertions

  • assert에서는 조건을 확인하고, 오류가 있는 경우 종료시킬 수 있다.
  • assertions에는 그 조건을 작성한다.
  • 특정 지표를 측정하지 않고 싶거나 (off), 특정 점수를 기준으로 경고 (warn)나 에러 (error)를 발생시키고 싶을 때 사용하면 된다.
  • 나의 경우 이러한 기능이 필요 없기 때문에 작성하지 않았다.

이외의 옵션들은 lighthouse ci 문서에 상세하게 설명되어 있다.
TMI이지만 사실 난 이 단계에서 startServerCommandurl을 잘못 작성하는 바람에 시행착오가 많았다 😅


🍡 lighthouserc.js

그럼 이제 mobile과 desktop을 각각 측정할 수 있는 코드를 살펴보자.
참고로 lighthouserc.js, lighthouserc-desktop.js, lighthouserc-mobile.js 모두 루트 디렉토리에 위치시켰다.

module.exports = {
  ci: {
   // 
  },
};

그렇다.. lighthouserc.js는 사실상 비어있다...
이 파일에는 mobile과 desktop 모두에 적용되는 설정을 작성할 곳인데, 내 경우에는 작성할 것이 따로 없었다.
추후 코드가 추가될 가능성을 염두에 두고 파일 생성만 해두었다.
그리고 이후 작업자 분들이 참고할 수 있도록 assert에 관한 설명과 예시를 주석으로 달아두었다.


🍡 lighthouserc-desktop.js

const {
  LHCI_MONITORING_PAGE_NAMES,
  getLhciUrlFromPageName,
} = require('./src/configs/lighthouse/Lighthouse.js');

const urls = LHCI_MONITORING_PAGE_NAMES.map(
  (name) => `http://localhost:3000${getLhciUrlFromPageName(name)}`
);

module.exports = {
  ci: {
    collect: {
      startServerCommand: 'npm run start',
      url: urls,
      numberOfRuns: 1,
      settings: {
        preset: 'desktop', 🍥
      },
    },

    upload: {
      target: 'filesystem',
      outputDir: './lhci_reports/desktop', 🍥
      reportFilenamePattern: '%%PATHNAME%%-%%DATETIME%%-report.%%EXTENSION%%',
    },
  },
};

desktop에만 적용되는 설정을 작성하는 파일이다.
mobile과 다른 부분에는 🍥 이모지를 붙여두었다.


🍡 lighthouserc-mobile.js

const {
  LHCI_MONITORING_PAGE_NAMES,
  getLhciUrlFromPageName,
} = require('./src/configs/lighthouse/Lighthouse.js');

const urls = LHCI_MONITORING_PAGE_NAMES.map(
  (name) => `http://localhost:3000${getLhciUrlFromPageName(name)}`
);

module.exports = {
  ci: {
    collect: {
      startServerCommand: 'npm run start',
      url: urls,
      numberOfRuns: 1,
    },

    upload: {
      target: 'filesystem',
      outputDir: './lhci_reports/mobile', 🍥
      reportFilenamePattern: '%%PATHNAME%%-%%DATETIME%%-report.%%EXTENSION%%',
    },
  },
};

desktop 설정과 mobile 설정의 다른 부분은 두 곳뿐이다.
desktop은 settings.preset'desktop'으로 설정해주었고, mobile은 별도의 설정을 해주지 않았다.
또 desktop은 outputDir'./lhci_reports/desktop'으로 지정했고, mobile은 './lhci_reports/mobile'로 지정했다.

이렇게 config 파일 구성을 마쳤으니, 본격적으로 워크플로우 작성으로 넘어가보자.


🍞 workflow 작성

코드를 작성하기 전에 완성본부터 먼저 살펴보자.
어떻게 만들건지는 알고 코드를 짜야 하니까...

PR Comment는 이런 식으로 구성할 예정이다.
제일 위에 제목과 점수 기준을 적어두고,
성능 점수는 토글 형식으로 접근할 수 있게 했다.

사실 처음에는 Performance, Accessibility, Best practices, SEO, PWA와
FCP, LCP, Speed Index, TBT, CLS를 각각의 표로 만들었다.
근데 이 부분은 그냥 합쳐달라는 요청을 받아서 합쳤다.
아래 5개는 Performance 하위의 지표들이기 때문에 표를 나누는 것도 괜찮은 방법이라고 생각한다.

Google Spreadsheet는 이렇게 구성할 것이다.
PR 번호에 하이퍼링크를 달아 PR로 바로 접근할 수 있도록 하고, 성능을 측정한 시간도 함께 기록하도록 했다.
그리고 Desktop 측정 결과에는 [D]를, Mobile 측정 결과에는 [M]을 붙였다.
색상같은 경우에는 그냥 내 맘대로 한거라 각자 취향껏 하면 될 것 같다.

이제 정말 코드를 작성해보자.
전체 코드는 글 마지막에 첨부되어있다.

먼저 루트 디렉토리의 .github/workflows/ 내부에 lighthouse.yml 파일을 생성하고, 그 파일에 작성을 시작해보자.


🍡 기본 세팅

# 워크플로우 이름
name: Run Lighthouse CI (Push on PR to master branch)

# 워크플로우를 실행할 조건
on:
  pull_request:  
    branches:
      # master 브랜치가 타겟브랜치인 PR
      - master 
    types:
      # PR이 생성되면
      - opened
      # PR이 업데이트되면
      - synchronize

워크플로우를 실행하는 조건은, master 브랜치를 타겟으로 하는 PR이 생성/업데이트될 때이다.
변경사항이 push될 때마다 Lighthouse를 재측정하기 위해서 synchronize를 추가했다.

# 액세스 권한 설정
permissions: 
  contents: read
  pull-requests: write 

PR comment를 읽고 작성하기 위해서 위와 같은 permissions 설정이 필요하다.

# 워크플로우가 실행할 일
jobs:
  lhci:
    name: Lighthouse CI
    runs-on: ubuntu-latest
    steps:
      # 워크플로우에서 액세스할 수 있도록 저장소의 코드를 가져옴
      - name: Checkout
        uses: actions/checkout@v4

      # Node.js 버전을 설치하고 설정
      # 각자의 프로젝트 설정에 맞는 노드 버전을 사용하면 됨
      - name: Use Node.js 18.12.1
        uses: actions/setup-node@v4
        with:
          node-version: 18.12.1

      # node modules 디렉토리 캐싱
      # 동일한 종속성으로 여러 번 실행되는 워크플로우 속도 향상시킴
      - name: Cache node_modules
        uses: actions/cache@v4
        id: npm-cache
        with:
          # 캐시할 디렉토리/파일의 경로 지정
          path: |
            **/node_modules
          # 캐시 키 설정
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
          # 캐시를 복원할 때 사용할 대체 키 지정
          restore-keys: |
            ${{ runner.os }}-node-
 
      # 프로젝트 종속성 설치
      # package.json 파일에 정의된 모든 종속성을 node_modules 디렉토리에 설정
      - name: Install packages 
        run: |
          npm install 

      # 프로젝트 빌드
      - name: Build
        run: |
          npm run build

jobs 하위에 워크플로우가 실행할 일을 작성하면 되고,
steps 하위에 순서대로 로직을 작성하면 된다.
이후로 나오는 코드는 모두 steps 하위에 작성되는 코드들이다.

워크플로우를 실행하기 위한 기본적인 스텝들을 작성해두었다.
사실 이 부분은 다 비슷비슷하기 때문에 대충... 다른 코드를 긁어와도 된다 🤷‍♀️


🍡 Lighthouse 측정

      # Desktop 설정으로 Lighthouse 측정
      - name: Run Lighthouse CI for Desktop
        # secrets에 저장한 LHCI_GITHUB_APP_TOKEN 값 사용
        env:
          LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
        # Lighthouse CI를 전역으로 설치
        # lighthouserc-desktop.js 설정 파일에 따라 Lighthouse 데이터 수집
        # lighthouserc-desktop.js 설정 파일에 따라 수집된 데이터 업로드
        # 실패 시 'Fail to Run Lighthouse CI 💦' 출력
        run: | 
          npm install -g @lhci/cli
          lhci collect --config=lighthouserc-desktop.js || echo 'Fail to Run Lighthouse CI 💦'
          lhci upload --config=lighthouserc-desktop.js || echo 'Fail to Run Lighthouse CI 💦'

      # Mobile 설정으로 Lighthouse 측정
      - name: Run Lighthouse CI for Mobile
        env:
          LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
        # lighthouserc-mobile.js 설정 파일에 따라 Lighthouse 데이터 수집
        # lighthouserc-mobile.js 설정 파일에 따라 수집된 데이터 업로드
        # 실패 시 'Fail to Run Lighthouse CI 💦' 출력
        run: | 
          lhci collect --config=lighthouserc-mobile.js || echo 'Fail to Run Lighthouse CI 💦'
          lhci upload --config=lighthouserc-mobile.js || echo 'Fail to Run Lighthouse CI 💦'

만약 mobile과 desktop을 분리하지 않고 lighthouserc.js에 모든 설정을 작성했다면 아래와 같이 작성하면 된다.

      - name: Run Lighthouse CI
          env:
            LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
          run: | 
            lhci autorun || echo "Fail to Run Lighthouse CI!"'

이 단계가 잘 실행되었다면, 워크플로우를 실행했을 때 위와 같은 화면이 보일 것이다.

결과는 이런 식으로 저장된다.

manifest.json 파일은 이렇게 객체로 이루어진 배열로 구성되어있다.
이 파일에 접근해서 Performance, Accessibility, Best Practices, SEO, PWA 점수를 가져올 수 있다.

이외의 파일들은 내용이 복잡하므로 lighthouse-ci 예시 파일을 보는 것을 추천한다.
audits 속성 내부에 Performance의 하위 지표들의 점수가 들어있다.


🍡 Lighthouse 결과 포맷팅하기

여기서부터는 코드가 좀 길어진다.

      # Lighthouse 결과를 PR Comment에 작성할 형식대로 포맷팅
      - name: Format lighthouse score
        id: format_lighthouse_score
        uses: actions/github-script@v7
        with:
          script: | 

위 코드의 script 하위에 작성된 코드만 따로 뽑아서 js 파일 형식으로 보자.

실제 코드에서는 js 파일이 아니기 때문에 문법 에러가 나도 빨간 줄이 뜨지 않는다.
난 정말 사소한 오타나 문법 실수들 때문에 시간을 많이 날렸다 😂
주의해서 코드를 작성하자.

            // Lighthouse 측정 결과 파일을 읽어오기 위해 'fs' import
            const fs = require('fs');
            const { getLhciPageNameFromUrl, LHCI_GREEN_MIN_SCORE, LHCI_ORANGE_MIN_SCORE, LHCI_RED_MIN_SCORE } = require('./src/configs/lighthouse/Lighthouse.js');

			// 점수를 받아서 해당 점수의 색상을 리턴해주는 함수
            const getColor = (score) => {
              if (score >= LHCI_GREEN_MIN_SCORE) return '🟢';
              else if (score >= LHCI_ORANGE_MIN_SCORE) return '🟠';
              return '🔴';
            }

            // 점수를 받아서 색상 + 점수를 리턴해주는 함수들
            // Performance, Accessibility, Best Practices, SEO, PWA에 적용됨
            const getAuditColorAndScore = (score) => getColor(score) + score;
            // Performance 하위 지표인 FCP, LCP, Speed Index, TBT, CLS에 적용됨
            const getPerformanceMetricColorAndScore = (category) => getColor(category.score * 100) + category.displayValue;

            // 점수는 0-1의 숫자로 표현되기 때문에 100을 곱해주는 함수 필요
            const formatResult = (res) => Math.round(res * 100);

            // Lighthouse 결과가 저장된 파일에서 내용을 읽어옴
            // path는 '{Github Actions 러너의 기본 디렉토리}/{GitHub Actions가 클론한 레포지토리가 위치한 경로}/{GitHub Actions 워크플로우에서 접근하려는 실제 파일 경로}'
            const desktopResults = JSON.parse(fs.readFileSync(''));
            const mobileResults = JSON.parse(fs.readFileSync(''));

            // Lighthouse를 측정한 시간 (Google Spreadsheet 기록 용도)
            const monitoringTime = new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' });
            // PR Comment에 작성될 색상별 점수 기준 
            const scoreDescription = `> 🟢: ${LHCI_GREEN_MIN_SCORE} - 100` + ' / ' + `🟠: ${LHCI_ORANGE_MIN_SCORE} - ${LHCI_GREEN_MIN_SCORE - 1}` + ' / ' + `🔴: ${LHCI_RED_MIN_SCORE} - ${LHCI_ORANGE_MIN_SCORE - 1}`;
            // PR Comment에 작성될 comments 변수
            let comments = '';

            // comments에 Comment 제목과 점수 기준 추가
            comments += `### Lighthouse report ✨\n`;
            comments += `${scoreDescription}\n\n`;

            // Google SpreadSheet에 기록될 scores 객체
            const scores = { desktop: {}, mobile: {} };

결과 파일에서 점수를 뽑아내고 포맷팅할 때 쓰일 변수, 함수들을 정의했다.

위에서 const desktopResults = JSON.parse(fs.readFileSync('')); 안에 들어갈 경로를 '{Github Actions 러너의 기본 디렉토리}/{GitHub Actions가 클론한 레포지토리가 위치한 경로}/{GitHub Actions 워크플로우에서 접근하려는 실제 파일 경로}' 라고 적어놨는데, 사실 이건 주석을 달기 위해서 적어본거고

여기서 Dumping reports to disk at ~~ 뒤에 나오는 경로를 그대로 복붙하고 뒤에 /manifest.json을 추가해주면 된다.

            // Lighthouse 측정 결과에서 각 점수를 추출해내는 함수
            const extractLhciResults = (results, device) => {
              // 소제목으로 mobile인지 desktop인지 작성
              comments += `#### ${device}\n\n`;

              results.forEach((result) => {
                // url, summary, jsonPath, audits 추출
                const { url, summary, jsonPath } = result;
                const { audits } = JSON.parse(fs.readFileSync(jsonPath));

                // pageUrl에서 'http://localhost:3000' 부분 제거 
                const pageUrl = url.replace('http://localhost:3000', '');
                // pageUrl을 이용해서 pageName 추출
                const pageName = getLhciPageNameFromUrl(pageUrl);

                // summary 내의 모든 점수에 100을 곱함 (0-1 사이의 수로 표현되기 때문)
                Object.keys(summary).forEach((key) => (summary[key] = formatResult(summary[key])));

                // summary에서 점수 추출
                const { performance, accessibility, 'best-practices': bestPractices, seo, pwa } = summary;
                // audits에서 점수 추출 (Performace 하위 지표들)
                const { 'first-contentful-paint': firstContentfulPaint, 'largest-contentful-paint': largestContentfulPaint, 'speed-index': speedIndex, 'total-blocking-time': totalBlockingTime, 'cumulative-layout-shift': cumulativeLayoutShift } = audits;

                // PR Comment에 작성하기 위해 점수를 표 형태로 생성
                const formattedScoreTable = [
                  `| Category | Score |`,
                  `| --- | --- |`,
                  `| ${getColor(performance)} Performance | ${performance} |`,
                  `| ${getColor(accessibility)} Accessibility | ${accessibility} |`,
                  `| ${getColor(bestPractices)} Best practices | ${bestPractices} |`,
                  `| ${getColor(seo)} SEO | ${seo} |`,
                  `| ${getColor(pwa)} PWA | ${pwa} |`,
                  `| ${getColor(firstContentfulPaint.score * 100)} First Contentful Paint | ${firstContentfulPaint.displayValue} |`,
                  `| ${getColor(largestContentfulPaint.score * 100)} Largest Contentful Paint | ${largestContentfulPaint.displayValue} |`,
                  `| ${getColor(speedIndex.score * 100)} Speed Index | ${speedIndex.displayValue} |`,
                  `| ${getColor(totalBlockingTime.score * 100)} Total Blocking Time | ${totalBlockingTime.displayValue} |`,
                  `| ${getColor(cumulativeLayoutShift.score * 100)} Cumulative Layout Shift | ${cumulativeLayoutShift.displayValue} |`,
                  `\n`,
                ].join('\n');

                // 점수를 Google SpreadSheet에 기록될 형태로 정리하여 객체로 생성
                const score = {
                  Performance: getAuditColorAndScore(performance),
                  Accessibility: getAuditColorAndScore(accessibility),
                  'Best Practices': getAuditColorAndScore(bestPractices),
                  SEO: getAuditColorAndScore(seo),
                  PWA: getAuditColorAndScore(pwa),
                  FCP: getPerformanceMetricColorAndScore(firstContentfulPaint),
                  LCP: getPerformanceMetricColorAndScore(largestContentfulPaint),
                  'Speed Index': getPerformanceMetricColorAndScore(speedIndex),
                  'TBT': getPerformanceMetricColorAndScore(totalBlockingTime),
                  'CLS': getPerformanceMetricColorAndScore(cumulativeLayoutShift),
                }
                
                // scores['desktop']['페이지A'] 형태로 접근할 수 있도록 할당
                scores[device][pageName] = score;

                // PR Comment에 작성할 형태로 만들어 comments에 추가
                // <details>와 <summary> 태그를 사용해 토글 형태로 생성
                comments += `<details>\n<summary>${pageName}</summary>\n\n> ${pageUrl}\n\n${formattedScoreTable}\n</details>\n\n`;
              });
            } // extractLhciResults 함수 끝 

그리고 결과 데이터를 받아 포맷팅해주는 함수를 작성했다.
PR Comment에 작성하기 위해 표 형태의 문자열을 생성하고, Google Spreadsheet에 기록하기 위해 객체를 생성한다.

나는 comment 부분에서 \n을 사용했지만, 백틱 내부이기 때문에 취향에 따라서는 실제 줄바꿈을 사용해도 좋다.
사실 개인적으로 후자가 가독성이 훨씬 좋긴 했다.
하지만 의도치 않은 탭이 들어가면서 마크다운 형식이 깨지는 경우가 생겨서 그냥 깔끔하게 \n으로 모두 바꿨다.

            // desktop 측정 결과 포맷팅
            extractLhciResults(desktopResults, 'desktop');
            // mobile 측정 결과 포맷팅
            extractLhciResults(mobileResults, 'mobile');
 
            // comments, monitoringTime, scores 값 내보내기
            core.setOutput('comments', comments);            
            core.setOutput('monitoringTime', monitoringTime);
            core.setOutput('scores', scores);

마지막으로 아까 만들어둔 함수를 통해 desktop과 mobile의 측정 결과를 포맷팅한다.

그리고 core.setOutput 함수를 통해 이 step 내에서 생성한 값을 내보낼 수 있다.
즉 워크플로우가 실행되는 동안 이후의 step들이 이 값을 사용할 수 있다.
core.setOutput('A', 'apple')이라는 코드가 있다면, 'A'라는 id로 'apple'이라는 값에 접근할 수 있다.
이 때 모든 값은 문자열로 처리된다는 점을 주의하자.


🍡 Lighthouse 결과 PR Comment로 달기

이제 포맷팅한 결과를 PR Comment로 달아보자.

      # Lighthouse 결과를 PR Comment로 작성
      - name: Comment PR
        uses: actions/github-script@v7
        with:
          # GITHUB_TOKEN은 GitHub Actions 워크플로우에서 자동으로 생성되고 제공되는 암호화된 토큰
          # Actions secrets에 자동으로 포함되므로 사용자가 명시적으로 설정하지 않아도 됨
          # 워크플로우 실행 중 특정 작업을 수행하는 데 필요한 권한을 제공하는 등의 경우에 사용됨
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |

            const fs = require('fs');
            # GitHub의 REST API와 상호 작용하기 위한 라이브러리
            # GitHub 레포지토리, 이슈, PR, 사용자 정보 등 다양한 데이터를 쉽게 조회하고 조작할 수 있음
            const { Octokit } = require('@octokit/rest');
            const octokit = new Octokit({ auth: `${{ secrets.GITHUB_TOKEN }}` });

            # context 객체는 @actions/github 패키지에서 제공하는 것으로, 워크플로우 실행 중 현재 컨텍스트에 대한 정보를 담고 있음
            # repo를 통해 현재 레포지토리에 대한 정보를 가져올 수 있음
            # payload를 통해 이벤트의 액션, PR에 대한 정보, 이슈에 대한 정보 등을 가져올 수 있음
            const { repo, payload } = context;
            
            # 현재 PR에 달린 모든 Comment 리스트를 가져옴
            const { data: previousComments } = await octokit.issues.listComments({
              owner: repo.owner,
              repo: repo.repo,
              issue_number: payload.pull_request.number,
            });

            # PR에 달린 Comment 중 `### Lighthouse report ✨\n`로 시작하는 Comment를 찾아냄
            # Lighthouse 측정 결과를 기록한 Comment를 찾아내는 것
            const previousLhciComment = previousComments.find((comment) => (comment.body.startsWith(`### Lighthouse report ✨\n`)));
            # Format lighthouse score 단계에서 내보냈던 comments 값을 newComment 변수에 할당
            const newComment = `${{ steps.format_lighthouse_score.outputs.comments }}`;
            
            # Lighthouse 측정 결과를 기록한 Comment가 이미 존재할 경우
            if (previousLhciComment) {
              # 기존의 Comment를 수정
              await octokit.issues.updateComment({
                owner: repo.owner,
                repo: repo.repo,
                comment_id: previousLHCIComment.id, # 수정할 Comment의 id
                body: newComment, # Comment 내용
              });
            } else { # Lighthouse 측정 결과를 기록한 Comment가 존재하지 않을 경우
              # 새로운 Comment 생성
              await octokit.issues.createComment({
                owner: repo.owner,
                repo: repo.repo,
                issue_number: payload.pull_request.number, # Comment를 작성할 PR 번호
                body: newComment,
              });
            }

먼저 octokit 라이브러리를 이용하여 PR의 이전 Comment에 접근한다.
그리고 Lighthouse 측정 결과를 기록한 Comment가 있다면 updateComment를 이용하여 해당 Comment를 수정하고,
없다면 createComment를 이용하여 새로운 Comment를 생성한다.
이 때 이전 Comment가 Lighthouse 측정 결과를 기록한 것인지 여부는 ### Lighthouse report ✨\n로 시작하는지로 판단한다.


🍡 Lighthouse 결과 Google Spreadsheet에 저장하기

	- name: Update Google SpreadSheet
        uses: actions/github-script@v7
        with: 
          script: |
 
            const fs = require('fs');
            # Google Spreadhsheet API를 이용하여 Google Spreadsheet의 데이터를 쉽게 읽고 쓰고 수정할 수 있도록 도와주는 라이브러리
            const { GoogleSpreadsheet } = require('google-spreadsheet');
            const { LHCI_GOOGLE_SPREAD_SHEET_ID, getLhciSheetIdFromPageName } = require('./src/configs/lighthouse/Lighthouse.js');
 
            const updateGoogleSheet = async () => {
              # 서비스 계정의 비공개 키 정보를 담는 객체 생성
              const creds = {
                client_email: `${{ secrets.LHCI_GOOGLE_CLIENT_EMAIL }}`, # secrets에 저장한 LHCI_GOOGLE_CLIENT_EMAIL 값 사용
                private_key: `${{ secrets.LHCI_GOOGLE_PRIVATE_KEY }}`, # secrets에 저장한 LHCI_GOOGLE_PRIVATE_KEY 값 사용
              };

              # Format lighthouse score 단계에서 내보냈던 scores 값을 desktop과 mobile에 구조 분해 할당
              const { desktop, mobile } = ${{ steps.format_lighthouse_score.outputs.scores }};
              # Format lighthouse score 단계에서 내보냈던 monitoringTime 값을 monitoringTime에 할당
              const monitoringTime = `${{ steps.format_lighthouse_score.outputs.monitoringTime }}`;

              const { repo, payload } = context;

              # GoogleSpreadsheet 인스턴스 생성
              const doc = new GoogleSpreadsheet(LHCI_GOOGLE_SPREAD_SHEET_ID); 
              # useServiceAccountAuth 메서드는 Google API에 접근할 수 있도록 인증 토큰 생성
              # Google API에 인증
              await doc.useServiceAccountAuth(creds);
              # loadInfo 메서드를 호출하면 doc 인스턴스에 해당 Spreadsheet에 대한 정보가 채워짐
              # Spreadsheet의 메타데이터 로드
              await doc.loadInfo();

              for (const pageName in desktop) {
                # 페이지의 시트 id
                const sheetId = getLhciSheetIdFromPageName(pageName);
                # 페이지의 desktop 점수
                const desktopScore = desktop[pageName];
                # 페이지의 mobile 점수
                const mobileScore = mobile[pageName];

                # Spreadsheet에 시트 id로 접근
                const sheet = doc.sheetsById[sheetId];
                # Spreadsheet의 헤더 행 (첫 번째 행) 로드
                await sheet.loadHeaderRow();

                # PR url
                const prUrl = `https://github.com/${repo.owner}/${repo.repo}/pull/${payload.pull_request.number}`;
                # PR 번호를 클릭하면 해당 PR로 바로 이동하도록 하이퍼링크를 걸어둠
                # '#'은 PR 번호라는 느낌을 주기 위해 붙임, 필수 x
                const prHyperlink = '=HYPERLINK("' + prUrl + '", "#' + payload.pull_request.number + '")';

                # Spreadsheet의 모든 행을 가져옴
                # 반환하는 값은 각 행을 나타내는 객체들의 배열
                const rows = await sheet.getRows(); 
                # 같은 PR 번호를 가진 행이 있는지 탐색 (해당 PR의 측정 결과가 이미 기록되어있는지 확인)
                const previousRow = rows.find((row) => row['PR url'] === `#${payload.pull_request.number}`);
      
                # 해당 PR의 Lighthouse 측정 결과 기록이 존재하는 경우
                if (previousRow) { 
                  # 기존 행의 Monitoring Time과 PR url 열에 새로운 기록을 덮어씌움
                  previousRow['Monitoring Time'] = monitoringTime;
                  previousRow['PR url'] = prHyperlink;
                  # 기존 행의 점수 관련 열들에 새로운 기록을 덮어씌움
                  Object.keys(desktopScore).forEach((key) => {
                    previousRow[key + ' [D]'] = desktopScore[key];
                    previousRow[key + ' [M]'] = mobileScore[key];
                  });

                  # previousRow의 변경사항을 저장
                  await previousRow.save();
                  continue;
                } 

                # 해당 PR의 Lighthouse 측정 결과 기록이 존재하지 않는 경우
                # 새로운 행 데이터 생성 후 PR url과 Monitoring Time 값 추가
                const newRow = { 'PR url': prHyperlink, 'Monitoring Time': monitoringTime };
                # 새로운 행 데이터에 점수 관련 데이터 추가
                Object.keys(desktopScore).forEach((key) => {
                    newRow[key + ' [D]'] = desktopScore[key];
                    newRow[key + ' [M]'] = mobileScore[key];
                  });

                # 새로운 행을 Spreadsheet에 추가
                await sheet.addRow(newRow);
              } 
            }

            # updateGoogleSheet 함수에서 에러가 발생하면 에러 메시지를 출력하고 작업을 실패로 표시함
            updateGoogleSheet().catch(err => core.setFailed(err.message));

여기서는 google-spreadsheet 라이브러리를 이용해 Google Spreadsheet에 접근한다.
LHCI_GOOGLE_SPREAD_SHEET_ID로 Spreadsheet를 찾고 LHCI_GOOGLE_CLIENT_EMAILLHCI_GOOGLE_PRIVATE_KEY로 권한을 얻어 접근할 수 있다.
Spreadsheet의 데이터를 로드한 후, PR 번호를 기준으로 해당 PR에 대한 이전 기록이 있는지 탐색한다.
PR Comment와 비슷하게 이미 기록이 존재한다면 덮어씌우고 존재하지 않는다면 새로 생성한다.

현재는 각 시트에 id로 접근하고 있는데, 처음에는 각 시트에 index로 접근하도록 했었다.
하지만 의도치 않게 시트의 순서가 바뀌었는데 알아차리지 못하는 불상사가 생기거나, 중요도에 따라 수시로 시트의 순서를 바꿔야 하는 상황이 생길 수 있기 때문에 시트 id를 사용하는 방식으로 변경했다.


이렇게 코드를 모두 작성하고 나서 브랜치에 변경사항을 push하면
아래와 같이 워크플로우가 잘 동작하는걸 볼 수 있다.


🍞 전체 코드

🍡 Lighthouse.js

module.exports = {
  LHCI_GOOGLE_SPREAD_SHEET_ID: '12345',
  
  LHCI_GREEN_MIN_SCORE: 90, 
  LHCI_ORANGE_MIN_SCORE: 50, 
  LHCI_RED_MIN_SCORE: 0,
  
  LHCI_MONITORING_PAGE_NAMES: [
    '페이지A',
    '페이지B',
    '페이지C',
    '페이지D',
    '페이지E',
  ],

  LHCI_PAGE_NAME_TO_URL: {
    '페이지A': '/page/typeA',
    '페이지B': '/page/typeB',
    '페이지C': '/page/typeC',
    '페이지D': '/page/typeD',
    '페이지E': '/page/typeE',
  },
  
  LHCI_PAGE_NAME_TO_SHEET_ID: {
    '페이지A': 0,
    '페이지B': 1,
    '페이지C': 2,
    '페이지D': 3,
    '페이지E': 4,
  },

  getLhciPageNameFromUrl: (url) => {
    for (const [name, path] of Object.entries(module.exports.LHCI_PAGE_NAME_TO_URL)) {
      if (decodeURIComponent(path) === decodeURIComponent(url)) return name;
    }
  },

  getLhciUrlFromPageName: (name) => {
    return module.exports.LHCI_PAGE_NAME_TO_URL[name];
  },
  
  getLhciSheetIdFromPageName: (name) => {
    return module.exports.LHCI_PAGE_NAME_TO_SHEET_ID[name];
  },
};

🍡 lighthouserc-desktop.js

const {
  LHCI_MONITORING_PAGE_NAMES,
  getLhciUrlFromPageName,
} = require('./src/configs/lighthouse/Lighthouse.js');

const urls = LHCI_MONITORING_PAGE_NAMES.map(
  (name) => `http://localhost:3000${getLhciUrlFromPageName(name)}`
);

module.exports = {
  ci: {
    collect: {
      startServerCommand: 'npm run start',
      startServerReadyPattern: 'ready on',
      url: urls,
      numberOfRuns: 1,
      settings: {
        preset: 'desktop',
      },
    },

    upload: {
      target: 'filesystem',
      outputDir: './lhci_reports/desktop',
      reportFilenamePattern: '%%PATHNAME%%-%%DATETIME%%-report.%%EXTENSION%%',
    },
  },
};

🍡 lighthouserc-mobile.js

const {
  LHCI_MONITORING_PAGE_NAMES,
  getLhciUrlFromPageName,
} = require('./src/configs/lighthouse/Lighthouse.js');

const urls = LHCI_MONITORING_PAGE_NAMES.map(
  (name) => `http://localhost:3000${getLhciUrlFromPageName(name)}`
);

module.exports = {
  ci: {
    collect: {
      startServerCommand: 'npm run start',
      url: urls,
      numberOfRuns: 1,
    },

    upload: {
      target: 'filesystem',
      outputDir: './lhci_reports/mobile', 
      reportFilenamePattern: '%%PATHNAME%%-%%DATETIME%%-report.%%EXTENSION%%',
    },
  },
};

🍡 lighthouse.yml

name: Run Lighthouse CI (Push on PR to master branch)
on:
  pull_request:  
    branches:
      - master 
    types:
      - opened
      - synchronize
      
permissions: 
  contents: read
  pull-requests: write 

jobs:
  lhci:
    name: Lighthouse CI
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Use Node.js 18.12.1
        uses: actions/setup-node@v4
        with:
          node-version: 18.12.1

      - name: Cache node_modules
        uses: actions/cache@v4
        id: npm-cache
        with:
          path: |
            **/node_modules
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-
 
      - name: Install packages 
        run: |
          npm install 

      - name: Build
        run: |
          npm run build
          
      - name: Run Lighthouse CI for Desktop
        env:
          LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
        run: | 
          npm install -g @lhci/cli
          lhci collect --config=lighthouserc-desktop.js || echo 'Fail to Run Lighthouse CI 💦'
          lhci upload --config=lighthouserc-desktop.js || echo 'Fail to Run Lighthouse CI 💦'

      - name: Run Lighthouse CI for Mobile
        env:
          LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
        run: | 
          lhci collect --config=lighthouserc-mobile.js || echo 'Fail to Run Lighthouse CI 💦'
          lhci upload --config=lighthouserc-mobile.js || echo 'Fail to Run Lighthouse CI 💦'
          
      - name: Format lighthouse score
        id: format_lighthouse_score
        uses: actions/github-script@v7
        with:
          script: | 

            const fs = require('fs');
            const { getLhciPageNameFromUrl, LHCI_GREEN_MIN_SCORE, LHCI_ORANGE_MIN_SCORE, LHCI_RED_MIN_SCORE } = require('./src/configs/lighthouse/Lighthouse.js');

            const getColor = (score) => {
              if (score >= LHCI_GREEN_MIN_SCORE) return '🟢';
              else if (score >= LHCI_ORANGE_MIN_SCORE) return '🟠';
              return '🔴';
            }

            const getAuditColorAndScore = (score) => getColor(score) + score;
            const getPerformanceMetricColorAndScore = (category) => getColor(category.score * 100) + category.displayValue;

            const formatResult = (res) => Math.round(res * 100);

            const desktopResults = JSON.parse(fs.readFileSync(''));
            const mobileResults = JSON.parse(fs.readFileSync(''));

            const monitoringTime = new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' });
            const scoreDescription = `> 🟢: ${LHCI_GREEN_MIN_SCORE} - 100` + ' / ' + `🟠: ${LHCI_ORANGE_MIN_SCORE} - ${LHCI_GREEN_MIN_SCORE - 1}` + ' / ' + `🔴: ${LHCI_RED_MIN_SCORE} - ${LHCI_ORANGE_MIN_SCORE - 1}`;
            let comments = '';

            comments += `### Lighthouse report ✨\n`;
            comments += `${scoreDescription}\n\n`;

            const scores = { desktop: {}, mobile: {} };

            const extractLhciResults = (results, device) => {
              comments += `#### ${device}\n\n`;

              results.forEach((result) => {
                const { url, summary, jsonPath } = result;
                const { audits } = JSON.parse(fs.readFileSync(jsonPath));

                const pageUrl = url.replace('http://localhost:3000', '');
                const pageName = getLhciPageNameFromUrl(pageUrl);

                Object.keys(summary).forEach((key) => (summary[key] = formatResult(summary[key])));

                const { performance, accessibility, 'best-practices': bestPractices, seo, pwa } = summary;
                const { 'first-contentful-paint': firstContentfulPaint, 'largest-contentful-paint': largestContentfulPaint, 'speed-index': speedIndex, 'total-blocking-time': totalBlockingTime, 'cumulative-layout-shift': cumulativeLayoutShift } = audits;

                const formattedScoreTable = [
                  `| Category | Score |`,
                  `| --- | --- |`,
                  `| ${getColor(performance)} Performance | ${performance} |`,
                  `| ${getColor(accessibility)} Accessibility | ${accessibility} |`,
                  `| ${getColor(bestPractices)} Best practices | ${bestPractices} |`,
                  `| ${getColor(seo)} SEO | ${seo} |`,
                  `| ${getColor(pwa)} PWA | ${pwa} |`,
                  `| ${getColor(firstContentfulPaint.score * 100)} First Contentful Paint | ${firstContentfulPaint.displayValue} |`,
                  `| ${getColor(largestContentfulPaint.score * 100)} Largest Contentful Paint | ${largestContentfulPaint.displayValue} |`,
                  `| ${getColor(speedIndex.score * 100)} Speed Index | ${speedIndex.displayValue} |`,
                  `| ${getColor(totalBlockingTime.score * 100)} Total Blocking Time | ${totalBlockingTime.displayValue} |`,
                  `| ${getColor(cumulativeLayoutShift.score * 100)} Cumulative Layout Shift | ${cumulativeLayoutShift.displayValue} |`,
                  `\n`,
                ].join('\n');

                const score = {
                  Performance: getAuditColorAndScore(performance),
                  Accessibility: getAuditColorAndScore(accessibility),
                  'Best Practices': getAuditColorAndScore(bestPractices),
                  SEO: getAuditColorAndScore(seo),
                  PWA: getAuditColorAndScore(pwa),
                  FCP: getPerformanceMetricColorAndScore(firstContentfulPaint),
                  LCP: getPerformanceMetricColorAndScore(largestContentfulPaint),
                  'Speed Index': getPerformanceMetricColorAndScore(speedIndex),
                  'TBT': getPerformanceMetricColorAndScore(totalBlockingTime),
                  'CLS': getPerformanceMetricColorAndScore(cumulativeLayoutShift),
                }
                
                scores[device][pageName] = score;

                comments += `<details>\n<summary>${pageName}</summary>\n\n> ${pageUrl}\n\n${formattedScoreTable}\n</details>\n\n`;
              });
            } 
            
            extractLhciResults(desktopResults, 'desktop');
            extractLhciResults(mobileResults, 'mobile');
 
            core.setOutput('comments', comments);            
            core.setOutput('monitoringTime', monitoringTime);
            core.setOutput('scores', scores);
            
      - name: Comment PR
        uses: actions/github-script@v7
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |

            const fs = require('fs');
            const { Octokit } = require('@octokit/rest');
            const octokit = new Octokit({ auth: `${{ secrets.GITHUB_TOKEN }}` });

            const { repo, payload } = context;
            
            const { data: previousComments } = await octokit.issues.listComments({
              owner: repo.owner,
              repo: repo.repo,
              issue_number: payload.pull_request.number,
            });

            const previousLhciComment = previousComments.find((comment) => (comment.body.startsWith(`### Lighthouse report ✨\n`)));
            const newComment = `${{ steps.format_lighthouse_score.outputs.comments }}`;
            
            if (previousLhciComment) {
              await octokit.issues.updateComment({
                owner: repo.owner,
                repo: repo.repo,
                comment_id: previousLHCIComment.id, 
                body: newComment, 
              });
            } else { 
              await octokit.issues.createComment({
                owner: repo.owner,
                repo: repo.repo,
                issue_number: payload.pull_request.number,
                body: newComment,
              });
            }

	- name: Update Google SpreadSheet
        uses: actions/github-script@v7
        with: 
          script: |
 
            const fs = require('fs');
            const { GoogleSpreadsheet } = require('google-spreadsheet');
            const { LHCI_GOOGLE_SPREAD_SHEET_ID, getLhciSheetIdFromPageName } = require('./src/configs/lighthouse/Lighthouse.js');
 
            const updateGoogleSheet = async () => {
              const creds = {
                client_email: `${{ secrets.LHCI_GOOGLE_CLIENT_EMAIL }}`, 
                private_key: `${{ secrets.LHCI_GOOGLE_PRIVATE_KEY }}`, 
              };

              const { desktop, mobile } = ${{ steps.format_lighthouse_score.outputs.scores }};
              const monitoringTime = `${{ steps.format_lighthouse_score.outputs.monitoringTime }}`;

              const { repo, payload } = context;

              const doc = new GoogleSpreadsheet(LHCI_GOOGLE_SPREAD_SHEET_ID); 
              await doc.useServiceAccountAuth(creds);
              await doc.loadInfo();

              for (const pageName in desktop) {
                const sheetId = getLhciSheetIdFromPageName(pageName);
                const desktopScore = desktop[pageName];
                const mobileScore = mobile[pageName];

                const sheet = doc.sheetsById[sheetId];
                await sheet.loadHeaderRow();

                const prUrl = `https://github.com/${repo.owner}/${repo.repo}/pull/${payload.pull_request.number}`;
                const prHyperlink = '=HYPERLINK("' + prUrl + '", "#' + payload.pull_request.number + '")';

                const rows = await sheet.getRows(); 
                const previousRow = rows.find((row) => row['PR url'] === `#${payload.pull_request.number}`);
      
                if (previousRow) { 
                  previousRow['Monitoring Time'] = monitoringTime;
                  previousRow['PR url'] = prHyperlink;
                  Object.keys(desktopScore).forEach((key) => {
                    previousRow[key + ' [D]'] = desktopScore[key];
                    previousRow[key + ' [M]'] = mobileScore[key];
                  });

                  await previousRow.save();
                  continue;
                } 

                const newRow = { 'PR url': prHyperlink, 'Monitoring Time': monitoringTime };
                Object.keys(desktopScore).forEach((key) => {
                    newRow[key + ' [D]'] = desktopScore[key];
                    newRow[key + ' [M]'] = mobileScore[key];
                  });

                await sheet.addRow(newRow);
              } 
            }

            updateGoogleSheet().catch(err => core.setFailed(err.message));



이렇게 Lighthouse 자동화 과정이 마무리되었다.

이런 작업이 익숙한 누군가에게는 쉬울 수 있겠지만, 나에겐 꽤나 쉽지 않았던 과정이었다.
하지만 Lighthouse, CI, Github Actions와 별로 친하지 않던 나에게 있어 정말 좋은 기회였다.

또 회사 내에서 여러 사람에게 도움이 될 만한 코드를 작성했다는 점이 크게 다가왔다.
팀원분이 나에게 "가은님이 학교로 돌아가도... 가은님이 남긴 유산인 Lighthouse CI는 영원히 돌아갈 거에요..." 라고 말해주셨다 ㅋㅋ
물론 진짜 영원히 돌아가진 않겠지만...

지금 적는 이 블로그나, 회사에 남기고 온 내 문서가 누군가에게 조금이라도 도움이 될 수 있으면 좋겠다.


참고 자료

10개의 댓글

comment-user-thumbnail
2024년 6월 21일

CI 까지 마스터 하셨군요 ㄷㄷ

1개의 답글
comment-user-thumbnail
2024년 6월 21일

Lighthouse CI 를 Github Action 에 적용해서 페이지 별 Web Vital 지수를 추출할 수 있는 방법도 있었군요..? 저도 한번 써먹어야겠네요 😀

1개의 답글
comment-user-thumbnail
2024년 6월 27일

도라에몽 기업으내영~ 우연히 포스팅 잘 보고갑니다!

1개의 답글
comment-user-thumbnail
2024년 6월 28일

우연히 포스팅 잘 보고갑니다

1개의 답글
comment-user-thumbnail
2024년 7월 4일

글 너무 잘 보고 있슴다!
혹시 썸네일 너무 탐나는데 퍼가두 될까요

1개의 답글