diff --git a/.github/scripts/performance-report.js b/.github/scripts/performance-report.js index c663d95f..46ccdbd2 100644 --- a/.github/scripts/performance-report.js +++ b/.github/scripts/performance-report.js @@ -30,54 +30,60 @@ 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 PROJECT_MODULE_PREFIX = '[project]/apps/timo-web/app'; + +const extractClientReferenceManifest = (jsFilePath) => { + if (!jsFilePath || !fs.existsSync(jsFilePath)) return null; + try { + const content = fs.readFileSync(jsFilePath, 'utf8'); + const match = content.match(/"\]\s*=\s*(\{[\s\S]*\});\s*$/); + return match ? JSON.parse(match[1]) : null; + } catch { + return null; + } }; +const stripRouteGroups = (routePath) => + routePath + .split('/') + .filter((segment) => !(segment.startsWith('(') && segment.endsWith(')'))) + .join('/') || '/'; + +// Turbopack (Next.js 16's default bundler) does not emit `app-build-manifest.json` +// or `static/chunks/app//`, so per-route sizes come from each route's +// `page_client-reference-manifest.js` (entryJSFiles already accumulates the page's +// own chunk plus every parent layout chunk, deduped against the shared root chunks). 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 }]; - } + const appPathsManifest = readJson(path.join(BUILD_DIR, 'server', 'app-paths-manifest.json')); + if (!buildManifest || !appPathsManifest) return null; + + // Turbopack의 산출물 구조는 비공식이라 rootMainFiles/entryJSFiles 키가 + // 바뀌면 번들 크기가 조용히 0으로 계산될 수 있다. CI에서 바로 드러나도록 명시적으로 검증한다. + if (!Array.isArray(buildManifest.rootMainFiles)) { + console.warn('[bundle] build-manifest.json에 rootMainFiles 배열이 없습니다. Next.js 빌드 산출물 구조가 변경되었을 수 있습니다.'); + return null; } + const sharedFiles = new Set(buildManifest.rootMainFiles); + const sharedBytes = [...sharedFiles].reduce((sum, chunk) => sum + gzipSize(path.join(BUILD_DIR, chunk)), 0); + + const routes = Object.entries(appPathsManifest) + .filter(([entryKey]) => !INTERNAL_ROUTES.has(entryKey.replace(/\/page$/, ''))) + .map(([entryKey, relPath]) => { + const routePath = stripRouteGroups(entryKey.replace(/\/page$/, '')); + const manifestPath = path.join(BUILD_DIR, 'server', relPath.replace(/\.js$/, '_client-reference-manifest.js')); + const manifest = extractClientReferenceManifest(manifestPath); + if (!manifest?.entryJSFiles || typeof manifest.entryJSFiles !== 'object') { + console.warn(`[bundle] ${routePath} 라우트의 entryJSFiles를 찾을 수 없습니다 (${manifestPath}).`); + } + const pageFiles = (manifest?.entryJSFiles?.[`${PROJECT_MODULE_PREFIX}${entryKey}`] ?? []) + .filter((file) => !sharedFiles.has(file)); + const pageBytes = pageFiles.reduce((sum, file) => sum + gzipSize(path.join(BUILD_DIR, file)), 0); + const firstLoad = pageBytes + sharedBytes; + return { path: routePath, size: formatBytes(pageBytes), firstLoad: formatBytes(firstLoad), firstLoadKb: firstLoad / 1024 }; + }); + return { routes, sharedSize: formatBytes(sharedBytes) }; }; diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml index ba8c2cd9..bf713bed 100644 --- a/.github/workflows/performance.yml +++ b/.github/workflows/performance.yml @@ -2,7 +2,7 @@ name: ✨ Performance Report on: pull_request: - types: [opened, reopened] + types: [opened, reopened, synchronize] branches: [main, develop] workflow_dispatch: diff --git a/apps/timo-web/components/layout/sidebar/navigation/NavigationSidebar.tsx b/apps/timo-web/components/layout/sidebar/navigation/NavigationSidebar.tsx index d29c399d..9dc98759 100644 --- a/apps/timo-web/components/layout/sidebar/navigation/NavigationSidebar.tsx +++ b/apps/timo-web/components/layout/sidebar/navigation/NavigationSidebar.tsx @@ -120,7 +120,13 @@ export const NavigationSidebar = () => { >
- Timo + Timo