Frontend Engineer - 박준형

Published on

Suspense 컴포넌트와 동시성(Concurrency)

Authors
  • Name
    Twitter

동시성이란 하나 이상의 작업이 동시에 실행되는 것을 의미한다. 멀티쓰레딩, 멀트프로세싱, 비동기 작업 등등 다양한 방법으로 구현이 가능하다.

동시성이란 무엇인가?

컴퓨터 시스템은 여러가지 작업을 처리해야 하는데, 이때 동시성은 작업 처리 시간을 단축시키고, 시스템 자원을 효율적으로 활용할 수 있게 해준다. 예를 들어 CPU 코어를 사용하여 작업을 분산시키거나, 한번에 여러 개의 작업을 처리 할 수 있는 비동기 처리 등을 이용하여 시스템의 처리 속도를 높일 수 있다.

동시성은 하나 이상의 작업이 동시에 실행될 때 발생하는 다양한 문제점도 가지고 있다. 대표적으로 Race-condition, Deadlock, BottleNeck 등 이 있다. 따라서 동시성을 구현할 때에는 이러한 문제점을 고려하여 안전하고 효율적인 구현을 필요로한다.

경쟁상태(Race-condition)

여러개의 스레드 혹은 프로세스가 동시에 어떤 자원에 접근하려고 할 때, 그 자원을 어떤 순서로 접근하느냐에 따라 달라지는 결과의 상황을 말한다.

즉 먼저 실행되는 스레드나 프로세스가 자원에 접근하여 작업을 수행하기 전에 다른 스레드나 프로세스가 먼저 접근하여 작업을 완료해버리는 문제가 발생하는것을 경쟁상태라고한다.

교착상태(Deadlock)

둘 이상의 프로세스나 스레드가 서로 상대방이 가지고 있는 자원을 점유하고 있어서 그 자원을 요청하는 다른 프로세스나 스레드가 대기상태에 빠지고 무한히 실행을 진행하지 못하는 상태를 말한다.

병목현상(Bottleneck)

시스템에서 처리 속도가 가장 느린 부분이 전체 시스템의 처리 속도를 결정하게 되는 상황을 말한다. 즉 시스템의 모든 처리과정이 매우 빠르게 이루어지지만, 하나의 작업이나 자원이 느리게 처리되어 전체적인 처리 속도가 떨어지는 상황을 말한다. 이러한 문제는 자원의 부하를 줄이는 방식으로 해결 할 수 있다.

Suspense 컴포넌트

→ 비동기 작업처리와 코드 스플리팅, 서버사이드렌더링 등에서 동시성 문제를 해결하기 위해 사용된다.

Suspense 컴포넌트의 컨셉은 다음과 같다.

  • 자식 컴포넌트에서 비동기 작업이나 코드 스플리팅이 발생할 때, 대기 상태로 전환되어 로딩 화면을 보여준다.
  • 비동기 작업 혹은 코드 스플리팅이 완료 될 때 까지 기다리며 사용자에게 로딩 상태를 보여줄 수 있다.

경쟁상태(Race-condition), 교착상태(Deadlock), 병목현상(BottleNeck)는 동시성에서 발생할 수 있는 문제점이다. Suspense컴포넌트를 이러한 문제를 개선하기 위해 다음과 같은 방법을 제공한다.

경쟁상태(Race-condition)

→ 자식 컴포넌트에서 비동기 작업이나 코드 스플리팅이 완료될 때까지 다른 작업을 하지 않도록 대기 상태로 전환한다. 이를 통해 여러개의 작업이 동시에 실행되어 발생할 수 있는 경쟁상태를 방지 할 수 있다.

function ParentComponent() {
  const [data, setData] = useState(null)

  useEffect(() => {
    async function fetchData() {
      const response = await fetch('https://api.example.com/data')
      const data = await response.json()
      setData(data)
    }
    fetchData()
  }, [])

  return (
    <div>
      {data ? (
        <Suspense fallback={<div>Loading...</div>}>
          <ChildComponent data={data} />
        </Suspense>
      ) : (
        <div>Loading...</div>
      )}
    </div>
  )
}

위 코드는 ParentComponent에서 fetchData 함수를 사용하여 비동기 데이터를 가져오고 해당 데이터를 data 상태에 저장한다.

data 상태가 업데이트되면 ChildComponent 를 Suspense 컴포넌트로 래핑하여 렌더링한다. 여기서 Suspense 컴포넌트는 ChildComponent가 로딩될때 까지 대기 상태로 전환하여 다른 작업을 하지않도록 한다.

교착상태(Deadlock)

→ 자식 컴포넌트에서 비동기 작업이나 코드 스플리팅이 완료될 때 까지 기다리는 대기 상태를 제공한다. 이를통해 서로 다른 작업이 서로를 기다리는 교착 상태를 방지 할 수 있다.

function ParentComponent() {
  const [isVisible, setIsVisible] = useState(false)

  function handleClick() {
    setIsVisible(true)
  }

  return (
    <div>
      <button onClick={handleClick}>Show Child Component</button>
      {isVisible && (
        <Suspense fallback={<div>Loading...</div>}>
          <ChildComponent />
        </Suspense>
      )}
    </div>
  )
}

