Files
invyone/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java
T
hjjeong 2675c82904 feat(batch): Phase 1 — BATCH_MAPPINGS.MAPPING_CONFIG JSONB 컬럼 + JSON 직렬화
- V021 Flyway: ALTER TABLE BATCH_MAPPINGS ADD COLUMN MAPPING_CONFIG JSONB
- StartupSchemaMigrator: 같은 ALTER 를 idempotent 항목으로 추가 (모든 활성 테넌트 DB 부팅 시 동기화)
- batch.xml: getBatchMappingsByConfigId SELECT 에 MAPPING_CONFIG::TEXT cast,
  insertBatchMapping VALUES 에 #{mapping_config,jdbcType=OTHER}::jsonb
- BatchService: ObjectMapper 주입, parseJsonField/stringifyJsonField 유틸,
  syncMappings 는 INSERT 전 직렬화, attachMappings 는 SELECT 후 Map 으로 역직렬화
- RUN_087_MIGRATION.md: 운영용 마이그레이션 runbook (사전 점검/사후 검증/롤백)

conditional 매핑(when/then/default) 룰을 행 단위 저장하는 컬럼.
direct/fixed 는 NULL. Phase 2~3 에서 프런트/엔진이 이 컬럼을 읽고 쓴다.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:24:56 +09:00

239 lines
12 KiB
Java

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<String> 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"
);
@EventListener(ApplicationReadyEvent.class)
public void run() {
log.info("[SchemaMigrator] start — {} migration(s)", MIGRATIONS.size());
String metaDb = parseMetaDbName(metaJdbcUrl);
applyTo(metaDb, "meta");
// 테넌트 목록 조회는 반드시 메타 라우팅으로
DbContextHolder.setMeta();
List<String> 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;
for (String db : tenantDbs) {
if (db == null || db.isBlank() || db.equalsIgnoreCase(metaDb)) continue;
if (applyTo(db, "tenant")) ok++; else fail++;
}
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);
}
}