hehihoho3@gmail.com 07-28 5ac9b23 feat: 공통코드 테이블(code_group, code) 추가 UNIX

공통코드 기반 구축 (Phase 1) Implementation Plan#

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 코드값을 담을 공통코드 테이블과 조회 경로를 만든다. 기존 화면 동작은 전혀 바뀌지 않는다.

Architecture: code_group / code 두 테이블에 11개 그룹 60여 개 코드를 시드한다. 백엔드 code 패키지가 이를 읽어 메모리에 캐시하고 GET /api/meta/codes 하나로 내려준다. 이 Phase에서는 아무도 이 API를 소비하지 않는다 — 소비 전환은 Phase 2다.

Tech Stack: Spring Boot 3.3.5, MyBatis, Flyway, PostgreSQL 16, JUnit5 + Testcontainers, AssertJ

Global Constraints#

  • 설계서: docs/superpowers/specs/2026-07-28-common-code-refactoring-design.md
  • 저장값 정책: code.code는 현재 DB에 저장 중인 한글 문자열 그대로. 진행단계만 숫자 문자열 '1'~'13'
  • Flyway 스키마는 itnhub. 다음 마이그레이션 번호는 V13
  • MyBatis 설정은 map-underscore-to-camel-case: true, 매퍼 XML 위치는 classpath:mapper/*.xml
  • 매퍼 인터페이스에는 @Mapper 애노테이션을 붙인다
  • DB 테스트는 AbstractDbTest를 상속한다. 컨트롤러 테스트는 @AutoConfigureMockMvc + @WithMockUser(roles = "ADMIN")
  • 주석은 한국어로, 기존 코드처럼 "왜 이렇게 했는지"를 적는다
  • 이 Phase의 완료 조건: 기존 테스트 40개(백엔드 22 + 프론트 18)가 전부 통과한다
  • jsonb는 커스텀 TypeHandler를 만들지 않는다. 조회 시 attrs::text로 문자열을 받아 Jackson으로 파싱한다

Task 1: 공통코드 테이블 생성#

Files:

  • Create: src/main/resources/db/migration/V13__common_code.sql
  • Test: src/test/java/kr/itn/itnhub/code/CodeSchemaTest.java

Interfaces:

  • Consumes: 없음

  • Produces: itnhub.code_group(group_id, group_name, description, editable), itnhub.code(group_id, code, label, sort_order, active, attrs) 테이블

  • Step 1: 실패하는 테스트를 쓴다

src/test/java/kr/itn/itnhub/code/CodeSchemaTest.java

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.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class CodeSchemaTest extends AbstractDbTest {

    @Autowired
    JdbcTemplate jdbc;

    @Test
    void 코드_테이블이_생성된다() {
        Integer count = jdbc.queryForObject(
                "select count(*) from information_schema.tables "
                        + "where table_schema = 'itnhub' and table_name in ('code_group', 'code')",
                Integer.class);

        assertThat(count).isEqualTo(2);
    }

    @Test
    void 그룹과_코드_조합이_유일하다() {
        jdbc.update("insert into code_group (group_id, group_name) values ('TEST_GRP', '테스트')");
        jdbc.update("insert into code (group_id, code, label, sort_order) "
                + "values ('TEST_GRP', 'A', '가', 1)");

        assertThatThrownBy(() -> jdbc.update(
                "insert into code (group_id, code, label, sort_order) "
                        + "values ('TEST_GRP', 'A', '다른라벨', 2)"))
                .isInstanceOf(DataIntegrityViolationException.class);
    }

    @Test
    void 없는_그룹의_코드는_넣을_수_없다() {
        assertThatThrownBy(() -> jdbc.update(
                "insert into code (group_id, code, label, sort_order) "
                        + "values ('NO_SUCH_GROUP', 'A', '가', 1)"))
                .isInstanceOf(DataIntegrityViolationException.class);
    }

    @Test
    void attrs는_기본값이_빈_객체다() {
        jdbc.update("insert into code_group (group_id, group_name) values ('TEST_ATTR', '테스트')");
        jdbc.update("insert into code (group_id, code, label, sort_order) "
                + "values ('TEST_ATTR', 'A', '가', 1)");

        String attrs = jdbc.queryForObject(
                "select attrs::text from code where group_id = 'TEST_ATTR' and code = 'A'",
                String.class);

        assertThat(attrs).isEqualTo("{}");
    }
}
  • Step 2: 테스트를 돌려 실패를 확인한다

Run: mvn -q test -Dtest=CodeSchemaTest
Expected: FAIL — relation "code_group" does not exist

  • Step 3: 마이그레이션을 쓴다

src/main/resources/db/migration/V13__common_code.sql

-- 공통코드. 진행단계·처리결과·공공누리유형 같은 선택지를 소스가 아니라 데이터로 관리한다.
-- 저장값(code)은 지금 각 업무 테이블에 들어 있는 한글 문자열을 그대로 쓴다. 기존 데이터를
-- 건드리지 않기 위해서다. 진행단계만 예외로 숫자 문자열이며 organization.stage는 smallint 유지.

create table code_group (
    group_id   varchar(50)  primary key,
    group_name varchar(100) not null,
    description text,
    -- 관리 화면에서 코드 추가·삭제를 허용할지. 로직과 강하게 묶인 그룹(STAGE 등)은 false로 두고
    -- 표시명·순서만 바꾸게 한다. 단계 추가는 마이그레이션으로 한다.
    editable   boolean      not null default true
);

create table code (
    group_id   varchar(50)  not null references code_group(group_id),
    -- 저장값. review_item.review_minor가 varchar(300)이라 그 길이에 맞춘다.
    code       varchar(300) not null,
    -- 화면 표시명. 저장값과 다를 수 있다(권리확인 세부항목은 저장값이 길고 표시는 짧다).
    label      varchar(200) not null,
    sort_order int          not null,
    active     boolean      not null default true,
    -- 코드별 부가 속성. 값만 담고 동작은 담지 않는다.
    attrs      jsonb        not null default '{}'::jsonb,
    primary key (group_id, code)
);

create index idx_code_group_sort on code (group_id, sort_order);
  • Step 4: 테스트를 돌려 통과를 확인한다

Run: mvn -q test -Dtest=CodeSchemaTest
Expected: PASS (4건)

  • Step 5: 커밋
git add src/main/resources/db/migration/V13__common_code.sql src/test/java/kr/itn/itnhub/code/CodeSchemaTest.java
git commit -m "feat: 공통코드 테이블(code_group, code) 추가"

Task 2: 코드 시드 데이터#

Files:

  • Modify: src/main/resources/db/migration/V13__common_code.sql (하단에 insert 추가)
  • Test: src/test/java/kr/itn/itnhub/code/CodeSeedTest.java

Interfaces:

  • Consumes: Task 1의 두 테이블
  • Produces: 11개 그룹 60개 코드. 그룹 ID는 STAGE, STAGE_GROUP, REVIEW_MAJOR, REVIEW_MINOR, REVIEW_RESULT, KOGL_TYPE, PROCESS_STATUS, CONTRACT_DOC, CONTACT_METHOD, CONTACT_CATEGORY, ATTACHMENT_YN, KOGL_ATTACHED

그룹은 12개다. 설계서 표에서 ATTACHMENT_YN/KOGL_ATTACHED를 한 줄에 묶어 적어 11개로 보였을 뿐이다.

  • Step 1: 실패하는 테스트를 쓴다

src/test/java/kr/itn/itnhub/code/CodeSeedTest.java

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;

class CodeSeedTest extends AbstractDbTest {

    @Autowired
    JdbcTemplate jdbc;

    @Test
    void 진행단계는_13개이며_13번이_보고서_제출_완료다() {
        List<String> labels = jdbc.queryForList(
                "select label from code where group_id = 'STAGE' order by sort_order", String.class);

        assertThat(labels).hasSize(13);
        assertThat(labels.get(0)).isEqualTo("신청");
        assertThat(labels.get(11)).isEqualTo("보고서 작성");
        assertThat(labels.get(12)).isEqualTo("보고서 제출 완료");
    }

    @Test
    void 진행상태는_1번만_신청이고_13번만_완료다() {
        List<String> applied = jdbc.queryForList(
                "select code from code where group_id = 'STAGE' and attrs->>'progress' = 'applied'",
                String.class);
        List<String> done = jdbc.queryForList(
                "select code from code where group_id = 'STAGE' and attrs->>'progress' = 'done'",
                String.class);
        Integer active = jdbc.queryForObject(
                "select count(*) from code where group_id = 'STAGE' and attrs->>'progress' = 'active'",
                Integer.class);

        assertThat(applied).containsExactly("1");
        assertThat(done).containsExactly("13");
        assertThat(active).isEqualTo(11);
    }

    @Test
    void KPI_임계값이_세_개_지정되어_있다() {
        assertThat(kpiStage("docSubmitted")).isEqualTo("2");
        assertThat(kpiStage("reportWriting")).isEqualTo("12");
        assertThat(kpiStage("completed")).isEqualTo("13");
    }

    private String kpiStage(String kpi) {
        return jdbc.queryForObject(
                "select code from code where group_id = 'STAGE' and attrs->>'kpiFrom' = ?",
                String.class, kpi);
    }

    @Test
    void 모든_단계가_업무구간에_속한다() {
        Integer orphan = jdbc.queryForObject(
                "select count(*) from code s where s.group_id = 'STAGE' "
                        + "and not exists (select 1 from code g where g.group_id = 'STAGE_GROUP' "
                        + "  and g.code = s.attrs->>'groupKey')",
                Integer.class);

        assertThat(orphan).isZero();
    }

    @Test
    void 공공누리_유형은_사용처별로_다르게_노출된다() {
        // 변호사 판정에는 보류가 있고, 권리처리 판정에는 없고, 기존 부착 유형은 0~4유형만이다.
        assertThat(koglScope("review")).hasSize(7);
        assertThat(koglScope("process")).hasSize(6).doesNotContain("보류");
        assertThat(koglScope("prior")).containsExactly("0유형", "1유형", "2유형", "3유형", "4유형");
    }

    private List<String> koglScope(String scope) {
        return jdbc.queryForList(
                "select code from code where group_id = 'KOGL_TYPE' "
                        + "and attrs->'scopes' ? cast(? as text) order by sort_order",
                String.class, scope);
    }

    @Test
    void 세부항목의_허용_처리결과는_실제_처리결과_코드다() {
        Integer orphan = jdbc.queryForObject(
                "select count(*) from code m, "
                        + "  lateral jsonb_array_elements_text(coalesce(m.attrs->'allowed', '[]'::jsonb)) a(v) "
                        + "where m.group_id = 'REVIEW_MINOR' "
                        + "  and not exists (select 1 from code r "
                        + "    where r.group_id = 'REVIEW_RESULT' and r.code = a.v)",
                Integer.class);

        assertThat(orphan).isZero();
    }

    @Test
    void 열두_그룹이_모두_시드된다() {
        Integer groups = jdbc.queryForObject(
                "select count(*) from code_group", Integer.class);
        Integer empty = jdbc.queryForObject(
                "select count(*) from code_group g "
                        + "where not exists (select 1 from code c where c.group_id = g.group_id)",
                Integer.class);

        assertThat(groups).isEqualTo(12);
        assertThat(empty).isZero();
    }

    @Test
    void 그룹마다_정렬순서가_중복되지_않는다() {
        Integer dup = jdbc.queryForObject(
                "select count(*) from (select group_id, sort_order from code "
                        + " group by group_id, sort_order having count(*) > 1) t",
                Integer.class);

        assertThat(dup).isZero();
    }
}
  • Step 2: 테스트를 돌려 실패를 확인한다

Run: mvn -q test -Dtest=CodeSeedTest
Expected: FAIL — 시드가 없어 hasSize(13)이 0으로 깨진다

  • Step 3: 시드를 V13__common_code.sql 하단에 덧붙인다
-- ============================================================
-- 시드
-- ============================================================

insert into code_group (group_id, group_name, description, editable) values
 ('STAGE',           '진행단계',     'organization.stage에 번호로 저장. 단계 추가는 마이그레이션으로 한다', false),
 ('STAGE_GROUP',     '업무구간',     '칸반 컬럼 색과 카드 모양을 결정한다', false),
 ('REVIEW_MAJOR',    '권리확인 대분류', null, true),
 ('REVIEW_MINOR',    '권리확인 세부',   '제3자 권리를 골랐을 때만 노출된다', true),
 ('REVIEW_RESULT',   '권리확인 처리결과', null, true),
 ('KOGL_TYPE',       '공공누리 유형',  'scopes로 사용처를 구분한다', true),
 ('PROCESS_STATUS',  '권리처리 상태',  null, false),
 ('CONTRACT_DOC',    '계약 서류',     null, true),
 ('CONTACT_METHOD',  '연락 방법',     null, true),
 ('CONTACT_CATEGORY','담당자 구분',    null, false),
 ('ATTACHMENT_YN',   '첨부파일 여부',  null, false),
 ('KOGL_ATTACHED',   '기존 공공누리 부착 여부', null, false);

-- 진행단계. progress는 대시보드 진행상태 필터(신청/진행중/완료)를,
-- kpiFrom은 "이 단계부터 해당 KPI에 집계"라는 임계값을 뜻한다.
-- 원문자(①②)는 저장하지 않는다 - 단계 번호에서 계산한다.
insert into code (group_id, code, label, sort_order, attrs) values
 ('STAGE','1', '신청',                    1, '{"groupKey":"intake","progress":"applied"}'::jsonb),
 ('STAGE','2', '목록접수',                2, '{"groupKey":"intake","progress":"active","kpiFrom":"docSubmitted"}'::jsonb),
 ('STAGE','3', '예비검토',                3, '{"groupKey":"intake","progress":"active"}'::jsonb),
 ('STAGE','4', '변호사 배당',             4, '{"groupKey":"assign","progress":"active"}'::jsonb),
 ('STAGE','5', '법률검토(권리확인)',      5, '{"groupKey":"check","progress":"active"}'::jsonb),
 ('STAGE','6', 'RE:확인',                 6, '{"groupKey":"check","progress":"active"}'::jsonb),
 ('STAGE','7', '법률검토(권리확인) 완료', 7, '{"groupKey":"check","progress":"active"}'::jsonb),
 ('STAGE','8', '법률검토(권리처리)',      8, '{"groupKey":"process","progress":"active"}'::jsonb),
 ('STAGE','9', 'RE:처리',                 9, '{"groupKey":"process","progress":"active"}'::jsonb),
 ('STAGE','10','법률검토(권리처리) 완료',10, '{"groupKey":"process","progress":"active"}'::jsonb),
 ('STAGE','11','법률검토 최종완료',      11, '{"groupKey":"closing","progress":"active"}'::jsonb),
 ('STAGE','12','보고서 작성',            12, '{"groupKey":"closing","progress":"active","kpiFrom":"reportWriting"}'::jsonb),
 ('STAGE','13','보고서 제출 완료',       13, '{"groupKey":"closing","progress":"done","kpiFrom":"completed"}'::jsonb);

-- 업무구간. tone은 의미 토큰이며 실제 CSS 클래스는 프론트가 갖는다.
-- cardBody는 칸반 카드에 진척 막대를 몇 줄 그릴지를 뜻한다.
insert into code (group_id, code, label, sort_order, attrs) values
 ('STAGE_GROUP','intake', '접수',    1, '{"tone":"slate","cardBody":"plain"}'::jsonb),
 ('STAGE_GROUP','assign', '배당',    2, '{"tone":"violet","cardBody":"assign"}'::jsonb),
 ('STAGE_GROUP','check',  '권리확인',3, '{"tone":"sky","cardBody":"review1"}'::jsonb),
 ('STAGE_GROUP','process','권리처리',4, '{"tone":"amber","cardBody":"review2"}'::jsonb),
 ('STAGE_GROUP','closing','마감',    5, '{"tone":"emerald","cardBody":"plain"}'::jsonb);

-- 권리확인 대분류. suggest는 이 대분류를 고르면 자동으로 채워줄 처리결과,
-- hasMinor는 세부항목을 노출할지를 뜻한다.
insert into code (group_id, code, label, sort_order, attrs) values
 ('REVIEW_MAJOR','만료 저작물',  '만료 저작물',  1, '{"suggest":"신유형 개방","hasMinor":false}'::jsonb),
 ('REVIEW_MAJOR','업무상 저작물','업무상 저작물',2, '{"suggest":"신유형 개방","hasMinor":false}'::jsonb),
 ('REVIEW_MAJOR','제3자 권리',   '제3자 권리',   3, '{"hasMinor":true}'::jsonb),
 ('REVIEW_MAJOR','개인정보 포함','개인정보 포함',4, '{"suggest":"개방불가","hasMinor":false}'::jsonb);

-- 권리확인 세부. code는 저장값 원문이고 label은 화면 표시용(짧은 형태)이다.
-- note는 라벨 아래 회색 부연설명, allowed는 고를 수 있는 처리결과 제한이다.
insert into code (group_id, code, label, sort_order, attrs) values
 ('REVIEW_MINOR','1. 원시적 권리 전부 보유','1. 원시적 권리 전부 보유',1,
   '{"suggest":"신유형 개방"}'::jsonb),
 ('REVIEW_MINOR','2. 후천적 권리 전부 보유(계약서 확인 필요)','2. 후천적 권리 전부 보유',2,
   '{"note":"(계약에 의한 전부 양수)","suggest":"계약서 등 재확인"}'::jsonb),
 ('REVIEW_MINOR','3. 권리 일부(공동) 보유','3. 권리 일부(공동) 보유',3,
   '{"note":"(보도자료·제3자저작물, 계약에 의한 전부/공동 보유 등)","suggest":"권리처리 추진","allowed":["권리처리 추진","권리처리 추진 미희망"]}'::jsonb),
 ('REVIEW_MINOR','4. 권리 미보유','4. 권리 미보유',4,
   '{"note":"(공모전 수상작 등)","suggest":"권리처리 추진","allowed":["권리처리 추진","권리처리 추진 미희망"]}'::jsonb),
 ('REVIEW_MINOR','초상권 포함','초상권 포함',5,
   '{"suggest":"권리처리 추진","allowed":["권리처리 추진","권리처리 추진 미희망","개방불가"]}'::jsonb);

-- 권리확인 처리결과. needsProcessing/koglSet은 값이고, 실제로 폼을 어떻게 채울지는 코드가 정한다.
-- koglCopiesPrior가 참이면 기존 부착 유형을 복사하고 비고에 안내문구를 덧붙인다(동작은 코드).
insert into code (group_id, code, label, sort_order, attrs) values
 ('REVIEW_RESULT','신유형 개방',         '신유형 개방',         1,'{"tone":"emerald","openable":"Y"}'::jsonb),
 ('REVIEW_RESULT','계약서 등 재확인',    '계약서 등 재확인',    2,'{"tone":"amber"}'::jsonb),
 ('REVIEW_RESULT','권리처리 추진',       '권리처리 추진',       3,'{"tone":"blue","needsProcessing":true,"koglSet":"보류"}'::jsonb),
 ('REVIEW_RESULT','개방불가',            '개방불가',            4,'{"tone":"red","needsProcessing":false,"koglSet":"개방불가"}'::jsonb),
 ('REVIEW_RESULT','권리처리 추진 미희망','권리처리 추진 미희망',5,'{"tone":"slate","needsProcessing":false,"koglCopiesPrior":true}'::jsonb);

-- 공공누리 유형. 세 화면의 목록이 서로 달랐던 이유는 쓰이는 자리가 달라서다.
--   review  = 변호사 판정값 / process = 권리처리 판정값 / prior = 기존에 부착돼 있던 유형
-- firstRow는 라디오 첫 줄에 놓을 자리를 뜻한다.
insert into code (group_id, code, label, sort_order, attrs) values
 ('KOGL_TYPE','0유형',   '0유형',   1,'{"scopes":["review","process","prior"],"firstRow":["review","process"]}'::jsonb),
 ('KOGL_TYPE','1유형',   '1유형',   2,'{"scopes":["review","process","prior"],"firstRow":[]}'::jsonb),
 ('KOGL_TYPE','2유형',   '2유형',   3,'{"scopes":["review","process","prior"],"firstRow":[]}'::jsonb),
 ('KOGL_TYPE','3유형',   '3유형',   4,'{"scopes":["review","process","prior"],"firstRow":[]}'::jsonb),
 ('KOGL_TYPE','4유형',   '4유형',   5,'{"scopes":["review","process","prior"],"firstRow":[]}'::jsonb),
 ('KOGL_TYPE','개방불가','개방불가',6,'{"scopes":["review","process"],"firstRow":["review","process"]}'::jsonb),
 ('KOGL_TYPE','보류',    '보류',    7,'{"scopes":["review"],"firstRow":["review"]}'::jsonb);

insert into code (group_id, code, label, sort_order, attrs) values
 ('PROCESS_STATUS','미처리',  '미처리',  1,'{"done":false}'::jsonb),
 ('PROCESS_STATUS','처리완료','처리완료',2,'{"done":true,"stampsProcessedAt":true}'::jsonb);

-- freeText가 참인 코드는 '기타:자유텍스트' 형태로 직렬화된다(직렬화 규칙 자체는 코드).
insert into code (group_id, code, label, sort_order, attrs) values
 ('CONTRACT_DOC','양도계약서',    '양도계약서',    1,'{}'::jsonb),
 ('CONTRACT_DOC','제안요청서',    '제안요청서',    2,'{}'::jsonb),
 ('CONTRACT_DOC','초상이용동의서','초상이용동의서',3,'{}'::jsonb),
 ('CONTRACT_DOC','공공누리동의서','공공누리동의서',4,'{}'::jsonb),
 ('CONTRACT_DOC','공문',          '공문',          5,'{}'::jsonb),
 ('CONTRACT_DOC','기타',          '기타',          6,'{"freeText":true}'::jsonb);

insert into code (group_id, code, label, sort_order, attrs) values
 ('CONTACT_METHOD','전화','전화',1,'{}'::jsonb),
 ('CONTACT_METHOD','메일','메일',2,'{}'::jsonb),
 ('CONTACT_METHOD','방문','방문',3,'{}'::jsonb),
 ('CONTACT_METHOD','기타','기타',4,'{}'::jsonb);

-- importAlias는 담당자 명부 엑셀의 한글 구분값이다(MemberDirectoryParser가 쓰던 매핑).
insert into code (group_id, code, label, sort_order, attrs) values
 ('CONTACT_CATEGORY','APPLICANT','신청기관',       1,'{"tone":"sky","importAlias":"신청기관"}'::jsonb),
 ('CONTACT_CATEGORY','MJ',       '주관기관',       2,'{"tone":"violet","importAlias":"주관기관"}'::jsonb),
 ('CONTACT_CATEGORY','LAWYER',   '변호사',         3,'{"tone":"emerald","importAlias":"변호사"}'::jsonb),
 ('CONTACT_CATEGORY','OPERATOR', '수행기관',       4,'{"tone":"amber","importAlias":"수행기관"}'::jsonb),
 ('CONTACT_CATEGORY','ITN',      '아이티앤 담당자',5,'{"tone":"slate","importAlias":"아이티앤 담당자"}'::jsonb);

insert into code (group_id, code, label, sort_order, attrs) values
 ('ATTACHMENT_YN','있음','있음',1,'{}'::jsonb),
 ('ATTACHMENT_YN','없음','없음',2,'{}'::jsonb),
 ('KOGL_ATTACHED','부착',  '부착',  1,'{}'::jsonb),
 ('KOGL_ATTACHED','미부착','미부착',2,'{}'::jsonb);
  • Step 4: 테스트를 돌려 통과를 확인한다

Run: mvn -q test -Dtest=CodeSeedTest
Expected: PASS (8건)

? 연산자가 MyBatis/JDBC 파라미터 자리표시자와 충돌해 koglScope가 깨지면
attrs->'scopes' ? cast(? as text) 대신
exists (select 1 from jsonb_array_elements_text(attrs->'scopes') s(v) where s.v = ?) 로 바꾼다.

  • Step 5: 커밋
git add src/main/resources/db/migration/V13__common_code.sql src/test/java/kr/itn/itnhub/code/CodeSeedTest.java
git commit -m "feat: 공통코드 12개 그룹 시드"

Task 3: 코드 도메인과 매퍼#

Files:

  • Create: src/main/java/kr/itn/itnhub/code/Code.java
  • Create: src/main/java/kr/itn/itnhub/code/CodeGroup.java
  • Create: src/main/java/kr/itn/itnhub/code/CodeRow.java
  • Create: src/main/java/kr/itn/itnhub/code/CodeMapper.java
  • Create: src/main/resources/mapper/CodeMapper.xml
  • Test: src/test/java/kr/itn/itnhub/code/CodeMapperTest.java

Interfaces:

  • Consumes: Task 2의 시드

  • Produces:

    • record CodeRow(String groupId, String code, String label, int sortOrder, boolean active, String attrsJson)
    • record Code(String groupId, String code, String label, int sortOrder, boolean active, Map<String,Object> attrs)
    • record CodeGroup(String groupId, String groupName, String description, boolean editable)
    • CodeMapper.findAllCodes() : List<CodeRow>, CodeMapper.findAllGroups() : List<CodeGroup>
  • Step 1: 실패하는 테스트를 쓴다

src/test/java/kr/itn/itnhub/code/CodeMapperTest.java

package kr.itn.itnhub.code;

import kr.itn.itnhub.AbstractDbTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

class CodeMapperTest extends AbstractDbTest {

    @Autowired
    CodeMapper mapper;

    @Test
    void 모든_코드를_그룹과_정렬순서대로_읽는다() {
        List<CodeRow> rows = mapper.findAllCodes();

        assertThat(rows).isNotEmpty();
        List<CodeRow> stages = rows.stream().filter(r -> r.groupId().equals("STAGE")).toList();
        assertThat(stages).hasSize(13);
        assertThat(stages.get(0).code()).isEqualTo("1");
        assertThat(stages.get(12).label()).isEqualTo("보고서 제출 완료");
    }

    @Test
    void attrs를_JSON_문자열로_읽는다() {
        CodeRow stage13 = mapper.findAllCodes().stream()
                .filter(r -> r.groupId().equals("STAGE") && r.code().equals("13"))
                .findFirst().orElseThrow();

        assertThat(stage13.attrsJson()).contains("\"progress\": \"done\"".replace(" ", ""))
                .contains("completed");
    }

    @Test
    void 그룹_목록을_읽는다() {
        List<CodeGroup> groups = mapper.findAllGroups();

        assertThat(groups).hasSize(12);
        assertThat(groups).anySatisfy(g -> {
            assertThat(g.groupId()).isEqualTo("STAGE");
            assertThat(g.editable()).isFalse();
        });
    }
}
  • Step 2: 테스트를 돌려 실패를 확인한다

Run: mvn -q test -Dtest=CodeMapperTest
Expected: FAIL — 컴파일 실패 (CodeMapper 없음)

  • Step 3: 도메인과 매퍼를 만든다

src/main/java/kr/itn/itnhub/code/CodeRow.java

package kr.itn.itnhub.code;

/**
 * DB에서 그대로 읽은 코드 한 줄. attrs는 jsonb를 문자열로 받는다 - 커스텀 TypeHandler를
 * 만들지 않고 {@link CodeService}가 한 번만 파싱해 캐시에 올린다.
 */
public record CodeRow(
        String groupId,
        String code,
        String label,
        int sortOrder,
        boolean active,
        String attrsJson) {
}

src/main/java/kr/itn/itnhub/code/Code.java

package kr.itn.itnhub.code;

import java.util.Map;

/** attrs까지 파싱된 코드. 화면과 서비스가 쓰는 형태다. */
public record Code(
        String groupId,
        String code,
        String label,
        int sortOrder,
        boolean active,
        Map<String, Object> attrs) {

    /** attrs의 문자열 속성. 없으면 null. */
    public String attr(String key) {
        Object value = attrs.get(key);
        return value == null ? null : String.valueOf(value);
    }

    /** attrs의 불리언 속성. 없으면 false. */
    public boolean flag(String key) {
        return Boolean.TRUE.equals(attrs.get(key));
    }
}

src/main/java/kr/itn/itnhub/code/CodeGroup.java

package kr.itn.itnhub.code;

public record CodeGroup(
        String groupId,
        String groupName,
        String description,
        boolean editable) {
}

src/main/java/kr/itn/itnhub/code/CodeMapper.java

package kr.itn.itnhub.code;

import org.apache.ibatis.annotations.Mapper;

import java.util.List;

@Mapper
public interface CodeMapper {

    /** 비활성 코드까지 전부. 활성 필터는 {@link CodeService}가 용도에 따라 건다. */
    List<CodeRow> findAllCodes();