function ChildComponent() {
  const [data1, setData1] = useState(null)
  const [data2, setData2] = useState(null)

  useEffect(() => {
    async function fetchData1() {
      const response = await fetch('https://api.example.com/data1')
      const data = await response.json()
      setData1(data)
    }
    fetchData1()
  }, [])

  useEffect(() => {
    async function fetchData2() {
      const response = await fetch('https://api.example.com/data2')
      const data = await response.json()
      setData2(data)
    }
    fetchData2()
  }, [])

  return (
    <div>
      {data1 && data2 ? (
        <div>
          <p>Data 1: {data1}</p>
          <p>Data 2: {data2}</p>
        </div>
      ) : (
        <div>Loading...</div>
      )}
    </div>
  )
}

위의 코드는 ParentComponent에서 클릭 이벤트가 발생하면 isVisible 상태를 true로 변경하여 ChildComponent를 렌더링한다.

그리고 ChildComponent에서는 두개의 비동기 데이터를 data1, data2 상태에 저장한다.

이 예제에서는 useEffect 훅을 사용하여 각각의 데이터를 가져오기 때문에 데이터를 가져오는 작업이 동시에 실행된다. 그러나 Suspense 컴포넌트는 각각의 작업이 완료될 때까지 기다렸다가 자식 컴포넌트를 렌더링 하므로 교착 상태를 문제를 해결 할 수있다.

병목현상(Bottleneck)

→ 비동기 작업이나 코드 스플리팅이 완료 될때까지 대기하는 동안 로딩 화면을 보여준다. 이를통해 다른 작업들이 대기 상태에 빠지는 병목현상을 방지 할 수 있다.

function ParentComponent() {
  const [data, setData] = useState(null)

  useEffect(() => {
    async function fetchData() {
      const response = await fetch('https://api.example.com/data')
      const data = await response.json()
      setData(data)
    }
    fetchData()
  }, [])

  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <ChildComponent data={data} />
      </Suspense>
    </div>
  )
}

function ChildComponent({ data }) {
  const processedData = useMemo(() => {
    // 복잡한 데이터 처리 로직
    return data.map((item) => item * 2)
  }, [data])

  return (
    <div>
      {processedData.map((item) => (
        <p>{item}</p>
      ))}
    </div>
  )
}

위 코드에서는 ParentComponentuseEffect훅에서 비동기함수를 호출하여 데이터를 가져와 data 상태에 저장한다. 그리고 ChildComponent에서는 data를 가공하여 화면에 출력한다.

하지만 data가 큰 경우, ChildComponent에서 useMemo 훅을 사용하여 복잡한 데이터 처리 로직을 최적화 할 수 있다.

이렇게 병목현상을 해결하기 위해 자주 실행되는 함수나 연산이 있을 경우 useMemo 혹은 useCallback 훅을 사용하여 최적화 할 수 있다.

Suspense 컴포넌트는 비동기적으로 렌더링을 처리하여 UI가 끊기지 않도록 해주는 역할을 한다. 따라서 위의 예제에서는 Suspense 컴포넌트를 사용하면 병목현상을 해결하는 것은 아니지만, UI가 끊기지 않고 사용자 경험을 향상 시키는데 도움을 줄 수 있다.

병목현상을 개선하려면 다른 방법을 사용해야한다. 예를 들면 아래와 같다.

  • 데이터를 미리 가져와 캐싱하거나 데이터를 작은 단위로 분할하여 여러 요청을 병렬로 처리한다. (이는 swr 혹은 react-query 같은 서버상태라이브러리를 사용하면 쉽게 구현가능)
  • 더 빠른 데이터베이스나 캐싱 시스템을 사용한다.

그러면 어떻게 비동기 요청을 Suspense 컴포넌트가 알아챌까?

현재 렌더링 중인 트리에서 모든 Promise 기반의 비동기 작업의 상태를 추적한다. 이 작업들 중 하나라도 해결되지 않은 경우 fallback prop 에 제공한 컴포넌트를 렌더링한다. fallback prop 컴포넌트가 보여지는동안에는 다른 작업은 수행하지않는다.

이를 가능하게 하기 위해서는 자바스크립트의 Promise가 필요하다. Promise는 비동기 작업의 상태를 추적할 수 있는 then()과 catch() 메서드를 제공한다.

Suspense 컴포넌트는 이 메서드들을 사용하여 자식 컴포넌트에서 발생한 Promise 기반의 비동기 작업의 상태를 추적한다.

이 작업의 상태가 pending 일 경우 fallback을 보여주고 이 작업이 setteld 상태가 되면 결과를 렌더링한다.

Suspense 의사코드

export default function Suspense({ fallback, children }) {
  const [isPending, setIsPending] = useState(true)

  useEffect(() => {
    let isMounted = true

    // children 에서 비동기 작업 시작
    children().then(() => {
      if (isMounted) setIsPending(false)
    })

    return () => {
      isMounted = false
    }
  }, [children])

  // Promise가 아직 이행 상태가 아닐 경우 fallback 렌더링
  if (isPending) return fallback

  // Promise가 이행된 경우 children 렌더링
  return children()
}

리액트팀에서 서스펜스 컴포넌트를 공개한지 안한지는 못봤습니다. 하지만 서스펜스 컴포넌트의 컨셉에 맞게 한번 의사코드로 작성해 보았습니다.

isPending 상태를 사용하여 Promise의 상태를 추적하고 Promise가 이행되지 않은 경우 fallback 컴포넌트를 렌더링하고 이행된 경우 children 컴포넌트를 렌더링한다.