반디북
모던 리액트 Deep Dive
Ch2
준영

JSX (JavaScript eXtension)

  • JS를 확장한 문법
  • JS 표준 코드가 아닌 페이스북이 임의로 만든 새로운 문법으로 반드시 트랜스컴파일러를 거쳐 JS 코드로 되어 실행된다.
  • @babel/plugin-transform-react-jsx 플러그인을 통해 JSX 구문을 JS가 이해할 수 있는 형태로 변환한다.

DOM (Document Object Model)

  • 웹페이지의 콘텐츠와 구조를 어떻게 보여줄지에 대한 정보를 담고 있는 모델

브라우저 렌더링 과정

1. 사용자가 접근한 주소에 대해 브라우저는 HTML을 다운로드
2. 렌더링 엔진은 HTML을 파싱하여 DOM Tree를 생성
3. 2의 과정중 CSS 파일을 만났다면, CSS 파일을 다운로드
4. 브라우저의 렌더링 엔진은 다운받은 CSS 파일도 파싱하여 CSSOM Tree를 생성
5. 브라우저는 DOM Tree를 순회하며 실제 사용자에게 보여지는 DOM 노드들을 방문하여 작업
6. 5의 과정을 통해 방문한, 눈에 보이는 노드들만 CSSOM Tree의 정보로 스타일링.
	 이때 실행되는 작업은
	 - 레이아웃: 각 DOM 노드가 브라우저의 어떤 좌표에 그려질지 계산
	 - 페인팅: 레이아웃 단계 이후, 실제로 DOM 노드에 유효한 모습을 그리는 과정

가상 DOM이 탄생하게 된 배경은 무엇일까?

  • 브라우저가 웹페이지를 렌더링하는 과정은 매우 복잡하고 많은 비용이 들고, 요즘 대부분의 웹페이지들은 사용자의 인터랙션을 통해 다양한 정보를 노출한다.
  • 따라서 렌더링이 끝난 이후에도, 사용자의 인터랙션을 통해 웹페이지가 변경되는 상황도 고려해야 한다.
  • 만약 사용자 인터랙션으로 요소의 크기, 위치를 다시 계산해야 하는 경우, 레이아웃과 리페인팅이 발생하며 많은 비용이 발생한다.

가상 DOM

  • 리액트가 관리하는 메모리 상의 가상의 DOM
  • 웹페이지가 표시해야 할 DOM을 메모리에 저장해두고, 실제 변경에 대한 준비가 되었을때 실제 DOM과 비교하여 변경된 부분만 실제 화면에 반영

→ 이렇게 메모리에서 계산하는 과정을 한 번 거쳐 렌더링 과정을 최소화하고 비용을 줄일 수 있다.

과거 리액트의 Reconciliation 알고리즘

이전에는 스택 알고리즘으로 이뤄져, 스택에 렌더링 작업이 쌓이면 스택이 빌 때까지 동기적으로 처리되었다.

따라서 싱글 스레드로 동작하는 JS의 특성상 동기 작업은 중단될 수 없어 굉장히 비효율적이었다.

→ 이를 해결하기 위해 등장한 것이 파이버 이다.

리액트 파이버

  • 가상 DOM과 렌더링 최적화를 가능하게 해줌
  • 리액트에서 관리하는 평범한 JS 객체
  • 파이버는 파이버 재조정자(fiber reconciler)가 관리하며 가상 DOM과 실제 DOM을 비교하여 변경사항을 수집한다.
    • 이때 변경사항이 존재한다면, 해당 파이버를 기준으로 화면에 렌더링을 요청한다.

Reconciliation

  • 가상 DOM과 실제 DOM을 비교하여 변경사항을 찾는 알고리즘

파이버의 동작

파이버는 하나의 작업 단위로 구성되어 있고, 다음과 같은 단계를 거쳐 처리된다.

  1. 렌더 단계
    • 파이버의 작업, 우선순위를 지정하거나 중지시키거나 버리는 등의 비동기 작업이 일어난다.
  2. 커밋 단계
    • DOM에 실제 변경 사항을 반영하기 위한 작업이 일어난다.
    • commitWork()가 실행되는데 동기식으로 일어나 중단될 수 없다.

파이버의 구조

  • 파이버는 컴포넌트가 최초로 마운트될 때 생성되어 이후 가급적이면 재사용된다.
  • 파이버는 다음과 같은 속성들을 갖는다.
    • tag : 파이버는 하나의 element에 하나가 생성되는 1:1 관계를 갖는다.
      • element는 리액트 컴포넌트, HTML의 DOM 노드, 혹은 다른 어떤 것일 수도 있다.
    • stateNode : 파이버 자체에 대한 참조 정보를 가지며, 이를 통해 리액트는 파이버와 관련된 상태에 접근한다. ✅ 파이버가 연결된 element와 연관된 state에 대한 정보를 의미하는 것 같다.
  • child, sibling, return : 파이버 간 관계 개념을 나타내는 속성
    • children이 아닌 child인 이유는 자식이 하나이고 sibling을 통한 형제 관계로 이루어지기 때문
      • 추가적으로 index라는 속성을 통해 몇번째 형제인지 표현한다.
    • return은 부모 파이버를 의미한다.

리액트의 핵심 원칙

  • UI를 문자열, 숫자, 배열과 같은 값으로 관리하자!

