프로젝트를 진행하면서 1:1 실시간 채팅 및 알림을 설계 및 구현하게 되었습니다.
채팅이 하나 생성되었을 때, 발생하는 상황이 많았기 때문에 기존의 모놀리틱 방식으로 구현하기에는 의존해야하는 모듈이 많았습니다. 따라서, 이벤트 기반으로 구현하게 되었습니다.
하위 내용은 실제와 상이하도록 작성하였습니다. 논리적인 흐름에 집중해주시면 좋을 거 같습니다.

채팅 메시지 생성 후 서버 내부에서 벌어지는 이벤트 기반 흐름을 세 가지 리스너 단위로 나누었습니다.
- 브로드캐스트
- 채팅 리스트/요약 갱신
- 푸시 알림 전송
각각 위의 이벤트 리스너로 처리되며, 최종적으로 Sender와 Receiver 양쪽이 WebSocket 업데이트를 받고, Receiver는 조건에 따라 푸시 알림까지 받게 됩니다.
시퀀스 다이어그램

Sequence 1 — ChatSentListener (Broadcast)
이 내용은 실시간 메시지 전달 흐름을 보여주는 다이어그램입니다.
- Sender가 메시지를 전송 → ChatService가 DB에 저장.
- ChatSentEvent 발생.
- ChatSentListener가 이벤트를 받아 메시지를 sender view와 receiver view 두 버전으로 가공.
- 두 사용자의 WebSocket 구독 경로(/topic/chatting-room/{roomId}/chats)에 브로드캐스트.
위 이벤트 리스너에서는 실시간 채팅 메시지가 양쪽 사용자에게 전달되는 과정을 다룹니다.

Sequence 2 — ChatListListener (Chat List Updates)
이 내용은 채팅 리스트 요약과 안 읽은 메시지 수 갱신을 담당하는 다이어그램입니다.
- 같은 ChatSentEvent를 ChatListListener가 구독.
- Sender와 Receiver 각각에 대해:
- User의 디바이스 목록 조회.
- 각 디바이스별 ChatSummaryResponse 생성 후 WebSocket으로 전송.
- Unread 메시지 수를 계산 후 /topic/chatting/count로 전송.
클라이언트의 채팅 리스트 UI가 즉시 갱신되도록 하는 기능입니다.

