Frontend

[Frontend] Production에서 만나는 Edge Case

teddy bear 2026. 7. 14. 17:45
반응형

중복 요청 · 트랜지션 취소 · 중복 에러 로깅

지금까지 Error Boundary의 기본기, react-error-boundary의 내부 구현, React 19의 Actions·useTransition 패턴을 다뤘습니다. 이론적으로 우아한 패턴도 실제 프로덕션 코드에 넣으면 세 가지 문제에 반드시 부딪힙니다. 중복 요청, 취소되지 않는 트랜지션, 그리고 중복 로깅되는 에러입니다. 하나씩 뜯어보겠습니다.


1. 중복 요청 방지: isPending만으로는 부족하다

문제 상황

useTransition을 쓰면 흔히 disabled={isPending}으로 버튼을 잠그는 패턴을 씁니다.

function SubmitButton() {
  const [isPending, startTransition] = useTransition();

  function handleClick() {
    startTransition(async () => {
      await submitOrder();
    });
  }

  return <button disabled={isPending} onClick={handleClick}>주문하기</button>;
}

이것만으로 충분해 보이지만, 실무에서는 두 가지 구멍이 있습니다.

  1. isPending이 true로 반영되기 전의 짧은 틈: 클릭과 리렌더링 사이에는 항상 미세한 지연이 있습니다. 매우 빠르게 두 번 클릭하거나, 터치스크린에서 중복 이벤트가 발생하면 startTransition이 두 번 호출될 수 있습니다.
  2. 키보드/폼 제출 등 클릭 외의 트리거: Enter 키로 제출, 스크린리더의 가상 클릭 등 disabled 속성만으로 막을 수 없는 경로가 존재합니다.

해결: ref 기반의 즉시 가드

isPending은 리렌더링을 거쳐야 반영되는 state이므로, 렌더링과 무관하게 즉시 값이 바뀌는 useRef로 이중 방어선을 만드는 것이 안전합니다.

function SubmitButton() {
  const [isPending, startTransition] = useTransition();
  const inFlightRef = useRef(false);

  function handleClick() {
    if (inFlightRef.current) return; // 렌더링을 기다리지 않는 즉시 가드
    inFlightRef.current = true;

    startTransition(async () => {
      try {
        await submitOrder();
      } finally {
        inFlightRef.current = false;
      }
    });
  }

  return <button disabled={isPending} onClick={handleClick}>주문하기</button>;
}

isPending은 사용자에게 보이는 UI 잠금(버튼 비활성화, 로딩 스피너)을 위한 것이고, inFlightRef는 로직 자체의 중복 실행을 막는 것이라는 역할 분리가 핵심입니다.

서버 측 idempotency도 함께

클라이언트에서 아무리 잘 막아도, 네트워크 재시도나 여러 탭에서의 동시 요청까지 완벽히 차단하기는 어렵습니다. 결제·주문처럼 중복 실행이 치명적인 액션은 idempotency key를 함께 보내 서버에서도 중복을 걸러내는 것이 정석입니다.

const idempotencyKeyRef = useRef(crypto.randomUUID());

startTransition(async () => {
  await submitOrder({ idempotencyKey: idempotencyKeyRef.current });
});

2. 트랜지션 취소: startTransition은 자동으로 취소되지 않는다

오해하기 쉬운 지점

useTransition이라는 이름 때문에 "새 트랜지션이 시작되면 이전 트랜지션이 자동으로 취소된다"고 오해하기 쉽습니다. 하지만 startTransition에 넘긴 비동기 함수 자체(예: fetch 호출)는 React가 대신 취소해주지 않습니다. React는 트랜지션의 우선순위와 렌더링 스케줄링만 관리할 뿐, 그 안에서 실행되는 프로미스의 생명주기까지 책임지지 않습니다.

즉, 검색어를 빠르게 여러 번 입력하면 이전 요청들이 여전히 백그라운드에서 진행 중이고, 늦게 시작된 요청이 먼저 끝나서 최신 상태를 덮어쓰는 전형적인 경쟁 조건(race condition)이 그대로 발생할 수 있습니다. <cite index="39-1">늦게 도착한 응답이 최신 응답을 덮어써서 화면에 오래된 결과가 남는 문제</cite>는 트랜지션을 쓴다고 저절로 사라지지 않습니다.

해결: AbortController를 직접 연결

취소는 여전히 AbortController의 몫입니다. 트랜지션 안에서 직접 컨트롤러를 만들고, 새 요청이 시작될 때 이전 것을 명시적으로 중단시켜야 합니다.

