왜 이 자동화를 하게 되었나
저희 피드줍줍 팀 프론트는 이번에 성능최적화를 진행하게되었습니다. 그런데... 개발자마다 환경이 달라 점수가 들쭉날쭉했고, 바쁠수록 수동 측정은 자연스럽게 밀렸습니다. 이번엔 최적화했겠지 하고 지나갔다가 뒤늦게 라이트하우스 점수가 별로라는 걸 확인하기도 했어요!
장기적으로 서비스 품질을 지키려면 사람의 의지에 기대지 않고 자동으로 측정하고, 자동으로 공유하고, 자동으로 기록하는 흐름이 필요하다고 느꼈어요. 팀원들이 불편해하는걸 빨리 개선해보고싶기도 했습니다!
그래서 목표를 이렇게 정했습니다.
개발서버에는 merge될때 구글 스프레드 시트에 성능 기록을 쌓고.
프로덕션 서버에는 PR이 올라갔을때 코멘트로 성능 내용을 남기고, 구글 스프레드 시트에 성능기록을 쌓자고!
이 목표를 만족시키려면, 측정 대상 페이지를 표준화하고, 데스크톱/모바일 프로파일을 분리하며, CI 머신에서 재현 가능한 설정을 고정해야 했습니다. 이걸 위해 이 두분의 블로그 포스팅을 참고했습니다! 정말 정말 감사합니다!
결과 미리보기
1) 다음과 같이 fe/develop(개발서버)의 PR이 머지됐을경우 스프레드 시트에 성능이 담긴다.


2) 다음과 같이 fe/production(프로덕션 서버)에 PR이 올라왔을경우 코멘트로 성능을 확인하고, 스프레드 시트에 성능이 담긴다.


Lighthouse CI 자동화 구축하기
CLI 환경세팅 하기
먼저 성능을 측정하고 확인하는 기능부터 만들어야 합니다. CI는 어디까지나 자동으로 동작을 시키는것뿐이니까요!
먼저 CLI를 설치합니다. 전역/로컬 아무 쪽이나 상관없지만, CI에서 확실히 쓰려면 job 내에서 설치하는 편이 안전하다고 합니다.
원본 명령:
npm install -g @lhci/cli
또는
npm install --save-dev @lhci/cli
그리고 다음과 같이 폴더구조를 세팅합니다.
lighthouse.config.cjs - 공통 설정 (페이지 목록, URL 매핑, 점수 기준) - 루트위치
lighthouserc.cjs - 데스크톱 Lighthouse CLI 옵션 - 루트위치
lighthouserc.mobile.cjs - 모바일 Lighthouse CLI 옵션 - 루트위치
각 코드들은 다음과 같습니다.
설정 파일 1: lighthouse.config.cjs (루트파일에)
원본 코드(전체):
module.exports = {
// Google Spreadsheet ID (나중에 필요시)
LHCI_GOOGLE_SPREAD_SHEET_ID: process.env.LHCI_GOOGLE_SPREAD_SHEET_ID,
// Lighthouse 점수 색상 기준
LHCI_GREEN_MIN_SCORE: 90,
LHCI_ORANGE_MIN_SCORE: 50,
LHCI_RED_MIN_SCORE: 0,
// 모니터링할 페이지 이름 목록
LHCI_MONITORING_PAGE_NAMES: [
'feedback_submit', // 피드백 작성
'feedback_dashboard', // 피드백 대시보드
'admin_home', // 관리자 방 목록
'admin_dashboard', // 관리자 방 대시보드
'admin_settings', // 관리자 설정 페이지
],
// 페이지 이름 - URL 매핑
LHCI_PAGE_NAME_TO_URL: {
feedback_submit: '/d0b1b979-7ae8-11f0-8408-0242ac120002/submit', // :id → 실제 UUID
feedback_dashboard: '/d0b1b979-7ae8-11f0-8408-0242ac120002/dashboard', // :id → 실제 UUID
admin_home: '/admin/home',
admin_dashboard: '/admin/d0b1b979-7ae8-11f0-8408-0242ac120002/dashboard', // :id → 실제 UUID
admin_settings: '/admin/settings',
},
// 페이지 이름 - 시트 ID 매핑 (구글 시트용)
LHCI_PAGE_NAME_TO_SHEET_ID: {
feedback_submit: 0,
feedback_dashboard: 1,
admin_home: 2,
admin_dashboard: 3,
admin_settings: 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;
}
}
return undefined;
},
// 페이지 이름에서 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 : GitHub Secrets로 주입해 CI 런타임에서 읽는다
- LHCI_GREEN_MIN_SCORE, LHCI_ORANGE_MIN_SCORE, LHCI_RED_MIN_SCORE: PR 코멘트에서 점수 옆에 🟢/🟠/🔴를 붙이기 위한 컷라인
- LHCI_MONITORING_PAGE_NAMES: “사람이 읽는 이름”을 별도로 둔것
- LHCI_PAGE_NAME_TO_URL : 이름→URL 매핑
- LHCI_PAGE_NAME_TO_SHEET_ID : 이름→Google Sheets 탭 gid 매핑. 이때 아래처럼 탭을 미리 만들어두어야 기록이 올바른 시트에 들어간다.

