채팅 시스템에서 "몇 명이 이 메시지를 아직 안 읽었는가"를 Message.unreadCount에 저장하는 방식은
직관적으로 보이지만, 두 가지 근본적인 결함을 내포한다.
첫째는 전송 시점의 멤버 수가 stale할 수 있고, 둘째는 읽음 처리 시 UPDATE 쿼리가 레이스 컨디션에 노출된다.
올바른 접근은 unreadCount를 저장하지 않고, 조회 시점에 lastReadMessageId 기반으로
동적으로 계산하는 것이다. 쓰기 비용 없이 항상 정확한 값을 얻을 수 있다.
Message.create(chatRoom, sender, content, members.size())unreadCount = members.size() - 1
메시지를 저장하는 순간의 활성 멤버 수를 기준으로 unreadCount를 초기화한다. 그런데 메시지 저장 직전에 누군가가 채팅방을 나갔거나, 직후에 누군가가 들어왔다면? unreadCount는 영원히 부정확한 값을 가진다.
-- 읽음 처리 시 실행되던 쿼리
UPDATE messages
SET unread_count = unread_count - 1
WHERE chat_room_id = ? AND id > ? AND unread_count > 0
두 사용자가 거의 동시에 읽음 처리를 하면 둘 다 unread_count - 1을 실행한다.
데이터베이스 수준에서 unread_count = unread_count - 1은 원자적이지만,
이 UPDATE 자체는 논리적으로 두 번 차감된다.
하지만 더 심각한 문제는 채팅방 퇴장(leave)이다.
결국 unreadCount를 메시지에 저장하는 한, join/leave 동시성을 완벽히 처리할 방법이 없다. 이는 설계 자체의 문제다.
각 ChatRoomUser는 lastReadMessageId를 가진다.
"이 사용자가 마지막으로 읽은 메시지 ID". 읽음 처리 시 이 값만 갱신한다.
그러면 "메시지 X의 unreadCount"는 다음 쿼리로 정확히 계산된다:
SELECT COUNT(*)
FROM chat_room_users cru
WHERE cru.chat_room_id = :chatRoomId
AND cru.status = 'ACTIVE'
AND cru.last_read_message_id < :messageId
즉, "현재 활성 멤버 중 이 메시지를 아직 안 읽은 사람 수". 항상 현재 시점의 정확한 값이다.
메시지 목록을 조회할 때 메시지마다 쿼리를 날리면 N+1 문제가 발생한다. 단일 JOIN GROUP BY 쿼리로 전체를 한 번에 처리한다:
SELECT m.id, COUNT(cru)
FROM Message m
LEFT JOIN ChatRoomUser cru
ON cru.chatRoom.id = m.chatRoom.id
AND cru.status = 'ACTIVE'
AND cru.lastReadMessageId < m.id
WHERE m.id IN :messageIds
GROUP BY m.id
결과는 Map<messageId, unreadCount>로 변환해 메시지 목록에 매핑한다.
메시지를 전송하는 순간에는 DB 조회 없이 members.size() - 1을 사용한다.
이 시점의 members는 방금 조회한 현재 활성 멤버 목록이므로 정확하다.
(발신자 본인은 unread 아니므로 -1)
Message.unreadCount 필드 삭제MessageRepository.decrementUnreadCountAfter() 삭제
구 설계(왼쪽)는 unreadCount를 메시지에 직접 저장하고 읽음 처리마다 감소시키는 방식으로,
동시성과 퇴장 이벤트에 취약하다. 새 설계(오른쪽)는 lastReadMessageId만 관리하고
unreadCount는 JOIN 쿼리로 동적 계산해 근본 문제를 해결한다.
| 항목 | 구 설계 (저장) | 새 설계 (동적 계산) |
|---|---|---|
| 정확성 | stale 가능, 퇴장 시 부정확 | 항상 현재 시점 정확 |
| 동시성 | decrement 레이스 컨디션 | 읽기만 하므로 문제 없음 |
| 읽음 처리 쓰기 | N개 메시지 UPDATE | 단일 행 UPDATE (lastReadMessageId) |
| 목록 조회 읽기 | SELECT만 (unreadCount 이미 있음) | JOIN GROUP BY 1회 추가 |
| 퇴장 처리 | 과거 메시지 보정 불가 | status='INACTIVE' 처리로 자동 반영 |
| 구현 복잡도 | decrement 로직, 예외 처리 필요 | 단순 (lastReadMessageId 갱신만) |
unreadCount를 메시지에 저장하는 설계는 처음에는 매력적으로 보인다. "조회할 때 그냥 꺼내면 되니까 빠르겠지"라는 직관이다. 하지만 이는 파생 데이터(derived data)를 원천 데이터로 잘못 취급하는 패턴이다.
unreadCount는 메시지 자체의 속성이 아니라, 메시지와 사용자 간의 관계에서 파생되는 값이다. 파생 데이터를 저장하면 원천 데이터가 변경될 때마다 동기화 문제가 생긴다. 이는 캐시 무효화 문제와 본질적으로 같다.
이 경우 JOIN GROUP BY 쿼리 1회 추가는 충분히 허용 가능한 비용이다. 메시지 목록 조회 자체가 이미 DB 쿼리를 포함하므로, 쿼리 1개 추가는 큰 부담이 아니다. 성능이 실제 문제가 된다면 Redis에 캐싱하는 방식을 고려하면 된다.