공통코드 기반 구축 (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() : voidCodes.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 | 계획 파일 | 내용 |
|---|---|---|
| 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에서 동일하게 쓰인다.