Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/ci.yml
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
Comment thread
kjhyeon0620 marked this conversation as resolved.
persist-credentials: false

- name: Set up JDK 17
uses: actions/setup-java@v4
Expand All @@ -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
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ UMC 8기 데모데이
5. [서버 아키텍처](#-서버-아키텍처)
6. [프로젝트 구조](#-프로젝트-구조)
7. [브랜치 전략](#-브랜치-전략)
8. [Github 관리 규칙](#-github-관리-규칙)
8. [데이터베이스 마이그레이션](#-데이터베이스-마이그레이션)
9. [Github 관리 규칙](#-github-관리-규칙)

---

Expand Down Expand Up @@ -174,6 +175,12 @@ src

---

## 🗄 데이터베이스 마이그레이션

데이터베이스 스키마는 Flyway로 관리합니다. 마이그레이션 생성과 버전 관리 규칙은 [Flyway 마이그레이션 가이드](docs/flyway-migration-guide.md)를 참고해 주세요.

---

## 📍 Github 관리 규칙

- 기본 API 문서는 Swagger(`/swagger-ui/index.html`)와 REST Docs(`/docs/index.html`)로 관리
Expand Down
74 changes: 74 additions & 0 deletions docs/flyway-migration-guide.md
Original file line number Diff line number Diff line change
@@ -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`를 임의로 수정하지 않습니다.
218 changes: 218 additions & 0 deletions scripts/validate-flyway-migrations.sh
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading