Skip to content

suspense 기반 data fetching ‐ use, getQuery, useQuery, getQuerySuspense

lybell edited this page Aug 15, 2024 · 2 revisions
use( promise ); // use
getQuery( key, ()=>Promise, dependencyArray ); // getQuery
useQuery( key, ()=>Promise, dependencyArray ); // useQuery

✨ 어떤 기능을 하나요?

React Suspense를 사용할 수 있는 선언적 데이터 페칭을 사용할 수 있게 하는 함수입니다.

use 함수는 promise를 인자로 받아서, promise가 진행 중이라면 promise를 throw하여 Suspense가 인지할 수 있게 합니다. promise가 완료되면 완료된 값을 반환합니다.

getQuery 함수는 key값과 promise를 반환하는 함수, 의존성 배열을 받아서, key값과 의존성 배열이 같다면 캐시에 저장된 promise를 반환하여, use 함수가 promise를 처음 평가할 때와 비동기 작업이 끝날 때 동일한 promise를 갖도록 보장합니다.

useQuery 함수는 key값이 동일한 mutate 함수가 실행될 때 자신을 리페칭하도록 등록하고, use(getQuery( key, ()=>Promise, dependencyArray ))를 반환합니다. 실제 컴포넌트에서 데이터를 페칭할 때에는 useQuery 함수를 사용하면 됩니다.

getQuerySuspense 함수는 use(getQuery( key, ()=>Promise, dependencyArray ))를 실행합니다. useQuery와는 다르게, 일반 함수나 조건문에서 사용할 수 있습니다. zustand에서 비동기 데이터를 가져와서 동기화하고 싶다면 getQuerySuspense 함수를 이용하는 것을 권장합니다.

💡 사용 예제

일반 컴포넌트에서 사용하는 방법

function DataConsumer()
{
	const data = useQuery( "my-data", ()=>fetch("/api/test").then( e=>e.json() ) );
	return <div>{data}</div>;
}

function Test()
{
	return <Suspense fallback={<div>로딩중</div>}>
		<DataConsumer />
	</Suspense>;
}

useQuery를 이용합니다. resolve되었을 때 값을 반환하는 Promise를 반환하는 함수를 정의한 뒤, useQuery의 첫 번째 인자에 key값을, 두 번째 인자에 Promise를 반환하는 함수를 집어넣습니다. 반환값은 Promise가 resolve되었을 때 나오는 값이므로, 그걸 그냥 사용해서 렌더링하면 됩니다.

useQuery를 사용하는 컴포넌트는 해당 컴포넌트를 렌더링하는 상위 컴포넌트에서 Suspense로 감싸주세요. 예시의 DataConsumer에서 Suspense로 감싸면 아무런 소용이 없습니다.

상태에 따라 다른 결과 받아오기

function DataConsumer({query})
{
	const data = useQuery( `my-data-${query}`, ()=>fetch(`/api/test/${query}`).then( e=>e.json() ) );
	return <div>
		<p>{data}</p>
	</div>;
}

function Test()
{
	const [query, setQuery] = useState("");
	return <Suspense fallback={<div>로딩중</div>}>
		<DataConsumer query={query} />
		<input value={query} onChange={(e)=>setQuery(e.current.value)} />
	</Suspense>
}

만약, 인자에 따라 다른 값을 서버에서 불러와야 한다면(검색 기능이나 페이지네이션 등), useQuery의 첫 번째 인자를 다르게 집어넣으면 됩니다. 위의 사례는 props를 이용하여 상태를 변경시키고, 변경된 상태를 기반으로 다른 경로에서 데이터를 불러오는 사례입니다.

const data = useQuery( "my-data", ()=>fetch(`/api/test/${query}`).then( e=>e.json() ), [query] );

물론, 첫 번째 인자인 key값을 동일하게 하고, 세 번째 인자 배열에 의존하는 값을 가져와도 동작합니다.

zustand 상태와 통합

const useZustandState = create( (set)=>({
	data : null,
	getData() {
		// 비동기 함수를 반환하는 함수를 정의합니다. async-await 문법을 이용하면 쉽습니다.
		async function fetcher() {
			const data = await fetch("/api/test").then( e=>e.json() );
			// set 함수를 이용하여 비동기 데이터를 zustand 상태와 동기화시키세요.
			set({data});
			return data;
		}
		// 컴포넌트가 아는 곳에선는 getQuerySuspense를 사용합니다. 의존성 배열로 set을 반드시 넣어주세요!
		return getQuerySuspense("my-data", fetcher, [set]); 
	}
}) );

function DataConsumer()
{
	// 사용할 때에는 getData 상태를 불러오고, 이를 호출하면 됩니다.
	const getData = useZustandState( state=>state.getData );
	const data = useZustandState( state=>state.data );
	getData();

	return <div>
		<p>{data}</p>
	</div>;
}

