본문 바로가기

개발정보

GitHub Action Lighthouse

GitHub Action에 대해서는 앞서 어떤 요소들이 있는지 알아봤다. 

최종적으로 나 역시 CI / CD를 구축하는 것을 목표로 GitHub Action을 사용하려고 한다. 

 

CI의 베이스가 되는 브랜치 전략으로 Git Flow를 사용하려고 한다. 

GitHub Flow 등도 있을 텐데 최초 브랜치 전략을 공부할 때 Git Flow를 알아봤었고 아직까지 많이 사용되는 

전략이라서 굳이 다른 방향을 알아보진 않았다.  

어떤 방향으로 GitHub Action을 사용할 것인가! 

 

이게 중요할 것 같은데, Git Flow를 이야기하는 이유는 브랜치의 속성에 따라 Action을 설정해줄 생각이다. 

내가 사용할 브랜치로

  • Main : 프로젝트 | 서비스의 메인 브랜치로 정식 배포가 된 환경의 브랜치다. 
  • Develop : 개발 환경을 가지고 있는 브랜치다. 해당 브랜치에서 개발자들이 작업을 한다고 생각하면 된다. 
  • Feat/[작업] : Develop 브랜치를 기준으로 기능 단위로 개발할 때 만들어지는 브랜치이다. 
  • Release : 정식 배포가 되기 전 사전 배포를 진행해서 테스트를 진행하는 브랜치이다. 
  • Hotfix: Develop => Feat 단계를 거치지 않고 급하게 작업 후 배포하는 경우 사용되는 브랜치이다. 

다음과 같은 용도의 브랜치가 있다. 

여기서 GitHub Action을 사용이 사용되는 부분은 다음과 같다. 

  • Feat 브랜치 병합 : 전날까지 작업해서 Develop 브랜치에 Merge를 자동으로 해주는 Workflow
    코드 리뷰를 진행다고 PR을 올린 사람이 수동으로 Merge를 해도 되지만 간혹 리뷰에 답글만 달고 넘어가는 경우가 발생해서 Auto Merge를 지원한다면 다른 사람이 추후 작업에 충돌이 발생하는 문제가 없을 것이라고 생각된다. 
  • PR 검사 : 개발자가 PR을 올릴 때 테스트 코드를 실행시켜서 오류가  발견된다면 PR을 자동으로 닫는 용도의 Workflow
    테스트 코드를 작성하고 있을 때도 매번 PR을 올릴 때 테스트 코드를 체크하지 않고 올리는 경우가 있어서 예방하기 위해서 사용한다. 
  • Lighthouse 검사 : PR이 올라오면 Lighthouse로 자동으로 검사해주는 Workflow 
  • Release 브랜치 병합 및 배포 : 주기적으로 Develop 브랜치의 코드를 Release 브랜치에 Merge 시켜주고 테스트 환경에 자동으로 배포를 진행시켜주는 Workflow 
    웹의 경우, lighthouse 까지 검사해서 성능을 확인할 수 있는 방향으로 고려할 생각이다. 
  • Main 브랜치 병합 및 배포 : Release 브랜치에 문제가 없는 상태일 때 정식 배포를 진행해주는 Workflow 

이번엔 올라온 PR을 Lighthouse를 사용해서 검사하는 기능을 구현할 생각이다. 

원래는 목표에 없었는데, GitHub Action을 알아보던 중 해당 기능을 구현하신 분이 있어서 글을 참고해서 만들었다. 

 

초기 설정 

Lighthouse와 Google Sheets를 사용하기 때문에 필요한 과정이 있다. 

사전 기능 구현을 먼저 다 진행하고 본 코드를 살펴보자. 

 

Lighthouse CI 설치 

측정한 결과를 Github과 연동해서 사용하기 위해서는 Lighthouse CI Github App을 설정해야한다. 

접속하면 Configure가 보일 것이다. 눌러주면 설치할 계정을 선택할 수 있는데, 필요한 계정을 선택해서 설치해주면 된다.

