이호영 이호영 07-28
feat: 공통코드 테이블(code_group, code) 추가
코드값을 소스가 아니라 데이터로 관리하기 위한 기반. 저장값은 지금 업무 테이블에
들어 있는 한글 문자열을 그대로 쓴다 - 영문 키로 바꾸면 기존 데이터 변환과 엑셀
양식 수정이 딸려와 위험 대비 실익이 없다.

설계서와 Phase 1 구현계획도 함께 넣는다.

Co-Authored-By: Claude Opus 5 (1M context) 
@339a962800e4f48da0411ef5eaba1d186f22ad23
 
docs/superpowers/plans/2026-07-28-phase1-common-code-foundation.md (added)
+++ docs/superpowers/plans/2026-07-28-phase1-common-code-foundation.md
@@ -0,0 +1,1268 @@
+# 공통코드 기반 구축 (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<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` 하단에 덧붙인다**
+
+```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<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`
+
+```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`
+
+```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<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`
+
+```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<CodeRow> findAllCodes();
+
+    List<CodeGroup> findAllGroups();
+}
+```
+
+`src/main/resources/mapper/CodeMapper.xml`
+
+```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: 커밋**
+
+```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<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`
+
+```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`
+
+```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;
+
+/**
+ * 코드값을 기동 후 첫 요청에 한 번 읽어 메모리에 들고 있는다. 코드는 관리 화면에서 가끔
+ * 바뀔 뿐이라 매 요청 조회가 낭비이고, 단일 인스턴스 운영이라 분산 캐시는 필요 없다.
+ *
+ * <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: 커밋**
+
+```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<String, List<Code>> 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<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`
+
+```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에서 동일하게 쓰인다.
 
docs/superpowers/specs/2026-07-28-common-code-refactoring-design.md (added)
+++ docs/superpowers/specs/2026-07-28-common-code-refactoring-design.md
@@ -0,0 +1,414 @@
+# 공통코드 도입과 하드코딩 제거 설계
+
+작성일 2026-07-28
+근거 문서: `ITN-HUB 요구사항 확인요청_260727_영익 검토.pptx` (28항목 중 회신분)
+
+## 1. 배경
+
+요구사항 회신에서 "보고서 제출 완료" 13단계 신설이 확정됐다. 이 한 줄을 반영하려면 지금은
+백엔드 4곳, 프론트 8곳, 테스트 6곳을 동시에 고쳐야 한다. 단계 번호와 선택지 문자열이
+소스 곳곳에 리터럴로 흩어져 있기 때문이다.
+
+회신 28항목 중 11항목이 아직 미정이다. 앞으로도 선택지 추가·이름 변경 요청이 계속 들어온다.
+그때마다 같은 규모의 수정을 반복하지 않으려면 코드값을 데이터로 옮겨야 한다.
+
+목표는 두 가지다.
+
+1. 코드값을 DB 공통코드로 옮기고, 소스에서 코드값 리터럴을 없앤다.
+2. 그 위에 확정된 요구사항 7건을 반영한다.
+
+## 2. 현황 진단
+
+전수 조사 결과 같은 코드체계가 여러 곳에 중복 정의되어 있다.
+
+| 코드체계 | 정의된 곳 | 실측 |
+|---|---|---|
+| `'처리완료'` | 프론트 6 + Java 2 + SQL 5 | **13곳** |
+| 공공누리 유형 | `ReviewBoard.tsx:50`(7개) / `:978`(5개) / `ProcessBoard.tsx:29`(6개) | **3중, 개수·순서 모두 다름** |
+| 담당자 구분 | `ContactsPage.tsx:15,33,128` + `MemberDirectoryParser.java:43` + `client.ts:3` | **5중** |
+| 진행단계 12 | `stages.ts:4` + `StageService.java:34` + `DashboardService.java:16-20` + `Dashboard.tsx:31,241,244` + `ProjectsPage.tsx:240` | **5중** |
+| 연락 방법 | `ProjectsPage.tsx:32` | 화면에만, **서버 검증 없음** |
+
+구조적 사실:
+
+- DB에 코드테이블이 없다. 마이그레이션 V1~V12가 만드는 테이블은 8개뿐이고, 코드값 컬럼은
+  전부 자유 `varchar` + 주석이다. CHECK 제약도 FK도 없다.
+- 백엔드에 진행단계·처리결과·공공누리유형 enum이 없다. enum은 `OrgStatus`, `ChannelKind`,
+  `ProvisionOutcome` 3개뿐이다.
+- 코드값의 정본이 프론트 컴포넌트 파일 안에 있다. 서버는 값을 저장만 하고,
+  검증은 `ProcessService.java:22`의 처리상태 화이트리스트 하나뿐이다.
+- 메타데이터 API가 없다(`/api/meta`, `/api/codes` 부재).
+- 화면 파일이 크다: `ReviewBoard.tsx` 1,279줄 / `ProcessBoard.tsx` 1,060줄 /
+  `OrgOverview.tsx` 740줄 / `Dashboard.tsx` 697줄.
+
+## 3. 저장값 정책
+
+**기존 한글 문자열을 저장값으로 그대로 유지한다.** (승인 완료)
+
+`code.code` = 지금 DB에 들어 있는 값. `'처리완료'`, `'권리처리 추진'` 그대로다.
+진행단계만 예외로 숫자 문자열(`'1'`~`'13'`)이며 `organization.stage`는 지금처럼 smallint다.
+
+이유: 기존 데이터를 건드리지 않고, 엑셀 업로드·다운로드 양식도 그대로 둘 수 있다.
+영문 코드키 전환은 데이터 변환과 파서 전면 수정을 동반해 위험 대비 실익이 없다.
+코드에서 리터럴이 사라진다는 목적은 이 방식으로도 완전히 달성된다.
+
+## 4. 범위
+
+### 포함
+
+- 공통코드 테이블과 시드
+- 백엔드 `code` 패키지, 코드 조회·관리 API
+- 프론트 `codes` 모듈, 화면의 하드코딩 배열 제거
+- 코드 관리 화면 (승인 완료)
+- SQL·Java 리터럴 제거, 엑셀 양식 정의 통합, 메시지 상수 분리
+- 손대는 화면 파일 분해
+- 확정 요구사항 7건 반영 (§8)
+
+### 제외
+
+회신에서 미정이거나 결정 대기인 항목은 이번 범위에서 뺀다.
+
+- 미응답 10건: 1(서류제출 기준)·3(보고서작성 기준)·9(최근변경 범위)·13(초상권+개방불가)·
+  14(검색 AND/OR)·18(자동 단계전환)·19(되돌림)·22(작성자 표기)·24(연락방법)·26(자료실)
+- 재질문 1건: 12(권리처리필요 자동체크)
+- 결정 대기: 20·21·27 (Mattermost 존치 여부와 사용자 계정 체계)
+- 28(보고서 양식) — 양식 수령 후 별건
+
+단, 1·3·24는 코드테이블로 옮겨두므로 답이 오면 **데이터만 바꾸면 된다**.
+
+## 5. 데이터 설계
+
+### 5.1 스키마
+
+```sql
+create table code_group (
+  group_id    varchar(50)  primary key,
+  group_name  varchar(100) not null,
+  description text,
+  editable    boolean      not null default true   -- 관리 화면에서 추가·삭제를 허용할지
+);
+
+create table code (
+  group_id    varchar(50)  not null references code_group(group_id),
+  code        varchar(300) not null,   -- 저장값. review_minor가 varchar(300)이라 이에 맞춤
+  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);
+```
+
+`code`와 `label`을 나눈 이유: 권리확인 세부항목은 저장값이
+`'2. 후천적 권리 전부 보유(계약서 확인 필요)'`인데 화면에는
+`'2. 후천적 권리 전부 보유'` + 회색 부연설명으로 나뉘어 보인다
+(`ReviewBoard.tsx:98-108`). 한 컬럼으로는 기존 데이터를 유지할 수 없다.
+
+`attrs`에는 코드마다 붙는 부가 속성을 넣는다. 값만 담고 동작은 담지 않는다(§5.4).
+
+### 5.2 코드 그룹
+
+11개 그룹, 60여 개 코드. 전체 시드는 `V13__common_code.sql`에 둔다.
+
+| group_id | 내용 | 개수 | 대체하는 하드코딩 |
+|---|---|---|---|
+| `STAGE` | 진행단계 | 13 | `stages.ts:4-17`, `DashboardService.java:16-20`, `StageService.java:34` |
+| `STAGE_GROUP` | 업무구간 | 5 | `stages.ts:28-34`, `Dashboard.tsx:34-40` |
+| `REVIEW_MAJOR` | 권리확인 대분류 | 4 | `ReviewBoard.tsx:18`, `suggestReviewResult():75-80` |
+| `REVIEW_MINOR` | 권리확인 세부 | 5 | `ReviewBoard.tsx:20-26, 59-63, 98-108` |
+| `REVIEW_RESULT` | 처리결과 | 5 | `ReviewBoard.tsx:32-42, 65-71` |
+| `KOGL_TYPE` | 공공누리 유형 | 7 | `ReviewBoard.tsx:50, 53, 978`, `ProcessBoard.tsx:29, 935, 948` |
+| `PROCESS_STATUS` | 권리처리 상태 | 2 | `ProcessBoard.tsx:20-24, 31`, `ProcessService.java:22` |
+| `CONTRACT_DOC` | 계약 서류 | 6 | `ProcessBoard.tsx:26, 28` |
+| `CONTACT_METHOD` | 연락 방법 | 4 | `ProjectsPage.tsx:32` |
+| `CONTACT_CATEGORY` | 담당자 구분 | 5 | `ContactsPage.tsx:15, 33, 128`, `MemberDirectoryParser.java:43-47` |
+| `ATTACHMENT_YN` / `KOGL_ATTACHED` | 첨부·부착 여부 | 2+2 | `ReviewBoard.tsx:939, 956` |
+
+### 5.3 STAGE 설계 — 요구사항 [7][11][2] 반영
+
+```
+code  label                    attrs
+1     신청                     {"groupKey":"intake",  "progress":"applied"}
+2     목록접수                 {"groupKey":"intake",  "progress":"active", "kpiFrom":"docSubmitted"}
+3     예비검토                 {"groupKey":"intake",  "progress":"active"}
+4     변호사 배당              {"groupKey":"assign",  "progress":"active"}
+5     법률검토(권리확인)       {"groupKey":"check",   "progress":"active"}
+6     RE:확인                  {"groupKey":"check",   "progress":"active"}
+7     법률검토(권리확인) 완료  {"groupKey":"check",   "progress":"active"}
+8     법률검토(권리처리)       {"groupKey":"process", "progress":"active"}
+9     RE:처리                  {"groupKey":"process", "progress":"active"}
+10    법률검토(권리처리) 완료  {"groupKey":"process", "progress":"active"}
+11    법률검토 최종완료        {"groupKey":"closing", "progress":"active"}
+12    보고서 작성              {"groupKey":"closing", "progress":"active", "kpiFrom":"reportWriting"}
+13    보고서 제출 완료         {"groupKey":"closing", "progress":"done", "kpiFrom":"completed"}
+```
+
+- `progress` — 요구사항 [7]. 신청은 ①만, 완료는 ⑬만, 나머지는 진행중.
+  단계가 `null`인 기관(채널 미생성)은 `progress` 대상이 아니며 화면에서 '단계 미지정'으로
+  따로 다룬다(승인 완료). 칸반 첫 컬럼이 이 자리다.
+- `kpiFrom` — "이 단계부터 해당 KPI에 집계"라는 임계값. `DashboardService`의 상수
+  `STAGE_DOC_SUBMITTED=2` / `STAGE_COMPLETED=11` / `STAGE_REPORT=12`를 대체한다.
+  요구사항 [2](보고서가 올라와야 최종완료)는 `completed`를 13에 두는 것으로 해결된다.
+  미응답인 [1]·[3]은 현행 값을 그대로 옮겨두고, 답이 오면 데이터만 바꾼다.
+- 원문자(①②③)는 저장하지 않는다. 단계 번호에서 계산한다(U+2460 + n − 1, 20 초과 시 숫자).
+  `Dashboard.tsx:31`의 `CIRCLED` 배열은 삭제한다.
+
+### 5.4 KOGL_TYPE 설계 — 검증에서 드러난 보완점
+
+세 곳의 목록이 7개/5개/6개로 달랐던 것은 실수가 아니라 **쓰이는 자리가 다르기 때문**이다.
+
+| 자리(scope) | 내용 | 항목 |
+|---|---|---|
+| `review` | 변호사 판정값 | 0~4유형, 개방불가, 보류 |
+| `process` | 권리처리 판정값 | 0~4유형, 개방불가 (보류 없음) |
+| `prior` | 기존 부착된 유형 | 0~4유형 |
+
+`sort_order` 하나로는 이 차이를 담을 수 없다. `attrs.scopes`로 사용처를,
+`attrs.firstRow`로 라디오 첫 줄 배치를 표현한다.
+
+```
+0유형    {"scopes":["review","process","prior"], "firstRow":["review","process"]}
+1~4유형  {"scopes":["review","process","prior"], "firstRow":[]}
+개방불가 {"scopes":["review","process"],         "firstRow":["review","process"]}
+보류     {"scopes":["review"],                   "firstRow":["review"]}
+```
+
+이 구분 없이 단순 병합했다면 권리처리 화면에 `보류`가 잘못 노출된다.
+
+### 5.5 색상 — DB에 CSS를 넣지 않는다
+
+뱃지 색이 지금 Tailwind 클래스 문자열이다(`ReviewBoard.tsx:65-71`).
+DB에는 `attrs.tone`으로 의미 토큰(`emerald`, `amber`, `blue`, `red`, `slate`)만 저장하고,
+토큰→클래스 매핑은 프론트가 갖는다. 화면 스타일 변경이 DB 수정을 부르지 않게 한다.
+
+### 5.6 값과 동작의 경계
+
+`applyResultDerivations`(`ReviewBoard.tsx:812-834`)를 실측한 결과, 처리결과 선택 시 동작은
+단순 대입이 아니었다.
+
+| 처리결과 | 동작 |
+|---|---|
+| 권리처리 추진 | 공공누리 `보류` 대입, 권리처리필요 켬 |
+| 개방불가 | 공공누리 `개방불가` 대입, 권리처리필요 끔 |
+| 권리처리 추진 미희망 | 권리처리필요 끔, **기존 부착 유형을 복사**, **비고에 안내문구 덧붙임** |
+
+마지막 행의 "복사"와 "덧붙임"까지 데이터로 옮기면 `attrs` 안에 규칙 언어를 새로 만드는
+꼴이 된다. **값은 데이터로, 동작은 코드로** 나눈다.
+
+```
+신유형 개방          {"tone":"emerald","openable":"Y"}
+계약서 등 재확인     {"tone":"amber"}
+권리처리 추진        {"tone":"blue",  "needsProcessing":true,  "koglSet":"보류"}
+개방불가             {"tone":"red",   "needsProcessing":false, "koglSet":"개방불가"}
+권리처리 추진 미희망 {"tone":"slate", "needsProcessing":false, "koglCopiesPrior":true}
+```
+
+`koglCopiesPrior`가 참일 때 무엇을 복사하고 어떤 문구를 붙일지는 코드가 정한다.
+문구 자체는 메시지 상수 모듈로 옮긴다. 코드에서 리터럴이 사라진다는 목적은 유지된다.
+
+`needsProcessing`을 데이터로 둔 이유는 이것이 요구사항 [12]의 대상이기 때문이다.
+자동 처리를 끄기로 결정되면 코드 수정 없이 속성만 제거하면 된다.
+
+### 5.7 기존 데이터 정합성
+
+운영 DB에 시드와 일치하지 않는 값이 남아 있을 수 있다(정책 변경 전 값, 공백 차이, 오타).
+Phase 1에 정합성 점검을 넣는다.
+
+```sql
+select 'review_result' as col, review_result as value, 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;
+```
+
+`review_major`, `review_minor`, `judged_kogl_type`, `process_status`, `contact_log.method`에
+같은 점검을 돌린다. 발견된 고아 값은 **삭제하지 않고** `active = false`로 코드테이블에
+추가한다. 기존 화면에서 값이 사라지는 사고를 막는다.
+
+## 6. 애플리케이션 설계
+
+### 6.1 백엔드 — `kr.itn.itnhub.code`
+
+| 클래스 | 역할 |
+|---|---|
+| `Code`, `CodeGroup` | record. `attrs`는 `Map<String,Object>` |
+| `CodeMapper` + `CodeMapper.xml` | 조회·저장 |
+| `CodeService` | 기동 시 전체 로드 후 메모리 캐시. 저장 시 캐시 갱신 |
+| `CodeController` | 조회·관리 API |
+| `Codes` | 그룹 ID 상수와 로직이 참조하는 코드 상수 |
+| `CodeValidator` | 저장 요청 값이 해당 그룹의 활성 코드인지 검증 |
+
+코드값은 자주 바뀌지 않으므로 캐시는 `ConcurrentHashMap` 한 겹으로 충분하다.
+분산 캐시는 넣지 않는다(단일 인스턴스 운영).
+
+API:
+
+```
+GET    /api/meta/codes                  전체 코드 (프론트 부팅 시 1회)
+GET    /api/admin/codes/groups          관리 화면용 그룹 목록
+GET    /api/admin/codes/{groupId}       그룹별 코드 목록 + 사용 건수
+POST   /api/admin/codes/{groupId}       코드 추가
+PUT    /api/admin/codes/{groupId}/{code} 라벨·순서·사용여부·attrs 수정
+DELETE /api/admin/codes/{groupId}/{code} 코드 삭제 (사용 중이면 거부)
+```
+
+### 6.2 프론트 — `frontend/src/codes/`
+
+```
+codes/
+  types.ts       Code, CodeMap 타입
+  CodeProvider.tsx  로그인 후 1회 조회, Context 제공
+  useCodes.ts    useCodes('REVIEW_RESULT'), useCode(group, code)
+  stage.ts       단계 전용 헬퍼 (label, symbol, groupKey, progress, kpiFrom)
+  tone.ts        tone 토큰 → Tailwind 클래스 매핑
+```
+
+기존 `frontend/src/stages.ts`는 삭제하고 `codes/stage.ts`로 대체한다.
+
+로딩 시점은 인증 성공 직후다. 로그인 화면에서는 코드가 필요 없다.
+조회 실패 시 화면을 그리지 않고 오류를 표시한다(빈 선택지로 저장되는 사고 방지).
+
+각 화면 상단의 옵션 배열은 전부 삭제하고 `useCodes`로 대체한다.
+
+### 6.3 코드 관리 화면
+
+좌측 그룹 목록, 우측 코드 목록. 사이드바에 '공통코드' 메뉴를 추가한다.
+
+- 편집 가능: 표시명, 정렬순서, 사용여부, 코드 추가·삭제
+- `attrs`는 접이식 JSON 편집기로 노출하고 경고 문구를 붙인다.
+  그룹별 전용 폼은 만들지 않는다. 실제 편집 수요는 표시명과 순서에 몰려 있고,
+  전용 폼은 그룹이 늘 때마다 화면을 늘려야 한다.
+- 삭제 안전장치: 해당 코드를 쓰는 데이터 건수를 먼저 조회하고, 1건 이상이면 삭제를 막고
+  사용여부 끄기를 안내한다.
+- `code_group.editable = false`인 그룹(`STAGE` 등 로직 결합이 강한 그룹)은 추가·삭제를 막고
+  표시명·순서만 허용한다. 단계 추가는 마이그레이션으로 한다.
+- 권한: 지금은 인증만으로 충분하다(관리자 1계정). 사용자 계정 도입 시 관리자 전용으로 제한한다.
+
+### 6.4 코드테이블 밖에서 처리하는 것
+
+**SQL 안의 `'처리완료'` 5곳** — 쿼리라서 코드테이블로 뺄 수 없다. MyBatis 파라미터로 주입한다.
+대상: `ProcessItemMapper.xml:42, 45, 148, 176, 194`, `DashboardMapper.xml:78`.
+매퍼 인터페이스 시그니처에 `doneStatus`를 추가하고 서비스가 `Codes`에서 읽어 넘긴다.
+
+**엑셀 양식 정의** — 코드값이 아니라 파일 포맷이다. 코드테이블에 넣지 않는다.
+다만 지금 업로드 쪽(`ReviewParser.COL_*` 23개, `ProcessParser.COL_*` 25개)과
+다운로드 쪽(`ReviewService.HEADERS` 25개, `ProcessService.HEADERS` 27개)이 서로 독립 정의라
+**한쪽만 고치면 양식이 어긋나는 결함**이 있다. 열 순서·헤더명·필드를 한 곳에서 선언하는
+스키마 모듈로 합치고, 업로드와 다운로드가 같은 정의를 참조하게 한다.
+
+주의: `ProcessParser.java:66-68`에 "헤더는 T열인데 데이터는 U열" 예외 처리가 있다.
+통합 시 이 예외를 놓치면 권리처리 업로드가 조용히 어긋난다.
+
+**안내 문구** — `NOTE_NO_PROCESSING` 등은 코드값이 아니라 메시지다. 메시지 상수 모듈로 분리한다.
+
+**Mattermost 채널 종류** — `ChannelKind` enum과 설정 프로퍼티로 이미 관리된다. 그대로 둔다.
+
+### 6.5 화면 파일 분해
+
+손대는 파일만 분해한다. 무관한 파일은 건드리지 않는다.
+
+| 현재 | 분해 |
+|---|---|
+| `ReviewBoard.tsx` 1,279 | `ReviewBoard` / `ReviewFilters` / `ReviewTable` / `ReviewDetail` / `ReviewBulkForm` |
+| `ProcessBoard.tsx` 1,060 | `ProcessBoard` / `ProcessFilters` / `ProcessTable` / `ProcessDetail` / `ProcessBulkForm` |
+| `Dashboard.tsx` 697 | `Dashboard` / `KpiTiles` / `KanbanBoard` / `RecentOrgTable` |
+| `OrgOverview.tsx` 740 | `OrgOverview` / `OrgStageStrip` / `OrgTabs` / `ReTab`(신규) |
+
+## 7. 실행 순서
+
+Phase 1~4는 **기존 동작이 바뀌지 않는다**. Phase 5는 화면을 새로 추가할 뿐 기존 동작을
+건드리지 않는다. 기존 테스트 40개(백엔드 22 + 프론트 18)가 그대로 통과해야 한다는 것이
+Phase 1~5 각 단계의 완료 조건이며, 이것이 이번 작업의 안전망이다.
+실제 동작이 바뀌는 것은 Phase 6뿐이다.
+
+| Phase | 내용 | 동작 변화 |
+|---|---|---|
+| 1 | 코드테이블 + 시드 + `code` 패키지 + `/api/meta/codes` + 정합성 점검 | 없음 |
+| 2 | 프론트 `codes` 모듈, 화면이 코드테이블에서 옵션을 읽도록 전환, 하드코딩 배열 삭제 | 없음 |
+| 3 | Java·SQL 리터럴 제거, 엑셀 양식 정의 통합, 메시지 상수 분리 | 없음 |
+| 4 | 화면 파일 분해 | 없음 |
+| 5 | 코드 관리 화면 | 화면 추가 |
+| 6 | 요구사항 반영 (§8) | 있음 |
+
+## 8. 요구사항 반영 (Phase 6)
+
+| 항목 | 내용 | 작업 |
+|---|---|---|
+| [7][11] | 13단계 "보고서 제출 완료" 신설, 진행상태 신청/진행중/완료 | 시드에 13 추가, `StageService` 상한을 코드 개수로, 칸반 컬럼 = 단계미지정 + 13 |
+| [2] | 완료 = 보고서 업로드 기준 | `kpiFrom:"completed"`를 13에 부여 |
+| [4][5] | RE 메모 기능 신설 | `re_memo` 테이블, `re` 패키지, 기관관리 RE 탭, 대시보드 미해결 RE 집계 |
+| [15] | 일괄등록에 비고 추가 | `BulkJudgmentRequest`에 `lawyerNote`, `BulkProcessingRequest`에 `reviewNote` |
+| [8] | 자료접수 = 권리확인·권리처리 통합 | `Dashboard`의 판정에 `processTotal` 포함 |
+| [6] | 업무구간 필터 삭제, 필터 순서 재배치 | 필터 UI만 제거. `STAGE_GROUP`은 칸반 색·카드 모양에 계속 쓰이므로 유지 |
+| [25] | 보고서 수정 | `PUT /api/orgs/{id}/reports/{reportId}` 신설 |
+| [16] | 표기 누락 점검 | 현행 2줄 표기 유지, 열 누락 여부만 확인 |
+| [17][21][10] | 현행 유지 | 작업 없음 |
+| [23] | 기관 100개 | 코드 변경 없음. 명부 엑셀 수령 후 업로드 |
+
+### RE 메모 설계
+
+회신 예시: `ooo 계약서 및 제안요청서 송부 요청, 장영익, 2026.07.08`
+
+```sql
+create table re_memo (
+  id          bigserial primary key,
+  org_id      bigint not null references organization(id),
+  content     text not null,
+  author      varchar(100) not null,
+  memo_date   date not null,
+  resolved    boolean not null default false,
+  resolved_at timestamptz,
+  created_at  timestamptz not null default now(),
+  updated_at  timestamptz not null default now()
+);
+```
+
+메모 1건 = RE 1건. 기관관리 RE 탭에서 목록·등록·수정·삭제한다.
+대시보드 '미해결 RE'는 `resolved = false` 건수 합계다.
+`DashboardMapper.xml:64`의 `0 as re_count`를 서브쿼리로 교체한다
+(해당 파일 44-45행 주석이 이미 이 방식을 예고하고 있다).
+
+> **확인받지 못한 가정**: `resolved` 플래그는 회신에 없던 항목이다. 대시보드 '미해결 RE'
+> 숫자를 세려면 해결 여부를 구분할 수단이 필요해 완료 체크박스로 설계했다.
+> 이 가정이 틀리면 RE 탭과 KPI 집계를 함께 손봐야 한다. 회신 요청 상태다.
+
+## 9. 테스트
+
+기존 테스트는 리팩터링의 회귀 안전망이다. Phase 1~5 동안 계속 통과해야 한다.
+
+신규로 필요한 것:
+
+- `CodeServiceTest` — 캐시 적재·갱신, scope 필터, 활성 코드만 노출
+- `CodeControllerTest` — 조회·수정·삭제, 사용 중 코드 삭제 거부
+- `DashboardServiceTest` — **현재 없다**. `kpiFrom` 기반 집계로 바꾸기 전에 먼저 만들어
+  현행 동작을 고정한다
+- `ReviewParserTest` / `ProcessParserTest` — **현재 없다**. 엑셀 양식 정의를 통합하기 전에
+  업로드→다운로드 왕복 테스트를 만들어 양식 호환을 고정한다. `ProcessParser`의 T/U열 예외를
+  반드시 포함한다
+- `ReMemoControllerTest` — RE 메모 CRUD
+- 프론트: `codes` 모듈 테스트, 분해된 컴포넌트별 테스트
+
+Phase 6에서 갱신이 필요한 기존 테스트:
+
+- `StageControllerTest:90,92` — "13은 400" 단언이 뒤집힌다
+- `DashboardControllerTest:57,96,98` — 11/12 기준 단언
+- `OrgOverview.test.tsx:254,466` — "업무단계 12개"
+
+## 10. 리스크
+
+| 리스크 | 대응 |
+|---|---|
+| 운영 DB에 시드와 다른 값이 남아 있음 | Phase 1 정합성 점검, 고아 값은 `active=false`로 보존 |
+| 엑셀 양식 통합 시 기존 파일 호환 깨짐 | 왕복 테스트를 먼저 작성, T/U열 예외 포함 |
+| `STAGE_GROUP`을 필터와 함께 지워 칸반이 깨짐 | 필터 UI만 제거, 색·카드 모양 용도는 유지 (§8) |
+| 코드 조회 실패 시 빈 선택지로 저장 | 조회 실패 시 화면을 그리지 않고 오류 표시 |
+| Phase 4 분해 중 회귀 | 각 Phase 완료 조건 = 기존 테스트 전량 통과 |
+
+## 11. 미결 항목
+
+- RE 메모의 `resolved` 플래그 (§8) — 회신 대기
+- 요구사항 미응답 10건, 재질문 1건, 결정 대기 3건 — §4 제외 목록
+- 기관 100개 명부 엑셀 — 수령 대기
+- 주간·월간 보고 양식 — 수령 대기
 
src/main/resources/db/migration/V13__common_code.sql (added)
+++ src/main/resources/db/migration/V13__common_code.sql
@@ -0,0 +1,31 @@
+-- 공통코드. 진행단계·처리결과·공공누리유형 같은 선택지를 소스가 아니라 데이터로 관리한다.
+--
+-- 저장값(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,
+    -- 화면 표시명. 저장값과 다를 수 있다 - 권리확인 세부항목은 저장값이
+    -- '2. 후천적 권리 전부 보유(계약서 확인 필요)'인데 화면에는 짧은 형태로 나온다.
+    label      varchar(200) not null,
+    sort_order int          not null,
+    active     boolean      not null default true,
+    -- 코드별 부가 속성. 값만 담고 동작은 담지 않는다 - 동작까지 밀어넣으면
+    -- DB 안에 규칙 언어를 새로 만드는 꼴이 되어 오히려 읽기 어려워진다.
+    attrs      jsonb        not null default '{}'::jsonb,
+    primary key (group_id, code)
+);
+
+create index idx_code_group_sort on code (group_id, sort_order);
 
src/test/java/kr/itn/itnhub/code/CodeSchemaTest.java (added)
+++ src/test/java/kr/itn/itnhub/code/CodeSchemaTest.java
@@ -0,0 +1,65 @@
+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);
+
+        jdbc.update("delete from code where group_id = 'TEST_GRP'");
+        jdbc.update("delete from code_group where group_id = 'TEST_GRP'");
+    }
+
+    @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("{}");
+
+        jdbc.update("delete from code where group_id = 'TEST_ATTR'");
+        jdbc.update("delete from code_group where group_id = 'TEST_ATTR'");
+    }
+}
Add a comment
List