    List<CodeGroup> findAllGroups();
}

src/main/resources/mapper/CodeMapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="kr.itn.itnhub.code.CodeMapper">

  <!--
    attrs는 jsonb지만 ::text로 문자열로 받는다. 커스텀 TypeHandler를 두면 매퍼마다
    등록을 신경써야 하는데, 코드는 기동 시 한 번만 읽으므로 서비스에서 파싱하는 편이 단순하다.
  -->
  <select id="findAllCodes" resultType="kr.itn.itnhub.code.CodeRow">
    select group_id, code, label, sort_order, active, attrs::text as attrs_json
    from code
    order by group_id, sort_order
  </select>

  <select id="findAllGroups" resultType="kr.itn.itnhub.code.CodeGroup">
    select group_id, group_name, description, editable
    from code_group
    order by group_id
  </select>

</mapper>
  • Step 4: 테스트를 돌려 통과를 확인한다

Run: mvn -q test -Dtest=CodeMapperTest
Expected: PASS (3건)

  • Step 5: 커밋
git add src/main/java/kr/itn/itnhub/code src/main/resources/mapper/CodeMapper.xml src/test/java/kr/itn/itnhub/code/CodeMapperTest.java
git commit -m "feat: 코드 도메인과 매퍼 추가"

Task 4: 코드 캐시 서비스#