선택해서 설치하면 Lighthouse 연동은 끝이다. 

 

마지막에 나오는 토큰은 Github Action에 필요하기 때문에 반드시 따로 저장해야한다.

토큰을 Github Action의 secrets에 LHCI_GITHUB_APP_TOKEN으로 설정한다. 

 

Google Spreadsheet API 설정

Lighthouse 를 통해서 성능을 측정하고 측정한 정보를 Google Spreadsheet에 추가하기 위해서 설정이 필요하다. 

Google Cloud Console을 통해서 프로젝트를 만들고 서비스 계정을 만들어준다. 

 

서비스 계정을 만들고 키를 추가해준다. 

추가하면 JSON 형태로 정보를 다운로드할 수 있는데, 여기서 private_key와 client_email이 필요하다. 

 

해당 정보 역시 Github Action에서 GOOGLE_CLIENT_EMAIL  GOOGLE_PRIVATE_KEY 명칭으로 저장시켰다. 

 

마지막으로 Google Spread Sheets를 허용해주고 Google Sheet 설정에서 생성한 client_email 요소를 추가해주면 끝이다. 이렇게 설정하면 사전 작업은 끝이났고 이젠 코드로 작성하는 부분이 남았다. 

 

설정 코드

Lighthouse로 테스트하기 위해서 설정 파일을 만들어야 한다. 

 

config.js

// configs/lighthouse/config.js

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: "...",

  // 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: ["메인페이지"],

  // lighthouse 성능 측정할 페이지 이름 - url 매핑
  LHCI_PAGE_NAME_TO_URL: {
    메인페이지: "/",
  },

  // lighthouse 성능 측정할 페이지 이름 - 시트 id 매핑
  // Google Spreadsheet 링크가 https://docs.google.com/spreadsheets/d/12345/edit#gid=123123라면, 시트 id는 123123 부분
  LHCI_PAGE_NAME_TO_SHEET_ID: {
    메인페이지: 0,
  },

  // 페이지 이름을 받아서 페이지 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];
  },
};

 

검사를 진행할 때 필요한 정보나, 상수 값을 저장하는 영역이다. 

 

LHCI_GOOGLE_SPREAD_SHEET_ID: "...",

Lighthouse 검사 후 결과를 저장할 Spread Sheet의 id를 적어야 한다. 어떤 부분인지는 주석에 나와있다. 

 

// lighthouse 성능 측정할 페이지 이름 목록
// PR Comment에 페이지 url이 아닌 페이지 이름을 노출시키기 위해 필요함
// 페이지 url이 짧다면 괜찮지만, 길다면 가독성이 떨어질 수 있기 때문에 페이지 이름을 보여주는 것을 추천
LHCI_MONITORING_PAGE_NAMES: ["메인페이지"],

// lighthouse 성능 측정할 페이지 이름 - url 매핑
LHCI_PAGE_NAME_TO_URL: {
  메인페이지: "/",
},

Lighthouse로 검사를 진행할 페이지 목록을 나타낸다. 지금은 나눈 상태인데 추가적으로 작업을 진행하고 

하나로 구성하는 것도 생각하고 있다. 

 

// lighthouse 성능 측정할 페이지 이름 - 시트 id 매핑
// Google Spreadsheet 링크가 https://docs.google.com/spreadsheets/d/12345/edit#gid=123123라면, 시트 id는 123123 부분
LHCI_PAGE_NAME_TO_SHEET_ID: {
  메인페이지: 0,
},

앞서 Spread Sheet의 ID를 넣었다면 이번엔 시트의 id를 저장하는 부분이다. 

이부분도 자동으로 처리할 수 있는 방법이 있을지 방법을 찾아보고 있다. 여기선 기능 구현에 중점을 두고 이후 확장판으로 수정한 부분을 작성하겠다. 

 

lighthouserc-desktop.js

