Suspense와 use()가 없으면 데이터 불러오기마다 로딩·오류·성공 상태를 직접 관리해야 해. 기존 방식의 비용을 먼저 확인해 보자.
세 가지 상태를 직접 관리하는 방식
fetch에는 로딩, 오류, 성공이라는 세 상태가 있어. Suspense를 쓰지 않으면 보통 다음 값을 각각 추적해.
const [data, setData] = useState<T | null>(null)const [error, setError] = useState<Error | null>(null)const [loading, setLoading] = useState(true)
여기에 요청을 시작할 useEffect, 오래된 요청을 취소할 AbortController, 컴포넌트가 사라진 뒤 상태를 바꾸지 않게 하는 정리 코드까지 필요해. fetch 하나를 안전하게 다루는 코드가 금방 길어지는 이유야.
Discriminated union으로 불가능한 조합 없애기
Track 2에서 배운 discriminated union을 쓰면 세 상태를 하나의 값으로 묶을 수 있어.
type Fetch<T> =
| { status: 'loading' }
| { status: 'error'; error: Error }
| { status: 'success'; data: T };
컴포넌트는 state.status를 보고 렌더링하고, TypeScript는 로딩 중인데 데이터가 있는 것처럼 불가능한 조합을 막아 줘. 상태 모델은 좋아지지만 요청과 정리 코드는 여전히 남아.
Suspense와 use()로 관심사 옮기기
Suspense와 use()를 쓰면 기다림은 위쪽 Suspense 경계가, 오류는 오류 경계가 맡아. 데이터를 읽는 컴포넌트에는 성공 경로만 남길 수 있어. 다음 레슨부터 이 구조를 경계, 오류 처리, 프로미스 안정성 순서로 조립할 거야.
새 기능을 좇는 게 아니라 관심사를 알맞은 곳으로 옮기는 거야. 세 개의 useState에서 discriminated union으로, 다시 Suspense와 use()로 가는 흐름은 로딩과 오류 처리를 컴포넌트 본문 밖으로 밀어내는 과정이야. 단계마다 코드가 표현할 수 있는 상태가 더 분명해져.
Code 옛 모양: useEffect + 세 상태· tsx import { useEffect, useState } from "react";
function UserOld({ userId }: { userId: string }) {
const [user, setUser] = useState<{ name: string } | null>(null);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const ctrl = new AbortController();
setLoading(true);
setError(null);
fetch(`/api/users/${userId}`, { signal: ctrl.signal })
.then((r) => r.ok ? r.json() : Promise.reject(new Error(r.statusText)))
.then(setUser)
.catch((e) => { if (e.name !== "AbortError") setError(e); })
.finally(() => setLoading(false));
return () => ctrl.abort();
}, [userId]);
if (loading) return <p>Loading…</p>;
if (error) return <p className="text-red-500">{error.message}</p>;
if (!user) return null;
return <p>{user.name}</p>;
}판별된 union으로 요청 상태를 분명하게 만들기· tsx type Fetch<T> =
| { status: "loading" }
| { status: "error"; error: Error }
| { status: "success"; data: T };
function useFetchedUser(userId: string): Fetch<{ name: string }> {
const [state, setState] = useState<Fetch<{ name: string }>>({ status: "loading" });
useEffect(() => {
const ctrl = new AbortController();
setState({ status: "loading" });
fetch(`/api/users/${userId}`, { signal: ctrl.signal })
.then((r) => r.json())
.then((data) => setState({ status: "success", data }))
.catch((e) => {
if (e.name !== "AbortError") setState({ status: "error", error: e });
});
return () => ctrl.abort();
}, [userId]);
return state;
}
function UserMid({ userId }: { userId: string }) {
const result = useFetchedUser(userId);
switch (result.status) {
case "loading": return <p>Loading…</p>;
case "error": return <p className="text-red-500">{result.error.message}</p>;
case "success": return <p>{result.data.name}</p>;
}
}