이호영 이호영 07-28
Merge branch 'feature/common-code-refactoring'
공통코드 도입과 260727 검토본 요구사항 반영을 한 번에 올린다. 커밋 16개.

주요 변경
  · 선택지를 코드테이블로 옮기고 소스에서 코드값 리터럴 제거
    (처리완료 13곳·공공누리 3곳·담당자 5곳·진행단계 5곳 → 각 1곳)
  · 진행단계 13단계 신설, 완료 기준을 보고서 제출 완료로 이동
  · 자료 업로드에 따른 자동 단계 전환 3종과 이력 자동/수동 구분
  · RE 메모, 자료실, 보고서 수정, 일괄등록 비고 신설
  · 작성자 표기를 로그인 아이디에서 표시 이름으로

함께 고친 결함
  · 권리확인 기존 공공누리 유형이 비어 보이던 문제 (1,926건)
  · 권리처리 엑셀 왕복 시 기존 증빙자료가 사라지던 문제
  · 공공누리 유형 표기를 N유형으로 통일 (68건)

배포 시 주의
  마이그레이션 V13~V18이 함께 적용된다. 운영 DB에 코드표에 없는 값이 남아
  있으면 해당 자료의 선택 칸이 비어 보이므로, 배포 전
  src/main/resources/db/consistency/check-orphan-codes.sql 을 돌려
  발견된 값을 비활성 코드로 등록해야 한다.

테스트: 백엔드 288건, 프런트 209건 전부 통과.
@93ccbdbcc848973af765cd44423b90338838e8c7
 
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개 명부 엑셀 — 수령 대기
+- 주간·월간 보고 양식 — 수령 대기
frontend/src/App.tsx
--- frontend/src/App.tsx
+++ frontend/src/App.tsx
@@ -8,14 +8,16 @@
 import OrgOverview from './components/OrgOverview'
 import ProjectsPage from './components/ProjectsPage'
 import SeedUpload from './components/SeedUpload'
+import SharedFiles from './components/SharedFiles'
 import Sidebar from './components/Sidebar'
 import SystemPage from './components/SystemPage'
+import { CodeProvider } from './codes/CodeProvider'
 import { useCollapsiblePanel } from './hooks/useCollapsiblePanel'
 
 // 화면 4개: Dashboard(전체 기관 현황 - 좌측 기관 목록 없이 전폭으로 쓴다),
 // 기관관리(시안 레이아웃의 기관 상세), 채널관리(명부 시드 + 담당자 배정 + 채널 생성),
 // 담당자관리(담당자 3종 CRUD - 담당자 정보가 입력되는 유일한 화면).
-type Page = 'dashboard' | 'orgs' | 'channels' | 'contacts' | 'projects' | 'system'
+type Page = 'dashboard' | 'orgs' | 'channels' | 'contacts' | 'projects' | 'files' | 'system'
 
 const PAGE_TITLE: Record<Page, string> = {
   dashboard: 'Dashboard',
@@ -23,6 +25,7 @@
   channels: '채널관리',
   contacts: '담당자관리',
   projects: '사업관리',
+  files: '자료실',
   system: '시스템관리',
 }
 
@@ -100,7 +103,10 @@
     }
   }
 
+  // 화면 선택지(진행단계·처리결과·공공누리유형 등)는 서버에서 한 번 받아 앱 전체가 나눠 쓴다.
+  // 로그인 화면에는 필요 없으므로 인증을 통과한 다음부터 감싼다.
   return (
+    <CodeProvider>
     <div className="flex h-screen">
       <Sidebar
         activeKey={page}
@@ -113,6 +119,7 @@
             key === 'channels' ||
             key === 'contacts' ||
             key === 'projects' ||
+            key === 'files' ||
             key === 'system'
           ) {
             setPage(key)
@@ -136,7 +143,8 @@
         </header>
 
         <div className="flex min-h-0 flex-1">
-          {page !== 'contacts' && page !== 'dashboard' && page !== 'system' && page !== 'projects' && (
+          {page !== 'contacts' && page !== 'dashboard' && page !== 'system'
+            && page !== 'projects' && page !== 'files' && (
             <div
               className={`flex shrink-0 flex-col border-r border-gray-200 transition-all duration-200 ${
                 listCollapsed ? 'w-14' : 'w-80'
@@ -172,6 +180,8 @@
                   setPage('orgs')
                 }}
               />
+            ) : page === 'files' ? (
+              <SharedFiles />
             ) : page === 'system' ? (
               <SystemPage onChanged={() => void reload()} />
             ) : page === 'contacts' ? (
@@ -189,5 +199,6 @@
         </div>
       </div>
     </div>
+    </CodeProvider>
   )
 }
frontend/src/api/client.ts
--- frontend/src/api/client.ts
+++ frontend/src/api/client.ts
@@ -2,6 +2,23 @@
 
 export type ContactCategory = 'APPLICANT' | 'MJ' | 'LAWYER' | 'OPERATOR' | 'ITN'
 
+/**
+ * 공통코드 한 건. 화면 선택지는 소스가 아니라 DB에서 온다.
+ *
+ * code는 실제로 저장되는 값이고 label은 화면 표시명이다. 둘이 다를 수 있다 -
+ * 권리확인 세부항목은 저장값이 길고 화면에는 짧은 형태로 나온다.
+ * attrs에는 코드별 부가 속성이 들어간다(색 토큰, 쓰이는 자리, 추천값 등).
+ */
+export interface Code {
+  code: string
+  label: string
+  sortOrder: number
+  attrs: Record<string, unknown>
+}
+
+/** 그룹ID -> 코드 목록(정렬순, 사용 중인 것만). */
+export type CodeMap = Record<string, Code[]>
+
 /** 담당자관리에 등록된 담당자 1명. 신청기관 담당자는 affiliation에 소속 기관명이 들어간다. */
 export interface Contact {
   id: number
@@ -147,6 +164,110 @@
 
 export function getOrgs(): Promise<Org[]> {
   return request<Org[]>('/api/orgs')
+}
+
+/** 모든 화면 선택지를 한 번에 받는다. 로그인 직후 1회만 부른다. */
+export function getCodes(): Promise<CodeMap> {
+  return request<CodeMap>('/api/meta/codes')
+}
+
+/** 자료실에 올려둔 공용 서식(요구사항 [26]). 기관에 딸리지 않는다. */
+export interface SharedFile {
+  id: number
+  fileName: string
+  description: string | null
+  byteSize: number
+  uploadedBy: string
+  uploadedAt: number
+}
+
+export function getSharedFiles(): Promise<SharedFile[]> {
+  return request<SharedFile[]>('/api/shared-files')
+}
+
+export async function addSharedFile(file: File, description: string): Promise<SharedFile[]> {
+  const form = new FormData()
+  form.append('file', file)
+  if (description) {
+    form.append('description', new Blob([description], { type: 'text/plain' }))
+  }
+  const res = await fetch('/api/shared-files', {
+    method: 'POST',
+    headers: { 'X-XSRF-TOKEN': csrfToken() },
+    credentials: 'same-origin',
+    body: form,
+  })
+  if (!res.ok) {
+    throw new ApiError(res.status, `자료 업로드 실패 (${res.status}): ${await res.text()}`)
+  }
+  return res.json() as Promise<SharedFile[]>
+}
+
+export function updateSharedFileDescription(id: number, description: string): Promise<SharedFile[]> {
+  return request<SharedFile[]>(`/api/shared-files/${id}`, {
+    method: 'PUT',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ description }),
+  })
+}
+
+export async function deleteSharedFile(id: number): Promise<void> {
+  await fetch(`/api/shared-files/${id}`, {
+    method: 'DELETE',
+    headers: { 'X-XSRF-TOKEN': csrfToken() },
+    credentials: 'same-origin',
+  })
+}
+
+export function sharedFileDownloadUrl(id: number): string {
+  return `/api/shared-files/${id}`
+}
+
+/** RE 메모 한 건. 요구사항 [4]대로 메모 1개가 RE 1개다. */
+export interface ReMemo {
+  id: number
+  orgId: number
+  content: string
+  author: string
+  /** yyyy-MM-dd */
+  memoDate: string
+  resolved: boolean
+  createdAt: number
+}
+
+export interface ReMemoInput {
+  content: string
+  author: string
+  memoDate: string
+  resolved: boolean
+}
+
+export function getReMemos(orgId: number): Promise<ReMemo[]> {
+  return request<ReMemo[]>(`/api/orgs/${orgId}/re-memos`)
+}
+
+export function addReMemo(orgId: number, body: ReMemoInput): Promise<ReMemo[]> {
+  return request<ReMemo[]>(`/api/orgs/${orgId}/re-memos`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(body),
+  })
+}
+
+export function updateReMemo(orgId: number, memoId: number, body: ReMemoInput): Promise<ReMemo[]> {
+  return request<ReMemo[]>(`/api/orgs/${orgId}/re-memos/${memoId}`, {
+    method: 'PUT',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(body),
+  })
+}
+
+export async function deleteReMemo(orgId: number, memoId: number): Promise<void> {
+  await fetch(`/api/orgs/${orgId}/re-memos/${memoId}`, {
+    method: 'DELETE',
+    headers: { 'X-XSRF-TOKEN': csrfToken() },
+    credentials: 'same-origin',
+  })
 }
 
 export function updateAssignments(orgId: number, body: AssignmentInput): Promise<Org> {
@@ -695,7 +816,14 @@
 /** 권리확인 일괄 등록. 비운 항목은 기존 값을 유지한다. */
 export function bulkUpdateReview(
   orgId: number,
-  body: { ids: number[]; reviewResult?: string; judgedKoglType?: string; opinion?: string },
+  body: {
+    ids: number[]
+    reviewResult?: string
+    judgedKoglType?: string
+    opinion?: string
+    /** 요구사항 [15]로 추가된 비고. */
+    lawyerNote?: string
+  },
 ): Promise<{ updated: number }> {
   return request<{ updated: number }>(`/api/orgs/${orgId}/review/bulk`, {
     method: 'PUT',
@@ -715,7 +843,14 @@
 /** 권리처리 일괄 등록. 비운 항목은 기존 값을 유지한다. */
 export function bulkUpdateProcess(
   orgId: number,
-  body: { ids: number[]; processStatus?: string; judgedKoglType?: string; finalOpinion?: string },
+  body: {
+    ids: number[]
+    processStatus?: string
+    judgedKoglType?: string
+    finalOpinion?: string
+    /** 요구사항 [15]로 추가된 비고. */
+    reviewNote?: string
+  },
 ): Promise<{ updated: number }> {
   return request<{ updated: number }>(`/api/orgs/${orgId}/process/bulk`, {
     method: 'PUT',
 
frontend/src/codes/CodeProvider.tsx (added)
+++ frontend/src/codes/CodeProvider.tsx
@@ -0,0 +1,71 @@
+import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
+import { getCodes, type CodeMap } from '../api/client'
+
+/**
+ * 화면 선택지를 서버에서 한 번 받아 앱 전체에 나눠준다.
+ *
+ * 화면마다 따로 부르지 않는 이유는 코드가 자주 안 바뀌고, 선택지를 기다리느라 폼이
+ * 깜빡이는 것을 피하기 위해서다. 로그인 화면에서는 필요 없으므로 인증 이후에 감싼다.
+ */
+const CodeContext = createContext<CodeMap | null>(null)
+
+type LoadState = 'loading' | 'ready' | 'error'
+
+/**
+ * @param codes 넘기면 서버를 부르지 않고 그 값을 그대로 쓴다. 테스트에서 화면 하나만
+ *   떼어 그릴 때 쓰라고 열어둔 자리다.
+ */
+export function CodeProvider({ children, codes: given }: { children: ReactNode; codes?: CodeMap }) {
+  const [codes, setCodes] = useState<CodeMap | null>(given ?? null)
+  const [state, setState] = useState<LoadState>(given ? 'ready' : 'loading')
+  const [message, setMessage] = useState('')
+
+  async function load() {
+    setState('loading')
+    try {
+      setCodes(await getCodes())
+      setState('ready')
+    } catch (e) {
+      setMessage((e as Error).message)
+      setState('error')
+    }
+  }
+
+  useEffect(() => {
+    if (!given) {
+      void load()
+    }
+  }, [given])
+
+  if (state === 'loading') {
+    return <div className="p-8 text-sm text-gray-500">불러오는 중…</div>
+  }
+
+  // 선택지를 못 받은 채로 화면을 그리면 빈 드롭다운이 뜨고, 거기서 저장하면 값이
+  // 지워진다. 그리지 않고 오류를 보여주는 편이 안전하다.
+  if (state === 'error' || !codes) {
+    return (
+      <div className="flex min-h-screen flex-col items-center justify-center gap-3 p-8 text-sm">
+        <p className="text-red-600">선택지 정보를 불러오지 못했습니다. {message}</p>
+        <button
+          type="button"
+          onClick={() => void load()}
+          className="rounded-md border border-gray-300 px-3 py-1.5 text-sm"
+        >
+          다시 시도
+        </button>
+      </div>
+    )
+  }
+
+  return <CodeContext.Provider value={codes}>{children}</CodeContext.Provider>
+}
+
+/** Provider 밖에서 부르면 개발 중에 바로 알아채도록 예외를 던진다. */
+export function useCodeMap(): CodeMap {
+  const codes = useContext(CodeContext)
+  if (!codes) {
+    throw new Error('CodeProvider 안에서만 쓸 수 있습니다.')
+  }
+  return codes
+}
 
frontend/src/codes/codes.test.tsx (added)
+++ frontend/src/codes/codes.test.tsx
@@ -0,0 +1,203 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { CodeProvider } from './CodeProvider'
+import { TEST_CODES, withCodes } from './fixtures'
+import { useStages } from './stage'
+import { badgeClass, barClass } from './tone'
+import { useCodeLabel, useCodes, useScopedCodes, useSplitByFirstRow } from './useCodes'
+
+const mocks = vi.hoisted(() => ({ getCodes: vi.fn() }))
+
+vi.mock('../api/client', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('../api/client')>()),
+  getCodes: mocks.getCodes,
+}))
+
+beforeEach(() => {
+  mocks.getCodes.mockReset()
+})
+
+describe('useCodes', () => {
+  it('그룹의 선택지를 정렬순서대로 준다', () => {
+    function Probe() {
+      return <span>{useCodes('REVIEW_RESULT').map((c) => c.label).join('|')}</span>
+    }
+    render(withCodes(<Probe />))
+
+    expect(
+      screen.getByText('신유형 개방|계약서 등 재확인|권리처리 추진|개방불가|권리처리 추진 미희망'),
+    ).toBeTruthy()
+  })
+
+  it('없는 그룹이면 빈 배열이라 화면이 죽지 않는다', () => {
+    function Probe() {
+      return <span data-testid="n">{useCodes('NO_SUCH_GROUP').length}</span>
+    }
+    render(withCodes(<Probe />))
+
+    expect(screen.getByTestId('n').textContent).toBe('0')
+  })
+
+  it('저장된 값의 표시명을 준다', () => {
+    function Probe() {
+      return <span>{useCodeLabel('CONTACT_CATEGORY', 'MJ')}</span>
+    }
+    render(withCodes(<Probe />))
+
+    // 엑셀에는 '주관기관'으로 오지만 화면에는 '문정원'으로 나와야 한다.
+    expect(screen.getByText('문정원')).toBeTruthy()
+  })
+
+  it('코드표에 없는 값이면 값 자체를 보여준다', () => {
+    function Probe() {
+      return <span>{useCodeLabel('CONTACT_METHOD', '문자')}</span>
+    }
+    render(withCodes(<Probe />))
+
+    // 과거 데이터가 빈칸으로 사라지는 것보다 원문이라도 보이는 편이 낫다.
+    expect(screen.getByText('문자')).toBeTruthy()
+  })
+})
+
+describe('공공누리 유형의 사용처 구분', () => {
+  function ScopeProbe({ scope }: { scope: string }) {
+    return <span>{useScopedCodes('KOGL_TYPE', scope).map((c) => c.code).join(',')}</span>
+  }
+
+  it('변호사 판정에는 보류가 있다', () => {
+    render(withCodes(<ScopeProbe scope="review" />))
+    expect(screen.getByText('0유형,1유형,2유형,3유형,4유형,개방불가,보류')).toBeTruthy()
+  })
+
+  it('권리처리 판정에는 보류가 없다', () => {
+    render(withCodes(<ScopeProbe scope="process" />))
+    expect(screen.getByText('0유형,1유형,2유형,3유형,4유형,개방불가')).toBeTruthy()
+  })
+
+  it('기존 부착 유형에는 조사표에서 온 값이 들어간다', () => {
+    render(withCodes(<ScopeProbe scope="prior" />))
+    expect(screen.getByText('0유형,1유형,2유형,3유형,4유형,유형 확인 필요,제4유형')).toBeTruthy()
+  })
+
+  it('라디오 첫 줄과 둘째 줄을 나눈다', () => {
+    function Probe() {
+      const { first, rest } = useSplitByFirstRow('KOGL_TYPE', 'review')
+      return (
+        <>
+          <span data-testid="first">{first.map((c) => c.code).join(',')}</span>
+          <span data-testid="rest">{rest.map((c) => c.code).join(',')}</span>
+        </>
+      )
+    }
+    render(withCodes(<Probe />))
+
+    expect(screen.getByTestId('first').textContent).toBe('0유형,개방불가,보류')
+    expect(screen.getByTestId('rest').textContent).toBe('1유형,2유형,3유형,4유형')
+  })
+})
+
+describe('useStages', () => {
+  function StageProbe() {
+    const stages = useStages()
+    return (
+      <>
+        <span data-testid="count">{stages.count}</span>
+        <span data-testid="label13">{stages.label(13)}</span>
+        <span data-testid="symbol13">{stages.symbol(13)}</span>
+        <span data-testid="symbol1">{stages.symbol(1)}</span>
+        <span data-testid="group5">{stages.groupKey(5)}</span>
+        <span data-testid="tone-assign">{stages.groupTone('assign')}</span>
+        <span data-testid="progress1">{stages.progressOf(1)}</span>
+        <span data-testid="progress7">{stages.progressOf(7)}</span>
+        <span data-testid="progress13">{stages.progressOf(13)}</span>
+        <span data-testid="completed-from">{stages.kpiThreshold('completed')}</span>
+        <span data-testid="counts12">{String(stages.countsToward('completed', 12))}</span>
+        <span data-testid="counts13">{String(stages.countsToward('completed', 13))}</span>
+      </>
+    )
+  }
+
+  it('단계 수와 라벨이 코드표에서 온다', () => {
+    render(withCodes(<StageProbe />))
+
+    expect(screen.getByTestId('count').textContent).toBe('13')
+    expect(screen.getByTestId('label13').textContent).toBe('보고서 제출 완료')
+  })
+
+  it('원문자는 번호에서 계산한다', () => {
+    render(withCodes(<StageProbe />))
+
+    expect(screen.getByTestId('symbol1').textContent).toBe('①')
+    expect(screen.getByTestId('symbol13').textContent).toBe('⑬')
+  })
+
+  it('진행상태는 1단계만 신청이고 13단계만 완료다', () => {
+    render(withCodes(<StageProbe />))
+
+    expect(screen.getByTestId('progress1').textContent).toBe('applied')
+    expect(screen.getByTestId('progress7').textContent).toBe('active')
+    expect(screen.getByTestId('progress13').textContent).toBe('done')
+  })
+
+  it('완료 KPI는 보고서가 올라온 13단계부터 센다', () => {
+    render(withCodes(<StageProbe />))
+
+    expect(screen.getByTestId('completed-from').textContent).toBe('13')
+    expect(screen.getByTestId('counts12').textContent).toBe('false')
+    expect(screen.getByTestId('counts13').textContent).toBe('true')
+  })
+
+  it('업무구간과 색이 코드표에서 온다', () => {
+    render(withCodes(<StageProbe />))
+
+    expect(screen.getByTestId('group5').textContent).toBe('check')
+    expect(screen.getByTestId('tone-assign').textContent).toBe('indigo')
+  })
+})
+
+describe('tone', () => {
+  it('의미 토큰을 실제 클래스로 바꾼다', () => {
+    expect(badgeClass('emerald')).toBe('bg-emerald-50 text-emerald-700')
+    expect(barClass('indigo')).toBe('bg-indigo-400')
+  })
+
+  it('모르는 토큰은 회색으로 떨어뜨린다', () => {
+    // 코드 관리 화면에서 오타가 들어가도 색만 밋밋해질 뿐 항목이 사라지면 안 된다.
+    expect(badgeClass('없는색')).toBe('bg-gray-100 text-gray-700')
+    expect(barClass(null)).toBe('bg-gray-300')
+  })
+})
+
+describe('CodeProvider', () => {
+  it('서버에서 선택지를 받아 아래 화면에 넘긴다', async () => {
+    mocks.getCodes.mockResolvedValue(TEST_CODES)
+
+    function Probe() {
+      return <span>{useCodes('PROCESS_STATUS').map((c) => c.label).join(',')}</span>
+    }
+    render(
+      <CodeProvider>
+        <Probe />
+      </CodeProvider>,
+    )
+
+    await waitFor(() => expect(screen.getByText('미처리,처리완료')).toBeTruthy())
+  })
+
+  it('선택지를 못 받으면 화면을 그리지 않고 오류를 보여준다', async () => {
+    mocks.getCodes.mockRejectedValue(new Error('서버 오류'))
+
+    function Probe() {
+      return <span>그려지면 안 됨</span>
+    }
+    render(
+      <CodeProvider>
+        <Probe />
+      </CodeProvider>,
+    )
+
+    // 빈 드롭다운으로 저장하면 값이 지워지므로 아예 안 그리는 편이 안전하다.
+    await waitFor(() => expect(screen.getByText(/선택지 정보를 불러오지 못했습니다/)).toBeTruthy())
+    expect(screen.queryByText('그려지면 안 됨')).toBeNull()
+  })
+})
 
frontend/src/codes/fixtures.tsx (added)
+++ frontend/src/codes/fixtures.tsx
@@ -0,0 +1,120 @@
+import type { ReactElement } from 'react'
+import type { Code, CodeMap } from '../api/client'
+import { CodeProvider } from './CodeProvider'
+
+/**
+ * 테스트용 코드표. V13~V15 시드와 같은 값이라 화면 테스트가 실제 선택지로 돌아간다.
+ *
+ * 서버 응답을 흉내 내는 것이므로 비활성 코드는 들어 있지 않다(서버가 활성만 내려준다).
+ * 시드가 바뀌면 여기도 같이 고쳐야 한다 - 어긋나면 화면 테스트가 실제와 다른 선택지로
+ * 통과해버린다.
+ */
+function code(c: string, label: string, sortOrder: number, attrs: Record<string, unknown> = {}): Code {
+  return { code: c, label, sortOrder, attrs }
+}
+
+export const TEST_CODES: CodeMap = {
+  STAGE: [
+    code('1', '신청', 1, { groupKey: 'intake', progress: 'applied' }),
+    code('2', '목록접수', 2, { groupKey: 'intake', progress: 'active', kpiFrom: 'docSubmitted' }),
+    code('3', '예비검토', 3, { groupKey: 'intake', progress: 'active' }),
+    code('4', '변호사 배당', 4, { groupKey: 'assign', progress: 'active' }),
+    code('5', '법률검토(권리확인)', 5, { groupKey: 'check', progress: 'active' }),
+    code('6', 'RE:확인', 6, { groupKey: 'check', progress: 'active' }),
+    code('7', '법률검토(권리확인) 완료', 7, { groupKey: 'check', progress: 'active' }),
+    code('8', '법률검토(권리처리)', 8, { groupKey: 'process', progress: 'active' }),
+    code('9', 'RE:처리', 9, { groupKey: 'process', progress: 'active' }),
+    code('10', '법률검토(권리처리) 완료', 10, { groupKey: 'process', progress: 'active' }),
+    code('11', '법률검토 최종완료', 11, { groupKey: 'closing', progress: 'active' }),
+    code('12', '보고서 작성', 12, { groupKey: 'closing', progress: 'active', kpiFrom: 'reportWriting' }),
+    code('13', '보고서 제출 완료', 13, { groupKey: 'closing', progress: 'done', kpiFrom: 'completed' }),
+  ],
+  STAGE_GROUP: [
+    code('intake', '접수', 1, { tone: 'slate' }),
+    code('assign', '배당', 2, { tone: 'indigo' }),
+    code('check', '권리확인', 3, { tone: 'emerald' }),
+    code('process', '권리처리', 4, { tone: 'amber' }),
+    code('closing', '마감', 5, { tone: 'violet' }),
+  ],
+  REVIEW_MAJOR: [
+    code('만료 저작물', '만료 저작물', 1, { suggest: '신유형 개방', hasMinor: false }),
+    code('업무상 저작물', '업무상 저작물', 2, { suggest: '신유형 개방', hasMinor: false }),
+    code('제3자 권리', '제3자 권리', 3, { hasMinor: true }),
+    code('개인정보 포함', '개인정보 포함', 4, { suggest: '개방불가', hasMinor: false }),
+  ],
+  REVIEW_MINOR: [
+    code('1. 원시적 권리 전부 보유', '1. 원시적 권리 전부 보유', 1, { suggest: '신유형 개방' }),
+    code('2. 후천적 권리 전부 보유(계약서 확인 필요)', '2. 후천적 권리 전부 보유', 2, {
+      note: '(계약에 의한 전부 양수)',
+      suggest: '계약서 등 재확인',
+    }),
+    code('3. 권리 일부(공동) 보유', '3. 권리 일부(공동) 보유', 3, {
+      note: '(보도자료·제3자저작물, 계약에 의한 전부/공동 보유 등)',
+      suggest: '권리처리 추진',
+      allowed: ['권리처리 추진', '권리처리 추진 미희망'],
+    }),
+    code('4. 권리 미보유', '4. 권리 미보유', 4, {
+      note: '(공모전 수상작 등)',
+      suggest: '권리처리 추진',
+      allowed: ['권리처리 추진', '권리처리 추진 미희망'],
+    }),
+    code('초상권 포함', '초상권 포함', 5, {
+      suggest: '권리처리 추진',
+      allowed: ['권리처리 추진', '권리처리 추진 미희망', '개방불가'],
+    }),
+  ],
+  REVIEW_RESULT: [
+    code('신유형 개방', '신유형 개방', 1, { tone: 'emerald', openable: 'Y' }),
+    code('계약서 등 재확인', '계약서 등 재확인', 2, { tone: 'amber' }),
+    code('권리처리 추진', '권리처리 추진', 3, { tone: 'blue', needsProcessing: true, koglSet: '보류' }),
+    code('개방불가', '개방불가', 4, { tone: 'red', needsProcessing: false, koglSet: '개방불가' }),
+    code('권리처리 추진 미희망', '권리처리 추진 미희망', 5, {
+      tone: 'slate',
+      needsProcessing: false,
+      koglCopiesPrior: true,
+    }),
+  ],
+  KOGL_TYPE: [
+    code('0유형', '0유형', 1, { scopes: ['review', 'process', 'prior'], firstRow: ['review', 'process'] }),
+    code('1유형', '1유형', 2, { scopes: ['review', 'process', 'prior'], firstRow: [] }),
+    code('2유형', '2유형', 3, { scopes: ['review', 'process', 'prior'], firstRow: [] }),
+    code('3유형', '3유형', 4, { scopes: ['review', 'process', 'prior'], firstRow: [] }),
+    code('4유형', '4유형', 5, { scopes: ['review', 'process', 'prior'], firstRow: [] }),
+    code('개방불가', '개방불가', 6, { scopes: ['review', 'process'], firstRow: ['review', 'process'] }),
+    code('보류', '보류', 7, { scopes: ['review'], firstRow: ['review'] }),
+    code('유형 확인 필요', '유형 확인 필요', 8, { scopes: ['prior'], firstRow: [], surveyOnly: true }),
+    code('제4유형', '제4유형', 9, { scopes: ['prior'], firstRow: [], surveyOnly: true, sameAs: '4유형' }),
+  ],
+  PROCESS_STATUS: [
+    code('미처리', '미처리', 1, { done: false }),
+    code('처리완료', '처리완료', 2, { done: true, stampsProcessedAt: true }),
+  ],
+  CONTRACT_DOC: [
+    code('양도계약서', '양도계약서', 1),
+    code('제안요청서', '제안요청서', 2),
+    code('초상이용동의서', '초상이용동의서', 3),
+    code('공공누리동의서', '공공누리동의서', 4),
+    code('공문', '공문', 5),
+    code('기타', '기타', 6, { freeText: true }),
+  ],
+  CONTACT_METHOD: [
+    code('전화', '전화', 1),
+    code('메일', '메일', 2),
+    code('방문', '방문', 3),
+    code('기타', '기타', 4),
+  ],
+  CONTACT_CATEGORY: [
+    code('APPLICANT', '신청기관', 1, { tone: 'blue', importAlias: '신청기관' }),
+    code('MJ', '문정원', 2, { tone: 'emerald', importAlias: '주관기관' }),
+    code('LAWYER', '변호사', 3, { tone: 'violet', importAlias: '변호사' }),
+    code('OPERATOR', '수행기관', 4, { tone: 'sky', importAlias: '수행기관' }),
+    code('ITN', '아이티앤 담당자', 5, { tone: 'orange', importAlias: '아이티앤 담당자' }),
+  ],
+  ATTACHMENT_YN: [code('있음', '있음', 1), code('없음', '없음', 2)],
+  KOGL_ATTACHED: [code('부착', '부착', 1), code('미부착', '미부착', 2)],
+}
+
+/** 화면 하나를 코드표와 함께 그린다. 서버를 부르지 않는다. */
+export function withCodes(ui: ReactElement, codes: CodeMap = TEST_CODES): ReactElement {
+  return <CodeProvider codes={codes}>{ui}</CodeProvider>
+}
 
frontend/src/codes/messages.ts (added)
+++ frontend/src/codes/messages.ts
@@ -0,0 +1,7 @@
+/**
+ * 화면이 자동으로 채워 넣는 안내 문구. 코드값이 아니라 문장이라 코드표에 넣지 않고
+ * 여기 모아 둔다 - 코드표에 문장을 넣으면 선택지 목록에 섞여 관리가 어려워진다.
+ */
+
+/** 권리처리를 추진하지 않아 기존 공공누리 유형을 그대로 쓸 때 비고에 남기는 문구. */
+export const NOTE_NO_PROCESSING = '권리처리 추진 미희망에 따른 기존 공공누리 부착'
 
frontend/src/codes/stage.ts (added)
+++ frontend/src/codes/stage.ts
@@ -0,0 +1,89 @@
+import { useMemo } from 'react'
+import type { Code } from '../api/client'
+import { useCodes } from './useCodes'
+
+/** 원문자 ①(U+2460)부터 ⑳까지. 그 밖은 번호를 그대로 쓴다. */
+const CIRCLED_BASE = 0x2460
+const CIRCLED_MAX = 20
+
+/**
+ * 진행단계를 다루는 헬퍼. 단계 개수·라벨·구간·진행상태·KPI 임계값이 전부 코드표에서 온다.
+ *
+ * 예전에는 stages.ts에 12개 라벨 배열이 박혀 있어서 단계를 하나 늘리려면 백엔드 4곳,
+ * 프론트 8곳을 같이 고쳐야 했다. 이제 코드표 한 줄이면 된다.
+ */
+export interface Stages {
+  /** 등록된 단계 수. 지금은 13. */
+  count: number
+  /** [1, 2, ... count] */
+  numbers: number[]
+  /** 화면 표시명. 모르는 번호면 번호를 문자열로 돌려준다. */
+  label(stage: number): string
+  /** ①②③… 20을 넘으면 번호 문자열. */
+  symbol(stage: number): string
+  /** 단계가 속한 업무구간 key. 미지정(null)이면 null. */
+  groupKey(stage: number | null): string | null
+  /** 업무구간 목록(정렬순). 칸반 색과 필터가 쓴다. */
+  groups: Code[]
+  /** 업무구간의 tone. 모르면 null. */
+  groupTone(key: string | null): string | null
+  /** 진행상태 - 'applied' | 'active' | 'done'. 단계 미지정이면 null. */
+  progressOf(stage: number | null): string | null
+  /** 이 KPI에 집계하기 시작하는 단계 번호. 지정이 없으면 null. */
+  kpiThreshold(kpi: string): number | null
+  /** 해당 단계가 이 KPI 집계 대상인지(임계값 이상인지). */
+  countsToward(kpi: string, stage: number | null): boolean
+}
+
+export function useStages(): Stages {
+  const stageCodes = useCodes('STAGE')
+  const groups = useCodes('STAGE_GROUP')
+
+  return useMemo(() => {
+    const byNumber = new Map<number, Code>()
+    for (const code of stageCodes) {
+      byNumber.set(Number(code.code), code)
+    }
+    const numbers = stageCodes.map((code) => Number(code.code)).sort((a, b) => a - b)
+
+    function attrOf(stage: number | null, key: string): string | null {
+      if (stage === null) {
+        return null
+      }
+      const value = byNumber.get(stage)?.attrs[key]
+      return value == null ? null : String(value)
+    }
+
+    function kpiThreshold(kpi: string): number | null {
+      for (const [number, code] of byNumber) {
+        if (code.attrs.kpiFrom === kpi) {
+          return number
+        }
+      }
+      return null
+    }
+
+    return {
+      count: numbers.length,
+      numbers,
+      label: (stage) => byNumber.get(stage)?.label ?? String(stage),
+      symbol: (stage) =>
+        stage >= 1 && stage <= CIRCLED_MAX
+          ? String.fromCharCode(CIRCLED_BASE + stage - 1)
+          : String(stage),
+      groupKey: (stage) => attrOf(stage, 'groupKey'),
+      groups,
+      groupTone: (key) => {
+        const found = groups.find((group) => group.code === key)
+        const tone = found?.attrs.tone
+        return tone == null ? null : String(tone)
+      },
+      progressOf: (stage) => attrOf(stage, 'progress'),
+      kpiThreshold,
+      countsToward: (kpi, stage) => {
+        const from = kpiThreshold(kpi)
+        return from !== null && stage !== null && stage >= from
+      },
+    }
+  }, [stageCodes, groups])
+}
 
frontend/src/codes/tone.ts (added)
+++ frontend/src/codes/tone.ts
@@ -0,0 +1,52 @@
+/**
+ * 코드의 tone(의미 토큰)을 실제 Tailwind 클래스로 바꾼다.
+ *
+ * DB에는 'emerald' 같은 이름표만 저장하고 클래스는 여기서 붙인다. DB에 Tailwind 클래스를
+ * 넣으면 화면 스타일을 손볼 때마다 DB를 고쳐야 하기 때문이다.
+ *
+ * 맵을 풀어 쓴 이유는 Tailwind가 `bg-${tone}-50` 같은 조립식 클래스명을 빌드 시점에
+ * 못 찾아 지워버리기 때문이다. 반드시 문자열 전체가 소스에 보여야 한다.
+ */
+
+/** 뱃지: 옅은 배경 + 진한 글씨. 처리결과·담당자 구분이 쓴다. */
+const BADGE: Record<string, string> = {
+  slate: 'bg-slate-100 text-slate-700',
+  gray: 'bg-gray-100 text-gray-700',
+  blue: 'bg-blue-50 text-blue-700',
+  sky: 'bg-sky-50 text-sky-700',
+  indigo: 'bg-indigo-50 text-indigo-700',
+  violet: 'bg-violet-50 text-violet-700',
+  emerald: 'bg-emerald-50 text-emerald-700',
+  amber: 'bg-amber-50 text-amber-700',
+  orange: 'bg-orange-50 text-orange-700',
+  red: 'bg-red-50 text-red-700',
+}
+
+/** 색 띠: 칸반 컬럼 상단에 그리는 한 줄. 업무구간이 쓴다. */
+const BAR: Record<string, string> = {
+  slate: 'bg-slate-400',
+  gray: 'bg-gray-400',
+  blue: 'bg-blue-400',
+  sky: 'bg-sky-400',
+  indigo: 'bg-indigo-400',
+  violet: 'bg-violet-400',
+  emerald: 'bg-emerald-400',
+  amber: 'bg-amber-400',
+  orange: 'bg-orange-400',
+  red: 'bg-red-400',
+}
+
+const BADGE_FALLBACK = 'bg-gray-100 text-gray-700'
+const BAR_FALLBACK = 'bg-gray-300'
+
+/**
+ * 모르는 tone이 와도 화면이 깨지지 않게 회색으로 떨어뜨린다 - 코드 관리 화면에서
+ * 오타가 들어가도 색만 밋밋해질 뿐 항목이 사라지지는 않아야 한다.
+ */
+export function badgeClass(tone: string | null | undefined): string {
+  return (tone && BADGE[tone]) || BADGE_FALLBACK
+}
+
+export function barClass(tone: string | null | undefined): string {
+  return (tone && BAR[tone]) || BAR_FALLBACK
+}
 
frontend/src/codes/useCodes.ts (added)
+++ frontend/src/codes/useCodes.ts
@@ -0,0 +1,69 @@
+import { useCodeMap } from './CodeProvider'
+import type { Code } from '../api/client'
+
+/**
+ * 그룹의 선택지. 서버가 정렬순서대로, 사용 중인 것만 내려준다.
+ * 없는 그룹이면 빈 배열이라 화면이 터지지는 않는다.
+ */
+export function useCodes(groupId: string): Code[] {
+  return useCodeMap()[groupId] ?? []
+}
+
+/**
+ * 쓰이는 자리(scope)로 거른 선택지. 공공누리 유형이 이걸 쓴다 -
+ * 같은 코드체계라도 변호사 판정(review)·권리처리 판정(process)·기존 부착(prior)에
+ * 나오는 목록이 서로 다르다.
+ */
+export function useScopedCodes(groupId: string, scope: string): Code[] {
+  return useCodes(groupId).filter((code) => scopes(code).includes(scope))
+}
+
+/** 라디오 첫 줄에 놓을 것과 둘째 줄에 놓을 것으로 나눈다. */
+export function useSplitByFirstRow(
+  groupId: string,
+  scope: string,
+): { first: Code[]; rest: Code[] } {
+  const codes = useScopedCodes(groupId, scope)
+  return {
+    first: codes.filter((code) => firstRow(code).includes(scope)),
+    rest: codes.filter((code) => !firstRow(code).includes(scope)),
+  }
+}
+
+/**
+ * 저장된 값의 화면 표시명. 코드표에 없는 값이면 값 자체를 그대로 돌려준다 -
+ * 과거 데이터가 화면에서 빈칸으로 사라지는 것보다 원문이라도 보이는 편이 낫다.
+ */
+export function useCodeLabel(groupId: string, code: string | null | undefined): string {
+  const found = useCodes(groupId).find((candidate) => candidate.code === code)
+  return found?.label ?? code ?? ''
+}
+
+/** 코드 한 건. 없으면 undefined. */
+export function useCode(groupId: string, code: string | null | undefined): Code | undefined {
+  return useCodes(groupId).find((candidate) => candidate.code === code)
+}
+
+function stringList(code: Code, key: string): string[] {
+  const raw = code.attrs[key]
+  return Array.isArray(raw) ? (raw as string[]) : []
+}
+
+export function scopes(code: Code): string[] {
+  return stringList(code, 'scopes')
+}
+
+export function firstRow(code: Code): string[] {
+  return stringList(code, 'firstRow')
+}
+
+/** attrs의 문자열 속성. 없으면 null. */
+export function attr(code: Code | undefined, key: string): string | null {
+  const value = code?.attrs[key]
+  return value == null ? null : String(value)
+}
+
+/** attrs의 불리언 속성. 없으면 false. */
+export function flag(code: Code | undefined, key: string): boolean {
+  return code?.attrs[key] === true
+}
frontend/src/components/ContactsPage.test.tsx
--- frontend/src/components/ContactsPage.test.tsx
+++ frontend/src/components/ContactsPage.test.tsx
@@ -1,6 +1,7 @@
 import { fireEvent, render, screen, waitFor } from '@testing-library/react'
 import { beforeEach, describe, expect, it, vi } from 'vitest'
 import ContactsPage from './ContactsPage'
+import { withCodes } from '../codes/fixtures'
 import type { Contact } from '../api/client'
 
 const mocks = vi.hoisted(() => ({
@@ -46,7 +47,7 @@
   it('담당자 목록을 구분·성명 등과 함께 보여준다', async () => {
     mocks.getContacts.mockResolvedValue([contact()])
 
-    render(<ContactsPage />)
+    render(withCodes(<ContactsPage />))
 
     await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
     expect(screen.getByRole('cell', { name: '신청기관' })).toBeTruthy()
@@ -59,7 +60,7 @@
   it('담당자가 없으면 안내 문구를 보여준다', async () => {
     mocks.getContacts.mockResolvedValue([])
 
-    render(<ContactsPage />)
+    render(withCodes(<ContactsPage />))
 
     await waitFor(() => {
       expect(screen.getByText('등록된 담당자가 없습니다.')).toBeTruthy()
@@ -72,7 +73,7 @@
       contact({ id: 2, name: '이변호', category: 'LAWYER', affiliation: null, deptName: null }),
     ])
 
-    render(<ContactsPage />)
+    render(withCodes(<ContactsPage />))
 
     await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
 
@@ -88,7 +89,7 @@
       contact({ id: 2, name: '홍수행', category: 'OPERATOR', affiliation: '수행사', deptName: '운영팀' }),
     ])
 
-    render(<ContactsPage />)
+    render(withCodes(<ContactsPage />))
 
     await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
 
@@ -101,7 +102,7 @@
   it('담당자 추가를 누르면 모달이 뜨고, 구분과 성명을 채워야 저장이 활성화된다', async () => {
     mocks.getContacts.mockResolvedValue([])
 
-    render(<ContactsPage />)
+    render(withCodes(<ContactsPage />))
     await waitFor(() => expect(mocks.getContacts).toHaveBeenCalled())
 
     fireEvent.click(screen.getByRole('button', { name: '담당자 추가' }))
@@ -118,7 +119,7 @@
     mocks.getContacts.mockResolvedValueOnce([]).mockResolvedValueOnce([contact({ name: '홍길동' })])
     mocks.createContact.mockResolvedValue(contact({ name: '홍길동' }))
 
-    render(<ContactsPage />)
+    render(withCodes(<ContactsPage />))
     await waitFor(() => expect(mocks.getContacts).toHaveBeenCalledTimes(1))
 
     fireEvent.click(screen.getByRole('button', { name: '담당자 추가' }))
@@ -145,7 +146,7 @@
     mocks.getContacts.mockResolvedValue([contact()])
     mocks.updateContactInfo.mockResolvedValue(contact({ phone: '02-0000-0000' }))
 
-    render(<ContactsPage />)
+    render(withCodes(<ContactsPage />))
     await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
 
     fireEvent.click(screen.getByRole('button', { name: '수정' }))
@@ -169,7 +170,7 @@
     mocks.deleteContact.mockResolvedValue(undefined)
     const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
 
-    render(<ContactsPage />)
+    render(withCodes(<ContactsPage />))
     await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
 
     fireEvent.click(screen.getByRole('button', { name: '삭제' }))
@@ -186,7 +187,7 @@
     mocks.getContacts.mockResolvedValue([contact()])
     const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
 
-    render(<ContactsPage />)
+    render(withCodes(<ContactsPage />))
     await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
 
     fireEvent.click(screen.getByRole('button', { name: '삭제' }))
@@ -201,7 +202,7 @@
     mocks.getContacts.mockResolvedValue([])
     mocks.importMembers.mockResolvedValue({ created: 2, updated: 1, skipped: 1, assigned: 1 })
 
-    render(<ContactsPage />)
+    render(withCodes(<ContactsPage />))
     await waitFor(() => expect(mocks.getContacts).toHaveBeenCalled())
 
     const file = new File(['dummy'], 'members.xlsx', {
@@ -220,7 +221,7 @@
     mocks.getContacts.mockResolvedValue([])
     mocks.importMembers.mockRejectedValue(new Error('파일 형식이 올바르지 않습니다.'))
 
-    render(<ContactsPage />)
+    render(withCodes(<ContactsPage />))
     await waitFor(() => expect(mocks.getContacts).toHaveBeenCalled())
 
     const file = new File(['dummy'], 'members.xlsx', {
frontend/src/components/ContactsPage.tsx
--- frontend/src/components/ContactsPage.tsx
+++ frontend/src/components/ContactsPage.tsx
@@ -10,32 +10,12 @@
   type ContactInput,
   type MemberImportReport,
 } from '../api/client'
+import { useCodes } from '../codes/useCodes'
+import { badgeClass } from '../codes/tone'
 import HelpButton from '../help/HelpButton'
 
-const CATEGORY_LABELS: Record<ContactCategory, string> = {
-  APPLICANT: '신청기관',
-  MJ: '문정원',
-  LAWYER: '변호사',
-  OPERATOR: '수행기관',
-  ITN: '아이티앤 담당자',
-}
-
-const CATEGORY_BADGE_STYLES: Record<ContactCategory, string> = {
-  APPLICANT: 'bg-blue-50 text-blue-700',
-  MJ: 'bg-emerald-50 text-emerald-700',
-  LAWYER: 'bg-violet-50 text-violet-700',
-  OPERATOR: 'bg-sky-50 text-sky-700',
-  ITN: 'bg-orange-50 text-orange-700',
-}
-
-const FILTERS: { key: ContactCategory | 'ALL'; label: string }[] = [
-  { key: 'ALL', label: '전체' },
-  { key: 'APPLICANT', label: '신청기관' },
-  { key: 'MJ', label: '문정원' },
-  { key: 'LAWYER', label: '변호사' },
-  { key: 'OPERATOR', label: '수행기관' },
-  { key: 'ITN', label: '아이티앤 담당자' },
-]
+// 구분 5종(표시명·뱃지색·필터·드롭다운)은 예전에 이 파일 안에서만 네 번 따로 적혀 있었다.
+// 지금은 전부 코드표(CONTACT_CATEGORY)에서 온다.
 
 function emptyForm(contact: Contact | null): ContactInput {
   return {
@@ -61,6 +41,7 @@
   const [form, setForm] = useState<ContactInput>(emptyForm(editing))
   const [busy, setBusy] = useState(false)
   const [error, setError] = useState<string | null>(null)
+  const categories = useCodes('CONTACT_CATEGORY')
 
   useEffect(() => {
     function onKeyDown(e: KeyboardEvent) {
@@ -125,11 +106,11 @@
               onChange={(e) => setField('category', e.target.value)}
               className="rounded-md border border-gray-300 px-2 py-1.5"
             >
-              <option value="APPLICANT">신청기관</option>
-              <option value="MJ">문정원</option>
-              <option value="LAWYER">변호사</option>
-              <option value="OPERATOR">수행기관</option>
-              <option value="ITN">아이티앤 담당자</option>
+              {categories.map((category) => (
+                <option key={category.code} value={category.code}>
+                  {category.label}
+                </option>
+              ))}
             </select>
           </div>
           <div className="flex flex-col gap-1">
@@ -240,6 +221,20 @@
   const [importReport, setImportReport] = useState<MemberImportReport | null>(null)
   const [importError, setImportError] = useState<string | null>(null)
 
+  const categories = useCodes('CONTACT_CATEGORY')
+  /** 코드표에 없는 구분값이 저장돼 있어도 뱃지가 사라지지 않도록 undefined를 허용한다. */
+  function categoryOf(category: ContactCategory) {
+    return categories.find((candidate) => candidate.code === category)
+  }
+  // '전체'는 코드가 아니라 화면에서만 쓰는 필터 항목이라 코드표에 넣지 않고 여기서 앞에 붙인다.
+  const filters: { key: ContactCategory | 'ALL'; label: string }[] = [
+    { key: 'ALL', label: '전체' },
+    ...categories.map((category) => ({
+      key: category.code as ContactCategory,
+      label: category.label,
+    })),
+  ]
+
   async function load() {
     setLoading(true)
     try {
@@ -307,7 +302,7 @@
     <div className="p-8">
       <div className="flex flex-wrap items-center justify-between gap-3">
         <div className="flex flex-wrap gap-2">
-          {FILTERS.map((f) => (
+          {filters.map((f) => (
             <button
               key={f.key}
               type="button"
@@ -397,9 +392,11 @@
                 <tr key={contact.id} className="border-b border-gray-100 last:border-b-0">
                   <td className="px-3 py-2">
                     <span
-                      className={`rounded-full px-2 py-0.5 text-xs font-medium ${CATEGORY_BADGE_STYLES[contact.category]}`}
+                      className={`rounded-full px-2 py-0.5 text-xs font-medium ${badgeClass(
+                        categoryOf(contact.category)?.attrs.tone as string | undefined,
+                      )}`}
                     >
-                      {CATEGORY_LABELS[contact.category]}
+                      {categoryOf(contact.category)?.label ?? contact.category}
                     </span>
                   </td>
                   <td className="px-3 py-2 font-medium text-gray-900">{contact.name}</td>
frontend/src/components/Dashboard.test.tsx
--- frontend/src/components/Dashboard.test.tsx
+++ frontend/src/components/Dashboard.test.tsx
@@ -1,6 +1,7 @@
 import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
 import { beforeEach, describe, expect, it, vi } from 'vitest'
 import Dashboard from './Dashboard'
+import { withCodes } from '../codes/fixtures'
 import type { DashboardData, DashboardOrgRow } from '../api/client'
 
 const mocks = vi.hoisted(() => ({
@@ -56,7 +57,7 @@
 }
 
 function renderDashboard(onOpenOrg = vi.fn(), onOpenOrgList = vi.fn()) {
-  render(<Dashboard onOpenOrg={onOpenOrg} onOpenOrgList={onOpenOrgList} />)
+  render(withCodes(<Dashboard onOpenOrg={onOpenOrg} onOpenOrgList={onOpenOrgList} />))
   return { onOpenOrg, onOpenOrgList }
 }
 
frontend/src/components/Dashboard.tsx
--- frontend/src/components/Dashboard.tsx
+++ frontend/src/components/Dashboard.tsx
@@ -5,7 +5,8 @@
   type DashboardData,
   type DashboardOrgRow,
 } from '../api/client'
-import { STAGE_GROUPS, STAGE_LABELS, stageGroupKey, stageLabel } from '../stages'
+import { useStages } from '../codes/stage'
+import { barClass } from '../codes/tone'
 import HelpButton from '../help/HelpButton'
 
 /**
@@ -28,16 +29,8 @@
   onOpenOrgList: () => void
 }
 
-const CIRCLED = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩', '⑪', '⑫']
-
-/** 칸반 컬럼 상단 색 띠. 구간별로 같은 색을 쓴다. */
-const GROUP_COLOR: Record<string, string> = {
-  intake: 'bg-slate-400',
-  assign: 'bg-indigo-400',
-  check: 'bg-emerald-400',
-  process: 'bg-amber-400',
-  closing: 'bg-violet-400',
-}
+// 원문자(①②)와 구간 색은 더 이상 여기 박아두지 않는다. 원문자는 단계 번호에서 계산하고
+// 색은 코드표의 tone에서 온다 - 단계가 하나 늘 때마다 배열을 같이 늘려야 했던 자리다.
 
 /** 표/칸반이 한 번에 보여주는 기관 수. [더보기]를 누를 때마다 이만큼 늘어난다. */
 const PAGE_SIZE = 8
@@ -123,8 +116,9 @@
 
 /** 단계 구간에 따라 카드 본문이 달라진다(시안의 컬럼별 카드 모양). */
 function CardBody({ row }: { row: DashboardOrgRow }) {
+  const stages = useStages()
   const stage = columnOf(row)
-  const group = stageGroupKey(stage)
+  const group = stages.groupKey(stage)
   const lawyer = row.lawyerName ?? '변호사 미배정'
 
   if (stage === null) {
@@ -170,20 +164,20 @@
     <div className="space-y-1 text-xs text-gray-500">
       <p>{lawyer}</p>
       <span className="inline-block rounded-full bg-violet-50 px-2 py-0.5 text-[11px] text-violet-700">
-        {CIRCLED[stage - 1]} {stageLabel(stage)}
+        {stages.symbol(stage)} {stages.label(stage)}
       </span>
     </div>
   )
 }
 
 export default function Dashboard({ onOpenOrg, onOpenOrgList }: Props) {
+  const stages = useStages()
   const [data, setData] = useState<DashboardData | null>(null)
   const [error, setError] = useState<string | null>(null)
   const [busy, setBusy] = useState(false)
   const [warning, setWarning] = useState<string | null>(null)
 
-  // 칸반 필터
-  const [groupFilter, setGroupFilter] = useState('')
+  // 칸반 필터 (업무 구간은 요구사항 [6]으로 제거했다)
   const [lawyerFilter, setLawyerFilter] = useState('')
   const [stageFilter, setStageFilter] = useState('')
   const [statusFilter, setStatusFilter] = useState('')
@@ -226,22 +220,22 @@
     const keyword = boardKeyword.trim()
     return rows.filter((row) => {
       const stage = columnOf(row)
-      if (groupFilter && stageGroupKey(stage) !== groupFilter) {
-        return false
-      }
       if (lawyerFilter && row.lawyerName !== lawyerFilter) {
         return false
       }
       if (stageFilter && String(stage ?? '') !== stageFilter) {
         return false
       }
+      // 진행상태는 코드표(STAGE의 progress)에서 온다 - 신청은 ①만, 완료는 ⑬만,
+      // 나머지가 진행중이다. 채널이 없어 단계가 아예 없는 기관은 '단계 미지정'으로 따로 본다.
       if (statusFilter === 'unassigned' && row.hasChannel) {
         return false
       }
-      if (statusFilter === 'active' && (!row.hasChannel || (stage ?? 0) >= 11)) {
-        return false
-      }
-      if (statusFilter === 'done' && (stage ?? 0) < 11) {
+      if (
+        statusFilter &&
+        statusFilter !== 'unassigned' &&
+        (!row.hasChannel || stages.progressOf(stage) !== statusFilter)
+      ) {
         return false
       }
       if (keyword && !row.orgName.includes(keyword) && !row.orgNo.includes(keyword)) {
@@ -249,7 +243,7 @@
       }
       return true
     })
-  }, [rows, groupFilter, lawyerFilter, stageFilter, statusFilter, boardKeyword])
+  }, [rows, lawyerFilter, stageFilter, statusFilter, boardKeyword, stages])
 
   /** 시안의 "정렬 기준 최근 진행순" - 칸반 카드도 최근 변동이 위로 온다. */
   const byRecent = useCallback(
@@ -273,10 +267,13 @@
         if (tableLawyer && row.lawyerName !== tableLawyer) {
           return false
         }
-        if (tableData === 'received' && row.reviewTotal === 0) {
+        // 요구사항 [8]: 권리확인·권리처리를 구분하지 않는다. 어느 쪽이든 한 건이라도
+        // 올라와 있으면 접수로 본다.
+        const received = row.reviewTotal + row.processTotal > 0
+        if (tableData === 'received' && !received) {
           return false
         }
-        if (tableData === 'pending' && row.reviewTotal > 0) {
+        if (tableData === 'pending' && received) {
           return false
         }
         return true
@@ -344,7 +341,8 @@
   }
 
   const s = data.summary
-  const columns: (number | null)[] = [null, ...STAGE_LABELS.map((_, i) => i + 1)]
+  // 첫 칸은 '단계 미지정'(채널 미생성 기관 자리)이고, 나머지는 코드표의 단계 수만큼이다.
+  const columns: (number | null)[] = [null, ...stages.numbers]
 
   return (
     <div className="space-y-4 p-6">
@@ -389,16 +387,21 @@
           <HelpButton topic="dashboard.kanban" />
         </h2>
 
+        {/*
+          요구사항 [6]: 업무 구간 필터는 뺐다. 현재 단계와 기능이 겹친다는 지적이 있었고,
+          순서도 현재 단계를 앞으로 옮겼다. 구간 구분 자체는 칸반 컬럼 색에 계속 쓰이므로
+          코드표에는 그대로 남겨 둔다.
+        */}
         <div className="mt-3 flex flex-wrap gap-2">
           <select
-            aria-label="업무 구간"
-            value={groupFilter}
-            onChange={(e) => setGroupFilter(e.target.value)}
+            aria-label="현재 단계"
+            value={stageFilter}
+            onChange={(e) => setStageFilter(e.target.value)}
             className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
           >
-            <option value="">업무 구간 전체</option>
-            {STAGE_GROUPS.map((g) => (
-              <option key={g.key} value={g.key}>{g.label}</option>
+            <option value="">현재 단계 전체</option>
+            {stages.numbers.map((n) => (
+              <option key={n} value={String(n)}>{stages.symbol(n)} {stages.label(n)}</option>
             ))}
           </select>
           <select
@@ -413,17 +416,6 @@
             ))}
           </select>
           <select
-            aria-label="현재 단계"
-            value={stageFilter}
-            onChange={(e) => setStageFilter(e.target.value)}
-            className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
-          >
-            <option value="">현재 단계 전체</option>
-            {STAGE_LABELS.map((label, i) => (
-              <option key={label} value={String(i + 1)}>{CIRCLED[i]} {label}</option>
-            ))}
-          </select>
-          <select
             aria-label="진행 상태"
             value={statusFilter}
             onChange={(e) => setStatusFilter(e.target.value)}
@@ -431,6 +423,7 @@
           >
             <option value="">진행 상태 전체</option>
             <option value="unassigned">단계 미지정</option>
+            <option value="applied">신청</option>
             <option value="active">진행중</option>
             <option value="done">완료</option>
           </select>
@@ -454,7 +447,7 @@
             const cards = boardRows.filter((row) => columnOf(row) === stage).sort(byRecent)
             const expanded = expandedColumns[key]
             const shown = expanded ? cards : cards.slice(0, COLUMN_SIZE)
-            const color = stage === null ? 'bg-gray-300' : GROUP_COLOR[stageGroupKey(stage) ?? ''] ?? 'bg-gray-300'
+            const color = stage === null ? 'bg-gray-300' : barClass(stages.groupTone(stages.groupKey(stage)))
 
             return (
               <div
@@ -475,7 +468,7 @@
                 <div className={`h-1 rounded-t-lg ${color}`} />
                 <div className="flex items-center justify-between px-3 py-2">
                   <span className="text-sm font-medium">
-                    {stage === null ? '단계 미지정' : `${CIRCLED[stage - 1]} ${stageLabel(stage)}`}
+                    {stage === null ? '단계 미지정' : `${stages.symbol(stage)} ${stages.label(stage)}`}
                   </span>
                   <span className="text-sm text-gray-500 tabular-nums">{cards.length}</span>
                 </div>
@@ -542,8 +535,8 @@
             className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
           >
             <option value="">현재 단계 전체</option>
-            {STAGE_LABELS.map((label, i) => (
-              <option key={label} value={String(i + 1)}>{CIRCLED[i]} {label}</option>
+            {stages.numbers.map((n) => (
+              <option key={n} value={String(n)}>{stages.symbol(n)} {stages.label(n)}</option>
             ))}
           </select>
           <select
@@ -622,7 +615,7 @@
                     <td className="px-2 py-2 font-medium">{row.orgName}</td>
                     <td className="px-2 py-2">
                       <span className="inline-block rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-700">
-                        {stage === null ? '단계 미지정' : `${CIRCLED[stage - 1]} ${stageLabel(stage)}`}
+                        {stage === null ? '단계 미지정' : `${stages.symbol(stage)} ${stages.label(stage)}`}
                       </span>
                     </td>
                     <td className="px-2 py-2 text-gray-600">{row.mjName ?? '-'}</td>
frontend/src/components/OrgOverview.test.tsx
--- frontend/src/components/OrgOverview.test.tsx
+++ frontend/src/components/OrgOverview.test.tsx
@@ -1,6 +1,7 @@
 import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
 import { beforeEach, describe, expect, it, vi } from 'vitest'
 import OrgOverview from './OrgOverview'
+import { withCodes } from '../codes/fixtures'
 import type { Contact, Org } from '../api/client'
 
 const mocks = vi.hoisted(() => ({
@@ -14,6 +15,7 @@
   getStageHistory: vi.fn(),
   getReviewPage: vi.fn(),
   getProcessPage: vi.fn(),
+  getReMemos: vi.fn(),
 }))
 
 vi.mock('../api/client', async (importOriginal) => ({
@@ -28,6 +30,7 @@
   getStageHistory: mocks.getStageHistory,
   getReviewPage: mocks.getReviewPage,
   getProcessPage: mocks.getProcessPage,
+  getReMemos: mocks.getReMemos,
 }))
 
 function contact(overrides: Partial<Contact> = {}): Contact {
@@ -86,7 +89,7 @@
 
 describe('OrgOverview', () => {
   it('연번_기관명 헤더와 신청기관 담당자 정보를 보여준다', async () => {
-    render(<OrgOverview org={org()} />)
+    render(withCodes(<OrgOverview org={org()} />))
 
     expect(screen.getByText('001_00_국제방송교류재단')).toBeTruthy()
     expect(screen.getByText('데이터정보화팀')).toBeTruthy()
@@ -97,7 +100,7 @@
   })
 
   it('문정원 담당자와 아이티앤 담당자와 담당 변호사 정보가 비어 있으면 카드에 -로 표시된다', async () => {
-    render(<OrgOverview org={org()} />)
+    render(withCodes(<OrgOverview org={org()} />))
 
     const mjCard = screen.getByText('문정원 담당자').closest('section') as HTMLElement
     const itnCard = screen.getByText('아이티앤 담당자').closest('section') as HTMLElement
@@ -111,6 +114,7 @@
 
   it('담당자 4종이 값을 가지고 있으면 각 카드에 값이 표시된다', async () => {
     render(
+      withCodes(
       <OrgOverview
         org={org({
           mj: contact({
@@ -139,6 +143,7 @@
           lawyerAssignedDate: '2026-07-21',
         })}
       />,
+      ),
     )
 
     const applicantCard = screen.getByText('신청기관 담당자').closest('section') as HTMLElement
@@ -168,7 +173,7 @@
   })
 
   it('배정된 카드에는 변경, 빈 카드에는 생성 버튼이 보인다', async () => {
-    render(<OrgOverview org={org()} />)
+    render(withCodes(<OrgOverview org={org()} />))
 
     expect(screen.getByRole('button', { name: '신청기관 담당자 변경' })).toBeTruthy()
     expect(screen.getByRole('button', { name: '문정원 담당자 생성' })).toBeTruthy()
@@ -183,7 +188,7 @@
     ])
     const onChanged = vi.fn()
 
-    render(<OrgOverview org={org()} onChanged={onChanged} />)
+    render(withCodes(<OrgOverview org={org()} onChanged={onChanged} />))
 
     fireEvent.click(screen.getByRole('button', { name: '문정원 담당자 생성' }))
 
@@ -208,7 +213,7 @@
     ])
     const onChanged = vi.fn()
 
-    render(<OrgOverview org={org()} onChanged={onChanged} />)
+    render(withCodes(<OrgOverview org={org()} onChanged={onChanged} />))
 
     fireEvent.click(screen.getByRole('button', { name: '아이티앤 담당자 생성' }))
 
@@ -228,7 +233,7 @@
   })
 
   it('상단 동작 버튼에서 담당 변호사 변경은 사라졌다', async () => {
-    render(<OrgOverview org={org()} />)
+    render(withCodes(<OrgOverview org={org()} />))
 
     expect(screen.queryByRole('button', { name: '담당 변호사 변경' })).toBeNull()
     expect(screen.getByRole('button', { name: '진행단계 변경' })).not.toBeDisabled()
@@ -242,7 +247,7 @@
       { stage: 1, changedAt: new Date('2026-07-15T09:00:00').getTime() },
     ])
 
-    render(<OrgOverview org={org({ stage: 3 })} />)
+    render(withCodes(<OrgOverview org={org({ stage: 3 })} />))
 
     await waitFor(() => {
       expect(screen.getByText('2026-07-24 10:00')).toBeTruthy()
@@ -252,7 +257,7 @@
   })
 
   it('업무단계 12개가 순서대로 보인다', async () => {
-    render(<OrgOverview org={org()} />)
+    render(withCodes(<OrgOverview org={org()} />))
 
     expect(screen.getByText('신청')).toBeTruthy()
     expect(screen.getByText('변호사 배당')).toBeTruthy()
@@ -261,14 +266,14 @@
   })
 
   it('기본 탭인 채팅은 자리표시 대신 채널 선택 화면을 보여준다', async () => {
-    render(<OrgOverview org={org()} />)
+    render(withCodes(<OrgOverview org={org()} />))
 
     expect(screen.getByRole('button', { name: '문정원' })).toBeTruthy()
     await waitFor(() => expect(mocks.getPosts).toHaveBeenCalledWith(1, 'mj'))
   })
 
   it('권리확인 탭을 누르면 권리확인 보드가 마운트되어 조회를 부른다', async () => {
-    render(<OrgOverview org={org()} />)
+    render(withCodes(<OrgOverview org={org()} />))
 
     await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
 
@@ -279,19 +284,23 @@
     expect(screen.queryByRole('button', { name: '문정원' })).toBeNull()
   })
 
-  it('탭을 누르면 해당 탭의 자리표시 내용으로 바뀐다', async () => {
-    render(<OrgOverview org={org()} />)
+  // 요구사항 [4][5]로 RE 탭에 실제 화면(메모장)이 생겼다. 자리표시가 아니다.
+  it('RE 탭을 누르면 메모 입력 화면이 나온다', async () => {
+    mocks.getReMemos.mockResolvedValue([])
+    render(withCodes(<OrgOverview org={org()} />))
 
     await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
 
     fireEvent.click(screen.getByRole('button', { name: 'RE' }))
 
-    expect(screen.getByText('RE 상세 화면은 추후 확정')).toBeTruthy()
+    await waitFor(() => expect(mocks.getReMemos).toHaveBeenCalledWith(1))
+    expect(screen.getByLabelText('RE 내용')).toBeTruthy()
+    expect(screen.getByLabelText('작성자')).toBeTruthy()
     expect(screen.queryByRole('button', { name: '문정원' })).toBeNull()
   })
 
   it('권리처리 탭을 누르면 권리처리 보드가 마운트되어 조회를 부른다', async () => {
-    render(<OrgOverview org={org()} />)
+    render(withCodes(<OrgOverview org={org()} />))
 
     await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
 
@@ -306,7 +315,7 @@
     mocks.getReviewPage.mockResolvedValue({ items: [], total: 12, done: 5, page: 0, size: 1 })
     mocks.getProcessPage.mockResolvedValue({ items: [], total: 8, done: 3, page: 0, size: 1 })
 
-    render(<OrgOverview org={org()} />)
+    render(withCodes(<OrgOverview org={org()} />))
 
     await waitFor(() => {
       expect(screen.getByText('완료 5 / 전체 12')).toBeTruthy()
@@ -315,7 +324,7 @@
   })
 
   it('타임라인 탭을 누르면 Timeline이 마운트되어 조회를 부른다', async () => {
-    render(<OrgOverview org={org()} />)
+    render(withCodes(<OrgOverview org={org()} />))
 
     await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
 
@@ -326,7 +335,7 @@
   })
 
   it('자료 탭을 누르면 자료 목록 화면으로 바뀐다', async () => {
-    render(<OrgOverview org={org()} />)
+    render(withCodes(<OrgOverview org={org()} />))
 
     fireEvent.click(screen.getByRole('button', { name: '자료' }))
 
@@ -335,7 +344,7 @@
   })
 
   it('채널이 있으면 Mattermost 열기가 채널 링크로 활성화된다', async () => {
-    render(<OrgOverview org={org()} />)
+    render(withCodes(<OrgOverview org={org()} />))
 
     const link = screen.getByRole('link', { name: 'Mattermost 열기' })
     expect(link).toHaveAttribute(
@@ -346,7 +355,7 @@
   })
 
   it('채널이 없으면 Mattermost 열기가 비활성이다', () => {
-    render(<OrgOverview org={org({ channelIdMj: null, channelIdLaw: null, status: 'READY' })} />)
+    render(withCodes(<OrgOverview org={org({ channelIdMj: null, channelIdLaw: null, status: 'READY' })} />))
 
     expect(screen.queryByRole('link', { name: 'Mattermost 열기' })).toBeNull()
     expect(screen.getByRole('button', { name: 'Mattermost 열기' })).toBeDisabled()
@@ -354,9 +363,11 @@
 
   it('치안정책연구소도 합성코드 슬러그로 채널 링크를 만든다', async () => {
     render(
-      <OrgOverview
-        org={org({ orgNo: '008_01', orgName: '경찰청_치안정책연구소', channelIdMj: 'x' })}
-      />,
+      withCodes(
+        <OrgOverview
+          org={org({ orgNo: '008_01', orgName: '경찰청_치안정책연구소', channelIdMj: 'x' })}
+        />,
+      ),
     )
 
     expect(screen.getByRole('link', { name: 'Mattermost 열기' })).toHaveAttribute(
@@ -367,7 +378,7 @@
   })
 
   it('미구현 동작 버튼들은 비활성이다', async () => {
-    render(<OrgOverview org={org()} />)
+    render(withCodes(<OrgOverview org={org()} />))
 
     expect(screen.getByRole('button', { name: '자료 업로드' })).toBeDisabled()
     expect(screen.getByRole('button', { name: '보고서 관리' })).toBeDisabled()
@@ -378,14 +389,14 @@
 
   describe('업무단계 현황 스트립', () => {
     it('채널이 없어 단계가 시작되지 않았으면 전부 회색이고 안내 문구가 보인다', async () => {
-      render(<OrgOverview org={org({ stage: null, channelIdMj: null, channelIdLaw: null, status: 'READY' })} />)
+      render(withCodes(<OrgOverview org={org({ stage: null, channelIdMj: null, channelIdLaw: null, status: 'READY' })} />))
 
       expect(screen.getByText('채널 생성 시 신청 단계가 시작됩니다')).toBeTruthy()
       expect(screen.queryByText('진행중')).toBeNull()
     })
 
     it('현재 단계 이전은 완료(체크) 표시, 현재 단계는 진행중, 이후는 미래 상태로 보인다', async () => {
-      render(<OrgOverview org={org({ stage: 3 })} />)
+      render(withCodes(<OrgOverview org={org({ stage: 3 })} />))
 
       const doneButton = screen.getByText('목록접수').closest('button') as HTMLElement
       const currentButton = screen.getByText('예비검토').closest('button') as HTMLElement
@@ -405,7 +416,7 @@
       const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
       const onChanged = vi.fn()
 
-      render(<OrgOverview org={org({ stage: 4 })} onChanged={onChanged} />)
+      render(withCodes(<OrgOverview org={org({ stage: 4 })} onChanged={onChanged} />))
 
       const currentButton = screen.getByText('변호사 배당').closest('button') as HTMLElement
       fireEvent.click(currentButton)
@@ -422,7 +433,7 @@
     it('확인을 취소하면 진행단계 변경 API를 부르지 않는다', async () => {
       const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
 
-      render(<OrgOverview org={org({ stage: 4 })} />)
+      render(withCodes(<OrgOverview org={org({ stage: 4 })} />))
 
       fireEvent.click(screen.getByText('변호사 배당').closest('button') as HTMLElement)
 
@@ -435,7 +446,7 @@
     it('현재 단계가 아닌 다른 단계를 클릭해도 아무 일도 일어나지 않는다', async () => {
       const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
 
-      render(<OrgOverview org={org({ stage: 4 })} />)
+      render(withCodes(<OrgOverview org={org({ stage: 4 })} />))
 
       fireEvent.click(screen.getByText('예비검토').closest('button') as HTMLElement)
       fireEvent.click(screen.getByText('RE:확인').closest('button') as HTMLElement)
@@ -446,12 +457,14 @@
       confirmSpy.mockRestore()
     })
 
-    it('마지막 단계(12)가 현재 단계이면 클릭해도 아무 일도 일어나지 않는다', async () => {
+    // 요구사항 [11]로 13단계(보고서 제출 완료)가 생겨 마지막 단계가 13으로 내려갔다.
+    // 12단계는 이제 마지막이 아니라서 다음 단계로 넘어갈 수 있다.
+    it('마지막 단계(13)가 현재 단계이면 클릭해도 아무 일도 일어나지 않는다', async () => {
       const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
 
-      render(<OrgOverview org={org({ stage: 12 })} />)
+      render(withCodes(<OrgOverview org={org({ stage: 13 })} />))
 
-      const lastButton = screen.getByText('보고서 작성').closest('button') as HTMLElement
+      const lastButton = screen.getByText('보고서 제출 완료').closest('button') as HTMLElement
       expect(lastButton).toBeDisabled()
       fireEvent.click(lastButton)
 
@@ -467,7 +480,7 @@
       const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
       const onChanged = vi.fn()
 
-      render(<OrgOverview org={org({ stage: 2 })} onChanged={onChanged} />)
+      render(withCodes(<OrgOverview org={org({ stage: 2 })} onChanged={onChanged} />))
 
       fireEvent.click(screen.getByRole('button', { name: '진행단계 변경' }))
 
@@ -487,7 +500,7 @@
     })
 
     it('닫기를 누르면 모달이 사라진다', async () => {
-      render(<OrgOverview org={org({ stage: 2 })} />)
+      render(withCodes(<OrgOverview org={org({ stage: 2 })} />))
 
       fireEvent.click(screen.getByRole('button', { name: '진행단계 변경' }))
       expect(screen.getByRole('heading', { name: '진행단계 변경' })).toBeTruthy()
frontend/src/components/OrgOverview.tsx
--- frontend/src/components/OrgOverview.tsx
+++ frontend/src/components/OrgOverview.tsx
@@ -11,12 +11,13 @@
   type Org,
   type PostView,
 } from '../api/client'
-import { STAGE_LABELS, stageLabel } from '../stages'
+import { useStages } from '../codes/stage'
 import HelpButton from '../help/HelpButton'
 import ChannelFiles from './ChannelFiles'
 import ChannelPosts from './ChannelPosts'
 import ContactPickerModal from './ContactPickerModal'
 import ProcessBoard from './ProcessBoard'
+import ReMemos from './ReMemos'
 import ReviewBoard from './ReviewBoard'
 import StatusBadge from './StatusBadge'
 import Timeline from './Timeline'
@@ -28,7 +29,7 @@
 
 const TABS = ['채팅', '자료', '권리확인', '권리처리', 'RE', '타임라인', '업무메모']
 
-/** 탭마다 그 탭 기능의 설명서를 띄운다. RE는 화면이 아직 없어 도움말도 없다. */
+/** 탭마다 그 탭 기능의 설명서를 띄운다. */
 const TAB_HELP: Record<string, string> = {
   '채팅': 'org.chat',
   '자료': 'org.files',
@@ -135,6 +136,7 @@
   org: Org
   onChanged?: () => void
 }) {
+  const stages = useStages()
   const [activeTab, setActiveTab] = useState(TABS[0])
   const [pickerRole, setPickerRole] = useState<'applicant' | 'mj' | 'itn' | 'lawyer' | null>(null)
   const [assignBusy, setAssignBusy] = useState(false)
@@ -273,18 +275,18 @@
 
   /** 업무단계 현황 스트립에서 "현재 단계"를 클릭했을 때만 호출된다 - 다음 단계로 1칸 전진. */
   async function advanceStage() {
-    if (org.stage === null || org.stage >= 12 || stageBusy) {
+    if (org.stage === null || org.stage >= stages.count || stageBusy) {
       return
     }
     const next = org.stage + 1
-    if (!window.confirm(`다음 단계 "${next}. ${stageLabel(next)}"(으)로 진행할까요?`)) {
+    if (!window.confirm(`다음 단계 "${next}. ${stages.label(next)}"(으)로 진행할까요?`)) {
       return
     }
     await changeStage(next)
   }
 
   async function pickStage(next: number) {
-    if (!window.confirm(`"${next}. ${stageLabel(next)}"(으)로 진행단계를 변경할까요?`)) {
+    if (!window.confirm(`"${next}. ${stages.label(next)}"(으)로 진행단계를 변경할까요?`)) {
       return
     }
     setStagePickerOpen(false)
@@ -439,18 +441,18 @@
             <HelpButton topic="org.stage" />
           </h2>
           <ol className="mt-3 flex gap-2 overflow-x-auto pb-2">
-            {STAGE_LABELS.map((step, i) => {
-              const n = i + 1
+            {stages.numbers.map((n) => {
+              const step = stages.label(n)
               const isDone = org.stage !== null && n < org.stage
               const isCurrent = org.stage !== null && n === org.stage
-              const clickable = isCurrent && n < 12
+              const clickable = isCurrent && n < stages.count
 
               return (
-                <li key={step} className="shrink-0">
+                <li key={n} className="shrink-0">
                   <button
                     type="button"
                     disabled={!clickable || stageBusy}
-                    title={isCurrent && n >= 12 ? '마지막 단계입니다' : undefined}
+                    title={isCurrent && n >= stages.count ? '마지막 단계입니다' : undefined}
                     onClick={() => {
                       if (isCurrent) {
                         void advanceStage()
@@ -594,6 +596,10 @@
           <div className="mt-4 rounded-lg border border-gray-200">
             <Timeline key={org.id} org={org} />
           </div>
+        ) : activeTab === 'RE' ? (
+          <div className="mt-4 rounded-lg border border-gray-200">
+            <ReMemos key={org.id} org={org} />
+          </div>
         ) : (
           <div className="mt-4 rounded-lg border border-dashed border-gray-200 p-10 text-center text-sm text-gray-400">
             {activeTab} 상세 화면은 추후 확정
@@ -697,6 +703,7 @@
   onPick: (stage: number) => void
   onClose: () => void
 }) {
+  const stages = useStages()
   return (
     <div className="fixed inset-0 flex items-center justify-center bg-black/30">
       <div className="w-96 rounded-lg bg-white p-5">
@@ -713,11 +720,11 @@
         </div>
 
         <ul className="mt-3 max-h-80 space-y-1 overflow-y-auto">
-          {STAGE_LABELS.map((label, i) => {
-            const n = i + 1
+          {stages.numbers.map((n) => {
+            const label = stages.label(n)
             const isCurrent = currentStage === n
             return (
-              <li key={label}>
+              <li key={n}>
                 <button
                   type="button"
                   disabled={busy}
frontend/src/components/ProcessBoard.test.tsx
--- frontend/src/components/ProcessBoard.test.tsx
+++ frontend/src/components/ProcessBoard.test.tsx
@@ -1,6 +1,7 @@
 import { fireEvent, render, screen, waitFor } from '@testing-library/react'
 import { beforeEach, describe, expect, it, vi } from 'vitest'
 import ProcessBoard from './ProcessBoard'
+import { withCodes } from '../codes/fixtures'
 import type { Org, ProcessItem, ProcessPage } from '../api/client'
 
 const mocks = vi.hoisted(() => ({
@@ -104,7 +105,7 @@
   it('목록을 표로 보여주고 처리상태 배지가 나온다', async () => {
     mocks.getProcessPage.mockResolvedValue(page())
 
-    render(<ProcessBoard org={org()} />)
+    render(withCodes(<ProcessBoard org={org()} />))
 
     await waitFor(() => {
       expect(screen.getByText('2026년 사업 공고')).toBeTruthy()
@@ -117,7 +118,7 @@
   it('자료가 없으면 안내 문구를 보여준다', async () => {
     mocks.getProcessPage.mockResolvedValue(page({ items: [], total: 0 }))
 
-    render(<ProcessBoard org={org()} />)
+    render(withCodes(<ProcessBoard org={org()} />))
 
     await waitFor(() => {
       expect(
@@ -129,7 +130,7 @@
   it('상태 칩을 누르면 status 파라미터로 다시 조회한다', async () => {
     mocks.getProcessPage.mockResolvedValue(page())
 
-    render(<ProcessBoard org={org()} />)
+    render(withCodes(<ProcessBoard org={org()} />))
 
     await waitFor(() =>
       expect(mocks.getProcessPage).toHaveBeenCalledWith(1, {
@@ -166,7 +167,7 @@
   it('검색어를 입력하고 검색을 누르면 keyword로 다시 조회한다', async () => {
     mocks.getProcessPage.mockResolvedValue(page())
 
-    render(<ProcessBoard org={org()} />)
+    render(withCodes(<ProcessBoard org={org()} />))
 
     await waitFor(() => expect(mocks.getProcessPage).toHaveBeenCalledTimes(1))
 
@@ -187,7 +188,7 @@
     mocks.getProcessPage.mockResolvedValue(page())
     mocks.importProcess.mockResolvedValue({ created: 3, updated: 2, total: 5 })
 
-    const { container } = render(<ProcessBoard org={org()} />)
+    const { container } = render(withCodes(<ProcessBoard org={org()} />))
 
     await waitFor(() => expect(mocks.getProcessPage).toHaveBeenCalledTimes(1))
 
@@ -206,7 +207,7 @@
   it('처리완료 건은 수정 버튼이, 미처리 건은 처리등록 버튼만 보인다', async () => {
     mocks.getProcessPage.mockResolvedValue(page({ items: [item({ processStatus: '처리완료' })] }))
 
-    render(<ProcessBoard org={org()} />)
+    render(withCodes(<ProcessBoard org={org()} />))
 
     await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
     expect(screen.getByRole('button', { name: '수정' })).toBeTruthy()
@@ -220,7 +221,7 @@
     mocks.bulkDeleteProcess.mockResolvedValue({ deleted: 1 })
     const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
 
-    render(<ProcessBoard org={org()} />)
+    render(withCodes(<ProcessBoard org={org()} />))
 
     await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
 
@@ -237,7 +238,7 @@
     mocks.getProcessPage.mockResolvedValue(page())
     mocks.bulkUpdateProcess.mockResolvedValue({ updated: 1 })
 
-    render(<ProcessBoard org={org()} />)
+    render(withCodes(<ProcessBoard org={org()} />))
     await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
 
     fireEvent.click(screen.getByLabelText('1번 선택'))
@@ -259,7 +260,7 @@
   it('상세검색 3칸은 열별 조건으로 함께 넘어간다', async () => {
     mocks.getProcessPage.mockResolvedValue(page())
 
-    render(<ProcessBoard org={org()} />)
+    render(withCodes(<ProcessBoard org={org()} />))
     await waitFor(() => expect(mocks.getProcessPage).toHaveBeenCalled())
 
     fireEvent.click(screen.getByRole('button', { name: '상세검색' }))
@@ -284,7 +285,7 @@
   it('다음 페이지 버튼을 누르면 page+1로 다시 조회한다', async () => {
     mocks.getProcessPage.mockResolvedValue(page({ total: 31 }))
 
-    render(<ProcessBoard org={org()} />)
+    render(withCodes(<ProcessBoard org={org()} />))
 
     await waitFor(() =>
       expect(mocks.getProcessPage).toHaveBeenCalledWith(1, {
@@ -313,7 +314,7 @@
     mocks.getProcessPage.mockResolvedValue(page())
     mocks.getProcessItem.mockResolvedValue(item({ finalOpinion: '검토 완료' }))
 
-    render(<ProcessBoard org={org()} />)
+    render(withCodes(<ProcessBoard org={org()} />))
 
     await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
     fireEvent.click(screen.getByRole('button', { name: '처리등록' }))
@@ -328,7 +329,7 @@
     mocks.getProcessPage.mockResolvedValue(page())
     mocks.getProcessItem.mockResolvedValue(item())
 
-    render(<ProcessBoard org={org()} />)
+    render(withCodes(<ProcessBoard org={org()} />))
 
     await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
     fireEvent.click(screen.getByRole('button', { name: '처리등록' }))
@@ -346,7 +347,7 @@
     mocks.getProcessItem.mockResolvedValue(item())
     mocks.updateProcessing.mockResolvedValue(item({ processStatus: '처리완료' }))
 
-    render(<ProcessBoard org={org()} />)
+    render(withCodes(<ProcessBoard org={org()} />))
 
     await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
     fireEvent.click(screen.getByRole('button', { name: '처리등록' }))
@@ -380,7 +381,7 @@
       item({ contractDocs: '양도계약서, 기타:구두 동의' }),
     )
 
-    render(<ProcessBoard org={org()} />)
+    render(withCodes(<ProcessBoard org={org()} />))
 
     await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
     fireEvent.click(screen.getByRole('button', { name: '처리등록' }))
@@ -395,7 +396,7 @@
     mocks.getProcessPage.mockResolvedValue(page())
     mocks.getProcessItem.mockResolvedValue(item())
 
-    render(<ProcessBoard org={org()} />)
+    render(withCodes(<ProcessBoard org={org()} />))
 
     await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
     fireEvent.click(screen.getByRole('button', { name: '처리등록' }))
@@ -413,7 +414,7 @@
     mocks.deleteProcessItem.mockResolvedValue(undefined)
     const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
 
-    render(<ProcessBoard org={org()} />)
+    render(withCodes(<ProcessBoard org={org()} />))
 
     await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
     fireEvent.click(screen.getByRole('button', { name: '처리등록' }))
frontend/src/components/ProcessBoard.tsx
--- frontend/src/components/ProcessBoard.tsx
+++ frontend/src/components/ProcessBoard.tsx
@@ -11,41 +11,47 @@
   type Org,
   type ProcessImportReport,
   type ProcessItem,
+  type Code,
   type ProcessStatusFilter,
   type ProcessingRequest,
 } from '../api/client'
+import { useCodes, useSplitByFirstRow } from '../codes/useCodes'
 
 const PAGE_SIZE = 30
 
-const STATUS_CHIPS: { key: ProcessStatusFilter | undefined; label: string }[] = [
-  { key: undefined, label: '전체' },
-  { key: 'PENDING', label: '미처리' },
-  { key: 'DONE', label: '처리완료' },
-]
+/** 공공누리 유형이 쓰이는 자리. 권리처리 판정에는 '보류'가 없다. */
+const SCOPE_PROCESS = 'process'
 
-const CONTRACT_DOC_OPTIONS = ['양도계약서', '제안요청서', '초상이용동의서', '공공누리동의서', '공문']
-const CONTRACT_ETC = '기타'
+/** 자유 입력이 붙는 계약서류('기타'). 저장값이 "기타:본문" 형태가 된다. */
+function etcDocOf(docs: Code[]): Code | undefined {
+  return docs.find((doc) => doc.attrs.freeText === true)
+}
 
-const JUDGED_KOGL_TYPE_OPTIONS = ['0유형', '개방불가', '1유형', '2유형', '3유형', '4유형']
-
-const PROCESS_STATUS_OPTIONS = ['미처리', '처리완료']
+/** '처리완료'에 해당하는 코드. 처리일시를 찍고 배지를 초록으로 바꾸는 기준이다. */
+function doneStatusOf(statuses: Code[]): Code | undefined {
+  return statuses.find((status) => status.attrs.done === true)
+}
 
 /** "양도계약서, 공문, 기타:직접 촬영본 사용동의" 형태의 저장값 → 체크박스 상태로 역파싱한다. */
-function parseContractDocs(value: string | null): { selected: Set<string>; etcText: string } {
+function parseContractDocs(
+  value: string | null,
+  docs: Code[],
+): { selected: Set<string>; etcText: string } {
   const selected = new Set<string>()
   let etcText = ''
   if (!value) {
     return { selected, etcText }
   }
+  const etc = etcDocOf(docs)
   for (const raw of value.split(',')) {
     const part = raw.trim()
     if (!part) {
       continue
     }
-    if (part.startsWith(`${CONTRACT_ETC}:`)) {
-      selected.add(CONTRACT_ETC)
-      etcText = part.slice(CONTRACT_ETC.length + 1)
-    } else if (CONTRACT_DOC_OPTIONS.includes(part)) {
+    if (etc && part.startsWith(`${etc.code}:`)) {
+      selected.add(etc.code)
+      etcText = part.slice(etc.code.length + 1)
+    } else if (docs.some((doc) => doc.code === part)) {
       selected.add(part)
     }
   }
@@ -53,10 +59,17 @@
 }
 
 /** 체크박스 상태 → 저장값. 아무것도 선택하지 않았으면 null. */
-function serializeContractDocs(selected: Set<string>, etcText: string): string | null {
-  const parts: string[] = CONTRACT_DOC_OPTIONS.filter((opt) => selected.has(opt))
-  if (selected.has(CONTRACT_ETC)) {
-    parts.push(`${CONTRACT_ETC}:${etcText}`)
+function serializeContractDocs(
+  selected: Set<string>,
+  etcText: string,
+  docs: Code[],
+): string | null {
+  const etc = etcDocOf(docs)
+  const parts: string[] = docs
+    .filter((doc) => doc.attrs.freeText !== true && selected.has(doc.code))
+    .map((doc) => doc.code)
+  if (etc && selected.has(etc.code)) {
+    parts.push(`${etc.code}:${etcText}`)
   }
   return parts.length > 0 ? parts.join(', ') : null
 }
@@ -154,20 +167,37 @@
 }
 
 function ProcessStatusBadge({ value }: { value: string | null }) {
-  const done = value === '처리완료'
+  // 어느 값이 '완료'인지는 코드표의 done 속성이 정한다.
+  const statuses = useCodes('PROCESS_STATUS')
+  const doneCode = doneStatusOf(statuses)
+  const done = !!doneCode && value === doneCode.code
+  const pendingLabel = statuses.find((status) => status.attrs.done !== true)?.label ?? ''
   return (
     <span
       className={`rounded-full px-2 py-0.5 text-xs font-medium ${
         done ? 'bg-emerald-50 text-emerald-700' : 'bg-gray-100 text-gray-500'
       }`}
     >
-      {done ? '처리완료' : '미처리'}
+      {done ? doneCode.label : pendingLabel}
     </span>
   )
 }
 
 /** 기관관리 상세의 권리처리 탭. 목록↔상세 두 화면을 이 컴포넌트 하나에서 전환한다(ReviewBoard와 동일 구조). */
 export default function ProcessBoard({ org }: { org: Org }) {
+  const statuses = useCodes('PROCESS_STATUS')
+  const doneStatusCode = doneStatusOf(statuses)?.code
+  const koglProcess = useSplitByFirstRow('KOGL_TYPE', SCOPE_PROCESS)
+  // 필터 칩의 '전체'는 코드가 아니라 화면에서만 쓰는 항목이라 앞에 붙인다.
+  // 나머지는 코드표에서 오되, 서버에 넘기는 키(PENDING/DONE)는 done 속성으로 정한다.
+  const statusChips: { key: ProcessStatusFilter | undefined; label: string }[] = [
+    { key: undefined, label: '전체' },
+    ...statuses.map((status) => ({
+      key: (status.attrs.done === true ? 'DONE' : 'PENDING') as ProcessStatusFilter,
+      label: status.label,
+    })),
+  ]
+
   const [mode, setMode] = useState<'list' | number>('list')
 
   const [items, setItems] = useState<ProcessItem[]>([])
@@ -188,6 +218,8 @@
   const [bulkStatus, setBulkStatus] = useState('')
   const [bulkKoglType, setBulkKoglType] = useState('')
   const [bulkOpinion, setBulkOpinion] = useState('')
+  /** 요구사항 [15]로 추가된 비고 일괄 입력. */
+  const [bulkNote, setBulkNote] = useState('')
   const [bulkBusy, setBulkBusy] = useState(false)
   const [bulkMessage, setBulkMessage] = useState<string | null>(null)
   const [loading, setLoading] = useState(false)
@@ -295,7 +327,7 @@
     if (selected.length === 0 || bulkBusy) {
       return
     }
-    if (!bulkStatus && !bulkKoglType && !bulkOpinion.trim()) {
+    if (!bulkStatus && !bulkKoglType && !bulkOpinion.trim() && !bulkNote.trim()) {
       setBulkMessage('반영할 값을 하나 이상 채워주세요.')
       return
     }
@@ -307,12 +339,14 @@
         processStatus: bulkStatus || undefined,
         judgedKoglType: bulkKoglType || undefined,
         finalOpinion: bulkOpinion.trim() || undefined,
+        reviewNote: bulkNote.trim() || undefined,
       })
       setBulkMessage(`${res.updated}건에 반영했습니다.`)
       setSelected([])
       setBulkStatus('')
       setBulkKoglType('')
       setBulkOpinion('')
+      setBulkNote('')
       setReloadKey((k) => k + 1)
     } catch (e) {
       setBulkMessage(e instanceof Error ? e.message : '일괄 등록에 실패했습니다.')
@@ -363,7 +397,7 @@
       <div className="flex flex-wrap items-center justify-between gap-3">
         <div className="flex flex-wrap items-center gap-2">
           <div className="flex items-center gap-1" role="group" aria-label="처리상태 필터">
-            {STATUS_CHIPS.map((chip) => (
+            {statusChips.map((chip) => (
               <button
                 key={chip.label}
                 type="button"
@@ -510,8 +544,8 @@
           className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
         >
           <option value="">공공누리 유형 그대로</option>
-          {JUDGED_KOGL_TYPE_OPTIONS.map((option) => (
-            <option key={option} value={option}>{option}</option>
+          {[...koglProcess.first, ...koglProcess.rest].map((option) => (
+            <option key={option.code} value={option.code}>{option.label}</option>
           ))}
         </select>
         <input
@@ -520,6 +554,14 @@
           value={bulkOpinion}
           onChange={(e) => setBulkOpinion(e.target.value)}
           placeholder="최종의견(비우면 유지)"
+          className="w-56 rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+        />
+        <input
+          type="text"
+          aria-label="일괄 비고"
+          value={bulkNote}
+          onChange={(e) => setBulkNote(e.target.value)}
+          placeholder="비고(비우면 유지)"
           className="w-56 rounded-md border border-gray-300 px-2 py-1.5 text-sm"
         />
         <button
@@ -644,11 +686,11 @@
                     <ProcessStatusBadge value={item.processStatus} />
                   </td>
                   <td className="px-3 py-2 text-gray-600">
-                    {item.processStatus === '처리완료' ? formatDate(item.processedAt) : '-'}
+                    {item.processStatus === doneStatusCode ? formatDate(item.processedAt) : '-'}
                   </td>
                   <td className="px-3 py-2">
                     <div className="flex gap-2">
-                      {item.processStatus === '처리완료' ? (
+                      {item.processStatus === doneStatusCode ? (
                         <>
                           <button
                             type="button"
@@ -713,6 +755,12 @@
   onBack: () => void
   onSaved: () => void
 }) {
+  const contractDocs = useCodes('CONTRACT_DOC')
+  const etcDoc = etcDocOf(contractDocs)
+  const statuses = useCodes('PROCESS_STATUS')
+  // 권리처리 판정에는 '보류'가 없다 - 변호사 판정과 목록이 다르다.
+  const kogl = useSplitByFirstRow('KOGL_TYPE', SCOPE_PROCESS)
+
   const [item, setItem] = useState<ProcessItem | null>(null)
   const [loading, setLoading] = useState(true)
   const [loadError, setLoadError] = useState<string | null>(null)
@@ -739,7 +787,7 @@
           return
         }
         setItem(data)
-        const { selected, etcText } = parseContractDocs(data.contractDocs)
+        const { selected, etcText } = parseContractDocs(data.contractDocs, contractDocs)
         setContractSelected(selected)
         setContractEtc(etcText)
         setJudgedKoglType(data.judgedKoglType ?? '')
@@ -784,7 +832,7 @@
     setSaveError(null)
     try {
       const body: ProcessingRequest = {
-        contractDocs: serializeContractDocs(contractSelected, contractEtc),
+        contractDocs: serializeContractDocs(contractSelected, contractEtc, contractDocs),
         judgedKoglType: judgedKoglType || null,
         judgedAiType: judgedAiType ? 'Y' : null,
         finalOpinion: finalOpinion || null,
@@ -897,26 +945,31 @@
           <InfoRow label="계약서 유무">
             <div className="flex flex-col gap-1.5">
               <div className="flex flex-wrap gap-x-4 gap-y-1">
-                {CONTRACT_DOC_OPTIONS.map((option) => (
-                  <label key={option} className="flex items-center gap-1.5 text-sm text-gray-800">
+                {/* '기타'는 자유 입력이 딸려 있어 뒤에 따로 그린다. */}
+                {contractDocs
+                  .filter((option) => option.attrs.freeText !== true)
+                  .map((option) => (
+                    <label key={option.code} className="flex items-center gap-1.5 text-sm text-gray-800">
+                      <input
+                        type="checkbox"
+                        checked={contractSelected.has(option.code)}
+                        onChange={() => toggleContractOption(option.code)}
+                      />
+                      {option.label}
+                    </label>
+                  ))}
+                {etcDoc && (
+                  <label className="flex items-center gap-1.5 text-sm text-gray-800">
                     <input
                       type="checkbox"
-                      checked={contractSelected.has(option)}
-                      onChange={() => toggleContractOption(option)}
+                      checked={contractSelected.has(etcDoc.code)}
+                      onChange={() => toggleContractOption(etcDoc.code)}
                     />
-                    {option}
+                    {etcDoc.label}
                   </label>
-                ))}
-                <label className="flex items-center gap-1.5 text-sm text-gray-800">
-                  <input
-                    type="checkbox"
-                    checked={contractSelected.has(CONTRACT_ETC)}
-                    onChange={() => toggleContractOption(CONTRACT_ETC)}
-                  />
-                  {CONTRACT_ETC}
-                </label>
+                )}
               </div>
-              {contractSelected.has(CONTRACT_ETC) && (
+              {etcDoc && contractSelected.has(etcDoc.code) && (
                 <input
                   type="text"
                   aria-label="기타 계약서 내용"
@@ -932,28 +985,28 @@
           <InfoRow label="공공누리 유형">
             <div className="flex flex-col gap-1.5">
               <div className="flex flex-wrap gap-x-4 gap-y-1">
-                {JUDGED_KOGL_TYPE_OPTIONS.filter((o) => o === '0유형' || o === '개방불가').map((option) => (
-                  <label key={option} className="flex items-center gap-1.5 text-sm text-gray-800">
+                {kogl.first.map((option) => (
+                  <label key={option.code} className="flex items-center gap-1.5 text-sm text-gray-800">
                     <input
                       type="radio"
                       name="judged-kogl-type"
-                      checked={judgedKoglType === option}
-                      onChange={() => setJudgedKoglType(option)}
+                      checked={judgedKoglType === option.code}
+                      onChange={() => setJudgedKoglType(option.code)}
                     />
-                    {option}
+                    {option.label}
                   </label>
                 ))}
               </div>
               <div className="flex flex-wrap items-center gap-x-4 gap-y-1">
-                {JUDGED_KOGL_TYPE_OPTIONS.filter((o) => o !== '0유형' && o !== '개방불가').map((option) => (
-                  <label key={option} className="flex items-center gap-1.5 text-sm text-gray-800">
+                {kogl.rest.map((option) => (
+                  <label key={option.code} className="flex items-center gap-1.5 text-sm text-gray-800">
                     <input
                       type="radio"
                       name="judged-kogl-type"
-                      checked={judgedKoglType === option}
-                      onChange={() => setJudgedKoglType(option)}
+                      checked={judgedKoglType === option.code}
+                      onChange={() => setJudgedKoglType(option.code)}
                     />
-                    {option}
+                    {option.label}
                   </label>
                 ))}
                 <label className="ml-2 flex items-center gap-1.5 text-sm text-gray-800">
@@ -1017,9 +1070,9 @@
               className="w-40 rounded-md border border-gray-300 px-2 py-1.5 text-sm"
             >
               <option value="">선택</option>
-              {PROCESS_STATUS_OPTIONS.map((option) => (
-                <option key={option} value={option}>
-                  {option}
+              {statuses.map((option) => (
+                <option key={option.code} value={option.code}>
+                  {option.label}
                 </option>
               ))}
             </select>
frontend/src/components/ProjectsPage.test.tsx
--- frontend/src/components/ProjectsPage.test.tsx
+++ frontend/src/components/ProjectsPage.test.tsx
@@ -1,6 +1,7 @@
 import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
 import { beforeEach, describe, expect, it, vi } from 'vitest'
 import ProjectsPage from './ProjectsPage'
+import { withCodes } from '../codes/fixtures'
 import type { ContactLog, DashboardOrgRow, OrgReport, ProjectRow } from '../api/client'
 
 const mocks = vi.hoisted(() => ({
@@ -77,7 +78,7 @@
 }
 
 async function openPanel() {
-  render(<ProjectsPage onOpenOrg={vi.fn()} />)
+  render(withCodes(<ProjectsPage onOpenOrg={vi.fn()} />))
   await screen.findByTestId('project-table')
   fireEvent.click(screen.getByRole('button', { name: '연락·보고서' }))
   await waitFor(() => expect(mocks.getContactLogs).toHaveBeenCalledWith(1))
@@ -95,7 +96,7 @@
     mocks.getProjects.mockResolvedValue([
       row({ lastContactedOn: '2026-07-20', contactCount: 3, reportCount: 1 }),
     ])
-    render(<ProjectsPage onOpenOrg={vi.fn()} />)
+    render(withCodes(<ProjectsPage onOpenOrg={vi.fn()} />))
 
     const table = within(await screen.findByTestId('project-table'))
     expect(table.getByText('국제방송교류재단')).toBeTruthy()
@@ -106,7 +107,7 @@
 
   it('기관명을 클릭하면 기관관리로 넘긴다', async () => {
     const onOpenOrg = vi.fn()
-    render(<ProjectsPage onOpenOrg={onOpenOrg} />)
+    render(withCodes(<ProjectsPage onOpenOrg={onOpenOrg} />))
 
     fireEvent.click(await screen.findByRole('button', { name: '국제방송교류재단' }))
     expect(onOpenOrg).toHaveBeenCalledWith(1)
@@ -117,7 +118,7 @@
       row(),
       row({ org: orgRow({ id: 2, orgNo: '002_00', orgName: '세종학당재단' }), contactCount: 2 }),
     ])
-    render(<ProjectsPage onOpenOrg={vi.fn()} />)
+    render(withCodes(<ProjectsPage onOpenOrg={vi.fn()} />))
     await screen.findByTestId('project-table')
 
     fireEvent.change(screen.getByLabelText('기관명/기관코드 검색'), { target: { value: '세종' } })
frontend/src/components/ProjectsPage.tsx
--- frontend/src/components/ProjectsPage.tsx
+++ frontend/src/components/ProjectsPage.tsx
@@ -13,7 +13,8 @@
   type OrgReport,
   type ProjectRow,
 } from '../api/client'
-import { stageLabel } from '../stages'
+import { useStages } from '../codes/stage'
+import { useCodes } from '../codes/useCodes'
 import HelpButton from '../help/HelpButton'
 
 /**
@@ -28,8 +29,6 @@
   /** 기관명을 클릭했을 때 기관관리 화면으로 넘긴다. */
   onOpenOrg: (orgId: number) => void
 }
-
-const METHODS = ['전화', '메일', '방문', '기타']
 
 function formatDateTime(ms: number): string {
   const d = new Date(ms)
@@ -55,6 +54,11 @@
 }
 
 export default function ProjectsPage({ onOpenOrg }: Props) {
+  // 단계 목록과 연락 방법은 코드표에서 온다. 단계는 예전에 12로 박혀 있어서
+  // 13단계를 늘리면 이 필터만 12개로 남는 문제가 있었다.
+  const stages = useStages()
+  const methods = useCodes('CONTACT_METHOD')
+
   const [rows, setRows] = useState<ProjectRow[] | null>(null)
   const [error, setError] = useState<string | null>(null)
   const [keyword, setKeyword] = useState('')
@@ -69,7 +73,7 @@
   const [panelError, setPanelError] = useState<string | null>(null)
 
   const [logDate, setLogDate] = useState(today())
-  const [logMethod, setLogMethod] = useState(METHODS[0])
+  const [logMethod, setLogMethod] = useState(methods[0]?.code ?? '')
   const [logSummary, setLogSummary] = useState('')
   const [editingLogId, setEditingLogId] = useState<number | null>(null)
 
@@ -237,8 +241,8 @@
             className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
           >
             <option value="">현재 단계 전체</option>
-            {Array.from({ length: 12 }, (_, i) => i + 1).map((n) => (
-              <option key={n} value={String(n)}>{`${n}. ${stageLabel(n)}`}</option>
+            {stages.numbers.map((n) => (
+              <option key={n} value={String(n)}>{`${n}. ${stages.label(n)}`}</option>
             ))}
           </select>
           <select
@@ -296,7 +300,9 @@
                       </button>
                     </td>
                     <td className="px-2 py-2 text-gray-600">
-                      {row.org.stage === null ? '단계 미지정' : `${row.org.stage}. ${stageLabel(row.org.stage)}`}
+                      {row.org.stage === null
+                        ? '단계 미지정'
+                        : `${row.org.stage}. ${stages.label(row.org.stage)}`}
                     </td>
                     <td className="px-2 py-2 text-gray-600">{row.org.mjName ?? '-'}</td>
                     <td className="px-2 py-2 text-gray-600">{row.org.lawyerName ?? '-'}</td>
@@ -384,8 +390,10 @@
                       onChange={(e) => setLogMethod(e.target.value)}
                       className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
                     >
-                      {METHODS.map((m) => (
-                        <option key={m} value={m}>{m}</option>
+                      {methods.map((m) => (
+                        <option key={m.code} value={m.code}>
+                          {m.label}
+                        </option>
                       ))}
                     </select>
                   </label>
 
frontend/src/components/ReMemos.tsx (added)
+++ frontend/src/components/ReMemos.tsx
@@ -0,0 +1,231 @@
+import { useCallback, useEffect, useState } from 'react'
+import {
+  addReMemo,
+  deleteReMemo,
+  getReMemos,
+  updateReMemo,
+  type Org,
+  type ReMemo,
+} from '../api/client'
+
+/**
+ * 기관관리 상세의 RE 탭.
+ *
+ * 요구사항 [4]: 별도 양식 없이 메모장처럼 등록한다. 메모 1개가 RE 1개다.
+ *   예) ooo 계약서 및 제안요청서 송부 요청, 장영익, 2026.07.08
+ *
+ * 완료 체크는 회신에 없던 항목이다. 대시보드 '미해결 RE' 숫자를 세려면 해결된 건과 아닌 건을
+ * 가릴 수단이 필요해 한 칸 두었다.
+ */
+function today(): string {
+  const d = new Date()
+  const pad = (n: number) => String(n).padStart(2, '0')
+  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
+}
+
+export default function ReMemos({ org }: { org: Org }) {
+  const [memos, setMemos] = useState<ReMemo[]>([])
+  const [loading, setLoading] = useState(true)
+  const [error, setError] = useState<string | null>(null)
+  const [busy, setBusy] = useState(false)
+
+  const [content, setContent] = useState('')
+  const [author, setAuthor] = useState('')
+  const [memoDate, setMemoDate] = useState(today())
+  const [editingId, setEditingId] = useState<number | null>(null)
+
+  const load = useCallback(async () => {
+    setLoading(true)
+    try {
+      setMemos(await getReMemos(org.id))
+      setError(null)
+    } catch (e) {
+      setError((e as Error).message)
+    } finally {
+      setLoading(false)
+    }
+  }, [org.id])
+
+  useEffect(() => {
+    void load()
+  }, [load])
+
+  function resetForm() {
+    setContent('')
+    setAuthor('')
+    setMemoDate(today())
+    setEditingId(null)
+  }
+
+  async function handleSave() {
+    if (!content.trim() || !author.trim() || busy) {
+      return
+    }
+    setBusy(true)
+    setError(null)
+    try {
+      const body = { content: content.trim(), author: author.trim(), memoDate, resolved: false }
+      const next = editingId === null
+        ? await addReMemo(org.id, body)
+        : await updateReMemo(org.id, editingId, {
+            ...body,
+            resolved: memos.find((m) => m.id === editingId)?.resolved ?? false,
+          })
+      setMemos(next)
+      resetForm()
+    } catch (e) {
+      setError((e as Error).message)
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  async function toggleResolved(memo: ReMemo) {
+    setBusy(true)
+    try {
+      setMemos(await updateReMemo(org.id, memo.id, {
+        content: memo.content,
+        author: memo.author,
+        memoDate: memo.memoDate,
+        resolved: !memo.resolved,
+      }))
+    } catch (e) {
+      setError((e as Error).message)
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  async function handleDelete(memo: ReMemo) {
+    if (!window.confirm('이 RE를 지울까요?')) {
+      return
+    }
+    setBusy(true)
+    try {
+      await deleteReMemo(org.id, memo.id)
+      await load()
+      if (editingId === memo.id) {
+        resetForm()
+      }
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  function startEdit(memo: ReMemo) {
+    setEditingId(memo.id)
+    setContent(memo.content)
+    setAuthor(memo.author)
+    setMemoDate(memo.memoDate)
+  }
+
+  const unresolved = memos.filter((m) => !m.resolved).length
+
+  return (
+    <div className="p-5">
+      <div className="flex items-center justify-between">
+        <h3 className="text-sm font-semibold">
+          RE <span className="ml-1 text-xs font-normal text-gray-500">미해결 {unresolved}건 / 전체 {memos.length}건</span>
+        </h3>
+      </div>
+
+      <div className="mt-3 rounded-lg border border-gray-200 p-3">
+        <textarea
+          aria-label="RE 내용"
+          value={content}
+          onChange={(e) => setContent(e.target.value)}
+          rows={2}
+          placeholder="예) ooo 계약서 및 제안요청서 송부 요청"
+          className="w-full rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+        />
+        <div className="mt-2 flex flex-wrap items-center gap-2">
+          <input
+            aria-label="작성자"
+            value={author}
+            onChange={(e) => setAuthor(e.target.value)}
+            placeholder="작성자"
+            className="w-32 rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+          />
+          <input
+            type="date"
+            aria-label="일자"
+            value={memoDate}
+            onChange={(e) => setMemoDate(e.target.value)}
+            className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+          />
+          <button
+            type="button"
+            disabled={!content.trim() || !author.trim() || busy}
+            onClick={() => void handleSave()}
+            className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white disabled:bg-gray-300"
+          >
+            {editingId === null ? '등록' : '수정'}
+          </button>
+          {editingId !== null && (
+            <button
+              type="button"
+              onClick={resetForm}
+              className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600"
+            >
+              취소
+            </button>
+          )}
+        </div>
+      </div>
+
+      {error && <p className="mt-2 text-sm text-red-600">{error}</p>}
+
+      {loading ? (
+        <p className="mt-4 text-sm text-gray-500">불러오는 중…</p>
+      ) : memos.length === 0 ? (
+        <p className="mt-4 text-sm text-gray-500">등록된 RE가 없습니다.</p>
+      ) : (
+        <ul className="mt-4 space-y-2">
+          {memos.map((memo) => (
+            <li
+              key={memo.id}
+              className={`rounded-lg border p-3 ${
+                memo.resolved ? 'border-gray-200 bg-gray-50' : 'border-amber-200 bg-amber-50/40'
+              }`}
+            >
+              <div className="flex items-start justify-between gap-3">
+                <p className={`whitespace-pre-wrap text-sm ${memo.resolved ? 'text-gray-400 line-through' : 'text-gray-900'}`}>
+                  {memo.content}
+                </p>
+                <div className="flex shrink-0 gap-2">
+                  <button
+                    type="button"
+                    disabled={busy}
+                    onClick={() => void toggleResolved(memo)}
+                    className="rounded-md border border-gray-300 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50"
+                  >
+                    {memo.resolved ? '미해결로' : '해결'}
+                  </button>
+                  <button
+                    type="button"
+                    disabled={busy}
+                    onClick={() => startEdit(memo)}
+                    className="rounded-md border border-gray-300 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50"
+                  >
+                    수정
+                  </button>
+                  <button
+                    type="button"
+                    disabled={busy}
+                    onClick={() => void handleDelete(memo)}
+                    className="rounded-md border border-gray-300 px-2 py-0.5 text-xs text-red-600 hover:bg-red-50"
+                  >
+                    삭제
+                  </button>
+                </div>
+              </div>
+              <p className="mt-1.5 text-xs text-gray-500">
+                {memo.author} · {memo.memoDate}
+              </p>
+            </li>
+          ))}
+        </ul>
+      )}
+    </div>
+  )
+}
frontend/src/components/ReviewBoard.test.tsx
--- frontend/src/components/ReviewBoard.test.tsx
+++ frontend/src/components/ReviewBoard.test.tsx
@@ -1,6 +1,7 @@
 import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
 import { beforeEach, describe, expect, it, vi } from 'vitest'
 import ReviewBoard from './ReviewBoard'
+import { withCodes } from '../codes/fixtures'
 import type { Org, ReviewItem, ReviewPage } from '../api/client'
 
 const mocks = vi.hoisted(() => ({
@@ -102,7 +103,7 @@
   it('목록을 표로 보여주고 처리결과 배지가 나온다', async () => {
     mocks.getReviewPage.mockResolvedValue(page())
 
-    render(<ReviewBoard org={org()} />)
+    render(withCodes(<ReviewBoard org={org()} />))
 
     await waitFor(() => {
       expect(screen.getByText('2026년 사업 공고')).toBeTruthy()
@@ -116,7 +117,7 @@
   it('자료가 없으면 안내 문구를 보여준다', async () => {
     mocks.getReviewPage.mockResolvedValue(page({ items: [], total: 0 }))
 
-    render(<ReviewBoard org={org()} />)
+    render(withCodes(<ReviewBoard org={org()} />))
 
     await waitFor(() => {
       expect(
@@ -128,7 +129,7 @@
   it('검색어를 입력하고 검색을 누르면 keyword로 다시 조회한다', async () => {
     mocks.getReviewPage.mockResolvedValue(page())
 
-    render(<ReviewBoard org={org()} />)
+    render(withCodes(<ReviewBoard org={org()} />))
 
     await waitFor(() =>
       expect(mocks.getReviewPage).toHaveBeenCalledWith(1, { keyword: undefined, page: 0, size: 30 }),
@@ -146,7 +147,7 @@
     mocks.getReviewPage.mockResolvedValue(page())
     mocks.importReview.mockResolvedValue({ created: 3, updated: 2, total: 5 })
 
-    const { container } = render(<ReviewBoard org={org()} />)
+    const { container } = render(withCodes(<ReviewBoard org={org()} />))
 
     await waitFor(() => expect(mocks.getReviewPage).toHaveBeenCalledTimes(1))
 
@@ -167,7 +168,7 @@
     mocks.bulkDeleteReview.mockResolvedValue({ deleted: 1 })
     const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
 
-    render(<ReviewBoard org={org()} />)
+    render(withCodes(<ReviewBoard org={org()} />))
 
     await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
 
@@ -186,7 +187,7 @@
   it('전체 선택 체크박스로 페이지의 모든 행을 고른다', async () => {
     mocks.getReviewPage.mockResolvedValue(page())
 
-    render(<ReviewBoard org={org()} />)
+    render(withCodes(<ReviewBoard org={org()} />))
     await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
 
     fireEvent.click(screen.getByLabelText('전체 선택'))
@@ -201,7 +202,7 @@
     mocks.getReviewPage.mockResolvedValue(page())
     mocks.bulkUpdateReview.mockResolvedValue({ updated: 1 })
 
-    render(<ReviewBoard org={org()} />)
+    render(withCodes(<ReviewBoard org={org()} />))
     await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
 
     fireEvent.click(screen.getByLabelText('1번 선택'))
@@ -223,7 +224,7 @@
   it('아무 값도 채우지 않고 일괄 등록하면 안내만 하고 호출하지 않는다', async () => {
     mocks.getReviewPage.mockResolvedValue(page())
 
-    render(<ReviewBoard org={org()} />)
+    render(withCodes(<ReviewBoard org={org()} />))
     await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
 
     fireEvent.click(screen.getByLabelText('1번 선택'))
@@ -236,7 +237,7 @@
   it('상세검색 3칸은 열별 조건으로 함께 넘어간다', async () => {
     mocks.getReviewPage.mockResolvedValue(page())
 
-    render(<ReviewBoard org={org()} />)
+    render(withCodes(<ReviewBoard org={org()} />))
     await waitFor(() => expect(mocks.getReviewPage).toHaveBeenCalled())
 
     fireEvent.click(screen.getByRole('button', { name: '상세검색' }))
@@ -260,7 +261,7 @@
   it('다음 페이지 버튼을 누르면 page+1로 다시 조회한다', async () => {
     mocks.getReviewPage.mockResolvedValue(page({ total: 31 }))
 
-    render(<ReviewBoard org={org()} />)
+    render(withCodes(<ReviewBoard org={org()} />))
 
     await waitFor(() =>
       expect(mocks.getReviewPage).toHaveBeenCalledWith(1, { keyword: undefined, page: 0, size: 30 }),
@@ -281,7 +282,7 @@
       item({ reviewMajor: '만료 저작물', opinion: '검토 완료' }),
     )
 
-    render(<ReviewBoard org={org()} />)
+    render(withCodes(<ReviewBoard org={org()} />))
 
     await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
     fireEvent.click(screen.getByRole('button', { name: '수정' }))
@@ -297,7 +298,7 @@
     mocks.getReviewPage.mockResolvedValue(page())
     mocks.getReviewItem.mockResolvedValue(item({ reviewMajor: null, reviewResult: null }))
 
-    render(<ReviewBoard org={org()} />)
+    render(withCodes(<ReviewBoard org={org()} />))
 
     await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
     fireEvent.click(screen.getByRole('button', { name: '수정' }))
@@ -322,7 +323,7 @@
       mocks.getReviewItem.mockResolvedValue(
         item({ reviewMajor: null, reviewMinor: null, reviewResult: null, judgedKoglType: null, ...itemOverrides }),
       )
-      render(<ReviewBoard org={org()} />)
+      render(withCodes(<ReviewBoard org={org()} />))
       await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
       fireEvent.click(screen.getByRole('button', { name: '수정' }))
       await waitFor(() => expect(mocks.getReviewItem).toHaveBeenCalled())
@@ -400,7 +401,7 @@
     mocks.getReviewItem.mockResolvedValue(item({ reviewMajor: null, reviewResult: null }))
     mocks.updateReviewJudgment.mockResolvedValue(item({ reviewResult: '신유형 개방' }))
 
-    render(<ReviewBoard org={org()} />)
+    render(withCodes(<ReviewBoard org={org()} />))
 
     await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
     fireEvent.click(screen.getByRole('button', { name: '수정' }))
@@ -436,7 +437,7 @@
     mocks.getReviewPage.mockResolvedValue(page())
     mocks.getReviewItem.mockResolvedValue(item())
 
-    render(<ReviewBoard org={org()} />)
+    render(withCodes(<ReviewBoard org={org()} />))
 
     await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
     fireEvent.click(screen.getByRole('button', { name: '수정' }))
frontend/src/components/ReviewBoard.tsx
--- frontend/src/components/ReviewBoard.tsx
+++ frontend/src/components/ReviewBoard.tsx
@@ -10,102 +10,50 @@
   type JudgmentRequest,
   type Org,
   type ReviewImportReport,
+  type Code,
   type ReviewItem,
 } from '../api/client'
+import { NOTE_NO_PROCESSING } from '../codes/messages'
+import { badgeClass } from '../codes/tone'
+import { attr, useCodes, useScopedCodes, useSplitByFirstRow } from '../codes/useCodes'
 
 const PAGE_SIZE = 30
 
-const REVIEW_MAJOR_OPTIONS = ['만료 저작물', '업무상 저작물', '제3자 권리', '개인정보 포함']
+/** 공공누리 유형이 쓰이는 자리. 변호사 판정과 기존 부착 유형은 고를 수 있는 목록이 다르다. */
+const SCOPE_REVIEW = 'review'
+const SCOPE_PRIOR = 'prior'
 
-const REVIEW_MINOR_OPTIONS = [
-  '1. 원시적 권리 전부 보유',
-  '2. 후천적 권리 전부 보유(계약서 확인 필요)',
-  '3. 권리 일부(공동) 보유',
-  '4. 권리 미보유',
-  '초상권 포함',
-]
+// 대분류·세부·처리결과·공공누리 유형은 모두 코드표에서 온다.
+// 판정 매트릭스는 종이 양식과 같은 자리에 칸을 놓아야 해서 목록을 순서대로 훑지 않고
+// majors[0], minors[1] 처럼 자리로 집는다. 순서는 코드표의 정렬순서가 정한다.
 
 /**
- * 처리결과 선택지. 앞 4개의 순서는 판정 매트릭스의 행 배치와 맞물려 있으므로 바꾸지 말고,
- * 새 항목은 뒤에 붙인다. '권리처리 추진 미희망'은 제3자 권리 정책변경으로 추가된 항목이다.
+ * 대분류/세부 선택에 따른 처리결과 추천값. 매칭되는 조합이 없으면 null(현재 값 유지).
+ *
+ * 예전에는 대분류·세부 이름을 직접 비교하는 if 사슬이었다. 지금은 코드의 suggest 속성만
+ * 읽는다 - 추천값이 바뀌어도 코드표만 고치면 된다.
  */
-const REVIEW_RESULT_OPTIONS = [
-  '신유형 개방',
-  '계약서 등 재확인',
-  '권리처리 추진',
-  '개방불가',
-  '권리처리 추진 미희망',
-]
-
-const RESULT_PROCESSING = '권리처리 추진'
-const RESULT_NO_PROCESSING = '권리처리 추진 미희망'
-const RESULT_BLOCKED = '개방불가'
-
-/** 권리처리를 추진하는 동안에는 공공누리 유형을 확정하지 않고 '보류'로 둔다(정책변경으로 신설). */
-const KOGL_ON_HOLD = '보류'
-
-/** 권리처리를 추진하지 않아 기존 공공누리 유형을 그대로 쓸 때 비고에 남기는 문구. */
-const NOTE_NO_PROCESSING = '권리처리 추진 미희망에 따른 기존 공공누리 부착'
-
-const JUDGED_KOGL_TYPE_OPTIONS = ['0유형', '1유형', '2유형', '3유형', '4유형', '개방불가', KOGL_ON_HOLD]
-
-/** 공공누리 유형 라디오 첫 줄에 놓는 항목(나머지는 둘째 줄). */
-const KOGL_FIRST_ROW = ['0유형', RESULT_BLOCKED, KOGL_ON_HOLD]
-
-/**
- * 제3자 권리 세부항목별로 고를 수 있는 처리결과. 정책변경(2026-07-20 검토안)에 따라
- * 3·4번은 추진/미희망 택일, 초상권은 개방불가까지 3택이다. 여기 없는 세부항목은 제한이 없다.
- */
-const ALLOWED_RESULTS_BY_MINOR: Record<string, string[]> = {
-  '3. 권리 일부(공동) 보유': [RESULT_PROCESSING, RESULT_NO_PROCESSING],
-  '4. 권리 미보유': [RESULT_PROCESSING, RESULT_NO_PROCESSING],
-  '초상권 포함': [RESULT_PROCESSING, RESULT_NO_PROCESSING, RESULT_BLOCKED],
+function suggestReviewResult(
+  major: string,
+  minor: string,
+  majors: Code[],
+  minors: Code[],
+): string | null {
+  const majorCode = majors.find((candidate) => candidate.code === major)
+  if (!majorCode) {
+    return null
+  }
+  // 세부항목을 쓰는 대분류(제3자 권리)는 세부에서 추천값이 나온다.
+  if (majorCode.attrs.hasMinor === true) {
+    return attr(minors.find((candidate) => candidate.code === minor), 'suggest')
+  }
+  return attr(majorCode, 'suggest')
 }
 
-const REVIEW_RESULT_BADGE_STYLES: Record<string, string> = {
-  '신유형 개방': 'bg-emerald-50 text-emerald-700',
-  '계약서 등 재확인': 'bg-amber-50 text-amber-700',
-  '권리처리 추진': 'bg-blue-50 text-blue-700',
-  '권리처리 추진 미희망': 'bg-slate-100 text-slate-700',
-  개방불가: 'bg-red-50 text-red-700',
+/** 세부항목의 회색 부연설명. 없으면 undefined. */
+function noteOf(code: Code | undefined): string | undefined {
+  return attr(code, 'note') ?? undefined
 }
-
-/** 대분류/세부 선택에 따른 처리결과 추천값. 매칭되는 조합이 없으면 null(현재 값 유지). */
-function suggestReviewResult(major: string, minor: string): string | null {
-  if (major === '만료 저작물' || major === '업무상 저작물') {
-    return '신유형 개방'
-  }
-  if (major === '개인정보 포함') {
-    return '개방불가'
-  }
-  if (major === '제3자 권리') {
-    if (minor === '1. 원시적 권리 전부 보유') {
-      return '신유형 개방'
-    }
-    if (minor === '2. 후천적 권리 전부 보유(계약서 확인 필요)') {
-      return '계약서 등 재확인'
-    }
-    if (ALLOWED_RESULTS_BY_MINOR[minor]) {
-      // 정책변경: 추진/미희망(초상권은 개방불가까지) 중 택일이며 기본값은 '권리처리 추진'이다.
-      return RESULT_PROCESSING
-    }
-  }
-  return null
-}
-
-/** 세부(review-minor) 라디오의 실제 저장값은 REVIEW_MINOR_OPTIONS 그대로 유지하고,
- * 참조 양식과 같은 화면 표기(줄바꿈된 부연설명)만 별도로 매핑한다. */
-const REVIEW_MINOR_DISPLAY: { value: string; label: string; note?: string }[] = [
-  { value: REVIEW_MINOR_OPTIONS[0], label: '1. 원시적 권리 전부 보유' },
-  { value: REVIEW_MINOR_OPTIONS[1], label: '2. 후천적 권리 전부 보유', note: '(계약에 의한 전부 양수)' },
-  {
-    value: REVIEW_MINOR_OPTIONS[2],
-    label: '3. 권리 일부(공동) 보유',
-    note: '(보도자료·제3자저작물, 계약에 의한 전부/공동 보유 등)',
-  },
-  { value: REVIEW_MINOR_OPTIONS[3], label: '4. 권리 미보유', note: '(공모전 수상작 등)' },
-  { value: REVIEW_MINOR_OPTIONS[4], label: '초상권 포함' },
-]
 
 /** 소제목: 작은 파란 사각 불릿 + 굵은 제목 + 회색 부연설명. */
 function SectionHeader({ title, note }: { title: string; note?: string }) {
@@ -212,22 +160,25 @@
 }
 
 function ReviewResultBadge({ value }: { value: string | null }) {
+  // 뱃지 색은 코드의 tone에서 온다. 코드표에 없는 값이면 badgeClass가 회색으로 떨어뜨린다.
+  const results = useCodes('REVIEW_RESULT')
   if (!value) {
     return (
       <span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-500">미판정</span>
     )
   }
+  const tone = attr(results.find((candidate) => candidate.code === value), 'tone')
   return (
-    <span
-      className={`rounded-full px-2 py-0.5 text-xs font-medium ${REVIEW_RESULT_BADGE_STYLES[value] ?? 'bg-gray-100 text-gray-600'}`}
-    >
-      {value}
-    </span>
+    <span className={`rounded-full px-2 py-0.5 text-xs font-medium ${badgeClass(tone)}`}>{value}</span>
   )
 }
 
 /** 기관관리 상세의 권리확인 탭. 목록↔상세 두 화면을 이 컴포넌트 하나에서 전환한다. */
 export default function ReviewBoard({ org }: { org: Org }) {
+  // 일괄 등록 드롭다운이 쓰는 선택지. 변호사가 고르는 자리라 review scope다.
+  const results = useCodes('REVIEW_RESULT')
+  const koglReview = useScopedCodes('KOGL_TYPE', SCOPE_REVIEW)
+
   const [mode, setMode] = useState<'list' | number>('list')
 
   const [items, setItems] = useState<ReviewItem[]>([])
@@ -251,6 +202,8 @@
   const [bulkResult, setBulkResult] = useState('')
   const [bulkKoglType, setBulkKoglType] = useState('')
   const [bulkOpinion, setBulkOpinion] = useState('')
+  /** 요구사항 [15]로 추가된 비고 일괄 입력. */
+  const [bulkNote, setBulkNote] = useState('')
   const [bulkBusy, setBulkBusy] = useState(false)
   const [bulkMessage, setBulkMessage] = useState<string | null>(null)
   const [loading, setLoading] = useState(false)
@@ -334,7 +287,7 @@
     if (selected.length === 0 || bulkBusy) {
       return
     }
-    if (!bulkResult && !bulkKoglType && !bulkOpinion.trim()) {
+    if (!bulkResult && !bulkKoglType && !bulkOpinion.trim() && !bulkNote.trim()) {
       setBulkMessage('반영할 값을 하나 이상 채워주세요.')
       return
     }
@@ -346,12 +299,14 @@
         reviewResult: bulkResult || undefined,
         judgedKoglType: bulkKoglType || undefined,
         opinion: bulkOpinion.trim() || undefined,
+        lawyerNote: bulkNote.trim() || undefined,
       })
       setBulkMessage(`${res.updated}건에 반영했습니다.`)
       setSelected([])
       setBulkResult('')
       setBulkKoglType('')
       setBulkOpinion('')
+      setBulkNote('')
       setReloadKey((k) => k + 1)
     } catch (e) {
       setBulkMessage(e instanceof Error ? e.message : '일괄 등록에 실패했습니다.')
@@ -533,8 +488,8 @@
           className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
         >
           <option value="">처리결과 그대로</option>
-          {REVIEW_RESULT_OPTIONS.map((option) => (
-            <option key={option} value={option}>{option}</option>
+          {results.map((option) => (
+            <option key={option.code} value={option.code}>{option.label}</option>
           ))}
         </select>
         <select
@@ -544,8 +499,8 @@
           className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
         >
           <option value="">공공누리 유형 그대로</option>
-          {JUDGED_KOGL_TYPE_OPTIONS.map((option) => (
-            <option key={option} value={option}>{option}</option>
+          {koglReview.map((option) => (
+            <option key={option.code} value={option.code}>{option.label}</option>
           ))}
         </select>
         <input
@@ -554,6 +509,14 @@
           value={bulkOpinion}
           onChange={(e) => setBulkOpinion(e.target.value)}
           placeholder="의견(비우면 유지)"
+          className="w-56 rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+        />
+        <input
+          type="text"
+          aria-label="일괄 비고"
+          value={bulkNote}
+          onChange={(e) => setBulkNote(e.target.value)}
+          placeholder="비고(비우면 유지)"
           className="w-56 rounded-md border border-gray-300 px-2 py-1.5 text-sm"
         />
         <button
@@ -725,6 +688,16 @@
   onBack: () => void
   onSaved: () => void
 }) {
+  // 판정 매트릭스가 쓰는 선택지. 자리(majors[0] 등)로 집으므로 코드표의 정렬순서가 곧 배치다.
+  const majors = useCodes('REVIEW_MAJOR')
+  const minors = useCodes('REVIEW_MINOR')
+  const results = useCodes('REVIEW_RESULT')
+  // 변호사 판정값과 기존 부착 유형은 고를 수 있는 목록이 다르다.
+  const kogl = useSplitByFirstRow('KOGL_TYPE', SCOPE_REVIEW)
+  const koglPrior = useScopedCodes('KOGL_TYPE', SCOPE_PRIOR)
+  const attachmentYn = useCodes('ATTACHMENT_YN')
+  const koglAttachedOptions = useCodes('KOGL_ATTACHED')
+
   const [item, setItem] = useState<ReviewItem | null>(null)
   const [loading, setLoading] = useState(true)
   const [loadError, setLoadError] = useState<string | null>(null)
@@ -791,7 +764,7 @@
     if (resultManuallySet) {
       return
     }
-    const suggestion = suggestReviewResult(major, minor)
+    const suggestion = suggestReviewResult(major, minor, majors, minors)
     if (suggestion) {
       setReviewResult(suggestion)
       applyResultDerivations(suggestion)
@@ -808,15 +781,24 @@
    *  - 개방불가          → 공공누리 유형 '개방불가'
    *
    * 사용자가 이 값들을 다시 손으로 바꿀 수 있게, 잠그지는 않고 값만 채워 준다.
+   *
+   * 어떤 값을 채울지는 코드표(needsProcessing·koglSet·koglCopiesPrior)에서 오고,
+   * "기존 유형을 복사하고 비고에 문구를 덧붙인다"는 동작만 여기 남는다. 동작까지 데이터로
+   * 옮기면 코드표 안에 규칙 언어를 새로 만드는 꼴이 된다.
    */
   function applyResultDerivations(result: string) {
-    if (result === RESULT_PROCESSING) {
-      setJudgedKoglType(KOGL_ON_HOLD)
-      setNeedsProcessing(true)
+    const resultCode = results.find((candidate) => candidate.code === result)
+    if (!resultCode) {
       return
     }
-    if (result === RESULT_NO_PROCESSING) {
-      setNeedsProcessing(false)
+
+    const needs = resultCode.attrs.needsProcessing
+    if (typeof needs === 'boolean') {
+      setNeedsProcessing(needs)
+    }
+
+    if (resultCode.attrs.koglCopiesPrior === true) {
+      // 기존에 부착돼 있던 유형을 그대로 쓰고, 그 사유를 비고에 남긴다.
       setJudgedKoglType(koglType || '')
       if (koglType) {
         setLawyerNote((prev) =>
@@ -827,21 +809,25 @@
       }
       return
     }
-    if (result === RESULT_BLOCKED) {
-      setJudgedKoglType(RESULT_BLOCKED)
-      setNeedsProcessing(false)
+
+    const koglSet = attr(resultCode, 'koglSet')
+    if (koglSet) {
+      setJudgedKoglType(koglSet)
     }
   }
 
   /** 지금 고른 세부항목에서 그 처리결과를 고를 수 있는지. 제한이 없는 세부항목은 전부 허용이다. */
   function resultAllowed(option: string): boolean {
-    const allowed = ALLOWED_RESULTS_BY_MINOR[reviewMinor]
-    return !allowed || allowed.includes(option)
+    const minorCode = minors.find((candidate) => candidate.code === reviewMinor)
+    const allowed = minorCode?.attrs.allowed
+    return !Array.isArray(allowed) || (allowed as string[]).includes(option)
   }
 
   function pickMajor(major: string) {
     setReviewMajor(major)
-    const minor = major === '제3자 권리' ? reviewMinor : ''
+    // 세부항목을 쓰지 않는 대분류로 옮기면 세부 선택을 비운다.
+    const majorCode = majors.find((candidate) => candidate.code === major)
+    const minor = majorCode?.attrs.hasMinor === true ? reviewMinor : ''
     setReviewMinor(minor)
     applySuggestion(major, minor)
   }
@@ -936,34 +922,34 @@
           />
           <InfoRow label="첨부파일 여부">
             <div className="flex items-center gap-5 text-sm">
-              {['있음', '없음'].map((option) => (
-                <label key={option} className="flex cursor-pointer items-center gap-1.5">
+              {attachmentYn.map((option) => (
+                <label key={option.code} className="flex cursor-pointer items-center gap-1.5">
                   <input
                     type="radio"
                     name="has-attachment"
-                    aria-label={`첨부파일 ${option}`}
-                    checked={hasAttachment === option}
-                    onChange={() => setHasAttachment(option)}
+                    aria-label={`첨부파일 ${option.code}`}
+                    checked={hasAttachment === option.code}
+                    onChange={() => setHasAttachment(option.code)}
                     className="h-3.5 w-3.5 accent-blue-600"
                   />
-                  {option}
+                  {option.label}
                 </label>
               ))}
             </div>
           </InfoRow>
           <InfoRow label="기존 공공누리">
             <div className="flex flex-wrap items-center gap-5 text-sm">
-              {['부착', '미부착'].map((option) => (
-                <label key={option} className="flex cursor-pointer items-center gap-1.5">
+              {koglAttachedOptions.map((option) => (
+                <label key={option.code} className="flex cursor-pointer items-center gap-1.5">
                   <input
                     type="radio"
                     name="kogl-attached"
-                    aria-label={`기존 공공누리 ${option}`}
-                    checked={koglAttached === option}
-                    onChange={() => setKoglAttached(option)}
+                    aria-label={`기존 공공누리 ${option.code}`}
+                    checked={koglAttached === option.code}
+                    onChange={() => setKoglAttached(option.code)}
                     className="h-3.5 w-3.5 accent-blue-600"
                   />
-                  {option}
+                  {option.label}
                 </label>
               ))}
               <label className="flex items-center gap-1.5">
@@ -975,8 +961,10 @@
                   className="rounded border border-gray-300 px-2 py-1 text-sm"
                 >
                   <option value="">선택</option>
-                  {['0유형', '1유형', '2유형', '3유형', '4유형'].map((t) => (
-                    <option key={t} value={t}>{t}</option>
+                  {/* 기관 조사표에서 온 값('유형 확인 필요' 등)도 여기 들어간다 - 없으면
+                      이미 그 값으로 저장된 1,900여 건이 "선택 안 됨"으로 보인다. */}
+                  {koglPrior.map((option) => (
+                    <option key={option.code} value={option.code}>{option.label}</option>
                   ))}
                 </select>
               </label>
@@ -1012,10 +1000,10 @@
                 <td className="border border-gray-300 px-3 py-2 align-top" rowSpan={1}>
                   <MatrixRadio
                     name="review-major"
-                    value={REVIEW_MAJOR_OPTIONS[0]}
+                    value={majors[0].code}
                     note="저작권성 없음, 배포용, 단순데이터"
-                    checked={reviewMajor === REVIEW_MAJOR_OPTIONS[0]}
-                    onChange={() => pickMajor(REVIEW_MAJOR_OPTIONS[0])}
+                    checked={reviewMajor === majors[0].code}
+                    onChange={() => pickMajor(majors[0].code)}
                   />
                 </td>
                 <td className="border border-gray-300 px-3 py-2" />
@@ -1025,10 +1013,10 @@
                 >
                   <MatrixRadio
                     name="review-result"
-                    value={REVIEW_RESULT_OPTIONS[0]}
-                    checked={reviewResult === REVIEW_RESULT_OPTIONS[0]}
-                    onChange={() => pickResult(REVIEW_RESULT_OPTIONS[0])}
-                    disabled={!resultAllowed(REVIEW_RESULT_OPTIONS[0])}
+                    value={results[0].code}
+                    checked={reviewResult === results[0].code}
+                    onChange={() => pickResult(results[0].code)}
+                    disabled={!resultAllowed(results[0].code)}
                     bold
                   />
                 </td>
@@ -1037,18 +1025,18 @@
                 <td className="border border-gray-300 px-3 py-2 align-top" rowSpan={1}>
                   <MatrixRadio
                     name="review-major"
-                    value={REVIEW_MAJOR_OPTIONS[1]}
-                    checked={reviewMajor === REVIEW_MAJOR_OPTIONS[1]}
-                    onChange={() => pickMajor(REVIEW_MAJOR_OPTIONS[1])}
+                    value={majors[1].code}
+                    checked={reviewMajor === majors[1].code}
+                    onChange={() => pickMajor(majors[1].code)}
                   />
                 </td>
                 <td className="border border-gray-300 px-3 py-2 align-top">
                   <MatrixRadio
                     name="review-minor"
-                    value={REVIEW_MINOR_DISPLAY[0].value}
-                    label={REVIEW_MINOR_DISPLAY[0].label}
-                    checked={reviewMinor === REVIEW_MINOR_DISPLAY[0].value}
-                    onChange={() => pickMinor(REVIEW_MINOR_DISPLAY[0].value)}
+                    value={minors[0].code}
+                    label={minors[0].label}
+                    checked={reviewMinor === minors[0].code}
+                    onChange={() => pickMinor(minors[0].code)}
                   />
                 </td>
               </tr>
@@ -1056,19 +1044,19 @@
                 <td className="border border-gray-300 px-3 py-2 align-top" rowSpan={4}>
                   <MatrixRadio
                     name="review-major"
-                    value={REVIEW_MAJOR_OPTIONS[2]}
-                    checked={reviewMajor === REVIEW_MAJOR_OPTIONS[2]}
-                    onChange={() => pickMajor(REVIEW_MAJOR_OPTIONS[2])}
+                    value={majors[2].code}
+                    checked={reviewMajor === majors[2].code}
+                    onChange={() => pickMajor(majors[2].code)}
                   />
                 </td>
                 <td className="border border-gray-300 px-3 py-2 align-top">
                   <MatrixRadio
                     name="review-minor"
-                    value={REVIEW_MINOR_DISPLAY[1].value}
-                    label={REVIEW_MINOR_DISPLAY[1].label}
-                    note={REVIEW_MINOR_DISPLAY[1].note}
-                    checked={reviewMinor === REVIEW_MINOR_DISPLAY[1].value}
-                    onChange={() => pickMinor(REVIEW_MINOR_DISPLAY[1].value)}
+                    value={minors[1].code}
+                    label={minors[1].label}
+                    note={noteOf(minors[1])}
+                    checked={reviewMinor === minors[1].code}
+                    onChange={() => pickMinor(minors[1].code)}
                   />
                 </td>
                 <td
@@ -1077,10 +1065,10 @@
                 >
                   <MatrixRadio
                     name="review-result"
-                    value={REVIEW_RESULT_OPTIONS[1]}
-                    checked={reviewResult === REVIEW_RESULT_OPTIONS[1]}
-                    onChange={() => pickResult(REVIEW_RESULT_OPTIONS[1])}
-                    disabled={!resultAllowed(REVIEW_RESULT_OPTIONS[1])}
+                    value={results[1].code}
+                    checked={reviewResult === results[1].code}
+                    onChange={() => pickResult(results[1].code)}
+                    disabled={!resultAllowed(results[1].code)}
                     bold
                   />
                 </td>
@@ -1089,11 +1077,11 @@
                 <td className="border border-gray-300 px-3 py-2 align-top">
                   <MatrixRadio
                     name="review-minor"
-                    value={REVIEW_MINOR_DISPLAY[2].value}
-                    label={REVIEW_MINOR_DISPLAY[2].label}
-                    note={REVIEW_MINOR_DISPLAY[2].note}
-                    checked={reviewMinor === REVIEW_MINOR_DISPLAY[2].value}
-                    onChange={() => pickMinor(REVIEW_MINOR_DISPLAY[2].value)}
+                    value={minors[2].code}
+                    label={minors[2].label}
+                    note={noteOf(minors[2])}
+                    checked={reviewMinor === minors[2].code}
+                    onChange={() => pickMinor(minors[2].code)}
                   />
                 </td>
                 <td
@@ -1104,20 +1092,20 @@
                   <div className="flex flex-col items-start gap-1.5">
                     <MatrixRadio
                       name="review-result"
-                      value={RESULT_PROCESSING}
-                      checked={reviewResult === RESULT_PROCESSING}
-                      onChange={() => pickResult(RESULT_PROCESSING)}
-                      disabled={!resultAllowed(RESULT_PROCESSING)}
+                      value={results[2].code}
+                      checked={reviewResult === results[2].code}
+                      onChange={() => pickResult(results[2].code)}
+                      disabled={!resultAllowed(results[2].code)}
                       bold
                     />
                     <MatrixRadio
                       name="review-result"
-                      value={RESULT_NO_PROCESSING}
-                      checked={reviewResult === RESULT_NO_PROCESSING}
-                      onChange={() => pickResult(RESULT_NO_PROCESSING)}
-                      disabled={!resultAllowed(RESULT_NO_PROCESSING)}
+                      value={results[4].code}
+                      checked={reviewResult === results[4].code}
+                      onChange={() => pickResult(results[4].code)}
+                      disabled={!resultAllowed(results[4].code)}
                     />
-                    {reviewMinor === '초상권 포함' && (
+                    {reviewMinor === minors[4].code && (
                       <span className="text-[11px] text-gray-400">개방불가도 선택 가능</span>
                     )}
                   </div>
@@ -1127,11 +1115,11 @@
                 <td className="border border-gray-300 px-3 py-2 align-top">
                   <MatrixRadio
                     name="review-minor"
-                    value={REVIEW_MINOR_DISPLAY[3].value}
-                    label={REVIEW_MINOR_DISPLAY[3].label}
-                    note={REVIEW_MINOR_DISPLAY[3].note}
-                    checked={reviewMinor === REVIEW_MINOR_DISPLAY[3].value}
-                    onChange={() => pickMinor(REVIEW_MINOR_DISPLAY[3].value)}
+                    value={minors[3].code}
+                    label={minors[3].label}
+                    note={noteOf(minors[3])}
+                    checked={reviewMinor === minors[3].code}
+                    onChange={() => pickMinor(minors[3].code)}
                   />
                 </td>
               </tr>
@@ -1139,10 +1127,10 @@
                 <td className="border border-gray-300 px-3 py-2 align-top">
                   <MatrixRadio
                     name="review-minor"
-                    value={REVIEW_MINOR_DISPLAY[4].value}
-                    label={REVIEW_MINOR_DISPLAY[4].label}
-                    checked={reviewMinor === REVIEW_MINOR_DISPLAY[4].value}
-                    onChange={() => pickMinor(REVIEW_MINOR_DISPLAY[4].value)}
+                    value={minors[4].code}
+                    label={minors[4].label}
+                    checked={reviewMinor === minors[4].code}
+                    onChange={() => pickMinor(minors[4].code)}
                   />
                 </td>
               </tr>
@@ -1150,10 +1138,10 @@
                 <td className="border border-gray-300 px-3 py-2 align-top" rowSpan={1}>
                   <MatrixRadio
                     name="review-major"
-                    value={REVIEW_MAJOR_OPTIONS[3]}
+                    value={majors[3].code}
                     note="공공누리 적용 제외 대상, 검토불가"
-                    checked={reviewMajor === REVIEW_MAJOR_OPTIONS[3]}
-                    onChange={() => pickMajor(REVIEW_MAJOR_OPTIONS[3])}
+                    checked={reviewMajor === majors[3].code}
+                    onChange={() => pickMajor(majors[3].code)}
                   />
                 </td>
                 <td className="border border-gray-300 px-3 py-2" />
@@ -1163,10 +1151,10 @@
                 >
                   <MatrixRadio
                     name="review-result"
-                    value={REVIEW_RESULT_OPTIONS[3]}
-                    checked={reviewResult === REVIEW_RESULT_OPTIONS[3]}
-                    onChange={() => pickResult(REVIEW_RESULT_OPTIONS[3])}
-                    disabled={!resultAllowed(REVIEW_RESULT_OPTIONS[3])}
+                    value={results[3].code}
+                    checked={reviewResult === results[3].code}
+                    onChange={() => pickResult(results[3].code)}
+                    disabled={!resultAllowed(results[3].code)}
                     bold
                   />
                 </td>
@@ -1179,32 +1167,32 @@
           <InfoRow label="공공누리 유형">
             <div className="flex flex-col gap-1.5">
               <div className="flex flex-wrap gap-x-4 gap-y-1">
-                {JUDGED_KOGL_TYPE_OPTIONS.filter((o) => KOGL_FIRST_ROW.includes(o)).map((option) => (
-                  <label key={option} className="flex items-center gap-1.5 text-sm text-gray-800">
+                {kogl.first.map((option) => (
+                  <label key={option.code} className="flex items-center gap-1.5 text-sm text-gray-800">
                     <input
                       type="radio"
                       name="judged-kogl-type"
                       // 처리결과에도 '개방불가'가 있어 라벨만으로는 구분되지 않는다.
-                      aria-label={`공공누리 유형 ${option}`}
-                      checked={judgedKoglType === option}
-                      onChange={() => setJudgedKoglType(option)}
+                      aria-label={`공공누리 유형 ${option.code}`}
+                      checked={judgedKoglType === option.code}
+                      onChange={() => setJudgedKoglType(option.code)}
                     />
-                    {option}
+                    {option.label}
                   </label>
                 ))}
               </div>
               <div className="flex flex-wrap items-center gap-x-4 gap-y-1">
-                {JUDGED_KOGL_TYPE_OPTIONS.filter((o) => !KOGL_FIRST_ROW.includes(o)).map((option) => (
-                  <label key={option} className="flex items-center gap-1.5 text-sm text-gray-800">
+                {kogl.rest.map((option) => (
+                  <label key={option.code} className="flex items-center gap-1.5 text-sm text-gray-800">
                     <input
                       type="radio"
                       name="judged-kogl-type"
                       // 처리결과에도 '개방불가'가 있어 라벨만으로는 구분되지 않는다.
-                      aria-label={`공공누리 유형 ${option}`}
-                      checked={judgedKoglType === option}
-                      onChange={() => setJudgedKoglType(option)}
+                      aria-label={`공공누리 유형 ${option.code}`}
+                      checked={judgedKoglType === option.code}
+                      onChange={() => setJudgedKoglType(option.code)}
                     />
-                    {option}
+                    {option.label}
                   </label>
                 ))}
                 <label className="ml-2 flex items-center gap-1.5 text-sm text-gray-800">
 
frontend/src/components/SharedFiles.tsx (added)
+++ frontend/src/components/SharedFiles.tsx
@@ -0,0 +1,213 @@
+import { useEffect, useRef, useState } from 'react'
+import {
+  addSharedFile,
+  deleteSharedFile,
+  getSharedFiles,
+  sharedFileDownloadUrl,
+  updateSharedFileDescription,
+  type SharedFile,
+} from '../api/client'
+
+/**
+ * 자료실 — 공용 양식 보관소(요구사항 [26]).
+ *
+ * 기관에 딸리지 않는 서식을 여기 올려 두고, 어느 기관 일을 하든 내려받아 씁니다.
+ * 기관별 자료는 기관관리 > 자료 탭에 그대로 있습니다.
+ */
+function formatSize(bytes: number): string {
+  if (bytes < 1024) {
+    return `${bytes} B`
+  }
+  if (bytes < 1024 * 1024) {
+    return `${(bytes / 1024).toFixed(1)} KB`
+  }
+  return `${(bytes / 1024 / 1024).toFixed(1)} MB`
+}
+
+function formatDate(ms: number): string {
+  const d = new Date(ms)
+  const pad = (n: number) => String(n).padStart(2, '0')
+  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
+}
+
+export default function SharedFiles() {
+  const [files, setFiles] = useState<SharedFile[]>([])
+  const [loading, setLoading] = useState(true)
+  const [error, setError] = useState<string | null>(null)
+  const [busy, setBusy] = useState(false)
+  const [description, setDescription] = useState('')
+  const [editingId, setEditingId] = useState<number | null>(null)
+  const [editingText, setEditingText] = useState('')
+  const inputRef = useRef<HTMLInputElement>(null)
+
+  async function load() {
+    setLoading(true)
+    try {
+      setFiles(await getSharedFiles())
+      setError(null)
+    } catch (e) {
+      setError((e as Error).message)
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  useEffect(() => {
+    void load()
+  }, [])
+
+  async function handleUpload(file: File) {
+    setBusy(true)
+    setError(null)
+    try {
+      setFiles(await addSharedFile(file, description.trim()))
+      setDescription('')
+    } catch (e) {
+      setError((e as Error).message)
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  async function handleDelete(file: SharedFile) {
+    if (!window.confirm(`「${file.fileName}」을(를) 지울까요?`)) {
+      return
+    }
+    setBusy(true)
+    try {
+      await deleteSharedFile(file.id)
+      await load()
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  async function saveDescription(file: SharedFile) {
+    setBusy(true)
+    try {
+      setFiles(await updateSharedFileDescription(file.id, editingText.trim()))
+      setEditingId(null)
+    } catch (e) {
+      setError((e as Error).message)
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  return (
+    <div className="p-8">
+      <h2 className="text-sm font-semibold">자료실</h2>
+      <p className="mt-1 text-xs text-gray-500">
+        기관에 딸리지 않는 공용 서식을 올려 둡니다. 기관별 자료는 기관관리 &gt; 자료 탭에 있습니다.
+      </p>
+
+      <div className="mt-4 flex flex-wrap items-center gap-2 rounded-lg border border-gray-200 p-3">
+        <input
+          type="text"
+          aria-label="자료 설명"
+          value={description}
+          onChange={(e) => setDescription(e.target.value)}
+          placeholder="설명(선택)"
+          className="w-72 rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+        />
+        <input
+          ref={inputRef}
+          type="file"
+          className="hidden"
+          onChange={(e) => {
+            const file = e.target.files?.[0]
+            if (file) void handleUpload(file)
+            e.target.value = ''
+          }}
+        />
+        <button
+          type="button"
+          disabled={busy}
+          onClick={() => inputRef.current?.click()}
+          className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white disabled:bg-gray-300"
+        >
+          자료 올리기
+        </button>
+      </div>
+
+      {error && <p className="mt-2 text-sm text-red-600">{error}</p>}
+
+      {loading ? (
+        <p className="mt-4 text-sm text-gray-500">불러오는 중…</p>
+      ) : files.length === 0 ? (
+        <p className="mt-6 text-sm text-gray-500">올려둔 자료가 없습니다.</p>
+      ) : (
+        <table data-testid="shared-file-table" className="mt-4 w-full text-sm">
+          <thead className="border-b border-gray-200 text-left text-xs text-gray-500">
+            <tr>
+              <th className="px-3 py-2 font-medium">파일명</th>
+              <th className="px-3 py-2 font-medium">설명</th>
+              <th className="whitespace-nowrap px-3 py-2 font-medium">크기</th>
+              <th className="whitespace-nowrap px-3 py-2 font-medium">올린 사람</th>
+              <th className="whitespace-nowrap px-3 py-2 font-medium">올린 날짜</th>
+              <th className="px-3 py-2" />
+            </tr>
+          </thead>
+          <tbody>
+            {files.map((file) => (
+              <tr key={file.id} className="border-b border-gray-100 last:border-b-0">
+                <td className="px-3 py-2">
+                  <a
+                    href={sharedFileDownloadUrl(file.id)}
+                    className="font-medium text-blue-700 hover:underline"
+                  >
+                    {file.fileName}
+                  </a>
+                </td>
+                <td className="px-3 py-2 text-gray-600">
+                  {editingId === file.id ? (
+                    <div className="flex items-center gap-1.5">
+                      <input
+                        aria-label="설명 수정"
+                        value={editingText}
+                        onChange={(e) => setEditingText(e.target.value)}
+                        className="w-56 rounded-md border border-gray-300 px-2 py-1 text-sm"
+                      />
+                      <button
+                        type="button"
+                        disabled={busy}
+                        onClick={() => void saveDescription(file)}
+                        className="rounded-md border border-gray-300 px-2 py-0.5 text-xs"
+                      >
+                        저장
+                      </button>
+                    </div>
+                  ) : (
+                    <button
+                      type="button"
+                      onClick={() => {
+                        setEditingId(file.id)
+                        setEditingText(file.description ?? '')
+                      }}
+                      className="text-left hover:underline"
+                    >
+                      {file.description ?? <span className="text-gray-300">설명 없음</span>}
+                    </button>
+                  )}
+                </td>
+                <td className="whitespace-nowrap px-3 py-2 text-gray-500">{formatSize(file.byteSize)}</td>
+                <td className="whitespace-nowrap px-3 py-2 text-gray-500">{file.uploadedBy}</td>
+                <td className="whitespace-nowrap px-3 py-2 text-gray-500">{formatDate(file.uploadedAt)}</td>
+                <td className="px-3 py-2 text-right">
+                  <button
+                    type="button"
+                    disabled={busy}
+                    onClick={() => void handleDelete(file)}
+                    className="rounded-md border border-gray-300 px-2 py-0.5 text-xs text-red-600 hover:bg-red-50"
+                  >
+                    삭제
+                  </button>
+                </td>
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      )}
+    </div>
+  )
+}
frontend/src/components/Sidebar.test.tsx
--- frontend/src/components/Sidebar.test.tsx
+++ frontend/src/components/Sidebar.test.tsx
@@ -40,13 +40,23 @@
     expect(onSelect).toHaveBeenCalledWith('channels')
   })
 
+  // 자료실은 요구사항 [26]으로 열렸다. 아직 준비 중인 메뉴는 권리확인·권리처리·Mattermost다.
   it('미구현 메뉴는 비활성으로 표시되고 링크가 아니다', () => {
     render(<Sidebar />)
 
-    const files = screen.getByText('자료실')
-    expect(files).toHaveAttribute('aria-disabled', 'true')
-    expect(files).toHaveAttribute('title', '준비 중')
-    expect(screen.queryByRole('link', { name: '자료실' })).toBeNull()
+    const mattermost = screen.getByText('Mattermost')
+    expect(mattermost).toHaveAttribute('aria-disabled', 'true')
+    expect(mattermost).toHaveAttribute('title', '준비 중')
+    expect(screen.queryByRole('link', { name: 'Mattermost' })).toBeNull()
+  })
+
+  it('자료실은 구현되어 링크로 열린다', () => {
+    const onSelect = vi.fn()
+    render(<Sidebar onSelect={onSelect} />)
+
+    fireEvent.click(screen.getByRole('link', { name: '자료실' }))
+
+    expect(onSelect).toHaveBeenCalledWith('files')
   })
 
   it('Dashboard는 구현되어 링크로 열린다', () => {
@@ -73,7 +83,7 @@
     expect(screen.getByText('IH')).toBeTruthy()
 
     expect(screen.getByRole('link', { name: '기관관리' })).toHaveAttribute('title', '기관관리')
-    expect(screen.getByTitle('자료실 (준비 중)')).toBeTruthy()
+    expect(screen.getByTitle('Mattermost (준비 중)')).toBeTruthy()
   })
 
   it('접기 버튼을 누르면 onToggle이 호출된다', () => {
frontend/src/components/Sidebar.tsx
--- frontend/src/components/Sidebar.tsx
+++ frontend/src/components/Sidebar.tsx
@@ -89,8 +89,9 @@
   },
   {
     key: 'files',
+    // 요구사항 [26]: 공용 양식 보관소. 기관에 딸리지 않는 서식을 여기 올려 둔다.
     label: '자료실',
-    enabled: false,
+    enabled: true,
     icon: (
       <svg className={ICON_CLASS} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
         <path d="M3 7h18v4H3zM5 11v9a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-9M10 15h4" />
frontend/src/components/SystemPage.test.tsx
--- frontend/src/components/SystemPage.test.tsx
+++ frontend/src/components/SystemPage.test.tsx
@@ -1,6 +1,7 @@
 import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
 import { beforeEach, describe, expect, it, vi } from 'vitest'
 import SystemPage from './SystemPage'
+import { withCodes } from '../codes/fixtures'
 import type { ResetTarget } from '../api/client'
 
 const mocks = vi.hoisted(() => ({
@@ -46,7 +47,7 @@
       target(),
       target({ orgId: 2, orgNo: '002', orgName: '세종학당재단', channels: [{ kind: '문정원', channelId: 'c2' }] }),
     ])
-    render(<SystemPage />)
+    render(withCodes(<SystemPage />))
 
     expect(await screen.findByText('대상 2개 기관 · 채널 3개')).toBeTruthy()
     const rows = within(screen.getByTestId('reset-targets')).getAllByRole('row')
@@ -55,7 +56,7 @@
   })
 
   it('확인 문구를 입력하기 전에는 실행 버튼이 잠겨 있다', async () => {
-    render(<SystemPage />)
+    render(withCodes(<SystemPage />))
     await screen.findByTestId('reset-targets')
 
     expect(screen.getByText('채널 초기화 실행')).toHaveProperty('disabled', true)
@@ -68,7 +69,7 @@
   })
 
   it('실행 버튼을 눌러도 확인창에서 한 번 더 물어본다', async () => {
-    render(<SystemPage />)
+    render(withCodes(<SystemPage />))
     await screen.findByTestId('reset-targets')
 
     fireEvent.change(screen.getByLabelText(/실행하려면/), { target: { value: '채널 초기화' } })
@@ -82,7 +83,7 @@
   })
 
   it('확인창에서 실행하면 문구와 함께 초기화를 요청하고 결과를 보여준다', async () => {
-    render(<SystemPage />)
+    render(withCodes(<SystemPage />))
     await screen.findByTestId('reset-targets')
 
     await typeConfirmAndRun()
@@ -99,7 +100,7 @@
       clearedOrgs: 0,
       failures: ['001 국제방송교류재단 (법률검토): 권한 없음'],
     })
-    render(<SystemPage />)
+    render(withCodes(<SystemPage />))
     await screen.findByTestId('reset-targets')
 
     await typeConfirmAndRun()
@@ -110,7 +111,7 @@
 
   it('대상이 없으면 실행할 수 없다', async () => {
     mocks.getResetPreview.mockResolvedValue([])
-    render(<SystemPage />)
+    render(withCodes(<SystemPage />))
 
     expect(await screen.findByText('초기화할 채널이 없습니다.')).toBeTruthy()
     fireEvent.change(screen.getByLabelText(/실행하려면/), { target: { value: '채널 초기화' } })
@@ -118,7 +119,7 @@
   })
 
   it('무엇이 남고 무엇이 사라지는지 화면에 적어 둔다', async () => {
-    render(<SystemPage />)
+    render(withCodes(<SystemPage />))
     await screen.findByTestId('reset-targets')
 
     expect(screen.getByText(/보관\(아카이브\)/)).toBeTruthy()
frontend/src/components/SystemPage.tsx
--- frontend/src/components/SystemPage.tsx
+++ frontend/src/components/SystemPage.tsx
@@ -6,7 +6,7 @@
   type ResetReport,
   type ResetTarget,
 } from '../api/client'
-import { stageLabel } from '../stages'
+import { useStages } from '../codes/stage'
 import HelpButton from '../help/HelpButton'
 
 /**
@@ -28,6 +28,7 @@
 }
 
 export default function SystemPage({ onChanged }: Props) {
+  const stages = useStages()
   const [targets, setTargets] = useState<ResetTarget[] | null>(null)
   const [error, setError] = useState<string | null>(null)
   const [confirm, setConfirm] = useState('')
@@ -134,7 +135,7 @@
                       <td className="px-3 py-2 text-gray-500 tabular-nums">{t.orgNo}</td>
                       <td className="px-3 py-2 font-medium">{t.orgName}</td>
                       <td className="px-3 py-2 text-gray-600">
-                        {t.stage === null ? '-' : `${t.stage}. ${stageLabel(t.stage)}`}
+                        {t.stage === null ? '-' : `${t.stage}. ${stages.label(t.stage)}`}
                       </td>
                       <td className="px-3 py-2 text-gray-600">
                         {t.channels.map((c) => c.kind).join(' · ')}
frontend/src/components/Timeline.test.tsx
--- frontend/src/components/Timeline.test.tsx
+++ frontend/src/components/Timeline.test.tsx
@@ -1,6 +1,7 @@
 import { fireEvent, render, screen, waitFor } from '@testing-library/react'
 import { beforeEach, describe, expect, it, vi } from 'vitest'
 import Timeline from './Timeline'
+import { withCodes } from '../codes/fixtures'
 import type { Org, TimelineEvent } from '../api/client'
 
 const mocks = vi.hoisted(() => ({
@@ -48,7 +49,7 @@
   it('불러오는 동안 로딩 문구를 보여준다', () => {
     mocks.getTimeline.mockReturnValue(new Promise(() => {}))
 
-    render(<Timeline org={org()} />)
+    render(withCodes(<Timeline org={org()} />))
 
     expect(screen.getByText('불러오는 중…')).toBeTruthy()
   })
@@ -56,7 +57,7 @@
   it('기록이 없으면 빈 상태 문구를 보여준다', async () => {
     mocks.getTimeline.mockResolvedValue([])
 
-    render(<Timeline org={org()} />)
+    render(withCodes(<Timeline org={org()} />))
 
     await waitFor(() =>
       expect(
@@ -76,7 +77,7 @@
       event({ type: 'STAGE', at: day1, title: '4', detail: null }),
     ])
 
-    render(<Timeline org={org()} />)
+    render(withCodes(<Timeline org={org()} />))
 
     await waitFor(() => expect(screen.getByText('보고서.pdf')).toBeTruthy())
 
@@ -99,7 +100,7 @@
   it('새로고침 버튼을 누르면 다시 조회한다', async () => {
     mocks.getTimeline.mockResolvedValue([])
 
-    render(<Timeline org={org()} />)
+    render(withCodes(<Timeline org={org()} />))
 
     await waitFor(() => expect(mocks.getTimeline).toHaveBeenCalledTimes(1))
 
@@ -111,7 +112,7 @@
   it('조회가 실패하면 오류 메시지를 보여준다', async () => {
     mocks.getTimeline.mockRejectedValue(new Error('타임라인을 불러오지 못했습니다.'))
 
-    render(<Timeline org={org()} />)
+    render(withCodes(<Timeline org={org()} />))
 
     await waitFor(() => expect(screen.getByText('타임라인을 불러오지 못했습니다.')).toBeTruthy())
   })
frontend/src/components/Timeline.tsx
--- frontend/src/components/Timeline.tsx
+++ frontend/src/components/Timeline.tsx
@@ -1,6 +1,6 @@
 import { useEffect, useState } from 'react'
 import { getTimeline, type Org, type TimelineEvent } from '../api/client'
-import { stageLabel } from '../stages'
+import { useStages, type Stages } from '../codes/stage'
 
 const dateFormatter = new Intl.DateTimeFormat('ko-KR', {
   year: 'numeric',
@@ -62,17 +62,18 @@
 }
 
 /** STAGE 이벤트의 title(단계 번호 문자열)을 "5. 법률검토(권리확인) 진행" 형태로 바꾼다. */
-function stageEventText(title: string): string {
+function stageEventText(title: string, stages: Stages): string {
   const n = Number(title)
   if (!Number.isInteger(n)) {
     return `${title} 단계로 진행`
   }
-  return `${n}. ${stageLabel(n)} 단계로 진행`
+  return `${n}. ${stages.label(n)} 단계로 진행`
 }
 
 function EventBody({ event }: { event: TimelineEvent }) {
+  const stages = useStages()
   if (event.type === 'STAGE') {
-    return <p className="text-sm font-medium text-gray-900">{stageEventText(event.title)}</p>
+    return <p className="text-sm font-medium text-gray-900">{stageEventText(event.title, stages)}</p>
   }
   if (event.type === 'FILE') {
     return (
frontend/src/help/helpContent.ts
--- frontend/src/help/helpContent.ts
+++ frontend/src/help/helpContent.ts
@@ -41,7 +41,7 @@
       '서류제출 — ②목록접수 단계 이상에 도달한 기관 수',
       '권리확인 / 권리처리 — 전 기관 건수 합계(완료 / 전체)와 진척률',
       '보고서 작성 — ⑫보고서 작성 단계에 도달한 기관 수',
-      '완료 — ⑪법률검토 최종완료 이상에 도달한 기관 수',
+      '완료 — ⑬보고서 제출 완료에 도달한 기관 수(최종 보고서가 올라온 기관만 센다)',
     ],
     cautions: [
       '미해결 RE는 아직 데이터가 없어 항상 0으로 표시된다(자리만 잡아 둔 상태).',
@@ -56,7 +56,7 @@
       {
         src: '/help/dashboard-kanban.png',
         alt: '단계 미지정 컬럼과 ①신청~ 컬럼들이 있는 칸반',
-        caption: '컬럼은 단계 미지정 + ①~⑫ 순서다. 오른쪽으로 스크롤하면 나머지 단계가 나온다',
+        caption: '컬럼은 단계 미지정 + ①~⑬ 순서다. 오른쪽으로 스크롤하면 나머지 단계가 나온다',
       },
       {
         src: '/help/dashboard-drag-warning.png',
@@ -160,7 +160,7 @@
 
   'org.stage': {
     title: '진행단계 변경',
-    summary: '12단계 업무 진행 상태를 사람이 직접 바꾼다. 자동으로 넘어가는 일은 없다.',
+    summary: '13단계 업무 진행 상태를 사람이 직접 바꾼다. 자동으로 넘어가는 일은 없다.',
     images: [
       {
         src: '/help/org-stage.png',
 
frontend/src/stages.ts (deleted)
--- frontend/src/stages.ts
@@ -1,42 +0,0 @@
-// 진행단계 12개 라벨. 백엔드는 숫자(1..12)만 저장하고 라벨은 여기서만 관리한다 -
-// OrgOverview(업무단계 현황 스트립·진행단계 변경 모달)와 Timeline(단계 변경 이벤트)이
-// 같은 배열을 가져다 쓴다. 인덱스 0이 1단계다.
-export const STAGE_LABELS: string[] = [
-  '신청',
-  '목록접수',
-  '예비검토',
-  '변호사 배당',
-  '법률검토(권리확인)',
-  'RE:확인',
-  '법률검토(권리확인) 완료',
-  '법률검토(권리처리)',
-  'RE:처리',
-  '법률검토(권리처리) 완료',
-  '법률검토 최종완료',
-  '보고서 작성',
-]
-
-/** 1..12 단계 번호를 라벨로 바꾼다. 범위 밖이면 번호를 그대로 문자열로 돌려준다. */
-export function stageLabel(stage: number): string {
-  return STAGE_LABELS[stage - 1] ?? String(stage)
-}
-
-/**
- * 12단계를 업무 성격으로 묶은 구간. 대시보드의 "업무 구간" 필터와 칸반 카드 모양
- * (권리확인 구간은 확인 진척 막대 1줄, 권리처리 구간은 확인+처리 2줄)이 이 묶음을 쓴다.
- */
-export const STAGE_GROUPS: { key: string; label: string; stages: number[] }[] = [
-  { key: 'intake', label: '접수', stages: [1, 2, 3] },
-  { key: 'assign', label: '배당', stages: [4] },
-  { key: 'check', label: '권리확인', stages: [5, 6, 7] },
-  { key: 'process', label: '권리처리', stages: [8, 9, 10] },
-  { key: 'closing', label: '마감', stages: [11, 12] },
-]
-
-/** 단계 번호가 속한 구간 key. 범위 밖이거나 null이면 null. */
-export function stageGroupKey(stage: number | null): string | null {
-  if (stage === null) {
-    return null
-  }
-  return STAGE_GROUPS.find((group) => group.stages.includes(stage))?.key ?? null
-}
 
src/main/java/kr/itn/itnhub/code/Code.java (added)
+++ src/main/java/kr/itn/itnhub/code/Code.java
@@ -0,0 +1,24 @@
+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/CodeController.java (added)
+++ src/main/java/kr/itn/itnhub/code/CodeController.java
@@ -0,0 +1,26 @@
+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();
+    }
+}
 
src/main/java/kr/itn/itnhub/code/CodeGroup.java (added)
+++ src/main/java/kr/itn/itnhub/code/CodeGroup.java
@@ -0,0 +1,12 @@
+package kr.itn.itnhub.code;
+
+/**
+ * 코드 그룹. {@code editable}이 false면 관리 화면에서 코드를 추가·삭제할 수 없다 -
+ * 진행단계처럼 로직과 강하게 묶인 그룹은 표시명·순서만 바꾸게 한다.
+ */
+public record CodeGroup(
+        String groupId,
+        String groupName,
+        String description,
+        boolean editable) {
+}
 
src/main/java/kr/itn/itnhub/code/CodeMapper.java (added)
+++ src/main/java/kr/itn/itnhub/code/CodeMapper.java
@@ -0,0 +1,14 @@
+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/java/kr/itn/itnhub/code/CodeRow.java (added)
+++ src/main/java/kr/itn/itnhub/code/CodeRow.java
@@ -0,0 +1,15 @@
+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/CodeService.java (added)
+++ src/main/java/kr/itn/itnhub/code/CodeService.java
@@ -0,0 +1,139 @@
+package kr.itn.itnhub.code;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+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 static final Logger log = LoggerFactory.getLogger(CodeService.class);
+
+    private final CodeMapper mapper;
+    private final ObjectMapper json = new ObjectMapper();
+
+    /** 그룹ID -> 활성 코드(정렬순). 화면 선택지가 여기서 나간다. */
+    private volatile Map<String, List<Code>> activeByGroup;
+    /** 그룹ID -> 코드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(), key -> new LinkedHashMap<>())
+                    .put(code.code(), code);
+            if (code.active()) {
+                active.computeIfAbsent(code.groupId(), key -> new 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<>() {
+            });
+        } catch (Exception e) {
+            // 잘못된 JSON 하나가 전체 코드 로딩을 막지는 않게 한다. 해당 코드만 속성이 빈다.
+            log.warn("코드 attrs 파싱 실패 - {}:{} 의 속성을 비운다", row.groupId(), row.code(), e);
+            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(code -> scopes(code).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(candidate -> candidate.code().equals(code));
+    }
+
+    /**
+     * 옛 표기를 표준 표기로 바꿔 준다.
+     *
+     * <p>코드에 {@code sameAs}가 붙어 있으면 그 값으로 옮긴다. 예를 들어 기관 조사표에
+     * 「제4유형」으로 적혀 오는 값은 「4유형」으로 통일한다 - 같은 유형인데 표기가 갈리면
+     * 유형별로 세거나 걸러낼 때 따로 잡히기 때문이다.</p>
+     *
+     * <p>비활성 코드도 본다. 선택지에서는 빠졌어도 옛 표기가 담긴 파일이 다시 올라올 수
+     * 있고, 그때 바꿔 넣어야 하기 때문이다.</p>
+     *
+     * @return 표준 표기. 아는 별칭이 아니면 받은 값을 그대로 돌려준다.
+     */
+    public String canonical(String groupId, String code) {
+        if (code == null || code.isBlank()) {
+            return code;
+        }
+        return find(groupId, code)
+                .map(found -> found.attr(ATTR_SAME_AS))
+                .filter(target -> target != null && !target.isBlank())
+                .orElse(code);
+    }
+
+    private static final String ATTR_SAME_AS = "sameAs";
+
+    /** 코드 관리 화면이 쓰는 그룹 메타. 캐시하지 않는다 - 호출 빈도가 낮다. */
+    public List<CodeGroup> groups() {
+        return mapper.findAllGroups();
+    }
+}
 
src/main/java/kr/itn/itnhub/code/Codes.java (added)
+++ src/main/java/kr/itn/itnhub/code/Codes.java
@@ -0,0 +1,39 @@
+package kr.itn.itnhub.code;
+
+/**
+ * 코드 그룹 ID와, 로직이 직접 참조해야 하는 attrs 키·scope 상수. 소스에 한글 리터럴을
+ * 흩뿌리지 않기 위한 단일 지점이다.
+ *
+ * <p>화면에 그리는 선택지 자체는 여기가 아니라 DB(code 테이블)에서 온다. 여기 있는 것은
+ * "코드체계를 가리키는 이름"뿐이고 "코드값 목록"이 아니다.</p>
+ */
+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/config/AdminProperties.java
--- src/main/java/kr/itn/itnhub/config/AdminProperties.java
+++ src/main/java/kr/itn/itnhub/config/AdminProperties.java
@@ -2,13 +2,23 @@
 
 import org.springframework.boot.context.properties.ConfigurationProperties;
 
+/**
+ * @param displayName 업무메모·연락이력·보고서의 작성자로 찍히는 이름(요구사항 [22]).
+ *                    예전에는 로그인 아이디(admin)가 그대로 찍혔다. 비워 두면 아이디를 쓴다.
+ */
 @ConfigurationProperties(prefix = "app.admin")
-public record AdminProperties(String username, String password) {
+public record AdminProperties(String username, String password, String displayName) {
+
+    /** 화면에 보일 이름. 설정이 없으면 로그인 아이디로 떨어진다. */
+    public String displayNameOrUsername() {
+        return (displayName == null || displayName.isBlank()) ? username : displayName;
+    }
 
     // record 기본 toString()은 password를 그대로 노출한다. 로그에 실수로 찍혀도
     // 원문이 남지 않도록 마스킹한다.
     @Override
     public String toString() {
-        return "AdminProperties[username=%s, password=****]".formatted(username);
+        return "AdminProperties[username=%s, password=****, displayName=%s]"
+                .formatted(username, displayName);
     }
 }
 
src/main/java/kr/itn/itnhub/config/CurrentUser.java (added)
+++ src/main/java/kr/itn/itnhub/config/CurrentUser.java
@@ -0,0 +1,35 @@
+package kr.itn.itnhub.config;
+
+import org.springframework.stereotype.Component;
+
+import java.security.Principal;
+
+/**
+ * 기록에 남길 "작성자 이름"을 한 곳에서 정한다(요구사항 [22]).
+ *
+ * <p>예전에는 각 컨트롤러가 {@code principal.getName()}을 그대로 써서 업무메모·연락이력·
+ * 보고서에 로그인 아이디(admin)가 찍혔다. 지금은 설정에 넣어둔 표시 이름을 쓴다.</p>
+ *
+ * <p>나중에 담당자별 계정이 생기면 여기만 고쳐서 각자의 이름이 찍히게 하면 된다.</p>
+ */
+@Component
+public class CurrentUser {
+
+    private final AdminProperties admin;
+
+    public CurrentUser(AdminProperties admin) {
+        this.admin = admin;
+    }
+
+    /** 기록에 남길 이름. 로그인한 사람이 관리자면 설정된 표시 이름을 쓴다. */
+    public String displayName(Principal principal) {
+        if (principal == null) {
+            return admin.displayNameOrUsername();
+        }
+        if (principal.getName().equals(admin.username())) {
+            return admin.displayNameOrUsername();
+        }
+        // 관리자 외 계정이 생기면 그 계정의 아이디를 그대로 쓴다.
+        return principal.getName();
+    }
+}
src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
--- src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
+++ src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
@@ -7,6 +7,7 @@
 import kr.itn.itnhub.org.OrgNotFoundException;
 import kr.itn.itnhub.process.InvalidProcessStatusException;
 import kr.itn.itnhub.process.ProcessNotFoundException;
+import kr.itn.itnhub.re.ReMemoNotFoundException;
 import kr.itn.itnhub.review.ReviewNotFoundException;
 import kr.itn.itnhub.seed.SeedParseException;
 import kr.itn.itnhub.stage.InvalidStageException;
@@ -85,6 +86,11 @@
         return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ApiError(e.getMessage()));
     }
 
+    @ExceptionHandler(ReMemoNotFoundException.class)
+    public ResponseEntity<ApiError> handleReMemoNotFound(ReMemoNotFoundException e) {
+        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ApiError(e.getMessage()));
+    }
+
     @ExceptionHandler(MethodArgumentNotValidException.class)
     public ResponseEntity<ApiError> handleValidation(MethodArgumentNotValidException e) {
         String fields = e.getBindingResult().getFieldErrors().stream()
src/main/java/kr/itn/itnhub/dashboard/DashboardMapper.java
--- src/main/java/kr/itn/itnhub/dashboard/DashboardMapper.java
+++ src/main/java/kr/itn/itnhub/dashboard/DashboardMapper.java
@@ -1,12 +1,18 @@
 package kr.itn.itnhub.dashboard;
 
 import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
 
 import java.util.List;
 
 @Mapper
 public interface DashboardMapper {
 
-    /** 기관 전체를 연번순으로, 권리확인/권리처리 집계와 단계·최근변동 시각까지 붙여 한 번에 읽는다. */
-    List<DashboardOrgRow> findOrgRows();
+    /**
+     * 기관 전체를 연번순으로, 권리확인/권리처리 집계와 단계·최근변동 시각까지 붙여 한 번에 읽는다.
+     *
+     * @param doneStatus 권리처리 완료로 셀 상태값. 코드표에서 와야 하는데 SQL이 코드표를
+     *                   참조할 방법이 없어 서비스가 읽어 넘긴다.
+     */
+    List<DashboardOrgRow> findOrgRows(@Param("doneStatus") String doneStatus);
 }
src/main/java/kr/itn/itnhub/dashboard/DashboardService.java
--- src/main/java/kr/itn/itnhub/dashboard/DashboardService.java
+++ src/main/java/kr/itn/itnhub/dashboard/DashboardService.java
@@ -1,5 +1,6 @@
 package kr.itn.itnhub.dashboard;
 
+import kr.itn.itnhub.code.CodeService;
 import org.springframework.stereotype.Service;
 
 import java.util.List;
@@ -8,29 +9,60 @@
  * 대시보드 화면 하나가 쓰는 서비스. 집계 쿼리는 {@link DashboardMapper#findOrgRows()} 한 번이고,
  * 상단 KPI는 그 결과를 Java에서 접어 만든다 - 같은 원본에서 계산해야 타일 숫자와 아래 표/칸반
  * 숫자가 어긋나지 않는다(쿼리를 따로 두면 두 결과 사이에 시차가 생긴다).
+ *
+ * <p>어느 단계부터 각 KPI에 세는지는 코드표(STAGE의 kpiFrom 속성)에서 온다. 예전에는 여기
+ * 상수 세 개로 박혀 있어서, 기준이 바뀔 때마다 배포가 필요했다.</p>
  */
 @Service
 public class DashboardService {
 
-    /** ②목록접수. 시안 KPI "서류제출"의 기준으로 쓴다. */
-    private static final int STAGE_DOC_SUBMITTED = 2;
-    /** ⑪법률검토 최종완료. 시안 KPI "완료"의 기준. */
-    private static final int STAGE_COMPLETED = 11;
-    /** ⑫보고서 작성. */
-    private static final int STAGE_REPORT = 12;
+    /** KPI 이름. 코드표의 kpiFrom 값과 맞물린다. */
+    static final String KPI_DOC_SUBMITTED = "docSubmitted";
+    static final String KPI_COMPLETED = "completed";
+    static final String KPI_REPORT = "reportWriting";
 
     private final DashboardMapper mapper;
+    private final CodeService codeService;
 
-    public DashboardService(DashboardMapper mapper) {
+    public DashboardService(DashboardMapper mapper, CodeService codeService) {
         this.mapper = mapper;
+        this.codeService = codeService;
+    }
+
+    /**
+     * 이 KPI에 집계하기 시작하는 단계 번호. 코드표에 지정이 없으면 아무 기관도 세지 않도록
+     * {@link Integer#MAX_VALUE}를 준다 - 0을 주면 단계가 있는 모든 기관이 잘못 집계된다.
+     */
+    private int thresholdOf(String kpi) {
+        return codeService.group(kr.itn.itnhub.code.Codes.STAGE).stream()
+                .filter(code -> kpi.equals(code.attr(kr.itn.itnhub.code.Codes.ATTR_KPI_FROM)))
+                .mapToInt(code -> Integer.parseInt(code.code()))
+                .min()
+                .orElse(Integer.MAX_VALUE);
     }
 
     public DashboardResponse dashboard() {
-        List<DashboardOrgRow> rows = mapper.findOrgRows();
+        List<DashboardOrgRow> rows = mapper.findOrgRows(doneStatus());
         return new DashboardResponse(summarize(rows), rows);
     }
 
+    /**
+     * 권리처리 완료로 셀 상태값. 지정이 없으면 어떤 행도 완료로 세지 않도록 매칭되지 않는
+     * 값을 준다 - 빈 문자열을 주면 처리상태가 비어 있는 행이 전부 완료로 잡힌다.
+     */
+    private String doneStatus() {
+        return codeService.group(kr.itn.itnhub.code.Codes.PROCESS_STATUS).stream()
+                .filter(code -> code.flag(kr.itn.itnhub.code.Codes.ATTR_DONE))
+                .map(kr.itn.itnhub.code.Code::code)
+                .findFirst()
+                .orElse(" ");
+    }
+
     DashboardSummary summarize(List<DashboardOrgRow> rows) {
+        int docSubmittedFrom = thresholdOf(KPI_DOC_SUBMITTED);
+        int completedFrom = thresholdOf(KPI_COMPLETED);
+        int reportFrom = thresholdOf(KPI_REPORT);
+
         int docSubmitted = 0;
         int reviewTotal = 0;
         int reviewDone = 0;
@@ -51,13 +83,13 @@
             if (stage == null) {
                 continue;
             }
-            if (stage >= STAGE_DOC_SUBMITTED) {
+            if (stage >= docSubmittedFrom) {
                 docSubmitted++;
             }
-            if (stage >= STAGE_COMPLETED) {
+            if (stage >= completedFrom) {
                 completed++;
             }
-            if (stage >= STAGE_REPORT) {
+            if (stage >= reportFrom) {
                 reportWriting++;
             }
         }
src/main/java/kr/itn/itnhub/feed/ChannelFeedService.java
--- src/main/java/kr/itn/itnhub/feed/ChannelFeedService.java
+++ src/main/java/kr/itn/itnhub/feed/ChannelFeedService.java
@@ -35,12 +35,15 @@
     private final OrganizationMapper orgMapper;
     private final MattermostClient mattermost;
     private final MattermostProperties mattermostProps;
+    private final kr.itn.itnhub.stage.StageService stageService;
 
     public ChannelFeedService(OrganizationMapper orgMapper, MattermostClient mattermost,
-                               MattermostProperties mattermostProps) {
+                               MattermostProperties mattermostProps,
+                               kr.itn.itnhub.stage.StageService stageService) {
         this.orgMapper = orgMapper;
         this.mattermost = mattermost;
         this.mattermostProps = mattermostProps;
+        this.stageService = stageService;
     }
 
     public List<PostView> posts(Long orgId, String channel) {
@@ -79,8 +82,18 @@
             }
             mattermost.pinPost(postId);
             mattermost.updateChannelHeader(channelId, noticeHeader(message, files, postId));
+
+            // 요구사항 [18]: 「목록접수 = Mattermost에 첫 자료 업로드 + 핀 고정 시」.
+            // 자료가 실려 있고 공지로 고정까지 된 경우에만 넘긴다 - 그냥 파일만 주고받은 것은
+            // 접수가 아니고, 자료 없는 공지도 접수가 아니다. 핀 고정이 끝난 뒤에 판단해야
+            // "고정까지 됐다"는 조건이 실제로 지켜진다.
+            if (!fileIds.isEmpty()) {
+                stageService.autoAdvance(orgId, kr.itn.itnhub.stage.AutoStage.LIST_RECEIVED,
+                        kr.itn.itnhub.stage.AutoStage.REASON_LIST_RECEIVED);
+            }
             return new SendResult(post, null);
         } catch (MattermostException e) {
+            // 고정에 실패했으면 단계도 넘기지 않는다.
             return new SendResult(post, "공지 등록 처리 중 일부가 실패했습니다: " + e.getMessage());
         }
     }
src/main/java/kr/itn/itnhub/memo/WorkMemoController.java
--- src/main/java/kr/itn/itnhub/memo/WorkMemoController.java
+++ src/main/java/kr/itn/itnhub/memo/WorkMemoController.java
@@ -19,9 +19,12 @@
 public class WorkMemoController {
 
     private final WorkMemoService memoService;
+    private final kr.itn.itnhub.config.CurrentUser currentUser;
 
-    public WorkMemoController(WorkMemoService memoService) {
+    public WorkMemoController(WorkMemoService memoService,
+                              kr.itn.itnhub.config.CurrentUser currentUser) {
         this.memoService = memoService;
+        this.currentUser = currentUser;
     }
 
     @GetMapping("/api/orgs/{id}/memos")
@@ -29,11 +32,14 @@
         return memoService.list(id);
     }
 
-    /** 작성자는 본문에서 받지 않고 로그인 사용자(principal)를 서버가 직접 기록한다. */
+    /**
+     * 작성자는 본문에서 받지 않고 로그인 사용자를 서버가 직접 기록한다.
+     * 요구사항 [22]로 아이디가 아니라 표시 이름이 찍힌다.
+     */
     @PostMapping("/api/orgs/{id}/memos")
     public WorkMemo create(@PathVariable Long id, @Valid @RequestBody MemoRequest request,
                            Principal principal) {
-        return memoService.create(id, request, principal.getName());
+        return memoService.create(id, request, currentUser.displayName(principal));
     }
 
     @PutMapping("/api/orgs/{id}/memos/{memoId}")
src/main/java/kr/itn/itnhub/process/BulkProcessingRequest.java
--- src/main/java/kr/itn/itnhub/process/BulkProcessingRequest.java
+++ src/main/java/kr/itn/itnhub/process/BulkProcessingRequest.java
@@ -10,5 +10,7 @@
         List<Long> ids,
         String processStatus,
         String judgedKoglType,
-        String finalOpinion) {
+        String finalOpinion,
+        /** 요구사항 [15]로 추가된 항목. 화면의 '비고'와 같은 칸이다. */
+        String reviewNote) {
 }
src/main/java/kr/itn/itnhub/process/ProcessImportService.java
--- src/main/java/kr/itn/itnhub/process/ProcessImportService.java
+++ src/main/java/kr/itn/itnhub/process/ProcessImportService.java
@@ -17,14 +17,25 @@
     private final ProcessParser parser;
     private final ProcessItemMapper mapper;
     private final OrganizationMapper orgMapper;
+    private final kr.itn.itnhub.stage.StageService stageService;
+    private final kr.itn.itnhub.code.CodeService codeService;
     private final TransactionTemplate transactionTemplate;
 
     public ProcessImportService(ProcessParser parser, ProcessItemMapper mapper, OrganizationMapper orgMapper,
+                                 kr.itn.itnhub.stage.StageService stageService,
+                                 kr.itn.itnhub.code.CodeService codeService,
                                  PlatformTransactionManager transactionManager) {
         this.parser = parser;
         this.mapper = mapper;
         this.orgMapper = orgMapper;
+        this.stageService = stageService;
+        this.codeService = codeService;
         this.transactionTemplate = new TransactionTemplate(transactionManager);
+    }
+
+    /** 공공누리 유형의 옛 표기를 표준 표기로 맞춘다(제4유형 → 4유형). */
+    private String canonicalKogl(String value) {
+        return codeService.canonical(kr.itn.itnhub.code.Codes.KOGL_TYPE, value);
     }
 
     /**
@@ -35,7 +46,15 @@
             throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
         }
         List<ProcessRow> rows = parser.parse(xlsx);
-        return transactionTemplate.execute(status -> importRows(orgId, rows));
+        ProcessImportReport report = transactionTemplate.execute(status -> importRows(orgId, rows));
+
+        // 요구사항 [18]: 권리처리 자료가 올라오면 ⑧법률검토(권리처리)로 저절로 넘어간다.
+        // 이미 그보다 앞서 있으면 아무 일도 하지 않는다.
+        if (report != null && report.total() > 0) {
+            stageService.autoAdvance(orgId, kr.itn.itnhub.stage.AutoStage.PROCESS_STARTED,
+                    kr.itn.itnhub.stage.AutoStage.REASON_PROCESS_STARTED);
+        }
+        return report;
     }
 
     private ProcessImportReport importRows(Long orgId, List<ProcessRow> rows) {
@@ -67,19 +86,20 @@
         item.setUrl(row.url());
         item.setDescription(row.description());
         item.setHasAttachment(row.hasAttachment());
-        item.setPriorKoglType(row.priorKoglType());
+        // 옛 표기(제4유형)로 적혀 온 값은 표준 표기(4유형)로 맞춰 넣는다.
+        item.setPriorKoglType(canonicalKogl(row.priorKoglType()));
         item.setContractDocs(row.contractDocs());
         item.setProducedDate(row.producedDate());
         item.setPublishedDate(row.publishedDate());
         item.setReviewMajor(row.reviewMajor());
         item.setReviewMinor(row.reviewMinor());
         item.setReviewResult(row.reviewResult());
-        item.setReviewKoglType(row.reviewKoglType());
+        item.setReviewKoglType(canonicalKogl(row.reviewKoglType()));
         item.setReviewAiType(row.reviewAiType());
         item.setReviewOpinion(row.reviewOpinion());
         item.setReviewNote(row.reviewNote());
         item.setPriorEvidence(row.priorEvidence());
-        item.setJudgedKoglType(row.judgedKoglType());
+        item.setJudgedKoglType(canonicalKogl(row.judgedKoglType()));
         item.setJudgedAiType(row.judgedAiType());
         item.setFinalOpinion(row.finalOpinion());
         item.setJudgmentBasis(row.judgmentBasis());
src/main/java/kr/itn/itnhub/process/ProcessItemMapper.java
--- src/main/java/kr/itn/itnhub/process/ProcessItemMapper.java
+++ src/main/java/kr/itn/itnhub/process/ProcessItemMapper.java
@@ -5,6 +5,13 @@
 
 import java.util.List;
 
+/**
+ * 권리처리 매퍼.
+ *
+ * <p>어느 값이 "처리완료"인지는 쿼리에 박지 않고 {@code doneStatus} 파라미터로 받는다.
+ * 코드값은 코드표에서 오는데 SQL 안에는 코드표를 참조할 방법이 마땅치 않아, 서비스가
+ * 읽어 넘긴다. 이 값이 틀리면 진행률과 처리일시가 조용히 어긋나므로 항상 함께 넘겨야 한다.</p>
+ */
 @Mapper
 public interface ProcessItemMapper {
 
@@ -15,19 +22,24 @@
     List<ProcessItem> findPage(@Param("orgId") Long orgId, @Param("keyword") String keyword,
                                 @Param("site") String site, @Param("board") String board,
                                 @Param("title") String title,
-                                @Param("status") String status, @Param("offset") int offset,
+                                @Param("status") String status,
+                                @Param("doneStatus") String doneStatus,
+                                @Param("offset") int offset,
                                 @Param("limit") int limit);
 
     /** keyword/status로 필터링된 건수 - 필터별 페이지 경계 계산용(현재 응답의 total과는 별개). */
     int countByOrg(@Param("orgId") Long orgId, @Param("keyword") String keyword,
                    @Param("site") String site, @Param("board") String board,
-                   @Param("title") String title, @Param("status") String status);
+                   @Param("title") String title, @Param("status") String status,
+                   @Param("doneStatus") String doneStatus);
 
     /** 여러 건에 같은 값을 한 번에 반영한다(일괄 등록). null 항목은 기존 값을 유지한다. */
     int updateProcessingBulk(@Param("orgId") Long orgId, @Param("ids") List<Long> ids,
                              @Param("processStatus") String processStatus,
                              @Param("judgedKoglType") String judgedKoglType,
-                             @Param("finalOpinion") String finalOpinion);
+                             @Param("finalOpinion") String finalOpinion,
+                             @Param("reviewNote") String reviewNote,
+                             @Param("doneStatus") String doneStatus);
 
     /** 일괄 삭제. */
     int deleteByIds(@Param("orgId") Long orgId, @Param("ids") List<Long> ids);
@@ -54,7 +66,8 @@
                           @Param("judgedAiType") String judgedAiType,
                           @Param("finalOpinion") String finalOpinion,
                           @Param("judgmentBasis") String judgmentBasis,
-                          @Param("processStatus") String processStatus);
+                          @Param("processStatus") String processStatus,
+                          @Param("doneStatus") String doneStatus);
 
     int deleteById(@Param("orgId") Long orgId, @Param("id") Long id);
 
@@ -62,5 +75,5 @@
     int deleteByOrg(@Param("orgId") Long orgId);
 
     /** 검색어/상태 필터와 무관한 기관 전체 진행률 - 목록 헤더와 기관 상세 통계 카드가 함께 쓴다. */
-    ProcessStats stats(@Param("orgId") Long orgId);
+    ProcessStats stats(@Param("orgId") Long orgId, @Param("doneStatus") String doneStatus);
 }
src/main/java/kr/itn/itnhub/process/ProcessParser.java
--- src/main/java/kr/itn/itnhub/process/ProcessParser.java
+++ src/main/java/kr/itn/itnhub/process/ProcessParser.java
@@ -43,35 +43,36 @@
 
     private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
 
-    private static final int COL_SEQ = 0;               // A
-    private static final int COL_SITE_NAME = 1;         // B
-    private static final int COL_CATEGORY = 2;          // C
-    private static final int COL_BOARD_NAME = 3;        // D
-    private static final int COL_POST_TITLE = 4;        // E
-    private static final int COL_URL = 5;                // F
-    private static final int COL_DESCRIPTION = 6;       // G
-    private static final int COL_HAS_ATTACHMENT = 7;    // H
-    private static final int COL_PRIOR_KOGL_TYPE = 8;   // I
-    private static final int COL_CONTRACT_DOCS = 9;     // J
-    private static final int COL_PRODUCED_DATE = 10;    // K
-    private static final int COL_PUBLISHED_DATE = 11;   // L
-    private static final int COL_REVIEW_MAJOR = 12;     // M
-    private static final int COL_REVIEW_MINOR = 13;     // N
-    private static final int COL_REVIEW_RESULT = 14;    // O
-    private static final int COL_REVIEW_KOGL_TYPE = 15; // P
-    private static final int COL_REVIEW_AI_TYPE = 16;   // Q
-    private static final int COL_REVIEW_OPINION = 17;   // R
-    private static final int COL_REVIEW_NOTE = 18;      // S
+    static final int COL_SEQ = 0;               // A
+    static final int COL_SITE_NAME = 1;         // B
+    static final int COL_CATEGORY = 2;          // C
+    static final int COL_BOARD_NAME = 3;        // D
+    static final int COL_POST_TITLE = 4;        // E
+    static final int COL_URL = 5;                // F
+    static final int COL_DESCRIPTION = 6;       // G
+    static final int COL_HAS_ATTACHMENT = 7;    // H
+    static final int COL_PRIOR_KOGL_TYPE = 8;   // I
+    static final int COL_CONTRACT_DOCS = 9;     // J
+    static final int COL_PRODUCED_DATE = 10;    // K
+    static final int COL_PUBLISHED_DATE = 11;   // L
+    static final int COL_REVIEW_MAJOR = 12;     // M
+    static final int COL_REVIEW_MINOR = 13;     // N
+    static final int COL_REVIEW_RESULT = 14;    // O
+    static final int COL_REVIEW_KOGL_TYPE = 15; // P
+    static final int COL_REVIEW_AI_TYPE = 16;   // Q
+    static final int COL_REVIEW_OPINION = 17;   // R
+    static final int COL_REVIEW_NOTE = 18;      // S
     // 실파일에서 헤더는 T열에 '기존 증빙자료'라고 적혀 있지만 실제 데이터는 U열에 있다
     // (T열에는 엑셀 화면용 '수정' 링크 텍스트가 들어 있음 - 데이터 기준으로 U를 읽는다)
-    private static final int COL_PRIOR_EVIDENCE = 20;   // U
-    // 20 = U, 수정 링크 열, 건너뜀
-    private static final int COL_JUDGED_KOGL_TYPE = 21; // V
-    private static final int COL_JUDGED_AI_TYPE = 22;   // W
-    private static final int COL_FINAL_OPINION = 23;    // X
+    static final int COL_PRIOR_EVIDENCE = 20;   // U
+    /** 헤더('기존 증빙자료')가 적힌 열. 이 시스템이 만드는 다운로드 파일은 여기에 값을 쓴다. */
+    static final int COL_PRIOR_EVIDENCE_HEADER = 19; // T
+    static final int COL_JUDGED_KOGL_TYPE = 21; // V
+    static final int COL_JUDGED_AI_TYPE = 22;   // W
+    static final int COL_FINAL_OPINION = 23;    // X
     // 24 = Y, 빈 열, 건너뜀
-    private static final int COL_JUDGMENT_BASIS = 25;   // Z
-    private static final int COL_PROCESS_STATUS = 26;   // AA
+    static final int COL_JUDGMENT_BASIS = 25;   // Z
+    static final int COL_PROCESS_STATUS = 26;   // AA
 
     public List<ProcessRow> parse(InputStream xlsx) {
         try (Workbook wb = WorkbookFactory.create(xlsx)) {
@@ -124,7 +125,7 @@
                     nullIfBlank(string(row, COL_REVIEW_AI_TYPE)),
                     nullIfBlank(string(row, COL_REVIEW_OPINION)),
                     nullIfBlank(string(row, COL_REVIEW_NOTE)),
-                    nullIfBlank(string(row, COL_PRIOR_EVIDENCE)),
+                    priorEvidence(row),
                     nullIfBlank(string(row, COL_JUDGED_KOGL_TYPE)),
                     nullIfBlank(string(row, COL_JUDGED_AI_TYPE)),
                     nullIfBlank(string(row, COL_FINAL_OPINION)),
@@ -134,6 +135,21 @@
         return result;
     }
 
+    /**
+     * 기존 증빙자료는 파일마다 값이 있는 열이 다르다.
+     *
+     * <p>기관이 보내오는 원본은 헤더가 T열인데 데이터가 U열에 있고(T에는 엑셀 화면용 '수정'
+     * 링크 텍스트가 들어 있다), 이 시스템이 만들어 주는 다운로드 파일은 헤더 자리인 T열에
+     * 값을 쓴다. 한쪽만 읽으면 "내려받아 고쳐서 다시 올리는" 흐름에서 값이 조용히 사라지므로
+     * U를 먼저 보고, 비어 있으면 T를 본다.</p>
+     */
+    private String priorEvidence(Row row) {
+        String fromDataColumn = nullIfBlank(string(row, COL_PRIOR_EVIDENCE));
+        return fromDataColumn != null
+                ? fromDataColumn
+                : nullIfBlank(string(row, COL_PRIOR_EVIDENCE_HEADER));
+    }
+
     private String string(Row row, int columnIndex) {
         Cell cell = row.getCell(columnIndex);
         if (cell == null) {
src/main/java/kr/itn/itnhub/process/ProcessService.java (Binary)
--- src/main/java/kr/itn/itnhub/process/ProcessService.java
+++ src/main/java/kr/itn/itnhub/process/ProcessService.java
@@ -1,208 +0,0 @@
-package kr.itn.itnhub.process;
-
-import kr.itn.itnhub.org.OrgNotFoundException;
-import kr.itn.itnhub.org.OrganizationMapper;
-import org.apache.poi.ss.usermodel.Cell;
-import org.apache.poi.ss.usermodel.Row;
-import org.apache.poi.ss.usermodel.Sheet;
-import org.apache.poi.xssf.usermodel.XSSFWorkbook;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.io.UncheckedIOException;
-import java.util.List;
-import java.util.Set;
-
-/** 기관관리 상세의 권리처리 탭이 쓰는 목록/상세/수정/삭제/다운로드 서비스. */
-@Service
-public class ProcessService {
-
-    private static final Set<String> VALID_PROCESS_STATUSES = Set.of("미처리", "처리완료");
-
-    /**
-     * 1행 그룹 제목(A=게시물 정보(조사원), M=권리확인(변호사), U=권리처리(변호사)) + 2행 컬럼 헤더.
-     * U열(수정 링크)과 Y열은 원본 시트의 건너뛰는 열이라 빈 문자열로 남겨둔다.
-     */
-    private static final String[] COLUMN_HEADERS = {
-            "순번", "사이트명", "범주", "게시판명", "게시물제목", "URL주소", "설명",
-            "첨부파일여부", "기존 공공누리유형(+AI)", "계약서 유무", "제작일", "공표일",
-            "권리확인(대분류)", "권리확인(세부)", "처리 구분", "공공누리 유형", "AI유형", "의견", "비고", "기존 증빙자료",
-            "", "공공누리유형(판정)", "AI유형(판정)", "최종의견", "", "판단근거", "처리상태"
-    };
-
-    private final ProcessItemMapper mapper;
-    private final OrganizationMapper orgMapper;
-
-    public ProcessService(ProcessItemMapper mapper, OrganizationMapper orgMapper) {
-        this.mapper = mapper;
-        this.orgMapper = orgMapper;
-    }
-
-    public ProcessPage list(Long orgId, String keyword, String site, String board, String title,
-                            String status, int page, int size) {
-        requireOrg(orgId);
-        String kw = normalizeKeyword(keyword);
-        String siteFilter = normalizeKeyword(site);
-        String boardFilter = normalizeKeyword(board);
-        String titleFilter = normalizeKeyword(title);
-        String st = normalizeStatus(status);
-        int safePage = Math.max(page, 0);
-        int safeSize = size <= 0 ? 30 : size;
-        int offset = safePage * safeSize;
-
-        List<ProcessItem> items =
-                mapper.findPage(orgId, kw, siteFilter, boardFilter, titleFilter, st, offset, safeSize);
-        // total/done은 검색어·상태 필터와 무관한 기관 전체 진행률이다(헤더/통계 카드용 - ProcessPage 참고).
-        ProcessStats stats = mapper.stats(orgId);
-        return new ProcessPage(items, stats.total(), stats.done(), safePage, safeSize);
-    }
-
-    /** 목록에서 고른 여러 건에 같은 값을 한 번에 반영한다. 화면에서 비워 둔 항목은 유지된다. */
-    @Transactional
-    public int updateBulk(Long orgId, BulkProcessingRequest request) {
-        requireOrg(orgId);
-        if (request.ids() == null || request.ids().isEmpty()) {
-            return 0;
-        }
-        return mapper.updateProcessingBulk(orgId, request.ids(),
-                blankToNull(request.processStatus()),
-                blankToNull(request.judgedKoglType()),
-                blankToNull(request.finalOpinion()));
-    }
-
-    /** 목록에서 고른 여러 건을 지운다. */
-    @Transactional
-    public int deleteBulk(Long orgId, List<Long> ids) {
-        requireOrg(orgId);
-        if (ids == null || ids.isEmpty()) {
-            return 0;
-        }
-        return mapper.deleteByIds(orgId, ids);
-    }
-
-    private String blankToNull(String value) {
-        return value == null || value.isBlank() ? null : value;
-    }
-
-    public ProcessItem get(Long orgId, Long itemId) {
-        requireOrg(orgId);
-        return requireItem(orgId, itemId);
-    }
-
-    @Transactional
-    public ProcessItem updateProcessing(Long orgId, Long itemId, ProcessingRequest request) {
-        requireOrg(orgId);
-        validateProcessStatus(request.processStatus());
-        int updated = mapper.updateProcessing(orgId, itemId,
-                request.contractDocs(), request.judgedKoglType(), request.judgedAiType(),
-                request.finalOpinion(), request.judgmentBasis(), request.processStatus());
-        if (updated == 0) {
-            throw new ProcessNotFoundException("게시물을 찾을 수 없습니다: " + itemId);
-        }
-        return mapper.findById(orgId, itemId);
-    }
-
-    @Transactional
-    public void delete(Long orgId, Long itemId) {
-        requireOrg(orgId);
-        int deleted = mapper.deleteById(orgId, itemId);
-        if (deleted == 0) {
-            throw new ProcessNotFoundException("게시물을 찾을 수 없습니다: " + itemId);
-        }
-    }
-
-    /** 원본 파일과 같은 헤더 배치(1행 그룹제목, 2행 컬럼헤더, 3행부터 데이터)로 xlsx를 만든다. */
-    public byte[] downloadWorkbook(Long orgId) {
-        requireOrg(orgId);
-        List<ProcessItem> items = mapper.findAllByOrg(orgId);
-
-        try (XSSFWorkbook wb = new XSSFWorkbook(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
-            Sheet sheet = wb.createSheet(ProcessParser.PRIMARY_SHEET_NAME);
-
-            Row groupRow = sheet.createRow(0);
-            groupRow.createCell(0).setCellValue("게시물 정보(조사원)");
-            groupRow.createCell(12).setCellValue("권리확인(변호사)");
-            groupRow.createCell(20).setCellValue("권리처리(변호사)");
-
-            Row headerRow = sheet.createRow(1);
-            for (int i = 0; i < COLUMN_HEADERS.length; i++) {
-                headerRow.createCell(i).setCellValue(COLUMN_HEADERS[i]);
-            }
-
-            int rowNum = 2;
-            for (ProcessItem item : items) {
-                Row row = sheet.createRow(rowNum++);
-                setCell(row, 0, String.valueOf(item.getSeq()));
-                setCell(row, 1, item.getSiteName());
-                setCell(row, 2, item.getCategory());
-                setCell(row, 3, item.getBoardName());
-                setCell(row, 4, item.getPostTitle());
-                setCell(row, 5, item.getUrl());
-                setCell(row, 6, item.getDescription());
-                setCell(row, 7, item.getHasAttachment());
-                setCell(row, 8, item.getPriorKoglType());
-                setCell(row, 9, item.getContractDocs());
-                setCell(row, 10, item.getProducedDate());
-                setCell(row, 11, item.getPublishedDate());
-                setCell(row, 12, item.getReviewMajor());
-                setCell(row, 13, item.getReviewMinor());
-                setCell(row, 14, item.getReviewResult());
-                setCell(row, 15, item.getReviewKoglType());
-                setCell(row, 16, item.getReviewAiType());
-                setCell(row, 17, item.getReviewOpinion());
-                setCell(row, 18, item.getReviewNote());
-                setCell(row, 19, item.getPriorEvidence());
-                // 20 = U, 수정 링크 열, 건너뜀
-                setCell(row, 21, item.getJudgedKoglType());
-                setCell(row, 22, item.getJudgedAiType());
-                setCell(row, 23, item.getFinalOpinion());
-                // 24 = Y, 빈 열
-                setCell(row, 25, item.getJudgmentBasis());
-                setCell(row, 26, item.getProcessStatus());
-            }
-
-            wb.write(out);
-            return out.toByteArray();
-        } catch (IOException e) {
-            throw new UncheckedIOException("권리처리 엑셀을 만들지 못했습니다.", e);
-        }
-    }
-
-    private void setCell(Row row, int columnIndex, String value) {
-        if (value == null) {
-            return;
-        }
-        Cell cell = row.createCell(columnIndex);
-        cell.setCellValue(value);
-    }
-
-    private void validateProcessStatus(String processStatus) {
-        if (processStatus != null && !VALID_PROCESS_STATUSES.contains(processStatus)) {
-            throw new InvalidProcessStatusException("처리상태는 미처리 또는 처리완료여야 합니다.");
-        }
-    }
-
-    private String normalizeKeyword(String keyword) {
-        return (keyword == null || keyword.isBlank()) ? null : keyword.trim();
-    }
-
-    private String normalizeStatus(String status) {
-        return (status == null || status.isBlank()) ? null : status.trim();
-    }
-
-    private void requireOrg(Long orgId) {
-        if (orgMapper.findById(orgId) == null) {
-            throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
-        }
-    }
-
-    private ProcessItem requireItem(Long orgId, Long itemId) {
-        ProcessItem item = mapper.findById(orgId, itemId);
-        if (item == null) {
-            throw new ProcessNotFoundException("게시물을 찾을 수 없습니다: " + itemId);
-        }
-        return item;
-    }
-}
+Binary file is not shown
src/main/java/kr/itn/itnhub/project/ProjectController.java
--- src/main/java/kr/itn/itnhub/project/ProjectController.java
+++ src/main/java/kr/itn/itnhub/project/ProjectController.java
@@ -29,9 +29,12 @@
 public class ProjectController {
 
     private final ProjectService projectService;
+    private final kr.itn.itnhub.config.CurrentUser currentUser;
 
-    public ProjectController(ProjectService projectService) {
+    public ProjectController(ProjectService projectService,
+                             kr.itn.itnhub.config.CurrentUser currentUser) {
         this.projectService = projectService;
+        this.currentUser = currentUser;
     }
 
     @GetMapping("/api/projects")
@@ -49,7 +52,7 @@
     public List<ContactLog> addLog(@PathVariable("id") Long orgId,
                                    @Valid @RequestBody ContactLogRequest request,
                                    Principal principal) {
-        return projectService.addLog(orgId, request, principal.getName());
+        return projectService.addLog(orgId, request, currentUser.displayName(principal));
     }
 
     @PutMapping("/api/orgs/{id}/contact-logs/{logId}")
@@ -75,7 +78,7 @@
                                      Principal principal) throws IOException {
         String name = file.getOriginalFilename() == null ? "report" : file.getOriginalFilename();
         return projectService.addReport(orgId, name, file.getContentType(), file.getBytes(),
-                principal.getName());
+                currentUser.displayName(principal));
     }
 
     /** 한글 파일명이 깨지지 않게 ChannelFeedController.download와 같은 RFC 5987 인코딩을 쓴다. */
@@ -95,6 +98,17 @@
                 .body(report.content());
     }
 
+    /** 올려둔 보고서를 새 파일로 바꾼다(요구사항 [25]). 목록에 새 줄이 생기지 않는다. */
+    @PutMapping("/api/orgs/{id}/reports/{reportId}")
+    public List<OrgReport> replaceReport(@PathVariable("id") Long orgId,
+                                         @PathVariable Long reportId,
+                                         @RequestPart("file") MultipartFile file,
+                                         Principal principal) throws IOException {
+        String name = file.getOriginalFilename() == null ? "report" : file.getOriginalFilename();
+        return projectService.replaceReport(orgId, reportId, name, file.getContentType(),
+                file.getBytes(), currentUser.displayName(principal));
+    }
+
     @ResponseStatus(HttpStatus.NO_CONTENT)
     @DeleteMapping("/api/orgs/{id}/reports/{reportId}")
     public void deleteReport(@PathVariable("id") Long orgId, @PathVariable Long reportId) {
src/main/java/kr/itn/itnhub/project/ProjectMapper.java
--- src/main/java/kr/itn/itnhub/project/ProjectMapper.java
+++ src/main/java/kr/itn/itnhub/project/ProjectMapper.java
@@ -29,6 +29,12 @@
                      @Param("contentType") String contentType, @Param("byteSize") long byteSize,
                      @Param("content") byte[] content, @Param("uploadedBy") String uploadedBy);
 
+    /** 보고서 교체(요구사항 [25]). 새 행을 만들지 않고 같은 자리를 덮어쓴다. */
+    int updateReport(@Param("orgId") Long orgId, @Param("id") Long id,
+                     @Param("fileName") String fileName,
+                     @Param("contentType") String contentType, @Param("byteSize") long byteSize,
+                     @Param("content") byte[] content, @Param("uploadedBy") String uploadedBy);
+
     /** 다운로드용. 파일 내용까지 읽는다. */
     ReportContent findReportContent(@Param("orgId") Long orgId, @Param("id") Long id);
 
src/main/java/kr/itn/itnhub/project/ProjectService.java
--- src/main/java/kr/itn/itnhub/project/ProjectService.java
+++ src/main/java/kr/itn/itnhub/project/ProjectService.java
@@ -99,6 +99,25 @@
         return mapper.findReportsByOrg(orgId);
     }
 
+    /**
+     * 올려둔 보고서를 새 파일로 바꾼다(요구사항 [25]). 목록에 새 줄이 생기지 않고 그 자리만
+     * 바뀐다 - 개정판으로 남기고 싶으면 새로 올리면 된다.
+     */
+    @Transactional
+    public List<OrgReport> replaceReport(Long orgId, Long reportId, String fileName,
+                                         String contentType, byte[] content, String uploadedBy) {
+        requireOrg(orgId);
+        if (content == null || content.length == 0) {
+            throw new IllegalArgumentException("빈 파일은 올릴 수 없습니다.");
+        }
+        int updated = mapper.updateReport(orgId, reportId, fileName, contentType, content.length,
+                content, uploadedBy);
+        if (updated == 0) {
+            throw new OrgNotFoundException("보고서를 찾을 수 없습니다: " + reportId);
+        }
+        return mapper.findReportsByOrg(orgId);
+    }
+
     public ProjectMapper.ReportContent reportContent(Long orgId, Long reportId) {
         requireOrg(orgId);
         ProjectMapper.ReportContent content = mapper.findReportContent(orgId, reportId);
src/main/java/kr/itn/itnhub/provision/ChannelProvisionService.java
--- src/main/java/kr/itn/itnhub/provision/ChannelProvisionService.java
+++ src/main/java/kr/itn/itnhub/provision/ChannelProvisionService.java
@@ -227,7 +227,8 @@
             }
             if (filled(current.getChannelIdMj()) && filled(current.getChannelIdLaw())) {
                 mapper.updateStage(orgId, 1);
-                stageHistoryMapper.insert(orgId, 1);
+                // 이것도 프로그램이 저절로 넣는 단계다(요구사항 [19]: 이력에 자동/수동 구분).
+                stageHistoryMapper.insert(orgId, 1, true, "채널 생성");
             }
         } catch (RuntimeException e) {
             log.warn("진행단계 자동 시작 실패 org={}", orgId, e);
 
src/main/java/kr/itn/itnhub/re/ReMemo.java (added)
+++ src/main/java/kr/itn/itnhub/re/ReMemo.java
@@ -0,0 +1,18 @@
+package kr.itn.itnhub.re;
+
+/**
+ * RE 메모 한 건. 요구사항 [4]대로 "메모 1개 = RE 1개"다.
+ *
+ * <p>예) 내용 "ooo 계약서 및 제안요청서 송부 요청" / 작성자 "장영익" / 일자 2026-07-08</p>
+ *
+ * @param resolved 해결 여부. 대시보드의 '미해결 RE'는 이 값이 false인 건수다.
+ */
+public record ReMemo(
+        Long id,
+        Long orgId,
+        String content,
+        String author,
+        String memoDate,
+        boolean resolved,
+        Long createdAt) {
+}
 
src/main/java/kr/itn/itnhub/re/ReMemoController.java (added)
+++ src/main/java/kr/itn/itnhub/re/ReMemoController.java
@@ -0,0 +1,48 @@
+package kr.itn.itnhub.re;
+
+import jakarta.validation.Valid;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/** 기관관리 상세의 RE 탭. 등록·수정·삭제 후에는 갱신된 목록을 그대로 돌려준다. */
+@RestController
+public class ReMemoController {
+
+    private final ReMemoService service;
+
+    public ReMemoController(ReMemoService service) {
+        this.service = service;
+    }
+
+    @GetMapping("/api/orgs/{id}/re-memos")
+    public List<ReMemo> list(@PathVariable("id") Long orgId) {
+        return service.list(orgId);
+    }
+
+    @PostMapping("/api/orgs/{id}/re-memos")
+    public List<ReMemo> add(@PathVariable("id") Long orgId,
+                            @Valid @RequestBody ReMemoRequest request) {
+        return service.add(orgId, request);
+    }
+
+    @PutMapping("/api/orgs/{id}/re-memos/{memoId}")
+    public List<ReMemo> update(@PathVariable("id") Long orgId, @PathVariable Long memoId,
+                               @Valid @RequestBody ReMemoRequest request) {
+        return service.update(orgId, memoId, request);
+    }
+
+    @ResponseStatus(HttpStatus.NO_CONTENT)
+    @DeleteMapping("/api/orgs/{id}/re-memos/{memoId}")
+    public void delete(@PathVariable("id") Long orgId, @PathVariable Long memoId) {
+        service.delete(orgId, memoId);
+    }
+}
 
src/main/java/kr/itn/itnhub/re/ReMemoMapper.java (added)
+++ src/main/java/kr/itn/itnhub/re/ReMemoMapper.java
@@ -0,0 +1,28 @@
+package kr.itn.itnhub.re;
+
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+@Mapper
+public interface ReMemoMapper {
+
+    /** 기관의 RE 메모를 일자 최신순으로. */
+    List<ReMemo> findByOrg(@Param("orgId") Long orgId);
+
+    ReMemo findById(@Param("orgId") Long orgId, @Param("id") Long id);
+
+    int insert(@Param("orgId") Long orgId, @Param("content") String content,
+               @Param("author") String author, @Param("memoDate") String memoDate,
+               @Param("resolved") boolean resolved);
+
+    int update(@Param("orgId") Long orgId, @Param("id") Long id,
+               @Param("content") String content, @Param("author") String author,
+               @Param("memoDate") String memoDate, @Param("resolved") boolean resolved);
+
+    int delete(@Param("orgId") Long orgId, @Param("id") Long id);
+
+    /** 채널 초기화 시 함께 지운다 - 기관 데이터가 되돌아가는데 메모만 남으면 어긋난다. */
+    int deleteByOrg(@Param("orgId") Long orgId);
+}
 
src/main/java/kr/itn/itnhub/re/ReMemoNotFoundException.java (added)
+++ src/main/java/kr/itn/itnhub/re/ReMemoNotFoundException.java
@@ -0,0 +1,9 @@
+package kr.itn.itnhub.re;
+
+/** 없는 RE 메모를 고치거나 지우려 할 때. GlobalExceptionHandler가 404로 바꾼다. */
+public class ReMemoNotFoundException extends RuntimeException {
+
+    public ReMemoNotFoundException(String message) {
+        super(message);
+    }
+}
 
src/main/java/kr/itn/itnhub/re/ReMemoRequest.java (added)
+++ src/main/java/kr/itn/itnhub/re/ReMemoRequest.java
@@ -0,0 +1,20 @@
+package kr.itn.itnhub.re;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+/** RE 메모 등록/수정 입력. 일자를 비우면 오늘로 넣는다. */
+public record ReMemoRequest(
+        @NotBlank(message = "내용을 입력하세요.")
+        @Size(max = 2000, message = "내용은 2000자까지 넣을 수 있습니다.")
+        String content,
+
+        @NotBlank(message = "작성자를 입력하세요.")
+        @Size(max = 100, message = "작성자는 100자까지 넣을 수 있습니다.")
+        String author,
+
+        /** yyyy-MM-dd. 비우면 오늘. */
+        String memoDate,
+
+        boolean resolved) {
+}
 
src/main/java/kr/itn/itnhub/re/ReMemoService.java (added)
+++ src/main/java/kr/itn/itnhub/re/ReMemoService.java
@@ -0,0 +1,67 @@
+package kr.itn.itnhub.re;
+
+import kr.itn.itnhub.org.OrgNotFoundException;
+import kr.itn.itnhub.org.OrganizationMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/**
+ * 기관관리 상세의 RE 탭이 쓰는 서비스.
+ *
+ * <p>요구사항 [4]: 별도 양식 없이 메모장처럼 등록한다. 메모 1개가 RE 1개다.</p>
+ */
+@Service
+public class ReMemoService {
+
+    private final ReMemoMapper mapper;
+    private final OrganizationMapper orgMapper;
+
+    public ReMemoService(ReMemoMapper mapper, OrganizationMapper orgMapper) {
+        this.mapper = mapper;
+        this.orgMapper = orgMapper;
+    }
+
+    public List<ReMemo> list(Long orgId) {
+        requireOrg(orgId);
+        return mapper.findByOrg(orgId);
+    }
+
+    @Transactional
+    public List<ReMemo> add(Long orgId, ReMemoRequest request) {
+        requireOrg(orgId);
+        mapper.insert(orgId, request.content().trim(), request.author().trim(),
+                blankToNull(request.memoDate()), request.resolved());
+        return mapper.findByOrg(orgId);
+    }
+
+    @Transactional
+    public List<ReMemo> update(Long orgId, Long memoId, ReMemoRequest request) {
+        requireOrg(orgId);
+        int updated = mapper.update(orgId, memoId, request.content().trim(),
+                request.author().trim(), blankToNull(request.memoDate()), request.resolved());
+        if (updated == 0) {
+            throw new ReMemoNotFoundException("RE 메모를 찾을 수 없습니다: " + memoId);
+        }
+        return mapper.findByOrg(orgId);
+    }
+
+    @Transactional
+    public void delete(Long orgId, Long memoId) {
+        requireOrg(orgId);
+        if (mapper.delete(orgId, memoId) == 0) {
+            throw new ReMemoNotFoundException("RE 메모를 찾을 수 없습니다: " + memoId);
+        }
+    }
+
+    private String blankToNull(String value) {
+        return (value == null || value.isBlank()) ? null : value.trim();
+    }
+
+    private void requireOrg(Long orgId) {
+        if (orgMapper.findById(orgId) == null) {
+            throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
+        }
+    }
+}
src/main/java/kr/itn/itnhub/review/BulkJudgmentRequest.java
--- src/main/java/kr/itn/itnhub/review/BulkJudgmentRequest.java
+++ src/main/java/kr/itn/itnhub/review/BulkJudgmentRequest.java
@@ -12,5 +12,7 @@
         List<Long> ids,
         String reviewResult,
         String judgedKoglType,
-        String opinion) {
+        String opinion,
+        /** 요구사항 [15]로 추가된 항목. 화면의 '비고(변호사)'와 같은 칸이다. */
+        String lawyerNote) {
 }
src/main/java/kr/itn/itnhub/review/ReviewImportService.java
--- src/main/java/kr/itn/itnhub/review/ReviewImportService.java
+++ src/main/java/kr/itn/itnhub/review/ReviewImportService.java
@@ -17,14 +17,25 @@
     private final ReviewParser parser;
     private final ReviewItemMapper mapper;
     private final OrganizationMapper orgMapper;
+    private final kr.itn.itnhub.stage.StageService stageService;
+    private final kr.itn.itnhub.code.CodeService codeService;
     private final TransactionTemplate transactionTemplate;
 
     public ReviewImportService(ReviewParser parser, ReviewItemMapper mapper, OrganizationMapper orgMapper,
+                                kr.itn.itnhub.stage.StageService stageService,
+                                kr.itn.itnhub.code.CodeService codeService,
                                 PlatformTransactionManager transactionManager) {
         this.parser = parser;
         this.mapper = mapper;
         this.orgMapper = orgMapper;
+        this.stageService = stageService;
+        this.codeService = codeService;
         this.transactionTemplate = new TransactionTemplate(transactionManager);
+    }
+
+    /** 공공누리 유형의 옛 표기를 표준 표기로 맞춘다(제4유형 → 4유형). */
+    private String canonicalKogl(String value) {
+        return codeService.canonical(kr.itn.itnhub.code.Codes.KOGL_TYPE, value);
     }
 
     /**
@@ -35,7 +46,15 @@
             throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
         }
         List<ReviewRow> rows = parser.parse(xlsx);
-        return transactionTemplate.execute(status -> importRows(orgId, rows));
+        ReviewImportReport report = transactionTemplate.execute(status -> importRows(orgId, rows));
+
+        // 요구사항 [18]: 권리확인 자료가 올라오면 ⑤법률검토(권리확인)로 저절로 넘어간다.
+        // 이미 그보다 앞서 있으면 아무 일도 하지 않는다.
+        if (report != null && report.total() > 0) {
+            stageService.autoAdvance(orgId, kr.itn.itnhub.stage.AutoStage.REVIEW_STARTED,
+                    kr.itn.itnhub.stage.AutoStage.REASON_REVIEW_STARTED);
+        }
+        return report;
     }
 
     private ReviewImportReport importRows(Long orgId, List<ReviewRow> rows) {
@@ -71,14 +90,15 @@
         item.setPublishedDate(row.publishedDate());
         item.setHasAttachment(row.hasAttachment());
         item.setKoglAttached(row.koglAttached());
-        item.setKoglType(row.koglType());
+        // 옛 표기(제4유형)로 적혀 온 값은 표준 표기(4유형)로 맞춰 넣는다.
+        item.setKoglType(canonicalKogl(row.koglType()));
         item.setAiType(row.aiType());
         item.setSurveyorNote(row.surveyorNote());
         item.setOpenable(row.openable());
         item.setReviewMajor(row.reviewMajor());
         item.setReviewMinor(row.reviewMinor());
         item.setReviewResult(row.reviewResult());
-        item.setJudgedKoglType(row.judgedKoglType());
+        item.setJudgedKoglType(canonicalKogl(row.judgedKoglType()));
         item.setJudgedAiType(row.judgedAiType());
         item.setOpinion(row.opinion());
         item.setLawyerNote(row.lawyerNote());
src/main/java/kr/itn/itnhub/review/ReviewItemMapper.java
--- src/main/java/kr/itn/itnhub/review/ReviewItemMapper.java
+++ src/main/java/kr/itn/itnhub/review/ReviewItemMapper.java
@@ -29,7 +29,8 @@
     int updateJudgmentBulk(@Param("orgId") Long orgId, @Param("ids") List<Long> ids,
                            @Param("reviewResult") String reviewResult,
                            @Param("judgedKoglType") String judgedKoglType,
-                           @Param("opinion") String opinion);
+                           @Param("opinion") String opinion,
+                           @Param("lawyerNote") String lawyerNote);
 
     /** 일괄 삭제. 목록에서 여러 건을 골라 지울 때 쓴다. */
     int deleteByIds(@Param("orgId") Long orgId, @Param("ids") List<Long> ids);
src/main/java/kr/itn/itnhub/review/ReviewService.java
--- src/main/java/kr/itn/itnhub/review/ReviewService.java
+++ src/main/java/kr/itn/itnhub/review/ReviewService.java
@@ -65,7 +65,8 @@
         return mapper.updateJudgmentBulk(orgId, request.ids(),
                 blankToNull(request.reviewResult()),
                 blankToNull(request.judgedKoglType()),
-                blankToNull(request.opinion()));
+                blankToNull(request.opinion()),
+                blankToNull(request.lawyerNote()));
     }
 
     /** 목록에서 고른 여러 건을 지운다. */
 
src/main/java/kr/itn/itnhub/shared/SharedFile.java (added)
+++ src/main/java/kr/itn/itnhub/shared/SharedFile.java
@@ -0,0 +1,15 @@
+package kr.itn.itnhub.shared;
+
+/**
+ * 자료실에 올려둔 공용 서식 한 건(요구사항 [26]: 공용 양식 보관소).
+ *
+ * <p>기관에 딸리지 않는다. 어느 기관 일을 하든 여기서 내려받아 쓴다.</p>
+ */
+public record SharedFile(
+        Long id,
+        String fileName,
+        String description,
+        long byteSize,
+        String uploadedBy,
+        Long uploadedAt) {
+}
 
src/main/java/kr/itn/itnhub/shared/SharedFileController.java (added)
+++ src/main/java/kr/itn/itnhub/shared/SharedFileController.java
@@ -0,0 +1,86 @@
+package kr.itn.itnhub.shared;
+
+import kr.itn.itnhub.config.CurrentUser;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestPart;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.Principal;
+import java.util.List;
+import java.util.Map;
+
+/** 자료실. 기관에 딸리지 않는 공용 서식을 올려두고 내려받는다(요구사항 [26]). */
+@RestController
+public class SharedFileController {
+
+    private final SharedFileService service;
+    private final CurrentUser currentUser;
+
+    public SharedFileController(SharedFileService service, CurrentUser currentUser) {
+        this.service = service;
+        this.currentUser = currentUser;
+    }
+
+    @GetMapping("/api/shared-files")
+    public List<SharedFile> list() {
+        return service.list();
+    }
+
+    @PostMapping("/api/shared-files")
+    public List<SharedFile> add(@RequestPart("file") MultipartFile file,
+                                @RequestPart(value = "description", required = false) String description,
+                                Principal principal) throws IOException {
+        String name = file.getOriginalFilename() == null ? "file" : file.getOriginalFilename();
+        return service.add(name, description, file.getContentType(), file.getBytes(),
+                currentUser.displayName(principal));
+    }
+
+    @PutMapping("/api/shared-files/{id}")
+    public List<SharedFile> updateDescription(@PathVariable Long id,
+                                              @RequestBody Map<String, String> body) {
+        return service.updateDescription(id, body.get("description"));
+    }
+
+    /** 한글 파일명이 깨지지 않게 보고서 다운로드와 같은 RFC 5987 인코딩을 쓴다. */
+    @GetMapping("/api/shared-files/{id}")
+    public ResponseEntity<byte[]> download(@PathVariable Long id) {
+        SharedFileMapper.Content found = service.content(id);
+        String encodedName = URLEncoder.encode(found.fileName(), StandardCharsets.UTF_8)
+                .replace("+", "%20");
+        MediaType type = found.contentType() == null || found.contentType().isBlank()
+                ? MediaType.APPLICATION_OCTET_STREAM
+                : MediaType.parseMediaType(found.contentType());
+
+        return ResponseEntity.ok()
+                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodedName)
+                .contentType(type)
+                .body(found.content());
+    }
+
+    @ResponseStatus(HttpStatus.NO_CONTENT)
+    @DeleteMapping("/api/shared-files/{id}")
+    public void delete(@PathVariable Long id) {
+        service.delete(id);
+    }
+
+    @ResponseStatus(HttpStatus.BAD_REQUEST)
+    @ExceptionHandler(IllegalArgumentException.class)
+    public Map<String, String> handleBadRequest(IllegalArgumentException e) {
+        return Map.of("message", e.getMessage());
+    }
+}
 
src/main/java/kr/itn/itnhub/shared/SharedFileMapper.java (added)
+++ src/main/java/kr/itn/itnhub/shared/SharedFileMapper.java
@@ -0,0 +1,27 @@
+package kr.itn.itnhub.shared;
+
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+@Mapper
+public interface SharedFileMapper {
+
+    List<SharedFile> findAll();
+
+    int insert(@Param("fileName") String fileName, @Param("description") String description,
+               @Param("contentType") String contentType, @Param("byteSize") long byteSize,
+               @Param("content") byte[] content, @Param("uploadedBy") String uploadedBy);
+
+    /** 설명만 고친다. 파일을 바꾸려면 새로 올리고 지운다. */
+    int updateDescription(@Param("id") Long id, @Param("description") String description);
+
+    Content findContent(@Param("id") Long id);
+
+    int delete(@Param("id") Long id);
+
+    /** 다운로드용. 파일 내용까지 읽는다. */
+    record Content(String fileName, String contentType, byte[] content) {
+    }
+}
 
src/main/java/kr/itn/itnhub/shared/SharedFileService.java (added)
+++ src/main/java/kr/itn/itnhub/shared/SharedFileService.java
@@ -0,0 +1,60 @@
+package kr.itn.itnhub.shared;
+
+import kr.itn.itnhub.org.OrgNotFoundException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/** 자료실(요구사항 [26]: 공용 양식 보관소)이 쓰는 서비스. */
+@Service
+public class SharedFileService {
+
+    private final SharedFileMapper mapper;
+
+    public SharedFileService(SharedFileMapper mapper) {
+        this.mapper = mapper;
+    }
+
+    public List<SharedFile> list() {
+        return mapper.findAll();
+    }
+
+    @Transactional
+    public List<SharedFile> add(String fileName, String description, String contentType,
+                                byte[] content, String uploadedBy) {
+        if (content == null || content.length == 0) {
+            throw new IllegalArgumentException("빈 파일은 올릴 수 없습니다.");
+        }
+        mapper.insert(fileName, blankToNull(description), contentType, content.length,
+                content, uploadedBy);
+        return mapper.findAll();
+    }
+
+    @Transactional
+    public List<SharedFile> updateDescription(Long id, String description) {
+        if (mapper.updateDescription(id, blankToNull(description)) == 0) {
+            throw new OrgNotFoundException("자료를 찾을 수 없습니다: " + id);
+        }
+        return mapper.findAll();
+    }
+
+    public SharedFileMapper.Content content(Long id) {
+        SharedFileMapper.Content found = mapper.findContent(id);
+        if (found == null) {
+            throw new OrgNotFoundException("자료를 찾을 수 없습니다: " + id);
+        }
+        return found;
+    }
+
+    @Transactional
+    public void delete(Long id) {
+        if (mapper.delete(id) == 0) {
+            throw new OrgNotFoundException("자료를 찾을 수 없습니다: " + id);
+        }
+    }
+
+    private String blankToNull(String value) {
+        return (value == null || value.isBlank()) ? null : value.trim();
+    }
+}
 
src/main/java/kr/itn/itnhub/stage/AutoStage.java (added)
+++ src/main/java/kr/itn/itnhub/stage/AutoStage.java
@@ -0,0 +1,25 @@
+package kr.itn.itnhub.stage;
+
+/**
+ * 자료가 올라온 것을 계기로 저절로 넘어가는 단계(요구사항 [18]).
+ *
+ * <p>기능확인 자료의 지시를 이 시스템의 실제 흐름에 맞춰 옮긴 것이다.
+ * 어느 자료가 어느 단계에 해당하는지는 답변 문서에 적어 확인 요청해 두었다.</p>
+ */
+public final class AutoStage {
+
+    private AutoStage() {
+    }
+
+    /** ②목록접수 — 기관 채널에 자료가 올라가고 공지로 고정까지 됐을 때. */
+    public static final int LIST_RECEIVED = 2;
+    public static final String REASON_LIST_RECEIVED = "채널에 자료 업로드 + 공지 고정";
+
+    /** ⑤법률검토(권리확인) — 권리확인 자료가 올라갔을 때. */
+    public static final int REVIEW_STARTED = 5;
+    public static final String REASON_REVIEW_STARTED = "권리확인 자료 업로드";
+
+    /** ⑧법률검토(권리처리) — 권리처리 자료가 올라갔을 때. */
+    public static final int PROCESS_STARTED = 8;
+    public static final String REASON_PROCESS_STARTED = "권리처리 자료 업로드";
+}
src/main/java/kr/itn/itnhub/stage/StageHistoryMapper.java
--- src/main/java/kr/itn/itnhub/stage/StageHistoryMapper.java
+++ src/main/java/kr/itn/itnhub/stage/StageHistoryMapper.java
@@ -11,7 +11,8 @@
     /** 최신순. */
     List<StageHistoryRow> findByOrg(@Param("orgId") Long orgId);
 
-    int insert(@Param("orgId") Long orgId, @Param("stage") int stage);
+    int insert(@Param("orgId") Long orgId, @Param("stage") int stage,
+               @Param("auto") boolean auto, @Param("reason") String reason);
 
     /** 채널 초기화용. 단계가 null로 돌아가므로 이력을 남겨두면 어긋난다. */
     int deleteByOrg(@Param("orgId") Long orgId);
src/main/java/kr/itn/itnhub/stage/StageHistoryRow.java
--- src/main/java/kr/itn/itnhub/stage/StageHistoryRow.java
+++ src/main/java/kr/itn/itnhub/stage/StageHistoryRow.java
@@ -5,10 +5,20 @@
 
     private int stage;
     private long changedAt;
+    /** 프로그램이 저절로 넘긴 것인지(요구사항 [19]: 이력에 자동/수동 구분 표시). */
+    private boolean auto;
+    /** 자동으로 넘어간 이유. 사람이 눌렀으면 null. */
+    private String reason;
 
     public int getStage() { return stage; }
     public void setStage(int stage) { this.stage = stage; }
 
     public long getChangedAt() { return changedAt; }
     public void setChangedAt(long changedAt) { this.changedAt = changedAt; }
+
+    public boolean isAuto() { return auto; }
+    public void setAuto(boolean auto) { this.auto = auto; }
+
+    public String getReason() { return reason; }
+    public void setReason(String reason) { this.reason = reason; }
 }
src/main/java/kr/itn/itnhub/stage/StageService.java
--- src/main/java/kr/itn/itnhub/stage/StageService.java
+++ src/main/java/kr/itn/itnhub/stage/StageService.java
@@ -1,5 +1,7 @@
 package kr.itn.itnhub.stage;
 
+import kr.itn.itnhub.code.CodeService;
+import kr.itn.itnhub.code.Codes;
 import kr.itn.itnhub.org.OrgNotFoundException;
 import kr.itn.itnhub.org.OrgResponse;
 import kr.itn.itnhub.org.Organization;
@@ -17,10 +19,13 @@
 
     private final OrganizationMapper orgMapper;
     private final StageHistoryMapper historyMapper;
+    private final CodeService codeService;
 
-    public StageService(OrganizationMapper orgMapper, StageHistoryMapper historyMapper) {
+    public StageService(OrganizationMapper orgMapper, StageHistoryMapper historyMapper,
+                        CodeService codeService) {
         this.orgMapper = orgMapper;
         this.historyMapper = historyMapper;
+        this.codeService = codeService;
     }
 
     @Transactional
@@ -30,17 +35,55 @@
             throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
         }
 
+        // 단계 개수는 코드표에서 온다. 예전에는 12가 박혀 있어서 단계를 늘릴 때
+        // 여기와 메시지까지 같이 고쳐야 했다.
+        int lastStage = codeService.group(Codes.STAGE).size();
         Integer stage = request.stage();
-        if (stage == null || stage < 1 || stage > 12) {
-            throw new InvalidStageException("진행단계는 1에서 12 사이의 값이어야 합니다.");
+        if (stage == null || stage < 1 || stage > lastStage) {
+            throw new InvalidStageException(
+                    "진행단계는 1에서 " + lastStage + " 사이의 값이어야 합니다.");
         }
 
         orgMapper.updateStage(orgId, stage);
-        historyMapper.insert(orgId, stage);
+        // 사람이 눌러서 넘긴 것이다(요구사항 [19]: 이력에 자동/수동 구분).
+        historyMapper.insert(orgId, stage, false, null);
 
         return OrgResponse.of(orgMapper.findById(orgId));
     }
 
+    /**
+     * 자료가 올라온 것을 계기로 단계를 저절로 넘긴다(요구사항 [18]).
+     *
+     * <p>앞으로만 간다. 이미 목표 단계에 있거나 그보다 앞서 있으면 아무 일도 하지 않는다 -
+     * 자료를 다시 올렸다고 단계가 뒤로 밀리면 안 되기 때문이다.</p>
+     *
+     * <p>채널이 없어 단계가 아직 없는 기관(stage=null)도 건드리지 않는다. 채널 생성 시
+     * ①신청으로 들어오는 것이 먼저다.</p>
+     *
+     * <p>사람이 이 단계를 다시 되돌리는 것은 막지 않는다(요구사항 [19]: 되돌림 허용).
+     * 진행단계 변경에서 이전 단계를 고르면 그대로 내려간다.</p>
+     *
+     * @return 실제로 넘어갔으면 true
+     */
+    @Transactional
+    public boolean autoAdvance(Long orgId, int targetStage, String reason) {
+        Organization org = orgMapper.findById(orgId);
+        if (org == null || org.getStage() == null) {
+            return false;
+        }
+        if (org.getStage() >= targetStage) {
+            return false;
+        }
+        int lastStage = codeService.group(Codes.STAGE).size();
+        if (targetStage < 1 || targetStage > lastStage) {
+            return false;
+        }
+
+        orgMapper.updateStage(orgId, targetStage);
+        historyMapper.insert(orgId, targetStage, true, reason);
+        return true;
+    }
+
     public java.util.List<StageHistoryRow> history(Long orgId) {
         if (orgMapper.findById(orgId) == null) {
             throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
src/main/resources/application.yml
--- src/main/resources/application.yml
+++ src/main/resources/application.yml
@@ -44,3 +44,6 @@
   admin:
     username: ${APP_ADMIN_USERNAME}
     password: ${APP_ADMIN_PASSWORD}
+    # 업무메모·연락이력·보고서에 작성자로 찍히는 이름(요구사항 [22]).
+    # 비워 두면 로그인 아이디가 그대로 찍힌다.
+    display-name: ${APP_ADMIN_DISPLAY_NAME:관리자}
 
src/main/resources/db/consistency/check-orphan-codes.sql (added)
+++ src/main/resources/db/consistency/check-orphan-codes.sql
@@ -0,0 +1,76 @@
+-- 운영 DB에 Phase 2(화면이 코드테이블만 보게 되는 단계)를 배포하기 전에 실행한다.
+--
+-- 결과가 한 줄이라도 나오면, 그 값을 active=false 코드로 code 테이블에 추가한 뒤
+-- 배포해야 한다. 값을 지우거나 다른 값으로 바꾸지 않는 이유는 이미 그 값으로 저장된
+-- 데이터가 있어서, 지우면 기존 화면에서 값이 조용히 사라지기 때문이다.
+--
+-- 실행 예:
+--   psql -h 192.168.0.60 -U itnhub -d itnhub -f check-orphan-codes.sql
+--
+-- 고아값을 발견했을 때의 보존 예:
+--   insert into code (group_id, code, label, sort_order, active)
+--   values ('CONTACT_METHOD', '문자', '문자', 90, false);
+
+select 'review_item.review_major' as source, review_major as value, count(*) as cnt
+  from review_item
+ where review_major is not null and review_major <> ''
+   and review_major not in (select code from code where group_id = 'REVIEW_MAJOR')
+ group by review_major
+
+union all
+select 'review_item.review_minor', review_minor, count(*)
+  from review_item
+ where review_minor is not null and review_minor <> ''
+   and review_minor not in (select code from code where group_id = 'REVIEW_MINOR')
+ group by review_minor
+
+union all
+select 'review_item.review_result', review_result, count(*)
+  from review_item
+ where review_result is not null and review_result <> ''
+   and review_result not in (select code from code where group_id = 'REVIEW_RESULT')
+ group by review_result
+
+union all
+select 'review_item.judged_kogl_type', judged_kogl_type, count(*)
+  from review_item
+ where judged_kogl_type is not null and judged_kogl_type <> ''
+   and judged_kogl_type not in (select code from code where group_id = 'KOGL_TYPE')
+ group by judged_kogl_type
+
+union all
+select 'review_item.kogl_type', kogl_type, count(*)
+  from review_item
+ where kogl_type is not null and kogl_type <> ''
+   and kogl_type not in (select code from code where group_id = 'KOGL_TYPE')
+ group by kogl_type
+
+union all
+select 'process_item.process_status', process_status, count(*)
+  from process_item
+ where process_status is not null and process_status <> ''
+   and process_status not in (select code from code where group_id = 'PROCESS_STATUS')
+ group by process_status
+
+union all
+select 'process_item.judged_kogl_type', judged_kogl_type, count(*)
+  from process_item
+ where judged_kogl_type is not null and judged_kogl_type <> ''
+   and judged_kogl_type not in (select code from code where group_id = 'KOGL_TYPE')
+ group by judged_kogl_type
+
+union all
+select 'process_item.prior_kogl_type', prior_kogl_type, count(*)
+  from process_item
+ where prior_kogl_type is not null and prior_kogl_type <> ''
+   and prior_kogl_type not in (select code from code where group_id = 'KOGL_TYPE')
+ group by prior_kogl_type
+
+union all
+select 'contact_log.method', method, count(*)
+  from contact_log
+ where method is not null and method <> ''
+   and method not in (select code from code where group_id = 'CONTACT_METHOD')
+ group by method
+
+order by 1, 2;
 
src/main/resources/db/migration/V13__common_code.sql (added)
+++ src/main/resources/db/migration/V13__common_code.sql
@@ -0,0 +1,159 @@
+-- 공통코드. 진행단계·처리결과·공공누리유형 같은 선택지를 소스가 아니라 데이터로 관리한다.
+--
+-- 저장값(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);
+
+
+-- ============================================================
+-- 시드
+-- ============================================================
+
+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 클래스는 프론트가 갖는다 - DB에 Tailwind 클래스를
+-- 넣으면 화면 스타일을 바꿀 때마다 DB를 고쳐야 한다.
+-- 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는 세부항목을 노출할지를 뜻한다(제3자 권리에서만 세부가 나온다).
+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는 고를 수 있는 처리결과 제한이다
+-- (2026-07-20 정책변경: 3·4번은 추진/미희망 택일, 초상권은 개방불가까지 3택).
+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가 참이면 기존 부착 유형을 복사하고 비고에 안내문구를 덧붙인다 - 이 "동작"까지
+-- 데이터로 옮기면 attrs 안에 규칙 언어를 만들게 되므로 코드에 남긴다.
+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);
+
+-- 요구사항 [24]는 미응답이라 현행 4종을 그대로 옮긴다. 답이 오면 데이터만 바꾸면 된다.
+-- 지금까지 서버 검증이 없어 아무 값이나 저장됐는데, 코드테이블 도입으로 검증도 생긴다.
+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);
 
src/main/resources/db/migration/V14__common_code_orphans.sql (added)
+++ src/main/resources/db/migration/V14__common_code_orphans.sql
@@ -0,0 +1,19 @@
+-- V13 시드를 넣은 뒤 운영 DB에 이미 저장된 값을 훑어보니, 코드테이블에 없는 값이 있었다.
+-- 화면이 코드테이블만 보게 되는 Phase 2 전에 이 값들을 코드로 등록해 둔다.
+-- 등록하지 않으면 아래 1,926건이 상세 화면에서 "선택 안 됨"으로 보인다.
+--
+-- 조사 시점(2026-07-28) 운영 DB 실측:
+--   review_item.kogl_type = '유형 확인 필요'  1,858건
+--   review_item.kogl_type = '제4유형'            68건
+--
+-- 둘 다 기관이 제출한 조사표에서 온 값이라 prior(기존 부착 유형) 자리에만 넣는다.
+-- 변호사가 고르는 판정값(review/process)에는 넣지 않는다.
+--
+-- '제4유형'은 '4유형'과 같은 뜻의 다른 표기다. 값을 통일하는 것은 데이터 변경이라
+-- 여기서 하지 않는다 - 표기 통일 여부는 별도로 확인받는다.
+
+insert into code (group_id, code, label, sort_order, active, attrs) values
+ ('KOGL_TYPE','유형 확인 필요','유형 확인 필요', 8, true,
+   '{"scopes":["prior"],"firstRow":[],"surveyOnly":true}'::jsonb),
+ ('KOGL_TYPE','제4유형',       '제4유형',        9, true,
+   '{"scopes":["prior"],"firstRow":[],"surveyOnly":true,"sameAs":"4유형"}'::jsonb);
 
src/main/resources/db/migration/V15__common_code_fix_display.sql (added)
+++ src/main/resources/db/migration/V15__common_code_fix_display.sql
@@ -0,0 +1,37 @@
+-- V13 시드의 화면 관련 값 두 가지가 실제 화면과 달랐다. Phase 2에서 화면이 코드테이블을
+-- 보게 되면 색과 표시명이 조용히 바뀌므로, 지금 값으로 맞춘다.
+--
+-- V13을 직접 고치지 않는 이유: 이미 적용된 마이그레이션을 수정하면 체크섬이 어긋나
+-- 다음 기동이 실패한다. 앞으로 나아가는 수정본을 따로 둔다.
+--
+-- 발견 경위: 시드를 넣고 나서 화면 소스의 색 정의와 대조해 보니 어긋나 있었다.
+-- 시드를 쓸 때 화면을 안 보고 의미만으로 색을 골랐던 것이 원인이다.
+
+-- (1) 업무구간 색. 기준은 Dashboard.tsx의 GROUP_COLOR(칸반 컬럼 색 띠)다.
+--     assign은 indigo, check는 emerald, closing은 violet이 맞다.
+--     cardBody 속성은 뺀다 - 칸반 카드 본문은 구간이 아니라 단계 번호로 갈리는 자리가 있어
+--     (1단계는 신청일만, 2·3단계는 목록 건수) 화면 로직으로 두는 편이 정확하다.
+update code set attrs = '{"tone":"slate"}'::jsonb   where group_id = 'STAGE_GROUP' and code = 'intake';
+update code set attrs = '{"tone":"indigo"}'::jsonb  where group_id = 'STAGE_GROUP' and code = 'assign';
+update code set attrs = '{"tone":"emerald"}'::jsonb where group_id = 'STAGE_GROUP' and code = 'check';
+update code set attrs = '{"tone":"amber"}'::jsonb   where group_id = 'STAGE_GROUP' and code = 'process';
+update code set attrs = '{"tone":"violet"}'::jsonb  where group_id = 'STAGE_GROUP' and code = 'closing';
+
+update code_group set description = '칸반 컬럼 색을 결정한다'
+ where group_id = 'STAGE_GROUP';
+
+-- (2) 담당자 구분. MJ는 화면에 '문정원'으로 나오는데 시드에 '주관기관'을 넣었다.
+--     '주관기관'은 담당자 명부 엑셀에만 쓰이는 표기라 importAlias 자리로 간다.
+--     뱃지 색 기준은 ContactsPage.tsx의 CATEGORY_BADGE_STYLES다.
+update code set label = '문정원' where group_id = 'CONTACT_CATEGORY' and code = 'MJ';
+
+update code set attrs = attrs || '{"tone":"blue"}'::jsonb
+ where group_id = 'CONTACT_CATEGORY' and code = 'APPLICANT';
+update code set attrs = attrs || '{"tone":"emerald"}'::jsonb
+ where group_id = 'CONTACT_CATEGORY' and code = 'MJ';
+update code set attrs = attrs || '{"tone":"violet"}'::jsonb
+ where group_id = 'CONTACT_CATEGORY' and code = 'LAWYER';
+update code set attrs = attrs || '{"tone":"sky"}'::jsonb
+ where group_id = 'CONTACT_CATEGORY' and code = 'OPERATOR';
+update code set attrs = attrs || '{"tone":"orange"}'::jsonb
+ where group_id = 'CONTACT_CATEGORY' and code = 'ITN';
 
src/main/resources/db/migration/V16__re_memo.sql (added)
+++ src/main/resources/db/migration/V16__re_memo.sql
@@ -0,0 +1,24 @@
+-- RE 메모(요구사항 [4][5]).
+--
+-- 회신 내용: "별도 양식까진 필요없고, 기관관리 메뉴의 RE 메뉴에서 메모장처럼 등록하는
+-- 기능을 만들어 주시면 됩니다. 메모 1개당 RE 1개입니다."
+--   예) ooo 계약서 및 제안요청서 송부 요청, 장영익, 2026.07.08
+--
+-- resolved(해결 여부)는 회신에 없던 항목이다. 대시보드 '미해결 RE' 숫자를 세려면 해결된
+-- 건과 아닌 건을 가릴 수단이 있어야 해서 완료 체크 한 칸으로 두었다. 확인 요청해 둔 상태다.
+
+create table re_memo (
+    id          bigserial primary key,
+    org_id      bigint       not null references organization (id) on delete cascade,
+    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()
+);
+
+-- 기관별 최신순 조회와 미해결 건수 집계를 함께 받는 인덱스.
+create index ix_re_memo_org on re_memo (org_id, memo_date desc, id desc);
+create index ix_re_memo_unresolved on re_memo (org_id) where not resolved;
 
src/main/resources/db/migration/V17__auto_stage_and_shared_files.sql (added)
+++ src/main/resources/db/migration/V17__auto_stage_and_shared_files.sql
@@ -0,0 +1,24 @@
+-- 요구사항 [19] · [26] 반영.
+
+-- [19] 단계 이력에 "자동으로 넘어간 것인지" 표시를 남긴다.
+-- 회신: 되돌림 허용 + 이력에 구분 표시.
+-- 기존 이력은 전부 사람이 눌러서 넘긴 것이므로 false가 맞다.
+alter table stage_history add column auto boolean not null default false;
+
+-- 자동 전환이 무엇 때문에 일어났는지 남긴다. 나중에 "왜 저절로 넘어갔지?"를 설명할 수 있어야 한다.
+alter table stage_history add column reason varchar(100);
+
+-- [26] 자료실 = 공용 양식 보관소.
+-- 기관에 딸리지 않는 공용 서식을 올려두는 자리다. 파일은 org_report와 같은 이유로
+-- DB에 직접 담는다(Mattermost 채널을 초기화해도 사라지면 안 된다).
+create table shared_file (
+    id           bigserial primary key,
+    file_name    varchar(300) not null,
+    description  varchar(500),
+    content_type varchar(200),
+    byte_size    bigint       not null,
+    content      bytea        not null,
+    uploaded_by  varchar(100) not null,
+    uploaded_at  timestamptz  not null default now()
+);
+create index ix_shared_file_recent on shared_file (uploaded_at desc, id desc);
 
src/main/resources/db/migration/V18__normalize_kogl_type.sql (added)
+++ src/main/resources/db/migration/V18__normalize_kogl_type.sql
@@ -0,0 +1,35 @@
+-- 공공누리 유형 표기를 「N유형」으로 통일한다.
+--
+-- 기관 조사표에 「제4유형」으로 적혀 온 자료가 68건 있었다. 나머지 자료와 코드표는 전부
+-- 「0유형 ~ 4유형」 형태라 같은 유형인데도 다른 값으로 잡혔다. 유형별로 세거나 걸러낼 때
+-- 둘이 따로 집계되므로 표기를 맞춘다.
+--
+-- '제숫자유형' 형태만 바꾼다. 「유형 확인 필요」처럼 뜻이 다른 값은 건드리지 않는다.
+
+update review_item
+   set kogl_type = regexp_replace(kogl_type, '^제([0-9]+)유형$', '\1유형')
+ where kogl_type ~ '^제[0-9]+유형$';
+
+update review_item
+   set judged_kogl_type = regexp_replace(judged_kogl_type, '^제([0-9]+)유형$', '\1유형')
+ where judged_kogl_type ~ '^제[0-9]+유형$';
+
+update process_item
+   set prior_kogl_type = regexp_replace(prior_kogl_type, '^제([0-9]+)유형$', '\1유형')
+ where prior_kogl_type ~ '^제[0-9]+유형$';
+
+update process_item
+   set review_kogl_type = regexp_replace(review_kogl_type, '^제([0-9]+)유형$', '\1유형')
+ where review_kogl_type ~ '^제[0-9]+유형$';
+
+update process_item
+   set judged_kogl_type = regexp_replace(judged_kogl_type, '^제([0-9]+)유형$', '\1유형')
+ where judged_kogl_type ~ '^제[0-9]+유형$';
+
+-- 「제4유형」은 이제 선택지에서 뺀다. 다만 행을 지우지는 않는다 -
+-- 기관이 예전 표기 그대로 엑셀을 다시 올릴 수 있고, 그때 sameAs를 보고 「4유형」으로
+-- 바꿔 넣어야 하기 때문이다(별칭 역할로 남긴다).
+update code
+   set active = false,
+       label = '제4유형 (옛 표기 → 4유형)'
+ where group_id = 'KOGL_TYPE' and code = '제4유형';
 
src/main/resources/mapper/CodeMapper.xml (added)
+++ src/main/resources/mapper/CodeMapper.xml
@@ -0,0 +1,22 @@
+<?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>
src/main/resources/mapper/DashboardMapper.xml
--- src/main/resources/mapper/DashboardMapper.xml
+++ src/main/resources/mapper/DashboardMapper.xml
@@ -41,8 +41,7 @@
     organization.updated_at만으로는 부족한 이유: 배정·단계·채널 변경 때만 갱신되고
     권리확인 업로드나 업무메모 작성은 이 컬럼을 건드리지 않는다.
 
-    re_count는 RE 테이블이 아직 없어서 0으로 고정한다. RE 모델이 들어오면 여기에
-    서브쿼리 하나만 붙이면 화면은 그대로 동작한다.
+    re_count는 아직 해결하지 않은 RE 메모 건수다(요구사항 [4]: 메모 1개 = RE 1개).
   -->
   <select id="findOrgRows" resultMap="orgRowResultMap">
     select
@@ -61,8 +60,12 @@
       (extract(epoch from sh.stage_at) * 1000)::bigint as stage_changed_at,
       (extract(epoch from greatest(o.updated_at, sh.any_at, rv.last_at, pr.last_at, wm.last_at)) * 1000)::bigint
         as last_changed_at,
-      0 as re_count
+      coalesce(re.unresolved, 0) as re_count
     from organization o
+    left join (
+      select org_id, count(*) as unresolved
+      from re_memo where not resolved group by org_id
+    ) re on re.org_id = o.id
     left join contact lc on lc.id = o.lawyer_contact_id
     left join contact mc on mc.id = o.mj_contact_id
     left join (
@@ -75,7 +78,7 @@
     left join (
       select org_id,
              count(*) as total,
-             count(*) filter (where process_status = '처리완료') as done,
+             count(*) filter (where process_status = #{doneStatus}) as done,
              max(updated_at) as last_at
       from process_item group by org_id
     ) pr on pr.org_id = o.id
src/main/resources/mapper/ProcessItemMapper.xml
--- src/main/resources/mapper/ProcessItemMapper.xml
+++ src/main/resources/mapper/ProcessItemMapper.xml
@@ -39,10 +39,10 @@
   <!-- status: null=전체, "DONE"=처리완료, "PENDING"=그 외(null/빈/미처리 등 처리완료가 아닌 전부). -->
   <sql id="statusFilter">
     <if test="status != null and status == 'DONE'">
-      and process_status = '처리완료'
+      and process_status = #{doneStatus}
     </if>
     <if test="status != null and status == 'PENDING'">
-      and (process_status is null or process_status = '' or process_status &lt;&gt; '처리완료')
+      and (process_status is null or process_status = '' or process_status &lt;&gt; #{doneStatus})
     </if>
   </sql>
 
@@ -145,7 +145,7 @@
       process_status   = #{processStatus},
       web_edited_at    = now(),
       processed_at     = case
-                            when #{processStatus} = '처리완료' and process_item.processed_at is null then now()
+                            when #{processStatus} = #{doneStatus} and process_item.processed_at is null then now()
                             else process_item.processed_at
                           end,
       updated_at       = now()
@@ -173,7 +173,8 @@
       process_status   = coalesce(#{processStatus}, process_status),
       judged_kogl_type = coalesce(#{judgedKoglType}, judged_kogl_type),
       final_opinion    = coalesce(#{finalOpinion}, final_opinion),
-      processed_at     = case when #{processStatus} = '처리완료' then now() else processed_at end,
+      review_note      = coalesce(#{reviewNote}, review_note),
+      processed_at     = case when #{processStatus} = #{doneStatus} then now() else processed_at end,
       web_edited_at    = now(),
       updated_at       = now()
     where org_id = #{orgId}
@@ -191,7 +192,7 @@
   <select id="stats" resultMap="statsResultMap">
     select
       count(*) as total,
-      count(*) filter (where process_status = '처리완료') as done
+      count(*) filter (where process_status = #{doneStatus}) as done
     from process_item
     where org_id = #{orgId}
   </select>
src/main/resources/mapper/ProjectMapper.xml
--- src/main/resources/mapper/ProjectMapper.xml
+++ src/main/resources/mapper/ProjectMapper.xml
@@ -71,6 +71,22 @@
     where id = #{id} and org_id = #{orgId}
   </select>
 
+  <!--
+    보고서 교체(요구사항 [25]). 같은 자리에 새 파일을 덮어쓰고 올린 사람·시각을 갱신한다.
+    새 행을 만들지 않는 이유는 "고쳐 올리기"가 개정판 쌓기와 다른 동작이기 때문이다 -
+    개정판으로 남기고 싶으면 지금처럼 새로 올리면 된다.
+  -->
+  <update id="updateReport">
+    update org_report set
+      file_name    = #{fileName},
+      content_type = #{contentType},
+      byte_size    = #{byteSize},
+      content      = #{content},
+      uploaded_by  = #{uploadedBy},
+      uploaded_at  = now()
+    where id = #{id} and org_id = #{orgId}
+  </update>
+
   <delete id="deleteReport">
     delete from org_report where id = #{id} and org_id = #{orgId}
   </delete>
 
src/main/resources/mapper/ReMemoMapper.xml (added)
+++ src/main/resources/mapper/ReMemoMapper.xml
@@ -0,0 +1,63 @@
+<?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.re.ReMemoMapper">
+
+  <!--
+    memo_date는 화면이 그대로 쓰도록 yyyy-MM-dd 문자열로, created_at은 다른 매퍼와 같은
+    관례로 밀리초 epoch로 내려준다.
+  -->
+  <sql id="columns">
+    id, org_id, content, author,
+    to_char(memo_date, 'YYYY-MM-DD') as memo_date,
+    resolved,
+    (extract(epoch from created_at) * 1000)::bigint as created_at
+  </sql>
+
+  <select id="findByOrg" resultType="kr.itn.itnhub.re.ReMemo">
+    select <include refid="columns"/>
+    from re_memo
+    where org_id = #{orgId}
+    order by memo_date desc, id desc
+  </select>
+
+  <select id="findById" resultType="kr.itn.itnhub.re.ReMemo">
+    select <include refid="columns"/>
+    from re_memo
+    where org_id = #{orgId} and id = #{id}
+  </select>
+
+  <!-- 일자를 비워 보내면 오늘로 넣는다. -->
+  <insert id="insert">
+    insert into re_memo (org_id, content, author, memo_date, resolved, resolved_at)
+    values (#{orgId}, #{content}, #{author},
+            coalesce(cast(#{memoDate} as date), current_date),
+            #{resolved},
+            case when #{resolved} then now() else null end)
+  </insert>
+
+  <!-- 해결로 바뀌는 순간만 resolved_at을 찍고, 되돌리면 지운다. -->
+  <update id="update">
+    update re_memo set
+      content     = #{content},
+      author      = #{author},
+      memo_date   = coalesce(cast(#{memoDate} as date), memo_date),
+      resolved    = #{resolved},
+      resolved_at = case
+                      when #{resolved} and resolved_at is null then now()
+                      when not #{resolved} then null
+                      else resolved_at
+                    end,
+      updated_at  = now()
+    where org_id = #{orgId} and id = #{id}
+  </update>
+
+  <delete id="delete">
+    delete from re_memo where org_id = #{orgId} and id = #{id}
+  </delete>
+
+  <delete id="deleteByOrg">
+    delete from re_memo where org_id = #{orgId}
+  </delete>
+
+</mapper>
src/main/resources/mapper/ReviewItemMapper.xml
--- src/main/resources/mapper/ReviewItemMapper.xml
+++ src/main/resources/mapper/ReviewItemMapper.xml
@@ -153,6 +153,7 @@
       review_result    = coalesce(#{reviewResult}, review_result),
       judged_kogl_type = coalesce(#{judgedKoglType}, judged_kogl_type),
       opinion          = coalesce(#{opinion}, opinion),
+      lawyer_note      = coalesce(#{lawyerNote}, lawyer_note),
       web_edited_at    = now(),
       updated_at       = now()
     where org_id = #{orgId}
 
src/main/resources/mapper/SharedFileMapper.xml (added)
+++ src/main/resources/mapper/SharedFileMapper.xml
@@ -0,0 +1,31 @@
+<?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.shared.SharedFileMapper">
+
+  <!-- 목록에는 파일 내용을 싣지 않는다. 서식이 커지면 목록 조회가 무거워진다. -->
+  <select id="findAll" resultType="kr.itn.itnhub.shared.SharedFile">
+    select id, file_name, description, byte_size, uploaded_by,
+           (extract(epoch from uploaded_at) * 1000)::bigint as uploaded_at
+    from shared_file
+    order by uploaded_at desc, id desc
+  </select>
+
+  <insert id="insert">
+    insert into shared_file (file_name, description, content_type, byte_size, content, uploaded_by)
+    values (#{fileName}, #{description}, #{contentType}, #{byteSize}, #{content}, #{uploadedBy})
+  </insert>
+
+  <update id="updateDescription">
+    update shared_file set description = #{description} where id = #{id}
+  </update>
+
+  <select id="findContent" resultType="kr.itn.itnhub.shared.SharedFileMapper$Content">
+    select file_name, content_type, content from shared_file where id = #{id}
+  </select>
+
+  <delete id="delete">
+    delete from shared_file where id = #{id}
+  </delete>
+
+</mapper>
src/main/resources/mapper/StageHistoryMapper.xml
--- src/main/resources/mapper/StageHistoryMapper.xml
+++ src/main/resources/mapper/StageHistoryMapper.xml
@@ -7,15 +7,17 @@
     changed_at을 밀리초 epoch로 내려준다 - WorkMemoMapper.created_at과 동일한 관례.
   -->
   <select id="findByOrg" resultType="kr.itn.itnhub.stage.StageHistoryRow">
-    select stage, (extract(epoch from changed_at) * 1000)::bigint as changed_at
+    select stage, (extract(epoch from changed_at) * 1000)::bigint as changed_at,
+           auto, reason
     from stage_history
     where org_id = #{orgId}
     order by changed_at desc, id desc
   </select>
 
+  <!-- auto=true면 프로그램이 저절로 넘긴 것이다(요구사항 [18][19]). reason에 계기를 남긴다. -->
   <insert id="insert">
-    insert into stage_history (org_id, stage)
-    values (#{orgId}, #{stage})
+    insert into stage_history (org_id, stage, auto, reason)
+    values (#{orgId}, #{stage}, #{auto}, #{reason})
   </insert>
 
   <!--
 
src/test/java/kr/itn/itnhub/code/CodeConsistencyTest.java (added)
+++ src/test/java/kr/itn/itnhub/code/CodeConsistencyTest.java
@@ -0,0 +1,73 @@
+package kr.itn.itnhub.code;
+
+import kr.itn.itnhub.AbstractDbTest;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * 업무 테이블에 저장된 값이 코드테이블에 다 있는지 확인한다.
+ *
+ * <p>운영 DB에는 정책 변경 전 값이나 공백만 다른 값이 남아 있을 수 있다. Phase 2에서
+ * 화면이 코드테이블만 보게 바뀌면 그런 값은 선택지에서 조용히 사라진다. 배포 전에
+ * {@code db/consistency/check-orphan-codes.sql}을 돌려 발견된 값을 active=false 코드로
+ * 추가해야 한다 - 지우지 않고 보존하는 이유는 이미 그 값으로 저장된 데이터가 있기 때문이다.</p>
+ */
+class CodeConsistencyTest extends AbstractDbTest {
+
+    @Autowired
+    JdbcTemplate jdbc;
+
+    @Autowired
+    CodeService codeService;
+
+    @Test
+    void 저장된_권리확인_값이_모두_코드테이블에_있다() {
+        assertThat(orphans("review_item", "review_major", Codes.REVIEW_MAJOR)).isEmpty();
+        assertThat(orphans("review_item", "review_minor", Codes.REVIEW_MINOR)).isEmpty();
+        assertThat(orphans("review_item", "review_result", Codes.REVIEW_RESULT)).isEmpty();
+        assertThat(orphans("review_item", "judged_kogl_type", Codes.KOGL_TYPE)).isEmpty();
+    }
+
+    @Test
+    void 저장된_권리처리_값이_모두_코드테이블에_있다() {
+        assertThat(orphans("process_item", "process_status", Codes.PROCESS_STATUS)).isEmpty();
+        assertThat(orphans("process_item", "judged_kogl_type", Codes.KOGL_TYPE)).isEmpty();
+    }
+
+    @Test
+    void 저장된_연락방법이_모두_코드테이블에_있다() {
+        assertThat(orphans("contact_log", "method", Codes.CONTACT_METHOD)).isEmpty();
+    }
+
+    @Test
+    void 점검이_고아값을_실제로_찾아낸다() {
+        jdbc.update("insert into organization (org_no, org_name, channel_slug) "
+                + "values ('900', '정합성테스트기관', '900')");
+        Long orgId = jdbc.queryForObject(
+                "select id from organization where org_no = '900'", Long.class);
+        jdbc.update("insert into contact_log (org_id, contacted_on, method, summary, author) "
+                + "values (?, current_date, '텔레그램', '코드테이블에 없는 값', 'admin')", orgId);
+
+        assertThat(orphans("contact_log", "method", Codes.CONTACT_METHOD))
+                .containsExactly("텔레그램");
+
+        jdbc.update("delete from contact_log where org_id = ?", orgId);
+        jdbc.update("delete from organization where id = ?", orgId);
+    }
+
+    /** 해당 컬럼에 저장돼 있으나 코드테이블에 없는 값. */
+    private List<String> orphans(String table, String column, String groupId) {
+        List<String> stored = jdbc.queryForList(
+                "select distinct " + column + " from " + table
+                        + " where " + column + " is not null and " + column + " <> ''",
+                String.class);
+        return stored.stream()
+                .filter(value -> codeService.find(groupId, value).isEmpty())
+                .toList();
+    }
+}
 
src/test/java/kr/itn/itnhub/code/CodeControllerTest.java (added)
+++ src/test/java/kr/itn/itnhub/code/CodeControllerTest.java
@@ -0,0 +1,46 @@
+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"))
+                // 0~4유형 + 개방불가 + 보류 + 조사표에서 온 '유형 확인 필요'.
+                // '제4유형'은 '4유형'으로 통일해 선택지에서 빠졌다(V18).
+                .andExpect(jsonPath("$.KOGL_TYPE.length()").value(8))
+                .andExpect(jsonPath("$.CONTACT_CATEGORY[0].code").value("APPLICANT"))
+                .andExpect(jsonPath("$.CONTACT_CATEGORY[0].label").value("신청기관"));
+    }
+
+    @Test
+    void 인증이_없으면_401이다() throws Exception {
+        mvc.perform(get("/api/meta/codes"))
+                .andExpect(status().isUnauthorized());
+    }
+}
 
src/test/java/kr/itn/itnhub/code/CodeMapperTest.java (added)
+++ src/test/java/kr/itn/itnhub/code/CodeMapperTest.java
@@ -0,0 +1,63 @@
+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")
+                .contains("done")
+                .contains("completed");
+    }
+
+    @Test
+    void 비활성_코드도_함께_읽는다() {
+        // 활성 필터는 CodeService가 용도에 따라 건다. 매퍼는 전부 내려줘야
+        // 과거 데이터의 라벨을 그릴 수 있다.
+        List<CodeRow> rows = mapper.findAllCodes();
+
+        assertThat(rows).allSatisfy(r -> assertThat(r.groupId()).isNotBlank());
+        assertThat(rows).anySatisfy(r -> assertThat(r.active()).isTrue());
+    }
+
+    @Test
+    void 그룹_목록을_읽는다() {
+        List<CodeGroup> groups = mapper.findAllGroups();
+
+        assertThat(groups).hasSize(12);
+        assertThat(groups).anySatisfy(g -> {
+            assertThat(g.groupId()).isEqualTo("STAGE");
+            assertThat(g.editable()).isFalse();
+        });
+        assertThat(groups).anySatisfy(g -> {
+            assertThat(g.groupId()).isEqualTo("CONTACT_METHOD");
+            assertThat(g.editable()).isTrue();
+        });
+    }
+}
 
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'");
+    }
+}
 
src/test/java/kr/itn/itnhub/code/CodeSeedTest.java (added)
+++ src/test/java/kr/itn/itnhub/code/CodeSeedTest.java
@@ -0,0 +1,250 @@
+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");
+        // 요구사항 [2]: 보고서가 실제로 올라온 기관만 완료로 센다.
+        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 공공누리_유형은_사용처별로_다르게_노출된다() {
+        // 세 화면의 목록이 7개/5개/6개로 달랐던 이유는 쓰이는 자리가 달라서다.
+        assertThat(koglScope("review")).hasSize(7)
+                .doesNotContain("유형 확인 필요")
+                .doesNotContain("제4유형");
+        assertThat(koglScope("process")).hasSize(6).doesNotContain("보류");
+    }
+
+    @Test
+    void 기존_부착유형에는_조사표에서_온_값도_들어간다() {
+        // 운영 DB 실측(2026-07-28): kogl_type에 '유형 확인 필요' 1,858건이 저장돼 있었다.
+        // 코드로 등록해 두지 않으면 상세 화면에서 선택 안 됨으로 보인다.
+        // '제4유형'은 '4유형'으로 통일했으므로 선택지에서는 빠진다(V18).
+        assertThat(koglScope("prior")).containsExactly(
+                "0유형", "1유형", "2유형", "3유형", "4유형", "유형 확인 필요");
+    }
+
+    /**
+     * 「제4유형」은 지웠으면 안 된다. 기관이 옛 표기 그대로 엑셀을 다시 올릴 수 있고,
+     * 그때 sameAs를 보고 「4유형」으로 바꿔 넣어야 한다.
+     */
+    @Test
+    void 통일된_옛_표기는_별칭으로_남아_있다() {
+        String active = jdbc.queryForObject(
+                "select active::text from code where group_id = 'KOGL_TYPE' and code = '제4유형'",
+                String.class);
+        assertThat(active).isEqualTo("false");
+        assertThat(attr("KOGL_TYPE", "제4유형", "sameAs")).isEqualTo("4유형");
+    }
+
+    @Test
+    void 조사표에서_온_값은_변호사_판정_선택지가_아니다() {
+        Integer leaked = jdbc.queryForObject(
+                "select count(*) from code c "
+                        + "where c.group_id = 'KOGL_TYPE' "
+                        + "  and (c.attrs->>'surveyOnly')::boolean is true "
+                        + "  and exists (select 1 from jsonb_array_elements_text(c.attrs->'scopes') s(v) "
+                        + "    where s.v in ('review', 'process'))",
+                Integer.class);
+
+        assertThat(leaked).isZero();
+    }
+
+    /** 화면 선택지에 실제로 나가는 것만 본다. 서버는 활성 코드만 내려준다. */
+    private List<String> koglScope(String scope) {
+        return jdbc.queryForList(
+                "select code from code c where c.group_id = 'KOGL_TYPE' and c.active "
+                        + "  and exists (select 1 from jsonb_array_elements_text(c.attrs->'scopes') s(v) "
+                        + "    where s.v = ?) "
+                        + "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 orphan = jdbc.queryForObject(
+                "select count(*) from code c "
+                        + "where c.group_id in ('REVIEW_MAJOR', 'REVIEW_MINOR') "
+                        + "  and c.attrs->>'suggest' is not null "
+                        + "  and not exists (select 1 from code r "
+                        + "    where r.group_id = 'REVIEW_RESULT' and r.code = c.attrs->>'suggest')",
+                Integer.class);
+
+        assertThat(orphan).isZero();
+    }
+
+    @Test
+    void 처리결과가_지정하는_공공누리_유형도_실제_코드다() {
+        Integer orphan = jdbc.queryForObject(
+                "select count(*) from code c "
+                        + "where c.group_id = 'REVIEW_RESULT' "
+                        + "  and c.attrs->>'koglSet' is not null "
+                        + "  and not exists (select 1 from code k "
+                        + "    where k.group_id = 'KOGL_TYPE' and k.code = c.attrs->>'koglSet')",
+                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();
+    }
+
+    /**
+     * tone은 화면에 실제로 쓰이는 색과 같아야 한다. 다르면 Phase 2에서 화면 색이 조용히 바뀐다.
+     * 기준: Dashboard.tsx의 GROUP_COLOR(칸반 컬럼 색 띠).
+     */
+    @Test
+    void 업무구간_색이_지금_화면과_같다() {
+        assertThat(tone("STAGE_GROUP", "intake")).isEqualTo("slate");
+        assertThat(tone("STAGE_GROUP", "assign")).isEqualTo("indigo");
+        assertThat(tone("STAGE_GROUP", "check")).isEqualTo("emerald");
+        assertThat(tone("STAGE_GROUP", "process")).isEqualTo("amber");
+        assertThat(tone("STAGE_GROUP", "closing")).isEqualTo("violet");
+    }
+
+    /** 기준: ContactsPage.tsx의 CATEGORY_BADGE_STYLES. */
+    @Test
+    void 담당자구분_색이_지금_화면과_같다() {
+        assertThat(tone("CONTACT_CATEGORY", "APPLICANT")).isEqualTo("blue");
+        assertThat(tone("CONTACT_CATEGORY", "MJ")).isEqualTo("emerald");
+        assertThat(tone("CONTACT_CATEGORY", "LAWYER")).isEqualTo("violet");
+        assertThat(tone("CONTACT_CATEGORY", "OPERATOR")).isEqualTo("sky");
+        assertThat(tone("CONTACT_CATEGORY", "ITN")).isEqualTo("orange");
+    }
+
+    /** 기준: ReviewBoard.tsx의 REVIEW_RESULT_BADGE_STYLES. */
+    @Test
+    void 처리결과_색이_지금_화면과_같다() {
+        assertThat(tone("REVIEW_RESULT", "신유형 개방")).isEqualTo("emerald");
+        assertThat(tone("REVIEW_RESULT", "계약서 등 재확인")).isEqualTo("amber");
+        assertThat(tone("REVIEW_RESULT", "권리처리 추진")).isEqualTo("blue");
+        assertThat(tone("REVIEW_RESULT", "권리처리 추진 미희망")).isEqualTo("slate");
+        assertThat(tone("REVIEW_RESULT", "개방불가")).isEqualTo("red");
+    }
+
+    /**
+     * MJ는 화면에 '문정원'으로 나오지만 담당자 명부 엑셀에는 '주관기관'으로 적혀 온다.
+     * 이 둘을 헷갈리면 화면 표시가 바뀌거나 엑셀 업로드가 깨진다.
+     */
+    @Test
+    void 담당자구분은_화면표시와_엑셀표기를_따로_갖는다() {
+        assertThat(label("CONTACT_CATEGORY", "MJ")).isEqualTo("문정원");
+        assertThat(attr("CONTACT_CATEGORY", "MJ", "importAlias")).isEqualTo("주관기관");
+        // 나머지는 화면표시와 엑셀표기가 같다.
+        assertThat(label("CONTACT_CATEGORY", "LAWYER")).isEqualTo("변호사");
+        assertThat(attr("CONTACT_CATEGORY", "LAWYER", "importAlias")).isEqualTo("변호사");
+    }
+
+    private String tone(String groupId, String code) {
+        return attr(groupId, code, "tone");
+    }
+
+    private String attr(String groupId, String code, String key) {
+        return jdbc.queryForObject(
+                "select attrs->>? from code where group_id = ? and code = ?",
+                String.class, key, groupId, code);
+    }
+
+    private String label(String groupId, String code) {
+        return jdbc.queryForObject(
+                "select label from code where group_id = ? and code = ?",
+                String.class, groupId, code);
+    }
+
+    @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();
+    }
+}
 
src/test/java/kr/itn/itnhub/code/CodeServiceTest.java (added)
+++ src/test/java/kr/itn/itnhub/code/CodeServiceTest.java
@@ -0,0 +1,134 @@
+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");
+        assertThat(stage13.attr("없는속성")).isNull();
+    }
+
+    @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();
+        assertThat(service.find(Codes.CONTRACT_DOC, "기타").orElseThrow().flag("freeText")).isTrue();
+    }
+
+    @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("보류"));
+        // prior에는 기관 조사표에서 온 값('유형 확인 필요')까지 들어간다.
+        // 운영 DB에 실제로 저장돼 있는 값이라 등록해 두지 않으면 화면에서 선택 안 됨으로 보인다.
+        assertThat(service.scoped(Codes.KOGL_TYPE, Codes.SCOPE_PRIOR))
+                .extracting(Code::code)
+                .containsExactly("0유형", "1유형", "2유형", "3유형", "4유형", "유형 확인 필요");
+    }
+
+    @Test
+    void 옛_표기를_표준_표기로_바꿔_준다() {
+        // 「제4유형」으로 적혀 온 값은 「4유형」으로 통일한다.
+        assertThat(service.canonical(Codes.KOGL_TYPE, "제4유형")).isEqualTo("4유형");
+        // 표준 표기는 그대로 둔다.
+        assertThat(service.canonical(Codes.KOGL_TYPE, "4유형")).isEqualTo("4유형");
+        // 뜻이 다른 값은 건드리지 않는다.
+        assertThat(service.canonical(Codes.KOGL_TYPE, "유형 확인 필요")).isEqualTo("유형 확인 필요");
+        // 모르는 값도 그대로 돌려준다.
+        assertThat(service.canonical(Codes.KOGL_TYPE, "이상한값")).isEqualTo("이상한값");
+        assertThat(service.canonical(Codes.KOGL_TYPE, null)).isNull();
+    }
+
+    @Test
+    void scopes가_없는_그룹은_빈_결과다() {
+        assertThat(service.scoped(Codes.CONTACT_METHOD, Codes.SCOPE_REVIEW)).isEmpty();
+    }
+
+    @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();
+        assertThat(service.find("NO_SUCH_GROUP", "A")).isEmpty();
+        assertThat(service.find(Codes.STAGE, "999")).isEmpty();
+    }
+
+    @Test
+    void 그룹_메타를_돌려준다() {
+        assertThat(service.groups())
+                .hasSize(12)
+                .anySatisfy(g -> {
+                    assertThat(g.groupId()).isEqualTo("STAGE");
+                    assertThat(g.groupName()).isEqualTo("진행단계");
+                });
+    }
+}
src/test/java/kr/itn/itnhub/dashboard/DashboardControllerTest.java
--- src/test/java/kr/itn/itnhub/dashboard/DashboardControllerTest.java
+++ src/test/java/kr/itn/itnhub/dashboard/DashboardControllerTest.java
@@ -85,6 +85,11 @@
                 orgId, seq, status);
     }
 
+    /**
+     * KPI 기준 단계는 코드표(STAGE의 kpiFrom)에서 온다. 요구사항 [2]로 완료 기준이
+     * ⑪법률검토 최종완료에서 ⑬보고서 제출 완료로 올라갔다 - 보고서가 실제로 올라온
+     * 기관만 완료로 센다. 그래서 11단계인 003은 더 이상 완료가 아니다.
+     */
     @Test
     void KPI는_전체_기관과_단계별_도달_기관수를_센다() throws Exception {
         mvc.perform(get("/api/dashboard"))
@@ -93,13 +98,23 @@
                 .andExpect(jsonPath("$.summary.applied").value(3))
                 // 서류제출 = 2단계 이상 (002, 003)
                 .andExpect(jsonPath("$.summary.docSubmitted").value(2))
-                // 완료 = 11단계 이상 (003)
-                .andExpect(jsonPath("$.summary.completed").value(1))
+                // 완료 = 13단계 이상 (없음)
+                .andExpect(jsonPath("$.summary.completed").value(0))
                 // 보고서 작성 = 12단계 이상 (없음)
                 .andExpect(jsonPath("$.summary.reportWriting").value(0));
     }
 
     @Test
+    void 완료는_보고서_제출까지_끝난_기관만_센다() throws Exception {
+        setStage(stage11Id, 13);
+
+        mvc.perform(get("/api/dashboard"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.summary.completed").value(1))
+                .andExpect(jsonPath("$.summary.reportWriting").value(1));
+    }
+
+    @Test
     void KPI의_권리확인_권리처리는_전_기관_건수_합계다() throws Exception {
         mvc.perform(get("/api/dashboard"))
                 .andExpect(status().isOk())
 
src/test/java/kr/itn/itnhub/feed/ChannelUploadAutoStageTest.java (added)
+++ src/test/java/kr/itn/itnhub/feed/ChannelUploadAutoStageTest.java
@@ -0,0 +1,124 @@
+package kr.itn.itnhub.feed;
+
+import kr.itn.itnhub.AbstractDbTest;
+import kr.itn.itnhub.mattermost.MattermostClient;
+import kr.itn.itnhub.mattermost.MattermostException;
+import kr.itn.itnhub.org.Organization;
+import kr.itn.itnhub.org.OrganizationMapper;
+import kr.itn.itnhub.stage.AutoStage;
+import kr.itn.itnhub.stage.StageHistoryMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.when;
+
+/**
+ * 「목록접수 = Mattermost에 첫 자료 업로드 + 핀 고정 시」(기능확인 PPT 14쪽, 요구사항 [18]).
+ *
+ * <p>조건이 둘이라는 점이 핵심이다. 자료만 올리고 공지로 고정하지 않았으면 접수가 아니고,
+ * 자료 없이 공지만 올린 것도 접수가 아니다.</p>
+ */
+class ChannelUploadAutoStageTest extends AbstractDbTest {
+
+    @Autowired
+    ChannelFeedService feedService;
+
+    @Autowired
+    OrganizationMapper orgMapper;
+
+    @Autowired
+    StageHistoryMapper historyMapper;
+
+    @Autowired
+    JdbcTemplate jdbc;
+
+    @MockBean
+    MattermostClient mattermost;
+
+    private Long orgId;
+
+    @BeforeEach
+    void setUp() {
+        jdbc.update("delete from stage_history");
+        jdbc.update("delete from organization where org_no = '940'");
+
+        Organization org = new Organization();
+        org.setOrgNo("940");
+        org.setOrgName("업로드전환테스트기관");
+        org.setChannelSlug("940");
+        orgMapper.upsertBySeed(org);
+        orgId = orgMapper.findByOrgNoAndOrgName("940", "업로드전환테스트기관").getId();
+        orgMapper.updateChannelIdMj(orgId, "chan-mj");
+        orgMapper.updateChannelIdLaw(orgId, "chan-law");
+
+        // 채널 생성으로 ①신청까지 와 있는 상태를 만든다.
+        orgMapper.updateStage(orgId, 1);
+        historyMapper.insert(orgId, 1, true, "채널 생성");
+
+        when(mattermost.uploadFile(anyString(), anyString(), any(), anyString()))
+                .thenReturn("file-1");
+        when(mattermost.createPost(anyString(), anyString(), any())).thenReturn("post-1");
+        when(mattermost.getRecentPosts(anyString(), any(Integer.class))).thenReturn(List.of());
+        when(mattermost.getPinnedPosts(anyString())).thenReturn(List.of());
+    }
+
+    private UploadedFile file() {
+        return new UploadedFile("목록.xlsx", new byte[]{1, 2, 3},
+                "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+    }
+
+    private Integer stage() {
+        return orgMapper.findById(orgId).getStage();
+    }
+
+    @Test
+    void 자료를_공지로_고정해서_올리면_목록접수로_넘어간다() {
+        feedService.sendMessage(orgId, "mj", "목록 보냅니다", List.of(file()), true);
+
+        assertThat(stage()).isEqualTo(AutoStage.LIST_RECEIVED);
+        assertThat(historyMapper.findByOrg(orgId).get(0).isAuto()).isTrue();
+        assertThat(historyMapper.findByOrg(orgId).get(0).getReason())
+                .isEqualTo(AutoStage.REASON_LIST_RECEIVED);
+    }
+
+    @Test
+    void 자료만_올리고_고정하지_않으면_넘어가지_않는다() {
+        feedService.sendMessage(orgId, "mj", "그냥 파일 하나 드립니다", List.of(file()), false);
+
+        assertThat(stage()).isEqualTo(1);
+    }
+
+    @Test
+    void 자료_없이_공지만_올리면_넘어가지_않는다() {
+        feedService.sendMessage(orgId, "mj", "안내드립니다", List.of(), true);
+
+        assertThat(stage()).isEqualTo(1);
+    }
+
+    @Test
+    void 고정에_실패하면_단계도_넘기지_않는다() {
+        doThrow(new MattermostException("핀 고정 실패")).when(mattermost).pinPost(anyString());
+
+        feedService.sendMessage(orgId, "mj", "목록 보냅니다", List.of(file()), true);
+
+        assertThat(stage()).isEqualTo(1);
+    }
+
+    @Test
+    void 이미_더_앞선_단계면_뒤로_밀지_않는다() {
+        orgMapper.updateStage(orgId, 7);
+
+        feedService.sendMessage(orgId, "mj", "목록 보냅니다", List.of(file()), true);
+
+        assertThat(stage()).isEqualTo(7);
+    }
+}
src/test/java/kr/itn/itnhub/memo/WorkMemoControllerTest.java
--- src/test/java/kr/itn/itnhub/memo/WorkMemoControllerTest.java
+++ src/test/java/kr/itn/itnhub/memo/WorkMemoControllerTest.java
@@ -66,13 +66,13 @@
                                 """))
                 .andExpect(status().isOk())
                 // 작성자는 요청 본문이 아니라 로그인 사용자에서 온다
-                .andExpect(jsonPath("$.contactName").value("admin"))
+                .andExpect(jsonPath("$.contactName").value("관리자"))
                 .andExpect(jsonPath("$.body").value("채널 생성 일정 문의"))
                 .andExpect(jsonPath("$.createdAt").isNumber());
 
         mvc.perform(get("/api/orgs/{id}/memos", orgId))
                 .andExpect(status().isOk())
-                .andExpect(jsonPath("$[0].contactName").value("admin"))
+                .andExpect(jsonPath("$[0].contactName").value("관리자"))
                 .andExpect(jsonPath("$[0].body").value("채널 생성 일정 문의"));
     }
 
@@ -192,7 +192,7 @@
                                 """))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.body").value("고친 뒤"))
-                .andExpect(jsonPath("$.contactName").value("admin"))
+                .andExpect(jsonPath("$.contactName").value("관리자"))
                 .andExpect(jsonPath("$.createdAt").value(createdAt));
     }
 
 
src/test/java/kr/itn/itnhub/process/ProcessDoneStatusTest.java (added)
+++ src/test/java/kr/itn/itnhub/process/ProcessDoneStatusTest.java
@@ -0,0 +1,110 @@
+package kr.itn.itnhub.process;
+
+import kr.itn.itnhub.AbstractDbTest;
+import kr.itn.itnhub.code.CodeService;
+import kr.itn.itnhub.org.Organization;
+import kr.itn.itnhub.org.OrganizationMapper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * "어느 값이 처리완료인가"가 코드표에서 온다는 것을 확인한다.
+ *
+ * <p>쿼리에 '처리완료'가 박혀 있던 시절에는 코드표를 바꿔도 집계가 그대로였다. 이 테스트는
+ * 코드표의 done 속성을 옮기면 진행률도 따라 움직이는지를 본다 - 파라미터화가 형식만 바뀐 게
+ * 아니라 실제로 동작하는지를 증명하는 자리다.</p>
+ */
+class ProcessDoneStatusTest extends AbstractDbTest {
+
+    @Autowired
+    ProcessService service;
+
+    @Autowired
+    OrganizationMapper orgMapper;
+
+    @Autowired
+    CodeService codeService;
+
+    @Autowired
+    JdbcTemplate jdbc;
+
+    private Long orgId;
+
+    @BeforeEach
+    void setUp() {
+        jdbc.update("delete from process_item");
+        jdbc.update("delete from organization where org_no = '910'");
+
+        Organization org = new Organization();
+        org.setOrgNo("910");
+        org.setOrgName("처리상태테스트기관");
+        org.setChannelSlug("910");
+        orgMapper.upsertBySeed(org);
+        orgId = orgMapper.findByOrgNoAndOrgName("910", "처리상태테스트기관").getId();
+
+        insert(1, "처리완료");
+        insert(2, "처리완료");
+        insert(3, "미처리");
+    }
+
+    @AfterEach
+    void restore() {
+        jdbc.update("update code set attrs = jsonb_set(attrs, '{done}', 'true') "
+                + "where group_id = 'PROCESS_STATUS' and code = '처리완료'");
+        jdbc.update("update code set attrs = jsonb_set(attrs, '{done}', 'false') "
+                + "where group_id = 'PROCESS_STATUS' and code = '미처리'");
+        codeService.reload();
+
+        jdbc.update("delete from process_item");
+        jdbc.update("delete from organization where org_no = '910'");
+    }
+
+    private void insert(int seq, String status) {
+        jdbc.update("insert into process_item (org_id, seq, process_status) values (?, ?, ?)",
+                orgId, seq, status);
+    }
+
+    @Test
+    void 기본_설정에서는_처리완료가_완료로_집계된다() {
+        ProcessPage page = service.list(orgId, null, null, null, null, null, 0, 30);
+
+        assertThat(page.total()).isEqualTo(3);
+        assertThat(page.done()).isEqualTo(2);
+    }
+
+    @Test
+    void 코드표에서_done을_옮기면_집계도_따라_움직인다() {
+        // '미처리'를 완료로, '처리완료'를 미완료로 뒤집는다. 쿼리에 값이 박혀 있었다면
+        // 아무 변화도 없었을 것이다.
+        jdbc.update("update code set attrs = jsonb_set(attrs, '{done}', 'false') "
+                + "where group_id = 'PROCESS_STATUS' and code = '처리완료'");
+        jdbc.update("update code set attrs = jsonb_set(attrs, '{done}', 'true') "
+                + "where group_id = 'PROCESS_STATUS' and code = '미처리'");
+        codeService.reload();
+
+        ProcessPage page = service.list(orgId, null, null, null, null, null, 0, 30);
+
+        assertThat(page.total()).isEqualTo(3);
+        assertThat(page.done()).isEqualTo(1);
+    }
+
+    @Test
+    void 상태_필터도_코드표를_따른다() {
+        assertThat(service.list(orgId, null, null, null, null, "DONE", 0, 30).items()).hasSize(2);
+        assertThat(service.list(orgId, null, null, null, null, "PENDING", 0, 30).items()).hasSize(1);
+
+        jdbc.update("update code set attrs = jsonb_set(attrs, '{done}', 'false') "
+                + "where group_id = 'PROCESS_STATUS' and code = '처리완료'");
+        jdbc.update("update code set attrs = jsonb_set(attrs, '{done}', 'true') "
+                + "where group_id = 'PROCESS_STATUS' and code = '미처리'");
+        codeService.reload();
+
+        assertThat(service.list(orgId, null, null, null, null, "DONE", 0, 30).items()).hasSize(1);
+        assertThat(service.list(orgId, null, null, null, null, "PENDING", 0, 30).items()).hasSize(2);
+    }
+}
 
src/test/java/kr/itn/itnhub/process/ProcessExcelRoundTripTest.java (added)
+++ src/test/java/kr/itn/itnhub/process/ProcessExcelRoundTripTest.java
@@ -0,0 +1,166 @@
+package kr.itn.itnhub.process;
+
+import kr.itn.itnhub.AbstractDbTest;
+import kr.itn.itnhub.org.Organization;
+import kr.itn.itnhub.org.OrganizationMapper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import java.io.ByteArrayInputStream;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * 권리처리 엑셀의 업로드↔다운로드 왕복.
+ *
+ * <p>업로드는 컬럼 번호({@code ProcessParser.COL_*})로, 다운로드는 헤더 이름
+ * ({@code ProcessService.COLUMN_HEADERS})으로 각각 따로 정의돼 있다. 두 정의가 어긋나면
+ * "내려받아 고쳐서 다시 올리는" 흐름에서 값이 조용히 사라진다. 그 어긋남을 여기서 잡는다.</p>
+ */
+class ProcessExcelRoundTripTest extends AbstractDbTest {
+
+    @Autowired
+    ProcessService service;
+
+    @Autowired
+    ProcessParser parser;
+
+    @Autowired
+    OrganizationMapper orgMapper;
+
+    @Autowired
+    JdbcTemplate jdbc;
+
+    private Long orgId;
+
+    @BeforeEach
+    void setUp() {
+        jdbc.update("delete from process_item");
+        jdbc.update("delete from organization where org_no = '920'");
+
+        Organization org = new Organization();
+        org.setOrgNo("920");
+        org.setOrgName("왕복테스트기관");
+        org.setChannelSlug("920");
+        orgMapper.upsertBySeed(org);
+        orgId = orgMapper.findByOrgNoAndOrgName("920", "왕복테스트기관").getId();
+    }
+
+    @AfterEach
+    void cleanUp() {
+        jdbc.update("delete from process_item");
+        jdbc.update("delete from organization where org_no = '920'");
+    }
+
+    /** 헤더 이름과 파서가 읽는 컬럼이 같은 자리를 가리키는지. 한쪽만 고치면 여기서 깨진다. */
+    @Test
+    void 헤더_이름과_파서_컬럼이_같은_자리를_가리킨다() {
+        assertHeader(ProcessParser.COL_SEQ, "순번");
+        assertHeader(ProcessParser.COL_SITE_NAME, "사이트명");
+        assertHeader(ProcessParser.COL_CATEGORY, "범주");
+        assertHeader(ProcessParser.COL_BOARD_NAME, "게시판명");
+        assertHeader(ProcessParser.COL_POST_TITLE, "게시물제목");
+        assertHeader(ProcessParser.COL_URL, "URL주소");
+        assertHeader(ProcessParser.COL_DESCRIPTION, "설명");
+        assertHeader(ProcessParser.COL_HAS_ATTACHMENT, "첨부파일여부");
+        assertHeader(ProcessParser.COL_PRIOR_KOGL_TYPE, "기존 공공누리유형(+AI)");
+        assertHeader(ProcessParser.COL_CONTRACT_DOCS, "계약서 유무");
+        assertHeader(ProcessParser.COL_PRODUCED_DATE, "제작일");
+        assertHeader(ProcessParser.COL_PUBLISHED_DATE, "공표일");
+        assertHeader(ProcessParser.COL_REVIEW_MAJOR, "권리확인(대분류)");
+        assertHeader(ProcessParser.COL_REVIEW_MINOR, "권리확인(세부)");
+        assertHeader(ProcessParser.COL_REVIEW_RESULT, "처리 구분");
+        assertHeader(ProcessParser.COL_REVIEW_KOGL_TYPE, "공공누리 유형");
+        assertHeader(ProcessParser.COL_REVIEW_AI_TYPE, "AI유형");
+        assertHeader(ProcessParser.COL_REVIEW_OPINION, "의견");
+        assertHeader(ProcessParser.COL_REVIEW_NOTE, "비고");
+        assertHeader(ProcessParser.COL_JUDGED_KOGL_TYPE, "공공누리유형(판정)");
+        assertHeader(ProcessParser.COL_JUDGED_AI_TYPE, "AI유형(판정)");
+        assertHeader(ProcessParser.COL_FINAL_OPINION, "최종의견");
+        assertHeader(ProcessParser.COL_JUDGMENT_BASIS, "판단근거");
+        assertHeader(ProcessParser.COL_PROCESS_STATUS, "처리상태");
+    }
+
+    private void assertHeader(int column, String expected) {
+        assertThat(ProcessService.COLUMN_HEADERS[column])
+                .as("%d번 열의 헤더", column)
+                .isEqualTo(expected);
+    }
+
+    /**
+     * 기존 증빙자료는 예외다. 기관이 보내오는 실제 파일은 헤더가 T열인데 데이터가 U열에 있어
+     * 파서가 U를 먼저 읽고, 비어 있으면 헤더 자리인 T를 읽는다. 이 어긋남 자체를 명시적으로
+     * 남겨 두어, 나중에 누가 "헤더랑 다르네" 하고 한쪽만 고치다 깨뜨리지 않게 한다.
+     */
+    @Test
+    void 기존_증빙자료만_헤더와_데이터_열이_다르다() {
+        assertThat(ProcessService.COLUMN_HEADERS[19]).isEqualTo("기존 증빙자료");
+        assertThat(ProcessParser.COL_PRIOR_EVIDENCE_HEADER).isEqualTo(19);
+        assertThat(ProcessParser.COL_PRIOR_EVIDENCE).isEqualTo(20);
+    }
+
+    /** 기관 원본처럼 U열에 값이 있는 파일도 그대로 읽어야 한다(다운로드 파일만 고치면 안 된다). */
+    @Test
+    void 원본처럼_U열에_값이_있어도_읽는다() throws Exception {
+        byte[] xlsx = ProcessParserFixtures.withPriorEvidenceAt(ProcessParser.COL_PRIOR_EVIDENCE, "원본증빙");
+
+        List<ProcessRow> rows = parser.parse(new ByteArrayInputStream(xlsx));
+
+        assertThat(rows).hasSize(1);
+        assertThat(rows.get(0).priorEvidence()).isEqualTo("원본증빙");
+    }
+
+    /** 내려받은 파일을 그대로 다시 올렸을 때 값이 살아남아야 한다. */
+    @Test
+    void 내려받아_다시_올려도_값이_그대로다() {
+        jdbc.update("insert into process_item (org_id, seq, site_name, category, board_name, "
+                        + "post_title, url, description, has_attachment, prior_kogl_type, contract_docs, "
+                        + "produced_date, published_date, review_major, review_minor, review_result, "
+                        + "review_kogl_type, review_ai_type, review_opinion, review_note, prior_evidence, "
+                        + "judged_kogl_type, judged_ai_type, final_opinion, judgment_basis, process_status) "
+                        + "values (?, 1, '사이트', '범주', '게시판', '제목', 'http://x', '설명', '있음', "
+                        + "'4유형', '양도계약서', '2026-01-02', '2026-01-03', '제3자 권리', '초상권 포함', "
+                        + "'권리처리 추진', '보류', 'Y', '의견', '비고', '증빙자료', '0유형', 'Y', "
+                        + "'최종의견', '판단근거', '처리완료')",
+                orgId);
+
+        byte[] xlsx = service.downloadWorkbook(orgId);
+        List<ProcessRow> rows = parser.parse(new ByteArrayInputStream(xlsx));
+
+        assertThat(rows).hasSize(1);
+        ProcessRow row = rows.get(0);
+
+        assertThat(row.siteName()).isEqualTo("사이트");
+        assertThat(row.category()).isEqualTo("범주");
+        assertThat(row.boardName()).isEqualTo("게시판");
+        assertThat(row.postTitle()).isEqualTo("제목");
+        assertThat(row.url()).isEqualTo("http://x");
+        assertThat(row.description()).isEqualTo("설명");
+        assertThat(row.hasAttachment()).isEqualTo("있음");
+        assertThat(row.priorKoglType()).isEqualTo("4유형");
+        assertThat(row.contractDocs()).isEqualTo("양도계약서");
+        assertThat(row.producedDate()).isEqualTo("2026-01-02");
+        assertThat(row.publishedDate()).isEqualTo("2026-01-03");
+        assertThat(row.reviewMajor()).isEqualTo("제3자 권리");
+        assertThat(row.reviewMinor()).isEqualTo("초상권 포함");
+        assertThat(row.reviewResult()).isEqualTo("권리처리 추진");
+        assertThat(row.reviewKoglType()).isEqualTo("보류");
+        assertThat(row.reviewAiType()).isEqualTo("Y");
+        assertThat(row.reviewOpinion()).isEqualTo("의견");
+        assertThat(row.reviewNote()).isEqualTo("비고");
+        assertThat(row.judgedKoglType()).isEqualTo("0유형");
+        assertThat(row.judgedAiType()).isEqualTo("Y");
+        assertThat(row.finalOpinion()).isEqualTo("최종의견");
+        assertThat(row.judgmentBasis()).isEqualTo("판단근거");
+        assertThat(row.processStatus()).isEqualTo("처리완료");
+
+        // 기존 증빙자료는 다운로드가 T열에 쓰고 파서가 U열을 읽어 왕복에서 사라진다.
+        assertThat(row.priorEvidence())
+                .as("내려받아 다시 올렸을 때 기존 증빙자료")
+                .isEqualTo("증빙자료");
+    }
+}
 
src/test/java/kr/itn/itnhub/process/ProcessParserFixtures.java (added)
+++ src/test/java/kr/itn/itnhub/process/ProcessParserFixtures.java
@@ -0,0 +1,35 @@
+package kr.itn.itnhub.process;
+
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+
+import java.io.ByteArrayOutputStream;
+
+/** 권리처리 엑셀 테스트용 최소 통합문서. 1행 그룹제목, 2행 헤더, 3행부터 데이터. */
+final class ProcessParserFixtures {
+
+    private ProcessParserFixtures() {
+    }
+
+    /** 순번 1행짜리 시트를 만들고, 기존 증빙자료 값을 지정한 열에만 넣는다. */
+    static byte[] withPriorEvidenceAt(int column, String value) throws Exception {
+        try (XSSFWorkbook wb = new XSSFWorkbook(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+            Sheet sheet = wb.createSheet(ProcessParser.PRIMARY_SHEET_NAME);
+            sheet.createRow(0).createCell(0).setCellValue("게시물 정보(조사원)");
+
+            Row header = sheet.createRow(1);
+            for (int i = 0; i < ProcessService.COLUMN_HEADERS.length; i++) {
+                header.createCell(i).setCellValue(ProcessService.COLUMN_HEADERS[i]);
+            }
+
+            Row data = sheet.createRow(2);
+            data.createCell(ProcessParser.COL_SEQ).setCellValue("1");
+            data.createCell(ProcessParser.COL_SITE_NAME).setCellValue("사이트");
+            data.createCell(column).setCellValue(value);
+
+            wb.write(out);
+            return out.toByteArray();
+        }
+    }
+}
src/test/java/kr/itn/itnhub/project/ProjectControllerTest.java
--- src/test/java/kr/itn/itnhub/project/ProjectControllerTest.java
+++ src/test/java/kr/itn/itnhub/project/ProjectControllerTest.java
@@ -76,7 +76,7 @@
                                 """))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.length()").value(1))
-                .andExpect(jsonPath("$[0].author").value("admin"))
+                .andExpect(jsonPath("$[0].author").value("관리자"))
                 .andExpect(jsonPath("$[0].contactedOn").value("2026-07-20"));
 
         mvc.perform(post("/api/orgs/{id}/contact-logs", orgId).with(csrf())
@@ -112,7 +112,7 @@
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$[0].summary").value("고침"))
                 .andExpect(jsonPath("$[0].method").value("방문"))
-                .andExpect(jsonPath("$[0].author").value("admin"));
+                .andExpect(jsonPath("$[0].author").value("관리자"));
     }
 
     @Test
@@ -151,7 +151,7 @@
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.length()").value(1))
                 .andExpect(jsonPath("$[0].fileName").value("최종보고서.pdf"))
-                .andExpect(jsonPath("$[0].uploadedBy").value("admin"))
+                .andExpect(jsonPath("$[0].uploadedBy").value("관리자"))
                 .andReturn().getResponse().getContentAsString();
         int reportId = com.jayway.jsonpath.JsonPath.parse(uploaded).read("$[0].id", Integer.class);
 
 
src/test/java/kr/itn/itnhub/re/ReMemoControllerTest.java (added)
+++ src/test/java/kr/itn/itnhub/re/ReMemoControllerTest.java
@@ -0,0 +1,141 @@
+package kr.itn.itnhub.re;
+
+import kr.itn.itnhub.AbstractDbTest;
+import kr.itn.itnhub.mattermost.MattermostClient;
+import kr.itn.itnhub.org.Organization;
+import kr.itn.itnhub.org.OrganizationMapper;
+import org.junit.jupiter.api.BeforeEach;
+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.http.MediaType;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@AutoConfigureMockMvc
+@WithMockUser(roles = "ADMIN")
+class ReMemoControllerTest extends AbstractDbTest {
+
+    @Autowired
+    MockMvc mvc;
+
+    @Autowired
+    OrganizationMapper orgMapper;
+
+    @Autowired
+    JdbcTemplate jdbc;
+
+    @MockBean
+    MattermostClient mattermost;
+
+    private Long orgId;
+
+    @BeforeEach
+    void setUp() {
+        jdbc.update("delete from re_memo");
+        jdbc.update("delete from organization");
+
+        Organization org = new Organization();
+        org.setOrgNo("001");
+        org.setOrgName("RE테스트기관");
+        org.setChannelSlug("001");
+        orgMapper.upsertBySeed(org);
+        orgId = orgMapper.findByOrgNoAndOrgName("001", "RE테스트기관").getId();
+    }
+
+    private String body(String content, String author, String date) {
+        return "{\"content\":\"" + content + "\",\"author\":\"" + author
+                + "\",\"memoDate\":\"" + date + "\",\"resolved\":false}";
+    }
+
+    @Test
+    void 메모를_등록하면_목록에_최신순으로_쌓인다() throws Exception {
+        mvc.perform(post("/api/orgs/{id}/re-memos", orgId).with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(body("ooo 계약서 및 제안요청서 송부 요청", "장영익", "2026-07-08")))
+                .andExpect(status().isOk());
+
+        mvc.perform(post("/api/orgs/{id}/re-memos", orgId).with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(body("추가 자료 요청", "장영익", "2026-07-10")))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.length()").value(2))
+                // 일자 최신순
+                .andExpect(jsonPath("$[0].content").value("추가 자료 요청"))
+                .andExpect(jsonPath("$[0].memoDate").value("2026-07-10"))
+                .andExpect(jsonPath("$[0].author").value("장영익"))
+                .andExpect(jsonPath("$[0].resolved").value(false));
+    }
+
+    @Test
+    void 내용과_작성자는_비울_수_없다() throws Exception {
+        mvc.perform(post("/api/orgs/{id}/re-memos", orgId).with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(body("", "장영익", "2026-07-08")))
+                .andExpect(status().isBadRequest());
+
+        mvc.perform(post("/api/orgs/{id}/re-memos", orgId).with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(body("내용", "", "2026-07-08")))
+                .andExpect(status().isBadRequest());
+    }
+
+    @Test
+    void 해결로_바꾸면_대시보드_미해결_수에서_빠진다() throws Exception {
+        mvc.perform(post("/api/orgs/{id}/re-memos", orgId).with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(body("미해결 건", "장영익", "2026-07-08")))
+                .andExpect(status().isOk());
+
+        mvc.perform(get("/api/dashboard"))
+                .andExpect(jsonPath("$.summary.unresolvedRe").value(1));
+
+        Long memoId = jdbc.queryForObject(
+                "select id from re_memo where org_id = ?", Long.class, orgId);
+
+        mvc.perform(put("/api/orgs/{id}/re-memos/{memoId}", orgId, memoId).with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"content\":\"미해결 건\",\"author\":\"장영익\","
+                                + "\"memoDate\":\"2026-07-08\",\"resolved\":true}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$[0].resolved").value(true));
+
+        mvc.perform(get("/api/dashboard"))
+                .andExpect(jsonPath("$.summary.unresolvedRe").value(0));
+    }
+
+    @Test
+    void 메모를_지울_수_있다() throws Exception {
+        mvc.perform(post("/api/orgs/{id}/re-memos", orgId).with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(body("지울 건", "장영익", "2026-07-08")))
+                .andExpect(status().isOk());
+
+        Long memoId = jdbc.queryForObject(
+                "select id from re_memo where org_id = ?", Long.class, orgId);
+
+        mvc.perform(delete("/api/orgs/{id}/re-memos/{memoId}", orgId, memoId).with(csrf()))
+                .andExpect(status().isNoContent());
+
+        mvc.perform(get("/api/orgs/{id}/re-memos", orgId))
+                .andExpect(jsonPath("$.length()").value(0));
+    }
+
+    @Test
+    void 없는_메모를_고치면_404다() throws Exception {
+        mvc.perform(put("/api/orgs/{id}/re-memos/{memoId}", orgId, 999999L).with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(body("없는 건", "장영익", "2026-07-08")))
+                .andExpect(status().isNotFound());
+    }
+}
 
src/test/java/kr/itn/itnhub/stage/AutoStageTest.java (added)
+++ src/test/java/kr/itn/itnhub/stage/AutoStageTest.java
@@ -0,0 +1,127 @@
+package kr.itn.itnhub.stage;
+
+import kr.itn.itnhub.AbstractDbTest;
+import kr.itn.itnhub.org.Organization;
+import kr.itn.itnhub.org.OrganizationMapper;
+import org.junit.jupiter.api.BeforeEach;
+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;
+
+/**
+ * 자료가 올라온 것을 계기로 단계가 저절로 넘어가는 규칙(요구사항 [18][19]).
+ *
+ * <p>핵심은 "앞으로만 간다"는 것이다. 자료를 다시 올렸다고 단계가 뒤로 밀리면 안 된다.</p>
+ */
+class AutoStageTest extends AbstractDbTest {
+
+    @Autowired
+    StageService stageService;
+
+    @Autowired
+    OrganizationMapper orgMapper;
+
+    @Autowired
+    StageHistoryMapper historyMapper;
+
+    @Autowired
+    JdbcTemplate jdbc;
+
+    private Long orgId;
+
+    @BeforeEach
+    void setUp() {
+        jdbc.update("delete from stage_history");
+        jdbc.update("delete from organization where org_no = '930'");
+
+        Organization org = new Organization();
+        org.setOrgNo("930");
+        org.setOrgName("자동전환테스트기관");
+        org.setChannelSlug("930");
+        orgMapper.upsertBySeed(org);
+        orgId = orgMapper.findByOrgNoAndOrgName("930", "자동전환테스트기관").getId();
+    }
+
+    private void setStage(int stage) {
+        orgMapper.updateStage(orgId, stage);
+        historyMapper.insert(orgId, stage, false, null);
+    }
+
+    @Test
+    void 뒤에_있으면_앞으로_넘어간다() {
+        setStage(1);
+
+        boolean moved = stageService.autoAdvance(orgId, AutoStage.REVIEW_STARTED,
+                AutoStage.REASON_REVIEW_STARTED);
+
+        assertThat(moved).isTrue();
+        assertThat(orgMapper.findById(orgId).getStage()).isEqualTo(AutoStage.REVIEW_STARTED);
+    }
+
+    @Test
+    void 이미_앞서_있으면_뒤로_밀지_않는다() {
+        setStage(10);
+
+        boolean moved = stageService.autoAdvance(orgId, AutoStage.REVIEW_STARTED,
+                AutoStage.REASON_REVIEW_STARTED);
+
+        assertThat(moved).isFalse();
+        assertThat(orgMapper.findById(orgId).getStage()).isEqualTo(10);
+    }
+
+    @Test
+    void 같은_단계면_이력을_또_쌓지_않는다() {
+        setStage(AutoStage.REVIEW_STARTED);
+        int before = historyMapper.findByOrg(orgId).size();
+
+        boolean moved = stageService.autoAdvance(orgId, AutoStage.REVIEW_STARTED,
+                AutoStage.REASON_REVIEW_STARTED);
+
+        assertThat(moved).isFalse();
+        assertThat(historyMapper.findByOrg(orgId)).hasSize(before);
+    }
+
+    @Test
+    void 채널이_없어_단계가_아직_없으면_건드리지_않는다() {
+        // 채널 생성으로 ①신청이 들어오는 것이 먼저다.
+        boolean moved = stageService.autoAdvance(orgId, AutoStage.LIST_RECEIVED,
+                AutoStage.REASON_LIST_RECEIVED);
+
+        assertThat(moved).isFalse();
+        assertThat(orgMapper.findById(orgId).getStage()).isNull();
+    }
+
+    @Test
+    void 이력에_자동_여부와_계기가_남는다() {
+        setStage(1);
+        stageService.autoAdvance(orgId, AutoStage.PROCESS_STARTED, AutoStage.REASON_PROCESS_STARTED);
+
+        List<StageHistoryRow> history = historyMapper.findByOrg(orgId);
+
+        assertThat(history).hasSize(2);
+        // 최신순이라 자동 전환이 앞에 온다.
+        assertThat(history.get(0).getStage()).isEqualTo(AutoStage.PROCESS_STARTED);
+        assertThat(history.get(0).isAuto()).isTrue();
+        assertThat(history.get(0).getReason()).isEqualTo(AutoStage.REASON_PROCESS_STARTED);
+        // 사람이 넣은 것은 자동이 아니다.
+        assertThat(history.get(1).isAuto()).isFalse();
+        assertThat(history.get(1).getReason()).isNull();
+    }
+
+    @Test
+    void 자동으로_올라간_단계를_사람이_되돌릴_수_있다() {
+        // 요구사항 [19]: 되돌림 허용.
+        setStage(1);
+        stageService.autoAdvance(orgId, AutoStage.REVIEW_STARTED, AutoStage.REASON_REVIEW_STARTED);
+        assertThat(orgMapper.findById(orgId).getStage()).isEqualTo(AutoStage.REVIEW_STARTED);
+
+        stageService.updateStage(orgId, new StageRequest(2));
+
+        assertThat(orgMapper.findById(orgId).getStage()).isEqualTo(2);
+        assertThat(historyMapper.findByOrg(orgId).get(0).isAuto()).isFalse();
+    }
+}
src/test/java/kr/itn/itnhub/stage/StageControllerTest.java
--- src/test/java/kr/itn/itnhub/stage/StageControllerTest.java
+++ src/test/java/kr/itn/itnhub/stage/StageControllerTest.java
@@ -82,19 +82,34 @@
         assertThat(mapper.findById(orgId).getStage()).isEqualTo(2);
     }
 
+    /**
+     * 상한은 코드표에 등록된 단계 수를 따른다. 요구사항 [11]로 13단계(보고서 제출 완료)가
+     * 생겼으므로 13은 이제 정상이고, 14부터 범위를 벗어난다.
+     */
     @Test
     void 범위를_벗어난_값은_400이다() throws Exception {
         mvc.perform(put("/api/orgs/{id}/stage", orgId)
                         .with(csrf())
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content("{\"stage\": 13}"))
+                        .content("{\"stage\": 14}"))
                 .andExpect(status().isBadRequest())
-                .andExpect(jsonPath("$.message").value(containsString("1에서 12")));
+                .andExpect(jsonPath("$.message").value(containsString("1에서 13")));
 
         assertThat(mapper.findById(orgId).getStage()).isNull();
     }
 
     @Test
+    void 마지막_단계인_13단계는_저장된다() throws Exception {
+        mvc.perform(put("/api/orgs/{id}/stage", orgId)
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"stage\": 13}"))
+                .andExpect(status().isOk());
+
+        assertThat(mapper.findById(orgId).getStage()).isEqualTo(13);
+    }
+
+    @Test
     void 하한_미만인_0단계는_400이다() throws Exception {
         mvc.perform(put("/api/orgs/{id}/stage", orgId)
                         .with(csrf())
Add a comment
List