
실시간 채팅에서 REST API와 WebSocket의 역할을 나누고 데이터를 동기화한 방법
이전 프로젝트에서 공동구매 서비스를 개발하며 실시간 채팅 구현을 시도한 경험이 있다.
처음에는 Long Polling 방식으로 채팅을 구현했고, 이후 WebSocket으로 전환하려 했다. 하지만 당시에는 WebSocket 연결을 안정적으로 구성하지 못했고, 배포 이후에도 원인을 명확히 파악하지 못한 오류로 실시간 채팅이 정상적으로 동작하지 않았다.
이 경험은 큰 아쉬움으로 남았다. SOPT에 지원할 때도 WebSocket 기반 실시간 채팅을 성공적으로 구현해 보고 싶다는 목표를 작성했다.
그래서 BUDDYS에서는 단순히 메시지가 한 번 실시간으로 전달되는 수준을 넘어 다음 요소까지 고려한 채팅을 구현하고자 했다.
이 과정에서 가장 중요했던 질문은 다음과 같았다.
WebSocket을 사용하면 채팅 데이터를 모두 WebSocket으로 관리해도 될까?
결론부터 말하면 BUDDYS에서는 REST API와 WebSocket 중 하나를 선택하지 않았다. 두 통신 방식이 담당하는 역할을 구분하고 함께 사용했다.
실시간 채팅을 구현한다고 하면 처음에는 다음과 같이 생각할 수 있다.
실시간 채팅
→ WebSocket으로 메시지를 모두 처리하면 되지 않을까?
하지만 채팅방에 처음 들어온 사용자는 WebSocket 연결 이후 발생하는 메시지만 필요한 것이 아니다. 해당 채팅방에 이미 존재하는 이전 메시지도 확인해야 한다.
수개월 동안 쌓인 메시지를 WebSocket 연결 시점에 한꺼번에 전달하는 것도 적절하지 않다. 사용자가 실제로 확인하려는 범위만 페이지 단위로 조회할 수 있어야 한다.
BUDDYS에서는 다음과 같이 역할을 나눴다.
채팅방 최초 진입
→ REST API로 채팅방 정보와 최신 메시지 조회
과거 메시지 확인
→ REST API의 cursor pagination 사용
새 메시지 발생
→ WebSocket을 통해 실시간 전달
읽음 상태 변경
→ WebSocket 이벤트로 실시간 전달
REST API는 클라이언트가 요청한 시점의 서버 상태를 조회하는 데 사용했다. WebSocket은 연결 이후 발생하는 변경 사항을 실시간 이벤트로 전달하는 데 사용했다.
이를 간단히 표현하면 다음과 같다.
REST API는 현재 상태를 조회하고, WebSocket은 상태의 변화를 전달한다.
여기서 REST 응답을 서버 전체 데이터의 완전한 스냅샷이라고 볼 수는 없다. 메시지 목록도 페이지 단위로 조회하기 때문이다.
따라서 더 정확하게는 REST API가 조회 시점에 서버가 보유한 상태의 일부를 확인하는 수단이고, WebSocket은 그 전후에 발생하는 변경을 빠르게 전달하는 수단이라고 할 수 있다.
REST와 WebSocket의 차이를 단순히 “단방향 통신과 양방향 통신”으로만 비교하면 실제 채팅 구현에서 어떤 역할을 맡겨야 하는지 판단하기 어렵다.
BUDDYS에서는 두 방식을 상태 동기화 관점에서 구분했다.
REST API는 클라이언트가 필요한 시점에 서버로 요청을 보내고, 해당 시점의 상태를 응답받는 방식이다.
클라이언트: 현재 채팅 메시지를 주세요.
서버: 현재 조회할 수 있는 메시지는 이것입니다.
다음 상황에 적합하다.
요청과 응답의 범위가 명확하고, 원하는 시점에 서버 상태를 다시 확인할 수 있다는 장점이 있다.
WebSocket은 클라이언트와 서버가 연결을 유지하고, 연결된 동안 양방향으로 데이터를 전달하는 방식이다.
서버: 방금 새로운 메시지가 발생했습니다.
서버: 상대방이 이 메시지까지 읽었습니다.
다음과 같이 즉각성이 필요한 상황에 적합하다.
새로운 상태를 확인하기 위한 반복적인 polling 없이 서버의 변경 사항을 즉시 전달받을 수 있다는 장점이 있다.
하지만 WebSocket 이벤트만으로 현재 서버 상태를 항상 완전하게 알 수 있는 것은 아니다.
클라이언트가 연결되지 않은 동안 발생한 이벤트를 현재 구조에서 다시 재생받는 것은 아니기 때문이다. 네트워크가 끊기거나 브라우저가 백그라운드에 있는 동안 이벤트를 놓칠 가능성도 고려해야 한다.
따라서 WebSocket의 자동 재연결만으로는 충분하지 않았다. 다시 연결된 이후 REST API를 통해 서버 상태를 재확인하는 과정이 필요했다.
BUDDYS에서는 Native WebSocket 연결 위에서 STOMP 프로토콜을 사용했다.
WebSocket은 양방향 통신을 위한 연결을 제공하지만, 메시지를 어떤 경로로 발행하고 구독할지에 대한 애플리케이션 수준의 규칙까지 정의하지는 않는다.
STOMP를 사용하면 destination을 기준으로 메시지를 발행하고 구독할 수 있다.
WebSocket
→ 클라이언트와 서버 사이의 양방향 통신 연결
STOMP
→ destination 기반의 subscribe·publish 규칙
서버가 Native WebSocket 연결을 지원했기 때문에 WebSocket fallback을 제공하는 SockJS는 사용하지 않았다. @stomp/stompjs의 brokerURL을 통해 WebSocket에 직접 연결했다.
return new Client({
brokerURL: WEBSOCKET_URL,
reconnectDelay: 5000,
beforeConnect: async (client) => {
const accessToken = getAccessToken();
if (!accessToken) {
await client.deactivate();
onMissingAccessToken(
new Error('액세스 토큰을 찾을 수 없습니다.'),
);
return;
}
client.connectHeaders = {
Authorization: `Bearer ${accessToken}`,
};
},
});
연결 전에 최신 Access Token을 읽어 STOMP 연결 헤더에 설정했고, 연결이 끊어지면 5초 후 자동으로 재연결을 시도하도록 구성했다.
채팅방 컴포넌트마다 WebSocket 연결을 직접 생성하면 화면 이동이나 리렌더링 과정에서 연결이 중복될 수 있다. 인증, 오류 처리, 재연결 로직도 여러 곳에 반복될 가능성이 있다.
이를 방지하기 위해 앱의 Provider 계층에서 하나의 STOMP 연결을 생성하고 공유했다.
Providers
└─ AuthSessionProvider
└─ AuthenticatedStompProvider
└─ StompProvider
└─ Chat Domain
StompProvider는 다음 책임을 담당한다.
연결 상태는 다음 네 가지로 관리했다.
type StompConnectionStatus =
| 'disconnected'
| 'connecting'
| 'connected'
| 'error';
반면 채팅 도메인은 STOMP 클라이언트의 생성 방법을 직접 알지 않는다. 공통 Provider가 제공하는 subscribe와 publish를 사용해 채팅에 필요한 destination만 관리한다.
useChatRoomStomp
→ 특정 채팅방 메시지 구독
→ 메시지 전송
→ 읽음 이벤트 송수신
useChatRoomListStomp
→ 채팅방 목록 변경 이벤트 구독
화면이 언마운트되거나 연결 상태가 변경되면 Effect의 cleanup을 통해 기존 구독도 해제한다.
이를 통해 공통 연결 인프라와 채팅 도메인의 이벤트 처리 책임을 분리할 수 있었다.
BUDDYS에서 사용한 역할을 표로 정리하면 다음과 같다.
| 상황 | 사용한 방식 |
|---|---|
| 채팅방 상세 정보 조회 | REST API |
| 채팅방 최초 메시지 조회 | REST API |
| 과거 메시지 무한 스크롤 | REST API |
| 새 메시지 전송 | STOMP WebSocket |
| 새 메시지 수신 | STOMP WebSocket |
| 읽음 이벤트 전송 및 수신 | STOMP WebSocket |
| 채팅방 목록 최신 정보 갱신 | STOMP WebSocket |
| 채팅방 목록 페이지네이션 | REST API |
| 채팅방 재진입 후 상태 확인 | REST API |
| 브라우저 포커스 복귀 후 검증 | REST API |
| 네트워크 복구 후 동기화 | REST API |
| WebSocket 재구독 이후 동기화 | REST API |
핵심은 실시간 이벤트와 REST 재조회의 목적을 구분하는 것이었다.
실시간 이벤트
→ 현재 실행 중인 화면에 변경 사항을 즉시 반영
REST 재조회
→ 놓친 데이터가 없는지 서버 상태와 다시 동기화
WebSocket은 실시간성을 담당하고, REST API는 현재 상태를 조회하고 복구하는 수단으로 사용했다.
기존 메시지는 TanStack Query의 useInfiniteQuery를 사용해 cursor 기반으로 조회했다.
첫 요청
→ 최신 메시지 페이지 조회
상단 스크롤
→ cursor를 이용해 이전 메시지 페이지 조회
WebSocket으로 수신한 신규 메시지는 realtimeMessages라는 로컬 상태에 추가했다.
setRealtimeMessages((previousMessages) => {
const isDuplicated = previousMessages.some(
(message) =>
message.messageId === receivedMessage.messageId,
);
if (isDuplicated) {
return previousMessages;
}
return [...previousMessages, receivedMessage];
});
화면에 표시할 때는 REST로 조회한 메시지와 WebSocket으로 수신한 메시지를 합쳤다.
const fetchedMessages = [
...messagePages.pages.flatMap(
(page) => page.messages,
),
].reverse();
const combinedMessages = [
...fetchedMessages,
...realtimeMessages,
];
REST 재조회 결과에는 이미 WebSocket으로 수신한 메시지가 포함될 수 있다. 이 경우 같은 메시지가 두 번 표시되지 않도록 messageId를 기준으로 중복을 제거했다.
const seenMessageIds = new Set<number>();
const uniqueMessages = combinedMessages.filter(
({ messageId }) => {
if (seenMessageIds.has(messageId)) {
return false;
}
seenMessageIds.add(messageId);
return true;
},
);
처음에는 filter 내부에서 findIndex를 호출했지만, 메시지 수가 늘어날수록 중복 확인 비용이 O(n²)까지 증가할 수 있었다. 이를 Set에 이미 확인한 messageId를 저장하면서 한 번만 순회하는 방식으로 변경해 중복 제거를 O(n)에 처리했다.
현재 구현의 데이터 흐름은 다음과 같다.
REST 메시지
→ TanStack Query infinite query cache
┐
├→ 병합 → messageId 중복 제거 → 화면
│
WebSocket 메시지 │
→ realtimeMessages state ──┘
이 구조는 기존 REST 페이지를 유지하면서 실시간 메시지를 빠르게 반영하기에 비교적 단순했다.
다만 REST 데이터와 실시간 데이터라는 두 개의 데이터 소스를 사용하므로 중복 제거와 정렬 규칙을 별도로 관리해야 한다는 부담도 생겼다.
초기 구현에서는 메시지 전송에 성공하면 넓은 범위의 채팅 쿼리 키를 무효화했다.
const handleSubmit = () => {
const isSent = sendMessage(message);
if (isSent) {
setMessage('');
void queryClient.invalidateQueries({
queryKey: CHAT_ROOM_QUERY_KEY.ALL,
});
}
};
이 구조에서는 하나의 메시지를 전송했을 때 다음 흐름이 발생할 수 있었다.
STOMP로 메시지 전송
→ 서버가 메시지 이벤트 전달
→ realtimeMessages에 메시지 추가
동시에
채팅 쿼리 invalidate
→ REST 메시지 목록 재조회
→ infinite query 데이터 변경
하나의 사용자 동작으로 WebSocket 상태와 REST 캐시가 연속해서 변경되는 구조였다.
REST 응답과 WebSocket 이벤트에 동일한 메시지가 포함될 수 있으므로 중복 제거가 필요했고, infinite query 데이터가 갱신되면서 메시지 배열과 렌더링 시점도 여러 번 변경될 수 있었다.
메시지 목록에서는 최신 메시지 ID가 변경될 때 최하단으로 자동 스크롤하고 있었기 때문에, 불필요한 재조회와 데이터 변경은 스크롤 동작에도 영향을 줄 수 있었다.
useEffect(() => {
bottomRef.current?.scrollIntoView();
}, [latestMessageId]);
따라서 메시지 전송 직후 전체 채팅 쿼리를 무효화하는 로직을 제거했다.
새 메시지는 서버가 STOMP destination을 통해 전달한 결과를 실시간 상태에 반영하고, REST 재조회는 누락 데이터 복구가 필요한 시점에 수행하도록 역할을 조정했다.
이 과정에서 얻은 기준은 다음과 같다.
실시간 이벤트로 변경된 일부 상태를 반영하기 위해 전체 목록을 매번 다시 조회하지 않는다.
상대방이 메시지를 읽었을 때도 전체 메시지 목록을 다시 조회할 수 있다.
하지만 읽음 이벤트에는 상대방이 마지막으로 읽은 메시지 ID가 포함되어 있었다. 이를 이용하면 전체 메시지를 다시 요청하지 않고 현재 상태에서 필요한 필드만 변경할 수 있다.
먼저 상대방이 읽은 가장 큰 메시지 ID를 저장했다.
setLastReadByParticipantMessageId(
(previousMessageId) =>
Math.max(
previousMessageId ?? 0,
response.lastReadMessageId,
),
);
이후 내가 보낸 메시지 중 해당 ID 이하의 메시지만 읽음 상태로 변경했다.
return uniqueMessages.map((message) => {
if (
!message.mine ||
message.isRead ||
message.messageId >
lastReadByParticipantMessageId
) {
return message;
}
return {
...message,
isRead: true,
};
});
데이터 흐름은 다음과 같다.
WebSocket READ 이벤트 수신
→ 상대방이 읽은 마지막 messageId 확인
→ 내가 보낸 메시지 중 해당 ID 이하의 isRead 변경
읽음 상태 하나를 갱신하기 위해 전체 메시지 목록을 다시 요청하지 않으므로 즉각적인 화면 반영이 가능하고, 기존 페이지 구성도 유지할 수 있었다.
읽음 이벤트를 발행할 때도 마지막으로 발행한 메시지 ID를 ref에 저장했다. 이미 같은 범위까지 읽음 처리를 전송했다면 중복해서 발행하지 않았다.
if (
lastPublishedReadMessageId !== null &&
latestMessage.messageId <=
lastPublishedReadMessageId
) {
return;
}
실시간 이벤트마다 REST를 재조회하지 않는다고 해서 REST refetch 자체가 불필요한 것은 아니다.
두 방식은 목적이 다르다.
현재 연결된 화면에서 발생한 변경을 빠르게 반영한다.
MESSAGE 이벤트
→ 신규 메시지 즉시 추가
READ 이벤트
→ 기존 메시지의 isRead 변경
채팅방 목록 이벤트
→ 해당 채팅방을 최신 정보로 교체하고 최상단 이동
클라이언트가 놓친 데이터가 없는지 서버 상태와 다시 동기화한다.
BUDDYS에서는 메시지와 채팅방 목록 infinite query의 특성에 따라 서로 다른 재조회 정책을 사용했다.
채팅방 목록은 최신 메시지와 읽지 않은 메시지 수, 정렬 순서가 계속 바뀌므로 조회 직후부터 stale 상태로 판단하고 화면 재진입 시 항상 서버 상태를 다시 확인했다.
{
staleTime: 0,
refetchOnMount: 'always',
refetchOnWindowFocus: true,
refetchOnReconnect: true,
}
반면 메시지 목록은 채팅방에 진입할 때 상세 정보 조회와 메시지 조회가 연이어 suspend되면서 같은 요청이 불필요하게 반복되지 않도록 5초의 짧은 fresh 구간을 두었다.
{
staleTime: 5 * 1000,
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
}
메시지 데이터가 5초 동안 fresh하더라도 STOMP 구독 완료 시에는 refetchMessages를 직접 호출한다. 따라서 짧은 캐시 유지 시간으로 중복 요청을 줄이면서도 최초 REST 조회와 WebSocket 구독 사이의 누락 가능성은 별도로 보완한다.
각 옵션의 목적은 다음과 같다.
| 설정 | 목적 |
|---|---|
채팅방 목록의 staleTime: 0 | 목록 데이터를 즉시 stale 상태로 판단 |
메시지 목록의 staleTime: 5 * 1000 | 짧은 시간 동안 동일한 메시지 요청이 중복되는 것을 방지 |
채팅방 목록의 refetchOnMount: 'always' | 화면 재진입 시 항상 서버 상태 재확인 |
메시지 목록의 refetchOnMount: true | stale 상태로 재진입했을 때 서버 상태 재확인 |
refetchOnWindowFocus: true | 브라우저 포커스 복귀 시 재검증 |
refetchOnReconnect: true | 네트워크 복구 시 재조회 |
또한 STOMP 구독이 완료되면 REST 데이터를 다시 조회했다.
const handleSubscribed = useCallback(() => {
void refetchMessages();
}, [refetchMessages]);
채팅방 목록에서는 목록 destination을 구독한 뒤 관련 infinite query를 무효화했다.
const handleSubscribed = useCallback(() => {
void queryClient.invalidateQueries({
queryKey:
CHAT_ROOM_QUERY_KEY.INFINITE_LIST_ALL(),
});
}, [queryClient]);
최초 REST 조회와 WebSocket 구독 사이에는 짧은 시간 차이가 존재한다. 네트워크 단절 후 재연결되는 동안에도 이벤트를 놓칠 수 있다.
구독 완료 후 REST 상태를 다시 확인하면 해당 구간에서 누락됐을 가능성이 있는 데이터를 보완할 수 있다.
즉, TanStack Query의 재조회 정책은 실시간 이벤트를 대체하는 것이 아니라 누락 복구를 위한 안전망으로 사용했다.
WebSocket
→ 빠른 변경 전달
REST refetch
→ 서버 상태와의 재동기화
메시지 목록뿐 아니라 채팅방 목록도 실시간으로 변경된다.
새 메시지가 발생하면 해당 채팅방에서 다음 정보가 바뀐다.
이벤트를 받을 때마다 채팅방 목록 전체를 다시 조회하지 않고, TanStack Query 캐시에서 해당 채팅방만 갱신했다.
채팅방 목록 이벤트 수신
→ 같은 chatRoomId의 기존 항목 제거
→ 최신 채팅방을 목록 최상단에 추가
→ 기존 페이지 크기에 맞게 캐시 재구성
채팅방 목록은 무한 스크롤로 구성되어 있었기 때문에 배열의 맨 앞에 채팅방을 추가하는 것만으로는 충분하지 않았다.
아직 불러오지 않은 다음 페이지의 채팅방이 실시간 이벤트로 현재 캐시에 들어오면 현재 페이지의 수용 범위가 초과되고, 이후 다음 페이지 조회에서 중복이 발생할 수 있었다.
이를 해결하기 위해 현재까지 조회한 모든 페이지를 하나의 배열로 합친 뒤 chatRoomId를 기준으로 중복을 제거하고, 각 페이지의 size에 맞게 목록을 다시 나눴다.
마지막 캐시 페이지의 범위를 초과한 항목이 있다면 다음 페이지 조회가 막히지 않도록 hasNext도 함께 보정했다.
이 경험을 통해 실시간 이벤트는 현재 항목 하나만 변경하는 것처럼 보여도, 정렬된 페이지 목록에서는 전체 페이지 경계에 영향을 줄 수 있다는 점을 알게 되었다.
현재 메시지 화면은 REST 메시지와 WebSocket 메시지를 서로 다른 위치에서 관리한다.
REST 메시지
→ TanStack Query cache
WebSocket 메시지
→ realtimeMessages local state
화면
→ 두 배열 병합 및 중복 제거
구현이 단순하고 기존 infinite query 페이지를 직접 변경하지 않아도 된다는 장점이 있지만 다음과 같은 한계도 있다.
개선 방향으로는 WebSocket 메시지도 queryClient.setQueryData를 통해 infinite query 캐시에 반영하는 구조를 고려할 수 있다.
REST 메시지 ───────┐
├→ TanStack Query cache → 화면
WebSocket 메시지 ──┘
이 구조에서는 메시지 상태를 하나의 데이터 소스에서 관리할 수 있다. 컴포넌트가 REST 메시지와 실시간 메시지를 직접 병합하는 책임도 줄어든다.
다만 WebSocket 메시지를 배열 끝에 추가하는 것만으로 끝나지는 않는다.
메시지 infinite query의 다음 요소를 함께 유지해야 한다.
따라서 단일 캐시 구조가 항상 더 단순하다고 단정할 수는 없다. 캐시 갱신 규칙을 명확히 정의하고 단위 테스트를 마련한 뒤 적용해야 한다.
현재 프로젝트에서는 로컬 실시간 상태와 REST 페이지를 병합하는 방식을 사용했고, 단일 캐시 구조는 이후 개선 방향으로 남겨두었다.
BUDDYS의 현재 메시지 흐름은 다음과 같다.
채팅방 상세 REST 조회
+
최신 메시지 REST 조회
→ TanStack Query 캐시에 저장
채팅방 destination 구독
→ 구독 완료
→ 메시지 REST refetch
→ 최초 조회와 구독 사이의 누락 가능성 보완
STOMP MESSAGE 이벤트 수신
→ realtimeMessages에 신규 메시지 추가
→ REST 메시지와 병합
→ messageId 기준 중복 제거
→ 화면 반영
사용자 입력
→ STOMP SEND destination으로 발행
→ 서버가 MESSAGE 이벤트 전달
→ 수신한 메시지를 realtimeMessages에 반영
전송 직후 화면에 임의의 메시지를 추가하는 optimistic update가 아니라, 서버가 전달한 MESSAGE 이벤트를 기준으로 화면에 반영한다.
가장 최신 메시지 확인
→ STOMP READ destination으로 마지막 messageId 발행
상대방의 READ 이벤트 수신
→ 내가 보낸 메시지 중 해당 messageId 이하의 isRead 변경
사용자가 화면 상단에 도달
→ REST cursor pagination 요청
→ 이전 메시지 페이지 추가
→ 늘어난 높이만큼 스크롤 위치 보정
네트워크 복구 또는 STOMP 재연결
→ destination 재구독
→ REST refetch
→ 놓친 데이터가 없는지 서버 상태와 재동기화
채팅방 목록 REST 조회
→ infinite query cache 저장
채팅방 목록 STOMP 이벤트 수신
→ 해당 채팅방 최신 정보 반영
→ 목록 최상단으로 이동
→ 페이지 크기와 hasNext 보정
채팅방 목록 이벤트는 파싱 과정에서 응답 구조를 검증하고 있지만, 개별 채팅방의 MESSAGE와 READ 이벤트는 현재 JSON.parse 결과를 TypeScript 타입으로 단언해 사용한다.
const response =
JSON.parse(body) as ReceiveChatRoomResponse;
TypeScript 타입은 컴파일 시점에만 존재하므로 서버가 잘못된 형식의 데이터를 보내더라도 런타임 안전성을 보장하지 않는다.
외부에서 들어오는 데이터인 만큼 이벤트 타입과 필수 필드를 런타임에 검증하는 계층을 추가하면 잘못된 이벤트로 인한 오류를 더 안전하게 차단할 수 있다.
현재는 가장 최신 메시지 ID가 바뀌면 최하단으로 이동한다.
useEffect(() => {
bottomRef.current?.scrollIntoView();
}, [latestMessageId]);
사용자가 과거 메시지를 읽는 중에 새 메시지가 도착하면 현재 위치를 잃을 수 있다.
다음과 같이 상황을 구분하는 것이 더 자연스러운 UX라고 생각한다.
실시간 데이터와 페이지네이션을 함께 사용하면 일반적인 조회보다 고려할 상태가 많아진다.
다음 시나리오를 중심으로 단위 테스트를 추가할 수 있다.
실시간 채팅에서 REST API와 WebSocket은 서로 대체하는 관계가 아니었다.
REST API는 클라이언트가 필요한 시점에 서버 상태를 조회하고, 페이지네이션과 재동기화를 수행하는 데 적합했다. WebSocket은 메시지와 읽음 상태처럼 연결 이후 발생한 변경을 즉시 전달하는 데 적합했다.
BUDDYS에서는 두 방식의 역할을 다음과 같이 구분했다.
REST API
→ 현재 서버 상태 조회
→ 기존 및 과거 데이터 조회
→ 재진입·포커스 복귀·재연결 이후 동기화
WebSocket
→ 새 메시지 송수신
→ 읽음 상태 전달
→ 채팅방 목록 변경 실시간 반영
WebSocket을 연결하는 것만으로 안정적인 실시간 채팅이 완성되지는 않았다.
연결이 끊기는 상황, 구독 전후의 이벤트 누락 가능성, REST 응답과 실시간 이벤트의 중복, 무한 스크롤 페이지 경계와 사용자 스크롤 위치까지 함께 고려해야 했다.
이번 구현을 통해 가장 크게 배운 점은 다음과 같다.
실시간 통신에서 어려운 것은 이벤트를 빠르게 받는 것만이 아니라, 이벤트와 기존 서버 상태를 일관되게 동기화하는 것이다.
REST API와 WebSocket의 책임을 명확히 나누고, 실시간 이벤트는 변경된 범위를 즉시 반영하며, REST 재조회는 누락 복구와 상태 재검증에 사용했다.
이러한 역할 분리가 실시간성과 데이터 일관성을 함께 확보하는 핵심이었다.