[숭팔이] 채팅방 목록 조회 성능 개선과정 : N+1부터 Redis 캐시까지

·2025년 11월 30일

troubleshooting

목록 보기
10/11

숭팔이 프로젝트에서 채팅방 목록을 조회하는 API가 있습니다.
해당 API는 사용자가 참여 중인 채팅방 목록을 불러올 때, 각 채팅방의 마지막 메시지와 게시글 정보를 함께 반환합니다.

기능 자체는 단순해 보이지만, 막상 구현하다 보면 "이 쿼리, 정말 괜찮은 걸까?"라는 의문이 생기기 시작했고,
이 글은 V1(N+1) → V2(상관 서브쿼리) → V3(MAX(id) JOIN + 인덱스) → V4(Redis 캐시)까지 네 가지 구현을 직접 측정한 과정을 담은 글입니다.

측정 환경

  • 채팅방 105개 (userId=40 참여 기준), 총 메시지 109,500개
  • JMeter 5.6.3, 스레드 1개, 버전별 단독 순차 실행, 30초

도메인 구조

채팅방 목록 API는 한 번의 요청에 여러 도메인을 조합해야 한다.

  • ChatRoom: 채팅방 기본 정보
  • ChatRoomUser: 채팅방 참여자 (ChatRoom과 User 사이의 연결 테이블)
  • ChatMessage: 채팅 메시지 (마지막 메시지만 필요)
  • Board: 채팅방이 속한 게시글 (제목, 작성자 닉네임 등)

응답에 필요한 필드를 채우려면 최소 4개 테이블을 참조해야 한다.

V1: 가장 직관적인 구현, 그리고 N+1

처음 구현은 단순했다. 채팅방 목록을 가져오고, 각 방마다 마지막 메시지를 개별 조회했다.

public List<ChatRoomResDto> getChatRoomsByUserV1(Long userId) {
    // 1) 유저가 참여 중인 채팅방 목록 조회 (쿼리 1번)
    List<ChatRoom> chatRooms = chatRoomRepository.findChatRoomsByUserId(userId);
    return chatRooms.stream()
            // 2) 채팅방마다 게시글 조회 (채팅방 수 N번 → 쿼리 N번)
            .map(c -> boardRepository.findById(c.getBoardId())
            		
                    .map(findBoard -> {
                        List<ChatRoomUserResDto> users = c.getChatRoomUsers().stream()
                                .map(ChatRoomUserResDto::from)
                                .toList();
                        // 3) 채팅방마다 마지막 메시지 개별 조회 → 또 N번 (총 쿼리: 1 + N + N)
                        ChatMessage lastMessage = chatMessageRepository.findLastMessageByRoomId(c.getId()).orElse(null);
                        String lastContent = lastMessage != null ? lastMessage.getContent() : null;
                        // 마지막 메시지가 없으면 채팅방 생성 시각을 대신 사용
                        LocalDateTime lastCreatedAt = lastMessage != null ? lastMessage.getCreatedAt() : c.getCreatedAt();
                        // 중고 거래 게시판이면 게시글 작성자 닉네임, 그 외엔 게시글 제목을 채팅방 이름으로 사용
                        if (findBoard.getCategory() == BoardCategory.USED) {
                            return ChatRoomResDto.of(c, findBoard.getUser().getNickName(), ...);
                        }
                        return ChatRoomResDto.of(c, findBoard.getTitle(), ...);
                    })
            )
            // 게시글이 없는 채팅방(boardId가 유효하지 않은 경우)은 결과에서 제외
            .flatMap(Optional::stream)
            .toList();
}

findLastMessageByRoomId가 채팅방 수만큼 실행된다. 채팅방이 105개면 마지막 메시지 조회 쿼리도 105번 발생한다.

// 특정 채팅방의 가장 최근 메시지 1개를 created_at 내림차순으로 조회하는 의도를 보여주기 위한 코드
// 채팅방 수(N)만큼 이 메서드가 반복 호출되는 것이 N+1 문제의 원인
@Query("SELECT cm FROM ChatMessage cm WHERE cm.chatRoom.id = :roomId ORDER BY cm.createdAt DESC LIMIT 1")
Optional<ChatMessage> findLastMessageByRoomId(@Param("roomId") Long roomId);

측정 결과

SamplesAvgMedianMinMaxp95p99Throughput
1,14825.9 ms22 ms20 ms317 ms40 ms86 ms38.4 req/s

채팅방 수에 비례해 쿼리 수가 증가하므로 처리량이 제한된다.

V2: 배치 쿼리로 N+1 제거 시도

N+1을 없애려면 모든 채팅방의 마지막 메시지를 한 번에 가져오면 된다. IN 절과 상관 서브쿼리를 조합했다.

