(번역) 블로그 답변: React 렌더링 동작에 대한 (거의) 완벽한 가이드

Chanhee Kim·2022년 11월 13일
387

FE 글 번역

목록 보기
8/22
post-thumbnail

원문: https://blog.isquaredsoftware.com/2020/05/blogged-answers-a-mostly-complete-guide-to-react-rendering-behavior/

이 글은 블로그 답변 시리즈의 글입니다.

리액트 렌더링의 동작 방식과 컨텍스트 및 React-Redux 사용이 렌더링에 미치는 영향에 대한 세부 사항

저는 리액트가 언제, 왜, 어떻게 컴포넌트를 리렌더링할 것인지, 그리고 컨텍스트와 React-Redux를 사용하는 것이 이러한 리렌더링의 타이밍과 범위에 어떤 영향을 미칠 것인지에 대해 많은 혼란이 지속되고 있는 것을 보았습니다. 이에 대한 다양한 설명들을 종합해 사람들이 참고할 수 있는 정리된 설명서를 만드는 것은 가치있어 보입니다. 이 모든 정보는 이미 온라인에서 제공되며, 다른 수많은 훌륭한 블로그 글과 기사에 설명되어 있습니다. 이 중 일부는 참고를 위해 "추가 정보" 섹션의 끝에 링크되어 있습니다. 하지만 사람들은 완벽한 이해를 위해 여러 조각들을 한데 모으려 고군분투 하고 있는 것 같으니, 이글이 누군가가 이를 명확히 하는데 도움이 되길 바랍니다.

참고: React 18 및 향후 React 업데이트를 포함하도록 2022년 10월 업데이트됨

저는 React Advanced 2022에서 이 글에 기반해 발표했습니다.

React Advanced 2022 - 리액트 렌더링 동작에 대한 (간단한) 가이드

목차

  • 렌더링이 뭘까요?
    • 렌더링 프로세스 개요
    • 렌더 및 커밋 단계
  • 리액트는 어떤 방식으로 렌더링할까요?
    • 렌더링 큐에 렌더링 등록하기
    • 일반적인 렌더링 동작
    • 리액트 렌더링 규칙
    • 컴포넌트 메타데이터와 파이버(Fibers)
    • 컴포넌트 타입(Component Types)과 재조정(Reconciliation)
    • Key와 재조정
    • 렌더링 일괄 처리(Render Batching)와 타이밍
    • 비동기 렌더링, 클로저 그리고 상태 스냅샷
    • 렌더링 동작 엣지 케이스
  • 렌더링 성능 개선
    • 컴포넌트 렌더링 최적화 기법
    • Props 참조가 렌더링 최적화에 미치는 영향
    • Props 참조 최적화
    • 전부 메모이제이션할까요?
    • 불변성(Immutability)과 리렌더링
    • 리액트 컴포넌트 렌더링 성능 측정
  • 컨텍스트(Context)와 렌더링 동작
    • 컨텍스트 기초
    • 컨텍스트 값 업데이트
    • 상태 업데이트, 컨텍스트 그리고 리렌더링
    • 컨텍스트 업데이트와 렌더링 최적화
    • 컨텍스트와 렌더러 경계(Renderer Boundaries)
  • React-Redux와 렌더링 동작
    • React-Redux 구독(Subscriptions)
    • connectuseSelector의 차이
  • 리액트의 향후 개선 사항
    • "React Forget" 메모이징 컴파일러
    • Context Selectors
  • 요약
  • 결론
  • 추가정보

렌더링이 뭘까요?

렌더링이란 현재 props 및 상태를 기반으로 리액트가 컴포넌트에게 UI 영역이 어떻게 보이길 원하는지 설명을 요청하는 프로세스입니다.

렌더링 프로세스 개요

렌더링 프로세스 동안 리액트는 컴포넌트 트리의 루트에서 시작해 업데이트가 필요하다고 표시된 모든 컴포넌트를 찾기 위해 아래로 순회합니다. 플래그가 지정된 각 컴포넌트에 대해 리액트는 함수 컴포넌트의 경우 FunctionComponent(props)를, 클래스 컴포넌트의 경우 classComponentInstance.render()를 호출하고 렌더 패스의 다음 단계를 위해 렌더 출력을 저장합니다.

컴포넌트 렌더 출력은 일반적으로 JSX 구문으로 작성되며 자바스크립트가 컴파일되고 배포를 위해 준비될때 React.createElement() 호출로 변환됩니다. createElement는 의도된 UI의 구조를 설명하는 일반 자바스크립트 객체인 React 요소를 반환합니다. 다음은 이에 대한 예시입니다.

// JSX 구문입니다.
return <MyComponent a={42} b="testing">Text here</MyComponent>

// 아래와 같은 호출로 변환됩니다.
return React.createElement(MyComponent, {a: 42, b: "testing"}, "Text Here")

// 그리고 이것은 다음과 같은 요소 객체가 됩니다.
{type: MyComponent, props: {a: 42, b: "testing"}, children: ["Text Here"]}

// 그리고 내부적으로는 리액트가 실제 함수를 호출해 렌더링 합니다.
let elements = MyComponent({...props, children})

// HTML 처럼 보이는 "호스트 컴포넌트"의 경우
return <button onClick={() => {}}>Click Me</button>
// 아래와 같이 호출되어
React.createElement("button", {onClick}, "Click Me")
// 최종적으로 아래와 같이 됩니다.
{type: "button", props: {onClick}, children: ["Click me"]}

컴포넌트 트리 전체에서 렌더 출력을 수집한 후 리액트는 새로운 객체 트리(흔히 "가상 DOM"이라고 함)와 비교해 실제 DOM을 현재 원하는 출력과 같이 보이게 하기 위해 적용해야 할 모든 변경 사항 목록을 수집합니다. 비교 및 계산 프로세스는 "재조정(reconciliation)"으로 알려져 있습니다.

그런 다음 리액트는 계산된 모든 변경 사항을 하나의 동기 시퀀스로 DOM에 적용합니다.

참고: 리액트팀은 최근 몇 년간 "가상 DOM"이라는 용어를 멀리했습니다. Dan Abramov는 다음과 같이 말했습니다.

저는 우리가 "가상 DOM"이라는 용어를 폐기할 수 있길 바랍니다. 2013년에는 지금과 달리 이렇게 이야기 하지 않으면 사람들은 리액트가 모든 렌더에서 DOM 노드를 생성한다고 생각했기 때문에 의미가 있었습니다. 그러나 오늘날 사람들은 이런 가정을 거의 하지 않습니다. "가상 DOM"은 일부 DOM 문제에 대한 해결 방법처럼 들리지만 리액트는 그런 것이 아닙니다.
리액트는 "값 UI"입니다. 그것의 핵심 원칙은 UI가 문자열이나 배열과 마찬가지로 값이라는 것입니다. 변수에 저장하고, 전달하고, 자바스크립트 제어 흐름에서 사용할 수 있습니다. 그 표현가능함이 요점입니다. DOM에 변경 사항을 적용하는 것을 피하기 위한 비교 같은 것이 아닙니다.
항상 DOM을 나타내는 것도 아닙니다. 예를 들어 <Message recipientId={10} />는 DOM이 아닙니다. 개념적으로 이는 지연 함수 호출 Message.bind(null, { recipientId: 10 })를 나타냅니다.

렌더 및 커밋 단계

리액트 팀은 이 작업을 개념적으로 다음과 같은 두 단계로 나눕니다.

  • "렌더 단계"에서는 컴포넌트 렌더링하고 변경 사항을 계산합니다.
  • "커밋 단계"에서는 렌더 단계에서 계산된 변경 사항을 DOM에 적용합니다.

리액트는 커밋 단계에서 DOM을 업데이트한 후 요청된 DOM 요소 및 컴포넌트 인스턴스를 가리키도록 모든 참조를 적절하게 업데이트 합니다. 그 다음 componentDidMountcomponentDidUpdate 클래스 라이프 사이클 메서드와 useLayoutEffect 훅을 동기적으로 실행합니다.

그런 다음 리액트는 짧은 시간 제한을 설정하고 이 시간이 만료되면 useEffect 훅을 실행합니다. 이 단계는 "패시브 이펙트(Passive Effects)" 단계라고도 합니다.

리액트 18은 useTransition과 같은 "동시 렌더링" 기능을 추가했습니다. 이를 통해 리액트는 브라우저가 이벤트를 처리할 수 있도록 렌더링 단계에서 작업을 일시 중지할 수 있습니다. 리액트는 나중에 적절하게 작업을 재개하거나 폐기하거나 다시 계산합니다. 렌더 패스가 완료되면 리액트는 마찬가지로 커밋 단계를 동기적으로 실행합니다.

여기서 이해해야 할 핵심은 "렌더링"은 "DOM 업데이트"와 같지 않으며 결과적으로 어떠한 가시적인 변경도 일어나지 않고 컴포넌트가 렌더링될 수 있다는 것입니다.

  • 컴포넌트가 지난번과 동일한 렌더 출력을 반환해 변경이 필요하지 않을 수 있습니다.
  • 동시 렌더링에서 리액트는 컴포넌트를 여러 번 렌더링할 수 있지만 다른 업데이트로 인해 현재 수행 중인 작업이 무효화되는 경우 렌더 출력을 버립니다.

이를 시각화하는데 도움이 되는 다음 리액트 훅 순서도(출처: Donovan West)를 참고하세요.

추가적인 시각 자료는 다음을 참고하세요.

리액트는 어떤 방식으로 렌더링할까요?

렌더링 큐에 렌더링 등록하기

첫 렌더링이 완료된 후 리액트에게 리렌더링을 큐에 등록하도록 지시하는 몇 가지 다른 방법이 있습니다.

  • 함수 컴포넌트
    • useState setter
    • useReducer dispatch
  • 클래스 컴포넌트
    • this.setState()
    • this.forceUpdate()
  • 기타
    • ReactDOM의 최상위인 render(<App>) 메서드를 다시 호출(루트 컴포넌트에서 forceUpdate를 호출하는 것과 동일)
    • 새로 추가된 useSyncExternalStore 훅에서 트리거된 업데이트

함수 컴포넌트에는 forceUpdate 메서드가 없지만 항상 카운터를 증가시키는 useReducer 훅을 사용해 동일하게 동작할 수 있습니다.

const [, forceRender] = useReducer((c) => c + 1, 0);

일반적인 렌더링 동작

우리가 기억해야 할 아주 중요한 사항이 있습니다.

리액트의 기본 동작은 상위 컴포넌트가 렌더링될 때 리액트가 해당 컴포넌트 내부의 모든 하위 컴포넌트를 순환하며 렌더링한다는 것입니다!

예를 들어 A > B > C > D 인 컴포넌트 트리가 있고 이미 페이지에 표시되어 있다고 가정해 보겠습니다. 사용자는 B에서 카운터를 증가시키는 버튼을 클릭합니다.

  • B에서 setState()를 호출해 B의 리렌더링을 렌더링 큐에 넣습니다.
  • 리액트는 트리 최상단에서 렌더 패스를 시작합니다.
  • 리액트는 A가 업데이트가 필요하다고 표시되어 있지 않음을 확인하고 이를 지나갑니다.
  • 리액트는 B가 업데이트가 필요하다고 표시되어 있음을 확인하고 렌더링합니다. B는 지난번과 같이 <C />를 반환합니다.
  • C는 업데이트가 필요하다고 표시되지 않았지만 부모 B가 렌더링 되었기 때문에 리액트는 하위 컴포넌트인 C도 렌더링합니다. C<D />를 다시 반환 합니다.
  • D도 렌더링 대상으로 표시되진 않았지만 부모 C가 렌더링되었기 때문에 리액트는 하위 컴포넌트인 D도 렌더링합니다.

