새 버전이 배포된 뒤 readiness는 UP이었는데, 모범 음원 조회는 HTTP 500을 반환했습니다. 새 앱은 음원의 저장소 객체 키를 읽으려 했고, 운영 DB에는 그 컬럼이 아직 없었습니다. 실행 중인 앱을 그대로 두고 밀린 마이그레이션을 적용하자 같은 요청은 게시된 음원이 없을 때의 응답인 404로 바뀌었습니다. DB 연결이 정상이어도 새 쿼리에 필요한 컬럼이 준비됐는지는 별도로 확인해야 했습니다.

당시 앱 배포는 자동이었고 Flyway 적용은 수동이었습니다. 앱 교체와 스키마 변경의 순서를 사람이 맞춰야 했습니다. 이 배포 흐름을 바꾸는 작업에 참여하면서, 새 앱이 필요한 스키마를 먼저 준비하고 이전 앱도 그 스키마에서 계속 동작할 수 있어야 한다는 두 가지 조건을 함께 다뤘습니다.

앱 교체 직전에 Flyway 실행

운영 앱을 시작할 때 Flyway를 켜는 방안도 검토했습니다. 그렇게 하면 마이그레이션 성공 여부가 앱 기동에 직접 영향을 주고 백업 시점을 따로 통제하기 어려워집니다. 운영 앱에서는 Flyway를 끈 상태로 두고, 배포 중에만 별도 프로필로 실행하기로 했습니다. 기존 앱을 실행한 채 마이그레이션을 끝내고, 성공이 확인된 뒤 새 앱으로 교체하는 순서입니다.

  1. 공유 배포 잠금을 얻고 DB를 백업합니다.
  2. 배포 후보를 검증하고 후보 JAR 안의 SQL 파일을 운영 DB의 Flyway 이력과 대조합니다.
  3. 적용할 파일이 있으면 같은 JAR을 웹 서버와 스케줄러를 끈 일회성 컨테이너로 실행합니다. 적용 뒤 이력을 다시 조회합니다.
  4. 앱을 교체하고 health를 확인합니다. 스키마가 바뀐 배포는 변경 후 DB도 백업합니다.

앞서 율명 시드를 수동으로 적용할 때는 영향 범위가 분명했습니다. 변경하는 율명 테이블과 Flyway 이력만 mysqldump --single-transaction으로 덤프했습니다. 운영 기록에는 덤프의 접근 권한을 0600으로 제한하고 SHA-256을 남긴 사실이 있습니다. 그 덤프를 복원하는 시험 결과는 기록에 없습니다.

당시 일회성 컨테이너에는 비밀값을 호스트 명령의 인수에 적지 않고 docker run --env-file로 전달했습니다. 컨테이너 안에서 앱이 읽는 변수명으로 바꿔 실행했습니다. Docker Compose와 docker run은 환경 파일의 따옴표를 다르게 해석할 수 있어, 전달 전에 필요한 값에 따옴표가 붙었는지만 확인했습니다. 환경 파일을 쓰면 호스트 명령줄에 값을 적는 일을 피할 수 있지만, 컨테이너 설정을 읽을 권한이 있는 사람에게 환경값까지 숨겨 주지는 않습니다. Docker 실행 옵션 문서 · Compose 환경 파일 문서

적용 전 율명 테이블은 0행이었습니다. 적용 뒤에는 율명 11행과 해당 Flyway 버전의 성공 이력을 확인했습니다. 0.093초는 이 수동 적용의 Flyway 로그에 남은 실행 시간입니다.

파일 이름이 다르면 두 브랜치가 같은 Flyway 버전 번호를 사용해도 Git에서 충돌 없이 병합될 수 있습니다. 그래서 일반 빌드의 단위 테스트에서 버전 중복을 검사하고 배포 도구도 후보 JAR을 다시 검사합니다. 운영 이력에 있는 파일이 후보에서 빠졌거나 이미 적용한 SQL의 체크섬이 달라져도 교체 전에 멈춥니다. 대기 중인 마이그레이션이 없는 배포도 이 대조를 거칩니다. Flyway 체크섬의 줄바꿈과 공백

이전 앱의 데이터 호환성

새 앱으로 교체한 뒤 health 확인이 실패하면 배포 도구는 이전 JAR을 다시 선택합니다. 그때 DB에는 이미 새 스키마가 적용돼 있습니다. 교체 직전에도 이전 앱이 그 DB를 사용하므로, 롤백 여부와 관계없이 두 버전이 잠시 같은 스키마를 쓸 수 있어야 합니다.

컬럼을 지우거나 이름을 바꾸는 SQL처럼 지정된 위험 패턴에 걸리면, 검토한 SQL 파일의 SHA-256이 현재 후보 파일과 일치할 때만 적용하도록 했습니다. 파일이 바뀌면 검토도 다시 받아야 합니다. 이 검사는 SQL의 모양을 기준으로 삼습니다. 예를 들어 이미지 URL에 NULL을 허용하고 이미지가 없는 데이터를 추가하면, 이전 코드가 여전히 모든 이미지 URL을 필수로 처리하는지 확인해야 합니다. 호환성을 검토할 때는 테이블 정의와 실제로 들어갈 행을 함께 봅니다.

연습 세션의 미션 컬럼을 삭제하는 V25에서는 배포를 두 번으로 나눴습니다. 배포 도구가 새 JAR을 선택하기 전에 V25를 적용하면, 그동안 실행 중인 이전 버전은 사라진 컬럼을 읽게 됩니다. 먼저 컬럼을 읽지 않는 코드로 바꾸고, 실행 JAR에는 V1부터 V24까지 넣어 컬럼을 남긴 채 운영에 배포했습니다. 이 상태에서 기존 세션 143건 중 해당 컬럼에 값이 있는 행은 0건이었습니다. 운영에서 새 세션을 생성해 201, 조회해 200을 받았고, 같은 키로 생성 요청을 다시 보내자 201과 기존 세션 ID가 돌아왔습니다.

