Tanstack Query, Svelte Store 트러블슈팅


사내 제품을 SvelteKit으로 개발하면서 Tanstack Query(Svelte Query)를 도입했을 때, 무한 API 호출 때문에 브라우저가 멈추는 이슈가 있었어요. 같은 문제를 만났을 때 빠르게 해결할 수 있도록 원인과 해결 과정을 기록했고, Svelte 관련 레퍼런스가 상대적으로 부족한 만큼, 이 글이 누군가에겐 작은 실마리가 되었음 해서 트러블슈팅 경험을 공유해보려 합니다.


무한 API 호출

어드민 화면에서 사용자 정보 수정(Edit) 폼을 열면 서비스가 멈췄는데요. 디버깅 결과, 사용자 상세 정보를 불러오는 API가 무한 반복 되고 있었어요.

코드는 다음과 같고, EditForm에서는 useDetailMutation을 사용했어요.

// useDetailMutation.ts
export const useDetailMutation = () =>
  createMutation<UserDetail, Error, string>({
    mutationFn: async (userId: string) => {
      const response = (await apiRequest(ENDPOINTS.userDetail, {
        id: userId,
      })) as ApiResponse<UserDetail>;

      return response.result;
    },
  });

// EditForm.svelte
let { selectedUserId } = $props();
let fetchUserDetail = useDetailMutation();

let userFormData = $state<UserFormData>({});

$effect(() => {
  if (selectedUserId) {
    $fetchUserDetail.mutate(selectedUserId, {
      onSuccess: (data) => {
        userFormData = data.result;
      },
      onError: (error) => {
        console.error('사용자 정보를 가져오는데 에럭');
      },
    });
  }
});

selectedUserId가 존재하면 API를 호출해 userFormData에 값을 채우도록 의도했어요. 그런데 실제로는 $effect 안에서 mutate()가 계속 실행되며 무한 루프가 발생했어요.





무엇이 원인이었을까?

핵심은 fetchUserDetail이 Svelte store라는 점이에요. $fetchUserDetail로 접근 하면 Svelte가 이 store를 구독하게 되는데, mutate()가 status.data 등을 바꾸면서 store가 업데이트되고, 그 변화가 다시 $effect를 재실행하죠. 결국 내가 트리거한 상태 변화가 나를 다시 트리거 하면서 무한 호출이 일어난 거예요.


Store란?

Svelte에서 store는 외부에서도 구독 가능한 반응형 상태에요. $store로 접근하면 값이 바뀔 때마다 Svelte가 자동으로 반응하죠. 무한 API 호출이 일어났던 이유도 여기에 있었는데요 fetchUserDetail은 Tanstack Query의 mutation store 였고, $fetchUserDetail처럼 접근하자 Svelte가 이 상태를 자동으로 구독했어요. 그래서 순환참조 구조가 생기게 된것이죠.

즉 Svelte store는 단순한 값 저장소가 아니고, 상태 변화를 자동으로 추적하고 반응하는 구독 기반 시스템이에요.

종류설명
writable읽기/쓰기 가능한 store. 값을 직접 set, update 할 수 있음
readable외부에서는 읽기만 가능한 store. 값은 내부에서만 갱신
derived다른 store로부터 값을 계산해서 만드는 store


해결 방법1: get()으로 store 구독 끊기

$fetchUserDetail처럼 store를 반응형으로 구독하지 않고, get()을 이용해 store 값을 한 번만 참조하게 했어요.

import { get } from 'svelte/store';
let { selectedUserId } = $props();
let fetchUserDetail = useDetailMutation();

let userFormData = $state<UserFormData>({});

$effect(() => {
  if (selectedUserId) {
    const fetchUserDetailData = get(fetchUserDetail);

    fetchUserDetailData.mutate(selectedUserId, {
      onSuccess: (data) => {
        userFormData = data.result;
      },
      onError: (error) => {
        console.error('사용자 정보를 가져오는데 에러가 발생했습니다:', error);
      },
    });
  }
});

한 번만 참조한다는 의미는 Svelte의 반응형 시스템에 의존성을 등록하지 않는단 의미에요. 즉 get() 함수를 통해 리턴되는 값을 사용하게 되면 mutate()로 내부 상태가 바뀌어도 $effect()와 의존성이 끊겨있어서 재실행되지 않고, 무한루프가 사라지죠.


해결 방법2: createQuery()로 전환

그런데 문제를 뭔가 임시방편으로 해결한 것 같아요. 다시 리팩토링을 진행했고, createMutation() 대신 createQuery()를 활용한 구조로 바꿔봤어요.

// useUserDetailQuery.ts
export const useUserDetailQuery = (userId: string, enabled = true) =>
  createQuery<UserDetail>({
    queryKey: ['userDetail', userId],
    queryFn: async () => {
      const response = await apiRequest<ApiResponse<UserDetail>>(
        ENDPOINTS.userDetail,
        {
          id: userId,
        }
      );

      return response.result;
    },
    enabled,
  });

