JIGGAG

리액트 쿼리, 캐시가 살아있다

2022년 2월 27일

리액트 쿼리로 왔다

리액트 쿼리를 사용하기 전까지 리덕스 사가를 무척 좋아했다.

액션에 따른 상태 관리가 쉽고 상당히 직관적이라고 생각했기 때문이다 (굉장히 주관적인 생각이였다). 유명한 보일러플레이트는 처음 프로젝트 구조를 설계하는 단계에서만 겪게 되는 것이고 초반에 정리를 잘 해두면 규모가 커졌을때에는 오히려 체계적이고 편리하다고 생각했다.

그러나 리액트 쿼리를 쓰다보니 이제야 느끼는 사실, 사가는 보일러플레이트 진짜 너무 많았다.

이렇게 장점만 보이던 리액트 쿼리가 이상해졌다.

분명 데이터가 있었는데 없어졌다!


상태 관리를 하려고 했는데

전역 상태 관리 에 대해 다시 생각해보았던 적이 있다.

당시 모든 비동기 + 서버 상태 + 클라이언트 상태리덕스 사가로 전역에서 관리하고 있었다. 서버 데이터는 물론이고 액션 상태까지 사가에서 처리하고 있었던 것들을 모두 전역 상태로 관리하였다. 그렇기 때문에 어디선가 로딩/에러가 발생했을때 앱 전체에 일괄 반영하는 처리를 간단하게 할 수 있었지만, 의도치 않은 전역 상태 변경으로 인해 전혀 다른 컴포넌트가 업데이트 될 수 있는 위험에 노출되어 있었다.

반면 리액트 쿼리를 도입하면서 얻을 수 있었던 것은 비동기 처리와 서버/클라이언트 상태 관리를 간단하게 분리 할 수 있는 것이였다.

액션 상태는 리액트 쿼리에서 반환하는 다양한 옵션들로 대체하여 쿼리 캐시와는 분리된 지역 상태로 처리하고, 비동기로 서버에서부터 가져온 데이터만 쿼리 캐시로 저장하게 되면서 클라이언트 상태와 완전히 분리된 형태를 유지할 수 있게 되었다.

만약 전역에서 공통된 로딩/에러 상태가 필요한 경우에는 별도의 쿼리 캐시를 만들어서 처리하면 간단했다.

(🤔 근데 꼭 쿼리 캐시로 할 필요가 없다... 리덕스나 컨텍스트를 사용해도 되는데 굳이 쿼리 캐시로 해야하는 이유가 있을까? 리덕스를 쓰지 않겠어! 리액트 쿼리로 통합할거야! 하는 미션에서는 적합한 이야기겠다)

이것들은 리액트 쿼리가 전역으로 상태를 전달 할 수 있는 컨텍스트를 사용 하고 있기 때문에 가능하였다.

이렇게 비동기 처리나 상태 관리를 리덕스 사가에서 리액트 쿼리로 잘 옮겨가고 있다고 생각했는데, 문제가 발생했다.

리액트 쿼리 캐시로 사용중인 값이 있었는데 없어진 것이다.


쿼리 캐시가 살아있다

쿼리 캐시를 이용해 서로 다른 컴포넌트에서 공유하는 전역 상태를 갖고 있었다. (서버 쿼리 캐시가 아닌 임의로 구성한 캐시이다)

그런데 예상과는 다르게 캐싱된 데이터가 자꾸 있었는데 없어지고 있었다 🚨 마치 쿼리 캐시 인스턴스가 하나가 아닌듯한 기분이였다. (여기에는 있었는데, 저기에는 없는)

// 컴포넌트A
const queryClient = useQueryClient();
queryClient.setQueryData(key, data);


// 컴포넌트B
const queryClient = useQueryClient();
queryClient.getQueryData(key);