7. getLhciPageNameFromUrl, getLhciUrlFromPageName, getLhciSheetIdFromPageName: PR 코멘트 생성 및 시트 업데이트 시 “사람이 읽는 이름”과 “실제 URL/시트 탭”을 상호 변환하는 헬퍼
설정 파일 2: lighthouserc.cjs (데스크톱 프로파일) (루트파일에)
const {
LHCI_MONITORING_PAGE_NAMES,
getLhciUrlFromPageName,
} = require('./lighthouse.config.cjs');
const paths = LHCI_MONITORING_PAGE_NAMES.map(getLhciUrlFromPageName);
module.exports = {
ci: {
collect: {
staticDistDir: './dist',
isSinglePageApplication: true,
url: paths.length ? paths : ['/'],
numberOfRuns: 1,
settings: {
preset: 'desktop',
formFactor: 'desktop',
screenEmulation: {
mobile: false,
width: 1350,
height: 940,
deviceScaleFactor: 1,
disabled: false,
},
chromeFlags: ['--disable-mobile-emulation'],
},
},
assert: {
assertions: {
'categories:performance': ['warn', { minScore: 0.8 }],
'categories:accessibility': ['warn', { minScore: 0.9 }],
'categories:best-practices': ['warn', { minScore: 0.9 }],
'categories:seo': ['warn', { minScore: 0.9 }],
},
},
upload: {
target: 'filesystem',
outputDir: './lighthouse-results',
reportFilenamePattern: '%%PATHNAME%%-%%DATETIME%%-report.%%EXTENSION%%',
},
},
};
- paths는 config의 페이지 이름 배열을 URL로 변환한 결과.
- staticDistDir는 정적 배포 디렉터리를 의미.
- isSinglePageApplication은 SPA 라우팅을 고려해 클라이언트 라우팅 기반 내비게이션을 처리하도록 힌트
- numberOfRuns는 1로 두었습니다. 이게 높으면 높을수록 측정의 안정도가 높아지는데 저희는 1로 해도 페이지 수가 5개라서 충분히 오래걸리더라구요. 관련 문제가 생기면 좀더 높여볼것같습니다!
- settings는 데스크톱 환경을 고정.
- assert는 카테고리별 경고선
- upload는 파일 시스템(target: 'filesystem')에 HTML/JSON 리포트를 남김.
설정 파일 3: lighthouserc.mobile.cjs (모바일 프로파일) (루트파일에)
const {
LHCI_MONITORING_PAGE_NAMES,
getLhciUrlFromPageName,
} = require('./lighthouse.config.cjs');
const paths = LHCI_MONITORING_PAGE_NAMES.map(getLhciUrlFromPageName);
module.exports = {
ci: {
collect: {
staticDistDir: './dist',
isSinglePageApplication: true,
url: paths.length ? paths : ['/'],
numberOfRuns: 1,
settings: {
preset: 'desktop',
formFactor: 'mobile',
screenEmulation: {
mobile: true,
width: 360,
height: 640,
deviceScaleFactor: 2.625,
disabled: false,
},
chromeFlags: [
'--headless',
'--no-sandbox',
'--disable-gpu',
'--disable-dev-shm-usage',
],
},
},
assert: {
assertions: {
'categories:performance': ['warn', { minScore: 0.6 }],
'categories:accessibility': ['warn', { minScore: 0.9 }],
'categories:best-practices': ['warn', { minScore: 0.9 }],
'categories:seo': ['warn', { minScore: 0.9 }],
},
},
upload: {
target: 'filesystem',
outputDir: './lighthouse-results-mobile',
reportFilenamePattern:
'%%PATHNAME%%-%%DATETIME%%-mobile-report.%%EXTENSION%%',
},
},
};
- formFactor만 'mobile'로 바뀌어도 점수 계산 기준이 달라짐.
- screenEmulation은 360×640, DPR 2.625로 고정.
- chromeFlags는 컨테이너/CI 환경의 제약을 고려한 설정.
- assert에서 performance 최소치를 0.6(=60점)으로 낮춘 건 모바일 특성 때문.
- upload 디렉터리를 별도로 두어 데스크톱/모바일 결과가 섞이지 않게 함.
실제로 동작 시켜보기
배포된 실제 페이지 하나를 지정해 간단히 돌려봅니다.
lhci collect --url="https://dev.feedzupzup.com/d0b1b979-7ae8-11f0-8408-0242ac120002/dashboard"

