query key prefix 충돌 이슈 해결 (문제 해결)

Devinix·2026년 7월 28일

[문제 해결]

목록 보기
48/48
post-thumbnail

개요

간편주문 화면에 "간편주문 추가" 기능을 붙이는 작업을 했다. 간편주문 등록은 주문내역 화면에서 이뤄지는데, 선주문 건이 하나도 없으면 등록 자체가 불가능하다. 그래서 버튼을 누른 시점에 주문내역이 있는지 먼저 확인하고, 없으면 "주문내역이 존재하지 않아요. 주문을 진행할까요?" 안내 모달을 띄우는 흐름을 추가했다.
존재 여부만 알면 되니 목록 전체를 받을 필요는 없었고, 1건만 조회하는 쿼리를 하나 새로 만들어 썼다. 기능은 의도대로 동작했고 그대로 머지했다.


문제 상황

QA내에서 주문내역 화면에서 방금 주문한 건을 취소하려는데, 아무리 눌러도 취소가 되지 않는다.라는 문제상황이 발생했다.

직접 재현해보니 증상이 기묘했다.

  • 버튼을 눌러도 취소 API 요청이 네트워크 탭에 뜨지 않는다
  • 대신 엉뚱한 요청들만 우르르 재조회된다
  • 에러 토스트도, 콘솔 에러도 눈에 띄지 않는다
  • 새로고침하면 정상 동작한다

보통 "취소가 안 된다"고 하면 서버가 에러를 내려줬거나 요청이 실패했으리라 짐작하게 된다. 그런데 여기서는 요청이 실패한 게 아니라 애초에 나가지도 않았다. 그러면서 무관한 쿼리들은 재조회되고, 새로고침 한 번이면 멀쩡해진다.

원인을 찾으려면 먼저 두 코드를 같이 놓고 봐야 한다. 하나는 몇 달째 돌아가던 취소 로직이고, 다른 하나는 내가 이번에 추가한 조회다.

원래 있던 코드 — 목록과 낙관적 취소

주문내역 목록은 무한 스크롤이라 useInfiniteQuery로 가져온다.

// queryKeys.ts
const orderQueryKeys = {
  all: ['order'] as const,
  listsAll: () => [...orderQueryKeys.all, 'list'] as const,
  lists: ({ size }: { size: number }) => [...orderQueryKeys.listsAll(), size] as const,
  detail: ({ orderId }: { orderId: number }) => [...orderQueryKeys.all, 'detail', orderId] as const,
};

주문을 취소하면, 서버 응답을 기다리지 않고 카드 상태가 즉시 "취소됨"으로 바뀌도록 낙관적 갱신을 걸어뒀다.

export function useCancelOrder() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: orderClient.cancelOrder,
    onMutate: async (variables) => {
      await queryClient.cancelQueries({ queryKey: orderQueryKeys.listsAll() });

      const previousLists = queryClient.getQueriesData<InfiniteData<OrderListResponse>>({
        queryKey: orderQueryKeys.listsAll(),
      });

      // 모든 목록 캐시에서 해당 주문의 상태를 즉시 CANCELLED로 바꾼다
      queryClient.setQueriesData<InfiniteData<OrderListResponse>>(
        { queryKey: orderQueryKeys.listsAll() },
        (old) => {
          if (!old) return old;
          return {
            ...old,
            pages: old.pages.map((page) => ({
              ...page,
              data: {
                ...page.data,
                content: page.data.content.map((order) =>
                  order.orderId === variables.orderId
                    ? { ...order, status: OrderStatus.CANCELLED }
                    : order,
                ),
              },
            })),
          };
        },
      );

      return { previousLists };
    },
    onError: (error, variables, context) => {
      // 실패 시 스냅샷으로 롤백
      context?.previousLists.forEach(([key, data]) => queryClient.setQueryData(key, data));
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: orderQueryKeys.all });
    },
  });
}

여기까지는 아무 문제 없이 몇 달을 돌았다.

이번에 추가한 코드 — 주문내역 존재 확인

내가 추가한 건 이게 전부였다. "간편주문 추가"를 누른 시점에 주문내역이 하나라도 있는지만 확인하면 되니 size: 1로 조회한다.

// queryKeys.ts — 여기서 사고가 났다
listsAll: () => [...orderQueryKeys.all, 'list'] as const,
lists: ({ size }: { size: number }) => [...orderQueryKeys.listsAll(), size] as const,

// 목록 하위에 두면 목록 invalidate에 함께 정산되겠지? 라는 생각
hasHistory: () => [...orderQueryKeys.listsAll(), 'hasHistory'] as const,
const listResponse = await queryClient.fetchQuery({
  queryKey: orderQueryKeys.hasHistory(),
  queryFn: () => orderClient.getList({ page: 0, size: 1 }),
});

