React 19 'use' API: useMemo가 터지는 이유와 안전한 프로미스 캐싱 패턴

Maru

@maru

React 19 'use' API: useMemo가 터지는 이유와 안전한 프로미스 캐싱 패턴

React 19 'use' API: useMemo가 터지는 이유와 안전한 프로미스 캐싱 패턴

React 19에 도입된 use API는 컴포넌트 렌더링 도중 비동기 프로미스를 직접 해결할 수 있는 강력한 도구입니다. 하지만 프로미스 참조가 안정적으로 유지되지 않으면 무한 재렌더링 루프에 빠지거나, 믿었던 useMemo 캐시가 무용지물이 되는 성능 문제를 마주하게 됩니다. 이번 글에서는 use API의 캐시 폐기 메커니즘을 살펴보고, 컴포넌트 마운트 중단에도 끄떡없는 안전한 프로미스 제어 패턴을 알아봅니다.

무한 재렌더링의 원인: 메모되지 않은 프로미스

컴포넌트 안에서 메모이징되지 않은 프로미스를 use API에 직접 전달하면 브라우저가 일시적으로 멈추는 무한 재렌더링 루프에 빠지게 됩니다. React 19 공식 문서에 따르면, 비동기 데이터 페칭 함수를 컴포넌트 본문이나 use 함수 내부에서 캐싱 없이 호출해 프로미스를 생성할 때 이러한 현상이 발생합니다.

원인은 React의 일시 중단 메커니즘에 있습니다. 전달된 프로미스가 아직 해결되지 않은 대기 상태일 때, React는 컴포넌트 렌더링을 즉시 중단하고 제어권을 가장 가까운 Suspense 영역으로 넘깁니다. 이후 비동기 작업이 완료되면 다시 렌더링을 시도합니다.

진짜 문제는 렌더링을 다시 시도하는 시점에 발생합니다. 컴포넌트 함수가 처음부터 다시 실행되면서 매번 새로운 프로미스 인스턴스를 생성하기 때문입니다. React는 이를 또 다른 새로운 비동기 작업으로 인식해 다시 렌더링을 중단하며, 이로 인해 중단과 재시도가 무한히 반복되는 늪에 갇힙니다. 이때 개발자 도구에는 캐싱되지 않은 프로미스로 인해 컴포넌트가 일시 중단되었다는 명확한 경고 메시지가 출력됩니다.

useMemo의 한계: 컴포넌트 마운트 중단 시 캐시 폐기 메커니즘

프로미스 참조를 안정적으로 유지하기 위해 useMemouseCallback을 선택하는 것은 React 19에서 무한 루프를 막지 못하는 가장 흔한 실수 중 하나입니다. React 공식 명세에 따르면, 컴포넌트가 최초 마운트되는 도중에 비동기 작업으로 인해 렌더링이 일시 중단되면 React는 해당 마운트 단계에서 생성 중이던 캐시를 완전히 폐기하도록 설계되어 있습니다.

아직 컴포넌트가 완전히 마운트되지 않은 불완전한 상태이기 때문에, React가 렌더링 시도를 취소하고 리소스를 정리하는 동작입니다. 문제는 use API에 완료되지 않은 프로미스를 넘기면 컴포넌트가 무조건 일시 중단된다는 점입니다. 이로 인해 마운트가 중단되면서 프로미스를 잡아두려던 useMemo 캐시도 함께 사라집니다.

이후 프로미스가 해결되어 React가 마운트를 재시도할 때는 기존 캐시가 이미 비어 있는 상태입니다. 결국 useMemo 내부의 연산이 다시 실행되면서 새로운 프로미스가 만들어지고, 또다시 일시 중단과 캐시 폐기가 끊임없이 반복되는 늪에 빠지게 됩니다. 따라서 마운트 중단 생명주기 속에서도 살아남을 수 있는 안전한 대체재가 필수적입니다.

안전한 캐싱 솔루션: useState 지연 초기화 함수 활용법

컴포넌트 범위에서 프로미스 참조를 안전하게 관리하는 가장 확실한 대안은 useState 지연 초기화 함수를 사용하는 것입니다.

