실시간 데이터 동기화의 핵심 과제
현대 웹 애플리케이션에서 서버-클라이언트 간 상태 일관성은 사용자 경험의 핵심 지표다. HTTP 기반 폴링 방식은 실시간성과 리소스 효율성 면에서 한계가 명확하며, 이를 극복하기 위해 SWR의 캐싱 전략과 WebSocket의 푸시 메커니즘을 결합한 하이브리드 접근법이 주목받고 있다.
SWR의 캐싱 체계와 재검증 메커니즘
SWR(Stale-While-Revalidate)은 React 생태계에서 데이터 페칭의 복잡도를 추상화하는 라이브러리다. 핵심 원리는 캐시된 데이터를 즉시 반환하고, 백그라운드에서 신선한 데이터를 가져와 교체하는 것이다.
캐시 키 기반 데이터 식별
SWR은 문자열 기반 키를 통해 데이터를 식별하고 싱한다. 동일한 키에 대한 요청은 자동으로 중복 제거(deduping)되며, 여러 컴포넌트가 동일한 데이터를 구독할 경우 단일 네트워크 요청만 발생한다.
// fetcher 함수 정의
const retrieveData = async (endpoint) => {
const response = await fetch(endpoint);
if (!response.ok) throw new Error('요청 실패');
return response.json();
};
// 컴포넌트 내 사용
function Dashboard({ projectId }) {
const { payload, isValidating, fault } = useSWR(
`/api/projects/${projectId}/metrics`,
retrieveData,
{
suspense: false,
keepPreviousData: true,
revalidateIfStale: false
}
);
if (isValidating && !payload) return <Skeleton />;
if (fault) return <ErrorBoundary error={fault} />;
return <MetricCharts dataset={payload} />;
}
자동 재검증 트리거 조건
| 트리거 유형 | 발생 시점 | 비고 |
|---|---|---|
| 포커스 재검증 | window focus 이벤트 | 탭 전환 후 복귀 시 |
| 네트워크 복구 | online 이벤트 | 오프라인 → 온라인 전환 |
| 주기적 재검증 | setInterval 기반 | refreshInterval 옵션 |
| 수동 재검증 | mutate() 호출 | 프로그래매틱 트리거 |
WebSocket 프로토콜의 내부 동작
WebSocket은 TCP 위에 구축된 전이중 통신 프로토콜로, 초기 핸드셰이크 이후 지속적인 연결을 유지한다. HTTP 업그레이드 메커니즘을 통해 기존 인프라(프록시, 방화벽)와의 호환성을 확보한다.
프레임 구조와 제어 메시지
WebSocket 데이터는 프레임 단위로 전송되며, 각 프레임은 FIN 비트, Opcode, 마스킹 키 등의 메타데이터를 포함한다. 텍스트(0x1), 바이너리(0x2), 연결 종료(0x8), 핑(0x9), 퐁(0xA) 등의 Opcode가 정의되어 있다.
// Node.js 환경의 WebSocket 서버 구현
import { WebSocketServer } from 'ws';
import { EventEmitter } from 'events';
class LiveDataHub extends EventEmitter {
constructor(httpServer) {
super();
this.wss = new WebSocketServer({ server: httpServer });
this.subscribers = new Map(); // 채널별 구독자 관리
this.wss.on('connection', (ws, req) => {
const channel = new URL(req.url, 'http://localhost').searchParams.get('channel');
if (!this.subscribers.has(channel)) {
this.subscribers.set(channel, new Set());
}
this.subscribers.get(channel).add(ws);
ws.on('message', (raw) => this.handleIncoming(ws, channel, raw));
ws.on('close', () => this.subscribers.get(channel)?.delete(ws));
ws.on('error', (err) => this.emit('connectionError', err));
});
}
broadcast(channel, payload) {
const message = JSON.stringify({
...payload,
dispatchedAt: Date.now()
});
const listeners = this.subscribers.get(channel) || new Set();
listeners.forEach(ws => {
if (ws.readyState === 1) { // OPEN 상태
ws.send(message);
}
});
}
handleIncoming(socket, channel, buffer) {
try {
const command = JSON.parse(buffer);
// 명령 처리 로직...
this.emit('commandReceived', { channel, command, socket });
} catch (e) {
socket.send(JSON.stringify({ error: 'INVALID_FORMAT' }));
}
}
}
통합 아키텍처: 푸시 기반 캐시 무효화
SWR의 mutate 함수와 WebSocket의 이벤트 스트림을 결합하면, 서버 주도 데이터 동기화를 구현할 수 있다. 핵심은 WebSocket 메시지를 캐시 무효화 신호로 변환하는 것이다.
커스텀 훅: useRealtimeQuery
import { useCallback, useEffect, useRef } from 'react';
import useSWR, { mutate as globalMutate } from 'swr';
const WS_ENDPOINT = process.env.REACT_APP_WS_URL;
export function useRealtimeQuery(cacheKey, fetcher, options = {}) {
const socketRef = useRef(null);
const reconnectTimerRef = useRef(null);
const attemptCountRef = useRef(0);
// SWR 기본 동작
const swrResult = useSWR(cacheKey, fetcher, {
...options,
revalidateOnFocus: false, // WebSocket이 실시간성을 담당
revalidateOnReconnect: false
});
// 수동 재검증 래퍼
const triggerRefresh = useCallback(() => {
globalMutate(cacheKey, undefined, { revalidate: true });
}, [cacheKey]);
// WebSocket 연결 관리
useEffect(() => {
const establishConnection = () => {
const ws = new WebSocket(`${WS_ENDPOINT}?watch=${encodeURIComponent(cacheKey)}`);
ws.onopen = () => {
attemptCountRef.current = 0;
console.debug(`[WS] 연결 성공: ${cacheKey}`);
};
ws.onmessage = (evt) => {
const envelope = JSON.parse(evt.data);
switch (envelope.signal) {
case 'INVALIDATE':
// 캐시 무효화 후 백그라운드 재검증
triggerRefresh();
break;
case 'SNAPSHOT':
// 서버에서 전체 데이터 푸시 (optimistic update)
globalMutate(cacheKey, envelope.payload, { revalidate: false });
break;
case 'DELTA':
// 부분 업데이트 (클라이언트 측 병합 필요)
const current = swrResult.data || {};
const merged = { ...current, ...envelope.patch };
globalMutate(cacheKey, merged, { revalidate: false });
break;
}
};
ws.onclose = (event) => {
if (!event.wasClean) {
// 비정상 종료 시 지수 백오프 재연결
const backoff = Math.min(1000 * Math.pow(2, attemptCountRef.current), 30000);
reconnectTimerRef.current = setTimeout(establishConnection, backoff);
attemptCountRef.current++;
}
};
ws.onerror = (err) => {
console.error('[WS] 연결 오류:', err);
};
socketRef.current = ws;
};
establishConnection();
return () => {
clearTimeout(reconnectTimerRef.current);
if (socketRef.current) {
socketRef.current.close(1000, 'CLEANUP');
socketRef.current = null;
}
};
}, [cacheKey, triggerRefresh]);
return {
...swrResult,
// 연결 상태 노출
connectionState: socketRef.current?.readyState === 1 ? 'LIVE' : 'STALE'
};
}
아키텍처 흐름도
- 클라이언트:
useRealtimeQuery('/api/inventory', fetcher)호출 - SWR: 캐시 미스 → fetcher 실행 → 초기 데이터 로드
- useEffect: WebSocket 연결 수립,
watch=/api/inventory구독 - 서버: 데이터베이스 변경 감지 → 해당 채널 구독자에게
INVALIDATE브로드캐스트 - 클라이언트:
mutate()호출 → SWR 백그라운드 재검증 → UI 업데이트
고급 패턴: 연결 풀링과 멀티플렉싱
다수의 실시간 데이터 소스를 다룰 때, 개별 WebSocket 연결은 리소스 낭비다. 단일 연결 위에 채널 멀티플렉싱을 구현하면 효율성을 극대화할 수 있다.
// 싱글톤 연결 관리자
class MultiplexedSocket {
constructor(url) {
this.url = url;
this.socket = null;
this.channels = new Map(); // channel -> Set of callbacks
this.pending = new Map(); // channel -> last message (재연결 시 재전송)
this.reconnectDelay = 1000;
}
connect() {
this.socket = new WebSocket(this.url);
this.socket.onmessage = (evt) => {
const frame = JSON.parse(evt.data);
const { channel, payload } = frame;
this.channels.get(channel)?.forEach(cb => cb(payload));
};
this.socket.onopen = () => {
this.reconnectDelay = 1000;
// 구독 복원
this.channels.forEach((_, ch) => this.subscribe(ch, () => {}));
};
this.socket.onclose = () => {
setTimeout(() => this.connect(), this.reconnectDelay);
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000);
};
}
subscribe(channel, handler) {
if (!this.channels.has(channel)) {
this.channels.set(channel, new Set());
this.sendControl({ action: 'SUBSCRIBE', channel });
}
this.channels.get(channel).add(handler);
return () => {
this.channels.get(channel).delete(handler);
if (this.channels.get(channel).size === 0) {
this.sendControl({ action: 'UNSUBSCRIBE', channel });
this.channels.delete(channel);
}
};
}
sendControl(message) {
if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(message));
}
}
}
// React 훅에서 활용
const hub = new MultiplexedSocket(WS_ENDPOINT);
function useSharedRealtime(cacheKey, fetcher) {
const { mutate } = useSWR(cacheKey, fetcher, { revalidateOnMount: false });
useEffect(() => {
const unsubscribe = hub.subscribe(cacheKey, (signal) => {
if (signal.type === 'REFRESH') mutate();
});
mutate(); // 초기 로드
return unsubscribe;
}, [cacheKey, mutate]);
return useSWR(cacheKey, fetcher, { revalidateOnMount: false });
}
장애 복구와 일관성 보장
메시지 유실 방지 전략
| 전략 | 구현 방식 | 적용 시점 |
|---|---|---|
| 서버 시퀀스 번호 | 각 메시지에 monotonic ID 부여 | 클라이언트 누락 감지 |
| 이벝 소싱(Event Sourcing) | 변경 로그 스트림 유지 | 상태 재구축 필요 시 |
| 하트비트/핑퐁 | 주기적 양방향 체크 | 좀비 연결 탐지 |
| 백프레셔(Backpressure) | 클라이언트 처리량 조절 요청 | 과부하 상황 |
Optimistic Update와 충돌 해결
WebSocket 지연 또는 재연결 중 로컬 상태 변경이 발생하면 일시적 불일치가 생긴다. SWR의 optimisticData 옵션과 함께 버전 벡터(Version Vector)를 활용하면 의도적인 충돌 해결이 가능하다.
// 낙관적 업데이트 with 롤백
const { mutate } = useSWR('/api/cart');
const addItem = async (item) => {
const previous = mutate.currentData;
// 즉시 UI 반영
mutate(
async () => {
const optimistic = { ...previous, items: [...previous.items, item] };
return optimistic;
},
{ revalidate: false }
);
try {
const confirmed = await api.post('/cart/items', item);
// 서버 응답으로 최종 동기화
mutate(confirmed, { revalidate: false });
} catch (e) {
// 실패 시 이전 상태 복원
mutate(previous, { revalidate: true });
throw e;
}
};
성능 최적화 체크리스트
- 연결 생명주기: 컴포넌트 언마운트 시 즉시
close()호출, 탭 비활성화 시 선택적 연결 유지 - 메시지 크기: JSON 대신 BSON 또는 MessagePack 고려, 대용량 페이로드는 CDN URL 참조
- 재검증 스로틀링: 연속적인 INVALIDATE 시그널에 debounce 적용 (예: 100ms 윈도우)
- 메모리 프로파일링: Chrome DevTools의 Performance 탭에서 WebSocket 메시지 핸들러의 클로저 누수 확인