티스토리 뷰

반응형

✏️ 상황

EAT-SSU 서버는 그동안 Flyway 마이그레이션이 새 컨테이너의 Spring Boot 기동 과정 안에서 실행되고 있었다. 문제가 없을 때는 아무 일도 아니지만, 마이그레이션 SQL에 문제가 있는 순간 이 구조는 그대로 배포 장애로 이어졌다. 기존 컨테이너는 이미 내려간 뒤였고, 새 컨테이너는 Flyway 실패로 아예 뜨지 못했다. 이 글은 그 구조적 위험을 없애기 위해 진행한 두 이슈(#441, #442)를 정리한 것이다.

 

✏️ 1. 배경

EAT-SSU 서버의 기존 CD 파이프라인(cd.yml)은 새 버전을 배포할 때 다음 순서로 동작하고 있었다.

기존 컨테이너 제거(docker rm -f)
  → 새 Docker 이미지 pull
  → 새 컨테이너 실행(docker run)
  → Spring Boot 기동
  → 애플리케이션 시작 과정에서 Flyway migrate 실행

문제는 Flyway 마이그레이션이 "이미 기존 컨테이너가 사라진 뒤"에야 처음 실행된다는 순서 그 자체였다. 마이그레이션 SQL에 문제가 있으면 다음이 순차적으로 벌어진다.

기존 컨테이너는 이미 제거됨
  → 새 컨테이너가 Spring Boot 기동 중 Flyway migrate 실행
  → Flyway 실패 → Spring 컨텍스트 초기화 실패
  → 컨테이너 자체가 죽음
  → 서버 다운

여기서 "롤백도 즉시 어렵다"는 건 단순히 컨테이너를 재기동하면 되는 문제가 아니라는 뜻이다. Flyway는 실패한 마이그레이션 버전을 flyway_schema_history 테이블에 success = 0으로 그대로 기록해버린다. 그러면 그 다음 재시도(재배포)에서도 Flyway가 "직전에 실패한 버전이 남아있다"고 판단해 같은 이유로 계속 실패한다. 결국 문제가 되는 마이그레이션 파일을 고치거나, flyway_schema_history에서 실패 기록을 수동으로 정리하기 전까지는 재배포 자체가 막혀버리는 상황이 된다.

 

한편 CI(ci.yml)는 PR마다 새로운 MySQL 컨테이너를 띄우고 완전히 빈 스키마에서 마이그레이션을 검증하고 있었다. 문제는 실제 dev/prod DB는 그동안 운영되면서 쌓인 데이터와 누적된 스키마 상태를 갖고 있다는 점이다. 빈 DB에서는 아무 문제 없이 통과하지만, 실제 데이터가 있는 DB에서만 실패하는 마이그레이션은 CI가 원천적으로 잡아낼 수 없는 구조였다. 구체적으로는 이런 케이스들이다.

  • 기존 행(row) 중 NULL인 값이 있는 상태에서 NOT NULL 제약을 거는 경우
  • 기존 데이터에 중복 값이 있는 상태에서 UNIQUE 제약을 거는 경우
  • 실제 데이터가 참조 무결성을 깨는 상태에서 FK/CHECK 제약을 추가하는 경우
  • 특정 운영 데이터의 상태(예: 특정 컬럼 조합)에서만 실패하는 DML

즉 "CI는 통과했는데 실제 배포에서만 터지는 마이그레이션"이 구조적으로 가능한 상태였다. 목표는 두 가지로 명확히 정리됐다.

  1. 마이그레이션이 실패해도 기존 서버는 절대 죽지 않게 한다.
  2. 가능하다면 실제 DB를 건드리기 전에 그 실패를 미리 감지한다.

 

✏️ 2. 가설과 방안 선택

이 목표를 달성하기 위한 방법을 고를 때 세 가지 축을 따로 떼어서 봤다.

  • 어디서 실행하는가: GitHub Actions 러너에서 직접 vs EC2 내부에서
  • 언제 실행하는가: 컨테이너 교체 전 vs 교체 후(=지금 구조)
  • 무엇에 대고 먼저 검증하는가: 실제 DB에 바로 vs 복제한 사본에서 먼저

GitHub Actions 러너에서 직접 RDS에 접속하는 방식은 가장 먼저 후보에서 제외했다. GitHub hosted runnerIP 대역은 고정돼 있지 않고 수시로 바뀌기 때문에, 이걸 허용하려면 RDS 보안그룹 인바운드 규칙을 사실상 "출처 불특정"에 가깝게 열어야 할 가능성이 컸다. 지금 구조상 RDSEC2에서만 접근 가능하도록 잠겨 있을 가능성이 높았고, 이 경계를 넓히는 건 이번 작업의 목적(안정성 확보)과 반대 방향의 리스크를 새로 만드는 셈이었다. 그래서 DB에 손대는 모든 작업은 기존처럼 appleboy/ssh-action으로 EC2에 들어가서, EC2 내부에서 수행하는 쪽으로 방향을 잡았다. 이 결정 덕분에 보안그룹은 한 줄도 바꿀 필요가 없었다.

 

"실제 DB에 바로 적용 vs 복제본에서 먼저 검증" 두 가지는 완전히 대체 관계가 아니라 보완 관계라는 걸 처음에 명확히 하고 넘어갔다. 실제 DB에 바로 적용하는 방식만으로도 "마이그레이션 실행 시점을 컨테이너 교체보다 앞으로 당긴다"는 것 자체로 핵심 리스크(컨테이너 다운)는 해소된다. 다만 이 경우 실제 DB에 처음 적용해보는 순간이 곧 "실패를 알게 되는 순간"이라, CI의 빈 스키마에서는 못 잡는 데이터 기반 실패는 여전히 실배포 시점에야 드러난다. 복제 DB 리허설은 이 마지막 갭을 메우는 역할이다. 그래서 자연스럽게 작업을 두 개의 이슈로 나눴다.

  • #441: 컨테이너 교체 전에 실제 DB에 Flyway migrate를 먼저 실행 (핵심 리스크 해소)
  • #442: 실제 DB를 복제한 임시 DB에서 먼저 리허설한 뒤 실제 DB에 적용 (탐지 시점을 한 단계 더 앞당기는 보강)

 

✏️ 3.  #441: 컨테이너 교체 전 실제 DB migrate

이슈 #441의 목표는 단순하고 명확했다. 기존 컨테이너를 내리기 전에, 실제 dev/prod RDS에 대해 Flyway migrate를 먼저 실행하고, 성공했을 때만 컨테이너를 교체한다.

변경 전: docker rm -f → docker run → (Spring Boot 기동 중) Flyway 실행
변경 후: (EC2 내부에서) Flyway 실행 → 성공 시 docker rm -f → docker run

구현을 위해 우선 마이그레이션 SQL 파일 자체를 EC2로 옮길 방법이 필요했다. build.gradle에는 flyway-core/flyway-mysql이 애플리케이션 의존성으로만 들어있고 org.flywaydb.flyway Gradle 플러그인은 없어서, ./gradlew flywayMigrate 같은 태스크를 바로 쓸 수 없는 상태였다. Gradle 플러그인을 새로 추가하는 대신, Spring Boot 앱 전체를 부팅하지 않고도 마이그레이션만 독립적으로 실행할 수 있는 공식 flyway/flyway Docker 이미지를 쓰기로 했다. JWT 시크릿이나 AWS 자격증명 같은 다른 설정 없이 마이그레이션만 가볍게 검증할 수 있다는 게 이 방식의 장점이다.

그래서 .github/workflows/cd.yml에 다음 흐름을 추가했다.

  1. appleboy/scp-action으로 src/main/resources/db/migration/*를 EC2의 ~/eatssu-deploy/{prod,dev} 경로로 전송 (rm: true로 기존 대상 경로를 지우고 새로 덮어씀)
  2. 기존 appleboy/ssh-action 스크립트 맨 앞부분에 MIGRATION_DIR 존재 여부를 test -d로 확인
  3. sudo docker run --rm -v "$MIGRATION_DIR:/flyway/sql:ro" ... flyway/flyway:9.5.1 migrate 실행
  4. 스크립트 최상단에 set -e를 걸어, 위 명령이 0이 아닌 종료 코드를 반환하면 그 즉시 스크립트 전체가 중단되고 이후에 있는 docker rm -f, docker run은 아예 실행되지 않음

CLI 이미지 버전flyway/flyway:9.5.1을 사용했는데, 프로젝트가 Spring Boot 3.0.4의 의존성 관리로 끌어오는 Flyway 버전과 동일한 라인이라 CLI와 애플리케이션이 같은 버전의 Flyway 동작(체크섬 계산 방식, 지원 문법)을 쓰도록 맞춘 것이다. 버전이 어긋나면 애플리케이션이 부팅 시점에 "이미 CLI가 적용한 마이그레이션의 체크섬이 다르다"고 오인할 여지가 있어서, 이 부분은 처음부터 신경 써서 맞췄다.

 

또한 기존 application-dev.yml/application-prod.yml에 있던 Flyway 설정과 실제 동작이 어긋나지 않도록, CLI 실행 시 환경변수로 동일한 설정을 그대로 넘겼다. FLYWAY_CONNECT_RETRIES=10도 추가했는데, 이건 배포 직후 컨테이너 내리기 전이라 DB 자체는 항상 떠 있는 상태이긴 하지만, 일시적인 네트워크 흔들림이나 RDS 커넥션 슬롯 경합으로 즉시 연결이 안 되는 순간을 대비한 안전장치다. #441을 통해 확보한 핵심 결과는 명확하다. 마이그레이션이 실패해도 docker rm -f 자체가 실행되지 않으므로, 기존 컨테이너와 기존 서비스는 그대로 유지된다.

application-*.yml 설정  flyway/flyway CLI 환경변수
baseline-on-migrate: true FLYWAY_BASELINE_ON_MIGRATE=true
baseline-version: 1 FLYWAY_BASELINE_VERSION=1
out-of-order: true (prod만 존재) FLYWAY_OUT_OF_ORDER=true (prod 스텝에만 추가, dev는 미설정)
locations: classpath:db/migration FLYWAY_LOCATIONS=filesystem:/flyway/sql

 

✏️ 4. #442: 복제 DB 기반 마이그레이션 리허설

#441은 "컨테이너가 죽는" 리스크는 막아주지만, 여전히 실제 dev/prod DB에 처음으로 마이그레이션을 적용해보는 순간에야 실패 여부를 알게 된다는 한계가 남는다. 442는 이 한계를 좁히기 위해, 실제 DB에 손대기 전에 그 DB의 사본에서 먼저 리허설하는 단계를 추가했다.

전체 목표 흐름은 다음과 같다.

신규 migration 파일 감지
  → 없으면 리허설 skip, 곧바로 441의 실제 DB migrate로
  → 있으면:
     EC2에서 실제 RDS mysqldump
     → 임시 mysql:8.0 컨테이너 실행
     → dump 복원
     → 복제 DB에 Flyway migrate 리허설
     → 성공 시 441의 실제 DB migrate로 진행
     → 실패 시 배포 중단 (실제 DB는 전혀 안 건드림, docker rm -f도 실행 안 됨)

4-1. 신규 마이그레이션 감지

매 배포마다 dump-restore-리허설을 다 돌리면 배포 시간이 불필요하게 늘어난다. 그래서 이번 배포 범위에 새로 추가된 마이그레이션 파일이 있을 때만 리허설을 태우도록 조건을 걸었다. --diff-filter=A로 "추가(Added)"된 파일만 본 이유는, 마이그레이션 파일은 관례상 수정이나 삭제 없이 새 버전 번호로만 추가되기 때문이다(기존 파일을 고치면 체크섬이 깨져서 Flyway가 바로 에러를 낸다). 이 스텝을 쓰기 위해 actions/checkout도 fetch-depth: 0으로 바꿔서 github.event.before~github.sha 범위의 커밋 히스토리를 온전히 가져오도록 했다(얕은 clone 상태에서는 git diff가 원하는 범위를 제대로 비교하지 못한다).

git diff --name-only --diff-filter=A "$BEFORE" "$AFTER" -- src/main/resources/db/migration/

4-2. 실제 DB 덤프

각 옵션은 다음 이유로 넣었다.

  • --single-transaction: InnoDB 테이블을 하나의 트랜잭션 스냅샷으로 일관성 있게 덤프하기 위함. 이 옵션 덕분에 덤프 도중 운영 중인 서비스의 쓰기 작업을 테이블 락으로 막지 않는다.
  • --quick: 큰 테이블을 한 번에 메모리로 읽어들이지 않고 행 단위로 스트리밍해서 덤프 시 메모리 사용량을 줄인다.
  • --routines --triggers: 저장 프로시저/함수/트리거까지 포함해서, 실제 DB 구조와 최대한 가깝게 복제하기 위함.
  • --set-gtid-purged=OFF: RDS의 GTID 관련 메타데이터를 덤프에 포함시키지 않아, 복원 대상인 순정 mysql:8.0 컨테이너에서 복원 시 GTID 불일치로 인한 에러를 방지한다.
sudo docker run --rm -e MYSQL_PWD='...' mysql:8.0 mysqldump \
  --single-transaction --quick --routines --triggers --set-gtid-purged=OFF \
  -h "$DB_HOST" -P "$DB_PORT" -u '...' "$DB_NAME" > "$DUMP_FILE"

4-3. 임시 컨테이너 실행 및 복원

mysql:8.0 컨테이너를 하나 새로 띄우고(MYSQL_ROOT_PASSWORD=rehearsal), 준비될 때까지 기다린 뒤 덤프 파일을 그 안에 복원했다. 이 "준비됐는지 확인하는" 부분에서 실제로 버그를 하나 잡았는데, 자세한 내용은 5장에서 다룬다.

4-4. 복제 DB에서 Flyway 리허설

--network container:$REHEARSAL_CONTAINER는 Flyway CLI 컨테이너를 별도 네트워크로 띄우는 대신, 방금 만든 리허설용 MySQL 컨테이너와 네트워크 네임스페이스를 공유하도록 만드는 옵션이다. 이렇게 하면 Flyway 컨테이너 입장에서는 127.0.0.1:3306이 곧 그 MySQL 컨테이너가 되므로, 별도의 Docker 브리지 네트워크를 만들거나 포트를 호스트에 노출시키지 않고도 두 컨테이너가 통신할 수 있다. 리허설용으로 잠깐 쓰고 버릴 컨테이너인 만큼, 불필요하게 호스트 포트를 점유하지 않는 이 방식이 더 깔끔했다.

sudo docker run --rm \
  --network "container:$REHEARSAL_CONTAINER" \
  -v "$MIGRATION_DIR:/flyway/sql:ro" \
  -e FLYWAY_URL="jdbc:mysql://127.0.0.1:3306/$DB_NAME?..." \
  ...
  flyway/flyway:9.5.1 migrate

4-5. 정리(clean up)

trap ... EXIT로 스크립트가 정상 종료든, set -e에 의한 중간 실패든 어떤 경로로 끝나더라도 리허설 컨테이너와 덤프 파일이 항상 정리되도록 했다. 덤프 파일에는 실제 운영 데이터가 그대로 들어있기 때문에, 리허설이 실패해서 스크립트가 중간에 죽더라도 EC2 로컬 디스크에 그 파일이 방치되지 않게 하는 게 중요했다.

cleanup_rehearsal() {
  sudo docker rm -f "$REHEARSAL_CONTAINER" >/dev/null 2>&1 || true
  rm -f "$DUMP_FILE"
}
trap cleanup_rehearsal EXIT

4-6. 타임아웃

리허설 과정이 무한정 길어지는 걸 막기 위해 각 단계에 개별 타임아웃을 걸었고, 이 과정 전체를 감싸는 SSH 명령 자체의 타임아웃도 늘렸다.

단계 타임아웃
mysqldump 20m
dump 복원 20m
Flyway 리허설 migrate 10m
SSH command_timeout (전체) 60m

✏️ 5. PR 검증 과정

#442의 핵심 가설은 "CI의 빈 테스트 DB에서는 통과하지만, 실제 dev 데이터가 있는 DB에서는 실패하는 마이그레이션을 리허설 단계가 실제로 잡아내는가"였다. 이걸 확인하려고 일부러 실패하는 마이그레이션을 만들어 검증했는데, 그 과정에서 시행착오가 있었다.

5-1. 첫 번째 시도: college 테이블 CHECK 제약 — 실패

의도는 "실제 college 데이터에는 id가 100 이하인 행이 있으니 이 제약을 걸면 실패하겠지"였다. 그런데 이 시도는 원하는 지점에서 실패하지 않았다. college 테이블은 V10__populate_i18n_college.sql 같은 초기 데이터 채우기 마이그레이션을 통해 CI의 빈 테스트 DB에도 마이그레이션 과정 자체에서 데이터가 들어간다. 즉 CI 단계의 Flyway 검증(PR 게이트, ci.yml)에서 이미 이 제약이 걸리는 순간 실패해버려서, 애초에 배포(CD) 단계까지 가지도 못했다. 이건 442가 검증하려는 지점("실제 데이터에서만 실패")이 아니라 그냥 "아무 DB에서나 실패"하는 케이스였다.

ALTER TABLE college
ADD CONSTRAINT chk_rehearsal_college_id_over_100
CHECK (college_id > 100);
원했던 흐름:  CI 통과 → (deploy) 리허설에서 실패
실제 흐름:    CI에서부터 Flyway 실패 → deploy 자체를 못 감

5-2. 두 번째 시도: review 테이블 INSERT - 성공

CI 테스트 DB와 실제 dev DB의 차이는 "마이그레이션으로 들어간 데이터"가 아니라 "애플리케이션이 런타임에 만들어낸 데이터"에 있다는 점에 착안했다. review 테이블은 마이그레이션으로 시드 데이터가 들어가지 않고, 오직 사용자가 실제로 리뷰를 작성해야 행이 생긴다. CI는 ddl-auto: create-drop으로 매번 스키마만 새로 만들 뿐 리뷰 데이터가 있을 수 없고, 반대로 dev DB에는 실제 사용 과정에서 쌓인 리뷰가 있다. 이 비대칭을 이용했다.

 

CI의 빈 테스트 DB에서는 review가 비어 있으니 SELECT ... LIMIT 1이 0건을 반환하고, 결과적으로 0건 INSERT라 아무 문제 없이 통과한다. 반면 dev DB를 덤프해서 복원한 리허설 DB에는 실제 리뷰 데이터가 있으므로, 이미 존재하는 review_id를 다시 INSERT하려다가 기본키(PK) 중복으로 실패한다. 기대한 에러는 Duplicate entry '1' for key 'review.PRIMARY'였다.

INSERT INTO review (review_id, created_date, modified_date)
SELECT existing_review.review_id, NOW(6), NOW(6)
FROM (
    SELECT review_id
    FROM review
    LIMIT 1
) existing_review;

5-3. 임시 검증 환경 구성

cd.yml은 원래 main, develop push에서만 도는데, PR 브랜치에서는 CD 자체가 실행되지 않는다. 리허설 로직을 실제로 눈으로 확인하려면 어쨌든 한 번은 CD를 돌려봐야 했기 때문에, 검증 목적으로 임시로 트리거 조건에 작업 브랜치를 추가했다. 그리고 실수로라도 prod에 영향이 가지 않도록, dev 배포 관련 스텝만 feature 브랜치에서도 조건부로 실행되게 열었고 prod 관련 스텝은 건드리지 않았다.

branches: [ "main", "develop", "feat/#442-rehearse-migration-with-db-clone" ]

5-4. 버그1: MySQL readiness 체크 불분명

처음에는 임시 컨테이너가 준비됐는지 mysqladmin ping으로 확인했다.

if sudo docker exec "$REHEARSAL_CONTAINER" mysqladmin ping -uroot -prehearsal --silent; then

그런데 검증 중 복원 단계에서 다음 에러를 만났다.

ERROR 1045 (28000): Access denied for user 'root'@'localhost'

mysqladmin pingMySQL 서버 프로세스가 응답하는지만 확인할 뿐, 그 시점에 root 계정으로 실제 인증까지 가능한 상태인지는 보장하지 않는다. 컨테이너 초기화 스크립트가 계정을 완전히 세팅하기 전 짧은 순간에는 ping은 성공하지만 실제 로그인은 거부되는 구간이 있었던 것이다. 그래서 readiness 체크를 실제 쿼리 실행 성공 여부로 바꿨다. 또한 이 참에 -p비밀번호 형태로 커맨드라인에 비밀번호를 직접 넘기던 부분도 MYSQL_PWD 환경변수를 쓰는 방식으로 정리했다(커맨드라인 인자로 비밀번호를 넘기면 ps 출력 등에 노출될 수 있어서, 환경변수 쪽이 더 안전하다).

mysql -uroot -e "SELECT 1"

5-5. 버그2:  신규 마이그레이션 감지 범위가 좁다

검증 도중 한 번은 신규 마이그레이션 감지가 false로 나오면서 리허설이 그냥 스킵되고, 곧바로 실제 dev DB에 검증용 마이그레이션이 적용돼버린 적이 있었다.

신규 마이그레이션 파일이 없어 dev 리허설을 건너뜁니다.
Migrating schema `dev` to version "9999 - rehearsal failure check"

원인은 감지 기준이 github.event.before~github.sha, 즉 "이번 push 하나에 포함된 커밋 범위"만 보고 있었기 때문이다. 검증용 마이그레이션 파일은 이전 커밋에서 이미 추가돼 있었고, 그 다음 push는 cd.yml 스크립트 수정 하나뿐이었던 상황이라 이번 push의 diff에는 신규 마이그레이션 파일이 잡히지 않은 것이다. 실제 운영에서도 "마이그레이션 파일을 추가한 커밋"과 "그걸 배포하는 push"가 여러 커밋으로 나뉘어 있으면 같은 문제가 재현될 수 있는 구조적인 허점이었다. 검증을 계속 진행하기 위해 임시로 감지 기준을 feature 브랜치와 developmerge-base 기준으로 넓혀서 다시 확인했고, 이후 최종적으로 원하는 로그를 확인할 수 있었다.

dev DB 덤프를 시작합니다
리허설 MySQL 컨테이너를 시작합니다
덤프를 리허설 DB에 복원합니다
복제 DB에서 Flyway 마이그레이션 리허설을 실행합니다
Migrating schema `dev` to version "9999 - rehearsal failure check"
ERROR: Migration V9999__rehearsal_failure_check.sql failed
Duplicate entry '1' for key 'review.PRIMARY'
Process exited with status 1

중요한 건 이 로그 뒤로 다음 줄이 전혀 나오지 않았다는 점이다. 즉 리허설 단계에서 실패가 감지됐고, set -e에 의해 스크립트가 그 자리에서 멈춰 실제 dev DB migrate와 컨테이너 교체로는 진행되지 않았다. 이걸로 442가 검증하려던 핵심 가설을 확인했다 => CI의 빈 DB에서는 통과하는 마이그레이션도 실제 데이터를 반영한 리허설 DB에서는 실패할 수 있고, 그 실패를 실제 DB에 적용하기 전에 잡아 배포를 중단시킬 수 있다.

실제 dev DB migrate
docker rm -f eatssu-dev
docker run eatssu-dev

5-6. 검증 중 주의했던 점

검증용으로 만든 V9999__rehearsal_failure_check.sql은 절대 최종 PR에 남기면 안 되는 파일이었다. 실제 버전 넘버링 체계를 오염시킬 뿐 아니라, 나중에 정말로 필요한 마이그레이션이 그보다 앞선 번호로 추가될 경우 out-of-order 설정과 맞물려 예상치 못한 적용 순서를 만들 수 있기 때문이다. 또한 앞서 언급한 감지 범위 버그로 인해 검증 도중 실제 dev DB에 9999 버전이 실패 상태로 기록된 적이 있었다. 이걸 방치하면 이후 정상적인 배포 시도에서도 Flyway가 "9999 실패 이력이 남아있다"고 판단해 계속 막힐 수 있어서, 실제 dev DB 쪽만 별도로 확인하고 정리했다.

 

반면 리허설 DB 쪽의 실패 기록은 신경 쓸 필요가 없었다. 리허설용 MySQL 컨테이너는 매 실행마다 새로 만들어졌다가 trap cleanup_rehearsal로 함께 삭제되는, 애초에 영속성이 없는 일회용 자원이기 때문이다. 오직 실제 dev/prod DB의 flyway_schema_history만이 정리 대상이라는 걸 명확히 구분해서 처리했다.

SELECT installed_rank, version, description, success
FROM flyway_schema_history
WHERE version = '9999';

DELETE FROM flyway_schema_history
WHERE version = '9999';

 

✏️ 6. 최종 정리

검증이 끝난 뒤 최종 PR에서는 검증 과정에서 임시로 넣었던 것들을 모두 제거했다.

  • V9999__rehearsal_failure_check.sql
  • feature 브랜치를 대상으로 한 CD 임시 트리거 조건
  • feature 브랜치에서 dev 배포 스텝이 돌게 열어둔 임시 if 조건
  • feature 브랜치 전용으로 넓혔던 마이그레이션 감지 기준(merge-base 기준)

최종적으로 남긴 변경은 .github/workflows/cd.yml에 대한 본 기능 구현뿐이다. 최종 구조는 다음과 같다.

 

신규 마이그레이션이 없는 배포는 리허설 단계를 건너뛰고 기존과 동일한 속도로 진행된다.

이번 작업을 통해 얻은 가장 큰 교훈은, migration 검증은 "빈 DB에서 SQL이 문법적으로 실행되는가"만으로는 부족하다는 점이다. 실제 서비스 DB에는 시간이 지나며 누적된 데이터가 있고, 마이그레이션 실패는 대부분 바로 그 데이터와의 충돌에서 발생한다. 배포 안정성을 높이려면 다음 두 단계가 함께 필요했다.

  1. 컨테이너를 내리기 전에 실제 DB에 마이그레이션을 먼저 적용해, 실패 시 기존 서버가 죽지 않게 한다.
  2. 가능하다면 실제 DB를 건드리기도 전에 복제 DB에서 먼저 리허설해, 탐지 시점을 한 단계 더 앞당긴다.

이 두 단계를 통해 "Flyway 마이그레이션 실패가 곧바로 서비스 다운으로 이어지는" 기존 구조를 끊을 수 있었다.

main push
  → prod 배포
  → 신규 migration 있으면 prod DB dump 기반 리허설
  → 성공 시 실제 prod DB migrate
  → 성공 시 컨테이너 교체

develop push
  → dev 배포
  → 신규 migration 있으면 dev DB dump 기반 리허설
  → 성공 시 실제 dev DB migrate
  → 성공 시 컨테이너 교체

 

✏️ 7. 남은 과제: expand-contract 규율

다만 이 파이프라인이 모든 걸 해결해주는 건 아니다. 마이그레이션이 에러 없이 성공은 하지만 그 순간 아직 서비스 중인 이전 버전 애플리케이션과 호환되지 않는 경우(컬럼 삭제, 이름 변경 등)는 이번 441/442 어느 단계에서도 걸러지지 않는다. 이 부분은 파이프라인이 아니라 마이그레이션을 작성하는 방식 자체의 문제라서, 이후에는 컬럼/테이블을 없애거나 바꿔야 하는 변경을 "새로 추가 → 배포 → (다음 배포에서) 기존 것 제거" 식으로 나누는 expand-contract 규율을 팀 차원에서 지키는 게 남은 과제다.

 

이 글은 EAT-SSU 백엔드 이슈 #441, #442의 실제 변경 이력을 바탕으로 정리한 것이다. 

https://github.com/EAT-SSU/Server/pull/443

 

feat: 컨테이너 교체 전 Flyway 마이그레이션 실행 by pooreumjung · Pull Request #443 · EAT-SSU/Server

#️⃣ Issue Number resolved feature: 배포 파이프라인에서 컨테이너 교체 전 실제 DB Flyway 마이그레이션 선행 실행 #441 📝 요약(Summary) CD 파이프라인에서 기존 컨테이너를 docker rm -f로 제거하기 전에,

github.com

https://github.com/EAT-SSU/Server/pull/445

 

feat: 배포 전 복제 DB로 Flyway 마이그레이션 리허설 by pooreumjung · Pull Request #445 · EAT-SSU/Server

#️⃣ Issue Number resolved feature: 복제 DB를 이용한 배포 전 Flyway 마이그레이션 리허설 도입 #442 📝 요약(Summary) CD 파이프라인에 "신규 마이그레이션 파일 감지" 스텝을 추가했습니다. actions/checkout을

github.com

 

반응형
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/08   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31
글 보관함