React 서버 상태 관리 실전: Query Cache, Mutation, Invalidation으로 화면 데이터 일관성 지키기
서버 상태는 클라이언트 상태와 다르다
React 애플리케이션에서 상태 관리를 이야기하면 전역 store부터 떠올리기 쉽습니다. 하지만 화면에 보이는 데이터 중 상당수는 클라이언트가 소유한 상태가 아닙니다. 사용자 목록, 주문 상태, 알림 개수, 결제 내역은 서버가 진짜 소스입니다. 클라이언트는 그 시점의 스냅샷을 잠시 들고 있을 뿐입니다.
서버 상태를 일반 전역 store에 넣으면 곧 문제가 생깁니다. 언제 다시 가져와야 하는지, 여러 화면에서 같은 요청을 중복으로 보내지 않는지, mutation 이후 어떤 목록을 갱신해야 하는지, 탭을 다시 열었을 때 오래된 데이터를 보여줘도 되는지 직접 관리해야 합니다. 이 복잡도를 줄이기 위해 React Query, TanStack Query 같은 서버 상태 라이브러리를 사용합니다.
핵심은 데이터를 저장하는 것이 아니라 데이터의 신선도를 관리하는 것입니다. Query cache는 응답을 잠시 보관하고, staleTime은 언제 낡았다고 볼지 정하고, invalidation은 mutation 이후 어떤 데이터를 다시 확인할지 선언합니다. 잘 설계하면 빠른 화면 전환과 데이터 일관성을 동시에 얻을 수 있습니다.

