# 공통코드 기반 구축 (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` ```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` ```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: 커밋** ```bash 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` ```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 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 applied = jdbc.queryForList( "select code from code where group_id = 'STAGE' and attrs->>'progress' = 'applied'", String.class); List 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 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` 하단에 덧붙인다** ```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: 커밋** ```bash 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 attrs)` - `record CodeGroup(String groupId, String groupName, String description, boolean editable)` - `CodeMapper.findAllCodes() : List`, `CodeMapper.findAllGroups() : List` - [ ] **Step 1: 실패하는 테스트를 쓴다** `src/test/java/kr/itn/itnhub/code/CodeMapperTest.java` ```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 rows = mapper.findAllCodes(); assertThat(rows).isNotEmpty(); List 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 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` ```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` ```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 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` ```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` ```java package kr.itn.itnhub.code; import org.apache.ibatis.annotations.Mapper; import java.util.List; @Mapper public interface CodeMapper { /** 비활성 코드까지 전부. 활성 필터는 {@link CodeService}가 용도에 따라 건다. */ List findAllCodes(); List findAllGroups(); } ``` `src/main/resources/mapper/CodeMapper.xml` ```xml ``` - [ ] **Step 4: 테스트를 돌려 통과를 확인한다** Run: `mvn -q test -Dtest=CodeMapperTest` Expected: PASS (3건) - [ ] **Step 5: 커밋** ```bash 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>` — 활성 코드만, 그룹별 정렬순 - `CodeService.group(String groupId) : List` — 활성 코드만 - `CodeService.find(String groupId, String code) : Optional` — 비활성 포함 - `CodeService.scoped(String groupId, String scope) : List` — `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` ```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 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` ```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` ```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; /** * 코드값을 기동 후 첫 요청에 한 번 읽어 메모리에 들고 있는다. 코드는 관리 화면에서 가끔 * 바뀔 뿐이라 매 요청 조회가 낭비이고, 단일 인스턴스 운영이라 분산 캐시는 필요 없다. * *

지연 로딩을 쓰는 이유는 Flyway 마이그레이션이 끝나기 전에 빈 초기화가 돌 수 있기 * 때문이다. 첫 조회 시점에는 마이그레이션이 반드시 끝나 있다.

*/ @Service public class CodeService { private final CodeMapper mapper; private final ObjectMapper json = new ObjectMapper(); /** 그룹ID -> 활성 코드(정렬순). */ private volatile Map> activeByGroup; /** 그룹ID -> 전체 코드(비활성 포함). 과거 데이터를 화면에 그릴 때 라벨이 필요하다. */ private volatile Map> allByGroup; public CodeService(CodeMapper mapper) { this.mapper = mapper; } /** 관리 화면에서 코드를 고친 뒤 부른다. */ public synchronized void reload() { Map> active = new LinkedHashMap<>(); Map> 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 attrs; try { attrs = json.readValue(row.attrsJson(), new TypeReference>() { }); } 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> all() { ensureLoaded(); return activeByGroup; } /** 활성 코드만. 없는 그룹이면 빈 목록. */ public List group(String groupId) { ensureLoaded(); return activeByGroup.getOrDefault(groupId, List.of()); } /** 비활성 코드도 찾는다 - 과거 데이터의 라벨을 그려야 하기 때문이다. */ public Optional find(String groupId, String code) { ensureLoaded(); return Optional.ofNullable(allByGroup.getOrDefault(groupId, Map.of()).get(code)); } /** attrs.scopes에 해당 자리가 든 활성 코드만. 공공누리 유형이 이걸 쓴다. */ public List scoped(String groupId, String scope) { return group(groupId).stream().filter(c -> scopes(c).contains(scope)).toList(); } @SuppressWarnings("unchecked") private List scopes(Code code) { Object raw = code.attrs().get(Codes.ATTR_SCOPES); return raw instanceof List list ? (List) list : Collections.emptyList(); } /** 저장 요청 값이 이 그룹의 활성 코드인지. */ public boolean isValid(String groupId, String code) { return group(groupId).stream().anyMatch(c -> c.code().equals(code)); } public List groups() { return mapper.findAllGroups(); } } ``` - [ ] **Step 4: 테스트를 돌려 통과를 확인한다** Run: `mvn -q test -Dtest=CodeServiceTest` Expected: PASS (8건) - [ ] **Step 5: 커밋** ```bash 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` ```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` ```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> codes() { return codeService.all(); } } ``` - [ ] **Step 4: 테스트를 돌려 통과를 확인한다** Run: `mvn -q test -Dtest=CodeControllerTest` Expected: PASS (2건) - [ ] **Step 5: 커밋** ```bash 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` ```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 orphans(String table, String column, String groupId) { List 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` ```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: 커밋** ```bash 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: 커밋** ```bash git add -A git commit -m "chore: Phase 1 회귀 확인 완료" ``` --- ## 다음 Phase 이 계획은 Phase 1만 다룬다. 나머지는 각각 별도 계획으로 쓴다 — 한 계획이 working software를 내놓아야 하고, Phase마다 산출물과 검증 방법이 다르기 때문이다. | Phase | 계획 파일 | 내용 | |---|---|---| | 2 | `2026-07-28-phase2-frontend-codes-module.md` | 프론트 `codes` 모듈, 화면의 하드코딩 배열 제거 | | 3 | `2026-07-28-phase3-literal-removal.md` | Java·SQL 리터럴 제거, 엑셀 양식 정의 통합, 메시지 상수 | | 4 | `2026-07-28-phase4-component-split.md` | 화면 파일 분해 | | 5 | `2026-07-28-phase5-code-admin-screen.md` | 코드 관리 화면 | | 6 | `2026-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에서 동일하게 쓰인다.