Files:

  • Create: src/main/java/kr/itn/itnhub/code/CodeService.java
  • Create: src/main/java/kr/itn/itnhub/code/Codes.java
  • Test: src/test/java/kr/itn/itnhub/code/CodeServiceTest.java

Interfaces:

  • Consumes: CodeMapper.findAllCodes(), CodeMapper.findAllGroups()

  • Produces:

    • CodeService.all() : Map<String, List<Code>> — 활성 코드만, 그룹별 정렬순
    • CodeService.group(String groupId) : List<Code> — 활성 코드만
    • CodeService.find(String groupId, String code) : Optional<Code> — 비활성 포함
    • CodeService.scoped(String groupId, String scope) : List<Code>attrs.scopes에 scope가 든 활성 코드
    • CodeService.isValid(String groupId, String code) : boolean — 활성 코드인지
    • CodeService.reload() : void
    • Codes.STAGE, Codes.REVIEW_RESULT, Codes.KOGL_TYPE, Codes.PROCESS_STATUS, Codes.CONTACT_METHOD, Codes.CONTACT_CATEGORY, Codes.REVIEW_MAJOR, Codes.REVIEW_MINOR, Codes.STAGE_GROUP, Codes.CONTRACT_DOC, Codes.ATTACHMENT_YN, Codes.KOGL_ATTACHED (그룹 ID 상수)
    • Codes.SCOPE_REVIEW, Codes.SCOPE_PROCESS, Codes.SCOPE_PRIOR
  • Step 1: 실패하는 테스트를 쓴다

