ITN Dev 07-23
feat: 담당자를 별도 contact 테이블로 분리하고 배정 API로 전환
기관에 박혀 있던 담당자 3종(신청기관/문정원/변호사) 컬럼을 contact 테이블로 옮기고,
기관은 담당자 id만 배정으로 참조하게 했다. 신청기관 담당자 배정 여부가 채널 생성
가능(READY) 판정 기준이 되도록 상태 로직도 함께 바꿨다.

- V4 마이그레이션: contact 테이블 생성 + 기존 임베디드 컬럼 백필 후 제거
- kr.itn.itnhub.contact 패키지: 담당자 CRUD API (GET/POST/PUT/DELETE /api/contacts)
- PUT /api/orgs/{id}/assignments가 기존 /contact 엔드포인트를 대체 (구분 불일치 400)
- SeedService: 시트의 담당자명은 신청기관 담당자가 아직 없을 때만 새로 만들어 연결
- ChannelProvisionService 게이트를 hasApplicantAssigned()로 교체
@053356d5cd1a5fbc2eebc8a80aaaf2691ec0bb5d
src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
--- src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
+++ src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
@@ -1,5 +1,7 @@
 package kr.itn.itnhub.config;
 
+import kr.itn.itnhub.contact.ContactNotFoundException;
+import kr.itn.itnhub.contact.InvalidContactException;
 import kr.itn.itnhub.feed.ChannelNotReadyException;
 import kr.itn.itnhub.feed.InvalidMessageException;
 import kr.itn.itnhub.org.OrgNotFoundException;
@@ -40,6 +42,13 @@
  * <p>{@link InvalidMessageException}은 채팅 탭에서 메시지 본문과 첨부파일이 둘 다
  * 없는 채로 전송을 시도할 때 던진다 - 흔한 사용자 실수이므로 400을 쓴다.</p>
  *
+ * <p>{@link ContactNotFoundException}은 존재하지 않는 담당자 id로 조회/수정/삭제를
+ * 시도했을 때 던진다 - URL의 id가 단순히 틀린 것뿐이므로 404가 맞다.</p>
+ *
+ * <p>{@link InvalidContactException}은 담당자 구분(category)이 APPLICANT/MJ/LAWYER
+ * 중 하나가 아니거나, 기관에 배정하려는 담당자의 구분이 그 역할과 맞지 않을 때
+ * 던진다 - 흔한 사용자 실수이므로 400을 쓴다.</p>
+ *
  * <p><b>여기에 {@code Exception.class} catch-all을 추가하지 말 것.</b> 예상하지 못한
  * 예외까지 4xx로 감싸버리면 진짜 버그가 조용히 묻힌다. 예상 밖 예외는 기본 500 처리
  * 그대로 두는 것이 의도다.</p>
@@ -50,12 +59,10 @@
     /** 검증 오류 메시지에 쓸 필드명 → 한글 라벨. 목록에 없는 필드는 원본 이름 그대로 보여준다. */
     private static final Map<String, String> FIELD_LABELS = Map.of(
             "deptName", "부서명",
-            "managerName", "담당자명",
-            "managerTitle", "직급/직함",
-            "managerPhone", "연락처",
-            "managerEmail", "이메일",
             "contactName", "담당자",
-            "body", "메모 내용");
+            "body", "메모 내용",
+            "category", "구분",
+            "name", "성명");
 
     @ExceptionHandler(OrgNotFoundException.class)
     public ResponseEntity<ApiError> handleNotFound(OrgNotFoundException e) {
@@ -85,4 +92,14 @@
     public ResponseEntity<ApiError> handleInvalidMessage(InvalidMessageException e) {
         return ResponseEntity.badRequest().body(new ApiError(e.getMessage()));
     }
+
+    @ExceptionHandler(ContactNotFoundException.class)
+    public ResponseEntity<ApiError> handleContactNotFound(ContactNotFoundException e) {
+        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ApiError(e.getMessage()));
+    }
+
+    @ExceptionHandler(InvalidContactException.class)
+    public ResponseEntity<ApiError> handleInvalidContact(InvalidContactException e) {
+        return ResponseEntity.badRequest().body(new ApiError(e.getMessage()));
+    }
 }
 
src/main/java/kr/itn/itnhub/contact/Contact.java (added)
+++ src/main/java/kr/itn/itnhub/contact/Contact.java
@@ -0,0 +1,42 @@
+package kr.itn.itnhub.contact;
+
+/**
+ * 담당자 1명. 신청기관/문정원/변호사 세 구분({@link #category})을 한 테이블에 담는다 -
+ * 세 구분이 필드 구성(성명·소속·부서·연락처 등)을 그대로 공유하기 때문이다.
+ * 신청기관 담당자는 {@code affiliation}에 소속 기관명이 들어간다.
+ */
+public class Contact {
+
+    private Long id;
+    private String category;
+    private String name;
+    private String affiliation;
+    private String deptName;
+    private String title;
+    private String phone;
+    private String email;
+
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+
+    public String getCategory() { return category; }
+    public void setCategory(String category) { this.category = category; }
+
+    public String getName() { return name; }
+    public void setName(String name) { this.name = name; }
+
+    public String getAffiliation() { return affiliation; }
+    public void setAffiliation(String affiliation) { this.affiliation = affiliation; }
+
+    public String getDeptName() { return deptName; }
+    public void setDeptName(String deptName) { this.deptName = deptName; }
+
+    public String getTitle() { return title; }
+    public void setTitle(String title) { this.title = title; }
+
+    public String getPhone() { return phone; }
+    public void setPhone(String phone) { this.phone = phone; }
+
+    public String getEmail() { return email; }
+    public void setEmail(String email) { this.email = email; }
+}
 
src/main/java/kr/itn/itnhub/contact/ContactController.java (added)
+++ src/main/java/kr/itn/itnhub/contact/ContactController.java
@@ -0,0 +1,49 @@
+package kr.itn.itnhub.contact;
+
+import jakarta.validation.Valid;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/** 담당자관리 화면 전용 CRUD. 담당자 정보가 입력되는 유일한 경로다. */
+@RestController
+@RequestMapping("/api/contacts")
+public class ContactController {
+
+    private final ContactService service;
+
+    public ContactController(ContactService service) {
+        this.service = service;
+    }
+
+    @GetMapping
+    public List<Contact> list(@RequestParam(required = false) String category) {
+        return service.findAll(category);
+    }
+
+    @PostMapping
+    public Contact create(@Valid @RequestBody ContactRequest request) {
+        return service.create(request);
+    }
+
+    @PutMapping("/{id}")
+    public Contact update(@PathVariable Long id, @Valid @RequestBody ContactRequest request) {
+        return service.update(id, request);
+    }
+
+    @ResponseStatus(HttpStatus.NO_CONTENT)
+    @DeleteMapping("/{id}")
+    public void delete(@PathVariable Long id) {
+        service.delete(id);
+    }
+}
 
src/main/java/kr/itn/itnhub/contact/ContactMapper.java (added)
+++ src/main/java/kr/itn/itnhub/contact/ContactMapper.java
@@ -0,0 +1,22 @@
+package kr.itn.itnhub.contact;
+
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+@Mapper
+public interface ContactMapper {
+
+    /** category가 null이면 전체를 구분→성명 오름차순으로 돌려준다. */
+    List<Contact> findAll(@Param("category") String category);
+
+    Contact findById(@Param("id") Long id);
+
+    int insert(Contact contact);
+
+    int update(Contact contact);
+
+    /** FK는 on delete set null이므로, 이 담당자를 가리키던 기관의 배정은 자동으로 해제된다. */
+    int delete(@Param("id") Long id);
+}
 
src/main/java/kr/itn/itnhub/contact/ContactNotFoundException.java (added)
+++ src/main/java/kr/itn/itnhub/contact/ContactNotFoundException.java
@@ -0,0 +1,11 @@
+package kr.itn.itnhub.contact;
+
+/**
+ * 존재하지 않는 담당자 id로 조회/수정/삭제를 시도했을 때 던진다.
+ * {@link kr.itn.itnhub.config.GlobalExceptionHandler}가 404로 변환한다.
+ */
+public class ContactNotFoundException extends RuntimeException {
+    public ContactNotFoundException(String message) {
+        super(message);
+    }
+}
 
src/main/java/kr/itn/itnhub/contact/ContactRequest.java (added)
+++ src/main/java/kr/itn/itnhub/contact/ContactRequest.java
@@ -0,0 +1,18 @@
+package kr.itn.itnhub.contact;
+
+import jakarta.validation.constraints.NotBlank;
+
+/**
+ * 담당자 추가/수정 요청. category는 여기서는 형식(공백 아님)만 검증하고,
+ * APPLICANT/MJ/LAWYER 중 하나인지는 {@link ContactService}가 검증해
+ * "구분 값이 올바르지 않습니다" 같은 도메인 메시지를 붙일 수 있게 한다.
+ */
+public record ContactRequest(
+        @NotBlank String category,
+        @NotBlank String name,
+        String affiliation,
+        String deptName,
+        String title,
+        String phone,
+        String email) {
+}
 
src/main/java/kr/itn/itnhub/contact/ContactService.java (added)
+++ src/main/java/kr/itn/itnhub/contact/ContactService.java
@@ -0,0 +1,75 @@
+package kr.itn.itnhub.contact;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.Set;
+
+@Service
+public class ContactService {
+
+    private static final Set<String> CATEGORIES = Set.of("APPLICANT", "MJ", "LAWYER");
+
+    private final ContactMapper mapper;
+
+    public ContactService(ContactMapper mapper) {
+        this.mapper = mapper;
+    }
+
+    public List<Contact> findAll(String category) {
+        if (category != null && !CATEGORIES.contains(category)) {
+            throw new InvalidContactException("구분 값이 올바르지 않습니다: " + category);
+        }
+        return mapper.findAll(category);
+    }
+
+    @Transactional
+    public Contact create(ContactRequest request) {
+        validateCategory(request.category());
+
+        Contact contact = new Contact();
+        applyRequest(contact, request);
+        mapper.insert(contact);
+        return mapper.findById(contact.getId());
+    }
+
+    @Transactional
+    public Contact update(Long id, ContactRequest request) {
+        validateCategory(request.category());
+
+        Contact existing = mapper.findById(id);
+        if (existing == null) {
+            throw new ContactNotFoundException("담당자를 찾을 수 없습니다: " + id);
+        }
+        applyRequest(existing, request);
+        mapper.update(existing);
+        return mapper.findById(id);
+    }
+
+    /** FK는 on delete set null이므로, 이 담당자를 배정으로 쓰던 기관들은 자동으로 미지정이 된다. */
+    @Transactional
+    public void delete(Long id) {
+        Contact existing = mapper.findById(id);
+        if (existing == null) {
+            throw new ContactNotFoundException("담당자를 찾을 수 없습니다: " + id);
+        }
+        mapper.delete(id);
+    }
+
+    private void applyRequest(Contact contact, ContactRequest request) {
+        contact.setCategory(request.category());
+        contact.setName(request.name());
+        contact.setAffiliation(request.affiliation());
+        contact.setDeptName(request.deptName());
+        contact.setTitle(request.title());
+        contact.setPhone(request.phone());
+        contact.setEmail(request.email());
+    }
+
+    private void validateCategory(String category) {
+        if (!CATEGORIES.contains(category)) {
+            throw new InvalidContactException("구분 값이 올바르지 않습니다: " + category);
+        }
+    }
+}
 
