ITN Dev 07-22
fix: 채널 프로비저닝 DB 저장 실패가 전파되지 않도록 예외 범위 확장
MattermostException만 catch하던 ensure()를 RuntimeException으로 넓혀,
채널 생성 직후 mapper 저장이 DataAccessException 계열로 실패해도
반대쪽 채널 시도를 막지 않고 FAILED로 강등되도록 수정. applyChannelId는
exhaustive switch 식으로 바꿔 컬럼 오기입 방지 주장을 실제로 컴파일
타임에 보장하고, 첫 채널 실패/양쪽 실패/DB 저장 실패 회귀 테스트를
추가했다.

Co-Authored-By: Claude Opus 4.8 (1M context) 
@568476b306773ae88961a0f22435d729357fd729
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,7 +2,6 @@
 
 import kr.itn.itnhub.config.MattermostProperties;
 import kr.itn.itnhub.mattermost.MattermostClient;
-import kr.itn.itnhub.mattermost.MattermostException;
 import kr.itn.itnhub.org.Organization;
 import kr.itn.itnhub.org.OrganizationMapper;
 import org.slf4j.Logger;
@@ -95,7 +94,11 @@
             applyChannelId(org.getId(), kind, created);
             return ProvisionOutcome.CREATED;
 
-        } catch (MattermostException e) {
+        } catch (RuntimeException e) {
+            // MattermostException(서버 API 실패)뿐 아니라, 채널 생성/복구 직후 ID를 저장하는
+            // mapper 호출이 던지는 DataAccessException 등도 여기서 잡는다. Error나 checked
+            // exception은 의도적으로 넓히지 않는다 - 이 두 경우는 채널 하나만 FAILED로
+            // 강등시키고 메시지에 남긴 뒤, 나머지 채널은 계속 시도되게 해야 한다.
             log.warn("채널 처리 실패 org={} channel={}", org.getOrgName(), displayName, e);
             messages.append("[").append(label).append("] ")
                     .append(e.getMessage()).append(System.lineSeparator());
@@ -105,14 +108,17 @@
 
     /**
      * Channel ID를 저장할 컬럼(mj/law)을 결정하는 유일한 지점.
-     * 문자열 키 기반 동적 분기 대신 정적 타입(ChannelKind)으로만 분기하여,
-     * 잘못된 컬럼에 쓰는 실수를 컴파일 타임에 막는다.
+     * switch 식(expression)으로 작성해 ChannelKind에 값이 추가되고도 분기를
+     * 채우지 않으면 컴파일이 실패하도록 강제한다 - if/else였다면 새 값이
+     * 조용히 else(법률검토) 분기로 흘러 들어가는 것을 막지 못했을 것이다.
      */
     private void applyChannelId(Long orgId, ChannelKind kind, String channelId) {
-        if (kind == ChannelKind.MJ) {
-            mapper.updateChannelIdMj(orgId, channelId);
-        } else {
-            mapper.updateChannelIdLaw(orgId, channelId);
-        }
+        // 반환값(영향받은 행 수)은 쓰지 않지만, switch를 식으로 유지해야
+        // 컴파일러가 exhaustiveness를 검사한다 - 문(statement) 형태의
+        // switch/if-else는 이 검사를 강제하지 않는다.
+        var ignored = switch (kind) {
+            case MJ -> mapper.updateChannelIdMj(orgId, channelId);
+            case LAW -> mapper.updateChannelIdLaw(orgId, channelId);
+        };
     }
 }
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
@@ -10,14 +10,16 @@
 import org.junit.jupiter.api.Test;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.boot.test.mock.mockito.SpyBean;
+import org.springframework.dao.DataIntegrityViolationException;
 import org.springframework.jdbc.core.JdbcTemplate;
 
 import java.util.Optional;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -27,7 +29,7 @@
     @Autowired
     ChannelProvisionService service;
 
-    @Autowired
+    @SpyBean
     OrganizationMapper mapper;
 
     @Autowired
@@ -213,4 +215,75 @@
 
         verify(mattermost, never()).createPrivateChannel(anyString(), anyString());
     }
