ITN Dev 07-23
feat: 담당자관리 화면을 신설하고 채널관리는 배정 전용으로 전환
담당자 정보를 직접 입력하던 채널관리 화면을 배정 전용(선택/해제)으로 바꾸고,
담당자 등록·수정은 새 담당자관리 화면에서만 하도록 입력 경로를 하나로 모았다.

- 담당자관리(신규): 구분 필터 + 목록 표 + 추가/수정 공용 모달 + 삭제 확인
- ContactPickerModal(신규): 채널관리에서 역할별로 등록된 담당자를 검색해 배정
- OrgDetail: 담당자 입력 표 제거 → 배정 행 3개(미지정/선택/해제) + 변호사 배정일
- client.ts: Org가 flat 필드 대신 applicant/mj/lawyer 중첩 Contact를 갖도록 변경,
  updateAssignments/getContacts/createContact/updateContactInfo/deleteContact 추가
- OrgOverview·WorkMemos·OrgList 등 org 픽스처와 조회 로직을 새 중첩 구조에 맞춤
@97f048983987e201fbf21affc08c07bf1e519ceb
frontend/src/App.tsx
--- frontend/src/App.tsx
+++ frontend/src/App.tsx
@@ -1,5 +1,6 @@
 import { useCallback, useEffect, useState } from 'react'
 import { ApiError, getOrgs, logout, type Org } from './api/client'
+import ContactsPage from './components/ContactsPage'
 import LoginPage from './components/LoginPage'
 import OrgDetail from './components/OrgDetail'
 import OrgList from './components/OrgList'
@@ -8,12 +9,14 @@
 import Sidebar from './components/Sidebar'
 import { useCollapsiblePanel } from './hooks/useCollapsiblePanel'
 
-// 화면 2개: 기관관리(시안 레이아웃의 기관 상세)와 채널관리(명부 시드 + 담당자 입력 + 채널 생성).
-type Page = 'orgs' | 'channels'
+// 화면 3개: 기관관리(시안 레이아웃의 기관 상세), 채널관리(명부 시드 + 담당자 배정 + 채널 생성),
+// 담당자관리(담당자 3종 CRUD - 담당자 정보가 입력되는 유일한 화면).
+type Page = 'orgs' | 'channels' | 'contacts'
 
 const PAGE_TITLE: Record<Page, string> = {
   orgs: '기관관리',
   channels: '채널관리',
+  contacts: '담당자관리',
 }
 
 // 'checking'이 필요하다. true로 시작하면 첫 렌더에서 getOrgs()가 아직 안 끝났는데도