리액트 파이버 트리

  • 파이버 트리에는 2가지가 존재한다.
    • 현재 모습을 담은 current 트리
    • 작업중인 상태를 나타내는 workInProgress 트리
  • 리액트 파이버 작업이 끝나면 리액트는 더블 버퍼링을 통해 트리를 변경한다.
    • “커밋 단계”에서 포인터만을 변경하여 current 트리를 workInProgress 트리로 바꿔버림
      • current 트리 기준, setState 등으로 업데이트가 발생하면 파이버는 workInProgress 트리를 빌드하기 시작
        • 이때 새로운 파이버를 생성하는 것이 아닌, 기존 파이버에 props를 받아 내부에서 처리한다.
      • 빌드가 끝나면 다음 렌더링에 workInProgress 트리를 사용
      • workInProgress 트리가 반영이 완료되면 currentworkInProgress로 변경

파이버의 작업 순서

1. beginWork() 함수를 통해 파이버 작업 수행
2. 작업이 끝나면 completeWork() 함수를 실행해 파이버 작업 완료
3. 형제가 있다면 형제로 넘어감
4. 모든 형제가 작업이 완료되었다면 "return"으로 부모에게 돌아가 작업이 완료되었음을 알림

과거의 함수형 컴포넌트

  • 별도의 상태 없이 요소를 정적으로 렌더링하는 것이 목적

클래스 컴포넌트

  • React.Component, React.PureComponent를 extends 해야 한다.
  • state항상 객체여야 하고 변화가 있을 때마다 리렌더링이 일어난다.

클래스 컴포넌트의 생명주기 메서드

  • 생명주기 메서드가 실행되는 시점은 크게 3가지로 나뉜다.

    • mount : 컴포넌트가 생성되는 시점
    • update : 이미 생성된 컴포넌트의 내용이 변경되는 시점
    • unmount : 컴포넌트가 더 이상 존재하지 않는 시점
  • 생명주기 메서드

    Untitled

Component vs PureComponent

  • Component는 state가 변경될 때마다 렌더링이 일어나지만, PureComponent는 state 값에 대해 얕은 비교를 수행해 결과가 다를 때만 렌더링을 수행한다.

클래스 컴포넌트의 한계

  1. 데이터 흐름 추적이 어렵다.

    • 여러 메서드에서 state가 변경될 수 있어 state의 흐름을 추적하기 어렵다.
    • 생명주기 메서드 순서대로 코드 작성이 강제되는 것은 아니기 때문에 읽기 어렵다.
  2. 어플리케이션 내부 로직의 재사용이 어렵다.

    • 재사용을 위하 고차 컴포넌트로 감싸거나, props를 사용할 경우 래퍼 지옥에 빠질 수 있다.

    • 래퍼 지옥

      Untitled

  • 기능이 많아질수록 컴포넌트의 크기가 커진다.
    • 내부 로직이 커질 경우, 컴포넌트의 크기가 매우 커진다.
  • 코드 크기 최적화가 어렵다.
    • 사용하지 않는 메서드도 트리 쉐이킹이 되지 않고 번들에 포함되어 번들 크기를 줄이는 데 어려움이 있다.
  • 핫 리로딩이 상대적으로 불리하다.

함수 컴포넌트

  • 리액트 16.8에서 훅이 등장한 이후로 각광 받게 되었다.
  • 클래스 컴포넌트에 비해 간결해졌으며 this 바인딩을 조심할 필요도 없어졌다.

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

  • 생명주기 메서드의 부재
    • 함수 컴포넌트에서는 useEffect를 통해 비슷하게 구현이 가능하다.
  • 렌더링된 값
    • 함수 컴포넌트는 렌더링이 일어날 때마다 해당 순간의 props와 state를 기준으로 렌더링된다.
    • 하지만 클래스 컴포넌트는 시간의 흐름에 따라 변화하는 this를 기준으로 렌더링이 일어나 값이 고정되지 않는다.

렌더링

  • HTML과 CSS를 기반으로 웹페이지에 필요한 UI를 그리는 과정

리액트의 렌더링이란?

  • 브라우저가 렌더링에 필요한 DOM 트리를 만드는 과정
  • 리액트 어플리케이션에 존재하는 모든 컴포넌트들이 현재 자신의 state와 props 값을 기반으로 어떤 DOM 트리를 만들어 브라우저에 제공할 것인지 계산하는 과정

렌더링이 일어나는 경우

  1. 최초 렌더링
    • 사용자가 처음 진입하였을때 수행되는 렌더링
  2. 리렌더링
    • 최초 렌더링 이후 발생하는 모든 렌더링
      • 클래스 컴포넌트의 forceUpdate
      • 상태의 변경
      • props의 변경
      • key prop의 변경
        • key는 형제 요소들 사이에서 동일한 요소를 식별하는 값
      • 부모 컴포넌트가 렌더링되는 경우

리액트의 렌더링 프로세스

  • 컴포넌트의 루트에서부터 아래로 내려가면서 업데이트가 필요하다고 지정돼 있는 모든 컴포넌트를 탐색
  • 업데이트가 필요한 컴포넌트를 발견하면,
    • 클래스 컴포넌트의 경우, render() 호출
    • 함수 컴포넌트의 경우, FunctionComponent() 호출
  • 이후 결과물 저장
  • 결과물은 JSX 문법으로 구성되어 있으며, JS로 컴파일되며 React.createElement()를 호출하는 구문으로 변환된다.
  • 위 과정을 통해 렌더링 결과물을 수집하고 가상 DOM과 비교하여 변경사항을 수집한다.