useMemo와 달리 useState 내부의 초기화 함수는 컴포넌트가 일시 중단되기 직전인 초기 마운트 단계에서 단 한 번만 실행됩니다. 덕분에 비동기 작업으로 인해 컴포넌트 렌더링이 일시 중단되고 이후 재시도가 발생하더라도, React는 매번 완전히 동일한 프로미스 참조를 일관되게 보장하여 무한 루프 문제를 깔끔하게 예방합니다.

만약 API 호출의 기준이 되는 props가 변경된다면 아래 코드와 같이 렌더링 단계에서 동기적으로 상태를 업데이트하면 됩니다. 일시 중단 도중에는 useEffect가 실행되지 않기 때문에, 렌더링 과정에서 직접 이전 props와 비교하여 상태를 갱신하는 것이 안전합니다.

javascript
import { useState, use } from 'react';

function UserProfile({ userId }) {
  // 마운트 시 최초 1회만 프로미스를 생성하여 안전하게 보관
  const [state, setState] = useState(() => ({
    userId,
    promise: fetchUserData(userId)
  }));

  let currentPromise = state.promise;

  // props가 변경되면 렌더링 단계에서 동기적으로 새 프로미스로 업데이트
  if (userId !== state.userId) {
    currentPromise = fetchUserData(userId);
    setState({ userId, promise: currentPromise });
  }

  // use API를 통해 안전하게 프로미스 해결
  const user = use(currentPromise);

  return <div>{user.name}</div>;
}

이 패턴을 활용하면 컴포넌트 자체를 자립적인 데이터 페칭 단주기로 구축할 수 있습니다. 상위 컴포넌트나 외부 전역 스토어로 상태 관리 대상을 호이스팅하여 관리 범위를 넓히는 것도 상황에 따라 유용한 대안이 될 수 있습니다.

실패한 프로미스의 처리: 에러 바운더리 연동과 중단 생명주기

React 19는 use API에 전달된 프로미스가 거부되었을 때 이를 렌더링 단계에서 직접 감지하여 효율적으로 처리합니다. 프로미스 거부가 발생하면 React는 즉시 해당 트리의 렌더링 작업을 중단합니다. 이는 비동기 렌더링 중 예외가 발생했을 때 불필요한 하위 형제 컴포넌트들까지 일단 끝까지 렌더링을 시도하던 React 18의 낭비적인 동작을 근본적으로 해결한 변화입니다.

이러한 중단 메커니즘 덕분에 불필요한 연산과 무의미한 자식 컴포넌트 마운트를 완전히 방지할 수 있습니다. 렌더링 단계에서 가로챈 프로미스 거부 상태는 일반적인 컴포넌트 런타임 에러와 동일한 흐름으로 다루어집니다. 따라서 해당 에러는 상위 컴포넌트 트리에서 가장 가까운 에러 바운더리로 즉시 전파됩니다.

결과적으로 개발자는 컴포넌트 내부에서 비동기 실패 분기 처리를 구현하기 위해 장황한 예외 처리 코드를 선언할 필요가 없습니다. 최상단에 배치된 에러 바운더리의 fallback 속성을 통해 네트워크 오류나 API 실패에 대응하는 선언적인 예외 처리 화면을 아주 깔끔하게 구현할 수 있습니다.

프로덕션 적용을 위한 핵심 체크리스트

React 19의 use API를 프로덕션에 안전하게 도입하려면 전통적인 메모이제이션 공식에서 완전히 벗어나야 합니다. useMemo 대신 useState 지연 초기화 함수를 사용해 최초 마운트 중단 시점에도 안정적으로 프로미스가 캐싱되도록 설계하고, 복잡한 비동기 흐름은 TanStack Query 같은 전문 라이브러리의 통합 캐싱 엔진에 위임하는 것이 현명합니다. 마지막으로 프로미스 거부로 인한 렌더링 중단 현상이 실제 장애로 이어지지 않도록 상위에 에러 바운더리를 단단히 구성해야 합니다. 이 몇 가지 핵심 규칙만 준수한다면 무한 루프 걱정 없이 렌더링 단계에서 안전하고 우아한 비동기 처리를 선언적으로 구현할 수 있습니다.


참고 링크

아직 댓글이 없습니다.