suspense 최적화된 비동기 요청을 zustand로 관리하고 싶을 때가 있을 겁니다. 그럴 때는 다음과 같이 하세요.

  1. zustand 상태를 정의할 때, getData 함수를 정의합니다. getData 함수 내에서 비동기 함수를 정의하고, 해당 비동기 함수를 getQuerySuspense의 인자로 넣습니다. 이 때, getQuerySuspense의 세 번째 의존성 배열에 [set]을 반드시 넣어야 합니다! 그렇지 않으면 개발 서버에서 zustand 상태를 바꿨는데 데이터가 안 불러와지는 버그를 볼 수 있을 겁니다.
  2. 컴포넌트에서는 만들어진 zustand 상태를 가져다가, getData 함수를 호출하세요. 비동기 요청이 완료되자마자 data 상태에 해당 응답값이 동기화됩니다.
  3. getData 함수에서 불러와진 데이터는 다른 컴포넌트에서 그냥 갖다 쓰는 것이 가능합니다. 그냥 사용하면 로딩이 없이 기본 상태에서 연동된 데이터 상태로 바뀌는 것처럼 보이지만, 다른 컴포넌트에서 getData 함수를 호출하면 suspense가 트랩해서 로딩을 구현하는 것이 가능합니다.

🔌 인터페이스

use(Promise)

  • Promise : suspense가 트랩하게 하고 싶은 Promise 값입니다. 이때, Promise는 외부에서 주입하거나, 캐시된 값이어야 합니다. 왜 그런지는 아래 주의사항-무한 렌더링 문제 섹션을 참조하세요.
  • 반환값
    • promise가 resolve되었을 때, promise의 결과값을 반환합니다.
    • promise가 reject되었을 때, 에러를 throw합니다.
    • promise가 pending 상태일 때, promise를 throw합니다.

getQuery( key, promiseFunc, dependencyArray );

  • key : 비동기 함수를 식별할 수 있는 key값으로, 문자열이 들어갑니다. 프로젝트에서 사용되는 비동기 요청의 key값은 서로 다른 값이어야 합니다.
  • promiseFunc : Promise를 반환하는 함수입니다.
  • dependencyArray : promiseFunc에서 의존하는 promiseFunc 스코프 바깥의 변수가 존재한다면, 해당 변수를 dependencyArray에 넣어야 합니다. 값은 배열이어야 하며, 배열의 원소의 타입은 아무거나 상관없습니다.
  • 반환값 : Promise를 반환합니다. 일반적으로 promiseFunc의 반환값이지만, key값과 dependencyArray가 같다면 (10분간) 동일함을 보장합니다.

useQuery( key, promiseFunc, config );

  • 매개변수
    • key : 비동기 자원을 식별할 수 있는 key값으로, 문자열이 들어갑니다. 만약 mutate 함수로 동일한 자원이 변경되었다면, useQuery를 사용하는 컴포넌트가 리렌더링됩니다. 프로젝트에서 사용되는 비동기 요청의 key값은 서로 다른 값이어야 합니다.
    • promiseFunc : Promise를 반환하는 함수입니다.
    • config : dependencyArray 배열, 혹은 객체가 들어갈 수 있습니다.
      • 배열을 인자로 넣으면 해당 배열이 dependencyArray로 작용합니다. 즉, 기존 방식과 동일하게 사용 가능합니다.
      • 객체를 인자로 넣을 수 있습니다. 이 때, 해당 객체의 프로퍼티는 다음과 같습니다.
        • dependencyArray : 의존성 배열입니다.
        • deferred : true일 때, useQuery를 사용하는 컴포넌트는 백그라운드에서 렌더링되며, 데이터가 완전히 불러와지면 갱신됩니다. 즉, 데이터를 불러오는 도중 로딩 인디케이터 대신 이전 데이터를 렌더링합니다.
  • 반환값 : promiseFunc가 반환하는 Promise 객체에 대해, 다음을 반환합니다.
    • promise가 resolve되었을 때, promise의 결과값을 반환합니다.
    • promise가 reject되었을 때, 에러를 throw합니다.
    • promise가 pending 상태일 때, promise를 throw합니다.

getQuerySuspense( key, promiseFunc, dependencyArray );

매개변수는 getQuery와 완전히 동일하며, 반환값은 useQuery와 완전히 동일합니다. 단, 이 함수는 컴포넌트 외부에서 사용할 수 있습니다.

❗ 주의사항

무한 렌더링 문제

