Apollo Client의 cache-and-network 재요청 중 이전 값을 지키는 법
- CS
- Apollo Client

들어가며
useQuery를 통째로 감싸는 패턴을 발견했다.
이 훅은 새 데이터가 undefined로 들어오면 직전에 받았던 값을 대신 반환하고 있었다. 처음에는 왜 이런 래퍼가 필요한지 바로 이해되지 않았다. useQuery가 이미 캐시를 사용하고 있는데, 굳이 이전 데이터를 따로 보관해야 할 이유가 있을까 싶었다.
코드가 만들어진 이유를 따라가 보니 Apollo Client가 재요청 중에 화면 데이터를 잠시 비워버리는 동작과 관련이 있었다. 정규화 캐시를 사용하는 Apollo Client를 다뤄봤다면, 비슷한 화면 깜빡임을 한 번쯤 마주했을 것이다.
캐시 정책은 서로 다른 약속이다
“캐시를 쓴다”는 말은 하나의 동작을 뜻하지 않는다.
Apollo Client 같은 정규화 캐시 기반 GraphQL 클라이언트는 쿼리를 실행할 때 캐시와 네트워크를 어떤 순서로 사용할지 선택할 수 있게 한다. 이 옵션을 fetchPolicy라고 부르며, 자주 사용하는 정책만 추리면 다음 세 가지가 있다.
| 정책 | 캐시에 값이 있을 때 | 네트워크 요청 |
|---|---|---|
cache-first | 캐시 값을 반환하고 실행을 끝냄 | 캐시에 값이 없을 때만 보냄 |
network-only | 캐시 값을 사용하지 않음 | 항상 보내고, 응답 전까지 캐시 값도 보여주지 않음 |
cache-and-network | 캐시 값을 먼저 보여줌 | 항상 보내고, 응답이 오면 화면을 갱신 |
cache-first는 캐시가 있는 동안 네트워크 요청을 보내지 않는다. network-only는 캐시를 무시하고 서버 응답을 기다린다. cache-and-network는 이 둘과 다르게 두 가지 동작을 함께 약속한다. 캐시 값이 있으면 우선 화면에 보여주고, 동시에 네트워크 요청을 보내 최신 응답이 도착하면 화면을 갱신한다.

재요청이 시작되면 데이터가 비어 보인다
cache-and-network는 재요청 중에도 이전 데이터를 유지해줄까?
처음 화면에 들어올 때는 문제가 잘 드러나지 않는다. 캐시가 비어 있으므로 로딩 상태가 먼저 나타나고, 네트워크 응답이 도착하면 목록이 표시된다.
문제는 이미 목록이 화면에 표시된 상태에서 같은 쿼리를 다시 실행할 때 발생한다. 사용자가 당겨서 새로고침하거나 명시적으로 refetch()를 호출하면, 클라이언트는 최신 데이터를 가져오기 위해 요청을 다시 시작한다.
이때 캐시에는 이전 목록이 남아 있는데도, 컴포넌트가 받는 data가 한순간 undefined로 바뀔 수 있다. 네트워크 응답이 도착하면 새 목록이 다시 들어오지만, 그 사이에 빈 화면이나 로딩 스켈레톤이 잠시 나타난다.

