React를 활용해 서버에서 데이터를 fetching하고 이를 화면에 그리는 과정은 간단합니다. 하지만 하나의 컴포넌트에서 여러개의 데이터를 fetching한다면 어떻게 화면이 랜더링될까요? 또 그 데이터들이 불러오는 시간이 각각 다르다면 어떻게 랜더링이 되는 걸까요?
const func1 = async () => {
await new Promise((resolve) => setTimeout(resolve, 2000));
console.log('func1 complete!');
return 'func1';
};
const func2 = async () => {
await new Promise((resolve) => setTimeout(resolve, 4000));
console.log('func2 complete!');
return 'func2';
};
우리는 각각 2초, 4초가 걸리는 func1
과 func2
를 실행하고 이 함수의 return 값들이 화면에 언제 그려지는지 확인해 보겠습니다.
const [data1, setData1] = useState();
const [data2, setData2] = useState();
// 2초대기
const func1 = async () => {
await new Promise((resolve) => setTimeout(resolve, 2000));
setData1("data1");
};
// 4초대기
const func2 = async () => {
await new Promise((resolve) => setTimeout(resolve, 4000));
setData2("data2");
};
useEffect(
func1();
)(), [data1])
useEffect(
func2();
)(), [data2])
return (
<View>
<View>{data1}</View>
<View>{data2}</View>
</View>
)
우리가 화면을 키자마자 서버에서 데이터를 받아오기 위해선 각 함수들을 정의하고 이를 useEffect
hook에 각각 정의해 주어야 합니다. 위의 코드에선 하나는 2초가 걸리고 나머지 하나는 4초가 걸리는데, 이러한 경우에는 화면에 어떻게 데이터들이 표시될까요?
처음에는 데이터들
이라고 적힌 텍스트만 렌더링되고, 2초이후에 func1
이 return한 data1
이, 4초 후에 func2
가 리턴한 data2
가 순차대로 화면에 그려지는 것을 확인할 수 있습니다. 한번에 그려지지 않고, 데이터가 도착할때마다 랜더링이 되어 화면을 다시 그려주는 현상이 발생하게 되는 것입니다.
이러한 현상을 Waterfall 현상
이라고 합니다. Waterfall 현상
은 여러 비동기 데이터 요청이 순차적으로 처리되면서 UI가 단계적으로 렌더링되는 현상을 의미합니다. 이는 한 페이지 내에서 여러 비동기 데이터를 요청하는 상황에서 자주 발생합니다.
컴포넌트들이 하나씩 나타나면 사용자는 UI가 완전히 로딩되지 않았다는 느낌을 받을 수 있고, 이는 사용자에게 불완전한 경험을 제공할 수 있습니다. 또한 비동기 요청이 반드시 순서대로 응답되지는 않을 수 있기 때문에. 요청한 순서와 다른 순서로 응답이 도착하면, 데이터가 일관성 없이 렌더링될 수 있습니다. 이는 사용자에게 잘못된 정보를 제공하거나, UI가 예기치 않게 동작할 수 있습니다.
우리는 이를 방지하기 위해 모든 데이터가 불러와졌을때 화면에 랜더링을 시켜주어야 합니다.
또한 데이터가 불러와 지고 있다는 것을 사용자에게 표시해주기 위하여 데이터가 불러와지고 있을때에 로딩 컴포넌트를 배치해주어야 합니다.
const [data1, setData1] = useState();
const [data2, setData2] = useState();
const [loading, setLoading] = useState(false);
// 2초대기
const func1 = async () => {
await new Promise((resolve) => setTimeout(resolve, 2000));
setData1("data1");
};
// 4초대기
const func2 = async () => {
await new Promise((resolve) => setTimeout(resolve, 4000));
setData2("data2");
};
useEffect((async() => {
setLoading(true); // 로딩 처리
const response = await func1();
setLoading(false); // 로딩 끝
})(), [data])
if(loading) return <View>loading....</View>
return ( <View>{data}</View> )
이렇게 loading
이라는 변수를 두어, useEffect
의 앞뒤에 배치하고 화면을 그려주면 될 것입니다. 하지만 이 방법은 여러 비동기 요청일때는 여러 loading
변수를 생성해주어야 할뿐더러, 코드가 길어지는 현상이 발생합니다.
우리는 이렇게 길어지는 코드를 React.suspense
하나로 해결할 수 있습니다. Suspense
는 react 18버전에 추가된 기능입니다.
Suspense
는 어떤 컴포넌트가 읽어야 하는 데이터가 아직 준비가 되지 않았다고 리액트에게 알려주는 새로운 매커니즘인데요, Suspense
라는 React의 신기술을 사용하면 컴포넌트의 랜더링을 어떤 작업이 끝날 때까지 잠시 중단시키고 다른 컴포넌트를 먼저 랜더링할 수 있습니다.
import { Suspense } from 'react';
import Albums from './Albums.js';
export default function ArtistPage({ artist }) {
return (
<>
<h1>{artist.name}</h1>
<Suspense fallback={<Loading />}>
<Albums artistId={artist.id} />
</Suspense>
</>
);
}
function Loading() {
return <h2>🌀 Loading...</h2>;
}
위 예제에서는 앨범 목록을 가져오는 동안 Albums
구성요소가 일시중단됩니다 . 렌더링할 준비가 될 때까지 React는 가장 가까운 Suspense
경계를 전환하여 대체 요소인 Loading
구성 요소를 표시합니다. 그런 다음 데이터가 로드되면 React는 Loading
대체를 숨기고 데이터가 있는 구성 요소를 렌더링하게 됩니다.
<Suspense fallback={<Loading />}>
<ComponentA />
<ComponentB />
<ComponentC />
</Suspense>
기본적으로 Suspense 내부의 전체 트리는 단일 단위로 처리됩니다. 위 예시에서는 컴포넌트 A,B,C중 하나만 일부 데이터 대기를 일시 중지 하더라도 모두 Loading
컴포넌트로 대체되게 됩니다.
그렇다면 Suspense
는 하위 children 컴포넌트들의 비동기 상태를 감지할 수 있다는 소리인데, 어떻게 알 수 있는걸까요?
function wrapPromise(promise) {
let status = "pending";
let response;
const suspender = promise.then(
(res) => {
status = "success";
response = res;
},
(err) => {
status = "error";
response = err;
}
);
const read = () => {
switch (status) {
case "pending":
throw suspender;
case "error":
throw response;
default:
return response;
}
};
return { read };
}
export default wrapPromise;
핵심은 하위 컴포넌트에서 Promise를 throw 해주는 것입니다.
위 코드는 React 코어 팀에서 Suspense로 비동기를 감지하는 과정 설명을 위해 작성한 컨셉 코드입니다. 컨셉 코드이므로 실제의 구현에서 바로 쓰기에는 무리가 있지만, 아래의 wrapPromise
함수를 보면 children 컴포넌트에서 어떻게 parent 컴포넌트인 Suspense와 소통하는지에 대한 힌트를 얻을 수 있습니다.
import React from "react";
import wrapPromise from "./wrapPromise";
const Test = () => {
const [data, setData] = useState();
const func1 = (url) => {
// 2초 대기 코드
const promise = await new Promise((resolve) => setTimeout(resolve, 2000));
// 위의 wrapPromise로 감쌈
return wrapPromise(promise);
}
return (
<div>Hi</div>
);
};
이렇게 promise 상태에 따라 상위로 throw함으로써 상위에 존재하는 Suspense
, ErrorBoundary
컴포넌트와 커뮤니케이션 할 수 있는 것입니다.
이후 메서드를 통해 throw된 promise가 error 상태면 상위 ErrorBoundary
컴포넌트로 다시 throw하고, pending의 경우 fallback UI
를 렌더하고, fulfilled 상태가 되면 children
컴포넌트를 렌더링 합니다.
useEffect
를 활용하여 데이터를 fetching하게 된다면 코드가 복잡해지고, 로딩등을 처리하는 것이 매우 힘들어 집니다. 물론 suspense
를 통해 로딩은 쉽게 적용할 수 있겠지만요
떄문에 우리의 이 코드들을 useQuery
라이브러리를 통해 작성해 보겠습니다. useQuery
의 기능은 단순히 캐싱에 그치지 않고 pending 상태를 이용한 loadingComponent 노출에도 있습니다.
useQuery
에는 다양한 함수들이 존재하는데, 각각이 어떠한 성질을 가지고 있는지에 대해 알아보고 이를 프로젝트에 적용해보겠습니다.
const func1 = async () => {
await new Promise((resolve) => setTimeout(resolve, 2000));
console.log('func1 complete!');
return 'func1';
};
const func2 = async () => {
await new Promise((resolve) => setTimeout(resolve, 4000));
console.log('func2 complete!');
return 'func2';
};
const { data: data1 } = useQuery({
queryKey: [`func1`],
queryFn: func1,
});
const { data: data2 } = useQuery({
queryKey: [`func2`],
queryFn: func2,
});
return (
<SafeAreaView className="flex-1 bg-white justify-center items-center">
<View>
<Text>데이터들</Text>
</View>
<View>
<Text>{data1}</Text>
</View>
<View>
<Text>{data2}</Text>
</View>
</SafeAreaView>
);
먼저 가장 기본적인 useQuery
입니다. 위에서 정의하였던 2초, 4초가 걸리는 두 함수를 각각 useQuery
를 통해 호출한다음, 언제 화면이 그려지는지 확인해 보겠습니다.
먼저 화면이 그려지고, 각각 데이터들이 도착할때마다 화면이 새로 랜더링되는 것을 확인할 수 있습니다. 기존과 똑같이 waterfall 현상
이 발생하는 것을 확인할 수 있습니다.
const func1 = async () => {
await new Promise((resolve) => setTimeout(resolve, 2000));
console.log('func1 complete!');
return 'func1';
};
const func2 = async () => {
await new Promise((resolve) => setTimeout(resolve, 4000));
console.log('func2 complete!');
return 'func2';
};
const { data1, data2 } = useQueries({
queries: [
{
queryKey: [`func1`],
queryFn: func1,
},
{
queryKey: [`func2`],
queryFn: func2,
},
],
combine: (results) => {
return {
data1: results[0].data,
data2: results[1].data,
};
},
});
여러 비동기 처리들을 각각 useQuery
로 감싸지 않고, useQuries
를 이용하여 한번에 병렬처리를 해줄 수 있습니다. 이처럼 단순하게만 사용하는 관점에서는 사실 둘중 어떤 것을 사용해도 차이가 없기 때문에 취향에 맞게 사용하셔도 무방합니다. 공식 문서에서도 이렇게 Manual 한 Query를 작성할 때는 useQuery를 원하는 만큼 나열식으로 작성하라고 말하고 있습니다.
const func1 = async () => {
await new Promise((resolve) => setTimeout(resolve, 2000));
console.log('func1 complete!');
return 'func1';
};
const func2 = async () => {
await new Promise((resolve) => setTimeout(resolve, 4000));
console.log('func2 complete!');
return 'func2';
};
const { data: data1, isPending: data1Pending } = useQuery({
queryKey: [`func1`],
queryFn: func1,
});
const { data: data2, isPending: data2Pending } = useQuery({
queryKey: [`func2`],
queryFn: func2,
});
if (data1Pending || data2Pending)
return (
<SafeAreaView className="flex-1 bg-white justify-center items-center">
<View>
<Text>Loading....</Text>
</View>
</SafeAreaView>
);
return (
<SafeAreaView className="flex-1 bg-white justify-center items-center">
<View>
<Text>데이터들</Text>
</View>
<View>
<Text>{data1}</Text>
</View>
<View>
<Text>{data2}</Text>
</View>
</SafeAreaView>
);
useQuery
에서의 로딩 처리는 간단합니다. useQuery
에서 제공하는 isPending
을 활용하여 loading component를 분기 처리해주면 됩니다. react-query
에서는 suspense
옵션이 존재하여 suspense
와 같이 로딩 처리가 가능하였지만 이제 이 옵션을 장착한 useSuspenseQuery
가 등장하였습니다.
useSuspenseQuery
는 useQuery
와 다르게 데이터의 존재성을 보장해줍니다. 위에도 타입을 확인하면 useQuery
를 통해 불러온 데이터는 undefined
일 수도 있다는 타입이 노출되고 있습니다.
작업이 끝난 것부터 순차적으로 보여줬던(사실 하나끝날때마다 화면을 다시 그리는 것) useQuery
와 달리useSuspenseQuery
는 어느 하나라도 pending상태라면 화면을 보여주지 않게됩니다.
const func1 = async () => {
await new Promise((resolve) => setTimeout(resolve, 2000));
console.log('func1 complete!');
return 'func1';
};
const func2 = async () => {
await new Promise((resolve) => setTimeout(resolve, 2000));
console.log('func2 complete!');
return 'func2';
};
const { data: data1 } = useSuspenseQuery({
queryKey: [`func1`],
queryFn: func1,
});
const { data: data2 } = useSuspenseQuery({
queryKey: [`func2`],
queryFn: func2,
});
return (
<SafeAreaView className="flex-1 bg-white justify-center items-center">
<View>
<Text>데이터들</Text>
</View>
<View>
<Text>{data1}</Text>
</View>
<View>
<Text>{data2}</Text>
</View>
</SafeAreaView>
);
위 함수들을 이번엔 useSuspenseQuery
를 이용하여 화면에 그려줘 보겠습니다. 이번엔 4초가 너무 기므로 각각 2초가 소요되게 만들어 주었습니다.
우선 useQuery
와 다르게 모든 데이터가 패칭된 이후에 화면이 그려지는 것을 확인할 수 있었습니다. 또 다른 차이점은 모든 비동기 함수들이 병렬적으로 실행되었던 useQuery
와 다르게 func1이 모두 실행된 뒤에야 func2가 실행되는 것을 콘솔에서 확인할 수 있었습니다. 때문에 각각의 함수가 2초가 걸렸지만 위 화면이 그려지는데 4초가 소요되는 것을 확인할 수 있었습니다.
만약에 각각 같은 컴포넌트에 존재하는 것이 아닌, 부모와 자식으로 존재한다면 어떻게 될까요? 이도 마찬가지죠. 부모가 먼저 다 실행되고 나서야 자식도 그때부터 2초뒤에 랜더링 되게 됩니다.
const func1 = async () => {
await new Promise((resolve) => setTimeout(resolve, 2000));
console.log('func1 complete!');
return 'func1';
};
const func2 = async () => {
await new Promise((resolve) => setTimeout(resolve, 2000));
console.log('func2 complete!');
return 'func2';
};
const { data1, data2 } = useSuspenseQueries({
queries: [
{
queryKey: [`func1`],
queryFn: func1,
},
{
queryKey: [`func2`],
queryFn: func2,
},
],
combine: (results) => {
return {
data1: results[0].data,
data2: results[1].data,
};
},
});
return (
<SafeAreaView className="flex-1 bg-white justify-center items-center">
<View>
<Text>데이터들</Text>
</View>
<View>
<Text>{data1}</Text>
</View>
<View>
<Text>{data2}</Text>
</View>
</SafeAreaView>
);
useQuery
와 useQuries
는 단순히 코딩방식에 차이였다면, useSuspenseQuery
와 useSuspenseQuries
는 다른 방식으로 사용되어야 합니다. 이번엔 useSuspenseQuries
를 사용하여 func1
func2
를 실행해 보겠습니다.
###
import { View, Text, Pressable } from 'react-native';
import {
useQueries,
useQuery,
useQueryClient,
useSuspenseQueries,
useSuspenseQuery,
} from '@tanstack/react-query';
import { TextInput } from 'react-native-gesture-handler';
import { SafeAreaView } from 'react-native-safe-area-context';
import React, { Suspense } from 'react';
import LoadingComponent from '@components/Common/Loading';
const TestScreen = () => {
const func1 = async () => {
await new Promise((resolve) => setTimeout(resolve, 2000));
console.log('func1 complete!');
return 'func1';
};
const func2 = async () => {
await new Promise((resolve) => setTimeout(resolve, 2000));
console.log('func2 complete!');
return 'func2';
};
const { data1, data2 } = useSuspenseQueries({
queries: [
{
queryKey: [`func1`],
queryFn: func1,
},
{
queryKey: [`func2`],
queryFn: func2,
},
],
combine: (results) => {
return {
data1: results[0].data,
data2: results[1].data,
};
},
});
return (
<Suspense fallback={<LoadingComponent />}>
<SafeAreaView className="flex-1 bg-white justify-center items-center">
<View>
<Text>데이터들</Text>
</View>
<View>
<Text>{data1}</Text>
</View>
<View>
<Text>{data2}</Text>
</View>
</SafeAreaView>
</Suspense>
);
};
export default TestScreen;
useSuspenseQuery
를 사용하게 되면 로딩중 중간에 빈화면 에서를 React.suspense에게 위임하고 공통적으로 LoadingComponent를 적용할 수 있습니다.
return (
<View>
<View> <Text>여기는 먼저 보여줄래</Text></View>
<Suspense fallback={<LoadingComponent />}>
<View>
<Text>데이터들</Text>
</View>
<View>
<Text>{data1}</Text>
</View>
<View>
<Text>{data2}</Text>
</View>
</Suspense>
</View>
};
suspense
의 장점은 특정 구역에만 suspense
를 씌워서 먼저 보여주고 싶은 부분들에 대해서는 보여줄 수 있다는 점입니다. useQuery
에서 처럼 분기문을 작성하여 특정 부분만 loading
을 보여주려 했다면, 상당히 많은 코드 중복이 일어나거나 공통된 부분을 위해 component를 또다시 만들었어야 했을 것입니다.
마지막으로 개발 측면에서도 useQuery
에서 처럼 if
조건문을 사용하여 어떤 컴포넌트를 보여줄지를 제어하는 것은 명령형 코드에 가깝기 때문에 선언적 코드를 지향하는 React
의 기본 방향성과 맞지 않게 느껴지고요. 기본적으로 데이터 로딩과 UI 랜더링이라는 두 가지 전혀 다른 목표가 하나의 컴포넌트 안에 커플링되어 코드가 읽기가 어려워지고 테스트를 작성하기도 힘들어집니다.
때문에 우리의 어플리케이션은 특정 로직을 제외하면 모두 useSuspenseQuery
와 useSuspenseQueries
를 활용하여 서버의 데이터를 페칭하고 있습니다.
프론트엔드에서 로딩 처리를 해주는 것은 사용자 경험(UX)을 향상시키고, 애플리케이션의 성능을 최적화하며, 사용자와의 상호작용을 보다 원활하게 만들기 위해서 중요합니다.
로딩 스피너나 진행 표시줄을 통해 사용자가 무언가가 백그라운드에서 진행 중임을 알 수 있어, 응답이 느려지거나 멈춘 것처럼 보이지 않게 합니다. 사용자에게 앱이 제대로 작동하고 있다는 신뢰를 줄 수 있기 때문에 필요한 무조건적으로 필요한 작업이라 생각합니다. 또한 로딩 상태에서 사용자 입력을 제한함으로써 데이터가 완전히 로드되기 전에 잘못된 입력이 발생하지 않도록 합니다.
프론트엔드에서 로딩 처리를 잘 해주는 것은 애플리케이션의 전반적인 품질과 사용자 만족도를 높이는 데 중요한 역할을 합니다. suspense
를 잘 활용하여 사용자의 만족도를 높이는 어플리케이션을 제작해보도록 합시다 :)