ITN Dev 07-22
fix: Mattermost 채널 표시명 조회를 팀 전체 페이지네이션으로 교체하고 404 매핑을 좁힘
- findChannelIdByDisplayName이 /users/me/teams/{id}/channels(토큰 사용자가 가입한
  채널만) 대신 /teams/{id}/channels를 페이지네이션(page/per_page=200, 상한 100페이지)
  하며 표시명을 찾도록 변경. 레거시 채널에 토큰 사용자가 가입하지 않은 경우 생기던
  중복 채널 생성 위험을 제거한다(system-admin PAT 필요, README에 명시).
- 존재하지 않는 기관 id에 대한 404를 폭넓은 IllegalArgumentException 대신 전용
  OrgNotFoundException으로 좁혀서 매핑.
- 검증 실패(@Valid) 응답을 원시 필드명이 담긴 JSON 대신 한글 라벨 메시지로 변환.
- SeedController 업로드 스트림 읽기 실패를 SeedParseException(400)으로 통일.

Co-Authored-By: Claude Opus 4.8 (1M context) 
@2859f3f659367bfcac3843c62e7773f33db8a590
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,17 +1,26 @@
 package kr.itn.itnhub.config;
 
+import kr.itn.itnhub.org.OrgNotFoundException;
 import kr.itn.itnhub.seed.SeedParseException;
 import org.springframework.http.HttpStatus;
 import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.MethodArgumentNotValidException;
 import org.springframework.web.bind.annotation.ExceptionHandler;
 import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import java.util.Map;
+import java.util.stream.Collectors;
 
 /**
  * 운영자의 흔한 실수를 서버 장애(500)가 아니라 4xx로 돌려준다.
  *
- * <p>{@link IllegalArgumentException}은 {@code OrganizationService.updateContact}와
+ * <p>{@link OrgNotFoundException}은 {@code OrganizationService.updateContact}와
  * {@code ChannelProvisionService.provision}이 존재하지 않는 기관 id에 대해 던진다 -
  * URL에 오타가 있는 것뿐이므로 404가 맞다.</p>
+ *
+ * <p>{@link MethodArgumentNotValidException}은 {@code @Valid} 대상 요청 바디의 필드가
+ * 비어있거나 형식이 틀렸을 때 Spring이 던진다 - 원본 필드명을 그대로 보여주는 대신
+ * 화면에 쓰는 한글 라벨로 바꿔서 알려준다.</p>
  *
  * <p>{@link SeedParseException}은 비어 있거나, 형식이 틀렸거나, 시트가 잘못된 업로드에
  * 대해 던진다 - 흔한 사용자 실수이므로 400과 함께 무엇이 잘못됐는지 알려줘야 한다.</p>
@@ -28,11 +37,28 @@
 @RestControllerAdvice
 public class GlobalExceptionHandler {
 
-    @ExceptionHandler(IllegalArgumentException.class)
-    public ResponseEntity<ApiError> handleNotFound(IllegalArgumentException e) {
+    /** 검증 오류 메시지에 쓸 필드명 → 한글 라벨. 목록에 없는 필드는 원본 이름 그대로 보여준다. */
+    private static final Map<String, String> FIELD_LABELS = Map.of(
+            "deptName", "부서명",
+            "managerName", "담당자명",
+            "managerTitle", "직급/직함",
+            "managerPhone", "연락처",
+            "managerEmail", "이메일");
+
+    @ExceptionHandler(OrgNotFoundException.class)
+    public ResponseEntity<ApiError> handleNotFound(OrgNotFoundException e) {
         return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ApiError(e.getMessage()));
     }
 