1. Query key는 API 계약이다
Query key는 캐시의 주소입니다. 같은 데이터를 가리키는 화면은 같은 key를 써야 캐시를 공유합니다. 반대로 다른 필터나 권한 조건을 가진 데이터는 다른 key를 써야 합니다. query key를 대충 문자열 하나로 만들면 캐시 충돌이나 불필요한 refetch가 생깁니다.
예를 들어 주문 목록은 단순히 orders가 아니라 orders, status, page, sort, search 같은 조건을 포함해야 합니다. status가 paid인 목록과 canceled인 목록이 같은 key를 쓰면 화면이 엉뚱한 데이터를 보여줄 수 있습니다. 반대로 불필요하게 매번 새 객체를 key에 넣으면 캐시가 재사용되지 않습니다.
좋은 key는 계층적입니다. 주문 상세는 orders, orderId처럼 표현하고, 주문 목록은 orders, list, filter처럼 표현합니다. 이렇게 만들면 mutation 이후 특정 상세만 무효화하거나, 주문 관련 목록 전체를 무효화하는 전략을 선택할 수 있습니다. key 설계는 나중의 invalidation 비용을 결정합니다.
2. staleTime과 cacheTime은 사용자 경험의 언어다
staleTime은 데이터가 신선하다고 보는 시간입니다. staleTime 안에서는 컴포넌트가 다시 마운트되어도 즉시 refetch하지 않습니다. 사용자 프로필, 카테고리 목록, 설정 값처럼 자주 바뀌지 않는 데이터는 staleTime을 길게 둘 수 있습니다. 반대로 주문 처리 상태, 실시간 알림, 재고는 짧게 둬야 합니다.
cacheTime은 사용하지 않는 캐시를 메모리에 얼마나 보관할지 정합니다. 사용자가 목록에서 상세로 갔다가 다시 목록으로 돌아올 때 캐시가 남아 있으면 화면이 즉시 그려집니다. 하지만 너무 많은 데이터를 오래 보관하면 메모리 부담이 커집니다. 모바일 브라우저를 고려하면 무작정 길게 두는 것도 답은 아닙니다.
중요한 점은 모든 요청에 같은 staleTime을 주지 않는 것입니다. 데이터마다 변경 빈도와 사용자의 기대가 다릅니다. "몇 초 늦어도 괜찮은가", "오래된 값을 보면 금전적 문제가 생기는가", "사용자가 새로고침 버튼을 기대하는가" 같은 질문으로 결정해야 합니다.
3. Mutation 이후에는 무엇을 믿을지 정한다
사용자가 저장 버튼을 눌렀을 때 화면을 어떻게 갱신할지 결정해야 합니다. 가장 단순한 방식은 mutation 성공 후 관련 query를 invalidate하는 것입니다. 서버에서 최신 데이터를 다시 가져오므로 안전합니다. 다만 네트워크 왕복이 필요하고, 큰 목록을 자주 다시 가져오면 비용이 큽니다.
낙관적 업데이트는 더 빠른 사용자 경험을 줍니다. 서버 응답을 기다리기 전에 캐시를 먼저 바꾸고, 실패하면 되돌립니다. 좋아요 버튼, 체크박스 토글처럼 실패 가능성이 낮고 되돌리기 쉬운 동작에 적합합니다. 결제, 권한 변경, 재고 차감처럼 실패 비용이 큰 동작에는 신중해야 합니다.
Mutation 설계에서 자주 빠지는 것은 목록과 상세의 동기화입니다. 주문 상세에서 상태를 변경했는데 주문 목록 캐시가 그대로라면 사용자는 서로 다른 화면에서 다른 상태를 보게 됩니다. query key 계층을 잘 잡아두면 관련 목록만 invalidate하거나, 상세 응답으로 목록 캐시 일부를 갱신할 수 있습니다.
4. 운영 체크리스트
- Query key에 필터, 정렬, 페이지, 사용자 권한 조건이 포함되어 있는가
- 변경 빈도에 따라 staleTime을 다르게 설정했는가
- mutation 성공 후 관련 목록과 상세가 함께 갱신되는가
- 낙관적 업데이트 실패 시 rollback 경로가 있는가
- 창 포커스 복귀 시 refetch가 필요한 데이터와 불필요한 데이터를 구분했는가
- 에러 상태와 재시도 횟수를 사용자 행동에 맞게 조정했는가
- 서버 응답이 바뀌었을 때 캐시 key도 함께 바뀌는가
5. Prefetch와 초기 데이터는 체감 속도를 바꾼다
서버 상태 관리에서 캐시는 요청 수를 줄이는 도구이기도 하지만, 사용자가 느끼는 대기 시간을 줄이는 도구이기도 합니다. 목록에서 상세로 이동할 가능성이 높은 화면이라면 hover, viewport 진입, 링크 노출 시점에 상세 데이터를 prefetch할 수 있습니다. 사용자가 실제로 클릭했을 때 이미 캐시에 데이터가 있으면 화면 전환이 훨씬 빠르게 느껴집니다.
다만 prefetch는 비용입니다. 사용자가 클릭하지 않을 데이터를 너무 많이 미리 가져오면 네트워크와 서버 비용이 늘어납니다. 모바일 환경에서는 데이터 사용량도 고려해야 합니다. 그래서 prefetch는 사용자 의도가 강한 순간에 제한적으로 적용하는 편이 좋습니다. 예를 들어 화면에 보이는 상위 몇 개 항목, 사용자가 마우스를 올린 항목, 다음 페이지 버튼 근처에서만 수행할 수 있습니다.
SSR이나 RSC 환경에서는 초기 데이터를 서버에서 채워 클라이언트 캐시에 hydration하는 전략도 유용합니다. 첫 화면 데이터는 서버에서 렌더링하고, 클라이언트는 같은 query key로 캐시를 이어받습니다. 이때 서버와 클라이언트의 query key가 조금이라도 다르면 같은 데이터를 다시 요청하게 됩니다. key factory를 공유하거나, query option을 한 곳에서 정의하는 방식이 도움이 됩니다.
초기 데이터의 staleTime도 중요합니다. 서버에서 렌더링한 데이터가 클라이언트에 도착하자마자 stale로 처리되어 즉시 refetch되면 첫 화면은 빠르지만 네트워크는 중복됩니다. 데이터 성격에 맞게 짧은 staleTime을 주면 불필요한 재요청을 줄일 수 있습니다. 반대로 실시간성이 중요한 화면에서는 즉시 refetch가 맞을 수 있습니다.
6. 에러와 로딩 상태도 캐시 정책의 일부다
서버 상태 라이브러리를 쓰면 데이터 캐시만 생각하기 쉽지만, 실제 사용자 경험은 로딩과 에러 상태에서 결정됩니다. 같은 화면을 다시 방문했을 때 이전 데이터를 유지하면서 백그라운드 refetch를 할 것인지, skeleton으로 전체를 비울 것인지 선택해야 합니다. 대부분의 목록 화면에서는 이전 데이터를 유지하는 편이 덜 흔들립니다.
에러도 종류별로 다르게 처리해야 합니다. 401은 로그인 흐름으로, 403은 권한 안내로, 404는 삭제되었거나 접근할 수 없는 데이터로, 500은 재시도 가능한 장애로 이어져야 합니다. 모든 오류를 toast 하나로 처리하면 사용자가 다음 행동을 알 수 없습니다. Query retry도 오류 종류에 따라 달라야 합니다. 인증 오류를 세 번 재시도하는 것은 의미가 없고, 일시적인 503은 짧은 backoff 후 재시도할 수 있습니다.
Mutation 에러에서는 rollback과 서버 상태 복원이 중요합니다. 낙관적 업데이트를 적용했다면 실패 시 이전 캐시 snapshot으로 되돌려야 합니다. 실패했는데 UI가 성공한 것처럼 남아 있으면 사용자는 잘못된 상태를 믿게 됩니다. 반대로 성공했는데 관련 query를 갱신하지 않으면 다른 화면에서 이전 상태가 보입니다.
운영 관점에서는 query 실패율과 mutation 실패율도 지표로 볼 수 있습니다. 특정 API의 실패가 증가하면 프론트엔드에서는 사용자 오류처럼 보일 수 있지만 실제로는 백엔드 장애일 수 있습니다. 클라이언트 로그에 query key, status code, requestId를 남기면 사용자 신고를 서버 로그와 연결하기 쉽습니다.
7. Query key factory로 규칙을 중앙화한다
프로젝트가 커지면 query key가 여기저기 흩어집니다. 어떤 컴포넌트는 ['orders', id]를 쓰고, 다른 컴포넌트는 ['order', id]를 쓰면 같은 상세 데이터를 두 번 가져오게 됩니다. mutation 이후 invalidate할 때도 어떤 key를 지워야 하는지 헷갈립니다. 그래서 query key factory를 두고 목록, 상세, 필터별 key를 한 곳에서 생성하는 방식이 유용합니다.
예를 들어 orderKeys.all, orderKeys.lists, orderKeys.list(filter), orderKeys.detail(id)처럼 계층을 만들면 invalidation 범위가 명확해집니다. 주문 상태를 변경한 뒤 특정 상세만 갱신할지, 모든 주문 목록을 갱신할지 코드에서 의도가 드러납니다. key 구조가 문서 역할을 하므로 신규 개발자도 캐시 정책을 이해하기 쉽습니다.
Query option도 함께 묶을 수 있습니다. staleTime, retry, refetchOnWindowFocus 같은 값이 화면마다 다르면 사용자 경험이 들쑥날쑥해집니다. 같은 도메인의 데이터는 기본 option을 공유하고, 화면 특성상 다른 경우에만 override합니다. 이렇게 하면 서버 상태 정책이 컴포넌트 구현 디테일로 흩어지는 것을 막을 수 있습니다.
테스트에서는 key factory를 기준으로 mutation이 올바른 query를 invalidate하는지 확인합니다. 특히 목록과 상세가 함께 있는 화면, 필터가 많은 화면, 권한별 데이터가 다른 화면에서 캐시 충돌이 없는지 검증해야 합니다. 서버 상태 버그는 데이터가 틀리게 보이는 문제라 발견이 늦으면 사용자 신뢰에 직접 영향을 줍니다.
8. 사용자에게 새로고침 의미를 분명히 보여준다
서버 상태가 오래될 수 있는 화면에서는 사용자가 갱신 상태를 이해할 수 있어야 합니다. "방금 업데이트됨", "1분 전 기준", "새 데이터가 있습니다" 같은 작은 표시가 혼란을 줄입니다. 특히 협업 도구나 관리자 화면에서는 다른 사용자가 변경한 내용이 언제 반영되는지 기대가 중요합니다.
자동 refetch만 믿으면 사용자는 화면이 왜 바뀌었는지 모를 수 있습니다. 반대로 수동 새로고침만 제공하면 오래된 데이터를 계속 볼 수 있습니다. 데이터 성격에 따라 자동 갱신, 새 데이터 배너, 수동 갱신 버튼을 조합해야 합니다. 서버 상태 관리는 기술 캐시이면서 사용자와의 시간 계약입니다.
9. 서버 상태 정책은 제품 요구에서 출발한다
같은 기술 스택을 쓰더라도 데이터별 정책은 달라야 합니다. 결제 상태는 오래된 값을 보여주면 안 되고, 블로그 글 목록은 몇 분 늦어도 괜찮을 수 있습니다. 채팅 메시지는 실시간 동기화가 중요하지만, 카테고리 목록은 하루 동안 거의 바뀌지 않습니다. 모든 query에 같은 staleTime과 retry 정책을 주는 것은 제품 요구를 무시하는 설정입니다.
따라서 서버 상태 설계는 API 목록이 아니라 화면별 사용자 기대에서 시작해야 합니다. 사용자가 새로고침을 기대하는지, 자동 갱신이 필요한지, 실패 시 이전 데이터를 보여줘도 되는지 정리합니다. 이 기준이 있으면 캐시 시간이 길거나 짧은 이유를 설명할 수 있고, 나중에 성능 문제와 일관성 문제 사이에서 판단하기 쉽습니다.
결론: 서버 상태 관리는 캐시 무효화 설계다
React에서 서버 데이터를 다루는 어려움은 fetch 호출 자체가 아닙니다. 언제 같은 데이터를 재사용하고, 언제 낡았다고 보고, 어떤 사용자 행동 뒤에 무엇을 다시 확인할지 정하는 일입니다. 서버 상태 라이브러리는 이 결정을 표현할 도구를 제공합니다.
좋은 구현은 화면을 빠르게 만들면서도 오래된 데이터를 오래 믿지 않습니다. Query key, staleTime, invalidation, mutation rollback을 명확히 설계하면 사용자는 빠른 UI를 보고, 개발자는 일관성 있는 데이터 흐름을 유지할 수 있습니다.