컴포넌트A에서 캐시 데이터를 업데이트하고 다른 컴포넌트B에서 이 데이터를 사용하려고 했다. 근데 컴포넌트A에서 데이터가 분명 업데이트 되었음을 확인했는데, 컴포넌트B에서 자꾸 사라지는 것이였다.

무엇을 잘못 생각하고 있는 것일까

그 이유는 cacheTimestaleTime 이였다.

전역 상태 대신 쿼리 캐시로 상태를 공유하고자 했는데, 캐시 인스턴스를 잘못 이해하고 사용하고 있었다.

정확하게 이해하지 않았으니 문제가 발생했을때 더 헷갈리기 시작했다 (그래서 혼란이 증폭되었다)


staleTime? cacheTime?

컴포넌트A가 마운트 되면서 생성된 인스턴스는 staleTime (디폴트 0) 이 지나버렸기 때문에 언마운트 시점에 캐시가 inactive 상태가 되었고 컴포넌트B가 마운트 되면서 새로운 인스턴스가 생성되었다.

이 상황에서 cacheTimestaleTime 가 시작되는 시점을 잘못 이해하고 있었다.

쿼리 인스턴스가 마운트 되는 순간에는 staleTime 이 시작된다. cacheTime 은 쿼리 인스턴스가 완전히 언마운트 되어서 inactive 상태가 되고 나면 시작된다.

마운트 되는 시점에 시작된 staleTime에서는 active, fresh한 상태를 가지고 있다.

그러다 staleTime이 끝나면 stale 상태를 가지고 있지만 아직 cacheTime이 시작되는 않는다. (화면에 아직 유효한 데이터가 존재하기 때문에 캐시 타임이 흐르지 않는다)

언마운트 되면서 화면에 존재하던 것들이 사라지고 inactive 상태를 지니면 이제서야 cacheTime이 시작된다. 이 cacheTime 동안에는 정말 캐시로 존재하기 때문에 다시 호출하면 이 캐싱된 데이터를 재사용할 수 있다.

그렇다면 staleTime과 cacheTime의 차이가 정확하게 무엇이였을까?

캐시를 사용하려고 하는건데 그럼 cacheTime만 있으면 되는게 아닌가? (staleTime=0, cacheTime=5분 으로 설정된 디폴트 옵션을 사용하면 되지 않나?)

staleTimefresh -> stale 로 변경되는 시간이다. cacheTimeinactive 한 상태의 쿼리를 캐시로 유지하는 시간이다.

기본값이 staleTime=0, cacheTime=5분 인 이유이다.

staleTime=0 으로 두면서 모든 쿼리가 호출이 끝나면 바로 stale 해지고, 다시 호출하면 서버로부터 가져오는 것을 기본으로 하는 것이다.

이때 리액트 쿼리는 캐싱된 데이터로 조금이나마 빠른 응답을 시켜주기 위해 cacheTime=5분 으로 기본 설정해주었고, stale 해진 쿼리를 서버로부터 다시 fresh 한 데이터를 가져오기 전까지 사용할 수 있다.


인스턴스가 마치 하나가 아닌듯이 동작했던 그 이유

컴포넌트A에서 생성한 인스턴스는 언마운트 되었고 컴포넌트B에서 새로운 인스턴스를 만들었다.

staleTime이 0이였기 때문에 언마운트 이후 새로 생성된 인스턴스에 캐시가 공유되지 않았다.

인스턴스 간 쿼리 캐시를 공유하기 위해 staleTime=Infinity 설정하였다. 이것은 한번 생성한 인스턴스를 계속 유지하는 것이라고 생각하면 되겠다.

쿼리 인스턴스를 사용하는 컴포넌트, 스크린이 마운트/언마운트 될 때 쿼리 인스턴스가 inactive 되면 캐시도 날아가버리기 때문이다.

staleTime=Infinity 설정으로 항상 인스턴스를 유지하도록 하고 직접적으로 refetch 하거나 invalidate 하는 경우를 제외하고는 캐싱된 데이터를 사용하는 것이다.


