마지막 수정: 2026년 2월 24일
React로 구축된 실시간 모니터링 대시보드 코드를 분석하면서, 12줄짜리 인메모리 Pub/Sub 구현체 하나가 전체 페이지의 리렌더링 최적화를 책임지는 구조를 발견했다. REST Polling으로 수집한 대량의 메트릭 데이터를 어떻게 효율적으로 UI에 반영하는지 정리한다.
다수의 외부 서비스 프로바이더로부터 트래픽, 히트율, 성공률 등의 메트릭을 실시간으로 수집하여 테이블과 차트로 보여주는 대시보드가 있다. 프로바이더가 N개이고 시간 슬롯이 M개면 테이블에 N×M개의 행이 존재하며, 각 행의 데이터는 독립적으로 API를 호출하여 갱신된다.
여기서 문제가 생긴다. 하나의 API 응답이 돌아올 때마다 전체 상태를 setState로 갱신하면, 변경되지 않은 나머지 행과 차트까지 전부 리렌더링된다.
이 대시보드는 SSE나 WebSocket 없이, REST Polling과 인메모리 Pub/Sub의 조합으로 실시간성을 구현했다. 세 가지 메커니즘이 맞물려 동작한다.
setInterval로 매초 실행되는 tick 함수가 시간 슬롯을 관리한다.
useEffect(() => {
const timer = setInterval(() => tick(metrics, settings), 1000);
return () => clearInterval(timer);
}, [settings]);tick은 현재 시각 기준으로 프로바이더별 interval(분 단위)에 맞는 시간 슬롯을 생성하거나 제거한다. 새 슬롯이 생기면 빈 메트릭 객체를 배열에 넣고, 이후 API 호출로 채워넣는 구조다.
const createLoop = (items, run) => {
let index = 0;
const loop = async () => {
const batch = Math.max(Math.floor(items.length / 10), 1);
while (items.length > 0 && isRunning) {
const tasks = [];
for (let i = 0; i < batch; i++) {
if (index >= items.length) index = 0;
tasks.push(run(items[index++]));
}
tasks.push(new Promise(resolve => setTimeout(resolve, 1000)));
await Promise.all(tasks);
}
};
return { start, end };
};배열을 순환하면서 1초 간격으로 length / 10개씩 API를 호출한다. 전체 아이템을 한 번에 호출하지 않고 배치로 나누어 서버 부하를 분산하는 구조다. 배열이 계속 순환하므로 각 메트릭은 주기적으로 최신 데이터로 갱신된다.
이것이 핵심이다. 12줄짜리 클로저 하나가 전체 최적화를 담당한다.
const createNotification = () => {
const subscribers = [];
return {
publish: (data) => subscribers.forEach(sub => sub(data)),
subscribe: (sub) => subscribers.push(sub),
unsubscribe: (sub) => {
const index = subscribers.indexOf(sub);
if (index >= 0) subscribers.splice(index, 1);
}
};
};이 함수로 생성된 notification 인스턴스가 4가지 독립적인 채널로 사용된다.
| 인스턴스 | publish 시점 | subscriber | 효과 |
|---|---|---|---|
timeMessenger | tick()에서 매초 | 시각 표시 컴포넌트 | 현재 시각 갱신 |
item.notification | API 응답 후 | 해당 테이블 행 | 그 행만 리렌더링 |
updateNotification | API 응답 후 | 차트 컴포넌트 | 차트 리렌더링 |
setting.notification | 설정 변경 시 | 해당 테이블 행 | threshold 반영 |
일반적인 React 패턴이라면 이렇게 할 것이다:
// 일반적 방식: API 응답마다 전체 배열을 새로 만들어 setState
const onApiResponse = (id, result) => {
setMetrics(prev => prev.map(item =>
item.id === id ? { ...item, ...result } : item
));
};
// → 모든 행 + 차트가 리렌더링됨이 코드는 다르게 접근한다:
// 이 코드의 방식: 객체를 직접 수정하고, notification으로 알림
const setMetric = async (item) => {
const result = await fetchMetrics({ id: item.entityId });
item.traffic = result.traffic;
item.hitRatio = result.hitRatio;
item.successRatio = result.successRatio;
const updatedAt = new Date();
item.notification.publish(updatedAt); // 이 행만 리렌더링
updateNotification.publish(updatedAt); // 차트도 갱신
};구독하는 컴포넌트에서는 setState를 콜백으로 등록한다:
// 테이블 행 컴포넌트
const [updatedAt, setUpdatedAt] = useState(null);
useEffect(() => {
item.notification.subscribe(setUpdatedAt);
return () => item.notification.unsubscribe(setUpdatedAt);
}, []);
// updatedAt이 useMemo deps에 포함되어 있어서
// notification이 publish될 때만 traffic 비율 등이 재계산됨
const used = useMemo(() => {
return (item.traffic * 100) / threshold;
}, [item, updatedAt]);notification은 리렌더링뿐 아니라 비즈니스 로직 트리거에도 활용된다. 테이블 행 컴포넌트에서 traffic이 임계치를 초과하면 자동으로 액션 큐에 작업을 등록한다:
useEffect(() => {
if (hasExceeded === true) {
queue.enqueue(action);
}
}, [hasExceeded, updatedAt]);notification.publish() → setUpdatedAt → useMemo 재계산(hasExceeded) → useEffect 트리거 → 큐에 액션 등록. Pub/Sub 하나가 리렌더링과 사이드 이펙트를 모두 연쇄적으로 처리하는 구조다.
이 구현은 영리하지만, React의 규칙을 벗어나는 부분이 있다. mutable 객체를 직접 수정하고 useEffect로 수동 subscribe/unsubscribe를 관리하는 것은 버그 유발 가능성이 있고, 코드를 처음 보는 사람이 흐름을 파악하기 어렵다. 같은 목적을 달성하는 더 선언적인 방법들이 있다.
React 18에서 도입된 useSyncExternalStore는 사실상 createNotification이 하는 일을 공식 API로 제공한다. 외부 mutable store를 구독하되, concurrent rendering에서도 tearing 없이 안전하게 동작한다.
// 현재 방식: 수동 subscribe + useEffect
const [updatedAt, setUpdatedAt] = useState(null);
useEffect(() => {
item.notification.subscribe(setUpdatedAt);
return () => item.notification.unsubscribe(setUpdatedAt);
}, []);
// useSyncExternalStore 방식
const updatedAt = useSyncExternalStore(
(callback) => {
item.notification.subscribe(callback);
return () => item.notification.unsubscribe(callback);
},
() => item.lastUpdatedAt
);useEffect 기반 구독은 mount 타이밍에 따라 초기값을 놓칠 수 있지만, useSyncExternalStore는 이를 보장한다. 커스텀 Pub/Sub를 유지하더라도, 구독하는 쪽은 이 훅으로 바꾸는 것만으로도 안정성이 올라간다.
근본적으로 createLoop + createNotification 조합이 하는 일은 "주기적으로 API를 호출하고, 응답이 오면 해당 컴포넌트만 갱신하라"는 것이다. 이것은 React Query(TanStack Query)가 기본 제공하는 기능이다.
// 현재: createLoop + mutable 객체 + notification
// 대안: 각 행이 자신의 데이터를 직접 polling
const MetricRow = ({ entityId, startDate }) => {
const { data } = useQuery({
queryKey: ['metrics', entityId, startDate.toISOString()],
queryFn: () => fetchMetrics({ id: entityId, date: startDate }),
refetchInterval: 10_000,
});
return <TableRow>...</TableRow>;
};React Query는 queryKey가 다른 쿼리끼리 독립적으로 동작하므로, 한 행의 응답이 다른 행의 리렌더링을 유발하지 않는다. structural sharing으로 데이터가 실제로 변경되지 않았으면 참조를 유지하여 불필요한 리렌더링도 방지한다. createLoop, createNotification, mutable 객체 패턴이 전부 사라지고, 각 컴포넌트가 자신의 데이터 생명주기를 선언적으로 관리하게 된다.
현재 구조에서는 부모 컴포넌트가 전체 cdnMetrics 배열을 들고 있고, 이를 자식에게 props로 내린다. 부모의 state가 바뀌면 모든 자식이 리렌더링 대상이 되기 때문에 Pub/Sub가 필요해진 것이다. React Composition 패턴을 적용하면, 상태를 사용하는 컴포넌트 안으로 데이터 페칭을 끌어내려 리렌더링 경계를 자연스럽게 분리할 수 있다.
// 현재: 부모가 전체 데이터를 관리하고 행에 분배
const Dashboard = () => {
const [metrics, setMetrics] = useState([]);
// createLoop, tick, notification...
return metrics.map(m => <Row item={m} />);
};
// Composition: 각 행이 자신의 데이터를 소유
const Dashboard = () => {
const slots = useTimeSlots(settings); // tick 로직만 담당
return slots.map(slot => <MetricRow key={slot.id} slot={slot} />);
};
const MetricRow = ({ slot }) => {
const { data } = useQuery({ ... }); // 자기 데이터를 직접 fetch
return <TableRow>...</TableRow>; // 이 컴포넌트만 리렌더링
};이렇게 하면 Pub/Sub 없이도 각 행이 독립적인 리렌더링 단위가 된다. React의 기본 렌더링 모델 자체가 리렌더링 최적화를 해주는 구조다.