// <root>/lighthouserc-desktop.js

const {
  LHCI_MONITORING_PAGE_NAMES,
  getLhciUrlFromPageName,
} = require("./src/configs/lighthouse/constant.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%%",
    },
  },
};

lighthouse를 사용해서 PC 버전으로 검사하는 부분이다. 해당 파일은 최상위 경로에 위치해야 한다. 

이후에 있을 mobile 부분과 전체적인 코드는 동일하지만 preset와 outputDir이 다르다. 

 

기종에 맞게 desktop을 추가해주었다. 

 

lighthouserc-mobile

// <root>/lighthouserc-desktop.js

const {
  LHCI_MONITORING_PAGE_NAMES,
  getLhciUrlFromPageName,
} = require("./src/configs/lighthouse/constant.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%%",
    },
  },
};

모바일 설정은 다른걸 할 필요 없이 outputDir 부분만 수정을 해주면 된다. 

settings는 추가로 할 필요는 없다.

 

Github Action 

원래 전체 코드를 공유하고 일부 코드를 나눠서 정리했지만 이번엔 코드가 너무 길어서 부분부분 나눠서 정리하겠습니다. 그래도 생략하는 부분 없이 전체 코드를 위에서부터 정리하는거니 너무 걱정하지마시라구요! 

 

name: lighthouse-page
run-name: Lighthouse Page Test

on:
  pull_request_target:
    branches: [develop]

Workflow의 이름과 Event가 실행되는 조건을 설정한 부분이다. 

이름이야 원하는 방식으로 작성하면 되고 Event의 실행 조건은 develop 브랜치를 목표로 PR이 올라오면 실행되게 설정했다. develop에 병합을 목적으로 PR을 올리면 실행된다. 

 

jobs:
  lhci:
    name: "Lighthouse Test"
    runs-on: "ubuntu-latest"

    strategy:
      matrix:
        node-version: ["18.x"]

    steps:
      - name: "Check for GitHub Token"
        run: |
          if [ -z "${{ secrets.ACTION_TOKEN }}" ]; then
            echo "GitHub token is missing"
            exit 1
          else
            echo "GitHub token is present"
          fi

      - name: Checkout PR
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.ref }}
          repository: ${{ github.event.pull_request.head.repo.full_name }}

      - name: Use Node.js ${{ matrix.node-version }} & Caching
        uses: actions/setup-node@v4
        with:
          node-version: ${{matrix.node-version}}
          cache: "yarn"

      - name: Install dependencies
        run: yarn install --frozen-lockfile

기본적인 Github Action을 위해서 사용하는 설정이다. 

간략하게 이야기하면 github script를 사용할 때 사용하는 Github Token 검증, 올라온 PR을 검증하기 위한 checkout과 의존성 패키지를 캐싱을 하면서 설치한다. 

 

일반적이면 checkout만 하면 되지만 아래 with 옵션이 있는 이유는 pull_request_target이 Event 실행 조건일땐 checkout이 base 브랜치를 체크아웃하기 때문이다. 

 

      - name: Install missing babel plugin
        run: yarn add @babel/plugin-proposal-private-property-in-object --dev --frozen-lockfile

      - name: Install Lighthouse CLI
        run: yarn global add @lhci/cli --frozen-lockfile

      - name: Install Spreadsheet
        run: yarn add google-spreadsheet google-auth-library --frozen-lockfile

Lighthouse CLI와 Google Spreadsheet는 프로젝트에 있을 필요가 없어서 github action에서 설치해줬다. 

이때 매번 새롭게 설치하는게 아닌 캐싱을 위해서 --frozen-lockfile 옵션을 추가해주었다. 

 

      - name: Run Build
        run:
          yarn build

          # Desktop 설정으로 Lighthouse 측정
      - name: Run Lighthouse CI for Desktop
        # secrets에 저장한 LHCI_GITHUB_APP_TOKEN 값 사용
        env:
          LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}

        # lighthouserc-desktop.js 설정 파일에 따라 Lighthouse 데이터 수집
        # lighthouserc-desktop.js 설정 파일에 따라 수집된 데이터 업로드
        # 실패 시 'Fail to Run Lighthouse CI 💦' 출력
        run: |
          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 💦'

