ITN Dev 07-22
feat: 기관관리 게시물/자료 탭용 Mattermost 채널 조회 API 추가
- MattermostClient에 게시글 조회(getRecentPosts), 채널 파일 수집(collectChannelFiles),
  파일 다운로드/정보 조회(downloadFile, fileInfo)를 추가하고 사용자 이름을 캐시해서 반복
  조회를 줄인다
- ChannelFeedService/Controller로 기관별 문정원/법률검토 채널의 게시글·자료 목록과
  파일 다운로드 프록시(GET /api/orgs/{id}/posts|files, GET /api/files/{fileId})를 제공
- 채널 미생성/잘못된 channel 파라미터는 ChannelNotReadyException(409)으로 구분한다
@8192ef8a87eed448f0e03e16c2d50c03004ae8fc
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,6 @@
 package kr.itn.itnhub.config;
 
+import kr.itn.itnhub.feed.ChannelNotReadyException;
 import kr.itn.itnhub.org.OrgNotFoundException;
 import kr.itn.itnhub.seed.SeedParseException;
 import org.springframework.http.HttpStatus;
@@ -29,6 +30,11 @@
  * SQL, 파일 경로는 어차피 이 메시지들에 담기지 않으므로 그대로 노출해도 안전하다.
  * {@code server.error.include-message=NEVER} 기본값은 건드리지 않는다 - 여기서 직접
  * {@link ApiError} 본문을 만들어 반환하므로 그 설정과는 무관하게 동작한다.</p>
+ *
+ * <p>{@link ChannelNotReadyException}은 {@code ChannelFeedService}가 존재하는 기관의
+ * 문정원/법률검토 채널이 아직 생성되지 않았거나 channel 파라미터가 잘못됐을 때 던진다 -
+ * 기관은 있고(404 아님) 요청 자체도 잘못되지 않았으니(400 아님) "아직 처리할 수 없는
+ * 상태"라는 의미로 409를 쓴다.</p>
  *
  * <p><b>여기에 {@code Exception.class} catch-all을 추가하지 말 것.</b> 예상하지 못한
  * 예외까지 4xx로 감싸버리면 진짜 버그가 조용히 묻힌다. 예상 밖 예외는 기본 500 처리
@@ -63,4 +69,9 @@
     public ResponseEntity<ApiError> handleSeedParse(SeedParseException e) {
         return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new ApiError(e.getMessage()));
     }
+
+    @ExceptionHandler(ChannelNotReadyException.class)
+    public ResponseEntity<ApiError> handleChannelNotReady(ChannelNotReadyException e) {
+        return ResponseEntity.status(HttpStatus.CONFLICT).body(new ApiError(e.getMessage()));
+    }
 }
 
src/main/java/kr/itn/itnhub/feed/ChannelFeedController.java (added)
+++ src/main/java/kr/itn/itnhub/feed/ChannelFeedController.java
@@ -0,0 +1,71 @@
+package kr.itn.itnhub.feed;
+
+import kr.itn.itnhub.mattermost.MattermostClient;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+/**
+ * 기관관리 상세의 게시물/자료 탭이 부르는 읽기 전용 엔드포인트.
+ * 브라우저는 Mattermost를 절대 직접 호출하지 않고 전부 이 컨트롤러를 거친다.
+ */
+@RestController
+public class ChannelFeedController {
+
+    private final ChannelFeedService feedService;
+    private final MattermostClient mattermost;
+
+    public ChannelFeedController(ChannelFeedService feedService, MattermostClient mattermost) {
+        this.feedService = feedService;
+        this.mattermost = mattermost;
+    }
+
+    @GetMapping("/api/orgs/{id}/posts")
+    public List<PostView> posts(@PathVariable Long id, @RequestParam String channel) {
+        return feedService.posts(id, channel);
+    }
+
+    @GetMapping("/api/orgs/{id}/files")
+    public List<FileView> files(@PathVariable Long id, @RequestParam String channel) {
+        return feedService.files(id, channel);
+    }
+
+    /**
+     * 한글 파일명이 대부분이라 {@code Content-Disposition}은 반드시 RFC 5987의
+     * {@code filename*=UTF-8''...} 형식으로 인코딩해야 한다. 일반 {@code filename="..."}
+     * 만 쓰면 브라우저마다 한글이 깨진다.
+     */
+    @GetMapping("/api/files/{fileId}")
+    public ResponseEntity<byte[]> download(@PathVariable String fileId) {
+        FileRef info = mattermost.fileInfo(fileId);
+        byte[] content = mattermost.downloadFile(fileId);
+
+        String encodedName = URLEncoder.encode(info.name(), StandardCharsets.UTF_8)
+                .replace("+", "%20");
+        MediaType contentType = resolveContentType(info.mimeType());
+
+        return ResponseEntity.ok()
+                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodedName)
+                .contentType(contentType)
+                .body(content);
+    }
+
+    private MediaType resolveContentType(String mimeType) {
+        if (mimeType == null || mimeType.isBlank()) {
+            return MediaType.APPLICATION_OCTET_STREAM;
+        }
+        try {
+            return MediaType.parseMediaType(mimeType);
+        } catch (org.springframework.util.InvalidMimeTypeException e) {
+            return MediaType.APPLICATION_OCTET_STREAM;
+        }
+    }
+}
 
