From c65dbda8c99ea63bd579aaf223d3c0801eda8357 Mon Sep 17 00:00:00 2001 From: kimminna Date: Wed, 24 Jun 2026 15:02:41 +0900 Subject: [PATCH 01/13] =?UTF-8?q?ci(root):=20PR=20=EB=B2=88=EB=93=A4=20?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EC=A6=88=20=EC=9E=90=EB=8F=99=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Next.js 빌드 출력을 파싱해 라우트별 번들 크기를 PR 코멘트로 자동 게시하는 워크플로우와 스크립트를 추가했습니다 --- .github/scripts/bundle-size-report.js | 144 ++++++++++++++++++++++++++ .github/workflows/bundle-size.yml | 49 +++++++++ 2 files changed, 193 insertions(+) create mode 100644 .github/scripts/bundle-size-report.js create mode 100644 .github/workflows/bundle-size.yml diff --git a/.github/scripts/bundle-size-report.js b/.github/scripts/bundle-size-report.js new file mode 100644 index 00000000..b63d29fa --- /dev/null +++ b/.github/scripts/bundle-size-report.js @@ -0,0 +1,144 @@ +'use strict'; + +const fs = require('fs'); + +/** + * Next.js 빌드 출력에서 라우트별 번들 크기를 파싱합니다. + * turbo run 출력 형식 (앱명:build: 접두사 포함/미포함 모두 처리) + */ +const parseBuildOutput = (rawOutput) => { + const output = rawOutput + .split('\n') + .map((line) => line.replace(/\x1b\[[0-9;]*m/g, '')) + .map((line) => line.replace(/^[^\s]+:build:\s?/, '')) + .join('\n'); + + const routes = []; + const routeRegex = + /[┌├└│]\s+[○●λ◑ƒ]\s+(\/\S*)\s+([\d.]+\s*[kMGT]?B)\s+([\d.]+\s*[kMGT]?B)/g; + + let match; + while ((match = routeRegex.exec(output)) !== null) { + routes.push({ + path: match[1], + size: match[2].trim(), + firstLoad: match[3].trim(), + }); + } + + const sharedMatch = output.match( + /First Load JS shared by all\s+([\d.]+\s*[kMGT]?B)/ + ); + + return { routes, sharedSize: sharedMatch?.[1]?.trim() ?? null }; +}; + +/** + * First Load JS 크기 문자열을 kB 단위 숫자로 변환합니다. + */ +const toKb = (sizeStr) => { + const match = sizeStr.match(/([\d.]+)\s*([kMG]?B)/); + if (!match) return 0; + const value = parseFloat(match[1]); + const unit = match[2]; + if (unit === 'MB') return value * 1024; + if (unit === 'kB') return value; + return value / 1024; +}; + +const WARN_THRESHOLD_KB = 200; +const ERROR_THRESHOLD_KB = 350; + +const sizeIcon = (firstLoadStr) => { + const kb = toKb(firstLoadStr); + if (kb >= ERROR_THRESHOLD_KB) return '🔴'; + if (kb >= WARN_THRESHOLD_KB) return '🟡'; + return '🟢'; +}; + +const formatRouteTable = (appLabel, { routes, sharedSize }) => { + if (!routes.length) { + return `### ${appLabel}\n\n> ⚠️ 빌드 출력을 파싱하지 못했습니다.\n`; + } + + const rows = routes + .map( + (r) => + `| \`${r.path}\` | ${r.size} | ${r.firstLoad} | ${sizeIcon(r.firstLoad)} |` + ) + .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; + + const readBuild = (filePath) => { + try { + return fs.readFileSync(filePath, 'utf8'); + } catch { + return ''; + } + }; + + const timoWebBuild = parseBuildOutput(readBuild('/tmp/timo-web-build.txt')); + + if (!timoWebBuild.routes.length) { + core.warning('번들 크기 데이터를 파싱하지 못했습니다. 빌드 출력을 확인하세요.'); + return; + } + + const legend = `> 🟢 정상 (<${WARN_THRESHOLD_KB}kB) 🟡 주의 (<${ERROR_THRESHOLD_KB}kB) 🔴 초과 (≥${ERROR_THRESHOLD_KB}kB) — First Load JS 기준`; + + 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, + }); + + for (const comment of comments) { + if ( + comment.user?.type === 'Bot' && + comment.body?.includes('번들 사이즈 리포트') + ) { + await github.rest.issues.deleteComment({ + owner, + repo, + comment_id: comment.id, + }); + } + } + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body, + }); + + core.info('번들 사이즈 리포트 PR 코멘트 게시 완료'); +}; diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml new file mode 100644 index 00000000..0428bd76 --- /dev/null +++ b/.github/workflows/bundle-size.yml @@ -0,0 +1,49 @@ +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: 20 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build timo-web + run: pnpm turbo run build --filter=timo-web --log-output=full 2>&1 | tee /tmp/timo-web-build.txt + continue-on-error: true + + - name: Debug - 빌드 출력 확인 + if: always() + run: | + echo "=== timo-web (마지막 50줄) ===" + tail -50 /tmp/timo-web-build.txt 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 }) From 5d953e3b80cf5a7ddb7fbf97b2ce081d668cfb7e Mon Sep 17 00:00:00 2001 From: kimminna Date: Wed, 24 Jun 2026 15:06:57 +0900 Subject: [PATCH 02/13] =?UTF-8?q?fix(ui):=20=EB=94=94=EC=9E=90=EC=9D=B8=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=ED=8C=A8=ED=82=A4=EC=A7=80?= =?UTF-8?q?=EB=AA=85=20=EB=B3=80=EA=B2=BD=20=EC=82=AC=ED=95=AD=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20(#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/timo-web/package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/timo-web/package.json b/apps/timo-web/package.json index ae48876f..2eb1bd37 100644 --- a/apps/timo-web/package.json +++ b/apps/timo-web/package.json @@ -11,7 +11,7 @@ "check-types": "next typegen && tsc --noEmit" }, "dependencies": { - "@repo/ui": "workspace:*", + "@repo/timo-design-system": "workspace:*", "next": "16.2.0", "react": "^19.2.0", "react-dom": "^19.2.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d09aaaa..3b5d177b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,9 +23,9 @@ importers: apps/timo-web: dependencies: - '@repo/ui': + '@repo/timo-design-system': specifier: workspace:* - version: link:../../packages/ui + version: link:../../packages/timo-design-system next: specifier: 16.2.0 version: 16.2.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -94,9 +94,7 @@ importers: specifier: ^8.50.0 version: 8.50.0(eslint@9.39.1)(typescript@5.9.2) - packages/typescript-config: {} - - packages/ui: + packages/timo-design-system: dependencies: react: specifier: ^19.2.0 @@ -127,6 +125,8 @@ importers: specifier: 5.9.2 version: 5.9.2 + packages/typescript-config: {} + packages: '@emnapi/runtime@1.7.1': From 742793f34d52dfa1812d0988402a726f4c79b498 Mon Sep 17 00:00:00 2001 From: kimminna Date: Wed, 24 Jun 2026 15:09:39 +0900 Subject: [PATCH 03/13] =?UTF-8?q?ci(root):=20Turbo=20TUI=20=EB=B9=84?= =?UTF-8?q?=ED=99=9C=EC=84=B1=ED=99=94=20=EB=B0=8F=20Node=2024=EB=A1=9C=20?= =?UTF-8?q?=EC=97=85=EA=B7=B8=EB=A0=88=EC=9D=B4=EB=93=9C=20(#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TURBO_UI=false 설정으로 TUI 모드를 비활성화해 빌드 출력을 파싱 가능한 텍스트로 캡처했습니다 - NO_COLOR=1 추가로 ANSI 색상 코드 없이 출력했습니다 - Node 20 deprecation 경고 해소를 위해 node-version을 24로 올렸습니다 --- .github/workflows/bundle-size.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml index 0428bd76..0006e975 100644 --- a/.github/workflows/bundle-size.yml +++ b/.github/workflows/bundle-size.yml @@ -24,13 +24,16 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 cache: 'pnpm' - name: Install dependencies run: pnpm install --frozen-lockfile - name: Build timo-web + env: + TURBO_UI: false + NO_COLOR: '1' run: pnpm turbo run build --filter=timo-web --log-output=full 2>&1 | tee /tmp/timo-web-build.txt continue-on-error: true From 80ae4f5bfdde1dbf05d78098371aaa093db6cdc3 Mon Sep 17 00:00:00 2001 From: kimminna Date: Wed, 24 Jun 2026 15:11:12 +0900 Subject: [PATCH 04/13] =?UTF-8?q?ci(root):=20turbo=20=ED=94=8C=EB=9E=98?= =?UTF-8?q?=EA=B7=B8=20--log-output=EC=9D=84=20--output-logs=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 현재 Turbo 버전에서 --log-output은 유효하지 않은 플래그였습니다 - 올바른 플래그인 --output-logs=full로 수정했습니다 --- .github/workflows/bundle-size.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml index 0006e975..082a4fc3 100644 --- a/.github/workflows/bundle-size.yml +++ b/.github/workflows/bundle-size.yml @@ -34,7 +34,7 @@ jobs: env: TURBO_UI: false NO_COLOR: '1' - run: pnpm turbo run build --filter=timo-web --log-output=full 2>&1 | tee /tmp/timo-web-build.txt + 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 - 빌드 출력 확인 From 4f5ab0be866215d20b19a0b9efed8b59a58fad39 Mon Sep 17 00:00:00 2001 From: kimminna Date: Wed, 24 Jun 2026 15:15:29 +0900 Subject: [PATCH 05/13] =?UTF-8?q?ci(root):=20=EB=B2=88=EB=93=A4=20?= =?UTF-8?q?=ED=81=AC=EA=B8=B0=20=EB=B6=84=EC=84=9D=EC=9D=84=20.next=20?= =?UTF-8?q?=EB=94=94=EB=A0=89=ED=86=A0=EB=A6=AC=20=ED=8C=8C=EC=8B=B1?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98=20(#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Next.js 16 Turbopack은 빌드 텍스트 출력에 번들 크기를 포함하지 않아 정규식 파싱 방식이 동작하지 않았습니다 - app-build-manifest.json과 build-manifest.json을 직접 읽어 파일 크기를 계산하는 방식으로 전환했습니다 - 디버그 스텝을 .next 디렉토리 기준으로 변경했습니다 --- .github/scripts/bundle-size-report.js | 108 +++++++++++++------------- .github/workflows/bundle-size.yml | 10 ++- 2 files changed, 62 insertions(+), 56 deletions(-) diff --git a/.github/scripts/bundle-size-report.js b/.github/scripts/bundle-size-report.js index b63d29fa..0a1cae42 100644 --- a/.github/scripts/bundle-size-report.js +++ b/.github/scripts/bundle-size-report.js @@ -1,70 +1,79 @@ 'use strict'; const fs = require('fs'); +const path = require('path'); -/** - * Next.js 빌드 출력에서 라우트별 번들 크기를 파싱합니다. - * turbo run 출력 형식 (앱명:build: 접두사 포함/미포함 모두 처리) - */ -const parseBuildOutput = (rawOutput) => { - const output = rawOutput - .split('\n') - .map((line) => line.replace(/\x1b\[[0-9;]*m/g, '')) - .map((line) => line.replace(/^[^\s]+:build:\s?/, '')) - .join('\n'); +const BUILD_DIR = path.join(process.cwd(), 'apps/timo-web/.next'); - const routes = []; - const routeRegex = - /[┌├└│]\s+[○●λ◑ƒ]\s+(\/\S*)\s+([\d.]+\s*[kMGT]?B)\s+([\d.]+\s*[kMGT]?B)/g; +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`; +}; - let match; - while ((match = routeRegex.exec(output)) !== null) { - routes.push({ - path: match[1], - size: match[2].trim(), - firstLoad: match[3].trim(), - }); - } +const toKb = (bytes) => bytes / 1024; - const sharedMatch = output.match( - /First Load JS shared by all\s+([\d.]+\s*[kMGT]?B)/ - ); +const safeStatSize = (filePath) => { + try { return fs.statSync(filePath).size; } catch { return 0; } +}; - return { routes, sharedSize: sharedMatch?.[1]?.trim() ?? null }; +const readJson = (filePath) => { + try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { return null; } }; -/** - * First Load JS 크기 문자열을 kB 단위 숫자로 변환합니다. - */ -const toKb = (sizeStr) => { - const match = sizeStr.match(/([\d.]+)\s*([kMG]?B)/); - if (!match) return 0; - const value = parseFloat(match[1]); - const unit = match[2]; - if (unit === 'MB') return value * 1024; - if (unit === 'kB') return value; - return value / 1024; +const analyzeBuild = (buildDir) => { + if (!fs.existsSync(buildDir)) return { routes: [], sharedSize: null }; + + const appBuildManifest = readJson(path.join(buildDir, 'app-build-manifest.json')); + const buildManifest = readJson(path.join(buildDir, 'build-manifest.json')); + + if (!appBuildManifest && !buildManifest) return { routes: [], sharedSize: null }; + + // rootMainFiles 경로는 .next/ 기준 상대 경로 + const rootMainFiles = buildManifest?.rootMainFiles ?? []; + const sharedBytes = rootMainFiles.reduce( + (sum, chunk) => sum + safeStatSize(path.join(buildDir, chunk)), + 0 + ); + + const pages = appBuildManifest?.pages ?? {}; + const routes = Object.entries(pages) + .filter(([route]) => route !== '/_not-found') + .map(([route, chunks]) => { + const pageBytes = chunks.reduce( + (sum, chunk) => sum + safeStatSize(path.join(buildDir, chunk)), + 0 + ); + const firstLoadBytes = pageBytes + sharedBytes; + return { + path: route, + size: formatBytes(pageBytes), + firstLoad: formatBytes(firstLoadBytes), + firstLoadKb: toKb(firstLoadBytes), + }; + }); + + return { routes, sharedSize: formatBytes(sharedBytes) }; }; const WARN_THRESHOLD_KB = 200; const ERROR_THRESHOLD_KB = 350; -const sizeIcon = (firstLoadStr) => { - const kb = toKb(firstLoadStr); - if (kb >= ERROR_THRESHOLD_KB) return '🔴'; - if (kb >= WARN_THRESHOLD_KB) return '🟡'; +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> ⚠️ 빌드 출력을 파싱하지 못했습니다.\n`; + return `### ${appLabel}\n\n> ⚠️ 번들 크기 데이터를 가져오지 못했습니다 (.next 디렉토리를 확인하세요).\n`; } const rows = routes .map( (r) => - `| \`${r.path}\` | ${r.size} | ${r.firstLoad} | ${sizeIcon(r.firstLoad)} |` + `| \`${r.path}\` | ${r.size} | ${r.firstLoad} | ${sizeIcon(r.firstLoadKb)} |` ) .join('\n'); @@ -87,22 +96,15 @@ module.exports = async ({ github, context, core }) => { const { owner, repo } = context.repo; - const readBuild = (filePath) => { - try { - return fs.readFileSync(filePath, 'utf8'); - } catch { - return ''; - } - }; - - const timoWebBuild = parseBuildOutput(readBuild('/tmp/timo-web-build.txt')); + core.info(`번들 분석 경로: ${BUILD_DIR}`); + const timoWebBuild = analyzeBuild(BUILD_DIR); if (!timoWebBuild.routes.length) { - core.warning('번들 크기 데이터를 파싱하지 못했습니다. 빌드 출력을 확인하세요.'); + core.warning(`번들 크기 데이터를 파싱하지 못했습니다. ${BUILD_DIR} 를 확인하세요.`); return; } - const legend = `> 🟢 정상 (<${WARN_THRESHOLD_KB}kB) 🟡 주의 (<${ERROR_THRESHOLD_KB}kB) 🔴 초과 (≥${ERROR_THRESHOLD_KB}kB) — First Load JS 기준`; + const legend = `> 🟢 정상 (<${WARN_THRESHOLD_KB}kB) 🟡 주의 (<${ERROR_THRESHOLD_KB}kB) 🔴 초과 (≥${ERROR_THRESHOLD_KB}kB) — First Load JS 기준 (비압축 크기)`; const body = `## 📦 번들 사이즈 리포트 diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml index 082a4fc3..f5d7faaf 100644 --- a/.github/workflows/bundle-size.yml +++ b/.github/workflows/bundle-size.yml @@ -37,11 +37,15 @@ jobs: 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 - 빌드 출력 확인 + - name: Debug - 빌드 결과 확인 if: always() run: | - echo "=== timo-web (마지막 50줄) ===" - tail -50 /tmp/timo-web-build.txt 2>/dev/null || echo "(파일 없음)" + echo "=== .next 디렉토리 ===" + ls apps/timo-web/.next/ 2>/dev/null || echo "(.next 없음)" + echo "=== app-build-manifest.json ===" + cat apps/timo-web/.next/app-build-manifest.json 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 "(없음)" - name: 번들 사이즈 PR 코멘트 게시 uses: actions/github-script@v7 From 84d42a0238524ba3cdc260803b1ef178664e50aa Mon Sep 17 00:00:00 2001 From: kimminna Date: Wed, 24 Jun 2026 15:18:52 +0900 Subject: [PATCH 06/13] =?UTF-8?q?ci(root):=20Turbopack=20=EB=B9=8C?= =?UTF-8?q?=EB=93=9C=EC=9A=A9=20routes-manifest=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98=20(#1?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Turbopack 빌드는 app-build-manifest.json을 생성하지 않아 분석 실패했습니다 - routes-manifest.json(라우트 목록)과 build-manifest.json(공유 청크)을 조합해 분석하도록 변경했습니다 - webpack/Turbopack 모드를 자동 감지해 각각 다른 경로로 분석합니다 --- .github/scripts/bundle-size-report.js | 92 +++++++++++++++++++++------ .github/workflows/bundle-size.yml | 6 +- 2 files changed, 76 insertions(+), 22 deletions(-) diff --git a/.github/scripts/bundle-size-report.js b/.github/scripts/bundle-size-report.js index 0a1cae42..f4e1edfa 100644 --- a/.github/scripts/bundle-size-report.js +++ b/.github/scripts/bundle-size-report.js @@ -21,37 +21,89 @@ const readJson = (filePath) => { try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { return null; } }; +const sumDirSize = (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() ? stat.size : 0); + }, 0); +}; + const analyzeBuild = (buildDir) => { if (!fs.existsSync(buildDir)) return { routes: [], sharedSize: null }; - const appBuildManifest = readJson(path.join(buildDir, 'app-build-manifest.json')); 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 (!appBuildManifest && !buildManifest) return { routes: [], sharedSize: null }; + if (!buildManifest) return { routes: [], sharedSize: null }; - // rootMainFiles 경로는 .next/ 기준 상대 경로 - const rootMainFiles = buildManifest?.rootMainFiles ?? []; + // 공유 번들 크기 (rootMainFiles) + const rootMainFiles = buildManifest.rootMainFiles ?? []; const sharedBytes = rootMainFiles.reduce( (sum, chunk) => sum + safeStatSize(path.join(buildDir, chunk)), 0 ); - const pages = appBuildManifest?.pages ?? {}; - const routes = Object.entries(pages) - .filter(([route]) => route !== '/_not-found') - .map(([route, chunks]) => { - const pageBytes = chunks.reduce( - (sum, chunk) => sum + safeStatSize(path.join(buildDir, chunk)), - 0 - ); - const firstLoadBytes = pageBytes + sharedBytes; - return { - path: route, - size: formatBytes(pageBytes), - firstLoad: formatBytes(firstLoadBytes), - firstLoadKb: toKb(firstLoadBytes), - }; - }); + let routes = []; + + if (appBuildManifest) { + // webpack 모드: app-build-manifest.json으로 라우트별 청크 매핑 + const pages = appBuildManifest.pages ?? {}; + routes = Object.entries(pages) + .filter(([route]) => route !== '/_not-found') + .map(([route, chunks]) => { + const pageBytes = chunks.reduce( + (sum, chunk) => sum + safeStatSize(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으로 라우트 목록 구성 + // 라우트별 클라이언트 청크는 static/chunks/app/ 하위에서 탐색 + const staticRoutes = [ + ...(routesManifest?.staticRoutes ?? []), + ...(routesManifest?.dynamicRoutes ?? []), + ]; + + const appChunksDir = path.join(buildDir, 'static', 'chunks', 'app'); + + routes = staticRoutes + .filter(({ page }) => page !== '/_not-found') + .map(({ page }) => { + // static/chunks/app/[page-segment]/ 디렉토리 크기를 페이지 크기로 사용 + const segment = page === '/' ? '' : page; + const pageChunkDir = path.join(appChunksDir, segment); + const pageBytes = sumDirSize(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) }; }; diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml index f5d7faaf..3c0e7c50 100644 --- a/.github/workflows/bundle-size.yml +++ b/.github/workflows/bundle-size.yml @@ -42,10 +42,12 @@ jobs: run: | echo "=== .next 디렉토리 ===" ls apps/timo-web/.next/ 2>/dev/null || echo "(.next 없음)" - echo "=== app-build-manifest.json ===" - cat apps/timo-web/.next/app-build-manifest.json 2>/dev/null || echo "(없음)" + 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 From c1a10d80fbce04df5fbea5e97f3c42fda3a1b3af Mon Sep 17 00:00:00 2001 From: kimminna Date: Wed, 24 Jun 2026 15:23:57 +0900 Subject: [PATCH 07/13] =?UTF-8?q?ci(root):=20gzip=20=ED=81=AC=EA=B8=B0=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=95=20=EB=B0=8F=20=EB=82=B4=EB=B6=80=20=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=ED=8A=B8=20=ED=95=84=ED=84=B0=EB=A7=81=20(#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 비압축 크기 대신 zlib.gzipSync로 gzip 크기를 측정해 Next.js 빌드 출력 기준과 통일했습니다 - /_global-error, /_not-found 등 Next.js 내부 라우트를 리포트에서 제외했습니다 --- .github/scripts/bundle-size-report.js | 31 ++++++++++++++++----------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/.github/scripts/bundle-size-report.js b/.github/scripts/bundle-size-report.js index f4e1edfa..6b416659 100644 --- a/.github/scripts/bundle-size-report.js +++ b/.github/scripts/bundle-size-report.js @@ -2,6 +2,7 @@ const fs = require('fs'); const path = require('path'); +const zlib = require('zlib'); const BUILD_DIR = path.join(process.cwd(), 'apps/timo-web/.next'); @@ -13,20 +14,23 @@ const formatBytes = (bytes) => { const toKb = (bytes) => bytes / 1024; -const safeStatSize = (filePath) => { - try { return fs.statSync(filePath).size; } catch { return 0; } +const safeGzipSize = (filePath) => { + try { + const content = fs.readFileSync(filePath); + return zlib.gzipSync(content).length; + } catch { return 0; } }; const readJson = (filePath) => { try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { return null; } }; -const sumDirSize = (dirPath) => { +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() ? stat.size : 0); + return sum + (stat.isFile() ? safeGzipSize(full) : 0); }, 0); }; @@ -40,10 +44,13 @@ const analyzeBuild = (buildDir) => { if (!buildManifest) return { routes: [], sharedSize: null }; - // 공유 번들 크기 (rootMainFiles) + // 내부 Next.js 라우트 필터 (사용자 정의 라우트가 아님) + const INTERNAL_ROUTES = new Set(['/_not-found', '/_global-error']); + + // 공유 번들 gzip 크기 (rootMainFiles) const rootMainFiles = buildManifest.rootMainFiles ?? []; const sharedBytes = rootMainFiles.reduce( - (sum, chunk) => sum + safeStatSize(path.join(buildDir, chunk)), + (sum, chunk) => sum + safeGzipSize(path.join(buildDir, chunk)), 0 ); @@ -53,10 +60,10 @@ const analyzeBuild = (buildDir) => { // webpack 모드: app-build-manifest.json으로 라우트별 청크 매핑 const pages = appBuildManifest.pages ?? {}; routes = Object.entries(pages) - .filter(([route]) => route !== '/_not-found') + .filter(([route]) => !INTERNAL_ROUTES.has(route)) .map(([route, chunks]) => { const pageBytes = chunks.reduce( - (sum, chunk) => sum + safeStatSize(path.join(buildDir, chunk)), + (sum, chunk) => sum + safeGzipSize(path.join(buildDir, chunk)), 0 ); const firstLoadBytes = pageBytes + sharedBytes; @@ -69,7 +76,6 @@ const analyzeBuild = (buildDir) => { }); } else { // Turbopack 모드: routes-manifest.json으로 라우트 목록 구성 - // 라우트별 클라이언트 청크는 static/chunks/app/ 하위에서 탐색 const staticRoutes = [ ...(routesManifest?.staticRoutes ?? []), ...(routesManifest?.dynamicRoutes ?? []), @@ -78,12 +84,11 @@ const analyzeBuild = (buildDir) => { const appChunksDir = path.join(buildDir, 'static', 'chunks', 'app'); routes = staticRoutes - .filter(({ page }) => page !== '/_not-found') + .filter(({ page }) => !INTERNAL_ROUTES.has(page)) .map(({ page }) => { - // static/chunks/app/[page-segment]/ 디렉토리 크기를 페이지 크기로 사용 const segment = page === '/' ? '' : page; const pageChunkDir = path.join(appChunksDir, segment); - const pageBytes = sumDirSize(pageChunkDir); + const pageBytes = sumDirGzipSize(pageChunkDir); const firstLoadBytes = pageBytes + sharedBytes; return { @@ -156,7 +161,7 @@ module.exports = async ({ github, context, core }) => { return; } - const legend = `> 🟢 정상 (<${WARN_THRESHOLD_KB}kB) 🟡 주의 (<${ERROR_THRESHOLD_KB}kB) 🔴 초과 (≥${ERROR_THRESHOLD_KB}kB) — First Load JS 기준 (비압축 크기)`; + const legend = `> 🟢 정상 (<${WARN_THRESHOLD_KB}kB) 🟡 주의 (<${ERROR_THRESHOLD_KB}kB) 🔴 초과 (≥${ERROR_THRESHOLD_KB}kB) — First Load JS 기준 (gzip 크기)`; const body = `## 📦 번들 사이즈 리포트 From 6d1ed19ddaf99de214cd88658b31494e6b611f26 Mon Sep 17 00:00:00 2001 From: kimminna Date: Wed, 24 Jun 2026 15:26:19 +0900 Subject: [PATCH 08/13] =?UTF-8?q?ci(root):=20=EB=B2=88=EB=93=A4=20?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EC=A6=88=20=EC=BD=94=EB=A9=98=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20=EC=82=AD=EC=A0=9C=20=ED=9B=84=20=EC=9E=AC=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EC=97=90=EC=84=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20(#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 커밋이 추가될 때마다 새 코멘트가 생기는 대신 기존 코멘트를 수정하도록 변경했습니다 --- .github/scripts/bundle-size-report.js | 36 +++++++++++++-------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/scripts/bundle-size-report.js b/.github/scripts/bundle-size-report.js index 6b416659..3f499976 100644 --- a/.github/scripts/bundle-size-report.js +++ b/.github/scripts/bundle-size-report.js @@ -179,25 +179,25 @@ ${legend} issue_number: prNumber, }); - for (const comment of comments) { - if ( - comment.user?.type === 'Bot' && - comment.body?.includes('번들 사이즈 리포트') - ) { - await github.rest.issues.deleteComment({ - owner, - repo, - comment_id: comment.id, - }); - } - } + const existing = comments.find( + (c) => c.user?.type === 'Bot' && c.body?.includes('번들 사이즈 리포트') + ); - await github.rest.issues.createComment({ - owner, - repo, - issue_number: prNumber, - body, - }); + 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 코멘트 게시 완료'); }; From 9811b764b0751cf3fdda0731bdd23e6a020c24fe Mon Sep 17 00:00:00 2001 From: kimminna Date: Thu, 25 Jun 2026 23:58:13 +0900 Subject: [PATCH 09/13] =?UTF-8?q?ci(root):=20Debug=20=EC=8A=A4=ED=85=9D?= =?UTF-8?q?=EC=9D=84=20=EB=B9=8C=EB=93=9C=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C?= =?UTF-8?q?=EC=97=90=EB=A7=8C=20=EC=8B=A4=ED=96=89=EB=90=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95=20(#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Build 스텝에 id: build를 추가했습니다 - Debug 스텝 조건을 if: always()에서 if: steps.build.outcome == 'failure'로 변경하여 빌드 실패 시에만 실행되도록 했습니다 --- .github/workflows/bundle-size.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml index 3c0e7c50..7b971078 100644 --- a/.github/workflows/bundle-size.yml +++ b/.github/workflows/bundle-size.yml @@ -31,6 +31,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Build timo-web + id: build env: TURBO_UI: false NO_COLOR: '1' @@ -38,7 +39,7 @@ jobs: continue-on-error: true - name: Debug - 빌드 결과 확인 - if: always() + if: steps.build.outcome == 'failure' run: | echo "=== .next 디렉토리 ===" ls apps/timo-web/.next/ 2>/dev/null || echo "(.next 없음)" From eae60130cec806aba8dde1a8d11634e2b3ed278f Mon Sep 17 00:00:00 2001 From: kimminna Date: Thu, 25 Jun 2026 23:59:38 +0900 Subject: [PATCH 10/13] =?UTF-8?q?fix(root):=20PR=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=20=ED=8C=8C=EC=9D=BC=EB=AA=85=20=EC=98=A4=ED=83=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/{PULL_REQUEST_TEPLATE.md => PULL_REQUEST_TEMPLATE.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/{PULL_REQUEST_TEPLATE.md => PULL_REQUEST_TEMPLATE.md} (100%) diff --git a/.github/PULL_REQUEST_TEPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md similarity index 100% rename from .github/PULL_REQUEST_TEPLATE.md rename to .github/PULL_REQUEST_TEMPLATE.md From 833dcdd22cb9a78938b7d8695e0f38f041ee413e Mon Sep 17 00:00:00 2001 From: kimminna Date: Fri, 26 Jun 2026 00:08:06 +0900 Subject: [PATCH 11/13] =?UTF-8?q?fix(root):=20label-by-files=EC=99=80=20la?= =?UTF-8?q?bel-pr=20=EA=B0=84=20=EB=9D=BC=EB=B2=A8=20=EC=B6=A9=EB=8F=8C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PR 변경 파일 목록을 조회해 label-by-files가 추가한 라벨을 보호했습니다 - packages/timo-design-system/** 파일이 변경된 PR에서 ⌚ Timo-Design-system 라벨이 label-pr에 의해 제거되던 버그를 수정했습니다 - apps/timo-web/** 변경 시 ⏰ Timo-web 라벨도 동일하게 보호했습니다 --- .github/workflows/auto-label.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index c0dddd89..f3683ce5 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -64,6 +64,24 @@ jobs: }); const currentLabelNames = new Set(currentLabels.map(l => l.name)); + // labeler.yml이 파일 경로 기준으로 관리하는 라벨은 파일 변경 여부로 보호 + // label-by-files job이 추가했을 수 있으므로 prefix 기반 제거 대상에서 제외 + const { data: changedFiles } = await github.rest.pulls.listFiles({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + per_page: 100, + }); + const fileLabelProtection = new Map([ + ['⌚ Timo-Design-system', 'packages/timo-design-system/'], + ['⏰ Timo-web', 'apps/timo-web/'], + ]); + const protectedByFiles = new Set( + [...fileLabelProtection.entries()] + .filter(([, pathPrefix]) => changedFiles.some(f => f.filename.startsWith(pathPrefix))) + .map(([label]) => label) + ); + // --- prefix 라벨 --- // edited 이벤트는 본문 수정만으로도 트리거되므로 제목 변경 여부를 확인 if (action !== 'edited' || context.payload.changes?.title) { @@ -73,6 +91,8 @@ jobs: for (const label of currentLabels) { if (allPrefixLabels.includes(label.name) && label.name !== desiredLabel) { + // 파일 변경으로 인해 label-by-files가 추가한 라벨은 제거하지 않음 + if (protectedByFiles.has(label.name)) continue; await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, From c8708c8efdd89341bd29e01ae7b65b1578dbdf13 Mon Sep 17 00:00:00 2001 From: kimminna Date: Fri, 26 Jun 2026 00:18:21 +0900 Subject: [PATCH 12/13] =?UTF-8?q?fix(root):=20listFiles=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EB=84=A4=EC=9D=B4=EC=85=98=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=EB=9D=BC?= =?UTF-8?q?=EB=B2=A8=20=EB=B3=B4=ED=98=B8=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pulls.listFiles를 github.paginate로 교체해 100개 초과 PR의 파일 목록을 모두 수집했습니다 - 파일이 100개를 넘는 PR에서 protectedByFiles가 불완전하게 구성되던 문제를 수정했습니다 --- .github/workflows/auto-label.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index f3683ce5..d7d37157 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -66,7 +66,7 @@ jobs: // labeler.yml이 파일 경로 기준으로 관리하는 라벨은 파일 변경 여부로 보호 // label-by-files job이 추가했을 수 있으므로 prefix 기반 제거 대상에서 제외 - const { data: changedFiles } = await github.rest.pulls.listFiles({ + const changedFiles = await github.paginate(github.rest.pulls.listFiles, { owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber, From 6bb845bea29f8090ee0790583ad26fb7758e145e Mon Sep 17 00:00:00 2001 From: kimminna Date: Fri, 26 Jun 2026 00:20:11 +0900 Subject: [PATCH 13/13] =?UTF-8?q?fix(root):=20safeGzipSize=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EB=AC=B4=EC=8B=9C=20=EC=A0=9C=EA=B1=B0=EB=A1=9C=20?= =?UTF-8?q?=EB=B2=88=EB=93=A4=20=EB=B6=84=EC=84=9D=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EB=AA=85=EC=8B=9C=ED=99=94=20(#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 파일 읽기 또는 gzip 압축 실패 시 0을 반환하던 동작을 throw로 변경했습니다 - 잘못된 크기값이 리포트에 포함되는 대신 워크플로우 스텝이 명시적으로 실패합니다 --- .github/scripts/bundle-size-report.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/scripts/bundle-size-report.js b/.github/scripts/bundle-size-report.js index 3f499976..96da2910 100644 --- a/.github/scripts/bundle-size-report.js +++ b/.github/scripts/bundle-size-report.js @@ -18,7 +18,9 @@ const safeGzipSize = (filePath) => { try { const content = fs.readFileSync(filePath); return zlib.gzipSync(content).length; - } catch { return 0; } + } catch (err) { + throw new Error(`번들 파일 처리 실패: ${filePath} — ${err.message}`); + } }; const readJson = (filePath) => {