다음 JAR에는 V25 SQL을 원본과 같은 바이트로 다시 넣었습니다. 폐기용 MySQL에서는 원본 V25를 적용하고 DB 통합 검사 25건과 세션 API 통합 검사 5건을 통과했습니다. 운영에서 확인한 것은 컬럼을 남긴 첫 배포의 세션 동작이며, 컬럼 삭제 검증은 폐기용 DB에서 진행했습니다.

앱 JAR을 되돌리는 것과 이미 넣은 시드 행을 지우는 일도 나눠 판단했습니다. 단소 악기와 곡을 추가한 시드의 되돌리기 계획에는 회원이나 연습 세션이 새 행을 외래 키로 참조하는지 먼저 세는 절차가 있었습니다. 참조가 0행일 때만 삭제를 검토하고, 사용자가 만든 참조가 생겼다면 작업을 멈춰 별도로 판단하기로 했습니다. 실제로 그 시드를 되돌렸다는 기록은 없습니다.

첫 ALTER 전 행과 스키마 검사

새 제약을 적용할 때는 기존 행이 조건을 만족하는지 먼저 검사했습니다. 아래는 status가 필수인 연습 기록에서 실패 상태와 실패 사유의 유무가 일치하는지만 보여 주는 예입니다. 새 제약과 같은 조건식을 임시 테이블에 삽입합니다. 검사에 실패하면 업무 테이블을 변경하는 ALTER TABLE에 도달하기 전에 Flyway가 멈춥니다.

CREATE TEMPORARY TABLE migration_precheck (
  ok BOOLEAN NOT NULL CHECK (ok = TRUE)
);

INSERT INTO migration_precheck (ok)
SELECT (status = 'FAILED') = (failure_reason IS NOT NULL)
FROM practice_sessions;

DROP TEMPORARY TABLE migration_precheck;

ALTER TABLE practice_sessions
  ADD CONSTRAINT chk_failure_reason
  CHECK ((status = 'FAILED') = (failure_reason IS NOT NULL));

실제 마이그레이션은 사전 검사와 최종 제약 양쪽에서 실패 코드가 허용 목록에 있는지도 확인합니다. 대소문자를 구분하지 않는 정렬 규칙에서는 소문자 no_sound도 NO_SOUND와 같다고 판단할 수 있습니다. 그래서 실패 코드 비교에는 COLLATE utf8mb4_bin을 지정했습니다. DB 통합 테스트는 허용 코드 6개가 저장되고, 소문자 코드·목록에 없는 코드·실패 사유 누락은 모두 제약 위반 3819로 거절되는지 확인했습니다. MySQL 정렬 규칙 이름의 의미

학습 단계와 진도 테이블을 바꾼 별도 마이그레이션에서는 스키마 이름도 사전에 검사했습니다. information_schema를 조회해 없앨 외래 키와 CHECK 제약, 이름을 바꿀 유일 인덱스가 예상한 이름으로 존재하는지 확인했습니다. 하나라도 다르면 임시 테이블의 검사에서 실패해 첫 ALTER TABLE 전에 멈추도록 구성했습니다. 운영에서 이름이 달라 이 검사에 걸린 기록은 없습니다.

MySQL에서 ALTER TABLE은 암묵적 커밋을 일으킵니다. 여러 DDL 문장 중 뒤쪽 문장이 실패해도 앞서 성공한 변경은 남을 수 있습니다. 재시도하기 전에는 Flyway 이력과 실제 테이블 정의를 모두 읽고 배포 전 백업과 대조해야 합니다. MySQL 8.4의 암묵적 커밋 문서

리허설 실패와 운영 배포 순서

리허설에서는 실제 배포 후보 JAR과 폐기용 MySQL 8.4.9를 사용했습니다. 마이그레이션을 계획하고 적용하는 배포 코드도 그대로 실행했고, Docker 호출만 로컬 Java와 MySQL 호출로 대체했습니다. 실패를 넣은 마이그레이션에서는 첫 문장이 추가한 컬럼이 남고 Flyway 이력에 실패가 기록됐습니다. 다음 배포를 계획할 때 남은 컨테이너가 실행을 막았고, 컨테이너가 없어도 실패 이력이 있으면 배포가 멈췄습니다.

배포 도구 테스트에서는 두 실패를 따로 확인했습니다. 마이그레이션 계획 단계가 실패하면 실행 중인 앱을 교체하지 않았습니다. 새 앱을 선택한 뒤 실행에 실패하도록 만들면, 도구가 이전 JAR을 다시 선택해 실행하고 배포 상태를 롤백으로 기록했습니다.

이후 운영에서 두 차례 연속으로 사전 백업, Flyway 자동 적용, 앱 교체, 사후 백업 순서를 확인했습니다. 첫 자동 적용에서는 시드 마이그레이션 한 건을 실행하는 데 0.23초가 걸렸습니다. Flyway 실행 로그에 남은 시간으로, 백업과 앱 교체는 그 앞뒤에 따로 진행됐습니다. 두 번째 자동 배포에서는 새 앱이 사용할 악보 초안 컬럼 4개가 먼저 추가됐고, 앱 교체 후 사후 DB 백업도 수신됐습니다.

이렇게 앱 배포와 수동 마이그레이션의 시점을 맞추던 작업을 배포 도구가 맡았습니다. 호환되는 변경은 새 앱을 교체하기 전에 적용하고, 이전 앱이 다시 실행될 경우를 고려해 변경 후 스키마와 데이터까지 검토합니다.