src/main/java/kr/itn/itnhub/contact/InvalidContactException.java (added)
+++ src/main/java/kr/itn/itnhub/contact/InvalidContactException.java
@@ -0,0 +1,12 @@
+package kr.itn.itnhub.contact;
+
+/**
+ * 담당자 구분(category)이 APPLICANT/MJ/LAWYER 중 하나가 아니거나, 기관 담당자 배정 시
+ * 지정한 contactId가 그 역할에 맞는 구분의 담당자가 아닐 때 던진다. 흔한 사용자 실수이므로
+ * {@link kr.itn.itnhub.config.GlobalExceptionHandler}가 400으로 변환한다.
+ */
+public class InvalidContactException extends RuntimeException {
+    public InvalidContactException(String message) {
+        super(message);
+    }
+}
 
src/main/java/kr/itn/itnhub/org/AssignmentRequest.java (added)
+++ src/main/java/kr/itn/itnhub/org/AssignmentRequest.java
@@ -0,0 +1,13 @@
+package kr.itn.itnhub.org;
+
+/**
+ * 채널관리 화면에서 담당자 3종(신청기관/문정원/변호사)을 배정한다.
+ * null은 "그 역할의 배정을 해제한다"는 뜻이며, 매 호출마다 세 배정값 + 배정일을 모두 보낸다
+ * (부분 갱신이 아니다 - PUT 하나가 항상 현재 상태 전체를 반영한다).
+ */
+public record AssignmentRequest(
+        Long applicantContactId,
+        Long mjContactId,
+        Long lawyerContactId,
+        String lawyerAssignedDate) {
+}
 
src/main/java/kr/itn/itnhub/org/ContactRequest.java (deleted)
--- src/main/java/kr/itn/itnhub/org/ContactRequest.java
@@ -1,21 +0,0 @@
-package kr.itn.itnhub.org;
-
-import jakarta.validation.constraints.Email;
-import jakarta.validation.constraints.NotBlank;
-import jakarta.validation.constraints.Size;
-
-public record ContactRequest(
-        @NotBlank String deptName,
-        @NotBlank String managerName,
-        String managerTitle,
-        @NotBlank String managerPhone,
-        @NotBlank @Email String managerEmail,
-        @Size(max = 200) String mjDeptName,
-        @Size(max = 100) String mjManagerName,
-        @Size(max = 50) String mjManagerPhone,
-        @Size(max = 200) String mjManagerEmail,
-        @Size(max = 100) String lawyerName,
-        @Size(max = 50) String lawyerPhone,
-        @Size(max = 200) String lawyerEmail,
-        @Size(max = 20) String lawyerAssignedDate) {
-}
src/main/java/kr/itn/itnhub/org/OrgResponse.java
--- src/main/java/kr/itn/itnhub/org/OrgResponse.java
+++ src/main/java/kr/itn/itnhub/org/OrgResponse.java
@@ -1,22 +1,15 @@
 package kr.itn.itnhub.org;
 
+import kr.itn.itnhub.contact.Contact;
+
 public record OrgResponse(
         Long id,
         String orgNo,
         String orgName,
         OrgStatus status,
-        String deptName,
-        String managerName,
-        String managerTitle,
-        String managerPhone,
-        String managerEmail,
-        String mjDeptName,
-        String mjManagerName,
-        String mjManagerPhone,
-        String mjManagerEmail,
-        String lawyerName,
-        String lawyerPhone,
-        String lawyerEmail,
+        Contact applicant,
+        Contact mj,
+        Contact lawyer,
         String lawyerAssignedDate,
         String channelIdMj,
         String channelIdLaw) {
@@ -27,18 +20,9 @@
                 org.getOrgNo(),
                 org.getOrgName(),
                 org.getStatus(),
-                org.getDeptName(),
-                org.getManagerName(),
-                org.getManagerTitle(),
-                org.getManagerPhone(),
-                org.getManagerEmail(),
-                org.getMjDeptName(),
-                org.getMjManagerName(),
-                org.getMjManagerPhone(),
-                org.getMjManagerEmail(),
-                org.getLawyerName(),
-                org.getLawyerPhone(),
-                org.getLawyerEmail(),
+                org.getApplicant(),
+                org.getMj(),
+                org.getLawyer(),
                 org.getLawyerAssignedDate(),
                 org.getChannelIdMj(),
                 org.getChannelIdLaw());
src/main/java/kr/itn/itnhub/org/Organization.java
--- src/main/java/kr/itn/itnhub/org/Organization.java
+++ src/main/java/kr/itn/itnhub/org/Organization.java
@@ -1,50 +1,45 @@
 package kr.itn.itnhub.org;
 