렌더와 커밋

  • 렌더 단계
    • 컴포넌트를 렌더링하고 변경사항을 계산하는 모든 작업
    • 즉, 렌더링 프로세스에서 컴포넌트를 실행한 결과와 가상 DOM을 비교하여 변경이 필요한 컴포넌트를 체크함
      • type, props, key를 비교하여 하나라도 변경되었다면 체크
  • 커밋 단계
    • 변경 사항을 실제 DOM에 적용하여 사용자에게 보여주는 단계
    • 이 단계가 끝나야 브라우저의 렌더링이 발생

렌더링 시나리오

메모이제이션 또한 비용이다!

이미 가상 DOM이 존재하니 렌더링 성능을 아껴야 한다!

도은

2.1 JSX란?

  • JSX는 ECMAScript라고 불리는 자바스크립트 표준의 일부 X
  • 즉, 여러 브라우저에 의해서 실행되거나 표현되도록 만들어진 구문 X
  • 즉, 트랜스파일러를 거쳐야 자바스크립트 런타임이 이해할 수 있는 자바스크립트 코드로 변환
  • JSX의 설계 목적은?
    • 다양한 트랜스파일러에서 다양한 속성을 가진 트리 구조를 토큰화해 ECMAScript로 변환하는 데 초점
    • JSX는 HTML, XML 외에도 다른 구문으로도 확장될 수 있게끔 고려되어 있다.
    • JSX는 자바스크립트 내부에서 표현하기 까다로웠던 XML 스타일의 트리 구문을 작성하는 데 많은 도움을 준 문법

2.1.1 JSX의 정의

  • JSX의 구성 - 4가지 컴포넌트
    • JSXElement
    • JSXAttributes
    • JSXChildren
    • JSXStrings

JSXElement

  • JSX를 구성하는 가장 기본 요소
  • HTML의 element와 비슷한 역할
  • JSXElement가 되기 위해 다음과 같은 형태
    • JSXOpeningElement : <JSXElement>
    • JSXClosingElement : </JSXElement>
    • JSXSelfClosingElement : <JSXElement />
    • JSXFragment : <></>
  • 리액트에서 컴포넌트을 만들 때 대문자로 시작해야 하는 이유?
    • JSXElement에 명시되어 있다.
    • HTML 태그명과 사용자가 만든 컴포넌트 태그명을 구분 짓기 위함
  • JSXElementName: JSXElement의 요소 이름으로 쓸 수 있는 것
    • JSXIdentifier: JSX 내부에서 사용할 수 있는 식별자. 자바스크립트 식별자 규칙과 동일하다. <$> , <_> 도 가능하지만, 그 외 특수문자로는 시작할 수 없다.
    • JSXNamespacedName: : 을 통해 서로 다른 식별자로 이어주는 것도 식별자로 취급. <foo:bar> , <foo:bar:baz>
    • JSXMemberExpression: . 을 통해 서로 다른 식별자로 이어주는 것. <foo.bar>, <foo.bar.baz>

JSXAttributes

  • JSXElement에 부여할 수 있는 속성을 의미
    • JSXSpreadAttributes : 자바스크립트의 전개 연산자와 동일한 역할
    • JSXAttribute : 속성을 나타내는 키와 값으로 짝을 이루어서 표현

JSXChildren

  • JSXElement의 자식 값

    • JSXText
    • JSXElement
    • { JSXChildrenExpression: (optional) }
    // 이 함수를 리액트에서 렌더링하면 "foo"라는 문자열이 출력
    export const App = () => {
      return <>{(() => 'foo')()}</>;
    };

JSXStrings

  • HTML에서 사용 가능한 문자열은 모두 가능

2.1.3 JSX는 어떻게 자바스크립트에서 변환될까?

  • 리액트에서 JSX를 변환하는 @babel/plugin-transform-react-jsx 플러그인
  • 위 플러그인은 **JSX 구문 → 자바스크립트가 이해할 수 있는 형태**로 변환

다음과 같은 JSX 코드

const ComponentA = <A required={true}>Hello World</A>;
 
const ComponentB = <>Hello World</>;
 
const ComponentC = (
  <div>
    <span>Hello World</span>
  </div>
);

이를 변환한 결과는 다음과 같다.

'use strict'
 
var ComponentA = React.createElement(
  A,
  {
    required: true,
  }
  'Hello World',
)
var ComponentB = React.createElement(React.Fragment, null, 'Hello World')
var ComponentC = React.createElement(
  'div',
  null,
  React.createElement('span', null, 'Hello World')
)

리액트 17, 바벨 7.9.0 이후 버전에서 추가된 자동 런타임(automatic runtime)으로 트랜스파일한 결과는 다음과 같다

'use strict';
 
var _jsxRuntime = require('custom-jsx-library/jsx-runtime');
 
