업로드 파일 캐시 정책 개선

This commit is contained in:
2026-07-01 18:12:43 +09:00
parent 445eb39a83
commit b26ae271d2
9 changed files with 80 additions and 13 deletions

View File

@@ -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`가 앱 한도보다 작지 않은지 함께 확인한다.
- 관리자 미디어 화면은 현재 업로드 파일 시스템을 기준으로 목록, 파일명 변경, 삭제를 처리한다.

View File

@@ -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가 생성된다. 이 파일들은 사용자가 직접 고른 원본이 아니라 원본 본문 이미지에 딸린 시스템 파생 파일이므로, 미디어 라이브러리의 원본 목록과 미사용 일괄 삭제 흐름에 섞이면 관리자가 실수로 삭제하기 쉽다. 따라서 관리자 미디어는 세 파생 파일을 모두 본문 이미지 탭으로 분류하고, 서버 사용처 계산도 세 파일을 원본 본문 이미지 사용처에 연결한다. 파일명 자체를 해상도형으로 바꾸는 작업은 기존 운영 볼륨 정리까지 필요하므로 별도 과제로 둔다.

View File

@@ -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`) |

View File

@@ -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/`)

View File

@@ -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`처럼 해상도형 이름으로 바꾸려면 신규 생성 규칙, 기존 파일 백필, 레거시 파일 정리 절차를 함께 설계해야 함.

View File

@@ -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
View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.10.10",
"version": "1.10.11",
"private": true,
"type": "module",
"imports": {

View File

@@ -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))
})