function DataConsumer1()
{
	// 무한 로딩의 주범입니다!
	const data = use( fetch("/api/test1").then( e=>e.json() );
	return <div>
		<p>{data}</p>
	</div>;
}

use 함수에서 promise를 받을 수 있지만, 위의 예제처럼 컴포넌트에서 생성한 Promise를 넣으면 무한 반복 현상이 발생합니다.

원리는 다음과 같습니다.

  1. DataConsumer 함수가 평가됩니다. 먼저, 비동기 호출이 평가되고, promise를 실행시킵니다. 아직 해당 Promise가 처리되지 않았으므로 promise를 throw합니다.
  2. Suspense에서 해당 비동기 요청이 완료되기까지 기다립니다.
  3. 비동기 호출이 완료되면, DataConsumer 함수가 처음부터 다시 평가됩니다. 하지만, 새로운 promise가 만들어졌으므로, promise가 처리되지 않은 것으로 평가하고 promise를 throw합니다.
  4. 이를 무한 반복합니다.

Promise도 객체기 때문에, 함수를 호출할 때마다 다른 Promise 객체가 생성된다는 것을 기억하세요. 컴포넌트가 평가될 때 동일한 Promise 객체를 사용해야 무한 로딩 문제를 해결할 수 있습니다.

use 함수에는 반드시 동일한 Promise 객체가 들어가야 합니다. 가장 쉬운 해결 방법으로, use를 직접 사용하는 것 대신 useQuery를 사용하세요. 그 외에도 Promise 객체를 상위 컴포넌트에서 주입하거나, useMemo나 useRef로 Promise 객체의 동일성을 보장하는 방법이 있습니다.

네트워크 워터폴 문제

function DataConsumer()
{
	//이러지 말것! 컴포넌트는 하나의 useQuery만을 사용해야 하며, 만약 두 개의 api를 호출해야 한다면 데이터 페칭 부분을 Promise.all로 감싸세요.
	const data1 = useQuery( "my-first-data", ()=>fetch("/api/test1").then( e=>e.json() ) );
	const data2 = useQuery( "my-second-data", ()=>fetch("/api/test2").then( e=>e.json() ) );
	return <div>
		<p>{data1}</p>
		<p>{data2}</p>
	</div>;
}

하나의 컴포넌트에서 여러 개의 suspense 기반 데이터 페칭을 수행하면, 그러니까 useQuery를 2개 이상 사용하면 네트워크 워터폴 현상이 발생합니다. 자세한 사항은 https://happysisyphe.tistory.com/54 아티클을 참조하시기 바랍니다.

원리는 다음과 같습니다.

  1. DataConsumer 함수가 평가됩니다. 먼저, "my-first-data" 비동기 호출이 평가되고, promise를 실행시킵니다. 아직 해당 비동기 함수가 처리되지 않았으므로 promise를 throw합니다.
  2. Suspense에서 해당 비동기 요청이 완료되기까지 기다립니다.
  3. "my-first-data" 비동기 호출이 완료되면, DataConsumer 함수가 평가됩니다. 캐시된 "my-first-data" promise를 가져옵니다.
  4. 해당 promise는 완료된 상태이므로, promise의 결과를 data1 변수에 대입합니다. 이후, 다음 명령을 실행합니다.
  5. "my-second-data" 비동기 호출이 평가되고, promise를 실행시킵니다. 해당 비동기 함수가 처리되지 않았으므로 promise를 throw합니다.
  6. Suspense에서 해당 비동기 요청이 완료되기까지 기다리다가, 완료되면 DataConsumer를 평가합니다. 모든 비동기 함수가 완료되었으므로, 렌더링을 할 수 있습니다.

promise를 실행하는 시점은 useQuery 함수가 맨 처음 평가될 때라는 것을 기억하세요.

꼭 여러 개의 비동기 요청을 사용하려면, useQuery 함수의 두 번째 인자로, Promise.all 등으로 비동기 함수를 감싸거나, 아예 컴포넌트를 다르게 하면 됩니다.

function DataConsumer()
{
	//두 개의 데이터 요청을 한 번에 받아서 렌더링하고 싶다면, Promise.all (혹은 Promise.allSettled)을 사용하세요.
	const [data1, data2] = useQuery( "my-datas", ()=>{
		return Promise.all([
			fetch("/api/test1").then( e=>e.json() ),
			fetch("/api/test2").then( e=>e.json() )
		]);
	} );
	return <div>
		<p>{data1}</p>
		<p>{data2}</p>
	</div>;
}

// 혹은 데이터를 받아오는 컴포넌트 자체를 분리해도 가능합니다.
function DataConsumer1()
{
	const data1 = useQuery( "my-first-data", ()=>fetch("/api/test1").then( e=>e.json() ) );
	return <div>
		<p>{data1}</p>
	</div>;
}

function DataConsumer2()
{
	const data2 = useQuery( "my-second-data", ()=>fetch("/api/test2").then( e=>e.json() ) );
	return <div>
		<p>{data2}</p>
	</div>;
}

// 여러 개의 데이터 불러오는 컴포넌트를 Suspense로 감쌀 수 있습니다. 이러면 두 컴포넌트가 모두 비동기 처리가 완료될 때 같이 렌더링됩니다.
function DataRenderer()
{
	return <Suspense fallback={<div>로딩중</div>}>
		<DataConsumer1 />
		<DataConsumer2 />
	</Suspense>
}
Clone this wiki locally