var ComponentA = (0, _jsxRuntime.jsx)(A, {
  required: true,
  children: 'Hello World',
});
var ComponentB = (0, _jsxRuntime.jsx)(_jsxRuntime.Fragment, {
  children: 'Hello World',
});
var ComponentC = (0, _jsxRuntime.jsx)('div', {
  children: (0, _jsxRuntime.jsx)('span', {
    children: 'Hello World',
  }),
});

🤔 _jsxRuntime... 호출되는 과정이 이해가 잘 안된다..

크게 보면 var ComponentA = ()() 형태인데, 결국 트랜스파일 되었을 때 ComponentA의 타입은 무엇인가?

  • 결론적으로, ‘object’
  • (0, _jsxRuntime.jsx)를 호출하고 (A, { … })를 호출하는 형태
  • (0, _jsxRuntime.jsx)_jsxRuntime.jsx 함수의 this 바인딩을 undefined로 설정하는 것
  • this가 의도하지 않은 객체를 참조하는 상황을 방지하기 위함
  • _jsxRuntime.jsx() 처럼 (0, _jsxRumtime.jsx)()는 호출하는 것이지만, this를 undefined로 설정하여 호출하는 것

JSX가 변환되는 특성을 활용해보자

import { createElement } from 'react';
 
// ❌ props 여부에 따라 children 요소만 달라지는 경우
// 굳이 번거롭게 전체 내용을 삼항 연산자로 처리할 필요가 없다.
// 이 경우 불필요한 코드 중복이 일어난다.
function TextOrHeading({ isHeading, children }) {
  return isHeading ? <h1>{children}</h1> : <span>{children}</span>;
}
 
// ⭕️ JSX가 변환되는 특성을 활용한다면 다음과 같이 간결하게 처리할 수 있다.
function TextOrHeading({ isHeading, children }) {
  return createElement(isHeading ? 'h1' : 'span', children);
}

JSX 반환값이 결국 React.createElement로 귀결된다는 사실을 파악한다면 이런 식으로 쉽게 리팩터링 가능

2.2 가상 DOM과 리액트 파이버

  • 리액트의 가상 DOM이 무엇인지
  • 실제 DOM에 비하면 어떤 이점이 있는지
  • 가상 DOM을 다룰 때 주의할 점은 무엇인지

2.2.1 DOM과 브라우저 렌더링 과정

  • DOM(Document Object Model)
    • 웹페이지에 대한 인터페이스
    • 브라우저가 웹페이지의 콘텐츠와 구조를 어떻게 보여줄지에 대한 정보
  • 브라우저가 웹사이트 접근 요청을 받고 화면을 그리는 과정
    1. 브라우저가 사용자가 요청한 주소로 방문해 HTML 파일을 다운로드
    2. 브라우저의 렌더링 엔진은 HTML을 파싱 → DOM트리 구성
    3. 2번 과정에서 CSS 파일을 만나면 CSS 다운로드
    4. 브라우저의 렌더링 엔진은 CSS 파싱 후 CSSDOM 구성
    5. 브라우저는 2번에서 만든 DOM 노드를 순회 (눈에 보이는 노드만 방문)
    6. 눈에 보이는 노드에 대한 CSSOM 정보를 찾고 발견한 CSS 스타일 정보를 노드에 적용. CSS를 적용하는 과정을 크게 두 가지
      • 레이아웃(layout, reflow): 각 노드가 브라우저 화면의 어느 좌표에 정확히 나타나야 하는지 계산하는 과정
      • 페인팅(painting): 레이아웃 단계를 거친 노드의 색과 같은 실제 유효한 모습을 그리는 과정

2.2.2 가상 DOM의 탄생 배경

  • 🚨 브라우저가 웹페이지를 렌더링하는 과정은 매우 복잡하고 많은 비용
  • 렌더링된 후에도 리렌더링은 많은 비용
    • 특정 요소의 색상이 변경되는 경우, 페인팅만 일어나므로 비교적 빠른 처리 가능
    • 요소의 위치나 크기를 재계산하는 경우, 레이아웃과 리페인팅 같이 발생
    • 자식 요소를 가지고 있는 경우, 하위 자식 요소도 덩달아 변경 필요 → 더더 많은 비용 필요
  • SPA에서는 더욱 많은 비용
    • 하나의 페이지에서 계속해서 요소의 위치를 재계산하기 때문
  • DOM의 모든 변경 사항을 추척하기보다, 결과적으로 만들어지는 DOM 결과물만을 받는 것이 유용할 것

🤔 가상 DOM이 없었다면? 모든 변경 사항을 추척해야했다면?

  • DOM 조작의 어려움

    • 상태가 변경될 때 자동으로 가상 DOM이 업데이트되는데, 만약 없었다면, 상태 변화를 모니터링하고 필요한 경우에만 DOM을 업데이트하는 로직을 구현해야 했을 것
  • 일괄 처리의 어려움

    • 가상 DOM은 일괄처리가 가능한데, 변경 사항을 모아서 한 번에 실제 DOM에 반영함으로써 브라우저의 리플로우와 리페인트를 최소화하고 성능을 개선
  • 가상 DOM은 웹페이지가 표시해야 할 DOM을 일단 메모리에 저장하고

  • 리액트가 실제 변경에 대한 준비가 완료되었을 때 실제 브라우저에 DOM을 반영

  • DOM 계산을 브라우저가 아닌 메모리에서 계산하는 과정을 거치게 되며

  • 렌더링 과정을 최소화

