이호영 이호영 07-28
refactor: 권리확인 화면을 코드표에 연결
이 파일 상단에 옵션 배열과 판정 규칙이 90줄 가까이 박혀 있었다. 전부 코드표로 옮겼다.

  대분류/세부 추천값   이름을 직접 비교하던 if 사슬을 suggest 속성 조회로 바꿈
  세부별 허용 처리결과 ALLOWED_RESULTS_BY_MINOR 맵을 allowed 속성으로
  세부 표시명·부연설명 REVIEW_MINOR_DISPLAY를 label·note로
  처리결과 뱃지색      Tailwind 클래스 맵을 tone 토큰으로
  공공누리 유형        3중 정의(7개/5개/6개)를 scope 하나로 통합

판정 매트릭스는 종이 양식과 같은 자리에 칸을 놓아야 해서 목록을 훑지 않고
majors[0], minors[1] 처럼 자리로 집는다. 배치는 그대로고 값의 출처만 바뀌었다.

값이 바뀔 때 폼을 채우는 동작(기존 유형 복사·비고 문구 덧붙임)은 코드에 남겼다.
데이터로 옮기면 코드표 안에 규칙 언어를 만드는 꼴이 된다. 문구는 messages.ts로 뺐다.

덤으로 실제 버그가 하나 고쳐진다. 기존 공공누리 유형 드롭다운이 0~4유형만 갖고
있어서, 운영 DB에 '유형 확인 필요'(1,858건)·'제4유형'(68건)으로 저장된 1,926건이
상세 화면에서 선택 안 됨으로 보이고 있었다. 이제 prior scope에 그 값들이 들어간다.

프론트 208건 전부 통과.

Co-Authored-By: Claude Opus 5 (1M context) 
@089f17b05443793692885f18d9651924becfdfa7
 
frontend/src/codes/messages.ts (added)
+++ frontend/src/codes/messages.ts
@@ -0,0 +1,7 @@
+/**
+ * 화면이 자동으로 채워 넣는 안내 문구. 코드값이 아니라 문장이라 코드표에 넣지 않고
+ * 여기 모아 둔다 - 코드표에 문장을 넣으면 선택지 목록에 섞여 관리가 어려워진다.
+ */
+
+/** 권리처리를 추진하지 않아 기존 공공누리 유형을 그대로 쓸 때 비고에 남기는 문구. */
+export const NOTE_NO_PROCESSING = '권리처리 추진 미희망에 따른 기존 공공누리 부착'
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 { 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
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState, type ReactNode } from 'react'
+import { useEffect, useRef, useState, type ReactNode } from 'react'
 import {
   bulkDeleteReview,
   bulkUpdateReview,
@@ -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[]>([])
@@ -533,8 +484,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 +495,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
@@ -725,6 +676,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 +752,7 @@
     if (resultManuallySet) {
       return
     }
-    const suggestion = suggestReviewResult(major, minor)
+    const suggestion = suggestReviewResult(major, minor, majors, minors)
     if (suggestion) {
       setReviewResult(suggestion)
       applyResultDerivations(suggestion)
@@ -808,15 +769,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 +797,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 +910,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 +949,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 +988,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 +1001,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 +1013,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 +1032,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 +1053,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 +1065,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 +1080,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 +1103,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 +1115,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 +1126,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 +1139,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 +1155,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">
Add a comment
List