[React] React suspense를 이용한 waterfall 현상 개선

쏘소·2022년 9월 25일
2

React

목록 보기
10/13

사용자 경험을 개선하는 방법 중에 하나인 React 18버전의 suspense 기능에 대해 깊이 공부해보려고 이 블로그를 읽던 중 이해가 되지 않는 문장이 있었다.

fetching 라이브러리는 워터폴(waterfall) 현상을 막아준다. 워터폴이라고 하면 이전 개발 과정이 끝나야 다음 과정을 진행할 수 있는 프로젝트 방법론을 떠올리는 사람이 많을 것이다. 하지만 fetching 라이브러리에서의 워터폴 현상은 이전 fetch 요청에 대한 응답이 도착해야 다음 fetch 요청을 보낼 수 있는 구조를 의미한다. 예를 들어 다음과 같은 상황이 있다고 생각해보자.
컴포넌트 1에서 데이터 1을 요청… 가져오는 동안 로딩 화면만을 렌더링(3초 소요)
컴포넌트 1에서 데이터 1의 응답을 받고 컴포넌트 2를 렌더링
컴포넌트 2에서 데이터 2를 요청… 가져오는 동안 로딩 화면만을 렌더링(2초 소요)
컴포넌트 2에서 데이터 2의 응답을 받고 컴포넌트 3을 렌더링..
이 상황에서 무조건 데이터 1에 대한 응답을 받고 나서야 데이터 2에 대한 요청이 실행된다. 데이터 2에 대한 요청 자체는 2초만 소요됨에도 불구하고 데이터 1에 대한 요청 때문에 3초를 무조건 기다려야 하는 문제가 발생하는 것이다.

그동안 알기론 React 는 자식 컴포넌트 부터 렌더링을 하고, 따라서 data fetching 과 같은 side effect를 다룰 수 있는 useEffect hook 또한 자식 컴포넌트 부터 시행된다. 근데 왜 부모 컴포넌트 부터 시행이 되는 것인지..

확인을 위해 다음과 같은 코드를 짜서 로컬에서 돌려주었다.

const Parent = () => {
  const [data, setData] = useState<IPlacesListResult>()

  useEffect(() => {
    console.log('start-3')
    setTimeout(() => {
      fetchApi('불고기').then((res) => {
        setData(res.data)
        console.log(res.data)
      })
    }, 500)
    console.log('end-3')
  }, [])

  if (!data) {
    return <div>loading...data</div>
  }

  return (
    <>
      <Child1 />
      {data.map((ele) => {
        return <div key={ele.data_id}>{ele.data_id}</div>
      })}
    </>
  )
}

export default Parent

export const Child1 = () => {
  const [data2, setData2] = useState<IPlacesListResult>()

  useEffect(() => {
    console.log('start-2')
    setTimeout(() => {
       fetchApi('사탕').then((res) => {
        setData2(res.data)
        console.log(res.data)
      })
    }, 100)
    console.log('end-2')
  }, [])

  if (!data2) {
    return <div>loading...data2</div>
  }

  return (
    <>
      <Child2 />
      {data2.map((ele) => {
        return <div key={ele.data_id}>{ele.data_id}</div>
      })}
    </>
  )
}

const Child2 = () => {
  const [data3, setData3] = useState<IPlacesListResult>()

  useEffect(() => {
    console.log('start-1')
    setTimeout(() => {
       fetchApiy('떡볶이').then((res) => {
        setData3(res.data)
        console.log(res.data)
      })
    }, 1000)
    console.log('end-1')
  }, [])

  if (!data3) {
    return <div>loading...data3</div>
  }

  return (
    <>
      {data3.map((ele) => {
        return <div key={ele.data_id}>{ele.data_id}</div>
      })}
    </>
  )
}

그리고 결과는 다음과 같았다.

콘솔에 찍힌 결과를 보면 useEffect가 Parent 컴포넌트 부터 실행된 것을 볼 수 있고, 불러진 api도 불고기, 사탕, 떡볶이 순으로 결과가 나왔다. setTimeout에 설정한 것 처럼 각각 100ms, 500ms, 1000ms 씩 기다린 후에 api 함수가 호출 되었다.