🤔 가상 DOM 원리

  1. 실제 DOM으로부터 가상 DOM을 만든다(가상 DOM은 메모리 상에 존재하는 하나의 객체다)
  2. 변화가 생기면 새로운 버전의 가상 DOM을 만든다.
  3. old 버전의 가상 DOM과 new 버전의 가상 DOM을 비교한다.(diff algorithm)
  4. 비교 과정을 통해서 발견한 차이점을 실제 DOM에 적용한다.

2.2.3 가상 DOM을 위한 아키텍처, 리액트 파이버

  • 리액트는 여러 번의 렌더링 과정을 어떻게 최소한의 렌더링 단위로?
  • 가상 DOM과 렌더링 과정 최적화를 가능하게 해주는 것 → 리액트 파이버(React Fiber)

🤔 DOM

js가 html을 조작하기 편하게 만든 어떤 객체

리액트 파이버란?

  • 리액트에서 관리하는 평범한 자바스크립트 객체
  • 파이버는 fiber reconciler가 관리
    • 가상 DOM과 실제 DOM을 비교해 변경 사항 수집
    • 둘의 차이가 있으면 변경에 관련된 정보를 가지고 있는 파이버를 기준으로
    • 화면에 렌더링을 요청하는 역할
  • 재조정(reconciliation)
    • 가상 DOM과 실제 DOM을 비교하는 작업(알고리즘)
  • 리액트 파이버가 하는 일
    • 작업을 작은 단위로 분할하고 쪼갠 다음, 우선순위를 매긴다.
    • 이러한 작업을 일시 중지하고 나중에 다시 시작할 수 있다.
    • 이전에 했던 작업을 다시 재사용하거나 필요하지 않은 경우에는 패기 가능
  • 파이버는 비동기
  • 과거에는 스택 알고리즘 → 동기적 → 다른 작업이 수행되고 싶어도 중단 불가능
  • 파이버는 가급적이면 컴포넌트를 재사용
  • 파이버가 실행되는 경우
    • state가 변경
    • 생명주기 메서드 실행
    • DOM의 변경이 필요한 시점
  • 파이버는 직접 바로 처리하기도, **스케줄링**하기도
    • 작업들을 작은 단위로 나눠 처리하기도
    • 우선순위가 높은 작업은 빠르게 처리하기도
    • 낮은 작업은 연기시키는 등 유연하게 처리

리액트 파이버 트리

  • 파이버 트리는 리액트 내부에 두 개 존재
  • (1) 현재 모습을 담은 파이버 트리, (2) 작업 중인 상태를 나타내는 workInProgress 트리
  • 리액트 파이버의 작업이 끝나면
    • 단순히 포인터만 변경해 workInProgress 트리를 현재 트리로 교체
    • 이런 기술을 더블 버퍼링

파이버의 작업 순서

  1. 리액트는 beginWork() 함수를 실행해 파이버 작업을 수행. 더 이상 자식이 없는 파이버를 만날 때까지 트리 형식으로 시작
  2. 1번에서 작업이 끝난다면 completeWork() 함수를 실행해 파이버 작업을 완료
  3. 형제가 있다면 형제로 넘어간다.
  4. 2번, 3번이 모두 끝났다면 return으로 돌아가 자신의 작업이 완료됐음을 알린다.

🤔 더 이상 자식이 없는 파이버를 만날 때까지 트리 형식으로 시작?

트리 구조를 따라서 시작하여 업데이트 작업을 수행하는 과정에서 더 이상 자식이 없는 파이버를 만날 때까지 순회한다는 뜻

setState 등으로 업데이트가 발생하면?

  • 아까 파이버 트리는 리액트 내부에 2개가 존재한다고 했었는데
    • current 트리
    • workInProgress 트리
  • setState로 인한 업데이트 요청을 받아 workInProgress 트리를 다시 빌드하기 시작
    • workInProgress 트리를 만드는 것도 앞서 트리를 만드는 과정과 동일
    • 처음부터 새로 만드는 것 ❌
    • current 트리를 가지고 업데이트된 props를 받아 파이버 내부에서 처리

가급적 새로운 파이버를 생성 X

  • 리액트 애플리케이션에서 트리를 비교해서 업데이트하는 작업은 시도때도 없이…
  • 이러한 반복적인 재조정마다 새롭게 파이버 자바스크립트 객체를 만드는 것은 낭비
  • 기존에 있는 객체를 재활용하기 위해
    • 속성값만 초기화하거나 바꾸는 형태로 트리 업데이트

2.2.4 파이버와 가상 DOM

  • 파이버가 재조정하고 current 트리로 업데이트하는 과정은 비동기
  • 실제 브라우저 구조인 DOM에 반영하는 것은 동기적으로 이뤄져야 한다.
  • 또, 처리하는 작업을 메모리상에서 먼저 수행해서 최종적인 결과물만 실제 브라우저 DOM에 적용