이 테스트로 .html/.json 리포트가 생성되는 걸 확인할 수 있습니다.
로컬에서 이렇게 한 번 성공 경험을 만들어두면 CI 단계에서 실패했을 때 비교가 쉬워져서 이 작업을 했습니다!
Lighthouse CI 설계하기
ci는 다음과 같이 설계했습니다. 저희는 개발서버와 프로덕션서버를 나눠두었는데요. fe/develop은 비교적 자주 push되는 개발서버이고, fe/production은 가끔 push되는 프로덕션 서버로 CD가 이어져있습니다.
그래서 fe/develop에는 push될때만 (merge 될때만) 스프레드 시트를 업데이트하고(코멘트가 큰 의미가 없어서)
fe/production에는 PR이 올라왔을때 코멘트와, 스프레드 시트로 성능을 확인할수있게했습니다. (프로덕션에 올라가기전에 한번 성능을 체크할수있도록)

GitHub Actions로 자동화
위에서 만든 기능을 CI에서 작동시키고, PR 코멘트에 달리게 하기위해서는 다음과 같은 세팅을 진행하면 됩니다.
lighthouse.yml (깃허브 CI 파일에 추가)
lighthouse:
runs-on: ubuntu-latest
needs: [type-check, lint, test]
if: |
(
github.event_name == 'push' && github.ref == 'refs/heads/fe/develop'
) ||
(
github.event_name == 'pull_request' &&
github.base_ref == 'fe/production' &&
github.event.action != 'closed'
)
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: "./frontend/package-lock.json"
- 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 dependencies
run: npm ci
- name: Build project
run: |
if [[ "${{ github.base_ref }}" == "fe/production" || "${{ github.ref }}" == "refs/heads/fe/production" ]]; then
echo "Running production build..."
npm run build:prod
else
echo "Running development build..."
npm run build:dev
fi
- 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.cjs || echo 'Fail to Run Lighthouse CI 💦'
lhci upload --config=lighthouserc.cjs || 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.cjs || echo 'Fail to Run Lighthouse CI Mobile 💦'
lhci upload --config=lighthouserc.mobile.cjs || echo 'Fail to Run Lighthouse CI Mobile 💦'
- name: Format lighthouse score
id: format_lighthouse_score
uses: actions/github-script@v7
with:
script: |
const { formatLighthouse } = await import('${{ github.workspace }}/frontend/scripts/format-lighthouse.js');
await formatLighthouse();
- name: Comment PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const body = process.env.LHCI_COMMENTS;
if (!body) {
core.setFailed('LHCI_COMMENTS is missing');
return;
}
const { owner, repo } = context.repo;
const prNumber = context.payload.pull_request.number;
const { data: comments } = await github.rest.issues.listComments({
owner, repo, issue_number: prNumber,
});
const prev = comments.find(c => c.body?.startsWith('### Lighthouse report ✨\n'));
if (prev) {
await github.rest.issues.updateComment({
owner, repo, comment_id: prev.id, body
});
} else {
await github.rest.issues.createComment({
owner, repo, issue_number: prNumber, body
});
}
env:
LHCI_COMMENTS: ${{ steps.format_lighthouse_score.outputs.comments }}
- name: Get merged PR number
id: get_pr
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
run: |
echo "=== Commit message ==="
git log -1 --pretty=%B
echo ""
PR_NUM=$(git log -1 --pretty=%B | grep -oP '#\K\d+' | head -1 || echo "")
echo "Extracted PR number: '$PR_NUM'"
echo "pr_number=$PR_NUM" >> $GITHUB_OUTPUT
if [ -n "$PR_NUM" ]; then
PR_URL="https://github.com/${{ github.repository }}/pull/$PR_NUM"
echo "pr_url=$PR_URL" >> $GITHUB_OUTPUT
echo "PR URL: $PR_URL"
fi
- name: Update Google SpreadSheet
if: |
(github.event_name == 'push') ||
(github.event_name == 'pull_request' && github.base_ref == 'fe/production')
run: node ./scripts/update-google-sheet.js
env:
LHCI_GOOGLE_CLIENT_EMAIL: ${{ secrets.LHCI_GOOGLE_CLIENT_EMAIL }}
LHCI_GOOGLE_PRIVATE_KEY: ${{ secrets.LHCI_GOOGLE_PRIVATE_KEY }}
LHCI_GOOGLE_SPREAD_SHEET_ID: ${{ secrets.LHCI_GOOGLE_SPREAD_SHEET_ID }}
LHCI_SCORES: ${{ steps.format_lighthouse_score.outputs.scores }}
LHCI_MONITORING_TIME: ${{ steps.format_lighthouse_score.outputs.monitoringTime }}
PR_NUMBER: ${{ steps.get_pr.outputs.pr_number || github.event.pull_request.number || '' }}
PR_URL: ${{ steps.get_pr.outputs.pr_url || github.event.pull_request.html_url || '' }}
REPO_OWNER: ${{ github.repository_owner }}
REPO_NAME: ${{ github.event.repository.name }}
코멘트에 올리도록 포맷해주는 스크립트