이야기로 생각해본다

const 컴포넌트A = () => {
	const queryA = useQuery(keyA, fn);
	const queryB = useQuery(keyB, fn, { staleTime: 1000 * 100 });
	const queryC = useQuery(keyC, fn, { staleTime: 1000 * 100 });

	...
};

const 컴포넌트B = () => {
	const queryA = useQuery(keyA, fn);
	const queryB = useQuery(keyB, fn, { staleTime: 1000 * 100 });
	const queryD = useQuery(keyD, fn);

	...
};

// staleTime: 0, cacheTime: 5분 (default)

컴포넌트A 마운트 -> 10초 후 컴포넌트A 언마운트 + 컴포넌트B 마운트 된다면, 이때 queryA, queryB, queryC, queryD의 데이터는 어떤 상태일까?

컴포넌트A 가 마운트 되면서 쿼리(a,b,c) 인스턴스가 active 상태가 되었다. 하지만 queryA는 staleTime=0 으로 설정되어 있으므로 곧바로 stale 상태가 되었고 언마운트 되는 시점에 inactive 되었다.

queryB와 queryC는 staleTime=100초 이므로 10초 후 언마운트 되는 시점에 inactive 되었지만 인스턴스 자체는 아직 유효한 fresh 한 상태를 유지하고 있다.

컴포넌트B 가 마운트 되는 시점에 queryA는 stale + inactive 상태이므로 새로운 인스턴스가 생성된다.

하지만 컴포넌트A에서 저장된 캐시가 아직 유효(cacheTime=5분)하므로 새로운 데이터를 fetch 하는 동안 캐시 데이터를 먼저 가져올 수 있다. (옵션 설정에 따라 이 데이터를 사용할 수 있다)

새로 데이터를 가져오면 캐시에 업데이트 되지만 queryA는 staleTime=0 이므로 곧바로 다시 stale 한 상태가 된다.

반면 queryB는 컴포넌트B 에서 다시 마운트 되는 시점에도 아직 fresh 한 상태(staleTime이 지나지 않았다)이기 때문에 새로 fetch 하지 않고 곧바로 캐시데이터를 반환한다.

그럼 각각 컴포넌트에서 공유하지 않고 있는 queryC와 queryD는 어떤 상태일까?

queryC는 컴포넌트A가 언마운트 되고 컴포넌트B가 마운트 된 시점에도 아직 staleTime이 지나지 않았기 때문에 fresh한 상태이다. 언제든 다시 queryC를 사용하기를 기다리고 있는 상태라고 보면 된다. (사용되지 않은 상태로 staleTime이 지나버리면 이미 컴포넌트A가 언마운트 되었기 때문에 곧바로 inactive 상태가 된다)

queryD는 컴포넌트B에서만 사용하고 있다. staleTime이 0이기 때문에 컴포넌트B가 언마운트 되는 시점에 inactive 될 것이고 다시 컴포넌트B를 마운트 하게 된다면 새로 fetch 하게 된다.


느낌대로

마운트 여부에 따라 쿼리 인스턴스는 active/inactive 상태를 갖는다. 캐시 데이터는 staleTime에 따라 fresh/stale 상태를 갖는다.

그럼 두가지 상태의 조합으로 쿼리 인스턴스가 반환하는 캐시 데이터의 상태는 아래와 같다.

1. active (마운트)
2. fresh (staleTime 만료 전)
3. stale (staleTime 만료 후)
4. inactive (언마운트)

쿼리가 이상하다고 느끼고 이 내용을 정리해보는 시간이 생각보다 오래 걸렸다. (의식의 흐름대로 이것저것 나열해봤더니 더 복잡했다)

그렇게 헤어나오지 못하고 있었는데, 지난번 TDD와 마찬가지로 한방에 정리해주는 세미나가 열렸다. (👍 React Query와 상태관리 :: 2월 우아한테크세미나)

이번에도 좋은 타이밍이였다!