본문 바로가기
SPA/React

React 19 use API, Promise와 Context를 조건부로 읽는 방법

by J4J 2026. 7. 12.
300x250
반응형

안녕하세요. J4J입니다.

 

이번 포스팅은 React 19에 새롭게 추가된 use API를 이용하여 Promise와 Context를 다루는 방법에 대해 알아보는 시간을 가져보려고 합니다.

 

 

 

use를 사용하면 좋은 상황

 

지금까지 컴포넌트 안에서 비동기 데이터를 다루려면 useState와 useEffect를 조합하는 방식이 일반적이었습니다.

 

데이터를 담을 state, 로딩 여부를 담을 state, 에러를 담을 state를 각각 선언하고 useEffect 안에서 fetch를 호출한 뒤 결과에 따라 각 state를 갱신하는 코드를 작성하게 됩니다.

 

제가 처음 이 패턴을 반복해서 작성할 때 가장 번거롭게 느꼈던 부분은 데이터를 사용하는 컴포넌트마다 동일한 형태의 보일러플레이트가 계속 늘어난다는 것이었습니다.

 

// ❌ useState + useEffect로 비동기 데이터를 처리하는 기존 방식
function ProfileCard() {
  const [profile, setProfile] = useState(null)
  const [isLoading, setIsLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    fetch('https://api.example.com/profile')
      .then((res) => res.json())
      .then(setProfile)
      .catch(setError)
      .finally(() => setIsLoading(false))
  }, [])

  if (isLoading) return <p>로딩 중...</p>
  if (error) return <p>에러가 발생했습니다.</p>
  return <p>{profile.name}</p>
}

 

이렇게 작성하면 데이터를 사용하는 컴포넌트마다 로딩·에러·데이터 3개의 state와 useEffect 호출이 반복되고, 로딩과 에러를 처리하는 분기문도 컴포넌트 내부에 계속 늘어나게 됩니다.

 

 

 

// ✅ use로 Promise를 직접 읽는 방식
function ProfileCard() {
  const profile = use(fetchProfilePromise)
  return <p>{profile.name}</p>
}

 

use를 사용하면 로딩 상태는 Suspense가, 에러 상태는 ErrorBoundary가 담당하기 때문에 컴포넌트는 데이터가 준비된 이후의 화면만 신경 쓰면 됩니다.

 

그러므로 컴포넌트 내부에서 로딩과 에러 상태를 직접 관리하지 않고 Suspense와 ErrorBoundary에 위임하고 싶은 경우에는 use를 사용하는 것이 좋습니다.

 

 

 

혹시 이미 TanStack Query의 useSuspenseQuery로 Suspense 기반 데이터 패칭을 하고 있다면, use가 굳이 필요한지 의문이 들 수 있습니다.

 

useSuspenseQuery는 queryKey를 기준으로 요청 결과를 캐싱하기 때문에, 뒤에서 다룰 렌더링마다 새로운 Promise가 생성되는 문제를 라이브러리 차원에서 이미 해결해 줍니다.

 

그러므로 서버 데이터를 다루는 경우라면 use를 위해 직접 캐시를 구성하기보다 TanStack Query를 계속 사용하는 것이 좋습니다.

 

 

 

다만 use가 다루는 범위는 TanStack Query보다 넓습니다.

 

use(Context)로 조건부 Context를 읽는 것은 TanStack Query와 전혀 관련이 없는 영역이고, React 19 공식 문서가 소개하는 사례처럼 서버 컴포넌트가 시작한 Promise를 클라이언트 컴포넌트에 prop으로 그대로 내려주고 클라이언트에서 use로 읽는 스트리밍 패턴에도 TanStack Query는 관여하지 않습니다.

 

저의 경우 TanStack Query를 사용하는 프로젝트에서도, 조건부로 Context 값을 읽어야 하는 컴포넌트에서는 use를 함께 사용합니다.

 

 

 

에러를 처리하는 방식에도 차이가 있습니다.

 

use는 Promise가 reject되면 항상 렌더링 중 에러를 던져 ErrorBoundary로 전파되지만, useSuspenseQuery는 캐시에 남아 있는 이전 데이터가 있으면 요청이 실패해도 그 데이터를 그대로 렌더링하고 에러를 던지지 않습니다.

 

정리하면 다음과 같은 상황에서 use를 사용하는 것이 좋습니다.

 

  • Suspense와 ErrorBoundary로 로딩·에러 상태를 위임하고 싶은 경우
  • 컴포넌트마다 반복되는 로딩·에러 state 선언을 줄이고 싶은 경우
  • 조건문이나 반복문 안에서 Context 값을 읽어야 하는 경우
  • TanStack Query 같은 라이브러리 없이 단발성 Promise나 서버 컴포넌트에서 내려온 Promise를 그대로 읽고 싶은 경우

 