+    @ExceptionHandler(MethodArgumentNotValidException.class)
+    public ResponseEntity<ApiError> handleValidation(MethodArgumentNotValidException e) {
+        String fields = e.getBindingResult().getFieldErrors().stream()
+                .map(fe -> FIELD_LABELS.getOrDefault(fe.getField(), fe.getField()))
+                .distinct()
+                .collect(Collectors.joining(", "));
+        return ResponseEntity.badRequest().body(new ApiError(fields + " 값을 확인해 주세요."));
+    }
+
     @ExceptionHandler(SeedParseException.class)
     public ResponseEntity<ApiError> handleSeedParse(SeedParseException e) {
         return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new ApiError(e.getMessage()));
src/main/java/kr/itn/itnhub/mattermost/MattermostRestClient.java
--- src/main/java/kr/itn/itnhub/mattermost/MattermostRestClient.java
+++ src/main/java/kr/itn/itnhub/mattermost/MattermostRestClient.java
@@ -54,24 +54,52 @@
         }
     }
 
+    /** 팀 채널 목록 조회 시 한 페이지에 요청하는 최대 개수. */
+    private static final int DISPLAY_NAME_PAGE_SIZE = 200;
+
+    /**
+     * 무한 루프 방지용 페이지 상한. VBA 원본의 안전장치를 그대로 옮긴 것으로,
+     * 정상적인 팀이라면 도달할 일이 없다.
+     */
+    private static final int DISPLAY_NAME_MAX_PAGES = 100;
+
+    /**
+     * 표시명으로 채널을 찾는다. {@code /users/me/teams/{teamId}/channels}는 토큰 사용자가
+     * "가입한" 채널만 돌려주므로 쓰지 않는다 - 이 조회는 레거시 채널(현재 내부명 규칙 이전에
+     * 만들어져 내부명으로는 못 찾는 채널)을 복구하기 위한 것인데, 토큰 사용자가 그 채널에
+     * 가입해 있지 않으면 조회가 누락되어 중복 채널이 생성된다. 대신 팀 전체 채널을
+     * {@code /teams/{teamId}/channels}로 페이지네이션하며 훑는다 - 이 엔드포인트는 비공개
+     * 채널까지 보려면 PAT가 system-admin 권한이어야 한다(README 참고).
+     */
     @Override
     public Optional<String> findChannelIdByDisplayName(String displayName) {
         try {
-            JsonNode channels = rest.get()
-                    .uri(uri -> uri.path("/api/v4/users/me/teams/{teamId}/channels")
-                            .build(teamId))
-                    .retrieve()
-                    .body(JsonNode.class);
+            for (int page = 0; page < DISPLAY_NAME_MAX_PAGES; page++) {
+                int currentPage = page;
+                JsonNode channels = rest.get()
+                        .uri(uri -> uri.path("/api/v4/teams/{teamId}/channels")
+                                .queryParam("page", currentPage)
+                                .queryParam("per_page", DISPLAY_NAME_PAGE_SIZE)
+                                .build(teamId))
+                        .retrieve()
+                        .body(JsonNode.class);
 
-            if (channels == null || !channels.isArray()) {
-                return Optional.empty();
-            }
-            for (JsonNode channel : channels) {
-                if (displayName.equals(channel.path("display_name").asText())) {
-                    return idOf(channel);
+                if (channels == null || !channels.isArray()) {
+                    return Optional.empty();
+                }
+
+                for (JsonNode channel : channels) {
+                    if (displayName.equals(channel.path("display_name").asText())) {
+                        return idOf(channel);
+                    }
+                }
+
+                if (channels.size() < DISPLAY_NAME_PAGE_SIZE) {
+                    return Optional.empty();
                 }
             }
-            return Optional.empty();
+            throw new MattermostException(
+                    "채널 목록 조회 페이지 한도(" + DISPLAY_NAME_MAX_PAGES + ") 초과: " + displayName);
         } catch (RestClientException e) {
             throw new MattermostException("채널 목록 조회 실패", e);
         }
 
src/main/java/kr/itn/itnhub/org/OrgNotFoundException.java (added)
+++ src/main/java/kr/itn/itnhub/org/OrgNotFoundException.java
@@ -0,0 +1,11 @@
+package kr.itn.itnhub.org;
+
+/**
+ * 존재하지 않는 기관 id로 조회/수정/채널생성을 시도했을 때 던진다.
+ * URL 경로의 id가 단순히 틀린 것뿐이므로 {@link GlobalExceptionHandler}가 404로 변환한다.
+ */
+public class OrgNotFoundException extends RuntimeException {
+    public OrgNotFoundException(String message) {
+        super(message);
+    }
+}
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
@@ -22,7 +22,7 @@
     public OrgResponse updateContact(Long id, ContactRequest request) {
         Organization org = mapper.findById(id);
         if (org == null) {
-            throw new IllegalArgumentException("기관을 찾을 수 없습니다: " + id);
+            throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + id);
         }
         org.setDeptName(request.deptName());
         org.setManagerName(request.managerName());
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
@@ -2,6 +2,7 @@
 
 import kr.itn.itnhub.config.MattermostProperties;
 import kr.itn.itnhub.mattermost.MattermostClient;
+import kr.itn.itnhub.org.OrgNotFoundException;
 import kr.itn.itnhub.org.Organization;
 import kr.itn.itnhub.org.OrganizationMapper;
 import org.slf4j.Logger;
@@ -43,7 +44,7 @@
     public ProvisionResult provision(Long orgId) {
         Organization org = mapper.findById(orgId);
         if (org == null) {
-            throw new IllegalArgumentException("기관을 찾을 수 없습니다: " + orgId);
+            throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
         }
 
         if (!org.hasRequiredContact()) {
src/main/java/kr/itn/itnhub/seed/SeedController.java
--- src/main/java/kr/itn/itnhub/seed/SeedController.java
+++ src/main/java/kr/itn/itnhub/seed/SeedController.java
@@ -18,7 +18,7 @@
     }
 
     @PostMapping("/api/seed")
-    public SeedReport upload(@RequestParam("file") MultipartFile file) throws IOException {
+    public SeedReport upload(@RequestParam("file") MultipartFile file) {
         if (file.isEmpty()) {
             throw new SeedParseException("업로드된 파일이 비어 있습니다.");
         }
@@ -29,8 +29,12 @@
         if (filename == null || !hasAllowedExtension(filename)) {
             throw new SeedParseException("엑셀 파일(.xlsx 또는 .xlsm)만 업로드할 수 있습니다.");
         }
+        // IOException을 그대로 던지면 바디 없는 500이 되어 다른 업로드 오류와 다르게
+        // 취급된다. 스트림 읽기 실패도 사용자가 이해할 수 있는 400으로 통일한다.
         try (InputStream in = file.getInputStream()) {
             return seedService.seed(in);
+        } catch (IOException e) {
+            throw new SeedParseException("엑셀 파일을 읽지 못했습니다.", e);
         }
     }
 
src/test/java/kr/itn/itnhub/mattermost/MattermostRestClientTest.java
--- src/test/java/kr/itn/itnhub/mattermost/MattermostRestClientTest.java
+++ src/test/java/kr/itn/itnhub/mattermost/MattermostRestClientTest.java
@@ -78,8 +78,10 @@
     }
 
     @Test
-    void 표시명으로_내_채널목록에서_id를_찾는다() {
-        server.stubFor(get(urlPathEqualTo("/api/v4/users/me/teams/team123/channels"))
+    void 표시명으로_팀전체_채널목록에서_id를_찾는다() {
+        server.stubFor(get(urlPathEqualTo("/api/v4/teams/team123/channels"))
+                .withQueryParam("page", equalTo("0"))
+                .withQueryParam("per_page", equalTo("200"))
                 .willReturn(okJson("""
                         [
                           {"id":"aaa","display_name":"001_국제방송교류재단 (문정원)"},
@@ -89,8 +91,73 @@
 
         assertThat(client.findChannelIdByDisplayName("001_국제방송교류재단 (법률검토)"))
                 .contains("bbb");
-        assertThat(client.findChannelIdByDisplayName("002_없는기관 (문정원)"))
-                .isEmpty();
+    }
+
+    @Test
+    void 표시명이_첫페이지에_없으면_빈값이다() {
+        server.stubFor(get(urlPathEqualTo("/api/v4/teams/team123/channels"))
+                .withQueryParam("page", equalTo("0"))
+                .withQueryParam("per_page", equalTo("200"))
+                .willReturn(okJson("""
+                        [
+                          {"id":"aaa","display_name":"001_국제방송교류재단 (문정원)"}
+                        ]
+                        """)));
+
+        assertThat(client.findChannelIdByDisplayName("002_없는기관 (문정원)")).isEmpty();
+    }
+
+    @Test
+    void 표시명_조회가_두번째_페이지에서_찾으면_id를_돌려준다() {
+        server.stubFor(get(urlPathEqualTo("/api/v4/teams/team123/channels"))
+                .withQueryParam("page", equalTo("0"))
+                .withQueryParam("per_page", equalTo("200"))
+                .willReturn(okJson(fullPageJson(200, "p0", null))));
+
+        server.stubFor(get(urlPathEqualTo("/api/v4/teams/team123/channels"))
+                .withQueryParam("page", equalTo("1"))
+                .withQueryParam("per_page", equalTo("200"))
+                .willReturn(okJson(fullPageJson(5, "p1", "004_찾는기관 (법률검토)"))));
+
+        assertThat(client.findChannelIdByDisplayName("004_찾는기관 (법률검토)"))
+                .contains("p1-match");
+    }
+
+    @Test
+    void 표시명_조회가_두_페이지를_모두_소진하면_빈값이다() {
+        server.stubFor(get(urlPathEqualTo("/api/v4/teams/team123/channels"))
+                .withQueryParam("page", equalTo("0"))
+                .withQueryParam("per_page", equalTo("200"))
+                .willReturn(okJson(fullPageJson(200, "p0", null))));
+
+        server.stubFor(get(urlPathEqualTo("/api/v4/teams/team123/channels"))
+                .withQueryParam("page", equalTo("1"))
+                .withQueryParam("per_page", equalTo("200"))
+                .willReturn(okJson(fullPageJson(10, "p1", null))));
+
+        assertThat(client.findChannelIdByDisplayName("005_존재하지않는기관 (법률검토)")).isEmpty();
+    }
+
+    /** 팀 채널 목록 한 페이지를 흉내낸 JSON을 만든다. matchDisplayName이 있으면 마지막에 추가한다. */
+    private static String fullPageJson(int fillerCount, String idPrefix, String matchDisplayName) {
+        StringBuilder sb = new StringBuilder("[");
+        for (int i = 0; i < fillerCount; i++) {
+            if (i > 0) {
+                sb.append(",");
+            }
+            sb.append("{\"id\":\"").append(idPrefix).append("-filler").append(i)
+                    .append("\",\"display_name\":\"filler-").append(idPrefix).append("-").append(i)
+                    .append("\"}");
+        }
+        if (matchDisplayName != null) {
+            if (fillerCount > 0) {
+                sb.append(",");
+            }
+            sb.append("{\"id\":\"").append(idPrefix).append("-match\",\"display_name\":\"")
+                    .append(matchDisplayName).append("\"}");
+        }
+        sb.append("]");
+        return sb.toString();
     }
 
     @Test
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
@@ -123,6 +123,29 @@
                 .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)
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {
+                                  "deptName": "",
+                                  "managerName": "",
+                                  "managerPhone": "02-3475-5434",
+                                  "managerEmail": "ming@arirang.com"
+                                }
+                                """))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.message").value(containsString("부서명")))
+                .andExpect(jsonPath("$.message").value(containsString("담당자명")));
+    }
+
     @Test
     void 채널생성을_요청하면_결과를_돌려준다() throws Exception {
         Organization ready = mapper.findById(orgId);
Add a comment
List