diff --git a/.github/workflows/database-docs.yml b/.github/workflows/database-docs.yml
new file mode 100644
index 0000000..a58726d
--- /dev/null
+++ b/.github/workflows/database-docs.yml
@@ -0,0 +1,108 @@
+name: Database Documentation
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'src/main/resources/db/migration/**'
+ - 'scripts/db-docs/**'
+ - '.github/workflows/database-docs.yml'
+ pull_request:
+ branches: [main]
+ paths:
+ - 'src/main/resources/db/migration/**'
+ - 'scripts/db-docs/**'
+ - '.github/workflows/database-docs.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: database-docs-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ build-docs:
+ name: Build database documentation
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+
+ services:
+ postgres:
+ image: postgres:17-alpine
+ env:
+ POSTGRES_DB: fowoco_docs
+ POSTGRES_USER: fowoco_docs
+ POSTGRES_PASSWORD: fowoco_docs_ci_only
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U fowoco_docs -d fowoco_docs"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+
+ - name: Set up Node.js 24
+ uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
+ with:
+ node-version: '24'
+
+ - name: Test documentation generator
+ run: node --test scripts/db-docs/generate-site.test.mjs
+
+ - name: Generate database documentation
+ run: ./scripts/db-docs/generate.sh
+ env:
+ DB_DOCS_CONTAINER_HOST: 127.0.0.1
+ DB_DOCS_EPHEMERAL: 'true'
+ DB_DOCS_PORT: '5432'
+ DB_DOCS_DATABASE: fowoco_docs
+ DB_DOCS_USER: fowoco_docs
+ DB_DOCS_PASSWORD: fowoco_docs_ci_only
+ DB_DOCS_GIT_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }}
+
+ - name: Upload database documentation preview
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: database-docs-site
+ path: build/db-docs/site
+ if-no-files-found: error
+ retention-days: 14
+
+ deploy-pages:
+ name: Deploy database documentation
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ needs: build-docs
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ pages: write
+ id-token: write
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+
+ steps:
+ - name: Download database documentation
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
+ with:
+ name: database-docs-site
+ path: site
+
+ - name: Configure GitHub Pages
+ uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5
+
+ - name: Upload GitHub Pages artifact
+ uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4
+ with:
+ path: site
+
+ - name: Deploy GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
diff --git a/README.md b/README.md
index 0dc9cc2..5e5fab1 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,12 @@ FOWOCO는 단순 번역 서비스가 아닙니다. 체류·계약·서류·신
계획 문서는 현재 동작하는 API가 아닙니다. 구현의 원본은 코드·테스트와 실행 시 생성되는 OpenAPI이고, 장기 아키텍처 결정은 [ADR](docs/adr/README.md), 계획 범위와 예시는 [API 카탈로그](https://github.com/fowoco/server/wiki/09-API-Specification)와 Issue에서 확인합니다.
+데이터베이스 구조는 [DB 문서 사이트](https://fowoco.github.io/server/)에서 전체
+ERD, 테이블·컬럼·제약조건과 Flyway 적용 이력을 확인할 수 있습니다. 이 사이트는
+`main`의 Migration을 일회용 빈 PostgreSQL에 적용해 자동 생성하며 실제 데이터는
+포함하지 않습니다. PR 미리보기와 로컬 생성 방법은
+[데이터베이스 문서 사용법](docs/database-documentation.md)을 봅니다.
+
## 5분 실행
### 필요한 것
diff --git a/docs/database-documentation.md b/docs/database-documentation.md
new file mode 100644
index 0000000..e76c3b7
--- /dev/null
+++ b/docs/database-documentation.md
@@ -0,0 +1,104 @@
+# 데이터베이스 문서 사용법
+
+FOWOCO Server의 데이터베이스 문서는 `main`의 Flyway Migration을 일회용
+PostgreSQL에 처음부터 적용한 결과로 생성합니다.
+
+- 팀 공유 사이트:
+- 변경의 원본: `src/main/resources/db/migration`
+- 구조 결정의 원본: `docs/adr`
+
+문서는 구조를 쉽게 찾기 위한 보조 수단입니다. 문서 화면에서 DB를 변경할 수
+없으며, Flyway SQL을 거치지 않은 변경은 정식 변경으로 인정하지 않습니다.
+
+## 무엇을 볼 수 있나요?
+
+| 메뉴 | 확인할 수 있는 내용 |
+| --- | --- |
+| 테이블 구조 | 전체 ERD, 컬럼 타입, Nullable, 기본값 |
+| 관계 | PK, FK, UNIQUE, CHECK, INDEX |
+| Migration 이력 | 적용 버전, 상태, 적용 시각, 실행 시간 |
+| 생성 정보 | 기준 Git commit, Flyway·Schema version, 생성 시각 |
+
+운영 DB의 데이터, 계정, 접속 주소, 비밀번호는 문서에 포함하지 않습니다.
+
+## 언제 갱신되나요?
+
+다음 경로가 변경되어 `main`에 병합되면 `Database Documentation` Workflow가
+자동 실행됩니다.
+
+```text
+src/main/resources/db/migration/**
+scripts/db-docs/**
+.github/workflows/database-docs.yml
+```
+
+Workflow는 다음 순서로 동작합니다.
+
+```text
+빈 PostgreSQL 시작
+→ Flyway migrate
+→ Flyway validate
+→ SchemaSpy HTML 생성
+→ Migration 이력 페이지 생성
+→ GitHub Pages 배포
+```
+
+Migration 적용이나 `validate`가 실패하면 기존 Pages를 덮어쓰지 않습니다.
+
+## PR에서 먼저 확인하기
+
+Migration을 바꾼 PR에서는 Pages를 배포하지 않습니다.
+
+1. PR의 `Checks`에서 `Database Documentation`을 엽니다.
+2. 실행 결과 아래의 `Artifacts`로 이동합니다.
+3. `database-docs-site`를 내려받습니다.
+4. 압축을 풀고 `index.html`을 브라우저로 엽니다.
+
+Artifact는 14일 동안 보관합니다. PR 작성자는 ERD 변경이 의도한 구조인지
+확인한 뒤 리뷰를 요청합니다.
+
+## 로컬에서 생성하기
+
+Docker Desktop 또는 Docker Engine과 Node.js 24 이상이 필요합니다.
+
+```bash
+./scripts/db-docs/generate-local.sh
+```
+
+이 명령은 작업마다 별도의 Docker network와 PostgreSQL container를 만들고,
+완료되거나 실패하면 정확히 그 임시 자원만 제거합니다.
+
+생성 결과는 Git에 포함되지 않는 아래 경로에 있습니다.
+
+```text
+build/db-docs/site/index.html
+```
+
+macOS에서는 다음 명령으로 열 수 있습니다.
+
+```bash
+open build/db-docs/site/index.html
+```
+
+## 실패했을 때 확인할 것
+
+| 증상 | 확인 |
+| --- | --- |
+| Docker를 찾지 못함 | Docker Desktop 설치·실행 여부 |
+| PostgreSQL 준비 실패 | 기존 container와 Docker 자원 상태 |
+| Flyway migrate 실패 | 가장 최근 Migration SQL과 PostgreSQL 문법 |
+| Flyway validate 실패 | 이미 적용된 Migration을 수정·삭제했는지 |
+| SchemaSpy 실패 | 테이블·FK·제약조건 오류와 container 로그 |
+| Pages 배포 실패 | Repository Pages Source와 `github-pages` Environment |
+
+적용된 Migration을 수정하거나 `flyway repair`로 실패를 숨기지 않습니다. 새
+버전의 Migration을 추가해 정정하고, 위험한 변경은 ADR과 리뷰를 먼저 거칩니다.
+
+## 보안 원칙
+
+- 운영·Staging DB에 연결하지 않습니다.
+- 실제 사용자·근로자·Demo Seed 데이터를 넣지 않습니다.
+- 임시 DB 값은 Workflow 내부에서만 사용합니다.
+- Personal Access Token과 별도 Cloud Secret은 사용하지 않습니다.
+- Workflow Action은 full-length commit SHA로 고정합니다.
+- 생성 사이트에는 Schema 구조와 비민감 build metadata만 게시합니다.
diff --git a/scripts/db-docs/generate-local.sh b/scripts/db-docs/generate-local.sh
new file mode 100755
index 0000000..4b551f3
--- /dev/null
+++ b/scripts/db-docs/generate-local.sh
@@ -0,0 +1,50 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+RUN_ID="$$"
+NETWORK_NAME="fowoco-db-docs-${RUN_ID}"
+CONTAINER_NAME="fowoco-db-docs-postgres-${RUN_ID}"
+POSTGRES_IMAGE="${DB_DOCS_POSTGRES_IMAGE:-postgres:17-alpine}"
+DATABASE_NAME="fowoco_docs"
+DATABASE_USER="fowoco_docs"
+DATABASE_PASSWORD="fowoco_docs_local_only"
+
+if ! command -v docker >/dev/null 2>&1 || ! docker info >/dev/null 2>&1; then
+ echo "[db-docs] 실행 중인 Docker Desktop 또는 Docker Engine이 필요합니다." >&2
+ exit 1
+fi
+
+cleanup() {
+ docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true
+ docker network rm "${NETWORK_NAME}" >/dev/null 2>&1 || true
+}
+trap cleanup EXIT INT TERM
+
+docker network create "${NETWORK_NAME}" >/dev/null
+docker run -d --rm \
+ --name "${CONTAINER_NAME}" \
+ --network "${NETWORK_NAME}" \
+ -e "POSTGRES_DB=${DATABASE_NAME}" \
+ -e "POSTGRES_USER=${DATABASE_USER}" \
+ -e "POSTGRES_PASSWORD=${DATABASE_PASSWORD}" \
+ "${POSTGRES_IMAGE}" >/dev/null
+
+for attempt in $(seq 1 30); do
+ if docker exec "${CONTAINER_NAME}" pg_isready -U "${DATABASE_USER}" -d "${DATABASE_NAME}" >/dev/null 2>&1; then
+ break
+ fi
+ if [[ "${attempt}" == "30" ]]; then
+ echo "[db-docs] PostgreSQL이 준비되지 않았습니다." >&2
+ exit 1
+ fi
+ sleep 1
+done
+
+DB_DOCS_DOCKER_NETWORK="${NETWORK_NAME}" \
+DB_DOCS_CONTAINER_HOST="${CONTAINER_NAME}" \
+DB_DOCS_EPHEMERAL=true \
+DB_DOCS_DATABASE="${DATABASE_NAME}" \
+DB_DOCS_USER="${DATABASE_USER}" \
+DB_DOCS_PASSWORD="${DATABASE_PASSWORD}" \
+"${SCRIPT_DIR}/generate.sh"
diff --git a/scripts/db-docs/generate-site.mjs b/scripts/db-docs/generate-site.mjs
new file mode 100644
index 0000000..eb189ba
--- /dev/null
+++ b/scripts/db-docs/generate-site.mjs
@@ -0,0 +1,262 @@
+import { readFile, writeFile, mkdir, access } from 'node:fs/promises'
+import path from 'node:path'
+import process from 'node:process'
+
+function parseArguments(argv) {
+ const result = {}
+ for (let index = 0; index < argv.length; index += 2) {
+ const key = argv[index]
+ const value = argv[index + 1]
+ if (!key?.startsWith('--') || value === undefined) {
+ throw new Error(`잘못된 인자입니다: ${key ?? '(없음)'}`)
+ }
+ result[key.slice(2)] = value
+ }
+ return result
+}
+
+function escapeHtml(value) {
+ return String(value ?? '')
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''')
+}
+
+function stateClass(state) {
+ const normalized = String(state ?? '').toLowerCase()
+ if (normalized === 'success') return 'success'
+ if (normalized === 'pending') return 'pending'
+ return 'warning'
+}
+
+function displayValue(value, fallback = '—') {
+ return value === null || value === undefined || value === '' ? fallback : String(value)
+}
+
+function validateGeneratedAt(value) {
+ const parsed = new Date(value)
+ if (Number.isNaN(parsed.getTime())) {
+ throw new Error(`생성 시각이 ISO-8601 형식이 아닙니다: ${value}`)
+ }
+ return parsed.toISOString()
+}
+
+function migrationRows(migrations) {
+ return migrations
+ .map((migration) => {
+ const executionTime = Number.isFinite(migration.executionTime)
+ ? `${migration.executionTime} ms`
+ : '—'
+ return `
+
+ ${escapeHtml(displayValue(migration.version, 'Repeatable'))} |
+ ${escapeHtml(displayValue(migration.description))} |
+ ${escapeHtml(displayValue(migration.type))} |
+ ${escapeHtml(displayValue(migration.state))} |
+ ${escapeHtml(displayValue(migration.installedOnUTC))} |
+ ${escapeHtml(executionTime)} |
+
`
+ })
+ .join('')
+}
+
+function pageShell({ title, description, body, assetPrefix = '' }) {
+ return `
+
+
+
+
+
+ ${escapeHtml(title)}
+
+
+
+
+ ${body}
+
+
+`
+}
+
+const styles = `
+:root {
+ color-scheme: light;
+ --ink: #172b2d;
+ --muted: #5f7375;
+ --line: #dbe5e4;
+ --surface: #f5f9f8;
+ --brand: #0c6a65;
+ --brand-dark: #084d49;
+ --success: #16794b;
+ --pending: #936300;
+ --warning: #a33a2b;
+}
+* { box-sizing: border-box; }
+body {
+ margin: 0;
+ color: var(--ink);
+ background: #fff;
+ font-family: Inter, Pretendard, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ line-height: 1.6;
+}
+.topbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 1rem clamp(1rem, 5vw, 4rem);
+ color: #fff;
+ background: var(--brand-dark);
+}
+.topbar a { color: inherit; text-decoration: none; }
+.brand { font-size: 1.1rem; font-weight: 800; letter-spacing: .02em; }
+nav { display: flex; gap: 1rem; font-size: .95rem; }
+main { width: min(1120px, calc(100% - 2rem)); margin: 0 auto; padding: 3.5rem 0; }
+.hero { max-width: 760px; margin-bottom: 2rem; }
+.eyebrow { color: var(--brand); font-weight: 800; text-transform: uppercase; letter-spacing: .08em; }
+h1 { margin: .25rem 0 1rem; font-size: clamp(2rem, 5vw, 3.2rem); line-height: 1.18; }
+h2 { margin-top: 2.25rem; }
+.lead { color: var(--muted); font-size: 1.08rem; }
+.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; }
+.card {
+ display: block;
+ padding: 1.3rem;
+ color: inherit;
+ text-decoration: none;
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ background: var(--surface);
+}
+.card:hover { border-color: var(--brand); transform: translateY(-1px); }
+.card h2 { margin: 0 0 .35rem; }
+.meta {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: .75rem;
+ margin: 2rem 0;
+}
+.meta div { padding: 1rem; border-left: 3px solid var(--brand); background: var(--surface); }
+.meta dt { color: var(--muted); font-size: .85rem; }
+.meta dd { margin: .25rem 0 0; font-weight: 700; overflow-wrap: anywhere; }
+.table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: 12px; }
+table { width: 100%; border-collapse: collapse; font-size: .92rem; }
+th, td { padding: .8rem; border-bottom: 1px solid var(--line); text-align: left; white-space: nowrap; }
+th { background: var(--surface); }
+tbody tr:last-child td { border-bottom: 0; }
+.badge { display: inline-block; padding: .15rem .55rem; border-radius: 999px; font-weight: 700; }
+.badge.success { color: var(--success); background: #def4e8; }
+.badge.pending { color: var(--pending); background: #fff0bd; }
+.badge.warning { color: var(--warning); background: #ffe2dd; }
+.notice { padding: 1rem 1.2rem; border-radius: 10px; background: #e9f5f3; }
+code { font-family: "SFMono-Regular", Consolas, monospace; }
+footer { padding: 2rem 1rem; color: var(--muted); text-align: center; border-top: 1px solid var(--line); }
+@media (max-width: 640px) {
+ .topbar { align-items: flex-start; flex-direction: column; }
+ main { padding-top: 2rem; }
+}
+`
+
+async function main() {
+ const args = parseArguments(process.argv.slice(2))
+ if (!args['flyway-info'] || !args.output) {
+ throw new Error('--flyway-info와 --output은 필수입니다.')
+ }
+
+ const generatedAt = validateGeneratedAt(args['generated-at'] ?? new Date().toISOString())
+ const commit = args.commit ?? 'unknown'
+ const repositoryUrl = args['repository-url'] ?? 'https://github.com/fowoco/server'
+ const info = JSON.parse(await readFile(path.resolve(args['flyway-info']), 'utf8'))
+ const migrations = Array.isArray(info.migrations) ? info.migrations : null
+ if (!migrations) {
+ throw new Error('Flyway info JSON에 migrations 배열이 없습니다.')
+ }
+
+ const output = path.resolve(args.output)
+ await access(path.join(output, 'schema', 'index.html'))
+ await mkdir(path.join(output, 'migrations'), { recursive: true })
+ await mkdir(path.join(output, 'assets'), { recursive: true })
+
+ const successCount = migrations.filter((migration) => migration.state === 'Success').length
+ const pendingCount = migrations.filter((migration) => migration.state === 'Pending').length
+ const warningCount = migrations.length - successCount - pendingCount
+ const commitHref = /^[0-9a-f]{7,40}$/i.test(commit)
+ ? `${repositoryUrl}/commit/${commit}`
+ : repositoryUrl
+
+ const homeBody = `
+
+ Generated from Flyway
+ FOWOCO 데이터베이스 문서
+ main의 Flyway Migration을 일회용 PostgreSQL에 적용한 뒤 자동 생성한 문서입니다. 실제 구조를 이해하는 보조 자료이며 변경의 원본은 Migration SQL과 ADR입니다.
+
+
+
+ - 현재 Schema Version
- ${escapeHtml(displayValue(info.schemaVersion))}
+ - Schema
- ${escapeHtml(displayValue(info.schemaName))}
+ - Flyway
- ${escapeHtml(displayValue(info.flywayVersion))}
+ - 생성 시각
- ${escapeHtml(generatedAt)}
+
+
+ 성공 ${successCount}개 · 대기 ${pendingCount}개 · 확인 필요 ${warningCount}개. 문서 생성 전에 flyway validate를 통과했습니다.
`
+
+ const migrationBody = `
+
+ Flyway History
+ Migration 적용 이력
+ 빈 PostgreSQL을 처음부터 구성한 결과입니다. 운영 DB의 데이터나 접속정보는 포함하지 않습니다.
+
+
+
+ | Version | 설명 | 유형 | 상태 | 적용 시각(UTC) | 실행 시간 |
+ ${migrationRows(migrations)}
+
+
`
+
+ await writeFile(path.join(output, 'index.html'), pageShell({
+ title: 'FOWOCO Database Documentation',
+ description: 'FOWOCO Server 데이터베이스 ERD와 Flyway Migration 문서',
+ body: homeBody,
+ }))
+ await writeFile(path.join(output, 'migrations', 'index.html'), pageShell({
+ title: 'FOWOCO Flyway Migration History',
+ description: 'FOWOCO Server Flyway Migration 적용 이력',
+ body: migrationBody,
+ assetPrefix: '../',
+ }))
+ await writeFile(path.join(output, 'assets', 'styles.css'), styles.trimStart())
+ await writeFile(path.join(output, '.nojekyll'), '')
+ await writeFile(path.join(output, 'metadata.json'), JSON.stringify({
+ generated_at: generatedAt,
+ git_commit: commit,
+ schema_version: info.schemaVersion ?? null,
+ schema_name: info.schemaName ?? null,
+ flyway_version: info.flywayVersion ?? null,
+ migration_counts: {
+ success: successCount,
+ pending: pendingCount,
+ attention_required: warningCount,
+ },
+ }, null, 2))
+}
+
+main().catch((error) => {
+ console.error(`[db-docs] ${error.message}`)
+ process.exitCode = 1
+})
diff --git a/scripts/db-docs/generate-site.test.mjs b/scripts/db-docs/generate-site.test.mjs
new file mode 100644
index 0000000..47b65f2
--- /dev/null
+++ b/scripts/db-docs/generate-site.test.mjs
@@ -0,0 +1,89 @@
+import assert from 'node:assert/strict'
+import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
+import os from 'node:os'
+import path from 'node:path'
+import { spawnSync } from 'node:child_process'
+import test from 'node:test'
+
+const script = path.resolve('scripts/db-docs/generate-site.mjs')
+
+test('Flyway JSON을 안전한 DB 문서 사이트로 변환한다', async () => {
+ const temporaryRoot = await mkdtemp(path.join(os.tmpdir(), 'fowoco-db-docs-'))
+ try {
+ const infoFile = path.join(temporaryRoot, 'flyway-info.json')
+ const output = path.join(temporaryRoot, 'site')
+ await mkdir(path.join(output, 'schema'), { recursive: true })
+ await writeFile(path.join(output, 'schema', 'index.html'), 'SchemaSpy')
+ await writeFile(infoFile, JSON.stringify({
+ schemaVersion: '5',
+ schemaName: 'public',
+ flywayVersion: '12.4.0',
+ migrations: [
+ {
+ version: '1',
+ description: 'baseline ',
+ type: 'SQL',
+ state: 'Success',
+ installedOnUTC: '2026-07-24T00:00:00Z',
+ executionTime: 14,
+ filepath: '/flyway/sql/V1__baseline.sql',
+ },
+ {
+ version: '6',
+ description: 'next',
+ type: 'SQL',
+ state: 'Pending',
+ installedOnUTC: '',
+ executionTime: 0,
+ filepath: '/private/path/V6__next.sql',
+ },
+ ],
+ }))
+
+ const result = spawnSync(process.execPath, [
+ script,
+ '--flyway-info', infoFile,
+ '--output', output,
+ '--commit', '1234567890abcdef1234567890abcdef12345678',
+ '--generated-at', '2026-07-24T01:02:03Z',
+ '--repository-url', 'https://github.com/fowoco/server',
+ ], { encoding: 'utf8' })
+
+ assert.equal(result.status, 0, result.stderr)
+ const index = await readFile(path.join(output, 'index.html'), 'utf8')
+ const migrations = await readFile(path.join(output, 'migrations', 'index.html'), 'utf8')
+ const metadata = JSON.parse(await readFile(path.join(output, 'metadata.json'), 'utf8'))
+
+ assert.match(index, /현재 Schema Version/)
+ assert.match(index, /성공 1개 · 대기 1개/)
+ assert.match(migrations, /baseline <safe>/)
+ assert.doesNotMatch(migrations, /private\/path/)
+ assert.equal(metadata.schema_version, '5')
+ assert.deepEqual(metadata.migration_counts, {
+ success: 1,
+ pending: 1,
+ attention_required: 0,
+ })
+ } finally {
+ await rm(temporaryRoot, { recursive: true, force: true })
+ }
+})
+
+test('SchemaSpy 결과가 없으면 불완전한 사이트 생성을 거부한다', async () => {
+ const temporaryRoot = await mkdtemp(path.join(os.tmpdir(), 'fowoco-db-docs-'))
+ try {
+ const infoFile = path.join(temporaryRoot, 'flyway-info.json')
+ await writeFile(infoFile, JSON.stringify({ migrations: [] }))
+
+ const result = spawnSync(process.execPath, [
+ script,
+ '--flyway-info', infoFile,
+ '--output', path.join(temporaryRoot, 'site'),
+ ], { encoding: 'utf8' })
+
+ assert.notEqual(result.status, 0)
+ assert.match(result.stderr, /\[db-docs\]/)
+ } finally {
+ await rm(temporaryRoot, { recursive: true, force: true })
+ }
+})
diff --git a/scripts/db-docs/generate.sh b/scripts/db-docs/generate.sh
new file mode 100755
index 0000000..c0e49b4
--- /dev/null
+++ b/scripts/db-docs/generate.sh
@@ -0,0 +1,146 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPOSITORY_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
+OUTPUT_ROOT="${REPOSITORY_ROOT}/build/db-docs"
+MIGRATION_DIRECTORY="${REPOSITORY_ROOT}/src/main/resources/db/migration"
+
+FLYWAY_IMAGE="${DB_DOCS_FLYWAY_IMAGE:-flyway/flyway:12.4.0}"
+SCHEMASPY_IMAGE="${DB_DOCS_SCHEMASPY_IMAGE:-schemaspy/schemaspy:7.0.2}"
+DATABASE_HOST="${DB_DOCS_CONTAINER_HOST:-127.0.0.1}"
+DATABASE_PORT="${DB_DOCS_PORT:-5432}"
+DATABASE_NAME="${DB_DOCS_DATABASE:-fowoco_docs}"
+DATABASE_USER="${DB_DOCS_USER:-fowoco_docs}"
+: "${DB_DOCS_PASSWORD:?DB_DOCS_PASSWORD를 설정해 주세요. 실제 운영 DB 비밀번호를 사용하면 안 됩니다.}"
+
+if [[ "${DB_DOCS_EPHEMERAL:-false}" != "true" ]]; then
+ echo "[db-docs] 일회용 DB 확인값(DB_DOCS_EPHEMERAL=true)이 필요합니다." >&2
+ exit 1
+fi
+if [[ "${DATABASE_NAME}" != "fowoco_docs" || "${DATABASE_USER}" != "fowoco_docs" ]]; then
+ echo "[db-docs] 문서 생성 전용 DB와 사용자(fowoco_docs)만 사용할 수 있습니다." >&2
+ exit 1
+fi
+
+if ! command -v docker >/dev/null 2>&1; then
+ echo "[db-docs] Docker를 찾지 못했습니다. Docker Desktop 또는 Docker Engine이 필요합니다." >&2
+ exit 1
+fi
+if ! command -v node >/dev/null 2>&1; then
+ echo "[db-docs] Node.js를 찾지 못했습니다. Node.js 24 이상을 설치해 주세요." >&2
+ exit 1
+fi
+if ! docker info >/dev/null 2>&1; then
+ echo "[db-docs] Docker가 실행 중이 아닙니다." >&2
+ exit 1
+fi
+
+NETWORK_ARGUMENTS=()
+if [[ -n "${DB_DOCS_DOCKER_NETWORK:-}" ]]; then
+ case "${DB_DOCS_DOCKER_NETWORK}" in
+ fowoco-db-docs-*) ;;
+ *)
+ echo "[db-docs] 문서 생성 전용 Docker network만 사용할 수 있습니다." >&2
+ exit 1
+ ;;
+ esac
+ NETWORK_ARGUMENTS=(--network "${DB_DOCS_DOCKER_NETWORK}")
+elif [[ "$(uname -s)" == "Linux" ]]; then
+ case "${DATABASE_HOST}" in
+ 127.0.0.1|localhost) ;;
+ *)
+ echo "[db-docs] Host network에서는 localhost DB만 사용할 수 있습니다." >&2
+ exit 1
+ ;;
+ esac
+ NETWORK_ARGUMENTS=(--network host)
+else
+ case "${DATABASE_HOST}" in
+ 127.0.0.1|localhost) DATABASE_HOST="host.docker.internal" ;;
+ host.docker.internal) ;;
+ *)
+ echo "[db-docs] Docker Desktop에서는 host.docker.internal DB만 사용할 수 있습니다." >&2
+ exit 1
+ ;;
+ esac
+fi
+
+case "${OUTPUT_ROOT}" in
+ "${REPOSITORY_ROOT}/build/db-docs") ;;
+ *)
+ echo "[db-docs] 허용되지 않은 출력 경로입니다: ${OUTPUT_ROOT}" >&2
+ exit 1
+ ;;
+esac
+
+rm -rf "${OUTPUT_ROOT}"
+mkdir -p "${OUTPUT_ROOT}/site/schema"
+chmod 0777 "${OUTPUT_ROOT}/site/schema"
+
+JDBC_URL="jdbc:postgresql://${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_NAME}"
+FLYWAY_ARGUMENTS=(
+ "-url=${JDBC_URL}"
+ "-user=${DATABASE_USER}"
+ "-password=${DB_DOCS_PASSWORD}"
+ "-locations=filesystem:/flyway/sql"
+ "-defaultSchema=public"
+ "-schemas=public"
+ "-connectRetries=20"
+)
+
+echo "[db-docs] 빈 PostgreSQL에 Flyway Migration을 적용합니다."
+docker run --rm \
+ "${NETWORK_ARGUMENTS[@]}" \
+ -v "${MIGRATION_DIRECTORY}:/flyway/sql:ro" \
+ "${FLYWAY_IMAGE}" \
+ "${FLYWAY_ARGUMENTS[@]}" \
+ migrate
+
+echo "[db-docs] 적용된 Migration과 저장소 checksum을 검증합니다."
+docker run --rm \
+ "${NETWORK_ARGUMENTS[@]}" \
+ -v "${MIGRATION_DIRECTORY}:/flyway/sql:ro" \
+ "${FLYWAY_IMAGE}" \
+ "${FLYWAY_ARGUMENTS[@]}" \
+ validate
+
+docker run --rm \
+ "${NETWORK_ARGUMENTS[@]}" \
+ -v "${MIGRATION_DIRECTORY}:/flyway/sql:ro" \
+ "${FLYWAY_IMAGE}" \
+ "${FLYWAY_ARGUMENTS[@]}" \
+ -outputType=json \
+ info > "${OUTPUT_ROOT}/flyway-info.json"
+
+echo "[db-docs] SchemaSpy로 테이블·관계 문서를 생성합니다."
+docker run --rm \
+ "${NETWORK_ARGUMENTS[@]}" \
+ --user "$(id -u):$(id -g)" \
+ -v "${OUTPUT_ROOT}/site/schema:/output" \
+ "${SCHEMASPY_IMAGE}" \
+ -t pgsql11 \
+ -host "${DATABASE_HOST}" \
+ -port "${DATABASE_PORT}" \
+ -db "${DATABASE_NAME}" \
+ -u "${DATABASE_USER}" \
+ -p "${DB_DOCS_PASSWORD}" \
+ -s public
+
+chmod -R a+rX "${OUTPUT_ROOT}"
+
+GIT_COMMIT="${DB_DOCS_GIT_COMMIT:-${GITHUB_SHA:-$(git -C "${REPOSITORY_ROOT}" rev-parse HEAD)}}"
+if [[ -n "${GITHUB_SERVER_URL:-}" && -n "${GITHUB_REPOSITORY:-}" ]]; then
+ REPOSITORY_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}"
+else
+ REPOSITORY_URL="https://github.com/fowoco/server"
+fi
+
+node "${SCRIPT_DIR}/generate-site.mjs" \
+ --flyway-info "${OUTPUT_ROOT}/flyway-info.json" \
+ --output "${OUTPUT_ROOT}/site" \
+ --commit "${GIT_COMMIT}" \
+ --generated-at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \
+ --repository-url "${REPOSITORY_URL}"
+
+echo "[db-docs] 생성 완료: ${OUTPUT_ROOT}/site/index.html"