채팅 메시지 조회 API 성능 63% 개선하기: N+1 문제 해결

·2025년 12월 3일

troubleshooting

목록 보기
9/9

채팅 메시지 목록을 조회하는 API에서 성능 이슈가 발생했습니다. 20개의 메시지를 조회할 때마다 각 메시지의 읽지 않은 사용자 수를 계산하는 과정에서 N+1 쿼리 문제가 발생하고 있었습니다.


1. 기존 코드 (V1)

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);

  • 문제점
    • 메시지 20개 조회 시 21번의 쿼리 실행
      • 1번: 메시지 목록 조회
      • 20번: 각 메시지의 unread count 조회
    • 응답 시간이 메시지 개수에 비례하여 증가

쿼리로 직접 확인해본 결과 select 문이 약 38개가 발생했습니다.


2. 개선된 코드 (V2)

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개가 발생하는 걸 확인할 수 있었습니다.

3. 성능 개선 결과 (Jmeter)

Statistics 테이블 분석

지표V1V2개선 효과
Average786.76ms292.04ms62.9% 감소
Median732ms267ms63.5% 감소
90th pct1077ms421ms60.9% 감소
95th pct1213.90ms473.95ms60.9% 감소
99th pct1518.79ms713.74ms53.0% 감소
Throughput17.46 req/s19.20 req/s10.0% 증가

N+1 문제 해결로 평균 응답 시간은 약 63% 개선되었습니다.

Response Times Over Time 그래프

  • V1 (노란선): 약 760~830ms 범위에서 안정적이지만 높은 응답 시간
  • V2 (파란선): 약 250~300ms 범위에서 매우 안정적이고 낮은 응답 시간
  • 두 버전 모두 안정적인 성능을 보이지만, V2가 현저히 빠릅니다.

Active Threads Over Time 그래프

  • 두 버전 모두 동일한 스레드 패턴 (10~21 threads)
  • 동일한 부하 조건에서 테스트되었음을 확인
  • V2가 더 적은 스레드로도 높은 처리량 달성

0개의 댓글