캐시에 실제로 데이터가 없었기 때문에, 최초 진입에서 data가 undefined인 것은 자연스럽다. 하지만 새로고침 중에 data가 undefined가 되는 상황은 다르다. 캐시에 이전 목록이 남아 있는데도, 현재 요청의 결과가 아직 준비되지 않았다는 이유로 값이 비어 보이는 것이다.
컴포넌트가 data가 없을 때마다 로딩 스켈레톤이나 빈 상태를 그리도록 작성되어 있다면, 사용자는 목록이 사라졌다가 다시 나타나는 것처럼 느끼게 된다.
캐시가 지워진 것은 아니다
캐시 값과 현재 쿼리의 결과가 잠시 분리되는 것이다.
이 현상을 캐시가 삭제된 것으로 오해하기 쉽다. 하지만 실제로는 캐시에 저장된 이전 목록이 사라진 것이 아니다. cache-and-network는 캐시 결과와 네트워크 결과를 함께 다룬다. 캐시에서 읽은 값은 먼저 화면에 전달하고, 네트워크 요청이 끝나면 최신 응답으로 결과를 갱신한다.
재요청이 시작되면 클라이언트는 현재 쿼리의 결과를 다시 계산한다. 네트워크 응답을 기다리는 상태로 전환되면서, 현재 실행 결과에 사용할 값이 아직 없다는 상태를 data: undefined로 표현할 수 있다.
정규화 캐시를 사용하는 클라이언트는 보통 다음 두 가지를 함께 관리한다.
이 정보를 하나의 결과 객체로 합쳐 컴포넌트에 전달하는 과정에서, 재요청 중인 상태가 먼저 반영될 수 있다. 그러면 캐시 안에는 값이 남아 있어도 컴포넌트가 읽는 data에는 값이 없는 것처럼 보인다.

previousData로 이전 값을 유지한다
이전 값을 유지하는 데 필요한 정보는 useQuery가 이미 제공한다.Apollo Client의 useQuery는 data 외에 previousData도 반환한다. 공식 문서에서는 previousData를 “이 쿼리의 가장 최근 이전 실행 결과”라고 설명한다.
새 요청이 진행되는 동안 data가 비어 있더라도, previousData에는 직전 실행에서 받은 결과가 남아 있을 수 있다. 화면에 보여줄 값을 다음처럼 조합하면 된다.
function TodoList() {
const { data, previousData, loading } = useQuery(TODOS_QUERY, {
fetchPolicy: "cache-and-network",
});
const fallbackTodos = previousData?.todos ?? [];
const todos = data?.todos ?? fallbackTodos;
return <TodoListView todos={todos} loading={loading} />;
}이 코드는 현재 응답인 data를 먼저 사용한다. 아직 새 응답이 없다면 previousData를 사용하고, 이전 데이터마저 없다면 빈 배열을 사용한다. 이렇게 하면 재요청 중 data가 잠시 undefined가 되더라도 직전 목록이 화면에 남는다. 네트워크 응답이 도착하면 새로운 data가 우선 사용되면서 화면이 갱신된다.
fallback 로직을 훅으로 감싼다
같은 조합을 여러 화면에서 반복한다면, fallback을 호출부 밖으로 옮길 수 있다.
매번 컴포넌트마다 data ?? previousData를 직접 작성하는 일은 번거롭다. 적용해야 할 곳이 많아지면 어떤 화면에는 폴백을 넣고, 어떤 화면에는 빠뜨리는 상황도 생길 수 있다. 그래서 이전 값으로 대체하는 로직을 훅으로 한 번 감쌀 수 있다.
function useStableQuery<TData, TVariables>(
query: DocumentNode,
options?: QueryHookOptions<TData, TVariables>,
) {
const result = useQuery<TData, TVariables>(query, options);
return {
...result,
data: result.data ?? result.previousData,
};
}이 훅은 기존 useQuery의 결과를 그대로 반환하되, data가 비어 있을 때 previousData를 대신 반환한다. 화면 컴포넌트는 이전 값을 어떻게 유지할지 알 필요 없이, 반환된 data만 사용하면 된다.
다만 이 래퍼가 모든 화면에 적합한 것은 아니다. 특히 data가 undefined가 된 이유가 “같은 자원을 다시 요청하는 중”인지, “아예 다른 자원으로 이동한 중”인지 구분하지 않고 이전 값을 반환한다는 점을 주의해야 한다.
라이브러리가 이전 값을 제공하지 않아도 같은 동작을 구현할 수 있다.
previousData를 제공하지 않는 버전의 클라이언트나 다른 라이브러리를 사용한다면, 마지막으로 받은 값을 ref에 저장해둘 수 있다.
function useStableData<T>(data: T | undefined) {
const previousDataRef = useRef<T>();
if (data !== undefined) {
previousDataRef.current = data;
}
return data ?? previousDataRef.current;
}새로운 data가 들어오면 ref에 저장한다. 이후 data가 undefined가 되면 ref에 저장해둔 이전 값을 반환한다. 구현 방식은 다르지만 목적은 previousData와 같다. 새 응답이 도착하기 전까지 화면에서 사용할 값을 별도로 보관하는 것이다.

