TanStack Query, 직접 구현해보기(2) - useQuery, useMutation 구현

재영·2025년 6월 30일
post-thumbnail

들어가며

지난 1편에서는 TanStack Query를 직접 구현하게 된 배경을 정리했다.
이번 글에서는 주요 기능을 직접 구현하면서, 내부 구조가 어떻게 구성되어 있는지도 함께 살펴보려고 한다.

이번 글에서 구현할 주요 기능

  • useQuery
    • Data fetching: isLoading, isError, data 제공
    • Query Key 기반 데이터 캐싱
    • useSyncExternalStore를 이용한 자동 리패칭
  • useMutation: 데이터를 생성, 수정, 삭제하는 등의 작업에 사용되는 훅
    • 리패칭 대상 지정 가능
    • onSuccess / onErro 콜백

핵심 개념 먼저 짚고 가기

먼저 구현할 기능들에 대한 개념과 사용법에 대해서 간단히 알아보고 넘어가자.

useQuery

사용 방법
const { data, isLoading, isError } = useQuery({ queryKey, queryFn })
Parameter (Options)
  • queryKey
    • 쿼리를 고유하게 식별하기 위한 값이다.
    • 배열 형태로 지정한다.
    • 이 키가 변경되면 쿼리가 자동으로 업데이트된다.
  • queryFn
    • 데이터를 가져오는 비동기 함수이다.
    • 꼭 데이터를 반환하거나 오류를 던져야한다.
Returns
  • data: 성공적으로 가져온 데이터
  • isLoading: 데이터 가져오기가 진행 중인지 여부
  • isError: 쿼리 함수에서의 오류 발생 여부

useMutation

사용 방법
const { mutate } = useMutation({ mutationFn, onSuccess, onError })
Parameter (Options)
  • mutationFn
    • queryFn와 같은 비동기 함수로, 실행할 비동기 변이 함수이다.
  • onSuccess
    • 변이가 성공할 때 호출되는 함수이다.
  • onError
    • 변이 중 오류가 발생할 때 호출되는 함수이다.
Returns
  • mutate: 변이 실행 함수

useSyncExternalStore

사용 방법
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)
Parameter
  • subscribe
    • 하나의 callback 인자 함수를 받아 외부 상태 저장소(store)에 구독하는 함수이다.
    • store가 변경될 때, 제공된 callback이 호출되어 React는 getSnapshot을 다시 실행해 컴포넌트를 리렌더링한다.
    • 반드시 구독 해제 함수를 반환해야 한다. (useEffect의 clean-up 함수처럼 동작함)
  • getSnapshot
    • 컴포넌트에 필요한 store 데이터의 스냅샷을 반환하는 함수이다.
    • 저장소가 변경되어 반환된 값이 다르면 (Object.is와 비교하여) React는 컴포넌트를 리렌더링한다.
  • getServerSnapshot(optional)
    • store에 있는 데이터의 초기 스냅샷을 반환하는 함수이다.
    • 서버 렌더링 도중과 클라이언트에서 서버 렌더링 된 콘텐츠의 하이드레이션 중에만 사용된다.
Return
  • 렌더링 로직에 사용할 수 있는 store의 현재 스냅샷.

💡 useSyncExternalStore가 왜 필요할까?

useSyncExternalStore는 React 18에서 도입된 훅으로, 외부 상태 저장소(store)를 리액트 컴포넌트와 동기화하는 데 사용된다.

export function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);
  
  const update = useCallback(() => {
    setIsOnline(navigator.onLine);
  }, [])
  
  useEffect(() => {
    window.addEventListener('online', update);
    window.addEventListener('offline', update);
    
    return () => {
      window.removeEventListener('online', update);
      window.removeEventListener('offline', update);
    }
  }, [update])
  
  return isOnline;
}

공식 홈페이지 예시 코드useSyncExternalStore 없이 구현한 코드이다.
위 코드를 보면 React 외부 상태 저장소를 useState, useEffect를 활용하여 충분히 동기화하고 있는 것 같아 보인다.

하지만 이 접근 방식에는 중요한 문제점이 있다.
useStateuseEffect 사이의 타이밍 차이 때문에, 초기 렌더링 직전 또는 직후 외부 상태가 변경되면 해당 변경을 놓칠 수 있다.
이처럼 React의 렌더링 상태와 외부 상태 간에 불일치가 발생하는 현상을 Tearing이라고 한다.

useSyncExternalStore는 이러한 문제를 해결하며, 외부 저장소와의 동기화를 정확하고 예측 가능하게 만들어준다.


외부 저장소(QueryStore) 구현

