티스토리 뷰

Data fetching Traditioanl Approach vs Suspense

데이터 fetching과 관련해서 3가지 접근방법이 있다.

  1. Fetch-on-render

이 방법의 문제점은 'waterfall' 이라고 불린다. 이 코드를 보면, App 컴포넌트가 마운트하고나서 todos를 fetching하기 시작하고, 데이터를 받아오는 도중에는 loading jsx를 리턴한다.즉 여러개의 async 리퀘스트들이 병렬적으로 수행되지 못함으로써 UI 렌더링하는데 시간이 오래 걸리게 된다.
만약 Tasks 컴포넌트가 또 다른 async 요청을 할 예정인데, todos를 fetch하는데 5초가 걸린다면 이 Task 리퀘스트는 5초가 지난 다음에야 시작할 수 있는 것이다. 왜냐면 fetch on render 방식을 택하고 있기 때문이다.

const App = () => { 
    const [todos, setTodos] = useState(); 
    useEffect(() => { 
        fetchTodos().then(todos => setTodos(todos)) 
    }, []) 

    if (!todos) return <p>Loading todos...</p> 
    return ( 
        <> 
            <Todos data={todos} /> 
            <Tasks /> // 이 컴포넌트는 또 다른 리퀘스트를 할 예정 
        </> 
    ) 
}

Fetch-on-render 문제점을 해결하기 위해서, 컴포넌트가 마운트된 다음에 네트워크 콜을 하는게 아니라 마운트하기 전에 네트워크 콜을 시작하는 경우를 생각해보자.

  1. Fetch-then-render

    const fetchData = () => {
        return Promise.all([fetchTodos(), fetchTasks()]).then(([todos, tasks]) => ({todos, tasks}))
    }
    
    const promise = fetchData(); // tasks, todos를 가져옴
    
    const App = () => {
      const [todos, setTodos] = useState();
      const [tasks, setTasks] = useState();
    
      useEffect(() => {
        promise().then(data => {
          setTodos(data.todos)
          setTasks(data.tasks)
        })
      }, [])
    
      if (!todos) return <p>Loading todos...</p>
    
      return (
          <>
            <Todos data={todos} />
            <Tasks data={tasks} /> 
        </>
      )
    }

App이 마운트하기 전에 먼저 네트워크 콜을 하고 있다. 저 프로미스는 아까 첫번째 방법과 달리 todos, tasks를 같이 병렬적으로 fetch하고 있다.

todos가 2초 걸리고, tasks가 9초가 걸린다고 하면 이 프로미스가 리졸브될 때까지 총 9초가 걸린다. todos는 훨씬 빨리 fetch가 완료됐음에도 불구하고, tasks를 위해서 7초를 더 낭비해야 todos를 렌더링할 수 있다는 뜻이다. 이를 해결하기 위해 promise all을 쓰지 않고 promise를 각자 나누면 되겠지만 앱이 커질수록 굉장히 지저분해질 것이다.

3번째 방법인 render as fetch는 fetch를 바로 시작하되, 렌더링 또한 이에 블로킹되지 않고 바로 UI를 보여주는 방법이다.

그렇다면 Suspense는 어떻게 동작할까?

세번째 방법이 좋다고 하는데, 그러면 suspense는 과연 어떻게 동작하는 것일까?

suspense는 컴포넌트 내부에서 쓰이는 값이 아직 resolve되지 않은 프로미스인 경우, 이 컴포넌트의 데이터가 아직 준비되지 않았다고 판단한다. 그래서 props로 받은 fallback UI를 렌더링한다.

만약 컴포넌트 내부에서 쓰이는 값이 그냥 resolved된 data라면, 문제 없이 그 dat를 렌더링한다.

만약 컴포넌트 내부에서 에러가 발생한다면, 이를 버블링해서 가까운 ErrorBoundary에게 처리를 위임한다.

서스펜스가 이렇게 동작하기 때문에, 서스펜스와 함께 쓰려면 data fetch를 할 때 특정한 설계를 해주어야 한다.

function wrapPromise(promise) {
  let status = 'pending'
  let response

  const suspender = promise.then(
    (res) => {
      status = 'success'
      response = res
    },
    (err) => {
      status = 'error'
      response = err
    },
  )

  const read = () => {
    switch (status) {
      case 'pending':
        throw suspender
      case 'error':
        throw response
      default:
        return response
    }
  }

  return { read }
}

위의 코드를 보면 프로미스를 아규먼트로 받아서 read라는 메서드를 리턴하고 있다. 이 read는 프로미스의 상태에 따라서 다른 것을 리턴한다.

만약 pending 상태의 프로미스라면 throw suspender 이 구문을 통해 프로미스 자체를 throw한다.

만약 resolve됐다면 resolve된 data를, 만약 에러가 발생했다면 에러를 리턴한다.

function fetchTodos() {
  const promise = fetch(pendingUrl)
    .then((res) => res.json())
    .then((res) => res.data)

  return wrapPromise(promise)
}

function fetchTasks() {
  const promise = fetch(completedUrl)
    .then((res) => res.json())
    .then((res) => res.data)

  return wrapPromise(promise)
}

export { fetchTodos, fetchTasks }
import { fetchTodos } from '../api/endpoints'

const resource = fetchTodos() // 컴포넌트가 렌더링되기 전에 콜하는 것이 포인트

export const CompletedTodos = () => {
  const todos = resource.read()

  return (
    <ul className="todos">
      {todos.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  )
}

그리고 위의 코드처럼 프로미스를 wrapPromise에 전달하여, read 메서드를 사용할 수 있게 한다. 이 read 메서드로부터 리턴받은 값을 보고 suspense가 판단하여 그에 맞는 UI를 보여주게 된다.

여기서 포인트는 렌더링 전에 네트워크 콜을 시작하는 것이다. 이렇게 함으로써 빠르게 렌더링을 할 수 있다. (We kick off fetching *before* rendering.)

Ref

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함