즉, 컴포넌트를 렌더링하면 기본적으로 모든 하위 컴포넌트가 렌더링 됩니다!
또한 일반적인 렌더링에서 리액트는 "props가 변경되었는지" 여부를 신경쓰지 않습니다. 부모가 렌더링되면 무조건 하위 컴포넌트를 렌더링합니다!

이는 루트 <App> 컴포넌트에서 setState()를 호출하면 동작을 변경하는 다른 변경 사항 없이 리액트가 컴포넌트 트리의 모든 컴포넌트들을 리렌더링 하게됨을 의미합니다. 애초에 리액트의 세일즈 포인트 중 하나는 "업데이트할 떄마다 전체 앱을 다시 그리는 것처럼 동작합니다"였습니다.

트리에서 대부분의 컴포넌트가 직전과 정확히 동일한 렌더 출력을 반환할 가능성이 매우 높으므로 리액트는 DOM을 변경할 필요가 없습니다. 그러나 리액트는 여전히 컴포넌트에 자체 렌더링을 요청하고 렌더 출력을 비교하는 작업을 수행해야 합니다. 둘 다 시간과 노력이 필요합니다.

기억해 둘 것은 렌더링은 나쁜 것이 아니라는 겁니다. 이는 리액트가 실제로 DOM을 변경해야 하는지 여부를 아는 방법일 뿐입니다!

리액트 렌더링 규칙

리액트 렌더링의 기본 규칙 중 하나는 렌더링이 "순수"해야 하며 어떠한 사이드 이펙트도 없어야 한다는 것입니다!

이는 어렵고 혼란스러울 수 있는데, 왜냐하면 많은 사이드 이펙트가 명확하지 않고, 결과적으로 어떤 것도 망가뜨리지 않기 때문입니다. 예를 들어 console.log()는 엄밀히 말해 사이드 이펙트 이지만 실제로는 아무것도 망가뜨리지 않습니다. prop을 변경하는 것은 명백히 사이드 이펙트고, 이는 아무것도 망가뜨리지 않을 수 있습니다. 렌더링 도중 AJAX 호출을 하는 것도 분명 사이드 이펙트이며, 요청 유형에 따라 예기치 못한 앱 동작이 발생할 수 있습니다.

Sebastian Markbage는 The Rules of React라는 제목의 훌륭한 글을 썼습니다. 글에서는 render를 포함한 리액트의 다양한 라이프 사이클 메서드에 대한 요구 동작과 어떤 동작이 안전하게 "순수"한지, 안전하지 않은지 정의합니다. 이 글은 전체적으로 읽을 가치가 있지만, 여기서는 핵심만 요약하겠습니다.

렌더 로직은 다음을 수행해선 안됩니다.

  • 기존 변수 및 객체를 변경할 수 없습니다.
  • Math.random() 또는 Date.now()와 같은 임의의 값을 생성할 수 없습니다.
  • 네트워크 요청을 할 수 없습니다.
  • 상태 업데이트를 큐에 추가할 수 없습니다.

렌더 로직은 다음을 수행할 수 있습니다.

  • 렌더링 도중 새로 생성된 객체 변경
  • 오류 발생
  • 캐시된 값과 같이 아직 생성되지 않은 데이터에 대한 "지연 초기화"

컴포넌트 메타데이터와 파이버(Fibers)

리액트는 애플리케이션에 현재 존재하는 모든 컴포넌트 인스턴스를 추적하는 내부 데이터 구조를 저장합니다. 이 데이터 구조의 핵심 부분은 "파이버(Fiber)"라고 불리는 객체로 다음과 같은 메타 데이터 필드를 포함합니다.

  • 컴포넌트 트리의 해당 지점에서 렌더링되어야 할 컴포넌트 타입
  • 해당 컴포넌트와 관련된 prop, 상태
  • 상위, 형제 및 하위 컴포넌트에 대한 포인터
  • 리액트가 렌더링 프로세스를 추적하는 데 사용하는 기타 내부 메타데이터

리액트 버전이나 기능을 설명하는데 "리액트 파이버"라는 문구가 사용된 것을 들어본 적이 있다면, 이는 주요 데이터 구조가 "파이버" 객체에 의존하도록 리액트 내부 렌더링 로직을 변경하도록 재작성했다는 것을 의미했을 것입니다. 리액트 파이버는 리액트 16에서 출시되어 이후 모든 버전에서 이를 사용하고 있습니다.

Fiber 타입을 간단히 나타내면 다음과 같습니다.

export type Fiber = {
  // 파이버 타입을 식별하기 위한 태그입니다.
  tag: WorkTag;

  // 해당 요소의 고유 식별자 입니다.
  key: null | string;

  // 파이버와 관련된 것으로 확인된 함수/클래스 입니다.
  type: any;

  // 단일 연결 리스트 트리 구조입니다.
  child: Fiber | null;
  sibling: Fiber | null;
  index: number;

  // 파이버로 입력되는 데이터 입니다. (arguments/props)
  pendingProps: any;
  memoizedProps: any; // 출력을 만드는데 사용되는 props입니다.

  // 상태 업데이트 및 콜백 큐 입니다.
  updateQueue: Array<State | StateUpdaters>;

  // 출력을 만드는데 사용되는 상태입니다.
  memoizedState: any;

  // 파이버에 대한 종속성(컨텍스트, 이벤트)입니다.(존재하는 경우)
  dependencies: Dependencies | null;
};

(여기에서 리액트 18의 Fiber 타입에 대한 전체 정의를 볼 수 있습니다.)

렌더링 패스 동안 리액트는 이 파이버 객체 트리를 순회하고 새 렌더링 결과를 계산할 때 업데이트된 트리를 구성합니다.

명심할 점은 이 "파이버" 객체는 실제 컴포넌트 props와 상태 값을 저장한다는 것입니다. 컴포넌트에서 propsstate를 사용할 때 리액트는 사실 파이버 객체에 저장된 값에 대한 접근을 제공하는 것입니다. 실제로 클래스 컴포넌트의 경우 리액트는 렌더링 직전 componentInstance.props = newProps를 통해 명시적으로 이를 컴포넌트에 복사합니다. 따라서 this.props는 존재하지만 리액트가 내부 데이터 구조에서 참조를 복사했기 때문에 존재합니다. 그런 의미에서 컴포넌트는 리액트 파이버 객체에 대한 일종의 외관입니다.

마찬가지로 리액트가 컴포넌트의 모든 훅을 해당 컴포넌트의 파이버 객체에 연결 리스트로 저장하기 때문에 리액트 훅이 동작합니다. 리액트가 함수 컴포넌트를 렌더링할 때 파이버에서 hook description entry를 담은 연결 리스트를 가져오고, 훅을 또 호출할 때마다 hook description 객체에 저장된 적절한 값(예를 들어 useReducerstatedispatch 값 등)을 반환합니다.

상위 컴포넌트가 주어진 하위 컴포넌트를 처음으로 렌더링할 때 리액트는 컴포넌트의 "인스턴스"를 추적하기 위해 파이버 객체를 만듭니다. 클래스 컴포넌트의 경우 문자 그대로 const instance = new YourComponentType(props)를 호출해 실제 컴포넌트 인스턴스를 파이버 객체에 저장합니다. 함수 컴포넌트의 경우 리액트는 YourComponentType(props)를 함수로 호출합니다.

컴포넌트 타입(Component Types)과 재조정(Reconciliation)

"재조정" 문서 페이지에 설명된 대로 리액트는 기존 컴포넌트 트리와 DOM 구조를 가능한 한 많이 재활용하여 효율적으로 리렌더링 하려고 노력합니다. 트리의 동일한 위치에 동일한 타입의 컴포넌트 또는 HTML 노드를 렌더링하도록 리액트에 요청하면 리액트는 처음부터 다시 만드는 대신 필요에 따라 업데이트를 적용합니다. 즉, 같은 위치에 해당 컴포넌트 타입을 렌더링 하도록 리액트에 계속 요청하는 동안 리액트는 컴포넌트 인스턴스를 활성 상태로 유지합니다. 클래스 컴포넌트의 경우 실제로 컴포넌트의 동일한 실제 인스턴스를 사용합니다. 함수 컴포넌트에는 클래스와 같은 진짜 "인스턴스"가 없지만 <MyFunctionComponent />가 "인스턴스"를 나타내는 것이라고 생각할 수 있습니다.

그렇다면 리액트는 출력이 실제로 변경된 시기와 방법을 어떻게 알 수 있을까요?

리액트 렌더링 로직은 먼저 === 참조 비교를 사용해 요소의 type필드를 기준으로 비교합니다. 지정된 지점의 요소가 <div>에서 <span> 또는 <ComponentA>에서 <ComponentB>로 변경되는 것과 같이 다른 타입으로 변경된 경우 리액트는 전체 트리가 변경되었다고 가정해 비교 프로세스의 속도를 높입니다. 결과적으로 리액트는 모든 DOM 노드를 포함해 기존 컴포넌트 트리 부분 전체를 삭제하고 새 컴포넌트 요소 인스턴스로 처음부터 다시 만듭니다.

즉, 렌더링하는 동안 새 컴포넌트 타입을 생성해서는 안됩니다. 새 컴포넌트 타입을 생성할 때마다 이 타입은 다른 참조가 되며, 이로 인해 리액트가 하위 컴포넌트 트리를 반복적으로 삭제 및 재생성 합니다.

다음과 같이 하지 마세요.

// ❌ BAD!
// 이는 매번 새로운 `ChildComponent` 참조를 생성합니다!
function ParentComponent() {
  function ChildComponent() {
    return <div>Hi</div>;
  }

  return <ChildComponent />;
}

대신 항상 컴포넌트를 별도로 정의하세요.

// ✅ GOOD
// 이는 하나의 컴포넌트 타입 참조만 생성합니다.
function ChildComponent() {
  return <div>Hi</div>;
}

function ParentComponent() {
  return <ChildComponent />;
}

Key와 재조정

리액트가 컴포넌트 인스턴스를 식별하는 또 다른 방법은 key pseudo-prop을 사용하는 것입니다. 리액트는 key를 컴포넌트 타입의 특정 인스턴스를 구별하는데 사용할 수 있는 고유 식별자로 사용합니다.

key실제 prop이 아니라 리액트에 대한 지침임을 명심해야합니다. 리액트는 항상 이를 제거해 실제 컴포넌트에 전달되지 않으므로 props.key를 가질 수 없습니다. 이는 항상 undefined가 됩니다.

