프론트엔드에서 데이터 신선도를 유지하려면 주기적인 동기화(폴링)가 필요합니다. 단순 setInterval로는 탭 비활성화, 네트워크 오프라인, 백오프, 중복 요청 같은 실무 상황을 처리하기 어렵습니다. 아래는 React에서 안정적이고 절전 친화적인 스케줄러를 설계/구현하는 방법입니다.
1. 스케줄러 요구사항 정리
목표는 다음과 같습니다: (1) 탭이 비활성화되면 일시 중지, (2) 오프라인이면 대기 후 재시도, (3) 실패 시 지수 백오프, (4) 요청 취소(AbortController)로 중복 방지, (5) 멀티 탭 중복 폴링 줄이기, (6) 서버 캐시와 효율적 동기화(ETag, 304).
2. 실무형 폴링 커스텀 훅
setInterval 대신 재귀 setTimeout을 사용하면 드리프트 제어와 동적 간격 조정(백오프)에 유리합니다. 최신 콜백 참조를 유지하고, 탭/네트워크 상태에 따라 일시 중지합니다.
import { useEffect, useRef } from "react";
export function usePoller({
task, // async ({ signal }) => void
baseInterval = 15000,
maxInterval = 120000,
enabled = true,
immediate = true,
}) {
const callbackRef = useRef(task);
const timerRef = useRef(null);
const abortRef = useRef(null);
const intervalRef = useRef(baseInterval);
// 최신 task 유지
useEffect(() => { callbackRef.current = task; }, [task]);
useEffect(() => {
let mounted = true;
const clear = () => {
if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; }
if (abortRef.current) { abortRef.current.abort(); abortRef.current = null; }
};
const schedule = (ms) => {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(run, ms);
};
const run = async () => {
if (!mounted || !enabled) return;
// 탭 비활성화 또는 오프라인이면 대기
if (document.hidden || !navigator.onLine) {
schedule(intervalRef.current);
return;
}
// 네트워크 요청 취소 토큰
const controller = new AbortController();
abortRef.current = controller;
try {
await callbackRef.current({ signal: controller.signal });
// 성공 시 간격 초기화
intervalRef.current = baseInterval;
} catch (err) {
if (err?.name !== "AbortError") {
// 실패 시 지수 백오프
intervalRef.current = Math.min(intervalRef.current * 2, maxInterval);
}
} finally {
abortRef.current = null;
schedule(intervalRef.current);
}
};
if (enabled) {
if (immediate) run(); else schedule(intervalRef.current);
}
// 문서 가시성 변화 시 즉시 재평가
const onVisibility = () => {
if (!enabled) return;
clearTimeout(timerRef.current);
schedule(intervalRef.current);
};
document.addEventListener("visibilitychange", onVisibility);
// 연결 상태 변화 시 즉시 재평가
const onOnline = () => onVisibility();
const onOffline = () => onVisibility();
window.addEventListener("online", onOnline);
window.addEventListener("offline", onOffline);
return () => {
mounted = false;
document.removeEventListener("visibilitychange", onVisibility);
window.removeEventListener("online", onOnline);
window.removeEventListener("offline", onOffline);
clear();
};
}, [baseInterval, maxInterval, enabled, immediate]);
}
3. 사용 예: 주문 목록 동기화(ETag/304)
서버가 ETag를 지원한다면 304(변경 없음)를 활용하여 트래픽을 줄입니다. 실패 시 백오프, 성공 시 간격 초기화를 적용합니다.
import { useState, useRef } from "react";
import { usePoller } from "./usePoller";
function fetchOrders({ signal, etag }) {
return fetch("/api/orders", {
method: "GET",
headers: etag ? { "If-None-Match": etag } : {},
signal,
cache: "no-store",
}).then(async (res) => {
if (res.status === 304) return { unchanged: true };
if (!res.ok) throw new Error("Fetch failed");
const data = await res.json();
const newEtag = res.headers.get("ETag");
return { data, etag: newEtag };
});
}
export default function Orders() {
const [orders, setOrders] = useState([]);
const etagRef = useRef(null);
usePoller({
baseInterval: 15000,
maxInterval: 120000,
enabled: true,
immediate: true,
task: async ({ signal }) => {
const result = await fetchOrders({ signal, etag: etagRef.current });
if (!result.unchanged) {
if (result.etag) etagRef.current = result.etag;
setOrders(result.data);
}
},
});
return (
<div>
<h4>주문 목록</h4>
<ul>
{orders.map((o) => (
<li key={o.id}>#{o.id} - {o.status}</li>
))}
</ul>
</div>
);
}
4. React Query/SWR로 간단히
이미 검증된 데이터 레이어를 쓰면 안정성과 부가 기능(캐싱, 재시도)을 쉽게 얻습니다.
import { useQuery } from "@tanstack/react-query";
const fetcher = async () => {
const res = await fetch("/api/orders", { cache: "no-store" });
if (!res.ok) throw new Error("Fetch failed");
return res.json();
};
function OrdersRQ() {
const { data, isFetching } = useQuery({
queryKey: ["orders"],
queryFn: fetcher,
staleTime: 30_000,
refetchInterval: (data) => 15_000,
refetchOnWindowFocus: true,
retry: 3,
retryDelay: (attempt) => Math.min(2 ** attempt * 1000, 30_000),
});
return <div>{isFetching ? "동기화 중" : JSON.stringify(data)}</div>;
}
5. 오프라인 동기화: Service Worker Background Sync
오프라인에서 생성된 작업(예: 주문 수정)은 IndexedDB에 큐잉 후 온라인 복귀 시 백그라운드에서 동기화합니다. One-off Background Sync는 크롬/엣지에서 지원되며, 정기적(Periodic) Sync는 제한적입니다.
// sw.js (Service Worker)
self.addEventListener("sync", (event) => {
if (event.tag === "sync-orders") {
event.waitUntil(syncQueuedOrders());
}
});
async function syncQueuedOrders() {
const items = await readQueueFromIndexedDB();
for (const item of items) {
try {
const res = await fetch("/api/orders/" + item.id, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(item.payload),
});
if (res.ok) await markDone(item.id);
} catch (e) {
// 네트워크 실패 시 다음 sync로 재시도
}
}
}
// App 등록 코드
navigator.serviceWorker.ready.then((reg) => {
if ("sync" in reg) {
reg.sync.register("sync-orders").catch(() => {});
}
});
6. 최적화 체크리스트
- 멀티 탭 중복 폴링 방지: BroadcastChannel 또는 localStorage를 사용해 리더 탭만 폴링하도록 조정합니다.
- 네트워크 품질에 따른 간격 조정: navigator.connection.effectiveType(4g/3g 등)을 읽어 간격을 늘리거나 줄입니다.
- API 친화성: If-None-Match/ETag, If-Modified-Since, 304 지원으로 트래픽 절감.
- 취소와 타임아웃: AbortController로 취소, fetch 타임아웃은 AbortController+setTimeout 래핑으로 구현합니다.
- 에러 관측: 실패/백오프 증가를 로깅해 서버/클라이언트 이슈를 조기에 발견합니다.
7. 운영 팁
- 간격은 비즈니스 필요(데이터 변동성, 사용자 규모)에 맞춰 결정하고, 피크 시간대에는 간격을 늘려 서버 부담을 줄입니다.
- 기능 토글로 특정 사용자 그룹에만 스케줄러를 활성화하고 성능 지표를 모니터링합니다.
- 서버 푸시(WebSocket/SSE)가 가능하면 변경 이벤트 기반으로 폴링을 대체하거나 혼합(푸시+간헐 폴링)합니다.
위 설계를 적용하면 React 앱에서 데이터 동기화가 안정적이고 비용 효율적으로 동작합니다. 탭/네트워크 상태를 존중하고, 실패 시 점진적 백오프로 서버를 보호하며, 오프라인에서도 유연한 사용자 경험을 제공합니다.
'React' 카테고리의 다른 글
| React 앱에서 Feature Flag 시스템 설계 및 적용하기 (0) | 2026.07.16 |
|---|---|
| React에서 TanStack Table로 고성능 데이터 테이블 구현하기 (0) | 2026.07.15 |
| React에서 다중 파일 드래그 앤 드롭 업로드 및 진행률 표시하기 (0) | 2026.07.14 |
| React 앱에서 AR(증강현실) 콘텐츠 뷰어 구현하기 (0) | 2026.07.14 |
| React에서 이메일 템플릿 미리보기 UI 개발하기 (0) | 2026.07.13 |