From 66a80c21b2a2708e99fe4cebf26c50a124341ed9 Mon Sep 17 00:00:00 2001 From: LeeJeongHeon02 Date: Wed, 22 Jul 2026 01:05:48 +0900 Subject: [PATCH] fix(migration): guard V12 vision_summary_5s recreate against fresh bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V12 was written to recover from a real prod incident where the table was manually DROPped, so it always ran a plain CREATE TABLE. Any environment that applies V1-V12 from scratch (new staging, MySQL-backed local setup, migration-only DR rebuild) hits "Table already exists" at V12 because V1 already creates the same table and nothing ever dropped it there. Confirmed with mysql:8.0.46 that V12's column/constraint/index list is byte-identical to what V1 + V4 already produce, so V12 now only runs its DDL when vision_summary_5s is actually missing (checked via information_schema + PREPARE/EXECUTE, since MySQL has no IF NOT EXISTS for CREATE INDEX). Verified both the fresh-bootstrap path and the original incident-recovery path (V1-V11, manual DROP, then V12) converge on an identical schema. Since this changes the checksum of a migration already applied in prod, the README's DB 마이그레이션 section now documents that prod needs a `flyway repair` before the next deploy. Co-Authored-By: Claude Sonnet 5 --- README.md | 17 ++++++- .../V12__recreate_vision_summary_5s.sql | 48 +++++++++++++++++-- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 94ce84b..66df8a7 100644 --- a/README.md +++ b/README.md @@ -184,10 +184,25 @@ docker rm -f klljs-mysql-test ## DB 마이그레이션 -운영 DB 스키마는 `src/main/resources/db/migration`의 Flyway 마이그레이션(V1~V12)으로 관리하고, +운영 DB 스키마는 `src/main/resources/db/migration`의 Flyway 마이그레이션(V1~V13)으로 관리하고, 애플리케이션은 `ddl-auto=validate`로 스키마와의 불일치만 검증할 뿐 직접 바꾸지 않는다. 로컬은 반대로 Flyway 없이 Hibernate `ddl-auto=create-drop`으로 매번 새로 만든다. +**검증은 반드시 실제 MySQL로 한다** — 로컬 프로필은 Flyway 자체를 안 타고, H2는 잠금/제약 +동작이 MySQL과 달라 Flyway·FK·동시성 관련 버그를 못 잡는다. 새 마이그레이션을 추가하면 +`mysql:8.0.46` Docker 컨테이너에 `V1`부터 전체 이력을 처음부터 적용해보고 통과하는지 확인할 것 +(신규 스테이징 환경, MySQL 기반 로컬 셋업, 마이그레이션만으로 하는 DR 복구가 모두 이 경로를 +탄다 - `V12__recreate_vision_summary_5s.sql`가 한동안 이 경로에서 "Table already exists"로 +막혀 있었던 사례가 있다). + +**이미 운영에 적용된 마이그레이션 파일은 원칙적으로 수정하지 않는다.** 불가피하게 수정하면 +(예: `V12`처럼 사고 복구용으로 작성된 마이그레이션을 신규 환경에서도 안전하게 만들 때) +파일 내용이 바뀌어 checksum이 바뀌므로, 그 버전이 이미 적용된 환경(운영)에서는 다음 배포 시 +Flyway validate가 checksum mismatch로 실패해 앱이 기동하지 않는다. 이런 수정을 배포하기 전에는 +운영 DB에 대해 먼저 `flyway repair`(또는 동등하게 `flyway_schema_history`의 해당 버전 checksum을 +새 파일 기준으로 수동 갱신)를 실행해야 한다 - repair는 메타데이터만 갱신하고 SQL을 실행하지 +않으므로 운영 스키마 자체에는 영향이 없다. + --- ## CI/CD diff --git a/src/main/resources/db/migration/V12__recreate_vision_summary_5s.sql b/src/main/resources/db/migration/V12__recreate_vision_summary_5s.sql index aa81406..7e7b0e8 100644 --- a/src/main/resources/db/migration/V12__recreate_vision_summary_5s.sql +++ b/src/main/resources/db/migration/V12__recreate_vision_summary_5s.sql @@ -7,8 +7,37 @@ -- -- 컬럼/제약/인덱스는 V1__init_schema.sql의 원본 정의에 V4__vision_summary_dedup_key_drop_seq.sql의 -- 변경사항(3컬럼 UNIQUE -> 2컬럼 UNIQUE, 중복 인덱스 제거)까지 반영한 "DROP 직전 최종 상태" 그대로다. +-- +-- [부트스트랩 가드] 이 마이그레이션은 애초에 "테이블이 이미 DROP돼서 없는" 운영 DB만 +-- 겨냥해 무조건 CREATE TABLE을 실행하도록 작성됐다. 그런데 V1부터 전체 이력을 처음부터 +-- 적용하는 환경(신규 스테이징, MySQL 기반 로컬 셋업, 마이그레이션만으로 하는 DR 복구 등 - +-- 사고가 실제로 일어난 적 없는 환경)에서는 V1이 이미 이 테이블을 만들어 놓은 상태라 +-- V12가 "Table 'vision_summary_5s' already exists"로 실패해 전체 마이그레이션이 막힌다. +-- 위 CREATE TABLE 정의는 V1 원본 + V4 변경사항을 반영한 스키마와 컬럼/제약/인덱스가 +-- 완전히 동일함을 실제 MySQL 8.0.46으로 확인했으므로, "테이블이 없을 때만" 실행하도록 +-- information_schema로 존재 여부를 확인한 뒤 PREPARE/EXECUTE로 조건부 실행한다 +-- (MySQL은 CREATE INDEX에 IF NOT EXISTS를 지원하지 않아 CREATE TABLE IF NOT EXISTS만으로는 +-- 부족하다 - 인덱스 생성문도 함께 가드해야 한다). 두 시나리오 모두 같은 최종 상태로 수렴한다: +-- 1) 사고 복구(운영): 테이블이 없음 -> 그대로 생성한다 (기존 동작 그대로). +-- 2) 신규 환경 부트스트랩: 테이블이 이미 있음(V1이 만듦) -> 아무것도 하지 않고 건너뛴다. +-- +-- 주의(배포 운영자용): 운영 DB에는 이 마이그레이션이 이미 V12로 적용돼 있고 +-- flyway_schema_history에 그때의 checksum이 기록돼 있다. 이 파일 내용을 바꿨으므로 +-- checksum도 바뀐다 - 그대로 배포하면 다음 기동 시 Flyway validate가 checksum mismatch로 +-- 실패해 앱이 뜨지 않는다. 이 PR을 운영에 배포하기 전에 운영 DB에 대해 반드시 먼저 +-- `flyway repair`(또는 동등하게 flyway_schema_history의 version=12 행 checksum을 새 +-- 파일 기준으로 수동 갱신)를 실행할 것. repair는 SQL을 실행하지 않고 메타데이터만 +-- 갱신하므로 운영 스키마 자체에는 영향이 없다. -- ============================================================ +SET @vision_summary_5s_exists = ( + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = 'vision_summary_5s' +); + +SET @ddl_create_table = IF(@vision_summary_5s_exists = 0, ' CREATE TABLE vision_summary_5s ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, media_unit_id BIGINT UNSIGNED NOT NULL, @@ -71,7 +100,20 @@ CREATE TABLE vision_summary_5s ( CONSTRAINT uk_vision_summary_media_time UNIQUE (media_unit_id, event_time), CONSTRAINT fk_vision_summary_media_unit FOREIGN KEY (media_unit_id) REFERENCES media_units (id) ON DELETE RESTRICT, CONSTRAINT fk_vision_summary_campaign FOREIGN KEY (campaign_id) REFERENCES campaigns (id) ON DELETE SET NULL -) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci; +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci', 'DO 0'); + +PREPARE stmt FROM @ddl_create_table; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @ddl_create_index_campaign_time = IF(@vision_summary_5s_exists = 0, + 'CREATE INDEX ix_vision_summary_campaign_time ON vision_summary_5s (campaign_id, event_time)', 'DO 0'); +PREPARE stmt FROM @ddl_create_index_campaign_time; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; -CREATE INDEX ix_vision_summary_campaign_time ON vision_summary_5s (campaign_id, event_time); -CREATE INDEX ix_vision_summary_event_time ON vision_summary_5s (event_time); +SET @ddl_create_index_event_time = IF(@vision_summary_5s_exists = 0, + 'CREATE INDEX ix_vision_summary_event_time ON vision_summary_5s (event_time)', 'DO 0'); +PREPARE stmt FROM @ddl_create_index_event_time; +EXECUTE stmt; +DEALLOCATE PREPARE stmt;