// EditForm.svelte
let { selectedUserId } = $props();
let userDetailQuery = useDetailQuery(selectedUserId, !!selectedUserId);

let userFormData = $state<UserFormData>({});

$effect(() => {
  const userDetailData = $userDetailQuery.data;

  if (!userDetailData) {
    return;
  }

  userFormData = {
    ...userDetailData,
  };
});

이 방식도 $effect안에서 store를 구독하고 있지만, 무한 루프가 발생하지 않아요. 그 이유는

  • createQuery는 내가 직접 호출하는 함수가 아니에요. 내부적으로 queryKey와 enabled 조건에 따라 queryFn을 실행하죠.
  • queryFn 실행 후 상태 변화(data, status, error)는 Query store 내부에서 일어나고,
  • 외부에서는 명시적으로 mutate()처럼 트리거 하지 않았기 때문에 내가 바꾼 값이 다시 나를 호출하는 일이 생기지 않는 것이죠.

즉, createQuery는 읽기 전용 상태를 제공하고, 명시적으로 상태를 변경하지 않기 때문에 Svelte의 $effect 안에서도 안전하게 구독할 수 있었어요.

이렇게 무한 api 호출의 원인을 이해하고 나니, 단순히 현상을 피해 가는 것이 아니라 문제의 구조를 바꾸는 방식으로 리팩토링 할 수 있었죠.


트러블슈팅 결과

저희 서비스는 금융/은행 솔루션 기반으로 운영되고 있어서, 모든 API 요청을 POST 방식으로 통일해 사용하고 있었어요. 민감한 사용자 정보를 URL에 노출하지 않도록 하기 위해, 데이터를 요청 본문(body)에 담아 전송하는 구조였고, 이는 보안 요건 충족과 규제 준수를 위한 정책적 판단이었어요.

이런 전제가 있다 보니 자연스럽게 POST 요청이면 createMutation()을 써야 한다는 생각을 했고, 그 판단이 결과적으로는 무한 호출 문제로 이어졌어요. 단순히 HTTP 메서드 기준이 아니라, 요청의 목적과 역할, 그리고 데이터 흐름이 어떤 방향으로 움직이는지에 따라 query와 mutation을 구분해야 한다는 점을 명확히 체감하게 됐죠.

이번 트러블슈팅을 계기로 Svelte의 store 구조와 TanStack Query의 내부 동작 방식도 깊이 이해하게 되었고, 잘못된 $store 접근 방식이 의도치 않은 사이드이펙트를 유발할 수 있다는 점을 실제 사례를 통해 확인할 수 있었어요.



Tanstack Query는 왜 도입 했을까?

어드민은 페이지는 사용자 · 대시보드 · 시스템 설정 등 서버 데이터 중심 화면이 많았어요. Svelte의 $store는 ui 토글, 폼 입력 상태 등을 다루기엔 충분했지만 서버 상태를 다루려면 반복 로직이 많았어요.

  • 데이터 요청 중 로딩 상태 관리
  • 요청 실패에 대한 재시도
  • 브라우저 포커스 전환 시 재요청
  • 동일 데이터에 대한 중복 요청 병합
  • 캐시 만료 · 메모리 정리

이러한 반복 로직 작성을 해결하기 위해 서버 상태 관리 전용 라이브러리를 사용하자는 결정을 했고, Tanstack Query가 가장 적합한 선택이라고 판단했어요.

Tanstack Query(Svelte Query)의 동작 구조 이해

TanStack Query는 단순히 HTTP 요청을 보내는 라이브러리가 아니에요. 캐시(메모리), 요청‑응답 흐름을 추적하는 로직, 그리고 UI를 자동으로 갱신하는 구독 시스템이라는 세 축으로 동작해요. 덕분에 언제 데이터를 가져오고, 언제 새로 고치며, 화면을 어떻게 동기화할지를 선언적으로 표현할 수 있어요.


Query: 읽기 전용 캐싱

createQuery() 함수는 데이터를 가져오고, 캐시에 저장하고, 필요할 때 자동으로 다시 불러오는 역할을 맡아요. 결과적으로 읽기 전용 데이터 흐름을 다루는 도구라고 보면 돼요.

const userQuery = createQuery({
  queryKey: ['user', id],
  queryFn: () => fetchUser(id),
});
- 내부 동작

createQuery()
 └─ QueryCache (중앙 캐시)
     └─ Query(key)
         └─ QueryObserver (Svelte store, UI 구독자)
QueryCache
  • queryKey를 기준으로 모든 응답을 보관해요.
  • 이미 존재하는 키라면 네트워크 요청을 합치거나 캐시를 그대로 재사용해요.

QueryObserver
  • 구독자가 생길 때에만 실제 네트워크 호출을 트리거해요.
  • 결과는 Svelte store 형태(data, status, error, refetch() 등)로 노출돼 UI가 자동으로 반응해요.

