[Tanstack Query] React Query 조금 더 알고 사용하기 - queryKey와 캐시 동기화

Lui.Slki·2026년 1월 29일

React

목록 보기
1/5

queryKey를 미리 설계하는 이유

React Query(TanstackQuery)는 단순히 "API 호출을 편하게 해주는 라이브러리" 가 아니라
서버 상태(server state)를 캐시로 관리하는 도구다.

그래서 React Query 를 조금 더 알고 쓰려면 useQuery 문법보다 먼저 이해해야하는게 있다.

queryKey = 캐시에 저장되는 데이터의 주소(식별자)

이 글에서는 React Query의 핵심 개념과,
특히 queryKey를 미리 세팅 (키 팩토리/컨벤션화) 해두면 얻는 장점을 정리해보려고 한다.

사실, 해당 내용을 인지하기 전 개인프로젝트에서 queryKey 관련으로 골머리 아픈 상황이 생겼고, 이를 방지하기 위해(다시는 그런 일을 겪지 않기 위해...) 정리하기로 마음 먹었다.

1) React Query가 다루는건 "서버 상태"다

프론트에서 상태는 크게 두 종류로 나뉜다.

  • UI 상태: 모달 열림, 탭 선택, 입력 중인 폼 값, 드롭다운 오픈 등
  • 서버 상태: 서버에서 받아온 목록/상세 데이터, 그리고 그 데이터의 최신성/동기화

React Query 는 후자인 서버 상태 를 캐시로 다루는 데 특화돼 있다.

  • 같은 데이터를 여러 화면에 써도 캐시를 공유
  • 필요할 때만 재조회(refetch)
  • 수정/삭제/토글 후 invalidate로 다시 맞추기

2) queryKey는 "데이터 주소"다

React Query에서 queryKey는 어떤 데이터를 캐시에 저장할지 결정한다.

  • 주소가 같으면 같은 캐시를 쓴다
  • 주소가 다르면 캐시가 분리된다

그래서 queryKey를 대충 쓰면

  • 캐시가 중복 저장되고
  • invalidate가 제대로 안 먹고
  • 화면마다 데이터가 제각각 보일 수 있다

반대로 queryKey를 잘 설계하면

  • 데이터 흐름이 안정적이고
  • 동기화가 명확해지고
  • 코드도 읽기 쉬워진다

3) queryKey를 "미리 세팅" 하는 방법 : Keys Factory

나는 queryKey를 컴포넌트에서 직접 하드코딩하지 않고, 한 파일에 모아두는 방식을 사용했다.

export const lessonKeys = {
  // 내 레슨 목록(정렬 기준을 key에 포함)
  myList: (sort: LessonSort) => ["myLessons", sort] as const,

  // 내 레슨 상세
  myDetail: (lessonId: number) => ["lessons", "me", "detail", lessonId] as const,

  // 타임슬롯(기간 포함)
  myTimeSlots: (lessonId: number, from: string, to: string) =>
    ["lessons", "me", "detail", lessonId, "time-slots", from, to] as const,

  // 반복 규칙
  myRecurrence: (lessonId: number) =>
    ["lessons", "me", "detail", lessonId, "recurrence"] as const,
};

as const 를 붙이는 이유

  • queryKey 타입이 "그냥 string[]"로 퍼지지 않게 고정해 준다.
  • invalidate/setQueryData에서 키를 일관되게 쓰기 쉬워진다.

참고 *: 프로젝트 중간에 컨벤션을 도입하면 기존에 쓰던 키(prefix)와 섞이기 쉬운데, 이때 같은 데이터를 서로 다른 key로 캐싱하게 되면서 invalidate가 먹지 않는 문제가 생길 수 있다.

그런의미에서 초반에 잡아두고 가는것이 좋다고 판단되었다.

4) key를 미리 세팅해두면 얻는 장점

  • 키가 곧 API 문서/도메인 구조가 된다
    myList, myDetail, myTimeSlots 처럼 키 이름이 정리돼 있으면 프로젝트에서 어떤 데이터들이 존재하는지 한 눈에 보인다
  • invalidate 범위를 설계하기 쉬워진다

예를 들어 내 레슨 목록만 갱신하고 싶다면

qc.invalidateQueries({ queryKey: lessonKeys.myList(sort) });

정렬 캐시를 한 번에 갱신하고 싶으면 prefix를 기준으로

qc.invalidateQueries({ queryKey: ["myLessons"] });

invalidate 전략을 코드로 설명가능하게 된다.

  • 협업에서 실수(키 불일치)를 크게 줄인다
    키를 하드코딩하면 사람이 늘어날수록 "미묘하게 다른 배열"이 생긴다.
    keys factory로 통일하면 팀 전체가 같은 key를 쓰게되면서 해당 문제를 줄일 수 있다.

  • 리팩토링이 용이하다
    나중에 key구조를 바꿔야 해도 한 파일만 수정하면 된다.

5) useQuery: 읽기(서버 -> 캐시)

키를 미리 세팅해두면 useQuery는 오히려 단순해진다.

export function useMyLessonList(sort: LessonSort) {
  return useQuery({
    queryKey: lessonKeys.myList(sort),
    queryFn: async (): Promise<LessonSummary[]> => {
      const resp = await getMyLessonsReq();
      return resp.data.data;
    },
  });
}

여기서 핵심은

  • queryKey = 캐시 주소
  • queryFn = 서버에서 가져오는 방법

6) useMutation: 쓰기(서버 변경 -> 캐시 동기화)

서버 데이터를 바꾸는 작업은 useMutation이 담당한다.

export function useToggleMyLessonStatus(sort: LessonSort) {
  const qc = useQueryClient();

  return useMutation({
    mutationFn: ({ lessonId, next }: { lessonId: number; next: LessonStatus }) =>
      updateMyLessonReq(lessonId, { status: next }),

    onSuccess: (_data, vars) => {
      // 목록 + 상세 캐시를 다시 최신화
      qc.v({ queryKey: lessonKeys.myList(sort) });
      qc.invalidateQueries({ queryKey: lessonKeys.myDetail(vars.lessonId) });
    },
  });
}

여기서 중요한 건 "쓰기 성공 이후 어떤 캐시를 갱신할지"가 key로 드러난다는 점이다.

정리

React Query를 "조금 더 알고" 사용한다는 말은 결국 이 말로 요약된다.

  • 서버 상태는 캐시로 관리한다
  • queryKey는 데이터의 주소다
  • key를 미리 세팅(컨벤션화)하면 동기화/리팩토링/협업이 쉬워진다

여기서 나는 미리 세팅 혹은, keys를 파일로 분리할 생각을 하지 못했었고 코드와 파일이 늘어날 수록 이전에 설정해놓은 key를 다시 설정한다던가 하는 실수를 해서 상태를 최신화 시키지 못하는 상황이 일어났었다.
다시는 그로 인해 쓸데없이 정신력 깎아먹는 시간을 만들지 않도록 성장해야겠다!

0개의 댓글