미디어 카탈로그 관찰 모드 정리

This commit is contained in:
2026-07-29 13:26:05 +09:00
parent ebc4a95b37
commit 3698032886
10 changed files with 27 additions and 41 deletions

View File

@@ -1,6 +1,6 @@
# 배포 가이드
> v1.11.0은 v1.10 개발 구간을 마감한 minor 릴리스다. 운영 배포 전 `068_csp_violation_aggregates.sql`, `069_media_catalog.sql`, `070_post_import_jobs.sql`을 순서대로 적용하고 미디어 카탈로그는 reconcile → (선택) shadow 관찰 → `MEDIA_CATALOG_MODE=database` 고정 순서를 지킨다. 게시물 Export·Import·일반 업로드는 디스크·임시 디렉터리 스트리밍을 사용하므로 NAS 앱 컨테이너의 임시 저장 공간과 `public/uploads/exports`, `public/uploads/_import-jobs` 쓰기 권한을 확인한다.
> v1.11.0은 v1.10 개발 구간을 마감한 minor 릴리스다. 운영 배포 전 `068_csp_violation_aggregates.sql`, `069_media_catalog.sql`, `070_post_import_jobs.sql`을 순서대로 적용하고 미디어 카탈로그는 reconcile 완료 후 `MEDIA_CATALOG_MODE=database` 고정 순서를 지킨다. 게시물 Export·Import·일반 업로드는 디스크·임시 디렉터리 스트리밍을 사용하므로 NAS 앱 컨테이너의 임시 저장 공간과 `public/uploads/exports`, `public/uploads/_import-jobs` 쓰기 권한을 확인한다.
> 로컬 기준 v1.11.2에서 `npm run lint`와 `npm run build` 검증을 통과했다.
## 빌드 유형
@@ -42,7 +42,6 @@ sh scripts/migrate-production-db.sh status
- `069_media_catalog.sql`은 미디어 카탈로그·참조·상태 계약을 만든다. 최초 상태는 `ready=false`이므로 마이그레이션 직후 곧바로 `MEDIA_CATALOG_MODE=database`만 켠 채 운영하지 않는다. 반드시 reconcile을 먼저 끝낸다.
- 배포 직후(또는 `filesystem`) 상태에서 관리자 인증 후 `POST /admin/api/media/reconcile`을 1회 실행한다. 이 API는 파일을 삭제하거나 이동하지 않고 발견 파일 upsert, 참조 백필, 사라진 파일 `missing` 표시만 수행한다.
- `media_catalog_state`의 `ready=true`, 파일시스템/카탈로그 `count`와 `bytes` 일치, `staging`·`error` 행 0을 확인한다.
- 필요하면 짧게 `MEDIA_CATALOG_MODE=shadow`로 재기동해 관리자 미디어를 탐색하고 로그의 `count`, `bytes`, 종류별 차이가 0인지 본다.
- 확인이 끝나면 운영 env를 `MEDIA_CATALOG_MODE=database`로 고정하고 재기동한다. 개발 환경도 동일하게 `database`로 고정한다. 준비 미완료·불일치·DB 조회 오류가 발생하면 애플리케이션은 자동으로 파일시스템 목록으로 fallback한다.
- 관리자 화면의 임시 전환 버튼·런타임 모드 덮어쓰기 API는 제거했다. 모드 변경은 환경 변수만 사용한다.
- `npm run media:reconcile:dev`는 loopback 비운영 DB 전용 안전장치가 있으므로 개발 DB 확인에만 사용한다. 운영에서는 관리자 API를 사용한다.

View File

