0. 개요


실시간성이 필요하지만 WebSocket까지는 필요 없는 기능들이 있다. 주문 상태 확인, 결제 처리 상태, 파일 업로드 진행률 같은 것들. 이럴 때 폴링(Polling)을 쓴다.


1. Polling의 개념


서버에 주기적으로 "야, 상태 바뀐 거 있어?" 하고 물어보는 것. 간단하지만 제대로 안 하면 성능 문제가 생긴다.


2. Polling 예시


2-1) 가장 기본 형태 (동작은 하지만 문제가 많음)

import { useEffect, useState } from 'react';

function usePolling<T>(
  fetchFn: () => Promise<T>,
  interval: number
) {
  const [data, setData] = useState<T | null>(null);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    const poll = async () => {
      try {
        const result = await fetchFn();
        setData(result);
      } catch (err) {
        setError(err as Error);
      }
    };

    poll(); // 첫 호출
    const intervalId = setInterval(poll, interval);

    return () => clearInterval(intervalId);
  }, [fetchFn, interval]);

  return { data, error };
}

// 사용
function OrderStatus({ orderId }: { orderId: string }) {
  const { data: order } = usePolling(
    () => fetch(`/api/orders/${orderId}`).then(res => res.json()),
    3000 // 3초마다
  );

  return <div>주문 상태: {order?.status}</div>;
}

문제점

  1. 이전 요청이 안끝났는데 다음 요청이 나감 (아래는 개선된 형태)
function usePolling<T>(
  fetchFn: () => Promise<T>,
  interval: number
) {
  const [data, setData] = useState<T | null>(null);
  const [isPolling, setIsPolling] = useState(false);

  useEffect(() => {
    let isMounted = true;
    let timeoutId: NodeJS.Timeout;

    const poll = async () => {
      if (!isMounted) return;
      
      try {
        const result = await fetchFn();
        if (isMounted) {
          setData(result);
        }
      } catch (err) {
        console.error(err);
      } finally {
        // 이전 요청이 끝난 후에 다음 타이머 설정
        if (isMounted) {
          timeoutId = setTimeout(poll, interval);
        }
      }
    };

    poll();

    return () => {
      isMounted = false;
      clearTimeout(timeoutId);
    };
  }, [fetchFn, interval]);

  return { data };
}

setInterval 대신 setTimeout을 재귀적으로 써서 이전 요청 완료 후 다음 요청이 나가도록 함

**2. 탭이 백그라운드일 때도 폴링함 (**브라우저 탭이 보이지 않을 때는 폴링을 멈춰야 한다.)

function usePolling<T>(
  fetchFn: () => Promise<T>,
  interval: number,
  options: { pauseWhenHidden?: boolean } = {}
) {
  const [data, setData] = useState<T | null>(null);
  const [isVisible, setIsVisible] = useState(!document.hidden);

  useEffect(() => {
    if (!options.pauseWhenHidden) return;

    const handleVisibilityChange = () => {
      setIsVisible(!document.hidden);
    };

    document.addEventListener('visibilitychange', handleVisibilityChange);
    return () => {
      document.removeEventListener('visibilitychange', handleVisibilityChange);
    };
  }, [options.pauseWhenHidden]);

  useEffect(() => {
    if (options.pauseWhenHidden && !isVisible) return;

    let isMounted = true;
    let timeoutId: NodeJS.Timeout;

    const poll = async () => {
      if (!isMounted) return;
      
      try {
        const result = await fetchFn();
        if (isMounted) setData(result);
      } catch (err) {
        console.error(err);
      } finally {
        if (isMounted) {
          timeoutId = setTimeout(poll, interval);
        }
      }
    };

    poll();

    return () => {
      isMounted = false;
      clearTimeout(timeoutId);
    };
  }, [fetchFn, interval, isVisible, options.pauseWhenHidden]);

  return { data };
}