From 98f678768d769224288d1445f04da02f5ac9f49d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=B0=EC=83=81=EB=B9=88?= Date: Sun, 16 Aug 2026 20:48:03 +0900 Subject: [PATCH] =?UTF-8?q?feat(pwa):=20Service=20Worker=20=EC=98=A4?= =?UTF-8?q?=ED=94=84=EB=9D=BC=EC=9D=B8=20=EC=BA=90=EC=8B=B1,=20=EC=98=A4?= =?UTF-8?q?=ED=94=84=EB=9D=BC=EC=9D=B8=20=ED=99=94=EB=A9=B4=20=EB=B0=8F=20?= =?UTF-8?q?=EC=95=B1=20=EB=A7=A4=EB=8B=88=ED=8E=98=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Web App Manifest에 id, scope, shortcuts(글 목록/카테고리/태그/검색), maskable 아이콘 및 basePath 연동 - 오프라인 상태 시 표시할 독립 HTML/CSS 폴백 화면(public/offline.html) 추가 - 불변 정적 에셋(CacheFirst), 페이지/RSC 페이로드(NetworkFirst) 캐싱 전략의 경량 Service Worker(public/sw.js) 구현 - load 이벤트 이후 Service Worker 지연 등록 및 신규 배포 시 토스트 알림을 제공하는 PwaRegister 컴포넌트 추가 - PWA 매니페스트, 오프라인 화면, 서비스 워커 검증 단위 테스트(tests/pwa.test.ts) 추가 --- deploy/nginx.conf | 2 + public/offline.html | 104 +++++++++++++++++++++++++++++ public/sw.js | 106 ++++++++++++++++++++++++++++++ src/app/layout.tsx | 2 + src/app/manifest.ts | 65 +++++++++++++++++- src/features/pwa/pwa-register.tsx | 55 ++++++++++++++++ tests/pwa.test.ts | 69 +++++++++++++++++++ 7 files changed, 400 insertions(+), 3 deletions(-) create mode 100644 public/offline.html create mode 100644 public/sw.js create mode 100644 src/features/pwa/pwa-register.tsx create mode 100644 tests/pwa.test.ts diff --git a/deploy/nginx.conf b/deploy/nginx.conf index 11b368f..d7e1b6f 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -8,6 +8,8 @@ map $uri $cache_control { # pagefind-entry.json 이 해시된 색인 파일을 가리키는데, 배포하면 옛 색인은 삭제된다. # 캐시되면 사라진 파일을 요청해 검색이 깨지므로 재검증시킨다. ~^/pagefind/ "no-cache"; + # 서비스 워커 및 HTML/RSC/매니페스트는 새 배포가 즉시 감지되어야 하므로 no-cache 로 재검증한다. + ~^/sw\.js$ "no-cache"; ~*\.(?:html|txt|xml|webmanifest)$ "no-cache"; } diff --git a/public/offline.html b/public/offline.html new file mode 100644 index 0000000..931b2cd --- /dev/null +++ b/public/offline.html @@ -0,0 +1,104 @@ + + + + + + 오프라인 상태 - True Log + + + +
+ +

오프라인 상태입니다

+

인터넷 연결이 원활하지 않습니다. 네트워크 연결을 확인한 후 다시 시도해 주세요.

+
+ +
+
+ + diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..f395855 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,106 @@ +const CACHE_VERSION = "true-log-v1" +const CACHE_NAME = `true-log-cache-${CACHE_VERSION}` +const OFFLINE_FALLBACK = "/offline.html" + +// 설치(install): 오프라인 폴백 페이지를 사전 캐싱(pre-cache) +self.addEventListener("install", (event) => { + event.waitUntil( + caches + .open(CACHE_NAME) + .then((cache) => cache.addAll([OFFLINE_FALLBACK])) + .then(() => self.skipWaiting()), + ) +}) + +// 활성화(activate): 구버전 캐시 정리 및 클라이언트 즉시 제어 +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((cacheNames) => + Promise.all( + cacheNames.filter((name) => name !== CACHE_NAME).map((name) => caches.delete(name)), + ), + ) + .then(() => self.clients.claim()), + ) +}) + +// 요청 가로채기(fetch) +self.addEventListener("fetch", (event) => { + const { request } = event + const url = new URL(request.url) + + // 1. GET 요청 및 동일 출처(same-origin)만 처리 (서드파티는 NetworkOnly) + if (request.method !== "GET" || url.origin !== self.location.origin) { + return + } + + // 2. 불변 정적 에셋: CacheFirst + // /_next/static/, /fonts/, /icons/, /katex/ + const isStaticAsset = + url.pathname.startsWith("/_next/static/") || + url.pathname.startsWith("/fonts/") || + url.pathname.startsWith("/icons/") || + url.pathname.startsWith("/katex/") + + if (isStaticAsset) { + event.respondWith( + caches.match(request).then((cachedResponse) => { + if (cachedResponse) { + return cachedResponse + } + return fetch(request).then((networkResponse) => { + if (networkResponse.ok) { + const clone = networkResponse.clone() + caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)) + } + return networkResponse + }) + }), + ) + return + } + + // 3. HTML 페이지 탐색 (Navigation): NetworkFirst + Offline Fallback + if (request.mode === "navigate") { + event.respondWith( + fetch(request) + .then((networkResponse) => { + if (networkResponse.ok) { + const clone = networkResponse.clone() + caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)) + } + return networkResponse + }) + .catch(async () => { + const cachedResponse = await caches.match(request) + if (cachedResponse) { + return cachedResponse + } + const offlinePage = await caches.match(OFFLINE_FALLBACK) + return ( + offlinePage || + new Response("오프라인 상태입니다.", { + status: 503, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }) + ) + }), + ) + return + } + + // 4. RSC 페이로드 (*.txt) 및 기타 정적 페이지 데이터: NetworkFirst + event.respondWith( + fetch(request) + .then((networkResponse) => { + if (networkResponse.ok) { + const clone = networkResponse.clone() + caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)) + } + return networkResponse + }) + .catch(() => caches.match(request)), + ) +}) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 14a3d4e..5e90780 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,6 +5,7 @@ import { AppShell } from "@/components/layout" import { Toaster } from "@/components/ui/sonner" import { getPublicIntegrations } from "@/config/integrations" import { siteConfig, themeColors } from "@/config/site" +import { PwaRegister } from "@/features/pwa/pwa-register" import { ThemeScript } from "@/features/theme/theme-script" import { createPageMetadata } from "@/lib/seo" import "./globals.css" @@ -45,6 +46,7 @@ export default function RootLayout({ children }: RootLayoutProps) { {children} + diff --git a/src/app/manifest.ts b/src/app/manifest.ts index 8f2a2ce..8945f30 100644 --- a/src/app/manifest.ts +++ b/src/app/manifest.ts @@ -3,30 +3,89 @@ import { siteConfig, themeColors } from "@/config/site" export const dynamic = "force-static" +function getBasePath(): string { + // biome-ignore lint/complexity/useLiteralKeys: tsconfig noPropertyAccessFromIndexSignature requires bracket access + const rawBasePath = process.env["NEXT_PUBLIC_BASE_PATH"]?.trim() + + return rawBasePath && rawBasePath !== "/" + ? (rawBasePath.startsWith("/") ? rawBasePath : `/${rawBasePath}`).replace(/\/+$/, "") + : "" +} + // biome-ignore lint/style/noDefaultExport: Next.js metadata files require default exports. export default function manifest(): MetadataRoute.Manifest { + const basePath = getBasePath() + const prefix = (path: string) => `${basePath}${path}` + return { name: siteConfig.name, short_name: siteConfig.name, description: siteConfig.description, - start_url: "/", + id: prefix("/"), + start_url: prefix("/"), + scope: prefix("/"), display: "standalone", + orientation: "portrait", + lang: siteConfig.language, + categories: ["blog", "technology", "development"], // 웹 매니페스트는 라이트/다크를 구분하지 못하므로, viewport 메타 태그와 같은 다크 값을 쓴다. background_color: themeColors.dark, theme_color: themeColors.dark, icons: [ { - src: "/icons/icon-192.png", + src: prefix("/icons/icon-192.png"), sizes: "192x192", type: "image/png", purpose: "any", }, { - src: "/icons/icon-512.png", + src: prefix("/icons/icon-192.png"), + sizes: "192x192", + type: "image/png", + purpose: "maskable", + }, + { + src: prefix("/icons/icon-512.png"), sizes: "512x512", type: "image/png", purpose: "any", }, + { + src: prefix("/icons/icon-512.png"), + sizes: "512x512", + type: "image/png", + purpose: "maskable", + }, + ], + shortcuts: [ + { + name: "전체 글 목록", + short_name: "글 목록", + description: "최신 기술 블로그 포스트 목록을 확인합니다.", + url: prefix("/posts/"), + icons: [{ src: prefix("/icons/icon-192.png"), sizes: "192x192" }], + }, + { + name: "카테고리", + short_name: "카테고리", + description: "주제별 분류 목록을 확인합니다.", + url: prefix("/categories/"), + icons: [{ src: prefix("/icons/icon-192.png"), sizes: "192x192" }], + }, + { + name: "태그 목록", + short_name: "태그", + description: "관심 태그별 글을 탐색합니다.", + url: prefix("/tags/"), + icons: [{ src: prefix("/icons/icon-192.png"), sizes: "192x192" }], + }, + { + name: "검색", + short_name: "검색", + description: "블로그 콘텐츠를 빠르게 검색합니다.", + url: prefix("/search/"), + icons: [{ src: prefix("/icons/icon-192.png"), sizes: "192x192" }], + }, ], } } diff --git a/src/features/pwa/pwa-register.tsx b/src/features/pwa/pwa-register.tsx new file mode 100644 index 0000000..2b4787b --- /dev/null +++ b/src/features/pwa/pwa-register.tsx @@ -0,0 +1,55 @@ +"use client" + +import { useEffect } from "react" +import { toast } from "sonner" + +export function PwaRegister() { + useEffect(() => { + if (typeof window === "undefined" || !("serviceWorker" in navigator)) { + return + } + + // LCP 및 초기 렌더링 성능 저하를 방지하기 위해 load 이벤트 이후 지연 등록 + const handleLoad = async () => { + // biome-ignore lint/complexity/useLiteralKeys: tsconfig noPropertyAccessFromIndexSignature requires bracket access + const basePath = process.env["NEXT_PUBLIC_BASE_PATH"]?.trim() ?? "" + const normalizedBasePath = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath + const swUrl = `${normalizedBasePath}/sw.js` + const scope = `${normalizedBasePath}/` + + try { + const registration = await navigator.serviceWorker.register(swUrl, { scope }) + + // 새 버전의 Service Worker 가 대기(waiting) 중일 때 업데이트 알림 제공 + registration.addEventListener("updatefound", () => { + const newWorker = registration.installing + if (newWorker === null) { + return + } + + newWorker.addEventListener("statechange", () => { + if (newWorker.state === "installed" && navigator.serviceWorker.controller) { + toast("새로운 글 또는 업데이트가 있습니다.", { + action: { + label: "새로고침", + onClick: () => window.location.reload(), + }, + duration: 8000, + }) + } + }) + }) + } catch { + // Service Worker 등록 실패는 조용히 넘어가 정상 웹 렌더링에 영향을 주지 않는다. + } + } + + if (document.readyState === "complete") { + handleLoad() + } else { + window.addEventListener("load", handleLoad, { once: true }) + } + }, []) + + return null +} diff --git a/tests/pwa.test.ts b/tests/pwa.test.ts new file mode 100644 index 0000000..78205ea --- /dev/null +++ b/tests/pwa.test.ts @@ -0,0 +1,69 @@ +import { existsSync, readFileSync } from "node:fs" +import { join } from "node:path" +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import manifest from "../src/app/manifest" + +describe("PWA integration", () => { + const originalEnv = process.env + + beforeEach(() => { + process.env = { ...originalEnv } + }) + + afterEach(() => { + process.env = originalEnv + }) + + describe("manifest.ts", () => { + it("Given default configuration When generating manifest Then includes required PWA fields", () => { + // biome-ignore lint/complexity/useLiteralKeys: tsconfig noPropertyAccessFromIndexSignature requires bracket access + delete process.env["NEXT_PUBLIC_BASE_PATH"] + const data = manifest() + + expect(data.name).toBe("True Log") + expect(data.short_name).toBe("True Log") + expect(data.id).toBe("/") + expect(data.start_url).toBe("/") + expect(data.scope).toBe("/") + expect(data.display).toBe("standalone") + expect(data.icons).toHaveLength(4) + expect(data.shortcuts).toHaveLength(4) + }) + + it("Given NEXT_PUBLIC_BASE_PATH When generating manifest Then prefixes URLs with base path", () => { + // biome-ignore lint/complexity/useLiteralKeys: tsconfig noPropertyAccessFromIndexSignature requires bracket access + process.env["NEXT_PUBLIC_BASE_PATH"] = "/blog" + const data = manifest() + + expect(data.id).toBe("/blog/") + expect(data.start_url).toBe("/blog/") + expect(data.scope).toBe("/blog/") + expect(data.icons?.[0]?.src).toBe("/blog/icons/icon-192.png") + expect(data.shortcuts?.[0]?.url).toBe("/blog/posts/") + }) + }) + + describe("offline.html fallback", () => { + const offlinePath = join(process.cwd(), "public", "offline.html") + + it("Given public directory When checking offline fallback Then file exists and contains offline content", () => { + expect(existsSync(offlinePath)).toBe(true) + const content = readFileSync(offlinePath, "utf8") + expect(content).toContain("오프라인 상태입니다") + expect(content).toContain('') + }) + }) + + describe("sw.js service worker", () => { + const swPath = join(process.cwd(), "public", "sw.js") + + it("Given public directory When checking service worker Then file exists and defines cache strategy", () => { + expect(existsSync(swPath)).toBe(true) + const content = readFileSync(swPath, "utf8") + expect(content).toContain("true-log-cache-") + expect(content).toContain("/offline.html") + expect(content).toContain('addEventListener("install"') + expect(content).toContain('addEventListener("fetch"') + }) + }) +})