package com.erp.migration; import com.erp.tenant.DbContextHolder; import com.erp.tenant.TenantDbSettings; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.ibatis.session.SqlSession; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import java.sql.Connection; import java.sql.DriverManager; import java.sql.Statement; import java.util.List; /** * 앱 부팅 시 idempotent 스키마 마이그레이션 실행. * * 왜 필요: * DB-per-tenant 구조 → 메타 DB 1개 + 활성 테넌트 DB N개 에 동일 ALTER 필요. * runbook 문서 (`db/migrations/RUN_*.md`) 로만 남기면 배포 때 사람이 까먹으면 장애. * 부팅 때 `IF NOT EXISTS` 로 안전하게 돌려두면 배포 순서/인적 실수와 무관. * * 원칙: * - 각 ALTER 는 반드시 idempotent (`IF NOT EXISTS` / 재실행 안전) * - 테넌트 DB 하나 실패해도 다른 DB 는 계속 진행 (ERROR 로그만) * - 메타 DB 가 실패하면 앱 시작은 계속되지만 WARN 크게 남김 */ @Component @RequiredArgsConstructor @Slf4j public class StartupSchemaMigrator { private final TenantDbSettings tenantDbSettings; private final SqlSession sqlSession; @Value("${spring.datasource.url}") private String metaJdbcUrl; private static final List MIGRATIONS = List.of( // RUN_082: 첫 로그인 비밀번호 강제 변경 플래그 "ALTER TABLE USER_INFO ADD COLUMN IF NOT EXISTS FORCE_PASSWORD_CHANGE BOOLEAN DEFAULT FALSE", // V017: 회사 관리 그룹 하위 관리자 메뉴 순서 재배열 // 조직 계층(회사→부서→사용자) + 권한 체계(메뉴→권한→권한그룹) // 메타 DB 는 Flyway V017 로도 적용되지만 프로비저닝된 테넌트 DB 는 // 회사 생성 시점 스냅샷이 박혀있으므로 부팅 때 모든 활성 DB 에 동기화. // SEQ 만 갱신 → 멱등. // 타입 주의: SEQ 가 varchar 이므로 THEN 값도 문자열 리터럴로 줄 것 // (정수 리터럴이면 ELSE SEQ 와 CASE 타입 불일치 42804 발생). """ UPDATE MENU_INFO SET SEQ = CASE MENU_NAME_KOR WHEN '회사관리' THEN '100' WHEN '부서관리' THEN '200' WHEN '사용자관리' THEN '300' WHEN '메뉴관리' THEN '400' WHEN '권한관리' THEN '500' WHEN '권한 그룹관리' THEN '600' ELSE SEQ END WHERE MENU_TYPE = '0' AND COMPANY_CODE = '*' AND PARENT_OBJ_ID IS NOT NULL AND PARENT_OBJ_ID <> '0' AND MENU_NAME_KOR IN ( '회사관리', '부서관리', '사용자관리', '메뉴관리', '권한관리', '권한 그룹관리' ) """, // V018 (1) 부서관리 V1 - DEPT_INFO 소프트삭제 컬럼. // DELETE 동작이 hard 가 아니라 DELETED_AT = NOW() 로 전환됨. // 메타 DB 는 Flyway V018 로도 적용되지만 프로비저닝된 테넌트 DB 는 부팅 때 동기화. "ALTER TABLE DEPT_INFO ADD COLUMN IF NOT EXISTS DELETED_AT TIMESTAMP NULL", // V018 (2) DEPT_INFO 활성 부서 부분 인덱스 (DELETED_AT IS NULL 쿼리 가속) "CREATE INDEX IF NOT EXISTS IDX_DEPT_INFO_ACTIVE ON DEPT_INFO (COMPANY_CODE, PARENT_DEPT_CODE) WHERE DELETED_AT IS NULL", // V019: 부서관리 V1 - DEPT_INFO 미사용/중복 컬럼 정리. // 메타 DB 는 Flyway V019 로도 적용되지만 프로비저닝된 테넌트 DB 는 부팅 때 동기화. // DROP IF EXISTS 로 멱등성 보장. "ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS MASTER_SABUN", "ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS MASTER_USER_ID", "ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS ORG_HEAD", "ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS LOCATION_NAME", "ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS SALES_YN", "ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS SHOW_IN_CHART", "ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS ERP_MANAGED", "ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS DATA_TYPE", // V020: 사용자별 메뉴 즐겨찾기 테이블. // 메타 DB 는 Flyway V020 으로도 적용되지만 프로비저닝된 테넌트 DB 는 부팅 때 동기화. // CREATE IF NOT EXISTS 로 멱등성 보장. """ CREATE TABLE IF NOT EXISTS USER_MENU_FAVORITES ( OBJID BIGSERIAL PRIMARY KEY, USER_ID VARCHAR(100) NOT NULL, MENU_OBJID VARCHAR(50) NOT NULL, SORT_ORDER INTEGER NOT NULL DEFAULT 0, CREATED_AT TIMESTAMP NOT NULL DEFAULT NOW(), CONSTRAINT UQ_USER_MENU_FAVORITES UNIQUE (USER_ID, MENU_OBJID) ) """, "CREATE INDEX IF NOT EXISTS IDX_USER_MENU_FAVORITES_USER ON USER_MENU_FAVORITES (USER_ID)", // RUN_086 (1) btree_gist 확장 — USER_SUBSTITUTES 의 EXCLUDE 제약 의존성 "CREATE EXTENSION IF NOT EXISTS btree_gist", // RUN_086 (2) 대무자(代務者) 관리 테이블 // self-위임 차단 (CHECK), 같은 쌍 활성 기간 겹침 차단 (EXCLUDE). // 재실행 시 IF NOT EXISTS 로 안전. EXCLUDE/CHECK 제약은 첫 생성 때만 적용. """ CREATE TABLE IF NOT EXISTS USER_SUBSTITUTES ( SUBSTITUTE_ID BIGSERIAL PRIMARY KEY, COMPANY_CODE VARCHAR(50) NOT NULL, ORIGINAL_USER_ID VARCHAR(50) NOT NULL, PROXY_USER_ID VARCHAR(50) NOT NULL, START_DATE DATE NULL, END_DATE DATE NOT NULL, REASON VARCHAR(500), IS_ACTIVE BOOLEAN NOT NULL DEFAULT TRUE, CREATED_BY VARCHAR(50), CREATED_DATE TIMESTAMP NOT NULL DEFAULT NOW(), UPDATED_BY VARCHAR(50), UPDATED_DATE TIMESTAMP NOT NULL DEFAULT NOW(), CONSTRAINT chk_user_substitutes_self CHECK (ORIGINAL_USER_ID <> PROXY_USER_ID), CONSTRAINT chk_user_substitutes_date CHECK (START_DATE IS NULL OR START_DATE <= END_DATE), CONSTRAINT excl_user_substitutes_overlap EXCLUDE USING gist ( COMPANY_CODE WITH =, ORIGINAL_USER_ID WITH =, PROXY_USER_ID WITH =, daterange(START_DATE, END_DATE, '[]') WITH && ) WHERE (IS_ACTIVE = TRUE) ) """, // RUN_086 (3) USER_SUBSTITUTES 인덱스 — Filter 핫패스 + 조회 가속 "CREATE INDEX IF NOT EXISTS idx_user_substitutes_original ON USER_SUBSTITUTES (COMPANY_CODE, ORIGINAL_USER_ID, IS_ACTIVE)", "CREATE INDEX IF NOT EXISTS idx_user_substitutes_proxy ON USER_SUBSTITUTES (COMPANY_CODE, PROXY_USER_ID, IS_ACTIVE)", // RUN_086 (4) SYSTEM_AUDIT_LOG — 처리자(actual processor) 분리 기록 컬럼 "ALTER TABLE SYSTEM_AUDIT_LOG ADD COLUMN IF NOT EXISTS PROCESSOR_ID VARCHAR(50)", "ALTER TABLE SYSTEM_AUDIT_LOG ADD COLUMN IF NOT EXISTS PROCESSOR_NAME VARCHAR(100)", // RUN_086 (5) APPROVAL_PROXY_SETTINGS → USER_SUBSTITUTES 1회 데이터 복사 (idempotent) // 기존 운영 데이터 보존 + 어댑터 read 경로가 즉시 동작하도록. // IS_ACTIVE 매핑: APPROVAL_PROXY_SETTINGS 의 CHAR('Y'/'N') → BOOLEAN. // 메타데이터(created/updated) 는 원본 컬럼 의존 없이 'migration_086' + NOW() 로 고정 // (APPROVAL_PROXY_SETTINGS 의 timestamp 컬럼명이 환경별로 다를 수 있어 안전한 default 채택). """ INSERT INTO USER_SUBSTITUTES ( COMPANY_CODE, ORIGINAL_USER_ID, PROXY_USER_ID, START_DATE, END_DATE, REASON, IS_ACTIVE, CREATED_BY, CREATED_DATE, UPDATED_BY, UPDATED_DATE ) SELECT p.COMPANY_CODE, p.ORIGINAL_USER_ID, p.PROXY_USER_ID, CAST(NULLIF(p.START_DATE, '') AS DATE), CAST(NULLIF(p.END_DATE, '') AS DATE), p.REASON, CASE WHEN p.IS_ACTIVE = 'Y' THEN TRUE ELSE FALSE END, 'migration_086', NOW(), 'migration_086', NOW() FROM APPROVAL_PROXY_SETTINGS p WHERE NULLIF(p.END_DATE, '') IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM USER_SUBSTITUTES s WHERE s.COMPANY_CODE = p.COMPANY_CODE AND s.ORIGINAL_USER_ID = p.ORIGINAL_USER_ID AND s.PROXY_USER_ID = p.PROXY_USER_ID AND s.START_DATE IS NOT DISTINCT FROM CAST(NULLIF(p.START_DATE, '') AS DATE) AND s.END_DATE = CAST(NULLIF(p.END_DATE, '') AS DATE) ) """, // V021 / RUN_087: BATCH_MAPPINGS 에 MAPPING_CONFIG JSONB 컬럼 추가. // conditional 매핑(when/then/default) 규칙 저장용. // direct/fixed 매핑은 NULL. 메타 DB 는 Flyway V021 로도 적용되지만 // 프로비저닝된 테넌트 DB 는 부팅 때 동기화. "ALTER TABLE BATCH_MAPPINGS ADD COLUMN IF NOT EXISTS MAPPING_CONFIG JSONB", // V022 / RUN_088: 부서별 다중 관리자(결재/부서/조직장) 매핑 테이블. // 기존 DEPT_INFO.APPROVAL_MANAGER/DEPT_MANAGER 단일 컬럼 → 매핑 테이블로 다중화. // role: 'approval' | 'dept' | 'org_leader'. 부서 hard-delete 시 CASCADE 로 정리. // 메타 DB 는 Flyway V022 로도 적용되지만 프로비저닝된 테넌트 DB 는 부팅 때 동기화. """ CREATE TABLE IF NOT EXISTS DEPT_MANAGERS ( DEPT_CODE VARCHAR(1024) NOT NULL, USER_ID VARCHAR(50) NOT NULL, ROLE VARCHAR(20) NOT NULL, SORT_ORDER INTEGER NOT NULL DEFAULT 1, CREATED_AT TIMESTAMP NOT NULL DEFAULT NOW(), PRIMARY KEY (DEPT_CODE, USER_ID, ROLE), CONSTRAINT chk_dept_managers_role CHECK (ROLE IN ('approval', 'dept', 'org_leader')), CONSTRAINT fk_dept_managers_dept FOREIGN KEY (DEPT_CODE) REFERENCES DEPT_INFO(DEPT_CODE) ON DELETE CASCADE ) """, "CREATE INDEX IF NOT EXISTS idx_dept_managers_role ON DEPT_MANAGERS (DEPT_CODE, ROLE, SORT_ORDER)", // V023 / RUN_089: MENU_INFO 에 IS_SOLUTION_ONLY 컬럼 추가. // 솔루션 관리 호스트(solution.invyone.com 등) 에서만 노출되는 메뉴 플래그. // 테넌트 사이트에선 mapper SQL 단계에서 제외. 메타 DB 는 Flyway V023 으로도 적용되지만 // 프로비저닝된 테넌트 DB 는 부팅 때 동기화. "ALTER TABLE MENU_INFO ADD COLUMN IF NOT EXISTS IS_SOLUTION_ONLY BOOLEAN DEFAULT FALSE NOT NULL", // V023 데이터 동기화: 솔루션 전용 메뉴 마킹. // 회사관리 / 회사 프로비저닝 / 감사로그는 관리 호스트에서만 노출돼야 함. // 이미 TRUE 인 행은 그대로 두기 위해 false 인 행만 갱신. """ UPDATE MENU_INFO SET IS_SOLUTION_ONLY = TRUE WHERE IS_SOLUTION_ONLY = FALSE AND MENU_URL IN ( '/admin/sysMng/subdomainList', '/admin/userMng/companyList', '/admin/audit-log' ) """, // V024 / RUN_089: TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO rename. // 5/15 common-code 재설계(commit 2348800e) 가 mapper SQL 의 컬럼 참조명만 // 바꾸고 DB rename 을 빠뜨려, 테이블타입관리 컬럼 조회 API 가 500 반환. // PostgreSQL 은 RENAME COLUMN 에 IF EXISTS 가 없어서 DO 블록으로 멱등 처리: // - CODE_CATEGORY 만 있는 기존 테넌트: rename 수행 // - 이미 CODE_INFO 인 신규 테넌트: no-op // - 둘 다 있거나 둘 다 없는 비정상 상태: no-op (방어적) """ DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'table_type_columns' AND column_name = 'code_category' ) AND NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'table_type_columns' AND column_name = 'code_info' ) THEN ALTER TABLE TABLE_TYPE_COLUMNS RENAME COLUMN CODE_CATEGORY TO CODE_INFO; END IF; END $$ """, // V025 / RUN_090 (1) TABLE_TYPE_COLUMNS 중복 행 정리. // PK 가 id 단일 (varchar) 인데 (TABLE_NAME, COLUMN_NAME, COMPANY_CODE) 에는 // UNIQUE 가 없어서 같은 키로 row 가 여러 개 INSERT 된 이력이 있음. // 메타 DB 실측: 35K rows 중 2 그룹 4 row 가 중복. 그 그룹들은 동일 데이터를 // updated_date NULL 짜리 옛 row 와 2026-03-16 마지막 갱신 row 가 공존하는 형태. // 가장 최근 (updated_date DESC NULLS LAST, id::bigint DESC) 행만 남기고 제거. // 테넌트 DB 들은 실측상 중복 없음 → DELETE 0건. 멱등 (재실행해도 변화 없음). """ DELETE FROM TABLE_TYPE_COLUMNS WHERE id IN ( SELECT id FROM ( SELECT id, ROW_NUMBER() OVER ( PARTITION BY TABLE_NAME, COLUMN_NAME, COMPANY_CODE ORDER BY UPDATED_DATE DESC NULLS LAST, id::bigint DESC ) AS rn FROM TABLE_TYPE_COLUMNS ) r WHERE r.rn > 1 ) """, // V025 / RUN_090 (2) ON CONFLICT 매칭용 UNIQUE INDEX 추가. // mapper 의 upsertColumnSettings / upsertNullable / upsertUnique / // upsertColumnInputType 모두 ON CONFLICT (TABLE_NAME, COLUMN_NAME, COMPANY_CODE) // 를 쓰는데 DB 엔 매칭 unique 제약이 없어서 모든 쓰기 API 가 500. // 인덱스 형태로 등록하면 ON CONFLICT 가 인식하고 ADD CONSTRAINT 식의 // IF NOT EXISTS 누락 문제도 회피. "CREATE UNIQUE INDEX IF NOT EXISTS UX_TABLE_TYPE_COLUMNS_TCC ON TABLE_TYPE_COLUMNS (TABLE_NAME, COLUMN_NAME, COMPANY_CODE)", // V026 / RUN_091: TABLE_TYPE_COLUMNS.INPUT_TYPE legacy → 표준 8종 정리. // 5/15 common-code 재설계가 화이트리스트를 8종으로 좁혔지만 운영 DB 의 // 옛 값(category 886, select 149, textarea 102, checkbox 55, radio 12, // datetime 2, boolean 1) 을 정리하는 마이그레이션을 빠뜨림. // 매핑: // category / select / radio / checkbox / boolean → code (commonCode 통합 의도) // textarea → text (single/multi line 구분 손실 — UI 동작 가벼움) // datetime → date // 메타 DB 1,207 row 갱신. 테넌트 DB 들은 비어있어 영향 0. // WHERE 절로 멱등 (재실행 시 0 row). """ UPDATE TABLE_TYPE_COLUMNS SET INPUT_TYPE = CASE INPUT_TYPE WHEN 'category' THEN 'code' WHEN 'select' THEN 'code' WHEN 'radio' THEN 'code' WHEN 'checkbox' THEN 'code' WHEN 'boolean' THEN 'code' WHEN 'textarea' THEN 'text' WHEN 'datetime' THEN 'date' END, UPDATED_DATE = NOW() WHERE INPUT_TYPE IN ('category','select','radio','checkbox','boolean','textarea','datetime') """ ); @EventListener(ApplicationReadyEvent.class) public void run() { log.info("[SchemaMigrator] start — {} migration(s)", MIGRATIONS.size()); String metaDb = parseMetaDbName(metaJdbcUrl); applyTo(metaDb, "meta"); // 테넌트 목록 조회는 반드시 메타 라우팅으로 DbContextHolder.setMeta(); List tenantDbs; try { tenantDbs = sqlSession.selectList("provisioning.listActiveDbNames"); } catch (Exception e) { log.warn("[SchemaMigrator] tenant list query failed — skipping tenants: {}", e.getMessage()); tenantDbs = List.of(); } finally { DbContextHolder.clear(); } int ok = 0, fail = 0; List failedDbs = new java.util.ArrayList<>(); for (String db : tenantDbs) { if (db == null || db.isBlank() || db.equalsIgnoreCase(metaDb)) continue; if (applyTo(db, "tenant")) { ok++; } else { fail++; failedDbs.add(db); } } if (!failedDbs.isEmpty()) { log.error("[SchemaMigrator] 마이그레이션 실패 테넌트 DB ({}건): {}", failedDbs.size(), failedDbs); } log.info("[SchemaMigrator] done — meta=done, tenants ok={}, fail={}", ok, fail); } private boolean applyTo(String dbName, String kind) { String url = tenantDbSettings.buildJdbcUrl(dbName); try (Connection c = DriverManager.getConnection(url, tenantDbSettings.username(), tenantDbSettings.password()); Statement s = c.createStatement()) { for (String ddl : MIGRATIONS) { s.execute(ddl); } log.info("[SchemaMigrator] {} db='{}' OK", kind, dbName); return true; } catch (Exception e) { log.error("[SchemaMigrator] {} db='{}' FAILED: {}", kind, dbName, e.getMessage()); return false; } } static String parseMetaDbName(String jdbcUrl) { int slash = jdbcUrl.lastIndexOf('/'); if (slash < 0) return "invyone"; String tail = jdbcUrl.substring(slash + 1); int q = tail.indexOf('?'); return q < 0 ? tail : tail.substring(0, q); } }