프론트엔드 데이터 패칭 다시 보기 (02) - 서버 상태를 다룬다는 것은 정확히 무엇인가
캐시, stale data, refetch가 왜 한 묶음으로 따라오는지, 서버 상태를 로컬 상태와 같은 방식으로 다룰 수 없는 이유를 정리합니다.
용어 보기접기/펼치기
서버 상태를 다룬다는 말은 값을 한 번 받아 오는 일보다, 그 값을 언제까지 믿고 언제 다시 맞출지를 정하는 일에 더 가깝습니다.
들어가며
이전 글에서는 React에서 비동기 데이터 처리가 왜 자꾸 복잡해지는지부터 살펴봤습니다. useEffect + useState는 첫 화면에서는 단순해 보이지만, 재방문과 중복 요청, stale data, 경쟁 상태 같은 조건이 붙는 순간 전혀 다른 질문을 만들기 시작합니다.
그 지점에서 자연스럽게 나오는 표현이 바로 "서버 상태"입니다. 많은 글과 라이브러리가 이 말을 당연하게 쓰지만, 막상 처음 접하면 로컬 상태와 무엇이 다른지 선명하게 잡히지 않을 때가 많습니다.
이번 글에서는 그 모호함을 줄이는 데 집중합니다. 서버 상태를 다룬다는 말이 정확히 무엇을 뜻하는지, 왜 캐시와 재검증, background refetch 같은 개념이 항상 함께 따라오는지, 그리고 왜 이 문제를 단순한 React state의 확장으로만 보면 설명이 자꾸 빗나가는지를 정리하겠습니다.
이 글에서는 네 가지를 중심으로 보겠습니다.
- 서버 상태가 로컬 상태와 본질적으로 다른 이유
- 캐시와 stale data가 왜 성능 문제가 아니라 모델의 일부가 되는지
- 로딩과 에러도 왜 데이터 상태와 함께 봐야 하는지
- 왜 서버 상태 도구는 결국 동기화 규칙을 추상화하게 되는지
서버 상태의 핵심은 값을 저장하는 일이 아니라, 원본이 화면 바깥에 있는 값을 어떤 규칙으로 동기화할지 정하는 일입니다.
로컬 상태와 서버 상태는 질문부터 다릅니다
React에서 상태를 다룬다고 할 때 가장 먼저 떠오르는 것은 보통 useState입니다. 실제로 아래 같은 값은 useState로 다루는 편이 자연스럽습니다.
- 현재 input에 들어 있는 값
- 모달이 열려 있는지 여부
- 어떤 탭이 선택되어 있는지
- 드롭다운이 펼쳐져 있는지 여부
이 값들은 대부분 화면 안에서 시작해 화면 안에서 끝납니다. 사용자가 조작하면 바뀌고, 화면이 사라지면 함께 사라져도 큰 문제가 없습니다.
반면 서버 데이터는 다릅니다.
- 원본은 서버에 있습니다.
- 화면은 그 사본을 잠시 들고 있을 뿐입니다.
- 시간이 지나면 지금 값이 더 이상 최신이 아닐 수 있습니다.
- 여러 화면이 같은 값을 함께 참조할 수도 있습니다.
즉 로컬 상태의 질문이 보통 이 값을 어디에 둘까에 가깝다면, 서버 상태의 질문은 이 값을 언제 다시 믿고, 언제 다시 가져오고, 누가 함께 쓸까에 가깝습니다.
이 차이를 먼저 분리하지 않으면, 캐시나 refetch 같은 개념도 전부 "있으면 좋은 부가 기능" 정도로만 보이기 쉽습니다.
한 줄로 비교하면 이렇습니다.
- 로컬 상태: 어디에 둘지 정하면 설명이 끝나는 경우가 많습니다.
- 서버 상태: 어디에 둘지보다 언제 다시 맞출지를 정해야 설명이 끝납니다.
서버 상태는 사본을 다루는 문제입니다
서버 상태를 더 짧게 표현하면, 원본이 내 컴포넌트 안에 없는 데이터라고 볼 수 있습니다.
예를 들어 글 목록을 받아 왔다고 가정해 보겠습니다. 화면에는 지금 배열이 들어와 있지만, 그 배열 자체가 기준은 아닙니다. 원본은 서버에 있고, 지금 화면에 있는 값은 어느 시점에 복사해 온 결과일 뿐입니다.
이 말은 곧 두 가지를 뜻합니다.
- 지금 값이 아직 유효한지 판단해야 합니다.
- 필요하면 같은 값을 다시 받아 와야 합니다.
그래서 서버 상태에는 "저장"보다 "동기화"라는 말이 더 잘 어울립니다. 화면에 들어온 데이터를 보관하는 일 자체보다, 그 값이 언제까지 믿을 만한지와 언제 다시 맞춰야 하는지가 더 중요하기 때문입니다.
서버 상태는 들고 있는 순간에도 완성된 값이 아닙니다. 지금도 믿어도 되는 값인지 계속 판단해야 하는 값에 가깝습니다.
그래서 캐시는 성능 기법이기 전에 작업 메모리입니다
캐시라는 단어를 들으면 보통 성능 최적화를 먼저 떠올립니다. 한 번 받은 값을 다시 활용해서 네트워크 요청을 줄이는 전략이라고 생각하기 쉽습니다.
물론 그 설명도 맞습니다. 하지만 서버 상태를 다룰 때 캐시는 단순한 속도 개선 장치보다 더 큰 역할을 합니다.
캐시는 화면이 서버 데이터를 다시 다루기 위한 작업 메모리에 가깝습니다.
예를 들어 사용자가 글 목록을 보고 상세 화면으로 들어갔다가 다시 목록으로 돌아온다고 가정해 보겠습니다. 이때 캐시가 없다면 화면은 목록을 다시 가져올지, 이전 값을 유지할지, 잠깐 비울지를 매번 처음부터 판단해야 합니다.
반대로 캐시가 있으면 최소한 "마지막으로 받아 온 값"이라는 기준점은 생깁니다. 그 기준점 위에서 아래 같은 선택이 가능해집니다.
- 일단 이전 값을 먼저 보여 줄지
- background에서 다시 가져올지
- 아직 fresh하다고 보고 그대로 쓸지
- 완전히 새 요청이 필요한 상황인지
즉 캐시는 빠르게 보이게 만드는 장치이기도 하지만, 그보다 먼저 서버 데이터의 현재 상태를 해석하는 기준점 역할을 합니다.
캐시가 없으면 화면은 매번 "처음 보는 데이터"처럼 판단해야 합니다. 캐시가 있으면 "마지막으로 확인한 데이터"를 기준으로 다음 행동을 결정할 수 있습니다.
stale data는 오류가 아니라 상태입니다
서버 상태를 다룰 때 중요한 단어 중 하나가 stale data입니다. 이 표현은 자칫 "틀린 데이터"처럼 들리지만, 실제 의미는 조금 다릅니다.
stale data는 완전히 잘못된 데이터라기보다, 지금도 최신이라고 장담할 수 없는 데이터에 가깝습니다.
예를 들어 알림 목록을 10초 전에 받아 왔다면, 그 값은 여전히 꽤 쓸 만할 수 있습니다. 하지만 방금 서버에서 새로운 알림이 생겼다면 최신 상태는 아닐 수 있습니다. 여기서 중요한 것은 값이 무조건 틀렸다고 보는 것이 아니라, 얼마 동안은 믿고 언제부터 다시 확인할지를 정하는 일입니다.
그래서 서버 상태를 다루는 순간 stale 여부가 항상 따라옵니다. 값이 있느냐 없느냐만으로는 부족하고, 그 값이 어느 정도 신선한지도 함께 다뤄야 하기 때문입니다.
이 지점부터는 캐시와 staleTime, background refetch 같은 개념이 왜 한 묶음으로 등장하는지도 자연스럽게 이어집니다.
즉 stale 여부를 다룬다는 것은 단순히 "오래됐나?"를 묻는 일이 아닙니다. 지금은 어떤 경험을 주고, 언제 최신성을 다시 확인할지를 정하는 일이기도 합니다.
실무에서는 이 차이가 코드에서 더 직접적으로 드러납니다. 예를 들어 목록 화면을 만들다 보면, 처음에는 아래처럼 시작하기 쉽습니다.
const [posts, setPosts] = useState<Post[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [lastFetchedAt, setLastFetchedAt] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const response = await fetch("/api/posts");
const data = (await response.json()) as Post[];
if (!cancelled) {
// 마지막 성공 결과와 시점을 함께 들고 있습니다.
setPosts(data);
setLastFetchedAt(Date.now());
setIsLoading(false);
}
} catch (err) {
if (!cancelled) {
// 실패했더라도 이전 데이터를 유지할지, 비울지는 별도 정책이 필요합니다.
setError(err as Error);
setIsLoading(false);
}
}
}
load();
return () => {
cancelled = true;
};
}, []);이 코드는 틀리지 않습니다. 다만 여기서부터 질문이 빠르게 늘어납니다.
- 마지막으로 받아 온 시점을 얼마 동안 믿을 것인가
- 다시 방문했을 때 이전 값을 먼저 보여 줄 것인가
- 백그라운드 갱신 중임을 별도로 표시할 것인가
- 첫 로드 실패와 재검증 실패를 같은 에러로 볼 것인가
즉 서버 상태를 다룬다는 말은, 결국 이 코드 주변에 정책을 계속 덧붙이는 일이 되기 쉽습니다.
로딩 상태도 하나가 아니라 여러 층으로 나뉩니다
비동기 데이터 처리를 다룰 때 isLoading 하나로 설명을 끝내고 싶어질 때가 많습니다. 하지만 서버 상태에서는 로딩도 하나의 경우로 끝나지 않습니다.
예를 들어 아래 세 상황은 모두 "로딩 중"이라고 말할 수 있습니다.
- 첫 진입이라 아직 아무 데이터도 없습니다.
- 예전 데이터는 있지만 최신 값으로 다시 확인하는 중입니다.
- 검색 조건이 바뀌어 완전히 다른 데이터를 기다리고 있습니다.
겉보기에는 비슷하지만, 화면이 취해야 할 대응은 같지 않습니다.
- 첫 진입이면 skeleton이나 placeholder가 필요할 수 있습니다.
- 이전 데이터가 있으면 그대로 보여 주면서 조용히 갱신하는 편이 더 낫습니다.
- 완전히 다른 결과를 기다리는 상황이면 이전 데이터를 유지할지 비울지부터 결정해야 합니다.
즉 로딩은 불리언 하나가 아니라, 현재 데이터가 어떤 전환 상태에 있는지를 나타내는 신호에 가깝습니다.
그래서 서버 상태를 잘 다루는 화면은 로딩을 없애기보다, 로딩의 종류를 구분합니다.
에러도 값의 부재만 뜻하지 않습니다
에러도 비슷합니다. 서버 상태에서 에러는 단순히 "실패했으니 아무것도 없다"로 끝나지 않을 때가 많습니다.
예를 들어 이전에 성공했던 데이터가 있는데, 최신 값을 다시 가져오는 데 실패했다고 가정해 보겠습니다. 이때 화면은 꼭 빈 상태로 돌아가야 할까요. 많은 경우 그렇지 않습니다. 이전 데이터를 유지한 채, 최신 확인에 실패했다는 사실만 알려 주는 편이 더 자연스럽습니다.
즉 서버 상태에서 에러는 아래처럼 더 세분된 질문을 만듭니다.
- 마지막 성공 값이 있나
- 이번 실패가 첫 요청의 실패인가, 재검증 실패인가
- 지금 화면을 비워야 하나, 유지해야 하나
- 자동 재시도를 할 것인가
이 때문에 서버 상태에서는 에러 처리 역시 데이터 모델 바깥의 부가 기능이 아니라, 모델 안쪽에 같이 들어와야 합니다.
같은 "실패"라도 첫 로드 실패와 재검증 실패를 다르게 취급하는 이유가 여기에 있습니다.
결국 중요한 것은 ownership보다 synchronization입니다
프론트엔드에서 상태를 다룬다고 하면 보통 "이 상태를 어디에 둘까"를 먼저 생각합니다. props로 내릴지, context로 뺄지, 전역 상태로 둘지 같은 고민입니다.
하지만 서버 상태는 ownership이라는 말만으로는 충분히 설명되지 않습니다. 어디에 둘지를 정하는 순간보다, 어떤 규칙으로 다시 맞출지를 정하는 순간이 더 중요하기 때문입니다.
예를 들어 글 목록을 여러 화면이 공유한다고 해도, 핵심은 누가 그 배열을 소유하느냐가 아닙니다. 그 값이 언제 stale해지고, 언제 다시 가져와야 하며, 누가 같은 캐시를 바라볼지 같은 규칙이 먼저입니다.
그래서 서버 상태 도구들은 결국 아래 문제를 공통으로 추상화하게 됩니다.
- 같은 데이터를 같은 키로 식별하는 방법
- 마지막으로 받아 온 값을 저장하는 방법
- 언제 stale로 볼지 정하는 방법
- 언제 다시 가져올지 정하는 방법
- 실패와 재시도를 다루는 방법
이렇게 보면 React Query나 SWR 같은 도구를 "fetch를 편하게 해 주는 라이브러리"라고만 설명하면 항상 반쯤 부족합니다. 실제로는 fetch 자체보다, 서버 데이터를 어떤 규칙으로 동기화할지를 정리해 주는 도구에 더 가깝기 때문입니다.
예를 들어 글 목록을 다시 설계한다고 해 보면, 실무에서는 아래처럼 역할을 나누고 싶어집니다.
const postsQuery = useQuery({
queryKey: ["posts", filters],
queryFn: () => fetchPosts(filters),
staleTime: 30_000,
});
if (postsQuery.isPending) {
return <PostsSkeleton />;
}
if (postsQuery.isError) {
return <PostsError />;
}
return (
<>
{postsQuery.isFetching ? <RefreshingIndicator /> : null}
<PostsList posts={postsQuery.data} />
</>
);이 코드는 단순히 훅 하나로 줄어든 예제가 아닙니다.
- 목록 데이터는 어떤 키로 식별할지
- 얼마 동안 fresh로 볼지
- 첫 진입 대기와 background refetch를 어떻게 다르게 보여 줄지
같은 질문을 매 화면마다 새로 쓰지 않고, 하나의 모델 안에서 반복 가능하게 만든 예시에 가깝습니다.
서버 상태 도구의 핵심은 요청 함수를 대신 써 주는 것이 아니라, 같은 질문에 매번 새로 답하지 않도록 공통 규칙을 제공하는 일입니다.
그래서 서버 상태 도구는 비슷한 모양을 갖습니다
서버 상태 도구마다 API는 조금씩 다르지만, 깊이 들어가 보면 다루는 문제는 놀랄 만큼 비슷합니다.
- 데이터를 어떤 이름으로 식별할 것인가
- 그 데이터를 어디까지 캐시할 것인가
- 언제 stale하다고 볼 것인가
- 언제 자동으로 다시 가져올 것인가
- 실패했을 때 어떻게 복구할 것인가
즉 도구의 차이보다 먼저 봐야 할 것은, 이 질문들이 왜 항상 같이 따라오는가입니다.
1편에서 이야기한 복잡함도 사실은 여기서 나왔습니다. useEffect + useState로 직접 구현하기 시작하면, 우리는 결국 이 질문들에 대한 답을 화면마다 따로 쓰게 됩니다. 그리고 어느 순간부터는 화면 코드를 쓰고 있는지, 작은 서버 상태 관리기를 쓰고 있는지 경계가 흐려집니다.
마치며
서버 상태를 다룬다는 말은 값을 한 번 받아 오는 일이 아닙니다. 원본이 화면 바깥에 있는 값을 얼마나 믿을지, 언제 다시 가져올지, 어떤 기준으로 공유할지를 정하는 일에 더 가깝습니다.
그래서 캐시, stale data, refetch, background refetch, retry 같은 개념은 따로따로 붙는 부가 기능이 아니라 한 문제의 다른 얼굴에 가깝습니다. 모두 같은 질문, 즉 "이 값을 지금 어떻게 다뤄야 하는가"에 대한 다른 답이기 때문입니다.
다음 글에서는 여기서 한 걸음 더 나가 보겠습니다. 서버 상태가 어떤 문제인지까지 정리했다면, 이제는 TanStack Query가 이 문제를 어떤 모델로 추상화하는지 살펴볼 차례입니다. query key, cache, invalidation 같은 개념이 어떻게 하나의 구조로 묶이는지를 이어서 정리하겠습니다.