Redis ZSET 기반 채팅 메시지 캐싱

임동혁 Ldhbenecia·2025년 11월 27일

SpringBoot

목록 보기
27/28
post-thumbnail

개요

예전 작업하다가 임시 중단했던 Sent Project를 보수하기로 했다.
채팅에 대해서 K6를 사용해서 부하테스트를 했었는데, 페이지네이션 + 인덱스 적용에 이어 Redis 캐싱을 적용해보자.


ZSET을 활용한 메시지 저장 로직 구현 (Write-Through)

메시지 저장 과정에서 MongoDB + Redis ZSET에 동시에 쓰는 방식(Write-Through)을 구현한다.
MongoDB는 영속 저장소라고 가정하고, Redis는 빠른 조회를 위한 캐시로 사용한다.

DB 백업을 담당하는 ChatMessageWriter (혹은 ChatMessageService)를 수정하여 DB 쓰기와 Redis 쓰기를 동시에 수행하도록 구현한다.


ZSET이란?

  • ZSET (Sorted Set)은 Redis의 복합 데이터 타입 중 하나로, 기본적으로 Set(집합)의 특징과 정렬 기능을 결합한 구조이다.
특징설명
Set (집합)요소(Member)는 중복될 수 없음 (현재 채팅 메시지의 id가 Member가 됨)
Sorted (정렬)모든 Member는 Score라는 실수 값과 연결되며, 이 Score를 기준으로 데이터가 정렬되어 저장됨

왜 ZSET을 채팅 메시지 캐싱에 사용하는가?

채팅 시스템에서 이전 메시지 조회는 단순히 데이터를 가져오는 것을 넘어, "특정 시점 이전의 메시지를 시간 순서대로 N개 가져오기"라는 복잡한 요구사항을 가진다. (페이지네이션)

ZSET은 이러한 요구사항을 데이터베이스(MongoDB)보다 훨씬 빠르게 처리할 수 있게 해준다.

ZSET의 특징채팅 시스템에서의 적용
시간 기반 정렬 (Score)ChatMessagetimestampScore로 사용한다. Redis는 Score를 기준으로 메시지를 자동 정렬하므로, DB에서 ORDER BY timestamp를 할 필요가 없다.
범위 조회 (Range Query)채팅 메시지를 불러올 때 lastMessageTimestamp를 기준으로 한다. ZSET은 Score의 범위를 지정하여 조회하는 기능을 제공한다. (ZREVRANGEBYSCORE)
페이지네이션 (Pagination)LIMIT 기능을 내장하고 있어, 대용량 데이터에서 N개만 잘라오는 연산을 O(log N + K)의 낮은 복잡도로 수행한다. (DB의 인덱스 검색보다 훨씬 빠름)

서비스 계층 통합 및 Write-Through 구현

fun saveToZSet(message: ChatMessage) {
        val key = chatCacheKeyPrefix + message.roomId
        val score = message.timestamp.toDouble()
        val value = message

        redisTemplate.opsForZSet().add(key, value, score)

        // 캐시 크기 관리: 오래된 메시지 제거 (0부터 -5001까지 제거)
        // 가장 최근 5000개만 남김
        val start: Long = 0L
        val end: Long = -(maxMessagesInCache + 1).toLong()
        redisTemplate.opsForZSet().removeRange(key, start, end)
    }

--------------------------------------------------------------------------

fun backup(message: ChatMessage): ChatMessage {
        val savedMessage = chatMessageRepository.save(message)
        logger.info("MongoDB에 메시지 백업 완료. ID: {}", savedMessage.id)

        try {
            chatCacheRepository.saveToZSet(savedMessage)
            logger.info("Redis ZSET에 메시지 캐싱 완료. Room ID: {}", savedMessage.roomId)
        } catch (e: Exception) {
            logger.error("Redis 캐시 업데이트 실패. Message ID: {}", savedMessage.id, e)
        }

        return savedMessage
    }


Redis Test구문을 날려보았다.
글을 작성하면서 보이게 되었는데 이름 밑에 가장 마지막으로 온 메시지도 동기화가 안되는 것 같아 봐봐야겠다.
쿼리문제인지 html문제인지,,


정상적으로 등록되는 것을 확인할 수 있다.