key는 주로 리스트를 렌더링할 때 사용됩니다. key는 리스트 아이템의 순서 변경, 추가 또는 삭제와 같은 방법으로 변경될 수 있는 데이터를 렌더링하는 경우 특히 중요합니다. 여기서 특히 중요한 것은 key가 가능한 한 데이터의 고유한 ID가 되어야 한다는 것입니다. 배열 인덱스만 key로 사용하는 것은 최후의 수단으로만 사용하세요!

// ✅ 데이터 객체의 ID를 리스트 아이템의 key로 사용
todos.map((todo) => <TodoListItem key={todo.id} todo={todo} />);

이것이 왜 중요한지에 대한 예입니다. 배열 인덱스를 key로 사용해 10개의 <TodoListItem> 컴포넌트 리스트를 렌더링한다고 가정해 보겠습니다. 리액트는 key가 0..9인 10개의 아이템을 마주합니다. 이제 아이템 6과 7을 삭제하고 끝에 세 개의 새 아이템을 추가하면 0..10의 key가 있는 아이템을 렌더링하게 됩니다. 따라서 리액트에게는 10개의 리스트 아이템에서 11개의 리스트 아이템으로 변경되었기 때문에 실제로 하나의 새 아이템을 추가한 것처럼 보입니다. 리액트는 기존 DOM 노드와 컴포넌트 인스턴스를 기꺼이 재사용 할 것입니다. 그러나 이는 리스트 아이템 #8에 전달된 할 일 아이템으로 <TodoListItem key={6}>를 렌더링하고 있음을 의미합니다. 즉, 컴포넌트 인스턴스는 여전히 유지되지만 이제 이전과 다른 데이터 객체를 prop으로 가져옵니다. 이 경우 제대로 동작할 수도 있지만 예기치 않은 동작을 초래할 수도 있습니다. 또한 기존 리스트 아이템이 이전과 다른 데이터를 표시해야 하기 때문에 리액트는 텍스트 및 기타 DOM 컨텐츠를 변경하기 위해 여러 리스트 아이템에 업데이트를 적용해야 합니다. 리스트 아이템이 변경되지 않았으므로 이러한 업데이트는 불필요합니다.

대신 각 리스트 아이템에 대해 key={todo.id}를 사용하는 경우 리액트는 두 아이템을 삭제하고 세 아이템을 추가한 것으로 올바르게 인식합니다. 그럼 삭제된 두 컴포넌트 인스턴스와 관련된 DOM이 삭제되고 세개의 새 컴포넌트 인스턴스와 이에 해당하는 DOM이 생성됩니다. 이는 실제로 변경되지 않은 컴포넌트를 불필요하게 업데이트하는 것 보다 낫습니다.

key는 리스트 외에도 컴포넌트 인스턴스 ID로도 유용합니다. 언제든지 리액트 컴포넌트에 key를 추가해 ID를 나타낼 수 있으며, 해당 key를 변경하면 리액트가 이전 컴포넌트 인스턴스와 DOM을 파괴하고 새 컴포넌트를 생성하게 됩니다. 일반적인 사용 사례는 리스트와 현재 선택한 리스트 아이템의 데이터를 표시하는 세부 정보 폼이 조합되는 경우입니다. <DetailForm key={selectedItem.id}>을 렌더링하면 선택한 항목이 변경될 때 리액트가 폼을 파괴하고 다시 생성하므로 폼 내에서 오래된 상태로 인한 문제가 발생하지 않습니다.

렌더링 일괄 처리(Render Batching)와 타이밍

기본적으로 각 setState() 호출은 리액트가 새 렌더 패스를 시작하고 이를 동기적으로 실행하고 반환하도록 합니다. 그러나 리액트는 렌더링을 일괄 처리하는 형태로 일종의 최적화를 자동으로 적용하기도 합니다. 렌더링 일괄 처리는 여러 setState() 호출 결과가 단일 렌더 패스의 렌더링 큐에 전달되어 실행되는 경우를 말하며 일반적으로 약간의 지연이 발생합니다.

리액트 커뮤니티는 이를 "상태 업데이트가 비동기적일 수 있다"라고 설명합니다. 또한 새로운 리액트 문서에서는 이를 "상태는 스냅샷"이라고 설명합니다. 이것이 렌더링 일괄 처리 동작에 대한 레퍼런스입니다.

리액트 17 및 이전 버전에서 리액트는 onClick 콜백과 같은 리액트 이벤트 핸들러에서만 일괄 처리를 수행했습니다. setTimeout, await 이후 또는 일반 JS 이벤트 핸들러와 같은 리액트 이벤트 핸들러 외에서의 업데이트는 큐에 추가되지 않았으며 각각 별도의 리렌더링이 발생했습니다.

그러나 리액트 18에서는 이제 단일 이벤트 루프 틱에 대기중인 모든 업데이트의 "자동 일괄 처리"를 수행합니다. 따라서 필요한 전체 렌더링 수를 줄일 수 있습니다.

구체적인 예를 살펴 보겠습니다.

const [counter, setCounter] = useState(0);

const onClick = async () => {
  setCounter(0);
  setCounter(1);

  const data = await fetchSomeData();

  setCounter(2);
  setCounter(3);
};

리액트 17을 사용하면 3번의 렌더 패스를 실행하게 됩니다. 첫 번째 패스는 setCounter(0)setCounter(1)을 함께 일괄 처리하는데, 둘 다 원래의 이벤트 핸들러 호출 스택 중에 발생하기 때문이며, 따라서 둘 다 unstable_batchedUpdates() 호출에서 업데이트가 발생하게 됩니다.

그러나 setCounter(2) 호출의 경우 await이후 일어나게 됩니다. 즉, 원래의 동기적 호출 스택이 완료되었고, 함수의 후반부가 완전히 분리된 이벤트 루프 호출 스택에서 더 나중에 실행된다는 것입니다. 따라서 리액트는 setCounter(2) 호출 내부의 마지막 단계로 전체 렌더 패스를 동기적으로 실행하고 setCounter(2)의 실행을 종료합니다.

기존의 이벤트 핸들러 호출 스택 외에서 실행되고 따라서 배치 처리 대상에 포함되지 않기 때문에 setCounter(3)에 대해서도 동일한 일이 발생합니다.

하지만 리액트 18을 사용하면 렌더 패스가 두 번 실행됩니다. 처음 두 setCounter(0)setCounter(1)는 하나의 이벤트 루프 틱에 있으므로 함께 일괄 처리됩니다. 이후 await 다음의 setCounter(2)setCounter(3)가 모두 일괄 처리됩니다. 더 늦더라도 동일한 이벤트 루프에서 큐에 추가된 두 상태 업데이트이므로 두 번째 렌더링으로 일괄 처리됩니다.

비동기 렌더링, 클로저 그리고 상태 스냅샷

매우 흔한 실수 중 하나는 사용자가 새로운 값을 설정한 다음 기존 변수명을 사용해 기록하려고 할 때입니다. 그러나 의도와 달리 업데이트된 값이 아니라 원래 값이 기록됩니다.

function MyComponent() {
  const [counter, setCounter] = useState(0);

  const handleClick = () => {
      setCounter(counter + 1);
      // ❌ 이것은 의도한대로 동작하지 않습니다.
      console.log(counter);
      // 원래 값이 기록됩니다. - 왜 아직 업데이트되지 않았을까요??????
    };
}

그렇다면, 왜 의도한대로 동작하지 않을까요?

위에서 언급한 바와 같이, 경험있는 사용자는 "리액트 상태 업데이트는 비동기입니다"라고 말하는 것이 일반적입니다. 이는 일종의 사실이지만 그보다 조금 더 많은 뉘앙스가 있으며 실제로 여기서의 동작에는 몇 가지 다른 문제들이 있습니다.

엄밀히 말하면 리액트의 렌더링은 말 그대로 동기식입니다. 이는 이벤트 루프 틱의 맨 마지막에 있는 "마이크로 태스크"에서 실행됩니다.(매우 현학적이지만 이 글의 목표는 정확한 세부 사항과 명확함을 제공하는 것입니다.) 다만 handleClick 함수의 관점에서는 결과를 바로 확인할 수 없고, 실제 업데이트는 setCounter() 호출보다 더 늦게 발생한다는 점에서 "비동기"가 맞습니다.

하지만 이것이 작동하지 않는 더 큰 이유가 있습니다. handleClick 함수는 "클로저"입니다. 이 함수는 오직 함수가 정의되었을 때 존재했던 변수의 값만 알 수 있습니다. 즉, 이러한 상태 변수는 특정 시점의 스냅샷입니다.

handleClick은 이 함수 컴포넌트의 가장 최근 렌더링 중에 정의되었으므로 오직 해당 렌더 패스 동안 존재했던 counter의 값만 알 수 있습니다. setCounter()를 호출하면 미래의 렌더 패스가 큐에 추가되며 미래의 렌더링에서는 새로운 값을 갖는 새로운 counter 변수와 새로운 handleClick 함수를 갖게 됩니다. 그러나 handleClick은 절대 새로운 값을 알 수 없습니다.

새로운 리액트 문서는 State as a snapshot 섹션에서 이에 대해 자세히 설명합니다. 이 섹션을 읽어보시는 것을 추천합니다.

원래의 예시로 돌아가서 업데이트된 값을 설정한 후 바로 변수를 사용하려는 것은 거의 항상 잘못된 접근 방식이며, 이 값을 사용하는 방법을 재고해야함을 시사합니다.

렌더링 동작 엣지 케이스

커밋 단계 생명 주기

커밋 단계의 생명 주기 메서드인 componentDidMount, componentDidUpdate 그리고 useLayoutEffect에는 몇가지 추가적인 엣지 케이스가 있습니다. 이 메서드들은 주로 렌더링 후 브라우저가 페인트할 기회를 갖기 전 추가적인 로직을 수행할 수 있도록 하기 위해 존재합니다. 일반적인 사용 사례는 다음과 같습니다.

  • 부분적이지만 불완전한 데이터가 있는 컴포넌트를 처음 렌더링 합니다.
  • 커밋 단계 생명 주기에서 ref를 사용해 페이지에 있는 실제 DOM 노드의 실제 크기를 측정합니다.
  • 측정을 기반으로 컴포넌트의 일부 상태를 설정합니다.
  • 업데이트된 데이터로 즉시 리렌더링합니다.

이 사용 사례에서는 초기 "부분" 렌더링된 UI를 사용자에게 전혀 표시하지 않고 "최종" UI만 표시하길 원합니다. 브라우저는 변경되는 DOM 구조를 다시 계산하지만 JS 스크립트가 여전히 실행되고 이벤트 루프를 차단하는 동안 실제로 아무 것도 화면에 그리지 않습니다. 따라서 DOM 변경을 여러번 수행할 수 있고, div.innerHTML = "a"; div.innerHTML = b";와 같은 경우 "a"는 절대 표시되지 않습니다.

이 때문에 리액트는 항상 커밋 단계 생명 주기에서 렌더링을 동기적으로 실행합니다. 이렇게 하면 "부분->최종" 전환과 같은 업데이트를 수행할 때 오직 "최종" 내용만 화면에 표시됩니다.

제가 알기로 useEffect 콜백의 상태 업데이트는 큐에 저장되어 useEffect 콜백이 모두 완료되면 "Passive Effects" 단계의 마지막에 한번에 플러시됩니다.

조정자(Reconciler) 일괄 처리 메서드

