React 18부터 Suspense가 API 요청에 따른 loading 상태를 표현할수 있게 되었다. 그에 따라 react-query, swr 같은 data fetching 라이브러리 역시 Suspense를 지원하고 있다. Suspense 옵션만 true 로 설정해주면, API 요청이 알아서 내부 처리를 통해 Suspense를 동작시킨다. 이로써 loading 상태를 선언적으로 보여줄 수 있게 되었다.
추상화에 대한 편리함과 사용법은 인지하고 있었지만, 추상화에 가려진 Suspense의 깊은 동작 원리에 대해서는 인지하지 못하였다.
프로젝트를 통해 Suspense 사용으로 로딩 성능이 저하되는 경험을 통해 Suspense가 어떻게 로딩 성능을 저하 시킬 수 있는지 이번 글을 통해 그 문제점과 해결책을 고민해 보았다.
data fetching 라이브러리로 react-query가 사용되었다.
정확히 말하면 data fetching을 편리하게 사용하고 관리할 수 있도록 도와주는 라이브러리 react-query가 사용되었다.
Use Suspense
function App() {
return (
<Suspense fallback={<div>...loading</div>}>
<TodoList />
</Suspense>
);
}
function TodoList() {
const { data: todoList } = useQuery("todos", () => client.get("/todos"), {
suspense: true,
});
return (
<div>
<section>
<h2>Todo List</h2>
{todoList?.data.map(({ id, title }) => (
<div key={id}>{title}</div>
))}
</section>
</div>
);
}
상위에서 Suspense로 컴포넌트를 감싸주고, useQuery 옵션에서 suspense:true로 설정해 주기만 하면 fallback으로 Loading 상태를 렌더 한다. TodoList 에서 API fetch가 발생하는 동안 Loading fallback을 보여주는 것이다.
그럼 Suspense는 어떻게 동작하는 것인가?
Suspense는 React에서 데이터 로딩 상태를 처리하기 위해 도입된 개념으로, 주로 비동기 작업과 연관되어 있다.
Suspense의 동작 원리는 Promise의 상태와 밀접하게 관련되어 있으며, 이를 통해 비동기 데이터를 손쉽게 처리할 수 있다.
Pending 상태 (대기 중):
컴포넌트가 비동기 작업을 통해 데이터를 가져올 때, 해당 Promise는 처음에 pending 상태가 된다.
이 시점에서 Suspense는 로딩 상태를 감지하고,
fallback
으로 지정된 로딩 UI를 표시한다.예를 들어, 네트워크 요청이 완료될 때까지 스피너(spinner)나 로딩 메시지가 표시될 수 있다.
Fulfilled 상태 (완료됨):
비동기 작업이 성공적으로 완료되면 Promise는 fulfilled 상태가 된다.
이 때 Suspense는 로딩 UI를 제거하고, 데이터를 성공적으로 가져온 컴포넌트를 화면에 렌더링한다.
예를 들어, 서버에서 데이터를 성공적으로 받아와 화면에 데이터를 표시한다.
Rejected 상태 (실패함):
비동기 작업이 실패하면 Promise는 rejected 상태가 된다.
이 경우, Suspense 자체는 실패 상태를 처리하지 않지만, 컴포넌트 내부에서 에러 경계(Error Boundary)를 사용하여 에러를 처리할 수 있다.
예를 들어, 네트워크 오류가 발생하였을 경우 에러 메시지를 표시한다.
Suspense 남용의 문제
아래 예시에서는 Before 컴포넌트를 Suspense가 감싸고 있다.
그리고 내부에서 2개의 Query를 요청하고 있다.
// App.jsx
function App() {
return (
<Suspense fallback={<div>...loading</div>}>
<Before />
</Suspense>
);
}
// Before.jsx
const BASE_URL = "https://jsonplaceholder.typicode.com";
const client = axios.create({ baseURL: BASE_URL });
function Before() {
const { data: todoList } = useQuery("todos", () => client.get("/todos"), {
suspense: true, // ✨
});
const { data: postList } = useQuery("posts", () => client.get("/posts"), {
suspense: true, // ✨
});
return (
<div style={{ display: "flex" }}>
<section>
<h2>Todo List</h2>
{todoList?.data.map(({ id, title }) => (
<div key={id}>{title}</div>
))}
</section>
<section>
<h2>Post List</h2>
{postList?.data.map(({ id, title }) => (
<div key={id}>{title}</div>
))}
</section>
</div>
);
}
API 요청이 어떻게 가고 있는지 네트워크 탭을 살펴보면
network waterfall을 만들고 있다. 해당 문제점을 겪으면서 앱의 로딩 시간이 길어지게 되었다.
왜 이런 현상이 발생하였을까? 원인은 바로 Suspense의 잘못된 사용, 아니 잘 모르고 사용한 원인이 더 크다.
위에서 설명한 대로 Suspense는 Promise 상태에 따라서 children 또는 fallback 컴포넌트를 반환한다. 즉, pending 상태일 때에는 Loading을 반환하고 있고, children을 실행시키지 않는다.
그렇기 때문에, 하나의 API 요청이 발생하면 children 컴포넌트의 실행은 멈추고 fallback을 반환하게 된다. Promise가 fulfilled 상태가 되면 다시 children을 반환하여 children 컴포넌트를 렌더링한다.
이러한 이유 때문에 Suspense가 감싸고 있는 하나의 컴포넌트에서 2개 이상의 요청을 할 때 네트워크 병목 현상이 발생하게 된다.
해결 방법 1 - Component와 Suspense 1:1 대응
Suspense 내의 컴포넌트에서 두 개 이상의 요청이 발생하면 네트워크 water fall이 직렬로 처리되는 현상이 생긴다. 그렇다면 한 컴포넌트에 하나의 요청을 유지하면 해결된다.
✨ 두 개 요청 모두 useQuery,
suspense: true
로 요청 시
아래 코드는 2개의 컴포넌트로 분리하고, 각각 Query 요청을 수행하고, 각각 Suspense로 감싸준 코드이다.
function AfterEachSuspense() {
return (
<div>
<div style={{ display: "flex" }}>
<section>
<h2>Todo List</h2>
<Suspense fallback={<div>...loading</div>}>
<TodoList />
</Suspense>
</section>
<section>
<h2>Post List</h2>
<Suspense fallback={<div>...loading</div>}>
<PostList />
</Suspense>
</section>
</div>
</div>
);
}
const TodoList = () => {
const { data: todoList } = useQuery("todos", () => client.get("/todos"), {
suspense: true, // ✨
});
return (
<div>
{todoList?.data.map(({ id, title }) => (
<div key={id}>{title}</div>
))}
</div>
);
};
const PostList = () => {
const { data: postList } = useQuery("posts", () => client.get("/posts"), {
suspense: true, // ✨
});
return (
<div>
{postList?.data.map(({ id, title }) => (
<div key={id}>{title}</div>
))}
</div>
);
};
네트워크 요청 결과는 다음과 같다.
병목 현상을 해결하고 정상적으로 병렬 처리되는 모습을 확인할 수 있다.
Suspense가 분리되었고, 각각의 Suspense가 각각의 children을 관장하므로 병렬적으로 실행되는 것이 당연한 결과다.
하지만 이 방식은 상황에 따라서 문제가 될 수 있다.
바로, 로딩 상태가 제각각 이라는 것이다.
위 네트워크 탭에서 todos와 posts 요청을 보면 끝나는 시간이 다르며, 각각의 Suspense 는 각각의 네트워크 요청이 끝나는 것을 감지하여 children을 렌더링 한다. 즉, 요청이 먼저 끝난 컴포넌트가 먼저 보여지게 된다.
TodoList가 먼저 렌더링 된다면 유저에게 버벅거리는 느김을 줄 수 있다는 점에서 유저 경험을 악화시킬 가능성도 있다. 이는 상황에 따라서 좋은 경험일 수 있고, 좋지 않은 경험일 수도 있다.
정리하자면, 컴포넌트를 분리한 후, 각각 Suspense를 감싸주는 것은 유저 경험을 악화시킬 가능성이 있다. 그렇다면 두 컴포넌트 모두 로딩이 끝나는 시점에 한번에 렌더링 하는 방법을 생각할 수 있겠다.
해결 방법 2 - Component와 Suspense n:1 대응
위와 같이 컴포넌트로 Query를 감싸여 요청을 분리하였지만, Suspense는 하나로 감싸고 있는 점이 다르다.
function AfterSameSuspense() {
return (
<div>
<Suspense fallback={<div>...loading</div>}>
<div style={{ display: "flex" }}>
<section>
<h2>Todo List</h2>
<TodoList />
</section>
<section>
<h2>Post List</h2>
<PostList />
</section>
</div>
</Suspense>
</div>
);
}
네트워크 요청 결과는 동일하게 병렬 처리되고 있으며, 임의의 API call이 먼저 끝나는 상황이다.
의도한 대로, 두 요청이 모두 끝날 때를 기다리고 렌더링이 수행된다.
이는 Suspense 내부 코드를 살펴봐야 정확히 알겠지만, Suspense 내부에서 API 요청이 발생한 소재를 파악하여, 그곳은 fallback으로 대체하고, 나머지 부분은 정상적으로 실행시키는 것을 알 수 있다.
해결 방법 3 - useQueries
여러 개의 query를 병렬로 실행시킬 수 있게 해주는 useQueries는 Suspense와 함께 사용 시 정상적으로 동작하지 않는 이슈가 있는데 v4.5.0 패치에 해당 사항이 수정되었다.
const results = useQueries({queries: [
{queryKey: ["posts"], queryFn: () => fetch("/posts") , suspense: true},
{queryKey: ["comments"], queryFn: () => fetch("/comments"), suspense:true},
{queryKey: ["issues"], queryFn: () => fetch("/issues"), suspense:true }
]})
useQueries를 사용하면 컴포넌트 내부 context를 이용해 병렬적으로 api를 호출할 수 있어서 context 사용을 위한 부가적인 구현을 하지 않아도 된다는 장점이 있다.
마치며
가독성과 클린 코드도 좋지만 무분별한 Suspense를 사용으로 로딩 성능을 저하 시키는 결과를 나을 수 있다. 그렇듯 Suspense의 장점만 인지하고 Suspense를 사용해서는 안 되며, 동작 원리와 적절한 poc를 통해 고민해 보아야 한다.
Top comments (0)