🤔 정리해보자면

  • 가상 DOM은 객체인 것
  • 특정 state에 변화가 생겼다는 알림을 받으면 실제 DOM 전체를 렌더링 시켜주는 것이 아니라, 가상 DOM을 렌더링
  • 상태 변화 발생: 컴포넌트의 상태가 변경
  • 가상 DOM 트리 생성: 새로운 가상 DOM 트리가 생성
    • 가상 DOM 트리는 ReactDOM 이 생성
    • 해당 상태를 사용하는 컴포넌트와 그 하위 컴포넌트 부분을 업데이트
      import ReactDOM from 'react-dom';
  • 재조정자 동작: 재조정자가 이전 가상 DOM 트리와 새로운 가상 DOM 트리를 비교하여 변경된 부분을 탐색
  • 파이버 트리 생성: 재조정자는 변경된 부분을 나타내는 파이버 트리를 생성
    • 파이버 트리의 생성과 관리는 재조정자가 담당
  • 파이버 트리 순회: 파이버 트리를 순회하면서 각 파이버에 대해 작업을 수행
    • Begin Work: 새로운 가상 DOM을 생성하고, 자식 파이버를 생성 또는 업데이트하며, props와 state를 업데이트하고, side effects를 수집
    • Complete Work: DOM 노드 생성 또는 업데이트를 준비하고, side effects를 연결하며, 자식 파이버의 결과를 통합
  • 실제 DOM 업데이트: 변경된 부분만 실제 DOM에 반영
    • 변경 사항을 실제 DOM에 반영하는 단계는 Commit Phase라고 하는데
    • 수집된 변경 사항을 실제 DOM에 반영
    • 실제 DOM 업데이트는 리액트의 **커미터(Committer)**가 수행

🤔 실제 DOM을 조작하는 것보다, 가상 DOM을 사용하는 것이 왜 더 좋은가?

  • 브라우저를 새롭게 렌더링 시키는 비용보다, 객체를 새로 만드는 비용이 훨씬 더 저렴
  • 거기서 변화가 생긴 내용을 비교해 마지막에 가서는 꼭 필요한 부분만 real dom에 적용시키는 방식으로 효율성을 높인 것
  • 일괄 처리면에서, 레이아웃 계산과 리플로우/리페인트 작업을 최소화하여 성능을 향상
  • 필요한 변경 사항만을 식별하여 실제 DOM 조작
  • 상태 변화가 없는 경우에는 DOM 조작을 최소화

결론적으로, 가상 DOM이 없었다면

  • 비교 라는 개념이 빠지기 때문에, 매번 DOM 요소를 직접적으로 조작하여 변경 사항을 반영하려고 할 것 (변경이 이루어지긴 해도, 값이 동일할 경우라도)
  • 또, 만약 부모에서 변경이 일어난다면 자식 DOM 요소들까지 모두 리플로우와 리페인팅이 필요할 수 있다.
  • 가상 DOM을 이용한다면 자식 DOM 요소일지라도 변경이 없다면 업데이트하지 않을 수 있다.
    • 가상 DOM에서도 부모 DOM 요소에 종속적으로 자식 DOM 요소의 업데이트를 수행하지만 (객체 값을 변경) 실제 DOM에 반영할 때는 정말 변경이 존재하는 DOM 요소만 업데이트를 수행하는 것

2.3 클래스 컴포넌트와 함수 컴포넌트

  • 함수 컴포넌트가 처음 나올적에는..
    • stateless functional component , 이른바 무사태 함수 컴포넌트라고
    • 별도의 상태 없이 단순히 어떠한 요소를 정적으로 렌더링하는 것이 목표

2.3.1 클래스 컴포넌트

  • constructor() : 컴포넌트가 초기화되는 시점에 호출
    • state 초기화 가능
  • props : 함수에 인자를 넣는 것과 비슷하게, 컴포넌트에 특정 속성을 전달하는 용도
  • state : 클래스 컴포넌트 내부에서 관리하는 값을 의미. 항상 객체여야 한다.

클래스 컴포넌트 생명주기 메서드

  • 생명주기 메서드가 실행되는 시점 크게 3가지
    • 마운트
    • 업데이트
    • 언마운트
  1. render()
    • 클래스 컴포넌트의 유일한 필수 값
    • 컴포넌트가 UI를 렌더링하기 위해 쓰임
    • 이 렌더링은 **마운트**와 업데이트 과정에서 일어난다.
    • render() 함수는 항상 순수해야 한다.
    • 즉, 내부에서 상태 업데이트가 일어나서는 안 된다.
  2. componentDidMount()
    • 클래스 컴포넌트가 마운트되고 준비가 됐다면
    • 그다음으로 호출되는 생명주기 메서드
    • 컴포넌트가 마운트되고 준비되는 즉시 실행
    • 내부에서 상태 업데이트 함수 실행 가능
  3. componentDidUpdate()
    • 컴포넌트 업데이트가 일어난 이후 바로 실행
    • 일반적으로 state나 props의 변화에 따라 DOM을 업데이트하는 등에 쓰임
  4. componentWillUnmount()
    • 컴포넌트가 더 이상 사용되지 않기 직전에 호출
    • 메모리 누수나 불필요한 작동을 막기 위한 클린업 함수를 호출하기 위함
    • 내부에서 상태 업데이트 함수를 호출할 수 없다.
  5. shouldComponentUpdate()
    • state나 props의 변경으로 컴포넌트가 다시 리렌더링되는 것을 막고 싶다면 사용
    • 기본적으로 this.setState가 호출되면 컴포넌트는 리렌더링
    • 이 생명주기 메서드를 활용하면, 컴포넌트에 영향을 받지 않는 변화에 대해 정의 가능
    • 즉, 조건으로 컴포넌트 업데이트가 가능
  6. static getDerivedStateFromProps()
    • 다음에 올 props를 바탕으로 현재의 state를 변경하고 싶을 때
  7. getSnapShotBeforeUpdate()
    • componentWillUpdate() 를 대체할 수 있는 메서드
    • DOM이 업데이트되기 직전에 호출
    • 렌더링되기 전에 작업을 처리하는 데 유용