@@ -97,7 +100,7 @@
         collapsed={menuCollapsed}
         onToggle={toggleMenu}
         onSelect={(key) => {
-          if (key === 'orgs' || key === 'channels') {
+          if (key === 'orgs' || key === 'channels' || key === 'contacts') {
             setPage(key)
           }
         }}
@@ -119,25 +122,29 @@
         </header>
 
         <div className="flex min-h-0 flex-1">
-          <div
-            className={`flex shrink-0 flex-col border-r border-gray-200 transition-all duration-200 ${
-              listCollapsed ? 'w-14' : 'w-80'
-            }`}
-          >
-            <OrgList
-              orgs={orgs}
-              selectedId={selectedId}
-              onSelect={setSelectedId}
-              collapsed={listCollapsed}
-              onToggle={toggleList}
-            />
-            {page === 'channels' && !listCollapsed && (
-              <SeedUpload onUploaded={() => void reload()} />
-            )}
-          </div>
+          {page !== 'contacts' && (
+            <div
+              className={`flex shrink-0 flex-col border-r border-gray-200 transition-all duration-200 ${
+                listCollapsed ? 'w-14' : 'w-80'
+              }`}
+            >
+              <OrgList
+                orgs={orgs}
+                selectedId={selectedId}
+                onSelect={setSelectedId}
+                collapsed={listCollapsed}
+                onToggle={toggleList}
+              />
+              {page === 'channels' && !listCollapsed && (
+                <SeedUpload onUploaded={() => void reload()} />
+              )}
+            </div>
+          )}
 
           <main className="min-w-0 flex-1 overflow-y-auto bg-gray-50/50">
-            {selected ? (
+            {page === 'contacts' ? (
+              <ContactsPage />
+            ) : selected ? (
               page === 'channels' ? (
                 <OrgDetail org={selected} onChanged={() => void reload()} />
               ) : (
frontend/src/api/client.test.ts
--- frontend/src/api/client.test.ts
+++ frontend/src/api/client.test.ts
@@ -1,5 +1,5 @@
 import { afterEach, describe, expect, it, vi } from 'vitest'
-import { ApiError, getOrgs, logout, provisionChannels, updateContact } from './client'
+import { ApiError, getOrgs, logout, provisionChannels, updateAssignments } from './client'
 
 afterEach(() => {
   vi.unstubAllGlobals()
@@ -56,14 +56,33 @@
       text: async () => 'bad request',
     }))
 
-    const promise = updateContact(1, {
-      deptName: '', managerName: '', managerTitle: '', managerPhone: '', managerEmail: '',
-      mjDeptName: '', mjManagerName: '', mjManagerPhone: '', mjManagerEmail: '',
-      lawyerName: '', lawyerPhone: '', lawyerEmail: '', lawyerAssignedDate: '',
+    const promise = updateAssignments(1, {
+      applicantContactId: null,
+      mjContactId: null,
+      lawyerContactId: null,
+      lawyerAssignedDate: null,
     })
 
     await expect(promise).rejects.toThrow()
     await expect(promise).rejects.toBeInstanceOf(ApiError)
     await expect(promise).rejects.toMatchObject({ status: 400 })
   })
+
+  it('배정 변경은 CSRF 헤더를 붙여 PUT한다', async () => {
+    document.cookie = 'XSRF-TOKEN=token-value'
+    const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })
+    vi.stubGlobal('fetch', fetchMock)
+
+    await updateAssignments(7, {
+      applicantContactId: 1,
+      mjContactId: null,
+      lawyerContactId: null,
+      lawyerAssignedDate: null,
+    })
+
+    const [url, init] = fetchMock.mock.calls[0]
+    expect(url).toBe('/api/orgs/7/assignments')
+    expect(init.method).toBe('PUT')
+    expect(init.headers['X-XSRF-TOKEN']).toBe('token-value')
+  })
 })
frontend/src/api/client.ts
--- frontend/src/api/client.ts
+++ frontend/src/api/client.ts
@@ -1,41 +1,49 @@
 export type OrgStatus = 'INFO_PENDING' | 'READY' | 'PARTIAL' | 'ACTIVE'
 
+export type ContactCategory = 'APPLICANT' | 'MJ' | 'LAWYER'
+
+/** 담당자관리에 등록된 담당자 1명. 신청기관 담당자는 affiliation에 소속 기관명이 들어간다. */
+export interface Contact {
+  id: number
+  category: ContactCategory
+  name: string
+  affiliation: string | null
+  deptName: string | null
+  title: string | null
+  phone: string | null
+  email: string | null
+}
+
+/** 담당자 등록/수정 폼 입력. 폼 상태는 문자열로만 다루므로 빈 문자열이 "값 없음"을 뜻한다. */
+export interface ContactInput {
+  category: ContactCategory
+  name: string
+  affiliation: string
+  deptName: string
+  title: string
+  phone: string
+  email: string
+}
+
 export interface Org {
   id: number
   orgNo: string
   orgName: string
   status: OrgStatus
-  deptName: string | null
-  managerName: string | null
-  managerTitle: string | null
-  managerPhone: string | null
-  managerEmail: string | null
-  mjDeptName: string | null
-  mjManagerName: string | null
-  mjManagerPhone: string | null
-  mjManagerEmail: string | null
-  lawyerName: string | null
-  lawyerPhone: string | null
-  lawyerEmail: string | null
+  applicant: Contact | null
+  mj: Contact | null
+  lawyer: Contact | null
   lawyerAssignedDate: string | null
   channelIdMj: string | null
   channelIdLaw: string | null
 }
 
-export interface Contact {
-  deptName: string
-  managerName: string
-  managerTitle: string
-  managerPhone: string
-  managerEmail: string
-  mjDeptName: string
-  mjManagerName: string
-  mjManagerPhone: string
-  mjManagerEmail: string
-  lawyerName: string
-  lawyerPhone: string
-  lawyerEmail: string
-  lawyerAssignedDate: string
+/** 채널관리 화면이 신청기관/문정원/변호사 배정을 한 번에 바꿀 때 보내는 요청. null은 해제다. */
+export interface AssignmentInput {
+  applicantContactId: number | null
+  mjContactId: number | null
+  lawyerContactId: number | null
+  lawyerAssignedDate: string | null
 }
 
 export type ProvisionOutcome = 'CACHED' | 'RECOVERED' | 'CREATED' | 'FAILED'
@@ -129,14 +137,48 @@
   return request<Org[]>('/api/orgs')
 }
 
-export function updateContact(id: number, contact: Contact): Promise<Org> {
-  return request<Org>(`/api/orgs/${id}/contact`, {
+export function updateAssignments(orgId: number, body: AssignmentInput): Promise<Org> {
+  return request<Org>(`/api/orgs/${orgId}/assignments`, {
     method: 'PUT',
     headers: { 'Content-Type': 'application/json' },
-    body: JSON.stringify(contact),
+    body: JSON.stringify(body),
   })
 }
 
+export function getContacts(category?: ContactCategory): Promise<Contact[]> {
+  const query = category ? `?category=${category}` : ''
+  return request<Contact[]>(`/api/contacts${query}`)
+}
+
+export function createContact(body: ContactInput): Promise<Contact> {
+  return request<Contact>('/api/contacts', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(body),
+  })
+}
+
+export function updateContactInfo(id: number, body: ContactInput): Promise<Contact> {
+  return request<Contact>(`/api/contacts/${id}`, {
+    method: 'PUT',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(body),
+  })
+}
+
+/** 204를 돌려주므로 request()의 json() 경로를 타지 않고 직접 부른다(deleteMemo()와 동일한 패턴). */
+export async function deleteContact(id: number): Promise<void> {
+  const response = await fetch(`/api/contacts/${id}`, {
+    method: 'DELETE',
+    credentials: 'same-origin',
+    headers: { 'X-XSRF-TOKEN': csrfToken() },
+  })
+  if (!response.ok) {
+    const body = await response.text()
+    throw new ApiError(response.status, `요청 실패 (${response.status}): ${body}`)
+  }
+}
+
 export function provisionChannels(id: number): Promise<ProvisionResult> {
   return request<ProvisionResult>(`/api/orgs/${id}/channels`, { method: 'POST' })
 }
frontend/src/components/ChannelFiles.test.tsx
--- frontend/src/components/ChannelFiles.test.tsx
+++ frontend/src/components/ChannelFiles.test.tsx
@@ -18,18 +18,9 @@
     orgNo: '001',
     orgName: '국제방송교류재단',
     status: 'ACTIVE',
-    deptName: null,
-    managerName: null,
-    managerTitle: null,
-    managerPhone: null,
-    managerEmail: null,
-    mjDeptName: null,
-    mjManagerName: null,
-    mjManagerPhone: null,
-    mjManagerEmail: null,
-    lawyerName: null,
-    lawyerPhone: null,
-    lawyerEmail: null,
+    applicant: null,
+    mj: null,
+    lawyer: null,
     lawyerAssignedDate: null,
     channelIdMj: 'chan-mj',
     channelIdLaw: 'chan-law',
frontend/src/components/ChannelPosts.test.tsx
--- frontend/src/components/ChannelPosts.test.tsx
+++ frontend/src/components/ChannelPosts.test.tsx
@@ -24,18 +24,9 @@
     orgNo: '001',
     orgName: '국제방송교류재단',
     status: 'ACTIVE',
-    deptName: null,
-    managerName: null,
-    managerTitle: null,
-    managerPhone: null,
-    managerEmail: null,
-    mjDeptName: null,
-    mjManagerName: null,
-    mjManagerPhone: null,
-    mjManagerEmail: null,
-    lawyerName: null,
-    lawyerPhone: null,
-    lawyerEmail: null,
+    applicant: null,
+    mj: null,
+    lawyer: null,
     lawyerAssignedDate: null,
     channelIdMj: 'chan-mj',
     channelIdLaw: 'chan-law',
 
frontend/src/components/ContactPickerModal.test.tsx (added)
+++ frontend/src/components/ContactPickerModal.test.tsx
@@ -0,0 +1,124 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import ContactPickerModal from './ContactPickerModal'
+import type { Contact } from '../api/client'
+
+const mocks = vi.hoisted(() => ({
+  getContacts: vi.fn(),
+}))
+
+vi.mock('../api/client', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('../api/client')>()),
+  getContacts: mocks.getContacts,
+}))
+
+function contact(overrides: Partial<Contact> = {}): Contact {
+  return {
+    id: 1,
+    category: 'APPLICANT',
+    name: '송민지',
+    affiliation: '국제방송교류재단',
+    deptName: '데이터정보화팀',
+    title: '과장',
+    phone: '02-3475-5434',
+    email: 'ming@arirang.com',
+    ...overrides,
+  }
+}
+
+beforeEach(() => {
+  mocks.getContacts.mockReset()
+})
+
+describe('ContactPickerModal', () => {
+  it('카테고리로 담당자 목록을 불러와 행으로 보여준다', async () => {
+    mocks.getContacts.mockResolvedValue([contact()])
+
+    render(
+      <ContactPickerModal
+        category="APPLICANT"
+        title="신청기관 담당자 선택"
+        onSelect={() => {}}
+        onClose={() => {}}
+      />,
+    )
+
+    expect(mocks.getContacts).toHaveBeenCalledWith('APPLICANT')
+    await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
+    expect(screen.getByText(/국제방송교류재단/)).toBeTruthy()
+    expect(screen.getByText(/데이터정보화팀/)).toBeTruthy()
+    expect(screen.getByRole('button', { name: '선택' })).toBeTruthy()
+  })
+
+  it('담당자가 없으면 안내 문구와 하단 메모를 보여준다', async () => {
+    mocks.getContacts.mockResolvedValue([])
+
+    render(
+      <ContactPickerModal category="MJ" title="문정원 담당자 선택" onSelect={() => {}} onClose={() => {}} />,
+    )
+
+    await waitFor(() => {
+      expect(screen.getByText('등록된 담당자가 없습니다.')).toBeTruthy()
+    })
+    expect(screen.getByText('담당자 등록·수정은 담당자관리 메뉴에서 합니다.')).toBeTruthy()
+  })
+
+  it('검색어로 이름·소속을 걸러낸다', async () => {
+    mocks.getContacts.mockResolvedValue([
+      contact({ id: 1, name: '송민지', affiliation: '국제방송교류재단' }),
+      contact({ id: 2, name: '권유나', affiliation: '세종학당재단' }),
+    ])
+
+    render(
+      <ContactPickerModal category="APPLICANT" title="신청기관 담당자 선택" onSelect={() => {}} onClose={() => {}} />,
+    )
+
+    await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
+
+    fireEvent.change(screen.getByLabelText('담당자 검색'), { target: { value: '세종' } })
+
+    expect(screen.queryByText('송민지')).toBeNull()
+    expect(screen.getByText('권유나')).toBeTruthy()
+  })
+
+  it('선택 버튼을 누르면 onSelect가 그 담당자로 호출된다', async () => {
+    const target = contact({ id: 42, name: '송민지' })
+    mocks.getContacts.mockResolvedValue([target])
+    const onSelect = vi.fn()
+
+    render(
+      <ContactPickerModal category="APPLICANT" title="신청기관 담당자 선택" onSelect={onSelect} onClose={() => {}} />,
+    )
+
+    await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
+    fireEvent.click(screen.getByRole('button', { name: '선택' }))
+
+    expect(onSelect).toHaveBeenCalledWith(target)
+  })
+
+  it('닫기 버튼을 누르면 onClose가 호출된다', async () => {
+    mocks.getContacts.mockResolvedValue([])
+    const onClose = vi.fn()
+
+    render(
+      <ContactPickerModal category="APPLICANT" title="신청기관 담당자 선택" onSelect={() => {}} onClose={onClose} />,
+    )
+
+    fireEvent.click(screen.getByRole('button', { name: '닫기' }))
+
+    expect(onClose).toHaveBeenCalled()
+  })
+
+  it('Esc를 누르면 onClose가 호출된다', async () => {
+    mocks.getContacts.mockResolvedValue([])
+    const onClose = vi.fn()
+
+    render(
+      <ContactPickerModal category="APPLICANT" title="신청기관 담당자 선택" onSelect={() => {}} onClose={onClose} />,
+    )
+
+    fireEvent.keyDown(window, { key: 'Escape' })
+
+    expect(onClose).toHaveBeenCalled()
+  })
+})
 
frontend/src/components/ContactPickerModal.tsx (added)
+++ frontend/src/components/ContactPickerModal.tsx
@@ -0,0 +1,129 @@
+import { useEffect, useState } from 'react'
+import { getContacts, type Contact, type ContactCategory } from '../api/client'
+
+const FOOTER_NOTE = '담당자 등록·수정은 담당자관리 메뉴에서 합니다.'
+
+/** 채널관리에서 담당자 3종(신청기관/문정원/변호사)을 배정할 때 여는 선택 모달.
+ * 담당자 정보를 여기서 입력하지 않는다 - 등록/수정은 항상 담당자관리 화면에서만 한다. */
+export default function ContactPickerModal({
+  category,
+  title,
+  onSelect,
+  onClose,
+}: {
+  category: ContactCategory
+  title: string
+  onSelect: (contact: Contact) => void
+  onClose: () => void
+}) {
+  const [contacts, setContacts] = useState<Contact[]>([])
+  const [loading, setLoading] = useState(true)
+  const [keyword, setKeyword] = useState('')
+  const [error, setError] = useState<string | null>(null)
+
+  useEffect(() => {
+    let cancelled = false
+    setLoading(true)
+    getContacts(category)
+      .then((data) => {
+        if (!cancelled) {
+          setContacts(data)
+        }
+      })
+      .catch((e) => {
+        if (!cancelled) {
+          setError(e instanceof Error ? e.message : '담당자 목록을 불러오지 못했습니다.')
+        }
+      })
+      .finally(() => {
+        if (!cancelled) {
+          setLoading(false)
+        }
+      })
+    return () => {
+      cancelled = true
+    }
+  }, [category])
+
+  useEffect(() => {
+    function onKeyDown(e: KeyboardEvent) {
+      if (e.key === 'Escape') {
+        onClose()
+      }
+    }
+    window.addEventListener('keydown', onKeyDown)
+    return () => window.removeEventListener('keydown', onKeyDown)
+  }, [onClose])
+
+  const keywordTrimmed = keyword.trim()
+  const visible = contacts.filter((c) => {
+    if (keywordTrimmed === '') {
+      return true
+    }
+    return c.name.includes(keywordTrimmed) || (c.affiliation ?? '').includes(keywordTrimmed)
+  })
+
+  return (
+    <div className="fixed inset-0 flex items-center justify-center bg-black/30">
+      <div className="w-[30rem] rounded-lg bg-white p-5">
+        <div className="flex items-center justify-between">
+          <h2 className="text-base font-semibold">{title}</h2>
+          <button
+            type="button"
+            onClick={onClose}
+            aria-label="닫기"
+            className="rounded-md p-1 text-gray-400 hover:bg-gray-100"
+          >
+            ✕
+          </button>
+        </div>
+
+        <input
+          type="text"
+          value={keyword}
+          onChange={(e) => setKeyword(e.target.value)}
+          placeholder="이름·소속으로 검색"
+          aria-label="담당자 검색"
+          className="mt-3 w-full rounded-md border border-gray-300 px-2.5 py-1.5 text-sm"
+        />
+
+        {error && <p className="mt-2 text-xs text-red-600">{error}</p>}
+
+        <div className="mt-3 max-h-72 overflow-y-auto">
+          {loading ? (
+            <p className="py-6 text-center text-sm text-gray-400">불러오는 중…</p>
+          ) : visible.length === 0 ? (
+            <p className="py-6 text-center text-sm text-gray-300">등록된 담당자가 없습니다.</p>
+          ) : (
+            <ul className="divide-y divide-gray-100">
+              {visible.map((contact) => (
+                <li key={contact.id} className="flex items-center justify-between gap-3 py-2 text-sm">
+                  <div>
+                    <p className="font-medium text-gray-900">
+                      {contact.name}
+                      {contact.affiliation && (
+                        <span className="ml-1 text-gray-500">· {contact.affiliation}</span>
+                      )}
+                    </p>
+                    <p className="text-xs text-gray-400">
+                      {[contact.deptName, contact.phone, contact.email].filter(Boolean).join(' · ') || '-'}
+                    </p>
+                  </div>
+                  <button
+                    type="button"
+                    onClick={() => onSelect(contact)}
+                    className="shrink-0 rounded-md border border-gray-300 px-2.5 py-1 text-xs text-gray-700 hover:bg-gray-50"
+                  >
+                    선택
+                  </button>
+                </li>
+              ))}
+            </ul>
+          )}
+        </div>
+
+        <p className="mt-4 border-t border-gray-100 pt-3 text-xs text-gray-400">{FOOTER_NOTE}</p>
+      </div>
+    </div>
+  )
+}
 