Sequence 3 — NotificationListener (Push Notifications)
이 내용은 Receiver에게 푸시 알림을 보내는 과정을 나타냅니다.
- ChatSentEvent를 NotificationListener가 구독.
- Receiver의 디바이스들을 조회.
- 각 디바이스에 대해:
- 기기 전체 알림 동의 여부 확인 (NotificationSetting).
- 채팅 방 별 알림 동의 여부 확인 (ChattingRoomNotificationSetting).
- 두 조건 모두 참일 경우 FCM으로 푸시 발송.
- 조건 불만족 시 “skip push”.
Receiver가 앱을 보지 않고 있어도 푸시 알림으로 새 메시지를 받을 수 있는 과정을 다룹니다.
한계점
- 메시지 생성부터 알림까지 모든 플로우가 DB 테이블 중심으로 돌아갑니다.
- ChatMessage, ChattingRoomUser, NotificationSetting, ChattingRoomNotificationSetting 등을 매 번 조회 및 갱신합니다.
- 부하가 몰리면 RDBMS 트랜잭션이 오래 열려 있어 다른 처리가 지연될 확률이 있습니다.
- RDBMS 의 LRU 알고리즘이 채팅 메시지와 불필요한 알림 데이터로 효율적으로 작동이 되지 않을 우려가 있습니다.
전환 방안
1. 메시지 저장 구조
- 여전히 RDBMS에는 영속 저장(백업/히스토리용) 필요합니다.
- 다만, 즉시 브로드캐스트와 리스트 갱신은 Redis를 활용할 수 있습니다.
- 메시지 저장 후 Redis에도 LPUSH room:{roomId}:messages 형태로 push.
- 최근 N개 메시지만 Redis에 유지 → Chat list를 조회하게 되면 Redis만 조회할 수 있습니다.
2. Unread Count 관리
- RDB 대신 Redis에서 카운터 관리.
- HINCRBY unread:{userId} {roomId} 1 (새 메시지)
- 읽음 처리 시 HDEL unread:{userId} {roomId} 또는 HSET unread:{userId} {roomId} 0.
- 이렇게 하면 getUnReadMessageCount가 Redis 단건 조회로 즉시 응답할 수 있습니다.
3. Chat List 요약
- 각 채팅방별 최신 메시지 메타데이터를 Redis Sorted Set으로 관리할 수 있습니다.
- 키: chatlist:{userId}
- 멤버: roomId, score: 최근 메시지 timestamp.
- 메시지 수신 시 ZADD chatlist:{userId} timestamp roomId
- ZRANGE로 최신 대화방 목록을 즉시 조회 가능합니다.
4. 푸시 알림 전송
- 현재는 NotificationListener가 DB 조회 후 FCM을 호출합니다.
- Redis에 알림 큐(LPUSH notification:queue)를 두고,
- Listener는 큐에 enqueue만 함.
- 별도 스레드/워커가 Redis에서 dequeue 하며 FCM에 발송하면 메인 트랜잭션을 경량화 할 수 있습니다.
- NotificationSetting, RoomSetting도 Redis에 캐시해두면 매번 DB에서 조회하는 것을 생략할 수 있습니다.
전환 후 생길 수 있는 문제점
1. 데이터 일관성 문제
- Redis에 기록된 unread count, chat list, 메시지 요약이 RDB와 불일치할 수 있습니다.
- 예: Redis에는 카운트가 증가했는데, DB 트랜잭션이 롤백되면 DB엔 메시지가 없습니다.
- 읽음 처리도 Redis만 업데이트하고 DB에는 반영이 늦으면, 서버 재시작 시 mismatch 발생할 가능성이 있습니다.
해결 방안:
- Redis를 cache + 실시간 처리 용도로만 쓰고, DB를 최종 진실(source of truth)로 삼아야 합니다.
- 정기적으로 Redis ↔ DB 싱크 검증 작업이 필요합니다 (cron job, background sync).
2. 데이터 유실 위험
- Redis는 메모리 기반이므로 장애 시 데이터 유실 가능성이 있습니다.
- AOF(Append-Only File)나 RDB Snapshot을 써도 지속성 보장은 RDB보다 약할 수 밖에 없습니다.
- 특히 알림 큐(queue)를 Redis에만 두면 장애 시 푸시 요청 자체가 날아갈 수 있습니다.
해결 방안:
- 알림 큐는 Redis Streams + durable storage(RDB/파일 로그) 병행하여 해결할 수 있습니다.
- 최소한 “발송 요청 기록”은 DB나 로그에 남겨야 합니다.
3. 운영 복잡성 증가
- Redis 클러스터를 따로 운영해야 하고,
- Key 설계/만료 정책/모니터링(AOF 크기, 메모리 사용량, eviction 정책)까지 관리해야 합니다.
- 특히 메시지가 많아질수록 Redis 메모리 사용량이 폭증합니다.
- 레디스는 단일 스레드를 사용하기 때문에 병목 현상도 고려해야합니다.
해결 방안:
- Redis에는 최근 데이터만 저장(예: 최근 50개 메시지, 최근 100개 방 요약) 합니다.
- 장기 데이터는 RDB에서만 관리합니다.
4. 장애 시 fallback 문제
- Redis가 죽으면 unread count, chat list 조회, 알림 큐 등 핵심 기능이 동시에 마비됩니다.
- 클라이언트는 실시간 기능이 동작하지 않는다고 느끼게 됩니다.
해결 방안 :
- Redis 장애 시 fallback으로 DB를 조회하는 코드 경로를 마련할 수 있습니다.
'spring' 카테고리의 다른 글
| [Spring] 통합 테스트 속도 개선 (2) | 2025.12.15 |
|---|---|
| [SNS] 레디스 세션 스토리지를 활용한 인증 인가 (2) | 2025.10.01 |
| [Spring] virtual thread vs webflux (3) | 2025.08.11 |
| mock대신 spy를 사용한 이유 (2) | 2025.08.06 |
| [Spring] 어느 레이어에서 리팩토링 해야할까? (3) | 2025.04.01 |
