From 45fb365c3b400fa332ba6168966ea19758906954 Mon Sep 17 00:00:00 2001 From: CoBool Date: Sat, 5 Sep 2026 21:37:47 +0900 Subject: [PATCH 1/5] docs: sync README with current architecture --- README.md | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index fd8b287..013dae5 100644 --- a/README.md +++ b/README.md @@ -20,13 +20,14 @@ Posts live in `content/posts/.md`, where the filename is the URL. The dev | Command | Description | | --- | --- | | `pnpm dev` | Development server | -| `pnpm build` | Static export to `out/`, then `postbuild` builds the search index | +| `pnpm build` | Generate a deployment version, run the static export to `out/`, write versioned PWA artifacts, then build and verify the Pagefind index in `postbuild` | | `pnpm test` | Vitest | +| `pnpm test:e2e` | Playwright browser smoke tests against the production export | | `pnpm typecheck` | `tsc --noEmit` | | `pnpm lint` | Biome check | | `pnpm format` | Biome autofix | -All four gates — lint, typecheck, test, build — run on every pull request in [CI](.github/workflows/ci.yml). +CI runs lint, typecheck, unit tests, the production build, and Playwright browser smoke tests on every pull request. Failure traces from the browser tests are preserved as workflow artifacts. ## Layout @@ -35,10 +36,10 @@ content/posts/ Posts (Markdown) src/ app/ Routes. The (site) group wraps the shared layout lib/markdown/ Markdown pipeline — host independent - lib/ App layer: file IO, caching, RSS, SEO - features/ post-toc · post-diagram · search · theme + lib/ Application logic: post IO, collections, deployment paths, RSS, SEO + features/ post-toc · post-diagram · pwa · search · theme components/ UI. ui/ is shadcn, typography.tsx holds the type primitives - config/ site · navigation · integrations + config/ site · navigation · integrations · deployment validation deploy/nginx.conf nginx configuration for deployment ``` @@ -48,7 +49,11 @@ deploy/nginx.conf nginx configuration for deployment MDX is absent for the same reason. The moment a post contains JSX, it cannot be rendered without React. -**Everything is a static export.** `output: "export"` means there is no server, and that constraint decides several things: CSP nonces are impossible (see [DEPLOY.md](DEPLOY.md)), the RSS feed is a `force-static` route, and the search index runs in `postbuild` because it has to read the built HTML. +**Everything is a static export.** `output: "export"` means there is no application server. That constraint decides several things: CSP nonces are impossible (see [DEPLOY.md](DEPLOY.md)), the RSS feed is a `force-static` route, and the search index runs after the HTML export because Pagefind has to read the built pages. + +**The build creates one deployment version for the page bundle, update endpoint and service worker.** `scripts/build.mjs` generates the version, exposes it to the client bundle as `NEXT_PUBLIC_BUILD_VERSION`, writes `out/build-version.json`, and replaces the service worker version marker before `postbuild` creates the search index. The variable is internal build metadata and is not a user configuration value. + +**PWA support is part of the production site.** The app ships a web manifest and registers a service worker only in production. The worker provides offline fallback and controlled caching for navigation, immutable Next assets, fonts, KaTeX, Pagefind and RSC payloads. Open tabs periodically compare their embedded deployment version with `build-version.json`; when a new deployment is detected, the UI offers a refresh instead of silently serving a mixed old/new asset set. **Diagrams render on the client, and only when needed.** Rendering Mermaid on the server would require a headless browser. Instead the module loads only on posts that contain a diagram, and only once that diagram scrolls into view via `IntersectionObserver`. @@ -56,14 +61,17 @@ MDX is absent for the same reason. The moment a post contains JSX, it cannot be ## Configuration -Every value is optional and is baked into the bundle at build time. Copy `.env.example` to `.env.local`. +Configuration values are baked into the bundle at build time. Copy `.env.example` to `.env.local`. | Variable | Purpose | | --- | --- | -| `NEXT_PUBLIC_SITE_URL` | Base for absolute URLs in canonical tags, Open Graph, sitemap and RSS | -| `NEXT_PUBLIC_GISCUS_*` | Comments. Set all five values or leave all five empty | -| `NEXT_PUBLIC_GA_MEASUREMENT_ID` | Google Analytics 4 | -| `NEXT_PUBLIC_KAKAO_JS_KEY` | KakaoTalk share button. JavaScript key from a Kakao Developers app | +| `NEXT_PUBLIC_SITE_URL` | Required for production. Base for absolute URLs in canonical tags, Open Graph, sitemap and RSS | +| `NEXT_PUBLIC_BASE_PATH` | Root deployment contract. Leave empty (or `/`); subpath deployments are rejected | +| `NEXT_PUBLIC_GISCUS_*` | Optional comments. Set all five values or leave all five empty | +| `NEXT_PUBLIC_GA_MEASUREMENT_ID` | Optional Google Analytics 4 | +| `NEXT_PUBLIC_KAKAO_JS_KEY` | Optional KakaoTalk share button. JavaScript key from a Kakao Developers app | + +`NEXT_PUBLIC_BUILD_VERSION` is intentionally not listed as configuration: `pnpm build` generates it internally for PWA update coordination. ## Documentation @@ -71,7 +79,8 @@ Every value is optional and is baked into the bundle at build time. Copy `.env.e | --- | --- | | [DESIGN.md](DESIGN.md) | Color, typography, spacing and component rules | | [CONTENT.md](CONTENT.md) | Frontmatter schema and authoring rules | -| [DEPLOY.md](DEPLOY.md) | Container deployment, cache policy, security headers | +| [DEPLOY.md](DEPLOY.md) | GitHub Pages and container deployment, PWA/cache policy, security headers and recovery | +| [SECURITY.md](SECURITY.md) | Dependency review status and deployment-specific security assessment | ## Third-party assets From e4288c75b82fbd85f88599a7da8cc267bc6f79c5 Mon Sep 17 00:00:00 2001 From: CoBool Date: Sat, 5 Sep 2026 21:38:08 +0900 Subject: [PATCH 2/5] docs: sync Korean README with current architecture --- README.ko.md | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/README.ko.md b/README.ko.md index 6ed8c9d..e221dd5 100644 --- a/README.ko.md +++ b/README.ko.md @@ -20,13 +20,14 @@ Node 22 이상, pnpm 은 `package.json` 의 `packageManager` 필드에서 버전 | 명령 | 설명 | | --- | --- | | `pnpm dev` | 개발 서버 | -| `pnpm build` | 정적 export → `out/`. 이어서 `postbuild` 가 검색 색인을 만듭니다 | +| `pnpm build` | 배포 버전 생성 → 정적 export → PWA 산출물 버전 주입 → `postbuild` 에서 Pagefind 색인 생성·검증 | | `pnpm test` | Vitest | +| `pnpm test:e2e` | production 산출물을 대상으로 하는 Playwright 브라우저 스모크 테스트 | | `pnpm typecheck` | `tsc --noEmit` | | `pnpm lint` | Biome 검사 | | `pnpm format` | Biome 자동 수정 | -네 가지(lint·typecheck·test·build)는 [CI](.github/workflows/ci.yml) 에서 모든 PR 에 대해 돌아갑니다. +CI에서는 모든 PR에 대해 lint, typecheck, 단위 테스트, production build, Playwright 브라우저 스모크 테스트를 실행합니다. 브라우저 테스트 실패 시 trace를 workflow artifact로 보관합니다. ## 구조 @@ -35,10 +36,10 @@ content/posts/ 글 (Markdown) src/ app/ 라우트. (site) 그룹이 공통 레이아웃을 감쌉니다 lib/markdown/ Markdown 파이프라인 — 호스트 비의존 - lib/ 파일 IO·캐시·RSS·SEO 등 앱 계층 - features/ post-toc · post-diagram · search · theme + lib/ 글 IO·컬렉션·배포 경로·RSS·SEO 등 애플리케이션 로직 + features/ post-toc · post-diagram · pwa · search · theme components/ UI. ui/ 는 shadcn, typography.tsx 는 타이포 프리미티브 - config/ site · navigation · integrations + config/ site · navigation · integrations · deployment 검증 deploy/nginx.conf 배포용 nginx 설정 ``` @@ -48,7 +49,11 @@ deploy/nginx.conf 배포용 nginx 설정 MDX 를 쓰지 않는 이유도 같습니다. 글이 JSX 를 품는 순간 React 없이는 렌더할 수 없어집니다. -**전체 정적 export 입니다.** `output: "export"` 라 서버가 없습니다. 이 제약이 몇 가지를 결정합니다 — CSP nonce 를 쓸 수 없고([DEPLOY.md](DEPLOY.md) 참고), RSS 는 `force-static` 라우트로 만들며, 검색 색인은 빌드된 HTML 을 읽어야 해서 `postbuild` 단계에 있습니다. +**전체 정적 export 입니다.** `output: "export"` 라 애플리케이션 서버가 없습니다. 이 제약이 몇 가지를 결정합니다 — CSP nonce 를 쓸 수 없고([DEPLOY.md](DEPLOY.md) 참고), RSS 는 `force-static` 라우트로 만들며, 검색 색인은 빌드된 HTML 을 읽어야 해서 export 이후에 생성합니다. + +**빌드는 페이지 번들·업데이트 엔드포인트·서비스 워커가 공유하는 하나의 배포 버전을 만듭니다.** `scripts/build.mjs` 가 버전을 생성해 클라이언트 번들에 `NEXT_PUBLIC_BUILD_VERSION` 으로 주입하고, `out/build-version.json` 을 만든 뒤 서비스 워커의 버전 마커를 치환합니다. 이 값은 사용자 설정이 아니라 내부 빌드 메타데이터입니다. + +**PWA 는 production 사이트의 일부입니다.** 웹 앱 매니페스트를 제공하고 production 에서만 서비스 워커를 등록합니다. 서비스 워커는 내비게이션, immutable Next 에셋, 폰트, KaTeX, Pagefind, RSC 페이로드를 대상으로 오프라인 복구와 제한된 캐시를 제공합니다. 열린 탭은 자기 번들에 박힌 배포 버전과 `build-version.json` 을 주기적으로 비교하고, 새 배포를 감지하면 오래된 자산과 새 자산을 섞어 쓰는 대신 새로고침 안내를 표시합니다. **다이어그램은 클라이언트에서, 필요할 때만 그립니다.** Mermaid 를 서버에서 렌더하려면 헤드리스 브라우저가 필요합니다. 대신 다이어그램이 있는 글에서만, 그것도 화면에 들어올 때 `IntersectionObserver` 로 모듈을 불러옵니다. @@ -56,14 +61,17 @@ MDX 를 쓰지 않는 이유도 같습니다. 글이 JSX 를 품는 순간 React ## 설정 -모든 값은 선택이며 빌드 시점에 번들에 박힙니다. `.env.example` 을 `.env.local` 로 복사해 쓰세요. +설정값은 빌드 시점에 번들에 박힙니다. `.env.example` 을 `.env.local` 로 복사해 쓰세요. | 변수 | 용도 | | --- | --- | -| `NEXT_PUBLIC_SITE_URL` | canonical·Open Graph·sitemap·RSS 의 절대 URL 기준 | -| `NEXT_PUBLIC_GISCUS_*` | 댓글. 다섯 값을 전부 채우거나 전부 비워야 합니다 | -| `NEXT_PUBLIC_GA_MEASUREMENT_ID` | Google Analytics 4 | -| `NEXT_PUBLIC_KAKAO_JS_KEY` | Kakao 공유 버튼(카카오톡). Kakao Developers 앱의 JavaScript 키 | +| `NEXT_PUBLIC_SITE_URL` | production 에서 필수. canonical·Open Graph·sitemap·RSS 의 절대 URL 기준 | +| `NEXT_PUBLIC_BASE_PATH` | 루트 배포 계약. 비우거나 `/`만 허용하며 하위 경로 배포는 거부 | +| `NEXT_PUBLIC_GISCUS_*` | 선택 댓글. 다섯 값을 전부 채우거나 전부 비워야 함 | +| `NEXT_PUBLIC_GA_MEASUREMENT_ID` | 선택 Google Analytics 4 | +| `NEXT_PUBLIC_KAKAO_JS_KEY` | 선택 Kakao 공유 버튼(카카오톡). Kakao Developers 앱의 JavaScript 키 | + +`NEXT_PUBLIC_BUILD_VERSION` 은 설정값으로 취급하지 않습니다. `pnpm build` 가 PWA 업데이트 조정을 위해 내부적으로 생성합니다. ## 문서 @@ -71,7 +79,8 @@ MDX 를 쓰지 않는 이유도 같습니다. 글이 JSX 를 품는 순간 React | --- | --- | | [DESIGN.md](DESIGN.md) | 색상·타이포·간격·컴포넌트 규칙 | | [CONTENT.md](CONTENT.md) | 프런트매터 스키마와 글 작성 규칙 | -| [DEPLOY.md](DEPLOY.md) | 컨테이너 배포, 캐시 정책, 보안 헤더 | +| [DEPLOY.md](DEPLOY.md) | GitHub Pages·컨테이너 배포, PWA/캐시 정책, 보안 헤더, 복구 절차 | +| [SECURITY.md](SECURITY.md) | 의존성 검토 상태와 배포 구조 기준 보안 평가 | ## 서드파티 자산 From f29d5dd94f9a132a6f652d1e93fa8a2ef24c6b22 Mon Sep 17 00:00:00 2001 From: CoBool Date: Sat, 5 Sep 2026 21:38:17 +0900 Subject: [PATCH 3/5] docs: clarify generated build version env --- .env.example | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 8c13a87..9c48475 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,7 @@ # 이 파일을 .env.local 로 복사한 뒤 필요한 값만 채우세요. -# 정적 export 사이트이므로 모든 값은 빌드 시점에 번들에 박힙니다. 값을 바꾸면 다시 빌드해야 합니다. +# 정적 export 사이트이므로 설정값은 빌드 시점에 번들에 박힙니다. 값을 바꾸면 다시 빌드해야 합니다. +# NEXT_PUBLIC_BUILD_VERSION은 pnpm build가 내부적으로 생성하는 배포 식별자입니다. +# 사용자 설정값이 아니므로 .env.local에 직접 넣지 마세요. # ───────────────────────────────────────────────────────────── # 사이트 From 27293a5de026490793b70a43a71fecf5001c5bb3 Mon Sep 17 00:00:00 2001 From: CoBool Date: Sat, 5 Sep 2026 21:39:03 +0900 Subject: [PATCH 4/5] docs: document PWA cache and deployment version lifecycle --- DEPLOY.md | 118 +++++++++++------------------------------------------- 1 file changed, 23 insertions(+), 95 deletions(-) diff --git a/DEPLOY.md b/DEPLOY.md index a0a00ac..5723d11 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -37,6 +37,26 @@ docker run -d -p 8080:8080 true-log secret 마운트는 빌드 중에만 존재하고 이미지 레이어에도 빌드 캐시에도 남지 않습니다. 프로덕션 빌드에는 `NEXT_PUBLIC_SITE_URL`이 필수이며 미설정 시 실패합니다. 개발 서버에서만 localhost 기본값을 사용합니다. +`NEXT_PUBLIC_BUILD_VERSION`은 사용자 설정값이 아닙니다. `pnpm build`가 실행될 때 `scripts/build.mjs`가 새 배포 식별자를 생성해 클라이언트 번들에 주입하고, 같은 값을 `out/build-version.json`과 `out/sw.js`에 반영합니다. 세 산출물이 같은 배포 버전을 공유해야 열린 탭의 업데이트 감지와 서비스 워커 캐시 교체가 안전하게 동작합니다. + +## PWA와 배포 버전 + +PWA는 production에서만 활성화됩니다. 웹 앱 매니페스트는 Next metadata route로 정적 생성하고, 클라이언트의 `PwaRegister`가 페이지 로드 뒤 `/sw.js`를 등록합니다. 개발 서버에서는 서비스 워커를 등록하지 않아 개발 중 캐시가 코드 변경을 가리는 상황을 피합니다. + +서비스 워커는 모든 요청을 가로채지 않습니다. 같은 origin·scope의 GET 요청 중 다음 범위만 관리합니다. + +- `/_next/static/`: 내용 해시가 있는 immutable 자산. Cache First. +- 페이지 내비게이션: 네트워크 재검증 우선, 실패 시 캐시 또는 `offline.html`. +- `/fonts/`, `/icons/`, `/katex/`, `/pagefind/`: 네트워크 재검증 후 캐시. +- RSC 정적 export 페이로드(`.txt`): 네트워크 재검증 후 캐시. +- `/build-version.json`, `/sw.js`: 서비스 워커 캐시에서 제외. 항상 origin의 현재 배포를 확인. + +서비스 워커 캐시는 배포 버전과 scope를 이름에 포함합니다. 새 worker가 활성화되면 같은 scope의 이전 True Log 캐시만 제거하고 다른 앱의 Cache Storage는 건드리지 않습니다. 캐시 항목은 offline fallback을 제외하고 최대 128개로 제한하며, quota나 Cache Storage 쓰기 실패는 정상 네트워크 응답을 실패로 바꾸지 않습니다. + +열린 탭은 자기 JS 번들에 들어 있는 `NEXT_PUBLIC_BUILD_VERSION`과 `/build-version.json`의 값을 비교합니다. production에서만 확인하며, 기본적으로 5분마다 실행하고 탭이 다시 보이거나 네트워크가 복구될 때도 재확인합니다. 다른 버전을 발견하면 서비스 워커 업데이트를 요청하고 "새로운 글 또는 업데이트" 새로고침 안내를 한 번 표시합니다. + +이 구조의 목적은 PWA 자체보다 **배포 원자성**입니다. 정적 호스팅에서는 열린 탭이 이전 JS를 가진 채 새 HTML·RSC·검색 색인을 요청할 수 있으므로, 배포 버전을 명시적으로 비교해 오래된 탭에 갱신 시점을 알려줍니다. + ## 캐시 정책 파일명이 만들어지는 방식에 따라 갈립니다. @@ -128,102 +148,10 @@ CSP 안에서도 `default-src 'self'`에 이미 덮이는 `connect-src`·`manife 색인 범위는 `data-pagefind-body`가 붙은 글 본문으로 한정했습니다. 붙이지 않으면 사이드바·내비게이션 텍스트까지 색인되어 모든 페이지가 아무 검색어에나 걸립니다(실제로 "글"이 14페이지에 매칭됐습니다). 목차·이전다음글은 `data-pagefind-ignore`로 뺐습니다. -Pagefind는 한국어 형태소 분석을 하지 않는다고 경고하지만, 접두 매칭이 조사를 흡수해서 실사용에는 무리가 없습니다 — "목차"가 "목차를", "공간"이 "공간이", "레이아웃"이 "레이아웃에서"에 모두 매칭되는 것을 확인했습니다. - -검색 UI는 Pagefind 기본 UI 대신 JS API만 쓰고 결과는 자체 컴포넌트로 그립니다. 기본 UI 번들(`pagefind-ui.*`, `pagefind-modular-ui.*`)은 참조하지 않으므로 전송되지 않습니다. - -## 서드파티를 켤 때 - -`.env`로 giscus나 GA4를 활성화하면 CSP에 출처를 추가해야 합니다. 기본 CSP는 둘 다 막습니다. - -```nginx -# giscus -script-src ... https://giscus.app -frame-src https://giscus.app - -# GA4 -script-src ... https://www.googletagmanager.com -connect-src 'self' https://*.google-analytics.com https://*.analytics.google.com -``` - -## 알아둘 nginx 동작 - -**`add_header`는 상속이 아니라 대체입니다.** 공식 문서: *"These directives are inherited from the previous configuration level if and only if there are no `add_header` directives defined on the current level."* `location` 안에서 `add_header`를 하나라도 쓰면 `server`의 헤더가 전부 사라집니다. 그래서 캐시 값은 `map`으로 계산하고 `add_header`는 `server`에서 한 번만 호출합니다. - -**`types` 블록도 같습니다.** `server`에 `types { }`를 넣으면 `include mime.types`로 만든 전체 맵을 대체해 모든 응답이 `application/octet-stream`이 됩니다. `manifest.webmanifest`만 `location`으로 범위를 좁혀 지정하는 이유입니다. (nginx의 `mime.types`에는 `webmanifest` 항목이 없어서, 지정하지 않으면 `octet-stream`으로 나가고 `nosniff`와 겹쳐 브라우저가 매니페스트를 거부할 수 있습니다.) - -**`absolute_redirect off`** — 없으면 리다이렉트 `Location`에 내부 포트 8080이 그대로 들어가, 앞단 리버스 프록시 뒤에서 접속 불가능한 주소가 나갑니다. - -**`listen [::]:8080`을 쓰지 않습니다** — IPv6가 꺼진 호스트·컨테이너에서 nginx 기동 자체가 실패합니다(`Address family not supported`). 바깥 IPv6는 도커의 포트 게시나 앞단 프록시가 처리합니다. - -**라우팅은 공식 예시와 같습니다.** [static-exports 문서](https://nextjs.org/docs/app/guides/static-exports#deploying)의 `error_page 404 /404.html` + `location = /404.html { internal; }`을 그대로 씁니다. `try_files`에서 `$uri.html`과 `/blog/` rewrite를 뺀 것은 문서가 "`trailingSlash: true`면 생략 가능"이라 명시했기 때문입니다. - -## 검증 - -nginx를 띄우고 실제 산출물로 확인한 항목입니다. - -``` -Content-Type html·txt·xml·png·svg·js 정상, webmanifest → application/manifest+json -캐시 정책 html/txt/xml/webmanifest → no-cache, _next/static → immutable, 나머지 → 1일 -재검증 조건부 요청 → 304 / 0B -라우팅 /posts → 301 /posts/ (상대 경로), /404.html 직접요청 → 404, /healthz → 200 -브라우저 CSP 위반 0건, 테마 스크립트 정상, KaTeX·Mermaid 렌더, 하이드레이션 정상 -``` - -## PWA 배포 버전과 캐시 - -`pnpm build`는 `scripts/build.mjs`를 통해 페이지 번들, `build-version.json`, -`sw.js`에 같은 배포 버전을 넣고, 이어서 Pagefind 색인을 생성합니다. -`next build`만 직접 실행하면 이 단계가 빠지므로 배포에는 반드시 `pnpm build`를 사용합니다. - -서비스 워커는 해시가 붙은 `_next/static/`만 Cache First로 제공합니다. -고정 URL의 폰트·아이콘·KaTeX·검색 파일과 페이지는 네트워크를 재검증하고, -오프라인일 때 해당 앱의 캐시로 복구합니다. 캐시는 최대 128개 항목이며, -오프라인 안내 페이지를 보존합니다. 방문한 모든 페이지의 영구 보관을 보장하지 않습니다. -새 워커는 자신의 scope에 속하는 이전 버전 캐시만 정리합니다. - -열린 탭은 자신의 빌드 버전과 `/build-version.json`을 비교합니다. load 이후, -온라인 복귀, 탭 재활성화 및 5분 주기로 확인하며 숨겨진 탭에서는 건너뜁니다. -조회 실패 시 다음 기회에 재시도합니다. 배포 버전 조회는 오프라인 캐시를 사용하지 않습니다. -개발 서버에서는 워커를 등록하지 않습니다. 이전에 등록한 개발용 워커가 있다면 -브라우저 개발자 도구에서 한 번 해제해야 합니다. - -이전 버전의 페이지를 이미 열어 둔 방문자는 새 알림 코드가 없으므로 최초 전환 때는 -한 번 페이지를 다시 열어야 합니다. 이후 배포부터 열린 탭의 버전 비교가 동작합니다. - +Pagefind는 한국어 형태소 분석을 하지 않는다고 경고하지만, 접두 매칭이 조사를 흡수해서 실사용에는 무리가 없습니다. 검색 품질은 브라우저 스모크 테스트에서 실제 production 색인을 대상으로 확인합니다. ## 배포 검증과 복구 -PR CI는 production export를 빌드한 후 Chromium에서 실제 Pagefind 검색과 글 이동, -방문한 페이지의 오프라인 복구, 미방문 URL의 오프라인 안내, 배포 버전 변경 알림, -HTTP 404를 검증합니다. `pnpm test:e2e`는 **이미 빌드한 out/**를 사용합니다. -단위 테스트는 `pnpm test`, 브라우저 테스트는 `pnpm test:e2e`로 분리합니다. - -```bash -pnpm exec playwright install --with-deps chromium -NEXT_PUBLIC_SITE_URL=https://blog.boolean.kr pnpm build -pnpm test:e2e -``` - -`pnpm build`의 마지막 단계는 버전 JSON과 워커 일치, 루트 canonical·RSS·sitemap, -검색 색인·오프라인·404 산출물을 검사합니다. Pages 배포와 Docker 빌드도 같은 검사를 -거칩니다. GitHub Pages의 응답 헤더는 nginx 설정으로 변경할 수 없습니다. - -배포 후에는 홈·최신 글·검색·RSS를 확인하고, 필요하면 브라우저를 오프라인으로 바꿔 -방문한 글을 다시 여는지 확인합니다. giscus·GA·카카오는 외부 서비스 설정과 네트워크에 -의존하므로 해당 기능을 켠 실제 도메인에서 별도로 확인합니다. - -장애 시 복구: - -1. 직전 정상 배포의 커밋과 실패 변경을 GitHub Actions 실행 기록에서 확인합니다. -2. 문제 변경을 revert하는 PR을 만들고 동일 CI를 통과시켜 main에 반영합니다. - 기존 배포 실행을 재실행하는 방법은 남아 있는 산출물과 당시 워크플로 조건을 확인한 - 경우에만 사용합니다. main의 기록을 강제로 되돌리지 않습니다. -3. 새 배포가 완료되면 홈·검색·버전 JSON을 확인합니다. 열린 탭에는 새로고침 안내가 - 표시됩니다. 사용자 저장소 전체 삭제를 일반 복구 절차로 요구하지 않습니다. -4. nginx는 직전 정상 이미지의 digest/tag를 보관하고, 그 이미지로 컨테이너를 교체합니다. - 문서·환경변수 변경도 이미지 빌드 시점 기준으로 함께 추적합니다. +CI는 lint·typecheck·unit test·production build 이후 Chromium에서 production 산출물을 직접 검증합니다. 검색→글 이동, 오프라인 fallback/복구, 배포 버전 변경 알림, 404 응답을 확인하며 실패 trace는 artifact로 남깁니다. -예약 배포가 며칠 동안 실행되지 않거나 실패한 경우 Actions의 예약 실행 상태와 로그를 -확인합니다. 예약 글 공개는 성공한 재빌드 시점에 이루어지므로 정확한 분 단위 발행은 -보장하지 않습니다. +배포 후 이상이 있으면 문제가 들어간 커밋을 직접 되돌려 `main`을 강제로 이동시키지 말고 revert PR을 만듭니다. GitHub Pages는 revert가 머지되면 새 정적 산출물을 다시 배포합니다. nginx 컨테이너는 이전에 검증된 이미지 태그로 되돌린 뒤 원인 수정 PR을 진행합니다. From 40aaf10a2de6fe02073e263e9c2f113a59532bd4 Mon Sep 17 00:00:00 2001 From: CoBool Date: Sat, 5 Sep 2026 21:40:10 +0900 Subject: [PATCH 5/5] docs: preserve deployment guide and clarify PWA lifecycle --- DEPLOY.md | 116 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 95 insertions(+), 21 deletions(-) diff --git a/DEPLOY.md b/DEPLOY.md index 5723d11..e3a1d39 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -39,24 +39,6 @@ secret 마운트는 빌드 중에만 존재하고 이미지 레이어에도 빌 `NEXT_PUBLIC_BUILD_VERSION`은 사용자 설정값이 아닙니다. `pnpm build`가 실행될 때 `scripts/build.mjs`가 새 배포 식별자를 생성해 클라이언트 번들에 주입하고, 같은 값을 `out/build-version.json`과 `out/sw.js`에 반영합니다. 세 산출물이 같은 배포 버전을 공유해야 열린 탭의 업데이트 감지와 서비스 워커 캐시 교체가 안전하게 동작합니다. -## PWA와 배포 버전 - -PWA는 production에서만 활성화됩니다. 웹 앱 매니페스트는 Next metadata route로 정적 생성하고, 클라이언트의 `PwaRegister`가 페이지 로드 뒤 `/sw.js`를 등록합니다. 개발 서버에서는 서비스 워커를 등록하지 않아 개발 중 캐시가 코드 변경을 가리는 상황을 피합니다. - -서비스 워커는 모든 요청을 가로채지 않습니다. 같은 origin·scope의 GET 요청 중 다음 범위만 관리합니다. - -- `/_next/static/`: 내용 해시가 있는 immutable 자산. Cache First. -- 페이지 내비게이션: 네트워크 재검증 우선, 실패 시 캐시 또는 `offline.html`. -- `/fonts/`, `/icons/`, `/katex/`, `/pagefind/`: 네트워크 재검증 후 캐시. -- RSC 정적 export 페이로드(`.txt`): 네트워크 재검증 후 캐시. -- `/build-version.json`, `/sw.js`: 서비스 워커 캐시에서 제외. 항상 origin의 현재 배포를 확인. - -서비스 워커 캐시는 배포 버전과 scope를 이름에 포함합니다. 새 worker가 활성화되면 같은 scope의 이전 True Log 캐시만 제거하고 다른 앱의 Cache Storage는 건드리지 않습니다. 캐시 항목은 offline fallback을 제외하고 최대 128개로 제한하며, quota나 Cache Storage 쓰기 실패는 정상 네트워크 응답을 실패로 바꾸지 않습니다. - -열린 탭은 자기 JS 번들에 들어 있는 `NEXT_PUBLIC_BUILD_VERSION`과 `/build-version.json`의 값을 비교합니다. production에서만 확인하며, 기본적으로 5분마다 실행하고 탭이 다시 보이거나 네트워크가 복구될 때도 재확인합니다. 다른 버전을 발견하면 서비스 워커 업데이트를 요청하고 "새로운 글 또는 업데이트" 새로고침 안내를 한 번 표시합니다. - -이 구조의 목적은 PWA 자체보다 **배포 원자성**입니다. 정적 호스팅에서는 열린 탭이 이전 JS를 가진 채 새 HTML·RSC·검색 색인을 요청할 수 있으므로, 배포 버전을 명시적으로 비교해 오래된 탭에 갱신 시점을 알려줍니다. - ## 캐시 정책 파일명이 만들어지는 방식에 따라 갈립니다. @@ -148,10 +130,102 @@ CSP 안에서도 `default-src 'self'`에 이미 덮이는 `connect-src`·`manife 색인 범위는 `data-pagefind-body`가 붙은 글 본문으로 한정했습니다. 붙이지 않으면 사이드바·내비게이션 텍스트까지 색인되어 모든 페이지가 아무 검색어에나 걸립니다(실제로 "글"이 14페이지에 매칭됐습니다). 목차·이전다음글은 `data-pagefind-ignore`로 뺐습니다. -Pagefind는 한국어 형태소 분석을 하지 않는다고 경고하지만, 접두 매칭이 조사를 흡수해서 실사용에는 무리가 없습니다. 검색 품질은 브라우저 스모크 테스트에서 실제 production 색인을 대상으로 확인합니다. +Pagefind는 한국어 형태소 분석을 하지 않는다고 경고하지만, 접두 매칭이 조사를 흡수해서 실사용에는 무리가 없습니다 — "목차"가 "목차를", "공간"이 "공간이", "레이아웃"이 "레이아웃에서"에 모두 매칭되는 것을 확인했습니다. + +검색 UI는 Pagefind 기본 UI 대신 JS API만 쓰고 결과는 자체 컴포넌트로 그립니다. 기본 UI 번들(`pagefind-ui.*`, `pagefind-modular-ui.*`)은 참조하지 않으므로 전송되지 않습니다. + +## 서드파티를 켤 때 + +`.env`로 giscus나 GA4를 활성화하면 CSP에 출처를 추가해야 합니다. 기본 CSP는 둘 다 막습니다. + +```nginx +# giscus +script-src ... https://giscus.app +frame-src https://giscus.app + +# GA4 +script-src ... https://www.googletagmanager.com +connect-src 'self' https://*.google-analytics.com https://*.analytics.google.com +``` + +## 알아둘 nginx 동작 + +**`add_header`는 상속이 아니라 대체입니다.** 공식 문서: *"These directives are inherited from the previous configuration level if and only if there are no `add_header` directives defined on the current level."* `location` 안에서 `add_header`를 하나라도 쓰면 `server`의 헤더가 전부 사라집니다. 그래서 캐시 값은 `map`으로 계산하고 `add_header`는 `server`에서 한 번만 호출합니다. + +**`types` 블록도 같습니다.** `server`에 `types { }`를 넣으면 `include mime.types`로 만든 전체 맵을 대체해 모든 응답이 `application/octet-stream`이 됩니다. `manifest.webmanifest`만 `location`으로 범위를 좁혀 지정하는 이유입니다. (nginx의 `mime.types`에는 `webmanifest` 항목이 없어서, 지정하지 않으면 `octet-stream`으로 나가고 `nosniff`와 겹쳐 브라우저가 매니페스트를 거부할 수 있습니다.) + +**`absolute_redirect off`** — 없으면 리다이렉트 `Location`에 내부 포트 8080이 그대로 들어가, 앞단 리버스 프록시 뒤에서 접속 불가능한 주소가 나갑니다. + +**`listen [::]:8080`을 쓰지 않습니다** — IPv6가 꺼진 호스트·컨테이너에서 nginx 기동 자체가 실패합니다(`Address family not supported`). 바깥 IPv6는 도커의 포트 게시나 앞단 프록시가 처리합니다. + +**라우팅은 공식 예시와 같습니다.** [static-exports 문서](https://nextjs.org/docs/app/guides/static-exports#deploying)의 `error_page 404 /404.html` + `location = /404.html { internal; }`을 그대로 씁니다. `try_files`에서 `$uri.html`과 `/blog/` rewrite를 뺀 것은 문서가 "`trailingSlash: true`면 생략 가능"이라 명시했기 때문입니다. + +## 검증 + +nginx를 띄우고 실제 산출물로 확인한 항목입니다. + +``` +Content-Type html·txt·xml·png·svg·js 정상, webmanifest → application/manifest+json +캐시 정책 html/txt/xml/webmanifest → no-cache, _next/static → immutable, 나머지 → 1일 +재검증 조건부 요청 → 304 / 0B +라우팅 /posts → 301 /posts/ (상대 경로), /404.html 직접요청 → 404, /healthz → 200 +브라우저 CSP 위반 0건, 테마 스크립트 정상, KaTeX·Mermaid 렌더, 하이드레이션 정상 +``` + +## PWA 배포 버전과 캐시 + +`pnpm build`는 `scripts/build.mjs`를 통해 페이지 번들, `build-version.json`, +`sw.js`에 같은 배포 버전을 넣고, 이어서 Pagefind 색인을 생성합니다. +`next build`만 직접 실행하면 이 단계가 빠지므로 배포에는 반드시 `pnpm build`를 사용합니다. + +서비스 워커는 해시가 붙은 `_next/static/`만 Cache First로 제공합니다. +고정 URL의 폰트·아이콘·KaTeX·검색 파일과 페이지는 네트워크를 재검증하고, +오프라인일 때 해당 앱의 캐시로 복구합니다. 캐시는 최대 128개 항목이며, +오프라인 안내 페이지를 보존합니다. 방문한 모든 페이지의 영구 보관을 보장하지 않습니다. +새 워커는 자신의 scope에 속하는 이전 버전 캐시만 정리합니다. +`build-version.json`과 `sw.js`는 서비스 워커 캐시 대상에서 제외해 항상 현재 배포를 확인합니다. + +열린 탭은 자신의 빌드 버전과 `/build-version.json`을 비교합니다. load 이후, +온라인 복귀, 탭 재활성화 및 5분 주기로 확인하며 숨겨진 탭에서는 건너뜁니다. +조회 실패 시 다음 기회에 재시도합니다. 배포 버전 조회는 오프라인 캐시를 사용하지 않습니다. +개발 서버에서는 워커를 등록하지 않습니다. 이전에 등록한 개발용 워커가 있다면 +브라우저 개발자 도구에서 한 번 해제해야 합니다. + +이전 버전의 페이지를 이미 열어 둔 방문자는 새 알림 코드가 없으므로 최초 전환 때는 +한 번 페이지를 다시 열어야 합니다. 이후 배포부터 열린 탭의 버전 비교가 동작합니다. ## 배포 검증과 복구 -CI는 lint·typecheck·unit test·production build 이후 Chromium에서 production 산출물을 직접 검증합니다. 검색→글 이동, 오프라인 fallback/복구, 배포 버전 변경 알림, 404 응답을 확인하며 실패 trace는 artifact로 남깁니다. +PR CI는 production export를 빌드한 후 Chromium에서 실제 Pagefind 검색과 글 이동, +방문한 페이지의 오프라인 복구, 미방문 URL의 오프라인 안내, 배포 버전 변경 알림, +HTTP 404를 검증합니다. `pnpm test:e2e`는 **이미 빌드한 out/**를 사용합니다. +단위 테스트는 `pnpm test`, 브라우저 테스트는 `pnpm test:e2e`로 분리합니다. + +```bash +pnpm exec playwright install --with-deps chromium +NEXT_PUBLIC_SITE_URL=https://blog.boolean.kr pnpm build +pnpm test:e2e +``` + +`pnpm build`의 마지막 단계는 버전 JSON과 워커 일치, 루트 canonical·RSS·sitemap, +검색 색인·오프라인·404 산출물을 검사합니다. Pages 배포와 Docker 빌드도 같은 검사를 +거칩니다. GitHub Pages의 응답 헤더는 nginx 설정으로 변경할 수 없습니다. + +배포 후에는 홈·최신 글·검색·RSS를 확인하고, 필요하면 브라우저를 오프라인으로 바꿔 +방문한 글을 다시 여는지 확인합니다. giscus·GA·카카오는 외부 서비스 설정과 네트워크에 +의존하므로 해당 기능을 켠 실제 도메인에서 별도로 확인합니다. + +장애 시 복구: + +1. 직전 정상 배포의 커밋과 실패 변경을 GitHub Actions 실행 기록에서 확인합니다. +2. 문제 변경을 revert하는 PR을 만들고 동일 CI를 통과시켜 main에 반영합니다. + 기존 배포 실행을 재실행하는 방법은 남아 있는 산출물과 당시 워크플로 조건을 확인한 + 경우에만 사용합니다. main의 기록을 강제로 되돌리지 않습니다. +3. 새 배포가 완료되면 홈·검색·버전 JSON을 확인합니다. 열린 탭에는 새로고침 안내가 + 표시됩니다. 사용자 저장소 전체 삭제를 일반 복구 절차로 요구하지 않습니다. +4. nginx는 직전 정상 이미지의 digest/tag를 보관하고, 그 이미지로 컨테이너를 교체합니다. + 문서·환경변수 변경도 이미지 빌드 시점 기준으로 함께 추적합니다. -배포 후 이상이 있으면 문제가 들어간 커밋을 직접 되돌려 `main`을 강제로 이동시키지 말고 revert PR을 만듭니다. GitHub Pages는 revert가 머지되면 새 정적 산출물을 다시 배포합니다. nginx 컨테이너는 이전에 검증된 이미지 태그로 되돌린 뒤 원인 수정 PR을 진행합니다. +예약 배포가 며칠 동안 실행되지 않거나 실패한 경우 Actions의 예약 실행 상태와 로그를 +확인합니다. 예약 글 공개는 성공한 재빌드 시점에 이루어지므로 정확한 분 단위 발행은 +보장하지 않습니다. \ No newline at end of file