앞에서 loading, error, data 세 상태를 useState로 손수 관리하는 법을 봤다. 컴포넌트 하나면 할 만하다. 문제는 이 패턴을 앱 곳곳에 복사하기 시작할 때다. 사용자 정보를 헤더에서도 부르고 프로필 화면에서도 부르고 댓글 목록에서도 부른다. 같은 데이터를 세 군데서 각자 fetch하고, 각자 로딩을 돌리고, 캐시도 없이 매번 새로 받아온다. 뭔가 근본적으로 잘못됐다는 느낌이 들 때쯤, 서버 상태라는 개념을 만나게 된다.


서버 상태와 클라이언트 상태는 종류가 다르다. 우리가 useState로 다루는 상태에는 사실 두 종류가 섞여 있다. 하나는 클라이언트 상태다. 모달이 열렸는지, 어느 탭이 선택됐는지, 입력창에 뭐라고 쳤는지 같은 것들이다. 이건 전적으로 내 브라우저 안에서 태어나고 죽으며, 진실의 원본이 내 손안에 있다. 다른 하나는 서버 상태다. 사용자 목록, 게시글, 주문 내역처럼 서버에 원본이 있고 나는 그 사본을 잠깐 빌려다 화면에 뿌리는 것뿐인 데이터다. 이 둘을 똑같이 useState로 다루려는 데서 고생이 시작된다.


서버 상태는 내 것이 아니라서 골치 아프다. 클라이언트 상태는 내가 바꾸기 전엔 안 바뀐다. 그런데 서버 상태의 원본은 남의 컴퓨터에 있고, 내가 보고 있는 사이에 다른 사람이 그걸 고칠 수 있다. 즉 내 화면의 데이터는 받아온 순간부터 이미 낡아가는 사본이다. 그래서 서버 상태에는 클라이언트 상태엔 없는 숙제들이 딸려 온다. 언제 다시 받아올지, 받아온 걸 얼마나 믿고 재사용할지, 같은 데이터를 여러 곳에서 쓸 때 요청을 어떻게 한 번으로 합칠지, 창을 다시 봤을 때 새로고침할지. 이걸 전부 useStateuseEffect로 손수 짜면 컴포넌트마다 같은 배관 코드가 수북이 쌓인다.


캐시. 한 번 받은 걸 기억해 두고 또 부르지 않는다. 이 숙제들 중 첫째가 캐싱(caching, 받아온 값을 저장해 재사용)이다. 같은 데이터를 두 컴포넌트가 필요로 하면, 순진하게 짜면 요청이 두 번 나간다. 한 번 받아온 걸 어딘가 기억해 두고 두 번째부터는 그걸 꺼내 쓰면 요청을 아낄 수 있다. 개념을 눈으로 보려고 모듈 바깥에 조잡한 캐시를 하나 만들어 보자.


const cache = new Map();

async function getUser(userId) {
if (cache.has(userId)) {
return cache.get(userId);
}
const res = await fetch(`/api/users/${userId}`);
const json = await res.json();
cache.set(userId, json);
return json;
}


두 번째 호출부터는 fetch 없이 Map에 담아둔 값을 바로 돌려준다. 아이디어는 이렇게 단순하다. 그런데 이 조잡한 캐시를 실전에 쓰려고 하면 금세 질문이 쏟아진다. 캐시에 담긴 값이 낡으면 언제 버리나. 데이터가 바뀌면 이 캐시를 어떻게 갱신하나. 캐시된 값을 쓰는 컴포넌트들에게 새 값이 왔다고 어떻게 알리나. 요청이 진행 중일 때 같은 요청이 또 들어오면 어떻게 하나로 합치나. 이걸 다 제대로 만들면 그게 곧 서버 상태 라이브러리다.


React Query 같은 라이브러리가 이 일을 대신한다. 서버 상태의 이 골칫거리들을 매번 손으로 짜는 대신, 그걸 전문으로 하는 도구를 쓰는 게 요즘의 표준이다. 리액트 쿼리(React Query, 지금은 TanStack Query라 불린다)나 SWR 같은 것들이다. 앞에서 손수 짰던 세 상태 코드가 이걸 쓰면 이렇게 줄어든다.