frontend/src/components/ContactsPage.test.tsx (added)
+++ frontend/src/components/ContactsPage.test.tsx
@@ -0,0 +1,180 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import ContactsPage from './ContactsPage'
+import type { Contact } from '../api/client'
+
+const mocks = vi.hoisted(() => ({
+  getContacts: vi.fn(),
+  createContact: vi.fn(),
+  updateContactInfo: vi.fn(),
+  deleteContact: vi.fn(),
+}))
+
+vi.mock('../api/client', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('../api/client')>()),
+  getContacts: mocks.getContacts,
+  createContact: mocks.createContact,
+  updateContactInfo: mocks.updateContactInfo,
+  deleteContact: mocks.deleteContact,
+}))
+
+function contact(overrides: Partial<Contact> = {}): Contact {
+  return {
+    id: 1,
+    category: 'APPLICANT',
+    name: '송민지',
+    affiliation: '국제방송교류재단',
+    deptName: '데이터정보화팀',
+    title: '과장',
+    phone: '02-3475-5434',
+    email: 'ming@arirang.com',
+    ...overrides,
+  }
+}
+
+beforeEach(() => {
+  mocks.getContacts.mockReset()
+  mocks.createContact.mockReset()
+  mocks.updateContactInfo.mockReset()
+  mocks.deleteContact.mockReset()
+})
+
+describe('ContactsPage', () => {
+  it('담당자 목록을 구분·성명 등과 함께 보여준다', async () => {
+    mocks.getContacts.mockResolvedValue([contact()])
+
+    render(<ContactsPage />)
+
+    await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
+    expect(screen.getByRole('cell', { name: '신청기관' })).toBeTruthy()
+    expect(screen.getByText('국제방송교류재단')).toBeTruthy()
+    expect(screen.getByText('데이터정보화팀')).toBeTruthy()
+    expect(screen.getByText('02-3475-5434')).toBeTruthy()
+    expect(screen.getByText('ming@arirang.com')).toBeTruthy()
+  })
+
+  it('담당자가 없으면 안내 문구를 보여준다', async () => {
+    mocks.getContacts.mockResolvedValue([])
+
+    render(<ContactsPage />)
+
+    await waitFor(() => {
+      expect(screen.getByText('등록된 담당자가 없습니다.')).toBeTruthy()
+    })
+  })
+
+  it('필터 칩을 누르면 해당 구분만 보인다', async () => {
+    mocks.getContacts.mockResolvedValue([
+      contact({ id: 1, name: '송민지', category: 'APPLICANT' }),
+      contact({ id: 2, name: '이변호', category: 'LAWYER', affiliation: null, deptName: null }),
+    ])
+
+    render(<ContactsPage />)
+
+    await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
+
+    fireEvent.click(screen.getByRole('button', { name: '변호사' }))
+
+    expect(screen.queryByText('송민지')).toBeNull()
+    expect(screen.getByText('이변호')).toBeTruthy()
+  })
+
+  it('담당자 추가를 누르면 모달이 뜨고, 구분과 성명을 채워야 저장이 활성화된다', async () => {
+    mocks.getContacts.mockResolvedValue([])
+
+    render(<ContactsPage />)
+    await waitFor(() => expect(mocks.getContacts).toHaveBeenCalled())
+
+    fireEvent.click(screen.getByRole('button', { name: '담당자 추가' }))
+
+    expect(screen.getByRole('heading', { name: '담당자 추가' })).toBeTruthy()
+    const save = screen.getByRole('button', { name: '저장' })
+    expect(save).toBeDisabled()
+
+    fireEvent.change(screen.getByLabelText('성명'), { target: { value: '홍길동' } })
+    expect(save).toBeEnabled()
+  })
+
+  it('저장하면 createContact가 폼 값으로 호출되고, 이후 목록을 다시 불러온다', async () => {
+    mocks.getContacts.mockResolvedValueOnce([]).mockResolvedValueOnce([contact({ name: '홍길동' })])
+    mocks.createContact.mockResolvedValue(contact({ name: '홍길동' }))
+
+    render(<ContactsPage />)
+    await waitFor(() => expect(mocks.getContacts).toHaveBeenCalledTimes(1))
+
+    fireEvent.click(screen.getByRole('button', { name: '담당자 추가' }))
+    fireEvent.change(screen.getByLabelText('성명'), { target: { value: '홍길동' } })
+    fireEvent.change(screen.getByLabelText('구분'), { target: { value: 'MJ' } })
+    fireEvent.click(screen.getByRole('button', { name: '저장' }))
+
+    await waitFor(() => {
+      expect(mocks.createContact).toHaveBeenCalledWith({
+        category: 'MJ',
+        name: '홍길동',
+        affiliation: '',
+        deptName: '',
+        title: '',
+        phone: '',
+        email: '',
+      })
+    })
+
+    await waitFor(() => expect(mocks.getContacts).toHaveBeenCalledTimes(2))
+  })
+
+  it('수정 버튼을 누르면 기존 값이 채워진 모달이 뜨고, 저장하면 updateContactInfo가 호출된다', async () => {
+    mocks.getContacts.mockResolvedValue([contact()])
+    mocks.updateContactInfo.mockResolvedValue(contact({ phone: '02-0000-0000' }))
+
+    render(<ContactsPage />)
+    await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
+
+    fireEvent.click(screen.getByRole('button', { name: '수정' }))
+
+    expect(screen.getByText('담당자 수정')).toBeTruthy()
+    expect(screen.getByLabelText('성명')).toHaveValue('송민지')
+
+    fireEvent.change(screen.getByLabelText('연락처'), { target: { value: '02-0000-0000' } })
+    fireEvent.click(screen.getByRole('button', { name: '저장' }))
+
+    await waitFor(() => {
+      expect(mocks.updateContactInfo).toHaveBeenCalledWith(
+        1,
+        expect.objectContaining({ phone: '02-0000-0000' }),
+      )
+    })
+  })
+
+  it('삭제를 누르면 확인을 물어보고, 확인하면 deleteContact를 호출한다', async () => {
+    mocks.getContacts.mockResolvedValue([contact()])
+    mocks.deleteContact.mockResolvedValue(undefined)
+    const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
+
+    render(<ContactsPage />)
+    await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
+
+    fireEvent.click(screen.getByRole('button', { name: '삭제' }))
+
+    expect(confirmSpy).toHaveBeenCalledWith(
+      '삭제하면 기관에 지정된 배정도 함께 해제됩니다. 삭제할까요?',
+    )
+    await waitFor(() => expect(mocks.deleteContact).toHaveBeenCalledWith(1))
+
+    confirmSpy.mockRestore()
+  })
+
+  it('삭제 확인을 취소하면 deleteContact를 호출하지 않는다', async () => {
+    mocks.getContacts.mockResolvedValue([contact()])
+    const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
+
+    render(<ContactsPage />)
+    await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
+
+    fireEvent.click(screen.getByRole('button', { name: '삭제' }))
+
+    expect(confirmSpy).toHaveBeenCalled()
+    expect(mocks.deleteContact).not.toHaveBeenCalled()
+
+    confirmSpy.mockRestore()
+  })
+})
 