src/test/java/kr/itn/itnhub/code/CodeServiceTest.java

package kr.itn.itnhub.code;

import kr.itn.itnhub.AbstractDbTest;
import org.junit.jupiter.api.AfterEach;
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;

class CodeServiceTest extends AbstractDbTest {

    @Autowired
    CodeService service;

    @Autowired
    JdbcTemplate jdbc;

    @AfterEach
    void restore() {
        jdbc.update("update code set active = true where group_id = 'CONTACT_METHOD'");
        service.reload();
    }

    @Test
    void 그룹을_정렬순서대로_돌려준다() {
        List<Code> stages = service.group(Codes.STAGE);

        assertThat(stages).hasSize(13);
        assertThat(stages.get(0).code()).isEqualTo("1");
        assertThat(stages.get(12).label()).isEqualTo("보고서 제출 완료");
    }

    @Test
    void attrs가_맵으로_파싱된다() {
        Code stage13 = service.find(Codes.STAGE, "13").orElseThrow();

        assertThat(stage13.attr("progress")).isEqualTo("done");
        assertThat(stage13.attr("kpiFrom")).isEqualTo("completed");
        assertThat(stage13.attr("groupKey")).isEqualTo("closing");
    }

    @Test
    void 불리언_속성을_읽는다() {
        assertThat(service.find(Codes.PROCESS_STATUS, "처리완료").orElseThrow().flag("done")).isTrue();
        assertThat(service.find(Codes.PROCESS_STATUS, "미처리").orElseThrow().flag("done")).isFalse();
        // 속성이 아예 없는 코드도 false여야 한다.
        assertThat(service.find(Codes.CONTRACT_DOC, "공문").orElseThrow().flag("freeText")).isFalse();
    }

