diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml old mode 100644 new mode 100755 index 406f2937..8e6891e1 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,9 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false - name: Set up JDK 17 uses: actions/setup-java@v4 @@ -28,7 +31,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 100755 index 00000000..81a1e924 --- /dev/null +++ b/scripts/validate-flyway-migrations.sh @@ -0,0 +1,218 @@ +#!/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 + + while IFS= read -r -d '' migration_file; do + 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 < <(find "$migration_directory" -type f -print0) + + (( 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 repository_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") + + while IFS= read -r -d '' path; do + repository_path="${path#"$repository_root"/}" + filename="$(basename "$path")" + + if git -C "$repository_root" cat-file -e "${ref}:${repository_path}" 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 < <(find "$migration_directory" -type f -print0) + + 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: