React Query v5 업데이트: onSuccess, onError로 구현하는 효율적 서비스 핸들링 전략

kiwon kim·2025년 2월 12일

Frontend

목록 보기
22/30
post-thumbnail

1. 서론

React Query(현재는 TanStack Query)는 비동기 데이터 fetching을 위한 강력한 라이브러리로, 서버와의 통신 및 데이터 캐싱, 상태 관리 등을 간편하게 처리할 수 있습니다.
이전 버전(v4 이하)에서는 useQuery 훅 자체에 onSuccess, onError와 같은 옵션을 제공해 데이터 fetching 후의 사이드 이펙트를 손쉽게 구현할 수 있었지만, v5부터는 이 옵션들이 deprecated되었습니다.
그 이유는 데이터 fetching 로직과 부수 효과(side effect) 로직을 명시적으로 분리하여 코드의 예측 가능성과 유지보수성을 높이기 위함입니다.
이번 포스트에서는 v5에서의 useQuery 사용법과 함께, 잘못된 구현으로 인해 발생할 수 있는 문제와 이를 해결하기 위한 커스텀 훅 구현 패턴(useAfterQuery)을 상세히 살펴보겠습니다.


2. v5 이전 방식과 Deprecated 사유

2-1. 이전 방식: onSuccess, onError 옵션

v4 이하에서는 useQuery 훅을 아래와 같이 사용하여, 옵션에 onSuccess와 onError 콜백을 전달할 수 있었습니다.

// v4 이하 사용 예시
const queryResult = useQuery<Data, Error>(["data"], fetchData, {
  onSuccess: (data) => console.log("데이터 성공:", data),
  onError: (error) => console.error("에러 발생:", error),
});

2-2. Deprecated된 이유

v5에서는 위 방식이 deprecated 되었는데, 그 주된 이유는 다음과 같습니다.

  • 사이드 이펙트의 명시적 분리 부족
    쿼리 옵션에 사이드 이펙트 관련 로직을 포함하면 데이터 fetching과 부수 효과가 암묵적으로 결합되어 컴포넌트의 책임이 모호해집니다.

  • 리렌더링 및 의존성 관리 문제
    부모 컴포넌트에서 inline 방식으로 onSuccess, onError 콜백을 전달할 경우, 매 렌더링마다 새로운 함수 참조가 생성되어 useEffect 의존성 배열에 의해 반복 실행될 위험이 있습니다.

  • 테스트와 예측 가능성의 저해
    내부적으로 자동 실행되는 사이드 이펙트는 실행 순서와 타이밍을 예측하기 어렵게 만들어 디버깅 및 유지보수를 어렵게 합니다.

따라서 v5에서는 데이터 fetching과 사이드 이펙트 처리를 분리하여, 개발자가 useEffect나 커스텀 훅을 이용해 명시적으로 제어하도록 유도합니다.


3. 문제 상황: 잘못된 구현으로 인한 무한 호출

3-1. inline 콜백과 의존성 배열 문제

아래와 같이 부모 컴포넌트에서 onSuccess와 onError 콜백을 inline으로 정의하게 되면,
매 렌더링마다 새로운 함수가 생성되어 useEffect의 의존성 배열이 계속 변경됩니다.
이로 인해 상태 업데이트가 반복되어 무한 호출(Infinite Loop)로 이어지고, 결국 Maximum Call Stack Exceeded 에러를 발생시킬 수 있습니다.

3-2. 잘못된 커스텀 훅 구현 예시: useAfterQueryWrong

import { QueryObserverResult } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { useEffect } from "react";

interface QueryCallbacks<TData, TError> {
  queryResult: QueryObserverResult<TData, TError>;
  enabled: boolean;
  onSuccess?: (data: TData) => void;
  onError?: (error: TError) => void;
}

/**
 * useAfterQueryWrong
 *
 * 쿼리 결과가 성공 혹은 에러 상태일 때마다 무조건 콜백을 실행합니다.
 * 부모 컴포넌트에서 inline 콜백을 전달하면, 매 렌더링마다 새로운 함수 참조가 전달되어
 * useEffect가 반복 실행되고, 상태 업데이트로 인해 무한 호출이 발생할 수 있습니다.
 */