    @Test
    void 사용처별로_공공누리_유형을_거른다() {
        assertThat(service.scoped(Codes.KOGL_TYPE, Codes.SCOPE_REVIEW)).hasSize(7);
        assertThat(service.scoped(Codes.KOGL_TYPE, Codes.SCOPE_PROCESS))
                .hasSize(6)
                .noneMatch(c -> c.code().equals("보류"));
        assertThat(service.scoped(Codes.KOGL_TYPE, Codes.SCOPE_PRIOR))
                .extracting(Code::code)
                .containsExactly("0유형", "1유형", "2유형", "3유형", "4유형");
    }

    @Test
    void 비활성_코드는_목록에서_빠지지만_조회는_된다() {
        jdbc.update("update code set active = false "
                + "where group_id = 'CONTACT_METHOD' and code = '방문'");
        service.reload();

        assertThat(service.group(Codes.CONTACT_METHOD))
                .extracting(Code::code)
                .containsExactly("전화", "메일", "기타");
        // 이미 '방문'으로 저장된 과거 데이터를 화면에 그리려면 조회는 되어야 한다.
        assertThat(service.find(Codes.CONTACT_METHOD, "방문")).isPresent();
    }

    @Test
    void 유효성_검사는_활성_코드만_통과시킨다() {
        assertThat(service.isValid(Codes.PROCESS_STATUS, "처리완료")).isTrue();
        assertThat(service.isValid(Codes.PROCESS_STATUS, "없는값")).isFalse();

        jdbc.update("update code set active = false "
                + "where group_id = 'CONTACT_METHOD' and code = '방문'");
        service.reload();

        assertThat(service.isValid(Codes.CONTACT_METHOD, "방문")).isFalse();
    }

    @Test
    void 전체_조회는_활성_코드만_그룹별로_담는다() {
        assertThat(service.all())
                .containsKeys(Codes.STAGE, Codes.KOGL_TYPE, Codes.CONTACT_CATEGORY)
                .hasSize(12);
    }

    @Test
    void 없는_그룹은_빈_목록이다() {
        assertThat(service.group("NO_SUCH_GROUP")).isEmpty();
    }
}
  • Step 2: 테스트를 돌려 실패를 확인한다

Run: mvn -q test -Dtest=CodeServiceTest
Expected: FAIL — 컴파일 실패 (CodeService 없음)

  • Step 3: 서비스와 상수를 만든다

src/main/java/kr/itn/itnhub/code/Codes.java

package kr.itn.itnhub.code;

/**
 * 코드 그룹 ID와, 로직이 직접 참조해야 하는 코드값 상수. 소스에 한글 리터럴을 흩뿌리지 않기
 * 위한 단일 지점이다. 화면에 그리는 선택지는 여기가 아니라 DB에서 온다.
 */
public final class Codes {

    private Codes() {
    }

    public static final String STAGE = "STAGE";
    public static final String STAGE_GROUP = "STAGE_GROUP";
    public static final String REVIEW_MAJOR = "REVIEW_MAJOR";
    public static final String REVIEW_MINOR = "REVIEW_MINOR";
    public static final String REVIEW_RESULT = "REVIEW_RESULT";
    public static final String KOGL_TYPE = "KOGL_TYPE";
    public static final String PROCESS_STATUS = "PROCESS_STATUS";
    public static final String CONTRACT_DOC = "CONTRACT_DOC";
    public static final String CONTACT_METHOD = "CONTACT_METHOD";
    public static final String CONTACT_CATEGORY = "CONTACT_CATEGORY";
    public static final String ATTACHMENT_YN = "ATTACHMENT_YN";
    public static final String KOGL_ATTACHED = "KOGL_ATTACHED";

    /** 공공누리 유형이 쓰이는 자리. 같은 코드체계라도 자리마다 노출 목록이 다르다. */
    public static final String SCOPE_REVIEW = "review";
    public static final String SCOPE_PROCESS = "process";
    public static final String SCOPE_PRIOR = "prior";

    /** attrs 키. */
    public static final String ATTR_SCOPES = "scopes";
    public static final String ATTR_PROGRESS = "progress";
    public static final String ATTR_KPI_FROM = "kpiFrom";
    public static final String ATTR_GROUP_KEY = "groupKey";
    public static final String ATTR_DONE = "done";
}

src/main/java/kr/itn/itnhub/code/CodeService.java

package kr.itn.itnhub.code;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;

import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

/**
 * 코드값을 기동 후 첫 요청에 한 번 읽어 메모리에 들고 있는다. 코드는 관리 화면에서 가끔
 * 바뀔 뿐이라 매 요청 조회가 낭비이고, 단일 인스턴스 운영이라 분산 캐시는 필요 없다.
 *
 * <p>지연 로딩을 쓰는 이유는 Flyway 마이그레이션이 끝나기 전에 빈 초기화가 돌 수 있기
 * 때문이다. 첫 조회 시점에는 마이그레이션이 반드시 끝나 있다.</p>
 */
