TanStack Query 캐시 키 안정화: 다중 선택 상태를 정렬 기반 집합으로 리팩토링
팀원이 담당한 도메인을 코드 리뷰하다가, 캐시가 이상하게 잘 안 맞는 구조를 하나 발견했습니다. 코드 자체에는 문제가 없었습니다. 쿼리 키도 규칙대로 잘 만들어져 있었고요. 문제는 그 키에 들어가는 값이 화면에서 어떻게 만들어지는지에 있었습니다. 이 글은 도메인 코드를 건드리지 않고 공통 스토어 한 줄로 이 문제를 정리한 과정입니다.
1) 문제 상황
쿼리 키 불안정으로 인한 캐시 분산
그룹별로 "집계에 포함된 항목"은 클라이언트 상태(useSelectionStore)의 getSelected(groupId)에서 관리되고 있었습니다. 이 함수는 내부적으로 Object.entries(visibilityMap) 순서를 그대로 사용해 항목 ID 배열을 만들고 있었는데, 이 값에 의존하는 쿼리에서 같은 항목 집합이라도 토글 순서에 따라 배열 순서가 달라지는 구조였습니다.
캐시 미스가 늘어날 수 있는 지점
목록 페이지들은 선택된 ID 배열을 그대로 쿼리 키에 포함하고 있었습니다.
tsx// tableQueries.list / chartQueries.list 내부 예시 queryOptions({ queryKey: [...lists(), params] as const, queryFn: () => getTableList(params), // 또는 getChartList enabled: params.selectedIds.length > 0, });
TanStack Query는 배열 기반 키에서 요소 순서를 구분합니다. ['s1', 's2']와 ['s2', 's1']는 서로 다른 키입니다. 즉 토글 경로에 따라 이런 문제가 생길 수 있었습니다.
- 같은 데이터에 대해 중복 캐시 엔트리가 생성됩니다.
- 같은 화면으로 돌아와도 불필요한 API 재요청이 발생합니다.
- 캐시를 무효화할 때 관리해야 할 키가 늘어납니다.
해당 도메인은 팀원의 담당 영역이었지만, 리뷰 과정에서 이 쿼리 키 설계가 선택 토글 방식과 맞물려 캐시를 분산시킬 여지가 있다는 점이 보였습니다. 그래서 도메인 단위가 아니라 공통 스토어 레벨에서 해결하는 방향으로 접근했습니다.
2) 해결 과정
Step 1: 키 동작부터 다시 짚기
먼저 팀원과 함께 TanStack Query의 키 동작을 다시 확인했습니다.
- 배열 기반 키
['todos', status, page]— 같은 요소라도 순서가 다르면 다른 키로 간주됩니다. - 객체 기반 키
['todos', { status, page }]— 프로퍼티 순서는 무시되고, 프로퍼티 집합과 값이 같으면 동일 키로 간주됩니다.
현재 구조는 queryKey: [...lists(), params] 형태로 params 자체를 키에 넣고 있었고, 그 안에 selectedIds: string[]가 포함돼 있었습니다. 객체 안에 있더라도 배열은 여전히 순서에 민감합니다. 정렬되지 않으면 같은 집합이어도 서로 다른 키가 만들어집니다.
Step 2: 실제 코드 경로 추적
선택 상태의 소스는 이랬습니다.
ts// features/selection-toggle/model/selection-store.ts getSelected: (groupId: string) => { const state = get(); const visibilityMap = state.visibilityByGroup[groupId]; if (visibilityMap) { return Object.entries(visibilityMap) .filter(([, v]) => v !== false) .map(([id]) => id); } return []; },
Object.entries는 키의 삽입 순서를 보존합니다. 따라서 항목을 껐다 켜는 순서에 따라 반환 배열의 순서가 매번 달라질 수 있습니다.
소비하는 쪽은 이랬습니다.
tsx// views/table-page.tsx const { getSelected } = useSelectionStore(); const selectedIds = getSelected(groupId); const { data } = useQuery( tableQueries.list({ selectedIds /* ... */ }) );
tsx// views/chart-page.tsx const { getSelected } = useSelectionStore(); const selectedIds = getSelected(groupId); const { data } = useQuery( chartQueries.list({ selectedIds /* ... */ }) );
두 페이지 모두 getSelected(groupId)가 반환한 배열을 그대로 넘기고 있었기 때문에, 집합이 같더라도 토글 경로에 따라 매번 새 캐시 키가 생성될 수 있는 구조였습니다.
Step 3: 어디서 정렬할 것인가
두 방향을 놓고 비교했습니다.
① 스토어 레벨에서 정렬 — getSelected가 같은 집합에 대해 항상 같은 순서를 보장하도록 수정합니다.
- 한 번의 수정으로 이 함수를 쓰는 모든 쿼리가 자동으로 안정화됩니다.
- 소비하는 쪽은 반환값을 그대로 받아도 됩니다.
② 쿼리 팩토리 레벨에서 정규화 — 각 쿼리 팩토리가 queryKey에 넣을 때만 [...params.selectedIds].sort()로 키용 파라미터를 정규화합니다.
- 쿼리 키와 네트워크 파라미터를 분리해 설계할 수 있습니다.
- 대신 새로운 소비자가 생길 때마다 같은 패턴을 반복해야 합니다.
리팩토링의 목표는 "기존 도메인 로직은 최대한 건드리지 않고 캐시 키만 안정적으로 만들자"였습니다. ②는 팀원 코드를 여러 군데 고쳐야 하고, 앞으로 추가될 도메인마다 같은 실수를 반복할 여지가 남습니다. 논의 끝에 ① 스토어 레벨 정렬을 선택했습니다. 팀원 코드의 인터페이스는 그대로 두고 내부 데이터 형태만 더 안전하게 만드는 방향입니다.
3) 최종 코드
tsx// features/selection-toggle/model/selection-store.ts getSelected: (groupId: string) => { const state = get(); const visibilityMap = state.visibilityByGroup[groupId]; if (visibilityMap) { return Object.entries(visibilityMap) .filter(([, v]) => v !== false) .map(([id]) => id) .sort(); // 배열을 쿼리 키로 쓰기 위해 정렬 // → 같은 ID 집합이면 항상 같은 순서의 배열로 매핑 } return []; },
실제 변경은 .sort() 한 줄입니다. 이 변경으로 다음이 보장됩니다.
- 같은 그룹에서 선택된 ID 집합이 같다면, 언제 어떤 순서로 토글했든
getSelected(groupId)가 항상 동일한 배열을 반환합니다. - 반환값을 그대로 파라미터로 쓰는 코드들은 수정 없이도 안정적인 쿼리 키를 갖게 됩니다.
- 새 도메인이
getSelected를 사용해도 별도 정렬 로직 없이 같은 안정성을 가져갑니다.
4) 결과
캐시 재사용률 향상
같은 집합에 대해 목록을 다시 열거나 필터 조건만 바꾸는 경우에도 기존 캐시를 재활용하게 되어, 불필요한 API 요청 가능성을 줄였습니다. 특히 "여러 번 토글해도 결국 같은 집합에 수렴하는" 사용자 행동에서 효과가 큽니다.
설계 원칙을 스토어에 내재화
"집합 의미를 갖는 파라미터는 정렬·정규화해서 키에 쓴다"는 규칙을 스토어 레벨에 녹였습니다. 같은 선택 상태를 공유하는 다른 도메인도 이 규칙을 자동으로 물려받습니다.
리뷰 관점에서의 기여
해당 도메인은 팀원 담당이었지만, 캐시 동작과 스토어 구현을 함께 놓고 보니 문제가 보였습니다. 도메인 로직은 그대로 두고 공통 스토어를 개선하는 방식으로 제안했고, 그 편이 리뷰를 받는 쪽에서도 받아들이기 쉬웠습니다. 개별 도메인 구현을 존중하면서 전역 캐시 전략 관점에서 품질을 끌어올릴 수 있었던 경험이었습니다.
아직 댓글이 없습니다
첫 번째 댓글을 작성해보세요!