리액트 조정자(ReactDOM, React Native)는 렌더링 일괄 처리를 변경하는 메서드를 가지고 있습니다.

리액트 17 및 이전 버전의 경우 이벤트 핸들러 외부의 여러 업데이트를 unstable_batchedUpdates()로 래핑해 배치 처리할 수 있습니다.(unstable_ 접두사에도 불구하고 Facebook 및 공개 라이브러리의 코드에서 많이 사용되고 의존한다는 점에 유의하세요. React-Redux v7은 내부적으로 unstable_batchedUpdates()를 사용합니다.)

리액트 18에서는 기본적으로 자동으로 일괄 처리 되므로, 리액트 18에서는 강제로 즉시 렌더링하고 자동 일괄 처리에서 제외하는 데 사용할 수 있는 flushSync() API가 있습니다.

조정자에 특정된 API이므로 react-three-fiberink와 같은 다른 조정자는 이를 노출시키지 않을 수 있습니다. API 선언 또는 구현정보를 확인하여 사용 가능한 항목을 확인하세요.

<StrictMode>

리액트는 개발 환경에서 <StrictMode> 내부의 컴포넌트를 이중 렌더링 합니다. 즉, 렌더링 로직이 실행되는 횟수는 커밋된 렌더 패스 수와 다르며, 발생한 렌더링 수를 세기위해 console.log()문에 의존할 수 없습니다. 대신 React DevTools Profiler를 사용해 추적을 캡처하고 커밋된 전체 렌더링 수를 세거나 useEffect 훅 또는 componentDidMount/Update 라이프 사이클 메서드 내부에 로깅을 추가할 수 있습니다. 그렇게 하면 리액트가 실제로 렌더 패스를 완료하고 커밋할 때만 로그를 출력할 수 있습니다.

렌더링 중 상태 설정

정상적인 상황에서는 실제 렌더링 로직을 수행하는 동안 상태 업데이트를 큐에 추가하면 안 됩니다. 즉, 클릭이 발생할 때 setSomeState()를 호출하는 클릭 콜백을 만드는 것은 괜찮지만 실제 렌더링 동작의 일부로 setSomeState()를 호출하면 안 됩니다.

그러나 여기에는 한 가지 예외가 있습니다. 함수 컴포넌트는 조건부로 실행되어 해당 컴포넌트가 렌더링 될 때마다 실행되지 않는다면 렌더링 중 setSomeState()를 직접 호출할 수 있습니다. 이는 클래스 컴포넌트의 getDerivedStateFromProps와 같이 함수 컴포넌트에서 동작합니다. 함수 컴포넌트가 렌더링되는 동안 상태 업데이트를 큐에 추가하면 리액트는 상태 업데이트를 즉시 적용하고 이어서 진행하기 전에 해당 컴포넌트를 동기적으로 리렌더링 합니다. 만약 컴포넌트가 상태 업데이트를 큐에 무한히 추가하고 이를 리액트가 리렌더링하도록 강제하면 리액트는 설정된 횟수의 재시도 후 루프를 중단하고 오류를 발생시킵니다(현재 50회 시도). 이 기법은 useEffect 내부의 setSomeState() 호출을 통한 리렌더링 없이 prop 변경에 따라 상태값을 즉시 강제로 업데이트하는 데 사용할 수 있습니다.

렌더링 성능 개선

렌더링은 리액트 동작 방식에서 일반적으로 요구되는 부분이지만 렌더링 작업이 때로는 "낭비"될 수 있다는 것도 사실입니다. 만약 컴포넌트의 렌더링 출력이 변경되지 않았고, DOM의 해당 부분을 업데이트할 필요가 없다면 해당 컴포넌트를 렌더링하는 작업은 정말 시간 낭비입니다.

리액트 컴포넌트의 렌더링 출력은 항상 현재 props 및 현재 컴포넌트 상태에 전적으로 기반해야 합니다. 따라서 컴포넌트의 props 및 상태가 변경되지 않았다는 것을 미리 알고 있다면 렌더링 출력이 동일하고 이 컴포넌트에 대한 변경이 필요하지 않으며 렌더링 작업을 안전하게 건너뛸 수 있다는 것도 알 수 있습니다.

일반적으로 소프트웨어 성능을 향상시키려 할 때, 두 가지 기본 접근 방식이 있습니다. 1) 동일한 작업을 더 빨리 수행하는 것, 그리고 2) 더 적게 수행하는 것입니다. 리액트 렌더링을 최적화 하는 것은 주로 컴포넌트 렌더링을 적절히 건너뛰어 작업량을 줄이는 것입니다.

컴포넌트 렌더링 최적화 기법

리액트는 컴포넌트 렌더링을 생략할 수 있는 세 가지 주요한 API를 제공합니다.

주된 메서드는 "higher order component" 형태로 내장된 React.memo()입니다. 사용자의 컴포넌트 타입을 인자로 전달받고 래핑된 새 컴포넌트를 반환합니다. 래퍼 컴포넌트의 기본 동작은 props가 변경되었는지 확인하고 변경되지 않은 경우 리렌더링되지 않도록 하는 것입니다. 함수 컴포넌트와 클래스 컴포넌트 둘 다 React.memo()를 사용해 래핑할 수 있습니다. (커스텀 비교 콜백을 전달할 수 있지만, 오직 이전 props와 새 props의 비교만 할 수 있으므로, 커스텀 비교 콜백은 주로 모든 props 대신 특정 prop만 비교하기 위해 사용됩니다.)

이외 다른 API는 다음과 같습니다.

  • React.Component.shouldComponentUpdate: 렌더링 프로세스 초기에 호출되는 선택적 클래스 컴포넌트 생명 주기 메서드 입니다.false를 반환하면 리액트는 컴포넌트 렌더링을 건너뜁니다. 렌더링 여부를 결정하는 boolean 결과값을 계산하는데 사용할 로직을 포함할 수 있고 가장 일반적인 방법은 컴포넌트의 props 및 상태가 이전과 달라졌는지 확인하고 변경되지 않았으면 false를 반환하는 것입니다.
  • React.PureComponent: 이러한 props 및 상태 비교는 shouldComponentUpdate를 구현하는 가장 일반적인 방법이기 때문에 PureComponent 기반 클래스는 기본적으로 해당 동작을 구현하며 Component + shouldComponentUpdate 대신 사용할 수 있습니다.

이 모든 접근 방식은 "얕은 비교"라고 불리는 비교 기법을 사용합니다. 이는 두 개의 서로 다른 객체의 모든 개별 필드를 확인하고 객체의 내용이 서로 다른 값인지 확인하는 것을 의미합니다. 달리 표현하면 obj1.a === obj2.a && obj1.b === obj2.b && .........입니다. === 비교 연산은 JS 엔진이 처리하기에 매우 간단하기 때문에 이는 일반적으로 빠른 프로세스입니다. 따라서 위 세 가지 접근 방식은 const shouldRender = !shallowEqual(newProps, prevProps)와 같습니다.

조금 덜 알려진 기법도 있습니다. 리액트 컴포넌트가 이전과 정확히 동일한 요소 참조를 반환하면 리액트는 해당하는 특정 하위 컴포넌트의 리렌더링을 건너뜁니다. 이 기법을 구현하는 두 가지 방법이 있습니다.

  • 출력에 props.children을 포함하는 경우 컴포넌트가 상태를 업데이트해도 해당 요소는 동일합니다.
  • useMemo()로 요소를 감싸면 종속성이 변경될 때까지 동일하게 유지됩니다.

예시

// `props.children`는 상태를 업데이트 하더라도 리렌더링되지 않습니다.
function SomeProvider({ children }) {
  const [counter, setCounter] = useState(0);

  return (
    <div>
      <button onClick={() => setCounter(counter + 1)}>Count: {counter}</button>
      <OtherChildComponent />
      {children}
    </div>
  );
}

function OptimizedParent() {
  const [counter1, setCounter1] = useState(0);
  const [counter2, setCounter2] = useState(0);

  const memoizedElement = useMemo(() => {
    // 카운터 2가 업데이트되면 이 요소는 동일한 참조를 유지합니다.
    // 따라서 카운터 1이 변경되지 않는 한 다시 렌더링되지 않습니다.
    return <ExpensiveChildComponent />;
  }, [counter1]);

  return (
    <div>
      <button onClick={() => setCounter1(counter1 + 1)}>
        Counter 1: {counter1}
      </button>
      <button onClick={() => setCounter1(counter2 + 1)}>
        Counter 2: {counter2}
      </button>
      {memoizedElement}
    </div>
  );
}

개념적으로 이 두 접근 방식의 차이는 다음과 같습니다.

  • React.memo(): 하위 컴포넌트에 의해 제어됨
  • 동일 요소 참조(Same-element references): 부모 컴포넌트에 의해 제어됨

이러한 모든 기법에서 컴포넌트 렌더링을 건너뛰는 것은 리액트가 해당 하위 트리 전체의 렌더링을 건너 뛰는 것을 의미합니다. 이는 리액트가 "재귀적으로 하위을 렌더링"하는 기본 동작을 중단하기 위해 정지 표시를 효과적으로 설정하기 때문입니다.

Props 참조가 렌더링 최적화에 미치는 영향

기본적으로 리액트는 props가 변경되지 않은 경우에도 중첩된 모든 컴포넌트를 리렌더링한다는 것을 앞서 확인했습니다. 또한 이는 하위 컴포넌트에 새로운 참조를 props로 전달하는 것이 중요하지 않다는 것을 의미하는데, 동일한 props를 전달하는지와 관계 없이 렌더링될 것이기 때문입니다. 그래서 다음과 같은 경우도 아무 문제 없습니다.

function ParentComponent() {
  const onClick = () => {
    console.log('Button clicked');
  };

  const data = { a: 1, b: 2 };

  return <NormalChildComponent onClick={onClick} data={data} />;
}

ParentComponent는 렌더링할 때마다 새 onClick 함수 참조와 새 data 객체 참조를 만들어 NormalChildComponent의 props로 전달합니다.(function 키워드를 사용해 onClick을 정의하든 화살표 함수로 정의하든 상관 없습니다. 어느 방법이든 새로운 함수 참조입니다.)

또한 이는 <div><button>과 같은 "호스트 컴포넌트(host components)"를 React.memo()로 래핑해 렌더링을 최적화하려는 것이 의미 없음을 뜻합니다. 이러한 기본 컴포넌트는 어차피 하위 컴포넌트가 없기 때문에 렌더링 프로세스는 여기서 중단됩니다.

그러나 하위 컴포넌트가 props가 변경되었는지 확인해 렌더링을 최적화하려고 하는 경우 새 참조를 props로 전달하면 하위 컴포넌트의 렌더링을 유발할 수 있습니다. 새 props 참조가 실제로 새 데이터인 경우는 괜찮습니다. 그러나 상위 컴포넌트가 콜백 함수를 전달하는 경우는 어떨까요?

const MemoizedChildComponent = React.memo(ChildComponent);

function ParentComponent() {
  const onClick = () => {
    console.log('Button clicked');
  };

  const data = { a: 1, b: 2 };

  return <MemoizedChildComponent onClick={onClick} data={data} />;
}