그러면 use를 실제로 어떻게 사용하는지 살펴보겠습니다.

 

 

반응형

 

 

use 기본 사용법, Promise와 Suspense

 

use는 react 패키지에서 바로 import하여 사용할 수 있습니다. Promise 또는 Context를 인자로 전달하면, Promise가 resolve된 값 또는 Context의 현재 값을 그대로 반환합니다.

 

이름 때문에 다른 훅과 동일하게 취급하기 쉽지만, 공식 문서에서는 use를 훅이 아니라고 설명합니다.

 

다만 컴포넌트 또는 훅 함수 본문 내부에서만 호출할 수 있다는 제약은 여전히 남아 있기 때문에, 일반 유틸 함수나 이벤트 핸들러 콜백 안에서 호출하면 동작하지 않습니다.

 

제가 처음 use를 사용해 보았을 때 가장 낯설게 느껴졌던 부분은 이것이 훅이 아니라는 설명이었는데, 실제로 사용해 보면 다른 훅처럼 컴포넌트 함수 본문 안에서 호출한다는 점은 크게 다르지 않았습니다.

 

 

 

[ 1. Promise를 모듈 최상단에 준비하기 ]

 

use에 전달할 Promise는 컴포넌트 바깥, 모듈 최상단에서 미리 생성해 둡니다.

 

// pages/basic/fetchProfile.ts
export interface Profile {
  name: string
  role: string
}

export const profilePromise: Promise<Profile> = fetch('https://api.example.com/profile').then(
  (res) => res.json(),
)

 

 

 

[ 2. use로 읽고 Suspense로 감싸기 ]

 

Promise를 읽는 컴포넌트를 Suspense로 감싸주면, Promise가 pending 상태인 동안 fallback이 대신 표시됩니다.

 

// pages/basic/index.tsx
import { Suspense, use } from 'react'
import { profilePromise } from './fetchProfile'

function ProfileCard() {
  const profile = use(profilePromise)

  return (
    <div>
      <h2>{profile.name}</h2>
      <p>{profile.role}</p>
    </div>
  )
}

export default function BasicPage() {
  return (
    <Suspense fallback={<p>프로필을 불러오는 중입니다...</p>}>
      <ProfileCard />
    </Suspense>
  )
}

 

 

 

ProfileCard는 profile이 아직 없는 상태를 신경 쓰지 않고, 값이 있다고 가정한 채로 그대로 렌더링합니다.

 

이를 통해 로딩 여부를 판단하는 조건문이 컴포넌트 내부에서 완전히 사라지고, 로딩 UI를 어디에 배치할지는 Suspense 위치로만 결정할 수 있게 됩니다.

 

다음으로는 use가 Context를 읽을 때 어떤 점이 달라지는지 알아보겠습니다.

 

 

 

 

use로 조건부 Context 읽기

 

useContext는 다른 훅과 마찬가지로 컴포넌트 최상위에서만 호출할 수 있습니다. 조건문 안이나 early return 이후에 호출하면 훅 호출 순서가 렌더링마다 달라질 수 있기 때문에 에러가 발생합니다.

 

use(Context)는 조건문과 반복문 안에서, 그리고 early return 이후에도 호출할 수 있다는 점에서 useContext와 다릅니다.

 

저의 경우 특정 조건에서만 필요한 Context 값을 읽으려고 컴포넌트를 억지로 분리했던 경험이 있는데, use(Context)를 사용하면 그 컴포넌트를 나누지 않고도 조건 분기 뒤에서 자연스럽게 값을 읽을 수 있었습니다.

 

다만 이 완화된 규칙이 모든 위치에서의 호출을 허용하는 것은 아닙니다. use는 여전히 컴포넌트 또는 훅 함수 본문 내부에서만 호출할 수 있고, 값을 찾는 방식도 useContext와 동일하게 자신보다 상위에 있는 가장 가까운 Provider를 탐색합니다.

 

 

 

Panel 컴포넌트는 showTheme이 false이면 use(ThemeContext)를 호출하지 않고 먼저 return 합니다. useContext였다면 허용되지 않는 구조입니다.

 

// pages/conditional-context/ThemeContext.tsx
import { createContext } from 'react'

export type Theme = 'light' | 'dark'

export const ThemeContext = createContext<Theme>('light')

 

// pages/conditional-context/index.tsx
import { use, useState } from 'react'
import { ThemeContext, type Theme } from './ThemeContext'

function Panel({ showTheme }: { showTheme: boolean }) {
  if (!showTheme) {
    return <p>테마 정보를 표시하지 않습니다.</p>
  }

  const theme = use(ThemeContext)

  return <p>현재 테마는 {theme} 입니다.</p>
}