function SearchResults() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();
  const abortControllerRef = useRef(null);

  function handleSearch(nextQuery) {
    setQuery(nextQuery);

    // 이전 요청이 아직 살아있다면 취소
    abortControllerRef.current?.abort();
    const controller = new AbortController();
    abortControllerRef.current = controller;

    startTransition(async () => {
      try {
        const res = await fetch(`/api/search?q=${nextQuery}`, {
          signal: controller.signal,
        });
        setResults(await res.json());
      } catch (error) {
        if (error.name === 'AbortError') return; // 취소는 에러가 아님
        throw error; // 진짜 실패만 Error Boundary로 전파
      }
    });
  }

  return (
    <>
      <input value={query} onChange={(e) => handleSearch(e.target.value)} />
      {isPending && <Spinner />}
      <ResultsList results={results} />
    </>
  );
}

여기서 가장 중요한 줄은 if (error.name === 'AbortError') return; 입니다. 의도적인 취소를 진짜 에러와 구분하지 않으면, 사용자가 검색어를 계속 바꿀 때마다 Error Boundary가 튀어나오는 참사가 벌어집니다. <cite index="38-1">이 구분을 catch 블록에서 항상 확인해야, 취소가 실패로 오인되지 않습니다</cite>.

컴포넌트 언마운트와의 조합

라우트 전환처럼 컴포넌트 자체가 사라지는 경우도 함께 챙겨야 합니다. useEffect의 클린업 함수에서 동일한 컨트롤러를 정리하면 됩니다.

useEffect(() => {
  return () => abortControllerRef.current?.abort();
}, []);

3. 에러 로깅 중복 방지: React 19의 onCaughtError / onUncaughtError

문제 상황: React 18 이하에서의 중복 로그

React 18까지는 렌더링 중 발생한 에러가 한 번은 React 내부적으로, 또 한 번은 componentDidCatch에서 콘솔에 찍히는 경우가 흔했습니다. Sentry 같은 모니터링 서비스와 연동해두면 같은 에러가 리포트에 두 번 잡히는 일이 잦았죠.

React 19의 변화

<cite index="33-1">React 19는 렌더링 중 발생한 에러를 다시 던지지 않는 방식으로 중복을 줄였습니다. Error Boundary에 잡히지 않은 에러는 window.reportError로, Error Boundary에 잡힌 에러는 console.error로 보고됩니다.</cite> 그리고 이를 세밀하게 제어할 수 있도록 createRoot/hydrateRoot에 새로운 콜백 옵션이 추가되었습니다.

import { createRoot } from 'react-dom/client';

const root = createRoot(document.getElementById('app'), {
  // Error Boundary가 잡지 못한 에러
  onUncaughtError: (error, errorInfo) => {
    reportToMonitoring(error, errorInfo.componentStack, { severity: 'critical' });
  },
  // Error Boundary가 정상적으로 잡은 에러
  onCaughtError: (error, errorInfo) => {
    reportToMonitoring(error, errorInfo.componentStack, { severity: 'handled' });
  },
});

root.render(<App />);

이 구조의 장점은 로깅 로직을 각 ErrorBoundary의 componentDidCatch에 흩어놓지 않고 루트 한 곳으로 모을 수 있다는 점입니다. 개별 Error Boundary는 폴백 UI를 그리는 역할에만 집중하고, "이 에러를 어디로 보고할지"는 앱 진입점에서 한 번만 결정하면 됩니다.

class ErrorBoundary extends React.Component {
  state = { error: null };

  static getDerivedStateFromError(error) {
    return { error };
  }

  // componentDidCatch에서 별도로 로깅하지 않는다.
  // 로깅은 root의 onCaughtError가 이미 담당하므로 여기서 또 하면 다시 중복이 발생한다.

  render() {
    if (this.state.error) {
      return this.props.fallback;
    }
    return this.props.children;
  }
}

⚠️ 흔한 실수: onCaughtError를 루트에 설정해두고서도, 개별 ErrorBoundary의 componentDidCatch 안에 예전 습관대로 Sentry.captureException을 또 넣어두는 경우가 많습니다. 이러면 React 19가 줄여준 중복을 개발자가 다시 만들어내는 셈입니다. 로깅 지점은 하나로 통일하세요.

Sentry 등 서드파티와 연동할 때 주의점

<cite index="31-1">onUncaughtError나 onCaughtError를 직접 설정하면 React의 기본 개발 모드 로깅이 함께 덮어써진다는 이슈</cite>가 보고된 적이 있습니다. 즉, 커스텀 핸들러를 등록하는 순간 React가 기본으로 찍어주던 개발자 친화적인 콘솔 메시지가 사라질 수 있습니다. 프로덕션에서는 문제없지만, 로컬 개발 환경에서 디버깅 편의성이 떨어졌다면 이 트레이드오프를 의심해보세요. Sentry는 이 문제를 Sentry.reactErrorHandler() 유틸리티로 감싸 기본 로깅을 보존하는 방식으로 대응하고 있습니다.

형제 컴포넌트에서의 중복도 함께 줄었다

<cite index="35-1">같은 Error Boundary 아래 형제 컴포넌트 여러 개가 동시에 에러를 던지는 상황에서도, React 19는 첫 번째 에러만 로깅하고 이후 렌더링을 중단합니다.</cite> 첫 에러 이후의 형제 컴포넌트는 어차피 같은 Error Boundary에 의해 폴백으로 대체되므로 렌더링을 계속할 이유가 없다는 논리입니다. React 18에서 이런 케이스마다 직접 디바운스나 dedup 로직을 짜야 했다면, React 19에서는 그런 방어 코드를 상당 부분 걷어낼 수 있습니다.


4. 세 가지를 하나의 컴포넌트에 통합하기

지금까지의 패턴을 모두 합치면 실전에서 쓸 수 있는 검색 컴포넌트는 이런 모습이 됩니다.

function SearchBox() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();
  const abortControllerRef = useRef(null);
  const requestIdRef = useRef(0); // 중복 요청 순서 보정용

  function handleChange(nextQuery) {
    setQuery(nextQuery);

    abortControllerRef.current?.abort(); // 이전 요청 취소
    const controller = new AbortController();
    abortControllerRef.current = controller;
    const requestId = ++requestIdRef.current;

    startTransition(async () => {
      try {
        const res = await fetch(`/api/search?q=${nextQuery}`, { signal: controller.signal });
        const data = await res.json();

        // 취소되지 않았더라도, 더 늦게 시작된 요청이 먼저 끝났다면 무시
        if (requestId === requestIdRef.current) {
          setResults(data);
        }
      } catch (error) {
        if (error.name === 'AbortError') return;
        throw error; // 로깅은 root의 onCaughtError가 전담
      }
    });
  }

  useEffect(() => {
    return () => abortControllerRef.current?.abort();
  }, []);

  return (
    <ErrorBoundary fallback={<p>검색 중 오류가 발생했습니다.</p>}>
      <input value={query} onChange={(e) => handleChange(e.target.value)} />
      {isPending && <Spinner />}
      <ResultsList results={results} />
    </ErrorBoundary>
  );
}

requestIdRef까지 더한 이유는, AbortController로 취소했더라도 네트워크 계층에 따라 완전히 취소되지 않고 응답이 뒤늦게 도착하는 경우를 한 번 더 방어하기 위함입니다. "이 응답이 아직 유효한 최신 요청의 결과인가" 를 스스로 확인하는 이 패턴은, AbortController가 지원되지 않는 환경이나 서드파티 SDK를 감싸야 하는 상황에서 특히 유용합니다.


5. 정리: 체크리스트

문제 원인 해결

중복 요청 isPending이 렌더링을 거쳐야 반영됨 useRef 기반 즉시 가드 + 서버 idempotency key
트랜지션이 취소 안 됨 startTransition은 우선순위만 관리, 프로미스 생명주기는 미관여 AbortController를 직접 생성·연결
취소를 에러로 오인 AbortError를 다른 에러와 동일하게 처리 catch 블록에서 error.name === 'AbortError' 분기
중복 에러 로깅 componentDidCatch와 React 내부 로그가 중복 React 19의 onCaughtError/onUncaughtError로 로깅 지점 일원화
늦게 도착한 stale 응답 취소가 네트워크 계층까지 완전히 전파되지 않음 요청 ID를 비교해 최신 응답만 반영

이 패턴들의 공통된 교훈은, React 19의 Actions·트랜지션이 "보일러플레이트를 줄여주는 것"이지 "동시성 문제를 대신 풀어주는 마법"은 아니라는 점입니다. isPending, startTransition은 여전히 개발자가 취소·중복 방지·로깅 전략을 명시적으로 설계해야 하는 얇은 편의 계층 위에 얹혀 있습니다. 편리해진 API 뒤에서 실제로 무슨 일이 일어나는지 이해하고 있어야, 이런 엣지 케이스를 만났을 때 당황하지 않고 대응할 수 있습니다.


이 시리즈는 여기서 마무리합니다. Error Boundary 기초부터 react-error-boundary 내부 구현, React 19 Actions, 그리고 오늘의 프로덕션 엣지 케이스까지 이 흐름이 실무에서 에러 핸들링 코드를 설계할 때 도움이 되길 바랍니다.