export function useAfterQueryWrong<TData = unknown, TError = AxiosError<{ message: string }>>({
  queryResult,
  enabled,
  onSuccess,
  onError,
}: QueryCallbacks<TData, TError>) {
  useEffect(() => {
    if (!enabled) return;
    if (queryResult.isSuccess && onSuccess) {
      onSuccess(queryResult.data);
    }
  }, [enabled, queryResult.isSuccess, queryResult.data, onSuccess]);

  useEffect(() => {
    if (!enabled) return;
    if (queryResult.isError && onError) {
      onError(queryResult.error);
    }
  }, [enabled, queryResult.isError, queryResult.error, onError]);
}

3-3. 무한 호출을 발생시키는 컴포넌트 예시

import React, { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { useAfterQueryWrong } from "./useAfterQueryWrong";

interface Data {
  id: number;
  name: string;
}

async function fetchData(): Promise<Data> {
  const response = await axios.get<Data>("https://example.com/api/data");
  return response.data;
}

export default function BadComponent() {
  const [message, setMessage] = useState("");
  const queryResult = useQuery({
    queryKey: ["data"],
    queryFn: fetchData,
    enabled: true,
  });

  // onSuccess와 onError를 inline으로 정의 (매 렌더마다 새로운 함수가 생성됨)
  useAfterQueryWrong({
    queryResult,
    enabled: true,
    onSuccess: (data) => {
      // 상태 업데이트로 인해 컴포넌트가 리렌더링되어 무한 루프 발생 가능
      setMessage(`데이터를 받았습니다: ${data.name}`);
    },
    onError: (error) => {
      setMessage(`에러 발생: ${error.message}`);
    },
  });

  return <div>{message || "로딩중..."}</div>;
}

위 예제와 같이 inline 콜백을 사용하면, 컴포넌트 리렌더링이 반복되어 무한 호출로 이어질 수 있습니다.


4. 해결 방법: useRef와 useCallback을 활용한 최적의 패턴

문제를 해결하기 위한 두 가지 핵심 포인트는 다음과 같습니다.

  • 이전 값 비교를 위한 useRef 사용
    useRef를 활용하면 값 변경이 컴포넌트 리렌더링을 유발하지 않으면서, 이전 데이터나 에러 값을 저장할 수 있습니다.
    이를 통해 실제 값이 변경된 경우에만 onSuccess 또는 onError 콜백이 실행되도록 할 수 있습니다.

  • 부모 컴포넌트의 콜백 함수 메모이제이션 (useCallback)
    부모 컴포넌트에서 onSuccess, onError 콜백을 useCallback으로 감싸면, 매 렌더링마다 새로운 함수 참조가 생성되지 않아 불필요한 useEffect 재실행을 방지할 수 있습니다.

4-1. 올바른 커스텀 훅 구현: useAfterQuery

import { QueryObserverResult } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { useEffect, useRef } from "react";

interface QueryCallbacks<TData, TError> {
  queryResult: QueryObserverResult<TData, TError>;
  /** useQuery의 enabled 옵션 값을 전달합니다. */
  enabled: boolean;
  onSuccess?: (data: TData) => void;
  onError?: (error: TError) => void;
}

/**
 * useAfterQuery
 *
 * 전달된 queryResult의 상태(isSuccess, isError)가 변경될 때마다
 * onSuccess와 onError 콜백을 실행합니다.
 * 단, useQuery의 enabled가 true일 때만 실행하며,
 * 동일한 데이터나 에러에 대해 중복 실행되지 않도록 이전 값을 비교합니다.
 */
export function useAfterQuery<TData = unknown, TError = AxiosError<{ message: string }>>({
  queryResult,
  enabled,
  onSuccess,
  onError,
}: QueryCallbacks<TData, TError>) {
  // 이전 데이터와 에러를 저장할 ref
  const prevDataRef = useRef<TData | undefined>(undefined);
  const prevErrorRef = useRef<TError | undefined>(undefined);

  // 쿼리 성공 시, 데이터가 변경되었을 경우에만 onSuccess 실행
  useEffect(() => {
    if (!enabled) return;
    if (queryResult.isSuccess && onSuccess) {
      if (prevDataRef.current !== queryResult.data) {
        onSuccess(queryResult.data);
        prevDataRef.current = queryResult.data;
      }
    }
  }, [enabled, queryResult.isSuccess, queryResult.data, onSuccess]);

  // 쿼리 에러 시, 에러가 변경되었을 경우에만 onError 실행
  useEffect(() => {
    if (!enabled) return;
    if (queryResult.isError && onError) {
      if (prevErrorRef.current !== queryResult.error) {
        onError(queryResult.error);
        prevErrorRef.current = queryResult.error;
      }
    }
  }, [enabled, queryResult.isError, queryResult.error, onError]);
}

4-2. 올바른 사용 예시: GoodComponent (v5 스타일 적용)

v5부터는 useQuery 훅의 옵션을 배열이 아닌 객체 형태로 전달합니다.
아래 예시는 useCallback을 사용해 onSuccess, onError 콜백을 메모이제이션하고, 커스텀 훅 useAfterQuery를 이용해 안전하게 사이드 이펙트를 처리하는 방식입니다.

import React, { useState, useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { useAfterQuery } from "./useAfterQuery";

interface Data {
  id: number;
  name: string;
}

async function fetchData(): Promise<Data> {
  const response = await axios.get<Data>("https://example.com/api/data");
  return response.data;
}

export default function GoodComponent() {
  const [message, setMessage] = useState("");

  // v5 스타일: useQuery에 객체 형태로 옵션 전달
  const queryResult = useQuery({
    queryKey: ["data"],
    queryFn: fetchData,
    enabled: true,
  });

  // 콜백 함수를 useCallback으로 메모이제이션하여 참조 안정성을 확보
  const handleSuccess = useCallback((data: Data) => {
    setMessage(`데이터를 받았습니다: ${data.name}`);
  }, []);

  const handleError = useCallback((error: Error) => {
    setMessage(`에러 발생: ${error.message}`);
  }, []);

  // 커스텀 훅을 통해 쿼리 결과에 따른 사이드 이펙트를 안전하게 처리
  useAfterQuery({
    queryResult,
    enabled: true,
    onSuccess: handleSuccess,
    onError: handleError,
  });

  return <div>{message || "로딩중..."}</div>;
}

5. 결론

이번 포스트에서는 React Query를 이용해 데이터를 fetching한 후,
onSuccess와 onError 콜백을 활용하여 서비스를 유연하게 핸들링하는 방법에 대해 자세히 살펴보았습니다.

  • v5 이전에는 useQuery 옵션으로 onSuccess, onError를 제공했지만,
    데이터 fetching과 부수 효과가 암묵적으로 결합되어 테스트, 유지보수, 리렌더링 문제 등이 발생할 수 있었습니다.
  • v5부터는 onSuccess, onError 옵션이 deprecated됨에 따라,
    개발자가 useEffect나 커스텀 훅(예: useAfterQuery)을 이용해 사이드 이펙트를 명시적으로 처리하도록 권장하고 있습니다.
  • 문제 해결를 위해, useRef를 사용해 이전 값과 비교하고,
    부모 컴포넌트에서는 useCallback을 사용해 함수 참조의 안정성을 확보함으로써 무한 호출 및 Maximum Call Stack Exceeded 에러를 방지할 수 있습니다.
  • 또한, v5 스타일에 맞춰 useQuery 훅을 객체 형태로 사용함으로써, 코드의 가독성과 명시성이 향상되었습니다.

이와 같은 최신 패턴을 적용하면, 보다 깔끔하고 예측 가능한 코드로 유지보수가 용이한 애플리케이션을 개발할 수 있습니다.
React Query와 같은 최신 도구를 활용해 여러분의 서비스 로직을 유연하고 안정적으로 구현해보시기 바랍니다.

참고 자료:

Happy Coding!

profile
FOR_THE_BEST_DEVELOPER

0개의 댓글