[리액트를 다루는 기술] 1장 리액트 특징

정통파 개발자·2024년 11월 30일
post-thumbnail

리액트를 다루는 기술의 책 1장에서는 리액트의 대표 컨셉과 중요한 특징을 다루고 있다. 여기서 제일 중요한 가상돔을 사용한 화면(Interface) 표현에 대해서 집중적으로 공부해보도록 하겠다! 🤗

리액트 컨셉

리액트 컨셉은 굉장히 단순하면서 터프하다
기존 자바스크립트의 라이브러리인 jQuery 와 비교해보면 왜 단순하고 터프하다고 하는지 알 수 있다.

만약, 사용자가 버튼을 눌러 Count 값을 1 증가한다고 생각해보자.
먼저, 제이쿼리에서

<html>
  <body>
    <p id="result">0</p>
    <button id="button">Click!</button>
  </body>
</html>
var count =$("#result").text();

$("#button").click(function(){
	count++;
	$('#result').text(count);
	$('#result').html($('<b>새로운 HTML</b> 콘텐츠');
})

제이쿼리에서는 자바스크립트를 통해 DOM 내부 요소에 직접 접근하여, 내부 데이터를 직접 조작하여 업데이트 하는 직관적인 방식을 사용한다.

제이쿼리는 직관적이라는 장점이 있는 반면, 치명적인 단점이 있다.

  • 셀렉터로 직접 조작하기 때문에 DOM 탐색이 빈번하게 일어난다.
  • DOM 반복적인 업데이트시 비효율적인 반복작업이 일어난다.
    아래의 상황에서 #list에 li 요소를 반복적으로 추가하고 있습니다. 이때 1000번의 DOM 업데이트가 발생한다. 🤮
for (var i = 0; i < 1000; i++) {
  $('#list').append('<li>' + i + '</li>');
}

각 업데이트마다 화면이 새로 렌더링되며 따라서 대규모 DOM 에서는 성능저하가 빈번하게 일어난다.


그럼 똑같은 상황에서 리액트는 어떨까?
결론적으로 말하면, 리액트는 데이터가 바뀌면 그 데이터가 포함된 컴포넌트 자체를 싹 교체해버린다. (Reconciliation)

리액트에서 중요한 개념인 컴포넌트가 등장하는데
개발자는 화면상에 데이터를 어떤 방식으로 노출하고, 어떻게 처리할지 등의 정보를 포함한 컴포넌트 단위를 개발한다.

이후에 개발자가 작성한 코드를 읽어 리액트는 최초 렌더링과, 리렌더링을 통해 데이터 변화에 대응한다.

최초 렌더링

리액트 앱를 최초로 실행하면, 리액트의 ReactDOM.render() 또는 createRoot().render()가 호출된다.

// index.js 또는 main.js
import React from 'react';
import ReactDOM from 'react-dom/client'; // React 18 이상에서는 createRoot 사용

import App from './App'; // 메인 App 컴포넌트

// React 18 이상에서는 createRoot 사용
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);

이때 리액트가 가진 엄청난 강점이 발휘된다.
리액트는 render()함수를 통해 Root부터 최하위 노드의 Child Component 까지 재귀함수를 통해 순회하며 각각 컴포넌트의 정보를 읽어 객체로 생성한다.

위에서와 같은 Count를 증가시키는 리액트 함수형 컴포넌트의 예시이다.

const CountComponent = () => {
  const [result, setResult] = useState<number>(0)

  return (
  <div>
   	<p>{result}</p>
   	<button onClick={() => setResult(result++)}>Click!</button>
  </div>
)}

리액트는 render() 함수를 통해 DOM 내부에 선언된 컴포넌트를 순회하며 자바스크립트 객체를 생성한다.

중요해서 다시한번 말해보았다. ㅋㅋ

위와 같은 컴포넌트를 읽을때 리액트는 다음과 같이 자바스크립트 객체를 생성한다.
리액트 가상돔 객체 안에는 객체 뷰가 어떻게 생겼고, 어떻게 동작하는지에 대한 정보를 담고 있다. 또한 가상돔임을 증명하는 Symbol 값과, 각각 Element들을 고유하게 구분하는 Key 값을 가지고 있다.

