이 글에서는 Suspense, Under the Hood의 내용을 토대로 흥미로운 이야기를 작성하고자 한다.
Suspense, Under the Hood
선언적인 프로그래밍에 굉장히 많은 관심을 갖고 있다.Suspense는 마법처럼 로딩 상태를 외부에 위임할 수 있다는 점때문에,굉장히 많이 사용하고 있다. 어떻게 그 기능을 구현했을까?내가 좋아
seongmin-kim.tistory.com
1. 조건부 Suspense 실험
2. 연속된 suspense query가 waterfall을 유발하는 이유
3. 컴포넌트를 분리하면 waterfall을 해소할 수 있는 이유
4. useSuspenseQuery에서 enabled 지원을 중단한 이유
1. 조건부로 Suspense를 동작시키는 ConditionalSuspense
회사에서 UX를 개선하던 작업 중, Suspense의 동작이 boolean 값으로 제어되어야 하는 경우가 존재했다.
거기서 아이디어를 얻어서 조건부로 Suspense로 실행하는 코드를 작성해보았다.
interface ConditionalSuspenseProps extends SuspenseProps {
enable: boolean;
}
function ConditionalSuspense({
children,
enable,
fallback
}: ConditionalSuspenseProps) {
const SuspenseTrigger = () => {
if (enable) {
throw Promise.resolve();
}
return null;
};
return enable ? (
<Suspense fallback={fallback}>
{children}
<SuspenseTrigger />
</Suspense>
) : (
<>{children}</>
);
}
하위 컴포넌트까지 모두 렌더링 될 때까지 기다려야 하는 Suspense의 단점을 보완하기 위해
enabled가 false인 경우, children만 렌더링한다.
전체 코드는 아래와 같다.
import {Suspense, SuspenseProps, useState} from "react";
export default function App() {
const [enable, setEnabled] = useState(false);
return (
<>
<button onClick={() => setEnabled((prev) => !prev)}>
{enable
? "fallback 렌더링중 (서스펜스되었습니다...)"
: "children 렌더링중 (정상 렌더)"}
</button>
<ConditionalSuspense enable={enable} fallback={<div>로딩 중...</div>}>
<ABC />
</ConditionalSuspense>
</>
);
}
function ABC() {
return <div>ABC 컴포넌트입니다</div>;
}
interface ConditionalSuspenseProps extends SuspenseProps {
enable: boolean;
}
function ConditionalSuspense({
children,
enable,
fallback
}: ConditionalSuspenseProps) {
const SuspenseTrigger = () => {
if (enable) {
throw Promise.resolve();
}
return null;
};
return enable ? (
<Suspense fallback={fallback}>
{children}
<SuspenseTrigger />
</Suspense>
) : (
<>{children}</>
);
}
Suspense의 트리거를 외부에서 주입할 수 있다는 점에서 유용하게 사용할 수 있을 것 같다.
2. 연속된 suspense query가 waterfall을 유발하는 이유
export function App() {
return (
<Suspense fallback={<div>로딩중..</div>}>
<Data />
</Suspense>
);
}
function Data() {
const {data: data1} = useSuspenseQuery({
queryKey: [""],
queryFn: () => getNumber()
});
const {data: data2} = useSuspenseQuery({
queryKey: [""],
queryFn: () => getNumber()
});
return <div>{data1 + data2}</div>;
}
위와 같은 코드가 있다고 가정하자.
1번째 suspense query는 이렇게 평가된다
1. React는 컴포넌트를 평가하는 과정에서 1번째 useSuspenseQuery가 Promise를 throw 한다.
2. 렌더링이 중단되고 상위의 Suspense 바운더리를 찾는다
3. queryFn의 Promise가 resolve 될 때까지 fallback을 렌더링한다
4. Promise가 resolve되면 중단된 렌더링을 이어간다
2번째 suspense query가 다시 Promise를 throw한다
위의 1~4번이 반복된다.
.
.
.
1개 컴포넌트 내부에서 여러개의 useSuspenseQuery를 사용하면, 네트워크 시간이 선형적으로 증가한다
tanstack query는 이러한 해결을 위해 useSuspenseQueries를 제공한다
3. 컴포넌트를 분리하면 waterfall을 해소할 수 있는 이유
재조정 단계에서, 던져진 Promise를 핸들링할 때 completeUnitOfWork 함수가 핵심적으로 사용된다
function completeUnitOfWork(unitOfWork: Fiber): void {
let completedWork = unitOfWork;
do {
const current = completedWork.alternate;
const returnFiber = completedWork.return;
// ...
const siblingFiber = completedWork.sibling;
// 형제 노드가 존재하면, 형제 노드를 탐색한다
if (siblingFiber !== null) {
workInProgress = siblingFiber;
return;
}
//...
} while (completedWork !== null);
}
코드의 문맥은 이미 Promise가 던져진 이후인데, React는 그럼에도 불구하고 형제노드를 탐색하고 평가한다.
연속된 useSuspenseQuery 코드를 아래와 같이 바꿔보자.
export function App() {
return (
<Suspense>
<Data1 />
<Data2 />
</Suspense>
);
}
function Data1() {
const {data: data1} = useSuspenseQuery({
queryKey: [""],
queryFn: () => getNumber()
});
return <div>{data1}</div>;
}
function Data2() {
const {data: data2} = useSuspenseQuery({
queryKey: [""],
queryFn: () => getNumber()
});
return <div>{data2}</div>;
}
1. React는 <Data1/> 컴포넌트의 평가 도중 throw된 Promise를 만나고, queryFn이 실행된다
2. 코드의 실행흐름은 <Data2/> 컴포넌트로 이동하고, throw된 Promise를 만나 queryFn이 실행된다.
3. 2개의 Promise가 리졸브되었을때 data1, data2가 렌더링된다.
이 과정에서 2번째 queryFn은 1번째 queryFn의 resolve를 기다리지 않아도 되므로 waterfall 현상이 일어나지 않는다.
4. useSuspenseQuery에서 enabled 지원을 중단한 이유
tanstack query v4까지는 suspense 옵션과 enabled 옵션이 함께 지원된다
이는 useSuspenseQuery라는 새로운 hook이 나오기 전이었고, 기존 인터페이스와의 통합을 위한 것으로 보인다.
v5부터 생긴 useSuspenseQuery는 enabled 옵션을 사용할 수 없다.
그래서 나는 회사에서 코드를 작성할 때,
원활한 마이그레이션을 위해 enabled, suspense를 동시에 사용하지 않는다
.
.
.
왜일까?
정확한 이유는 모르지만, Suspense의 흐름과 부합하지 않아서 일 확률이 제일 높다
enabled의 목적은 queryFn의 실행을 외부에서 주입하는 것이다.
queryFn의 실행은 controlled 된다.
하지만 Suspense는 컴포넌트가 평가되면서 throw되는 Promise를 캐치하고 fallback을 렌더링한다
이는 컴포넌트가 마운트되는 즉시,
1. queryFn이 실행되고
2. Promise가 throw되고
3. Promise가 resolve되고
4. 유저는 flickering 없는 UI를 본다
위와 같은 Suspense의 흐름을 최대한 살리기 위함이라고 생각한다.
'개발' 카테고리의 다른 글
Vite 마이그레이션 (0) | 2025.04.06 |
---|---|
ES Module의 동작과 Insight (0) | 2025.04.06 |
Suspense, Under the Hood (0) | 2025.03.30 |