src/main/java/kr/itn/itnhub/feed/ChannelFeedService.java (added)
+++ src/main/java/kr/itn/itnhub/feed/ChannelFeedService.java
@@ -0,0 +1,58 @@
+package kr.itn.itnhub.feed;
+
+import kr.itn.itnhub.mattermost.MattermostClient;
+import kr.itn.itnhub.org.Organization;
+import kr.itn.itnhub.org.OrganizationMapper;
+import kr.itn.itnhub.org.OrgNotFoundException;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/**
+ * 기관관리 상세 화면의 게시물/자료 탭이 쓰는 조회 전용 서비스.
+ * Mattermost를 매번 실시간으로 읽어오는 뷰이므로 {@code @Transactional}을 걸지 않는다 -
+ * DB 조회는 채널 id를 얻기 위한 짧은 findById 한 번뿐이고, 외부 API 호출을 트랜잭션
+ * 안에 묶어둘 이유가 없다.
+ */
+@Service
+public class ChannelFeedService {
+
+    /** 게시물 탭 한 번에 보여줄 최근 게시글 수. */
+    private static final int RECENT_POSTS_PAGE_SIZE = 60;
+
+    private final OrganizationMapper orgMapper;
+    private final MattermostClient mattermost;
+
+    public ChannelFeedService(OrganizationMapper orgMapper, MattermostClient mattermost) {
+        this.orgMapper = orgMapper;
+        this.mattermost = mattermost;
+    }
+
+    public List<PostView> posts(Long orgId, String channel) {
+        String channelId = resolveChannelId(orgId, channel);
+        return mattermost.getRecentPosts(channelId, RECENT_POSTS_PAGE_SIZE);
+    }
+
+    public List<FileView> files(Long orgId, String channel) {
+        String channelId = resolveChannelId(orgId, channel);
+        return mattermost.collectChannelFiles(channelId);
+    }
+
+    private String resolveChannelId(Long orgId, String channel) {
+        Organization org = orgMapper.findById(orgId);
+        if (org == null) {
+            throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
+        }
+
+        String channelId = switch (channel) {
+            case "mj" -> org.getChannelIdMj();
+            case "law" -> org.getChannelIdLaw();
+            default -> throw new ChannelNotReadyException("알 수 없는 채널 구분입니다: " + channel);
+        };
+
+        if (channelId == null || channelId.isBlank()) {
+            throw new ChannelNotReadyException("해당 채널이 아직 생성되지 않았습니다.");
+        }
+        return channelId;
+    }
+}
 
