이호영 이호영 07-28
feat: 기존 데이터와 코드테이블 정합성 점검
운영 DB에는 정책 변경 전 값이나 공백만 다른 값이 남아 있을 수 있다. Phase 2에서
화면이 코드테이블만 보게 되면 그런 값은 선택지에서 조용히 사라지므로, 배포 전에
찾아내 active=false 코드로 보존해야 한다.

Phase 1 회귀 확인: 백엔드 255건(기존 221 + 신규 34), 프론트 191건 전부 통과.
기존 동작은 바뀌지 않았다.

Co-Authored-By: Claude Opus 5 (1M context) 
@f7e17eaf80cd42e083dc161e008768c2b6fcc57c
 
src/main/resources/db/consistency/check-orphan-codes.sql (added)
+++ src/main/resources/db/consistency/check-orphan-codes.sql
@@ -0,0 +1,76 @@
+-- 운영 DB에 Phase 2(화면이 코드테이블만 보게 되는 단계)를 배포하기 전에 실행한다.
+--
+-- 결과가 한 줄이라도 나오면, 그 값을 active=false 코드로 code 테이블에 추가한 뒤
+-- 배포해야 한다. 값을 지우거나 다른 값으로 바꾸지 않는 이유는 이미 그 값으로 저장된
+-- 데이터가 있어서, 지우면 기존 화면에서 값이 조용히 사라지기 때문이다.
+--
+-- 실행 예:
+--   psql -h 192.168.0.60 -U itnhub -d itnhub -f check-orphan-codes.sql
+--
+-- 고아값을 발견했을 때의 보존 예:
+--   insert into code (group_id, code, label, sort_order, active)
+--   values ('CONTACT_METHOD', '문자', '문자', 90, false);
+
+select 'review_item.review_major' as source, review_major as value, count(*) as cnt
+  from review_item
+ where review_major is not null and review_major <> ''
+   and review_major not in (select code from code where group_id = 'REVIEW_MAJOR')
+ group by review_major
+
+union all
+select 'review_item.review_minor', review_minor, count(*)
+  from review_item
+ where review_minor is not null and review_minor <> ''
+   and review_minor not in (select code from code where group_id = 'REVIEW_MINOR')
+ group by review_minor
+
+union all
+select 'review_item.review_result', review_result, count(*)
+  from review_item
+ where review_result is not null and review_result <> ''
+   and review_result not in (select code from code where group_id = 'REVIEW_RESULT')
+ group by review_result
+
+union all
+select 'review_item.judged_kogl_type', judged_kogl_type, count(*)
+  from review_item
+ where judged_kogl_type is not null and judged_kogl_type <> ''
+   and judged_kogl_type not in (select code from code where group_id = 'KOGL_TYPE')
+ group by judged_kogl_type
+
+union all
+select 'review_item.kogl_type', kogl_type, count(*)
+  from review_item
+ where kogl_type is not null and kogl_type <> ''
+   and kogl_type not in (select code from code where group_id = 'KOGL_TYPE')
+ group by kogl_type
+
+union all
+select 'process_item.process_status', process_status, count(*)
+  from process_item
+ where process_status is not null and process_status <> ''
+   and process_status not in (select code from code where group_id = 'PROCESS_STATUS')
+ group by process_status
+
+union all
+select 'process_item.judged_kogl_type', judged_kogl_type, count(*)
+  from process_item
+ where judged_kogl_type is not null and judged_kogl_type <> ''
+   and judged_kogl_type not in (select code from code where group_id = 'KOGL_TYPE')
+ group by judged_kogl_type
+
+union all
+select 'process_item.prior_kogl_type', prior_kogl_type, count(*)
+  from process_item
+ where prior_kogl_type is not null and prior_kogl_type <> ''
+   and prior_kogl_type not in (select code from code where group_id = 'KOGL_TYPE')
+ group by prior_kogl_type
+
+union all
+select 'contact_log.method', method, count(*)
+  from contact_log
+ where method is not null and method <> ''
+   and method not in (select code from code where group_id = 'CONTACT_METHOD')
+ group by method
+
+order by 1, 2;
 
src/test/java/kr/itn/itnhub/code/CodeConsistencyTest.java (added)
+++ src/test/java/kr/itn/itnhub/code/CodeConsistencyTest.java
@@ -0,0 +1,73 @@
+package kr.itn.itnhub.code;
+
+import kr.itn.itnhub.AbstractDbTest;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * 업무 테이블에 저장된 값이 코드테이블에 다 있는지 확인한다.
+ *
+ * <p>운영 DB에는 정책 변경 전 값이나 공백만 다른 값이 남아 있을 수 있다. Phase 2에서
+ * 화면이 코드테이블만 보게 바뀌면 그런 값은 선택지에서 조용히 사라진다. 배포 전에
+ * {@code db/consistency/check-orphan-codes.sql}을 돌려 발견된 값을 active=false 코드로
+ * 추가해야 한다 - 지우지 않고 보존하는 이유는 이미 그 값으로 저장된 데이터가 있기 때문이다.</p>
+ */
+class CodeConsistencyTest extends AbstractDbTest {
+
+    @Autowired
+    JdbcTemplate jdbc;
+
+    @Autowired
+    CodeService codeService;
+
+    @Test
+    void 저장된_권리확인_값이_모두_코드테이블에_있다() {
+        assertThat(orphans("review_item", "review_major", Codes.REVIEW_MAJOR)).isEmpty();
+        assertThat(orphans("review_item", "review_minor", Codes.REVIEW_MINOR)).isEmpty();
+        assertThat(orphans("review_item", "review_result", Codes.REVIEW_RESULT)).isEmpty();
+        assertThat(orphans("review_item", "judged_kogl_type", Codes.KOGL_TYPE)).isEmpty();
+    }
+
+    @Test
+    void 저장된_권리처리_값이_모두_코드테이블에_있다() {
+        assertThat(orphans("process_item", "process_status", Codes.PROCESS_STATUS)).isEmpty();
+        assertThat(orphans("process_item", "judged_kogl_type", Codes.KOGL_TYPE)).isEmpty();
+    }
+
+    @Test
+    void 저장된_연락방법이_모두_코드테이블에_있다() {
+        assertThat(orphans("contact_log", "method", Codes.CONTACT_METHOD)).isEmpty();
+    }
+
+    @Test
+    void 점검이_고아값을_실제로_찾아낸다() {
+        jdbc.update("insert into organization (org_no, org_name, channel_slug) "
+                + "values ('900', '정합성테스트기관', '900')");
+        Long orgId = jdbc.queryForObject(
+                "select id from organization where org_no = '900'", Long.class);
+        jdbc.update("insert into contact_log (org_id, contacted_on, method, summary, author) "
+                + "values (?, current_date, '텔레그램', '코드테이블에 없는 값', 'admin')", orgId);
+
+        assertThat(orphans("contact_log", "method", Codes.CONTACT_METHOD))
+                .containsExactly("텔레그램");
+
+        jdbc.update("delete from contact_log where org_id = ?", orgId);
+        jdbc.update("delete from organization where id = ?", orgId);
+    }
+
+    /** 해당 컬럼에 저장돼 있으나 코드테이블에 없는 값. */
+    private List<String> orphans(String table, String column, String groupId) {
+        List<String> stored = jdbc.queryForList(
+                "select distinct " + column + " from " + table
+                        + " where " + column + " is not null and " + column + " <> ''",
+                String.class);
+        return stored.stream()
+                .filter(value -> codeService.find(groupId, value).isEmpty())
+                .toList();
+    }
+}
Add a comment
List