ParentComponent가 렌더링할 때마다 새 참조로 인해 MemoizedChildComponent가 props가 변경되었다고 보고 리렌더링하게됩니다. 비록 onClick 함수와 data 객체가 기본적으로 매번 같더라도 말입니다!

이는 다음을 의미합니다.

  • 대부분의 경우 렌더링을 건너뛰길 원했지만 MemoizedChildComponent는 항상 리렌더링됩니다.
  • 이전 props와 새 props를 비교하기 위해 하는 작업은 의미가 없어졌습니다.

마찬가지로 <MemoizedChild><OtherComponent /></MemoizedChild>를 렌더링하면 항상 하위 컴포넌트 또한 렌더링하게 됩니다. 왜냐하면 prop.children은 항상 새로운 참조이기 때문입니다.

Props 참조 최적화

클래스 컴포넌트는 항상 동일 참조인 인스턴스 메서드를 가질 수 있기 때문에 실수로 새 콜백 함수 참조를 만드는 것을 크게 걱정할 필요가 없습니다. 그러나 개별 하위 리스트 아이템에 대해 고유한 콜백을 생성하거나 익명 함수에서 값을 캡처해 하위 컴포넌트에 전달해야할 수 있습니다. 그러면 새 참조를 생성하고 렌더링중 하위 컴포넌트의 props로 새 객체를 생성하게 됩니다. 리액트에는 이러한 경우를 최적화하는데 도움이 되는 것을 제공하지 않습니다.

함수 컴포넌트의 경우 리액트는 동일한 참조를 재사용할 수 있도록 두개의 훅을 제공합니다. 즉, 객체를 만들거나 복잡한 계산을 수행하는 것과 같은 모든 종류의 일반적인 데이터를 위한 useMemo와 콜백 함수를 만드는데 사용할 수 있는 useCallback입니다.

전부 메모이제이션할까요?

앞서 언급한 바와 같이 props로 내려주는 모든 함수나 객체에 대해 useMemouseCallback을 사용하지 않아도 됩니다. 단지 그것이 하위 컴포넌트를 다르게 동작하게 만드는 경우에 한합니다.(즉, useEffect의 종속성 배열 비교는 하위 컴포넌트가 일관된 props 참조를 받을 필요가 있게 하는 또 다른 상황을 추가하므로 상황을 더욱 복잡하게 만듭니다.)

항상 등장하는 또 다른 질문은 "왜 리액트는 기본적으로 모든 컴포넌트를 React.memo()로 래핑하지 않나요?"입니다.

Dan Abramov는 메모이제이션 하더라도 props를 비교하는 비용이 발생하며, 컴포넌트는 항상 새로운 props를 받기 때문에 메모이제이션 체크로는 리렌더링을 막을 수 없는 경우가 많다고 거듭 지적했습니다. 예를 들어 다음 Dan의 트위터 스레드를 봅시다.

왜 리액트는 기본적으로 모든 컴포넌트를 memo()로 감싸지 않나요? 더 빠르지 않나요? 확인할 벤치마크를 만들어야 할까요?

스스로에게 물어보세요.

왜 모든 함수를 Lodash memoize()로 감싸지 않나요? 그러면 모든 함수가 더 빨라지지 않을까요? 이를 위한 벤치마크가 필요한가요? 왜 그러지 않죠?

또한 이에 대한 특정 링크는 없지만, 기본적으로 모든 컴포넌트에 이를 적용하려고 하면 불변성을 유지하며 업데이트 하지 않고 데이터를 변경하는 사람들의 경우 버그를 발생시킬 수 있습니다.

저는 Dan과 트위터에서 이에 대해 공개적으로 논의한 적이 있습니다. 개인적으로 React.memo()를 광범위하게 사용하는 것은 전반적인 앱의 렌더링 성능향상에 도움이 될 가능성이 크다고 생각합니다. 제가 작년에 트위터 스레드에 이어 말했듯이요.

리액트 커뮤니티 전체가 "성능"에 지나치게 집착하는 것 처럼 보이지만 논의의 대부분은 구체적인 사용법에 기반한 것이 아니라 Medium 게시글과 트위터 댓글을 통해 전해지는 낡은 "부족적 지혜"를 중심으로 이루어집니다.

거기에는 "렌더링"과 성능 영향에 대한 집단적인 오해가 분명히 존재합니다. 리액트가 전적으로 렌더링에 기반하고 있는 것은 맞습니다. 무엇이든 하기 위해선 렌더링을 해야합니다. 그러나 대부분의 렌더링은 그렇게 비용이 크지 않습니다.

"낭비된" 렌더링이 세상을 무너지게 하지 않습니다. 앱 전체를 루트부터 리렌더링하지도 않습니다. 그렇긴 해도 DOM 업데이트가 없는 "낭비된" 렌더링이 쓸데없이 CPU 사이클을 사용할 필요가 없다는 것은 사실입니다. 대부분의 앱에 이것이 문제가 될까요? 아마 그렇지 않을겁니다. 개선할 수 있는 부분일까요? 아마도요.

기본적으로 "모든 것을 리렌더링"하는 접근 방식으로 충분하지 않은 앱이 있을까요? 물론입니다. 그렇기 때문에 sCU, PureComponent 그리고 memo()가 존재하는 것입니다.

사용자는 기본적으로 모든 것을 memo()로 래핑해야 할까요? 앱의 성능 요구 사항을 고려해야 하기 때문에 그렇지 않을 수도 있습니다. 그렇게 하면 정말 문제가 될까요? 아니요, 그리고 현실적으로 유익할 것으로 기대합니다.(비교가 낭비라는 Dan의 지적에도 불구하고)

벤치마크에 결함이 있고 시나리오와 앱에 따라 결과가 크게 변하나요? 물론입니다. 그렇긴 하지만 사람들이 이러한 토론을 위해 "내가 이런 댓글을 본적 있는데..."와 같이 전화 게임을 하는 대신 어려운 것들에 대해 주목하기 시작한다면 정말 많은 도움이 될 것입니다.

저는 리액트 팀과 큰 커뮤니티의 벤치마크를 많이 보고 싶습니다. 여러 시나리오를 측정하여 이 문제에 대한 논쟁을 완전히 끝낼 수 있길 바랍니다. 함수 생성, 비용 최적화...구체적인 증거를 제시해 주세요!

하지만 이것이 사실인지 아닌지를 입증할 수 있는 좋은 벤치마크를 만든 사람은 아무도 없습니다.

Dan은 보통 앱 구조와 업데이트 패턴이 매우 다양하기 때문에 이를 대표하는 벤치마크를 만들기가 어렵다고 답합니다.

저는 실제 수치 몇개는 토론을 돕는데 유용할 것이라고 생각합니다.

또한 리액트에 대한 쟁점 중 "React.memo를 언제 사용하면 안될까요?"에 대한 논의도 있습니다.

새로운 리액트 문서는 "전부 메모해야하나요?"라는 질문에 대해 다루고 있습니다.

memo로 최적화하는 것은 컴포넌트가 동일한 props으로 리렌더링 하는 경우가 많고 리렌더링 로직의 비용이 큰 경우에만 가치가 있습니다. 컴포넌트가 리렌더링될 때 지각할 수 있는 지연이 없다면 memo가 필요하지 않습니다. 렌더링중 정의된 객체나 일반 함수를 전달하는 경우와 같이 컴포넌트에 전달하는 props가 항상 다른 경우 memo는 전혀 소용이 없습니다. 따라서 useMemouseCallbackmemo와 함께 사용해야하는 경우가 많습니다.

이외의 경우는 컴포넌트를 memo로 래핑해도 아무런 이점이 없습니다. 하지만 그렇게 해도 크게 문제는 되지 않기 때문에 어떤 팀들은 개별적으로 생각하지 않고 가능한 한 많이 메모이제이션하는 것을 선택하기도 합니다. 이 접근 방식의 단점은 코드를 읽기 어려워진다는 것입니다. 또한 모든 메모이제이션이 효과적이지도 않습니다. "항상 새로운" 단일 값은 전체 컴포넌트에 대한 메모이제이션을 무너뜨리기에 충분합니다.

불필요한 메모이제이션 방지 및 성능 향상에 대한 자세한 내용은 다음 섹션에서 살펴보겠습니다.

불변성(Immutability)과 리렌더링

리액트의 상태 업데이트는 항상 불변하게 수행되어야 합니다. 그 이유는 크게 두가지가 있습니다.

  • 변경한 내용과 위치에 따라 렌더링할 것으로 예상한 컴포넌트가 렌더링되지 않을 수 있습니다.
  • 데이터가 실제로 업데이트된 시기와 원인에 대해 혼란을 일으킵니다.

몇 가지 구체적인 예를 살펴보겠습니다.

지금까지 살펴본 바와 같이 React.memo / PureComponent / shouldComponentUpdate는 모두 현재 props와 이전 props의 얕은 비교에 의존합니다. 따라서 props가 새로운 값인지 아닌지는 props.someValue !== prevProps.someValue를 통해 알 것으로 예상할 수 있습니다.

만약 변경한 someValue가 동일한 참조를 갖는다면 컴포넌트는 아무것도 변경되지 않았다고 가정할 것입니다.

이는 특히 불필요한 리렌더링을 방지해 성능을 최적화 하려는 경우 중요합니다. 렌더링은 props가 변경되지 않은 경우 "불필요"하거나 "낭비"됩니다. 변경(mutate)을 하면 컴포넌트가 아무것도 변경되지 않았다고 착각할 수 있고, 컴포넌트가 왜 리렌더링되지 않았는지 모르게될 수 있습니다.

다른 문제는 useStateuseReducer 훅입니다. setCounter()dispatch()를 호출할 때 마다 리액트는 리렌더링을 큐에 추가합니다. 그러나 리액트에서는 모든 훅의 상태 업데이트가 새 객체/배열 참조 또는 새 원시값(string, number 등)인지 여부에 관계 없이 새 참조로 새 상태 값으로 전달/반환 되어야 합니다.

리액트는 렌더 단계에서 모든 상태 업데이트를 적용합니다. 리액트가 훅에서 상태 업데이트를 적용할 때 새 값이 동일한 참조인지 확인합니다. 리액트는 항상 업데이트를 큐에 넣은 컴포넌트 렌더링을 완료합니다. 그러나 이전과 동일한 참조이고 렌더링을 계속할 다른 이유(예: 상위 컴포넌트가 렌더링한 경우)가 없는 경우 리액트는 컴포넌트에 대한 렌더링 결과를 버리고 렌더 패스에서 완전히 빠져나갑니다. 따라서 다음과 같이 배열을 변경하면

const [todos, setTodos] = useState(someTodosArray);

const onClick = () => {
  todos[3].completed = true;
  setTodos(todos);
};

컴포넌트가 리렌더링되지 않습니다.

(리액트는 실제로 상태 업데이트를 큐에 추가하기 전에 새로운 값을 확인하려고 시도하는 "빠른 경로(fast path)" 구제 메커니즘을 가지고 있습니다. 이 역시 직접 참조 검사에 의존하므로 불변하게 업데이트 해야하는 또 다른 예입니다.)

기술적으로 가장 바깥 참조만 불변하게 업데이트 하면 됩니다. 위 예제를 다음과 같이 바꿀 수 있습니다.