src/main/java/kr/itn/itnhub/feed/ChannelNotReadyException.java (added)
+++ src/main/java/kr/itn/itnhub/feed/ChannelNotReadyException.java
@@ -0,0 +1,13 @@
+package kr.itn.itnhub.feed;
+
+/**
+ * 기관은 존재하지만 요청한 채널(문정원/법률검토)이 아직 생성되지 않았거나, channel 파라미터가
+ * "mj"/"law" 둘 중 하나가 아닐 때 던진다. 기관 자체는 있으므로 404(찾을 수 없음)가 아니고,
+ * 요청 형식이 틀린 것도 아니므로(존재하는 기관 id + 유효해 보이는 채널 구분) 400도 아니다 -
+ * "지금은 아직 처리할 수 없는 상태"라는 의미로 409(CONFLICT)를 쓴다.
+ */
+public class ChannelNotReadyException extends RuntimeException {
+    public ChannelNotReadyException(String message) {
+        super(message);
+    }
+}
 
src/main/java/kr/itn/itnhub/feed/FileRef.java (added)
+++ src/main/java/kr/itn/itnhub/feed/FileRef.java
@@ -0,0 +1,12 @@
+package kr.itn.itnhub.feed;
+
+/**
+ * 게시글 첨부파일 하나를 가리키는 최소 정보. {@code mimeType}은 원래 스펙 문서의 3필드
+ * 초안({@code id, name, size})에는 없었지만, 다운로드 프록시({@code ChannelFeedController})가
+ * {@code Content-Type} 응답 헤더를 확장자 추측이 아니라 Mattermost가 실제로 알려준 값으로
+ * 채우려면 {@code /files/{id}/info}의 {@code mime_type}을 어딘가에 담아야 해서 추가했다.
+ * 게시글 메타데이터의 첨부파일 항목도 동일한 필드를 내려주므로 값 채우기에 문제가 없고,
+ * 프론트엔드 {@code FileRef} 타입은 필요한 필드(name, size)만 읽으므로 영향이 없다.
+ */
+public record FileRef(String id, String name, long size, String mimeType) {
+}
 
src/main/java/kr/itn/itnhub/feed/FileView.java (added)
+++ src/main/java/kr/itn/itnhub/feed/FileView.java
@@ -0,0 +1,6 @@
+package kr.itn.itnhub.feed;
+
+/** 자료실 목록 한 줄. 채널 전체를 훑어 모은 첨부파일 + 올린 사람/시각. */
+public record FileView(String id, String name, long size, String mimeType,
+                       long createAt, String uploader) {
+}
 
src/main/java/kr/itn/itnhub/feed/PostView.java (added)
+++ src/main/java/kr/itn/itnhub/feed/PostView.java
@@ -0,0 +1,11 @@
+package kr.itn.itnhub.feed;
+
+import java.util.List;
+
+/**
+ * 채널 게시글 하나를 화면에 보여주기 위한 뷰 모델. {@code user}는 원본 user_id가 아니라
+ * 표시용 이름(닉네임 우선, 없으면 아이디)으로 이미 치환된 값이다.
+ */
+public record PostView(String id, String user, String message, long createAt,
+                       boolean system, List<FileRef> files) {
+}
src/main/java/kr/itn/itnhub/mattermost/MattermostClient.java
--- src/main/java/kr/itn/itnhub/mattermost/MattermostClient.java
+++ src/main/java/kr/itn/itnhub/mattermost/MattermostClient.java
@@ -1,5 +1,10 @@
 package kr.itn.itnhub.mattermost;
 
+import kr.itn.itnhub.feed.FileRef;
+import kr.itn.itnhub.feed.FileView;
+import kr.itn.itnhub.feed.PostView;
+
+import java.util.List;
 import java.util.Optional;
 
 public interface MattermostClient {
@@ -9,4 +14,14 @@
     Optional<String> findChannelIdByDisplayName(String displayName);
 
     String createPrivateChannel(String internalName, String displayName);
+
+    /** 채널의 최근 게시글 한 페이지를 오래된순(화면 표시 순서)으로 돌려준다. */
+    List<PostView> getRecentPosts(String channelId, int perPage);
+
+    /** 채널 전체를 페이지네이션하며 첨부파일을 전부 모아 최신순으로 돌려준다. */
+    List<FileView> collectChannelFiles(String channelId);
+
+    byte[] downloadFile(String fileId);
+
+    FileRef fileInfo(String fileId);
 }
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
@@ -2,21 +2,36 @@
 
 import com.fasterxml.jackson.databind.JsonNode;
 import kr.itn.itnhub.config.MattermostProperties;
