From 0581de6c17c32867631d89e40b86a148bce39fd3 Mon Sep 17 00:00:00 2001 From: zenn Date: Tue, 31 Mar 2026 17:44:03 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94=20=EC=9E=91=EC=97=85=20=ED=81=90=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/db.js | 82 ++++++++++++++++++++ backend/src/lib/image-storage.js | 126 +++++++++++++++++++++++++------ docs/update.md | 5 ++ 3 files changed, 191 insertions(+), 22 deletions(-) diff --git a/backend/src/db.js b/backend/src/db.js index 5e5f8a6..c01237d 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -78,6 +78,23 @@ function mapImageAssetRow(row) { createdAt: Number(row.created_at || 0), } } + +function mapImageOptimizationJobRow(row) { + if (!row) return null + return { + id: row.id, + status: row.status, + sourceCategory: row.source_category || '', + targetDirectory: row.target_directory || '', + originalByteSize: Number(row.original_byte_size || 0), + optimizedByteSize: Number(row.optimized_byte_size || 0), + reusedAsset: !!row.reused_asset, + errorMessage: row.error_message || '', + queuedAt: Number(row.queued_at || 0), + startedAt: Number(row.started_at || 0), + finishedAt: Number(row.finished_at || 0), + } +} function mapTierListRow(row) { if (!row) return null return { @@ -298,6 +315,24 @@ async function ensureSchema() { INDEX idx_image_assets_created_at (created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) + + await query(` + CREATE TABLE IF NOT EXISTS image_optimization_jobs ( + id VARCHAR(64) PRIMARY KEY, + status VARCHAR(20) NOT NULL DEFAULT 'queued', + source_category VARCHAR(40) NOT NULL DEFAULT '', + target_directory VARCHAR(40) NOT NULL DEFAULT '', + original_byte_size INT UNSIGNED NOT NULL DEFAULT 0, + optimized_byte_size INT UNSIGNED NOT NULL DEFAULT 0, + reused_asset TINYINT(1) NOT NULL DEFAULT 0, + error_message VARCHAR(255) NOT NULL DEFAULT '', + queued_at BIGINT NOT NULL, + started_at BIGINT NOT NULL DEFAULT 0, + finished_at BIGINT NOT NULL DEFAULT 0, + INDEX idx_image_optimization_jobs_status_queued (status, queued_at), + INDEX idx_image_optimization_jobs_finished_at (finished_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + `) await query(` CREATE TABLE IF NOT EXISTS template_requests ( id VARCHAR(64) PRIMARY KEY, @@ -566,6 +601,49 @@ async function createImageAsset({ id, contentHash, src, mimeType = "image/webp", ) return findImageAssetByHash(contentHash) } + +async function createImageOptimizationJob({ id, sourceCategory, targetDirectory, originalByteSize }) { + const queuedAt = now() + await query( + 'INSERT INTO image_optimization_jobs (id, status, source_category, target_directory, original_byte_size, optimized_byte_size, reused_asset, error_message, queued_at, started_at, finished_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [id, 'queued', sourceCategory || '', targetDirectory || '', originalByteSize || 0, 0, 0, '', queuedAt, 0, 0] + ) + return findImageOptimizationJobById(id) +} + +async function findImageOptimizationJobById(id) { + const rows = await query( + 'SELECT id, status, source_category, target_directory, original_byte_size, optimized_byte_size, reused_asset, error_message, queued_at, started_at, finished_at FROM image_optimization_jobs WHERE id = ? LIMIT 1', + [id] + ) + return mapImageOptimizationJobRow(rows[0]) +} + +async function updateImageOptimizationJobStatus({ id, status, optimizedByteSize = 0, reusedAsset = false, errorMessage = '', startedAt, finishedAt }) { + const fields = ['status = ?', 'optimized_byte_size = ?', 'reused_asset = ?', 'error_message = ?'] + const params = [status, optimizedByteSize, reusedAsset ? 1 : 0, errorMessage.slice(0, 255)] + + if (typeof startedAt === 'number') { + fields.push('started_at = ?') + params.push(startedAt) + } + if (typeof finishedAt === 'number') { + fields.push('finished_at = ?') + params.push(finishedAt) + } + + params.push(id) + await query(`UPDATE image_optimization_jobs SET ${fields.join(', ')} WHERE id = ?`, params) + return findImageOptimizationJobById(id) +} + +async function listRecentImageOptimizationJobs(limit = 20) { + const safeLimit = Math.max(1, Math.min(100, Number(limit) || 20)) + const rows = await query( + `SELECT id, status, source_category, target_directory, original_byte_size, optimized_byte_size, reused_asset, error_message, queued_at, started_at, finished_at FROM image_optimization_jobs ORDER BY queued_at DESC LIMIT ${safeLimit}` + ) + return rows.map(mapImageOptimizationJobRow) +} async function createGameItem({ id, gameId, src, label }) { const createdAt = now() await query('INSERT INTO game_items (id, game_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [ @@ -1450,6 +1528,10 @@ module.exports = { updateGameThumbnail, findImageAssetByHash, createImageAsset, + createImageOptimizationJob, + findImageOptimizationJobById, + updateImageOptimizationJobStatus, + listRecentImageOptimizationJobs, createGameItem, updateGameItemLabel, deleteGameItem, diff --git a/backend/src/lib/image-storage.js b/backend/src/lib/image-storage.js index d19edb9..1bb1cbf 100644 --- a/backend/src/lib/image-storage.js +++ b/backend/src/lib/image-storage.js @@ -3,10 +3,19 @@ const path = require('path') const crypto = require('crypto') const sharp = require('sharp') const { nanoid } = require('nanoid') -const { findImageAssetByHash, createImageAsset } = require('../db') +const { + findImageAssetByHash, + createImageAsset, + createImageOptimizationJob, + updateImageOptimizationJobStatus, +} = require('../db') const UPLOAD_ROOT = path.join(__dirname, '..', '..', 'uploads') const OPTIMIZED_DIR = 'assets' +const OPTIMIZATION_CONCURRENCY = Math.max(1, Number(process.env.IMAGE_OPTIMIZATION_CONCURRENCY || 1)) + +let activeCount = 0 +const pendingJobs = [] function ensureImageMimeType(file) { return typeof file?.mimetype === 'string' && file.mimetype.startsWith('image/') @@ -26,26 +35,21 @@ function createMemoryUpload(multer, { fileSize = 6 * 1024 * 1024, maxCount } = { }) } -async function writeOptimizedImage({ - file, - directory, - width, - height, - fit = 'inside', - quality = 82, -}) { - if (!file?.buffer?.length) { - const error = new Error('file_required') - error.code = 'file_required' - throw error - } - - if (!ensureImageMimeType(file)) { - const error = new Error('image_file_required') - error.code = 'image_file_required' - throw error +function scheduleQueue() { + while (activeCount < OPTIMIZATION_CONCURRENCY && pendingJobs.length) { + const job = pendingJobs.shift() + activeCount += 1 + processQueuedJob(job) + .then(job.resolve) + .catch(job.reject) + .finally(() => { + activeCount = Math.max(0, activeCount - 1) + scheduleQueue() + }) } +} +async function optimizeAndPersist({ file, width, height, fit, quality }) { const { data, info } = await sharp(file.buffer, { failOn: 'none' }) .rotate() .resize({ @@ -68,7 +72,6 @@ async function writeOptimizedImage({ height: existing.height, contentHash: existing.contentHash, reused: true, - directory, } } @@ -100,7 +103,6 @@ async function writeOptimizedImage({ height: asset.height, contentHash: asset.contentHash, reused: false, - directory, } } catch (error) { try { @@ -120,7 +122,6 @@ async function writeOptimizedImage({ height: asset.height, contentHash: asset.contentHash, reused: true, - directory, } } } @@ -129,8 +130,89 @@ async function writeOptimizedImage({ } } +async function processQueuedJob(job) { + await updateImageOptimizationJobStatus({ + id: job.jobId, + status: 'processing', + startedAt: Date.now(), + }) + + try { + const result = await optimizeAndPersist(job) + await updateImageOptimizationJobStatus({ + id: job.jobId, + status: 'completed', + optimizedByteSize: result.size, + reusedAsset: result.reused, + finishedAt: Date.now(), + }) + return result + } catch (error) { + await updateImageOptimizationJobStatus({ + id: job.jobId, + status: 'failed', + errorMessage: error?.message || 'optimization_failed', + finishedAt: Date.now(), + }) + throw error + } +} + +async function writeOptimizedImage({ + file, + directory, + width, + height, + fit = 'inside', + quality = 82, +}) { + if (!file?.buffer?.length) { + const error = new Error('file_required') + error.code = 'file_required' + throw error + } + + if (!ensureImageMimeType(file)) { + const error = new Error('image_file_required') + error.code = 'image_file_required' + throw error + } + + const jobId = nanoid() + await createImageOptimizationJob({ + id: jobId, + sourceCategory: directory, + targetDirectory: OPTIMIZED_DIR, + originalByteSize: file.size || file.buffer.length, + }) + + return new Promise((resolve, reject) => { + pendingJobs.push({ + jobId, + file, + directory, + width, + height, + fit, + quality, + resolve: (result) => resolve({ ...result, directory }), + reject, + }) + scheduleQueue() + }) +} + +function getImageOptimizationQueueState() { + return { + concurrency: OPTIMIZATION_CONCURRENCY, + activeCount, + pendingCount: pendingJobs.length, + } +} + module.exports = { createMemoryUpload, ensureImageMimeType, writeOptimizedImage, + getImageOptimizationQueueState, } diff --git a/docs/update.md b/docs/update.md index 04ec69f..b3a5397 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-03-31 v1.3.2 +- 업로드 최적화는 이제 백엔드 내부 대기열을 통해 처리되어, 다수 이미지가 한 번에 들어와도 설정된 동시성 안에서 순차적으로 안정적으로 변환되도록 정리함. +- `image_optimization_jobs` 작업 기록 테이블을 추가해 queued/processing/completed/failed 상태와 원본·최적화 용량, 재사용 여부, 시작/종료 시각을 저장하도록 확장함. +- 현재 라우트 응답 방식은 유지하면서도 내부적으로는 큐를 타도록 구조를 바꿔, 이후 관리자 대시보드와 작업 통계 화면을 바로 얹을 수 있는 기반을 마련함. + ## 2026-03-31 v1.3.1 - 최적화된 WebP 결과물 기준으로 SHA-256 해시를 계산해, 같은 이미지가 다시 업로드되면 새 파일을 저장하지 않고 기존 자산을 재사용하도록 중복 이미지 해시 검사를 추가함. - 이미지 자산 메타데이터를 저장하는 `image_assets` 테이블을 도입해 파일 경로, 해시, 원본 대비 최적화 용량, 해상도를 함께 기록하도록 확장함.