개발자
류준열

Vercel의 React BestPractice - Waterfall

Vercel에서 React BestPractice skill이 공개되었다.

한줄 한줄 읽어보는데, AI를 떠나서 프론트엔드 개발자로써 인사이트를 많이 얻는다.

AI가 나오기 전에 이미 습관으로 체화한 내용도 있고 생소한 내용도 있다.

읽으면서 기록을 남길 예정이다.

Early Return일때 순서 조정으로 불필요한 요청 줄이기

early return이 있으면 바로 return 할 수 있는 경우에 비싼 api call을 하지 않는것이다.

async function handleRequest(userId: string, skipProcessing: boolean) {
  const permission = await getPermission(userId)
  
  if (!permission) {
    return ;
  }
  
  return getSomethingWithOutPermission() // 권한 없어도 조회 가능한 데이터
}

위 코드는 권한이 필요 없는 데이터를 요청하기 전에 권한을 요청하고 있다.

불필요한 권한 요청을 함으로써 getSomethingWithOutPermission의 데이터 처리가 늦어지고 있다.

이럴땐 아래와 같이 권한 요청하기 전 getSomethingWithOutPermission 부터 조회하는 것이 좋다.

async function handleRequest(userId: string, skipProcessing: boolean) {

// 권한 없어도 조회 가능한 데이터를 먼저 요청한다.
const somethingWithOutPermission = getSomethingWithOutPermission() 

if(somethingWithOutPermission) return somethingWithOutPermission

const permission = await getPermission(userId)
  
  if (!permission) {
    return ;
  }
  
}

better-all

여러개의 Promise들이 모두 독립적으로 실행되면 promise.all을 사용하면 된다.
하지만 Promise들의 몇몇이 의존되어 있을때는 어떻게 해야 할까?

const [coupon, worker, discount] = await Promise.all([
  getUser().then(user => getCoupon(user.id)),
  getWorker(),
  getProduct().then(product => getDiscount(product.id)),
])

나는 위와 같이 의존성이 걸린 곳에 then 체이닝을 걸었는데, React best practice에서는 better-all을 쓰라고 한다.

import { all } from 'better-all'

const { user, config, profile } = await all({
  async user() { return fetchUser() },
  async config() { return fetchConfig() },
  async profile() {
    return fetchProfile((await this.$.user).id)
  }
})

난 better-all 을 처음들어봤는데, 다운로드 수가 급증한걸보면 다들 vercel best practice 보고 왔나 싶기도 하다.

await는 필요한 순간에만 하기

독립적인 작업은 일단 바로 시작하고 await는 필요한 순간에만 하라고 한다. 연쇄 await으로 인한 waterfall을 막기 위해

무분별한 await은 불필요한 waterfall을 만든다.

const session = await auth()        // 1) auth 끝날 때까지 대기
const config = await fetchConfig()  // 2) auth 끝난 뒤에야 config 시작
const data = await fetchData(session.user.id) // 3) config 끝난 뒤에야 data 시작

여기서 fetchConfig()auth()랑 독립인데, await auth() 때문에 시작조차 늦어진다.

Promise 먼저 Start

const sessionPromise = auth()          // auth 시작
const configPromise = fetchConfig()    // config도 동시에 시작

const session = await sessionPromise   // 이제 필요하니 기다림

const [config, data] = await Promise.all([
  configPromise,                        // 이미 돌고 있던 config 기다림
  fetchData(session.user.id)            // session 필요하니 여기서 시작
])

위와 같이 promise를 먼저 시작시키고 await은 꼭 필요한 순간에 하라고 한다.

chatGPT한테 앞서 말했던 아래 패턴과 차이가 있냐고 물어보았다.

const [config, data] = await Promise.all([
  fetchConfig(), 
  auth().then(session=>fetchData(session.user.id))
])

성능상으로는 config가 auth를 기다리지 않고 병렬처리 되기 때문에 정답에 가깝지만 실무 관점에서는 다음과 같은 주의점이 있다고 한다.

  • auth()가 실패했는지 fetchData()가 실패했는지 덜 직관적이다.
  • auth 결과를 재사용 할 수 없다.

그래서 확장성을 고려했을때 아래처럼 Promise를 먼저 실행시키고 await을 필요한곳에 붙히는게 좋다고 한다.

const sessionP = auth()
const configP = fetchConfig()

const dataP = sessionP.then(s => fetchData(s.user.id))

const [config, data] = await Promise.all([configP, dataP])

전략적으로 Suspense 사용

Instead of awaiting data in async components before returning JSX, use Suspense boundaries to show the wrapper UI faster while data loads.

느린 부분만 Suspense로 격리해서 먼저 레이아웃을 그리라는데, 정리하면 Streaming Rendering 하라는 뜻

SSR이 길어지는 순간

async function Page() {
  const data = await fetchData() // 여기서 페이지 전체가 대기하면서 SSR이 길어짐
  return (
    <Sidebar />
    <Header />
    <DataDisplay data={data} />
    <Footer />
  )
}

DataDisplay의 데이터만 필요한데 Sidebar/Header/Footer 까지 전부 기다린다.

Streaming Rendering

아래와 같이 하면 Sidebar/Header/Footer는 즉시 렌더링 되고 DataDisplay는 스켈레톤을 보여주다가 데이터가 오면 교체된다.

-> 점진적으로 렌더링되기 때문에 유저 초기 체감 속도가 개선된다.

function Page() {
  return (
    <>
      <Sidebar />
      <Header />
      <Suspense fallback={<Skeleton />}>
        <DataDisplay />
      </Suspense>
      <Footer />
    </>
  )
}

async function DataDisplay() {
  const data = await fetchData() // 이 컴포넌트만 대기
  return <div>{data.content}</div>
}