원인을 찾기 위해서 리액트 렌더링 과정을 다시 확인해야겠다는 생각이 들었고, 이 블로그를 통해 리액트 렌더링 과정과 개념을 다잡을 수 있었다.

react rendering summary

리액트는 처음에, 함수 컴포넌트를 시행(initial render)하면 props를 받고, hook을 생성하고, 내부 변수와 내부 함수를 생성한다. 이후, render 를 만나면 해당 JSX를 바벨을 통해 React.createElement로 convert 하고 react element를 생성한다. 메모리에 저장된 이 react element으로 virtual DOM이 생성된다(render phase). 이후 이 결과를 실제 DOM에 그리게 된다(commit phase). 이후 브라우저에 paint 가 이루어지고, useEffect 가 시행된다. 이 때 useEffect 구현부에서 상태 변화가 일어나면 컴포넌트는 재렌더링(리렌더링)을 하게 된다.

리렌더링을 하게 되면 다음과 같다. 함수 컴포넌트의 리렌더링은 state, props가 변경되었거나, 부모의 컴포넌트가 렌더링 될 때 발생한다. 이 때 변화가 있는 컴포넌트에 flag 가 표시되고, 이 flag 가 있는 JSX를 마찬가지로 바벨을 통해 React.createElement로 convert 하고 react element를 생성한다. 메모리에 저장된 이 react element 로 virtual DOM이 생성되고, 기존의 virtual DOM 과의 차이를 비교하여 effect list 를 만든다(render phase). 이후 이 결과를 실제 DOM에 적용시켜 그리게 된다(commit phase).

결과 이유

위와 같은 과정으로 React rendering 이 이루어지게 된다. 과정에 대한 개념을 다시 잡으면서 위의 콘솔과 같은 결과가 나온 이유를 알 수 있었다.
먼저, Parent 컴포넌트는 useEffect를 통해 데이터를 fetching 하여 응답 받기 전이기 때문에 return 에 로딩 로직만 띄워지기 때문에 자식 컴포넌트 자체가 렌더링 될 수 없다. return 로딩 로직이 렌더링되면, useEffect 가 시행되고 응답받은 data를 이용해

 return (
    <>
      <Child1 />
      {data.map((ele) => {
        return <div key={ele.data_id}>{ele.data_id}</div>
      })}
    </>

를 렌더링 할 수 있게 된다. 이후 Child1 컴포넌트도 Parent 컴포넌트와 마찬가지로 작동하게 된다.

그러면 여기서, fetching 을 상위 Parent 컴포넌트에서 한 번에 하여 응답받은 data를 props로 자식 컴포넌들에 전달해준다면 어떻게 될까?
코드는 다음과 같다.

const Parent = () => {
  const [data, setData] = useState<IPlacesListResult>()

  useEffect(() => {
    console.log(3)
    setTimeout(() => {
      fetchApi('불고기').then((res) => {
        setData(res.data)
        console.log(res.data)
      })
    }, 500)
  }, [])

  if (!data) {
    return <div>loading...data</div>
  }

  return (
    <>
      <Child1 data={data} />
      {data.map((ele) => {
        return <div key={ele.data_id}>{ele.data_id}</div>
      })}
    </>
  )
}

export default Parent

export const Child1 = ({ data }: any) => {
  useEffect(() => {
    console.log(2)
  }, [])

  if (!data) {
    return <div>loading...data2</div>
  }

  return (
    <>
      <Child2 />
      {data.map((ele) => {
        return <div key={ele.data_id}>{ele.data_id}</div>
      })}
    </>
  )
}

const Child2 = ({ data }: any) => {
  useEffect(() => {
    console.log(1)
  }, [])

  if (!data) {
    return <div>loading...data3</div>
  }

  return (
      {data.map((ele) => {
        return <div key={ele.data_id}>{ele.data_id}</div>
      })}
  )
}

결과는 예상대로였다.

사실 결과는 리액트 렌더링 과정에 대해 정확하게 알고 있으면 크게 이상할 것이 없었던 것 같다.
이번 기회로 리액트 렌더링에 대해 다시 정리할 수 있어 좋았다.

profile
개발하면서 행복하기

0개의 댓글