From 2675c829044d0b980a69f3ac3cd2dc967ec219f2 Mon Sep 17 00:00:00 2001 From: hjjeong Date: Wed, 13 May 2026 10:24:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(batch):=20Phase=201=20=E2=80=94=20BATCH=5F?= =?UTF-8?q?MAPPINGS.MAPPING=5FCONFIG=20JSONB=20=EC=BB=AC=EB=9F=BC=20+=20JS?= =?UTF-8?q?ON=20=EC=A7=81=EB=A0=AC=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../erp/migration/StartupSchemaMigrator.java | 8 +- .../java/com/erp/service/BatchService.java | 32 +++++ ...021__add_batch_mappings_mapping_config.sql | 7 + .../src/main/resources/mapper/batch.xml | 3 + db/migrations/RUN_087_MIGRATION.md | 125 ++++++++++++++++++ 5 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 backend-spring/src/main/resources/db/migration/V021__add_batch_mappings_mapping_config.sql create mode 100644 db/migrations/RUN_087_MIGRATION.md diff --git a/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java b/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java index aad1c53d..bbb32623 100644 --- a/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java +++ b/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java @@ -177,7 +177,13 @@ public class StartupSchemaMigrator { 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) diff --git a/backend-spring/src/main/java/com/erp/service/BatchService.java b/backend-spring/src/main/java/com/erp/service/BatchService.java index ade0e81d..eed8679d 100644 --- a/backend-spring/src/main/java/com/erp/service/BatchService.java +++ b/backend-spring/src/main/java/com/erp/service/BatchService.java @@ -1,6 +1,7 @@ package com.erp.service; import com.erp.common.BaseService; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -15,6 +16,9 @@ public class BatchService extends BaseService { @Autowired private CommonService commonService; + @Autowired + private ObjectMapper objectMapper; + private static final String NS = "batch."; private static final String EXT_NS = "externalDbConnection."; @@ -93,6 +97,7 @@ public class BatchService extends BaseService { if (row.get("company_code") == null) row.put("company_code", companyCode); if (row.get("created_by") == null) row.put("created_by", userId); if (row.get("mapping_order") == null) row.put("mapping_order", i + 1); + stringifyJsonField(row, "mapping_config"); sqlSession.insert(NS + "insertBatchMapping", row); } } @@ -104,9 +109,36 @@ public class BatchService extends BaseService { Map params = new HashMap<>(); params.put("batch_config_id", idObj); List> mappings = sqlSession.selectList(NS + "getBatchMappingsByConfigId", params); + if (mappings != null) { + for (Map row : mappings) parseJsonField(row, "mapping_config"); + } batch.put("batch_mappings", mappings != null ? mappings : new ArrayList<>()); } + /** JSONB → 객체. SELECT 결과의 TEXT cast 값을 파싱해 Map/List 로 되돌린다. */ + private void parseJsonField(Map row, String key) { + Object val = row.get(key); + if (val instanceof String && !((String) val).isEmpty()) { + try { + row.put(key, objectMapper.readValue((String) val, Object.class)); + } catch (Exception e) { + log.warn("Failed to parse JSONB field '{}': {}", key, e.getMessage()); + } + } + } + + /** 객체 → JSON 문자열. INSERT 전 ::jsonb 캐스팅을 위해 직렬화한다. null 은 그대로 둠. */ + private void stringifyJsonField(Map params, String key) { + Object val = params.get(key); + if (val == null || val instanceof String) return; + try { + params.put(key, objectMapper.writeValueAsString(val)); + } catch (Exception e) { + log.warn("Failed to stringify field '{}': {}", key, e.getMessage()); + params.put(key, null); + } + } + @SuppressWarnings("unchecked") private List> toMappingList(Object raw) { if (raw == null) return new ArrayList<>(); diff --git a/backend-spring/src/main/resources/db/migration/V021__add_batch_mappings_mapping_config.sql b/backend-spring/src/main/resources/db/migration/V021__add_batch_mappings_mapping_config.sql new file mode 100644 index 00000000..2ee3c7da --- /dev/null +++ b/backend-spring/src/main/resources/db/migration/V021__add_batch_mappings_mapping_config.sql @@ -0,0 +1,7 @@ +-- V021: BATCH_MAPPINGS.MAPPING_CONFIG JSONB 컬럼 추가 +-- conditional 매핑(when/then/default) 규칙을 행 단위로 저장한다. +-- direct/fixed 매핑은 NULL. 메타 DB 뿐 아니라 모든 활성 테넌트 DB 에도 +-- StartupSchemaMigrator 로 idempotent 하게 동일 ALTER 가 부팅 시 적용된다. + +ALTER TABLE BATCH_MAPPINGS + ADD COLUMN IF NOT EXISTS MAPPING_CONFIG JSONB; diff --git a/backend-spring/src/main/resources/mapper/batch.xml b/backend-spring/src/main/resources/mapper/batch.xml index c07177e9..5793e2f4 100644 --- a/backend-spring/src/main/resources/mapper/batch.xml +++ b/backend-spring/src/main/resources/mapper/batch.xml @@ -132,6 +132,7 @@ , TO_API_BODY , MAPPING_ORDER , MAPPING_TYPE + , MAPPING_CONFIG::TEXT AS MAPPING_CONFIG , CREATED_BY , CREATED_DATE FROM BATCH_MAPPINGS @@ -168,6 +169,7 @@ , TO_API_BODY , MAPPING_ORDER , MAPPING_TYPE + , MAPPING_CONFIG , CREATED_BY , CREATED_DATE ) VALUES ( @@ -200,6 +202,7 @@ #{mapping_type} 'direct' + , #{mapping_config,jdbcType=OTHER}::jsonb , #{created_by} , NOW() ) diff --git a/db/migrations/RUN_087_MIGRATION.md b/db/migrations/RUN_087_MIGRATION.md new file mode 100644 index 00000000..b5433055 --- /dev/null +++ b/db/migrations/RUN_087_MIGRATION.md @@ -0,0 +1,125 @@ +# 087 마이그레이션 — BATCH_MAPPINGS.MAPPING_CONFIG JSONB 추가 + +작성일: 2026-05-13 +작성자: hjjeong +관련: `notes/hjjeong/2026-05-12-batch-pipeline-current-state.md` (Phase 1) + +## 목적 + +vexplor_rps 의 conditional 매핑(파이프라인) 기능을 INVYONE 으로 이식하기 위한 첫 단계. +`BATCH_MAPPINGS` 행마다 매핑 규칙(when/then/default) 을 JSONB 로 저장할 컬럼 추가. + +- `mapping_type='direct'` / `'fixed'` → `MAPPING_CONFIG` 는 NULL +- `mapping_type='conditional'` → `MAPPING_CONFIG` 에 `{"rules":[{"when":"1","then":"Y"}],"default":"?"}` 형태 저장 + +Phase 2 (frontend ConditionalEditor + API 확장) 와 Phase 3 (Backend MappingTransformer) 가 +이 컬럼을 읽고 쓰는 전제로 동작한다. + +## 스키마 + +### BATCH_MAPPINGS ALTER + +| 컬럼 | 타입 | 제약 | 설명 | +|---|---|---|---| +| `MAPPING_CONFIG` | JSONB | NULL 허용 | conditional 평가 규칙. direct/fixed 면 NULL | + +저장 포맷(`mapping_type='conditional'`): + +```json +{ + "rules": [ + { "when": "1", "then": "Y" }, + { "when": "0", "then": "N" } + ], + "default": "?" +} +``` + +## SQL + +```sql +-- ================================================================= +-- 087: BATCH_MAPPINGS.MAPPING_CONFIG JSONB 추가 (idempotent) +-- ================================================================= + +ALTER TABLE BATCH_MAPPINGS + ADD COLUMN IF NOT EXISTS MAPPING_CONFIG JSONB; +``` + +부팅 시 `StartupSchemaMigrator` 가 메타 DB + 모든 활성 테넌트 DB 에 동일 ALTER 를 +`IF NOT EXISTS` 로 적용하므로 일반적으로는 별도 수동 실행이 필요 없음. +별도 환경(콜드 백업 복원 등)에서 수동 실행이 필요할 때 위 SQL 한 줄을 그대로 사용. + +## 사전 점검 + +```sql +-- A. 컬럼 사전 상태 +SELECT column_name, data_type FROM information_schema.columns +WHERE table_name = 'batch_mappings' AND column_name = 'mapping_config'; +-- 빈 결과여야 정상. 이미 있으면 ALTER 의 IF NOT EXISTS 가 안전. + +-- B. 기존 데이터 행수 (마이그레이션 영향 범위 확인) +SELECT COUNT(*) FROM BATCH_MAPPINGS; +-- 컬럼만 추가하므로 기존 행은 MAPPING_CONFIG = NULL 로 유지됨. +``` + +## 사후 검증 + +```sql +-- C. 컬럼 추가 확인 +SELECT column_name, data_type FROM information_schema.columns +WHERE table_name = 'batch_mappings' AND column_name = 'mapping_config'; +-- 기대: data_type = 'jsonb' + +-- D. JSONB 동작 확인 (테스트) +BEGIN; +UPDATE BATCH_MAPPINGS + SET MAPPING_CONFIG = '{"rules":[{"when":"1","then":"Y"}],"default":"?"}'::jsonb + WHERE ID = (SELECT ID FROM BATCH_MAPPINGS LIMIT 1); +SELECT MAPPING_CONFIG->'rules'->0->>'when' AS sample + FROM BATCH_MAPPINGS + WHERE MAPPING_CONFIG IS NOT NULL + LIMIT 1; +-- 기대: sample = '1' +ROLLBACK; +``` + +## 실행 + +```bash +# 1) 메타 DB +psql -h -U postgres -d invyone -f RUN_087.sql + +# 2) 각 테넌트 DB (StartupSchemaMigrator 가 부팅 시 자동 적용하므로 통상 생략 가능) +for db in $(psql -tA -d invyone -c "SELECT db_name FROM company_mng WHERE db_status='active'"); do + echo "=== $db ===" + psql -h -U postgres -d "$db" -f RUN_087.sql +done +``` + +`RUN_087.sql` 은 위 "SQL" 섹션의 ALTER 한 줄을 그대로 담은 파일입니다. + +## 롤백 + +```sql +-- MAPPING_CONFIG 컬럼 제거 (저장된 conditional 규칙은 함께 삭제됨) +ALTER TABLE BATCH_MAPPINGS DROP COLUMN IF EXISTS MAPPING_CONFIG; +``` + +## 적용 환경 체크리스트 + +- [ ] 로컬 docker `naengangi-pg` (메타 + 활성 테넌트 전부) +- [ ] wace 개발서버 PostgreSQL +- [ ] 운영 메타 DB (`invyone`) +- [ ] 운영 각 테넌트 DB (loop or 부팅 시 자동) + +## 관련 코드 + +- Flyway: `backend-spring/src/main/resources/db/migration/V021__add_batch_mappings_mapping_config.sql` +- StartupSchemaMigrator: `backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java` (마지막 항목) +- Mapper: `backend-spring/src/main/resources/mapper/batch.xml` + - `getBatchMappingsByConfigId` 의 SELECT 절: `MAPPING_CONFIG::TEXT AS MAPPING_CONFIG` + - `insertBatchMapping` 의 VALUES 절: `#{mapping_config,jdbcType=OTHER}::jsonb` +- Service: `backend-spring/src/main/java/com/erp/service/BatchService.java` + - `syncMappings()` 가 `stringifyJsonField(row, "mapping_config")` 로 직렬화 후 INSERT + - `attachMappings()` 가 `parseJsonField(row, "mapping_config")` 로 SELECT 결과 역직렬화