React Suspense

박세영·2022년 7월 8일
0

프론트엔드 어플리케이션이 점점 복잡해 짐에 따라 데이터 로딩을 관리하는 것은 점점 어려워지고 있다. 이러한 이유 때문에, 프론트엔드 생태계의 다양한 상태관리 라이브러리들이 등장했다.

React팀은 이러한 문제를 인지하고 있었고, Suspense는 React 컴포넌트의 Loading state를 선언적으로 관리하기 위해 등장했다.

What is React Suspense?

Suspense는 React app에서 비동기 처리를 관리하기 위헤 등장했다. 이는 컴포넌트가 특정 데이터를 기다리고 있음을 알린다.

주의할 점은 Suspense가 axios같은 fetching 라이브러리도 아니고, Redux와 같은 상태관리 라이브러리도 아니라는 점이다. Suspense는 단지 비동기 동작 중 로딩 상태일 때 표한할 컴포넌트를 선언적으로 표현한다.

또한, Suspense는 다양한 컴포넌트 간에 로딩 상태를 동기화하여 더 나은 유저 경험을 제공한다. 그리고 기존 서비스에 손쉽게 적용가능하다.

How to use Suspense

아래 예제를 통해 Suspense를 사용하는 기본적인 상황을 살펴보자.

const [todos, isLoading] = fetchData('/todos')

if(isLoading) return <Spinner />

return <Todos data={todos} />

위 코드는 대부분의 프론트앤드 개발자가 네트워크 요청 대기를 처리하기 위해 사용하는 패턴이다. 이 컴포넌트에 사용된 fetchData함수, Spinner컴포넌트, Todos컴포넌트는 서로 연관성이 떨어진다.

변수 isLoading은 이 때, 요청의 상태를 추적하기 위해 존재한다. 만약 해당 값이 true라면, 유저에게 이러한 상태를 공유하기 위해 Spinner를 렌더링한다. 기존의 사용하던 방식에 큰 문제는 없지만 Suspense를 사용하면 어떻게 처리할 수 있는지 살펴보자.

const todos = fetchData('/todos')

return (
	<Suspense fallback={<Spinner />}>
    	<Todos data={todos} />
    </Suspense>
)

사소하지만 중요한 변화가 코드에 일어났다. 변수로 로딩 상태를 관리하는 것과 그 변수의 값이 따라 Spinner 컴포넌트를 랜더링하는 대신에, Suspense를 통해 해당 로직은 더욱 선언적으로 처리 할 수 있다.

Suspense를 활용한 예제에서는 React는 네트워크 요청이 발생한 것을 알 수 있으며, 요청이 종료되기 전까지 Todos컴포넌트를 지연(suspense)시킬 수 있다.

또 한가지 중요한 점은 Suspense에 전달하는 fallback프로퍼티이다. fallback프로퍼티에 네트워크 요청이 끝나기 전에 보여주고 싶은 컴포넌트를 전달할 수 있다. 보통 Spinner를 fallback프로퍼티에 전달한다.

그렇다면, React는 어떻게 네트워크 요청이 진행 중인 것을 알 수 있을까? 지금까지 알기로는 Suspense는 단순히 요청을 기다리는 동안 fallback 컴포넌트를 렌더링한다. 리액트에게 네트워크 요청에 대한 정보를 알리는 코드는 어디에 존재할까?

이 때, data fetching 라이브러리가 등장한다. Relay나 SWR는 Suspense와 함께 사용하여 React에게 현재 로딩 상태를 알릴 수 있다.

다음으로 몇 가지 Data fetching 방식에 대해 알아보고 그들의 한계가 무엇인지 그리고 Suspense는 어떻게 사용자 경험과 개발자 경험을 향상시켰는지에 대해 알아보자.

Data fetching approaches

클라이언트 사이드에서 API를 통한 데이터가 필요할때, 보통 네트워크 요청을 통해 해당 데이터를 받아온다. 이번 챕터에서 데이터를 받아오는 3가지 방식에 대해 알아보자.

Fetch-on-render

이 접근법을 사용하면, 컴포넌트가 마운트된 다음 네트워크 요청이 수행된다. 이 접근법의 이름이 fetch-on-render인 이유는 컴포넌트가 렌더링되기 전까지는 fetch가 수행되지 않기 때문이다. fetch-on-render 접근법을 사용했을 때는 "waterfall"문제가 발생할 수 있다.

