Last edited: March 31, 2026
React로 실시간 모니터링 대시보드를 Publish-subscribe 패턴 구현 경험을 기록하고 회고한다. REST Polling으로 수집한 대량의 메트릭 데이터를 어떻게 효율적으로 UI에 반영하는지 정리한다.
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를 호출한다. 전체 아이템을 한 번에 호출하지 않고 배치로 나누어 부하를 분산하는 구조다.
클로저 하나가 전체 최적화를 담당한다.
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);
}, []);
const used = useMemo(() => {
return (item.traffic * 100) / threshold;
}, [item, updatedAt]);
notification은 리렌더링뿐 아니라 비즈니스 로직 트리거에도 활용된다.
useEffect(() => {
if (hasExceeded === true) {
queue.enqueue(action);
}
}, [hasExceeded, updatedAt]);
notification.publish() → setUpdatedAt → useMemo 재계산(hasExceeded) → useEffect 트리거 → 큐에 액션 등록.
이 구현은 영리하지만, React의 규칙을 벗어나는 부분이 있다. mutable 객체를 직접 수정하고 useEffect로 수동 subscribe/unsubscribe를 관리하는 것은 버그 유발 가능성이 있고, 코드를 처음 보는 사람이 흐름을 파악하기 어렵다.
React 18에서 도입된 useSyncExternalStore는 사실상 createNotification이 하는 일을 공식 API로 제공한다.
const updatedAt = useSyncExternalStore(
(callback) => {
item.notification.subscribe(callback);
return () => item.notification.unsubscribe(callback);
},
() => item.lastUpdatedAt
);
const MetricRow = ({ entityId, startDate }) => {
const { data } = useQuery({
queryKey: ['metrics', entityId, startDate.toISOString()],
queryFn: () => fetchMetrics({ id: entityId, date: startDate }),
refetchInterval: 10_000,
});
return <TableRow>...</TableRow>;
};
// Composition: 각 행이 자신의 데이터를 소유
const Dashboard = () => {
const slots = useTimeSlots(settings);
return slots.map(slot => <MetricRow key={slot.id} slot={slot} />);
};
const MetricRow = ({ slot }) => {
const { data } = useQuery({ ... });
return <TableRow>...</TableRow>;
};