본문 바로가기

React

React 앱에서 주기적인 데이터 동기화 스케줄러 구축하기

프론트엔드에서 데이터 신선도를 유지하려면 주기적인 동기화(폴링)가 필요합니다. 단순 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 앱에서 데이터 동기화가 안정적이고 비용 효율적으로 동작합니다. 탭/네트워크 상태를 존중하고, 실패 시 점진적 백오프로 서버를 보호하며, 오프라인에서도 유연한 사용자 경험을 제공합니다.