데이터(Query) 및 저장소 구조

  • Query: useQuery에서 반환되는 data, isLoading, isError 값을 하나의 객체로 묶은 타입이다.
  • store: useQuery에 인자로 넣어주는 queryKey를 기준으로 데이터를 저장, 수정, 삭제하는 저장소이다.
    • 이러한 작업을 효율적으로 처리하기 위해, 내부적으로 Map 자료구조를 사용했다.
type Query<TData> = { data: TData; isLoading: boolean; isError: boolean };

const store = new Map<string, Query<unknown>>();

내부적으로는 단순한 Map 객체 하나로 전체 캐시 저장소가 구성된다. 처음 봤을 때는 이 구조가 의외로 단순해서 놀라웠다.

subscribe 함수

  • subscribeuseSyncExternalStore의 첫 번째 인자로 전달되는 함수로, 외부 저장소에 특정 queryKey를 기준으로 컴포넌트를 구독하게 해준다.
  • 내부적으로는 Record<string, Set<Listener>> 구조를 사용하여, queryKey마다 여러 컴포넌트가 구독될 수 있도록 한다.
  • 콜백 등록 시 중복 방지를 위해 Set을 활용하였다.
  • 구독 해제하는 clean-up 함수를 반환한다.
type Listener = () => void;

const listeners: Record<string, Set<Listener>> = {};

export function subscribe(key: string, callback: Listener) {
  if (!listeners[key]) {
    listeners[key] = new Set();
  }

  listeners[key].add(callback);

  // cleanup function 반환
  return () => {
    listeners[key]?.delete(callback);
  };
}

getSnapshot 함수

  • getSnapshot은 현재 queryKey에 해당하는 데이터를 저장소에서 가져오는 함수다.
  • 이 값은 useSyncExternalStore가 컴포넌트를 리렌더링할지 결정하는 기준이 된다.
  • 값이 존재하지 않으면, 초기값(isLoading: true)을 직접 삽입한 후 반환한다.
export function getSnapshot<TData>(key: string) {
  if (!store.get(key))
    store.set(key, { data: null, isLoading: true, isError: false });

  return store.get(key) as Query<TData>;
}

외부 저장소에 저장된 데이터 update 함수

  • 데이터를 패칭하는 과정에서 변경되는 data, isLoading, isError 값을 갱신해주는 함수이다.
  • store에 새로운 값을 저장하고, 구독된 콜백 함수를 모두 실행한다.
export function updateQuery<TData>(key: string, newValue: Query<TData>) {
  store.set(key, newValue);

  // 해당 key에 연결된 listener만 실행
  listeners[key]?.forEach((callback) => callback());
}

💡 listeners에 등록된 callback은 무슨 함수일까?

function subscribeToStore(fiber, inst, subscribe) {
  return subscribe(function () {
    checkIfSnapshotChanged(inst) && forceStoreRerender(fiber);
  });
}

위 코드는 React 내부 코드에서 subscribe 함수를 반환하는 코드를 가져온 것이다.

간단히 함수 이름만 봤을 때, callback 함수의 역할은 다음과 같다.
1. snapshot이 변경되었는지 확인
2. 변경이 감지되면 해당 컴포넌트(fiber)를 리렌더링

즉, subscribe 함수에서 인자로 넣어주는 callback 함수는 store의 변경을 감지해 리렌더링을 일으키는 함수이다.

결국 updateQuery의 내부 로직은 store의 데이터를 바꾸는 순간,
→ subscribe에서 등록된 콜백 실행
→ snapshot이 변경됐는지 확인 후, 필요한 컴포넌트만 리렌더링하는 것이다.


QueryStore 전체 코드

// QueryStore.ts

type Query<TData> = { data: TData; isLoading: boolean; isError: boolean };
type Listener = () => void;

const store = new Map<string, Query<unknown>>();
const listeners: Record<string, Set<Listener>> = {};

export function subscribe(key: string, callback: Listener) {
  if (!listeners[key]) {
    listeners[key] = new Set();
  }

  listeners[key].add(callback);

  return () => {
    listeners[key]?.delete(callback);
  };
}

export function getSnapshot<TData>(key: string) {
  if (!store.get(key))
    store.set(key, { data: null, isLoading: true, isError: false });

  return store.get(key) as Query<TData>;
}

export function updateQuery<TData>(key: string, newValue: Query<TData>) {
  store.set(key, newValue);

  listeners[key]?.forEach((callback) => callback());
}

👉 여기까지 구현한 subscribe, getSnapshot, updateQuery는 모두 useSyncExternalStore에서 동기화를 위한 핵심 도구이다.