const onClick = () => {
  const newTodos = todos.slice();
  newTodos[3].completed = true;
  setTodos(newTodos);
};

새로운 배열 참조를 만들어 전달하면 컴포넌트가 리렌더링됩니다.

클래스 컴포넌트의 this.setState()와 함수 컴포넌트의 useState 그리고 useReducer 훅 사이에는 변경 및 리렌더링과 관련한 동작에 뚜렷한 차이가 있습니다. this.setState()는 변경과 관계없이 항상 리렌더링을 완료합니다. 따라서 다음의 경우 리렌더링됩니다.

const { todos } = this.state;
todos[3].completed = true;
this.setState({ todos });

this.setState({})와 같이 빈 객체를 전달하는 것도 마찬가지 입니다.

모든 실제 렌더링 동작 외에도 뮤테이션(mutation)은 리액트의 표준 단방향 데이터 흐름에 혼란을 초래합니다. 뮤테이션은 다른 코드들이 전혀 변하지 않았을 때 다른 값을 보게 할 수 있습니다. 따라서 주어진 상태의 일부가 실제로 업데이트되어야 하는 시기와 이유, 변경 사항이 어디에서 발생했는지 알 수 없게 만듭니다.

결론: 리액트 및 리액트 생태계의 나머지 부분은 불변하게 업데이트 되는 것을 가정합니다. 뮤테이션을 할 때마다 버그가 발생할 위험이 있습니다. 하지 마세요.

리액트 컴포넌트 렌더링 성능 측정

React DevTools Profiler를 사용해 각 커밋에서 렌더링되는 컴포넌트를 확인합니다. 예기치 않게 렌더링되는 컴포넌트를 찾고, 개발자 도구(DevTools)를 사용해 렌더링 원인을 파악한 후 React.memo()로 감싸거나 상위 컴포넌트가 전달하는 props를 메모하도록 수정하세요.