const virtualDom = {
  type: 'div',
  props: {
    children: [
      {
        type: 'p',
        props: {
          children: ['0'] // result의 초기값인 0
        }
      },
      {
        type: 'button',
        props: {
          children: ['Click!'],
          onClick: expectFunction // onClick 이벤트 핸들러 (setResult 호출)
        }
      }
    ]
  }
};

그리고 최상단부터 최하위까지의 객체 생성과정이 끝나면 HTML 마크업을 만들고, 실제 DOM 안에 주입하여 사용자가 볼 수 있는 화면이 렌더링된다.

Reconciliation, 리렌더링

화면에 CountComponent가 그려지고 사용자는 button을 클릭하여 result 데이터를 1증가 시키려고한다.

리액트는 해당 컴포넌트의 데이터를 직접 바꾸지 않는다.
컴포넌트 내에 데이터 값이 변경되면, 해당 데이터를 기반으로 render() 함수를 다시 실행시켜 새로운 virtualDom 객체를 생성한다.
그리고 리액트는 새로 생성된 가상 DOM 객체는 Diff 알고리즘을 통해 이전 DOM과 비교하는 작업을 거친다.
이 과정에서 데이터가 바뀐 부분을 dirty 체크를 하고 batch에 추가한다.
(속성 값이 변한 경우에는 속성 값만 업데이트하고, 해당 엘리먼트의 태그 혹은 컴포넌트가 변경되면 해당 노드를 포함한 하위 모든 노드를 제거(언마운트)한 후 새로운 엘리먼트를 생성한다)

이런식으로 batch에 쌓인 모든 변경사항을 처리 한 후, 딱 한번 DOM을 업데이트 한다.

우리가 알고있는 가상돔, Virtual DOM은 위의 과정을 말한다.
리액트는 가상돔의 객체를 생성하여 실제 돔의 반영까지 비교연산을 수행하여 필요한 부분만 최적화하여 렌더링 하는 작업을 관리해준다.

리액트의 비교 알고리즘

위에서 언급한 바와 같이 리액트는 어떤 비교 알고리즘에 의해 이전 돔과, 바뀐 돔을 비교하여 재조정(Reconciliation)을 통해 화면을 다시 생성하는 작업을 반복한다.
.
.
.
여기서 두 가지 의문점이 생길 수 있다. 🤔

Question1. 데이터 하나가 바뀔때, 그 많은 요소들의 객체를 처음부터 끝까지 탐색한다고?
Question2. 자바스크립트 언어인데 어떤 기준으로 속성값의 데이터를 비교하여 변경이 감지되는거지?

Answer1. Diffing, 휴리스틱 알고리즘

리액트는 각 노드 컴포넌트들의 객체 정보를 가지고있다. 그리고 이를 비교하는데 모두 다 비교하기에는 너무 큰 비용이 발생한다.

휴리스틱 알고리즘이란? 기본적으로 모두 최적해가 될 가능성이 없는 답들을 탐색하는 것을 방지하여 만들어봐야 할 답의 수를 줄이는 것을 목표로 한다.

리액트는 휴리스틱 알고리즘 방식을 채택했다.
간단히 이야기하면 전부 비교하지 않고, 상식적으로 바뀔만한 곳만 비교하여 비교에 발생하는 비용을 절감하겠다는 기조이다.

리액트는 아래와 같은 방식으로 변화를 감지한다.

  1. 총 노드의 개수가 같을때 노드의 레벨별로 비교한다.

  1. 레벨별로 비교를 하다가 요소의 타입, 예를들어 p -> div 바뀌거나, 다른 컴포넌트가 들어가는 등의 변화를 감지하면 해당 노드를 포함한 하위 노드를 제거(언마운트)한 후 새로 생성한다.

  2. 요소의 타입이 같고 속성만 같은경우는 내부 가상돔 객체의 프로퍼티 값만 수정한다.

  3. 같은 레벨간의 형제 요소들간의 key를 부여하면 기존 트리의 자식과 새로운 트리의 자식이 일치하는지 확인하여 불필요한 비교를 줄일 수 있다.

    유니크한 키는 데이터상 절대 변하지 않은 key 값으로 사용하자, index를 사용하는건 최후의 방법!

Answer2. 데이터 타입의 따른 속성 비교, Object.is()