-- 대상 채팅방 메시지만 필터링 (IN 절)
SELECT cm.chat_room_id AS roomId,
       cm.content      AS content,
       cm.created_at   AS createdAt
FROM chat_messages cm
WHERE cm.chat_room_id IN (:roomIds)
  -- 상관 서브쿼리: 외부 쿼리의 각 행을 참조해 해당 채팅방의 MAX(created_at)을 구한다
  AND cm.created_at = (
      SELECT MAX(cm2.created_at)
      FROM chat_messages cm2
      WHERE cm2.chat_room_id = cm.chat_room_id  -- 외부 쿼리 행을 참조 → 상관 서브쿼리
  )

쿼리 수는 1번으로 줄었다. 그런데 실제로 돌려보니 이게 훨씬 더 느렸다.

측정 결과

SamplesAvgThroughput비고
측정 불가~15,000 ms/req~0.07 req/s채팅방 3개 기준 직접 실행 시 15.7초

30초 동안 요청을 거의 처리하지 못했다. 채팅방 3개짜리 쿼리를 DB에 직접 실행했을 때도 15.7초가 걸렸다.

왜 이렇게 느렸나

chat_messages 테이블에 (chat_room_id, created_at) 복합 인덱스가 없었다. 이 실행 계획에서는 MAX(created_at)를 계산하는 과정에서 많은 범위를 반복적으로 스캔하는 형태가 선택되었다. 쿼리 수를 1번으로 줄였지만, 그 1번이 N번보다 훨씬 비쌌다.

상관 서브쿼리라고 해서 항상 외부 행마다 실행되는 것은 아니다. 옵티마이저가 세미 조인, 구체화 등으로 재작성해 훨씬 효율적으로 처리하는 경우도 많다. 이번에 느렸던 원인은 "상관 서브쿼리라서"가 아니라, 이 실행 계획에서 적절한 인덱스가 없어 반복 스캔이 발생했기 때문이다.

또한 이 쿼리는 created_at이 동일한 메시지가 여러 개 존재할 경우 중복 행을 반환할 수 있다는 한계도 있다. 같은 타임스탬프로 저장된 메시지가 있으면 cm.created_at = MAX(...) 조건을 여러 행이 동시에 만족하기 때문이다. V3에서 MAX(id) 기준으로 바꾼 이유 중 하나도 이 문제를 피하기 위해서다.

V3: MAX(id) JOIN + 복합 인덱스

원인을 파악하고 두 가지를 동시에 고쳤다.

쿼리 변경: created_at 기반 상관 서브쿼리 대신, auto-increment id를 기준으로 채팅방별 마지막 메시지 ID를 구한 뒤 JOIN하는 방식으로 바꿨다. 현재 프로젝트에서는 메시지가 순차적으로 저장되고 auto increment PK를 사용하므로, 마지막 메시지를 MAX(id)로 판단할 수 있다.

다만 auto-increment 값이 항상 커밋 순서와 정확히 일치하는 것은 아니다. 동시 트랜잭션 환경에서는 먼저 id를 할당받은 트랜잭션이 나중에 커밋될 수도 있다. 현재 서비스에서는 마지막 메시지를 커밋 순서가 아닌, 가장 마지막으로 발급된 메시지 ID 기준으로 판단해도 비즈니스 요구사항에 문제가 없었다.
동일 시각 중복 문제도 id 기준이라 발생하지 않는다.

인덱스 추가: (chat_room_id, id) 복합 인덱스를 추가해, GROUP BY chat_room_id + MAX(id) 조합을 효율적으로 처리할 수 있도록 했다.

-- (chat_room_id, id) 복합 인덱스: GROUP BY chat_room_id + MAX(id) 조합을
CREATE INDEX idx_chat_messages_room_id ON chat_messages (chat_room_id, id);
SELECT cm.chat_room_id AS roomId,
       cm.content      AS content,
       cm.created_at   AS createdAt
FROM chat_messages cm
JOIN (
    -- 서브쿼리: 채팅방별로 가장 큰 id(= 가장 최근 메시지 id)를 한 번에 구한다
    -- (chat_room_id, id) 인덱스를 활용해 효율적으로 처리된다
    SELECT chat_room_id, MAX(id) AS last_message_id
    FROM chat_messages
    WHERE chat_room_id IN (:roomIds)   -- 대상 채팅방만 필터링
    GROUP BY chat_room_id
) latest ON cm.id = latest.last_message_id  -- id로 JOIN → PK(id) 조회

MAX(id)를 빠르게 구할 수 있는 이유는 id가 PK라서가 아니라 (chat_room_id, id) 복합 인덱스를 추가했기 때문이다. PK(id) 인덱스 하나만으로는 chat_room_id 기준 GROUP BY를 빠르게 처리할 수 없다. 엄밀히는 두 단계로 동작한다

