#suspense, #react

사용자 경험 개선 1편 - react suspense

📜 읽기 전에…

  • 🎈 본문에 등장하는 문제에 대한 예시는 suspense를 사용하지 않고도 해결할 수 있습니다. 하지만 앱이 커지고 복잡해짐에 따라 각 문제를 어떻게 선언적으로 손쉽게 해결할 수 있느냐에 대한 관점으로 봐주시길 바랍니다.
  • 🎈 2021.07.11 기준 시험적인 기능입니다. 현재 정식 버전에서 suspense는 code splitting으로만 사용되고 있습니다. 유의해주세요.
  • 🎈 본문은 리액트 공식 문서를 참고하여 재구성하였음을 알립니다.

🙋‍♂️ suspense가 뭐죠?

혹시 웹페이지에 로딩 화면을 만들어 본 적이 있나요?

대부분 사용자에게 무언가 진행되고 있음을 알리기 위해서 로딩 화면을 띄워본 경험이 있을 것이다. react suspense는 이러한 로딩 화면과 연관이 있으며 다음과 같이 정의할 수 있다.

특정 컴포넌트에서 사용되고 있는 데이터의 준비가 아직 끝나지 않았음을 react에 알릴 수 있으며 data fetching 라이브러리와 함께 사용할 수 있는 구조.

위의 정의에서 데이터의 준비가 끝나지 않았음을 알린다라는 것은 해당 데이터를 사용하는 컴포넌트를 렌더링하지 않고 다른 로딩 화면을 보여줄 수 있음을 의미한다.

여기서 누군가는 의아할지도 모른다. 지금까지 suspense 없이도 로딩 화면을 잘 만들어 왔는데? 라고 할지도 모른다. 그렇다면 지금까지 만들어왔던 방식보다 suspense가 왜 좋은지 같이 알아보도록 하자.

👀 왜 사용하는 건가요?

앞서 설명한 것처럼 suspense는 data fetching 라이브러리와 함께 사용하면 효과적인 구조다. 그렇다면 data fetching 라이브러리를 사용했을 때, 기존 방식보다 좋은 이유는 무엇인지부터 우선으로 알아보자.

data fetching 라이브러리의 역할

fetching 라이브러리는 워터폴(waterfall) 현상을 막아준다. 워터폴이라고 하면 이전 개발 과정이 끝나야 다음 과정을 진행할 수 있는 프로젝트 방법론을 떠올리는 사람이 많을 것이다. 하지만 fetching 라이브러리에서의 워터폴 현상은 이전 fetch 요청에 대한 응답이 도착해야 다음 fetch 요청을 보낼 수 있는 구조를 의미한다. 예를 들어 다음과 같은 상황이 있다고 생각해보자.

  • 컴포넌트 1에서 데이터 1을 요청… 가져오는 동안 로딩 화면만을 렌더링(3초 소요)
  • 컴포넌트 1에서 데이터 1의 응답을 받고 컴포넌트 2를 렌더링
  • 컴포넌트 2에서 데이터 2를 요청… 가져오는 동안 로딩 화면만을 렌더링(2초 소요)
  • 컴포넌트 2에서 데이터 2의 응답을 받고 컴포넌트 3을 렌더링

이 상황에서 무조건 데이터 1에 대한 응답을 받고 나서야 데이터 2에 대한 요청이 실행된다. 데이터 2에 대한 요청 자체는 2초만 소요됨에도 불구하고 데이터 1에 대한 요청 때문에 3초를 무조건 기다려야 하는 문제가 발생하는 것이다. 참고 코드

이러한 문제는 컴포넌트 렌더링 -> data fetching 요청 -> data 응답과 같이 동작하는 구조 때문에 일어난다. 이 문제를 data fetching 라이브러리는 컴포넌트 트리 구조에 필요한 모든 data fetching 요청을 렌더링 이전에 실행하도록 중앙화하여 해결한다. 즉 data fetching 요청 -> data 응답 -> 컴포넌트 렌더링의 구조로 바뀌는 것이다.

