From edf942362ab39412f85a2d3753efdbaa772ef5b5 Mon Sep 17 00:00:00 2001 From: kjh0620 Date: Fri, 14 Aug 2026 02:20:36 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[REFACTOR]=20Flyway=20=EB=82=A0=EC=A7=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EB=B2=84=EC=A0=84=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 7 +- README.md | 9 +- docs/flyway-migration-guide.md | 74 +++++++++ scripts/validate-flyway-migrations.sh | 222 ++++++++++++++++++++++++++ src/main/resources/application.yml | 1 + 5 files changed, 311 insertions(+), 2 deletions(-) create mode 100644 docs/flyway-migration-guide.md create mode 100644 scripts/validate-flyway-migrations.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 406f2937..d5e8ea04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Set up JDK 17 uses: actions/setup-java@v4 @@ -28,7 +30,10 @@ jobs: uses: gradle/actions/setup-gradle@v4 - name: Grant Gradle permission - run: chmod +x ./gradlew + run: chmod +x ./gradlew ./scripts/validate-flyway-migrations.sh + + - name: Validate Flyway migrations + run: ./scripts/validate-flyway-migrations.sh "${{ github.event.pull_request.base.sha }}" - name: Test, JaCoCo and RestDocs run: ./gradlew clean build diff --git a/README.md b/README.md index 4a7b643f..0bd07b14 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,8 @@ UMC 8기 데모데이 5. [서버 아키텍처](#-서버-아키텍처) 6. [프로젝트 구조](#-프로젝트-구조) 7. [브랜치 전략](#-브랜치-전략) -8. [Github 관리 규칙](#-github-관리-규칙) +8. [데이터베이스 마이그레이션](#-데이터베이스-마이그레이션) +9. [Github 관리 규칙](#-github-관리-규칙) --- @@ -174,6 +175,12 @@ src --- +## 🗄 데이터베이스 마이그레이션 + +데이터베이스 스키마는 Flyway로 관리합니다. 마이그레이션 생성과 버전 관리 규칙은 [Flyway 마이그레이션 가이드](docs/flyway-migration-guide.md)를 참고해 주세요. + +--- + ## 📍 Github 관리 규칙 - 기본 API 문서는 Swagger(`/swagger-ui/index.html`)와 REST Docs(`/docs/index.html`)로 관리 diff --git a/docs/flyway-migration-guide.md b/docs/flyway-migration-guide.md new file mode 100644 index 00000000..7dd5c56e --- /dev/null +++ b/docs/flyway-migration-guide.md @@ -0,0 +1,74 @@ +# Flyway 마이그레이션 가이드 + +NOOK 서버의 PostgreSQL 스키마는 Flyway 마이그레이션으로 관리합니다. 모든 마이그레이션은 `src/main/resources/db/migration`에 둡니다. + +## 버전 규칙 + +신규 마이그레이션은 UTC 기준 날짜와 시간을 사용합니다. + +```text +VyyyyMMdd_HHmmss__snake_case_description.sql +``` + +예시는 다음과 같습니다. + +```text +V20260814_063000__add_library_status_index.sql +``` + +- `yyyyMMdd`는 UTC 날짜입니다. +- `HHmmss`는 UTC 시간입니다. +- 설명은 변경 목적이 드러나는 영문 소문자 `snake_case`로 작성합니다. +- 동일한 초에 여러 마이그레이션을 만들었다면 각각 다른 타임스탬프를 사용합니다. + +기존 `V1`부터 `V6`까지는 날짜 규칙 도입 전에 생성된 레거시 마이그레이션입니다. Flyway 적용 이력을 보존하기 위해 이름과 내용을 변경하지 않습니다. + +## 파일 생성 + +저장소 루트에서 다음 명령을 실행하고 `description`을 실제 변경 내용으로 바꿉니다. + +```bash +touch "src/main/resources/db/migration/V$(date -u +%Y%m%d_%H%M%S)__description.sql" +``` + +예를 들어 서재 상태 인덱스를 추가한다면 다음과 같이 생성합니다. + +```bash +touch "src/main/resources/db/migration/V$(date -u +%Y%m%d_%H%M%S)__add_library_status_index.sql" +``` + +## 작성 원칙 + +1. 하나의 마이그레이션에는 하나의 명확한 목적만 담습니다. +2. 공유 브랜치에 병합된 마이그레이션은 수정, 삭제하거나 이름을 바꾸지 않습니다. +3. 이미 적용된 변경을 보완해야 한다면 더 높은 버전의 새 마이그레이션으로 roll-forward 합니다. +4. 애플리케이션 코드와 스키마 변경의 배포 순서를 고려해 이전 버전과의 호환성을 유지합니다. +5. Flyway의 `out-of-order` 옵션은 활성화하지 않습니다. + +## PR 전 검증 + +먼저 작업 브랜치를 대상 브랜치의 최신 상태로 갱신합니다. 그다음 대상 브랜치를 인자로 전달해 마이그레이션을 검증합니다. + +```bash +./scripts/validate-flyway-migrations.sh origin/develop-demo +./gradlew clean build +``` + +검증 스크립트는 다음 조건을 확인합니다. + +- 신규 파일이 날짜·시간 버전 형식을 따르는지 +- 날짜와 시간이 실제로 유효한지 +- 버전이 중복되지 않았는지 +- 기존 마이그레이션이 변경 또는 삭제되지 않았는지 +- 신규 버전이 대상 브랜치의 최신 버전보다 큰지 + +오래된 작업 브랜치의 버전이 대상 브랜치의 최신 버전보다 낮다면, 아직 공유 환경에 적용되지 않았는지 확인한 뒤 현재 UTC 시각으로 파일명을 다시 생성합니다. 공유 환경에 적용된 파일은 이름을 바꾸지 않고 새 마이그레이션으로 보완합니다. + +GitHub Actions에서도 Pull Request 대상 커밋을 기준으로 같은 검증을 실행합니다. + +## 실패 대응 + +- 파일명 오류: `VyyyyMMdd_HHmmss__snake_case_description.sql` 형식으로 수정합니다. +- 중복 또는 낮은 버전: 대상 브랜치를 최신화한 뒤 현재 UTC 시각으로 버전을 다시 생성합니다. +- 기존 파일 변경: 변경을 되돌리고 새 마이그레이션으로 작성합니다. +- 적용 실패: 실패 원인을 수정한 새 마이그레이션으로 roll-forward 합니다. 공유 DB의 `flyway_schema_history`를 임의로 수정하지 않습니다. diff --git a/scripts/validate-flyway-migrations.sh b/scripts/validate-flyway-migrations.sh new file mode 100644 index 00000000..5dacc067 --- /dev/null +++ b/scripts/validate-flyway-migrations.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +set -euo pipefail + +repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +migration_relative_path="src/main/resources/db/migration" +migration_directory="${repository_root}/${migration_relative_path}" +base_ref="${1:-}" + +legacy_migrations=( + "V1__init_schema.sql" + "V2__add_unique_index_for_aladin_book_isbn.sql" + "V3__cleanup_legacy_hibernate_schema.sql" + "V4__create_book_view_history.sql" + "V5__add_on_delete_cascade.sql" + "V6__add_users_status_deleted_at_index.sql" +) + +fail() { + echo "Flyway migration validation failed: $1" >&2 + exit 1 +} + +is_legacy_migration() { + local filename="$1" + local legacy_migration + + for legacy_migration in "${legacy_migrations[@]}"; do + if [[ "$filename" == "$legacy_migration" ]]; then + return 0 + fi + done + + return 1 +} + +is_valid_timestamp() { + local date_part="$1" + local time_part="$2" + local year=$((10#${date_part:0:4})) + local month=$((10#${date_part:4:2})) + local day=$((10#${date_part:6:2})) + local hour=$((10#${time_part:0:2})) + local minute=$((10#${time_part:2:2})) + local second=$((10#${time_part:4:2})) + local max_day + + if (( year < 1970 || month < 1 || month > 12 || hour > 23 || minute > 59 || second > 59 )); then + return 1 + fi + + case "$month" in + 1|3|5|7|8|10|12) max_day=31 ;; + 4|6|9|11) max_day=30 ;; + 2) + max_day=28 + if (( year % 400 == 0 || (year % 4 == 0 && year % 100 != 0) )); then + max_day=29 + fi + ;; + esac + + (( day >= 1 && day <= max_day )) +} + +timestamp_version() { + local filename="$1" + + if [[ "$filename" =~ ^V([0-9]{8})_([0-9]{6})__([a-z0-9]+(_[a-z0-9]+)*)\.sql$ ]]; then + printf '%s%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" + return 0 + fi + + return 1 +} + +validate_filename() { + local filename="$1" + local date_part + local time_part + + if is_legacy_migration "$filename"; then + return 0 + fi + + if [[ ! "$filename" =~ ^V([0-9]{8})_([0-9]{6})__([a-z0-9]+(_[a-z0-9]+)*)\.sql$ ]]; then + fail "${filename} must match VyyyyMMdd_HHmmss__snake_case_description.sql" + fi + + date_part="${BASH_REMATCH[1]}" + time_part="${BASH_REMATCH[2]}" + + if ! is_valid_timestamp "$date_part" "$time_part"; then + fail "${filename} contains an invalid UTC date or time" + fi +} + +validate_repository_files() { + local migration_file + local legacy_migration + local filename + local version + local seen_versions=" " + local migration_count=0 + + [[ -d "$migration_directory" ]] || fail "missing migration directory: ${migration_relative_path}" + + for legacy_migration in "${legacy_migrations[@]}"; do + [[ -f "${migration_directory}/${legacy_migration}" ]] \ + || fail "missing legacy migration: ${legacy_migration}" + done + + shopt -s nullglob + for migration_file in "$migration_directory"/*; do + [[ -f "$migration_file" ]] || continue + filename="$(basename "$migration_file")" + validate_filename "$filename" + + if is_legacy_migration "$filename"; then + version="${filename%%__*}" + version="${version#V}" + else + version="$(timestamp_version "$filename")" + fi + + if [[ "$seen_versions" == *" ${version} "* ]]; then + fail "duplicate migration version: ${version}" + fi + + seen_versions+="${version} " + ((migration_count += 1)) + done + shopt -u nullglob + + (( migration_count > 0 )) || fail "no migration files found" + printf '%s\n' "$migration_count" +} + +base_max_version() { + local ref="$1" + local path + local filename + local version + local max_version=0 + + while IFS= read -r path; do + [[ -n "$path" ]] || continue + filename="$(basename "$path")" + + if is_legacy_migration "$filename"; then + version="${filename%%__*}" + version="${version#V}" + elif version="$(timestamp_version "$filename")"; then + : + else + fail "base ref ${ref} contains an unsupported migration filename: ${filename}" + fi + + if (( 10#$version > 10#$max_version )); then + max_version="$version" + fi + done < <(git -C "$repository_root" ls-tree -r --name-only "$ref" -- "$migration_relative_path") + + printf '%s\n' "$max_version" +} + +validate_changes_from_base() { + local ref="$1" + local path + local filename + local version + local max_version + local added_count=0 + + git -C "$repository_root" rev-parse --verify --quiet "${ref}^{commit}" >/dev/null \ + || fail "base ref does not resolve to a commit: ${ref}" + + max_version="$(base_max_version "$ref")" + + while IFS= read -r path; do + [[ -n "$path" ]] || continue + filename="$(basename "$path")" + [[ -f "${repository_root}/${path}" ]] \ + || fail "existing migration files are immutable (deleted: ${path})" + + if ! git -C "$repository_root" show "${ref}:${path}" | cmp -s - "${repository_root}/${path}"; then + fail "existing migration files are immutable (modified: ${path})" + fi + done < <(git -C "$repository_root" ls-tree -r --name-only "$ref" -- "$migration_relative_path") + + shopt -s nullglob + for path in "$migration_directory"/*; do + [[ -f "$path" ]] || continue + filename="$(basename "$path")" + + if git -C "$repository_root" cat-file -e "${ref}:${migration_relative_path}/${filename}" 2>/dev/null; then + continue + fi + + if is_legacy_migration "$filename"; then + fail "legacy migration cannot be added again: ${filename}" + fi + + version="$(timestamp_version "$filename")" + if (( 10#$version <= 10#$max_version )); then + fail "${filename} must have a version greater than the base maximum ${max_version}" + fi + + ((added_count += 1)) + done + shopt -u nullglob + + printf '%s\n' "$added_count" +} + +migration_count="$(validate_repository_files)" + +if [[ -n "$base_ref" ]]; then + added_count="$(validate_changes_from_base "$base_ref")" + echo "Validated ${migration_count} Flyway migrations and ${added_count} migration changes against ${base_ref}." +else + echo "Validated ${migration_count} Flyway migrations. Pass a base ref to validate changed migrations." +fi diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ce965f17..85c2209e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -16,6 +16,7 @@ spring: locations: classpath:db/migration baseline-on-migrate: ${FLYWAY_BASELINE_ON_MIGRATE:true} baseline-version: ${FLYWAY_BASELINE_VERSION:1} + validate-migration-naming: true cache: type: redis data: From ee5e15af40837877cc6b980bfa42906ecda2ded0 Mon Sep 17 00:00:00 2001 From: kjh0620 Date: Mon, 17 Aug 2026 20:57:05 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[FIX]=20Flyway=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EB=B0=8F=20CI=20=EB=B3=B4=EC=95=88=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 1 + scripts/validate-flyway-migrations.sh | 18 +++++++----------- 2 files changed, 8 insertions(+), 11 deletions(-) mode change 100644 => 100755 .github/workflows/ci.yml mode change 100644 => 100755 scripts/validate-flyway-migrations.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml old mode 100644 new mode 100755 index d5e8ea04..8e6891e1 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + persist-credentials: false - name: Set up JDK 17 uses: actions/setup-java@v4 diff --git a/scripts/validate-flyway-migrations.sh b/scripts/validate-flyway-migrations.sh old mode 100644 new mode 100755 index 5dacc067..81a1e924 --- a/scripts/validate-flyway-migrations.sh +++ b/scripts/validate-flyway-migrations.sh @@ -109,9 +109,7 @@ validate_repository_files() { || fail "missing legacy migration: ${legacy_migration}" done - shopt -s nullglob - for migration_file in "$migration_directory"/*; do - [[ -f "$migration_file" ]] || continue + while IFS= read -r -d '' migration_file; do filename="$(basename "$migration_file")" validate_filename "$filename" @@ -128,8 +126,7 @@ validate_repository_files() { seen_versions+="${version} " ((migration_count += 1)) - done - shopt -u nullglob + done < <(find "$migration_directory" -type f -print0) (( migration_count > 0 )) || fail "no migration files found" printf '%s\n' "$migration_count" @@ -166,6 +163,7 @@ base_max_version() { validate_changes_from_base() { local ref="$1" local path + local repository_path local filename local version local max_version @@ -187,12 +185,11 @@ validate_changes_from_base() { fi done < <(git -C "$repository_root" ls-tree -r --name-only "$ref" -- "$migration_relative_path") - shopt -s nullglob - for path in "$migration_directory"/*; do - [[ -f "$path" ]] || continue + while IFS= read -r -d '' path; do + repository_path="${path#"$repository_root"/}" filename="$(basename "$path")" - if git -C "$repository_root" cat-file -e "${ref}:${migration_relative_path}/${filename}" 2>/dev/null; then + if git -C "$repository_root" cat-file -e "${ref}:${repository_path}" 2>/dev/null; then continue fi @@ -206,8 +203,7 @@ validate_changes_from_base() { fi ((added_count += 1)) - done - shopt -u nullglob + done < <(find "$migration_directory" -type f -print0) printf '%s\n' "$added_count" }