Hibernate: 
    select
        ue1_0.id,
        ue1_0.created_at,
        ue1_0.display_name,
        ue1_0.email,
        ue1_0.profile_image_url,
        ue1_0.provider,
        ue1_0.updated_at 
    from
        user ue1_0 
    where
        ue1_0.provider=? 
        and ue1_0.email=?
Hibernate: 
    select
        ue1_0.id,
        ue1_0.created_at,
        ue1_0.display_name,
        ue1_0.email,
        ue1_0.profile_image_url,
        ue1_0.provider,
        ue1_0.updated_at 
    from
        user ue1_0 
    where
        ue1_0.id=?
17:33:17.958| INFO|                                ,                |o.s.w.s.c.WebSocketMessageBrokerStats   |WebSocketSession[0 current WS(0)-HttpStream(0)-HttpPoll(0), 1 total, 0 closed abnormally (0 connect failure, 0 send limit, 0 transport error)], stompSubProtocol[processed CONNECT(1)-CONNECTED(0)-DISCONNECT(0)], stompBrokerRelay[1 sessions, ReactorNettyTcpClient[reactor.netty.tcp.TcpClientConnect@9f36eeb] (available), processed CONNECT(1)-CONNECTED(1)-DISCONNECT(0)], inboundChannel[pool size = 3, active threads = 0, queued tasks = 0, completed tasks = 3], outboundChannel[pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 0], sockJsScheduler[pool size = 10, active threads = 1, queued tasks = 1, completed tasks = 9]
Hibernate: 
    select
        ue1_0.id,
        ue1_0.created_at,
        ue1_0.display_name,
        ue1_0.email,
        ue1_0.profile_image_url,
        ue1_0.provider,
        ue1_0.updated_at 
    from
        user ue1_0 
    where
        ue1_0.id=?
Hibernate: 
    select
        ue1_0.id,
        ue1_0.created_at,
        ue1_0.display_name,
        ue1_0.email,
        ue1_0.profile_image_url,
        ue1_0.provider,
        ue1_0.updated_at 
    from
        user ue1_0 
    where
        ue1_0.id=?
Hibernate: 
    select
        cre1_0.id,
        cre1_0.last_message,
        cre1_0.last_message_time,
        ue1_0.id,
        ue1_0.display_name,
        ue1_0.profile_image_url 
    from
        chat_room cre1_0 
    join
        chat_room_user crue1_0 
            on cre1_0.id=crue1_0.room_id 
            and crue1_0.user_id=? 
            and crue1_0.visible=1 
    join
        chat_room_user crue2_0 
            on cre1_0.id=crue2_0.room_id 
            and crue2_0.user_id<>? 
    join
        user ue1_0 
            on crue2_0.user_id=ue1_0.id 
    order by
        cre1_0.last_message_time desc
Hibernate: 
    select
        ue1_0.id,
        ue1_0.created_at,
        ue1_0.display_name,
        ue1_0.email,
        ue1_0.profile_image_url,
        ue1_0.provider,
        ue1_0.updated_at 
    from
        user ue1_0 
    where
        ue1_0.id=?
17:33:48.912| INFO|                                ,                |c.b.l.c.c.controller.ChatController     |Sending message from user: 8fe4708b-4c4d-404d-92aa-614ecbe705cd to opponent: fd208f4e-a8da-48f5-b4a5-e1db91337802
Hibernate: 
    select
        crue1_0.room_id 
    from
        chat_room_user crue1_0 
    where
        crue1_0.user_id in (?, ?) 
        and crue1_0.visible=1 
    group by
        crue1_0.room_id 
    having
        count(crue1_0.user_id)=2
17:33:48.934| INFO|                                ,                |c.b.l.c.c.controller.ChatController     |Found or created chat room. Room ID: 2
17:33:48.942| INFO|                                ,                |c.b.l.c.c.controller.ChatController     |Message sent and backed up for room ID: 2
17:33:49.001| INFO|                                ,                |c.b.l.c.c.service.ChatMessageWriter     |MongoDB에 메시지 백업 완료. ID: 692023ec34b5a3228f5e8b09
17:33:49.042| INFO|                                ,                |c.b.l.c.c.service.ChatMessageWriter     |Redis ZSET에 메시지 캐싱 완료. Room ID: 2
Hibernate: 
    select
        cre1_0.id,
        cre1_0.created_at,
        cre1_0.last_message,
        cre1_0.last_message_time,
        cre1_0.updated_at 
    from
        chat_room cre1_0 
    where
        cre1_0.id=?