이렇게 구조가 바뀌면 data fetching 요청이 컴포넌트 렌더링에 의존되지 않고 모두 한 번에 실행되므로 워터폴 현상을 막을 수 있다.

그렇다면 data fetching 라이브러리만으로 모든 문제가 해결될까? 그건 아니었다. 컴포넌트에 필요한 모든 data 응답을 받을 때까지 해당 컴포넌트는 물론이고 하위 컴포넌트들도 렌더링할 수 없다는 점이 문제였다. 또한 하위 컴포넌트들의 로딩도 의미가 없어져 버렸다. 하위 컴포넌트가 렌더링 된 시점에서는 상위 컴포넌트에 의해 이미 모든 데이터를 응답받은 상태이기 때문에 하위 컴포넌트의 로딩은 절대 렌더링 되지 않을 것이다.

참고코드

사실 위 참고 코드에서는 Promise.all()을 사용하고 있는데 굳이 Promise.all()을 사용하지 않고 따로 fetching 요청을 나누어도 이 문제에 대한 해결이 가능하다. 하지만 이렇게 따로 나누는 방식은 데이터와 컴포넌트 트리가 복잡해짐에 따라 점점 어려워진다. 개발자들은 좀 더 간단한 방식을 원했다.

여기서 suspense가 등장한다.

suspense의 역할

suspense의 역할은 크게 2가지로 나눌 수 있다. 하나씩 알아보자.

✨ 1. 모든 요청을 기다리지 않고도 화면을 렌더링할 수 있다.

앞서 fetching 라이브러리만을 사용했을 때 구조를 다시 보자.

data fetching 요청 -> data 응답 -> 컴포넌트 렌더링

이 과정을 좀 더 자세히 들여다 보면 다음과 같다.

data fetching 요청 -> 로딩중 UI 렌더링 -> data 응답 -> 컴포넌트에 응답 반영

이러한 구조에서 suspense는 요청 직후 요청 리소스를 바로 컴포넌트로 주입하는 방식으로 바꿔준다.

data fetching 요청 -> suspense 하위의 컴포넌트에 요청 리소스를 반영 -> suspense에 의해 로딩 UI 렌더 -> 요청 리소스로 data 응답이 들어옴 -> 컴포넌트에 응답 반영

여기서 말하는 요청 리소스는 Promise의 형태가 아니다. data fetching 라이브러리 내부적으로 구현되어있는 일반 객체인데 fetch API로 모방한 형태가 궁금하다면 여기를 참고하자.

이런 구조로 바뀌면 fetching 라이브러리만 사용했을 때 모든 data 응답을 기다려야 컴포넌트 트리를 렌더링할 수 있었던 문제를 해결할 수 있다. fetching 요청 직후 응답 도착 여부와는 상관없이 렌더링을 수행하기 때문이다.

✨ 2. 경쟁 상태 발생을 방지한다.

suspense는 data fetching에 의한 경쟁 상태(Race Condition) 발생을 방지한다.

우선 경쟁 상태라는 용어부터 알아보자. 위키백과에서는 이렇게 설명하고 있다.

공유 자원에 대해 여러 개의 프로세스가 동시에 접근을 시도할 때 접근의 타이밍이나 순서 등이 결과값에 영향을 줄 수 있는 상태

그렇다면 javascript 환경에서 경쟁 상태란 무엇일까? 여러 개의 비동기 작업(fetching response)의 결과가 하나의 DOM 객체에 반영되는 상황이 있을 것이다.

예를 들어 다음과 같은 상황을 가정해보자.

  • 미키, 주모, 심바, 파노, 카일 5개의 버튼이 있고 각 버튼을 누르면 각 크루에 대한 프로필을 서버로 요청한다.
  • 프로필 요청 응답을 앱이 받으면 해당 정보를 컴포넌트에 업데이트한다.

여기서 여러 버튼을 빠른 속도로 누른다고 생각해보자. 과연 마지막에 누른 버튼과 컴포넌트의 정보는 일치할까? 아마 마지막으로 누른 버튼이 미키 버튼이라고 해도 주모의 정보가 표시될 수 있을 것이다.

