React

React 18: use 훅과 Suspense로 구현하는 선언적 데이터 페칭

고전적인 useEffect 방식에서 벗어나 use 훅을 활용한 현대적 React 패턴으로 전환하기

2026년 02월 03일
Reading Time : 9

✨ 들어가며

최근 React 생태계는 어떻게(How) 데이터를 가져올 것인가 보다 무엇을(What) 보여줄 것인가 에 집중하는 방향으로 진화하고 있다.

과거에는 useStateuseEffect를 조합해 로딩 상태를 수동으로 관리하는 방식이 일반적이었다.
하지만 React 18부터는 데이터 페칭 로직을 렌더링 모델에 자연스럽게 녹여낼 수 있는 선언적인 패턴을 제공한다.

이번에 졸업 작품인 Fresh-Plate 페이지의 비동기 fetch 로직을 use + Suspense조합으로 변경하며 겪은 시행착오를 정리해보고자 한다.

🔗 고전적인 방식: 명령형 데이터 페칭

가장 흔히 사용되던 방식은 컴포넌트 마운트 시점에 useEffect에서 비동기 요청을 수행하고 이를 useState로 관리하는 패턴이다.

tsx
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
 
useEffect(() => {
  setIsLoading(true);
  fetchData().then((res) => {
    setData(res);
    setIsLoading(false);
  });
}, []);
 
if (isLoading) return <Spinner />;
return <div>{data.name}</div>;

한계점

  • 로딩/성공/실패 상태가 컴포넌트 내부에 파편화
  • 비즈니스 로직UI 로직이 강하게 결합됨
  • 컴포넌트가 무엇을 렌더링할지보다 언제 데이터를 가져올지에 더 집중하게 됨

나같은 경우, 데이터의 양이 방대하거나 복잡한 상태관리 로직을 필요로 하지 않음에도 불구하고 그냥 귀찮아서(...) 리액트 쿼리RHF 같은 라이브러리를 사용했었다.

사실 이러면 안되는데 시간도 촉박하고 졸업 작품의 압박감 때문에 실험적인 코드보다 익숙한 방식을 택했었다.

🔗 현대적인 방식: use 훅과 Suspense

이제 학기도 마쳤겠다 기존의 불필요하게 번들 사이즈를 키우는 라이브러리들을 걷어내고, React 18+의 use 훅과 Suspense를 활용한 선언적 데이터 페칭으로 리팩토링을 시도했다.

React 18+의 use 훅은 Promise를 직접 받아, 데이터가 준비될 때까지 렌더링을 일시 중단(suspend)한다.
로딩 처리는 컴포넌트가 아닌 Suspense 경계가 담당한다.

이를 통해 기존의 리액트 쿼리axios등의 라이브러리를 걷어내고 빌트인 기능만으로 깔끔한 데이터 페칭 로직을 구현할 수 있었다.

tsx
// axios instance를 대체하는 serverApiClient를 작성했다.
 
// next의 api route
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const restaurantId = searchParams.get("restaurantId");
 
  try {
    const data = await serverApiClient.get(`api/restaurant/${restaurantId}/reviews`);
    const list = data?.reviews ?? [];
    return NextResponse.json(list);
  } catch (error) {
    return NextResponse.json([], { status: 500 });
  }
}
 
// reviews.tsx 내부 데이터 페칭 함수
const reviewsPromise = useMemo(
  () => fetch(`/api/reviews?restaurantId=${restaurant.id}`).then((res) => res.json()),
  [restaurant.id]
);

위와 같이 api route를 작성하고, 클라이언트에서 이를 호출할 때 async/await 대신 Promise 자체를 반환하도록 하여 use 훅에 전달한다.
그러면 use 훅이 해당 Promise가 해결될 때까지 컴포넌트의 렌더링을 중단하는 구조이다.

이 방식의 이점

  • (이전 방식 한정) 데이터 수만큼 필요로 하던 isLoading, error등의 상태값을 제거하여 복잡도를 낮추고, 데이터가 있을 때의 로직에만 집중할 수 있음
  • 로딩 UI는 Suspense 경계에서 한 번만 정의하면 되므로, 중복 코드 제거재사용성 향상
  • use 훅을 통해 비동기 호출 결과를 변수에 바로 할당하는 것처럼 쓸 수 있어 가독성을 향상시키고 보다 동기적인 코드처럼 작성 가능

🔗 실전에서 만난 문제들

1. Promise 무한 루프

Promise는 참조형 객체이기 떄문에, 매 렌더링마다 새로운 참조가 생성되면 use 훅은 이를 새로운 요청으로 인식한다.

tsx
const dataPromise = fetchData(); // ❌ 렌더링마다 새로운 Promise 생성

이는 Suspense -> 리렌더링 -> Suspense 와 같은 무한 루프를 초래한다.
때문에 동일한 Promise 참조를 유지하기 위해 useMemo등의 훅을 활용할 수 있다.

tsx
const dataPromise = useMemo(() => fetchData(), []);

이러면 리액트가 동일한 비동기 처리 로직으로 인식한다.
부가적으로 useMemo 훅 사용에 따른 비용은 무시할 수 있을 정도로 미미하다.

💡추가적으로 알게 된 부분인데, 상위 로직에서 Activity(최신 리액트의 분기처리 훅)을 사용하면
display: hidden 속성과 같은 효과를 가지게 되어 화면에 보이지 않아도 내부적으로 렌더링이 계속 발생할 수 있다.
Activity 함수는 가급적이면 내부에 페칭 로직을 포함하지 않는 컴포넌트 분기 처리에만 활용하자!
(덕분에 오늘치 카카오 place api 호출 횟수를 날려먹었다..)

2. Server Action과의 충돌

렌더링 도중 비동기 처리를 수행하는 use 훅은 Server Action과 함께 사용할 때 충돌이 발생할 수 있다.

md
[Error] Cannot update a component while rendering a different component

이는 Server Action이 내부적으로 상태 변경을 동반하는데, 동시에 use 훅이 렌더링 단계에서 실행되어 리액트의 규칙에 위반되기 때문이다.

이를 해결하기 위해 위에 작성했던 Next.jsRoute Handler를 활용하여 데이터 조회 로직을 구성하고 실질적으로 변경을 수행하는 함수만 Server Action으로 분리하는 방식을 택했다.

🔗 결론

💡선언적 프로그래밍은 코드를 줄이기 위한 기법이 아니라, 데이터 흐름을 더 예측 가능하게 만들기 위한 설계 방식이다.

use 훅과 Suspense는 강력한 도구지만, JS의 참조 특성과 React의 렌더링 모델을 정확히 이해하지 못하면 무한 루프런타임 에러로 이어질 수 있다.

기술을 최신화하는 것만큼이나, 기초적인 개념들을 정확히 이해하는 것이 중요하다는 것을 다시 한번 느낄 수 있었다.