import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import * as core from '@actions/core';
import config from '../lighthouse.config.cjs';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const {
getLhciPageNameFromUrl,
LHCI_GREEN_MIN_SCORE,
LHCI_ORANGE_MIN_SCORE,
LHCI_RED_MIN_SCORE,
} = config;
function color(n) {
return n >= LHCI_GREEN_MIN_SCORE
? '🟢'
: n >= LHCI_ORANGE_MIN_SCORE
? '🟠'
: '🔴';
}
function pct(v) {
return Math.round(v * 100);
}
function fmtScore(n) {
return `${color(n)}${n}`;
}
function fmtAudit(audit) {
if (!audit || typeof audit.score !== 'number') return '—';
const score100 = Math.round(audit.score * 100);
return `${color(score100)}${audit.displayValue || ''}`.trim();
}
function loadJsons(dir) {
return fs.existsSync(dir)
? fs
.readdirSync(dir)
.filter((f) => f.endsWith('.json'))
.map((f) => path.join(dir, f))
.map((file) => {
const report = JSON.parse(fs.readFileSync(file, 'utf8'));
return {
url:
report.finalUrl ||
report.requestedUrl ||
report.mainDocumentUrl ||
'',
report,
};
})
: [];
}
export function formatLighthouse() {
const FRONTEND_ROOT = path.resolve(__dirname, '..');
const desktop = loadJsons(path.join(FRONTEND_ROOT, 'lighthouse-results'));
const mobile = loadJsons(
path.join(FRONTEND_ROOT, 'lighthouse-results-mobile')
);
const monitoringTime = new Date().toLocaleString('ko-KR', {
timeZone: 'Asia/Seoul',
});
const legend = `> 🟢: ${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 = `### Lighthouse report ✨\n${legend}\n\n`;
const scores = { desktop: {}, mobile: {} };
function addTable(results, deviceKey, title) {
comments += `#### ${title}\n\n`;
if (results.length === 0) {
comments += `(결과 없음)\n\n`;
return;
}
comments += `<table>\n<thead>\n<tr>\n`;
comments += `<th>Page</th><th>Perf</th><th>A11y</th><th>Best</th><th>SEO</th><th>PWA</th>`;
comments += `<th style="background-color: #f6f8fa;">FCP</th>`;
comments += `<th style="background-color: #f6f8fa;">LCP</th>`;
comments += `<th style="background-color: #f6f8fa;">Speed Index</th>`;
comments += `<th style="background-color: #f6f8fa;">TBT</th>`;
comments += `<th style="background-color: #f6f8fa;">CLS</th>`;
comments += `</tr>\n</thead>\n<tbody>\n`;
for (const { url, report } of results) {
if (!url) continue;
const pageUrl = url.replace(/^http:\/\/localhost:\d+/, '');
const page = getLhciPageNameFromUrl(pageUrl);
const { categories, audits } = report;
const perf = pct(categories.performance.score);
const a11y = pct(categories.accessibility.score);
const best = pct(categories['best-practices'].score);
const seo = pct(categories.seo.score);
const pwa = categories.pwa ? pct(categories.pwa.score) : 0;
const FCP = fmtAudit(audits['first-contentful-paint']);
const LCP = fmtAudit(audits['largest-contentful-paint']);
const SI = fmtAudit(audits['speed-index']);
const TBT = fmtAudit(audits['total-blocking-time']);
const CLS = fmtAudit(audits['cumulative-layout-shift']);
comments += `<tr>\n`;
comments += `<td>${page}</td>`;
comments += `<td>${fmtScore(perf)}</td>`;
comments += `<td>${fmtScore(a11y)}</td>`;
comments += `<td>${fmtScore(best)}</td>`;
comments += `<td>${fmtScore(seo)}</td>`;
comments += `<td>${fmtScore(pwa)}</td>`;
comments += `<td style="background-color: #f6f8fa;">${FCP}</td>`;
comments += `<td style="background-color: #f6f8fa;">${LCP}</td>`;
comments += `<td style="background-color: #f6f8fa;">${SI}</td>`;
comments += `<td style="background-color: #f6f8fa;">${TBT}</td>`;
comments += `<td style="background-color: #f6f8fa;">${CLS}</td>`;
comments += `</tr>\n`;
scores[deviceKey][page] = {
Performance: fmtScore(perf),
Accessibility: fmtScore(a11y),
'Best Practices': fmtScore(best),
SEO: fmtScore(seo),
PWA: fmtScore(pwa),
FCP,
LCP,
'Speed Index': SI,
TBT,
CLS,
};
}
comments += `</tbody>\n</table>\n\n`;
}
addTable(desktop, 'desktop', 'Desktop');
addTable(mobile, 'mobile', 'Mobile');
core.setOutput('comments', comments);
core.setOutput('monitoringTime', monitoringTime);
core.setOutput('scores', scores);
}
// 실행 entrypoint
if (process.argv[1] && process.argv[1].endsWith('format-lighthouse.js')) {
formatLighthouse();
}
Google Sheets 업데이트 스크립트
실제 레포에는 민감정보가 있어 그대로 공개하긴 어렵지만, 아래 코드는 위 워크플로가 기대하는 입력값(LHCI_SCORES 등)을 그대로 받아 동작하는 동등 구현입니다. 레포에 scripts/update-google-sheet.js로 두고 그대로 실행할 수 있습니다.