frontend/src/components/ContactsPage.tsx (added)
+++ frontend/src/components/ContactsPage.tsx
@@ -0,0 +1,381 @@
+import { useEffect, useState } from 'react'
+import {
+  createContact,
+  deleteContact,
+  getContacts,
+  updateContactInfo,
+  type Contact,
+  type ContactCategory,
+  type ContactInput,
+} from '../api/client'
+
+const CATEGORY_LABELS: Record<ContactCategory, string> = {
+  APPLICANT: '신청기관',
+  MJ: '문정원',
+  LAWYER: '변호사',
+}
+
+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',
+}
+
+const FILTERS: { key: ContactCategory | 'ALL'; label: string }[] = [
+  { key: 'ALL', label: '전체' },
+  { key: 'APPLICANT', label: '신청기관' },
+  { key: 'MJ', label: '문정원' },
+  { key: 'LAWYER', label: '변호사' },
+]
+
+function emptyForm(contact: Contact | null): ContactInput {
+  return {
+    category: contact?.category ?? 'APPLICANT',
+    name: contact?.name ?? '',
+    affiliation: contact?.affiliation ?? '',
+    deptName: contact?.deptName ?? '',
+    title: contact?.title ?? '',
+    phone: contact?.phone ?? '',
+    email: contact?.email ?? '',
+  }
+}
+
+function ContactFormModal({
+  editing,
+  onClose,
+  onSaved,
+}: {
+  editing: Contact | null
+  onClose: () => void
+  onSaved: () => void
+}) {
+  const [form, setForm] = useState<ContactInput>(emptyForm(editing))
+  const [busy, setBusy] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+
+  useEffect(() => {
+    function onKeyDown(e: KeyboardEvent) {
+      if (e.key === 'Escape') {
+        onClose()
+      }
+    }
+    window.addEventListener('keydown', onKeyDown)
+    return () => window.removeEventListener('keydown', onKeyDown)
+  }, [onClose])
+
+  const canSave = form.category.trim() !== '' && form.name.trim() !== '' && !busy
+
+  function setField(key: keyof ContactInput, value: string) {
+    setForm((prev) => ({ ...prev, [key]: value }))
+  }
+
+  async function handleSave() {
+    if (!canSave) {
+      return
+    }
+    setBusy(true)
+    setError(null)
+    try {
+      if (editing) {
+        await updateContactInfo(editing.id, form)
+      } else {
+        await createContact(form)
+      }
+      onSaved()
+    } catch (e) {
+      setError(e instanceof Error ? e.message : '저장에 실패했습니다.')
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  return (
+    <div className="fixed inset-0 flex items-center justify-center bg-black/30">
+      <div className="w-[26rem] rounded-lg bg-white p-5">
+        <div className="flex items-center justify-between">
+          <h2 className="text-base font-semibold">{editing ? '담당자 수정' : '담당자 추가'}</h2>
+          <button
+            type="button"
+            onClick={onClose}
+            aria-label="닫기"
+            disabled={busy}
+            className="rounded-md p-1 text-gray-400 hover:bg-gray-100 disabled:opacity-50"
+          >
+            ✕
+          </button>
+        </div>
+
+        <div className="mt-4 grid grid-cols-2 gap-3 text-sm">
+          <div className="flex flex-col gap-1">
+            <label htmlFor="contact-category" className="text-xs text-gray-500">
+              구분
+            </label>
+            <select
+              id="contact-category"
+              value={form.category}
+              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>
+            </select>
+          </div>
+          <div className="flex flex-col gap-1">
+            <label htmlFor="contact-name" className="text-xs text-gray-500">
+              성명
+            </label>
+            <input
+              id="contact-name"
+              value={form.name}
+              onChange={(e) => setField('name', e.target.value)}
+              className="rounded-md border border-gray-300 px-2 py-1.5"
+            />
+          </div>
+          <div className="flex flex-col gap-1">
+            <label htmlFor="contact-affiliation" className="text-xs text-gray-500">
+              소속
+            </label>
+            <input
+              id="contact-affiliation"
+              value={form.affiliation}
+              onChange={(e) => setField('affiliation', e.target.value)}
+              className="rounded-md border border-gray-300 px-2 py-1.5"
+            />
+          </div>
+          <div className="flex flex-col gap-1">
+            <label htmlFor="contact-dept" className="text-xs text-gray-500">
+              부서
+            </label>
+            <input
+              id="contact-dept"
+              value={form.deptName}
+              onChange={(e) => setField('deptName', e.target.value)}
+              className="rounded-md border border-gray-300 px-2 py-1.5"
+            />
+          </div>
+          <div className="flex flex-col gap-1">
+            <label htmlFor="contact-title" className="text-xs text-gray-500">
+              직급/직함
+            </label>
+            <input
+              id="contact-title"
+              value={form.title}
+              onChange={(e) => setField('title', e.target.value)}
+              className="rounded-md border border-gray-300 px-2 py-1.5"
+            />
+          </div>
+          <div className="flex flex-col gap-1">
+            <label htmlFor="contact-phone" className="text-xs text-gray-500">
+              연락처
+            </label>
+            <input
+              id="contact-phone"
+              value={form.phone}
+              onChange={(e) => setField('phone', e.target.value)}
+              className="rounded-md border border-gray-300 px-2 py-1.5"
+            />
+          </div>
+          <div className="col-span-2 flex flex-col gap-1">
+            <label htmlFor="contact-email" className="text-xs text-gray-500">
+              이메일
+            </label>
+            <input
+              id="contact-email"
+              value={form.email}
+              onChange={(e) => setField('email', e.target.value)}
+              className="rounded-md border border-gray-300 px-2 py-1.5"
+            />
+          </div>
+        </div>
+
+        {error && <p className="mt-3 text-xs text-red-600">{error}</p>}
+
+        <div className="mt-5 flex justify-end gap-2">
+          <button
+            type="button"
+            disabled={busy}
+            onClick={onClose}
+            className="rounded-md border border-gray-300 px-3 py-1.5 text-sm disabled:opacity-50"
+          >
+            취소
+          </button>
+          <button
+            type="button"
+            disabled={!canSave}
+            onClick={() => void handleSave()}
+            className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white disabled:bg-gray-300"
+          >
+            저장
+          </button>
+        </div>
+      </div>
+    </div>
+  )
+}
+
+/** 담당자관리 화면. 담당자 정보가 입력되는 유일한 곳이다 - 채널관리는 여기 등록된
+ * 담당자를 ContactPickerModal로 골라 배정만 한다. */
+export default function ContactsPage() {
+  const [contacts, setContacts] = useState<Contact[]>([])
+  const [loading, setLoading] = useState(true)
+  const [filter, setFilter] = useState<ContactCategory | 'ALL'>('ALL')
+  const [modalOpen, setModalOpen] = useState(false)
+  const [editing, setEditing] = useState<Contact | null>(null)
+  const [error, setError] = useState<string | null>(null)
+
+  async function load() {
+    setLoading(true)
+    try {
+      setContacts(await getContacts())
+    } catch (e) {
+      setError(e instanceof Error ? e.message : '담당자 목록을 불러오지 못했습니다.')
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  useEffect(() => {
+    void load()
+  }, [])
+
+  const visible = filter === 'ALL' ? contacts : contacts.filter((c) => c.category === filter)
+
+  function openAdd() {
+    setEditing(null)
+    setModalOpen(true)
+  }
+
+  function openEdit(contact: Contact) {
+    setEditing(contact)
+    setModalOpen(true)
+  }
+
+  function closeModal() {
+    setModalOpen(false)
+    setEditing(null)
+  }
+
+  async function handleSaved() {
+    closeModal()
+    await load()
+  }
+
+  async function handleDelete(contact: Contact) {
+    if (!window.confirm('삭제하면 기관에 지정된 배정도 함께 해제됩니다. 삭제할까요?')) {
+      return
+    }
+    try {
+      await deleteContact(contact.id)
+      await load()
+    } catch (e) {
+      setError(e instanceof Error ? e.message : '삭제에 실패했습니다.')
+    }
+  }
+
+  return (
+    <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) => (
+            <button
+              key={f.key}
+              type="button"
+              onClick={() => setFilter(f.key)}
+              aria-current={filter === f.key ? 'true' : undefined}
+              className={`rounded-full px-3 py-1.5 text-sm ${
+                filter === f.key
+                  ? 'bg-gray-900 text-white'
+                  : 'border border-gray-300 text-gray-600 hover:bg-gray-50'
+              }`}
+            >
+              {f.label}
+            </button>
+          ))}
+        </div>
+
+        <button
+          type="button"
+          onClick={openAdd}
+          className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700"
+        >
+          담당자 추가
+        </button>
+      </div>
+
+      {error && <p className="mt-4 text-sm text-red-600">{error}</p>}
+
+      <div className="mt-5 overflow-x-auto rounded-lg border border-gray-200 bg-white">
+        <table className="w-full min-w-[860px] text-sm">
+          <thead>
+            <tr className="border-b border-gray-200 bg-gray-50 text-left text-xs text-gray-500">
+              <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="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="whitespace-nowrap px-3 py-2 font-medium">이메일</th>
+              <th className="whitespace-nowrap px-3 py-2 font-medium">관리</th>
+            </tr>
+          </thead>
+          <tbody>
+            {loading ? (
+              <tr>
+                <td colSpan={8} className="py-8 text-center text-sm text-gray-400">
+                  불러오는 중…
+                </td>
+              </tr>
+            ) : visible.length === 0 ? (
+              <tr>
+                <td colSpan={8} className="py-8 text-center text-sm text-gray-300">
+                  등록된 담당자가 없습니다.
+                </td>
+              </tr>
+            ) : (
+              visible.map((contact) => (
+                <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]}`}
+                    >
+                      {CATEGORY_LABELS[contact.category]}
+                    </span>
+                  </td>
+                  <td className="px-3 py-2 font-medium text-gray-900">{contact.name}</td>
+                  <td className="px-3 py-2 text-gray-600">{contact.affiliation ?? '-'}</td>
+                  <td className="px-3 py-2 text-gray-600">{contact.deptName ?? '-'}</td>
+                  <td className="px-3 py-2 text-gray-600">{contact.title ?? '-'}</td>
+                  <td className="px-3 py-2 text-gray-600">{contact.phone ?? '-'}</td>
+                  <td className="px-3 py-2 text-gray-600">{contact.email ?? '-'}</td>
+                  <td className="px-3 py-2">
+                    <div className="flex gap-2">
+                      <button
+                        type="button"
+                        onClick={() => openEdit(contact)}
+                        className="rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50"
+                      >
+                        수정
+                      </button>
+                      <button
+                        type="button"
+                        onClick={() => void handleDelete(contact)}
+                        className="rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50"
+                      >
+                        삭제
+                      </button>
+                    </div>
+                  </td>
+                </tr>
+              ))
+            )}
+          </tbody>
+        </table>
+      </div>
+
+      {modalOpen && (
+        <ContactFormModal editing={editing} onClose={closeModal} onSaved={() => void handleSaved()} />
+      )}
+    </div>
+  )
+}
frontend/src/components/OrgDetail.test.tsx
--- frontend/src/components/OrgDetail.test.tsx
+++ frontend/src/components/OrgDetail.test.tsx
@@ -1,18 +1,34 @@
 import { fireEvent, render, screen, waitFor } from '@testing-library/react'
 import { beforeEach, describe, expect, it, vi } from 'vitest'
 import OrgDetail from './OrgDetail'
-import type { Org } from '../api/client'
+import type { Contact, Org } from '../api/client'
 
 const mocks = vi.hoisted(() => ({
-  updateContact: vi.fn(),
+  updateAssignments: vi.fn(),
   provisionChannels: vi.fn(),
+  getContacts: vi.fn(),
 }))
 
 vi.mock('../api/client', async (importOriginal) => ({
   ...(await importOriginal<typeof import('../api/client')>()),
-  updateContact: mocks.updateContact,
+  updateAssignments: mocks.updateAssignments,
   provisionChannels: mocks.provisionChannels,
+  getContacts: mocks.getContacts,
 }))
+
+function contact(overrides: Partial<Contact> = {}): Contact {
+  return {
+    id: 1,
+    category: 'APPLICANT',
+    name: '이름없음',
+    affiliation: null,
+    deptName: null,
+    title: null,
+    phone: null,
+    email: null,
+    ...overrides,
+  }
+}
 
 function org(overrides: Partial<Org> = {}): Org {
   return {
@@ -20,18 +36,9 @@
     orgNo: '001',
     orgName: '국제방송교류재단',
     status: 'INFO_PENDING',
-    deptName: null,
-    managerName: null,
-    managerTitle: null,
-    managerPhone: null,
-    managerEmail: null,
-    mjDeptName: null,
-    mjManagerName: null,
-    mjManagerPhone: null,
-    mjManagerEmail: null,
-    lawyerName: null,
-    lawyerPhone: null,
-    lawyerEmail: null,
+    applicant: null,
+    mj: null,
+    lawyer: null,
     lawyerAssignedDate: null,
     channelIdMj: null,
     channelIdLaw: null,
@@ -40,8 +47,9 @@
 }
 
 beforeEach(() => {
-  mocks.updateContact.mockReset()
+  mocks.updateAssignments.mockReset()
   mocks.provisionChannels.mockReset()
+  mocks.getContacts.mockReset().mockResolvedValue([])
 })
 
 describe('OrgDetail', () => {
@@ -57,72 +65,115 @@
     expect(screen.getByRole('button', { name: '채널 생성' })).toBeEnabled()
   })
 
-  it('담당자 정보를 저장하면 API를 호출한다', async () => {
-    mocks.updateContact.mockResolvedValue(org({ status: 'READY' }))
+  it('배정되지 않은 역할은 미지정으로 표시된다', () => {
+    render(<OrgDetail org={org()} onChanged={() => {}} />)
+
+    expect(screen.getAllByText('미지정')).toHaveLength(3)
+  })
+
+  it('배정된 담당자는 요약(이름·부서·연락처)으로 표시된다', () => {
+    render(
+      <OrgDetail
+        org={org({
+          applicant: contact({
+            name: '송민지',
+            deptName: '데이터정보화팀',
+            phone: '02-3475-5434',
+          }),
+        })}
+        onChanged={() => {}}
+      />,
+    )
+
+    expect(screen.getByText('송민지 · 데이터정보화팀 · 02-3475-5434')).toBeTruthy()
+  })
+
+  it('선택 버튼을 누르면 담당자 선택 모달이 뜬다', async () => {
+    mocks.getContacts.mockResolvedValue([contact({ id: 9, name: '송민지' })])
+
+    render(<OrgDetail org={org()} onChanged={() => {}} />)
+
+    fireEvent.click(screen.getByRole('button', { name: '신청기관 담당자 선택' }))
+
+    expect(screen.getByText('신청기관 담당자 선택')).toBeTruthy()
+    await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
+    expect(mocks.getContacts).toHaveBeenCalledWith('APPLICANT')
+  })
+
+  it('모달에서 담당자를 고르면 updateAssignments가 그 담당자ID로 호출된다', async () => {
+    mocks.getContacts.mockResolvedValue([contact({ id: 9, name: '송민지' })])
+    mocks.updateAssignments.mockResolvedValue(org({ status: 'READY' }))
     const onChanged = vi.fn()
 
     render(<OrgDetail org={org()} onChanged={onChanged} />)
-    fireEvent.change(screen.getByLabelText('신청기관 담당자 부서명'), { target: { value: '데이터정보화팀' } })
-    fireEvent.change(screen.getByLabelText('신청기관 담당자 담당자명'), { target: { value: '송민지' } })
-    fireEvent.change(screen.getByLabelText('신청기관 담당자 직급/직함'), { target: { value: '과장' } })
-    fireEvent.change(screen.getByLabelText('신청기관 담당자 연락처'), { target: { value: '02-3475-5434' } })
-    fireEvent.change(screen.getByLabelText('신청기관 담당자 이메일'), { target: { value: 'ming@arirang.com' } })
-    fireEvent.click(screen.getByRole('button', { name: '정보 저장' }))
+
+    fireEvent.click(screen.getByRole('button', { name: '신청기관 담당자 선택' }))
+    await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
+    fireEvent.click(screen.getByRole('button', { name: '선택' }))
 
     await waitFor(() => {
-      expect(mocks.updateContact).toHaveBeenCalledWith(1, {
-        deptName: '데이터정보화팀',
-        managerName: '송민지',
-        managerTitle: '과장',
-        managerPhone: '02-3475-5434',
-        managerEmail: 'ming@arirang.com',
-        mjDeptName: '',
-        mjManagerName: '',
-        mjManagerPhone: '',
-        mjManagerEmail: '',
-        lawyerName: '',
-        lawyerPhone: '',
-        lawyerEmail: '',
-        lawyerAssignedDate: '',
+      expect(mocks.updateAssignments).toHaveBeenCalledWith(1, {
+        applicantContactId: 9,
+        mjContactId: null,
+        lawyerContactId: null,
+        lawyerAssignedDate: null,
       })
       expect(onChanged).toHaveBeenCalled()
     })
   })
 
-  it('문정원 담당자와 담당 변호사 정보를 입력하면 한 번의 저장으로 함께 전송된다', async () => {
-    mocks.updateContact.mockResolvedValue(org({ status: 'READY' }))
+  it('해제 버튼을 누르면 해당 역할만 null로 배정을 해제한다', async () => {
+    mocks.updateAssignments.mockResolvedValue(org())
     const onChanged = vi.fn()
 
-    render(<OrgDetail org={org()} onChanged={onChanged} />)
-    fireEvent.change(screen.getByLabelText('신청기관 담당자 부서명'), { target: { value: '데이터정보화팀' } })
-    fireEvent.change(screen.getByLabelText('신청기관 담당자 담당자명'), { target: { value: '송민지' } })
-    fireEvent.change(screen.getByLabelText('신청기관 담당자 연락처'), { target: { value: '02-3475-5434' } })
-    fireEvent.change(screen.getByLabelText('신청기관 담당자 이메일'), { target: { value: 'ming@arirang.com' } })
-    fireEvent.change(screen.getByLabelText('문정원 담당자 부서명'), { target: { value: '문화체육관광부 저작권정책과' } })
-    fireEvent.change(screen.getByLabelText('문정원 담당자 담당자명'), { target: { value: '김문정' } })
-    fireEvent.change(screen.getByLabelText('문정원 담당자 연락처'), { target: { value: '02-1234-5678' } })
-    fireEvent.change(screen.getByLabelText('문정원 담당자 이메일'), { target: { value: 'mj@mcst.go.kr' } })
-    fireEvent.change(screen.getByLabelText('담당 변호사 성명'), { target: { value: '이변호' } })
-    fireEvent.change(screen.getByLabelText('담당 변호사 연락처'), { target: { value: '02-9876-5432' } })
-    fireEvent.change(screen.getByLabelText('담당 변호사 이메일'), { target: { value: 'lawyer@lawfirm.kr' } })
-    fireEvent.change(screen.getByLabelText('담당 변호사 배정일'), { target: { value: '2026-07-21' } })
+    render(
+      <OrgDetail
+        org={org({
+          status: 'READY',
+          applicant: contact({ id: 5, name: '송민지' }),
+          mj: contact({ id: 6, category: 'MJ', name: '김문정' }),
+        })}
+        onChanged={onChanged}
+      />,
+    )
 
-    fireEvent.click(screen.getByRole('button', { name: '정보 저장' }))
+    fireEvent.click(screen.getByRole('button', { name: '신청기관 담당자 해제' }))
 
     await waitFor(() => {
-      expect(mocks.updateContact).toHaveBeenCalledWith(1, {
-        deptName: '데이터정보화팀',
-        managerName: '송민지',
-        managerTitle: '',
-        managerPhone: '02-3475-5434',
-        managerEmail: 'ming@arirang.com',
-        mjDeptName: '문화체육관광부 저작권정책과',
-        mjManagerName: '김문정',
-        mjManagerPhone: '02-1234-5678',
-        mjManagerEmail: 'mj@mcst.go.kr',
-        lawyerName: '이변호',
-        lawyerPhone: '02-9876-5432',
-        lawyerEmail: 'lawyer@lawfirm.kr',
+      expect(mocks.updateAssignments).toHaveBeenCalledWith(1, {
+        applicantContactId: null,
+        mjContactId: 6,
+        lawyerContactId: null,
+        lawyerAssignedDate: null,
+      })
+      expect(onChanged).toHaveBeenCalled()
+    })
+  })
+
+  it('배정이 없는 역할에는 해제 버튼이 없다', () => {
+    render(<OrgDetail org={org()} onChanged={() => {}} />)
+
+    expect(screen.queryByRole('button', { name: '신청기관 담당자 해제' })).toBeNull()
+  })
+
+  it('담당 변호사 배정일을 바꾸면 updateAssignments가 호출된다', async () => {
+    mocks.updateAssignments.mockResolvedValue(org())
+    const onChanged = vi.fn()
+
+    render(
+      <OrgDetail
+        org={org({ lawyer: contact({ id: 7, category: 'LAWYER', name: '이변호' }) })}
+        onChanged={onChanged}
+      />,
+    )
+
+    fireEvent.change(screen.getByLabelText('배정일'), { target: { value: '2026-07-21' } })
+
+    await waitFor(() => {
+      expect(mocks.updateAssignments).toHaveBeenCalledWith(1, {
+        applicantContactId: null,
+        mjContactId: null,
+        lawyerContactId: 7,
         lawyerAssignedDate: '2026-07-21',
       })
       expect(onChanged).toHaveBeenCalled()
@@ -229,30 +280,6 @@
     })
   })
 
-  it('필수 항목이 비어있으면 정보 저장 버튼이 비활성화된다', () => {
-    render(<OrgDetail org={org()} onChanged={() => {}} />)
-
-    expect(screen.getByRole('button', { name: '정보 저장' })).toBeDisabled()
-  })
-
-  it('직급/직함이 비어있어도 나머지 필수 항목만 채우면 정보 저장 버튼이 활성화된다', () => {
-    render(<OrgDetail org={org()} onChanged={() => {}} />)
-    fireEvent.change(screen.getByLabelText('신청기관 담당자 부서명'), { target: { value: '데이터정보화팀' } })
-    fireEvent.change(screen.getByLabelText('신청기관 담당자 담당자명'), { target: { value: '송민지' } })
-    fireEvent.change(screen.getByLabelText('신청기관 담당자 연락처'), { target: { value: '02-3475-5434' } })
-    fireEvent.change(screen.getByLabelText('신청기관 담당자 이메일'), { target: { value: 'ming@arirang.com' } })
-
-    expect(screen.getByRole('button', { name: '정보 저장' })).toBeEnabled()
-  })
-
-  it('문정원 담당자와 담당 변호사 항목을 채워도 신청기관 필수 항목이 비어있으면 저장 버튼은 비활성이다', () => {
-    render(<OrgDetail org={org()} onChanged={() => {}} />)
-    fireEvent.change(screen.getByLabelText('문정원 담당자 담당자명'), { target: { value: '김문정' } })
-    fireEvent.change(screen.getByLabelText('담당 변호사 성명'), { target: { value: '이변호' } })
-
-    expect(screen.getByRole('button', { name: '정보 저장' })).toBeDisabled()
-  })
-
   it('reload로 org 객체가 새로 만들어져도 같은 기관이면 결과가 지워지지 않는다', async () => {
     mocks.provisionChannels.mockResolvedValue({
       mj: 'CREATED',
@@ -280,7 +307,7 @@
     expect(screen.getByText(/법률검토 채널 생성에 실패/)).toBeTruthy()
   })
 
-  it('다른 기관으로 전환하면 이전 결과와 입력값이 초기화된다', async () => {
+  it('다른 기관으로 전환하면 이전 결과가 초기화된다', async () => {
     mocks.provisionChannels.mockResolvedValue({
       mj: 'CREATED',
       law: 'FAILED',
frontend/src/components/OrgDetail.tsx
--- frontend/src/components/OrgDetail.tsx
+++ frontend/src/components/OrgDetail.tsx
@@ -1,175 +1,126 @@
 import { useEffect, useState } from 'react'
 import {
   provisionChannels,
-  updateContact,
+  updateAssignments,
+  type AssignmentInput,
   type Contact,
+  type ContactCategory,
   type Org,
   type ProvisionResult,
 } from '../api/client'
 import ConfirmDialog from './ConfirmDialog'
+import ContactPickerModal from './ContactPickerModal'
 import StatusBadge from './StatusBadge'
 
-// 엑셀처럼 한 사람이 한 행이다. 셀이 null이면 그 역할에 해당 항목이 없다는 뜻(- 표시).
-// aria-label은 "행 제목 + 항목"으로 유일하게 만들어 라벨 중복 없이 접근 가능하게 한다.
-const CONTACT_COLUMNS = ['부서명', '담당자명', '직급/직함', '연락처', '이메일', '배정일']
+// 채널관리 상세. 담당자 정보는 여기서 입력하지 않는다(담당자관리에서만 입력) - 이 화면은
+// 이미 등록된 담당자를 ContactPickerModal로 골라 신청기관/문정원/변호사 역할에 배정만 한다.
 
-interface ContactCell {
-  key: keyof Contact
+interface RoleConfig {
+  key: 'applicant' | 'mj' | 'lawyer'
+  category: ContactCategory
   label: string
-  type?: string
+  pickerTitle: string
+  field: keyof AssignmentInput
 }
 
-const CONTACT_ROWS: { title: string; cells: (ContactCell | null)[] }[] = [
+const ROLES: RoleConfig[] = [
   {
-    title: '신청기관 담당자',
-    cells: [
-      { key: 'deptName', label: '신청기관 담당자 부서명' },
-      { key: 'managerName', label: '신청기관 담당자 담당자명' },
-      { key: 'managerTitle', label: '신청기관 담당자 직급/직함' },
-      { key: 'managerPhone', label: '신청기관 담당자 연락처' },
-      { key: 'managerEmail', label: '신청기관 담당자 이메일' },
-      null,
-    ],
+    key: 'applicant',
+    category: 'APPLICANT',
+    label: '신청기관 담당자',
+    pickerTitle: '신청기관 담당자 선택',
+    field: 'applicantContactId',
   },
   {
-    title: '문정원 담당자',
-    cells: [
-      { key: 'mjDeptName', label: '문정원 담당자 부서명' },
-      { key: 'mjManagerName', label: '문정원 담당자 담당자명' },
-      null,
-      { key: 'mjManagerPhone', label: '문정원 담당자 연락처' },
-      { key: 'mjManagerEmail', label: '문정원 담당자 이메일' },
-      null,
-    ],
+    key: 'mj',
+    category: 'MJ',
+    label: '문정원 담당자',
+    pickerTitle: '문정원 담당자 선택',
+    field: 'mjContactId',
   },
   {
-    title: '담당 변호사',
-    cells: [
-      null,
-      { key: 'lawyerName', label: '담당 변호사 성명' },
-      null,
-      { key: 'lawyerPhone', label: '담당 변호사 연락처' },
-      { key: 'lawyerEmail', label: '담당 변호사 이메일' },
-      { key: 'lawyerAssignedDate', label: '담당 변호사 배정일', type: 'date' },
-    ],
+    key: 'lawyer',
+    category: 'LAWYER',
+    label: '담당 변호사',
+    pickerTitle: '담당 변호사 선택',
+    field: 'lawyerContactId',
   },
 ]
 
-function emptyContact(org: Org): Contact {
-  return {
-    deptName: org.deptName ?? '',
-    managerName: org.managerName ?? '',
-    managerTitle: org.managerTitle ?? '',
-    managerPhone: org.managerPhone ?? '',
-    managerEmail: org.managerEmail ?? '',
-    mjDeptName: org.mjDeptName ?? '',
-    mjManagerName: org.mjManagerName ?? '',
-    mjManagerPhone: org.mjManagerPhone ?? '',
-    mjManagerEmail: org.mjManagerEmail ?? '',
-    lawyerName: org.lawyerName ?? '',
-    lawyerPhone: org.lawyerPhone ?? '',
-    lawyerEmail: org.lawyerEmail ?? '',
-    lawyerAssignedDate: org.lawyerAssignedDate ?? '',
+function contactOf(org: Org, role: RoleConfig): Contact | null {
+  switch (role.key) {
+    case 'applicant':
+      return org.applicant
+    case 'mj':
+      return org.mj
+    case 'lawyer':
+      return org.lawyer
   }
 }
 
-const REQUIRED_FIELDS: (keyof Contact)[] = [
-  'deptName',
-  'managerName',
-  'managerPhone',
-  'managerEmail',
-]
-
-function ContactEditTable({
-  contact,
-  onChange,
-}: {
-  contact: Contact
-  onChange: (key: keyof Contact, value: string) => void
-}) {
-  return (
-    <div className="overflow-x-auto rounded-lg border border-gray-200 bg-white">
-      <table className="w-full min-w-[860px] text-sm">
-        <thead>
-          <tr className="border-b border-gray-200 bg-gray-50 text-left text-xs text-gray-500">
-            <th className="whitespace-nowrap px-3 py-2 font-medium">구분</th>
-            {CONTACT_COLUMNS.map((col) => (
-              <th key={col} className="whitespace-nowrap px-3 py-2 font-medium">
-                {col}
-              </th>
-            ))}
-          </tr>
-        </thead>
-        <tbody>
-          {CONTACT_ROWS.map((row) => (
-            <tr key={row.title} className="border-b border-gray-100 last:border-b-0">
-              <th
-                scope="row"
-                className="whitespace-nowrap px-3 py-2 text-left text-sm font-medium text-gray-700"
-              >
-                {row.title}
-              </th>
-              {row.cells.map((cell, i) => (
-                <td key={cell?.key ?? `empty-${i}`} className="px-2 py-1.5">
-                  {cell ? (
-                    <input
-                      aria-label={cell.label}
-                      type={cell.type ?? 'text'}
-                      className="w-full min-w-[7rem] rounded-md border border-gray-300 px-2.5 py-1.5 text-sm"
-                      value={contact[cell.key]}
-                      onChange={(e) => onChange(cell.key, e.target.value)}
-                    />
-                  ) : (
-                    <span className="block text-center text-gray-300">-</span>
-                  )}
-                </td>
-              ))}
-            </tr>
-          ))}
-        </tbody>
-      </table>
-    </div>
-  )
+/** 배정된 담당자의 한 줄 요약: 이름 · 부서(또는 소속) · 연락처. 값이 없는 항목은 건너뛴다. */
+function contactSummary(contact: Contact): string {
+  const parts = [contact.name, contact.deptName ?? contact.affiliation ?? null, contact.phone]
+  return parts.filter((p): p is string => Boolean(p)).join(' · ')
 }
 
 export default function OrgDetail({ org, onChanged }: { org: Org; onChanged: () => void }) {
-  const [contact, setContact] = useState<Contact>(emptyContact(org))
   const [confirming, setConfirming] = useState(false)
   const [result, setResult] = useState<ProvisionResult | null>(null)
   const [error, setError] = useState<string | null>(null)
   const [busy, setBusy] = useState(false)
+  const [pickerRole, setPickerRole] = useState<RoleConfig | null>(null)
+  const [lawyerDate, setLawyerDate] = useState(org.lawyerAssignedDate ?? '')
 
-  // org.id로만 키를 잡는다(org 객체 전체가 아니라) - App.reload()가 채널 생성 뒤에
+  // org.id로만 키를 잡는다(org 객체 전체가 아니라) - App.reload()가 채널 생성/배정 뒤에
   // 새 배열/새 객체를 만들어 넘기더라도, 같은 기관을 계속 보고 있는 한 방금 받은
-  // result/error를 이 effect가 지워버리면 안 되기 때문이다(같은 이유로 입력 중이던
-  // contact 값도 다른 기관으로 전환할 때만 초기화한다).
+  // result/error를 이 effect가 지워버리면 안 되기 때문이다.
   useEffect(() => {
-    setContact(emptyContact(org))
     setResult(null)
     setError(null)
+    setLawyerDate(org.lawyerAssignedDate ?? '')
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [org.id])
 
   const displayMj = `${org.orgNo}_${org.orgName} (문정원)`
   const displayLaw = `${org.orgNo}_${org.orgName} (법률검토)`
   const canProvision = org.status !== 'INFO_PENDING'
-  const requiredFilled = REQUIRED_FIELDS.every((key) => contact[key].trim() !== '')
 
-  function setField(key: keyof Contact, value: string) {
-    setContact((prev) => ({ ...prev, [key]: value }))
+  function currentAssignment(): AssignmentInput {
+    return {
+      applicantContactId: org.applicant?.id ?? null,
+      mjContactId: org.mj?.id ?? null,
+      lawyerContactId: org.lawyer?.id ?? null,
+      lawyerAssignedDate: org.lawyerAssignedDate ?? null,
+    }
   }
 
-  async function save() {
+  /** 매 호출이 세 배정값 + 배정일을 통째로 다시 보낸다(부분 갱신이 아니다). */
+  async function persistAssignments(overrides: Partial<AssignmentInput>) {
     setError(null)
     setBusy(true)
     try {
-      await updateContact(org.id, contact)
+      await updateAssignments(org.id, { ...currentAssignment(), ...overrides })
       onChanged()
     } catch (e) {
-      setError((e as Error).message)
+      setError(e instanceof Error ? e.message : '저장에 실패했습니다.')
     } finally {
       setBusy(false)
     }
+  }
+
+  async function handleSelect(role: RoleConfig, contact: Contact) {
+    setPickerRole(null)
+    await persistAssignments({ [role.field]: contact.id })
+  }
+
+  async function handleClear(role: RoleConfig) {
+    await persistAssignments({ [role.field]: null })
+  }
+
+  async function handleLawyerDateChange(value: string) {
+    setLawyerDate(value)
+    await persistAssignments({ lawyerAssignedDate: value === '' ? null : value })
   }
 
   async function provision() {
@@ -196,22 +147,67 @@
         <StatusBadge status={org.status} />
       </div>
 
-      <section className="mt-6">
-        <ContactEditTable contact={contact} onChange={setField} />
-        <p className="mt-2 text-xs text-gray-400">
-          신청기관 담당자의 부서명·담당자명·연락처·이메일은 채널 생성을 위해 필수입니다.
-        </p>
+      <section className="mt-6 space-y-3">
+        {ROLES.map((role) => {
+          const contact = contactOf(org, role)
+          return (
+            <div
+              key={role.key}
+              className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-gray-200 bg-white p-4"
+            >
+              <div className="flex flex-col gap-1">
+                <span className="text-xs text-gray-500">{role.label}</span>
+                {contact ? (
+                  <span className="text-sm font-medium text-gray-900">{contactSummary(contact)}</span>
+                ) : (
+                  <span className="text-sm text-gray-300">미지정</span>
+                )}
+              </div>
+
+              <div className="flex items-end gap-2">
+                {role.key === 'lawyer' && (
+                  <div className="flex flex-col gap-1">
+                    <label htmlFor="lawyer-assigned-date" className="text-xs text-gray-500">
+                      배정일
+                    </label>
+                    <input
+                      id="lawyer-assigned-date"
+                      type="date"
+                      disabled={busy}
+                      value={lawyerDate}
+                      onChange={(e) => void handleLawyerDateChange(e.target.value)}
+                      className="rounded-md border border-gray-300 px-2 py-1 text-sm"
+                    />
+                  </div>
+                )}
+                <button
+                  type="button"
+                  disabled={busy}
+                  aria-label={`${role.label} 선택`}
+                  onClick={() => setPickerRole(role)}
+                  className="rounded-md border border-gray-300 px-3 py-1.5 text-sm disabled:opacity-50"
+                >
+                  선택
+                </button>
+                {contact && (
+                  <button
+                    type="button"
+                    disabled={busy}
+                    aria-label={`${role.label} 해제`}
+                    onClick={() => void handleClear(role)}
+                    className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-500 disabled:opacity-50"
+                  >
+                    해제
+                  </button>
+                )}
+              </div>
+            </div>
+          )
+        })}
+        <p className="text-xs text-gray-400">신청기관 담당자를 지정해야 채널을 만들 수 있습니다.</p>
       </section>
 
       <div className="mt-5 flex gap-2">
-        <button
-          type="button"
-          disabled={busy || !requiredFilled}
-          onClick={() => void save()}
-          className="rounded-md border border-gray-300 px-3 py-1.5 text-sm disabled:opacity-50"
-        >
-          정보 저장
-        </button>
         <button
           type="button"
           disabled={busy || !canProvision}
@@ -265,6 +261,15 @@
           onCancel={() => setConfirming(false)}
         />
       )}
+
+      {pickerRole && (
+        <ContactPickerModal
+          category={pickerRole.category}
+          title={pickerRole.pickerTitle}
+          onSelect={(contact) => void handleSelect(pickerRole, contact)}
+          onClose={() => setPickerRole(null)}
+        />
+      )}
     </div>
   )
 }
frontend/src/components/OrgList.test.tsx
--- frontend/src/components/OrgList.test.tsx
+++ frontend/src/components/OrgList.test.tsx
@@ -6,18 +6,12 @@
 const orgs: Org[] = [
   {
     id: 1, orgNo: '001', orgName: '국제방송교류재단', status: 'ACTIVE',
-    deptName: null, managerName: null, managerTitle: null,
-    managerPhone: null, managerEmail: null,
-    mjDeptName: null, mjManagerName: null, mjManagerPhone: null, mjManagerEmail: null,
-    lawyerName: null, lawyerPhone: null, lawyerEmail: null, lawyerAssignedDate: null,
+    applicant: null, mj: null, lawyer: null, lawyerAssignedDate: null,
     channelIdMj: 'a', channelIdLaw: 'b',
   },
   {
     id: 2, orgNo: '008', orgName: '경찰청_치안정책연구소', status: 'INFO_PENDING',
-    deptName: null, managerName: null, managerTitle: null,
-    managerPhone: null, managerEmail: null,
-    mjDeptName: null, mjManagerName: null, mjManagerPhone: null, mjManagerEmail: null,
-    lawyerName: null, lawyerPhone: null, lawyerEmail: null, lawyerAssignedDate: null,
+    applicant: null, mj: null, lawyer: null, lawyerAssignedDate: null,
     channelIdMj: null, channelIdLaw: null,
   },
 ]
frontend/src/components/OrgOverview.test.tsx
--- frontend/src/components/OrgOverview.test.tsx
+++ frontend/src/components/OrgOverview.test.tsx
@@ -1,7 +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 type { Org } from '../api/client'
+import type { Contact, Org } from '../api/client'
 
 const mocks = vi.hoisted(() => ({
   getPosts: vi.fn(),
@@ -16,24 +16,38 @@
   getMemos: mocks.getMemos,
 }))
 
+function contact(overrides: Partial<Contact> = {}): Contact {
+  return {
+    id: 1,
+    category: 'APPLICANT',
+    name: '이름없음',
+    affiliation: null,
+    deptName: null,
+    title: null,
+    phone: null,
+    email: null,
+    ...overrides,
+  }
+}
+
 function org(overrides: Partial<Org> = {}): Org {
   return {
     id: 1,
     orgNo: '001',
     orgName: '국제방송교류재단',
     status: 'ACTIVE',
-    deptName: '데이터정보화팀',
-    managerName: '송민지',
-    managerTitle: '과장',
-    managerPhone: '02-3475-5434',
-    managerEmail: 'ming@arirang.com',
-    mjDeptName: null,
-    mjManagerName: null,
-    mjManagerPhone: null,
-    mjManagerEmail: null,
-    lawyerName: null,
-    lawyerPhone: null,
-    lawyerEmail: null,
+    applicant: contact({
+      id: 1,
+      category: 'APPLICANT',
+      name: '송민지',
+      affiliation: '국제방송교류재단',
+      deptName: '데이터정보화팀',
+      title: '과장',
+      phone: '02-3475-5434',
+      email: 'ming@arirang.com',
+    }),
+    mj: null,
+    lawyer: null,
     lawyerAssignedDate: null,
     channelIdMj: 'chan-mj',
     channelIdLaw: 'chan-law',
@@ -74,13 +88,21 @@
     render(
       <OrgOverview
         org={org({
-          mjDeptName: '문화체육관광부 저작권정책과',
-          mjManagerName: '김문정',
-          mjManagerPhone: '02-1234-5678',
-          mjManagerEmail: 'mj@mcst.go.kr',
-          lawyerName: '이변호',
-          lawyerPhone: '02-9876-5432',
-          lawyerEmail: 'lawyer@lawfirm.kr',
+          mj: contact({
+            id: 2,
+            category: 'MJ',
+            name: '김문정',
+            deptName: '문화체육관광부 저작권정책과',
+            phone: '02-1234-5678',
+            email: 'mj@mcst.go.kr',
+          }),
+          lawyer: contact({
+            id: 3,
+            category: 'LAWYER',
+            name: '이변호',
+            phone: '02-9876-5432',
+            email: 'lawyer@lawfirm.kr',
+          }),
           lawyerAssignedDate: '2026-07-21',
         })}
       />,
frontend/src/components/OrgOverview.tsx
--- frontend/src/components/OrgOverview.tsx
+++ frontend/src/components/OrgOverview.tsx
@@ -120,39 +120,40 @@
           </div>
         </div>
 
-        {/* 담당자 카드 3개 (신청기관/문정원/변호사) */}
+        {/* 담당자 카드 3개 (신청기관/문정원/변호사) - 담당자 정보는 담당자관리에서만 입력하고,
+            여기서는 org.applicant/mj/lawyer로 배정된 담당자를 읽기 전용으로 보여준다. */}
         <div className="mt-5 grid grid-cols-1 gap-3 lg:grid-cols-3">
           <ContactCard
             title="신청기관 담당자"
             fields={[
-              { label: '부서', value: org.deptName },
+              { label: '부서', value: org.applicant?.deptName ?? null },
               {
                 label: '담당자',
-                value: org.managerName
-                  ? org.managerTitle
-                    ? `${org.managerName} ${org.managerTitle}`
-                    : org.managerName
+                value: org.applicant
+                  ? org.applicant.title
+                    ? `${org.applicant.name} ${org.applicant.title}`
+                    : org.applicant.name
                   : null,
               },
-              { label: '연락처', value: org.managerPhone },
-              { label: '이메일', value: org.managerEmail },
+              { label: '연락처', value: org.applicant?.phone ?? null },
+              { label: '이메일', value: org.applicant?.email ?? null },
             ]}
           />
           <ContactCard
             title="문정원 담당자"
             fields={[
-              { label: '부서', value: org.mjDeptName },
-              { label: '담당자', value: org.mjManagerName },
-              { label: '연락처', value: org.mjManagerPhone },
-              { label: '이메일', value: org.mjManagerEmail },
+              { label: '부서', value: org.mj?.deptName ?? null },
+              { label: '담당자', value: org.mj?.name ?? null },
+              { label: '연락처', value: org.mj?.phone ?? null },
+              { label: '이메일', value: org.mj?.email ?? null },
             ]}
           />
           <ContactCard
             title="담당 변호사"
             fields={[
-              { label: '성명', value: org.lawyerName },
-              { label: '연락처', value: org.lawyerPhone },
-              { label: '이메일', value: org.lawyerEmail },
+              { label: '성명', value: org.lawyer?.name ?? null },
+              { label: '연락처', value: org.lawyer?.phone ?? null },
+              { label: '이메일', value: org.lawyer?.email ?? null },
               { label: '배정일', value: org.lawyerAssignedDate },
             ]}
           />
frontend/src/components/Sidebar.test.tsx
--- frontend/src/components/Sidebar.test.tsx
+++ frontend/src/components/Sidebar.test.tsx
@@ -10,6 +10,7 @@
       'Dashboard',
       '채널관리',
       '기관관리',
+      '담당자관리',
       '사업관리',
       '권리확인',
       '권리처리',
frontend/src/components/Sidebar.tsx
--- frontend/src/components/Sidebar.tsx
+++ frontend/src/components/Sidebar.tsx
@@ -46,6 +46,16 @@
     ),
   },
   {
+    key: 'contacts',
+    label: '담당자관리',
+    enabled: true,
+    icon: (
+      <svg className={ICON_CLASS} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+        <path d="M16 14a4 4 0 1 0-8 0M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM4 21a8 8 0 0 1 16 0" />
+      </svg>
+    ),
+  },
+  {
     key: 'projects',
     label: '사업관리',
     enabled: false,
frontend/src/components/WorkMemos.test.tsx
--- frontend/src/components/WorkMemos.test.tsx
+++ frontend/src/components/WorkMemos.test.tsx
@@ -1,7 +1,7 @@
 import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
 import { beforeEach, describe, expect, it, vi } from 'vitest'
 import WorkMemos from './WorkMemos'
-import type { Org, WorkMemo } from '../api/client'
+import type { Contact, Org, WorkMemo } from '../api/client'
 
 const mocks = vi.hoisted(() => ({
   getMemos: vi.fn(),
@@ -16,24 +16,38 @@
   deleteMemo: mocks.deleteMemo,
 }))
 
+function contact(overrides: Partial<Contact> = {}): Contact {
+  return {
+    id: 1,
+    category: 'APPLICANT',
+    name: '이름없음',
+    affiliation: null,
+    deptName: null,
+    title: null,
+    phone: null,
+    email: null,
+    ...overrides,
+  }
+}
+
 function org(overrides: Partial<Org> = {}): Org {
   return {
     id: 1,
     orgNo: '001',
     orgName: '국제방송교류재단',
     status: 'ACTIVE',
-    deptName: '데이터정보화팀',
-    managerName: '송민지',
-    managerTitle: '과장',
-    managerPhone: '02-3475-5434',
-    managerEmail: 'ming@arirang.com',
-    mjDeptName: null,
-    mjManagerName: null,
-    mjManagerPhone: null,
-    mjManagerEmail: null,
-    lawyerName: null,
-    lawyerPhone: null,
-    lawyerEmail: null,
+    applicant: contact({
+      id: 1,
+      category: 'APPLICANT',
+      name: '송민지',
+      affiliation: '국제방송교류재단',
+      deptName: '데이터정보화팀',
+      title: '과장',
+      phone: '02-3475-5434',
+      email: 'ming@arirang.com',
+    }),
+    mj: null,
+    lawyer: null,
     lawyerAssignedDate: null,
     channelIdMj: 'chan-mj',
     channelIdLaw: 'chan-law',
@@ -105,8 +119,8 @@
     render(
       <WorkMemos
         org={org({
-          mjManagerName: '김문정',
-          lawyerName: '이변호',
+          mj: contact({ id: 2, category: 'MJ', name: '김문정' }),
+          lawyer: contact({ id: 3, category: 'LAWYER', name: '이변호' }),
         })}
       />,
     )
frontend/src/components/WorkMemos.tsx
--- frontend/src/components/WorkMemos.tsx
+++ frontend/src/components/WorkMemos.tsx
@@ -15,17 +15,17 @@
   return dateFormatter.format(new Date(createdAt))
 }
 
-function managerOptionLabel(org: Org): string | null {
-  if (!org.managerName) {
+function applicantOptionLabel(org: Org): string | null {
+  if (!org.applicant) {
     return null
   }
-  return org.managerTitle ? `${org.managerName} ${org.managerTitle}` : org.managerName
+  return org.applicant.title ? `${org.applicant.name} ${org.applicant.title}` : org.applicant.name
 }
 
-/** 신청기관 담당자, 문정원 담당자, 담당 변호사 중 이름이 채워진 사람만 후보로 낸다.
+/** 신청기관 담당자, 문정원 담당자, 담당 변호사 중 배정되어 있는 사람만 후보로 낸다.
  * 이름이 같은 사람이 중복 등록돼 있어도 셀렉트 옵션은 한 번만 보이도록 중복을 없앤다. */
 function contactOptions(org: Org): string[] {
-  const candidates = [managerOptionLabel(org), org.mjManagerName, org.lawyerName]
+  const candidates = [applicantOptionLabel(org), org.mj?.name ?? null, org.lawyer?.name ?? null]
   const seen = new Set<string>()
   const options: string[] = []
   for (const candidate of candidates) {
Add a comment
List