폴백이 없으면 컴포넌트는 이전 목록 → 없음 → 새 목록이라는 세 단계를 그대로 받는다. 폴백을 적용하면 중간의 undefined를 이전 목록으로 채우므로, 화면에서는 이전 목록 → 새 목록이라는 두 단계만 보인다.
사용자에게는 새로고침이 끝날 때까지 기존 목록이 유지되고, 네트워크 응답이 도착한 뒤 최신 목록으로 자연스럽게 바뀌는 것처럼 보인다.
적용 범위는 신중하게 정해야 한다
훅을 만드는 것과 모든 화면에 적용하는 것은 별개의 결정이다.
문제가 특정 화면에서만 발생한다면 해당 화면에서만 useStableQuery를 사용하면 된다. 이 방식은 적용 범위가 분명하고, 이전 데이터를 유지하지 않아야 하는 다른 화면에 영향을 주지 않는다.
하지만 프로젝트 전체에서 cache-and-network를 자주 사용하고 같은 문제가 여러 화면에서 반복된다면, 화면마다 직접 래퍼를 선택하게 하는 방식은 오래 유지되기 어렵다. 개발자가 매번 useQuery 대신 useStableQuery를 기억해야 하기 때문이다.
GraphQL 쿼리마다 훅을 자동으로 생성하는 codegen을 사용한다면, 생성되는 훅이 참조하는 useQuery 구현 자체를 래퍼로 교체하는 방법도 있다.

전역으로 적용하면 장점만큼의 대가도 존재한다.
전역 적용을 하면 개발자가 별도의 훅을 기억하지 않아도 된다. 생성되는 쿼리 훅이 자동으로 이전 데이터를 유지하기 때문이다.
하지만 편리함만 보고 결정할 문제는 아니다. 훅을 바꿔치기한 코드가 프로젝트 어딘가에 조용히 존재하게 되고, 그 사실을 모르는 개발자는 로딩 상태가 예상과 다르게 바뀌는 이유를 찾다가 오래 헤맬 수 있다.
전역으로 적용한 순간부터 이전 데이터 유지 정책은 특정 화면의 구현 세부사항이 아니라, 프로젝트 전체의 로딩 상태를 정의하는 규칙이 된다. 어떤 문제를 해결하기 위해 적용했는지, 어떤 화면에서는 예외가 필요한지, 실제 구현이 어디에 있는지를 문서로 남겨야 한다.
이전 데이터를 유지하면 안 되는 경우
이전 값을 유지하는 것이 항상 더 나은 사용자 경험을 만드는 것은 아니다.
목록을 새로고침하는 상황에서는 이전 목록을 유지하는 편이 자연스럽다. 사용자는 같은 목록의 최신 상태를 기다리고 있으므로, 요청이 진행되는 동안 기존 목록이 사라질 이유가 크지 않다.
하지만 쿼리 변수가 바뀌는 상황에서는 다르게 판단해야 한다. 상세 화면에서 A 항목을 보고 있다가 B 항목으로 이동한다고 해보자. 새 데이터가 도착하기 전까지 A의 데이터를 계속 보여주면, 사용자는 B로 이동했는데 화면에는 A의 정보가 남게 된다.
이 경우 이전 값을 유지하면 화면 깜빡임은 줄어들지만, 잘못된 정보를 보여줄 수 있다. “데이터가 아직 로딩 중”이라는 사실을 숨기는 것이 오히려 더 큰 문제를 만들 수 있는 상황이다.
요청이 실패했을 때도 잘못된 정보를 보여주는 위험이 존재한다.
에러가 발생했을 때도 확인이 필요하다. 네트워크 요청이 실패했는데 이전 데이터가 화면에 계속 남아 있으면, 사용자는 요청이 실패했다는 사실을 알아차리지 못할 수 있다. 이전 데이터를 보여주더라도 error 상태는 별도로 확인해 에러 메시지나 재시도 UI를 표시해야 한다.
이전 값을 유지할지는 다음 기준으로 판단할 수 있다.
데이터가 같은 자원인지, 그리고 요청이 실제로 성공했는지에 따라 판단할 수 있다.
error 상태를 확인해야 한다.
React Query와 SWR도 같은 문제를 다룬다
Apollo Client만의 특수한 상황이 아니다.
데이터를 가져오는 라이브러리들은 대부분 새 요청이 진행되는 동안 이전 값을 유지하는 기능을 각자의 방식으로 제공한다.
React Query는 쿼리 키가 변경되어 새 데이터를 가져오는 동안 이전 데이터를 유지할 수 있도록 keepPreviousData를 제공했고, 최신 버전에서는 placeholderData를 통해 비슷한 동작을 구성할 수 있다.
SWR도 fallbackData나 keepPreviousData와 같은 옵션으로 이전 값을 유지하는 방식을 지원한다. 라이브러리마다 이름과 세부 동작은 다르지만, 새 요청이 진행되는 동안 이전 데이터를 계속 보여준다는 목적은 같다.

