diff --git a/backend/package.json b/backend/package.json index 41d786f..5ae1b62 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,6 +8,7 @@ "start": "APP_ORIGIN=http://localhost:5173 DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node index.js", "images:backfill": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/backfill-legacy-image-assets.js", "images:migrate-legacy": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/migrate-legacy-uploads-to-assets.js", + "images:shard-assets": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/migrate-flat-assets-to-sharded.js", "uploads:cleanup-legacy": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/cleanup-unreferenced-legacy-uploads.js" }, "keywords": [], diff --git a/backend/scripts/migrate-flat-assets-to-sharded.js b/backend/scripts/migrate-flat-assets-to-sharded.js new file mode 100644 index 0000000..00a5bff --- /dev/null +++ b/backend/scripts/migrate-flat-assets-to-sharded.js @@ -0,0 +1,102 @@ +const fs = require('fs/promises') +const path = require('path') +const { + ensureData, + closePool, + updateImageAssetSrc, + replaceUploadSourceReferences, +} = require('../src/db') + +const BACKEND_ROOT = path.join(__dirname, '..') +const ASSETS_ROOT = path.join(BACKEND_ROOT, 'uploads', 'assets') +const FLAT_ASSET_PATTERN = /^\/uploads\/assets\/[^/]+$/ + +function getShardedAssetSrc(src) { + const filename = path.basename(src || '') + const shardDirectory = filename.slice(0, 2) + if (!filename || shardDirectory.length < 2) return '' + return `/uploads/assets/${shardDirectory}/${filename}` +} + +async function moveAssetFile(fromSrc, toSrc) { + const fromPath = path.join(BACKEND_ROOT, fromSrc.replace(/^\//, '')) + const toPath = path.join(BACKEND_ROOT, toSrc.replace(/^\//, '')) + await fs.mkdir(path.dirname(toPath), { recursive: true }) + + try { + await fs.rename(fromPath, toPath) + return 'moved' + } catch (error) { + if (error?.code !== 'ENOENT') throw error + } + + try { + await fs.access(toPath) + return 'already_moved' + } catch (error) { + if (error?.code === 'ENOENT') return 'missing' + throw error + } +} + +async function main() { + await ensureData() + + let dirEntries = [] + try { + dirEntries = await fs.readdir(ASSETS_ROOT, { withFileTypes: true }) + } catch (error) { + if (error?.code !== 'ENOENT') throw error + } + + const flatAssets = dirEntries + .filter((entry) => entry.isFile()) + .map((entry) => ({ src: `/uploads/assets/${entry.name}` })) + .filter((asset) => FLAT_ASSET_PATTERN.test(asset.src || '')) + const summary = { + scanned: flatAssets.length, + migrated: 0, + alreadyMoved: 0, + skipped: 0, + missingFiles: 0, + failed: 0, + updatedRows: 0, + } + + for (const asset of flatAssets) { + const nextSrc = getShardedAssetSrc(asset.src) + if (!nextSrc) { + summary.skipped += 1 + continue + } + + try { + const moveStatus = await moveAssetFile(asset.src, nextSrc) + if (moveStatus === 'missing') { + summary.missingFiles += 1 + continue + } + + await updateImageAssetSrc({ fromSrc: asset.src, toSrc: nextSrc }) + const replaced = await replaceUploadSourceReferences({ fromSrc: asset.src, toSrc: nextSrc }) + summary.updatedRows += Number(replaced.updatedRows || 0) + + if (moveStatus === 'already_moved') summary.alreadyMoved += 1 + else summary.migrated += 1 + } catch (error) { + summary.failed += 1 + console.error('[migrate-flat-assets-to-sharded] failed:', asset.src, error?.message || error) + } + } + + console.log(JSON.stringify(summary, null, 2)) +} + +main() + .catch((error) => { + console.error(error) + process.exitCode = 1 + }) + .finally(async () => { + await closePool() + }) diff --git a/backend/src/db.js b/backend/src/db.js index f27d2bc..defcfd3 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -1300,6 +1300,12 @@ async function findImageAssetById(id) { return mapImageAssetRow(rows[0]) } +async function updateImageAssetSrc({ fromSrc, toSrc }) { + if (!fromSrc || !toSrc || fromSrc === toSrc) return null + await query('UPDATE image_assets SET src = ? WHERE src = ?', [toSrc, fromSrc]) + return findImageAssetBySrc(toSrc) +} + async function getReferencedUploadFootprint() { const [referencedSrcs, assets] = await Promise.all([listReferencedUploadSources(), listImageAssets()]) const assetMap = new Map(assets.map((asset) => [asset.src, asset])) @@ -3087,6 +3093,7 @@ module.exports = { findImageAssetByHash, findImageAssetBySrc, findImageAssetById, + updateImageAssetSrc, createImageAsset, createImageOptimizationJob, findImageOptimizationJobById, @@ -3094,6 +3101,7 @@ module.exports = { listRecentImageOptimizationJobs, listUnusedImageAssets, deleteImageAssets, + listImageAssets, listReferencedUploadSources, listReferencedUploadUsage, replaceUploadSourceReferences, diff --git a/docs/history.md b/docs/history.md index caef87f..f55533c 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-04-03 v1.4.60 +- 신규 업로드만 샤딩 저장하고 기존 평면 `assets` 파일을 그대로 두면 운영자가 파일 구조를 볼 때 두 방식이 오래 섞여 보여 정리성이 떨어지므로, 기존 평면 자산도 같은 규칙으로 옮기는 일회성 마이그레이션 스크립트를 제공하는 편이 맞다고 판단했다. +- 기존 파일을 재인코딩해서 새 자산으로 다시 만드는 방식은 해시 중복 처리와 품질/메타 차이가 다시 얽힐 수 있으므로, 이번 샤딩 정리는 실제 파일 rename과 경로 참조 치환만 수행해 이미지 내용 자체는 건드리지 않는 쪽으로 정리했다. + ## 2026-04-03 v1.4.59 - 최근 최적화 이미지가 `assets` 바로 아래 평면 파일로 저장되면서 경로만으로 프로필/썸네일 역할을 구분할 수 없게 되었으므로, 관리자 아이템 분류는 폴더명 규칙 하나에만 기대지 말고 실제 DB 참조 컬럼을 역추적해 판별하는 편이 더 안전하다고 판단했다. - 이미지가 장기적으로 많이 쌓일 수 있는 서비스라면 한 폴더에 모든 파일을 계속 몰아넣기보다 적당한 수준의 하위 폴더 분산이 낫다고 보고, 신규 파일만 ID 앞 2글자로 1단계 샤딩 저장하되 기존 평면 경로는 그대로 유지하는 점진 방식으로 정리했다. diff --git a/docs/spec.md b/docs/spec.md index c2fa80c..7f38241 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -25,6 +25,7 @@ - 커스텀 아이템: `backend/uploads/custom/` - 시드 이미지: `backend/uploads/seeds/` - 최적화 이미지 자산: 신규 업로드는 `backend/uploads/assets/<앞2글자>/<파일명>.webp` 형태로 1단계 샤딩 저장하고, 기존 `backend/uploads/assets/<파일명>.webp` 평면 경로도 계속 읽는다. + - 기존 평면 자산을 샤딩 구조로 정리할 때는 `npm --prefix backend run images:shard-assets`를 실행하며, 스크립트가 파일 이동과 DB/JSON 참조 치환을 함께 처리한다. ## 화면 구조 - 좌측 패널 diff --git a/docs/todo.md b/docs/todo.md index 9fce140..83ff451 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,6 +1,7 @@ # 할 일 및 이슈 ## 단기 확인 +- `v1.4.60`에서 추가한 `npm --prefix backend run images:shard-assets`를 로컬/운영에 적용할 때는 먼저 백업을 확보한 뒤 실행하고, 평면 `/uploads/assets/<파일명>.webp` 파일이 샤딩 폴더로 이동하면서 `image_assets.src`와 각 참조 컬럼/JSON이 모두 새 경로로 바뀌었는지 확인한다. - `v1.4.59`에서 `thumbnail/avatar` 필터를 실제 DB 참조 역할 기준으로 다시 판별하도록 바꿨으므로, 최근 업로드처럼 `/uploads/assets/<파일명>.webp` 또는 `/uploads/assets/<앞2글자>/<파일명>.webp` 경로여도 썸네일 이미지/프로필 이미지 필터에서 빠지지 않는지 확인한다. - 신규 업로드 이미지는 `/uploads/assets/<앞2글자>/<파일명>.webp`로 저장되므로, 템플릿 썸네일/티어표 썸네일/프로필 아바타/아이템 업로드를 각각 새로 올린 뒤 실제 파일이 샤딩 폴더에 생성되고, 브라우저 표시·삭제·중복 재사용이 모두 기존처럼 동작하는지 QA한다. - 기존 `/uploads/assets/<파일명>.webp` 평면 경로는 그대로 유지되므로, 예전에 만든 티어표 썸네일과 아이템 이미지가 새 저장 구조 변경 후에도 깨지지 않는지 확인한다. diff --git a/docs/update.md b/docs/update.md index 0ee80e9..8de2c29 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-04-03 v1.4.60 +- 샤딩 구조가 생기기 전에 이미 `/uploads/assets/<파일명>.webp`로 평면 저장된 기존 최적화 이미지도 `/uploads/assets/<앞2글자>/<파일명>.webp`로 옮길 수 있도록 일회성 마이그레이션 스크립트 `backend/scripts/migrate-flat-assets-to-sharded.js`를 추가했다. +- 이 스크립트는 `backend/uploads/assets` 루트에 남아 있는 실제 평면 파일을 기준으로 샤딩 폴더로 이동하고, `image_assets.src`와 사용자 아바타/주제 썸네일/템플릿 아이템/사용자 아이템/티어표 JSON/템플릿 요청 JSON 참조도 같은 새 경로로 일괄 치환한다. +- 로컬 실행용 `npm --prefix backend run images:shard-assets` 스크립트를 추가해, 기존 100여 개 수준의 평면 자산도 별도 수작업 없이 한 번에 정리할 수 있게 했다. + ## 2026-04-03 v1.4.59 - 최근 업로드된 최적화 이미지가 `/uploads/assets/<파일명>.webp`처럼 하위 폴더 없이 저장되면서, `썸네일 이미지 / 프로필 이미지` 필터가 경로 문자열만으로 자산 종류를 판별하지 못해 비어 보일 수 있던 문제를 고쳤다. - 관리자 아이템 목록 생성 시 `users.avatar_src`, `topics.thumbnail_src`, `tierlists.thumbnail_src`, `template_requests.thumbnail_src_snapshot`을 역으로 모아 해당 `src`가 프로필 이미지인지 썸네일 이미지인지 먼저 판별하고, `thumbnail/avatar` 필터는 `sourceType`이 아니라 이 실제 참조 역할(`assetKind`) 기준으로 걸리도록 보정했다.