function UserProfile({ userId }) {
const { data, isLoading, error } = useQuery({
queryKey: ["user", userId],
queryFn: () =>
fetch(`/api/users/${userId}`).then((res) => res.json()),
});

if (isLoading) return <p>불러오는 중...</p>;
if (error) return <p>문제가 생겼다</p>;
return <p>{data.name}</p>;
}


loading, error, datauseState로 세 개 선언하고 useEffect로 요청을 넣던 배관이 통째로 사라졌다. useQuery가 그 세 상태를 알아서 만들어 돌려준다. 그리고 그 안에서 우리가 앞서 손으로 고민하던 캐싱, 중복 요청 합치기, 배경 갱신을 다 처리한다. 우리가 쓴 건 어떤 키로 무엇을 어떻게 받아올지, 그 두 가지를 알려준 것뿐이다. 게다가 같은 키의 요청이 거의 동시에 여러 곳에서 일어나도, 라이브러리가 그걸 하나로 합쳐 서버로는 한 번만 보낸다. 앞서 손으로 짠 Map 캐시가 풀지 못하던 진행 중 요청 합치기까지 기본으로 해주는 것이다.


queryKey가 캐시의 이름표다. 이 도구의 핵심은 queryKey다. 위에서 ["user", userId]가 그것이다. 라이브러리는 이 키를 캐시의 주소로 쓴다. 같은 키를 쓰는 useQuery가 앱 안 어디서 몇 번 불리든, 데이터는 캐시에서 공유되고 실제 요청은 한 번만 나간다. 헤더와 프로필과 댓글 목록이 전부 ["user", 1]이라는 같은 키로 사용자 1번을 요구하면, 셋이 같은 캐시를 바라보고 서버로는 요청이 한 번만 간다. 아까 손으로 만든 Map의 키가 하던 일을, 훨씬 야무지게 대신 해주는 셈이다. 키가 바뀌면, 가령 userId가 2로 바뀌어 키가 ["user", 2]가 되면 새 데이터로 인식하고 그때 새로 받아온다.


낡은 걸 먼저 보여주고 뒤에서 조용히 갱신한다. 서버 상태 라이브러리가 특히 영리한 지점이 여기다. 캐시에 값이 있으면 일단 그 값을 즉시 화면에 뿌린다. 그래서 두 번째 방문부터는 로딩 화면 없이 데이터가 바로 뜬다. 동시에, 그 값이 낡았다고 판단하면 뒤에서 몰래 새로 받아와 조용히 갈아끼운다. 이걸 stale-while-revalidate(낡은 걸 보여주면서 뒤에서 다시 검증한다)라고 부른다. 얼마나 지나야 낡은 걸로 볼지는 staleTime 같은 설정으로 정한다. 사용자 눈엔 화면이 늘 즉각적으로 뜨는데, 실제로는 최신 데이터가 알아서 따라붙는 것이다. 새로고침을 언제 걸지도 알아서 판단한다. 가령 다른 탭에 갔다가 이 창으로 돌아오면, 그 사이 데이터가 바뀌었을 수 있으니 조용히 다시 받아오는 식이다. 이걸 앞의 useState 방식으로 직접 구현하려면 코드가 상당히 지저분해진다.


서버 상태 라이브러리를 언제 쓰나. 오해하면 안 되는 건, 이게 useState를 대체하는 물건이 아니라는 점이다. 클라이언트 상태, 그러니까 모달 열림 여부나 입력값 같은 건 여전히 useState의 몫이다. 서버 상태 라이브러리는 서버에서 받아오는 데이터에만 쓰는 도구다. 앱이 작고 API 호출이 한두 개뿐이면 앞에서처럼 useEffect로 손수 짜도 충분하다. 그런데 같은 데이터를 여러 화면이 공유하고, 캐싱과 갱신을 신경 쓰기 시작하는 순간부터는 손으로 만든 배관이 감당이 안 된다. 그 배관을 매번 다시 짜느니 검증된 도구에 맡기는 편이 낫다. 서버 상태와 클라이언트 상태를 갈라서 보는 눈만 생겨도, 어떤 상태에 어떤 도구를 댈지가 훨씬 또렷해진다.