채팅 메시지 목록을 조회하는 API에서 성능 이슈가 발생했습니다. 20개의 메시지를 조회할 때마다 각 메시지의 읽지 않은 사용자 수를 계산하는 과정에서 N+1 쿼리 문제가 발생하고 있었습니다.
public ChatPageResDto<ChatMessageResDto> getMessages(Long roomId, Long userId, int page) {
Pageable pageable = PageRequest.of(page, 20, Sort.by("createdAt").descending());
boolean hasAccess = chatRoomUserRepository.findByChatRoomIdAndUserId(roomId, userId).isPresent();
if (!hasAccess) {
throw new ChatException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED);
}
Page<ChatMessageResDto> messages = chatMessageRepository.findByChatRoomId(roomId, pageable)
.map((ChatMessage message) -> {
// 문제: 각 메시지마다 쿼리 실행
Long unreadCount = chatRoomUserRepository.countUnreadUsers(roomId, message.getId());
return ChatMessageResDto.from(message, unreadCount);
});
return ChatPageResDto.from(messages);
}
@Query("SELECT cm FROM ChatMessage cm WHERE cm.chatRoom.id = :roomId ORDER BY cm.createdAt DESC")
Page<ChatMessage> findByChatRoomId(@Param("roomId") Long roomId, Pageable pageable);
@Query("SELECT COUNT(cru) FROM ChatRoomUser cru " +
"WHERE cru.chatRoom.id = :roomId " +
"AND (cru.lastReadMessageId IS NULL OR cru.lastReadMessageId < :messageId)")
Long countUnreadUsers(@Param("roomId") Long roomId, @Param("messageId") Long messageId);

쿼리로 직접 확인해본 결과 select 문이 약 38개가 발생했습니다.
Service Layer - fetch join 및 IN 절 기반 벌크 집계 쿼리
public ChatPageResDto<ChatMessageResDto> getMessagesV2(Long roomId, Long userId, int page) {
Pageable pageable = PageRequest.of(page, 20, Sort.by("createdAt").descending());
boolean hasAccess = chatRoomUserRepository.findByChatRoomIdAndUserId(roomId, userId).isPresent();
if (!hasAccess) {
throw new ChatException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED);
}
// Fetch Join으로 sender 정보도 한 번에 조회
Page<ChatMessage> messages = chatMessageRepository.findByChatRoomIdWithSender(roomId, pageable);
// 모든 메시지 ID 수집
List<Long> messageIds = messages.getContent().stream()
.map(ChatMessage::getId)
.toList();
// 개선: 한 번의 쿼리로 모든 unread count 조회
final Map<Long, Long> unreadCountMap;
if (!messageIds.isEmpty()) {
unreadCountMap = chatRoomUserRepository.countUnreadUsersBatch(roomId, messageIds)
.stream()
.collect(Collectors.toMap(
row -> (Long) row[0], // message ID
row -> (Long) row[1] // unread count
));
} else {
unreadCountMap = Map.of();
}
// DTO 매핑
Page<ChatMessageResDto> dtoPage = messages.map(msg ->
ChatMessageResDto.from(msg, unreadCountMap.getOrDefault(msg.getId(), 0L))
);
return ChatPageResDto.from(dtoPage);
}
// Fetch Join으로 N+1 문제 추가 방지
@Query("SELECT cm FROM ChatMessage cm " +
"JOIN FETCH cm.sender " +
"WHERE cm.chatRoom.id = :roomId " +
"ORDER BY cm.createdAt DESC")
Page<ChatMessage> findByChatRoomIdWithSender(@Param("roomId") Long roomId, Pageable pageable);
// Native Query로 벌크 집계 쿼리
@Query(value = "SELECT cm.id, COUNT(cru.id) FROM chat_messages cm " +
"LEFT JOIN chat_room_users cru " +
"ON cru.chat_room_id = :roomId " +
"AND (cru.last_read_message_id IS NULL OR cru.last_read_message_id < cm.id) " +
"WHERE cm.id IN (:messageIds) GROUP BY cm.id",
nativeQuery = true)
List<Object[]> countUnreadUsersBatch(
@Param("roomId") Long roomId,
@Param("messageIds") List<Long> messageIds);

쿼리로 직접 확인해본 결과 select 문이 4개가 발생하는 걸 확인할 수 있었습니다.

| 지표 | V1 | V2 | 개선 효과 |
|---|---|---|---|
| Average | 786.76ms | 292.04ms | 62.9% 감소 |
| Median | 732ms | 267ms | 63.5% 감소 |
| 90th pct | 1077ms | 421ms | 60.9% 감소 |
| 95th pct | 1213.90ms | 473.95ms | 60.9% 감소 |
| 99th pct | 1518.79ms | 713.74ms | 53.0% 감소 |
| Throughput | 17.46 req/s | 19.20 req/s | 10.0% 증가 |
N+1 문제 해결로 평균 응답 시간은 약 63% 개선되었습니다.