그렇다면 Apollo Client는 왜 옵션 하나로 끝내지 않았을까?
Apollo Client도 쿼리 변수가 바뀌어도 로딩 중엔 이전 결과를 그대로 돌려주던 시절이 있었다. 문제는 변수가 바뀌면 그 이전 결과가 지금 요청과 전혀 다른 자원의 데이터일 수 있다는 점이었다.
Apollo Client 3.0은 이 자동 재사용을 걷어내고, 대응하는 캐시 데이터가 없으면 data를 undefined로 명확히 드러내도록 바꿨다. 이후 3.3에서 previousData를 별도로 추가해, 이전 값을 쓸지는 개발자가 직접 판단하도록 넘겼다.
정리하며
이전 데이터를 보여줄지는 기술보다 데이터의 성격이 결정한다.
cache-and-network는 캐시 값을 먼저 보여주고, 네트워크에서 최신 데이터를 가져오는 정책이다. 하지만 재요청이 시작될 때 data가 잠시 undefined가 되면 캐시에 이전 값이 남아 있어도 화면이 비어 보일 수 있다.
Apollo Client의 previousData를 사용하면 새 응답이 도착하기 전까지 직전 데이터를 유지할 수 있다. 여러 화면에서 같은 처리가 필요하다면 별도의 훅으로 감싸 반복을 줄일 수도 있고, 프로젝트의 요구사항에 따라 codegen이나 공통 계층에 적용할 수도 있다.
다만 이전 데이터를 유지하는 방식은 적용 범위를 신중하게 정해야 한다.
같은 자원을 다시 요청하는 새로고침이나 폴링에서는 기존 값을 유지하는 편이 자연스럽다. 반면 다른 자원으로 이동하는 상황에서는 이전 값을 보여주는 것이 잘못된 정보를 전달할 수 있다. 요청이 실패했을 때도 이전 데이터가 에러 상태를 가려버리지 않는지 확인해야 한다.
재요청 중 화면이 깜빡이는 문제를 발견하면, 먼저 이 요청이 같은 자원을 다시 확인하는 상황인지, 그리고 요청이 실제로 성공했는지부터 살펴봐야 한다. 같은 자원이라면 이전 값을 유지하는 방법을 고려하고, 다른 자원이라면 로딩 상태를 명확히 보여주는 편이 맞을 수 있다. 요청이 실패했다면 둘 중 어느 쪽이든 에러 상태를 별도로 확인해야 한다.
결국 중요한 것은 로딩 중인 상태를 무조건 “데이터가 없음”으로 볼지, 아니면 “이전 데이터가 아직 유효함”으로 볼지 결정하는 일이다. 그 판단이 먼저 서면 어떤 라이브러리의 어떤 옵션을 사용할지도 자연스럽게 따라온다.