Hibernate: 
    update
        chat_room 
    set
        created_at=?,
        last_message=?,
        last_message_time=?,
        updated_at=? 
    where
        id=?
17:33:49.104| INFO|                                ,                |c.b.l.c.c.s.ChatMessageBackupConsumer   |비동기 메시지 백업, 캐시 업데이트 및 채팅방 정보 업데이트 성공. Room ID: 2

DB 로그 및 효율성 분석 (GEMINI)

1. 채팅방 목록 조회 (Reading Rooms)

발생 쿼리내용문제점/평가
복잡한 JOIN 쿼리 (Long Query)로그인 사용자의 채팅방 요약 정보(상대방 정보, 마지막 메시지 등)를 조회적절하지만 성능 최적화 필요. 1:1 채팅방 목록을 효율적으로 가져오기 위한 다중 JOIN 쿼리입니다. 이 쿼리는 상대방(crue2_0)과 상대방의 표시 정보(user ue1_0)를 가져오는 것이 목적입니다.

2. 채팅 메시지 전송 및 백업 (Sending Message)

사용자가 메시지를 전송했을 때 발생하는 로그입니다.

발생 쿼리/로그내용문제점/평가
Sending message from user: ... to opponent: ...ChatController 진입 로그정상
select crue1_0.room_id from chat_room_user crue1_0 where ... group by ... having count(...)=2chatRoomService.findOrCreateRoom()에서 1:1 채팅방 존재 여부 확인적절. 두 사용자 ID를 사용하여 해당 사용자들만 참여하고 있는 방을 찾는 표준 쿼리입니다.
Found or created chat room. Room ID: 2ChatRoomService 동작 확인정상
Message sent and backed up for room ID: 2ChatController에서 RabbitMQ로 메시지 발행 완료정상
MongoDB에 메시지 백업 완료. ID: ...ChatMessageService의 DB 저장 완료정상 (비동기 처리 확인)
Redis ZSET에 메시지 캐싱 완료. Room ID: 2Redis Write-Through 성공정상. 구현하신 Redis 캐싱 로직이 제대로 작동하고 있음을 증명합니다.
select * from chat_room where cre1_0.id=?채팅방 정보 조회비효율적. update chat_room을 수행하기 위해 Hibernate가 변경 전 엔티티 상태를 로드하는 쿼리입니다.
update chat_room set last_message=?, last_message_time=?, updated_at=? where id=?chatRoomService.updateLastMessage()의 결과적절. 메시지 백업 후 채팅방의 마지막 메시지 정보를 업데이트하는 필수적인 쿼리입니다.

💡 최종 결론: 전반적인 흐름은 양호하나 DB 최적화 필요

  1. 기능 및 비동기: 메시지 전송, RabbitMQ 발행, MongoDB 백업, Redis 캐싱 및 채팅방 정보 업데이트까지의 전체적인 비동기 흐름은 완벽하게 작동하고 있습니다. (구현하신 Redis 로직 성공)
  2. DB 효율성: 사용자 인증 과정과 채팅방 정보 업데이트 시 반복적인 select 쿼리가 발생하고 있습니다. 이는 서비스 부하가 증가했을 때 병목 현상을 일으킬 수 있는 주요 지점입니다.

Select 쿼리에 대한 튜닝은 지금 실습과는 관계없으므로 패싱한다.


ZSET 기반 메시지 조회 로직 구현 (Read-Through)

메시지 조회는 커서 기반 페이지네이션 (Cursor-based Pagination)을 사용하며, ZSET의 Score(timestamp)를 커서로 활용한다.


