리액트의 동시성 렌더링

jiny·2024년 6월 23일
0

React

목록 보기
11/11
post-thumbnail

Intro

리액트 18부터 본격적으로 concurrent mode를 도입하게 되었다.

이 글에서는

  1. 리액트에서 언급하는 동시성이 무엇인지
  2. 기존에 어떤 문제가 있었는지
  3. 이 개념을 통해 어떤 문제를 해결하고자 했는지

정도 살펴볼 예정이다.

concurrency

Concurrency is not a feature, per se. It’s a new behind-the-scenes mechanism that enables React to prepare multiple versions of your UI at the same time. - react docs -

리액트 공식문서에서 동시성은 특정 기능을 의미하는 것이 아닌, React가 동시에 여러 버전의 UI를 준비할 수 있게 해주는 새로운 메커니즘이다.

간단한 예시

즉, 여러 작업들을 동시에 작업하는 것을 가능하게 만든다.

전화를 받으면서 동시에 글을 작성하는 경우를 예로 들어보자.

상대방이 말하는 경우엔 글을 작성하는 것이 우선 순위가 더 높을 것이다.

반면, 상대방의 질문에 대답하는 경우 질문에 답변하는 것이 우선 순위가 더 높을 것이다.

말하기 - 글쓰기 과정에서 우선 순위에 따라 작업을 수행하게 된다.

parallelism vs concurrency

비슷한 개념인 parallelism과 비교해 확인해보자.

parallelism은 2개 이상의 코어로 작업들을 처리하는데 반해, concurrency는 싱글 코어로 처리한다는 차이점이 있다.

즉, 실제로 동시에 실행하지 않지만 task - task 간 빠른 context switching을 통해 마치 동시에 실행되는 것처럼 보이는 것이 핵심이다.

즉, concurrency는 작업들을 아래와 같은 과정으로 처리한다.

  1. 여러 작업들을 작은 단위로 분리
  2. 그 작업에 대한 우선 순위 분류
  3. 작업을 번갈아 수행(context switching)

리액트는 어떻게 이 개념이 필요하게 되었을까?

Blocking Rendering

v18 이전까지만해도 컴포넌트들의 렌더링을 동기적으로 처리했었다.

이로 인해 만약 연산이 오래걸리거나 네트워크 지연이 발생하는 경우 그 시간만큼 대기해야하는 문제점이 존재했다.

예시를 통해 한번 살펴보자.

예시

이 gif의 경우 사용자 입력마다 10000개의 dom element를 생성하는 예제다.

dom element가 생성되는 순서는 아래와 같이 전개된다.

  1. 입력창에 값을 입력한다. (ex - 1)
  2. 그 값에 대한 텍스트가 바깥에 렌더링 된다.

잘 살펴보면 입력창의 경우 계속 누르고 있음에도 바깥에 있는 텍스트가 나오기 전까지 지연되는 것을 알 수 있다.

왜 지연 될까? 코드를 통해 살펴보자.

export function App(props) {
  const [input, setInput] = useState("");
  const [array, setArray] = useState([...Array(10000)]);

  const handleChange = (event) => {
    setInput(event.target.value);
    setArray(
      array.map((_, index) => ({ ... }))
    );
  };

  return (
    <div>
      <input
        placeholder="아무 값이나 입력해보세요!"
        value={input}
        onChange={handleChange}
      />
      {array.map(
        {/* ... */}
      )}
    </div>
  );
}

App 컴포넌트를 렌더링하려면 input과 array를 모두 사용해야 한다.

하지만, array를 재 생성하려면 다음과 같은 과정이 필요하다.

  1. 사용자가 input 내 값 입력(onChange)
  2. 이벤트 핸들러 실행이 끝나면 setInput(가벼운 작업)과 setArray(무거운 작업)가 동시에 실행 (batch 처리)

즉, 변경된 array들을 화면에 그리는 작업, input 내 value를 보여주는 작업이 동시에 진행되기 때문에 array들이 다 보여지기 전까지 input의 value도 볼 수 없게 된다.

debounce & throttle

이런 문제는 debounce와 throttle로 해결 할 수도 있지만 한계점이 존재한다.

모두 개발자가 timeout을 지정해줘야 한다.

만약 사용자의 기기 성능은 뛰어난데 timeout이 길게 설정되어 오래 기다리게 되거나,

반대로 오래된 기기를 사용하는데 너무 자주 이벤트가 실행되어 화면이 버벅대면, 사용자는 매우 불쾌한 경험을 하게 될 수 있다.

