ITN Dev 07-22
feat: Mattermost REST 클라이언트 추가
WireMock(Jetty)의 h2c 업그레이드 협상이 POST 요청에서 RST_STREAM을
유발하는 문제가 있어, JDK HttpClient를 HTTP/1.1로 고정하도록
requestFactory를 명시적으로 지정했다(브리핑 예시 코드 대비 유일한 편차).
@62baed09b5401efa26a431c8f4c421f44e175124
src/main/java/kr/itn/itnhub/ItnHubApplication.java
--- src/main/java/kr/itn/itnhub/ItnHubApplication.java
+++ src/main/java/kr/itn/itnhub/ItnHubApplication.java
@@ -2,8 +2,10 @@
 
 import org.springframework.boot.SpringApplication;
 import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
 
 @SpringBootApplication
+@ConfigurationPropertiesScan
 public class ItnHubApplication {
     public static void main(String[] args) {
         SpringApplication.run(ItnHubApplication.class, args);
 
src/main/java/kr/itn/itnhub/config/MattermostProperties.java (added)
+++ src/main/java/kr/itn/itnhub/config/MattermostProperties.java
@@ -0,0 +1,13 @@
+package kr.itn.itnhub.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.boot.context.properties.bind.DefaultValue;
+
+@ConfigurationProperties(prefix = "mattermost")
+public record MattermostProperties(
+        String baseUrl,
+        String token,
+        String teamId,
+        @DefaultValue("문정원") String channelNameMj,
+        @DefaultValue("법률검토") String channelNameLaw) {
+}
 
src/main/java/kr/itn/itnhub/mattermost/MattermostClient.java (added)
+++ src/main/java/kr/itn/itnhub/mattermost/MattermostClient.java
@@ -0,0 +1,12 @@
+package kr.itn.itnhub.mattermost;
+
+import java.util.Optional;
+
+public interface MattermostClient {
+
+    Optional<String> findChannelIdByInternalName(String internalName);
+
+    Optional<String> findChannelIdByDisplayName(String displayName);
+
+    String createPrivateChannel(String internalName, String displayName);
+}
 
src/main/java/kr/itn/itnhub/mattermost/MattermostException.java (added)
+++ src/main/java/kr/itn/itnhub/mattermost/MattermostException.java
@@ -0,0 +1,11 @@
+package kr.itn.itnhub.mattermost;
+
+public class MattermostException extends RuntimeException {
+    public MattermostException(String message, Throwable cause) {
+        super(message, cause);
+    }
+
+    public MattermostException(String message) {
+        super(message);
+    }
+}
 
src/main/java/kr/itn/itnhub/mattermost/MattermostRestClient.java (added)
+++ src/main/java/kr/itn/itnhub/mattermost/MattermostRestClient.java
@@ -0,0 +1,114 @@
+package kr.itn.itnhub.mattermost;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import kr.itn.itnhub.config.MattermostProperties;
+import org.springframework.http.MediaType;
+import org.springframework.http.client.JdkClientHttpRequestFactory;
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestClient;
+import org.springframework.web.client.RestClientException;
+
+import java.net.http.HttpClient;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Optional;
+
+@Component
+public class MattermostRestClient implements MattermostClient {
+
+    private final RestClient rest;
+    private final String teamId;
+
+    public MattermostRestClient(MattermostProperties props) {
+        this.teamId = props.teamId();
+        // JDK HttpClient의 기본 HTTP/2 h2c 업그레이드 협상이 WireMock(Jetty) 상대로
+        // POST 요청에서 간헐적으로 RST_STREAM을 유발해 HTTP/1.1로 고정한다.
+        HttpClient httpClient = HttpClient.newBuilder()
+                .version(HttpClient.Version.HTTP_1_1)
+                .build();
+        this.rest = RestClient.builder()
+                .baseUrl(props.baseUrl())
+                .requestFactory(new JdkClientHttpRequestFactory(httpClient))
+                .defaultHeader("Authorization", "Bearer " + props.token())
+                .build();
+    }
+
+    @Override
+    public Optional<String> findChannelIdByInternalName(String internalName) {
+        try {
+            JsonNode body = rest.get()
+                    .uri("/api/v4/teams/{teamId}/channels/name/{name}", teamId, internalName)
+                    .retrieve()
+                    .onStatus(status -> status.value() == 404, (req, res) -> {
+                        throw new NotFound();
+                    })
+                    .body(JsonNode.class);
+
+            return idOf(body);
+        } catch (NotFound e) {
+            return Optional.empty();
+        } catch (RestClientException e) {
+            throw new MattermostException("채널 조회 실패: " + internalName, e);
+        }
+    }
+
+    @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);
+
+            if (channels == null || !channels.isArray()) {
+                return Optional.empty();
+            }
+            for (JsonNode channel : channels) {
+                if (displayName.equals(channel.path("display_name").asText())) {
+                    return idOf(channel);
+                }
+            }
+            return Optional.empty();
+        } catch (RestClientException e) {
+            throw new MattermostException("채널 목록 조회 실패", e);
+        }
+    }
+
+    @Override
+    public String createPrivateChannel(String internalName, String displayName) {
+        Map<String, String> body = new LinkedHashMap<>();
+        body.put("team_id", teamId);
+        body.put("name", internalName);
+        body.put("display_name", displayName);
+        body.put("type", "P");
+        body.put("purpose", "");
+        body.put("header", "");
+
+        try {
+            JsonNode created = rest.post()
+                    .uri("/api/v4/channels")
+                    .contentType(MediaType.APPLICATION_JSON)
+                    .body(body)
+                    .retrieve()
+                    .body(JsonNode.class);
+
+            return idOf(created).orElseThrow(() ->
+                    new MattermostException("채널 생성 응답에 id가 없습니다: " + displayName));
+        } catch (RestClientException e) {
+            throw new MattermostException("채널 생성 실패: " + displayName, e);
+        }
+    }
+
+    private Optional<String> idOf(JsonNode node) {
+        if (node == null) {
+            return Optional.empty();
+        }
+        String id = node.path("id").asText("");
+        return id.isBlank() ? Optional.empty() : Optional.of(id);
+    }
+
+    /** 404를 정상 흐름으로 되돌리기 위한 내부 신호. 밖으로 새지 않는다. */
+    private static final class NotFound extends RuntimeException {
+    }
+}
src/main/resources/application.yml
--- src/main/resources/application.yml
+++ src/main/resources/application.yml
@@ -28,3 +28,8 @@
 logging:
   level:
     kr.itn.itnhub: INFO