export default function ConditionalContextPage() {
  const [theme, setTheme] = useState<Theme>('light')
  const [showTheme, setShowTheme] = useState(true)

  return (
    <ThemeContext.Provider value={theme}>
      <div className="flex flex-col gap-4 p-6">
        <div className="flex gap-2">
          <button
            className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
            onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
          >
            테마 전환
          </button>
          <button
            className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
            onClick={() => setShowTheme((prev) => !prev)}
          >
            테마 정보 표시 토글
          </button>
        </div>
        <Panel showTheme={showTheme} />
      </div>
    </ThemeContext.Provider>
  )
}

 

 

 

하지만 테마 정보 표시 토글 버튼을 눌러 showTheme을 true로 바꾸면 Panel은 use(ThemeContext) 호출을 통해 useContext를 사용하던 것과 동일한 결과를 만들게 됩니다.

 

 

 

 

use와 데이터 패칭을 함께 사용할 때 주의사항

 

이 문제는 TanStack Query처럼 캐싱을 자체적으로 제공하는 라이브러리를 쓰면 라이브러리가 대신 해결해 줍니다. 다만 라이브러리 없이 fetch를 직접 use에 넘기는 경우에는 다음과 같은 문제를 직접 신경 쓰는 것이 좋습니다.

 

use에 전달하는 Promise를 컴포넌트 렌더링 도중에 새로 만들면 문제가 발생합니다. fetch()는 호출할 때마다 새로운 Promise를 반환하기 때문에, 렌더링 본문에서 직접 호출하면 매 렌더마다 다른 Promise가 use에 전달됩니다.

 

개인적으로 이 문제를 처음 겪었을 때 콘솔에 에러가 전혀 남지 않아서 원인을 찾는 데 시간이 걸렸던 경험이 있습니다. 무한 루프가 도는 것이 아니라, 화면이 계속 로딩 상태로만 보이기 때문에 실수를 알아차리기 어렵습니다.

 

 

 

// ❌ 컴포넌트 렌더링 도중 매번 새로운 Promise를 만드는 경우
function UserCard({ userId }) {
  const user = use(fetch(`https://api.example.com/users/${userId}`).then((res) => res.json()))
  return <p>{user.name}</p>
}

 

이렇게 작성하면 렌더링마다 새로운 Promise가 생성되어 매번 pending 상태로 인식되고, Suspense fallback이 계속 반복해서 표시되며 콘텐츠가 화면에 완성되지 않는 문제가 발생합니다.

 

이를 해결하려면 모듈 레벨 캐시로 동일한 Promise를 재사용하도록 구성합니다.

 

// pages/cached-fetch/fetchUser.ts
export interface User {
  id: number
  name: string
}

const cache = new Map<number, Promise<User>>()

export function fetchUser(id: number): Promise<User> {
  if (!cache.has(id)) {
    cache.set(
      id,
      fetch(`https://api.example.com/users/${id}`).then((res) => res.json()),
    )
  }

  return cache.get(id)!
}

 

 

 

fetchUser는 같은 id로 다시 호출되면 새 요청을 만들지 않고 캐시에 저장된 Promise를 그대로 반환합니다. userId가 바뀔 때만 캐시에 없는 새 Promise가 만들어집니다.

 

// pages/cached-fetch/index.tsx
import { Suspense, use, useState } from 'react'
import { fetchUser } from './fetchUser'

function UserCard({ userId }: { userId: number }) {
  const user = use(fetchUser(userId))

  return (
    <p>
      {user.id}번 사용자: {user.name}
    </p>
  )
}

export default function CachedFetchPage() {
  const [userId, setUserId] = useState(1)

  return (
    <div className="flex flex-col gap-4 p-6">
      <div className="flex gap-2">
        {[1, 2, 3].map((id) => (
          <button
            key={id}
            className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
            onClick={() => setUserId(id)}
          >
            사용자 {id}
          </button>
        ))}
      </div>
      <Suspense fallback={<p>사용자 정보를 불러오는 중입니다...</p>}>
        <UserCard userId={userId} />
      </Suspense>
    </div>
  )
}

 

 

 

그러므로 use에 전달하는 Promise는 컴포넌트 렌더링 바깥에서 캐싱하여 재사용하는 것이 좋습니다. Next.js처럼 프레임워크가 자체 캐싱을 제공하지 않는 순수 React 환경에서는 이렇게 모듈 레벨 Map으로 직접 캐시를 구성하는 방식이 기본적인 대응 방법이 됩니다.

 

[Next] Next13의 새로운 기능 알아보기 (2) - Data Fetching

 

[Next] Next13의 새로운 기능 알아보기 (2) - Data Fetching

안녕하세요. J4J입니다. 이번 포스팅은 Next13의 새로운 기능 알아보기 두 번째인 Data Fetching에 대해 적어보는 시간을 가져보려고 합니다. 들어가기에 앞서 next의 data fetching이 무엇인지에 대해 궁금