useQuery 훅 구현

useQuery Parameters

  • 실제 TanStack Query에서는 queryKey를 배열로 관리한다.
  • 이는 동일한 쿼리라도 다양한 파라미터 조합을 다룰 수 있도록 하기 위함이다.
  • 하지만 이 글에서는 기초적인 구조를 먼저 구현하는 데 집중하기 위해 문자열(string) 형태로 단순화했다.
interface UseQueryOptions<TData> {
  queryKey: string;
  queryFn: () => Promise<TData>;
}

useSyncExternalStore로 외부 저장소 동기화

const snapshot = useSyncExternalStore(
  (onStoreChange) => subscribe(queryKey, onStoreChange),
  () => getSnapshot<TData>(queryKey)
);

snapshot이 없을 때 queryFn 실행

useEffect(() => {
  const fetchQueryData = async () => {
    try {
      const result = await queryFn();
      updateQuery(queryKey, {
        data: result,
        isLoading: false,
        isError: false,
      });
    } catch {
      updateQuery(queryKey, {
        data: null,
        isLoading: false,
        isError: true,
      });
    }
  };

  if (!snapshot?.data) {
    fetchQueryData();
  }
}, [queryKey, snapshot, queryFn]);

useQuery 전체 코드

// useQuery.ts

interface UseQueryOptions<TData> {
  queryKey: string;
  queryFn: () => Promise<TData>;
}

export const useQuery = <TData>({
  queryKey,
  queryFn,
}: UseQueryOptions<TData>) => {
  const snapshot = useSyncExternalStore(
    (onStoreChange) => subscribe(queryKey, onStoreChange),
    () => getSnapshot<TData>(queryKey)
  );

  useEffect(() => {
    const fetchQueryData = async () => {
      try {
        const result = await queryFn();
        updateQuery(queryKey, {
          data: result,
          isLoading: false,
          isError: false,
        });
      } catch {
        updateQuery(queryKey, {
          data: null,
          isLoading: false,
          isError: true,
        });
      }
    };

    if (!snapshot?.data) {
      fetchQueryData();
    }
  }, [queryKey, snapshot, queryFn]);

  return snapshot;
};

useQuery 흐름도

  1. useSyncExternalStore로 구독을 시작한다.
  2. getSnapshot을 통해 외부 저장소에서 queryKey에 해당하는 데이터를 가져온다.
  3. 가져온 snapshot을 반환한다. → 데이터가 있을 수도, 없을 수도 있다.
  4. snapshot?.data가 없을 경우, useEffect에서 fetchQueryData 함수를 실행한다.
  5. queryFn 실행 결과에 따라 updateQuery로 상태를 저장하고,
  6. 해당 queryKey를 구독 중인 컴포넌트들이 리렌더링된다.

useMutation 훅 구현

useMutation Parameters

  • TVariable: mutation 함수에 전달할 인자의 타입
  • TData: mutation 결과로 반환될 데이터의 타입
interface UseMutationOptions<TVariable, TData> {
  mutationFn: (variables: TVariable) => Promise<TData>;
  onSuccess?: (result: TData) => void;
  onError?: () => void;
}

mutate 함수

const mutate = useCallback(
  async (variables: TVariable) => {
    try {
      const result = await mutationFn(variables);
      // 요청 성공 시 onSuccess 콜백 실행 (있을 경우)
      onSuccess?.(result);

      return result;
    } catch (error) {
      // 요청 실패 시 onError 콜백 실행 (있을 경우)
      onError?.();

      throw error;
    }
  },
  [mutationFn, onSuccess, onError]
);

useMutation 전체 코드

// useMutation.ts

interface UseMutationOptions<TVariable, TData> {
  mutationFn: (variables: TVariable) => Promise<TData>;
  onSuccess?: (result: TData) => void;
  onError?: () => void;
}

export function useMutation<TVariable, TData>({
  mutationFn,
  onSuccess,
  onError,
}: UseMutationOptions<TVariable, TData>) {
  const mutate = useCallback(
    async (variables: TVariable) => {
      try {
        const result = await mutationFn(variables);
        onSuccess?.(result);

        return result;
      } catch (error) {
        onError?.();

        throw error;
      }
    },
    [mutationFn, onSuccess, onError]
  );

  return { mutate };
}

+ 리팩토링

useQuery에 있는 useEffect를 없애기

  • 기존 코드의 useEffect는 의존성 배열에 있는 queryKey, snapshot, queryFn 값들이 변경되면 실행된다.
  • useSyncExternalStore의 첫 번째 인자로 넣은 subscribe 함수는 리렌더링이 될 때마다 호출된다.
  • subscribe함수에 useCallback을 감싸면 특정 값이 변경될 때만 다시 구독하도록 가능하다.
