핵심 인사이트
"안 읽은 수"는 메시지의 속성이 아니라 읽는 시점에 계산되는 관계의 속성이다.
메시지에 저장하면 동시성 문제가 필연적으로 발생한다.

채팅 시스템에서 "몇 명이 이 메시지를 아직 안 읽었는가"를 Message.unreadCount에 저장하는 방식은 직관적으로 보이지만, 두 가지 근본적인 결함을 내포한다. 첫째는 전송 시점의 멤버 수가 stale할 수 있고, 둘째는 읽음 처리 시 UPDATE 쿼리가 레이스 컨디션에 노출된다.

올바른 접근은 unreadCount를 저장하지 않고, 조회 시점에 lastReadMessageId 기반으로 동적으로 계산하는 것이다. 쓰기 비용 없이 항상 정확한 값을 얻을 수 있다.

구 설계의 두 가지 결함

결함 1 — 전송 시점 memberCount가 stale

Message.create(chatRoom, sender, content, members.size())
unreadCount = members.size() - 1

메시지를 저장하는 순간의 활성 멤버 수를 기준으로 unreadCount를 초기화한다. 그런데 메시지 저장 직전에 누군가가 채팅방을 나갔거나, 직후에 누군가가 들어왔다면? unreadCount는 영원히 부정확한 값을 가진다.

결함 2 — decrementUnreadCountAfter 레이스 컨디션

-- 읽음 처리 시 실행되던 쿼리
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 = 2, 동시에 1명이 채팅방 퇴장
→ 퇴장 처리는 미래의 메시지에만 적용됨
→ 이미 저장된 메시지의 unreadCount는 2로 영원히 남음
→ 실제론 1명만 안 읽었는데 UI에는 2 표시

결국 unreadCount를 메시지에 저장하는 한, join/leave 동시성을 완벽히 처리할 방법이 없다. 이는 설계 자체의 문제다.

새 설계: lastReadMessageId 기반 동적 계산

핵심 아이디어

ChatRoomUserlastReadMessageId를 가진다. "이 사용자가 마지막으로 읽은 메시지 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 방지

메시지 목록을 조회할 때 메시지마다 쿼리를 날리면 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>로 변환해 메시지 목록에 매핑한다.

WebSocket 실시간 전송 시 unreadCount

메시지를 전송하는 순간에는 DB 조회 없이 members.size() - 1을 사용한다. 이 시점의 members는 방금 조회한 현재 활성 멤버 목록이므로 정확하다. (발신자 본인은 unread 아니므로 -1)

무엇이 사라졌나

Message.unreadCount 필드 삭제
MessageRepository.decrementUnreadCountAfter() 삭제
✅ readMessages 시 UPDATE 쿼리 없음 → lastReadMessageId 갱신만
✅ 동시성 버그 원천 제거
시각화 — 구 설계 vs 새 설계
구 설계 (저장 방식) 새 설계 (동적 계산) Message id, content, senderId unreadCount: Int ← 문제의 필드 createdAt members.size()-1 readMessages() — 문제 발생 1. 최신 messageId 조회 2. lastReadMessageId 갱신 3. UPDATE unread_count = unread_count - 1 → 동시 실행 시 이중 차감! leaveRoom() — 또 다른 문제 퇴장한 사용자의 과거 unreadCount는 영원히 잘못된 값으로 남음 (이미 저장된 숫자를 어떻게 정정할 것인가?) 설계 자체가 잘못됨 memberCount는 메시지가 아닌 채팅방의 관심사 ChatRoomUser userId, chatRoomId, status lastReadMessageId: Long ← 이것만 joinedAt Message id, content, senderId unreadCount 없음 ✓ readMessages() — 단순 1. 최신 messageId 조회 2. lastReadMessageId 갱신만 (UPDATE 1건) unreadCount 조회 — 배치 SELECT m.id, COUNT(cru) FROM messages m JOIN chat_room_users cru WHERE cru.last_read < m.id 항상 정확 · 동시성 문제 없음 · O(1) 쿼리

구 설계(왼쪽)는 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에 캐싱하는 방식을 고려하면 된다.

함께 알면 좋은 개념