서버 상태 관리는 다른 상태관리와 뭐가 다른 점인가?
React로 개발을 하다보면 자연스럽게 useState를 사용하게 됩니다. 이를 사용하여 입력창의 값, 모달의 열림 여부, 선택된 탭처럼 UI를 구성하는 수많은 값들은 대부분 useState로 충분히 관리할 수 있습니다. React에서는 state를 컴포넌트가 화면에 필요한 정보를 "기억"하기 위한 메커니즘으로 설명하며, 이 state는 각 컴포넌트 인스턴스에 로컬하고 독립적이라고 표현을 합니다. 예를 들어 우리가 자주 사용하는 상태 값은 다음과 같이 다룹니다.
function EditorPage() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedTab, setSelectedTab] = useState<'preview' | 'write'>('write');
const [draftTitle, setDraftTitle] = useState('');
// ...
}
하지만 이번 글에서 다루고자 하는 서버 상태는 앞선 상태와는 다른 특징을 갖고 있습니다. TanStack Query에서 이야기하는 서버 상태 값에는 어떠한 차이가 있기 때문에 서버 상태 관리에 유리한 라이브러리로 검증을 받을 수 있게 된 것인지 이야기해 보겠습니다.
일반적으로 웹에서 많이 말하는 상태 값은 입력창 값, 모달 열림 여부와 같이 앱 내에서 상태 값을 업데이트가 가능한 "내 앱이 직접 소유하는 상태" 값을 의미합니다. 서버 상태는 반대로 **"내 앱이 직접 소유하지 않는 상태 값"**을 의미합니다. 서버 상태는 다음과 같은 특징이 있습니다.
- 원본 데이터가 제어할 수 없거나 소유하지 않은 원격 위치, 서버에 저장됩니다.
- 데이터 가져오기 및 업데이트를 위해 비동기 API가 필요합니다.
- 다른 사람이 당신의 동의 없이 변경이 가능한, 공동 소유권을 지니고 있습니다.
- 주의하지 않으면 애플리케이션 내의 상태 값은 "구식(stale)" 상태 값으로 표시될 수 있습니다.
서버 상태에 대한 간단한 예시를 들어보겠습니다.
type Todo = {
id: number;
title: string;
completed: boolean;
};
async function fetchTodos(): Promise<Todo[]> {
const response = await fetch('/api/todos');
if (!response.ok) {
throw new Error('할 일 목록을 불러오지 못했습니다.');
}
return response.json();
}
위와 같이 상태 값을 불러와서 업데이트를 수행한다고 가정을 해보겠습니다. 위의 코드는 의도대로 제대로 작성이 되어 있습니다. 하지만 문제점은 처음 데이터를 가져온 뒤, 그 이후 서버에서 일어나는 변화를 전혀 고려하지 않는다는 점입니다. 조금 더 상황을 추가하여 자세하게 살펴보겠습니다.
function App() {
const [todos, setTodos] = useState<Todo[]>([]);
useEffect(() => {
async function load() {
const data = await fetchTodos();
setTodos(data);
}
load();
}, []);
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
)
}
위와 같은 상황에서 오전 10시에 사용자가 이 페이지를 열어 todos를 가져왔다고 가정해 보겠습니다. 그런데 오전 10시 1분에 다른 사용자가 같은 할 일 하나를 완료 처리하거나 새로운 할 일을 추가했습니다. 그 뒤 오전 10시 2분에 내가 다시 이 탭으로 돌아와도, 위 코드는 useEffect가 처음 한 번만 실행되므로 여전히 예전 목록을 보여주게 됩니다.
이러한 상황이 바로 애플리케이션 내의 상태 값이 "구식(stale)" 상태 값으로 표시되는 상황에 대한 예시입니다. 이렇듯 외부에 의해서 변경되거나 조작될 수 있는 상태 값의 경우, 변경 상황에 대한 꼼꼼한 대처가 제대로 되어 있지 않으면 사용자에게 잘못된 정보를 제공하게 됩니다. 따라서 최신 값을 빠르게 업데이트하는 것이 중요한 서비스의 경우가 아니더라도 서버 상태 관리는 굉장히 중요한 문제입니다.
이에 대한 해결책은 오늘날 여러 가지가 나왔지만, 이번에는 TanStack Query에서 이 문제를 어떻게 해결하는지에 대해서 중점적으로 다루어보고자 합니다.
Query는 무엇인가?
쿼리(Query)란, 어떤 대상에게 정보·응답·판단을 얻기 위해 던지는 질문 또는 그 질문을 일정한 형식으로 표현한 요청을 의미합니다. 쿼리는 응답을 전제로 한 질문의 행위이거나 원하는 정보에 도달하기 위해 조건을 붙여서 던지는 질의를 의미합니다.
여기서 중요한 점은 query가 그냥 막연한 생각이나 궁금증이 아니라, 보통은 어떤 대상에게 실제로 응답을 기대하는 형태라는 점입니다. 질문과 쿼리는 큰 차이가 존재합니다. 질문의 경우 광범위한 답변이나 응답을 기대하고 수행하지만, 쿼리의 경우 질문보다 목적 지향적이며, 응답 지향적이며 알고 싶은 것을 얻기 위해 대상에게 던지는 '구조화된 질문'이라는 것에 큰 차이가 존재합니다.
데이터베이스를 예시로 들어 설명을 이어가 보겠습니다. 아래와 같이 users라는 테이블이 존재한다고 가정해 보겠습니다.
| id | name | age | city |
| 1 | Min | 20 | Seoul |
| 2 | Jisoo | 17 | Busan |
| 3 | Alex | 25 | Seoul |
위의 테이블에서 20살 이상인 사람들은 어떤 사람들이 있는지 궁금해졌습니다. 이 목적을 달성하려면 어떻게 해야 할까요?
SELECT name FROM users
WHERE age >= 20;
목적을 달성하기 위해서 우리는 테이블을 탐색하고, 나이가 20살 이상인 사람들을 찾아 리스트를 만들어내달라고 요청을 했고 이에 대한 구조화된 답변을 반환받게 됩니다.
이와 같이 쿼리는 특정 분야에 국한되지 않고 데이터베이스, 웹 검색 엔진, API 통신 등 다양한 IT 환경에서 "원하는 정보를 정확히 얻어내기 위한 구조화된 요청 수단"으로 폭넓게 활용됩니다. 즉, 쿼리는 단순한 질문을 넘어 정보 시스템과 상호작용하여 명확한 결과물을 도출하는 핵심적인 의사소통 방식입니다.
시스템의 핵심 구성 요소
TanStack Query의 아키텍처는 크게 4가지 계층으로 나뉩니다. 데이터는 상위 관리자부터 하위 캐시 엔트리, 그리고 이를 소비하는 관찰자(Observer) 순으로 구조화됩니다.
QueryClient
└─ QueryCache
└─ Query (queryKey/queryHash 기준의 실제 캐시 엔트리)
├─ state(data, error, fetchStatus, dataUpdatedAt..)
└─ observers[] ← 여러 QueryObserver가 붙을 수 있음
├─ Observer A ← Component A의 useQuery
└─ Observer B ← Component B의 useQuery
이러한 구조에서 실제 사용하는 방법을 예시로 구체적으로 설명해 보겠습니다.
const queryClient = new QueryClient();
function App() {
return (
<QueryProvider client={queryClient}>
<Todo id={1} />
</QueryProvider>
)
}
우선 QueryClient 객체를 초기화합니다. 이 객체는 "cache와 상호작용하기 위한 객체"이며, 실제 fetchQuery, prefetchQuery와 같은 API를 전부 이 객체에서 제공합니다. 또한 내부에 QueryCache와 MutationCache를 소유하며, 앱 전체의 기본 옵션을 관리합니다. 이 객체에 대한 참조를 Provider로 전달하여 제한된 범위 내에서 하나의 객체를 참조하여 사용할 수 있게끔 합니다.
QueryClient는 클라이언트 밖에서 캐시를 읽고 쓰거나, 특정 쿼리를 무효화하고, 필요하면 미리 prefetch하고, stale 여부에 따라 fetch를 할지 말지를 결정하는 명령형 제어 지점을 제공합니다. 즉, QueryClient는 관리자에 해당하고 Query는 개별 queryKey에 해당하는 실제 캐시 엔트리에 해당합니다.
TanStack Query에서의 쿼리
쿼리는 고유 키에 연결된 비동기 데이터 소스에 대한 선언적 의존성입니다. 쿼리는 GET 및 POST 메서드를 포함한 모든 Promise 기반 메서드와 함께 사용하여 서버에서 데이터를 가져올 수 있습니다. (참고: 특정 메서드가 서버의 데이터를 '수정'하는 경우에는 쿼리가 아닌 뮤테이션(Mutation)을 사용하는 것이 좋습니다.)
function Todo ({ id }) {
const info = useQuery({
queryKey: ['todo', id],
staleTime: 1000 * 60 * 5,
queryFn: ({ queryKey }) => {
const [, id] = queryKey;
return getTodoItem(id);
}
});
}
위와 같이 우리는 특정 queryKey에 대한 새로운 캐시 엔트리를 생성하거나 이미 생성되어 있는 캐시 엔트리에 대한 참조 값을 읽어오는 방식으로 Query를 사용합니다. 이때 생성되는 캐시 엔트리는 단순히 데이터를 캐싱하는 것이 아닌 QueryCache에 queryKey로 매핑되어 있는 Query 객체를 참조하는 것입니다.
Query와 QueryObserver
Query는 우리가 사용하는 핵심 엔트리 객체입니다. 우리는 쿼리를 사용하여 캐시 상태를 갱신하고, 데이터의 유효시간을 설정하고, 주기적으로 데이터를 요청하여 상태를 업데이트할 수 있습니다. 이 객체 내부에는 QueryObserver라는 객체가 존재합니다.
QueryObserver는 쿼리를 관찰하는 객체입니다. 이 객체는 queryKey에 종속되어 있는 데이터의 상태를 관찰하여 변경해야 할 시점을 파악하거나 최소 시간의 범위를 결정하는 역할을 수행합니다. 예를 들어, 사용자가 너무 짧은 시간을 staleTime으로 지정하였을 경우 데이터에 대한 요청이 중복되거나 무시되지 않게끔 최소시간을 기준으로 동작하게끔 하는 역할 또한 수행합니다.
Observer가 이렇게 값의 stale 한 상태를 관리하는 이유는 해당 데이터의 단일 진실 공급원(SSOT, Single Source of Truth)을 지키기 위해서입니다. stale 여부는 완전히 “Query 자체의 절대값”이 아니라, observer 옵션을 포함한 현재 구독 문맥의 영향을 받기 때문입니다. 이러한 영향으로부터 Observer는 공유되는 캐시 원본(Query)을 바탕으로 해당 컴포넌트 전용의 결과(Result)를 파생합니다.
function ComponentA() {
const info = useQuery({
queryKey: ['todos'],
select: data => data.length,
})
}
function ComponentB() {
const info = useQuery({
queryKey: ['todos'],
select: data => data[0],
})
}
예를 들어 ComponentA와 ComponentB가 둘 다 queryKey: ['todos']를 쓴다고 해보겠습니다. query key는 캐시 정체성을 결정하고 결정적으로 해시되므로, 둘은 보통 같은 Query 하나를 공유합니다.
그런데 A는 select: data => data.length를 쓰고, B는 select: data => data[0]를 쓸 수 있습니다. 이때 캐시에는 원본 todos가 한 번 저장되고, A/B 각각의 QueryObserver가 자기 옵션에 맞게 서로 다른 결과값을 계산해서 각 컴포넌트에 돌려줍니다. 이건 문서의 query key 동일성 규칙과 select가 cache가 아닌 returned data에만 영향을 준다는 규칙을 합치면 나오는 자연스러운 해석입니다.
변경된 상태 업데이트가 전파되는 것은 다음과 같은 흐름을 따릅니다.
- 네트워크 응답이나 캐시 무효화로 인해
Query의 내부 상태가 변경됩니다. Query는 자신을 구독 중인 모든QueryObserver의onQueryUpdate()를 호출합니다.QueryObserver는 이전 결과와 새로운 결과를 비교합니다. (이때, 컴포넌트가 실제로 읽은 속성에 변화가 있는지만 확인합니다.)- 실제 유의미한 변화가 있다면, 보관 중인 Listener들을 호출하여 컴포넌트 리렌더링 및 업데이트를 유도합니다.
캐싱 메커니즘과 상태 관리
TanStack Query에서 캐싱, 상태 변경에 대한 감지와 데이터의 흐름을 결정하는 가장 중요한 요소는 queryKey입니다. 이러한 queryKey로 매핑되어 있는 Query를 저장하고 관리하는 객체는 QueryCache입니다.
queryKey의 변경은 내부적인 "감시(Watch)"가 아닌 "재연결(Subscribe/Unsubscribe)" 이라는 것을 주의해야 합니다. queryKey가 ['todos', 1]에서 ['todos', 2]로 변경될 때, 시스템은 기존 배열의 값이 변했는지 내부적으로 감시하는 것이 아닙니다. 새로운 옵션과 키를 기반으로 QueryCache에 질의하여 완전히 다른(또는 새로 생성된) Query 객체를 가져오고, Observer가 이전 Query의 구독을 해제한 뒤 새로운 Query에 구독을 연결하는 방식입니다.
타이머와 Stale (데이터 만료) 판별 방식
데이터가 "오래되었는지(Stale)" 판단하는 기준은 백그라운드에서 계속 돌아가는 타이머 카운트를 기준으로 하는 것이 아닙니다.
구체적으로 setTimeout을 통해 작업을 호출할 경우, Node.js나 브라우저는 타이머를 등록해 두고 EventLoop가 돌면서 지정한 시간이 지났는지 확인을 수행합니다. 콜 스택(Call Stack)에 처리 중이던 작업이 있다면 지연이 발생하므로, setTimeout에서 사용하는 delay 시간은 정확한 시간을 의미하지 않습니다.
이러한 불확실성과 타이머의 시스템 리소스 점유 문제 때문에, TanStack Query는 setTimeout이 아닌 시간 기록(Timestamp) 비교 방식을 사용합니다. 처음 쿼리가 생성되었을 때의 시간을 기록하고, 추후 리렌더링이나 콜백 함수가 실행될 때의 현재 시간과 비교하여 staleTime이 지났는지를 판단하여 새롭게 데이터를 갱신합니다.
메모리 관리: gcTime (Garbage Collection Time)
staleTime과 별개로 gcTime에 대한 명확한 이해도 필요합니다. gcTime은 데이터가 유효한지 확인하기 위한 시간이 아니라, 캐시 데이터가 메모리에 얼마나 오래 남아 있을지를 결정하는 시간입니다.
- staleTime: 화면을 그릴 때 API에 재요청(Refetch)을 보낼지 말지 결정합니다. 데이터가 화면에 보여지고 있는 상태(
active상태)에 주로 적용되는 기준입니다. - gcTime: 화면에서 사라진 데이터를 언제 메모리(
QueryCache)에서 삭제할지를 결정합니다. 오직 데이터가 화면에서 사라진 상태(inactive상태)에서만 의미가 있습니다.gcTime타이머는 쿼리가 생성된 시점이 아니라, 쿼리를 참조하는 모든 Observer가 언마운트되어inactive상태가 된 시점부터 시작되며 기본값은 5분입니다.
이 gcTime은 캐시 히트(Cache Hit) 와 직접적인 관련이 있습니다. 쿼리가 inactive 상태가 되었더라도 아직 gcTime이 지나지 않은 시점에 같은 queryKey로 다시 마운트할 경우, 데이터가 메모리에 남아 있으므로 즉시 화면에 이전 데이터를 보여줍니다. 이때 해당 데이터가 staleTime을 초과했다면, 이전 데이터를 먼저 렌더링한 후 백그라운드에서 조용히 API를 호출하여 최신 데이터로 업데이트합니다. 반면 gcTime이 만료되었다면 가비지 컬렉터(GC)에 의해 QueryCache에서 객체가 완전히 사라졌기 때문에 초기 상태처럼 동작하며, API 응답이 올 때까지 하드 로딩(Loading) 상태가 표기됩니다.
캐시 무효화 (Cache Invalidation)
서버 상태 관리를 하다 보면, POST/PUT/DELETE 등의 작업 이후 캐시를 무효화하여 최신 데이터를 다시 불러와야 하는 상황이 발생합니다. TanStack Query에서는 invalidateQueries를 호출하여 캐시를 무효화합니다. 앞서 이야기했던 todos의 캐시를 무효화한다고 가정해 보겠습니다.
queryClient.invalidateQueries({ queryKey: ['todos'] })
위와 같이 메서드를 호출할 경우 QueryClient는 QueryCache를 검색하여 해당 queryKey와 일치하는 Query 객체를 찾습니다. 찾아낸 Query의 상태를 설정된 staleTime을 무시하고 즉시 만료된(stale) 데이터로 전환시킵니다.
이러한 전환 과정을 갖는 이유는 캐시를 무효화할 때 화면에 미치는 영향을 최소화하기 위해서입니다.
- Active 상태인 데이터 무효화: 화면에 렌더링되어 있어 해당
Query객체를 구독 중인QueryObserver가 존재한다면, 시스템은 즉시 백그라운드에서 데이터를 다시 가져오는 작업(Refetch)을 트리거합니다. 이를 통해 사용자는 화면을 명시적으로 새로고침하지 않아도 최신 서버 상태를 보게 됩니다. - Inactive 상태인 데이터 무효화: 현재 화면에서 사용하고 있지 않아 구독 중인 Observer가 없다면, 즉시 데이터를 다시 가져오지 않습니다. 대신 해당 캐시를 'stale' 상태로만 마킹해 둡니다. 이후 다른 컴포넌트가 마운트되어 해당 데이터를 요청할 때 최신 데이터를 가져오도록 지연시켜 불필요한 네트워크 요청을 방지합니다.
TanStack Query의 기본 동작과 생명주기 요약
TanStack Query는 서버 상태가 외부 요인에 의해 언제든 변경될 수 있다는 점을 전제로 설계되었습니다. 이에 따라 시스템은 데이터를 최신 상태로 유지하기 위해 다음과 같은 기본 설정과 생명주기를 따릅니다.
-
즉각적인 Stale 상태 전환 및 자동 재검증
useQuery등을 통해 데이터를 가져오면, 명시적인staleTime설정이 없는 한 캐시된 데이터는 즉시 '구식(Stale)'으로 간주됩니다(staleTime: 0). 데이터가 Stale 상태로 분류되어 있을 때, 해당Query객체를 구독하는QueryObserver는 아래의 이벤트가 발생하면 백그라운드에서 데이터를 자동으로 다시 가져옵니다(Refetch).- 쿼리를 사용하는 컴포넌트가 새롭게 마운트될 때 (
refetchOnMount) - 브라우저 창에 다시 포커스가 맞춰졌을 때 (
refetchOnWindowFocus) - 네트워크가 끊어졌다가 다시 연결되었을 때 (
refetchOnReconnect) 빈번한 네트워크 요청을 줄이려면staleTime옵션을 길게 설정하여 쿼리가 데이터를 다시 가져오는 빈도를 제어할 수 있습니다.
- 쿼리를 사용하는 컴포넌트가 새롭게 마운트될 때 (
-
비활성 쿼리와 가비지 컬렉션 (
gcTime) 해당 쿼리 데이터를 참조하는 컴포넌트가 모두 언마운트되어QueryObserver가 0개가 되면, 쿼리는 '비활성(Inactive)' 상태로 전환됩니다. 기본적으로 비활성 상태의 쿼리 데이터는 5분(1000 * 60 * 5밀리초) 동안 캐시에 유지된 후 가비지 컬렉터에 의해QueryCache에서 완전히 삭제됩니다. -
오류 발생 시 재시도 (Retry) 데이터 페칭 과정에서 실패가 발생할 경우, 일시적인 네트워크 오류일 가능성을 고려하여 즉각적으로 UI에 에러를 던지지 않습니다. 대신 지수적 백오프(Exponential Backoff) 지연 시간을 적용하여 기본적으로 최대 3회까지 내부적으로 재시도(Retry)를 수행한 후, 모든 시도가 실패했을 때 최종적으로 에러 상태를 반환합니다.
마무리
지금까지 클라이언트 상태와 서버 상태의 근본적인 차이점부터 시작하여, TanStack Query가 내부적으로 서버 상태를 어떻게 구조화하고(QueryCache, Query, Observer) 관리하는지 살펴보았습니다.
단순히 fetch 함수를 사용하여 데이터를 가져오고 useState에 담는 방식은 초기의 서버 데이터를 화면에 그리는 데는 충분할지 모릅니다. 하지만 다수의 사용자가 접근하는 웹 환경에서 데이터는 지속적으로 변하며, 프론트엔드 애플리케이션은 이 '변경되는 원격 데이터'를 얼마나 효율적이고 정확하게 화면에 동기화할 것인가 하는 복잡한 문제에 직면하게 됩니다.
TanStack Query는 단순한 데이터 페칭 라이브러리(Data Fetching Library)를 넘어, 비동기 상태 관리 및 동기화 도구(Async State Management & Synchronization Tool)로서 기능합니다. queryKey를 통한 정교한 캐시 분리, 시간 카운팅이 아닌 타임스탬프 비교를 통한 staleTime 관리, gcTime을 통한 메모리 최적화, 그리고 Observer 패턴을 활용한 컴포넌트 단위의 스마트한 재검증과 무효화 처리는 개발자가 복잡한 상태 동기화 로직을 직접 구현하는 수고를 덜어줍니다.
이러한 내부 동작 원리를 이해하고 라이브러리를 활용한다면, 단순히 API를 호출하는 것을 넘어 불필요한 네트워크 요청을 최소화하고 사용자에게 항상 신뢰할 수 있는 최신 상태의 UI를 제공하는 더욱 견고한 웹 애플리케이션을 구축할 수 있을 것입니다.