+import kr.itn.itnhub.feed.FileRef;
+import kr.itn.itnhub.feed.FileView;
+import kr.itn.itnhub.feed.PostView;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.http.MediaType;
 import org.springframework.stereotype.Component;
 import org.springframework.web.client.RestClient;
 import org.springframework.web.client.RestClientException;
 
+import java.util.ArrayList;
+import java.util.Collections;
 import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
 
 @Component
 public class MattermostRestClient implements MattermostClient {
 
     private final RestClient rest;
     private final String teamId;
+
+    /**
+     * 사용자 id → 표시용 이름(닉네임 우선, 없으면 아이디) 캐시. 게시물/자료 목록 조회 시
+     * 같은 몇 명이 계속 반복해서 등장하므로, 매 게시글마다 {@code /users/{id}}를 다시
+     * 부르지 않고 여기서 재사용한다. 조회 실패도 원본 id로 캐시해 전체 목록 조회가
+     * 실패하지 않게 한다.
+     */
+    private final Map<String, String> userNameCache = new ConcurrentHashMap<>();
 
     @Autowired
     public MattermostRestClient(MattermostProperties props) {
@@ -130,6 +145,162 @@
         }
     }
 
+    /** {@code collectChannelFiles} 페이지네이션에 쓰는 페이지당 게시글 수. */
+    private static final int FILES_PAGE_SIZE = 200;
+
+    /** {@code findChannelIdByDisplayName}과 같은 이유의 무한 루프 방지 상한. */
+    private static final int FILES_MAX_PAGES = 100;
+
+    @Override
+    public List<PostView> getRecentPosts(String channelId, int perPage) {
+        try {
+            JsonNode body = rest.get()
+                    .uri(uri -> uri.path("/api/v4/channels/{channelId}/posts")
+                            .queryParam("per_page", perPage)
+                            .build(channelId))
+                    .retrieve()
+                    .body(JsonNode.class);
+
+            if (body == null) {
+                return List.of();
+            }
+
+            List<PostView> result = new ArrayList<>();
+            JsonNode posts = body.path("posts");
+            for (JsonNode idNode : body.path("order")) {
+                result.add(toPostView(posts.path(idNode.asText())));
+            }
+            // Mattermost는 newest-first로 내려주지만 화면은 오래된순으로 보여준다.
+            Collections.reverse(result);
+            return result;
+        } catch (RestClientException e) {
+            throw new MattermostException("게시글 조회 실패: " + channelId, e);
+        }
+    }
+
+    @Override
+    public List<FileView> collectChannelFiles(String channelId) {
+        List<FileView> files = new ArrayList<>();
+        try {
+            for (int page = 0; page < FILES_MAX_PAGES; page++) {
+                int currentPage = page;
+                JsonNode body = rest.get()
+                        .uri(uri -> uri.path("/api/v4/channels/{channelId}/posts")
+                                .queryParam("per_page", FILES_PAGE_SIZE)
+                                .queryParam("page", currentPage)
+                                .build(channelId))
+                        .retrieve()
+                        .body(JsonNode.class);
+
+                if (body == null) {
+                    return files;
+                }
+
+                JsonNode order = body.path("order");
+                JsonNode posts = body.path("posts");
+                int count = 0;
+                for (JsonNode idNode : order) {
+                    count++;
+                    JsonNode post = posts.path(idNode.asText());
+                    long createAt = post.path("create_at").asLong();
+                    String uploader = resolveUserName(post.path("user_id").asText());
+                    for (JsonNode f : post.path("metadata").path("files")) {
+                        files.add(new FileView(
+                                f.path("id").asText(),
+                                f.path("name").asText(),
+                                f.path("size").asLong(),
+                                f.path("mime_type").asText(""),
+                                createAt,
+                                uploader));
+                    }
+                }
+
+                if (count < FILES_PAGE_SIZE) {
+                    return files;
+                }
+            }
+            throw new MattermostException(
+                    "채널 파일 목록 조회 페이지 한도(" + FILES_MAX_PAGES + ") 초과: " + channelId);
+        } catch (RestClientException e) {
+            throw new MattermostException("채널 파일 목록 조회 실패: " + channelId, e);
+        }
+    }
+
+    @Override
+    public byte[] downloadFile(String fileId) {
+        try {
+            byte[] body = rest.get()
+                    .uri("/api/v4/files/{id}", fileId)
+                    .retrieve()
+                    .body(byte[].class);
+            return body == null ? new byte[0] : body;
+        } catch (RestClientException e) {
+            throw new MattermostException("파일 다운로드 실패: " + fileId, e);
+        }
+    }
+
+    @Override
+    public FileRef fileInfo(String fileId) {
+        try {
+            JsonNode body = rest.get()
+                    .uri("/api/v4/files/{id}/info", fileId)
+                    .retrieve()
+                    .body(JsonNode.class);
+
+            if (body == null) {
+                throw new MattermostException("파일 정보 조회 실패: " + fileId);
+            }
+            return new FileRef(fileId,
+                    body.path("name").asText(""),
+                    body.path("size").asLong(),
+                    body.path("mime_type").asText(""));
+        } catch (RestClientException e) {
+            throw new MattermostException("파일 정보 조회 실패: " + fileId, e);
+        }
+    }
+
+    private PostView toPostView(JsonNode post) {
+        String id = post.path("id").asText();
+        String message = post.path("message").asText("");
+        long createAt = post.path("create_at").asLong();
+        boolean system = post.path("type").asText("").startsWith("system_");
+
+        List<FileRef> files = new ArrayList<>();
+        for (JsonNode f : post.path("metadata").path("files")) {
+            files.add(new FileRef(
+                    f.path("id").asText(),
+                    f.path("name").asText(),
+                    f.path("size").asLong(),
+                    f.path("mime_type").asText("")));
+        }
+
+        return new PostView(id, resolveUserName(post.path("user_id").asText()), message,
+                createAt, system, files);
+    }
+
+    /** 실패해도 원본 id로 캐시해, 사용자 조회 실패 하나 때문에 목록 전체가 죽지 않게 한다. */
+    private String resolveUserName(String userId) {
+        return userNameCache.computeIfAbsent(userId, id -> {
+            try {
+                JsonNode user = rest.get()
+                        .uri("/api/v4/users/{id}", id)
+                        .retrieve()
+                        .body(JsonNode.class);
+                if (user == null) {
+                    return id;
+                }
+                String nickname = user.path("nickname").asText("");
+                if (!nickname.isBlank()) {
+                    return nickname;
+                }
+                String username = user.path("username").asText("");
+                return username.isBlank() ? id : username;
+            } catch (RestClientException e) {
+                return id;
+            }
+        });
+    }
+
     private Optional<String> idOf(JsonNode node) {
         if (node == null) {
             return Optional.empty();
 
src/test/java/kr/itn/itnhub/feed/ChannelFeedControllerTest.java (added)
+++ src/test/java/kr/itn/itnhub/feed/ChannelFeedControllerTest.java
@@ -0,0 +1,134 @@
+package kr.itn.itnhub.feed;
+
+import kr.itn.itnhub.AbstractDbTest;
+import kr.itn.itnhub.mattermost.MattermostClient;
+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.boot.test.mock.mockito.MockBean;
+import org.springframework.http.HttpHeaders;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
+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 ChannelFeedControllerTest extends AbstractDbTest {
+
+    @Autowired
+    MockMvc mvc;
+
+    @Autowired
+    OrganizationMapper mapper;
+
+    @Autowired
+    JdbcTemplate jdbc;
+
+    @MockBean
+    MattermostClient mattermost;
+
+    private Long orgId;
+
+    @BeforeEach
+    void setUp() {
+        jdbc.update("delete from organization");
+
+        Organization org = new Organization();
+        org.setOrgNo("001");
+        org.setOrgName("국제방송교류재단");
+        org.setChannelSlug("001");
+        mapper.upsertBySeed(org);
+
+        orgId = mapper.findAll().get(0).getId();
+        mapper.updateChannelIdMj(orgId, "chan-mj");
+        mapper.updateChannelIdLaw(orgId, "chan-law");
+    }
+
+    @Test
+    void 게시물_조회는_문정원_채널로_라우팅한다() throws Exception {
+        when(mattermost.getRecentPosts(eq("chan-mj"), eq(60))).thenReturn(List.of(
+                new PostView("p1", "송민지", "안녕하세요", 100L, false, List.of())));
+
+        mvc.perform(get("/api/orgs/{id}/posts", orgId).param("channel", "mj"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$[0].id").value("p1"))
+                .andExpect(jsonPath("$[0].user").value("송민지"))
+                .andExpect(jsonPath("$[0].message").value("안녕하세요"));
+    }
+
+    @Test
+    void 게시물_조회는_law_파라미터면_법률검토_채널로_라우팅한다() throws Exception {
+        when(mattermost.getRecentPosts(eq("chan-law"), eq(60))).thenReturn(List.of(
+                new PostView("p2", "변호사", "검토합니다", 200L, false, List.of())));
+
+        mvc.perform(get("/api/orgs/{id}/posts", orgId).param("channel", "law"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$[0].id").value("p2"));
+    }
+
+    @Test
+    void 자료_조회는_문정원_채널로_라우팅한다() throws Exception {
+        when(mattermost.collectChannelFiles(eq("chan-mj"))).thenReturn(List.of(
+                new FileView("f1", "보고서.pdf", 1234L, "application/pdf", 100L, "송민지")));
+
+        mvc.perform(get("/api/orgs/{id}/files", orgId).param("channel", "mj"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$[0].id").value("f1"))
+                .andExpect(jsonPath("$[0].name").value("보고서.pdf"))
+                .andExpect(jsonPath("$[0].uploader").value("송민지"));
+    }
+
+    @Test
+    void 채널이_아직_생성되지_않았으면_409다() throws Exception {
+        jdbc.update("delete from organization");
+        Organization org = new Organization();
+        org.setOrgNo("002");
+        org.setOrgName("생성전기관");
+        org.setChannelSlug("002");
+        mapper.upsertBySeed(org);
+        Long freshOrgId = mapper.findAll().get(0).getId();
+
+        mvc.perform(get("/api/orgs/{id}/posts", freshOrgId).param("channel", "mj"))
+                .andExpect(status().isConflict())
+                .andExpect(jsonPath("$.message").value("해당 채널이 아직 생성되지 않았습니다."));
+    }
+
+    @Test
+    void 존재하지_않는_기관이면_404다() throws Exception {
+        long missingId = orgId + 999999L;
+
+        mvc.perform(get("/api/orgs/{id}/posts", missingId).param("channel", "mj"))
+                .andExpect(status().isNotFound());
+    }
+
+    @Test
+    void 알수없는_채널_구분이면_409다() throws Exception {
+        mvc.perform(get("/api/orgs/{id}/posts", orgId).param("channel", "etc"))
+                .andExpect(status().isConflict())
+                .andExpect(jsonPath("$.message").value("알 수 없는 채널 구분입니다: etc"));
+    }
+
+    @Test
+    void 파일_다운로드는_한글파일명을_UTF8로_인코딩한_Content_Disposition을_설정한다() throws Exception {
+        when(mattermost.fileInfo(eq("f1")))
+                .thenReturn(new FileRef("f1", "보고서.pdf", 1234L, "application/pdf"));
+        when(mattermost.downloadFile(eq("f1"))).thenReturn("hello".getBytes());
+
+        mvc.perform(get("/api/files/{fileId}", "f1"))
+                .andExpect(status().isOk())
+                .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION,
+                        "attachment; filename*=UTF-8''%EB%B3%B4%EA%B3%A0%EC%84%9C.pdf"));
+    }
+}
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
@@ -2,6 +2,9 @@
 
 import com.github.tomakehurst.wiremock.WireMockServer;
 import kr.itn.itnhub.config.MattermostProperties;
+import kr.itn.itnhub.feed.FileRef;
+import kr.itn.itnhub.feed.FileView;
+import kr.itn.itnhub.feed.PostView;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -9,6 +12,7 @@
 import org.springframework.web.client.RestClient;
 
 import java.net.http.HttpClient;
+import java.util.List;
 
 import static com.github.tomakehurst.wiremock.client.WireMock.*;
 import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options;
@@ -185,4 +189,171 @@
         assertThatThrownBy(() -> client.createPrivateChannel("org-003-mj", "003_기관 (문정원)"))
                 .isInstanceOf(MattermostException.class);
     }
+
+    @Test
+    void 게시글_조회는_최신순_응답을_오래된순으로_뒤집어_돌려준다() {
+        server.stubFor(get(urlPathEqualTo("/api/v4/channels/chan001mj/posts"))
+                .withQueryParam("per_page", equalTo("60"))
+                .willReturn(okJson("""
+                        {
+                          "order": ["p2", "p1"],
+                          "posts": {
+                            "p1": {"id":"p1","user_id":"u1","message":"먼저 씀","create_at":100,"type":""},
+                            "p2": {"id":"p2","user_id":"u1","message":"나중에 씀","create_at":200,"type":""}
+                          }
+                        }
+                        """)));
+        server.stubFor(get(urlPathEqualTo("/api/v4/users/u1"))
+                .willReturn(okJson("{\"username\":\"user1\",\"nickname\":\"\"}")));
+
+        List<PostView> posts = client.getRecentPosts("chan001mj", 60);
+
+        assertThat(posts).extracting(PostView::id).containsExactly("p1", "p2");
+        assertThat(posts).extracting(PostView::message).containsExactly("먼저 씀", "나중에 씀");
+    }
+
+    @Test
+    void 시스템_메시지는_system_플래그가_참이다() {
+        server.stubFor(get(urlPathEqualTo("/api/v4/channels/chan001mj/posts"))
+                .withQueryParam("per_page", equalTo("60"))
+                .willReturn(okJson("""
+                        {
+                          "order": ["p1"],
+                          "posts": {
+                            "p1": {"id":"p1","user_id":"u1","message":"님이 입장했습니다.","create_at":100,"type":"system_join_channel"}
+                          }
+                        }
+                        """)));
+        server.stubFor(get(urlPathEqualTo("/api/v4/users/u1"))
+                .willReturn(okJson("{\"username\":\"user1\"}")));
+
+        List<PostView> posts = client.getRecentPosts("chan001mj", 60);
+
+        assertThat(posts).hasSize(1);
+        assertThat(posts.get(0).system()).isTrue();
+    }
+
+    @Test
+    void 게시글의_첨부파일_메타데이터를_읽는다() {
+        server.stubFor(get(urlPathEqualTo("/api/v4/channels/chan001mj/posts"))
+                .withQueryParam("per_page", equalTo("60"))
+                .willReturn(okJson("""
+                        {
+                          "order": ["p1"],
+                          "posts": {
+                            "p1": {
+                              "id":"p1","user_id":"u1","message":"자료 첨부","create_at":100,"type":"",
+                              "metadata": {
+                                "files": [
+                                  {"id":"f1","name":"보고서.pdf","size":1234,"mime_type":"application/pdf"}
+                                ]
+                              }
+                            }
+                          }
+                        }
+                        """)));
+        server.stubFor(get(urlPathEqualTo("/api/v4/users/u1"))
+                .willReturn(okJson("{\"username\":\"user1\"}")));
+
+        List<PostView> posts = client.getRecentPosts("chan001mj", 60);
+
+        assertThat(posts.get(0).files()).hasSize(1);
+        FileRef file = posts.get(0).files().get(0);
+        assertThat(file.id()).isEqualTo("f1");
+        assertThat(file.name()).isEqualTo("보고서.pdf");
+        assertThat(file.size()).isEqualTo(1234L);
+    }
+
+    @Test
+    void 같은_작성자의_게시글_두개면_사용자조회는_한번만_한다() {
+        server.stubFor(get(urlPathEqualTo("/api/v4/channels/chan001mj/posts"))
+                .withQueryParam("per_page", equalTo("60"))
+                .willReturn(okJson("""
+                        {
+                          "order": ["p2", "p1"],
+                          "posts": {
+                            "p1": {"id":"p1","user_id":"u1","message":"첫번째","create_at":100,"type":""},
+                            "p2": {"id":"p2","user_id":"u1","message":"두번째","create_at":200,"type":""}
+                          }
+                        }
+                        """)));
+        server.stubFor(get(urlPathEqualTo("/api/v4/users/u1"))
+                .willReturn(okJson("{\"username\":\"user1\",\"nickname\":\"유저원\"}")));
+
+        List<PostView> posts = client.getRecentPosts("chan001mj", 60);
+
+        assertThat(posts).extracting(PostView::user).containsExactly("유저원", "유저원");
+        server.verify(1, getRequestedFor(urlPathEqualTo("/api/v4/users/u1")));
+    }
+
+    @Test
+    void 파일목록_조회는_두번째_페이지까지_모아_최신순으로_돌려준다() {
+        server.stubFor(get(urlPathEqualTo("/api/v4/channels/chan001mj/posts"))
+                .withQueryParam("per_page", equalTo("200"))
+                .withQueryParam("page", equalTo("0"))
+                .willReturn(okJson(postsPageJson(200, "p0", true))));
+
+        server.stubFor(get(urlPathEqualTo("/api/v4/channels/chan001mj/posts"))
+                .withQueryParam("per_page", equalTo("200"))
+                .withQueryParam("page", equalTo("1"))
+                .willReturn(okJson(postsPageJson(5, "p1", true))));
+
+        server.stubFor(get(urlPathEqualTo("/api/v4/users/uploader"))
+                .willReturn(okJson("{\"username\":\"uploader\"}")));
+
+        List<FileView> files = client.collectChannelFiles("chan001mj");
+
+        assertThat(files).extracting(FileView::name).containsExactly("p0-file.txt", "p1-file.txt");
+    }
+
+    /**
+     * 채널 게시글 목록 한 페이지를 흉내낸 JSON을 만든다. 마지막 게시글에 첨부파일을 하나 붙여
+     * 페이지네이션과 파일 수집을 동시에 검증할 수 있게 한다.
+     */
+    private static String postsPageJson(int count, String idPrefix, boolean withFile) {
+        StringBuilder order = new StringBuilder("[");
+        StringBuilder posts = new StringBuilder("{");
+        for (int i = 0; i < count; i++) {
+            String id = idPrefix + "-" + i;
+            if (i > 0) {
+                order.append(",");
+                posts.append(",");
+            }
+            order.append("\"").append(id).append("\"");
+            posts.append("\"").append(id).append("\":{\"id\":\"").append(id)
+                    .append("\",\"user_id\":\"uploader\",\"message\":\"\",\"create_at\":1,\"type\":\"\"");
+            if (withFile && i == count - 1) {
+                posts.append(",\"metadata\":{\"files\":[{\"id\":\"").append(id)
+                        .append("-file\",\"name\":\"").append(idPrefix)
+                        .append("-file.txt\",\"size\":10,\"mime_type\":\"text/plain\"}]}");
+            }
+            posts.append("}");
+        }
+        order.append("]");
+        posts.append("}");
+        return "{\"order\":" + order + ",\"posts\":" + posts + "}";
+    }
+
+    @Test
+    void 파일을_다운로드하면_바이트를_그대로_돌려준다() {
+        server.stubFor(get(urlEqualTo("/api/v4/files/f1"))
+                .willReturn(aResponse().withStatus(200).withBody("binary-content")));
+
+        byte[] bytes = client.downloadFile("f1");
+
+        assertThat(new String(bytes)).isEqualTo("binary-content");
+    }
+
+    @Test
+    void 파일_정보를_조회하면_이름과_크기와_MIME타입을_돌려준다() {
+        server.stubFor(get(urlEqualTo("/api/v4/files/f1/info"))
+                .willReturn(okJson("{\"name\":\"보고서.pdf\",\"size\":1234,\"mime_type\":\"application/pdf\"}")));
+
+        FileRef info = client.fileInfo("f1");
+
+        assertThat(info.id()).isEqualTo("f1");
+        assertThat(info.name()).isEqualTo("보고서.pdf");
+        assertThat(info.size()).isEqualTo(1234L);
+        assertThat(info.mimeType()).isEqualTo("application/pdf");
+    }
 }
Add a comment
List