diff --git a/docs/deploy.md b/docs/deploy.md index 1aec36f..c20b269 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -1,6 +1,6 @@ # 배포 가이드 -> 로컬 기준 v1.10.10에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다. +> 로컬 기준 v1.10.11에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다. ## 빌드 유형 @@ -423,6 +423,7 @@ Homepage 위젯 연동은 `docs/homepage-widget.md`를 참고한다. Docker에 - `public/uploads/`는 Git에 포함하지 않는다. - NAS 운영에서는 `docker-compose.yml`의 `./public/uploads:/app/public/uploads` 볼륨으로 업로드 파일을 유지한다. - 운영 빌드에서는 `/uploads/**` 서버 라우트가 `.output/public`이 아니라 `/app/public/uploads` 볼륨의 실제 파일을 직접 제공한다. +- `/uploads/**` 응답은 `ETag`와 `Last-Modified`를 제공한다. 고유 URL로 저장되는 원본·시스템·회원 썸네일 파일은 1년 immutable 캐시를 사용하고, 같은 URL로 재생성될 수 있는 게시물 `thumbs` 파생 파일은 1시간 캐시와 `stale-while-revalidate`를 사용한다. - `MAX_FILE_SIZE`, `MAX_VIDEO_FILE_SIZE`, `MAX_AUDIO_FILE_SIZE`, `MAX_DOCUMENT_FILE_SIZE` 환경 변수로 관리자 미디어 업로드 파일별 최대 크기를 제한한다. 1회 업로드 최대 파일 수와 전체 용량은 관리자 Settings > POST 설정 > 업로드 제한에서 조절한다. 리버스 프록시(Nginx 등)를 쓰면 `client_max_body_size`가 앱 한도보다 작지 않은지 함께 확인한다. - 관리자 미디어 화면은 현재 업로드 파일 시스템을 기준으로 목록, 파일명 변경, 삭제를 처리한다. diff --git a/docs/history.md b/docs/history.md index d5a7849..2211bfd 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-07-01 v1.10.11 — 업로드 파일 캐시는 원본과 파생 이미지를 다르게 둔다 + +업로드 파일은 대부분 고유 URL로 저장되어 같은 URL의 파일 내용이 바뀌지 않는다. 이런 원본·시스템·회원 썸네일 파일은 장기 immutable 캐시를 적용해 이미지 많은 페이지의 반복 요청 비용을 줄인다. 반면 게시물 `thumbs` 파생 이미지는 관리자 미디어에서 같은 URL로 다시 생성할 수 있으므로 1년 immutable을 적용하면 수정 결과가 오래 보이지 않을 수 있다. 따라서 파생 이미지는 1시간 캐시와 `stale-while-revalidate`를 사용하고, 모든 업로드 응답에 `ETag`와 `Last-Modified`를 제공해 재검증 시 304로 빠르게 응답하도록 정리한다. + ## 2026-07-01 v1.10.10 — 본문 파생 이미지는 원본 라이브러리에서 숨긴다 본문 이미지 최적화를 위해 하나의 원본에서 1600px 표시용, 720px 표시용, 2560px 라이트박스용 WebP가 생성된다. 이 파일들은 사용자가 직접 고른 원본이 아니라 원본 본문 이미지에 딸린 시스템 파생 파일이므로, 미디어 라이브러리의 원본 목록과 미사용 일괄 삭제 흐름에 섞이면 관리자가 실수로 삭제하기 쉽다. 따라서 관리자 미디어는 세 파생 파일을 모두 본문 이미지 탭으로 분류하고, 서버 사용처 계산도 세 파일을 원본 본문 이미지 사용처에 연결한다. 파일명 자체를 해상도형으로 바꾸는 작업은 기존 운영 볼륨 정리까지 필요하므로 별도 과제로 둔다. diff --git a/docs/map.md b/docs/map.md index f921996..c969310 100644 --- a/docs/map.md +++ b/docs/map.md @@ -88,7 +88,7 @@ |------|------| | server/middleware/admin-api-session.js | `/admin/api/*` 요청마다 관리자 세션과 현재 DB 권한(`owner`/`admin`) 재확인 | | server/middleware/html-page-renderer.js | HTML 문서 모드 고정 페이지(`/pages/:slug`)를 Nuxt 렌더링 대신 `text/html` 원문으로 응답 | -| server/routes/uploads/[...path].get.js | 런타임 업로드 파일 제공 API(`/app/public/uploads` 볼륨 파일을 `/uploads/**`로 스트리밍) | +| server/routes/uploads/[...path].get.js | 런타임 업로드 파일 제공 API(`/app/public/uploads` 볼륨 파일을 `/uploads/**`로 스트리밍, 원본 장기 캐시·파생 이미지 단기 캐시·조건부 요청 304 지원) | | server/routes/sitemap.xml.get.js | 공개 sitemap XML(`/sitemap.xml`) | | server/routes/robots.txt.get.js | 공개 robots.txt(`/robots.txt`, sitemap 위치 안내) | | server/routes/rss.xml.get.js | 공개 RSS 2.0 피드(`/rss.xml`) | diff --git a/docs/spec.md b/docs/spec.md index 2738f51..d5a514e 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -608,7 +608,7 @@ components/content/ ### 업로드 파일 제공 -- `GET /uploads/**` - 런타임 업로드 파일 제공. 운영 Docker에서는 빌드 산출물 `.output/public`이 아니라 `public/uploads` 볼륨의 실제 파일을 읽어 로고·게시물 이미지·회원 썸네일을 즉시 제공한다. +- `GET /uploads/**` - 런타임 업로드 파일 제공. 운영 Docker에서는 빌드 산출물 `.output/public`이 아니라 `public/uploads` 볼륨의 실제 파일을 읽어 로고·게시물 이미지·회원 썸네일을 즉시 제공한다. 응답은 `ETag`, `Last-Modified`를 제공하고 조건부 요청이 일치하면 304를 반환한다. 원본·시스템·회원 썸네일처럼 새 URL로 생성되는 업로드 파일은 `public, max-age=31536000, immutable`을 사용하며, 같은 URL로 재생성될 수 있는 게시물 `thumbs/*-(card|body|body-720|lightbox).webp` 파생 파일은 `public, max-age=3600, stale-while-revalidate=86400`을 사용한다. ### 관리자 API (`/admin/api/`) diff --git a/docs/todo.md b/docs/todo.md index 8c0b5e4..22bb396 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -11,9 +11,6 @@ - 업로드 총량 제한 및 메모리 사용량 개선. - 관리자 업로드는 사이트 설정에서 1회 최대 파일 수·전체 용량을 조절하고 클라이언트·서버 양쪽에서 검사함. - 서버는 아직 multipart 전체를 메모리에 받은 뒤 검사하므로, 고용량 다중 업로드의 메모리 피크를 더 줄이려면 스트리밍 업로드 파서 도입 검토. -- 업로드 파일 캐시 정책 개선. - - 현재 `/uploads/**` 응답은 `cache-control: no-cache`라 이미지가 많은 페이지에서 재검증 요청이 늘어날 수 있음. - - 업로드 파일명은 충돌 방지로 고유하게 저장되므로 `public, max-age=31536000, immutable` 적용 가능 여부 검토. - 본문 파생 이미지 파일명 정리 검토. - 현재 본문 파생 이미지는 `*-body.webp`(1600px), `*-body-720.webp`(720px), `*-lightbox.webp`(2560px) 이름을 사용함. - 운영 볼륨에 이미 생성된 파일이 있으므로 `*-body-1600.webp`, `*-body-2560.webp`처럼 해상도형 이름으로 바꾸려면 신규 생성 규칙, 기존 파일 백필, 레거시 파일 정리 절차를 함께 설계해야 함. diff --git a/docs/update.md b/docs/update.md index 251aa5b..87a49c4 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,12 @@ # 업데이트 이력 +## v1.10.11 + +- `/uploads/**` 런타임 파일 제공 API에 `ETag` 조건부 요청 304 응답 추가. +- 고유 URL 업로드 파일은 1년 immutable 캐시를 사용하도록 수정. +- 같은 URL로 재생성 가능한 게시물 `thumbs` 파생 이미지는 1시간 캐시와 `stale-while-revalidate`를 사용하도록 분리. +- 업로드 파일 캐시 정책 개선 과제를 `docs/todo.md`에서 제거. + ## v1.10.10 - 관리자 미디어 라이브러리에서 `*-body-720.webp`, `*-lightbox.webp` 파생 이미지가 원본 라이브러리에 섞이던 문제 수정. diff --git a/package-lock.json b/package-lock.json index 38cb7fa..852eea1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.10.10", + "version": "1.10.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.10.10", + "version": "1.10.11", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 17422da..ad86c22 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.10.10", + "version": "1.10.11", "private": true, "type": "module", "imports": { diff --git a/server/routes/uploads/[...path].get.js b/server/routes/uploads/[...path].get.js index 9d49fa8..1d77217 100644 --- a/server/routes/uploads/[...path].get.js +++ b/server/routes/uploads/[...path].get.js @@ -1,7 +1,14 @@ import { createReadStream } from 'node:fs' import { stat } from 'node:fs/promises' import { extname, join, relative } from 'node:path' -import { createError, getRequestURL, sendStream, setResponseHeader } from 'h3' +import { + createError, + getRequestHeader, + getRequestURL, + sendStream, + setResponseHeader, + setResponseStatus +} from 'h3' const uploadRoot = join(process.cwd(), 'public', 'uploads') const contentTypes = new Map([ @@ -13,6 +20,47 @@ const contentTypes = new Map([ ['.svg', 'image/svg+xml'], ['.ico', 'image/x-icon'] ]) +const regeneratableUploadPattern = /^\/uploads\/posts\/\d{4}\/\d{2}\/thumbs\/[^/?#]+-(?:card|body|body-720|lightbox)\.webp$/i + +/** + * 업로드 파일 캐시 정책을 반환한다. + * @param {string} pathname - 요청 경로 + * @returns {string} Cache-Control 값 + */ +const getUploadCacheControl = (pathname) => { + if (regeneratableUploadPattern.test(pathname)) { + return 'public, max-age=3600, stale-while-revalidate=86400' + } + + return 'public, max-age=31536000, immutable' +} + +/** + * 파일 통계 기반 약한 ETag 값을 만든다. + * @param {import('node:fs').Stats} fileStat - 파일 통계 + * @returns {string} ETag 값 + */ +const createUploadEtag = (fileStat) => `W/"${fileStat.size}-${Math.trunc(fileStat.mtimeMs)}"` + +/** + * 조건부 요청이 현재 파일과 일치하는지 확인한다. + * @param {import('h3').H3Event} event - 요청 이벤트 + * @param {string} etag - 현재 ETag + * @param {Date} modifiedAt - 마지막 수정 시각 + * @returns {boolean} 변경 없음 여부 + */ +const isUploadNotModified = (event, etag, modifiedAt) => { + const ifNoneMatch = String(getRequestHeader(event, 'if-none-match') || '') + + if (ifNoneMatch.split(',').map((value) => value.trim()).includes(etag)) { + return true + } + + const ifModifiedSince = String(getRequestHeader(event, 'if-modified-since') || '') + const modifiedSinceTime = Date.parse(ifModifiedSince) + + return Number.isFinite(modifiedSinceTime) && modifiedAt.getTime() <= modifiedSinceTime +} /** * 업로드 요청 URL을 디스크 파일 경로로 변환한다. @@ -59,7 +107,8 @@ const resolveUploadFilePath = (pathname) => { * @returns {Promise} 업로드 파일 스트림 */ export default defineEventHandler(async (event) => { - const filePath = resolveUploadFilePath(getRequestURL(event).pathname) + const pathname = getRequestURL(event).pathname + const filePath = resolveUploadFilePath(pathname) const fileStat = await stat(filePath).catch(() => null) if (!fileStat?.isFile()) { @@ -76,9 +125,18 @@ export default defineEventHandler(async (event) => { setResponseHeader(event, 'content-type', contentType) } - setResponseHeader(event, 'content-length', String(fileStat.size)) - setResponseHeader(event, 'cache-control', 'no-cache') + const etag = createUploadEtag(fileStat) + + setResponseHeader(event, 'cache-control', getUploadCacheControl(pathname)) setResponseHeader(event, 'last-modified', fileStat.mtime.toUTCString()) + setResponseHeader(event, 'etag', etag) + + if (isUploadNotModified(event, etag, fileStat.mtime)) { + setResponseStatus(event, 304) + return null + } + + setResponseHeader(event, 'content-length', String(fileStat.size)) return sendStream(event, createReadStream(filePath)) })