import { GoogleSpreadsheet } from 'google-spreadsheet';
import { JWT } from 'google-auth-library';
import process from 'node:process';
import config from '../lighthouse.config.cjs';
const { LHCI_GOOGLE_SPREAD_SHEET_ID, getLhciSheetIdFromPageName } = config;
const scores = JSON.parse(process.env.LHCI_SCORES || '{}');
const monitoringTime = process.env.LHCI_MONITORING_TIME;
const prNumber = process.env.PR_NUMBER;
const repoOwner = process.env.REPO_OWNER;
const repoName = process.env.REPO_NAME;
async function updateGoogleSheet() {
try {
const serviceAccountAuth = new JWT({
email: process.env.LHCI_GOOGLE_CLIENT_EMAIL,
key: (process.env.LHCI_GOOGLE_PRIVATE_KEY || '').replace(/\\n/g, '\n'),
scopes: [
'https://www.googleapis.com/auth/spreadsheets',
'https://www.googleapis.com/auth/drive.file',
],
});
const doc = new GoogleSpreadsheet(
LHCI_GOOGLE_SPREAD_SHEET_ID,
serviceAccountAuth
);
await doc.loadInfo();
const { desktop = {}, mobile = {} } = scores;
for (const pageName of Object.keys(desktop)) {
const sheetId = getLhciSheetIdFromPageName(pageName);
const sheet = doc.sheetsById[sheetId];
if (!sheet) {
continue;
}
await sheet.loadHeaderRow();
const desktopScores = desktop[pageName] || {};
const mobileScores = mobile[pageName] || {};
const prUrl = `https://github.com/${repoOwner}/${repoName}/pull/${prNumber}`;
const prHyperlink = `=HYPERLINK("${prUrl}", "#${prNumber}")`;
const rowData = {
'PR url': prHyperlink,
'Monitoring Time': monitoringTime,
};
for (const k of Object.keys(desktopScores)) {
rowData[`${k} [D]`] = desktopScores[k];
rowData[`${k} [M]`] = mobileScores[k];
}
const rows = await sheet.getRows();
const existing = rows.find(
(r) => r['PR url'] && String(r['PR url']).includes(`#${prNumber}`)
);
if (existing) {
Object.assign(existing, rowData);
await existing.save();
} else {
await sheet.addRow(rowData);
}
}
} catch {
process.exit(1);
}
}
updateGoogleSheet();
환경변수 설정 가이드
필요한 환경변수는 네 가지입니다. 이 블로그에서 캡쳐까지 해주시면서 설명해주시고있기 때문에 참고하시면 좋을것같습니다!
- LHCI_GITHUB_APP_TOKEN: GitHub “Lighthouse CI” 앱 설치 후 레포/오거나이제이션에 연결하여 발급. 레포 Secrets에 저장. (저는 권한상 안돼서, 깃허브 Personal Token을 만들어서 진행했어요.)
- LHCI_GOOGLE_CLIENT_EMAIL / LHCI_GOOGLE_PRIVATE_KEY: Google Cloud에서 서비스 계정을 만들고, 키(JSON)를 발급 받은 뒤 이메일과 프라이빗 키를 Secrets에 저장. 프라이빗 키는 줄바꿈 이스케이프를 주의합니다.
- LHCI_GOOGLE_SPREAD_SHEET_ID: 스프레드시트 URL에서 ID 부분을 복사해 Secrets에 저장.
스프레드시트 탭(gid)은 사전에 만들어 두어야 합니다. config의 LHCI_PAGE_NAME_TO_SHEET_ID와 이름을 맞춰두면 자동으로 원하는 탭에 행이 추가됩니다!
저는... 이 부분을 놓쳐서 탭을 못 찾는 에러로 잠시 삽질했고, 그 이후로는 새 페이지를 모니터링에 추가할 때 항상 탭부터 만들고 gid를 config에 반영했습니다.
자동화의 효과
왼쪽과 같이 PR을 올릴때마다 성능을 코멘트로 달고, 오른쪽에 구글시트에도 각 페이지 별로 내용이 담깁니다. (이러면 히스토리도 확인할수있습니다! 어떤 PR이 올라왔을때 어떻게 성능이 변했는지도 확인할수있습니다!! )


