From 4346f48bbe1536996af92242ec274bd8d1321fae Mon Sep 17 00:00:00 2001 From: Lectom Date: Fri, 15 May 2026 14:16:34 +0900 Subject: [PATCH] perf(userfront): optimize login web loading --- ...erfront_loading_performance_policy_test.sh | 41 +++++++ .../scripts/serve-userfront-build.mjs | 57 ++++++++- .../tests/login-performance-budget.spec.ts | 116 ++++++++++++++++++ userfront/Dockerfile | 15 ++- userfront/lib/core/i18n/locale_registry.dart | 16 +++ userfront/lib/core/theme/app_theme.dart | 6 +- userfront/lib/main.dart | 39 +++--- userfront/nginx.conf | 52 +++++--- userfront/pubspec.yaml | 6 - userfront/scripts/optimize-web-build.mjs | 81 ++++++++++++ .../test/app_theme_default_font_test.dart | 12 ++ userfront/web/index.html | 4 - 12 files changed, 383 insertions(+), 62 deletions(-) create mode 100644 test/userfront_loading_performance_policy_test.sh create mode 100644 userfront-e2e/tests/login-performance-budget.spec.ts create mode 100644 userfront/scripts/optimize-web-build.mjs create mode 100644 userfront/test/app_theme_default_font_test.dart diff --git a/test/userfront_loading_performance_policy_test.sh b/test/userfront_loading_performance_policy_test.sh new file mode 100644 index 00000000..85dee518 --- /dev/null +++ b/test/userfront_loading_performance_policy_test.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +fail() { + echo "[userfront-loading-policy] $*" >&2 + exit 1 +} + +if rg -n "FontLoader|assets/fonts/NotoSansKR|_loadBundledFonts" userfront/lib userfront/pubspec.yaml; then + fail "userfront must not block first render on bundled NotoSansKR font loading" +fi + +if rg -n "fontFamily:\s*['\"]NotoSansKR['\"]" userfront/lib; then + fail "userfront theme must use the platform default font" +fi + +if rg -n "await ThemeController\.(app|auth)\.restore" userfront/lib/main.dart; then + fail "theme restore must not block the first render" +fi + +if rg -n "fonts\.googleapis\.com/icon\?family=Material\+Icons" userfront/web/index.html; then + fail "userfront must not load Google Material Icons stylesheet on the login critical path" +fi + +if rg -n -- "--no-tree-shake-icons" userfront/Dockerfile userfront-e2e/package.json; then + fail "userfront web release build must allow icon tree shaking" +fi + +rg -q "optimize-web-build\.mjs" userfront/Dockerfile || fail "Docker build must hash and pre-compress Flutter web entrypoints" +rg -q "nginx-mod-http-brotli" userfront/Dockerfile || fail "runtime image must install the nginx Brotli module" +rg -Fq "main\\.dart\\.[0-9a-f]{12}" userfront/nginx.conf || fail "hashed app entrypoints must use immutable cache" +rg -q "brotli_static\s+on;" userfront/nginx.conf || fail "nginx must serve pre-compressed brotli assets" +rg -q "brotliCompressSync" userfront/scripts/optimize-web-build.mjs || fail "Docker build optimization must generate brotli assets" +if rg -n "gzip|gzipSync|\\.gz" userfront/nginx.conf userfront/scripts/optimize-web-build.mjs; then + fail "userfront web compression must be managed as brotli-only" +fi +rg -q "Cache-Control.*no-cache" userfront/nginx.conf || fail "HTML/app shell must use no-cache revalidation" +rg -q "Cache-Control.*immutable" userfront/nginx.conf || fail "versioned static assets must use immutable cache" diff --git a/userfront-e2e/scripts/serve-userfront-build.mjs b/userfront-e2e/scripts/serve-userfront-build.mjs index 6f74f203..73ab126d 100644 --- a/userfront-e2e/scripts/serve-userfront-build.mjs +++ b/userfront-e2e/scripts/serve-userfront-build.mjs @@ -45,17 +45,39 @@ const server = createServer((req, res) => { } let filePath = candidate; + let servesAppShellFallback = false; if (!existsSync(filePath) || statSync(filePath).isDirectory()) { // Flutter web 라우팅 경로(`/ko`, `/ko/signin`)도 index.html로 fallback 처리 filePath = join(root, 'index.html'); + servesAppShellFallback = true; } + const acceptsBrotli = /\bbr\b/.test(req.headers['accept-encoding'] ?? ''); + const brotliPath = `${filePath}.br`; + const servedPath = acceptsBrotli && existsSync(brotliPath) ? brotliPath : filePath; const ext = extname(filePath); const contentType = contentTypes[ext] ?? 'application/octet-stream'; + const stats = statSync(servedPath); + const etag = `"${stats.size.toString(16)}-${Math.trunc(stats.mtimeMs).toString(16)}"`; + const cacheControl = cacheControlFor(pathname, filePath, servesAppShellFallback); res.setHeader('Content-Type', contentType); - createReadStream(filePath) + res.setHeader('ETag', etag); + res.setHeader('Last-Modified', stats.mtime.toUTCString()); + res.setHeader('Cache-Control', cacheControl); + res.setHeader('Vary', 'Accept-Encoding'); + if (servedPath === brotliPath) { + res.setHeader('Content-Encoding', 'br'); + } + + if (req.headers['if-none-match'] === etag) { + res.statusCode = 304; + res.end(); + return; + } + + createReadStream(servedPath) .on('error', () => { res.statusCode = 500; res.end('Internal Server Error'); @@ -63,6 +85,39 @@ const server = createServer((req, res) => { .pipe(res); }); +function cacheControlFor(pathname, filePath, servesAppShellFallback) { + const basename = filePath.split('/').pop() ?? ''; + + if ( + servesAppShellFallback || + basename === 'index.html' || + basename === 'flutter_bootstrap.js' || + basename === 'flutter_service_worker.js' || + basename === 'version.json' || + basename === 'manifest.json' + ) { + return 'no-cache, max-age=0, must-revalidate'; + } + + if (/^\/canvaskit\/.*\.(?:js|wasm)$/i.test(pathname)) { + return 'public, max-age=31536000, immutable'; + } + + if (/^\/main\.dart\.[0-9a-f]{12}\.(?:js|mjs|wasm)$/i.test(pathname)) { + return 'public, max-age=31536000, immutable'; + } + + if (/\.(?:png|ico|svg|webp|woff|woff2)$/i.test(pathname)) { + return 'public, max-age=31536000, immutable'; + } + + if (/\.(?:js|css|json|mjs|wasm)$/i.test(pathname)) { + return 'no-cache, max-age=0, must-revalidate'; + } + + return 'no-cache, max-age=0, must-revalidate'; +} + server.listen(port, '127.0.0.1', () => { console.log(`[userfront-e2e] serving ${root} at http://127.0.0.1:${port}`); }); diff --git a/userfront-e2e/tests/login-performance-budget.spec.ts b/userfront-e2e/tests/login-performance-budget.spec.ts new file mode 100644 index 00000000..e913b982 --- /dev/null +++ b/userfront-e2e/tests/login-performance-budget.spec.ts @@ -0,0 +1,116 @@ +import { expect, test, type Page, type Request } from '@playwright/test'; + +type LoadMetrics = { + durationMs: number; + transferredBytes: number; + requestedUrls: string[]; + cacheControlByPath: Map; + contentEncodingByPath: Map; +}; + +async function mockPublicApis(page: Page): Promise { + await page.route('**/api/v1/**', async (route) => { + const requestUrl = new URL(route.request().url()); + if (requestUrl.pathname.endsWith('/api/v1/user/me')) { + await route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ error: 'unauthorized' }), + }); + return; + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }); + }); +} + +async function measureSigninLoad(page: Page): Promise { + const requestedUrls: string[] = []; + const cacheControlByPath = new Map(); + const contentEncodingByPath = new Map(); + let transferredBytes = 0; + + page.on('request', (request: Request) => { + requestedUrls.push(request.url()); + }); + + page.on('response', async (response) => { + const url = new URL(response.url()); + const cacheControl = response.headers()['cache-control']; + if (cacheControl) { + cacheControlByPath.set(url.pathname, cacheControl); + } + const contentEncoding = response.headers()['content-encoding']; + if (contentEncoding) { + contentEncodingByPath.set(url.pathname, contentEncoding); + } + + const timing = response.request().timing(); + if (timing.responseEnd >= 0) { + const sizes = await response.request().sizes().catch(() => null); + transferredBytes += sizes?.responseBodySize ?? 0; + } + }); + + const start = performance.now(); + await page.goto('/ko/signin', { waitUntil: 'networkidle' }); + await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/); + const durationMs = Math.round(performance.now() - start); + + return { + durationMs, + transferredBytes, + requestedUrls, + cacheControlByPath, + contentEncodingByPath, + }; +} + +test.describe('UserFront login performance budget', () => { + test('warm login page load stays within the two second budget and reuses cached assets', async ({ + page, + }) => { + await mockPublicApis(page); + + const cold = await measureSigninLoad(page); + const warm = await measureSigninLoad(page); + console.log( + `[userfront-perf] cold=${cold.durationMs}ms/${cold.transferredBytes}B warm=${warm.durationMs}ms/${warm.transferredBytes}B`, + ); + + expect(warm.durationMs).toBeLessThanOrEqual(2000); + expect(warm.transferredBytes).toBeLessThanOrEqual(1_000_000); + expect(warm.requestedUrls.some((url) => url.includes('NotoSansKR'))).toBe( + false, + ); + expect( + warm.requestedUrls.some((url) => + url.includes('fonts.googleapis.com/icon?family=Material+Icons'), + ), + ).toBe(false); + + const cacheControlByPath = new Map([ + ...cold.cacheControlByPath, + ...warm.cacheControlByPath, + ]); + const contentEncodingByPath = new Map([ + ...cold.contentEncodingByPath, + ...warm.contentEncodingByPath, + ]); + + const appShellCache = cacheControlByPath.get('/ko/signin') ?? ''; + expect(appShellCache).toContain('no-cache'); + + expect(cold.durationMs).toBeGreaterThanOrEqual(0); + const brotliEntrypoint = [...contentEncodingByPath.entries()].some( + ([path, encoding]) => + /^\/main\.dart\.[0-9a-f]{12}\.(?:mjs|wasm|js)$/.test(path) && + encoding === 'br', + ); + expect(brotliEntrypoint).toBe(true); + }); +}); diff --git a/userfront/Dockerfile b/userfront/Dockerfile index dadfb397..785a86fa 100644 --- a/userfront/Dockerfile +++ b/userfront/Dockerfile @@ -8,14 +8,21 @@ RUN /bin/sh ./scripts/sync_userfront_locales.sh WORKDIR /app/userfront RUN flutter pub get RUN touch .env -RUN flutter build web --release --no-tree-shake-icons --wasm +RUN flutter build web --release --wasm + +FROM node:24-alpine AS optimize +WORKDIR /work +COPY --from=build /app/userfront/build/web /work/build/web +COPY userfront/scripts/optimize-web-build.mjs /work/scripts/optimize-web-build.mjs +RUN node /work/scripts/optimize-web-build.mjs /work/build/web # Stage 2: Serve with Nginx -FROM nginx:alpine +FROM alpine:3.23 +RUN apk add --no-cache nginx nginx-mod-http-brotli # Copy built assets -COPY --from=build /app/userfront/build/web /usr/share/nginx/html +COPY --from=optimize /work/build/web /usr/share/nginx/html # Copy custom Nginx config -COPY userfront/nginx.conf /etc/nginx/conf.d/default.conf +COPY userfront/nginx.conf /etc/nginx/http.d/default.conf EXPOSE 5000 CMD ["nginx", "-g", "daemon off;"] diff --git a/userfront/lib/core/i18n/locale_registry.dart b/userfront/lib/core/i18n/locale_registry.dart index 7e0e7583..005c4ca7 100644 --- a/userfront/lib/core/i18n/locale_registry.dart +++ b/userfront/lib/core/i18n/locale_registry.dart @@ -31,6 +31,22 @@ class LocaleRegistry { static final Set _localeCodes = {}; static bool _initialized = false; + static void primeWithDefaults({ + Iterable localeCodes = const ['en', 'ko'], + }) { + if (_localeCodes.isNotEmpty) { + return; + } + _localeCodes.addAll( + localeCodes + .map((code) => code.toLowerCase().replaceAll('_', '-')) + .where(_isValidLocaleCode), + ); + if (_localeCodes.isEmpty) { + _localeCodes.add(_safeFallbackLocaleCode); + } + } + static Future initialize({AssetBundle? assetBundle}) async { if (_initialized) { return; diff --git a/userfront/lib/core/theme/app_theme.dart b/userfront/lib/core/theme/app_theme.dart index 328a6b22..59fbdb8b 100644 --- a/userfront/lib/core/theme/app_theme.dart +++ b/userfront/lib/core/theme/app_theme.dart @@ -42,11 +42,7 @@ ThemeData buildDarkTheme() { ThemeData _buildTheme(ColorScheme colorScheme) { final isDark = colorScheme.brightness == Brightness.dark; - final base = ThemeData( - useMaterial3: true, - colorScheme: colorScheme, - fontFamily: 'NotoSansKR', - ); + final base = ThemeData(useMaterial3: true, colorScheme: colorScheme); return base.copyWith( scaffoldBackgroundColor: colorScheme.surfaceContainerLowest, diff --git a/userfront/lib/main.dart b/userfront/lib/main.dart index 55407814..98153f6a 100644 --- a/userfront/lib/main.dart +++ b/userfront/lib/main.dart @@ -1,4 +1,5 @@ // ignore_for_file: avoid_print +import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; @@ -7,7 +8,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:easy_localization/easy_localization.dart' hide tr; import 'package:go_router/go_router.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; import 'features/auth/presentation/login_screen.dart'; import 'features/auth/presentation/signup_screen.dart'; @@ -90,18 +90,6 @@ void _attemptRecoveryFromNullCheck({ webWindow.redirectTo(target); } -Future _loadBundledFonts() async { - const family = 'NotoSansKR'; - final loader = FontLoader(family); - try { - loader.addFont(rootBundle.load('assets/fonts/NotoSansKR-Regular.ttf')); - loader.addFont(rootBundle.load('assets/fonts/NotoSansKR-Bold.ttf')); - await loader.load(); - } catch (e) { - _log.warning("Failed to preload bundled fonts: $e"); - } -} - Future _silentSessionRecovery() async { _log.info("[SessionRecovery] Starting silent session recovery check..."); @@ -165,11 +153,20 @@ Future _silentSessionRecovery() async { } } +bool _shouldRunStartupSessionRecovery(Uri uri) { + final requestedLocale = extractLocaleFromPath(uri); + if (requestedLocale == null) { + return true; + } + final path = stripLocalePath(uri); + return !isPublicAuthPath(path, uri); +} + void main() async { WidgetsFlutterBinding.ensureInitialized(); usePathUrlStrategy(); await EasyLocalization.ensureInitialized(); - await LocaleRegistry.initialize(); + LocaleRegistry.primeWithDefaults(); // 1. Global Error Handling FlutterError.onError = (details) { @@ -201,14 +198,6 @@ void main() async { // 0. Initialize Logger LoggerService.init(); - await ThemeController.app.restore(); - await ThemeController.auth.restore(); - - // 폰트를 먼저 로딩해서 렌더링 깨짐(FOIT/FOUT) 최소화 - await _loadBundledFonts(); - - // 2. Silent Session Recovery (from cookies) - await _silentSessionRecovery(); runApp( // URL(/en, /ko)이 있으면 우선 적용해서 첫 렌더부터 올바른 언어로 시작합니다. @@ -552,6 +541,12 @@ class _BaronSSOAppState extends State { // Re-run router redirects after the first frame so session-only web // storage state is reflected even when startup routing evaluated too early. AuthNotifier.instance.notify(); + unawaited(LocaleRegistry.initialize()); + unawaited(ThemeController.app.restore()); + unawaited(ThemeController.auth.restore()); + if (_shouldRunStartupSessionRecovery(Uri.base)) { + unawaited(_silentSessionRecovery()); + } }); } diff --git a/userfront/nginx.conf b/userfront/nginx.conf index fbe5ad34..3ae6f2c9 100644 --- a/userfront/nginx.conf +++ b/userfront/nginx.conf @@ -21,11 +21,15 @@ log_format json_combined escape=json server { listen 5000; + root /usr/share/nginx/html; + index index.html; include /etc/nginx/mime.types; types { application/javascript mjs; - application/wasm wasm; } + etag on; + brotli off; + brotli_static on; error_log /dev/stderr warn; access_log /var/log/nginx/access.log json_combined; @@ -40,35 +44,43 @@ server { } # --- UserFront Static Files --- - - # Disable cache for all static files to ensure updates are reflected immediately - location ~* \.(js|css|html|json|mjs|wasm)$ { - root /usr/share/nginx/html; - add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; + + # App shell and Flutter bootstrap files must revalidate on each deployment. + location = /index.html { + add_header Cache-Control "no-cache, max-age=0, must-revalidate"; + try_files /index.html =404; + } + + location ~* ^/(flutter_bootstrap\.js|flutter_service_worker\.js|version\.json|manifest\.json)$ { + add_header Cache-Control "no-cache, max-age=0, must-revalidate"; try_files $uri =404; } - # dart2wasm 엔트리포인트는 module 스크립트(.mjs)로 로드되므로 - # MIME이 정확히 내려가지 않으면 브라우저가 로딩을 차단합니다. - location ~* \.mjs$ { - root /usr/share/nginx/html; - default_type application/javascript; - add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; + # Flutter engine files are SDK-versioned by the build and are safe to keep warm. + location ~* ^/canvaskit/.*\.(js|wasm)$ { + add_header Cache-Control "public, max-age=31536000, immutable"; try_files $uri =404; } - # dart2wasm 바이너리 MIME 명시 - location ~* \.wasm$ { - root /usr/share/nginx/html; - default_type application/wasm; - add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; + # App entrypoints use content hashes after the Docker build optimization step. + location ~* "^/main\.dart\.[0-9a-f]{12}\.(js|mjs|wasm)$" { + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + location ~* \.(png|ico|svg|webp|woff|woff2)$ { + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + # Non-hashed app entrypoints keep browser cache validation without serving stale code after deploy. + location ~* \.(js|css|json|mjs|wasm)$ { + add_header Cache-Control "no-cache, max-age=0, must-revalidate"; try_files $uri =404; } location / { - root /usr/share/nginx/html; - index index.html; - add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; + add_header Cache-Control "no-cache, max-age=0, must-revalidate"; try_files $uri $uri/ /index.html; } } diff --git a/userfront/pubspec.yaml b/userfront/pubspec.yaml index cc71655e..2aa16432 100644 --- a/userfront/pubspec.yaml +++ b/userfront/pubspec.yaml @@ -105,9 +105,3 @@ flutter: # # For details regarding fonts from package dependencies, # see https://flutter.dev/to/font-from-package - fonts: - - family: NotoSansKR - fonts: - - asset: assets/fonts/NotoSansKR-Regular.ttf - - asset: assets/fonts/NotoSansKR-Bold.ttf - weight: 700 diff --git a/userfront/scripts/optimize-web-build.mjs b/userfront/scripts/optimize-web-build.mjs new file mode 100644 index 00000000..7717fba6 --- /dev/null +++ b/userfront/scripts/optimize-web-build.mjs @@ -0,0 +1,81 @@ +import { brotliCompressSync, constants } from 'node:zlib'; +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs'; +import { basename, extname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const buildDir = process.argv[2] ?? join(__dirname, '..', 'build', 'web'); +const bootstrapPath = join(buildDir, 'flutter_bootstrap.js'); +const hashableEntrypoints = ['main.dart.js', 'main.dart.mjs', 'main.dart.wasm']; +const compressibleExtensions = new Set([ + '.css', + '.html', + '.js', + '.json', + '.mjs', + '.svg', + '.toml', + '.wasm', +]); + +if (!existsSync(bootstrapPath)) { + throw new Error(`Missing Flutter bootstrap file: ${bootstrapPath}`); +} + +let bootstrap = readFileSync(bootstrapPath, 'utf8'); + +for (const entrypoint of hashableEntrypoints) { + const sourcePath = join(buildDir, entrypoint); + if (!existsSync(sourcePath)) { + continue; + } + + const content = readFileSync(sourcePath); + const hash = createHash('sha256').update(content).digest('hex').slice(0, 12); + const extension = extname(entrypoint); + const stem = entrypoint.slice(0, -extension.length); + const hashedName = `${stem}.${hash}${extension}`; + const targetPath = join(buildDir, hashedName); + + renameSync(sourcePath, targetPath); + bootstrap = bootstrap.replaceAll(entrypoint, hashedName); +} + +writeFileSync(bootstrapPath, bootstrap); + +for (const filePath of walk(buildDir)) { + if (filePath.endsWith('.br')) { + continue; + } + if (!compressibleExtensions.has(extname(filePath))) { + continue; + } + + const content = readFileSync(filePath); + writeFileSync( + `${filePath}.br`, + brotliCompressSync(content, { + params: { + [constants.BROTLI_PARAM_QUALITY]: 11, + }, + }), + ); +} + +function* walk(directory) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const entryPath = join(directory, entry.name); + if (entry.isDirectory()) { + yield* walk(entryPath); + continue; + } + if (entry.isFile()) { + yield entryPath; + } + } +} + +console.log(`[userfront] optimized ${basename(buildDir)} with hashed entrypoints and brotli assets`); diff --git a/userfront/test/app_theme_default_font_test.dart b/userfront/test/app_theme_default_font_test.dart new file mode 100644 index 00000000..d7534ce7 --- /dev/null +++ b/userfront/test/app_theme_default_font_test.dart @@ -0,0 +1,12 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:userfront/core/theme/app_theme.dart'; + +void main() { + test('themes use the platform default font family', () { + final lightTheme = buildLightTheme(); + final darkTheme = buildDarkTheme(); + + expect(lightTheme.textTheme.bodyMedium?.fontFamily, isNot('NotoSansKR')); + expect(darkTheme.textTheme.bodyMedium?.fontFamily, isNot('NotoSansKR')); + }); +} diff --git a/userfront/web/index.html b/userfront/web/index.html index 288bc054..aa787a0a 100644 --- a/userfront/web/index.html +++ b/userfront/web/index.html @@ -31,10 +31,6 @@ Baron 로그인 -