본문 바로가기
C.W.K.
Stream
Lesson 01 of 07 · published

React의 프로미스 — fetch, 상태, 에러

~14 min · promises, fetch, async

Level 0React 입문자
0 XP0/54 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
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>;
  }
}

External links

Exercise

UserOld를 먼저 discriminated union을 쓰는 useFetchedUser로 바꿔. 그다음 use()와 Suspense를 쓰는 버전에서는 로딩과 오류 상태가 어디로 이동할지 설계해. 실제 구현은 Lesson 4에서 이어 가.
Hint
첫 리팩터링 뒤에는 result.status를 switch해. Suspense 버전의 말단 컴포넌트에는 return <p>{user.name}</p> 같은 성공 경로만 남고 로딩과 오류는 부모 경계가 맡아.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고
💛 by 똘이warm

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.