jforj.tistory.com

 

 

 

다음으로는 Promise가 reject된 경우 이 에러를 어떻게 처리하는지 알아보겠습니다.

 

 

 

 

use에서 reject된 Promise 처리, ErrorBoundary 결합

 

use에 전달한 Promise가 reject되면 이 에러는 가장 가까운 ErrorBoundary로 전파됩니다.

 

use는 try-catch로 감쌀 수 없기 때문에, 에러를 화면에서 처리하려면 ErrorBoundary로 컴포넌트를 감싸는 것이 좋습니다.

 

개인적으로 use를 처음 도입했을 때 에러 처리를 try-catch로 시도하다가 전혀 동작하지 않아 당황했던 경험이 있습니다.

 

React 코어에는 ErrorBoundary 컴포넌트가 기본으로 포함되어 있지 않기 때문에, react-error-boundary 패키지를 설치해서 사용하는 것이 일반적입니다.

 

// terminal
$ npm install react-error-boundary

 

 

 

이 주제에 대해서도 간단한 코드를 작성해 보겠습니다.

 

fetchPost는 응답이 실패하면 Promise를 reject시키고, retryPost는 캐시에서 해당 id를 지워 다시 시도할 수 있게 해줍니다.

 

// pages/error-handling/fetchPost.ts
export interface Post {
  id: number
  title: string
}

const cache = new Map<number, Promise<Post>>()

function requestPost(id: number): Promise<Post> {
  return fetch(`https://api.example.com/posts/${id}`).then((res) => {
    if (!res.ok) {
      throw new Error(`게시글 ${id}를 찾을 수 없습니다.`)
    }

    return res.json()
  })
}

export function fetchPost(id: number): Promise<Post> {
  if (!cache.has(id)) {
    cache.set(id, requestPost(id))
  }

  return cache.get(id)!
}

export function retryPost(id: number) {
  cache.delete(id)
}

 

 

 

ErrorBoundary의 resetKeys에 postId를 지정하면, postId가 바뀔 때 ErrorBoundary가 자동으로 초기화되어 다시 렌더링을 시도합니다.

 

다시 시도 버튼은 캐시를 비운 뒤 startTransition으로 ErrorBoundary를 직접 초기화합니다.

 

// pages/error-handling/index.tsx
import { Suspense, startTransition, use, useState } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
import { fetchPost, retryPost } from './fetchPost'

function PostCard({ postId }: { postId: number }) {
  const post = use(fetchPost(postId))

  return (
    <p>
      {post.id}번 게시글: {post.title}
    </p>
  )
}

export default function ErrorHandlingPage() {
  const [postId, setPostId] = useState(1)

  return (
    <div className="flex flex-col gap-4 p-6">
      <div className="flex gap-2">
        <button
          className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
          onClick={() => setPostId(1)}
        >
          정상 게시글
        </button>
        <button
          className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
          onClick={() => setPostId(999)}
        >
          존재하지 않는 게시글
        </button>
      </div>
      <ErrorBoundary
        resetKeys={[postId]}
        fallbackRender={({ resetErrorBoundary }) => (
          <div className="flex flex-col gap-2">
            <p>게시글을 불러오는 중 문제가 발생했습니다.</p>
            <button
              className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
              onClick={() => {
                startTransition(() => {
                  retryPost(postId)
                  resetErrorBoundary()
                })
              }}
            >
              다시 시도
            </button>
          </div>
        )}
      >
        <Suspense fallback={<p>게시글을 불러오는 중입니다...</p>}>
          <PostCard postId={postId} />
        </Suspense>
      </ErrorBoundary>
    </div>
  )
}

 

 

 

 

존재하지 않는 게시글 버튼을 누르면 fetchPost가 reject한 에러가 ErrorBoundary까지 전파되어 fallbackRender가 표시됩니다.

 

이 상태에서 정상 게시글 버튼을 누르면 postId가 바뀌면서 resetKeys에 의해 ErrorBoundary가 다시 초기화됩니다.

 

Suspense와 ErrorBoundary는 서로 다른 실패 모드를 담당합니다.

 

Promise가 아직 pending 상태이면 Suspense의 fallback이, reject되어 에러가 발생하면 ErrorBoundary의 fallback이 대신 표시됩니다.

 

이처럼 use를 사용하면 로딩과 에러 처리를 컴포넌트 내부의 조건문 대신 Suspense와 ErrorBoundary라는 선언적인 경계로 옮길 수 있습니다.

 

 

 

 

 

 

 

이상으로 React 19의 use API에 대해 간단하게 알아보는 시간이었습니다.

 

읽어주셔서 감사합니다.

 

 

 

728x90
반응형

댓글