프로젝트를 build하고 lighthouse로 설정해둔 Desktop과 Mobile 설정을 기준으로 검사한다. 

 

        # Lighthouse 결과를 PR Comment에 작성할 형식대로 포맷팅
      - name: Format lighthouse score
        id: format_lighthouse_score
        uses: actions/github-script@v7
        with:
          script: |
            // 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/constant.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('/home/runner/work/React-CI-CD/React-CI-CD/lhci_reports/desktop/manifest.json'));
            const mobileResults = JSON.parse(fs.readFileSync('/home/runner/work/React-CI-CD/React-CI-CD/lhci_reports/mobile/manifest.json'));

            // 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: {} };

            // 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} : ${pageUrl}</summary>\n\n${formattedScoreTable}\n</details>\n\n`;
              });
            } // extractLhciResults 함수 끝 

            // desktop 측정 결과 포맷팅
            extractLhciResults(desktopResults, 'desktop');
            // mobile 측정 결과 포맷팅
            extractLhciResults(mobileResults, 'mobile');

            // comments, monitoringTime, scores 값 내보내기
            core.setOutput('comments', comments);            
            core.setOutput('monitoringTime', monitoringTime);
            core.setOutput('scores', scores);

검사 완료한 결과를 포맷팅을 위해서 설정해준다. 아직까지 원본 코드에서 따로 변경한게 없긴하지만 이부분은 더더욱 따로 건들이지 않았다. 

 

      - name: Comment PR
        uses: actions/github-script@v7
        with:
          # GITHUB_TOKEN은 GitHub Actions 워크플로우에서 자동으로 생성되고 제공되는 암호화된 토큰
          # Actions secrets에 자동으로 포함되므로 사용자가 명시적으로 설정하지 않아도 됨
          # 워크플로우 실행 중 특정 작업을 수행하는 데 필요한 권한을 제공하는 등의 경우에 사용됨
          github-token: ${{ secrets.ACTION_TOKEN }}
          script: |
            // context 객체는 @actions/github 패키지에서 제공하는 것으로, 워크플로우 실행 중 현재 컨텍스트에 대한 정보를 담고 있음
            // repo를 통해 현재 레포지토리에 대한 정보를 가져올 수 있음
            // payload를 통해 이벤트의 액션, PR에 대한 정보, 이슈에 대한 정보 등을 가져올 수 있음
            const { repo, payload } = context;

            // 현재 PR에 달린 모든 Comment 리스트를 가져옴
            const { data: previousComments } = await github.rest.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 github.rest.issues.updateComment({
                owner: repo.owner,
                repo: repo.repo,
                comment_id: previousLhciComment.id, // 수정할 Comment의 id
                body: newComment, // Comment 내용
              });
            } else { // Lighthouse 측정 결과를 기록한 Comment가 존재하지 않을 경우
              // 새로운 Comment 생성
              await github.rest.issues.createComment({
                owner: repo.owner,
                repo: repo.repo,
                issue_number: payload.pull_request.number, // Comment를 작성할 PR 번호
                body: newComment,
              });
            }

포멧팅한 정보를 PR로 댓글을 다는 기능이다. 

 

기존 글에는 Octokit을 호출해서 사용하는 방식이었는데 github-script로 제공하는 기능이 있어서 변경했다. 