클래스 컴포넌트의 한계

  • 함수 컴포넌트에 훅을 도입한 새로운 패러다임 왜 등장?
    • 데이터의 흐름을 추적하기 어려움
      • 생명주기 메서드는 실행되는 순서가 있지만, 순서와 상관없이 작성되어 코드를 읽는 과정에서 흐름을 파악하기 어려울 수 있다.
    • 기능이 많아질수록 컴포넌트의 크기가 커진다
      • 내부에서 처리하는 데이터 흐름이 복잡해져 생명주기 메서드 사용이 잦아져
      • 컴포넌트의 크기가 기하급수적으로 벌크업
    • 클래스는 함수에 비해 상대적으로 어렵다
      • this를 비롯한 자바스크립트 작동 방식은 쉽지만은 않다.
    • 코드 크기를 최적화하기 어렵다 (= 번들 크기를 줄이는 데에 어려움)
      • 사용되지 않는 메서드에 대해서 트리 쉐이킹 X
      • 클래스 컴포넌트 내부에 그대로 번들됨
    • 핫 리로딩 하는 데 상대적으로 불리함
      • 실행된 채로 코드 수정 내용이 바로 반영되는 것이 핫 리로딩 덕분인데
      • 최초 렌더링 시에 instance를 생성하고, 그 내부에서 state 값을 관리
      • 즉, 상태값이 초기화
      • 🤔 이거 함수 컴포넌트도 안 되지 않나..

2.3.2 함수 컴포넌트

  • 16.8에서 함수 컴포넌트에서 사용 가능한 훅이 등장하면서 각광
  • 클래스 컴포넌트와 비교했을 때 확실히 간결
  • render 내부에서 필요한 함수를 선언할 때 this 바인딩을 조심할 필요 X
  • state 는 객체가 아닌 각각의 원시값으로 관리 → 훨씬 사용하기 간편

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

  • 둘은 정확하게 어떤 차이?

생명주기 메서드의 부재

  • 클래스 컴포넌트의 생명주기 메서드가 함수 컴포넌트에는 없다.
  • 그 이유
    • 함수 컴포넌트는 props를 받아 단순히 리액트 요소만 반환
    • 클래스 컴포넌트는 render 메서드가 있는 React.Component를 상속받아 구현하는 자바스크립트 클래스이기 때문
    • 즉, 생명주기 메서드는 React.Component 에서 오는 것
  • 함수 컴포넌트는 useEffect 훅을 사용해 componentDidMount , componentDidUpdate , componentWillUnmount 를 비슷하게 구현 가능
    • 비슷할 뿐이지 똑같다는 것은 아니다.
    • 메커니즘이 같을 뿐

함수 컴포넌트와 렌더링된 값

  • 함수 컴포넌트는 렌더린된 값을 고정
    • 함수 컴포넌트는 props를 인자로 받는다.
    • 컴포넌트는 그 값을 변경할 수 없다.
    • 렌더링이 일어날 때마다 그 순간의 값인 props와 state를 기준으로 렌더링
    • props와 state가 변경되면 다시 한 번 그 값을 기준으로 함수가 호출된다.
  • 클래스 컴포넌트는 그렇지 못하다
    • 클래스 컴포넌트는 props를 항상 this로부터 가져온다.
    • 클래스 컴포넌트의 인스턴스의 멤버는 변경 가능한(mutable) 값이다.
    • 부모 컴포넌트가 props를 변경해 컴포넌트가 다시 렌더링되었다는 것은 this.props 의 값이 변경된 것

클래스 컴포넌트를 공부해야 할까?

  • 결론적으로, 클래스 컴포넌트는 죽여질 계획은 없어 보인다.
  • 기존 클래스 컴포넌트 기반의 코드가 존재하는 상태
  • 클래스 컴포넌트를 함수 컴포넌트로 변경하는 것은 단순히 코드를 옮기는 것 이상으로 세심한 주의 필요
  • 당연히 리액트를 배우기 시작했다면 함수 컴포넌트
  • 어느정도 익숙해졌다면 클래스 컴포넌트도 한 번쯤 공부해 볼 만하다.
  • 자식 컴포넌트에서 발생한 에러에 대한 처리는 현재 클래스 컴포넌트로만 가능하므로
    • 에러 처리를 위해서라도 클래스 컴포넌트에 대한 어느정도의 지식은 필요

2.4 렌더링은 어떻게 일어나는가?

  • 브라우저에서, 렌더링 정의
    • HTML과 CSS 리소스를 기반으로 웹페이지에 필요한 UI를 그리는 과정
  • 렌더링이 어떻게 이뤄지느냐에 따라 성능에도 큰 영향
  • 리액트에서의, 렌더링 정의
    • 브라우저가 렌더링에 필요한 DOM 트리를 만드는 과정
  • 리액트의 렌더링은 시간과 리소스를 소비해 수행되는 과정 → 모든 과정이 비용
    • 비용이 클수록 유저의 사용자 경험은 저해
    • 렌더링 과정을 최소한으로 줄여야 한다.

