Modern React Deep Dive: 리액트 핵심 요소 깊게 살펴보기

이번 글에서는 리액트를 이루는 근본적인 요소 중 JSX와 가상 DOM, 리액트 파이버 등에 대해 알아봅니다.

JSX란?

JSX란 XML 스타일의 트리 구조로 표현하고 싶은 것들을 자바스크립트와 함께 작성하는 문법입니다. 자바스크립트로 트랜스파일링되어 실행됩니다.

JSX의 구성 요소

  • JSXElement: HTML element와 비슷한 역할을 하는 컴포넌트

  • JSXAttributes: JSXElement에 부여할 수 있는 속성, 필수가 아님

  • JSXChildren: JSXElement의 자식, 0개 이상 존재 가능

  • JSXStrings: 문자열

가상 DOM과 리액트 파이버

리액트의 특징 중 하나는, 실제 DOM을 개발자가 직접 건드리지 않아도 된다는 점입니다.
리액트는 가상 DOM을 운영하여 변경점만을 포착하여 실제 DOM에 반영합니다.
이 과정을 재조정(Reconciliation) 이라고 부릅니다.
가상 DOM은 DOM의 경량화된 복사본 객체입니다.

가상 DOM 탄생 배경

DOM의 모든 변경 사항을 추적하는 것 보다 결과적으로 만들어지는 DOM 결과물을 아는 것이 높은 개발 편의성을 제공해주기 때문입니다.

가상 DOM에 대한 일반적인 오해

많은 사람들이가상 DOM을 통한 DOM 관리가 직접 DOM을 조작하는 것 보다 빠르다고 오해합니다.
하지만 가상 DOM의 diffing, 배치 업데이트 과정에 추가적인 리소스 소모가 있을 수 있습니다.
실제로 직접 DOM을 조작해서 리액트보다 더 빠른 속도를 가진 라이브러리들이 존재합니다. (ex: Svelte, Solid.js)
가상 DOM의 이점은 상태 변경에 따라 전체 UI를 새로 그리는 것 처럼 개발할 수 있으나 실제 DOM 반영은 일부만 되는 것과, 컴포넌트 트리를 가상 DOM이란 레이어로 추상화하여 실제로 그리는 렌더러를 바꿀 수 있는 것에 있습니다.

dan abramov: 가상 DOM은 무조건 빠른 것이 아니라 대부분의 상황에 웬만한 애플리케이션을 만들 정도로 충분히 빠르다.


리액트 파이버

리액트에서 관리하는 객체. 하나의 태그와 1:1 매칭되고 가상 DOM과 실제 DOM의 차이, 변경점을 수집하여 차이가 있을 시 렌더링을 요청하는 역할입니다.

React 16 버전 이전에는 재조정 알고리즘이 동기적인 스택 알고리즘으로 작동했습니다.
동기적으로 동작하면서 발생하는 인터랙션의 반응성 문제를 해결하는 것이 리액트 파이버의 목표였습니다.
따라서 리액트 파이버는 비동기적으로 작동하고, 작업을 멈추고 우선순위가 높은 작업을 처리할 수도 있습니다.

파이버의 동작은 렌더 단계와 커밋 단계로 나뉘어져 있습니다.

리액트는 Value UI 라이브러리이다.

리액트는 UI를 값으로 관리하여 변수에 UI 관련 값을 보관하고, 이를 관리하고 표현하는 라이브러리입니다.

파이버는 컴포넌트가 최초로 마운트되는 시점에 생성되어 가급적이면 재사용됩니다.

리액트 파이버 트리

리액트 파이버 트리는 현재의 모습을 담은 current 파이버 트리와 작업중인 상태를 나타내는 workInProgress 트리가 있습니다.
리액트 파이버의 작업이 끝나면 트리를 가리키는 포인터만 변경해 workInProgress 트리를 current 트리로 바꿔치기합니다. 이런 기법을 더블 버퍼링이라고 하는데, 컴퓨터 그래픽스에서 사용하는 용어로 보이지 않는 곳에서 다음 그려야 할 그림을 그린 후 그림이 완성되면 현재 상태를 새로운 그림으로 바꾸는 것입니다. 리액트에서 더블 버퍼링은 커밋 단계에서 수행됩니다.

