React Query / SWR / Apollo는 재요청 중 이전 데이터를 어떻게 다룰까
- CS
- Apollo Client
- React Query
- SWR
들어가며
Apollo Client는 쿼리 조건이 바뀌면 data가 잠깐 빈다. 이 빈 순간을 메워주는 게 previousData다. React Query나 SWR에도 비슷한 이름의 옵션이 있다. 세 라이브러리는 이 빈 순간을 같은 방식으로 메울까?
먼저 확인해야 할 건 이 빈 순간이 언제 찾아오느냐다. 세 라이브러리 모두 같은 조건으로 다시 조회하는 상황에서는 옵션 없이도 이전 값을 그대로 보여준다. 화면이 비는 건 조회 조건 자체가 바뀌는 순간, 즉 다른 자원으로 넘어가는 상황뿐이다.

React Query는 이전 값과 플래그를 함께 돌려준다
쿼리 키가 바뀌어 다른 자원으로 넘어가는 상황에서 React Query는 placeholderData에 이전 데이터를 그대로 반환하는 함수를 넘겨 이전 값을 유지한다. 이 방식이 자주 쓰이다 보니 라이브러리는 아예 keepPreviousData라는 헬퍼 함수로 만들어 제공한다.
import { useQuery, keepPreviousData } from '@tanstack/react-query'
function Todos({ page }: { page: number }) {
const { data, isPlaceholderData } = useQuery({
queryKey: ['todos', page],
queryFn: () => fetchTodos(page),
placeholderData: keepPreviousData,
})
return <TodoList todos={data?.todos ?? []} isStale={isPlaceholderData} />
}placeholderData: keepPreviousData: 새 페이지 요청이 끝날 때까지 이전 페이지의 data를 그대로 화면에 남겨둠isPlaceholderData: 지금 보이는 data가 새 페이지 응답이 아니라 이전 페이지에서 넘어온 값이라는 표시 (위 예시에서는 이 값을 isStale이라는 이름으로 TodoList에 그대로 전달해 목록을 흐리게 표시)다만 새 페이지 요청이 실패하면 이 병합은 유지되지 않는다. placeholderData는 실제로 캐시된 값이 아니라 성공한 것처럼 보여주는 임시 데이터라, 요청이 실패하는 순간 data는 다시 undefined로, isPlaceholderData는 false로 돌아가면서 화면에는 이전 값 대신 빈 상태가 나타난다.
SWR도 이전 값과 로딩 상태를 함께 돌려준다
SWR은 기본 철학 자체가 stale-while-revalidate다. 같은 키를 다시 요청할 때는 캐시에 있는 값을 먼저 보여주면서 백그라운드에서 갱신하므로, 같은 키의 재요청만으로는 화면이 비지 않는다.
SWR도 키가 바뀌어 다른 자원으로 넘어가는 상황에서는 다르다. 이때를 위한 옵션이 SWR 2.0에서 추가된 keepPreviousData로, 새 키의 데이터가 도착하기 전까지 이전 키의 데이터를 유지한다. 공식 문서는 타이핑할 때마다 검색어가 바뀌는 실시간 검색 UI를 예로 든다. 검색어가 바뀔 때마다 결과가 사라졌다 나타나는 대신, 이전 검색 결과를 보여주면서 새 결과로 자연스럽게 바뀐다.
function Search() {
const [keyword, setKeyword] = useState('')
const { data, isLoading } = useSWR(`/search?q=${keyword}`, fetcher, {
keepPreviousData: true,
})
return <SearchResults items={data?.items ?? []} isLoading={isLoading} />
}SWR도 실패까지 가려주지는 않는다. 키가 바뀐 뒤 재요청이 실패해도 data는 이전 키의 값을 그대로 유지하고, 실패 정보는 error에 별도로 담긴다. 화면에 실패를 반영하려면 error를 직접 확인해야 한다.
Apollo는 병합과 판단을 개발자에게 맡긴다
위와 같이 React Query와 SWR은 이전 값을 data 자체에 담아 돌려준다. Apollo는 두 가지 지점에서 앞의 둘과 다르다. previousData를 data와 별도 필드로 주고, 어느 쪽을 보여줄지는 data ?? previousData처럼 직접 조합해야 한다. 그리고 지금 보이는 값이 이전 데이터인지 아닌지도 개발자가 직접 판단해야 한다.
React Query의 isPlaceholderData처럼 "지금 보이는 게 이전 값"이라고 알려주는 플래그가 Apollo에는 없다. 목록처럼 같은 종류의 자원을 다시 조회하는 화면이라면 이 조합이 자연스럽지만, 상세 페이지처럼 다른 항목으로 이동하는 화면에서 같은 방식을 그대로 쓰면 문제가 생긴다.
function TodoDetail({ id }: { id: string }) {
const { data, previousData } = useQuery(TODO_QUERY, {
variables: { id },
fetchPolicy: 'cache-and-network',
})
const todo = data?.todo ?? previousData?.todo
return <TodoDetailView todo={todo} />
}A 항목을 보다가 B로 이동하면 previousData엔 A의 데이터가 남아 있다. B의 응답이 도착하기 전까지 화면엔 A의 제목과 내용이 그대로 떠 있다. 사용자는 이미 B로 이동했는데 화면엔 A의 정보가 보이는 셈이다. 목록에서는 자연스러웠던 병합이 상세 화면에서는 잘못된 정보를 보여주는 대가로 바뀐다.

Apollo에는 그 구분이 없어서, 같은 종류의 재조회인지 다른 항목으로의 전환인지를 코드 작성자가 직접 판단해야 한다. 요청이 실패했을 때도 previousData는 그대로 남는다. 화면에 이전 값이 보이는 것과 별개로 error 상태는 직접 확인해야 실패를 놓치지 않는다.

실패했을 때의 태도도 갈린다. SWR과 Apollo는 실패해도 이전 값을 화면에 남겨두고 error를 따로 확인하게 하지만, React Query의 placeholderData는 캐시된 값이 아니라 임시 데이터라 실패하는 순간 함께 사라진다.

정리하며
React Query, SWR, Apollo는 조회 조건이 바뀌는 순간 화면이 비면서, 로딩 중인 상태를 데이터가 없는 것으로 볼지 이전 데이터가 아직 유효한 것으로 볼지를 판단해야 하는 같은 상황에 놓인다. 세 라이브러리는 이 상황을 previousData / placeholderData / keepPreviousData라는 이름의 같은 장치로 풀어낸다.
다만 로딩 중인 값을 데이터가 없는 것으로 볼지 이전 데이터로 볼지를 누가 판단하느냐는 라이브러리마다 다르다. React Query와 SWR은 이전 값을 자동으로 병합하고 플래그로 구분까지 해주는 반면, Apollo는 값만 넘겨줄 뿐 병합과 판단을 코드 작성자에게 맡긴다. 그래서 Apollo를 쓴다면 같은 자원을 다시 조회하는 화면인지 다른 자원으로 전환하는 화면인지부터 직접 구분해야 previousData를 안전하게 쓸 수 있다.