자동으로 제공되는 편의 기능
  • 캐싱 & 중복 병합: 동일 queryKey로 중복 요청하면 한 번만 호출해요.
  • 자동 재시도: 네트워크 오류 시 retry 횟수만큼 재시도해요.
  • 포커스 복귀 시 새로고침: 탭을 다시 활성화하면 오래된(stale) 데이터만 새로 받아요.
  • staleTime / cacheTime: 데이터 신선도 · 메모리 사용을 세밀하게 조절해요.
  • GC: 화면에 표시되지 않는 쿼리는 일정 시간 뒤 메모리에서 자동으로 정리돼요.

Mutation: 쓰기(변경) & 사이드이펙트

createMutation()은 POST · PUT · DELETE 같이 데이터를 변경하거나 부수 효과가 필요한 작업 흐름을 다루는데요. 여기서도 복잡한 부분은 TanStack Query가 전부 관리해줘요.

const updateUser = createMutation({
  mutationFn: (data) => updateUserAPI(data),
  onSuccess: () => queryClient.invalidateQueries(['user', data.id]),
});
- 내부 동작

createMutation()
 └─ MutationCache
     └─ Mutation (각 mutate 호출마다 생성)
         └─ MutationObserver (Svelte store 형태로 상태 전달)
  • mutate()를 호출하면 진행 상태, 성공/실패 여부 등이 Svelte store로 흘러가요($mutation.status, $mutation.error 등).
  • UI는 이 상태 변화를 즉시 반영할 수 있어요.

Mutation에서 자주 쓰는 패턴
  • 캐시 동기화: 성공 시 관련 쿼리 캐시를 무효화(invalidate)하거나, 즉시 값을 덮어쓰기(setQueryData)로 동기화할 수 있어요.

  • Optimistic UI + 롤백

const mutation = createMutation({
  mutationFn: updateUser,

  onMutate: async (newData) => {
    const prev = queryClient.getQueryData(['user', newData.id]);
    queryClient.setQueryData(['user', newData.id], { ...prev, ...newData });

    // 실패 시 rollback용 이전 상태 반환
    return () => queryClient.setQueryData(['user', newData.id], prev);
  },

  onError: (_err, _newData, rollback) => {
   rollback?.(),
  },
});

onMutate: 네트워크 응답을 기다리지 않고 UI를 즉시 업데이트해요. (낙관적 반영) onError: 실패하면 rollback()으로 이전 상태를 복구해요.

  • 오프라인 큐(Paused Queue)
const postSomething = createMutation({
  mutationFn: postAPI,
  newworkMode: 'offlineFirst',
});

오프라인 상태에서도 mutate() 호출이 실패하지 않고 대기해요. 브라우저가 온라인으로 전환되면 자동으로 재실행돼요. 모바일 환경이나 불안정한 네트워크에서 유용하죠.


정리

언제어떤걸
읽기 전용 요청createQuery()
쓰기 또는 side-effect 포함createMutation()
POST지만 캐싱.재사용 필요createQuery() + enabled: true/false
Optimistic UI or 롤백 필요createMutation() + onMutate, onError

TanStack Query는 데이터를 언제, 어떻게 가져오고 유지할지를 선언적으로 기술할 수 있게 해줘요.

  • Query는 읽기 전용 데이터의 “저장소 + 신선도 관리자” 역할을 해요.
  • Mutation은 변경 작업의 “실행 · 상태 추적자” 역할을 해요.
  • 두 객체 모두 Svelte store 형태라 $store로 바로 구독할 수 있어요.
  • 실시간 UX, 네트워크 오류, 오프라인 대응 같은 까다로운 요구 사항도 코드 몇 줄로 해결할 수 있어요.

덕분에 비즈니스 로직에 집중하면서도 데이터 요청부터 화면 반영까지의 흐름을 예측 가능하고 일관된 방식으로 구현할 수 있었어요. 특히 Svelte의 반응형 특성과 결합했을 때, 서버 상태 관리와 UI 간의 연동을 더 명확하고 선언적으로 작성할 수 있었어요.



글을 마무리하며

이번 이슈는 단순히 코드 한 줄의 문제가 아니라, 프레임워크의 동작 방식과 외부 라이브러리의 추상화가 어떻게 충돌할 수 있는지를 실감하게 해줬어요.

눈앞의 현상만 고치기보다는, 그 원인을 만들어낸 구조와 흐름을 이해하고 개선하는 방향으로 접근하려 했고, 그 과정에서 얻은 인사이트가 더 컸어요. 실제 서비스에서 반응형 시스템과 서버 상태 관리 도구를 함께 쓸 때 어떤 문제가 생길 수 있는지, 그리고 그럴 때 어떤 부분을 조심해야 하는지를 구체적으로 확인할 수 있었던 경험이었어요.