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}
+