const hasOrderHistory = (listResponse.data?.content?.length ?? 0) > 0;
if (!hasOrderHistory) {
  openNoOrderHistoryModal();
  return;
}

"주문 목록 관련 캐시니까 listsAll() 밑에 두는 게 자연스럽지" — 이 판단 하나가 전부였다.


원인

짚고 넘어가기 — 프리픽스 매칭이란

React Query의 쿼리 키는 배열이고, 캐시를 찾을 때 키 배열의 앞부분(prefix)이 일치하는가로 판정한다. 뒤에 뭐가 더 붙어 있는지는 보지 않는다.

폴더 경로에 비유하면 쉽다. ['order', 'list']/order/list 폴더를 가리키는 것이고, setQueriesData그 폴더 아래 있는 걸 전부 열어서 고치는 명령이다.

queryClient.setQueriesData({ queryKey: ['order', 'list'] }, updater);
캐시 키['order','list']로 시작하나걸리나
['order', 'list', 20]걸림
['order', 'list', 'hasHistory']걸림
['order', 'detail', 1234]안 걸림
['cart']안 걸림

키를 계층으로 설계하는 이유가 여기에 있다. invalidateQueries({ queryKey: ['order'] }) 한 번이면 주문 관련 캐시를 통째로 무효화할 수 있다. 편하다. 그런데 이 편의에는 대가가 따른다.

프리픽스 매칭은 내가 의도한 곳까지만 멈추지 않는다

내가 추가한 hasHistory 키는 ['order', 'list', 'hasHistory'], 즉 목록 폴더 안에 있었다.

['order', 'list', 20]           ← 무한쿼리 목록      → InfiniteData<OrderListResponse>
['order', 'list', 'hasHistory'] ← 새로 추가한 존재 확인 → OrderListResponse

두 캐시는 프리픽스가 같지만 데이터 형태가 완전히 다르다. 앞의 것은 { pages: [...], pageParams: [...] }이고, 뒤의 것은 그냥 응답 객체라 pages가 없다.

그런데 setQueriesData의 updater는 무한쿼리라고 굳게 믿고 있다.

(old) => {
  if (!old) return old;
  return { ...old, pages: old.pages.map(...) };  // 💥 old.pages is undefined
}

hasHistory 캐시가 들어오는 순간 old.pages.mapTypeError: Cannot read properties of undefined (reading 'map').

setQueriesData<InfiniteData<OrderListResponse>>라는 제네릭은 런타임에 아무것도 보장하지 않는다. 타입 파라미터는 "이 키에 담긴 건 이 타입일 것"이라는 개발자의 선언일 뿐, 실제로 프리픽스에 무엇이 걸리는지는 검사하지 않는다. 타입이 거짓말을 한 셈이다.

왜 요청이 아예 안 나갔나

여기가 핵심이다. TanStack Query v5의 mutation 실행부는 onMutatemutationFn같은 try 블록에서 처리한다. 대략 이런 모양이다.

try {
  const context = await this.options.onMutate?.(variables);  // ← 여기서 throw
  const data = await this.options.mutationFn(variables);     // ← 도달하지 못함
  // onSuccess ...
} catch (error) {
  // onError ...
} finally {
  // onSettled ...
}

onMutate가 던지면:

콜백실행 여부
onMutate도중에 중단
mutationFn호출되지 않음 → HTTP 요청 없음
onSuccess실행 안 됨
onError실행됨 (단, contextundefined롤백도 못 함)
onSettled실행됨 → invalidateQueries는 그대로 돈다

관찰된 증상이 정확히 이 표다.

  • 취소 요청은 안 나감 (mutationFn 미실행)
  • 그런데 onSettledinvalidateQueries는 실행되어 관련 없는 쿼리들만 재조회

"요청은 안 나가는데 다른 것들만 새로 불러온다"는 기묘한 증상의 정체가 이거였다.

왜 조용했나

이 프로젝트는 에러 안내를 axios 응답 인터셉터에 일임하고 있었다. 서버가 에러를 내려주면 인터셉터가 토스트를 띄우는 구조다.

그런데 이번 에러는 HTTP 요청이 발생하기도 전에 터졌다. 인터셉터를 지나갈 일이 없으니 토스트도 없다. mutate()는 (mutateAsync와 달리) 프라미스를 반환하지 않아 unhandled rejection도 안 뜬다. 결국 아무 일도 일어나지 않은 것처럼 보이는 완벽한 침묵이 만들어졌다.

왜 새로고침하면 됐나

이게 결정적 단서였다. 원인을 알고 나면 당연하다.

