Loki's Blog

previousData로 variables 바뀔 때 이전 데이터 유지하기

  • CS
  • Apollo Client
2026. 08. 22.

들어가며

프로젝트 코드를 읽다가 useQuery를 감싼 낯선 커스텀 훅과 마주쳤다. 새 응답이 도착하기 전까지 직전 데이터를 대신 돌려주는 훅이었다. Apollo Client는 이미 정규화 캐시를 쓰는데, 왜 이전 데이터를 한 번 더 따로 보관하고 있었을까? Apollo Client 이슈 트래커를 따라가 보니, 같은 쿼리라도 variables가 바뀌는 순간 dataundefined로 리셋된다는 게 원인이었다.

재요청은 data를 지우지 않는다

cache-and-network는 캐시에 있는 데이터를 먼저 보여주고, 백그라운드에서 서버에 최신 데이터를 요청하는 fetchPolicy다. 응답이 도착하면 캐시와 화면이 동시에 갱신된다. 같은 쿼리를 같은 variables로 다시 요청하는 refetch()는 이 정책과 잘 어울린다. 이미 화면에 목록이 떠 있는 상태에서 refetch()를 호출해도 data는 비지 않는다.

반면 data가 실제로 undefined가 되는 지점은 따로 있다. Apollo Client GitHub 이슈(#6603, #7038)를 따라가 보면, 재현 조건은 전부 같은 쿼리의 variables가 바뀌는 순간을 가리킨다. Apollo Client 3.0에서 도입된 동작으로, 새 variables는 사실상 새 쿼리로 취급된다.

variables가 바뀌면 data는 비어 있다

다음과 같은 Todo 목록 컴포넌트를 상상해 보자.

function TodoList({ page }: { page: number }) {
  const { data, loading } = useQuery(TODOS_QUERY, {
    variables: { page },
    fetchPolicy: 'cache-and-network',
  })

  if (loading || !data) {
    return <TodoListSkeleton />
  }

  return <TodoListView todos={data.todos} />
}

1페이지를 보다가 2페이지로 넘어가면 page prop이 바뀌고, useQuery는 이를 새 variables로 인식한다. 이 순간 data는 실제로 undefined가 되고 loadingtrue로 바뀐다. 화면엔 1페이지 목록 대신 스켈레톤이 뜬다. 앞서 본 refetch()와 달리 이번엔 data가 정말로 비어서 생기는 현상이다.

Notion image

사라진 건 화면일 뿐, 캐시는 그대로다

화면에서 1페이지 데이터가 사라졌다고 해서 캐시까지 지워진 건 아니다. Apollo Client는 서버 응답을 정규화해 캐시에 저장해 두고, variables가 바뀌면 현재 쿼리(2페이지)에 해당하는 결과를 다시 계산해 새로운 data를 만든다. 1페이지의 정규화 데이터는 캐시 어딘가에 여전히 남아 있지만, 이번 렌더링에서 useQuery가 돌려주는 data는 2페이지의 응답을 가리키므로 그 사이엔 값이 없다.

여기서 서로 다른 두 개념을 구분해야 한다.

  • Apollo Client의 정규화 캐시에 저장된 데이터
  • 지금 렌더링에서 useQuery가 반환하는 data
  • 캐시엔 1페이지의 데이터가 남아 있어도, useQuery가 그 값을 지금 이 렌더링의 data로 다시 꺼내 쓰는 건 아니다. data가 비어 보인다고 캐시가 삭제됐다고 판단해서는 안 된다.

    Notion image

    previousData로 이전 결과 유지하기

    useQuery는 data 외에 previousData도 함께 반환한다. previousData는 같은 쿼리를 마지막으로 실행했을 때의 결과를 담고 있고, 첫 실행처럼 이전 결과가 없으면 undefined다. previousData는 바로 이 variables 변경 문제를 해결하려고 3.3에서 추가됐다 (PR #7082). 프로젝트에서 발견한 커스텀 훅은 아마 그 이전 버전에서 같은 문제를 우회하려고 만든 코드였을 것이다.

    앞서 본 TodoListpreviousData를 더하면 이렇게 쓸 수 있다.

    function TodoList({ page }: { page: number }) {
      const { data, previousData, loading, error } = useQuery(TODOS_QUERY, {
        variables: { page },
        fetchPolicy: 'cache-and-network',
      })
    
      const fallbackTodos = previousData?.todos ?? []
      const todos = data?.todos ?? fallbackTodos
    
      // loading이 true여도 todos는 이전 페이지 데이터로 채워질 수 있다.
      // error는 별도로 확인해 에러 UI를 표시해야 한다.
      return <TodoListView todos={todos} loading={loading} error={error} />
    }

    현재 응답인 data를 먼저 쓰고, 아직 없으면 previousData를, 그마저 없으면 빈 배열을 쓴다. page가 바뀌어 data가 잠깐 비어도 직전 페이지 목록을 화면에 유지할 수 있다.

    Notion image

    previousData가 오히려 잘못된 정보를 보여줄 때

    같은 방식을 모든 상황에 적용할 수 있는 건 아니다. 다음과 같은 Todo 상세 컴포넌트라면 얘기가 다르다.

    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의 정보가 떠 있는 셈이다. 깜빡임은 줄어들지만 잘못된 정보를 보여주는 대가를 치른다.

    Notion image

    에러가 발생했을 때도 마찬가지다. 요청이 실패했는데 이전 데이터가 화면에 계속 남아 있으면 사용자는 요청이 실패했다는 사실을 알아차리지 못할 수 있다. cache-and-network에서는 네트워크 요청이 실패해도 캐시 값은 그대로 남으므로, 이전 데이터를 보여주더라도 error 상태는 별도로 확인해 에러 메시지나 재시도 UI를 표시해야 한다.

    판단 기준은 두 가지로 정리된다.

    같은 데이터를 다시 조회하는 상황인가?
  • 새로고침이나 페이지네이션처럼 같은 종류의 데이터를 다시 조회하는 상황이라면 이전 값을 유지하는 편이 자연스럽다.
  • 다른 항목 선택이나 다른 페이지 이동처럼 다른 데이터로 전환하는 상황이라면, 이전 값을 지우고 로딩 상태를 보여주는 편이 정확하다.
  • 잔고나 주문 상태처럼 값의 최신성이 중요한 데이터는 같은 조회라도 이전 값을 쓰지 않는 게 안전하다.
  • 요청이 성공했는가?
  • 실패했다면 이전 데이터의 표시 여부와 관계없이 error 상태를 확인해야 한다.
  • Notion image

    정리하며

    정규화 캐시와 useQuery가 돌려주는 data는 서로 다른 층이다. variables가 바뀌는 순간 비는 건 이 data뿐이고, previousData가 3.3부터 그 공백을 메워왔다. 프로젝트에서 마주친 커스텀 훅도 previousData로 대체할 수 있는 코드였다. 다만 이 값을 쓸지는, 지금 요청이 같은 데이터를 다시 조회하는 것인지 그리고 그 요청이 성공했는지부터 확인하고 판단해야 한다.

    관련 글

    댓글

    0/2000