+ ) 덤으로 구글 스프레드 표 세팅하기
위에는 페이지별로 성능을 확인하고있으니 모든 페이지를 확인하려면 탭에 하나하나 들어가서 확인해야하는 불편함이있었는데요.

다음과 같이 대시보드라는 탭을 새로 만들고 A1란에 다음과 같은 서식을 집어넣었습니다! 각각의 탭에서 가장 최신의 성능측정 내역을 가져와서 뿌리는 내용입니다!
={
"페이지", "PR", "Performance", "Accessibility", "Best Practices", "SEO", "PWA", "FCP", "LCP", "Speed Index", "TBT", "CLS";
"feedback_submit",
INDEX(feedback_submit!A:A, COUNTA(feedback_submit!A:A)),
INDEX(feedback_submit!C:C, COUNTA(feedback_submit!C:C)),
INDEX(feedback_submit!D:D, COUNTA(feedback_submit!D:D)),
INDEX(feedback_submit!E:E, COUNTA(feedback_submit!E:E)),
INDEX(feedback_submit!F:F, COUNTA(feedback_submit!F:F)),
INDEX(feedback_submit!G:G, COUNTA(feedback_submit!G:G)),
INDEX(feedback_submit!H:H, COUNTA(feedback_submit!H:H)),
INDEX(feedback_submit!I:I, COUNTA(feedback_submit!I:I)),
INDEX(feedback_submit!J:J, COUNTA(feedback_submit!J:J)),
INDEX(feedback_submit!K:K, COUNTA(feedback_submit!K:K)),
INDEX(feedback_submit!L:L, COUNTA(feedback_submit!L:L));
"feedback_dashboard",
INDEX(feedback_dashboard!A:A, COUNTA(feedback_dashboard!A:A)),
INDEX(feedback_dashboard!C:C, COUNTA(feedback_dashboard!C:C)),
INDEX(feedback_dashboard!D:D, COUNTA(feedback_dashboard!D:D)),
INDEX(feedback_dashboard!E:E, COUNTA(feedback_dashboard!E:E)),
INDEX(feedback_dashboard!F:F, COUNTA(feedback_dashboard!F:F)),
INDEX(feedback_dashboard!G:G, COUNTA(feedback_dashboard!G:G)),
INDEX(feedback_dashboard!H:H, COUNTA(feedback_dashboard!H:H)),
INDEX(feedback_dashboard!I:I, COUNTA(feedback_dashboard!I:I)),
INDEX(feedback_dashboard!J:J, COUNTA(feedback_dashboard!J:J)),
INDEX(feedback_dashboard!K:K, COUNTA(feedback_dashboard!K:K)),
INDEX(feedback_dashboard!L:L, COUNTA(feedback_dashboard!L:L));
"admin_home",
INDEX(admin_home!A:A, COUNTA(admin_home!A:A)),
INDEX(admin_home!C:C, COUNTA(admin_home!C:C)),
INDEX(admin_home!D:D, COUNTA(admin_home!D:D)),
INDEX(admin_home!E:E, COUNTA(admin_home!E:E)),
INDEX(admin_home!F:F, COUNTA(admin_home!F:F)),
INDEX(admin_home!G:G, COUNTA(admin_home!G:G)),
INDEX(admin_home!H:H, COUNTA(admin_home!H:H)),
INDEX(admin_home!I:I, COUNTA(admin_home!I:I)),
INDEX(admin_home!J:J, COUNTA(admin_home!J:J)),
INDEX(admin_home!K:K, COUNTA(admin_home!K:K)),
INDEX(admin_home!L:L, COUNTA(admin_home!L:L));
"admin_dashboard",
INDEX(admin_dashboard!A:A, COUNTA(admin_dashboard!A:A)),
INDEX(admin_dashboard!C:C, COUNTA(admin_dashboard!C:C)),
INDEX(admin_dashboard!D:D, COUNTA(admin_dashboard!D:D)),
INDEX(admin_dashboard!E:E, COUNTA(admin_dashboard!E:E)),
INDEX(admin_dashboard!F:F, COUNTA(admin_dashboard!F:F)),
INDEX(admin_dashboard!G:G, COUNTA(admin_dashboard!G:G)),
INDEX(admin_dashboard!H:H, COUNTA(admin_dashboard!H:H)),
INDEX(admin_dashboard!I:I, COUNTA(admin_dashboard!I:I)),
INDEX(admin_dashboard!J:J, COUNTA(admin_dashboard!J:J)),
INDEX(admin_dashboard!K:K, COUNTA(admin_dashboard!K:K)),
INDEX(admin_dashboard!L:L, COUNTA(admin_dashboard!L:L));
"admin_settings",
INDEX(admin_settings!A:A, COUNTA(admin_settings!A:A)),
INDEX(admin_settings!C:C, COUNTA(admin_settings!C:C)),
INDEX(admin_settings!D:D, COUNTA(admin_settings!D:D)),
INDEX(admin_settings!E:E, COUNTA(admin_settings!E:E)),
INDEX(admin_settings!F:F, COUNTA(admin_settings!F:F)),
INDEX(admin_settings!G:G, COUNTA(admin_settings!G:G)),
INDEX(admin_settings!H:H, COUNTA(admin_settings!H:H)),
INDEX(admin_settings!I:I, COUNTA(admin_settings!I:I)),
INDEX(admin_settings!J:J, COUNTA(admin_settings!J:J)),
INDEX(admin_settings!K:K, COUNTA(admin_settings!K:K)),
INDEX(admin_settings!L:L, COUNTA(admin_settings!L:L))
}
그렇게 했더니 다음과 같이 한페이지에서 현재의 성능만 페이지별로 확인할 수 있게 되었습니다!

