feat(member): 회원 썸네일 업로드를 WebP 리사이즈로 표준화

회원 아바타 업로드 시 원본 포맷을 WebP로 변환하고 최대 해상도/품질을 환경변수로 제어해 저장 용량과 전송 비용을 줄인다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-11 17:24:29 +09:00
parent eab800b6c1
commit 65af30724c
9 changed files with 567 additions and 27 deletions

View File

@@ -1,7 +1,8 @@
import { randomUUID } from 'node:crypto'
import { mkdir, writeFile } from 'node:fs/promises'
import { extname, join } from 'node:path'
import { join } from 'node:path'
import { createError, readMultipartFormData } from 'h3'
import sharp from 'sharp'
import { getPostgresClient } from '../../repositories/postgres-client'
import { updateMemberProfile, getUserById } from '../../repositories/member-repository'
import { requireMemberSession } from '../../utils/member-auth'
@@ -24,21 +25,6 @@ const sanitizePathPart = (value) => value
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
/**
* 파일 확장자 조회
* @param {Object} file - multipart 파일 파트
* @returns {string} 확장자
*/
const getUploadExtension = (file) => {
const extension = extname(file.filename || '').toLowerCase()
if (allowedImageTypes.has(file.type)) {
return allowedImageTypes.get(file.type)
}
return extension
}
/**
* 회원 썸네일 업로드 API
* @param {import('h3').H3Event} event - 요청 이벤트
@@ -56,6 +42,9 @@ export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const maxFileSize = Number(config.maxFileSize || 10485760)
const avatarMaxWidth = Number(config.avatarMaxWidth || 512)
const avatarMaxHeight = Number(config.avatarMaxHeight || 512)
const avatarWebpQuality = Number(config.avatarWebpQuality || 82)
const formData = await readMultipartFormData(event)
const file = (formData || []).find((part) => part.name === 'file' && part.filename)
@@ -90,12 +79,32 @@ export default defineEventHandler(async (event) => {
await mkdir(directoryPath, { recursive: true })
const originalName = sanitizePathPart(file.filename.replace(/\.[^.]+$/g, '')) || 'avatar'
const extension = getUploadExtension(file)
const fileName = `${originalName}-${randomUUID()}${extension}`
const fileName = `${originalName}-${randomUUID()}.webp`
const filePath = join(directoryPath, fileName)
const avatarUrl = `${uploadBaseUrl}/members/avatars/${year}/${month}/${fileName}`
const metadata = await sharp(file.data).metadata()
await writeFile(filePath, file.data)
if (!metadata.width || !metadata.height) {
throw createError({
statusCode: 400,
message: '이미지 메타데이터를 읽을 수 없습니다.'
})
}
const resizedBuffer = await sharp(file.data)
.rotate()
.resize({
width: avatarMaxWidth,
height: avatarMaxHeight,
fit: 'inside',
withoutEnlargement: true
})
.webp({
quality: avatarWebpQuality
})
.toBuffer()
await writeFile(filePath, resizedBuffer)
await updateMemberProfile({
userId: session.userId,