const App = () => {
	const [userDetail, setUserDetails] = useState({});
    
    useEffect(() => {
    	fetchUserDetails().then(setUserDetails)
    , []};
    
    if(!userDetails.id) return <p>Fetching user details...</p>
    
    return(
    	<div>
        	<h2>Simple Todo</h2>
            
            <UserWelcome user={userDetails} />
            <Todos />
    	</div>
    )
}

위 예제는 컴포넌트에서 사용할 데이터를 fetch할 때 흔히 볼 수 있는 코드이다. 익숙해 보이지만, 이 코드는 문제를 갖고 있다.

만약 Todos컴포넌트가 API를 통해 다른 데이터를 fetch해야 한다면, 그 데이터는 fetchUserDetails()가 완료될 때까지 요청될 수 없다. 각 요청은 병렬적으로 처리되는 대신에, 직렬적으로 앞 요청이 완료될 때가지 뒷 요청은 대기를 해야한다.

네트워크 탭을 통해 살펴보면 이러한 문제는 극명히 드러난다.

중첩된 컴포넌트 각각에서 요청이 발생한다면 "waterfall"문제가 발생하고 이런 문제는 유저 경험에 치명적이다.

Fetch-then-render

이 접근법을 통해 컴포넌트를 렌더링 하기 이전에 해당 컴포넌트에 필요한 데이터들을 미리 요청할 수 있다. 이전 예시를 Fetch-then-render 접근법으로 수정해 보자.

const App = () => {
	const [userDetails, setUserDetails] = useState({});
    const [todos, setTodos] = useState([]);
    
    useEffect(()=>{
   		fetchDataPromise.then((data) => {
        	setUserDetails(data.userDetails);
            setTodos(data.todos);
        }
    }, []);
    
    return (
    	<div className="app">
        	<h2>Simple Todo</h2>
            
            <UserWelcome user={userDetails} />
            <Todos todos ={todos} />
    	</div>
    )
}

위 예제에서는 기존 Todos 컴포넌트 안에 있던 요청 로직을 App컴포넌트 쪽으로 뺐다. Todos 컴포넌트는 더 이상 비동기 요청을 하지 않고 대신, App 컴포넌트로부터 데이터를 받아서 처리한다.

다시 개발자 도구의 네트워크 탭을 보면 이제 요청이 병렬적으로 수행되는 것을 확인할 수 있다.

fetchUserDetailsAndTodos 함수가 아래와 같이 정의되어 있다고 가정해보자.

const fetchUserDetailsAndTodos = () => {
	return Promise.all([fetchUserDetails(), fetchTodos()])
    	.then(([userDetails, todos]) => ({ userDetails, todos }))
}

비록 fetchUserDetails요청과 fetchTodos요청이 병렬적으로 시작하지만, Promise.all() 구문의 특성상 모든 요청의 응답이 와야 반환하기 때문에, 응답이 일찍오는 쪽은 응답이 늦은 쪽을 기다려야 한다.

만약 fetchUserDetails 요청이 200ms이 소요됐고, fetchTodos 요청이 900ms이 소요됐다면, fetchUserDetails요청은 데이터가 준비되었음에도 불구하고 700ms나 기다려야 한다는 문제점이 발생한다.

또한 부모 컴포넌트에서 두 개의 자식컴포넌트의 상태를 모두 관리해야 하는데, 이는 컴포넌트의 복잡도를 증가시키는 원인이 되기도 한다.

Render-as-you-fetch

Suspense 문법이 React에 가져온 가장 강력한 이점 중 하나가 Render-as-you-fetch를 가능하게 했다는 것이다. Render-as-you-fetch 접근법을 통해 지금까지 다뤘던 다른 접근법의 문제를 모두 해결할 수 있다. Render-as-you-fetch 접근법은 말 그대로 데이터를 fetch하자마자 컴포넌트를 렌더링한다.

이는 fetch-then-render와 같이 렌더링 하기 전에 데이터를 fetch한다는 공통점이 있지만, 다른 요청이 완료될 떄까지 기다릴 필요가 없다. 아래 예제를 살펴보자.

const data = fetchData()

const App = () => (
	<>
    	<Suspense fallback={<p>Fetching user details..</p>}
        	<UserWelcome />
        </Suspense>
        
        <Suspense fallback={<p>Loading todos..</p>}
        	<Todos />
        </Suspense>
    </>
)

const UserWelcome = () => {
	const userDetails = data.userDetails.read()
    // code to render welcome message
}

const Todos = () => {
	const todos = data.todos.read()
    // code to map and render todos
}

위 예제가 다소 낯설게 느껴질지도 모른다. 하지만 이 코드는 그렇게 복잡하지 않다. 대부분의 로직은 fetchData()안에서 발생한다. 구현이 어떻게 되었는지 살펴보기에 앞서, 나머지 코드들을 한 번 둘러보자.

먼저 컴포넌트를 렌더링하기 전에 네트워크 요청을 수행했다. 메인 App 컴포넌트에서 UserWelcome 컴포넌트와 Todos 컴포넌트를 각각 Suspense 컴포넌트로 감싸주었다.

App 컴포넌트가 처음 마운트 될 때, UserWelcome 컴포넌트가 먼저 렌더링 되려 한다. 그 과정에서 data.userDetails.read()가 수행된다. 만약 데이터가 아직 준비되지 않았다면, 다시 Suspense로 돌아가 fallback 컴포넌트를 렌더링한다.

fallback 컴포넌트는 데이터가 준비되기 전까지 렌더링되며, 만약 데이터가 준비되었다면 기존 컴포넌트가 렌더링 된다. 이 접근법의 가장 큰 강점은 다른 컴포넌트가 렌더링 되기를 기다릴 필요가 없다는 것이다. 어느 컴포넌트든 만약 데이터가 준비되었다면, 다른 컴포넌트의 로딩 상황과 관련 없이 렌더링한다.

마지막으로 Suspense와 소통하기 위한 promise wrap 코드를 살펴보자.

wrapPromise

promise를 이번에 만들 wrapPromise로 감싸 React Suspense와 소통할 수 있다. 그렇기 때문에 이 작업은 Suspense API를 활용하기 위한 추상화를 할 떄 가장 중요하다.

wrapPromise.js는 Promise를 감싸는 wrapper이며, Promise가 준비되면 읽을 수 있는 메서드를 제공한다. 만약 promise가 resolve되면, 데이터를 반환하고, reject되었다면, 에러를 throw한다. 그리고 만약 아직 요청이 진행중이라면 다시 promise를 던진다.

wrapPromise함수는 아래와 같은 요구사항을 만족해야 한다.

  • Promise를 인수로 받는다.
  • Promise가 resolve되면, resolve된 값을 반환하다. (return)
  • Promise가 reject되면, rejected된 값을 던진다. (throw)
  • Promise가 아직 pending이라면, 다시 Promise를 던진다. (throw)
  • Promise의 상태를 확인하기 위한 메서드를 노출한다.

주어진 요구사항에 기반해 아래와 같이 코드를 작성할 수 있다.

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

wrapPromise 함수 안을 살펴보면, 두가지 변수를 선언했다.
1. status, promise 인수의 상태를 추적하는 변수.
2. response, pormise의 결과를 담고 있는 변수(resolved or rejected).

다음으로 우리는 suspender라는 새로운 변수를 선언했다. suspender는 전달받은 promise에 then 메서드를 붙여 초기화 했다.

then 메서드 안을 살펴보면 두 개의 콜백 함수가 있다. 첫 번째 함수는 resolved된 경우를 처리하고, 두 번째 함수는 rejected된 경우를 처리한다. 만약 promise가 성공적으로 resolve했다면, status의 값은 "success"로 변경하고 response 변수 안에 resolved된 값을 담는다.

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

export default wrapPromise;

마지막으로, 우리는 read라는 이름의 함수를 만들었다. 그리고 이 함수 안에서 switch문을 사용해서 값의 status에 따른 대응을 했다.

suspender변수 혹은 response변수를 throw하는 이유는 suspense에게 아직 promise가 resolved되지 않았다는 것을 알리기 위해서이다.

throw구문을 통해 마치 컴포넌트에서 발생한 에러처럼 처리를 하고 Suspense 컴포넌트는 던져진 값을 확인해 실제 에러인지 아니면 promise인지 판단한다.

만약 값이 promise라면, Suspense 컴포넌트는 아직 컴포넌트가 데이터를 기다리고 있음을 파악하고 fallback을 렌더링한다. 만약 값이 에러라면, 가장 가까운 에러 바운더리에 버블링한다.

//UserWelcome.jsx
import React from 'react'
import fetchData from '../api/fetchData'

const resource = fetchData(
  'https://run.mocky.io/v3/d6ac91ac-6dab-4ff0-a08e-9348d7deed51'
)

const UserWelcome = () => {
  const userDetails = resource.read()

  return (
    <div>
      <p>
        Welcome <span className="user-name">{userDetails.name}</span>, here are
        your Todos for today
      </p>
      <small>Completed todos have a line through them</small>
    </div>
  )
}

export default UserWelcome

0개의 댓글