조용진의 개발 블로그
← 글 목록

캐시를 지웠는데도 불안했던 이유: React Query queryKey에 데이터의 소유자를 담기

React QueryTanStack QueryReact Native캐시멀티테넌시

지점 전환 뒤 이전 지점 데이터가 노출되던 문제를 캐시 삭제로 먼저 막고, queryKey에 지점 식별자를 포함하는 구조로 개선한 과정을 정리합니다.

관리 앱에서 사용자는 여러 지점을 오가며 학생, 수업, 청구서, 채팅 데이터를 확인할 수 있습니다. 어느 날 지점을 바꾼 직후, 새 지점 화면에 이전 지점의 데이터가 잠시 표시되는 문제가 발견됐습니다. API가 잘못된 지점의 데이터를 반환한 것은 아니었습니다. 원인은 프런트엔드 캐시가 서로 다른 지점의 요청을 같은 데이터로 인식한 데 있었습니다.

이 글은 급한 노출을 막기 위해 캐시를 비우는 조치를 먼저 적용하고, 이후 queryKey에 데이터의 소유 범위를 포함하는 구조로 개선한 과정을 다룹니다.

같은 화면이라는 이유로 같은 데이터는 아니다

TanStack Query(당시 React Query)는 queryKey를 기준으로 서버 데이터를 구분하고 캐시합니다. 기존 키는 화면과 조회 조건을 중심으로 구성돼 있었습니다.

아래 코드 예시는 설명을 위해 식별자와 운영 설정값을 일반화했습니다.

queryKey: ["dashboard", "records", selectedMonth.format("YYYY-MM")]

이 키만 보면 "특정 월의 홈 청구 데이터"라는 의미는 알 수 있습니다. 하지만 어느 지점의 데이터인지는 알 수 없습니다. 실제 API 클라이언트는 현재 선택된 지점 정보를 이용해 요청하더라도, 캐시 계층은 그 숨은 문맥을 모릅니다.

예를 들어 A 지점에서 4월 청구 데이터를 조회한 뒤 B 지점으로 전환해 같은 화면을 열면, 두 요청의 키는 모두 ['home', 'bill', '2026-04']입니다. React Query 관점에서는 동일한 요청이므로 A 지점의 캐시를 B 지점에서도 재사용할 수 있었습니다.

문제를 단순화하면 다음 흐름입니다.

flowchart LR
  A[A 지점 조회] --> B[공통 캐시 키에 저장]
  B --> C[B 지점으로 전환]
  C --> D[같은 키로 캐시 조회]
  D --> E[A 지점 데이터 노출]

화면 이름과 필터가 같아도 데이터의 소유자가 다르면 서로 다른 캐시 항목이어야 합니다.

첫 대응은 지점 선택 화면에서 캐시를 비우는 것이었다

사용자에게 다른 지점 데이터가 보이는 문제를 빠르게 차단하기 위해, 지점 선택 화면에 진입할 때 지점 목록을 제외한 Query 캐시를 제거했습니다.

useFocusEffect(
  useCallback(() => {
    queryClient.removeQueries({
      predicate: (query) => query.queryKey[0] !== "tenantList",
    });
  }, [queryClient])
);

이 조치는 지점 전환 전에 이전 데이터를 제거하므로 즉각적인 방어책으로는 유효했습니다. 다만 캐시의 의미가 올바르게 정의된 것은 아니었습니다.

즉, 캐시 삭제는 증상을 막았지만 잘못된 키 설계라는 원인을 없애지는 못했습니다.

해결의 핵심은 queryKey를 데이터의 실제 정체성과 맞추는 것이었다

후속 개선에서는 지점별 데이터의 모든 queryKey 앞에 지점 식별자를 추가했습니다.

queryKey: [
  tenant?.id,
  "dashboard",
  "records",
  selectedMonth.format("YYYY-MM"),
]

이제 A 지점과 B 지점은 같은 월, 같은 화면을 조회해도 서로 다른 캐시 공간을 사용합니다.

[A 지점 ID, "dashboard", "records", "2026-04"]
[B 지점 ID, "dashboard", "records", "2026-04"]

API 응답을 결정하는 값은 queryKey에도 들어가야 한다는 원칙을 코드에 반영한 것입니다. 지점 식별자뿐 아니라 페이지, 검색어, 정렬, 기간처럼 응답을 바꾸는 입력도 같은 기준으로 판단할 수 있습니다.