먼저 (chat_room_id, id) 인덱스로 채팅방별 MAX(id)를 구하고, 그다음 그 id로 원본 행을 찾는 PK 조회를 한 번 더 거친다.

서비스 레이어는 채팅방 목록을 먼저 가져온 뒤, 마지막 메시지와 게시글 정보를 각각 한 번씩 일괄 조회한다. 채팅방 목록 조회, 마지막 메시지 일괄 조회, 게시글 일괄 조회까지 총 3번의 쿼리가 발생하며, 채팅방 수가 늘어도 이 3번에서 늘어나지 않는다.

public List<ChatRoomResDto> getChatRoomsByUser(Long userId) {
    // 1) 유저가 참여 중인 채팅방 목록 조회 (쿼리 1번)
    List<ChatRoom> chatRooms = chatRoomRepository.findChatRoomsByUserId(userId);
    // 2) 채팅방 id 목록 추출 → 마지막 메시지 일괄 조회 (쿼리 1번, IN 절 + MAX(id) JOIN)
    List<Long> roomIds = chatRooms.stream().map(ChatRoom::getId).toList();
    Map<Long, LastMessageDto> lastMessageMap = chatRoomRepository.findLastMessagesByRoomIds(roomIds)
            .stream()
            .map(this::toLastMessageDto)
            // roomId를 키로 Map에 담아 O(1) 접근
            .collect(Collectors.toMap(LastMessageDto::roomId, m -> m));
    // 3) 게시글 id 목록 추출 → 게시글 일괄 조회 (쿼리 1번, IN 절)
    List<Long> boardIds = chatRooms.stream().map(ChatRoom::getBoardId).toList();
    Map<Long, Board> boardMap = boardRepository.findAllById(boardIds)
            .stream()
            // boardId를 키로 Map에 담아 O(1) 접근
            .collect(Collectors.toMap(Board::getId, b -> b));
    // 4) 위에서 가져온 Map을 조합해 응답 생성 (DB 추가 조회 없음)
    return chatRooms.stream()
            .map(c -> Optional.ofNullable(boardMap.get(c.getBoardId()))
                    .map(findBoard -> {
                        LastMessageDto lastMessage = lastMessageMap.get(c.getId());
                        ...
                    })
            )
            // 게시글이 없는 채팅방은 결과에서 제외
            .flatMap(Optional::stream)
            .toList();
}

측정 결과

SamplesAvgMedianMinMaxp95p99Throughput
1,41720.9 ms18 ms9 ms404 ms39 ms80 ms47.3 req/s

V1(38.4 req/s) 대비 약 23% 향상됐다. 채팅방 수가 늘어도 쿼리 횟수는 3번으로 고정되므로, 사용자가 참여한 채팅방 수가 늘어날수록 V1과의 성능 차이는 더 커질 수 있다.

V4: Redis 캐시로 DB 부하 줄이기

마지막 메시지는 조회 빈도가 매우 높지만, 실제 변경은 새로운 메시지가 저장될 때만 발생한다. 즉 읽기 요청이 쓰기보다 훨씬 많으므로, 조회할 때마다 매번 DB를 치는 건 낭비다. Redis에 캐싱하면 캐시 히트 시 DB 조회를 생략할 수 있다.

public List<ChatRoomResDto> getChatRoomsByUserV3(Long userId) {
    // 1) 유저가 참여 중인 채팅방 목록 조회 (쿼리 1번)
    List<ChatRoom> chatRooms = chatRoomRepository.findChatRoomsByUserId(userId);
    List<Long> roomIds = chatRooms.stream().map(ChatRoom::getId).toList();
    // 2) Redis 키 목록 생성: "chat:room:{id}:last-message" 형태
    List<String> keys = roomIds.stream()
            .map(id -> "chat:room:" + id + ":last-message")
            .toList();
    // 3) multiGet: 클라이언트와 Redis 사이의 네트워크 왕복을 1회로 줄여 N개의 키를 한 번에 조회
    //    반환값은 keys와 같은 순서로 정렬된 List. 없는 키는 null로 채워짐
    List<String> cachedValues = redisTemplate.opsForValue().multiGet(keys);
    Map<Long, LastMessageDto> lastMessageMap = new HashMap<>();
    List<Long> missedIds = new ArrayList<>(); // 캐시 미스된 채팅방 id 수집
    for (int i = 0; i < roomIds.size(); i++) {
        String json = cachedValues != null ? cachedValues.get(i) : null;
        if (json != null) {
            try {
                // JSON 역직렬화: Redis에 저장된 문자열 → LastMessageDto
                LastMessageDto dto = objectMapper.readValue(json, LastMessageDto.class);
                lastMessageMap.put(roomIds.get(i), dto); // 캐시 히트
            } catch (Exception ignored) {
                missedIds.add(roomIds.get(i)); // 역직렬화 실패 시 DB 재조회
            }
        } else {
            missedIds.add(roomIds.get(i)); // 캐시 미스
        }
    }
    // 4) 캐시 미스된 채팅방만 DB에서 일괄 조회 후 Redis에 저장 (다음 요청부터 캐시 히트)
    if (!missedIds.isEmpty()) {
        chatRoomRepository.findLastMessagesByRoomIds(missedIds)
                .stream()
                .map(this::toLastMessageDto)
                .forEach(dto -> {
                    lastMessageMap.put(dto.roomId(), dto);
                    try {
                        // 조회 결과를 JSON으로 직렬화해서 Redis에 저장
                        redisTemplate.opsForValue().set(
                                "chat:room:" + dto.roomId() + ":last-message",
                                objectMapper.writeValueAsString(dto)
                        );
                    } catch (Exception e) {
                        // 캐시 저장 실패는 무시 (서비스 응답에는 영향 없음)
                        log.warn("Redis cache write failed for room {}: {}", dto.roomId(), e.getMessage());
                    }
                });
    }
    ...
}

