diff --git a/components/site/PostCard.vue b/components/site/PostCard.vue
index 5ca421c..1e0d3eb 100644
--- a/components/site/PostCard.vue
+++ b/components/site/PostCard.vue
@@ -14,6 +14,7 @@ defineProps({
:to="post.to"
:title="post.title"
:featured-image="post.featuredImage"
+ :thumbnail-image="post.featuredImageThumbnail"
link-class="h-20 w-36 shrink-0"
aspect-class="h-full w-full"
/>
diff --git a/components/site/PostCardMedia.vue b/components/site/PostCardMedia.vue
index 44ebcfb..d9bd20e 100644
--- a/components/site/PostCardMedia.vue
+++ b/components/site/PostCardMedia.vue
@@ -1,5 +1,5 @@
@@ -42,10 +49,10 @@ defineProps({
>
diff --git a/docs/changelog.md b/docs/changelog.md
index c00185f..d03cc3a 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -1,5 +1,10 @@
# 업데이트 요약
+## v1.5.78
+
+- 게시물 목록 카드에서 원본 대표 이미지 대신 생성된 카드용 썸네일을 우선 사용하도록 개선했다.
+- 기존 업로드 이미지도 한 번에 카드용 썸네일로 변환할 수 있는 백필 명령을 추가했다.
+
## v1.5.77
- 메인 화면 Latest 목록에서 긴 설명 때문에 메타 정보가 잘리는 문제를 줄였다.
diff --git a/docs/deploy.md b/docs/deploy.md
index 2444241..af69e17 100644
--- a/docs/deploy.md
+++ b/docs/deploy.md
@@ -1,6 +1,6 @@
# 배포 가이드
-> 로컬 기준 v1.5.77에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
+> 로컬 기준 v1.5.78에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
## 빌드 유형
@@ -16,6 +16,13 @@
## 로컬 개발
+### v1.5.78 참고
+
+- 추가 DB 마이그레이션은 없다.
+- 새 게시물 이미지 업로드 시 `/public/uploads/posts/YYYY/MM/thumbs/*-card.webp` 카드 썸네일이 함께 생성되는지 확인한다.
+- 기존 업로드 이미지가 많은 운영 환경에서는 배포 후 `npm run images:backfill-post-thumbnails`를 한 번 실행해 누락된 카드 썸네일을 생성한다.
+- 메인 Featured·Latest, 게시물 목록, 태그 목록에서 생성된 썸네일 URL을 우선 요청하고, 썸네일이 없는 외부/기존 이미지는 원본으로 대체되는지 확인한다.
+
### v1.5.77 참고
- 추가 DB 마이그레이션은 없다.
diff --git a/docs/history.md b/docs/history.md
index efda0cd..ef23a34 100644
--- a/docs/history.md
+++ b/docs/history.md
@@ -1,5 +1,9 @@
# 의사결정 이력
+## 2026-06-08 v1.5.78 — 공개 목록 이미지는 원본 대신 카드 썸네일을 우선 사용한다
+
+대표 이미지는 상세 화면, OG 이미지, RSS 같은 원본 품질이 필요한 경로에서도 쓰인다. 하지만 메인·목록·태그 카드에서는 작은 썸네일만 필요하므로 사이트 접속만으로 큰 원본 이미지를 내려받는 비용이 크다. 업로드 시점에 640×360 WebP 카드 썸네일을 함께 만들고, 공개 목록 응답에는 파일이 실제로 존재할 때만 `featuredImageThumbnail`을 추가해 기존 이미지와 외부 URL의 fallback을 유지한다. 기존 업로드 파일은 운영 볼륨에 저장되므로 저장소에 포함하지 않고 백필 명령으로 생성한다.
+
## 2026-06-08 v1.5.77 — Latest 목록은 기존 썸네일 레이아웃을 유지하며 메타 잘림을 줄인다
메인 Latest 목록은 빠르게 훑는 영역이므로 설명이 길어질 때 메타 정보가 잘려 보이면 스캔성이 떨어진다. 썸네일 레이아웃을 바꾸면 기존 compact 보기의 균형이 깨질 수 있으므로 썸네일 구조는 유지하고, 요약 영역이 메타 행을 밀어내지 않도록 최소 범위에서 조정한다.
diff --git a/docs/map.md b/docs/map.md
index fc7e0cd..dc7a576 100644
--- a/docs/map.md
+++ b/docs/map.md
@@ -51,6 +51,7 @@
| 파일 | 용도 |
|------|------|
| scripts/check-js-syntax.js | `npm run lint`에서 JS/MJS/CJS 파일을 `node --check`로 문법 점검 |
+| scripts/backfill-post-thumbnails.js | 기존 `public/uploads/posts` 이미지의 목록 카드용 WebP 썸네일 백필 |
## 서버 미들웨어
@@ -63,6 +64,7 @@
| server/routes/feed.xml.get.js | 공개 RSS 2.0 피드 별칭(`/feed.xml`) |
| server/routes/rss.get.js | 공개 RSS 2.0 피드 별칭(`/rss`) |
| server/plugins/site-custom-code.js | 공개 Nuxt HTML 응답에 사이트 설정 헤더·푸터 코드 삽입(`/admin`, `/api`, `/uploads`, `/_nuxt`, `/ads.txt` 제외) |
+| server/utils/post-thumbnail-image.js | 게시물 업로드 이미지의 카드 썸네일 URL·디스크 경로·생성 가능 여부 규칙 |
| server/utils/rss-feed.js | 공개 발행글 기반 RSS 2.0 XML 생성, 게시물 이미지 Media RSS 썸네일 출력 |
## 사이트 컴포넌트
@@ -81,7 +83,7 @@
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
| components/site/HomeHero.vue | 홈 상단 720px 커버 배너, 라이트·다크 테마별 이미지 교체, 왼쪽 하단 오버레이 제목·본문 |
| components/site/PostCard.vue | 목록의 게시물 카드, 카드 hover 인터랙션, 태그는 있을 때만 메타에 표시 |
-| components/site/PostCardMedia.vue | 게시물 카드 썸네일(대표 이미지 없으면 모바일 3줄·`sm` 이상 4줄 제목 placeholder 표시) |
+| components/site/PostCardMedia.vue | 게시물 카드 썸네일(`featuredImageThumbnail` 우선, 없으면 대표 이미지 원본 사용. 대표 이미지 없으면 모바일 3줄·`sm` 이상 4줄 제목 placeholder 표시) |
| components/site/TagHeader.vue | 태그 페이지 헤더 |
| components/comments/PostComments.vue | 게시물 상세 `#comments` 영역, 회원 댓글/답글(1단) 작성 및 목록 표시, 입력값 기반 등록 버튼 활성화, 작성자 썸네일/좋아요/상대시간 표시 |
diff --git a/docs/spec.md b/docs/spec.md
index 5c3ba77..70633d0 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -56,6 +56,7 @@
- 가로 카드 트랙은 `overflow-x-auto`와 `snap-x`/`snap-mandatory`로 슬라이드 느낌을 낸다.
- Featured 영역은 추천 글(`isFeatured=true`)이 1개 이상 있을 때만 표시한다.
- Featured 글에 대표 이미지가 없으면 목록 썸네일과 동일하게 카드 안에 게시물 제목을 표시하는 placeholder를 사용한다.
+- Featured 대표 이미지가 게시물 업로드 이미지이고 카드 썸네일이 생성되어 있으면 원본 대신 `featuredImageThumbnail`을 우선 표시한다.
- 모바일 터치: `touch-pan-x`, `-webkit-overflow-scrolling: touch`, `overscroll-x-contain`으로 가로 스크롤 우선·부모로의 스크롤 전파 완화.
- 헤더의 이전·다음 화살표는 `scrollLeft`와 최대 스크롤 거리로 양 끝에서 `disabled` 처리하며, `scroll` 이벤트와 `ResizeObserver`로 동기화한다.
@@ -63,6 +64,7 @@
- 기본 보기 방식은 `compact`이며, Default 선택 시에도 `compact`로 복원한다.
- `compact`는 썸네일을 포함한 짧은 행 형태이며 설명은 최대 2줄로 제한한다. 모바일 compact 썸네일은 오른쪽 제목·요약·메타 높이에 맞춘 80px 정사각형으로 표시하고, `sm` 이상에서는 기존 비율형 썸네일을 사용한다. 대표 이미지가 없는 placeholder 제목은 모바일 3줄, `sm` 이상 4줄로 제한하며 긴 단어는 썸네일 안에서 줄바꿈한다. `list`는 텍스트 중심 목록 형태로 설명은 최대 2줄, `cards`는 카드 그리드 형태로 표시한다.
+- 홈 Latest·게시물 목록·태그 목록의 카드 이미지는 `featuredImageThumbnail`을 우선 사용하고, 값이 없으면 기존 `featuredImage` 원본 URL로 대체한다.
- Latest 섹션은 게시물이 적어도 보기 방식 선택 메뉴가 아래쪽에서 잘리지 않도록 최소 높이를 둔다.
- 사이트 설정 Ads의 메인 피드 광고 코드가 있으면 Featured 영역과 Latest 목록 사이에 광고 슬롯을 표시한다. 메인 인피드 광고 코드가 있으면 Latest 게시물 목록 사이 한 곳에 브라우저 렌더 시점 기준으로 무작위 삽입한다. 비어 있으면 슬롯 자체를 렌더링하지 않는다.
@@ -88,6 +90,13 @@
- 공유·SEO 설명은 SEO 설명이 있으면 우선 사용하고, 없으면 게시물 요약, 요약도 없으면 본문에서 마크다운 기호를 제거한 짧은 텍스트를 사용한다.
- 홈 Latest·게시물 목록·태그 목록의 카드 설명도 동일하게 요약이 비어 있으면 본문에서 `createPostSummary`로 짧은 텍스트를 만든다. 목록용 설명은 문자열에 수동 말줄임을 붙이지 않고 `post-summary-clamp` 전용 클래스가 실제 표시 줄 끝에서 말줄임을 처리한다.
+### 게시물 업로드 이미지 썸네일
+
+- 관리자 미디어 업로드 API는 JPG·PNG·WebP 파일을 `/uploads/posts/YYYY/MM/원본파일`에 저장한 뒤, 목록 카드용 WebP 썸네일을 `/uploads/posts/YYYY/MM/thumbs/원본파일명-card.webp`에 함께 생성한다.
+- 카드 썸네일은 640×360 기준 `cover` 리사이즈와 WebP 품질 82를 사용한다. GIF·동영상·문서 파일은 썸네일을 자동 생성하지 않는다.
+- 공개 게시물 응답은 원본 대표 이미지 `featuredImage`를 유지하고, 대응 썸네일 파일이 실제로 존재할 때만 `featuredImageThumbnail`을 추가한다.
+- 기존 업로드 이미지는 `npm run images:backfill-post-thumbnails`로 `public/uploads/posts` 하위 파일을 스캔해 누락된 카드 썸네일을 생성한다.
+
### 공개 목록·상세의 발행일 표시
- API의 ISO 8601 `publishedAt`를 공개 UI에서는 로컬 날짜 기준 `YYYY.MM.DD`로 표시한다.
@@ -290,7 +299,7 @@ components/content/
| created_at | DateTime | 생성일 |
| updated_at | DateTime | 수정일 |
-> API 응답의 게시물 객체는 `isFeatured`와 `commentCount`를 함께 반환한다. `commentCount`는 `published` 상태 댓글 수를 기준으로 한다.
+> API 응답의 게시물 객체는 원본 대표 이미지 `featuredImage`, 목록용 카드 썸네일 `featuredImageThumbnail`, `isFeatured`, `commentCount`를 함께 반환한다. `commentCount`는 `published` 상태 댓글 수를 기준으로 한다.
> 공개 게시물 목록·상세는 `published` 상태만 기본 노출하며, `members` 상태는 VIP 이상 등급(`vip`/`admin`/`owner`) 회원에게만 노출한다. `private`와 `draft`는 공개 화면에서 노출하지 않는다.
### PostExportJobs / PostExportFiles
diff --git a/docs/update.md b/docs/update.md
index 06644b1..cc8c4cb 100644
--- a/docs/update.md
+++ b/docs/update.md
@@ -1,5 +1,12 @@
# 업데이트 이력
+## v1.5.78
+
+- 게시물 이미지 업로드: JPG·PNG·WebP 업로드 시 목록 카드용 WebP 썸네일 자동 생성 추가.
+- 공개 목록 API: 생성된 카드 썸네일이 있으면 `featuredImageThumbnail`로 함께 내려주도록 추가.
+- 메인 Featured·Latest, 게시물 목록, 태그 목록: 카드 이미지는 썸네일을 우선 사용하고 없으면 원본 대표 이미지로 대체하도록 수정.
+- 기존 업로드 이미지용 `npm run images:backfill-post-thumbnails` 백필 명령 추가.
+
## v1.5.77
- 메인 화면 Latest 목록: 기존 썸네일 레이아웃은 유지하고, 요약 영역이 메타 정보를 밀어내지 않도록 수정.
diff --git a/package-lock.json b/package-lock.json
index 5ed02fd..177a1b7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "sori.studio",
- "version": "1.5.77",
+ "version": "1.5.78",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
- "version": "1.5.77",
+ "version": "1.5.78",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",
diff --git a/package.json b/package.json
index a5a83b8..faf04c7 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "sori.studio",
- "version": "1.5.77",
+ "version": "1.5.78",
"private": true,
"type": "module",
"imports": {
@@ -22,6 +22,7 @@
"db:migrate:prod": "sh scripts/migrate-production-db.sh migrate",
"db:migrate:prod:status": "sh scripts/migrate-production-db.sh status",
"db:migrate:prod:baseline": "sh scripts/migrate-production-db.sh baseline",
+ "images:backfill-post-thumbnails": "node scripts/backfill-post-thumbnails.js",
"postinstall": "nuxt prepare"
},
"dependencies": {
diff --git a/pages/index.vue b/pages/index.vue
index 6f3cac1..e2cf6d6 100644
--- a/pages/index.vue
+++ b/pages/index.vue
@@ -154,6 +154,7 @@ const mapLatestPost = (post) => {
appendEllipsis: false
}),
featuredImage: post.featuredImage,
+ featuredImageThumbnail: post.featuredImageThumbnail || '',
tagName: tagMeta.name,
tagColor: tagMeta.color,
publishedAt: formatPostDate(post.publishedAt),
@@ -354,7 +355,7 @@ watch([latestPosts, hasHomeInfeedAd], () => {
>
{
:to="post.to"
:title="post.title"
:featured-image="post.featuredImage"
+ :thumbnail-image="post.featuredImageThumbnail"
:link-class="isPostFeedCards ? 'post-feed__media post-feed__media--cards mb-3 block aspect-video w-full' : 'post-feed__media post-feed__media--list relative h-20 w-20 shrink-0 self-start aspect-square sm:h-auto sm:w-auto sm:flex-1 sm:shrink sm:self-auto sm:min-w-16 sm:aspect-video'"
:aspect-class="isPostFeedCards ? 'aspect-video' : 'aspect-square sm:aspect-video'"
/>
diff --git a/pages/posts/index.vue b/pages/posts/index.vue
index 6094b41..fd7734b 100644
--- a/pages/posts/index.vue
+++ b/pages/posts/index.vue
@@ -10,6 +10,7 @@ const postCards = computed(() => posts.value.map((post) => ({
appendEllipsis: false
}),
featuredImage: post.featuredImage,
+ featuredImageThumbnail: post.featuredImageThumbnail || '',
tag: post.tags?.[0] ? String(post.tags[0]).toUpperCase() : '',
publishedAt: formatPostDate(post.publishedAt),
to: `/post/${post.slug}`
diff --git a/pages/tag/[slug].vue b/pages/tag/[slug].vue
index bbdf03b..d0ff511 100644
--- a/pages/tag/[slug].vue
+++ b/pages/tag/[slug].vue
@@ -21,6 +21,7 @@ const tagPosts = computed(() => posts.value
appendEllipsis: false
}),
featuredImage: post.featuredImage,
+ featuredImageThumbnail: post.featuredImageThumbnail || '',
tag: tags.value.find((item) => item.slug === (post.tags?.[0] || slug.value))?.name || (post.tags?.[0] || slug.value).toUpperCase(),
tagColor: tags.value.find((item) => item.slug === (post.tags?.[0] || slug.value))?.color || '#4d4d4d',
isFeatured: Boolean(post.isFeatured),
@@ -59,6 +60,7 @@ const tagPosts = computed(() => posts.value
:to="post.to"
:title="post.title"
:featured-image="post.featuredImage"
+ :thumbnail-image="post.featuredImageThumbnail"
link-class="relative aspect-square min-w-16 flex-1 sm:aspect-video"
aspect-class="aspect-square w-full sm:aspect-video"
/>
diff --git a/scripts/backfill-post-thumbnails.js b/scripts/backfill-post-thumbnails.js
new file mode 100644
index 0000000..20e1708
--- /dev/null
+++ b/scripts/backfill-post-thumbnails.js
@@ -0,0 +1,98 @@
+import { mkdir, readdir, stat } from 'node:fs/promises'
+import { extname, join } from 'node:path'
+import sharp from 'sharp'
+import {
+ POST_THUMBNAIL_HEIGHT,
+ POST_THUMBNAIL_QUALITY,
+ POST_THUMBNAIL_WIDTH,
+ getPostThumbnailDirectoryPath,
+ getPostThumbnailPathForFile,
+ isPostThumbnailSource
+} from '../server/utils/post-thumbnail-image.js'
+
+const uploadRoot = join(process.cwd(), 'public', 'uploads', 'posts')
+
+/**
+ * 게시물 업로드 이미지 파일 목록을 수집한다.
+ * @param {string} directoryPath - 탐색 디렉터리
+ * @returns {Promise} 이미지 파일 경로 목록
+ */
+const collectPostImages = async (directoryPath) => {
+ let entries = []
+
+ try {
+ entries = await readdir(directoryPath, { withFileTypes: true })
+ } catch {
+ return []
+ }
+
+ const files = []
+
+ for (const entry of entries) {
+ const entryPath = join(directoryPath, entry.name)
+
+ if (entry.isDirectory()) {
+ if (entry.name === 'thumbs') {
+ continue
+ }
+
+ files.push(...await collectPostImages(entryPath))
+ continue
+ }
+
+ if (entry.isFile() && isPostThumbnailSource('', entry.name)) {
+ files.push(entryPath)
+ }
+ }
+
+ return files
+}
+
+/**
+ * 게시물 이미지 카드 썸네일을 생성한다.
+ * @param {string} imagePath - 원본 이미지 경로
+ * @returns {Promise} 새로 생성했는지 여부
+ */
+const createPostThumbnail = async (imagePath) => {
+ const thumbnailPath = getPostThumbnailPathForFile(imagePath)
+
+ try {
+ await stat(thumbnailPath)
+ return false
+ } catch {
+ await mkdir(getPostThumbnailDirectoryPath(imagePath), { recursive: true })
+ }
+
+ await sharp(imagePath)
+ .rotate()
+ .resize({
+ width: POST_THUMBNAIL_WIDTH,
+ height: POST_THUMBNAIL_HEIGHT,
+ fit: 'cover',
+ position: 'centre',
+ withoutEnlargement: true
+ })
+ .webp({ quality: POST_THUMBNAIL_QUALITY })
+ .toFile(thumbnailPath)
+
+ return true
+}
+
+const images = await collectPostImages(uploadRoot)
+let createdCount = 0
+let skippedCount = 0
+
+for (const imagePath of images) {
+ if (!isPostThumbnailSource('', imagePath) || extname(imagePath).toLowerCase() === '.gif') {
+ skippedCount += 1
+ continue
+ }
+
+ if (await createPostThumbnail(imagePath)) {
+ createdCount += 1
+ } else {
+ skippedCount += 1
+ }
+}
+
+process.stdout.write(`게시물 카드 썸네일 생성 완료: 생성 ${createdCount}개, 건너뜀 ${skippedCount}개\n`)
diff --git a/server/repositories/content-repository.js b/server/repositories/content-repository.js
index bddef8a..46b3770 100644
--- a/server/repositories/content-repository.js
+++ b/server/repositories/content-repository.js
@@ -21,6 +21,7 @@ import {
parseSignupBlockedUsernamesFromDb
} from '../../lib/signup-blocked-usernames.js'
import { normalizeSocialLinks } from '../../lib/social-links.js'
+import { getExistingPostThumbnailUrl } from '../utils/post-thumbnail-image.js'
import { getPostgresClient } from './postgres-client'
/**
@@ -36,6 +37,7 @@ const mapPostRow = (row) => ({
content: row.content,
excerpt: row.excerpt,
featuredImage: row.featured_image,
+ featuredImageThumbnail: getExistingPostThumbnailUrl(row.featured_image),
isFeatured: Boolean(row.is_featured),
commentCount: Number(row.comment_count || 0),
seoTitle: row.seo_title || '',
diff --git a/server/routes/admin/api/uploads.post.js b/server/routes/admin/api/uploads.post.js
index a4cccde..d78ebf1 100644
--- a/server/routes/admin/api/uploads.post.js
+++ b/server/routes/admin/api/uploads.post.js
@@ -1,6 +1,7 @@
import { mkdir, stat, writeFile } from 'node:fs/promises'
import { extname, join } from 'node:path'
import { createError, readMultipartFormData } from 'h3'
+import sharp from 'sharp'
import {
buildDefaultUploadSizeLimits,
formatUploadSizeLimit,
@@ -11,6 +12,13 @@ import {
import { requireAdminSession } from '../../../utils/admin-auth'
import { getRuntimeEnvNumber } from '../../../utils/runtime-env.js'
import { upsertMediaMetadataCategory } from '../../../utils/media-library'
+import {
+ POST_THUMBNAIL_HEIGHT,
+ POST_THUMBNAIL_QUALITY,
+ POST_THUMBNAIL_WIDTH,
+ getPostThumbnailFileName,
+ isPostThumbnailSource
+} from '../../../utils/post-thumbnail-image.js'
const allowedUploadTypes = new Map([
['image/jpeg', '.jpg'],
@@ -118,6 +126,38 @@ const pickUniqueDiskFileName = async (directoryPath, stem, extension) => {
})
}
+/**
+ * 게시물 목록용 카드 썸네일을 생성한다.
+ * @param {Object} options - 생성 옵션
+ * @param {Buffer} options.sourceBuffer - 원본 이미지 버퍼
+ * @param {string} options.directoryPath - 원본 이미지 저장 디렉터리
+ * @param {string} options.fileName - 원본 파일명
+ * @returns {Promise} 생성된 썸네일 파일명
+ */
+const createPostCardThumbnail = async ({ sourceBuffer, directoryPath, fileName }) => {
+ const thumbnailDirectoryPath = join(directoryPath, 'thumbs')
+ const thumbnailFileName = getPostThumbnailFileName(fileName)
+ const thumbnailPath = join(thumbnailDirectoryPath, thumbnailFileName)
+
+ await mkdir(thumbnailDirectoryPath, { recursive: true })
+
+ const thumbnailBuffer = await sharp(sourceBuffer)
+ .rotate()
+ .resize({
+ width: POST_THUMBNAIL_WIDTH,
+ height: POST_THUMBNAIL_HEIGHT,
+ fit: 'cover',
+ position: 'centre',
+ withoutEnlargement: true
+ })
+ .webp({ quality: POST_THUMBNAIL_QUALITY })
+ .toBuffer()
+
+ await writeFile(thumbnailPath, thumbnailBuffer)
+
+ return thumbnailFileName
+}
+
/**
* 관리자 미디어 업로드 API
* @param {import('h3').H3Event} event - 요청 이벤트
@@ -179,11 +219,22 @@ export default defineEventHandler(async (event) => {
await writeFile(filePath, file.data)
const publicUrl = `${uploadBaseUrl}/posts/${year}/${month}/${fileName}`
+ let thumbnailUrl = ''
+
+ if (isPostThumbnailSource(file.type, fileName)) {
+ const thumbnailFileName = await createPostCardThumbnail({
+ sourceBuffer: file.data,
+ directoryPath,
+ fileName
+ })
+ thumbnailUrl = `${uploadBaseUrl}/posts/${year}/${month}/thumbs/${thumbnailFileName}`
+ }
await upsertMediaMetadataCategory(publicUrl, '미분류')
uploadedFiles.push({
url: publicUrl,
+ thumbnailUrl,
name: fileName,
size: file.data.length
})
diff --git a/server/utils/post-thumbnail-image.js b/server/utils/post-thumbnail-image.js
new file mode 100644
index 0000000..0950b40
--- /dev/null
+++ b/server/utils/post-thumbnail-image.js
@@ -0,0 +1,121 @@
+import { existsSync } from 'node:fs'
+import { dirname, extname, join, posix } from 'node:path'
+
+export const POST_THUMBNAIL_WIDTH = 640
+export const POST_THUMBNAIL_HEIGHT = 360
+export const POST_THUMBNAIL_QUALITY = 82
+
+const postUploadUrlPattern = /^\/uploads\/posts\/\d{4}\/\d{2}\/[^?#]+$/i
+const postThumbnailDirectoryName = 'thumbs'
+const postThumbnailSuffix = '-card'
+const postThumbnailExtension = '.webp'
+const supportedPostThumbnailExtensions = new Set(['.jpg', '.jpeg', '.png', '.webp'])
+const supportedPostThumbnailTypes = new Set(['image/jpeg', 'image/png', 'image/webp'])
+
+/**
+ * URL 경로 조각을 파일 시스템 경로용 문자열로 디코딩한다.
+ * @param {string} value - URL 경로 조각
+ * @returns {string} 디코딩된 값
+ */
+const decodeUrlPathPart = (value) => {
+ try {
+ return decodeURIComponent(value)
+ } catch {
+ return value
+ }
+}
+
+/**
+ * 게시물 이미지에서 카드 썸네일을 생성할 수 있는지 확인한다.
+ * @param {string} mimeType - 파일 MIME 타입
+ * @param {string} fileName - 파일명
+ * @returns {boolean} 썸네일 생성 가능 여부
+ */
+export const isPostThumbnailSource = (mimeType, fileName = '') => {
+ if (supportedPostThumbnailTypes.has(mimeType)) {
+ return true
+ }
+
+ return supportedPostThumbnailExtensions.has(extname(fileName).toLowerCase())
+}
+
+/**
+ * 원본 파일명에서 게시물 카드 썸네일 파일명을 만든다.
+ * @param {string} fileName - 원본 파일명
+ * @returns {string} 썸네일 파일명
+ */
+export const getPostThumbnailFileName = (fileName) => {
+ const extension = extname(fileName)
+ const stem = extension ? fileName.slice(0, -extension.length) : fileName
+
+ return `${stem}${postThumbnailSuffix}${postThumbnailExtension}`
+}
+
+/**
+ * 게시물 원본 이미지 URL에 대응하는 카드 썸네일 URL을 만든다.
+ * @param {string|null|undefined} imageUrl - 원본 이미지 URL
+ * @returns {string} 카드 썸네일 URL
+ */
+export const getPostThumbnailUrl = (imageUrl) => {
+ if (!imageUrl || !postUploadUrlPattern.test(imageUrl)) {
+ return ''
+ }
+
+ const directory = posix.dirname(imageUrl)
+ const fileName = posix.basename(imageUrl)
+ const extension = extname(fileName).toLowerCase()
+
+ if (!supportedPostThumbnailExtensions.has(extension)) {
+ return ''
+ }
+
+ return `${directory}/${postThumbnailDirectoryName}/${getPostThumbnailFileName(fileName)}`
+}
+
+/**
+ * 게시물 카드 썸네일 URL의 디스크 경로를 조회한다.
+ * @param {string} imageUrl - 원본 이미지 URL
+ * @returns {string} 썸네일 디스크 경로
+ */
+export const getPostThumbnailDiskPath = (imageUrl) => {
+ const thumbnailUrl = getPostThumbnailUrl(imageUrl)
+
+ if (!thumbnailUrl) {
+ return ''
+ }
+
+ return join(process.cwd(), 'public', decodeUrlPathPart(thumbnailUrl.replace(/^\/+/, '')))
+}
+
+/**
+ * 게시물 원본 이미지 파일의 썸네일 저장 디렉터리 경로를 조회한다.
+ * @param {string} imageFilePath - 원본 이미지 디스크 경로
+ * @returns {string} 썸네일 저장 디렉터리 경로
+ */
+export const getPostThumbnailDirectoryPath = (imageFilePath) => join(dirname(imageFilePath), postThumbnailDirectoryName)
+
+/**
+ * 게시물 원본 이미지 파일의 썸네일 저장 경로를 조회한다.
+ * @param {string} imageFilePath - 원본 이미지 디스크 경로
+ * @returns {string} 썸네일 저장 경로
+ */
+export const getPostThumbnailPathForFile = (imageFilePath) => {
+ const fileName = imageFilePath.split(/[\\/]/).pop() || ''
+
+ return join(getPostThumbnailDirectoryPath(imageFilePath), getPostThumbnailFileName(fileName))
+}
+
+/**
+ * 이미 생성된 게시물 카드 썸네일 URL을 조회한다.
+ * @param {string|null|undefined} imageUrl - 원본 이미지 URL
+ * @returns {string} 존재하는 썸네일 URL
+ */
+export const getExistingPostThumbnailUrl = (imageUrl) => {
+ const thumbnailUrl = getPostThumbnailUrl(imageUrl)
+
+ if (!thumbnailUrl) {
+ return ''
+ }
+
+ return existsSync(getPostThumbnailDiskPath(imageUrl)) ? thumbnailUrl : ''
+}