2.4.1 리액트의 렌더링이란?

리액트 애플리케이션 트리 안에 있는 모든 컴포넌트들이 현재 자신들이 가지고 있는 props와 state의 값을 기반으로 어떻게 UI를 구성하고 이를 바탕으로 어떤 DOM 결과를 브라우저에 제공할 것인지 계산하는 일련의 과정을 의미

2.4.2 리액트의 렌더링이 일어나는 이유

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

  1. 최초 렌더링
    • 사용자가 처음 애플리케이션에 진입하면 가장 처음 보여질 결과물
    • 리액트는 브라우저에 이 정보를 제공하기 위해 최초 렌더링을 수행
  2. 리렌더링
    • 최초 렌더링 이후에 발생하는 모든 렌더링을 의미

리렌더링이 발생하는 경우

  • 함수 컴포넌트의 useState()의 두 번째 요소인 setter가 실행되는 경우
  • 함수 컴포넌트의 useReducer()의 두 번째 요소인 dispatch가 실행되는 경우
  • 컴포넌트의 key props가 변경되는 경우
  • props가 변경되는 경우
  • 부모 컴포넌트가 렌더링될 경우 (부모 컴포넌트가 리렌더링되면 자식 컴포넌트도 무조건 리렌더링)

2.4.3 리액트의 렌더링 프로세스

  • 렌더링 프로세스가 시작되면
  • 리액트 컴포넌트의 루트에서부터 차근차근 아래쪽으로 내려가면서
  • 업데이트가 필요하다고 지정돼 있는 모든 컴포넌트를 탐색
  • 수집한 다음, 새로운 트리인 가상 DOM과 실제 DOM을 비교해 (재조정 과정)
  • 실제 DOM에 반영하기 위한 모든 변경 사항을 차례차례 수집
  • 재조정 과정이 끝나면, 모든 변경 사항을 하나의 동기 시퀀스로 DOM에 적용해(순차적으로 수행) 변경된 결과물이 보이도록 한다.

2.4.4 렌더와 커밋

렌더 단계(Render Phase)

  • 변경 사항을 계산하는 모든 작업
  • 렌더링 프로세스에서 컴포넌트를 실행해 이 결과과 이전 DOM을 비교하는 과정을 거쳐
  • 변경이 필요한 컴포넌트를 체크하는 단계
  • 비교하는 것은 크게 3가지
    • type
    • props
    • key
    • ^ 이 중 하나라도 변경된 것이 있으면 변경이 필요한 컴포넌트로 체크

커밋 단계(Commit Phase)

  • 렌더 단계의 변경 사항을 실제 DOM에 적용해 사용자에게 보여주는 과정
  • 이 단계가 끝나야 비로소 브라우저의 렌더링이 발생

이어서 보면

  • 리액트가 먼저 DOM을 커밋 단계에서 업데이트한다면
  • 이렇게 만들어진 모든 DOM 노드 및 인스턴스를 가리키도록 리액트 내부에서 참조를 업데이트
  • 그 다음, 생명주기 개념이 있는 클래스 컴포넌트에서 componentDidMount , componentDidUpdate 메서드 호출하고, 함수 컴포넌트에서는 useLayoutEffect 훅을 호출
  • ⭐️ 중요한 사실
    • 리액트의 렌더링이 일어난다고 해서 무조건 DOM 업데이트가 일어나는 것 X
    • 렌더링을 수행했지만, 커밋 단계까지 갈 필요가 없다면
    • 즉, 변경 사항을 계산했는데 아무런 변경 사항이 감지되지 않는다면
    • 커밋 단계를 생략
  • ⭐️ 렌더링은 항상 동기식
    • 렌더링 과정이 길어질수록 성능 저하
    • 결과적으로 브라우저의 다른 작업을 지연시킬 가능성

2.5 컴포넌트와 함수의 무거운 연산을 기억해 두는 메모이제이션

  • useMemo , useCallback , memo 는 렌더링을 최소한으로 줄이기 위해 제공
  • 이러한 메모이제이션 기법은 언제 사용하는 것이 좋을까?

2.5.1 주장1: 섣부른 최적화는 독이다. 꼭 필요한 곳에만 메모이제이션을 추가하자

  • 꼭 필요한 곳을 신중히 골라서 메모이제이션해야 한다는 입장
  • 메모이제이션도 비용 → 신중히 적용
function sum(a, b) {
  return a + b;
}

위와 같이 간단한 연산을 수행하는 함수의 결과도 메모이제이션해 두는 것이 좋을까?

  • 대부분의 가벼운 작업 자체는 메모이제이션해서
  • 자바스크립트 메모리 어딘가에 두었다가
  • 그것을 꺼내오는 것보다는 매번 이 작업을 수행해 결과를 반환하는 것이 더 빠를 수도 있다.
  • 메모이제이션도 비용
    • 렌더링 또는 재계산이 필요한지 확인하는 비용
    • 이전 결과물을 저장해 두었다가 다시 꺼내와야 하는 비용