hasHistory 캐시는 사용자가 간편주문 화면에서 "간편주문 추가"를 눌러야만 생긴다. 새로고침하면 메모리 캐시가 통째로 날아가므로, listsAll() 프리픽스에 걸리는 건 정상적인 무한쿼리뿐이다. → onMutate가 안 터지고 취소가 정상 동작한다.

반대로 말하면 이 버그는 "간편주문 화면을 거쳐온 세션에서만" 재현되는 조건부 버그였다. 제보가 늦게 들어온 것도, 마침 그 경로를 탄 사람이 있어야 재현되기 때문이다. 간편주문 추가와 주문 취소는 화면상 아무 접점이 없으니 QA에서 놓치기 딱 좋은 형태다.

디버깅 교훈: "새로고침하면 된다"는 곧 "메모리에만 존재하는 상태가 오염됐다"는 뜻이다. persist 스토리지나 서버 상태가 아니라, 그 세션에서만 쌓인 무언가를 의심해야 한다. 이 경우엔 React Query 캐시였다.


해결 과정

1. 근본 원인 — 키 계층을 바로잡는다

형태가 다른 캐시를 같은 프리픽스 아래 둔 것이 잘못이었다. 밖으로 뺐다.

const orderQueryKeys = {
  all: ['order'] as const,
  listsAll: () => [...orderQueryKeys.all, 'list'] as const,
  lists: ({ size }: { size: number }) => [...orderQueryKeys.listsAll(), size] as const,

  // 주문내역 존재 여부만 확인하는 1건 조회.
  // listsAll 하위에 두면 안 된다 — 목록 낙관적 갱신(setQueriesData)이 listsAll 프리픽스로
  // 매칭해 InfiniteData로 다루므로, 형태가 다른 이 캐시가 섞이면 updater가 터진다.
  hasHistory: () => [...orderQueryKeys.all, 'hasHistory'] as const,
};

원래 listsAll() 밑에 둔 이유는 "목록이 갱신되면 이것도 같이 무효화되겠지"였다. 하지만 이 쿼리는 fetchQuery로 클릭 시점에 조회하고 기본 staleTime0이라 매번 새로 가져온다. 무효화에 얹혀갈 필요가 애초에 없었다. 없어도 되는 편의를 위해 형태 계약을 깬 셈이다.

2. 방어 — updater가 형태를 확인하게 한다

키를 옮기는 것만으로 당장의 버그는 사라진다. 하지만 다음 사람이 또 같은 실수를 하면 똑같이 터진다. updater가 스스로 방어하게 했다.

queryClient.setQueriesData<InfiniteData<OrderListResponse>>(
  { queryKey: orderQueryKeys.listsAll() },
  (old) => {
    // listsAll 프리픽스에 무한쿼리가 아닌 캐시가 걸릴 수 있어 형태를 확인하고 건너뛴다.
    if (!old?.pages) return old;

    return {
      ...old,
      pages: old.pages.map((page) => ({
        ...page,
        data: {
          ...page.data,
          content: page.data.content.map((order) =>
            order.orderId === variables.orderId
              ? { ...order, status: OrderStatus.CANCELLED }
              : order,
          ),
        },
      })),
    };
  },
);

if (!old) return oldif (!old?.pages) return old로 바꾼 한 글자 수준의 변경이지만, "내가 다룰 줄 아는 형태가 아니면 손대지 않는다" 는 계약이 코드에 명시된다.

3. 같은 패턴을 전부 찾아 고친다

버그 하나를 고칠 때 가장 중요한 단계다. setQueriesData를 전수 조사했더니, 동일한 프리픽스에 동일한 가정을 하는 updater가 하나 더 있었다. 주문내역 삭제(soft-hide) 쪽에서 목록 캐시의 해당 주문을 즉시 제거하는 코드였는데, 취소와 판박이 구조였다. 마침 그날은 안 터졌을 뿐 언제든 같은 방식으로 죽을 수 있었다. 함께 고쳤다.

# 같은 지뢰가 또 있는지부터 확인한다
grep -rn "setQueriesData" src

결론

한 줄로 요약하면 이렇다.

쿼리 키의 계층 구조는 "무효화 범위"인 동시에 "데이터 형태의 계약"이다.

같은 프리픽스를 공유한다는 건 invalidateQueries 한 번에 함께 처리된다는 편의만 뜻하지 않는다. setQueriesData, getQueriesData처럼 프리픽스로 캐시를 훑는 모든 API가 그것들을 같은 타입으로 취급한다는 뜻이기도 하다. 형태가 다른 캐시를 편의만 보고 같은 가지에 매달면, 언젠가 그 가지를 훑는 코드가 부러진다.

참고자료

profile
React, Next.Js, React-Native

0개의 댓글