[배우는 중] 서버 데이터를 useState에 담았다가 캐시 지옥을 봤다
API로 받아온 데이터를 useState·전역 스토어에 넣고 직접 동기화하다 매번 꼬였다. "상태에는 세 종류가 있고 서버 상태는 내 것이 아니다"를 배우며, TanStack Query로 넘어간 과정을 남긴다.
배우는 중(Learning log) — 완성된 정리가 아니라, 혼자 공부하다 막힌 지점과 그걸 넘은 과정을 그대로 남기는 시리즈입니다. 지금 제 이해라서 틀릴 수 있고, 나중에 알게 되면 정정 노트를 답니다.
파생 상태 글에서 “state는 최소한의 진실만 담자”를 배웠는데, 그다음 질문이 남았어요.
그럼 API로 받아온 데이터는 어디에 둬야 하지.
버릇처럼 이렇게 했어요.
const [projects, setProjects] = useState([]);
useEffect(() => {
fetch(`/api/teams/${teamId}/projects`)
.then(r => r.json())
.then(setProjects);
}, [teamId]);
규모가 커지자 여러 화면이 같은 데이터를 쓰길래, 목록을 통째로 전역 스토어(Context/Zustand)에 올려버렸고요.
거기서 지옥이 시작됐어요.
글을 등록했는데 목록이 안 바뀌어요.
그래서 등록 후 setProjects를 손으로 다시 불렀죠.
화면 전환할 때마다 API를 또 쏘거나, 반대로 낡은 데이터가 그대로 남거나.
전역 스토어에 넣은 서버 데이터를 언제 새로 받아 갱신할지 몰라서 동기화 코드가 계속 늘었어요.
로딩·에러 플래그도 화면마다 손으로 달았고요.
어느 순간 이런 생각이 들었어요.
나 지금 캐시를 손으로 만들고 있는 거 아닌가.
공부 자료의 첫 문장이 정곡을 찔렀어요.
”상태 관리가 어렵다는 건 보통 종류를 안 나눠서 생긴다.”
상태엔 세 종류가 있더라고요.
| 종류 | 예시 | 도구 |
|---|---|---|
| 로컬 UI 상태 | 모달 열림, 입력값, 탭 | useState, useReducer |
| 전역 클라이언트 상태 | 로그인 사용자, 테마, 언어 | Context, Zustand |
| 서버 상태 | 사용자·프로젝트 목록, 상세 | TanStack Query |
그리고 결정적인 한 줄.
서버에서 온 데이터를 전역 스토어에 직접 넣어 관리하면 캐시·동기화가 지옥이 된다.
제가 딱 그걸 하고 있었어요.
관점을 바꿔야 했어요.
useState에 담는 값은 내 앱이 소유한 값이지만, API로 받아온 데이터는 서버가 원본을 가진 “서버 상태”라서 받는 순간부터 낡기 시작해요(stale). 그러니 내가 소유하려 들 게 아니라 캐시로 다뤄야 했어요.
그래서 TanStack Query로 옮겼어요.
const { data, isPending, isError } = useQuery({
queryKey: ['projects', teamId], // 캐시 식별자 — teamId 바뀌면 별도 캐시
queryFn: () => fetch(`/api/teams/${teamId}/projects`).then(r => r.json()),
staleTime: 60_000, // 1분간 '신선' — 재마운트해도 재요청 안 함
});
staleTime 동안은 캐시를 신선으로 보고 재요청을 안 해요.
지나면 stale로 표시하고, 창 포커스·재마운트 같은 신호가 오면 백그라운드에서 조용히 다시 받아와요.
화면은 기존 데이터를 계속 보여주다 새 값으로 바뀌고요.
쓰기 후엔 queryClient.invalidateQueries({ queryKey: ['projects'] }) 한 줄로 “이 캐시 낡았어”라고 알리면 목록이 알아서 refetch. 제가 손으로 setProjects 부르던 걸 이게 대체했어요.
로딩·에러·중복 요청 제거도 전부 딸려 오고요 — 화면마다 짜던 그 코드요.
캐시는 미리 뽑아둔 출력 시안 — 즉시 보여줄 수 있지만 원본(서버)이 바뀌었을 수 있으니 주기적으로 다시 뽑는다.
퍼블리셔한테 이 비유가 딱 와닿았어요.
”화면에 필요한 데이터는 다 내 state여야 한다”고 생각했는데, 서버 데이터는 빌려 쓰는 캐시였던 거예요.
아직 감이 없는 건 queryKey 설계예요.
prefix 매칭이라 ['projects']를 invalidate하면 ['projects', teamId]도 같이 날아간다는데, 어디까지 계층으로 쪼갤지 아직 모르겠어요.
staleTime이랑 gcTime(캐시 보관 시간)의 실전 기본값도 다음 숙제고요.
혹시 서버 데이터를 useState에 담고 동기화 코드가 자꾸 늘어난다면 — 캐시를 손으로 재발명하고 있는 신호예요.
서버 상태는 전역 스토어 말고 쿼리 라이브러리에.
댓글