From 14832a28ab19f3f56cb6e684a25ca724ee5aad6c Mon Sep 17 00:00:00 2001 From: johngreen Date: Fri, 15 May 2026 23:39:30 +0900 Subject: [PATCH] =?UTF-8?q?fix(=ED=85=8C=EC=9D=B4=EB=B8=94=ED=83=80?= =?UTF-8?q?=EC=9E=85):=20TABLE=5FTYPE=5FCOLUMNS=20=EC=97=90=20ON=20CONFLIC?= =?UTF-8?q?T=20=EB=A7=A4=EC=B9=AD=EC=9A=A9=20UNIQUE=20INDEX=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20+=20=EC=A4=91=EB=B3=B5=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 테이블 타입관리의 모든 쓰기 API (UNIQUE/NOT NULL 토글, 컬럼 설정 저장, input-type upsert) 가 500 반환. 원인은 mapper SQL 의 ON CONFLICT (TABLE_NAME, COLUMN_NAME, COMPANY_CODE) 가 매칭할 unique 제약/인덱스가 운영 DB 에 존재하지 않아 PG 가 "there is no unique or exclusion constraint matching the ON CONFLICT specification" 으로 거부. - StartupSchemaMigrator MIGRATIONS 에 V025 / RUN_090 (1) (2) 추가: (1) ROW_NUMBER 로 (table, column, company) 중복 행 정리 (운영 메타 DB 실측 2 그룹 / 4 row — 동일 데이터의 NULL updated_date 옛 row 제거. 테넌트 DB 들은 중복 0건). (2) UX_TABLE_TYPE_COLUMNS_TCC UNIQUE INDEX 생성 (IF NOT EXISTS — 멱등). - RUN_090_MIGRATION.md 신설. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../erp/migration/StartupSchemaMigrator.java | 31 +++++ db/migrations/RUN_090_MIGRATION.md | 109 ++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 db/migrations/RUN_090_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 e0a0c5bb..02b60156 100644 --- a/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java +++ b/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java @@ -250,7 +250,38 @@ public class StartupSchemaMigrator { 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)" ); @EventListener(ApplicationReadyEvent.class) diff --git a/db/migrations/RUN_090_MIGRATION.md b/db/migrations/RUN_090_MIGRATION.md new file mode 100644 index 00000000..aee77f8c --- /dev/null +++ b/db/migrations/RUN_090_MIGRATION.md @@ -0,0 +1,109 @@ +# 090 마이그레이션 — TABLE_TYPE_COLUMNS 중복 정리 + ON CONFLICT 용 UNIQUE INDEX + +작성일: 2026-05-15 +작성자: johngreen +관련 버그: 테이블 타입관리에서 모든 쓰기 API (UNIQUE 토글 / NOT NULL 토글 / 컬럼 설정 저장) 가 500 반환. + +## 증상 + +``` +PSQLException: ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification + mapper: tableManagement.upsertColumnSettings / upsertNullable / upsertUnique / upsertColumnInputType +``` + +## 원인 + +`TABLE_TYPE_COLUMNS` 의 PK 는 `id` 단일(varchar). 운영 DB 어디에도 +`(TABLE_NAME, COLUMN_NAME, COMPANY_CODE)` UNIQUE 제약/인덱스가 없음. +mapper 의 `INSERT … ON CONFLICT (TABLE_NAME, COLUMN_NAME, COMPANY_CODE) DO UPDATE …` +구문이 매칭할 unique constraint 를 찾지 못해 즉시 BadSqlGrammar 로 500. + +RUN_044 가 company_code 컬럼을 추가했지만 함께 도입했어야 할 unique index 가 +빠진 채로 운영에 들어간 것으로 보이며, 그 후 mapper 가 ON CONFLICT 패턴으로 작성되면서 +실제로는 한 번도 정상 동작하지 못한 채로 잠복했던 정황 (운영 메타 DB 의 35,316 행 중 +중복 키 그룹 2개 = 추가 4 row 가 그 흔적). + +## 조치 + +### (1) 중복 행 정리 + +각 `(TABLE_NAME, COLUMN_NAME, COMPANY_CODE)` 그룹에서 +`updated_date DESC NULLS LAST, id::bigint DESC` 로 정렬해 첫 행만 유지, 나머지 DELETE. + +```sql +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 + ); +``` + +실측(2026-05-15) 중복: + +| DB | 중복 그룹 | 삭제될 row | +|---|---|---| +| meta `invyone` | 2 (`sales_order_mng.incoterms@COMPANY_16`, `sales_order_mng.payment_term@COMPANY_16`) | 2 | +| `siflex_invyone` | 0 | 0 | +| `test01_invyone` | 0 | 0 | +| `test02_invyone` | 0 | 0 | + +남는 행은 가장 최근에 갱신된 동일 키 row (column_label/input_type 모두 동일 — 옛 NULL updated_date row 가 제거 대상). + +### (2) UNIQUE INDEX 추가 + +```sql +CREATE UNIQUE INDEX IF NOT EXISTS UX_TABLE_TYPE_COLUMNS_TCC + ON TABLE_TYPE_COLUMNS (TABLE_NAME, COLUMN_NAME, COMPANY_CODE); +``` + +PostgreSQL 은 ON CONFLICT 가 인덱스도 인식하므로 mapper 의 모든 upsert SQL 이 +즉시 정상 동작. `IF NOT EXISTS` 로 멱등. + +## 적용 방법 + +부팅 시 자동 적용 — 별도 작업 불필요. `StartupSchemaMigrator.MIGRATIONS` 리스트에 +V025 / RUN_090 (1) (2) 항목으로 등록되어 있어서 앱이 시작할 때 메타 DB + 모든 활성 +테넌트 DB 에 차례로 실행된다. + +## 검증 + +```sql +-- 중복 없음 +SELECT COUNT(*) FROM ( + SELECT 1 FROM TABLE_TYPE_COLUMNS + GROUP BY TABLE_NAME, COLUMN_NAME, COMPANY_CODE HAVING COUNT(*) > 1 +) d; +-- → 0 + +-- 인덱스 존재 +SELECT indexname FROM pg_indexes + WHERE tablename = 'table_type_columns' AND indexname = 'ux_table_type_columns_tcc'; +-- → 1 row +``` + +브라우저 검증: +1. 솔루션 또는 테넌트 사이트 > 시스템 관리 > 테이블 타입관리 > 거래처 클릭 +2. 어느 컬럼이든 `UQ` / `NN` 토글 클릭 → 200, 토스트 "UNIQUE/NOT NULL 제약이 설정되었습니다" +3. "컬럼 설정 저장" 버튼 클릭 → 200, 토스트 "모든 컬럼 설정을 성공적으로 저장했습니다" + +## 영향 범위 + +- 테이블 타입관리 페이지 쓰기 API 4종 (`unique`, `nullable`, `columns/settings`, `columns/{c}/input-type`) 정상화. +- 멱등 — 재실행 시 DELETE 0건, CREATE INDEX 도 IF NOT EXISTS 라 skip. +- 부팅 시점 1회 실행, 런타임 트래픽에는 영향 없음. + +## 롤백 + +```sql +DROP INDEX IF EXISTS UX_TABLE_TYPE_COLUMNS_TCC; +``` +DELETE 된 중복 row 는 정보 손실 없음 (남은 row 와 column_label/input_type 동일) 이라 +복구가 의미 없음. 그래도 굳이 되돌리려면 사전 백업 필요.