조회 (Look-Aside) - 설계만 작성

  1. Redis 캐시에서 데이터를 조회한다.
    1. 페이지네이션을 위해 기본적으로 default size는 30개씩 조회한다.
    2. 현재 시간을 기준으로 30개 이전의 데이터를 조회한다.
  2. 30개가 넘게 있으면 캐시로 조회한 데이터를 바로 보내준다.
  3. 30개보다 없다면 MongoDB에 접근하여 데이터를 조회하는 FallBack 로직을 구현한다.
    1. DB에서 조회할 커서는 가장 오래된 메신저로 지정한다.
      1. 30개를 요청했는데 Redis에 5개밖에 없으면 Redis에서 100(최신), 99, 98, 97, 96(가장 오래됨)을 찾는다.
      2. 이때 우리는 DB에게 Redis가 96번까지 찾았으니까 96번보다 더 예전꺼를 찾아주라고 말하기 위해서 지정한다.
      3. 이때 아무것도 없을 수도 있으니 삼항 연산자로 아무것도 못찾으면 lastMessageTimestamp부터 찾으라고 시킨다.
      4. 첫 채팅방이면 메세지 자체가 없기 때문에 NULL이다. lastMessageTimestamp가 없기 떄문이다. 그렇기 떄문에 Long.MAX_VALUE를 넣어서 가장 큰 값이니 작은거 다 내놓으라고 적어놓는다.
  4. MongoDB와 Redis에서 조회한 데이터를 합친 후 반환한다.

궁금증 질문 목록 (GEMINI)

Q1. 왜 Redis List가 아니라 ZSet(Sorted Set)을 사용했나요?

  • 답변:
    • "List는 단순한 push/pop에는 빠르지만, 채팅처럼 '특정 시점 이전의 데이터 30개'를 가져오는 범위 기반 조회(Pagination)에는 비효율적입니다.
    • 반면 ZSetTimestamp를 Score로 두면 O(log N)의 속도로 정확한 범위 검색(reverseRangeByScore)이 가능하기 때문에 커서 기반 페이지네이션을 메모리 상에서 구현하기에 가장 적합했습니다.”

Q2. DB에 먼저 저장하고 Redis에 저장하던데(Write-Through), 만약 DB엔 들어갔는데 Redis 저장에서 에러가 나면 어떡하나요?

  • 답변:
    • "현재 로직은 데이터의 영속성(Persistence)이 최우선입니다. 그래서 Source of Truth인 DB 저장을 먼저 수행합니다.
    • Redis 저장은 try-catch로 감싸두었기 때문에 실패하더라도 클라이언트에게는 성공 응답이 나갑니다.
    • 이 경우 순간적으로 캐시와 DB의 불일치가 발생할 수 있지만, 캐시 미스(Cache Miss) 발생 시 DB에서 읽어오는 로직(Look-Aside)이 있거나, 혹은 TTL 만료 후 자연스럽게 갱신되도록 하여 결국에는 일관성이 맞춰지도록(Eventual Consistency) 설계했습니다."

Q3. 커서 기반 페이지네이션(No-Offset)이 Offset 방식보다 왜 빠른가요?

  • 답변:
    • "Offset 방식(LIMIT 10000, 30)은 앞의 10,000개를 읽고 버리는 과정이 필요해서 데이터가 뒤에 있을수록 느려집니다.
    • 반면 커서 기반은 '마지막으로 읽은 시간(timestamp)'이라는 인덱스를 타고 '그 바로 다음 위치'로 점프해서 30개만 딱 읽어오기 때문에, 데이터가 100만 건이 쌓여도 첫 페이지와 마지막 페이지의 조회 속도가 동일합니다."

Q4. Redis에 최신 5,000개만 남기는 로직은 왜 넣으셨나요?

  • 답변:
    • "Redis는 인메모리 저장소라 용량 비용이 비쌉니다. 채팅 내역 전체를 캐싱하면 OOM(Out Of Memory) 위험이 큽니다.
    • 사용자 패턴 분석 상 90% 이상의 트래픽이 '최신 대화'에 집중되므로, 5,000개(약 수십 페이지 분량)만 캐싱해도 성능 효과는 충분하다고 판단하여 비용 효율성을 위해 제한을 두었습니다."

Q5. 타임스탬프가 완벽하게 동일한 메시지가 동시에 오면 어떻게 되나요?

  • 답변:
    • "현재는 timestamp만을 커서로 쓰고 있어 중복 이슈 가능성이 미약하게나마 있습니다.
    • 이를 완벽히 해결하려면 (timestamp, messageId)를 묶어서 유니크한 커서를 만들어야 합니다. 추후 고도화 단계에서 이 부분을 적용하여 정밀함을 높일 계획입니다."

0개의 댓글