@Service
public class CodeService {

    private final CodeMapper mapper;
    private final ObjectMapper json = new ObjectMapper();

    /** 그룹ID -> 활성 코드(정렬순). */
    private volatile Map<String, List<Code>> activeByGroup;
    /** 그룹ID -> 전체 코드(비활성 포함). 과거 데이터를 화면에 그릴 때 라벨이 필요하다. */
    private volatile Map<String, Map<String, Code>> allByGroup;

    public CodeService(CodeMapper mapper) {
        this.mapper = mapper;
    }

    /** 관리 화면에서 코드를 고친 뒤 부른다. */
    public synchronized void reload() {
        Map<String, List<Code>> active = new LinkedHashMap<>();
        Map<String, Map<String, Code>> all = new LinkedHashMap<>();

        for (CodeRow row : mapper.findAllCodes()) {
            Code code = toCode(row);
            all.computeIfAbsent(code.groupId(), k -> new LinkedHashMap<>())
                    .put(code.code(), code);
            if (code.active()) {
                active.computeIfAbsent(code.groupId(), k -> new java.util.ArrayList<>()).add(code);
            }
        }

        this.activeByGroup = active;
        this.allByGroup = all;
    }

    private Code toCode(CodeRow row) {
        Map<String, Object> attrs;
        try {
            attrs = json.readValue(row.attrsJson(), new TypeReference<Map<String, Object>>() {
            });
        } catch (Exception e) {
            // 잘못된 JSON이 들어와도 전체 코드 로딩을 막지는 않는다. 해당 코드만 속성이 빈다.
            attrs = Map.of();
        }
        return new Code(row.groupId(), row.code(), row.label(), row.sortOrder(), row.active(), attrs);
    }

    private void ensureLoaded() {
        if (activeByGroup == null) {
            reload();
        }
    }

    /** 활성 코드만, 그룹별 정렬순. */
    public Map<String, List<Code>> all() {
        ensureLoaded();
        return activeByGroup;
    }

    /** 활성 코드만. 없는 그룹이면 빈 목록. */
    public List<Code> group(String groupId) {
        ensureLoaded();
        return activeByGroup.getOrDefault(groupId, List.of());
    }

    /** 비활성 코드도 찾는다 - 과거 데이터의 라벨을 그려야 하기 때문이다. */
    public Optional<Code> find(String groupId, String code) {
        ensureLoaded();
        return Optional.ofNullable(allByGroup.getOrDefault(groupId, Map.of()).get(code));
    }

    /** attrs.scopes에 해당 자리가 든 활성 코드만. 공공누리 유형이 이걸 쓴다. */
    public List<Code> scoped(String groupId, String scope) {
        return group(groupId).stream().filter(c -> scopes(c).contains(scope)).toList();
    }

    @SuppressWarnings("unchecked")
    private List<String> scopes(Code code) {
        Object raw = code.attrs().get(Codes.ATTR_SCOPES);
        return raw instanceof List<?> list ? (List<String>) list : Collections.emptyList();
    }

    /** 저장 요청 값이 이 그룹의 활성 코드인지. */
    public boolean isValid(String groupId, String code) {
        return group(groupId).stream().anyMatch(c -> c.code().equals(code));
    }

    public List<CodeGroup> groups() {
        return mapper.findAllGroups();
    }
}
  • Step 4: 테스트를 돌려 통과를 확인한다

Run: mvn -q test -Dtest=CodeServiceTest
Expected: PASS (8건)

  • Step 5: 커밋
git add src/main/java/kr/itn/itnhub/code/CodeService.java src/main/java/kr/itn/itnhub/code/Codes.java src/test/java/kr/itn/itnhub/code/CodeServiceTest.java
git commit -m "feat: 코드 캐시 서비스와 그룹 상수"

Task 5: 코드 조회 API#

Files:

  • Create: src/main/java/kr/itn/itnhub/code/CodeController.java
  • Test: src/test/java/kr/itn/itnhub/code/CodeControllerTest.java

Interfaces:

  • Consumes: CodeService.all()

  • Produces: GET /api/meta/codes{ "STAGE": [{code,label,sortOrder,attrs}, ...], "KOGL_TYPE": [...] }

  • Step 1: 실패하는 테스트를 쓴다

src/test/java/kr/itn/itnhub/code/CodeControllerTest.java

package kr.itn.itnhub.code;

import kr.itn.itnhub.AbstractDbTest;
import kr.itn.itnhub.mattermost.MattermostClient;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@AutoConfigureMockMvc
class CodeControllerTest extends AbstractDbTest {

    @Autowired
    MockMvc mvc;

    @MockBean
    MattermostClient mattermost;

