
예전 작업하다가 임시 중단했던 Sent Project를 보수하기로 했다.
채팅에 대해서 K6를 사용해서 부하테스트를 했었는데, 페이지네이션 + 인덱스 적용에 이어 Redis 캐싱을 적용해보자.
메시지 저장 과정에서 MongoDB + Redis ZSET에 동시에 쓰는 방식(Write-Through)을 구현한다.
MongoDB는 영속 저장소라고 가정하고, Redis는 빠른 조회를 위한 캐시로 사용한다.
DB 백업을 담당하는 ChatMessageWriter (혹은 ChatMessageService)를 수정하여 DB 쓰기와 Redis 쓰기를 동시에 수행하도록 구현한다.
| 특징 | 설명 |
|---|---|
| Set (집합) | 요소(Member)는 중복될 수 없음 (현재 채팅 메시지의 id가 Member가 됨) |
| Sorted (정렬) | 모든 Member는 Score라는 실수 값과 연결되며, 이 Score를 기준으로 데이터가 정렬되어 저장됨 |
채팅 시스템에서 이전 메시지 조회는 단순히 데이터를 가져오는 것을 넘어, "특정 시점 이전의 메시지를 시간 순서대로 N개 가져오기"라는 복잡한 요구사항을 가진다. (페이지네이션)
ZSET은 이러한 요구사항을 데이터베이스(MongoDB)보다 훨씬 빠르게 처리할 수 있게 해준다.
| ZSET의 특징 | 채팅 시스템에서의 적용 |
|---|---|
| 시간 기반 정렬 (Score) | ChatMessage의 timestamp를 Score로 사용한다. Redis는 Score를 기준으로 메시지를 자동 정렬하므로, DB에서 ORDER BY timestamp를 할 필요가 없다. |
| 범위 조회 (Range Query) | 채팅 메시지를 불러올 때 lastMessageTimestamp를 기준으로 한다. ZSET은 Score의 범위를 지정하여 조회하는 기능을 제공한다. (ZREVRANGEBYSCORE) |
| 페이지네이션 (Pagination) | LIMIT 기능을 내장하고 있어, 대용량 데이터에서 N개만 잘라오는 연산을 O(log N + K)의 낮은 복잡도로 수행한다. (DB의 인덱스 검색보다 훨씬 빠름) |
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
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(...)=2 | chatRoomService.findOrCreateRoom()에서 1:1 채팅방 존재 여부 확인 | 적절. 두 사용자 ID를 사용하여 해당 사용자들만 참여하고 있는 방을 찾는 표준 쿼리입니다. |
Found or created chat room. Room ID: 2 | ChatRoomService 동작 확인 | 정상 |
Message sent and backed up for room ID: 2 | ChatController에서 RabbitMQ로 메시지 발행 완료 | 정상 |
MongoDB에 메시지 백업 완료. ID: ... | ChatMessageService의 DB 저장 완료 | 정상 (비동기 처리 확인) |
Redis ZSET에 메시지 캐싱 완료. Room ID: 2 | Redis 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 최적화 필요
select 쿼리가 발생하고 있습니다. 이는 서비스 부하가 증가했을 때 병목 현상을 일으킬 수 있는 주요 지점입니다.Select 쿼리에 대한 튜닝은 지금 실습과는 관계없으므로 패싱한다.
메시지 조회는 커서 기반 페이지네이션 (Cursor-based Pagination)을 사용하며, ZSET의 Score(timestamp)를 커서로 활용한다.
default size는 30개씩 조회한다.FallBack 로직을 구현한다.96(가장 오래됨)을 찾는다.lastMessageTimestamp부터 찾으라고 시킨다.NULL이다. lastMessageTimestamp가 없기 떄문이다. 그렇기 떄문에 Long.MAX_VALUE를 넣어서 가장 큰 값이니 작은거 다 내놓으라고 적어놓는다.Q1. 왜 Redis List가 아니라 ZSet(Sorted Set)을 사용했나요?
push/pop에는 빠르지만, 채팅처럼 '특정 시점 이전의 데이터 30개'를 가져오는 범위 기반 조회(Pagination)에는 비효율적입니다.Timestamp를 Score로 두면 O(log N)의 속도로 정확한 범위 검색(reverseRangeByScore)이 가능하기 때문에 커서 기반 페이지네이션을 메모리 상에서 구현하기에 가장 적합했습니다.”Q2. DB에 먼저 저장하고 Redis에 저장하던데(Write-Through), 만약 DB엔 들어갔는데 Redis 저장에서 에러가 나면 어떡하나요?
Source of Truth인 DB 저장을 먼저 수행합니다.try-catch로 감싸두었기 때문에 실패하더라도 클라이언트에게는 성공 응답이 나갑니다.Q3. 커서 기반 페이지네이션(No-Offset)이 Offset 방식보다 왜 빠른가요?
LIMIT 10000, 30)은 앞의 10,000개를 읽고 버리는 과정이 필요해서 데이터가 뒤에 있을수록 느려집니다.Q4. Redis에 최신 5,000개만 남기는 로직은 왜 넣으셨나요?
Q5. 타임스탬프가 완벽하게 동일한 메시지가 동시에 오면 어떻게 되나요?
timestamp만을 커서로 쓰고 있어 중복 이슈 가능성이 미약하게나마 있습니다.(timestamp, messageId)를 묶어서 유니크한 커서를 만들어야 합니다. 추후 고도화 단계에서 이 부분을 적용하여 정밀함을 높일 계획입니다."