@@ -1,3 +1,7 @@
## 2026-07-29 v1.11.127 — 미디어 카탈로그 전환 관찰 모드는 정리한다
미디어 카탈로그는 개발과 운영 전환 과정에서 reconcile과 `shadow` 비교를 거쳐 `database` 모드로 고정하는 흐름을 사용했다. 이제 런타임 전환 UI가 제거되고 운영 계약도 환경 변수 기반으로 굳었으므로, 계속 사용하지 않는 `shadow` 비교 분기는 관리자 미디어 조회의 부수 로그와 비동기 DB 조회만 남긴다. 따라서 허용 모드는 `filesystem`과 `database`로 축소하고, `database` 준비 실패 시 파일시스템 목록으로 되돌아가는 안전 fallback만 유지한다.
## 2026-07-29 v1.11.126 — 미디어 삽입 실행은 에디터 밖에서 연결한다
관리자 Markdown 에디터의 미디어 선택 상태와 업로드 요청은 이미 `useAdminMarkdownMediaPicker`로 분리되어 있었지만, 선택된 항목을 이미지·갤러리·활성 갤러리·슬래시 대기 줄에 실제 적용하는 연결부는 에디터 본체에 남아 있었다. 이 실행부가 본체에 남으면 미디어 모달 상태와 본문 삽입 규칙, 업로드 후 목록 갱신이 한 파일에서 다시 얽힌다. 따라서 `useAdminMarkdownMediaActions`가 선택·업로드 미디어 삽입 계획을 실제 본문 반영으로 연결하고, 에디터는 블록 삽입·활성 갤러리 추가·토스트 같은 외부 콜백만 제공한다.

View File

@@ -139,8 +139,8 @@
### 미디어 카탈로그
- 파일시스템은 미디어 파일의 실체와 제공 기준이고 PostgreSQL은 목록 검색·사용처·상태를 위한 카탈로그다. `MEDIA_CATALOG_MODE`는 `filesystem`, `shadow`, `database`를 허용하며 그 외 값·미설정은 `filesystem`으로 처리한다. 개발·전환 완료 환경은 `database`로 고정하고, 운영도 reconcile 완료 뒤 `database`로 고정한다.
- `filesystem`은 디스크 스캔 결과를 반환한다. `shadow`는 파일시스템 결과를 반환하면서 DB 카탈로그의 개수·bytes·종류 차이를 로그로 비교한다. `database`는 DB에서 목록을 반환하되 `media_catalog_state`가 준비되지 않았거나 reconcile 전, `staging`/`error` 행 존재, 개수·bytes 불일치, 쿼리 실패 시 파일시스템으로 fallback한다.
- 파일시스템은 미디어 파일의 실체와 제공 기준이고 PostgreSQL은 목록 검색·사용처·상태를 위한 카탈로그다. `MEDIA_CATALOG_MODE`는 `filesystem`, `database`를 허용하며 그 외 값·미설정은 `filesystem`으로 처리한다. 개발·전환 완료 환경은 `database`로 고정하고, 운영도 reconcile 완료 뒤 `database`로 고정한다.
- `filesystem`은 디스크 스캔 결과를 반환한다. `database`는 DB에서 목록을 반환하되 `media_catalog_state`가 준비되지 않았거나 reconcile 전, `staging`/`error` 행 존재, 개수·bytes 불일치, 쿼리 실패 시 파일시스템으로 fallback한다.
- `media_metadata`는 URL·저장 키·파일명·확장자·종류·역할·용량·수정 시각·원본/파생 관계·생성 출처·`staging|active|missing|deleting|error` 상태를 저장한다. `media_references`는 게시물·페이지·태그·회원·사이트 설정·네비게이션의 URL 참조와 표시 메타를 저장하고, `media_catalog_state` 단일 행은 reconcile 시각과 파일시스템/카탈로그 개수·bytes·준비 상태를 저장한다.
- 관리자 일반 업로드는 multipart 파일명을 UTF-8로 복구한 뒤 NFC 정규화·안전 문자 치환으로 저장한다. macOS NFD 한글 파일명도 `스크린샷-…` 형태로 유지되며, busboy Latin-1 오해로 인한 `á-á-³…` 깨짐을 막는다.
- 앱이 관리하는 업로드·Import·썸네일·아바타·파일명 변경·삭제는 디스크 성공 뒤 카탈로그에 최선 노력으로 dual-write한다. 게시물·페이지·태그·회원·사이트 설정·네비게이션 저장은 해당 대상의 `media_references`를 현재 필드 값으로 교체한다. 보조 카탈로그 기록 실패는 이미 성공한 파일·콘텐츠 작업을 되돌리지 않는다.

View File

@@ -2,9 +2,6 @@
## v1.11 내부 개선
- 미디어 카탈로그 전환 코드 수명 정리.
- 운영 `MEDIA_CATALOG_MODE=database` 안정화 후 `shadow`/`filesystem` 런타임 분기 축소 가능 여부 검토.
- fallback은 운영 안정성에 필요하므로 제거 전 reconcile·로그·테스트 기준을 먼저 정리.
- 운영 의존성 보안 업데이트 검토.
- `sharp <0.35.0` audit 경고는 breaking change 가능성이 있어 별도 검증 브랜치에서 이미지 업로드·썸네일 생성·본문 이미지 백필까지 확인 후 업데이트.
- 문서 구조 정리.