파이버 동작 방식

  1. setState 등으로 업데이트가 발생하면 workInProgress 트리를 빌드하여 상태가 변경된 부분을 반영 (렌더 단계)

  2. 이를 react-dom 혹은 React Native 내부의 렌더러 등이 실제 렌더링 (커밋 단계)

알고 넘어갈 점
리액트 파이버와 DOM/네이티브 렌더링은 별개입니다.
따라서 렌더러가 다르다 하더라도 동일한 재조정자를 사용할 수 있습니다.


클래스 컴포넌트, 함수 컴포넌트

리액트에서는 클래스와 함수를 기반으로 컴포넌트를 만들 수 있습니다.
클래스 컴포넌트는 사실상 레거시인데, 다만 생명주기 메소드 중 함수 컴포넌트로 모사 불가능한 것이 필요할 경우 사용이 불가피합니다.

클래스 컴포넌트의 한계 → 함수 컴포넌트의 장점

  • 데이터 흐름을 추적하기 어렵다. → this와 관련된 혼란을 줄이고 Hooks API를 통해 일관된 흐름 유지.

  • 로직 재사용이 어렵다. → 커스텀 훅으로 로직 재사용 가능.

  • 클래스의 한계로 번들 크기 증가 → 사용하지 않는 함수 등을 트리쉐이킹 가능.

  • instance 내부에 state를 관리하여 핫 리로딩에 불리하다. → 리액트 아키텍처 내부 클로저에 state 저장하여 핫 리로딩에 최적화 되어있다.

클래스 컴포넌트 vs 함수 컴포넌트

클래스 컴포넌트는 props, state의 값을 항상 this로부터 가져온다. (mutable하여 렌더링 이후에 변경된 값을 읽을 수 있음)
함수 컴포넌트는 props를 함수의 인자로 받고, 컴포넌트가 그 값을 변경할 수 없다. (렌더링이 일어난 순간의 값을 참조)


리액트에서 렌더링이 일어나는 과정

리액트의 렌더링이란 컴포넌트들이 지닌 state와 props의 값을 기반으로 UI의 구성을 결정하고 어떤 DOM 결과를 브라우저에 제공할지 계산하는 과정입니다.

리액트에서 렌더링이 발생하는 시나리오

  1. 최초 렌더링: 처음 애플리케이션에 진입했을 시 화면을 그려야 합니다.

  2. 리렌더링: state 업데이트, key prop 변경 시 업데이트 된 상태에 따라 UI 또한 업데이트 됩니다.

렌더링 프로세스

상태 변경을 감지하고, 변경된 상태를 기반으로 새로운 가상 DOM을 생성한 후, 실제 DOM에 변경된 결과물을 반영합니다.

렌더 단계

컴포넌트를 실행하여 그 결과와 이전 가상 DOM을 비교하여 변경이 필요한 컴포넌트를 체크하는 단계.

커밋 단계

렌더 단계의 변경 사항을 실제 DOM에 적용하는 단계. (렌더 단계에서 변경 사항이 없다면 실행되지 않습니다.)


메모이제이션

리액트에서 React.memo, useMemo, useCallback은 각각 컴포넌트, 값, 함수를 메모이제이션하는 기능입니다.

메모이제이션은 성능 최적화를 위한 기술이지만 거기에도 비용이 들며, 두 가지 관점에서 갑론을박이 벌어지는 중입니다.

주장1: 메모이제이션은 꼭 필요한 곳에만

메모이제이션은 값을 어딘가에 저장해두어야 하기 때문에 리소스가 추가로 들고, 성능에 영향이 미미한 작업까지 메모이제이션 할 필요는 없다는 관점입니다.

주장2: 모조리 메모이제이션

모든 것을 메모이제이션 하는 비용보다 필요한 곳에 메모이제이션이 적용되지 않았을 때 드는 비용이 훨씬 크므로 메모이제이션을 적용할 곳을 선별하지 말고 모두 메모이제이션하자는 관점입니다.