export const useQuery = <TData>({
  queryKey,
  queryFn,
}: UseQueryOptions<TData>) => {
  const snapshot = useSyncExternalStore(
    useCallback(
      (onStoreChange) => {
        const unsubscribe = subscribe(queryKey, onStoreChange);

        const fetchQueryData = async () => {
          try {
            const result = await queryFn();
            updateQuery(queryKey, {
              data: result,
              isLoading: false,
              isError: false,
            });
          } catch {
            updateQuery(queryKey, {
              data: null,
              isLoading: false,
              isError: true,
            });
          }
        };

        const snapshot = getSnapshot<TData>(queryKey);

        if (!snapshot?.data) {
          fetchQueryData();
        }

        return unsubscribe;
      },
      [queryKey, queryFn]
    ),
    () => getSnapshot<TData>(queryKey)
  );

  return snapshot;
};

fetchQueryData 함수 분리

  • fetchQueryData 함수가 구독할 때마다 새로 생성되는 것을 방지하기 위해 외부로 분리한다.
  • 데이터 패칭과 상태 구독의 책임을 분리하여 useQuery 내부의 가독성을 높인다.
  • 데이터 요청 로직을 외부 함수로 분리하여 테스트와 재사용이 용이해진다.
export const useQuery = <TData>({
  queryKey,
  queryFn,
}: UseQueryOptions<TData>) => {
  const snapshot = useSyncExternalStore(
    useCallback(
      (onStoreChange) => {
        const unsubscribe = subscribe(queryKey, onStoreChange);
        loadQueryData(queryKey, queryFn);
        return unsubscribe;
      },
      [queryKey, queryFn]
    ),
    () => getSnapshot<TData>(queryKey)
  );

  return snapshot;
};
export async function loadQueryData<TData>(
  key: string,
  queryFn: () => Promise<TData>
) {
  const snapshot = getSnapshot(key);
  if (snapshot.data) return;

  await fetchAndUpdateQueryData(key, queryFn);
}

async function fetchAndUpdateQueryData<TData>(
  key: string,
  queryFn: () => Promise<TData>
) {
  try {
    const result = await queryFn();
    updateQuery(key, {
      data: result,
      isLoading: false,
      isError: false,
    });
  } catch {
    updateQuery(key, {
      data: null,
      isLoading: false,
      isError: true,
    });
  }
}

💡 useEffect 안에 있던 로직을 subscribe의 인자로 전달되는 함수 내부로 옮겨도 괜찮을까?

// react/packages/use-sync-external-store/src/useSyncExternalStoreShimClient.js

useEffect(() => {
  // Check for changes right before subscribing. Subsequent changes will > be
  // detected in the subscription handler.
  if (checkIfSnapshotChanged(inst)) {
    // Force a re-render.
    forceUpdate({inst});
 	}
  const handleStoreChange = () => {
    // TODO: Because there is no cross-renderer API for batching updates, it's
    // up to the consumer of this library to wrap their subscription event
    // with unstable_batchedUpdates. Should we try to detect when this isn't
    // the case and print a warning in development?
    // The store changed. Check if the snapshot changed since the last time we
    // read from the store.
    if (checkIfSnapshotChanged(inst)) {
      // Force a re-render.
      forceUpdate({inst});
    }
  };
  // Subscribe to the store and return a clean-up function.
  return subscribe(handleStoreChange);
}, [subscribe]);
  • useSyncExternalStore 내부 구현 코드를 보면, 실제로 subscribe 함수는 useEffect 안에서 실행된다.
  • 즉, subscribe 함수 내부에서 side effect를 수행하는 것은 React의 사용 규칙에 어긋나지 않는다.
  • useEffect와 동일한 타이밍에 실행되므로, 데이터를 요청하는 로직을 subscribe 내부에 넣어도 부작용 없이 동작한다.

마치며

이번 글에서는 TanStack Query의 핵심 기능인 useQueryuseMutation을 직접 구현해보며,
React의 외부 상태와 동기화하는 방식(useSyncExternalStore)까지 함께 살펴보았다.

다음 글에서는 staleTime, gcTime, refetch 등의 고급 캐싱 전략과 Request Coalescing 등 실제 라이브러리 수준에서 필요한 기능들을 구현해볼 예정이다.

3개의 댓글

comment-user-thumbnail
2025년 6월 30일

엄마 난 커서 재오가 될래

2개의 답글