    @Test
    @WithMockUser(roles = "ADMIN")
    void 전체_코드를_그룹별로_내려준다() throws Exception {
        mvc.perform(get("/api/meta/codes"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.STAGE.length()").value(13))
                .andExpect(jsonPath("$.STAGE[12].code").value("13"))
                .andExpect(jsonPath("$.STAGE[12].label").value("보고서 제출 완료"))
                .andExpect(jsonPath("$.STAGE[12].attrs.progress").value("done"))
                .andExpect(jsonPath("$.KOGL_TYPE.length()").value(7))
                .andExpect(jsonPath("$.CONTACT_CATEGORY[0].code").value("APPLICANT"));
    }

    @Test
    void 인증이_없으면_401이다() throws Exception {
        mvc.perform(get("/api/meta/codes"))
                .andExpect(status().isUnauthorized());
    }
}
  • Step 2: 테스트를 돌려 실패를 확인한다

Run: mvn -q test -Dtest=CodeControllerTest
Expected: FAIL — 404 (엔드포인트 없음)

  • Step 3: 컨트롤러를 만든다

src/main/java/kr/itn/itnhub/code/CodeController.java

package kr.itn.itnhub.code;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;
import java.util.Map;

/**
 * 프론트가 로그인 직후 한 번 불러 모든 선택지를 받아가는 엔드포인트. 화면마다 따로 부르지
 * 않는 이유는 코드가 자주 안 바뀌고, 화면이 옵션을 기다리느라 깜빡이는 것을 피하기 위해서다.
 */
@RestController
public class CodeController {

    private final CodeService codeService;

    public CodeController(CodeService codeService) {
        this.codeService = codeService;
    }

    @GetMapping("/api/meta/codes")
    public Map<String, List<Code>> codes() {
        return codeService.all();
    }
}
  • Step 4: 테스트를 돌려 통과를 확인한다

Run: mvn -q test -Dtest=CodeControllerTest
Expected: PASS (2건)

  • Step 5: 커밋
git add src/main/java/kr/itn/itnhub/code/CodeController.java src/test/java/kr/itn/itnhub/code/CodeControllerTest.java
git commit -m "feat: 코드 조회 API(/api/meta/codes) 추가"

Task 6: 기존 데이터 정합성 점검#

운영 DB에는 시드와 다른 값이 남아 있을 수 있다(정책 변경 전 값, 공백 차이, 오타).
Phase 2에서 화면이 코드테이블만 보게 바뀌면 그런 값은 선택지에서 사라진다.
먼저 찾아내고 비활성 코드로 보존한다.

Files:

  • Create: src/main/resources/db/consistency/check-orphan-codes.sql
  • Test: src/test/java/kr/itn/itnhub/code/CodeConsistencyTest.java

Interfaces:

  • Consumes: CodeService.isValid(groupId, code)

  • Produces: 운영 적용 전 실행할 점검 쿼리, 그리고 테스트 DB에서 정합성이 깨지지 않음을 보장하는 테스트

  • Step 1: 실패하는 테스트를 쓴다

src/test/java/kr/itn/itnhub/code/CodeConsistencyTest.java

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;

/**
 * 업무 테이블에 저장된 값이 코드테이블에 다 있는지 확인한다. 운영 DB에 정책 변경 전
 * 값이 남아 있으면 Phase 2에서 화면 선택지가 사라지므로, 배포 전에 이 점검을 돌려
 * 발견된 값을 active=false 코드로 추가해야 한다.
 */
class CodeConsistencyTest extends AbstractDbTest {

    @Autowired
    JdbcTemplate jdbc;

    @Autowired
    CodeService codeService;

    @Test
    void 저장된_처리결과가_모두_코드테이블에_있다() {
        assertThat(orphans("review_item", "review_result", Codes.REVIEW_RESULT)).isEmpty();
    }

    @Test
    void 저장된_처리상태가_모두_코드테이블에_있다() {
        assertThat(orphans("process_item", "process_status", Codes.PROCESS_STATUS)).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, method, contacted_at, note) "
                + "values (?, '텔레그램', now(), '코드테이블에 없는 값')", 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(v -> codeService.find(groupId, v).isEmpty())
                .toList();
    }
}
  • Step 2: 테스트를 돌려 실패를 확인한다

Run: mvn -q test -Dtest=CodeConsistencyTest
Expected: 점검_쿼리가_고아값을_실제로_찾아낸다가 FAIL —
contact_log 컬럼명이 실제와 다르면 SQL 오류가 난다.
실패 메시지를 보고 V12__project_management.sql의 실제 컬럼명으로 맞춘다.
나머지 3건은 테스트 DB가 비어 있어 통과한다(정상).

  • Step 3: 운영용 점검 쿼리를 만든다

src/main/resources/db/consistency/check-orphan-codes.sql

-- 운영 DB 배포 전에 실행한다. 결과가 한 줄이라도 나오면 그 값을
-- active=false 코드로 code 테이블에 추가한 뒤 Phase 2를 배포해야 한다.
-- 값을 지우지 않는 이유: 지우면 기존 화면에서 값이 조용히 사라진다.

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 '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 '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;
  • Step 4: 테스트를 돌려 통과를 확인한다

Run: mvn -q test -Dtest=CodeConsistencyTest
Expected: PASS (4건)

  • Step 5: 커밋
git add src/main/resources/db/consistency/check-orphan-codes.sql src/test/java/kr/itn/itnhub/code/CodeConsistencyTest.java
git commit -m "feat: 기존 데이터와 코드테이블 정합성 점검"

Task 7: Phase 1 회귀 확인#

Files: 없음 (검증만)

  • Step 1: 백엔드 전체 테스트를 돌린다

Run: mvn -q test
Expected: 기존 22개 + 신규 6개 클래스 전부 PASS.
한 건이라도 깨지면 Phase 2로 넘어가지 않는다. 이 Phase는 기존 동작을 바꾸지 않으므로
기존 테스트가 깨졌다면 그 자체가 결함이다.

  • Step 2: 프론트 테스트를 돌린다

Run: cd frontend && npm test
Expected: 18개 전부 PASS (이 Phase에서 프론트는 건드리지 않았으므로 당연히 통과해야 한다)

  • Step 3: 애플리케이션이 기동되는지 확인한다

Run: mvn -q spring-boot:run (또는 run-local.ps1)
Expected: 기동 후 GET /api/meta/codes가 12개 그룹을 반환. 확인 후 종료.

  • Step 4: 커밋
git add -A
git commit -m "chore: Phase 1 회귀 확인 완료"

다음 Phase#

이 계획은 Phase 1만 다룬다. 나머지는 각각 별도 계획으로 쓴다 — 한 계획이 working software를
내놓아야 하고, Phase마다 산출물과 검증 방법이 다르기 때문이다.

Phase계획 파일내용
22026-07-28-phase2-frontend-codes-module.md프론트 codes 모듈, 화면의 하드코딩 배열 제거
32026-07-28-phase3-literal-removal.mdJava·SQL 리터럴 제거, 엑셀 양식 정의 통합, 메시지 상수
42026-07-28-phase4-component-split.md화면 파일 분해
52026-07-28-phase5-code-admin-screen.md코드 관리 화면
62026-07-28-phase6-requirements.md요구사항 반영 (13단계, RE 메모, 비고, 자료접수, 업무구간, 보고서 수정)

Self-Review 결과#

스펙 커버리지 — 설계서 §5.1(스키마) → Task 1, §5.2~5.6(시드·attrs 설계) → Task 2,
§6.1(백엔드 모듈) → Task 3·4·5, §5.7(정합성) → Task 6, §7 Phase 1 완료 조건 → Task 7.
Phase 1 범위의 스펙 항목에 빠진 것은 없다.

미해결로 남긴 것CodeService.groups()는 Task 4에서 만들지만 소비자는 Phase 5(관리 화면)다.
지금은 테스트만 쓴다. Phase 5 전에 지우지 말 것.

타입 일관성CodeRow.attrsJson(String) → Code.attrs(Map)로의 변환은 CodeService.toCode()
한 곳에서만 일어난다. Codes.* 상수명은 Task 4~6에서 동일하게 쓰인다.