이번에 자동화를 세팅하면서 편해진점이 너무 많은데요! 먼저 성능최적화 할때의 장점도 있지만, 특정 기능을 추가하고 프로덕션에 올리기전에 성능을 확인할수있고, 성능 히스토리를 확인할 수 있다는 점이 무척 매력적이게 느껴졌습니다!
PR을 올렸을때에는 PR 숫자와 링크를 잘 받아왔었는데, merge할때는 PR넘버를 가져오지 못하는 문제가 있어서 해당 문제를 해결하기위해 CI를 추가로 손보고, 포맷형태도 많이 손 본 것 같습니다. 시간은 오래걸렸지만 그래도 너무 뿌듯하고, 미래를 생각하면 시간을 더 절약한거니 즐겁습니다ㅎㅎ
혹시 진행하시면서 막히는게 있으시거나 설명이 부족한 부분이 있다면 언제든지 피드백 해주세요!
'프론트엔드' 카테고리의 다른 글
| Safari에서는 쿠키가 저장되지 않는 이유 (Chrome은 되는데요?) (0) | 2025.11.08 |
|---|---|
| Sentry로 에러 모니터링 시스템 구축하기 in 피드줍줍 (0) | 2025.09.21 |
| AWS CodePipeline으로 자동 배포 시스템 구축하기 (0) | 2025.09.21 |
| 피드줍줍 성능 최적화: Lighthouse에 반영되지 않은 개선들 (0) | 2025.09.21 |
| React + PWA에서 FCM 알림 API 연동기 (0) | 2025.09.21 |