PCG.dev
Blog
RedisMySQLPerformanceBackend

[Redis] 채팅방 목록 조회 API 성능 개선 — 서브쿼리와 GROUP BY에서 Redis 캐싱으로 해결하기

채팅방 목록 조회에서 안읽음 메시지 수를 계산하던 MySQL 쿼리의 병목을 Redis 캐시로 대체해 성능을 크게 개선한 과정을 정리합니다.

·8분 읽기

들어가며

채팅 서비스에서 채팅방 목록을 조회할 때 여러 채팅방의 목록이 나열되고, 각 방마다 빨간 숫자(안읽은 메시지 개수)가 화면에 렌더링 되도록 했습니다.

처음 이 기능을 구현할 때 GROUP BY를 이용한 MySQL 쿼리 하나면 간단하게 해결할 수 있었습니다. 그런데 실제 서비스처럼 동시 요청이 늘어나자 채팅방 4개짜리 유저에게 응답하는 데만 평균 1.6초, 최악의 경우 10초가 넘게 걸리는 심각한 성능 문제가 발생했습니다.

이 글에서는 그 원인을 코드와 SQL 레벨에서 낱낱이 파헤치고, 부하 테스트로 수치를 측정한 뒤, Redis 캐싱으로 성능을 83% 끌어올린 과정을 기록합니다.


1. 문제의 코드

API 흐름

GET /chatRoom/list
  → ChatRoomServiceImpl.getChatRoomList()
    → 1단계: 내가 속한 채팅방 목록 조회 (JPA)
    → 2단계: 각 채팅방의 안읽음 메시지 개수 계산 (GROUP BY 쿼리) ← 여기가 문제

실제 서비스 코드

public List<ChatRoomPreviewDto> getChatRoomList(Long userId, String keyword) {
    List<ChatRoomPreviewDto> chatRoomPreviews = 
        userChatRoomService.findChatRoomPreviewsByUserId(userId, keyword);
 
    List<Long> roomIds = chatRoomPreviews.stream()
        .map(ChatRoomPreviewDto::getRoomId)
        .toList();
 
    Map<Long, Long> unreadMessageCount = 
        messageService.getUnreadMessageCount(roomIds, userId);
 
    chatRoomPreviews.forEach(preview ->
        preview.setUnreadCount(unreadMessageCount.getOrDefault(preview.getRoomId(), 0L))
    );
 
    return chatRoomPreviews;
}

문제의 SQL 쿼리

@Query("""
    SELECT m.chatRoom.id, COUNT(m)
    FROM Messages m
    JOIN m.chatRoom r
    WHERE r.id IN :roomIds
    AND m.id > (
        SELECT COALESCE(ucr.lastReadMessageId, 0)
        FROM UserChatRoom ucr
        WHERE ucr.chatRoom.id = m.chatRoom.id AND ucr.user.id = :userId
    )
    AND m.sender.id <> :userId
    GROUP BY m.chatRoom.id
""")
List<Object[]> countUnreadMessages(List<Long> roomIds, Long userId);

2. 왜 문제인가? — 쿼리 구조 분석

문제 1: 상관 서브쿼리(Correlated Subquery)

이 쿼리에서 가장 치명적인 부분은 WHERE m.id > ( SELECT ... ) 안에 있는 서브쿼리입니다.

이 서브쿼리는 외부 쿼리의 각 행(m)마다 독립적으로 실행됩니다. 즉, 메시지 테이블에 1,000개의 행이 있으면 이 서브쿼리도 최대 1,000번 실행될 수 있습니다. 이를 상관 서브쿼리(Correlated Subquery)라고 하며, 데이터가 많아질수록 기하급수적으로 느려지는 구조입니다.

-- 실행 과정
-- messages 테이블의 모든 행에 대해 아래를 반복 실행
-- (행 1에 대해) SELECT lastReadMessageId FROM user_chat_room WHERE ...
-- (행 2에 대해) SELECT lastReadMessageId FROM user_chat_room WHERE ...
-- (행 3에 대해) SELECT lastReadMessageId FROM user_chat_room WHERE ...
-- ...N번 반복

문제 2: GROUP BY로 인한 전체 테이블 스캔

GROUP BY m.chatRoom.id는 MySQL이 그룹화를 위해 조건에 맞는 모든 메시지 행을 메모리에 올려서 집계해야 합니다.

채팅방 1개당 메시지 1,000개, 채팅방 4개이면 최대 4,000개의 행을 스캔합니다. 데이터가 쌓일수록 이 비용은 계속 증가합니다.