근데 리액트는 자바스크립트의 언어이다. 자바스크립트의 데이터는 원시형과 참조형 데이터로 나뉘고 각각의 Equal 연산은 다르게 동작하고 있다.

  • 원시형 (문자 타입, 숫자 타입, Boolean 타입 등): 값 자체를 비교함
  • 참조형 (배열 타입, 객체 타입, 함수형 타입 등): 주소 값을 비교함

따라서 리액트의 비교 연산 알고리즘을 모르고 사용한다면, 데이터가 변화하지 않는 등의 개발자의 의도와 다르게 실행될 수 있다. (실제로 현업에서 많은 버그를 발생시킴)

리액트는 Object.is로 먼저 비교를 수행한 다음에 Object.is에서 수행하지 못하는 비교, 즉 객체 간 얕은 비교(객체의 첫 번째 깊이에 존재하는 값만 비교)를 한 번 더 수행한다.

- 얕은 비교(Shallow Compare)란? 원시타입은 값을 비교한다. 객체 등 참조타입의 경우 참조값만 비교한다. 
- 깊은 비교(Deep Compare)란? 객체 내 중첩된 객체까지 값을 비교한다. 

리액트는 state, props의 데이터의 값이 변경되면 렌더링이 일어난다.
props는 객체이고, 기본적으로 리액트는 props에서 꺼내온 값을 기준으로 렌더링을 수행하기 때문에 일반적인 케이스에서는 얕은 비교로 충분하여 얕은 비교를 사용한다.

만약 props가 깊은 객체를 가지고 있다면 어떻게 될까?
일반적인 컴포넌트 같은 경우에는 대부분 렌더링될것이다.
그러나 React.memo()를 사용하여 컴포넌트를 최적화 하고 싶을때는 예상하지 못한 케이스가 발생한다.

깊은 객체의 경우, 실제로 데이터가 변경된 값이 없음에도 불구하고, 데이터가 변경되었다고 감지하여 (값은 똑같으나 주소값이 달라지는 경우), 메모이제이션 된 컴포넌트를 반환하지 못한다.

만약 이럴 경우는 Deep Compare 방식을 채택하여 메모이제이션을 할 수 있다.

import { isEqual } from 'lodash';

const DeepCompareComponent = React.memo(MyComponent, (prevProps, nextProps) => isEqual(prevProps, nextProps));

.
.
.
.
.

[11/12 업데이트]

리액트 내부 객체 , Fiber

리액트 버전 16부터 등장한 재조정 엔진이다. 핵심 기능은 증분 렌더링으로, 렌더링 작업을 여러 개의 덩어리로 나누어 처리하는 것이다.
이로써 새로운 업데이트가 들어올 때 작업을 일시 중지, 중단, 재사용 등을 통해 업데이트의 우선순위를 지정하는 기능, 새로운 동시성 기본 기능을 사용할 수 있게 되었다.

  • useTransition UI 업데이트 중 긴 연산(데이터 패칭 등)과 즉시 반영해야하는 입력을 분리한다.
import { useTransition } from "react";

function Example() {
  const [isPending, startTransition] = useTransition();
  const [data, setData] = useState([]);

  const handleChange = (e) => {
    startTransition(() => {
      // 우선순위 낮은 작업 (렌더링 지연 가능)
      setData(fetchFilteredData(e.target.value));
    });
  };

  return (
    <>
      <input onChange={handleChange} />
      {isPending ? <p>Loading...</p> : <List data={data} />}
    </>
  );
}
  • useDeferredValue 값 자체를 지연(defer) 시켜서 렌더링을 느리게 처리할 수 있다.
import { useDeferredValue } from "react";

function Search({ query }) {
  const deferredQuery = useDeferredValue(query);
  const results = useMemo(() => searchData(deferredQuery), [deferredQuery]);

  return <ResultsList results={results} />;
}
  • Suspense + Streaming SSR "페이지 껍데기(shell)를 먼저 보내고, 준비되는 조각부터 순차적으로(증분) 흘려보내며, 클라이언트에서 순서대로 하이드레이션" 하는 기술입니다. (느린 데이터/큰 컴포넌트가 있어도 초기 TTFB를 낮추고 체감 성능을 끌어올립니다.)
profile
🙋🏻‍♀️

0개의 댓글