이때 github.issues를 사용하니 오류가 나서 docs를 확인해보니 rest 객체 안에 있어서 변경해주었다. 

 

      - name: Update Google SpreadSheet
        uses: actions/github-script@v7
        with:
          script: |
            // Google Spreadhsheet API를 이용하여 Google Spreadsheet의 데이터를 쉽게 읽고 쓰고 수정할 수 있도록 도와주는 라이브러리
            const { GoogleSpreadsheet } = require('google-spreadsheet');
            const { JWT } = require('google-auth-library');
            const { LHCI_GOOGLE_SPREAD_SHEET_ID, getLhciSheetIdFromPageName } = require('./src/configs/lighthouse/constant.js');

            const updateGoogleSheet = async () => {
              // 서비스 계정의 비공개 키 정보를 담는 객체 생성
              const creds = new JWT({
                email: `${{ secrets.GOOGLE_CLIENT_EMAIL }}`, // secrets에 저장한 GOOGLE_CLIENT_EMAIL 값 사용
                key: `${{ secrets.GOOGLE_PRIVATE_KEY }}`, // secrets에 저장한 GOOGLE_PRIVATE_KEY 값 사용
                scopes: ['https://www.googleapis.com/auth/spreadsheets']
              });

              // 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, 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];
                
                try{
                  // Spreadsheet의 헤더 행 (첫 번째 행) 로드
                  await sheet.loadHeaderRow();
                } catch(err) {
                  // 없다면 새로운 행 추가
                  await sheet.setHeaderRow(['PR url', 'Monitoring Time', ...Object.keys(desktopScore).map(key => key + ' [D]'), ...Object.keys(mobileScore).map(key => key + ' [M]')]);
                 }

                // 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));

마지막으로 Spread Sheet에 올려주는 부분인데, 변경점이 꽤 많다. 

google-spreadsheet 패키지가 버전이 올라서 그런지 useServiceAccountAuth 함수가 없어져있었다. 

new GoogleSpreadsheet에서 바로 인증을 할 수 있는 방식이었다. 

 

            const { JWT } = require('google-auth-library');
            const { LHCI_GOOGLE_SPREAD_SHEET_ID, getLhciSheetIdFromPageName } = require('./src/configs/lighthouse/constant.js');

            const creds = new JWT({
              email: `${{ secrets.GOOGLE_CLIENT_EMAIL }}`, // secrets에 저장한 GOOGLE_CLIENT_EMAIL 값 사용
              key: `${{ secrets.GOOGLE_PRIVATE_KEY }}`, // secrets에 저장한 GOOGLE_PRIVATE_KEY 값 사용
              scopes: ['https://www.googleapis.com/auth/spreadsheets']
            });
            
            const doc = new GoogleSpreadsheet(LHCI_GOOGLE_SPREAD_SHEET_ID, creds);

google auth library 패키지를 사용해서 인증을 해줄 수 있다. 

 

                try{
                  // Spreadsheet의 헤더 행 (첫 번째 행) 로드
                  await sheet.loadHeaderRow();
                } catch(err) {
                  // 없다면 새로운 행 추가
                  await sheet.setHeaderRow(['PR url', 'Monitoring Time', ...Object.keys(desktopScore).map(key => key + ' [D]'), ...Object.keys(mobileScore).map(key => key + ' [M]')]);
                }

마지막으로 헤더의 행 로드하는 부분도 Spreadsheet에서 행이 아에 없으면 오류가 발생한다. 

그래서 try catch 문을 활용해서 행이 없다면 정해진 형식으로 행을 추가해주는 코드를 넣어줬다. 

 

여기까지 작성하면 성능 측정이 가능해진다! 

마지막으로 다시한번 전체적인 내용은 원본 글을 참고해주시면 좋을 것 같고 이만 글을 마치겠습니다. 

반응형

'개발정보' 카테고리의 다른 글

GitHub Action PR 검사  (1) 2024.09.14
GitHub Action Feature 합치기  (2) 2024.09.01
GitHub Action 알아보기  (2) 2024.08.28
VSCode Git 계정 변경  (0) 2023.04.13
소프트웨어 개발 3대 원칙 - KISS, YAGNI, DRY  (0) 2023.01.26