또한 리액트는 개발 빌드에서 훨씬 더 느리게 실행된다는 것을 기억하세요. 개발 모드에서 앱을 프로파일링해 렌더링하는 컴포넌트를 확인하고 컴포넌트를 렌더링하는데 필요한 상대적 시간을 서로 비교할 수 있습니다.(이 커밋에서 "컴포넌트 B"는 "컴포넌트 A"보다 3배 더 오래걸렸습니다" 와 같이) 그러나 리액트 개발 빌드를 사용해 절대적인 렌더링 시간을 측정하지 마세요. 프로덕션 빌드를 사용해서만 절대 시간을 측정하세요!(그렇지 않으면 Dan Abramov가 정확하지 않은 측정값을 사용했다고 당신에게 호통칠 것입니다.) 실제로 프로파일러를 사용하여 프로덕션과 유사한 빌드에서 타이밍 데이터를 캡처하려면 리액트의 특별한 "프로파일링" 빌드를 사용해야 합니다.

컨텍스트(Context)와 렌더링 동작

리액트의 Context API는 단일 사용자 제공 값을 컴포넌트의 하위 트리에서 사용할 수 있도록 하는 메커니즘입니다. 주어진 <MyContext.Provider> 내부의 모든 컴포넌트는 사이의 모든 컴포넌트를 통해 해당 값을 명시적으로 전달할 필요 없이 해당 컨텍스트 인스턴스에서 값을 읽을 수 있습니다.

컨텍스트는 "상태 관리"도구가 아닙니다. 컨텍스트에 전달되는 값은 직접 관리해야 합니다. 이는 일반적으로 데이터를 리액트 컴포넌트 상태로 유지하고 해당 데이터를 기반으로 컨텍스트 값을 구성함으로써 수행됩니다.

컨텍스트 기초

컨텍스트 공급자는 <MyContext.Provider value={42}>와 같이 하나의 value prop을 받습니다. 하위 컴포넌트는 컨텍스트 소비자 컴포넌트를 렌더링하고 다음과 같이 렌더 prop을 제공해 컨텍스트를 소비할 수 있습니다.

<MyContext.Consumer>{ (value) => <div>{value}</div>}</MyContext.Consumer>

또는 함수 컴포넌트에서 useContext 훅을 호출합니다.

const value = useContext(MyContext)

컨텍스트 값 업데이트

리액트는 공급자를 감싸고 있는 컴포넌트가 공급자를 렌더링할 때 컨텍스트 공급자에 새 값이 제공되었는지 확인합니다. 공급자의 값이 새 참조인 경우 리액트는 값이 변경되었으며 해당 컨텍스트를 사용하는 컴포넌트를 업데이트 해야한다는 것을 알게됩니다.

다음과 같이 컨텍스트 공급자에 새 객체를 전달하면 업데이트를 유발합니다.

function GrandchildComponent() {
  const value = useContext(MyContext);
  return <div>{value.a}</div>;
}

function ChildComponent() {
  return <GrandchildComponent />;
}

function ParentComponent() {
  const [a, setA] = useState(0);
  const [b, setB] = useState('text');

  const contextValue = { a, b };

  return (
    <MyContext.Provider value={contextValue}>
    <ChildComponent />
    </MyContext.Provider>
  );
}

이 예제에서 ParentComponent가 렌더링될 때마다 리액트는 MyContext.Provider에 새 값이 주어졌는지 확인하고, 아래로 순회하며 MyContext를 소비하는 컴포넌트를 찾습니다. 컨텍스트 공급자에 새 값이 있으면 해당 컨텍스트를 사용하는 모든 중첩된 컴포넌트가 강제로 리렌더링됩니다.

리액트의 관점에서 볼 때 각 컨텍스트 공급자는 단일 값만 가집니다. 즉, 객체, 배열 또는 원시값인 상관 없이 하나의 컨텍스트 값일 뿐입니다. 현재 컨텍스트를 소비하는 컴포넌트가 새 값의 일부만 관련되었더라도 새 컨텍스트 값으로 인한 업데이트를 건너뛸 수 있는 방법은 없습니다.

만약 컴포넌트가 오직 value.a만 필요하고 새로운 value.b 참조를 위해 업데이트가 이루어진 경우 불변하게 업데이트하는 규칙 및 컨텍스트 렌더링 규칙에 따라 해당 값도 새 참조가 되어야 하므로 value.a를 읽는 컴포넌트도 렌더링됩니다.

상태 업데이트, 컨텍스트 그리고 리렌더링

이제 이것들을 한데 모을 시간이 됐습니다. 우리는 다음을 알고 있습니다.

  • setState() 호출은 해당 컴포넌트의 렌더링을 큐에 추가합니다.
  • 리액트는 기본적으로 중첩된 컴포넌트를 순회하며 렌더링합니다.
  • 컨텍스트 공급자는 이를 렌더링하는 컴포넌트에 의해 값이 지정됩니다.
  • 이 값은 일반적으로 상위 컴포넌트의 상태로부터 가져옵니다.

즉, 기본적으로 컨텍스트 공급자를 렌더링하는 상위 컴포넌트에 대한 상태 업데이트는 컨텍스트 값을 읽는지 여부에 관계 없이 모든 하위 컴포넌트가 리렌더링되도록 합니다!

위의 Parent/Child/Grandchild의 예를 다시 살펴보면, GrandchildComponent리렌더링되지만 컨텍스트 업데이트 때문이 아니라 ChildComponent가 렌더링되었기 때문임을 알 수 있습니다. 이 예제에서는 "불필요한" 렌더링을 최적화하려는 작업이 없으므로 리액트는 ParentComponent가 렌더링될 때마다 기본적으로 ChildComponentGrandchildComponent를 렌더링합니다. 상위 컴포넌트가 새 컨텍스트 값을 MyContext.Provider에 넣는 경우 GrandchildComponent가 그 값을 알고 사용하게 되지만 컨텍스트 업데이트로 인해 GrandchildComponent가 리렌더링된 것은 아닙니다. 어쨌든 그렇게 됩니다.

컨텍스트 업데이트와 렌더링 최적화

위 예제를 수정해 실제로 최적화를 해 보겠습니다. 하지만 GreatGrandchildComponent를 최하단에 추가해 조금 꼬아보겠습니다.

function GreatGrandchildComponent() {
  return <div>Hi</div>
}

function GrandchildComponent() {
  const value = useContext(MyContext);
  return (
    <div>
    {value.a}
    <GreatGrandchildComponent />
    </div>
  )
}

function ChildComponent() {
  return <GrandchildComponent />
}

const MemoizedChildComponent = React.memo(ChildComponent);

function ParentComponent() {
  const [a, setA] = useState(0);
  const [b, setB] = useState("text");

  const contextValue = {a, b};

  return (
    <MyContext.Provider value={contextValue}>
      <MemoizedChildComponent />
    </MyContext.Provider>
  )
}

이제 setA(42)를 호출해 보겠습니다.

  • ParentComponent가 렌더링됩니다.
  • 새로운 contextValue 참조가 생성됩니다.
  • 리액트는 MyContext.Provider에 새 컨텍스트 값이 있음을 확인하고, 따라서 MyContext의 모든 소비자를 업데이트 해야합니다.
  • 리액트는 MemoizedChildComponent를 렌더링하려고 하지만 React.memo()로 감싸져 있음을 확인합니다. 전달되는 props가 하나도 없기 때문에 props는 실제로 변경되지 않았습니다. 리액트는 ChildComponent 렌더링을 완전히 건너뜁니다.
  • 그러나 MyContext.Provider에 대한 업데이트가 있었습니다. 따라서 더 아래쪽에 이에 대해 알아야 할 컴포넌트가 있을 수 있습니다.
  • 리액트는 계속 아래로 내려가 GrandchildComponent에 도달합니다. GrandchildComponentMyContext를 읽고 있으므로 리렌더링 해야 합니다. 새로운 컨텍스트 값이 있기 때문입니다. 리액트는 컨텍스트 변경에 따라 GrandchildComponent를 리렌더링합니다.
  • GrandchildComponent가 렌더링되었기 때문에 리액트는 계속 렌더링을 진행하며 모든 하위 컴포넌트를 리렌더링 합니다. 따라서 GreatGrandchildComponent도 리렌더링할 것입니다.

Sophie Alpert는 다음과 같이 말했습니다.

컨텍스트 공급자 바로 아래에 있는 리액트 컴포넌트는 React.memo를 사용해야합니다.

이렇게 하면 상위 컴포넌트의 상태 업데이트가 모든 컴포넌트를 강제로 리렌더링하지 않고, 컨텍스트를 읽는 부분만 리렌더링합니다.(기본적으로 ParentComponent<MyContext.Provider>{props.children}</MyContext.Provider>를 렌더링하도록 해 동일한 결과를 얻을수도 있습니다. "동일 요소 참조" 기법을 활용해 하위 컴포넌트를 리렌더링하지 않도록 하고, 한 수준 위에서 <ParentComponent><ChildComponent /></ParentComponent>를 렌더링합니다.)

그러나 일단 GrandchildComponent가 다음 컨텍스트 값을 기반으로 렌더링되면 리액트는 순회하며 모두 렌더링하는 기본 동작으로 돌아갑니다. 그래서 GreatGrandchildComponent가 렌더링되었고, 그 아래에 어떤 것이 있더라도 리렌더링 되었을 것입니다.

컨텍스트와 렌더러 경계(Renderer Boundaries)

일반적으로 리액트 앱은 ReactDOM이나 React Native와 같은 단일 렌더러로 빌드됩니다. 그러나 코어 렌더링 및 재조정 로직은 react-reconciler라는 패키지로 배포되며 다른 환경을 대상으로 하는 자체적인 버전의 리액트를 빌드할 수 있습니다. 이것의 좋은 예로 리액트를 사용해 Three.js 모델과 WebGL 렌더링을 구동하는 react-three-fiber와 리액트를 사용해 터미널 텍스트 UI를 그리는 ink가 있습니다.

한가지 오래된 한계점은 ReactDOM 컨텐츠 내부에 React-Three-Fiber를 표시하는 것과 같이 여러 렌더러를 하나의 앱에 사용하는 경우 컨텍스트 공급자가 렌더러 경계를 통과하지 않는다는 것입니다. 따라서 컴포넌트 트리가 다음과 같은 경우

function App() {
  return (
    <MyContext.Provider>
      <DomComponent>
        <ReactThreeFiberParent>
          <ReactThreeFiberChild />
        </ReactThreeFiberParent>
      </DomComponent>
    </MyContext.Provider>
  );
}

여기서 ReactFiberParentReact-Three-Fiber로 렌더링된 콘텐츠를 생성하고 표시합니다. 그러면 <ReactThreeFiberChild><MyContext.Provider>의 값을 알 수 없습니다.

이는 리액트의 알려진 한계이며 현재 이를 해결할 공식적인 방법은 없습니다.

React-Three-Fiber를 관리하는 Poimandres는 컨텍스트 브리징을 가능하게 하는 몇가지 내부적인 핵을 갖고 있으며, 최근 유효한 해결 방법인 useContextBridge을 포함하는 its-fine이라는 라이브러리를 출시했습니다.

React-Redux와 렌더링 동작

다양한 형태의 "CONTEXT VS REDUX?!?!??!"는 제가 리액트 커뮤니티에서 가장 많이 보는 질문인 것 같습니다.(Redux와 Context는 서로 다른 일을 하는 도구이므로 그러한 이분법적인 질문은 애초부터 잘못됐습니다.)

아무튼 사람들이 이런 질문에 대해 반복적으로 주장하는 것 중 하나는 "React-Redux는 실제로 렌더링해야 하는 컴포넌트만 리렌더링하므로 컨텍스트보다 낫다"라는 것입니다.

어느 정도는 맞는 말이지만, 실제로는 그것보다 훨씬 더 미묘한 차이가 있습니다.

React-Redux 구독(Subscriptions)

저는 많은 사람들이 "React-Redux는 내부적으로 컨텍스트를 사용한다"라는 문구를 반복해 사용하는 것을 보았습니다. 기술적으로 그렇지만, React-Redux는 컨텍스트를 사용하여 현재 상태 값이 아닌 Redux 저장소 인스턴스를 전달합니다. 즉, 항상 동일한 컨텍스트 값을 <ReactReduxContext.Provider>에 전달합니다.

Redux 저장소는 액션이 디스패치될 때마다 모든 구독자 알림 콜백을 실행한다는 것을 기억해야합니다. Redux를 사용해야 하는 UI 계층은 항상 Redux 저장소를 구독하고, 구독자 콜백을 통해 최신 상태를 읽고, 값을 비교하고, 관련 데이터가 변경된 경우 강제로 리렌더링합니다. 구독 콜백 프로세스는 전적으로 리액트 외부에서 일어나며 React-Redux가 특정 리액트 컴포넌트에 필요한 데이터가 변경되었음을 인지하는 경우에만 리액트가 관여합니다.(mapState 또는 useSelector의 반환 값에 기반)

이로 인해 컨텍스트와 매우 다른 성능 특성이 나타납니다. 항상 더 적은 수의 컴포넌트가 렌더링될 가능성이 높지만 React-Redux는 저장소의 상태가 업데이트될 때마다 전체 컴포넌트 트리에 대해 항상 mapState/useSelector 함수를 실행해야 합니다. 대부분의 경우 이러한 셀렉터를 실행하는 비용은 리액트가 다른 렌더 패스를 수행하는 비용보다 적기 때문에 일반적으로 유익하지만, 해야만 하는 일입니다. 그러나 이러한 셀렉터가 비용이 큰 변환을 수행하거나 실수로 새 값을 반환하는 경우 속도가 느려질 수 있습니다.

connectuseSelector의 차이

connect는 고차 컴포넌트입니다. 컴포넌트를 전달하면 connect는 저장소를 구독하고, mapStatemapDispatch를 실행하고, 결합된 props를 컴포넌트로 전달하는 모든 작업을 수행하는 래퍼 컴포넌트를 반환합니다.

connect 래퍼 컴포넌트는 항상 PureComponent/React.memo()와 동일한 역할을 수행하지만 약간 다른 점이 있습니다. connect는 컴포넌트로 전달되는 결합된 props가 변경되는 경우에만 컴포넌트를 렌더링합니다. 일반적으로 결합된 최종 props는 {...ownProps, ...stateProps, ...dispatchProps}와 같은 조합이므로 상위 컴포넌트에서 새로운 props을 내려주게되면 PureComponentReact.memo()와 같이 컴포넌트가 렌더링됩니다. 상위 컴포넌트 외에도 mapState에서 반환되는 모든 새로운 참조는 컴포넌트 렌더링을 유발합니다.(ownProps/stateProps/dispatchProps을 조합하는 방법을 커스텀할 수 있으므로 해당 동작을 변경할 수도 있습니다.)

반면 useSelector는 컴포넌트 내부에서 호출되는 훅입니다. 따라서 useSelector는 상위 컴포넌트가 렌더링될 때 컴포넌트의 렌더링을 중단할 방법이 없습니다!

이는 connectuseSelector간 성능 차이의 핵심입니다. connect를 사용하면 연결된 모든 컴포넌트가 PureComponent처럼 동작하므로 리액트의 기본 렌더링 동작이 전체 컴포넌트 트리 아래로 계단식으로 내려가는 것을 방지하는 방화벽 역할을 합니다. 일반적인 React-Redux 앱에는 연결된 컴포넌트가 많기 때문에 대부분의 리렌더 캐스케이드는 컴포넌트 트리의 상당히 작은 부분으로 제한됩니다. React-Redux는 데이터 변경 사항을 기반으로 연결된 컴포넌트를 강제로 렌더링하고, 그 아래에 있는 다음 2~3개의 컴포넌트도 렌더링 할 수 있습니다. 그 다음 리액트는 업데이트할 필요가 없는 연결된 다른 컴포넌트로 실행되어 렌더링 캐스케이드를 중단합니다.

또한 연결된 컴포넌트가 더 많다는 것은 각 컴포넌트가 저장소에서 더 작은 데이터 조각을 읽고 있다는 것을 의미하며, 따라서 주어진 액션 후에 리렌더링해야할 가능성이 적다는 것을 뜻합니다.

함수 컴포넌트만을 사용하고 useSelector를 사용하는 경우 connect에 비해 Redux 저장소 업데이트를 기반으로 컴포넌트 트리의 더 큰 부분이 리렌더링 될 가능성이 큽니다. 왜냐하면 이러한 렌더 캐스케이드가 트리에서 계속 이어지는 것을 막을 다른 연결된 컴포넌트가 없기 때문입니다.

성능 문제가 발생하면 필요에 따라 React.memo()로 컴포넌트를 래핑해 상위 컴포넌트로 인해 발생하는 불필요한 리렌더링을 방지하는 것이 해답입니다.

리액트의 향후 개선 사항

"React Forget" 메모이징 컴파일러

리액트 훅이 처음 출시되고 useEffectuseMemo와 같은 훅에 대한 의존성 배열을 다루기 시작했을 때부터 리액트 팀은 훅의 의존성 배열을 "발전된 컴파일러가 충분히 자동으로 생성할 수 있는 것"으로 의도했다고 말했습니다. 즉, 훅 내부에 counter라는 변수를 사용하면 컴파일러가 빌드 시 자동으로 [counter] 의존성 배열을 삽입할 수 있습니다.

"충분히 발전된 컴파일러"는 몇 번의 논의에도 불구하고 실현되지 않았습니다. 커뮤니티는 바벨 매크로와 같은 자체 자동 메모이제이션 접근 방식을 만들려고 했지만 공식 컴파일러는 나타나지 않았습니다.

리액트 팀이 ReactConf 2021에서 "React Without Memo"라는 제목의 발표를 하기 전까지요. 그 발표에서 그들은 "React Forget"이라는 실험적인 컴파일러를 시연했습니다. 이것은 자동으로 메모이제이션 기능을 추가하기 위해 함수 컴포넌트의 본문을 다시 작성하도록 설계됐습니다.

React Forget이 정말 흥미로운 점은 훅의 종속성 배열을 메모이제이션 할 뿐만 아니라 JSX 요소 반환 값도 메모이제이션한다는 것입니다. 그리고 우리는 리액트가 하위 컴포넌트들의 리렌더링을 방지할 수 있는 "동일 요소 참조" 최적화를 할 수 있다는 것을 이미 알고 있습니다. 즉, React Forget이 리액트 컴포넌트 트리 전체에서 불필요한 렌더링을 효과적으로 제거할 수 있다는 것을 뜻합니다!

2022년 10월 현재 React Forget 컴파일러는 아직 출시되지 않았지만 리액트 팀에서 흘러나오는 약간의 뉴스는 고무적입니다. 3~4명의 엔지니어가 풀타임으로 작업하고 있으며 커뮤니티가 시험해 볼 수 있도록 공개되기 전에 Facebook.com이 완전히 작동하도록 하는 것을 목표로 하고 있습니다. 작업이 잘 진행되고 있다는 다른 힌트도 있었습니다. React Forget이 무사히 진행되면 완전히 필요 없어질 수 있다는 이유로 useEvent RFC가 close되었으며, 다른 토론에서는 일반적으로 "자동 메모이제이션 때문에 너무 많은 렌더링 문제가 사라지면 어떨까요?"와 같이 시사했습니다.

따라서 현재로서는 확신할 수는 없지만 React Forget 출시 가능성에 대해 낙관할 이유가 있습니다.

Context Selectors

앞서 Context API의 가장 큰 약점은 컴포넌트가 컨텍스트 값의 일부를 선택적으로 구독할 수 없기 때문에 해당 컨텍스트를 읽는 모든 컴포넌트가 값이 업데이트 될 때 리렌더링된다는 점이라고 했습니다.

2019년 7월에 한 커뮤니티 멤버가 컴포넌트가 컨텍스트의 일부만 선택적으로 구독할 수 있도록 하는 "context selectors" API를 제안하는 RFC를 작성했습니다. 그 RFC는 잠시 정체되었다가 마침내 되살아났습니다. Andrew Clark는 2021년 1월 리액트의 컨텍스트 셀렉터를 위한 개념을 증명하는 접근 방식을 실험을 위한 내부 기능 플래그로 숨겨진 새로운 기능을 사용해 구현했습니다.

안타깝게도, 그 이후로 컨텍스트 선택기 기능에 대한 추가 움직임은 없습니다. 토론과 PR에서 개념 증명 버전은 API 설계의 변경과 반복을 거의 확실히 필요로 할 것입니다. 또한 React Forget 컴파일러가 출시된다면 이는 또 하나의 반쯤 쓸모없는 기능이 될 수 있습니다.

이 기능이 출시되면 컨텍스트 + useReducer 조합이 더 많은 리액트 상태에 대한 옵션이 될 것입니다.

그동안 폴리필로 사용할 수 있는 Daishi Kato(Zustand와 Jotai의 메인테이너)의 useContextSelector 라이브러리가 있다는 점에 주목할 필요가 있습니다.

요약

  • 리액트는 항상 기본적으로 컴포넌트를 순회하며 렌더링하므로, 상위 컴포넌트가 렌더링될 때 하위 컴포넌트들도 렌더링됩니다.
  • 렌더링 자체는 괜찮습니다. 리액트가 DOM 변경이 필요한지 아는 방법입니다.
  • 그러나 렌더링에는 시간이 걸리고 UI 출력이 변경되지 않는 "낭비된 렌더링"이 추가될 수 있습니다.
  • 대부분의 경우 콜백 함수 및 객체와 같은 새로운 참조를 내려주는 것은 문제되지 않습니다.
  • React.memo()와 같은 API는 props가 변경되지 않은 경우 불필요한 렌더링을 건너뛸 수 있습니다.
  • 그러나 props로 항상 새로운 참조를 전달하면, React.memo()는 렌더링을 절대 건너뛸 수 없으므로 이러한 값들은 메모이제이션해야 할 수 있습니다.
  • 컨텍스트는 깊이 중첩된 컴포넌트가 관련된 값에 접근할 수 있도록 해줍니다.
  • 컨텍스트 공급자는 참조를 비교해 값이 변경되었는지 확인합니다.
  • 새 컨텍스트 값은 중첩된 모든 소비자가 리렌더링되도록 강제합니다.
  • 그러나 많은 경우, 일반적인 상위 컴포넌트 렌더링 캐스케이드 프로세스로 인해 하위 컴포넌트는 어쨌든 리렌더링 됐을 것입니다.
  • 따라서 컨텍스트 값을 업데이트할 때 전체 트리가 항상 렌더링되지 않도록 컨텍스트 공급자의 하위 컴포넌트를 React.memo()로 감싸거나 {props.children}를 사용할 수 있습니다.
  • 하위 컴포넌트가 새 컨텍스트 값에 기반해 렌더링되면 리액트는 해당 지점에서 다시 내려가며 렌더링합니다.
  • React-Redux는 컨텍스트로 저장소 상태 값을 전달하는 대신 Redux 저장소에 대한 구독을 통해 업데이트를 확인합니다.
  • 이러한 구독은 모든 Redux 저장소 업데이트에서 실행되므로 가능한 한 빨라야 합니다.
  • React-Redux는 데이터가 변경된 컴포넌트만 리렌더링 하기 위해 많은 작업을 수행합니다.
  • connectReact.memo()와 같은 역할을 하므로 연결된 컴포넌트가 많으면 한 번에 렌더링하는 컴포넌트의 수를 최소화할 수 있습니다.
  • useSelector는 훅이므로 상위 컴포넌트로 인해 발생하는 렌더링을 중단할 수 없습니다. useSelector만 사용하는 앱이 있다면 React.memo()를 일부 컴포넌트에 적용해 렌더링이 항상 캐스케이드 되는 것을 방지해야 합니다.
  • "React Forget" 자동 메모이제이션 컴파일러가 출시되면 이 모든 것을 획기적으로 간소화시킬 수 있습니다.

결론

분명히 전체 상황은 "컨텍스트는 모든 것을 렌더링하고 Redux는 그렇지 않으니, Redux를 쓰세요"라고 하는 것 보다 훨씬 더 복잡합니다. 오해하지 마세요, 저는 사람들이 Redux를 사용하길 바랍니다. 또한 사람들이 다양한 도구와 관련된 동작과 트레이드 오프를 명확하게 이해해 자신의 상황에 가장 적합한게 무엇인지 정보에 기반해 결정할 수 있길 바랍니다.

모두가 "언제 컨텍스트를 사용하고 언제 Redux를 사용해야 하나요?"라고 묻는 것 같으니 몇 가지 일반적인 규칙을 다시 살펴보겠습니다.

  • 다음의 경우 컨텍스트 사용
    • 자주 바뀌지 않는 몇 가지 간단한 값만 전달하면 되는 경우
    • 앱의 일부분을 통해 접근해야 하는 일부 상태 또는 함수가 있으며 이를 prop으로 내려주고 싶지 않을 때
    • 추가적인 라이브러리를 추가하지 않고 리액트 내장 기능을 사용하려는 경우
  • 다음의 경우 React-Redux 사용
    • 앱의 여러곳에 필요한 많은 양의 상태가 있는 경우
    • 앱의 상태가 자주 업데이트되는 경우
    • 상태를 업데이트 하는 로직이 복잡한 경우
    • 앱이 중간 또는 큰 크기의 코드베이스를 가지고 있으며 여러 사람이 함께 작업하는 경우

이러한 규칙은 어렵고 특별한 규칙이 아닙니다. 이러한 툴들이 언제 적합한지에 대한 몇 가지 가이드라인일 뿐입니다! 언제나 그렇듯 스스로의 상황에 맞는 가장 좋은 도구가 무엇인지 직접 결정할 시간을 가지세요.

아무쪼록 이 설명이 사람들이 다양한 상황에서 리액트의 렌더링 동작이 실제로 어떻게 이루어지는지 더 큰 그림을 이해하는 데 도움이 되길 바랍니다.

추가 정보

저는 2022년 10월에 React Advanced에서 이 글에 기반해 발표한 내용을 기록했습니다.

저는 최근 "리액트 렌더링은 어떻게 동작하나요?"라는 주제를 구체적으로 다루는 다른 좋은 글들을 몇 개 보았는데, 아래 추천할 만한 글들을 몇 개 짚어보겠습니다.

이 외에도 아래의 추가 자료들을 살펴보세요.


이 글은 블로그 답변 시리즈입니다. 시리즈의 다른 글들은 아래를 통해 보실 수 있습니다.

Jul 06, 2022 - Blogged Answers: How I Estimate NPM Package Market Share (and how Redux usage compares to other libraries)
Jun 22, 2021 - Blogged Answers: The Evolution of Redux Testing Approaches
Jan 18, 2021 - Blogged Answers: Why React Context is Not a "State Management" Tool (and Why It Doesn't Replace Redux)
Jun 21, 2020 - Blogged Answers: React Components, Reusability, and Abstraction
May 17, 2020 - Blogged Answers: A (Mostly) Complete Guide to React Rendering Behavior
May 12, 2020 - Blogged Answers: Why I Write
Feb 22, 2020 - Blogged Answers: Why Redux Toolkit Uses Thunks for Async Logic
Feb 22, 2020 - Blogged Answers: Coder vs Tech Lead - Balancing Roles
Jan 19, 2020 - Blogged Answers: React, Redux, and Context Behavior
Jan 01, 2020 - Blogged Answers: Years in Review, 2018-2019
Jan 01, 2020 - Blogged Answers: Reasons to Use Thunks
Jan 01, 2020 - Blogged Answers: A Comparison of Redux Batching Techniques
Nov 26, 2019 - Blogged Answers: Learning and Using TypeScript as an App Dev and a Library Maintainer
Jul 10, 2019 - Blogged Answers: Thoughts on React Hooks, Redux, and Separation of Concerns
Jan 19, 2019 - Blogged Answers: Debugging Tips
Mar 29, 2018 - Blogged Answers: Redux - Not Dead Yet!
Dec 18, 2017 - Blogged Answers: Resources for Learning Redux
Dec 18, 2017 - Blogged Answers: Resources for Learning React
Aug 02, 2017 - Blogged Answers: Webpack HMR vs React-Hot-Loader
Sep 14, 2016 - How I Got Here: My Journey Into the World of Redux and Open Source

🚀 한국어로 된 프런트엔드 아티클을 빠르게 받아보고 싶다면 Korean FE Article(https://kofearticle.substack.com/)을 구독해주세요!

profile
FE 개발을 하고 있어요🌱

27개의 댓글

comment-user-thumbnail
2022년 11월 14일

멋져요!

1개의 답글
comment-user-thumbnail
2022년 11월 14일

글을 찾는 인사이트가 대단하신 것 같아요! 메일로 받아보고 있는데 항상 좋은 글 번역 및 공유해주셔서 감사합니다!

1개의 답글
comment-user-thumbnail
2022년 11월 14일

항상 좋은글 감사합니다~

1개의 답글
comment-user-thumbnail
2022년 11월 14일

잘 읽었습니다 감사합니다 !

1개의 답글
comment-user-thumbnail
2022년 11월 15일

최고!!

2개의 답글
comment-user-thumbnail
2022년 11월 15일

잘 읽었습니다 :)

1개의 답글
comment-user-thumbnail
2022년 11월 15일

와우...

1개의 답글
comment-user-thumbnail
2022년 11월 15일

근래 읽었던 글 중에 가장 감명을 많이 받은거 같아요 감사합니다 😃

답글 달기
comment-user-thumbnail
2022년 11월 19일

좋은글 감사합니다.

답글 달기
comment-user-thumbnail
2022년 11월 20일

좋아요!

답글 달기
comment-user-thumbnail
2022년 11월 23일

정보 감사합니다 정말 많은 geometry dash 도움이 되었습니다.

1개의 답글
comment-user-thumbnail
2022년 11월 23일

우와..좋은글 고맙습니다!!

답글 달기
comment-user-thumbnail
2022년 11월 24일

asdfasdf

답글 달기
comment-user-thumbnail
2022년 12월 2일

요약 : React Forget이 다해줄거임 ㅅㄱ

답글 달기
comment-user-thumbnail
2022년 12월 13일

원본 저자의 말처럼 리액트 렌더링과 관련된 명쾌하게 정리된 아티클을 찾기가 어려웠는데(리액트 공식문서도... ㅠㅠ), 이렇게 번역된 글로 접할 수 있어서 정말 좋았습니다!
좋은 내용 발굴하고 번역해주셔서 감사합니다! :)

답글 달기
comment-user-thumbnail
2022년 12월 27일

안녕하세요 글 잘 읽었습니다!
읽다가 궁금한게 생겨서요.

일반적인 렌더링 동작 부분에 "이제 트리에서 대부분의 컴포넌트가 직전과 정확히 동일한 렌더 출력을 반환할 가능성이 매우 높으므로 리액트는 DOM을 변경할 필요가 없습니다. 그러나 리액트는 여전히 컴포넌트에 자체 렌더링을 요청하고 렌더 출력을 비교하는 작업을 수행해야 합니다. 둘 다 시간과 노력이 필요합니다."라고 적혀있는데

카운터 값이 바뀜에 따라 컴포넌트들이 다른 렌더 출력을 반환할 수도 있는데 '대부분의 컴포넌트가 직전과 정확히 동일한 렌더 출력을 반환할 가능성이 매우 높으므로'라는 부분이 왜 그런건지 이해가 안 가서요..!
혹시 어떻게 이해하셨는지 궁금합니다!

1개의 답글