이번 변경은 홈 화면 하나에 그치지 않았습니다. 학생, 수업, 청구서, 채팅 등 51개 파일의 키를 함께 정리했습니다. 일반 조회와 무한 스크롤뿐 아니라 수동 캐시 조작도 같은 규칙을 따라야 했기 때문입니다.

조회 키만 바꾸면 절반만 고친 것이다

React Query를 사용하다 보면 useQuery의 키만 눈에 띄기 쉽습니다. 하지만 실제 캐시는 여러 API를 통해 읽고 쓰입니다.

queryClient.invalidateQueries({
  queryKey: [tenant?.id, "records"],
});

이번 작업에서는 다음 지점을 모두 같은 키 구조로 맞췄습니다.

특히 채팅처럼 낙관적 업데이트를 사용하는 화면에서는 하나라도 예전 키가 남으면 문제가 더 복잡해집니다. 요청 취소는 새 키에 수행했는데 이전 데이터는 옛 키에서 읽거나, 성공 결과와 롤백 데이터가 서로 다른 캐시 항목에 기록될 수 있기 때문입니다.

실제로 일괄 변경 뒤에도 청구서 무효화, 수업 선택 조회, 채팅 스냅샷에서 빠진 키를 추가로 보완했습니다. 이 과정은 queryKey 변경이 단순 문자열 치환이 아니라 캐시 읽기와 쓰기의 계약을 바꾸는 작업임을 보여줬습니다.

queryFn의 인자 위치도 함께 바뀐다

배열형 키 앞에 지점 식별자를 추가하면서 queryFn에서 값을 꺼내는 위치도 한 칸씩 이동했습니다.

// 이전: ["items", group, category, itemName]
const [, group, category, itemName] = queryKey;

// 이후: [tenantId, "items", group, category, itemName]
const [, , group, category, itemName] = queryKey;

키를 사용하는 쪽만 바꾸고 queryFn의 구조 분해를 놓치면, 필터 값이 엉뚱한 인자로 전달될 수 있습니다. 변경 범위를 찾을 때 Query Hook뿐 아니라 queryKey를 직접 해석하는 함수까지 확인해야 하는 이유입니다.

결과는 속도 수치보다 데이터 경계의 명확성에 있었다

이 변경의 직접적인 효과는 캐시 항목이 어느 지점의 데이터인지 구분할 수 있게 된 것입니다. 적용 범위는 다음과 같습니다.

핵심 성과는 캐시를 더 오래 보관한 것이 아니라, 캐시가 재사용돼도 되는 범위를 코드로 명확히 만든 것입니다.

다음 단계라면 키 팩토리로 누락 가능성을 줄인다

이번 개선은 여러 화면의 배열형 키를 직접 수정하는 방식이었습니다. 실제로 후속 누락을 보완해야 했다는 점에서, 다음 단계는 키 생성을 한곳에 모으는 것입니다.

const cacheKeys = {
  records: (tenantId: number | undefined) =>
    [tenantId, "records"] as const,
  recordsByMonth: (tenantId: number | undefined, month: string) =>
    [tenantId, "records", month] as const,
};

이 코드는 당시 적용된 구현이 아니라, 같은 종류의 변경을 다시 한다면 고려할 후속 방향입니다. 키 팩토리를 사용하면 조회와 무효화가 동일한 접두사를 공유하고, 식별자 누락을 코드 리뷰에서 찾기도 쉬워집니다. 식별자가 준비되기 전에는 enabled 조건으로 요청을 막는 규칙도 함께 두는 편이 안전합니다.

캐시는 성능 기능이기 전에 정확성 경계다

React Query를 도입하면 흔히 중복 요청 감소와 로딩 상태 관리부터 기대합니다. 하지만 여러 조직, 계정, 프로젝트를 전환하는 제품에서는 캐시가 데이터 격리 경계의 일부가 됩니다.

이번 경험에서 남은 기준은 간단합니다.

  1. API 응답을 바꾸는 모든 입력을 찾습니다.
  2. 그중 캐시를 분리해야 하는 값을 queryKey에 포함합니다.
  3. 조회뿐 아니라 무효화와 수동 업데이트도 같은 키 규칙을 사용합니다.
  4. 전환 시 전체 캐시 삭제는 방어책으로 두되, 올바른 키 설계를 대신하게 하지 않습니다.

좋은 queryKey는 화면의 이름이 아니라 데이터의 정체성을 설명합니다. 이 기준을 지키면 캐시는 단순한 속도 최적화를 넘어, 사용자가 지금 보고 있는 데이터가 누구의 것인지 보장하는 구조가 됩니다.