[Redis] 메시지 캐시를 지우지 않고 읽음 상태를 관리
메시지의 readCount를 직접 갱신하는 대신 사용자별 lastReadMessageId 포인터를 Redis Hash로 관리해 캐시 정합성과 조회 성능을 함께 확보한 과정을 정리합니다.
들어가며
채팅 서비스에서 메시지 목록을 조회하면 각 메시지에는 상대방이 읽었는지를 나타내는 readCount가 포함됩니다.
1:1 채팅에서는 다음과 같이 사용합니다.
readCount = 1 → 상대방이 읽지 않음
readCount = 0 → 상대방이 읽음기존 구현에서는 상대방이 채팅방에 입장하거나 메시지를 확인하면 Messages 테이블의 readCount를 직접 변경했습니다.
UPDATE messages
SET read_count = 0
WHERE room_id = ?
AND sender_id <> ?
AND read_count = 1;DB만 사용했을 때는 이 방식도 정상적으로 동작했습니다.
하지만 최근 메시지 조회 성능을 개선하기 위해 Redis List 캐시를 적용하면서 문제가 생겼습니다.
DB의 readCount를 변경해도 Redis에 캐싱된 메시지의 readCount는 그대로 남아 있었기 때문입니다.
DB Messages.readCount = 0
Redis 메시지 readCount = 1이 문제를 해결하기 위해 메시지를 읽을 때마다 Redis 메시지 캐시를 삭제할 수도 있습니다.
하지만 그렇게 하면 다음 흐름이 반복됩니다.
메시지 읽음 처리
→ Messages 벌크 UPDATE
→ Redis 메시지 캐시 삭제
→ 다음 조회에서 DB 재조회
→ Redis 캐시 재적재대화가 활발할수록 캐시가 더 자주 파괴되는 구조입니다.
이번 글에서는 메시지의 readCount를 직접 변경하지 않고, 사용자별 lastReadMessageId 포인터를 Redis Hash로 관리하여 메시지 캐시를 유지하는 방식으로 개선한 과정을 정리합니다.
1. 기존 읽음 처리 구조
메시지 조회 API 흐름
GET /chatRoom/{roomId}/messages
↓
MessageServiceImpl.getMessagesByChatRoom()
↓
RecentMessageCacheService.getRecentMessages()
├─ Redis HIT → 캐싱된 메시지 DTO 반환
└─ Redis MISS → DB 조회 후 Redis 적재기존에는 캐시 HIT가 발생하면 Redis에 저장된 readCount를 그대로 반환했습니다.
List<ChatResponse.MessageResponseDto> recentMessages =
recentMessageCacheService.getRecentMessages(roomId);
if (!recentMessages.isEmpty()) {
return recentMessages;
}메시지 전송 시에는 readCount=1로 생성된 DTO 전체를 Redis에 저장했습니다.
Messages newMessage =
ChatConverter.toMessage(request, sender, chatRoom);
Messages savedMessage =
messageService.save(newMessage);
recentMessageCacheService.saveMessage(
chatRoom.getId(),
ChatConverter.toMessageResponseDto(savedMessage)
);따라서 Redis 메시지는 다음과 같은 데이터를 가지고 있었습니다.
{
"messageId": 100,
"roomId": 10,
"senderId": 1,
"content": "안녕하세요",
"messageType": "TEXT",
"sentAt": "2026-08-13T10:00:00",
"readCount": 1
}기존 채팅방 입장 읽음 처리
사용자가 채팅방에 입장하면 읽지 않은 메시지를 모두 조회하고 readCount를 벌크 UPDATE했습니다.
List<Messages> unreadMessages =
messageRepository.findUnreadMessages(roomId, userId);
if (!unreadMessages.isEmpty()) {
messageRepository.markAsReadAllInRoom(roomId, userId);
for (Messages message : unreadMessages) {
broadcastReadStatus(roomId, message);
}
}Repository에서는 다음 쿼리를 사용했습니다.
@Query("""
SELECT m
FROM Messages m
WHERE m.chatRoom.id = :roomId
AND m.readCount = 1
AND m.sender.id <> :userId
""")
List<Messages> findUnreadMessages(
Long roomId,
Long userId
);@Modifying(clearAutomatically = true)
@Query("""
UPDATE Messages m
SET m.readCount = 0
WHERE m.chatRoom.id = :roomId
AND m.sender.id <> :userId
AND m.readCount = 1
""")
void markAsReadAllInRoom(
Long roomId,
Long userId
);2. 기존 구조의 문제점
문제 1: DB와 Redis의 읽음 상태 불일치
DB에서 메시지의 readCount를 변경해도 Redis List 안의 메시지는 자동으로 변경되지 않습니다.
읽음 처리 전
DB: messageId=100, readCount=1
Redis: messageId=100, readCount=1읽음 처리 후
DB: messageId=100, readCount=0
Redis: messageId=100, readCount=1이 상태에서 메시지 캐시 HIT가 발생하면 프론트엔드에는 오래된 readCount가 전달됩니다.
문제 2: 캐시를 삭제하면 캐싱 효과가 감소
정합성을 맞추기 위해 읽음 처리마다 Redis 메시지 캐시를 삭제할 수 있습니다.
읽음 처리
→ 메시지 캐시 삭제
→ 다음 조회에서 캐시 MISS
→ DB에서 최근 메시지 재조회
→ Redis 재적재하지만 읽음 이벤트는 메시지 전송만큼 자주 발생할 수 있습니다.
특히 1:1 실시간 채팅에서는 다음 순환이 반복됩니다.
메시지 도착
→ 상대방 읽음
→ 캐시 삭제
→ 메시지 조회
→ DB 접근
→ 다시 캐싱결과적으로 캐시 Hit Rate가 낮아지고 DB 조회 부하가 다시 증가합니다.
문제 3: 읽은 메시지 수에 따라 DB 변경 범위 증가
채팅방에 입장했을 때 읽지 않은 메시지가 많으면 여러 메시지 행이 UPDATE 대상이 됩니다.
안 읽은 메시지 1개
→ Messages 최대 1개 행 상태 변경
안 읽은 메시지 100개
→ Messages 최대 100개 행 상태 변경벌크 UPDATE는 SQL 한 번으로 실행되지만, 실제로는 읽은 메시지 수만큼 행을 변경하고 인덱스와 로그에도 변경을 기록해야 합니다.
메시지 내용은 변하지 않았는데 읽음 상태 때문에 메시지 테이블의 여러 행이 지속적으로 수정되는 구조였습니다.
3. 해결 아이디어: 메시지가 아니라 읽은 위치를 저장하자
읽음 여부는 메시지 자체의 고정된 속성이 아닙니다.
같은 메시지도 상대방이 읽기 전에는:
readCount=1읽은 후에는:
readCount=0이 됩니다.
따라서 readCount를 메시지 원본의 일부로 저장하는 대신, 각 사용자가 어디까지 읽었는지만 저장하도록 변경했습니다.
UserChatRoom.lastReadMessageId예를 들어 채팅방에 사용자 A와 B가 있고 다음 메시지가 있다고 가정합니다.
메시지 100: A가 보냄
메시지 101: A가 보냄
메시지 102: B가 보냄사용자 B의 포인터가 100이라면:
메시지 100 → 읽음
메시지 101 → 읽지 않음계산식은 단순합니다.
readCount =
messageId <= recipientLastReadMessageId ? 0 : 1;이제 저장해야 할 값은 메시지마다 존재하는 readCount가 아니라 사용자별 포인터 하나입니다.
기존:
Messages 100.readCount
Messages 101.readCount
Messages 102.readCount
...
개선:
UserChatRoom.lastReadMessageId4. Redis 데이터 구조 설계
메시지와 읽음 포인터를 서로 다른 Redis 자료구조로 관리했습니다.
메시지 캐시: Redis List
Key: chat:room_messages:{roomId}
Type: ListRedis List에는 다음과 같은 메시지 원본 정보가 저장됩니다.
{
"messageId": 100,
"roomId": 10,
"senderId": 1,
"content": "안녕하세요",
"messageType": "TEXT",
"sentAt": "2026-08-13T10:00:00"
}현재 구현에서는 기존 DTO 구조와의 호환성을 위해 Redis 메시지에 readCount 필드가 남아 있습니다.
다만 조회 결과를 만들 때 Redis에 들어 있던 readCount는 신뢰하지 않고, 읽음 포인터를 기준으로 다시 계산합니다.
향후에는 캐시 전용 DTO를 분리해 readCount 필드를 완전히 제거할 수 있습니다.
읽음 포인터 캐시: Redis Hash
Key: chat:room_read_pointer:{roomId}
Type: HashHash의 Field에는 사용자 ID, Value에는 마지막으로 읽은 메시지 ID를 저장합니다.
Key: chat:room_read_pointer:10
Field Value
1 105
2 100Redis 명령으로 표현하면 다음과 같습니다.
HSET chat:room_read_pointer:10 1 105
HSET chat:room_read_pointer:10 2 100의미는 다음과 같습니다.
사용자 1은 메시지 105까지 읽음
사용자 2는 메시지 100까지 읽음5. Redis 읽음 포인터 캐시 구현
읽음 포인터 전용 서비스를 추가했습니다.
@Service
@RequiredArgsConstructor
public class ReadPointerCacheService {
private final StringRedisTemplate redisTemplate;
public Map<Long, Long> getPointers(Long roomId) {
Map<Object, Object> cached =
redisTemplate.opsForHash().entries(generateKey(roomId));
return cached.entrySet().stream()
.collect(Collectors.toMap(
entry -> Long.valueOf(entry.getKey().toString()),
entry -> Long.valueOf(entry.getValue().toString())
));
}
public void savePointers(Long roomId, Map<Long, Long> pointers) {
if (pointers == null || pointers.isEmpty()) {
return;
}
Map<String, String> values = pointers.entrySet().stream()
.collect(Collectors.toMap(
entry -> entry.getKey().toString(),
entry -> entry.getValue().toString()
));
redisTemplate.opsForHash()
.putAll(generateKey(roomId), values);
}
public void savePointer(
Long roomId,
Long userId,
Long lastReadMessageId
) {
redisTemplate.opsForHash().put(
generateKey(roomId),
userId.toString(),
lastReadMessageId.toString()
);
}
public void deletePointers(Long roomId) {
redisTemplate.delete(generateKey(roomId));
}
private String generateKey(Long roomId) {
return "chat:room_read_pointer:" + roomId;
}
}각 메서드의 역할은 다음과 같습니다.
| 메서드 | 역할 |
|---|---|
getPointers() | 해당 채팅방 참여자들의 포인터 전체 조회 |
savePointers() | 캐시 미스 시 참여자 포인터 전체 저장 |
savePointer() | 읽음 처리한 사용자 한 명의 포인터 갱신 |
deletePointers() | 캐시 갱신 실패 시 포인터 캐시 삭제 |
6. Cache-Aside 방식으로 포인터 조회
메시지 조회 시 먼저 Redis Hash에서 읽음 포인터를 조회합니다.
private Map<Long, Long> getReadPointers(Long roomId) {
Map<Long, Long> pointers =
readPointerCacheService.getPointers(roomId);
if (pointers.size() == 2) {
return pointers;
}
List<UserChatRoom> participants =
userChatRoomService.findAllByRoomId(roomId);
if (participants.isEmpty()) {
throw new ChatHandler(ErrorStatus.CHATROOM_NOT_FOUND);
}
Map<Long, Long> loadedPointers = participants.stream()
.collect(Collectors.toMap(
participant -> participant.getUser().getId(),
participant -> {
Long pointer = participant.getLastReadMessageId();
return pointer == null ? 0L : pointer;
}
));
readPointerCacheService.savePointers(roomId, loadedPointers);
return loadedPointers;
}현재 서비스는 1:1 채팅이므로 두 사용자의 포인터가 모두 있어야 정상적인 캐시 HIT로 판단합니다.
if (pointers.size() == 2) {
return pointers;
}포인터 캐시가 없거나 한 사용자의 값만 있다면 DB에서 참여자 정보를 조회한 후 Redis Hash를 완성합니다.
Redis Hash HIT
→ DB 조회 없이 포인터 반환
Redis Hash MISS
→ UserChatRoom DB 조회
→ Redis Hash 저장
→ 포인터 반환7. 메시지 조회 시 readCount 동적 계산
기존 메시지 조회 API에는 roomId와 cursor만 전달했습니다.
개선 후에는 현재 사용자 ID도 함께 전달합니다.
Long currentUserId = SecurityUtils.currentUserIdOrThrow();
List<ChatResponse.MessageResponseDto> messages =
messageService.getMessagesByChatRoom(
roomId,
cursor,
currentUserId
);현재 사용자 ID는 채팅방 참여 여부를 확인하고, 각 메시지의 수신자가 누구인지 판단하기 위해 필요합니다.
전체 메시지 조회 코드를 그대로 보여주면 Redis HIT/MISS 및 커서 분기 때문에 핵심이 잘 드러나지 않습니다.
핵심 흐름만 축약하면 다음과 같습니다.
public List<ChatResponse.MessageResponseDto> getMessagesByChatRoom(
Long roomId,
Long cursor,
Long currentUserId
) {
Map<Long, Long> readPointers = getReadPointers(roomId);
if (!readPointers.containsKey(currentUserId)) {
throw new ChatHandler(ErrorStatus.CHATROOM_NOT_FOUND);
}
Long otherUserId = readPointers.keySet().stream()
.filter(userId -> !userId.equals(currentUserId))
.findFirst()
.orElseThrow(() ->
new ChatHandler(ErrorStatus.CHATROOM_NOT_FOUND)
);
// Redis HIT/MISS 또는 cursor에 따라 메시지 조회
List<ChatResponse.MessageResponseDto> messages = ...;
for (ChatResponse.MessageResponseDto message : messages) {
Long recipientId = message.getSenderId().equals(currentUserId)
? otherUserId
: currentUserId;
long lastReadMessageId =
readPointers.getOrDefault(recipientId, 0L);
message.setReadCount(
message.getMessageId() <= lastReadMessageId ? 0 : 1
);
}
return messages;
}현재 사용자가 보낸 메시지라면 수신자는 상대방입니다.
recipientId = otherUserId;상대방이 보낸 메시지라면 수신자는 현재 사용자입니다.
recipientId = currentUserId;수신자의 포인터와 메시지 ID를 비교합니다.
messageId <= recipientLastReadMessageId조건이 참이면 수신자가 해당 메시지까지 읽었다는 뜻입니다.
참 → readCount=0
거짓 → readCount=1Redis 메시지 캐시에서 가져온 readCount를 그대로 반환하는 것이 아니라, Redis Hash에 저장된 수신자의 lastReadMessageId를 기준으로 응답 직전에 덮어씁니다.
프론트엔드는 기존과 동일한 readCount 필드를 받기 때문에 수정하지 않아도 됩니다.
8. 캐시 HIT 시 DB 접근이 없어지는 과정
최근 메시지와 읽음 포인터가 모두 Redis에서 조회되면 호출 흐름은 다음과 같습니다.
GET /chatRoom/{roomId}/messages
↓
ReadPointerCacheService.getPointers()
↓
Redis HGETALL
↓
RecentMessageCacheService.getRecentMessages()
↓
Redis LRANGE
↓
Java 메모리에서 readCount 계산
↓
프론트 응답호출 횟수는 다음과 같습니다.
Redis 조회: 2회
DB SELECT: 0회
DB UPDATE: 0회현재는 다음 두 데이터를 각각 Redis에 저장합니다.
메시지 원본 → Redis List
읽음 위치 → Redis Hash조회 시 두 값을 조합합니다.
messageId + recipient.lastReadMessageId
→ readCount따라서 readCount를 계산하기 위해 DB를 다시 조회할 필요가 없습니다.
다만 다음 경우에는 DB를 조회합니다.
메시지 Redis List MISS
→ DB에서 최근 메시지 조회
읽음 포인터 Redis Hash MISS
→ DB에서 UserChatRoom 조회
cursor != 0
→ 현재 캐시 범위 밖의 과거 메시지를 DB에서 조회즉, DB 접근이 없는 조건은 다음과 같습니다.
cursor == 0
AND 메시지 Redis List HIT
AND 읽음 포인터 Redis Hash HIT9. 읽음 처리 로직 변경
기존에는 메시지의 readCount를 변경했습니다.
개선 후에는 사용자의 포인터만 현재 최신 메시지 ID까지 이동합니다.
private void markMessagesAsReadUpToLatest(
Long roomId,
Long userId
) {
UserChatRoom userChatRoom =
userChatRoomService.findByRoomIdAndUserId(roomId, userId)
.orElseThrow(() ->
new ChatHandler(ErrorStatus.CHATROOM_NOT_FOUND)
);
long oldPointer = userChatRoom.getLastReadMessageId() == null
? 0L
: userChatRoom.getLastReadMessageId();
messageRepository.findTopByChatRoom_IdOrderByIdDesc(roomId)
.ifPresent(lastMessage -> {
long newPointer = lastMessage.getId();
if (newPointer <= oldPointer) {
return;
}
List<Long> newlyReadMessageIds =
messageRepository.findNewlyReadMessageIds(
roomId,
userId,
oldPointer,
newPointer
);
userChatRoom.setLastReadMessageId(newPointer);
userChatRoom.setLastReadAt(LocalDateTime.now());
userChatRoomService.save(userChatRoom);
updateReadStateAfterCommit(
roomId,
userId,
newPointer,
newlyReadMessageIds
);
});
}DB에서 변경되는 읽음 상태는 UserChatRoom 한 행입니다.
UPDATE user_chat_room
SET last_read_message_id = ?,
last_read_at = ?
WHERE id = ?;Messages.readCount 벌크 UPDATE는 더 이상 실행하지 않습니다.
새로 읽은 메시지 ID는 다음 범위로 조회합니다.
oldLastReadMessageId
< messageId
<= newLastReadMessageId이 목록은 기존 프론트엔드와의 호환성을 위해 메시지별 STOMP 읽음 이벤트를 발송하는 데 사용합니다.
10. 기존과 개선 후 호출 흐름 비교
기존 읽음 처리
findUnreadMessages()
→ 읽지 않은 Messages 엔티티 N개 조회
→ markAsReadAllInRoom()
→ Messages.readCount 벌크 UPDATE
→ UserChatRoom.lastReadMessageId 변경
→ 메시지별 STOMP 발송
→ Redis 메시지 캐시와 DB 불일치개선 후 읽음 처리
기존 lastReadMessageId 조회
→ 최신 messageId 조회
→ findNewlyReadMessageIds()
→ 이벤트 대상 messageId만 조회
→ UserChatRoom.lastReadMessageId 변경
→ DB 커밋
→ Redis Hash 포인터 변경
→ 메시지별 STOMP 발송핵심 메서드 변화는 다음과 같습니다.
findUnreadMessages()
+ markAsReadAllInRoom()
+ Messages.setReadCount()
↓
findNewlyReadMessageIds()
+ UserChatRoom.setLastReadMessageId()
+ ReadPointerCacheService.savePointer()각 메서드의 역할도 명확하게 분리됩니다.
findNewlyReadMessageIds()
→ 기존 프론트에 읽음 이벤트를 보낼 메시지 ID 조회
UserChatRoom.setLastReadMessageId()
→ DB의 영속 읽음 포인터 변경
ReadPointerCacheService.savePointer()
→ Redis의 조회용 포인터 캐시 변경11. 성능 관점에서 달라진 점
새로 읽은 메시지 수를 N이라고 했을 때:
| 작업 | 기존 | 개선 후 |
|---|---|---|
| 읽음 대상 조회 | Messages 엔티티 N개 | messageId N개 |
| Messages UPDATE | 최대 N개 행 | 0개 |
| UserChatRoom UPDATE | 1개 행 | 1개 행 |
| Redis 메시지 캐시 무효화 | 필요할 수 있음 | 불필요 |
| Redis 포인터 갱신 | 없음 | Hash Field 1개 |
| STOMP 이벤트 | N개 | N개 |
| 캐시 HIT 조회 시 DB 접근 | 정합성 문제 존재 | 0회 |
프론트엔드 호환성을 위해 메시지별 STOMP 이벤트는 여전히 N개 발송합니다.
따라서 읽음 처리 전체가 완전한 O(1)이 된 것은 아닙니다.
하지만 DB 쓰기 관점에서는 다음과 같이 달라졌습니다.
기존:
Messages 최대 N개 행 변경
개선:
UserChatRoom 포인터 1개 행 변경정리하면:
Messages 상태 변경: 제거
읽음 상태 DB 쓰기: 포인터 1건
STOMP 이벤트 비용: O(N) 유지메시지 조회에서는 Redis Hash 조회가 한 번 추가됩니다.
기존:
Redis List 1회
개선:
Redis Hash 1회
+ Redis List 1회
+ 최대 20번의 정수 비교Redis 호출이 한 번 늘어났지만 다음 비용을 제거하거나 방지할 수 있습니다.
읽음 처리에 따른 메시지 캐시 삭제
캐시 MISS 후 메시지 DB 재조회
캐시 재적재
Messages 벌크 UPDATE마치며
이번 개선의 핵심은 단순히 Redis 캐시를 하나 더 추가한 것이 아닙니다.
읽음 상태를 바라보는 기준 자체를 변경했습니다.
기존:
각 메시지에 읽음 상태 저장
개선:
각 사용자의 마지막 읽은 위치 저장readCount는 더 이상 저장된 원본 상태가 아닙니다.
messageId
+ 수신자의 lastReadMessageId
→ readCount메시지 원본은 Redis List에 캐싱하고, 사용자별 읽음 포인터는 Redis Hash에 캐싱합니다.
메시지 캐시 HIT
+ 포인터 캐시 HIT
→ DB 접근 없이 readCount 계산덕분에 읽음 처리가 발생해도 메시지 캐시를 삭제하거나 수정하지 않아도 됩니다.
또한 기존 프론트엔드의 API와 STOMP 이벤트 형식을 유지하면서 서버 내부 구현만 포인터 기반으로 교체했습니다.
이번 작업을 통해 캐시를 효과적으로 사용하려면 단순히 데이터를 Redis에 복사하는 것보다, 자주 변하는 값과 변하지 않는 값을 분리해 모델링하는 것이 중요하다는 점을 배울 수 있었습니다.
자주 변하는
readCount를 메시지 캐시에 저장하고 계속 동기화하는 대신, 작은lastReadMessageId포인터만 관리하고 필요한 값은 조회 시 계산한다.