View File

@@ -1,5 +1,11 @@
# 업데이트 이력
## v1.11.127
- 미디어 카탈로그 조회 모드에서 전환 관찰용 `shadow` 모드 제거.
- 관리자 미디어 목록 API의 비동기 shadow 비교 로그와 비교 요약 코드 정리.
- `MEDIA_CATALOG_MODE` 허용값을 `filesystem`·`database`로 축소하고 모드 정규화 단위 테스트 추가.
## v1.11.126
- 관리자 Markdown 에디터의 미디어 삽입 적용 실행을 `useAdminMarkdownMediaActions` composable로 분리.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.11.126",
"version": "1.11.127",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.11.126",
"version": "1.11.127",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

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

View File

@@ -226,17 +226,6 @@ export const createPagedMediaResponse = (items, query) => {
}
}
/**
* 미디어 목록의 민감정보 없는 비교 수치를 만든다.
* @param {Array<Object>} items - 미디어 항목 목록
* @returns {{ count: number, bytes: number, kinds: Record<string, number> }} 비교 수치
*/
const createMediaComparisonSummary = (items) => ({
count: items.length,
bytes: items.reduce((total, item) => total + Number(item.size || 0), 0),
kinds: countMediaKinds(items)
})
/**
* 관리자 미디어 목록 API
* @param {import('h3').H3Event} event - 요청 이벤트
@@ -272,23 +261,6 @@ export default defineEventHandler(async (event) => {
}
} else {
items = await listMediaItems()
if (mode === 'shadow') {
listMediaCatalogItems().then((catalogItems) => {
const filesystem = createMediaComparisonSummary(items)
const catalog = createMediaComparisonSummary(catalogItems)
console.info('[media-catalog] shadow 차이', {
count: catalog.count - filesystem.count,
bytes: catalog.bytes - filesystem.bytes,
kinds: Object.fromEntries(Object.keys(filesystem.kinds).map((kind) => [
kind,
Number(catalog.kinds[kind] || 0) - Number(filesystem.kinds[kind] || 0)
]))
})
}).catch(() => {
console.warn('[media-catalog] shadow 비교 실패')
})
}
}
if (paged) {

View File

@@ -5,17 +5,17 @@
/**
* 카탈로그 모드 문자열을 허용 값으로 정규화한다.
* @param {unknown} value - 원본 모드
* @returns {'filesystem'|'shadow'|'database'|null} 정규화된 모드
* @returns {'filesystem'|'database'|null} 정규화된 모드
*/
export const normalizeMediaCatalogMode = (value) => {
const mode = String(value || '').trim().toLowerCase()
return ['filesystem', 'shadow', 'database'].includes(mode) ? mode : null
return ['filesystem', 'database'].includes(mode) ? mode : null
}
/**
* 현재 적용 중인 미디어 카탈로그 모드를 반환한다.
* @returns {'filesystem'|'shadow'|'database'} 조회 모드
* @returns {'filesystem'|'database'} 조회 모드
*/
export const getMediaCatalogMode = () => {
return normalizeMediaCatalogMode(process.env.MEDIA_CATALOG_MODE) || 'filesystem'

View File

@@ -7,6 +7,14 @@ import {
extractMediaReferences,
normalizeMediaCatalogUrl
} from '../lib/media-catalog.js'
import { normalizeMediaCatalogMode } from '../server/utils/media-catalog-mode.js'
test('미디어 카탈로그 모드는 운영 조회 모드만 허용한다', () => {
assert.equal(normalizeMediaCatalogMode('filesystem'), 'filesystem')
assert.equal(normalizeMediaCatalogMode('DATABASE'), 'database')
assert.equal(normalizeMediaCatalogMode(' shadow '), null)
assert.equal(normalizeMediaCatalogMode(''), null)
})
test('SQL LIKE wildcard 검색 문자를 리터럴로 이스케이프한다', () => {
assert.equal(escapePostgresLikeLiteral('100%_done!'), '100!%!_done!!')