diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/api/ChatApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/api/ChatApi.java
new file mode 100644
index 00000000..07b6da16
--- /dev/null
+++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/api/ChatApi.java
@@ -0,0 +1,31 @@
+package kr.co.pennyway.api.apis.chat.api;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.Parameters;
+import io.swagger.v3.oas.annotations.enums.ParameterIn;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.media.SchemaProperty;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import kr.co.pennyway.api.common.response.SliceResponseTemplate;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestParam;
+
+@Tag(name = "[채팅 API]")
+public interface ChatApi {
+ @Operation(summary = "채팅 조회", description = "채팅방의 채팅 이력을 무한스크롤 조회합니다. 결과는 최신순으로 정렬되며, `lastMessageId`를 포함하지 않은 이전 메시지들을 조회합니다.
content는 채팅방 조회의 `recentMessages` 필드와 동일합니다.")
+ @Parameters({
+ @Parameter(name = "chatRoomId", description = "채팅방 ID", required = true, in = ParameterIn.PATH),
+ @Parameter(name = "lastMessageId", description = "마지막으로 읽은 메시지 ID", required = true, in = ParameterIn.QUERY),
+ @Parameter(name = "size", description = "조회할 채팅 수(default: 30)", example = "30", required = false, in = ParameterIn.QUERY)
+ })
+ @ApiResponse(responseCode = "200", description = "채팅 조회 성공", content = @Content(schemaProperties = @SchemaProperty(name = "chats", schema = @Schema(implementation = SliceResponseTemplate.class))))
+ ResponseEntity> readChats(
+ @PathVariable("chatRoomId") Long chatRoomId,
+ @RequestParam(value = "lastMessageId") Long lastMessageId,
+ @RequestParam(value = "size", defaultValue = "30") int size
+ );
+}
diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/controller/ChatController.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/controller/ChatController.java
new file mode 100644
index 00000000..2b773d3d
--- /dev/null
+++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/controller/ChatController.java
@@ -0,0 +1,31 @@
+package kr.co.pennyway.api.apis.chat.controller;
+
+import kr.co.pennyway.api.apis.chat.api.ChatApi;
+import kr.co.pennyway.api.apis.chat.usecase.ChatUseCase;
+import kr.co.pennyway.api.common.response.SuccessResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+@Slf4j
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/v2/chat-rooms/{chatRoomId}/chats")
+public class ChatController implements ChatApi {
+ private static final String CHATS = "chats";
+
+ private final ChatUseCase chatUseCase;
+
+ @Override
+ @GetMapping("")
+ @PreAuthorize("isAuthenticated() and @chatRoomManager.hasPermission(principal.userId, #chatRoomId)")
+ public ResponseEntity> readChats(
+ @PathVariable("chatRoomId") Long chatRoomId,
+ @RequestParam(value = "lastMessageId") Long lastMessageId,
+ @RequestParam(value = "size", defaultValue = "30") int size
+ ) {
+ return ResponseEntity.ok(SuccessResponse.from(CHATS, chatUseCase.readChats(chatRoomId, lastMessageId, size)));
+ }
+}
diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/mapper/ChatMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/mapper/ChatMapper.java
new file mode 100644
index 00000000..b3b5ee90
--- /dev/null
+++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/mapper/ChatMapper.java
@@ -0,0 +1,20 @@
+package kr.co.pennyway.api.apis.chat.mapper;
+
+import kr.co.pennyway.api.apis.chat.dto.ChatRes;
+import kr.co.pennyway.api.common.response.SliceResponseTemplate;
+import kr.co.pennyway.common.annotation.Mapper;
+import kr.co.pennyway.domain.common.redis.message.domain.ChatMessage;
+import org.springframework.data.domain.Slice;
+
+import java.util.List;
+
+@Mapper
+public class ChatMapper {
+ public static SliceResponseTemplate toChatDetails(Slice chatMessages) {
+ List details = chatMessages.getContent().stream()
+ .map(ChatRes.ChatDetail::from)
+ .toList();
+
+ return SliceResponseTemplate.of(details, chatMessages.getPageable(), chatMessages.getNumberOfElements(), chatMessages.hasNext());
+ }
+}
diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatSearchService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatSearchService.java
new file mode 100644
index 00000000..215b63b7
--- /dev/null
+++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/service/ChatSearchService.java
@@ -0,0 +1,19 @@
+package kr.co.pennyway.api.apis.chat.service;
+
+import kr.co.pennyway.domain.common.redis.message.domain.ChatMessage;
+import kr.co.pennyway.domain.common.redis.message.service.ChatMessageService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Slice;
+import org.springframework.stereotype.Service;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ChatSearchService {
+ private final ChatMessageService chatMessageService;
+
+ public Slice readChats(Long chatRoomId, Long lastMessageId, int size) {
+ return chatMessageService.readMessagesBefore(chatRoomId, lastMessageId, size);
+ }
+}
diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/usecase/ChatUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/usecase/ChatUseCase.java
new file mode 100644
index 00000000..70baf630
--- /dev/null
+++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/chat/usecase/ChatUseCase.java
@@ -0,0 +1,24 @@
+package kr.co.pennyway.api.apis.chat.usecase;
+
+import kr.co.pennyway.api.apis.chat.dto.ChatRes;
+import kr.co.pennyway.api.apis.chat.mapper.ChatMapper;
+import kr.co.pennyway.api.apis.chat.service.ChatSearchService;
+import kr.co.pennyway.api.common.response.SliceResponseTemplate;
+import kr.co.pennyway.common.annotation.UseCase;
+import kr.co.pennyway.domain.common.redis.message.domain.ChatMessage;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Slice;
+
+@Slf4j
+@UseCase
+@RequiredArgsConstructor
+public class ChatUseCase {
+ private final ChatSearchService chatSearchService;
+
+ public SliceResponseTemplate readChats(Long chatRoomId, Long lastMessageId, int size) {
+ Slice chatMessages = chatSearchService.readChats(chatRoomId, lastMessageId, size);
+
+ return ChatMapper.toChatDetails(chatMessages);
+ }
+}
diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatPaginationGetIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatPaginationGetIntegrationTest.java
new file mode 100644
index 00000000..0c9b50b0
--- /dev/null
+++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/chat/integration/ChatPaginationGetIntegrationTest.java
@@ -0,0 +1,257 @@
+package kr.co.pennyway.api.apis.chat.integration;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import kr.co.pennyway.api.apis.chat.dto.ChatRes;
+import kr.co.pennyway.api.common.response.SliceResponseTemplate;
+import kr.co.pennyway.api.common.response.SuccessResponse;
+import kr.co.pennyway.api.common.util.ApiTestHelper;
+import kr.co.pennyway.api.common.util.RequestParameters;
+import kr.co.pennyway.api.config.ExternalApiDBTestConfig;
+import kr.co.pennyway.api.config.ExternalApiIntegrationTest;
+import kr.co.pennyway.api.config.fixture.ChatMemberFixture;
+import kr.co.pennyway.api.config.fixture.ChatRoomFixture;
+import kr.co.pennyway.api.config.fixture.UserFixture;
+import kr.co.pennyway.domain.common.redis.message.domain.ChatMessage;
+import kr.co.pennyway.domain.common.redis.message.domain.ChatMessageBuilder;
+import kr.co.pennyway.domain.common.redis.message.repository.ChatMessageRepository;
+import kr.co.pennyway.domain.common.redis.message.type.MessageCategoryType;
+import kr.co.pennyway.domain.common.redis.message.type.MessageContentType;
+import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom;
+import kr.co.pennyway.domain.domains.chatroom.repository.ChatRoomRepository;
+import kr.co.pennyway.domain.domains.member.domain.ChatMember;
+import kr.co.pennyway.domain.domains.member.repository.ChatMemberRepository;
+import kr.co.pennyway.domain.domains.member.type.ChatMemberRole;
+import kr.co.pennyway.domain.domains.user.domain.User;
+import kr.co.pennyway.domain.domains.user.repository.UserRepository;
+import kr.co.pennyway.infra.client.guid.IdGenerator;
+import kr.co.pennyway.infra.common.jwt.JwtProvider;
+import lombok.extern.slf4j.Slf4j;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.fail;
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+@Slf4j
+@ExternalApiIntegrationTest
+public class ChatPaginationGetIntegrationTest extends ExternalApiDBTestConfig {
+ private static final String BASE_URL = "/v2/chat-rooms/{chatRoomId}/chats";
+
+ @Autowired
+ private TestRestTemplate restTemplate;
+
+ @Autowired
+ private UserRepository userRepository;
+
+ @Autowired
+ private ChatRoomRepository chatRoomRepository;
+
+ @Autowired
+ private ChatMemberRepository chatMemberRepository;
+
+ @Autowired
+ private ChatMessageRepository chatMessageRepository;
+
+ @Autowired
+ private JwtProvider accessTokenProvider;
+
+ @Autowired
+ private IdGenerator idGenerator;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @Autowired
+ private RedisTemplate redisTemplate;
+
+ private ApiTestHelper apiTestHelper;
+
+ @BeforeEach
+ void setUp() {
+ apiTestHelper = new ApiTestHelper(restTemplate, objectMapper, accessTokenProvider);
+ }
+
+ @AfterEach
+ void tearDown() {
+ Set keys = redisTemplate.keys("chatroom:*:message");
+ if (keys != null && !keys.isEmpty()) {
+ redisTemplate.delete(keys);
+ }
+ }
+
+ @Test
+ @DisplayName("채팅방의 이전 메시지들을 정상적으로 페이징하여 조회할 수 있다")
+ void successReadChats() {
+ // given
+ User user = createUser();
+ ChatRoom chatRoom = createChatRoom();
+ createChatMember(user, chatRoom, ChatMemberRole.ADMIN);
+ List messages = setupTestMessages(chatRoom.getId(), user.getId(), 50);
+
+ // when
+ ResponseEntity> response = performRequest(user, chatRoom.getId(), messages.get(49).getChatId(), 30);
+
+ // then
+ assertAll(
+ () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
+ () -> {
+ SliceResponseTemplate slice = extractChatDetail(response);
+
+ assertEquals(messages.get(48).getChatId(), slice.contents().get(0).chatId(), "lastMessageId에 해당하는 메시지는 포함되지 않아야 합니다");
+ assertThat(slice.contents()).hasSize(30);
+ assertThat(slice.hasNext()).isTrue();
+ }
+ );
+ }
+
+ @Test
+ @DisplayName("채팅방 멤버가 아닌 사용자는 메시지를 조회할 수 없다")
+ void readChatsWithoutPermissionTest() {
+ // given
+ User nonMember = createUser();
+ ChatRoom chatRoom = createChatRoom();
+
+ // when
+ ResponseEntity> response = performRequest(nonMember, chatRoom.getId(), 0L, 30);
+
+ // then
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 채팅방의 메시지는 조회할 수 없다")
+ void readChatsFromNonExistentRoomTest() {
+ // given
+ User user = createUser();
+ Long nonExistentRoomId = 9999L;
+
+ // when
+ ResponseEntity> response = performRequest(user, nonExistentRoomId, 0L, 30);
+
+ // then
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
+ }
+
+ @Test
+ @DisplayName("마지막 페이지를 조회할 때 hasNext는 false여야 한다")
+ void readLastPageTest() {
+ // given
+ User user = createUser();
+ ChatRoom chatRoom = createChatRoom();
+ createChatMember(user, chatRoom, ChatMemberRole.ADMIN);
+ List messages = setupTestMessages(chatRoom.getId(), user.getId(), 10);
+
+ // when
+ ResponseEntity> response = performRequest(user, chatRoom.getId(), messages.get(0).getChatId(), 10);
+
+ // then
+ assertAll(
+ () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
+ () -> {
+ SliceResponseTemplate slice = extractChatDetail(response);
+
+ assertThat(slice.contents()).hasSize(0);
+ assertThat(slice.hasNext()).isFalse();
+ }
+ );
+ }
+
+ @Test
+ @DisplayName("메시지가 없는 채팅방을 조회하면 빈 리스트를 반환해야 한다")
+ void readEmptyChatsTest() {
+ // given
+ User user = createUser();
+ ChatRoom chatRoom = createChatRoom();
+ createChatMember(user, chatRoom, ChatMemberRole.ADMIN);
+
+ // when
+ ResponseEntity> response = performRequest(user, chatRoom.getId(), Long.MAX_VALUE, 30);
+
+ // then
+ assertAll(
+ () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
+ () -> {
+ SliceResponseTemplate slice = extractChatDetail(response);
+
+ assertThat(slice.contents()).isEmpty();
+ assertThat(slice.hasNext()).isFalse();
+ }
+ );
+ }
+
+ private ResponseEntity> performRequest(User user, Long chatRoomId, Long lastMessageId, int size) {
+ RequestParameters parameters = RequestParameters.defaultGet(BASE_URL)
+ .user(user)
+ .queryParams(RequestParameters.createQueryParams("lastMessageId", lastMessageId, "size", size))
+ .uriVariables(new Object[]{chatRoomId})
+ .build();
+
+ return apiTestHelper.callApi(
+ parameters,
+ new TypeReference>>>() {
+ }
+ );
+ }
+
+ private User createUser() {
+ return userRepository.save(UserFixture.GENERAL_USER.toUser());
+ }
+
+ private ChatRoom createChatRoom() {
+ return chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(idGenerator.generate()));
+ }
+
+ private ChatMember createChatMember(User user, ChatRoom chatRoom, ChatMemberRole role) {
+ return switch (role) {
+ case ADMIN -> chatMemberRepository.save(ChatMemberFixture.ADMIN.toEntity(user, chatRoom));
+ case MEMBER -> chatMemberRepository.save(ChatMemberFixture.MEMBER.toEntity(user, chatRoom));
+ };
+ }
+
+ private List setupTestMessages(Long chatRoomId, Long senderId, int count) {
+ List messages = new ArrayList<>();
+
+ for (int i = 0; i < count; i++) {
+ ChatMessage message = ChatMessageBuilder.builder()
+ .chatRoomId(chatRoomId)
+ .chatId(idGenerator.generate())
+ .content("Test message " + i)
+ .contentType(MessageContentType.TEXT)
+ .categoryType(MessageCategoryType.NORMAL)
+ .sender(senderId)
+ .build();
+
+ messages.add(chatMessageRepository.save(message));
+ }
+
+ return messages;
+ }
+
+ private SliceResponseTemplate extractChatDetail(ResponseEntity> response) {
+ SliceResponseTemplate slice = null;
+
+ try {
+ SuccessResponse