+
+mattermost:
+  base-url: ${MATTERMOST_URL}
+  token: ${MATTERMOST_TOKEN}
+  team-id: ${MATTERMOST_TEAM_ID}
 
src/test/java/kr/itn/itnhub/mattermost/MattermostRestClientTest.java (added)
+++ src/test/java/kr/itn/itnhub/mattermost/MattermostRestClientTest.java
@@ -0,0 +1,108 @@
+package kr.itn.itnhub.mattermost;
+
+import com.github.tomakehurst.wiremock.WireMockServer;
+import kr.itn.itnhub.config.MattermostProperties;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.*;
+import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class MattermostRestClientTest {
+
+    private WireMockServer server;
+    private MattermostRestClient client;
+
+    @BeforeEach
+    void setUp() {
+        server = new WireMockServer(options().dynamicPort());
+        server.start();
+
+        MattermostProperties props = new MattermostProperties(
+                "http://localhost:" + server.port(),
+                "test-token",
+                "team123",
+                "문정원",
+                "법률검토");
+
+        client = new MattermostRestClient(props);
+    }
+
+    @AfterEach
+    void tearDown() {
+        server.stop();
+    }
+
+    @Test
+    void 내부명으로_채널을_찾으면_id를_돌려준다() {
+        server.stubFor(get(urlEqualTo("/api/v4/teams/team123/channels/name/org-001-mj"))
+                .willReturn(okJson("{\"id\":\"chan001mj\",\"name\":\"org-001-mj\"}")));
+
+        assertThat(client.findChannelIdByInternalName("org-001-mj"))
+                .contains("chan001mj");
+    }
+
+    @Test
+    void 내부명으로_채널이_없으면_빈값이다() {
+        server.stubFor(get(urlEqualTo("/api/v4/teams/team123/channels/name/org-999-mj"))
+                .willReturn(aResponse().withStatus(404)));
+
+        assertThat(client.findChannelIdByInternalName("org-999-mj")).isEmpty();
+    }
+
+    @Test
+    void 토큰을_Authorization_헤더로_보낸다() {
+        server.stubFor(get(urlEqualTo("/api/v4/teams/team123/channels/name/org-001-mj"))
+                .willReturn(okJson("{\"id\":\"chan001mj\"}")));
+
+        client.findChannelIdByInternalName("org-001-mj");
+
+        server.verify(getRequestedFor(urlEqualTo("/api/v4/teams/team123/channels/name/org-001-mj"))
+                .withHeader("Authorization", equalTo("Bearer test-token")));
+    }
+
+    @Test
+    void 표시명으로_내_채널목록에서_id를_찾는다() {
+        server.stubFor(get(urlPathEqualTo("/api/v4/users/me/teams/team123/channels"))
+                .willReturn(okJson("""
+                        [
+                          {"id":"aaa","display_name":"001_국제방송교류재단 (문정원)"},
+                          {"id":"bbb","display_name":"001_국제방송교류재단 (법률검토)"}
+                        ]
+                        """)));
+
+        assertThat(client.findChannelIdByDisplayName("001_국제방송교류재단 (법률검토)"))
+                .contains("bbb");
+        assertThat(client.findChannelIdByDisplayName("002_없는기관 (문정원)"))
+                .isEmpty();
+    }
+
+    @Test
+    void 비공개_채널을_생성하고_id를_돌려준다() {
+        server.stubFor(post(urlEqualTo("/api/v4/channels"))
+                .willReturn(okJson("{\"id\":\"newchan\"}")));
+
+        String id = client.createPrivateChannel("org-002-mj", "002_세종학당재단 (문정원)");
+
+        assertThat(id).isEqualTo("newchan");
+
+        server.verify(postRequestedFor(urlEqualTo("/api/v4/channels"))
+                .withRequestBody(matchingJsonPath("$.team_id", equalTo("team123")))
+                .withRequestBody(matchingJsonPath("$.name", equalTo("org-002-mj")))
+                .withRequestBody(matchingJsonPath("$.display_name",
+                        equalTo("002_세종학당재단 (문정원)")))
+                .withRequestBody(matchingJsonPath("$.type", equalTo("P"))));
+    }
+
+    @Test
+    void 서버가_오류를_주면_MattermostException을_던진다() {
+        server.stubFor(post(urlEqualTo("/api/v4/channels"))
+                .willReturn(aResponse().withStatus(500).withBody("boom")));
+
+        assertThatThrownBy(() -> client.createPrivateChannel("org-003-mj", "003_기관 (문정원)"))
+                .isInstanceOf(MattermostException.class);
+    }
+}
Add a comment
List