그래서 동시성이 뭘 해결하려고 하는데?

반면, 동시성 개념을 적용한다면 무거운 계산 작업 & 다른 작업을 동시에 수행할 수 있다.

이를 해결하고자 했던 hook들을 한번 살펴보자.

useTransition

UI를 차단하지 않고 상태를 업데이트할 수 있는 React Hook

이 hook의 핵심은 startTransition 함수다.

const handleChange = (event) => {
  setInput(event.target.value);
  startTransition(() => {
    setArray(
      array.map( // ... )
    );
  })
};

이전과 다르게 array를 변경하는 작업에 대해 startTransition으로 감싼 것을 알 수 있다.

startTransition의 역할은 특정 작업의 우선 순위를 낮춰 그 작업을 나중에 진행시킨다.

즉, 아래와 같이 변경된다.

  1. input 작업 진행
  2. App 리렌더링
  3. array 작업 진행
  4. App 리렌더링

이로 인해 다음과 같이 입력 창에서 사용자가 입력할 때마다 그 값이 실시간으로 잘 보여지는 것을 알 수 있다.

하지만 이전과 다르게 app이 2번 렌더링 되기 때문에 만약 그 컴포넌트 내 child 컴포넌트가 무거운 컴포넌트라면 별도의 메모이제이션(ex - memo, useCallback, useMemo) 작업이 필요할 것이다.

또한 useTransition에서 제공하는 isPending을 통해 다음과 같이 fallback을 제공할 수도 있다.

예시를 통해 살펴보자.

export function App(props) {
  // ...
  const [isPending, startTransition] = useTransition();
  // ...
  return (
    // ...
    <>
      {isPending ? (
        <div>pending...</div>
      ) : (
        array.map( ... )
      )}
    </>
  );
}

사용자가 값을 입력할 때마다 그에 대한 fallback ui(pending...)를 보여주는 것을 확인할 수 있다.

useDeferredValue

UI 일부 업데이트를 지연시킬 수 있는 React Hook

useTransition은 startTransition을 통해 상태를 update 하는 handler에 대한 우선 순위를 변경시킨다.

반면 useDeferredValue의 경우 ‘값’의 업데이트 우선순위를 낮춘다는 차이점이 있다.

It’s useful when the value comes “from above” and you don’t actually have control over the corresponding setState call. - Dan Abramov -

React의 핵심 팀원이었던 Dan Abramov는 이 hook에 대해 상태를 props로 받는 등 제어할 수 없을 때 사용하는 것을 권장하고 있다.

예시를 통해 살펴보자.

import React, { useState, useTransition, useDeferredValue } from 'react';

export function App(props) {
  const [input, setInput] = useState('');
  const [array, setArray] = useState([...Array(10000)]);

  const handleChange = (event) => {
    setInput(event.target.value);
    setArray(
      array.map((_, index) => ({
        text: texts[getRandomInt(0, 3)] + getRandomInt(0, 100),
        key: getRandomInt(0, 100),
      }))
    );
  };

  return (
    <div>
      <input placeholder="아무 값이나 입력해보세요!" value={input} onChange={handleChange} />
      <TextList array={array} />
    </div>
  );
}


function TextList({array}) {
  const texts = useDeferredValue(array)
  return (
    <>
      {texts.map(
        (item, index) =>
          item && (
            <div key={index} data-key={item.key}>
              <div>{item.text}</div>
            </div>
          )
      )}
    </>
  );
}

이전과 다른 차이점들은 아래와 같다.

  1. array를 통해 렌더링 시 TextList라는 별도의 컴포넌트로 분리
  2. 핸들러 내에서 startTransition을 이제 사용하지 않음

즉, TextList에서 무거운 연산을 수행하는 값인 array를 useDeferredValue로 감싸서 나온 값으로 렌더링 하는 것을 알 수 있다.

이전과 동일하게 나오는 것을 알 수 있다.

마무리

리액트 18에서 추가된 동시성 개념을 통해 기존에 해결하기 어려웠던 문제들을 해결할 수 있었다.

useTransition이나 useDeferredValue를 남용하기 보단, 문제를 해결하기 위한 별도의 option 정도로 생각하는게 좋을거 같다.

이를 위해

  1. 동시성 렌더링이 어떤 문제를 해결하고자 했는지
  2. 해당 hook에 대한 추가적인 use case들은 어떤 것들이 있는지

정도 더 고민해보면 좋을거 같다.

0개의 댓글