diff --git a/.github/scripts/bundle-size-report.js b/.github/scripts/bundle-size-report.js deleted file mode 100644 index 96da2910..00000000 --- a/.github/scripts/bundle-size-report.js +++ /dev/null @@ -1,205 +0,0 @@ -'use strict'; - -const fs = require('fs'); -const path = require('path'); -const zlib = require('zlib'); - -const BUILD_DIR = path.join(process.cwd(), 'apps/timo-web/.next'); - -const formatBytes = (bytes) => { - if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(2)} MB`; - if (bytes >= 1024) return `${(bytes / 1024).toFixed(2)} kB`; - return `${bytes} B`; -}; - -const toKb = (bytes) => bytes / 1024; - -const safeGzipSize = (filePath) => { - try { - const content = fs.readFileSync(filePath); - return zlib.gzipSync(content).length; - } catch (err) { - throw new Error(`번들 파일 처리 실패: ${filePath} — ${err.message}`); - } -}; - -const readJson = (filePath) => { - try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { return null; } -}; - -const sumDirGzipSize = (dirPath) => { - if (!fs.existsSync(dirPath)) return 0; - return fs.readdirSync(dirPath).reduce((sum, file) => { - const full = path.join(dirPath, file); - const stat = fs.statSync(full); - return sum + (stat.isFile() ? safeGzipSize(full) : 0); - }, 0); -}; - -const analyzeBuild = (buildDir) => { - if (!fs.existsSync(buildDir)) return { routes: [], sharedSize: null }; - - const buildManifest = readJson(path.join(buildDir, 'build-manifest.json')); - const routesManifest = readJson(path.join(buildDir, 'routes-manifest.json')); - // webpack 빌드에서만 생성됨 - const appBuildManifest = readJson(path.join(buildDir, 'app-build-manifest.json')); - - if (!buildManifest) return { routes: [], sharedSize: null }; - - // 내부 Next.js 라우트 필터 (사용자 정의 라우트가 아님) - const INTERNAL_ROUTES = new Set(['/_not-found', '/_global-error']); - - // 공유 번들 gzip 크기 (rootMainFiles) - const rootMainFiles = buildManifest.rootMainFiles ?? []; - const sharedBytes = rootMainFiles.reduce( - (sum, chunk) => sum + safeGzipSize(path.join(buildDir, chunk)), - 0 - ); - - let routes = []; - - if (appBuildManifest) { - // webpack 모드: app-build-manifest.json으로 라우트별 청크 매핑 - const pages = appBuildManifest.pages ?? {}; - routes = Object.entries(pages) - .filter(([route]) => !INTERNAL_ROUTES.has(route)) - .map(([route, chunks]) => { - const pageBytes = chunks.reduce( - (sum, chunk) => sum + safeGzipSize(path.join(buildDir, chunk)), - 0 - ); - const firstLoadBytes = pageBytes + sharedBytes; - return { - path: route, - size: formatBytes(pageBytes), - firstLoad: formatBytes(firstLoadBytes), - firstLoadKb: toKb(firstLoadBytes), - }; - }); - } else { - // Turbopack 모드: routes-manifest.json으로 라우트 목록 구성 - const staticRoutes = [ - ...(routesManifest?.staticRoutes ?? []), - ...(routesManifest?.dynamicRoutes ?? []), - ]; - - const appChunksDir = path.join(buildDir, 'static', 'chunks', 'app'); - - routes = staticRoutes - .filter(({ page }) => !INTERNAL_ROUTES.has(page)) - .map(({ page }) => { - const segment = page === '/' ? '' : page; - const pageChunkDir = path.join(appChunksDir, segment); - const pageBytes = sumDirGzipSize(pageChunkDir); - - const firstLoadBytes = pageBytes + sharedBytes; - return { - path: page, - size: formatBytes(pageBytes), - firstLoad: formatBytes(firstLoadBytes), - firstLoadKb: toKb(firstLoadBytes), - }; - }); - - // 라우트가 없으면 공유 번들만이라도 / 라우트로 표시 - if (routes.length === 0 && sharedBytes > 0) { - routes = [{ - path: '/', - size: '0 B', - firstLoad: formatBytes(sharedBytes), - firstLoadKb: toKb(sharedBytes), - }]; - } - } - - return { routes, sharedSize: formatBytes(sharedBytes) }; -}; - -const WARN_THRESHOLD_KB = 200; -const ERROR_THRESHOLD_KB = 350; - -const sizeIcon = (firstLoadKb) => { - if (firstLoadKb >= ERROR_THRESHOLD_KB) return '🔴'; - if (firstLoadKb >= WARN_THRESHOLD_KB) return '🟡'; - return '🟢'; -}; - -const formatRouteTable = (appLabel, { routes, sharedSize }) => { - if (!routes.length) { - return `### ${appLabel}\n\n> ⚠️ 번들 크기 데이터를 가져오지 못했습니다 (.next 디렉토리를 확인하세요).\n`; - } - - const rows = routes - .map( - (r) => - `| \`${r.path}\` | ${r.size} | ${r.firstLoad} | ${sizeIcon(r.firstLoadKb)} |` - ) - .join('\n'); - - const sharedLine = sharedSize ? `\n> 공유 번들: **${sharedSize}**\n` : ''; - - return `### ${appLabel} - -| 라우트 | 크기 | First Load JS | 상태 | -|--------|------|---------------|------| -${rows} -${sharedLine}`; -}; - -module.exports = async ({ github, context, core }) => { - const prNumber = context.payload.pull_request?.number; - if (!prNumber) { - core.warning('PR 컨텍스트를 찾을 수 없습니다.'); - return; - } - - const { owner, repo } = context.repo; - - core.info(`번들 분석 경로: ${BUILD_DIR}`); - const timoWebBuild = analyzeBuild(BUILD_DIR); - - if (!timoWebBuild.routes.length) { - core.warning(`번들 크기 데이터를 파싱하지 못했습니다. ${BUILD_DIR} 를 확인하세요.`); - return; - } - - const legend = `> 🟢 정상 (<${WARN_THRESHOLD_KB}kB) 🟡 주의 (<${ERROR_THRESHOLD_KB}kB) 🔴 초과 (≥${ERROR_THRESHOLD_KB}kB) — First Load JS 기준 (gzip 크기)`; - - const body = `## 📦 번들 사이즈 리포트 - -${formatRouteTable('🕐 Timo Web', timoWebBuild)} - ---- - -${legend} - -*빌드 커밋: \`${context.sha.slice(0, 7)}\`*`; - - const { data: comments } = await github.rest.issues.listComments({ - owner, - repo, - issue_number: prNumber, - }); - - const existing = comments.find( - (c) => c.user?.type === 'Bot' && c.body?.includes('번들 사이즈 리포트') - ); - - if (existing) { - await github.rest.issues.updateComment({ - owner, - repo, - comment_id: existing.id, - body, - }); - } else { - await github.rest.issues.createComment({ - owner, - repo, - issue_number: prNumber, - body, - }); - } - - core.info('번들 사이즈 리포트 PR 코멘트 게시 완료'); -}; diff --git a/.github/scripts/performance-report.js b/.github/scripts/performance-report.js new file mode 100644 index 00000000..c663d95f --- /dev/null +++ b/.github/scripts/performance-report.js @@ -0,0 +1,242 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const zlib = require('zlib'); + +// ─── Utilities ──────────────────────────────────────────────────────────────── + +const readJson = (filePath) => { + if (!filePath || !fs.existsSync(filePath)) return null; + try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } + catch { return null; } +}; + +const gzipSize = (filePath) => { + try { return zlib.gzipSync(fs.readFileSync(filePath)).length; } + catch { return 0; } +}; + +const formatBytes = (bytes) => { + if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(2)} MB`; + if (bytes >= 1024) return `${(bytes / 1024).toFixed(2)} kB`; + return `${bytes} B`; +}; + +// ─── Bundle ─────────────────────────────────────────────────────────────────── + +const BUILD_DIR = path.join(process.cwd(), 'apps/timo-web/.next'); +const INTERNAL_ROUTES = new Set(['/_not-found', '/_global-error']); +const BUNDLE_WARN_KB = 200; +const BUNDLE_ERROR_KB = 350; + +const sumDirGzipSize = (dir) => { + if (!fs.existsSync(dir)) return 0; + return fs.readdirSync(dir).reduce((sum, file) => { + const full = path.join(dir, file); + return sum + (fs.statSync(full).isFile() ? gzipSize(full) : 0); + }, 0); +}; + +const analyzeBundle = () => { + const buildManifest = readJson(path.join(BUILD_DIR, 'build-manifest.json')); + if (!buildManifest) return null; + + const routesManifest = readJson(path.join(BUILD_DIR, 'routes-manifest.json')); + const appBuildManifest = readJson(path.join(BUILD_DIR, 'app-build-manifest.json')); + + const sharedBytes = (buildManifest.rootMainFiles ?? []).reduce( + (sum, chunk) => sum + gzipSize(path.join(BUILD_DIR, chunk)), + 0 + ); + + let routes; + if (appBuildManifest) { + routes = Object.entries(appBuildManifest.pages ?? {}) + .filter(([route]) => !INTERNAL_ROUTES.has(route)) + .map(([route, chunks]) => { + const pageBytes = chunks.reduce((sum, c) => sum + gzipSize(path.join(BUILD_DIR, c)), 0); + const firstLoad = pageBytes + sharedBytes; + return { path: route, size: formatBytes(pageBytes), firstLoad: formatBytes(firstLoad), firstLoadKb: firstLoad / 1024 }; + }); + } else { + const allRoutes = [ + ...(routesManifest?.staticRoutes ?? []), + ...(routesManifest?.dynamicRoutes ?? []), + ]; + const chunksDir = path.join(BUILD_DIR, 'static', 'chunks', 'app'); + routes = allRoutes + .filter(({ page }) => !INTERNAL_ROUTES.has(page)) + .map(({ page }) => { + const pageBytes = sumDirGzipSize(path.join(chunksDir, page === '/' ? '' : page)); + const firstLoad = pageBytes + sharedBytes; + return { path: page, size: formatBytes(pageBytes), firstLoad: formatBytes(firstLoad), firstLoadKb: firstLoad / 1024 }; + }); + + if (routes.length === 0 && sharedBytes > 0) { + routes = [{ path: '/', size: '0 B', firstLoad: formatBytes(sharedBytes), firstLoadKb: sharedBytes / 1024 }]; + } + } + + return { routes, sharedSize: formatBytes(sharedBytes) }; +}; + +const renderBundle = (data) => { + const wrap = (content) => `
\nBundle Size — timo-web\n${content}\n
`; + + if (!data?.routes?.length) return wrap('\n> ⚠️ 번들 데이터를 가져오지 못했습니다.\n'); + + const rows = data.routes.map(({ path: p, size, firstLoad, firstLoadKb }) => { + const icon = firstLoadKb >= BUNDLE_ERROR_KB ? '🔴' : firstLoadKb >= BUNDLE_WARN_KB ? '🟡' : '🟢'; + return `| \`${p}\` | ${size} | ${icon} ${firstLoad} |`; + }).join('\n'); + + return wrap(` +| 라우트 | 크기 | First Load JS | +|--------|-----:|:-------------| +${rows} + +> 공유 번들: **${data.sharedSize}** +> 🟢 < ${BUNDLE_WARN_KB}kB  |  🟡 < ${BUNDLE_ERROR_KB}kB  |  🔴 ≥ ${BUNDLE_ERROR_KB}kB (First Load JS · gzip) +`); +}; + +// ─── Lighthouse ─────────────────────────────────────────────────────────────── + +const LHCI_DIR = path.join(process.cwd(), '.lighthouseci'); + +const analyzeLighthouse = () => { + const manifest = readJson(path.join(LHCI_DIR, 'manifest.json')); + if (!manifest) return null; + + return manifest + .filter((r) => r.isRepresentativeRun && r.summary) + .map(({ url, summary, jsonPath }) => { + let pathname; + try { pathname = new URL(url).pathname || '/'; } + catch { pathname = url; } + + const audits = readJson(jsonPath)?.audits ?? {}; + return { + pathname, + perf: summary.performance, + a11y: summary.accessibility, + lcp: audits['largest-contentful-paint']?.numericValue, + cls: audits['cumulative-layout-shift']?.numericValue, + tbt: audits['total-blocking-time']?.numericValue, + }; + }); +}; + +const fmtScore = (v, threshold) => { + const icon = v >= threshold + 0.1 ? '🟢' : v >= threshold ? '🟡' : '🔴'; + return `${icon} ${Math.round(v * 100)}`; +}; + +const renderLighthouse = (data) => { + const wrap = (content) => `
\nLighthouse — timo-web\n${content}\n
`; + + if (!data?.length) return wrap('\n> ⚠️ Lighthouse 결과를 가져오지 못했습니다.\n'); + + const rows = data.map(({ pathname, perf, a11y, lcp, cls, tbt }) => { + const lcpFmt = lcp != null ? `${lcp < 2500 ? '🟢' : lcp < 4000 ? '🟡' : '🔴'} ${(lcp / 1000).toFixed(1)}s` : '-'; + const clsFmt = cls != null ? `${cls < 0.1 ? '🟢' : cls < 0.25 ? '🟡' : '🔴'} ${cls.toFixed(3)}` : '-'; + const tbtFmt = tbt != null ? `${tbt < 200 ? '🟢' : tbt < 600 ? '🟡' : '🔴'} ${Math.round(tbt)}ms` : '-'; + return `| \`${pathname}\` | ${fmtScore(perf, 0.7)} | ${fmtScore(a11y, 0.85)} | ${lcpFmt} | ${clsFmt} | ${tbtFmt} |`; + }).join('\n'); + + return wrap(` +| URL | Perf | A11y | LCP | CLS | TBT | +|-----|:----:|:----:|----:|----:|----:| +${rows} + +> **Perf** ≥ 70 / **A11y** ≥ 85 목표 +> **LCP** 🟢 < 2.5s 🟡 < 4s 🔴 ≥ 4s  |  **CLS** 🟢 < 0.1 🟡 < 0.25 🔴 ≥ 0.25  |  **TBT** 🟢 < 200ms 🟡 < 600ms 🔴 ≥ 600ms +`); +}; + +// ─── Images ─────────────────────────────────────────────────────────────────── + +const PUBLIC_DIR = path.join(process.cwd(), 'apps/timo-web/public'); +const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif', '.svg', '.bmp', '.tiff']); +const MODERN_EXTS = new Set(['.webp', '.avif', '.svg']); +const IMG_WARN = 200 * 1024; +const IMG_ERROR = 500 * 1024; + +const scanImages = (dir, base = dir) => { + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir).flatMap((file) => { + const full = path.join(dir, file); + if (fs.statSync(full).isDirectory()) return scanImages(full, base); + const ext = path.extname(file).toLowerCase(); + if (!IMAGE_EXTS.has(ext)) return []; + return [{ rel: path.relative(base, full), bytes: fs.statSync(full).size, ext }]; + }); +}; + +const analyzeImages = () => { + const images = scanImages(PUBLIC_DIR); + return images.length > 0 ? images : null; +}; + +const renderImages = (data) => { + const wrap = (content) => `
\nImage Optimization — timo-web\n${content}\n
`; + + if (!data) return wrap('\n> public/ 디렉토리에 이미지가 없습니다.\n'); + + const rows = data.map(({ rel, bytes, ext }) => { + const sizeIcon = bytes >= IMG_ERROR ? '🔴' : bytes >= IMG_WARN ? '🟡' : '🟢'; + const fmtBadge = MODERN_EXTS.has(ext) ? '✅' : '⚠️'; + return `| \`${rel}\` | ${formatBytes(bytes)} | ${ext.slice(1).toUpperCase()} ${fmtBadge} | ${sizeIcon} |`; + }).join('\n'); + + const total = formatBytes(data.reduce((s, i) => s + i.bytes, 0)); + const nonModern = data.filter((i) => !MODERN_EXTS.has(i.ext)).length; + const formatNote = nonModern > 0 + ? `⚠️ ${nonModern}개 파일 WebP/AVIF 변환 권장` + : '✅ 모든 이미지가 최적화된 포맷'; + + return wrap(` +| 파일 | 크기 | 포맷 | 상태 | +|------|-----:|:----:|:----:| +${rows} + +> 총 ${data.length}개 · ${total}  |  🟢 < 200KB  |  🟡 < 500KB  |  🔴 ≥ 500KB +> ${formatNote} +`); +}; + +// ─── Main ───────────────────────────────────────────────────────────────────── + +const COMMENT_MARKER = ''; + +module.exports = async ({ github, context, core }) => { + const prNumber = context.payload.pull_request?.number; + if (!prNumber) { core.warning('PR 컨텍스트를 찾을 수 없습니다.'); return; } + + const { owner, repo } = context.repo; + + const body = [ + COMMENT_MARKER, + '## Timo Performance Report', + '', + renderBundle(analyzeBundle()), + '', + renderLighthouse(analyzeLighthouse()), + '', + renderImages(analyzeImages()), + '', + `*측정 커밋: \`${context.sha.slice(0, 7)}\`*`, + ].join('\n'); + + const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number: prNumber }); + const existing = comments.find((c) => c.user?.type === 'Bot' && c.body?.includes(COMMENT_MARKER)); + + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + core.info('성능 리포트 업데이트 완료'); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body }); + core.info('성능 리포트 게시 완료'); + } +}; diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml index 75617d8c..a1153e67 100644 --- a/.github/workflows/auto-assign.yml +++ b/.github/workflows/auto-assign.yml @@ -1,4 +1,4 @@ -name: Auto Assign +name: 👽 Auto Assign on: pull_request: diff --git a/.github/workflows/auto-issue.yml b/.github/workflows/auto-issue.yml index 4b7b6d25..79b9e849 100644 --- a/.github/workflows/auto-issue.yml +++ b/.github/workflows/auto-issue.yml @@ -1,4 +1,4 @@ -name: Auto Issue +name: 👽 Auto Issue on: issues: diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index d7d37157..e18118b7 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -1,4 +1,4 @@ -name: Auto Label +name: 👽 Auto Label on: pull_request: diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml deleted file mode 100644 index 7b971078..00000000 --- a/.github/workflows/bundle-size.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: 📦 번들 사이즈 리포트 - -on: - pull_request: - types: [opened, synchronize, reopened] - branches: [main, develop] - -permissions: - contents: read - pull-requests: write - -concurrency: - group: bundle-size-${{ github.ref }} - cancel-in-progress: true - -jobs: - bundle-size: - name: Bundle Size Report - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v4 - - - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build timo-web - id: build - env: - TURBO_UI: false - NO_COLOR: '1' - run: pnpm turbo run build --filter=timo-web --output-logs=full 2>&1 | tee /tmp/timo-web-build.txt - continue-on-error: true - - - name: Debug - 빌드 결과 확인 - if: steps.build.outcome == 'failure' - run: | - echo "=== .next 디렉토리 ===" - ls apps/timo-web/.next/ 2>/dev/null || echo "(.next 없음)" - echo "=== routes-manifest staticRoutes ===" - node -e "const m=require('./apps/timo-web/.next/routes-manifest.json');console.log(JSON.stringify(m.staticRoutes,null,2))" 2>/dev/null || echo "(없음)" - echo "=== build-manifest rootMainFiles ===" - node -e "const m=require('./apps/timo-web/.next/build-manifest.json');console.log(JSON.stringify(m.rootMainFiles,null,2))" 2>/dev/null || echo "(없음)" - echo "=== static/chunks/app 디렉토리 ===" - ls apps/timo-web/.next/static/chunks/app/ 2>/dev/null || echo "(없음)" - - - name: 번들 사이즈 PR 코멘트 게시 - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const fn = require('./.github/scripts/bundle-size-report.js') - await fn({ github, context, core }) diff --git a/.github/workflows/discord-notification.yml b/.github/workflows/discord-notification.yml index 03edf28f..2433b55f 100644 --- a/.github/workflows/discord-notification.yml +++ b/.github/workflows/discord-notification.yml @@ -1,4 +1,4 @@ -name: Automated Discord Notification +name: 🎵 Automated Discord Notification on: pull_request: diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml new file mode 100644 index 00000000..ba8c2cd9 --- /dev/null +++ b/.github/workflows/performance.yml @@ -0,0 +1,65 @@ +name: ✨ Performance Report + +on: + pull_request: + types: [opened, reopened] + branches: [main, develop] + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +concurrency: + group: performance-${{ github.ref }} + cancel-in-progress: true + +jobs: + performance: + name: 성능 분석 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: 'pnpm' + + - name: 의존성 설치 + run: pnpm install --frozen-lockfile + + - name: timo-web 빌드 + env: + TURBO_UI: false + NO_COLOR: '1' + run: pnpm turbo run build --filter=timo-web + + - name: Next.js 서버 시작 + run: pnpm --filter=timo-web start & + + - name: 서버 준비 대기 + run: npx wait-on@8 http://localhost:3000 --timeout 60000 + + - name: Lighthouse CI 실행 + run: npx @lhci/cli@0.14.x autorun --config=apps/timo-web/lighthouserc.cjs + continue-on-error: true + + - name: 성능 리포트 PR 코멘트 게시 + if: always() + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fn = require('./.github/scripts/performance-report.js') + await fn({ github, context, core }) + + - name: Lighthouse 결과 아티팩트 업로드 + if: always() + uses: actions/upload-artifact@v4 + with: + name: lighthouse-results + path: .lighthouseci/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index 96fab4fe..ee778085 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,9 @@ npm-debug.log* yarn-debug.log* yarn-error.log* +# Lighthouse CI +.lighthouseci/ + # Misc .DS_Store *.pem diff --git a/apps/timo-web/lighthouserc.cjs b/apps/timo-web/lighthouserc.cjs new file mode 100644 index 00000000..bd0fd5ec --- /dev/null +++ b/apps/timo-web/lighthouserc.cjs @@ -0,0 +1,30 @@ +/* global module */ +'use strict'; + +module.exports = { + ci: { + collect: { + url: [ + 'http://localhost:3000', + ], + numberOfRuns: 3, + settings: { + chromeFlags: '--no-sandbox --disable-setuid-sandbox --disable-dev-shm-usage', + }, + }, + assert: { + preset: 'lighthouse:no-pwa', + assertions: { + 'categories:performance': ['warn', { minScore: 0.7 }], + 'categories:accessibility': ['warn', { minScore: 0.85 }], + 'categories:best-practices': ['warn', { minScore: 0.8 }], + 'categories:seo': ['warn', { minScore: 0.8 }], + }, + }, + upload: { + target: 'filesystem', + outputDir: '.lighthouseci', + reportFilenamePattern: '%%PATHNAME%%-%%DATETIME%%-report.%%EXTENSION%%', + }, + }, +};