+
+    @Test
+    void 문정원이_실패해도_법률검토는_시도된다() {
+        when(mattermost.findChannelIdByInternalName(anyString())).thenReturn(Optional.empty());
+        when(mattermost.findChannelIdByDisplayName(anyString())).thenReturn(Optional.empty());
+        when(mattermost.createPrivateChannel(eq("org-001-mj"), anyString()))
+                .thenThrow(new MattermostException("문정원 서버 오류"));
+        when(mattermost.createPrivateChannel(eq("org-001-law"), anyString()))
+                .thenReturn("id-law");
+
+        ProvisionResult result = service.provision(orgId);
+
+        assertThat(result.mj()).isEqualTo(ProvisionOutcome.FAILED);
+        assertThat(result.law()).isEqualTo(ProvisionOutcome.CREATED);
+        assertThat(result.message()).contains("문정원");
+
+        Organization after = mapper.findById(orgId);
+        assertThat(after.getChannelIdMj()).isNull();
+        assertThat(after.getChannelIdLaw()).isEqualTo("id-law");
+        assertThat(after.getStatus()).isEqualTo(OrgStatus.PARTIAL);
+    }
+
+    @Test
+    void 양쪽_다_실패하면_메시지에_두_채널_라벨이_모두_누적된다() {
+        when(mattermost.findChannelIdByInternalName(anyString())).thenReturn(Optional.empty());
+        when(mattermost.findChannelIdByDisplayName(anyString())).thenReturn(Optional.empty());
+        when(mattermost.createPrivateChannel(eq("org-001-mj"), anyString()))
+                .thenThrow(new MattermostException("문정원 서버 오류"));
+        when(mattermost.createPrivateChannel(eq("org-001-law"), anyString()))
+                .thenThrow(new MattermostException("법률검토 서버 오류"));
+
+        ProvisionResult result = service.provision(orgId);
+
+        assertThat(result.mj()).isEqualTo(ProvisionOutcome.FAILED);
+        assertThat(result.law()).isEqualTo(ProvisionOutcome.FAILED);
+        assertThat(result.message()).contains("문정원").contains("법률검토");
+
+        Organization after = mapper.findById(orgId);
+        assertThat(after.getChannelIdMj()).isNull();
+        assertThat(after.getChannelIdLaw()).isNull();
+    }
+
+    /**
+     * Finding 1 회귀 테스트: MattermostClient 호출은 성공했는데 그 직후 DB 기록(mapper)이
+     * DataAccessException 계열로 실패하는 경우. 수정 전에는 ensure()가 MattermostException만
+     * catch 하므로 이 예외가 provision() 밖으로 전파되어 두 번째(법률검토) 채널은 시도조차
+     * 되지 않고 호출자는 ProvisionResult를 받지 못한다. 수정 후에는 해당 채널만 FAILED로
+     * 강등되고 나머지 채널은 정상적으로 계속 시도되어야 한다.
+     */
+    @Test
+    void 채널_생성_성공_직후_DB_저장_실패는_예외로_전파되지_않고_FAILED로_처리된다() {
+        when(mattermost.findChannelIdByInternalName(anyString())).thenReturn(Optional.empty());
+        when(mattermost.findChannelIdByDisplayName(anyString())).thenReturn(Optional.empty());
+        when(mattermost.createPrivateChannel(eq("org-001-mj"), anyString()))
+                .thenReturn("id-mj");
+        when(mattermost.createPrivateChannel(eq("org-001-law"), anyString()))
+                .thenReturn("id-law");
+
+        doThrow(new DataIntegrityViolationException("DB 저장 실패"))
+                .when(mapper).updateChannelIdMj(eq(orgId), anyString());
+
+        ProvisionResult result = service.provision(orgId);
+
+        assertThat(result.mj()).isEqualTo(ProvisionOutcome.FAILED);
+        assertThat(result.law()).isEqualTo(ProvisionOutcome.CREATED);
+        assertThat(result.message()).contains("문정원");
+
+        Organization after = mapper.findById(orgId);
+        assertThat(after.getChannelIdMj()).isNull();
+        assertThat(after.getChannelIdLaw()).isEqualTo("id-law");
+    }
 }
Add a comment
List