모든 글

[배우는 중] 서버 데이터를 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에 담고 동기화 코드가 자꾸 늘어난다면 — 캐시를 손으로 재발명하고 있는 신호예요.

서버 상태는 전역 스토어 말고 쿼리 라이브러리에.

N
NACRE
Writing on less · Seoul
다른 글 보기

댓글

  1. 불러오는 중…