
0. TanStack Query의 대한 기본지식
클래식한 API 통신은 클래식한 방식 (useEffect + useState을 활용하여 아래처럼 한다.
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch('/api/users')
.then(res => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, []);
어떤 문제상황이 발생할까?
- 컴포넌트가 언마운트됐다가 다시 마운트되면? → 또 요청
- 다른 컴포넌트에서 같은 데이터가 필요하면? → 또 요청
- 탭 전환 후 돌아왔을 때 최신 데이터인지? → 모름
- 로딩/에러 상태를 매번 손으로 관리해야 함
탠스택 쿼리의 흐름은 아래 4단계 흐름이다.
- queryKey를 이용하여 이 요청이 어떤 데이터인지 이름표를 붙인다.
- 받아온 데이터를 잠깐 저장해둔다.
- 이 저장된 데이터를 언제까지 새것으로 볼건지 시간을 정한다. → staleTime
- 어떤 상황에서 다시 서버에 요청할지 정한다. → refetch, invalidateQueries, enabled
이 4가지로, 사실 간단하게 말하면 서버 데이터를 캐싱하여, 재요청할래말래 하는 녀석이다.
이번 리팩토링에서 TanStack Query는 useEffect + 수동 fetch를 치환하는 수준이 아니라, 조회 정책을 코드로 표준화하는 역할을 했다.
핵심은 어떤 조건에서, 얼마나 유지하고, 언제 다시 불러올지를 화면별 감각이 아니라 공통 규칙으로 관리한 것이다.
1. Query Key - 데이터 서버 데이터주소
Query key는 캐시 식별자다. 쉽게 말하면 불러오는 API의 대한 정보를 식별하는 주민등록증같은 것이다.
API 호출이 아래와 같다고 가정했을때,
GET /search?keyword=장학금&type=NEWS&category=all&sort=relevance&page=0
이 요청의 결과는 그냥 “검색 결과”가 아니라,
- 검색어 = 장학금
- 타입 = NEWS
- 정렬 = relevance
- 페이지 = 0
의 특정 조건인 검색 결과이다.
따라서, 탠스택 쿼리에서도 아무렇게나 저장하는 것이 아닌 아래처럼 특정 값에 매칭되게끔 저장해야한다.
queryKey: ['search', 'detail', 'NEWS', '장학금', 'all', 'relevance', 0]
queryKey는 엔드포인트만 작성하는 것이 아닌, 서버 응답이 바뀔 수 있는 값을 넣는다.
리액트쿼리는 이 key를 기준으로 캐시를 저장하고 꺼내서 쓴다. key가 같으면 같은 데이터로 취급을 하는 것이다.
같은 엔드포인트라도 서버로부터 받아오는 데이터자체가 너무 달라지는 것은 자명하다.
fetchPosts({ page: 1, sort: 'latest' }) // 최신글 1페이지
fetchPosts({ page: 2, sort: 'popular' }) // 인기글 2페이지
분명 둘은 명백하게 다른 데이터인데, queryKey를 [’posts’]로만 작성한다면 같은 데이터로 착학하게 된다.
따라서 같은 엔드포인트라도, 이렇게 queryKey에다가 계층을 만들어놔야한다.
queryKey 의미
| ['posts'] | 게시글 전체 목록 |
| ['posts', 1] | 1번 게시글 |
| ['posts', 1, 'comments'] | 1번 게시글의 댓글 |
| ['posts', { page: 2, sort: 'latest' }] | 필터 적용된 목 |
queryKey를 이용하여, 재방문시 캐시 히트하여, api 호출이 아닌 캐시에서 꺼내다 쓰고
조건이 변경시에만 새 요청을 하겠다. 즉 key를 잘 짜놔야해 이러한 캐시히트가 자주 발생하겠다.
2. staleTime
Tanstack Query는 데이터를 두 가지 상태로 본다.
- fresh: 신선함. 재조회하지 않는다.
- stale: 낡음. 트리거(마운트, 포커스 등)가 발생하면 재조회한다.
staleTime은 이 둘 사이의 경계선이다. 기본값은 0 — 즉, 받자마자 바로 stale이 된다. 이 기본값을 그대로 두면 컴포넌트가 리마운트될 때마다 재호출이 발생하기 쉽다.
API도메인별로 staleTime을 다르게 설정하기 위한 상수 선언
export const SEARCH_STALE_TIME_MS = 2 * 60 * 1000; // 2분
export const BOARD_DETAIL_STALE_TIME_MS = 2 * 60 * 1000; // 2분
export const BOARD_COMMENTS_STALE_TIME_MS = 60 * 1000; // 1분
export const MYPAGE_STALE_TIME_MS = 5 * 60 * 1000; // 5분
숫자 하나하나에 이 데이터는 얼마나 빠르게 변해야하는가의 주관적인 판단이 들어간 선택이었다.
도메인 staleTime 판단 근거
| 댓글 | 1분 | 가장 실시간성이 중요. 다른 사용자의 댓글이 바로 보여야 함 |
| 검색 결과 | 2분 | 새 게시물이 검색에 반영되는 체감 주기 |
| 게시글 상세 | 2분 | 조회수·좋아요 등 변동 요소 존재 |
| 마이페이지 | 5분 | 본인 데이터, 외부 요인으로 바뀔 일 거의 없음 |
이게 왜 중요하냐면, 모든 쿼리에 동일한 staleTime을 적용하면 반드시 어딘가에서 손해를 본다.
댓글 기준으로 짧게 잡으면 마이페이지에서 불필요한 호출이 폭증하고, 마이페이지 기준으로 길게 잡으면 댓글이 낡은 채로 보인다.
또한 그냥 선언된 상수를 사용하지 않고, 매직넘버로 박는 것 보단 DX적으로 친절하다.
3. gcTime - 캐시의 수명
StatleTime과는 어떤것이 다를까?
StaleTime은 캐시된 데이터가 언제부터 낡은 데이터니까 (재조회 트리거)를 하는지를 나타내는 시간이라면
gcTime은 언제 메모리에서 삭제할지를 나타내는 시간대이다. (이거 아무도 안보지?) 이런 느낌이다.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 10 * 60 * 1000,
},
},
});
우리 MJS(thingo)서비스의 전형적인 사용 시나리오를 고려해야한다.
게시글 목록 → 상세 진입 → 뒤로가기 → 다른 글 보다가 → 아까 그 글 다시 클릭
게시글 A 목록 → A 상세 진입 → 뒤로가기 → B,C,D 구경 → A 다시 클릭
이때 A 상세 쿼리(['board-detail', 'A'])의 생애주기를 보자.
1단계: A 상세 진입
- A 컴포넌트 마운트 → 쿼리 active 상태
- 네트워크 요청 → 데이터 받음 → 캐시에 저장
- staleTime 카운트다운 시작 (2분)
2단계: 뒤로가기
- A 컴포넌트 언마운트 → 쿼리 inactive 상태
- 여기서부터 gcTime 카운트다운 시작
- (staleTime은 계속 돌아가는 중)
3단계: B, C, D 구경하는 동안
- A 캐시는 inactive 상태로 메모리에 계속 존재
- 시간이 흐름
4단계: A 다시 클릭
여기서 두 타이머의 상태에 따라 네 가지 경우가 갈린다.
경과 시간 staleTime (2분) gcTime (10분) 결과
| 1분 후 복귀 | 아직 fresh | 아직 살아있음 | 캐시 즉시 표시, 네트워크 호출 X |
| 3분 후 복귀 | stale | 아직 살아있음 | 캐시 즉시 표시 + 백그라운드 재조회 |
| 11분 후 복귀 | stale | 이미 삭제됨 | 로딩 스피너 + 새로 요청 |
gcTIme이 아직 남아서, 캐시가 살아있다면, stale이더라도 일단 캐시를 보여주고, 뒤에서 조용히 최신데이터로 교체가 된다. 사용자 입장에서는 “로딩 스피너” 없이 UX적으로 좋게 보여지겠다.
gcTime은 UX와 메모리 압박의 절충점
gcTime을 여유롭게 잡아서, 로딩 스피너 없이 렌더링되는 UX을 좋게 끌어올릴 수도 있지만, 너무 gcTime을 올리게된다면, staleTime과 다르게 실제 메모리를 잡아 놓는 시간이기에, 메모리 압박이 생길 수 밖에 없어서, 적정한 gcTime을 설정해야겠다.
그래서 우리는 그래서 "사용자 행동 패턴(잠깐 이탈 후 복귀)은 커버하되, 메모리가 무한히 쌓이진 않게" 하는 절충점으로 10분을 잡은 것이다. 이 절충은 전적으로 gcTime의 영역이다.
4. refetchOnWindowFocus - 전역 정책
기본값은 True인데, Saas 대시보드 같은 환경을 고려한것이다. 즉 탭은 며칠 켜뒀다가 돌아왔을때 낡은 데이터를 보여주지 않겠다는 탠스택 설계자의 의도인데
나는 False로 두었다.
이유는 우리같이 일반적인 웹서비스 사용 패턴은 다르다.
- 다른 탭에서 자료 찾다가 돌아옴
- 슬랙 확인하고 돌아옴
- 유튜브 틀어놓고 돌아옴
이렇게 된다면 이런 순간마다 모든 활성 쿼리가 재조회되면 서버 비용도 낭비되고 UX도 깜빡인다.
staleTime을 이미 도메인별로 세심하게 잡았기 때문에, "포커스 복귀"라는 추가 트리거는 오히려 정책의 일관성을 깬다. staleTime이 데이터 신선도를 책임지는 단일 기준이 되도록 focus refetch를 꺼버린 것이 맞다고 판단하였다.
탭 이동이 잦은 사용 패턴에서 의미 없는 재호출을 줄이기 위한 운영 정책이다.
그러나 뭐 주식,시세 이런 실시간 모니터링 환경에서는 키는게 맞다고 생각이 든다만.
5. enabled - 조건부 호출 게이팅
쓰잘데기 없는 이상한 API 호출은 막는 것.
다시말하면 enabled로 “필요할 때만” 호출되게 만들었다.
// 검색어 없으면 검색 호출 금지
enabled: enabled && !!keyword
// 자동완성: 공백만 입력 시 호출 금지
enabled: !!keyword.trim()
// 방송 검색: 검색어 있을 때만 실행
enabled: !!keyword.trim()
!!keyword와 !!keyword.trim()의 차이를 보자. 사용자가 스페이스바만 여러 번 누른 경우 keyword는 " "가 되는데, !!keyword는 true다. 그러면 공백만 가지고 서버에 요청이 간다. trim()을 거치면 이 엣지 케이스를 서버까지 안 내려보낸다.
유효한 입력의 정의를 프론트에서 먼저 한 번 걸러주는 것 — 네트워크 낭비 + 서버 부하 + 의미 없는 빈 결과 렌더링을 한 번에 막는다.
ex) ALL 탭에서 단일 탭 쿼리 비활성화
enabled: selectedTab !== 'ALL'
이게 가장 설계적으로 중요한 케이스다. 구조를 상상해보자.
- ALL 탭: 모든 카테고리 통합 결과를 한 번에 가져옴
- CATEGORY_A 탭: A 카테고리만 가져옴
- CATEGORY_B 탭: B 카테고리만 가져옴
만약 enabled 없이 각 탭의 쿼리가 모두 활성화되면, ALL 탭에 들어갔을 때 통합 API + 개별 카테고리 API들이 동시에 호출된다. 같은 데이터를 중복해서 받는 셈.
enabled: selectedTab !== 'ALL'로 막으면, 탭 상태가 곧 "어떤 쿼리를 살릴지"를 결정한다. UI 상태와 데이터 페칭이 일대일로 매핑되는 깔끔한 구조가 된다.
6. invalidateQueries - 수정 후 데이터 최신화
mutation 이후로 데이터를 최신화 하는 방법론인데, mutation은 무엇인가?
위에는 대부분은 query 이야기만 했다. tanstackQuery는 서버와의 통신을 두종류로 나눈다.
구분 Query Mutation
| 목적 | 데이터를 읽기 | 데이터를 변경 (쓰기) |
| HTTP | 주로 GET | POST, PUT, PATCH, DELETE |
| 실행 시점 | 자동 (컴포넌트 마운트 시) | 수동 (사용자가 버튼 클릭 등) |
| 캐싱 | O | X |
| 예시 | 게시글 목록 조회, 댓글 목록 조회 | 댓글 작성, 좋아요 누르기, 게시글 삭제 |
즉, query는 데이터를 read하는 거라면, mutation은 서버의 상태를 바꾼다. 댓글작성, 회원가입, 프로필 수정 등등 데이터를 수정하는 일을 전담하는 것이다.
mutation(서버의 상태를 바꾼후) 데이터를 최신화 하는 방법
mutation 이후 데이터를 최신화하는 방법은 여러 가지다.
- queryClient.invalidateQueries() — 전체 쿼리 무효화 (가장 게으른 방식)
- queryClient.invalidateQueries({ queryKey: ['board'] }) — 'board'로 시작하는 전부
- queryClient.invalidateQueries({ queryKey: ['board-comments', uuid] }) — 정확히 그 댓글 목록만
ex) 사용자가 댓글 목록을 보고 댓글을 단다고 가정하자.
그럼 대개 아래처럼 코딩을 할 것이다. 댓글 남기기 성공했다!
그렇다면 queryKey에서 게시판 댓글 관련된 API를 수정 및 최신화를 해라 라는 지시인것이다.
const { mutate } = useMutation({
mutationFn: (newComment) => postComment(uuid, newComment),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['board-comments', uuid] });
queryClient.invalidateQueries({ queryKey: ['board-detail', uuid] });
}
});
// 사용자가 댓글 작성 버튼을 눌렀을 때
<button onClick={() => mutate({ content: '댓글 내용' })}>
댓글 작성
</button>
invalidateQuery가 없다면?
1. 사용자가 댓글 목록을 본다
→ useQuery가 댓글 10개를 받아서 캐시에 저장
→ 화면에 10개 표시
2. 사용자가 새 댓글을 작성한다
→ useMutation이 서버에 POST 요청
→ 서버 DB에는 댓글이 11개가 됨
→ 하지만 React Query 캐시에는 여전히 10개
3. 화면에는 여전히 10개만 보임 ❌
invalidateQuery가 있다면?
1. 댓글 10개 캐시에 저장, 화면 표시
2. 새 댓글 작성 → 서버 DB 11개
→ onSuccess 실행 → invalidateQueries 호출
→ ['board-comments', uuid] 쿼리가 stale 처리됨
→ React Query가 자동으로 재조회
→ 캐시가 11개로 갱신됨
3. 화면에 11개 표시 ✅
댓글 작성이라는 하나의 행동이 영향을 주는 범위를 정확히 두 개로 식별했다.
- board-comments: 댓글 목록 자체 (새 댓글이 추가되어야 함)
- board-detail: 댓글 수, 최근 활동 시각 등 상세 정보
이 외의 다른 쿼리 — 게시글 목록, 검색 결과, 마이페이지 등 — 는 이 행동과 무관하므로 건드리지 않는다.
그래서 queryKey설계가 중요한 것이다. (invalidation 전략이 쉽거든)
앞서 쿼리 키를 계층적으로 설계했다면, 이 부분에서 무효화 범위를 자유자재로 조절할 수 있다는 보상이 돌아온다. 쿼리 키 설계와 invalidation 전략은 한 쌍으로 동작한다.
// 모든 board-comments 관련 쿼리 무효화 (uuid 상관없이)
queryClient.invalidateQueries({ queryKey: ['board-comments'] })
// 특정 게시글의 댓글만
queryClient.invalidateQueries({ queryKey: ['board-comments', uuid] })
// 특정 게시글의 댓글 중 특정 페이지만
queryClient.invalidateQueries({ queryKey: ['board-comments', uuid, page] })
'AI를 슬기롭게' 카테고리의 다른 글
| 풀스택 환경에서 더욱 더 통일된 npm run 검증을 해야하는 이유 (0) | 2026.02.23 |
|---|---|
| AI에게 맡기기 전 인지해야 할 실수로 push한 커밋 대처법 (1) | 2026.02.01 |