+import kr.itn.itnhub.contact.Contact;
+
 public class Organization {
 
     private Long id;
     private String orgNo;
     private String orgName;
     private String channelSlug;
-    private String deptName;
-    private String managerName;
-    private String managerTitle;
-    private String managerPhone;
-    private String managerEmail;
-    private String mjDeptName;
-    private String mjManagerName;
-    private String mjManagerPhone;
-    private String mjManagerEmail;
-    private String lawyerName;
-    private String lawyerPhone;
-    private String lawyerEmail;
+    private Long applicantContactId;
+    private Long mjContactId;
+    private Long lawyerContactId;
     private String lawyerAssignedDate;
     private String channelIdMj;
     private String channelIdLaw;
+
+    /** findAll/findById가 contact 테이블과 조인해 채워준다. 시드/upsert 경로에서는 비어 있다. */
+    private Contact applicant;
+    private Contact mj;
+    private Contact lawyer;
 
     /**
      * 상태는 저장하지 않고 항상 파생한다.
      * 저장하면 채널ID와 상태가 어긋나는 순간이 생기고, 그 어긋남을 고칠 방법이 없다.
      */
     public OrgStatus getStatus() {
-        boolean mj = filled(channelIdMj);
-        boolean law = filled(channelIdLaw);
+        boolean mjActive = filled(channelIdMj);
+        boolean lawActive = filled(channelIdLaw);
 
-        if (mj && law) {
+        if (mjActive && lawActive) {
             return OrgStatus.ACTIVE;
         }
-        if (mj || law) {
+        if (mjActive || lawActive) {
             return OrgStatus.PARTIAL;
         }
-        return hasRequiredContact() ? OrgStatus.READY : OrgStatus.INFO_PENDING;
+        return hasApplicantAssigned() ? OrgStatus.READY : OrgStatus.INFO_PENDING;
     }
 
-    /** 직급/직함은 비어 있는 기관이 실제로 있으므로 필수에서 뺀다. */
-    public boolean hasRequiredContact() {
-        return filled(deptName)
-                && filled(managerName)
-                && filled(managerPhone)
-                && filled(managerEmail);
+    /** 신청기관 담당자가 배정되어야 채널을 만들 수 있다(담당자관리에서 등록·배정된 담당자). */
+    public boolean hasApplicantAssigned() {
+        return applicantContactId != null;
     }
 
     private static boolean filled(String value) {
@@ -63,41 +58,14 @@
     public String getChannelSlug() { return channelSlug; }
     public void setChannelSlug(String channelSlug) { this.channelSlug = channelSlug; }
 
-    public String getDeptName() { return deptName; }
-    public void setDeptName(String deptName) { this.deptName = deptName; }
+    public Long getApplicantContactId() { return applicantContactId; }
+    public void setApplicantContactId(Long applicantContactId) { this.applicantContactId = applicantContactId; }
 
-    public String getManagerName() { return managerName; }
-    public void setManagerName(String managerName) { this.managerName = managerName; }
+    public Long getMjContactId() { return mjContactId; }
+    public void setMjContactId(Long mjContactId) { this.mjContactId = mjContactId; }
 
-    public String getManagerTitle() { return managerTitle; }
-    public void setManagerTitle(String managerTitle) { this.managerTitle = managerTitle; }
-
-    public String getManagerPhone() { return managerPhone; }
-    public void setManagerPhone(String managerPhone) { this.managerPhone = managerPhone; }
-
-    public String getManagerEmail() { return managerEmail; }
-    public void setManagerEmail(String managerEmail) { this.managerEmail = managerEmail; }
-
-    public String getMjDeptName() { return mjDeptName; }
-    public void setMjDeptName(String mjDeptName) { this.mjDeptName = mjDeptName; }
-
-    public String getMjManagerName() { return mjManagerName; }
-    public void setMjManagerName(String mjManagerName) { this.mjManagerName = mjManagerName; }
-
-    public String getMjManagerPhone() { return mjManagerPhone; }
-    public void setMjManagerPhone(String mjManagerPhone) { this.mjManagerPhone = mjManagerPhone; }
-
-    public String getMjManagerEmail() { return mjManagerEmail; }
-    public void setMjManagerEmail(String mjManagerEmail) { this.mjManagerEmail = mjManagerEmail; }
-
-    public String getLawyerName() { return lawyerName; }
-    public void setLawyerName(String lawyerName) { this.lawyerName = lawyerName; }
-
-    public String getLawyerPhone() { return lawyerPhone; }
-    public void setLawyerPhone(String lawyerPhone) { this.lawyerPhone = lawyerPhone; }
-
-    public String getLawyerEmail() { return lawyerEmail; }
-    public void setLawyerEmail(String lawyerEmail) { this.lawyerEmail = lawyerEmail; }
+    public Long getLawyerContactId() { return lawyerContactId; }
+    public void setLawyerContactId(Long lawyerContactId) { this.lawyerContactId = lawyerContactId; }
 
     public String getLawyerAssignedDate() { return lawyerAssignedDate; }
     public void setLawyerAssignedDate(String lawyerAssignedDate) { this.lawyerAssignedDate = lawyerAssignedDate; }
@@ -107,4 +75,13 @@
 
     public String getChannelIdLaw() { return channelIdLaw; }
     public void setChannelIdLaw(String channelIdLaw) { this.channelIdLaw = channelIdLaw; }
+
+    public Contact getApplicant() { return applicant; }
+    public void setApplicant(Contact applicant) { this.applicant = applicant; }
+
+    public Contact getMj() { return mj; }
+    public void setMj(Contact mj) { this.mj = mj; }
+
+    public Contact getLawyer() { return lawyer; }
+    public void setLawyer(Contact lawyer) { this.lawyer = lawyer; }
 }
src/main/java/kr/itn/itnhub/org/OrganizationController.java
--- src/main/java/kr/itn/itnhub/org/OrganizationController.java
+++ src/main/java/kr/itn/itnhub/org/OrganizationController.java
@@ -1,6 +1,5 @@
 package kr.itn.itnhub.org;
 
-import jakarta.validation.Valid;
 import kr.itn.itnhub.provision.ChannelProvisionService;
 import kr.itn.itnhub.provision.ProvisionResult;
 import org.springframework.web.bind.annotation.GetMapping;
@@ -31,10 +30,10 @@
         return orgService.findAll();
     }
 
-    @PutMapping("/{id}/contact")
-    public OrgResponse updateContact(@PathVariable Long id,
-                                     @Valid @RequestBody ContactRequest request) {
-        return orgService.updateContact(id, request);
+    @PutMapping("/{id}/assignments")
+    public OrgResponse updateAssignments(@PathVariable Long id,
+                                         @RequestBody AssignmentRequest request) {
+        return orgService.updateAssignments(id, request);
     }
 
     /**
src/main/java/kr/itn/itnhub/org/OrganizationMapper.java
--- src/main/java/kr/itn/itnhub/org/OrganizationMapper.java
+++ src/main/java/kr/itn/itnhub/org/OrganizationMapper.java
@@ -12,10 +12,17 @@
 
     Organization findById(@Param("id") Long id);
 
+    /** 연번+기관명 유니크 키로 단건 조회. 시드가 upsert 직후 방금 반영된 행을 다시 읽을 때 쓴다. */
+    Organization findByOrgNoAndOrgName(@Param("orgNo") String orgNo, @Param("orgName") String orgName);
+
     /** 시드 전용. 비어 있는 칸만 채우고 채널ID·기입된 값은 그대로 둔다. */
     int upsertBySeed(Organization org);
 
-    int updateContact(Organization org);
+    int updateAssignments(@Param("id") Long id,
+                          @Param("applicantContactId") Long applicantContactId,
+                          @Param("mjContactId") Long mjContactId,
+                          @Param("lawyerContactId") Long lawyerContactId,
+                          @Param("lawyerAssignedDate") String lawyerAssignedDate);
 
     int updateChannelIdMj(@Param("id") Long id,
                           @Param("channelId") String channelId);
src/main/java/kr/itn/itnhub/org/OrganizationService.java
--- src/main/java/kr/itn/itnhub/org/OrganizationService.java
+++ src/main/java/kr/itn/itnhub/org/OrganizationService.java
@@ -1,5 +1,8 @@
 package kr.itn.itnhub.org;
 
+import kr.itn.itnhub.contact.Contact;
+import kr.itn.itnhub.contact.ContactMapper;
+import kr.itn.itnhub.contact.InvalidContactException;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -9,36 +12,49 @@
 public class OrganizationService {
 
     private final OrganizationMapper mapper;
+    private final ContactMapper contactMapper;
 
-    public OrganizationService(OrganizationMapper mapper) {
+    public OrganizationService(OrganizationMapper mapper, ContactMapper contactMapper) {
         this.mapper = mapper;
+        this.contactMapper = contactMapper;
     }
 
     public List<OrgResponse> findAll() {
         return mapper.findAll().stream().map(OrgResponse::of).toList();
     }
 
+    /**
+     * 담당자관리에서 이미 등록된 담당자를 신청기관/문정원/변호사 역할에 배정한다.
+     * null인 필드는 그 역할의 배정을 해제한다는 뜻이며, 매 호출은 세 배정값과
+     * 배정일을 통째로 덮어쓴다(부분 갱신이 아니다).
+     */
     @Transactional
-    public OrgResponse updateContact(Long id, ContactRequest request) {
+    public OrgResponse updateAssignments(Long id, AssignmentRequest request) {
         Organization org = mapper.findById(id);
         if (org == null) {
             throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + id);
         }
-        org.setDeptName(request.deptName());
-        org.setManagerName(request.managerName());
-        org.setManagerTitle(request.managerTitle());
-        org.setManagerPhone(request.managerPhone());
-        org.setManagerEmail(request.managerEmail());
-        org.setMjDeptName(request.mjDeptName());
-        org.setMjManagerName(request.mjManagerName());
-        org.setMjManagerPhone(request.mjManagerPhone());
-        org.setMjManagerEmail(request.mjManagerEmail());
-        org.setLawyerName(request.lawyerName());
-        org.setLawyerPhone(request.lawyerPhone());
-        org.setLawyerEmail(request.lawyerEmail());
-        org.setLawyerAssignedDate(request.lawyerAssignedDate());
-        mapper.updateContact(org);
+
+        validateAssignment(request.applicantContactId(), "APPLICANT", "신청기관 담당자");
+        validateAssignment(request.mjContactId(), "MJ", "문정원 담당자");
+        validateAssignment(request.lawyerContactId(), "LAWYER", "담당 변호사");
+
+        mapper.updateAssignments(id, request.applicantContactId(), request.mjContactId(),
+                request.lawyerContactId(), request.lawyerAssignedDate());
 
         return OrgResponse.of(mapper.findById(id));
     }
+
+    private void validateAssignment(Long contactId, String expectedCategory, String roleLabel) {
+        if (contactId == null) {
+            return;
+        }
+        Contact contact = contactMapper.findById(contactId);
+        if (contact == null) {
+            throw new InvalidContactException(roleLabel + "로 지정한 담당자를 찾을 수 없습니다: " + contactId);
+        }
+        if (!expectedCategory.equals(contact.getCategory())) {
+            throw new InvalidContactException(roleLabel + "에는 해당 구분의 담당자만 지정할 수 있습니다.");
+        }
+    }
 }
src/main/java/kr/itn/itnhub/provision/ChannelProvisionService.java
--- src/main/java/kr/itn/itnhub/provision/ChannelProvisionService.java
+++ src/main/java/kr/itn/itnhub/provision/ChannelProvisionService.java
@@ -47,11 +47,11 @@
             throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
         }
 
-        if (!org.hasRequiredContact()) {
+        if (!org.hasApplicantAssigned()) {
             return new ProvisionResult(
                     ProvisionOutcome.FAILED,
                     ProvisionOutcome.FAILED,
-                    "담당자 정보가 채워지지 않아 채널을 만들 수 없습니다.");
+                    "신청기관 담당자가 지정되지 않아 채널을 만들 수 없습니다.");
         }
 
         StringBuilder messages = new StringBuilder();
src/main/java/kr/itn/itnhub/seed/SeedService.java
--- src/main/java/kr/itn/itnhub/seed/SeedService.java
+++ src/main/java/kr/itn/itnhub/seed/SeedService.java
@@ -1,5 +1,7 @@
 package kr.itn.itnhub.seed;
 
+import kr.itn.itnhub.contact.Contact;
+import kr.itn.itnhub.contact.ContactMapper;
 import kr.itn.itnhub.org.Organization;
 import kr.itn.itnhub.org.OrganizationMapper;
 import org.springframework.stereotype.Service;
@@ -16,12 +18,14 @@
 
     private final SeedParser parser;
     private final OrganizationMapper mapper;
+    private final ContactMapper contactMapper;
     private final TransactionTemplate transactionTemplate;
 
-    public SeedService(SeedParser parser, OrganizationMapper mapper,
+    public SeedService(SeedParser parser, OrganizationMapper mapper, ContactMapper contactMapper,
                         PlatformTransactionManager transactionManager) {
         this.parser = parser;
         this.mapper = mapper;
+        this.contactMapper = contactMapper;
         this.transactionTemplate = new TransactionTemplate(transactionManager);
     }
 
@@ -54,9 +58,39 @@
                 updated++;
             }
             mapper.upsertBySeed(toOrganization(row));
+            linkApplicantContactIfMissing(row);
         }
 
         return new SeedReport(created, updated, rows.size());
+    }
+
+    /**
+     * 시트에 담당자 이름이 있고 그 기관이 아직 신청기관 담당자가 배정되지 않았을 때만
+     * 새 담당자를 만들어 연결한다. 이미 배정돼 있으면(담당자관리 화면에서 사람이 고쳤을 수도
+     * 있는 값이므로) 절대 건드리지 않는다 - 재시드가 배정을 되돌리거나 중복 생성하면 안 된다.
+     */
+    private void linkApplicantContactIfMissing(SeedRow row) {
+        if (row.managerName() == null) {
+            return;
+        }
+
+        Organization saved = mapper.findByOrgNoAndOrgName(row.orgNo(), row.orgName());
+        if (saved.getApplicantContactId() != null) {
+            return;
+        }
+
+        Contact contact = new Contact();
+        contact.setCategory("APPLICANT");
+        contact.setName(row.managerName());
+        contact.setAffiliation(row.orgName());
+        contact.setDeptName(row.deptName());
+        contact.setTitle(row.managerTitle());
+        contact.setPhone(row.managerPhone());
+        contact.setEmail(row.managerEmail());
+        contactMapper.insert(contact);
+
+        mapper.updateAssignments(saved.getId(), contact.getId(),
+                saved.getMjContactId(), saved.getLawyerContactId(), saved.getLawyerAssignedDate());
     }
 
     private String key(String orgNo, String orgName) {
@@ -68,11 +102,6 @@
         org.setOrgNo(row.orgNo());
         org.setOrgName(row.orgName());
         org.setChannelSlug(row.channelSlug());
-        org.setDeptName(row.deptName());
-        org.setManagerName(row.managerName());
-        org.setManagerTitle(row.managerTitle());
-        org.setManagerPhone(row.managerPhone());
-        org.setManagerEmail(row.managerEmail());
         return org;
     }
 }
 
src/main/resources/db/migration/V4__contact_directory.sql (added)
+++ src/main/resources/db/migration/V4__contact_directory.sql
@@ -0,0 +1,99 @@
+create table contact (
+    id          bigserial primary key,
+    category    varchar(20)  not null,   -- 'APPLICANT' | 'MJ' | 'LAWYER'
+    name        varchar(100) not null,
+    affiliation varchar(200),            -- 소속 (신청기관 담당자는 기관명이 들어감)
+    dept_name   varchar(200),
+    title       varchar(100),
+    phone       varchar(50),
+    email       varchar(200),
+    created_at  timestamptz  not null default now(),
+    updated_at  timestamptz  not null default now()
+);
+
+alter table organization
+    add column applicant_contact_id bigint references contact (id) on delete set null,
+    add column mj_contact_id        bigint references contact (id) on delete set null,
+    add column lawyer_contact_id    bigint references contact (id) on delete set null;
+
+-- 백필 1/3: 신청기관 담당자. 기관 행마다 최대 1명이므로 기관 id로 바로 correlate 한다.
+do $$
+declare
+    org_row record;
+    new_contact_id bigint;
+begin
+    for org_row in
+        select id, org_name, dept_name, manager_name, manager_title, manager_phone, manager_email
+        from organization
+        where manager_name is not null
+    loop
+        insert into contact (category, name, affiliation, dept_name, title, phone, email)
+        values ('APPLICANT', org_row.manager_name, org_row.org_name, org_row.dept_name,
+                org_row.manager_title, org_row.manager_phone, org_row.manager_email)
+        returning id into new_contact_id;
+
+        update organization set applicant_contact_id = new_contact_id where id = org_row.id;
+    end loop;
+end $$;
+
+-- 백필 2/3: 문정원 담당자. 여러 기관이 같은 사람을 공유할 수 있어 distinct 튜플당 1행만 만든다.
+do $$
+declare
+    mj_row record;
+    new_contact_id bigint;
+begin
+    for mj_row in
+        select distinct mj_manager_name, mj_dept_name, mj_manager_phone, mj_manager_email
+        from organization
+        where mj_manager_name is not null
+    loop
+        insert into contact (category, name, dept_name, phone, email)
+        values ('MJ', mj_row.mj_manager_name, mj_row.mj_dept_name,
+                mj_row.mj_manager_phone, mj_row.mj_manager_email)
+        returning id into new_contact_id;
+
+        update organization
+        set mj_contact_id = new_contact_id
+        where mj_manager_name = mj_row.mj_manager_name
+          and mj_dept_name is not distinct from mj_row.mj_dept_name
+          and mj_manager_phone is not distinct from mj_row.mj_manager_phone
+          and mj_manager_email is not distinct from mj_row.mj_manager_email;
+    end loop;
+end $$;
+
+-- 백필 3/3: 담당 변호사. 배정일은 사람이 아니라 기관↔변호사 관계에 속하므로 organization에 남긴다.
+do $$
+declare
+    lawyer_row record;
+    new_contact_id bigint;
+begin
+    for lawyer_row in
+        select distinct lawyer_name, lawyer_phone, lawyer_email
+        from organization
+        where lawyer_name is not null
+    loop
+        insert into contact (category, name, phone, email)
+        values ('LAWYER', lawyer_row.lawyer_name, lawyer_row.lawyer_phone, lawyer_row.lawyer_email)
+        returning id into new_contact_id;
+
+        update organization
+        set lawyer_contact_id = new_contact_id
+        where lawyer_name = lawyer_row.lawyer_name
+          and lawyer_phone is not distinct from lawyer_row.lawyer_phone
+          and lawyer_email is not distinct from lawyer_row.lawyer_email;
+    end loop;
+end $$;
+
+alter table organization
+    drop column dept_name,
+    drop column manager_name,
+    drop column manager_title,
+    drop column manager_phone,
+    drop column manager_email,
+    drop column mj_dept_name,
+    drop column mj_manager_name,
+    drop column mj_manager_phone,
+    drop column mj_manager_email,
+    drop column lawyer_name,
+    drop column lawyer_phone,
+    drop column lawyer_email;
 
src/main/resources/mapper/ContactMapper.xml (added)
+++ src/main/resources/mapper/ContactMapper.xml
@@ -0,0 +1,48 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="kr.itn.itnhub.contact.ContactMapper">
+
+  <sql id="columns">
+    id, category, name, affiliation, dept_name, title, phone, email
+  </sql>
+
+  <select id="findAll" resultType="kr.itn.itnhub.contact.Contact">
+    select <include refid="columns"/>
+    from contact
+    <if test="category != null">
+      where category = #{category}
+    </if>
+    order by category asc, name asc
+  </select>
+
+  <select id="findById" resultType="kr.itn.itnhub.contact.Contact">
+    select <include refid="columns"/>
+    from contact
+    where id = #{id}
+  </select>
+
+  <insert id="insert" parameterType="kr.itn.itnhub.contact.Contact"
+          useGeneratedKeys="true" keyProperty="id">
+    insert into contact (category, name, affiliation, dept_name, title, phone, email)
+    values (#{category}, #{name}, #{affiliation}, #{deptName}, #{title}, #{phone}, #{email})
+  </insert>
+
+  <update id="update" parameterType="kr.itn.itnhub.contact.Contact">
+    update contact set
+      category    = #{category},
+      name        = #{name},
+      affiliation = #{affiliation},
+      dept_name   = #{deptName},
+      title       = #{title},
+      phone       = #{phone},
+      email       = #{email},
+      updated_at  = now()
+    where id = #{id}
+  </update>
+
+  <delete id="delete">
+    delete from contact where id = #{id}
+  </delete>
+
+</mapper>
src/main/resources/mapper/OrganizationMapper.xml
--- src/main/resources/mapper/OrganizationMapper.xml
+++ src/main/resources/mapper/OrganizationMapper.xml
@@ -3,64 +3,117 @@
         "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
 <mapper namespace="kr.itn.itnhub.org.OrganizationMapper">
 
-  <sql id="columns">
-    id, org_no, org_name, channel_slug,
-    dept_name, manager_name, manager_title, manager_phone, manager_email,
-    mj_dept_name, mj_manager_name, mj_manager_phone, mj_manager_email,
-    lawyer_name, lawyer_phone, lawyer_email, lawyer_assigned_date,
-    channel_id_mj, channel_id_law
+  <!--
+    담당자 3종은 contact 테이블을 세 번 조인해서 채운다(신청기관/문정원/변호사 배정이 각각
+    다른 contact 행을 가리킬 수 있으므로). 각 조인에 별칭 컬럼을 붙여야 resultMap의
+    association 세 개가 서로 컬럼을 침범하지 않는다.
+  -->
+  <resultMap id="organizationResultMap" type="kr.itn.itnhub.org.Organization">
+    <id property="id" column="id"/>
+    <result property="orgNo" column="org_no"/>
+    <result property="orgName" column="org_name"/>
+    <result property="channelSlug" column="channel_slug"/>
+    <result property="applicantContactId" column="applicant_contact_id"/>
+    <result property="mjContactId" column="mj_contact_id"/>
+    <result property="lawyerContactId" column="lawyer_contact_id"/>
+    <result property="lawyerAssignedDate" column="lawyer_assigned_date"/>
+    <result property="channelIdMj" column="channel_id_mj"/>
+    <result property="channelIdLaw" column="channel_id_law"/>
+
+    <association property="applicant" javaType="kr.itn.itnhub.contact.Contact">
+      <id property="id" column="applicant_id"/>
+      <result property="category" column="applicant_category"/>
+      <result property="name" column="applicant_name"/>
+      <result property="affiliation" column="applicant_affiliation"/>
+      <result property="deptName" column="applicant_dept_name"/>
+      <result property="title" column="applicant_title"/>
+      <result property="phone" column="applicant_phone"/>
+      <result property="email" column="applicant_email"/>
+    </association>
+
+    <association property="mj" javaType="kr.itn.itnhub.contact.Contact">
+      <id property="id" column="mj_c_id"/>
+      <result property="category" column="mj_c_category"/>
+      <result property="name" column="mj_c_name"/>
+      <result property="affiliation" column="mj_c_affiliation"/>
+      <result property="deptName" column="mj_c_dept_name"/>
+      <result property="title" column="mj_c_title"/>
+      <result property="phone" column="mj_c_phone"/>
+      <result property="email" column="mj_c_email"/>
+    </association>
+
+    <association property="lawyer" javaType="kr.itn.itnhub.contact.Contact">
+      <id property="id" column="lawyer_c_id"/>
+      <result property="category" column="lawyer_c_category"/>
+      <result property="name" column="lawyer_c_name"/>
+      <result property="affiliation" column="lawyer_c_affiliation"/>
+      <result property="deptName" column="lawyer_c_dept_name"/>
+      <result property="title" column="lawyer_c_title"/>
+      <result property="phone" column="lawyer_c_phone"/>
+      <result property="email" column="lawyer_c_email"/>
+    </association>
+  </resultMap>
+
+  <sql id="joinedColumns">
+    o.id, o.org_no, o.org_name, o.channel_slug,
+    o.applicant_contact_id, o.mj_contact_id, o.lawyer_contact_id,
+    o.lawyer_assigned_date, o.channel_id_mj, o.channel_id_law,
+    ac.id as applicant_id, ac.category as applicant_category, ac.name as applicant_name,
+    ac.affiliation as applicant_affiliation, ac.dept_name as applicant_dept_name,
+    ac.title as applicant_title, ac.phone as applicant_phone, ac.email as applicant_email,
+    mc.id as mj_c_id, mc.category as mj_c_category, mc.name as mj_c_name,
+    mc.affiliation as mj_c_affiliation, mc.dept_name as mj_c_dept_name,
+    mc.title as mj_c_title, mc.phone as mj_c_phone, mc.email as mj_c_email,
+    lc.id as lawyer_c_id, lc.category as lawyer_c_category, lc.name as lawyer_c_name,
+    lc.affiliation as lawyer_c_affiliation, lc.dept_name as lawyer_c_dept_name,
+    lc.title as lawyer_c_title, lc.phone as lawyer_c_phone, lc.email as lawyer_c_email
   </sql>
 
-  <select id="findAll" resultType="kr.itn.itnhub.org.Organization">
-    select <include refid="columns"/>
-    from organization
-    order by org_no asc, org_name asc
+  <sql id="joins">
+    from organization o
+    left join contact ac on ac.id = o.applicant_contact_id
+    left join contact mc on mc.id = o.mj_contact_id
+    left join contact lc on lc.id = o.lawyer_contact_id
+  </sql>
+
+  <select id="findAll" resultMap="organizationResultMap">
+    select <include refid="joinedColumns"/>
+    <include refid="joins"/>
+    order by o.org_no asc, o.org_name asc
   </select>
 
-  <select id="findById" resultType="kr.itn.itnhub.org.Organization">
-    select <include refid="columns"/>
-    from organization
-    where id = #{id}
+  <select id="findById" resultMap="organizationResultMap">
+    select <include refid="joinedColumns"/>
+    <include refid="joins"/>
+    where o.id = #{id}
+  </select>
+
+  <select id="findByOrgNoAndOrgName" resultMap="organizationResultMap">
+    select <include refid="joinedColumns"/>
+    <include refid="joins"/>
+    where o.org_no = #{orgNo} and o.org_name = #{orgName}
   </select>
 
   <!--
-    시드 upsert. coalesce(기존값, 새값) 순서가 핵심이다.
-    기존값이 있으면 그대로 두고 비어 있을 때만 시트 값으로 채운다.
-    반대로 쓰면 사람이 웹에서 고친 값이 시트 값으로 되돌아간다.
+    시드 upsert. 담당자 정보는 이제 여기서 다루지 않는다(담당자관리에서만 입력) -
+    SeedService가 upsert 이후 별도로 신청기관 담당자 연결 여부를 확인해 필요할 때만
+    contact를 만들어 연결한다.
   -->
   <insert id="upsertBySeed" parameterType="kr.itn.itnhub.org.Organization">
-    insert into organization
-      (org_no, org_name, channel_slug,
-       dept_name, manager_name, manager_title, manager_phone, manager_email)
-    values
-      (#{orgNo}, #{orgName}, #{channelSlug},
-       #{deptName}, #{managerName}, #{managerTitle}, #{managerPhone}, #{managerEmail})
+    insert into organization (org_no, org_name, channel_slug)
+    values (#{orgNo}, #{orgName}, #{channelSlug})
     on conflict (org_no, org_name) do update set
-      channel_slug  = excluded.channel_slug,
-      dept_name     = coalesce(organization.dept_name,     excluded.dept_name),
-      manager_name  = coalesce(organization.manager_name,  excluded.manager_name),
-      manager_title = coalesce(organization.manager_title, excluded.manager_title),
-      manager_phone = coalesce(organization.manager_phone, excluded.manager_phone),
-      manager_email = coalesce(organization.manager_email, excluded.manager_email),
-      updated_at    = now()
+      channel_slug = excluded.channel_slug,
+      updated_at   = now()
   </insert>
 
-  <update id="updateContact" parameterType="kr.itn.itnhub.org.Organization">
+  <update id="updateAssignments">
     update organization set
-      dept_name            = #{deptName},
-      manager_name         = #{managerName},
-      manager_title        = #{managerTitle},
-      manager_phone        = #{managerPhone},
-      manager_email        = #{managerEmail},
-      mj_dept_name         = #{mjDeptName},
-      mj_manager_name      = #{mjManagerName},
-      mj_manager_phone     = #{mjManagerPhone},
-      mj_manager_email     = #{mjManagerEmail},
-      lawyer_name          = #{lawyerName},
-      lawyer_phone         = #{lawyerPhone},
-      lawyer_email         = #{lawyerEmail},
-      lawyer_assigned_date = #{lawyerAssignedDate},
-      updated_at           = now()
+      applicant_contact_id = #{applicantContactId},
+      mj_contact_id         = #{mjContactId},
+      lawyer_contact_id     = #{lawyerContactId},
+      lawyer_assigned_date  = #{lawyerAssignedDate},
+      updated_at            = now()
     where id = #{id}
   </update>
 
 
src/test/java/kr/itn/itnhub/contact/ContactControllerTest.java (added)
+++ src/test/java/kr/itn/itnhub/contact/ContactControllerTest.java
@@ -0,0 +1,182 @@
+package kr.itn.itnhub.contact;
+
+import kr.itn.itnhub.AbstractDbTest;
+import kr.itn.itnhub.org.Organization;
+import kr.itn.itnhub.org.OrganizationMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.http.MediaType;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@AutoConfigureMockMvc
+@WithMockUser(roles = "ADMIN")
+class ContactControllerTest extends AbstractDbTest {
+
+    @Autowired
+    MockMvc mvc;
+
+    @Autowired
+    ContactMapper contactMapper;
+
+    @Autowired
+    OrganizationMapper orgMapper;
+
+    @Autowired
+    JdbcTemplate jdbc;
+
+    @BeforeEach
+    void clean() {
+        jdbc.update("delete from organization");
+        jdbc.update("delete from contact");
+    }
+
+    @Test
+    void 담당자를_등록하면_저장된_값을_돌려준다() throws Exception {
+        mvc.perform(post("/api/contacts")
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {
+                                  "category": "APPLICANT",
+                                  "name": "송민지",
+                                  "affiliation": "국제방송교류재단",
+                                  "deptName": "데이터정보화팀",
+                                  "title": "과장",
+                                  "phone": "02-3475-5434",
+                                  "email": "ming@arirang.com"
+                                }
+                                """))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.category").value("APPLICANT"))
+                .andExpect(jsonPath("$.name").value("송민지"))
+                .andExpect(jsonPath("$.affiliation").value("국제방송교류재단"))
+                .andExpect(jsonPath("$.id").isNumber());
+
+        assertThat(contactMapper.findAll(null)).hasSize(1);
+    }
+
+    @Test
+    void 구분_필터로_목록을_조회한다() throws Exception {
+        Contact applicant = new Contact();
+        applicant.setCategory("APPLICANT");
+        applicant.setName("송민지");
+        contactMapper.insert(applicant);
+
+        Contact lawyer = new Contact();
+        lawyer.setCategory("LAWYER");
+        lawyer.setName("이변호");
+        contactMapper.insert(lawyer);
+
+        mvc.perform(get("/api/contacts").param("category", "LAWYER"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.length()").value(1))
+                .andExpect(jsonPath("$[0].name").value("이변호"));
+
+        mvc.perform(get("/api/contacts"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.length()").value(2));
+    }
+
+    @Test
+    void 구분값이_잘못되면_등록시_400이다() throws Exception {
+        mvc.perform(post("/api/contacts")
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                { "category": "UNKNOWN", "name": "홍길동" }
+                                """))
+                .andExpect(status().isBadRequest());
+    }
+
+    @Test
+    void 성명이_비면_등록시_400이다() throws Exception {
+        mvc.perform(post("/api/contacts")
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                { "category": "APPLICANT", "name": "" }
+                                """))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("성명")));
+    }
+
+    @Test
+    void 담당자를_수정하면_변경된_값이_저장된다() throws Exception {
+        Contact contact = new Contact();
+        contact.setCategory("MJ");
+        contact.setName("김문정");
+        contactMapper.insert(contact);
+
+        mvc.perform(put("/api/contacts/{id}", contact.getId())
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {
+                                  "category": "MJ",
+                                  "name": "김문정",
+                                  "phone": "02-1234-5678",
+                                  "email": "mj@mcst.go.kr"
+                                }
+                                """))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.phone").value("02-1234-5678"))
+                .andExpect(jsonPath("$.email").value("mj@mcst.go.kr"));
+    }
+
+    @Test
+    void 존재하지_않는_담당자를_수정하면_404다() throws Exception {
+        mvc.perform(put("/api/contacts/{id}", 999999)
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                { "category": "MJ", "name": "김문정" }
+                                """))
+                .andExpect(status().isNotFound());
+    }
+
+    @Test
+    void 존재하지_않는_담당자를_삭제하면_404다() throws Exception {
+        mvc.perform(delete("/api/contacts/{id}", 999999).with(csrf()))
+                .andExpect(status().isNotFound());
+    }
+
+    @Test
+    void 담당자를_삭제하면_배정되어_있던_기관의_배정도_함께_해제된다() throws Exception {
+        Contact contact = new Contact();
+        contact.setCategory("APPLICANT");
+        contact.setName("송민지");
+        contactMapper.insert(contact);
+
+        Organization org = new Organization();
+        org.setOrgNo("001");
+        org.setOrgName("국제방송교류재단");
+        org.setChannelSlug("001");
+        orgMapper.upsertBySeed(org);
+        Long orgId = orgMapper.findAll().get(0).getId();
+        orgMapper.updateAssignments(orgId, contact.getId(), null, null, null);
+
+        assertThat(orgMapper.findById(orgId).getStatus().name()).isEqualTo("READY");
+
+        mvc.perform(delete("/api/contacts/{id}", contact.getId()).with(csrf()))
+                .andExpect(status().isNoContent());
+
+        assertThat(contactMapper.findById(contact.getId())).isNull();
+
+        Organization after = orgMapper.findById(orgId);
+        assertThat(after.getApplicantContactId()).isNull();
+        assertThat(after.getStatus().name()).isEqualTo("INFO_PENDING");
+    }
+}
src/test/java/kr/itn/itnhub/org/OrganizationControllerTest.java
--- src/test/java/kr/itn/itnhub/org/OrganizationControllerTest.java
+++ src/test/java/kr/itn/itnhub/org/OrganizationControllerTest.java
@@ -1,6 +1,8 @@
 package kr.itn.itnhub.org;
 
 import kr.itn.itnhub.AbstractDbTest;
+import kr.itn.itnhub.contact.Contact;
+import kr.itn.itnhub.contact.ContactMapper;
 import kr.itn.itnhub.mattermost.MattermostClient;
 import kr.itn.itnhub.mattermost.MattermostException;
 import org.junit.jupiter.api.BeforeEach;
@@ -39,6 +41,9 @@
     OrganizationMapper mapper;
 
     @Autowired
+    ContactMapper contactMapper;
+
+    @Autowired
     JdbcTemplate jdbc;
 
     @MockBean
@@ -49,6 +54,7 @@
     @BeforeEach
     void setUp() {
         jdbc.update("delete from organization");
+        jdbc.update("delete from contact");
 
         Organization org = new Organization();
         org.setOrgNo("001");
@@ -57,6 +63,14 @@
         mapper.upsertBySeed(org);
 
         orgId = mapper.findAll().get(0).getId();
+    }
+
+    private Long createContact(String category, String name) {
+        Contact contact = new Contact();
+        contact.setCategory(category);
+        contact.setName(name);
+        contactMapper.insert(contact);
+        return contact.getId();
     }
 
     @Test
@@ -69,151 +83,115 @@
     }
 
     @Test
-    void 담당자정보를_저장하면_생성가능_상태가_된다() throws Exception {
-        mvc.perform(put("/api/orgs/{id}/contact", orgId)
+    void 신청기관_담당자를_배정하면_생성가능_상태가_된다() throws Exception {
+        Long applicantId = createContact("APPLICANT", "송민지");
+
+        mvc.perform(put("/api/orgs/{id}/assignments", orgId)
                         .with(csrf())
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content("""
-                                {
-                                  "deptName": "데이터정보화팀",
-                                  "managerName": "송민지",
-                                  "managerTitle": "과장",
-                                  "managerPhone": "02-3475-5434",
-                                  "managerEmail": "ming@arirang.com"
-                                }
-                                """))
+                        .content("{\"applicantContactId\": " + applicantId + "}"))
                 .andExpect(status().isOk())
-                .andExpect(jsonPath("$.status").value("READY"));
+                .andExpect(jsonPath("$.status").value("READY"))
+                .andExpect(jsonPath("$.applicant.name").value("송민지"));
 
-        assertThat(mapper.findById(orgId).getDeptName()).isEqualTo("데이터정보화팀");
+        assertThat(mapper.findById(orgId).getApplicantContactId()).isEqualTo(applicantId);
     }
 
     @Test
-    void 문정원_담당자와_변호사_정보를_포함해_저장하면_응답과_재조회에_모두_반영된다() throws Exception {
-        mvc.perform(put("/api/orgs/{id}/contact", orgId)
+    void 문정원_담당자와_변호사를_함께_배정하면_응답과_재조회에_모두_반영된다() throws Exception {
+        Long applicantId = createContact("APPLICANT", "송민지");
+        Long mjId = createContact("MJ", "김문정");
+        Long lawyerId = createContact("LAWYER", "이변호");
+
+        mvc.perform(put("/api/orgs/{id}/assignments", orgId)
                         .with(csrf())
                         .contentType(MediaType.APPLICATION_JSON)
                         .content("""
                                 {
-                                  "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",
+                                  "applicantContactId": %d,
+                                  "mjContactId": %d,
+                                  "lawyerContactId": %d,
                                   "lawyerAssignedDate": "2026-07-21"
                                 }
-                                """))
+                                """.formatted(applicantId, mjId, lawyerId)))
                 .andExpect(status().isOk())
-                .andExpect(jsonPath("$.mjDeptName").value("문화체육관광부 저작권정책과"))
-                .andExpect(jsonPath("$.mjManagerName").value("김문정"))
-                .andExpect(jsonPath("$.mjManagerPhone").value("02-1234-5678"))
-                .andExpect(jsonPath("$.mjManagerEmail").value("mj@mcst.go.kr"))
-                .andExpect(jsonPath("$.lawyerName").value("이변호"))
-                .andExpect(jsonPath("$.lawyerPhone").value("02-9876-5432"))
-                .andExpect(jsonPath("$.lawyerEmail").value("lawyer@lawfirm.kr"))
+                .andExpect(jsonPath("$.mj.name").value("김문정"))
+                .andExpect(jsonPath("$.lawyer.name").value("이변호"))
                 .andExpect(jsonPath("$.lawyerAssignedDate").value("2026-07-21"));
 
         mvc.perform(get("/api/orgs"))
                 .andExpect(status().isOk())
-                .andExpect(jsonPath("$[0].mjManagerName").value("김문정"))
-                .andExpect(jsonPath("$[0].lawyerName").value("이변호"));
+                .andExpect(jsonPath("$[0].mj.name").value("김문정"))
+                .andExpect(jsonPath("$[0].lawyer.name").value("이변호"));
     }
 
     @Test
-    void 문정원_변호사_정보없이_기존방식_페이로드만으로도_저장에_성공한다() throws Exception {
-        mvc.perform(put("/api/orgs/{id}/contact", orgId)
+    void 신청기관_배정없이_문정원_변호사만_배정해도_저장에_성공하고_정보대기로_남는다() throws Exception {
+        Long mjId = createContact("MJ", "김문정");
+
+        mvc.perform(put("/api/orgs/{id}/assignments", orgId)
                         .with(csrf())
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content("""
-                                {
-                                  "deptName": "데이터정보화팀",
-                                  "managerName": "송민지",
-                                  "managerTitle": "과장",
-                                  "managerPhone": "02-3475-5434",
-                                  "managerEmail": "ming@arirang.com"
-                                }
-                                """))
+                        .content("{\"mjContactId\": " + mjId + "}"))
                 .andExpect(status().isOk())
-                .andExpect(jsonPath("$.status").value("READY"))
-                .andExpect(jsonPath("$.mjManagerName").value(nullValue()))
-                .andExpect(jsonPath("$.lawyerName").value(nullValue()));
+                .andExpect(jsonPath("$.status").value("INFO_PENDING"))
+                .andExpect(jsonPath("$.applicant").value(nullValue()))
+                .andExpect(jsonPath("$.mj.name").value("김문정"));
     }
 
     @Test
-    void 존재하지_않는_기관ID로_담당자정보를_수정하면_404다() throws Exception {
+    void 배정_해제는_null을_보내면_반영되어_정보대기로_돌아간다() throws Exception {
+        Long applicantId = createContact("APPLICANT", "송민지");
+        mapper.updateAssignments(orgId, applicantId, null, null, null);
+
+        mvc.perform(put("/api/orgs/{id}/assignments", orgId)
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"applicantContactId\": null}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.status").value("INFO_PENDING"))
+                .andExpect(jsonPath("$.applicant").value(nullValue()));
+    }
+
+    @Test
+    void 존재하지_않는_기관ID로_배정을_수정하면_404다() throws Exception {
         long missingId = orgId + 999999L;
 
-        mvc.perform(put("/api/orgs/{id}/contact", missingId)
+        mvc.perform(put("/api/orgs/{id}/assignments", missingId)
                         .with(csrf())
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content("""
-                                {
-                                  "deptName": "데이터정보화팀",
-                                  "managerName": "송민지",
-                                  "managerTitle": "과장",
-                                  "managerPhone": "02-3475-5434",
-                                  "managerEmail": "ming@arirang.com"
-                                }
-                                """))
+                        .content("{}"))
                 .andExpect(status().isNotFound())
                 .andExpect(jsonPath("$.message").value(containsString(String.valueOf(missingId))));
     }
 
     @Test
-    void 필수값이_비면_400이다() throws Exception {
-        mvc.perform(put("/api/orgs/{id}/contact", orgId)
+    void 존재하지_않는_담당자ID를_배정하면_400이다() throws Exception {
+        long missingContactId = 999999L;
+
+        mvc.perform(put("/api/orgs/{id}/assignments", orgId)
                         .with(csrf())
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content("""
-                                {
-                                  "deptName": "",
-                                  "managerName": "송민지",
-                                  "managerPhone": "02-3475-5434",
-                                  "managerEmail": "ming@arirang.com"
-                                }
-                                """))
+                        .content("{\"applicantContactId\": " + missingContactId + "}"))
                 .andExpect(status().isBadRequest());
     }
 
-    /**
-     * Finding I6 회귀 테스트: 검증 실패 응답 본문이 필드명을 그대로 노출하는 원시 JSON
-     * 블롭(예: {"deptName":"must not be blank"})이 아니라, 운영자가 읽을 수 있는
-     * 한글 라벨(부서명 등)이 담긴 {@link kr.itn.itnhub.config.ApiError} 메시지여야 한다.
-     */
     @Test
-    void 필수값이_비면_한글_필드명이_담긴_메시지를_돌려준다() throws Exception {
-        mvc.perform(put("/api/orgs/{id}/contact", orgId)
+    void 구분이_맞지_않는_담당자를_배정하면_400이다() throws Exception {
+        Long mjContactId = createContact("MJ", "김문정");
+
+        mvc.perform(put("/api/orgs/{id}/assignments", orgId)
                         .with(csrf())
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content("""
-                                {
-                                  "deptName": "",
-                                  "managerName": "",
-                                  "managerPhone": "02-3475-5434",
-                                  "managerEmail": "ming@arirang.com"
-                                }
-                                """))
+                        .content("{\"applicantContactId\": " + mjContactId + "}"))
                 .andExpect(status().isBadRequest())
-                .andExpect(jsonPath("$.message").value(containsString("부서명")))
-                .andExpect(jsonPath("$.message").value(containsString("담당자명")));
+                .andExpect(jsonPath("$.message").value(containsString("신청기관 담당자")));
     }
 
     @Test
     void 채널생성을_요청하면_결과를_돌려준다() throws Exception {
-        Organization ready = mapper.findById(orgId);
-        ready.setDeptName("데이터정보화팀");
-        ready.setManagerName("송민지");
-        ready.setManagerTitle("과장");
-        ready.setManagerPhone("02-3475-5434");
-        ready.setManagerEmail("ming@arirang.com");
-        mapper.updateContact(ready);
+        Long applicantId = createContact("APPLICANT", "송민지");
+        mapper.updateAssignments(orgId, applicantId, null, null, null);
 
         when(mattermost.findChannelIdByInternalName(anyString())).thenReturn(Optional.empty());
         when(mattermost.findChannelIdByDisplayName(anyString())).thenReturn(Optional.empty());
@@ -226,12 +204,12 @@
     }
 
     @Test
-    void 담당자정보없이_채널생성하면_실패결과를_돌려준다() throws Exception {
+    void 담당자배정없이_채널생성하면_실패결과를_돌려준다() throws Exception {
         mvc.perform(post("/api/orgs/{id}/channels", orgId).with(csrf()))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.mj").value("FAILED"))
                 .andExpect(jsonPath("$.law").value("FAILED"))
-                .andExpect(jsonPath("$.message").value(containsString("담당자 정보")));
+                .andExpect(jsonPath("$.message").value(containsString("신청기관 담당자")));
     }
 
     /**
@@ -242,13 +220,8 @@
      */
     @Test
     void 법률검토만_실패해도_200과_부분실패_결과를_돌려준다() throws Exception {
-        Organization ready = mapper.findById(orgId);
-        ready.setDeptName("데이터정보화팀");
-        ready.setManagerName("송민지");
-        ready.setManagerTitle("과장");
-        ready.setManagerPhone("02-3475-5434");
-        ready.setManagerEmail("ming@arirang.com");
-        mapper.updateContact(ready);
+        Long applicantId = createContact("APPLICANT", "송민지");
+        mapper.updateAssignments(orgId, applicantId, null, null, null);
 
         when(mattermost.findChannelIdByInternalName(anyString())).thenReturn(Optional.empty());
         when(mattermost.findChannelIdByDisplayName(anyString())).thenReturn(Optional.empty());
src/test/java/kr/itn/itnhub/org/OrganizationMapperTest.java
--- src/test/java/kr/itn/itnhub/org/OrganizationMapperTest.java
+++ src/test/java/kr/itn/itnhub/org/OrganizationMapperTest.java
@@ -1,6 +1,8 @@
 package kr.itn.itnhub.org;
 
 import kr.itn.itnhub.AbstractDbTest;
+import kr.itn.itnhub.contact.Contact;
+import kr.itn.itnhub.contact.ContactMapper;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -16,54 +18,61 @@
     OrganizationMapper mapper;
 
     @Autowired
+    ContactMapper contactMapper;
+
+    @Autowired
     JdbcTemplate jdbc;
 
     @BeforeEach
     void clean() {
         jdbc.update("delete from organization");
+        jdbc.update("delete from contact");
     }
 
-    private Organization seedRow(String orgNo, String orgName, String dept) {
+    private Organization seedRow(String orgNo, String orgName) {
         Organization org = new Organization();
         org.setOrgNo(orgNo);
         org.setOrgName(orgName);
         org.setChannelSlug(orgNo);
-        org.setDeptName(dept);
-        org.setManagerName("담당자");
-        org.setManagerTitle("과장");
-        org.setManagerPhone("02-0000-0000");
-        org.setManagerEmail("a@b.kr");
         return org;
+    }
+
+    private Long insertContact(String category, String name) {
+        Contact contact = new Contact();
+        contact.setCategory(category);
+        contact.setName(name);
+        contactMapper.insert(contact);
+        return contact.getId();
     }
 
     @Test
     void 신규_기관을_넣고_다시_읽는다() {
-        mapper.upsertBySeed(seedRow("001", "국제방송교류재단", "데이터정보화팀"));
+        mapper.upsertBySeed(seedRow("001", "국제방송교류재단"));
 
         List<Organization> all = mapper.findAll();
 
         assertThat(all).hasSize(1);
         assertThat(all.get(0).getOrgNo()).isEqualTo("001");
         assertThat(all.get(0).getOrgName()).isEqualTo("국제방송교류재단");
-        assertThat(all.get(0).getStatus()).isEqualTo(OrgStatus.READY);
+        assertThat(all.get(0).getStatus()).isEqualTo(OrgStatus.INFO_PENDING);
     }
 
     @Test
     void 연번이_같아도_기관명이_다르면_별도_행이다() {
-        mapper.upsertBySeed(seedRow("008", "경찰청", "데이터정책계"));
-        mapper.upsertBySeed(seedRow("008", "경찰청_치안정책연구소", "연구지원과"));
+        mapper.upsertBySeed(seedRow("008", "경찰청"));
+        mapper.upsertBySeed(seedRow("008", "경찰청_치안정책연구소"));
 
         assertThat(mapper.findAll()).hasSize(2);
     }
 
     @Test
     void 시드_재실행은_기존_채널ID를_지우지_않는다() {
-        mapper.upsertBySeed(seedRow("001", "국제방송교류재단", "데이터정보화팀"));
+        mapper.upsertBySeed(seedRow("001", "국제방송교류재단"));
         Long id = mapper.findAll().get(0).getId();
         mapper.updateChannelIdMj(id, "chan-mj");
         mapper.updateChannelIdLaw(id, "chan-law");
 
-        mapper.upsertBySeed(seedRow("001", "국제방송교류재단", "데이터정보화팀"));
+        mapper.upsertBySeed(seedRow("001", "국제방송교류재단"));
 
         Organization after = mapper.findById(id);
         assertThat(after.getChannelIdMj()).isEqualTo("chan-mj");
@@ -72,38 +81,10 @@
     }
 
     @Test
-    void 시드_재실행은_사람이_입력한_값을_덮어쓰지_않는다() {
-        mapper.upsertBySeed(seedRow("001", "국제방송교류재단", "데이터정보화팀"));
-        Organization org = mapper.findAll().get(0);
-        org.setDeptName("사람이 고친 부서");
-        mapper.updateContact(org);
-
-        Organization reseeded = seedRow("001", "국제방송교류재단", "시트에 있던 부서");
-        mapper.upsertBySeed(reseeded);
-
-        assertThat(mapper.findById(org.getId()).getDeptName())
-                .isEqualTo("사람이 고친 부서");
-    }
-
-    @Test
-    void 시드_재실행은_비어_있던_칸만_채운다() {
-        Organization blank = new Organization();
-        blank.setOrgNo("009");
-        blank.setOrgName("서울도서관");
-        blank.setChannelSlug("009");
-        mapper.upsertBySeed(blank);
-
-        mapper.upsertBySeed(seedRow("009", "서울도서관", "정보서비스과"));
-
-        Organization after = mapper.findAll().get(0);
-        assertThat(after.getDeptName()).isEqualTo("정보서비스과");
-        assertThat(after.getManagerEmail()).isEqualTo("a@b.kr");
-    }
-
-    @Test
     void 채널ID를_한쪽만_저장할_수_있다() {
-        mapper.upsertBySeed(seedRow("002", "세종학당재단", "콘텐츠개발팀"));
+        mapper.upsertBySeed(seedRow("002", "세종학당재단"));
         Long id = mapper.findAll().get(0).getId();
+        mapper.updateAssignments(id, insertContact("APPLICANT", "담당자"), null, null, null);
 
         mapper.updateChannelIdMj(id, "only-mj");
 
@@ -114,51 +95,73 @@
     }
 
     @Test
-    void 문정원_담당자와_변호사_정보가_저장되고_그대로_읽힌다() {
-        mapper.upsertBySeed(seedRow("003", "한국문학번역원", "정보관리팀"));
+    void 담당자_3종을_배정하면_조인된_담당자_정보가_그대로_읽힌다() {
+        mapper.upsertBySeed(seedRow("003", "한국문학번역원"));
         Organization org = mapper.findAll().get(0);
 
-        org.setMjDeptName("문화체육관광부 저작권정책과");
-        org.setMjManagerName("김문정");
-        org.setMjManagerPhone("02-1234-5678");
-        org.setMjManagerEmail("mj@mcst.go.kr");
-        org.setLawyerName("이변호");
-        org.setLawyerPhone("02-9876-5432");
-        org.setLawyerEmail("lawyer@lawfirm.kr");
-        org.setLawyerAssignedDate("2026-07-21");
-        mapper.updateContact(org);
+        Long applicantId = insertContact("APPLICANT", "송민지");
+        Long mjId = insertContact("MJ", "김문정");
+        Long lawyerId = insertContact("LAWYER", "이변호");
+
+        mapper.updateAssignments(org.getId(), applicantId, mjId, lawyerId, "2026-07-21");
 
         Organization after = mapper.findById(org.getId());
-        assertThat(after.getMjDeptName()).isEqualTo("문화체육관광부 저작권정책과");
-        assertThat(after.getMjManagerName()).isEqualTo("김문정");
-        assertThat(after.getMjManagerPhone()).isEqualTo("02-1234-5678");
-        assertThat(after.getMjManagerEmail()).isEqualTo("mj@mcst.go.kr");
-        assertThat(after.getLawyerName()).isEqualTo("이변호");
-        assertThat(after.getLawyerPhone()).isEqualTo("02-9876-5432");
-        assertThat(after.getLawyerEmail()).isEqualTo("lawyer@lawfirm.kr");
+        assertThat(after.getApplicantContactId()).isEqualTo(applicantId);
+        assertThat(after.getMjContactId()).isEqualTo(mjId);
+        assertThat(after.getLawyerContactId()).isEqualTo(lawyerId);
         assertThat(after.getLawyerAssignedDate()).isEqualTo("2026-07-21");
+
+        assertThat(after.getApplicant().getName()).isEqualTo("송민지");
+        assertThat(after.getApplicant().getCategory()).isEqualTo("APPLICANT");
+        assertThat(after.getMj().getName()).isEqualTo("김문정");
+        assertThat(after.getLawyer().getName()).isEqualTo("이변호");
+        assertThat(after.getStatus()).isEqualTo(OrgStatus.READY);
     }
 
     @Test
-    void 시드_재실행은_문정원_변호사_정보를_지우지_않는다() {
-        mapper.upsertBySeed(seedRow("001", "국제방송교류재단", "데이터정보화팀"));
+    void 배정되지_않은_역할은_담당자가_null이다() {
+        mapper.upsertBySeed(seedRow("004", "예시기관"));
         Organization org = mapper.findAll().get(0);
-        org.setMjManagerName("김문정");
-        org.setLawyerName("이변호");
-        mapper.updateContact(org);
 
-        mapper.upsertBySeed(seedRow("001", "국제방송교류재단", "데이터정보화팀"));
+        Long applicantId = insertContact("APPLICANT", "담당자");
+        mapper.updateAssignments(org.getId(), applicantId, null, null, null);
 
         Organization after = mapper.findById(org.getId());
-        assertThat(after.getMjManagerName()).isEqualTo("김문정");
-        assertThat(after.getLawyerName()).isEqualTo("이변호");
+        assertThat(after.getApplicant()).isNotNull();
+        assertThat(after.getMj()).isNull();
+        assertThat(after.getLawyer()).isNull();
+    }
+
+    @Test
+    void 배정_해제는_null로_넘기면_반영된다() {
+        mapper.upsertBySeed(seedRow("005", "예시기관2"));
+        Organization org = mapper.findAll().get(0);
+
+        Long applicantId = insertContact("APPLICANT", "담당자");
+        mapper.updateAssignments(org.getId(), applicantId, null, null, null);
+        mapper.updateAssignments(org.getId(), null, null, null, null);
+
+        Organization after = mapper.findById(org.getId());
+        assertThat(after.getApplicantContactId()).isNull();
+        assertThat(after.getApplicant()).isNull();
+        assertThat(after.getStatus()).isEqualTo(OrgStatus.INFO_PENDING);
+    }
+
+    @Test
+    void 연번과_기관명으로_단건_조회할_수_있다() {
+        mapper.upsertBySeed(seedRow("006", "예시기관3"));
+
+        Organization found = mapper.findByOrgNoAndOrgName("006", "예시기관3");
+
+        assertThat(found).isNotNull();
+        assertThat(found.getOrgName()).isEqualTo("예시기관3");
     }
 
     @Test
     void 목록은_연번_오름차순이다() {
-        mapper.upsertBySeed(seedRow("010", "국립아시아문화전당", "기획운영과"));
-        mapper.upsertBySeed(seedRow("002", "세종학당재단", "콘텐츠개발팀"));
-        mapper.upsertBySeed(seedRow("001", "국제방송교류재단", "데이터정보화팀"));
+        mapper.upsertBySeed(seedRow("010", "국립아시아문화전당"));
+        mapper.upsertBySeed(seedRow("002", "세종학당재단"));
+        mapper.upsertBySeed(seedRow("001", "국제방송교류재단"));
 
         assertThat(mapper.findAll())
                 .extracting(Organization::getOrgNo)
src/test/java/kr/itn/itnhub/org/OrganizationStatusTest.java
--- src/test/java/kr/itn/itnhub/org/OrganizationStatusTest.java
+++ src/test/java/kr/itn/itnhub/org/OrganizationStatusTest.java
@@ -6,20 +6,17 @@
 
 class OrganizationStatusTest {
 
-    private Organization withContact() {
+    private Organization withApplicant() {
         Organization org = new Organization();
         org.setOrgNo("001");
         org.setOrgName("국제방송교류재단");
         org.setChannelSlug("001");
-        org.setDeptName("데이터정보화팀");
-        org.setManagerName("송민지");
-        org.setManagerPhone("02-3475-5434");
-        org.setManagerEmail("ming@arirang.com");
+        org.setApplicantContactId(1L);
         return org;
     }
 
     @Test
-    void 담당자정보가_없으면_정보대기다() {
+    void 신청기관_담당자가_배정되지_않으면_정보대기다() {
         Organization org = new Organization();
         org.setOrgNo("008");
         org.setOrgName("경찰청_치안정책연구소");
@@ -28,21 +25,22 @@
     }
 
     @Test
-    void 담당자정보가_다_차면_생성가능이다() {
-        assertThat(withContact().getStatus()).isEqualTo(OrgStatus.READY);
+    void 신청기관_담당자가_배정되면_생성가능이다() {
+        assertThat(withApplicant().getStatus()).isEqualTo(OrgStatus.READY);
     }
 
     @Test
-    void 직급은_없어도_생성가능이다() {
-        Organization org = withContact();
-        org.setManagerTitle(null);
+    void 문정원_변호사_배정이_없어도_신청기관_담당자만_있으면_생성가능이다() {
+        Organization org = withApplicant();
+        org.setMjContactId(null);
+        org.setLawyerContactId(null);
 
         assertThat(org.getStatus()).isEqualTo(OrgStatus.READY);
     }
 
     @Test
     void 채널이_한쪽만_있으면_부분생성이다() {
-        Organization org = withContact();
+        Organization org = withApplicant();
         org.setChannelIdMj("chan-mj");
 
         assertThat(org.getStatus()).isEqualTo(OrgStatus.PARTIAL);
@@ -50,7 +48,7 @@
 
     @Test
     void 채널이_둘_다_있으면_운영중이다() {
-        Organization org = withContact();
+        Organization org = withApplicant();
         org.setChannelIdMj("chan-mj");
         org.setChannelIdLaw("chan-law");
 
@@ -58,7 +56,7 @@
     }
 
     @Test
-    void 담당자정보가_비어도_채널이_있으면_운영중이다() {
+    void 신청기관_담당자_배정이_없어도_채널이_있으면_운영중이다() {
         Organization org = new Organization();
         org.setOrgNo("008");
         org.setOrgName("경찰청_치안정책연구소");
@@ -69,10 +67,11 @@
     }
 
     @Test
-    void 공백만_있는_값은_없는_것으로_본다() {
-        Organization org = withContact();
-        org.setManagerEmail("   ");
+    void hasApplicantAssigned은_배정된_담당자ID_유무만_본다() {
+        Organization org = withApplicant();
+        assertThat(org.hasApplicantAssigned()).isTrue();
 
-        assertThat(org.getStatus()).isEqualTo(OrgStatus.INFO_PENDING);
+        org.setApplicantContactId(null);
+        assertThat(org.hasApplicantAssigned()).isFalse();
     }
 }
src/test/java/kr/itn/itnhub/provision/ChannelProvisionServiceTest.java
--- src/test/java/kr/itn/itnhub/provision/ChannelProvisionServiceTest.java
+++ src/test/java/kr/itn/itnhub/provision/ChannelProvisionServiceTest.java
@@ -1,6 +1,8 @@
 package kr.itn.itnhub.provision;
 
 import kr.itn.itnhub.AbstractDbTest;
+import kr.itn.itnhub.contact.Contact;
+import kr.itn.itnhub.contact.ContactMapper;
 import kr.itn.itnhub.mattermost.MattermostClient;
 import kr.itn.itnhub.mattermost.MattermostException;
 import kr.itn.itnhub.org.OrgStatus;
@@ -33,6 +35,9 @@
     OrganizationMapper mapper;
 
     @Autowired
+    ContactMapper contactMapper;
+
+    @Autowired
     JdbcTemplate jdbc;
 
     @MockBean
@@ -43,18 +48,25 @@
     @BeforeEach
     void setUp() {
         jdbc.update("delete from organization");
+        jdbc.update("delete from contact");
 
         Organization org = new Organization();
         org.setOrgNo("001");
         org.setOrgName("국제방송교류재단");
         org.setChannelSlug("001");
-        org.setDeptName("데이터정보화팀");
-        org.setManagerName("송민지");
-        org.setManagerPhone("02-3475-5434");
-        org.setManagerEmail("ming@arirang.com");
         mapper.upsertBySeed(org);
 
         orgId = mapper.findAll().get(0).getId();
+        assignApplicant(orgId, "송민지");
+    }
+
+    private Long assignApplicant(Long id, String name) {
+        Contact contact = new Contact();
+        contact.setCategory("APPLICANT");
+        contact.setName(name);
+        contactMapper.insert(contact);
+        mapper.updateAssignments(id, contact.getId(), null, null, null);
+        return contact.getId();
     }
 
     @Test
@@ -173,15 +185,12 @@
         special.setOrgNo("008");
         special.setOrgName("경찰청_치안정책연구소");
         special.setChannelSlug("008-policy");
-        special.setDeptName("연구지원과");
-        special.setManagerName("담당자");
-        special.setManagerPhone("02-0000-0000");
-        special.setManagerEmail("a@police.go.kr");
         mapper.upsertBySeed(special);
 
         Long specialId = mapper.findAll().stream()
                 .filter(o -> "경찰청_치안정책연구소".equals(o.getOrgName()))
                 .findFirst().orElseThrow().getId();
+        assignApplicant(specialId, "담당자");
 
         when(mattermost.findChannelIdByInternalName(anyString())).thenReturn(Optional.empty());
         when(mattermost.findChannelIdByDisplayName(anyString())).thenReturn(Optional.empty());
@@ -196,7 +205,7 @@
     }
 
     @Test
-    void 담당자정보가_없으면_생성하지_않는다() {
+    void 신청기관_담당자가_배정되지_않으면_생성하지_않는다() {
         Organization pending = new Organization();
         pending.setOrgNo("099");
         pending.setOrgName("정보없는기관");
@@ -211,7 +220,7 @@
 
         assertThat(result.mj()).isEqualTo(ProvisionOutcome.FAILED);
         assertThat(result.law()).isEqualTo(ProvisionOutcome.FAILED);
-        assertThat(result.message()).contains("담당자 정보");
+        assertThat(result.message()).contains("신청기관 담당자");
 
         verify(mattermost, never()).createPrivateChannel(anyString(), anyString());
     }
src/test/java/kr/itn/itnhub/seed/SeedServiceTest.java
--- src/test/java/kr/itn/itnhub/seed/SeedServiceTest.java
+++ src/test/java/kr/itn/itnhub/seed/SeedServiceTest.java
@@ -1,6 +1,8 @@
 package kr.itn.itnhub.seed;
 
 import kr.itn.itnhub.AbstractDbTest;
+import kr.itn.itnhub.contact.Contact;
+import kr.itn.itnhub.contact.ContactMapper;
 import kr.itn.itnhub.org.OrgStatus;
 import kr.itn.itnhub.org.Organization;
 import kr.itn.itnhub.org.OrganizationMapper;
@@ -27,11 +29,15 @@
     OrganizationMapper mapper;
 
     @Autowired
+    ContactMapper contactMapper;
+
+    @Autowired
     JdbcTemplate jdbc;
 
     @BeforeEach
     void clean() {
         jdbc.update("delete from organization");
+        jdbc.update("delete from contact");
     }
 
     private byte[] workbook(String[]... dataRows) throws Exception {
@@ -128,4 +134,59 @@
         assertThat(all).extracting(Organization::getStatus)
                 .containsExactly(OrgStatus.READY, OrgStatus.INFO_PENDING);
     }
+
+    @Test
+    void 시트에_담당자명이_있으면_신청기관_담당자_연락처를_만들어_연결한다() throws Exception {
+        byte[] xlsx = workbook(
+                new String[]{"001", "00", "국제방송교류재단", "데이터정보화팀", "송민지", "과장", "02-1", "a@b.kr"});
+
+        seedService.seed(new ByteArrayInputStream(xlsx));
+
+        Organization org = mapper.findAll().get(0);
+        assertThat(org.getApplicantContactId()).isNotNull();
+
+        Contact applicant = org.getApplicant();
+        assertThat(applicant.getCategory()).isEqualTo("APPLICANT");
+        assertThat(applicant.getName()).isEqualTo("송민지");
+        assertThat(applicant.getAffiliation()).isEqualTo("국제방송교류재단");
+        assertThat(applicant.getDeptName()).isEqualTo("데이터정보화팀");
+        assertThat(applicant.getTitle()).isEqualTo("과장");
+        assertThat(applicant.getPhone()).isEqualTo("02-1");
+        assertThat(applicant.getEmail()).isEqualTo("a@b.kr");
+    }
+
+    @Test
+    void 재시드는_이미_연결된_신청기관_담당자를_중복생성하거나_다시_연결하지_않는다() throws Exception {
+        byte[] xlsx = workbook(
+                new String[]{"001", "00", "국제방송교류재단", "팀", "송민지", "과장", "02-1", "a@b.kr"});
+
+        seedService.seed(new ByteArrayInputStream(xlsx));
+        Long firstContactId = mapper.findAll().get(0).getApplicantContactId();
+
+        seedService.seed(new ByteArrayInputStream(xlsx));
+
+        Organization after = mapper.findAll().get(0);
+        assertThat(after.getApplicantContactId()).isEqualTo(firstContactId);
+        assertThat(jdbc.queryForObject("select count(*) from contact", Integer.class)).isEqualTo(1);
+    }
+
+    @Test
+    void 이미_다른_담당자로_배정된_기관은_재시드가_배정을_바꾸지_않는다() throws Exception {
+        byte[] xlsx = workbook(
+                new String[]{"001", "00", "국제방송교류재단", "팀", "송민지", "과장", "02-1", "a@b.kr"});
+        seedService.seed(new ByteArrayInputStream(xlsx));
+        Long id = mapper.findAll().get(0).getId();
+
+        Contact manual = new Contact();
+        manual.setCategory("APPLICANT");
+        manual.setName("사람이 직접 배정한 담당자");
+        contactMapper.insert(manual);
+        mapper.updateAssignments(id, manual.getId(), null, null, null);
+
+        seedService.seed(new ByteArrayInputStream(xlsx));
+
+        Organization after = mapper.findById(id);
+        assertThat(after.getApplicantContactId()).isEqualTo(manual.getId());
+        assertThat(after.getApplicant().getName()).isEqualTo("사람이 직접 배정한 담당자");
+    }
 }
Add a comment
List