From 0e70d4482de2932c31c76a2a998466db507e56de Mon Sep 17 00:00:00 2001 From: zenn Date: Wed, 20 May 2026 14:33:13 +0900 Subject: [PATCH] =?UTF-8?q?v1.3.6:=20=EC=9A=B4=EC=98=81=20DB=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20=EC=9D=B4=EB=A0=A5=20=EB=B0=8F=20NAS=20=EB=AA=85?= =?UTF-8?q?=EB=A0=B9=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit schema_migrations로 적용 파일을 추적하고, 기존 운영 DB는 001부터 자동 실행하지 않도록 baseline 흐름을 둔다. Co-authored-by: Cursor --- docs/deploy.md | 20 +- docs/history.md | 6 + docs/map.md | 3 +- docs/spec.md | 8 + docs/update.md | 5 + package-lock.json | 4 +- package.json | 9 +- scripts/migrate-database.js | 331 ++++++++++++++++++++++++++++++ scripts/migrate-development-db.js | 124 +---------- 9 files changed, 372 insertions(+), 138 deletions(-) create mode 100644 scripts/migrate-database.js diff --git a/docs/deploy.md b/docs/deploy.md index 24d5b51..2fc035a 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -137,16 +137,14 @@ cp .env.example .env.production # Docker 빌드 및 실행 docker compose --env-file .env.production up -d --build -# 기존 운영 DB를 유지한 채 새 버전을 올릴 때 추천 글·네비 location 마이그레이션 적용 -docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/023_add_post_featured.sql -docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/024_navigation_recommended_location.sql -docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/025_posts_status_no_private.sql -docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/026_site_settings_show_post_updated_at.sql -docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/027_site_settings_home_cover.sql -docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/028_site_settings_announcement.sql -docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/029_site_settings_signup_blocked_usernames.sql -docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/030_analytics_daily_stats.sql -docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/031_analytics_engagement_and_realtime.sql +# 운영 DB 마이그레이션 상태 확인 +npm run db:migrate:prod:status + +# schema_migrations 도입 전 운영 DB가 이미 최신이면 최초 1회 기준점 기록(실제 SQL 실행 없음) +npm run db:migrate:prod:baseline + +# 이후 배포에서는 아직 적용되지 않은 SQL만 순서대로 실행 +npm run db:migrate:prod ``` ### Docker 네트워크 충돌 대응 @@ -200,6 +198,8 @@ docker compose --env-file .env.production up -d --build - NAS Docker 배포 시 PostgreSQL 초기 스키마는 `db/migrations/`의 SQL로 생성 - 로컬 개발 Docker Compose 실행 시 `ENV_FILE=.env.development`와 `--env-file .env.development`를 함께 사용 - 로컬 개발 DB 마이그레이션은 `npm run db:migrate:dev`로 실행 +- NAS 운영 DB 마이그레이션은 `npm run db:migrate:prod:status`로 적용 상태를 확인하고, `npm run db:migrate:prod`로 미적용 파일만 실행한다. +- `schema_migrations`가 없는 기존 운영 DB에서 `posts` 테이블이 감지되면 `db:migrate:prod`는 001부터 자동 실행하지 않는다. 현재 코드 기준 최신 DB라면 최초 1회 `npm run db:migrate:prod:baseline`으로 기존 파일을 적용 완료로 기록한다. 특정 번호까지만 기록하려면 예: `npm run db:migrate:prod:baseline -- --to=031`. - 네비게이션 계층(`parent_id`, `is_folder`)은 `017_navigation_hierarchy.sql` 적용 후 저장 API가 정상 동작한다(미적용 시 `INSERT` 컬럼 불일치). - 회원 마지막 로그인 표시(`previous_last_seen_at`, `previous_last_seen_ip`)는 `021_add_member_previous_login.sql` 적용 후 정상 동작한다. - 사이트 로고와 파비콘 저장(`logo_url`, `favicon_url`)은 `022_add_site_logo_urls.sql` 적용 후 정상 동작한다. diff --git a/docs/history.md b/docs/history.md index e0ae594..b3d4c81 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,11 @@ # 의사결정 이력 +## 2026-05-20 v1.3.6 + +### NAS 운영 마이그레이션 적용 이력 도입 + +운영 NAS에서 몇 번 SQL까지 적용했는지 파일명만으로 추적하면 누락과 중복 실행을 사람이 기억해야 한다. `schema_migrations` 테이블에 적용 완료 파일을 기록하고, 운영용 `db:migrate:prod`는 기록이 없는 파일만 실행하도록 했다. 이미 운영 중인 DB에는 과거 적용 기록이 없으므로, 기존 스키마가 감지되면 001부터 자동 실행하지 않고 baseline 기록을 요구해 데이터 변경 SQL의 의도치 않은 재실행을 막는다. + ## 2026-05-20 v1.3.5 ### 관리자 로그인·대시보드 차트·통계 보관 후속 diff --git a/docs/map.md b/docs/map.md index 06b77d1..c7104fd 100644 --- a/docs/map.md +++ b/docs/map.md @@ -291,7 +291,8 @@ | composables/useThemeMode.js | 사용자 화면 라이트/다크 테마 상태 관리 | | middleware/admin-auth.global.js | 관리자 페이지 접근 인증 | | scripts/dev-server.js | 로컬 개발 서버 링크 요약 출력 | -| scripts/migrate-development-db.js | 로컬 개발 DB 마이그레이션 실행 | +| scripts/migrate-database.js | 로컬·NAS DB 마이그레이션 적용/상태/baseline 실행 | +| scripts/migrate-development-db.js | 기존 로컬 개발 DB 마이그레이션 명령 호환 래퍼 | | .env.example | 환경 변수 예시 | | Dockerfile | NAS 운영 이미지 빌드 | | docker-compose.yml | NAS 컨테이너 실행 초안 | diff --git a/docs/spec.md b/docs/spec.md index 2e91f04..9f1ed30 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -213,6 +213,14 @@ components/content/ - 운영 DB 접속 정보는 로컬 기본 `.env`에 기록하지 않음 - DB 관리 도구는 CloudBeaver 등을 사용할 수 있도록 접속 정보를 환경별로 분리 +### 마이그레이션 적용 이력 + +- `schema_migrations` 테이블은 적용 완료된 SQL 파일명을 `file_name` 기준으로 기록한다. +- `npm run db:migrate:dev`와 `npm run db:migrate:prod`는 `db/migrations/*.sql` 중 `schema_migrations`에 없는 파일만 순서대로 실행한다. +- `npm run db:migrate:prod:status`는 NAS 운영 DB의 적용/대기 파일 목록을 출력한다. +- 기존 운영 DB에 `posts` 테이블은 있지만 `schema_migrations`가 없으면 `npm run db:migrate:prod`는 데이터 보호를 위해 001부터 자동 실행하지 않고 중단한다. +- 기존 운영 DB가 현재 코드 기준으로 이미 최신이면 `npm run db:migrate:prod:baseline`으로 현재 마이그레이션 파일들을 실행 없이 적용 완료로 기록한 뒤 이후 새 파일만 적용한다. + ### Posts (블로그 글) | 필드 | 타입 | 설명 | diff --git a/docs/update.md b/docs/update.md index 12c6a13..86722d8 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 이력 +## v1.3.6 + +- DB 마이그레이션: `schema_migrations` 적용 이력 관리와 `db:migrate:prod`, `db:migrate:prod:status`, `db:migrate:prod:baseline` 명령 추가. +- 운영 보호: 기존 운영 스키마가 있는데 적용 이력이 없으면 001부터 자동 실행하지 않고 baseline 안내 후 중단. + ## v1.3.5 - 관리자 로그인: 자동완성 값 동기화·제출 중만 버튼 비활성. 운영 HTTP에서 Secure 쿠키 미저장으로 로그인 루프되던 문제 수정. `server/utils/session-cookie.js`로 path `/`·`x-forwarded-proto` Secure 통일. diff --git a/package-lock.json b/package-lock.json index 5764ef2..cace365 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.3.4", + "version": "1.3.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.3.4", + "version": "1.3.6", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 91ff71b..2190986 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.3.5", + "version": "1.3.6", "private": true, "type": "module", "imports": { @@ -16,7 +16,12 @@ "test": "npm run build", "verify": "npm run lint && npm run test", "preview": "nuxt preview --dotenv .env.development --host 127.0.0.1 --port 43117", - "db:migrate:dev": "node scripts/migrate-development-db.js", + "db:migrate": "node scripts/migrate-database.js migrate", + "db:migrate:dev": "MIGRATION_ENV=development ENV_FILE=.env.development node scripts/migrate-database.js migrate", + "db:migrate:dev:status": "MIGRATION_ENV=development ENV_FILE=.env.development node scripts/migrate-database.js status", + "db:migrate:prod": "MIGRATION_ENV=production ENV_FILE=.env.production node scripts/migrate-database.js migrate", + "db:migrate:prod:status": "MIGRATION_ENV=production ENV_FILE=.env.production node scripts/migrate-database.js status", + "db:migrate:prod:baseline": "MIGRATION_ENV=production ENV_FILE=.env.production node scripts/migrate-database.js baseline", "postinstall": "nuxt prepare" }, "dependencies": { diff --git a/scripts/migrate-database.js b/scripts/migrate-database.js new file mode 100644 index 0000000..c9dd475 --- /dev/null +++ b/scripts/migrate-database.js @@ -0,0 +1,331 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' + +const rootDir = process.cwd() +const migrationMode = process.argv[2] || 'migrate' +const migrationEnvironment = process.env.MIGRATION_ENV || 'development' +const isProductionMigration = migrationEnvironment === 'production' +const envFile = process.env.ENV_FILE || (isProductionMigration ? '.env.production' : '.env.development') +const serviceName = process.env.DB_SERVICE || 'sori-studio-db' +const migrationsDir = join(rootDir, 'db', 'migrations') +const containerMigrationsDir = '/docker-entrypoint-initdb.d' +const schemaMigrationsTable = 'schema_migrations' + +/** + * 환경 파일 값 조회 + * @returns {Record} 환경 변수 맵 + */ +const readEnvFile = () => { + const envPath = join(rootDir, envFile) + + if (!existsSync(envPath)) { + return {} + } + + return readFileSync(envPath, 'utf8') + .split('\n') + .reduce((envValues, line) => { + const trimmedLine = line.trim() + + if (!trimmedLine || trimmedLine.startsWith('#')) { + return envValues + } + + const separatorIndex = trimmedLine.indexOf('=') + + if (separatorIndex < 0) { + return envValues + } + + const key = trimmedLine.slice(0, separatorIndex).trim() + const value = trimmedLine.slice(separatorIndex + 1).trim().replace(/^["']|["']$/g, '') + envValues[key] = value + return envValues + }, {}) +} + +const envValues = readEnvFile() +const databaseName = process.env.POSTGRES_DB || envValues.POSTGRES_DB || 'sori_studio' +const databaseUser = process.env.POSTGRES_USER || envValues.POSTGRES_USER || 'sori_studio' + +/** + * SQL 문자열 리터럴 이스케이프 + * @param {string} value - SQL 문자열 값 + * @returns {string} 이스케이프된 SQL 문자열 + */ +const escapeSqlLiteral = (value) => value.replaceAll("'", "''") + +/** + * 기준 마이그레이션 번호 인자 조회 + * @returns {string} 기준 번호 또는 파일명 + */ +const getBaselineTarget = () => { + const toArgument = process.argv.find((argument) => argument.startsWith('--to=')) + return toArgument ? toArgument.replace('--to=', '') : '' +} + +/** + * 명령 실행 결과 확인 + * @param {string} command - 실행할 명령 + * @param {string[]} args - 명령 인자 + * @param {Object} options - 실행 옵션 + * @returns {import('node:child_process').SpawnSyncReturns} 실행 결과 + */ +const runCommand = (command, args, options = {}) => { + const result = spawnSync(command, args, { + cwd: rootDir, + env: { + ...process.env, + ENV_FILE: envFile + }, + encoding: 'utf8', + stdio: options.stdio || 'inherit', + ...options + }) + + if (result.status !== 0) { + process.exit(result.status || 1) + } + + return result +} + +/** + * Docker compose 인자 생성 + * @param {string[]} args - compose 하위 명령 인자 + * @returns {string[]} 전체 docker compose 인자 + */ +const getComposeArgs = (args) => ['compose', '--env-file', envFile, ...args] + +/** + * psql 실행 인자 생성 + * @param {string[]} args - psql 인자 + * @returns {string[]} docker compose exec psql 인자 + */ +const getPsqlArgs = (args) => getComposeArgs([ + 'exec', + '-T', + serviceName, + 'psql', + '-v', + 'ON_ERROR_STOP=1', + '-U', + databaseUser, + '-d', + databaseName, + ...args +]) + +/** + * SQL 실행 + * @param {string} sql - 실행할 SQL + * @returns {void} + */ +const runSql = (sql) => { + runCommand('docker', getPsqlArgs(['-c', sql])) +} + +/** + * SQL 실행 결과 조회 + * @param {string} sql - 조회할 SQL + * @returns {string} 조회 결과 + */ +const querySql = (sql) => { + const result = runCommand('docker', getPsqlArgs(['-At', '-c', sql]), { + stdio: 'pipe' + }) + + return (result.stdout || '').trim() +} + +/** + * 마이그레이션 파일 목록 조회 + * @returns {string[]} SQL 파일 목록 + */ +const getMigrationFiles = () => readdirSync(migrationsDir) + .filter((fileName) => fileName.endsWith('.sql')) + .sort((firstFile, secondFile) => firstFile.localeCompare(secondFile)) + +/** + * DB 컨테이너 시작 + * @returns {void} + */ +const startDatabase = () => { + runCommand('docker', getComposeArgs([ + 'up', + '-d', + serviceName + ])) +} + +/** + * DB 준비 상태 확인 + * @returns {void} + */ +const waitForDatabase = () => { + for (let attempt = 1; attempt <= 20; attempt += 1) { + const result = spawnSync('docker', getComposeArgs([ + 'exec', + '-T', + serviceName, + 'pg_isready', + '-U', + databaseUser, + '-d', + databaseName + ]), { + cwd: rootDir, + stdio: 'ignore' + }) + + if (result.status === 0) { + return + } + + spawnSync('sleep', ['1']) + } + + console.error('DB가 준비되지 않았습니다.') + process.exit(1) +} + +/** + * 적용 이력 테이블 존재 여부 확인 + * @returns {boolean} 존재 여부 + */ +const hasSchemaMigrationsTable = () => querySql(`SELECT to_regclass('public.${schemaMigrationsTable}') IS NOT NULL;`) === 't' + +/** + * 기존 애플리케이션 스키마 존재 여부 확인 + * @returns {boolean} 존재 여부 + */ +const hasApplicationSchema = () => querySql("SELECT to_regclass('public.posts') IS NOT NULL;") === 't' + +/** + * 적용 이력 테이블 생성 + * @returns {void} + */ +const ensureSchemaMigrationsTable = () => { + runSql(` +CREATE TABLE IF NOT EXISTS ${schemaMigrationsTable} ( + file_name TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +`) +} + +/** + * 적용 완료 마이그레이션 목록 조회 + * @returns {Set} 적용 완료 파일명 집합 + */ +const getAppliedMigrationFiles = () => { + if (!hasSchemaMigrationsTable()) { + return new Set() + } + + const output = querySql(`SELECT file_name FROM ${schemaMigrationsTable} ORDER BY file_name;`) + return new Set(output ? output.split('\n').filter(Boolean) : []) +} + +/** + * SQL 마이그레이션 파일 실행 + * @param {string} fileName - 실행할 SQL 파일명 + * @returns {void} + */ +const runMigrationFile = (fileName) => { + runCommand('docker', getPsqlArgs(['-f', `${containerMigrationsDir}/${fileName}`])) + runSql(`INSERT INTO ${schemaMigrationsTable} (file_name) VALUES ('${escapeSqlLiteral(fileName)}') ON CONFLICT (file_name) DO NOTHING;`) +} + +/** + * 마이그레이션 상태 출력 + * @returns {void} + */ +const printMigrationStatus = () => { + const migrationFiles = getMigrationFiles() + const appliedFiles = getAppliedMigrationFiles() + + if (!hasSchemaMigrationsTable()) { + console.log('schema_migrations 테이블이 없습니다.') + console.log('기존 운영 DB라면 먼저 npm run db:migrate:prod:baseline 으로 현재 파일들을 적용 완료로 기록하세요.') + } + + for (const fileName of migrationFiles) { + const status = appliedFiles.has(fileName) ? 'applied' : 'pending' + console.log(`${status} ${fileName}`) + } +} + +/** + * 기준 파일까지 적용 완료로 기록 + * @returns {void} + */ +const baselineMigrations = () => { + ensureSchemaMigrationsTable() + + const migrationFiles = getMigrationFiles() + const baselineTarget = getBaselineTarget() + const targetIndex = baselineTarget + ? migrationFiles.findIndex((fileName) => fileName === baselineTarget || fileName.startsWith(`${baselineTarget}_`)) + : migrationFiles.length - 1 + + if (targetIndex < 0) { + console.error(`기준 마이그레이션을 찾을 수 없습니다: ${baselineTarget}`) + process.exit(1) + } + + const baselineFiles = migrationFiles.slice(0, targetIndex + 1) + + for (const fileName of baselineFiles) { + runSql(`INSERT INTO ${schemaMigrationsTable} (file_name) VALUES ('${escapeSqlLiteral(fileName)}') ON CONFLICT (file_name) DO NOTHING;`) + console.log(`baseline ${fileName}`) + } + + console.log(`baseline 완료: ${baselineFiles.length}개 파일 기록`) +} + +/** + * 마이그레이션 실행 + * @returns {void} + */ +const migrateDatabase = () => { + const trackingTableExists = hasSchemaMigrationsTable() + + if (isProductionMigration && !trackingTableExists && hasApplicationSchema()) { + console.error('운영 DB에 기존 스키마가 있지만 schema_migrations 적용 이력이 없습니다.') + console.error('데이터 보호를 위해 001부터 자동 실행하지 않습니다.') + console.error('현재 운영 DB가 최신 상태라면 npm run db:migrate:prod:baseline 으로 기준점을 먼저 기록하세요.') + console.error('상태 확인: npm run db:migrate:prod:status') + process.exit(1) + } + + ensureSchemaMigrationsTable() + + const migrationFiles = getMigrationFiles() + const appliedFiles = getAppliedMigrationFiles() + const pendingFiles = migrationFiles.filter((fileName) => !appliedFiles.has(fileName)) + + if (pendingFiles.length === 0) { + console.log('적용할 마이그레이션이 없습니다.') + return + } + + for (const fileName of pendingFiles) { + console.log(`apply ${fileName}`) + runMigrationFile(fileName) + } + + console.log(`마이그레이션 완료: ${pendingFiles.length}개 파일 적용`) +} + +startDatabase() +waitForDatabase() + +if (migrationMode === 'status') { + printMigrationStatus() +} else if (migrationMode === 'baseline') { + baselineMigrations() +} else { + migrateDatabase() +} diff --git a/scripts/migrate-development-db.js b/scripts/migrate-development-db.js index c1f0a32..e121abd 100644 --- a/scripts/migrate-development-db.js +++ b/scripts/migrate-development-db.js @@ -1,123 +1 @@ -import { readdirSync } from 'node:fs' -import { join } from 'node:path' -import { spawnSync } from 'node:child_process' - -const rootDir = process.cwd() -const envFile = process.env.ENV_FILE || '.env.development' -const serviceName = process.env.DB_SERVICE || 'sori-studio-db' -const databaseName = process.env.POSTGRES_DB || 'sori_studio' -const databaseUser = process.env.POSTGRES_USER || 'sori_studio' -const migrationsDir = join(rootDir, 'db', 'migrations') -const containerMigrationsDir = '/docker-entrypoint-initdb.d' - -/** - * 명령 실행 결과 확인 - * @param {string} command - 실행할 명령 - * @param {string[]} args - 명령 인자 - * @param {Object} options - 실행 옵션 - * @returns {void} - */ -const runCommand = (command, args, options = {}) => { - const result = spawnSync(command, args, { - cwd: rootDir, - env: { - ...process.env, - ENV_FILE: envFile - }, - stdio: 'inherit', - ...options - }) - - if (result.status !== 0) { - process.exit(result.status || 1) - } -} - -/** - * 마이그레이션 파일 목록 조회 - * @returns {string[]} SQL 파일 목록 - */ -const getMigrationFiles = () => readdirSync(migrationsDir) - .filter((fileName) => fileName.endsWith('.sql')) - .sort((firstFile, secondFile) => firstFile.localeCompare(secondFile)) - -/** - * 개발 DB 컨테이너 시작 - * @returns {void} - */ -const startDatabase = () => { - runCommand('docker', [ - 'compose', - '--env-file', - envFile, - 'up', - '-d', - serviceName - ]) -} - -/** - * 개발 DB 준비 상태 확인 - * @returns {void} - */ -const waitForDatabase = () => { - for (let attempt = 1; attempt <= 20; attempt += 1) { - const result = spawnSync('docker', [ - 'exec', - serviceName, - 'pg_isready', - '-U', - databaseUser, - '-d', - databaseName - ], { - cwd: rootDir, - stdio: 'ignore' - }) - - if (result.status === 0) { - return - } - - spawnSync('sleep', ['1']) - } - - console.error('개발 DB가 준비되지 않았습니다.') - process.exit(1) -} - -/** - * SQL 마이그레이션 파일 실행 - * @param {string} fileName - 실행할 SQL 파일명 - * @returns {void} - */ -const runMigrationFile = (fileName) => { - runCommand('docker', [ - 'exec', - serviceName, - 'psql', - '-v', - 'ON_ERROR_STOP=1', - '-U', - databaseUser, - '-d', - databaseName, - '-f', - `${containerMigrationsDir}/${fileName}` - ]) -} - -/** - * 개발 DB 마이그레이션 실행 - * @returns {void} - */ -const migrateDevelopmentDatabase = () => { - startDatabase() - waitForDatabase() - - for (const fileName of getMigrationFiles()) { - runMigrationFile(fileName) - } -} - -migrateDevelopmentDatabase() +import './migrate-database.js'