실시간 데이터가 필요한 화면에서 상태 불일치나 성능 이슈를 겪었던 경험이 있다면, 어떤 방식으로 해결했는지 설명해 주세요.
polling, SSE, WebSocket 등 기술 도입도 중요하지만, 언제 데이터를 갱신해야 할지 설계한 기준이 있다면 강점이 됩니다.
🔧 채팅방 목록과 채팅방 내부 메시지의 상태가 서로 어긋나는 문제
-> polling 문제 상황
사용자 수가 많은 실시간 채팅/알림 화면에서 polling 기반으로 데이터를 3초마다 갱신하던 중,
- 메시지가 중복으로 도착하거나,
- 최신 상태가 화면에 반영되지 않거나,
- 트래픽 증가로 인해 서버 부하가 증가하는 문제가 발생했습니다.
🧠 해결 접근 방식
- 갱신 조건을 명확하게 설계
- 모든 데이터를 주기적으로 갱신하지 않고, 사용자의 행동에 따라 조건부 갱신을 적용했습니다.
- 채팅방 목록: 새 메시지 이벤트가 도착했을 때만 해당 채팅방의 미리보기와 읽음 상태 업데이트
- 채팅방 내부: 현재 사용자가 보고 있는 방이면 SSE로 실시간 반영, 아니라면 새 메시지 알림만 전송
- 모든 데이터를 주기적으로 갱신하지 않고, 사용자의 행동에 따라 조건부 갱신을 적용했습니다.
- 기술 선택 기준
- WebSocket: 양방향 메시지 전송이 필요한 실제 채팅 메시지에만 사용
- SSE (Server-Sent Events): 단방향 실시간 이벤트 전달 (예: 메시지 수신 알림, 시스템 알림 등)
- Polling: 비실시간 데이터 (설정값, 캐시성 리스트 등)에는 polling을 유지하되, 주기를 10초 이상으로 늘림
- 추가 최적화
- WebSocket 연결 시, 불필요한 데이터 재전송 방지를 위해 클라이언트에서 lastMessageId를 전달 → 서버는 이후 데이터만 push
- SSE는 Redis Pub/Sub과 연동하여 다중 서버에서도 일관된 이벤트 전송이 가능하도록 구성
✅ 실시간 채팅에서 상태 불일치/성능 이슈 해결 경험
🔧 문제 상황
WebSocket 기반 채팅을 구현했지만, 다음과 같은 이슈들이 있었습니다:
- 채팅방 입장 시 메시지가 일부 누락되거나 중복 수신됨
- 읽음 처리(read cursor)가 제대로 동기화되지 않아 UX 문제가 발생
- 다중 서버 환경에서 WebSocket 연결 유지 및 메시지 일관성에 어려움
초기 메시지 로딩 vs 실시간 메시지 분리
- 채팅방 입장 시에는 DB 또는 Redis Stream에서 최신 N개 메시지를 조회하여 렌더링
- 이후 WebSocket으로 수신되는 메시지는 별도로 처리, 중복 방지를 위해 messageId 기준 deduplication 적용
읽음 상태 동기화 기준 설계
- 사용자가 메시지를 끝까지 스크롤한 경우에만 lastReadMessageId를 서버에 전송
- 서버는 이를 기준으로 ChatRoomReadCursorEntity를 갱신하고, 다른 참여자에게 읽음 이벤트 전송
Kafka + WebSocket 조합으로 다중 서버 대응
- WebSocket 서버는 각 서버에서 메시지를 받지만, Kafka를 통해 중앙 브로커에서 메시지 broadcast
- 특정 서버에서 WebSocket 연결이 되어있지 않은 사용자도 Kafka를 통해 전달 가능
트래픽 최적화
- 사용자가 채팅방을 나간 경우 WebSocket 연결을 종료하고, 이후에는 Push 알림만 전달
- 메시지 수신 중 typing, 입장/퇴장 이벤트는 필요 최소한만 전송하여 네트워크 부하 감소
🎯 효과
- 메시지 중복 및 누락 해결 → 채팅 정확도 개선
- 읽음 상태 동기화 정확도 향상 (특히 그룹채팅에서)
- 다중 서버 구조에서 메시지 누락 없이 안정적인 broadcast
- 전체 트래픽 30% 감소
Kafka + Redis + WebSocket 기반의 MSA형 실시간 채팅 시스템
✅ 실시간 채팅 시스템에서 상태 불일치 및 성능 이슈 해결 경험
🔧 문제 상황
WebSocket 기반의 채팅 서비스를 직접 구현하면서, 다음과 같은 문제를 경험했습니다:
- 다중 서버 환경에서 WebSocket 세션이 분산되면서 메시지 유실 및 브로드캐스트 누락 발생
- 채팅방 입장/퇴장 및 메시지 읽음 상태가 클라이언트에 일관되게 반영되지 않음
- 대용량 그룹 채팅에서 읽음 처리 로직이 비효율적으로 작동하여 성능 이슈 유발
🧠 해결 전략
1. Kafka 기반 브로드캐스트 설계
- 메시지를 DB에 저장한 후, Kafka의 chat-messages 토픽에 메시지를 발행
- 다중 채팅 서버는 동일 토픽을 구독하고, 자신의 WebSocket 세션에만 해당 메시지를 전달
→ 서버 수에 관계없이 메시지 유실 없이 일관된 동기화 보장
2. Redis를 통한 채팅방-세션 맵핑 관리
- 유저의 채팅방 참여 정보와 서버 위치를 Redis에 저장
- 메시지를 전달할 서버를 빠르게 확인하고, 불필요한 서버에 전파하지 않도록 최적화
→ 전체 트래픽 감소 및 서버 부하 경감
3. 읽음 처리 기준 최적화 (ChatRoomReadCursorEntity)
- 각 사용자가 "어느 메시지까지 읽었는지"만 저장하는 커서 구조 도입
- lastReadMessageId를 기준으로 읽음 동기화 수행, 별도 이벤트로 처리
→ 메시지 수천 개가 쌓인 대화방에서도 읽음 상태 업데이트가 최소 쿼리로 가능
4. 갱신 기준 명확화
- 채팅방 최초 입장 시에는 DB에서 최근 N개 메시지만 조회
- 이후 메시지는 WebSocket으로 실시간 수신 → 중복 방지를 위해 클라이언트에서 lastMessageId를 관리
- 읽음 처리는 사용자가 채팅방을 벗어날 때, 혹은 마지막 메시지를 확인했을 때만 서버에 반영
✅ 그룹 채팅 읽음 처리 방식 요약
📌 핵심 아이디어
- 각 유저가 각 방에서 마지막으로 읽은 메시지 ID를 기록하면 됨.
- ChatRoomReadCursorEntity를 활용해서 (user_id, chat_room_id) → last_read_message_id 를 저장하면 OK.
1. 각 유저별로 읽은 위치 저장
p_chat_room_read_cursors
- user_id
- chat_room_id
- last_read_message_id
이렇게 저장하면:
✅ "유저 A는 채팅방 123에서 메시지 48번까지 읽었다"
✅ "유저 B는 메시지 52번까지 읽었다"
→ 서버는 메시지 53번 이후가 안 읽힌 메시지임을 판단 가능
2. 읽지 않은 사람 수 계산 (badge 등)
SELECT COUNT(*)
FROM p_chat_room_read_cursors
WHERE chat_room_id = :roomId
AND last_read_message_id < :messageId;
→ 결과: 이 메시지를 아직 안 읽은 유저 수
🧠 이렇게 하면 "읽음 3 / 5명" 같은 표시도 가능!
3. 클라이언트에서 어떻게 호출하냐?
읽음 처리 API는 채팅방에 있는 유저가 마지막 메시지를 확인했을 때만 호출해도 충분해.
POST /chat/read
{
"userId": "xxx",
"chatRoomId": "yyy",
"lastReadMessageId": "zzz"
}
✅ 왜 효율적인가?
구조 | 장점 |
Cursor 기반 저장 (현재 구조) | 유저 수 x 방 수 만큼만 저장하면 됨 (O(n)), 대용량 메시지여도 OK |
모든 메시지에 읽음 유저 리스트 저장 | 너무 많은 row 필요 (O(n²)), 비효율 |
✨ 캐싱 전략
- 읽음 정보는 Redis에 캐싱 가능
- key: chat:cursor:{roomId}:{userId}
- value: messageId
- badge용 정보도 Redis Set이나 SortedSet으로 활용 가능
🧩 시나리오
/*
단순히 "이 사람은 이 방에서 어느 메시지까지 읽었는지"만 저장
읽음 처리 부담이 줄어듦 (특히 단체방에서 수백 개 메시지 읽을 때)
MongoDB
*/
@Entity
@Table(name = "p_chat_room_read_cursors")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class ChatRoomReadCursorEntity {
@Id
@GeneratedValue(generator = "UUID")
@Column(name = "chat_room_read_cursor_id", updatable = false, nullable = false, unique = true)
private UUID chatRoomReadCursorId;// PK
@ManyToOne
@JoinColumn(name = "user_id", nullable = false)
private UsersEntity user;// FK
@ManyToOne
@JoinColumn(name = "chat_room_id", nullable = false)
private ChatRoomsEntity chatRooms;//FK
@ManyToOne
@JoinColumn(name = "chat_message_id", nullable = false)
private ChatMessagesEntity lastReadMessageId;//FK
}
- 채팅방 ID: room-1234
- 메시지는 100까지 전송됨
- 참여 유저: A, B, C, D, E, F, G
유저가 읽은 메시지 ID가 아래와 같다고 가정
🧾 테이블: p_chat_room_read_cursors
chat_room_read_cursor_id | user_id | chat_room_id | last_read_message_id |
uuid-1 | A | room-1234 | 100 |
uuid-2 | B | room-1234 | 98 |
uuid-3 | C | room-1234 | 100 |
uuid-4 | D | room-1234 | 95 |
uuid-5 | E | room-1234 | 97 |
uuid-6 | F | room-1234 | 100 |
uuid-7 | G | room-1234 | 99 |
- 유저 A, C, F는 최신 메시지(100번)까지 읽었음
- 유저 B, D, E, G는 아직 읽지 않은 메시지가 존재함
🔍 읽지 않은 유저 수 구하기 쿼리 (예: 메시지 100 기준)
SELECT COUNT(*)
FROM p_chat_room_read_cursors
WHERE chat_room_id = 'room-1'
AND last_read_message_id < 100;
📌 결과: 4
(읽지 않은 유저: B, D, E, G)
✨ Redis 구조로도 저장하면?
Key: chat:cursor:room-1234:A → 100
Key: chat:cursor:room-1234:B → 98
...
- 빠르게 읽음 비교 가능
- 메모리 기반이라 badge 연산/카운팅에도 유리
✅ 클라이언트에 보여줄 수 있는 UI 예
- “읽음 3 / 7명”
- 혹은 “아직 안 읽은 사람: B, D, E, G”
(이걸 위해서 JOIN으로 유저 이름도 붙이면 됨)
📌 프론트에서는 어떻게 읽음 처리를 서버에 알릴까?
💡 일반적인 흐름:
- 프론트는 채팅방에 입장한 후 메시지를 읽음
- 맨 마지막 메시지 ID (또는 timestamp)를 기억함
- 해당 정보를 서버로 POST 요청해서 "여기까지 읽었다"는 커서를 서버에 저장
@Operation(summary = "읽음 처리", description = "채팅방에서 사용자가 마지막으로 읽은 메시지를 서버에 저장합니다.")
@PostMapping("/read")
public ResponseEntity<Void> readMessage(@RequestBody ChatReadRequest request) {
chatService.readMessage(request.getUserId(), request.getChatRoomId(), request.getLastReadMessageId());
return ResponseEntity.ok().build();
}
public class ChatReadRequest {
private UUID userId;
private UUID chatRoomId;
private UUID lastReadMessageId;
}
@Transactional
public void readMessage(UUID userId, UUID chatRoomId, UUID lastReadMessageId) {
ChatRoomsEntity room = chatRoomService.getChatRoomEntity(chatRoomId);
UsersEntity user = userService.getUserEntity(userId);
ChatMessagesEntity message = chatMessageService.getChatMessageEntity(lastReadMessageId);
// 기존 커서가 있으면 update, 없으면 insert
chatRoomReadCursorRepository.saveOrUpdate(user, room, message);
}
✅ 프론트에서는 언제 호출?
상황 | 호출 시점 |
1:1 채팅 | 채팅방 들어오고 마지막 메시지까지 스크롤하면 호출 |
그룹 채팅 | 메시지 마지막까지 봤을 때 호출 or 주기적으로 호출 |
메시지 클릭 시 | 특정 메시지를 기준으로 읽음 처리할 수도 있음 |
// 예시: 마지막 메시지까지 스크롤했을 때
function onScrollToBottom() {
fetch("/chat/read", {
method: "POST",
body: JSON.stringify({
userId: currentUserId,
chatRoomId: currentRoomId,
lastReadMessageId: lastMessageId
}),
headers: { "Content-Type": "application/json" }
});
}
✅ 실시간 채팅에서 상태 불일치/성능 이슈 해결 경험
🔧 문제 상황
다중 서버에서 채팅 메시지를 처리하는 구조에서 다음과 같은 문제를 겪었습니다:
- 채팅방에 연결된 유저가 다른 서버에 분산되어 있어, 메시지 브로드캐스트가 누락되거나 지연
- 읽음 처리, 입장/퇴장 이벤트, 중복 수신 문제가 발생
- WebSocket 세션이 단일 서버에 귀속되어 있어 서버 재시작 시 끊김 이슈
🧠 해결 방법 및 구조
Kafka 기반 메시지 브로드캐스트
- 각 채팅 메시지는 Kafka topic에 발행되며, 모든 채팅 서버가 해당 topic을 consume
- WebSocket 세션이 연결된 서버에서만 클라이언트에게 메시지를 전송 → 서버 간 메시지 동기화 문제 해결
Redis 기반 세션/방 정보 관리
- 유저의 접속 정보 (어떤 채팅방에 속해 있는지)는 Redis에 저장
- userId → session 매핑이 아닌, userId → roomId 리스트 기반으로 구성
- 다중 서버에서도 중앙화된 세션/방 정보 관리로 브로드캐스트 대상 정확하게 지정
읽음 처리 및 메시지 동기화 기준 설계
- 클라이언트는 lastReadMessageId를 서버에 전달
- 서버는 해당 메시지까지 읽음 처리하고, 다른 유저들에게 읽음 이벤트 전파
- 채팅방 재입장 시 DB에서 메시지와 읽음 커서를 조회하여 정확한 상태 재구성
WebSocket 연결 상태 모니터링 및 복구
- 연결이 끊겼을 때는 일정 시간 내 재접속을 허용하며 세션 유지
- 재접속 시 missed message는 Kafka offset 기반으로 조회해 재전송 가능
🎯 효과
- 메시지 유실 없이 정확하고 일관된 메시지 전송
- 클라이언트 단에서 메시지 중복 없이 동기화 가능
- 서버 스케일아웃 가능 구조 완성 (서버 수와 관계없이 메시지 동기화)
- 실시간 읽음 처리 정확도 향상
'학습 기록 (Learning Logs) > Today I Learned' 카테고리의 다른 글
로컬에서 채팅 테스트 (0) | 2025.04.23 |
---|---|
컴퓨터 핵심 부품(메모리, cpu, 보조기억장치, 보조기억장치) (0) | 2025.04.23 |
머신러닝에서 나오는 vector, bias 뭔데? (0) | 2025.04.17 |
GPT에서 웹툰을 그린다고?! (0) | 2025.04.17 |
이미지 모델 테스트 (0) | 2025.04.17 |