
React Query(현재는 TanStack Query)는 비동기 데이터 fetching을 위한 강력한 라이브러리로, 서버와의 통신 및 데이터 캐싱, 상태 관리 등을 간편하게 처리할 수 있습니다.
이전 버전(v4 이하)에서는 useQuery 훅 자체에 onSuccess, onError와 같은 옵션을 제공해 데이터 fetching 후의 사이드 이펙트를 손쉽게 구현할 수 있었지만, v5부터는 이 옵션들이 deprecated되었습니다.
그 이유는 데이터 fetching 로직과 부수 효과(side effect) 로직을 명시적으로 분리하여 코드의 예측 가능성과 유지보수성을 높이기 위함입니다.
이번 포스트에서는 v5에서의 useQuery 사용법과 함께, 잘못된 구현으로 인해 발생할 수 있는 문제와 이를 해결하기 위한 커스텀 훅 구현 패턴(useAfterQuery)을 상세히 살펴보겠습니다.
v4 이하에서는 useQuery 훅을 아래와 같이 사용하여, 옵션에 onSuccess와 onError 콜백을 전달할 수 있었습니다.
// v4 이하 사용 예시
const queryResult = useQuery<Data, Error>(["data"], fetchData, {
onSuccess: (data) => console.log("데이터 성공:", data),
onError: (error) => console.error("에러 발생:", error),
});
v5에서는 위 방식이 deprecated 되었는데, 그 주된 이유는 다음과 같습니다.
사이드 이펙트의 명시적 분리 부족
쿼리 옵션에 사이드 이펙트 관련 로직을 포함하면 데이터 fetching과 부수 효과가 암묵적으로 결합되어 컴포넌트의 책임이 모호해집니다.
리렌더링 및 의존성 관리 문제
부모 컴포넌트에서 inline 방식으로 onSuccess, onError 콜백을 전달할 경우, 매 렌더링마다 새로운 함수 참조가 생성되어 useEffect 의존성 배열에 의해 반복 실행될 위험이 있습니다.
테스트와 예측 가능성의 저해
내부적으로 자동 실행되는 사이드 이펙트는 실행 순서와 타이밍을 예측하기 어렵게 만들어 디버깅 및 유지보수를 어렵게 합니다.
따라서 v5에서는 데이터 fetching과 사이드 이펙트 처리를 분리하여, 개발자가 useEffect나 커스텀 훅을 이용해 명시적으로 제어하도록 유도합니다.
아래와 같이 부모 컴포넌트에서 onSuccess와 onError 콜백을 inline으로 정의하게 되면,
매 렌더링마다 새로운 함수가 생성되어 useEffect의 의존성 배열이 계속 변경됩니다.
이로 인해 상태 업데이트가 반복되어 무한 호출(Infinite Loop)로 이어지고, 결국 Maximum Call Stack Exceeded 에러를 발생시킬 수 있습니다.
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]);
}
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 콜백을 사용하면, 컴포넌트 리렌더링이 반복되어 무한 호출로 이어질 수 있습니다.
문제를 해결하기 위한 두 가지 핵심 포인트는 다음과 같습니다.
이전 값 비교를 위한 useRef 사용
useRef를 활용하면 값 변경이 컴포넌트 리렌더링을 유발하지 않으면서, 이전 데이터나 에러 값을 저장할 수 있습니다.
이를 통해 실제 값이 변경된 경우에만 onSuccess 또는 onError 콜백이 실행되도록 할 수 있습니다.
부모 컴포넌트의 콜백 함수 메모이제이션 (useCallback)
부모 컴포넌트에서 onSuccess, onError 콜백을 useCallback으로 감싸면, 매 렌더링마다 새로운 함수 참조가 생성되지 않아 불필요한 useEffect 재실행을 방지할 수 있습니다.
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]);
}
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>;
}
이번 포스트에서는 React Query를 이용해 데이터를 fetching한 후,
onSuccess와 onError 콜백을 활용하여 서비스를 유연하게 핸들링하는 방법에 대해 자세히 살펴보았습니다.
이와 같은 최신 패턴을 적용하면, 보다 깔끔하고 예측 가능한 코드로 유지보수가 용이한 애플리케이션을 개발할 수 있습니다.
React Query와 같은 최신 도구를 활용해 여러분의 서비스 로직을 유연하고 안정적으로 구현해보시기 바랍니다.
참고 자료:
Happy Coding!