multiGet으로 Redis에서 모든 키를 한 번에 가져오고, 없는 것(캐시 미스)만 DB에서 조회한 뒤 Redis에 저장한다. 이후 같은 채팅방에 대한 요청은 캐시 히트가 유지되는 한 DB를 거치지 않는다.

위 코드에는 TTL을 설정하지 않았다. 마지막 메시지는 최신 상태가 중요하므로, TTL로 만료시키기보다 메시지 저장 직후 애플리케이션에서 Redis를 함께 갱신하는 방식을 쓸 예정이다.

측정 결과

구분SamplesAvgMedianMinMaxp95p99Throughput
cold (캐시 미스)2,24113.0 ms7 ms5 ms938 ms35 ms85 ms74.9 req/s
warm (캐시 히트)3,2908.9 ms7 ms5 ms66 ms19 ms38 ms110.0 req/s

cold는 캐시가 비어있는 첫 번째 상태로, Redis 미스가 발생해 DB(V3 쿼리)로 폴백한다. warm은 캐시가 채워진 이후로, 마지막 메시지 조회를 DB에서 하지 않는다.

warm 기준 throughput 110.0 req/s는 V1 대비 약 2.9배, V3 대비 약 2.3배다.

전체 결과 정리

VersionAvgp95Throughput비고
V1 N+125.9 ms40 ms38.4 req/s채팅방 수만큼 쿼리 발생
V2 상관 서브쿼리~15,000 ms-~0.07 req/s인덱스 없는 실행 계획, 측정 불가 수준
V3 MAX(id) JOIN + index20.9 ms39 ms47.3 req/sV1 대비 23% 향상
V4 Redis cold13.0 ms35 ms74.9 req/s첫 요청 시 DB 폴백 포함
V4 Redis warm8.9 ms19 ms110.0 req/sDB 미접근, V1 대비 2.9배

배운 것

쿼리 수보다 각 쿼리의 실행 계획이 더 중요하다. V2에서 N+1을 1번으로 줄였지만 오히려 V1보다 약 580배 느렸다. 상관 서브쿼리 자체가 문제라기보다, 이번 실행 계획에서 (chat_room_id, created_at) 인덱스가 없어 외부 쿼리 행마다 반복적인 테이블 스캔이 발생한 게 원인이다. 옵티마이저가 상관 서브쿼리를 세미 조인 등으로 재작성해 처리하는 경우도 있으므로, "상관 서브쿼리라서 느리다"보다는 "이 실행 계획에서 인덱스를 못 탔다"가 정확한 설명이다. 쿼리 수를 줄이는 것만으로는 충분하지 않다. 실행 계획과 인덱스를 함께 확인해야 실제 성능이 개선된다.

N+1이라도 개별 조회 비용이 매우 작다면, 데이터 규모가 작을 때는 문제가 크게 드러나지 않을 수 있다. V1은 채팅방당 마지막 메시지 조회 쿼리 1회라 105번이어도 평균 25.9 ms다. 다만 이는 채팅방 수가 적을 때의 이야기이고, 수가 늘어날수록 쿼리 횟수에 비례해 선형적으로 느려지는 구조라는 한계는 그대로 남는다.

Redis 캐시는 DB 최적화의 상한선을 넘는다. V3에서 쿼리와 인덱스를 최대한 최적화해도 DB 왕복 비용은 남는다. V4 warm은 캐시 히트 시 그 왕복 자체를 생략해서 V3 대비 2.3배 더 빠르다. 단, 캐시 무효화 전략이 없으면 stale한 데이터를 보여줄 수 있다.

0개의 댓글