업로드 파일 캐시 정책 개선
This commit is contained in:
@@ -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`가 앱 한도보다 작지 않은지 함께 확인한다.
|
||||
- 관리자 미디어 화면은 현재 업로드 파일 시스템을 기준으로 목록, 파일명 변경, 삭제를 처리한다.
|
||||
|
||||
|
||||
@@ -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가 생성된다. 이 파일들은 사용자가 직접 고른 원본이 아니라 원본 본문 이미지에 딸린 시스템 파생 파일이므로, 미디어 라이브러리의 원본 목록과 미사용 일괄 삭제 흐름에 섞이면 관리자가 실수로 삭제하기 쉽다. 따라서 관리자 미디어는 세 파생 파일을 모두 본문 이미지 탭으로 분류하고, 서버 사용처 계산도 세 파일을 원본 본문 이미지 사용처에 연결한다. 파일명 자체를 해상도형으로 바꾸는 작업은 기존 운영 볼륨 정리까지 필요하므로 별도 과제로 둔다.
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
@@ -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/`)
|
||||
|
||||
|
||||
@@ -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`처럼 해상도형 이름으로 바꾸려면 신규 생성 규칙, 기존 파일 백필, 레거시 파일 정리 절차를 함께 설계해야 함.
|
||||
|
||||
@@ -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` 파생 이미지가 원본 라이브러리에 섞이던 문제 수정.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.10.10",
|
||||
"version": "1.10.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -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<void>} 업로드 파일 스트림
|
||||
*/
|
||||
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))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user