3. 부하 테스트 — k6

테스트 환경

  • 도구: k6 v2.0.0
  • 서버: Spring Boot 3.2.5 / Local 환경 (MySQL 8.0, Redis 7.2)
  • 테스트 시나리오: 채팅방 목록 조회 API에 가상 유저 50명이 25초 동안 지속적으로 요청을 보내는 부하 상황

테스트 결과 (캐싱 도입 전)

Case 1: 채팅방 1개 유저

http_req_duration: avg=39.64ms  p(95)=59.44ms
http_reqs........: 987 req  (38.8 req/s)

채팅방이 1개이고 메시지 수도 적으니 59ms로 빠르게 느껴집니다. 하지만 이건 함정입니다.

Case 2: 채팅방 4개 유저

http_req_duration: avg=1.63s  p(95)=4.63s
http_reqs........: 390 req  (15.1 req/s)

채팅방이 겨우 4개로 늘었을 뿐인데, 95th percentile(p95) 응답 속도가 59ms → 4,630ms로 78배 폭등했습니다.


4. 해결 전략 — Redis로 O(1) 카운터 만들기

핵심 아이디어

안읽음 메시지 개수를 메시지가 쌓일 때마다 미리 계산해서 Redis에 보관해두면, 목록 조회 시 DB를 전혀 건드리지 않아도 됩니다.

기존: 목록 조회 요청이 올 때마다 → MySQL GROUP BY로 실시간 계산
개선: 메시지가 오는 순간 → Redis 카운터 +1 보관 / 목록 조회 시 → Redis에서 꺼내기

Redis 키 설계

chat:unread:{roomId}:{userId}
예시) chat:unread:42:7 → 채팅방 42번에서 유저 7번이 읽지 않은 메시지 수

이벤트별 Redis 연산

이벤트Redis 연산설명
새 메시지 수신INCR chat:unread:{roomId}:{receiverId}원자적 1 증가
채팅방 입장 / 읽음 처리SET chat:unread:{roomId}:{userId} 0카운터 초기화
채팅방 목록 조회MGET chat:unread:{roomId1}...모든 방 카운트 한 번에 조회

각 레이어의 적용

1. UnreadCountCacheService (Redis 전담 서비스)

public Map<Long, Long> getUnreadCounts(List<Long> roomIds, Long userId) {
    List<String> keys = roomIds.stream()
        .map(roomId -> generateKey(roomId, userId))
        .collect(Collectors.toList());
 
    List<Object> values = redisTemplate.opsForValue().multiGet(keys);
    // ... 값 매핑 후 반환
}

2. ChatFacade (메시지 수신 시 카운트 +1)

unreadCountCacheService.incrementUnreadCount(chatRoom.getId(), otherUserId);

3. MessageServiceImpl (읽음 처리 및 목록 조회)

unreadCountCacheService.resetUnreadCount(roomId, userId);
 
public Map<Long, Long> getUnreadMessageCount(List<Long> roomIds, Long userId) {
    return unreadCountCacheService.getUnreadCounts(roomIds, userId);
}

마지막으로, 병목을 일으켰던 MessageRepositoryCOUNT(m) ... GROUP BY 쿼리는 삭제했습니다.


5. 개선 후 실제 성능 측정 결과

코드 적용 후, 이전과 동일한 조건으로 k6 부하 테스트를 다시 실행했습니다.

성능 비교

지표개선 전 (MySQL)개선 후 (Redis)변화량
p(95) 응답 속도4.63초 (4,630ms)0.76초 (766ms)83.4% 단축
평균 응답 속도1.63초 (1,630ms)0.21초 (212ms)87.0% 단축
최대 응답 속도10.6초1.76초83.3% 단축
TPS15.1 req/s32.8 req/s2.1배 증가

마치며

처음 구현했던 GROUP BY 쿼리는 정확하고 구현도 쉬웠습니다. 하지만 동시 요청과 데이터가 늘어나는 순간 병목이 드러났고, 이를 k6 부하 테스트로 증명하고 개선해 나가는 과정이 매우 흥미로웠습니다.

Redis의 INCR/MGET는 명령어 자체는 단순하지만, 이 단순함이 복잡한 SQL을 O(1)로 대체하는 강력한 무기임을 배웠습니다.

다음 과제: 최근 메시지 내역 조회를 Redis List로 캐싱하여, 채팅방 입장 시 DB를 거치지 않도록 하기