이러한 문제는 왜 발생할까? 그 이유는 버튼을 누른 순서대로 프로필 요청에 대한 응답이 도착하고 순서대로 에 반영될 것이라는 잘못된 가정에서 발생한다. A 요청이 B 요청보다 먼저 수행되었다고 해서 무조건 A 요청에 대한 응답이 먼저 도착하는 것은 아니기 때문에 해당 가정은 잘못되었다.

그렇다면 이 문제를 suspense는 어떻게 해결할 수 있을까. suspense는 state 설정 시기를 바꾸어 이를 해결한다.

이전의 코드는 A 프로필 요청 -> 로딩 UI 렌더 -> A 프로필 응답 -> <Profile />에 응답 반영의 순서였다면 suspense는 이 과정을 A 프로필 요청 -> <Profile />에 A 프로필 요청 리소스 반영 -> suspense에 의해 A 요청에 대한 로딩 UI 렌더 -> 요청 리소스로 A 프로필 응답이 들어옴 -> <Profile />에 응답 반영으로 바꾼다.

경쟁 상태를 이런 방식으로 해결 가능한 이유는 suspense가 응답이 언제 오는지, 시간에 대한 것을 고려하지 않아도 되기 때문이다. 프로필을 요청함과 동시에 해당 요청 리소스를 에 반영하기 때문에 이전에 수행하고 있던 요청이 있더라도 해당 요청은 무시하고 새로운 요청으로 대체된다.

suspense는 이를 통해 경쟁 상태를 해결할 수 있다.

🔍 suspense와 함께 사용되는 data fetching 라이브러리

거의 무조건 data fetching 라이브러리와 함께 사용하기 때문에 fetching 라이브러리와 suspense를 함께 사용하는 예시를 몇 가지 보도록 하자.

Relay

graphQL에 의존적인 data fetching 라이브러리다.

페이스북에서는 suspense를 Relay와 함께 실무에서 사용하고 있다고 한다. 그래서 Relay 외의 다른 라이브러리와 suspense를 사용했을 때 원활히 동작할지 장담할 수 없으므로 만약 suspense를 실무에서 사용하고 싶다면 페이스북은 Relay와 함께 사용할 것을 추천하고 있다.

만약 graphQL에 익숙하고 suspense 기능을 실무에서 사용해야 한다면 Relay를 쓰는 것이 적합할 것이다.

SWR

Data Fetching을 위한 react 훅 라이브러리인 SWR도 시험적으로 suspense를 지원하고 있다. 간략하게 코드를 확인해보자.

import { Suspense } from 'react'
import useSWR from 'swr'

function Profile () {
  const { data } = useSWR('/api/user', fetcher, { suspense: true })
  return <div>hello, {data.name}</div>
}

function App () {
  return (
    <Suspense fallback={<div>loading...</div>}>
      <Profile/>
    </Suspense>
  )
}

useSWR의 세 번째 인자로 suspense: true를 전달함으로써 suspense 옵션을 활성화할 수 있다. 이런 간단한 설정으로 suspense 사용이 가능하다.

그 외 suspense를 지원하는 라이브러리들

💕 마무리

사용자 경험 개선에 대한 주제로 아직 시험적인 기능이지만 React 18에서 추가될 기능인 suspense에 대해서 알아보았다. 공부를 하다 보니 비동기 동작이 얼마나 사용자 경험에 지대한 영향을 미칠 수 있는가에 대해 새로운 인사이트를 얻을 수 있었다. 생각해보면 사용자가 기다리는 대부분의 시간은 data fetching과 관련이 있다. suspense를 통해 이 과정을 좀 더 효율적으로 처리할 수 있다면 좀 더 쾌적한 경험을 사용자에게 줄 수 있을 것이다. 다음 2편에서는 이를 좀 더 개선할 수 있는 concurrent mode에 대해서 알아볼 예정이다.

참고