React Deep Dive 2강 - 1

RookieAND·2024년 3월 9일
0

React Deep Dive

목록 보기
2/8
post-thumbnail

✒️ JSX

✏️ JSX 란 무엇인가?

JSX is a syntax extension for JavaScript that lets you write HTML-like markup inside a JavaScript file.

  • JSX 는 Meta 에서 독자적으로 개발한 JS 내에 HTML Markup 코드를 삽입할 수 있도록 돕는 문법 확장 기능이다.
  • ECMAScript 나 V8 엔진의 표준이 아니기 때문에 JSX 코드를 그냥 실행하면 문법 오류가 나며, babel 과 같은 트랜스파일러를 통해 코드를 변환한 후 실행해야 한다.

✒️ JSX 의 구조

JSX 는 JSXElement, JSXAttributes, JSXChildren, JSXString 의 4가지 요소로 구성된다.

✏️ JSXElement

  • JSX 를 구성하는 가장 기본적인 요소이며, HTML Element 와 유사한 기능을 한다.
  • JSXElement 를 충족시키기 위해서는 아래 목록 중 하나의 조건에 부합해야 한다.
  1. JSXOpeningElement : 특정 요소가 새롭게 열렸을 경우 쓰인다 (JSXClosingElement 와 한 쌍을 이뤄야 한다.)
  2. JSXClosingElement : 앞선 요소가 열렸을 경우 이를 닫기 위해 쓰이는 형태다.
  3. JSXSelfClosingElement : 요소가 시작되고 스스로 종료되는 형태이다.
  4. JSXFragment : 특정 요소를 내포하고 있지 않지만, 주로 다른 JSX 를 그룹핑할 때 쓰인다.

✏️ JSXElementName

  • JSXElementName 은 JSXElement 요소 명으로 쓰일 수 있는 것을 의미한다.
  1. JSXIdentifier : Javascript 내부에서 사용 가능한 식별자
  2. JSXNamespaceName : : 을 통해 두 개의 JSXIdentifier 를 이은 식별자. 3개 이상은 불가하다.
  3. JSXMemberExpression: . 을 통해 여러 개의 JSXIdentifier 를 이은 식별자.
// JSXNamespaceName
<Foo:bar></Foo:bar>

// JSXMemberExpression
<Layout.Navbar.Item></Layout.Navbar.Item>

✏️ JSXAttribute

  • JSXElement 에 부여 가능한 속성을 의미한다.
  • 단순 속성을 의미하기에 Require 한 값은 아니며 JSXElement 내부에서 존재하지 않아도 문법적인 오류는 아니다.
  1. JSXSpreadAttributes: JS 의 Spread Operator 를 사용하여 값을 할당할 때 쓰이는 표현식을 한번에 넣을 수 있는 방식이다.
  2. JSXAttribute : 속성의 key 와 value 를 한 쌍으로 표현한 방식이다. 키는 JSXAttributeKey, 값은 JSXAttributeValue 로 불린다.
    • JSXAttributeValue 에는 문자열 (큰 따옴표, 작은 따옴표) 및 다른 JSXElement 도 들어갈 수 있다.
    • 추가로 JSXAttributeValue 에는 JS 에서 쓰이는 AssignmentExpression, 즉 값의 할당에 쓰이는 표현식도 추가할 수 있다.
  3. JSXElement : JSX 의 속성으로 또 다른 JSXElement 를 추가할 수 있다.
    • 보통 속성으로 JSXElement 를 추가할 때는 중괄호를 씌우는데, 이는 Prettier 의 설정으로 인한 변환이지 문법적으로는 없어도 된다.
  4. JSXFragment : 별도의 속성을 가지지 않는 Fragment 또한 JSXElement 의 속성으로 추가될 수 있다.

✏️ JSXChildren

  • JSX 는 속성을 가진 트리 구조를 표현하기 위해 제작되었으므로 부모 자식 관계를 나타낼 수 있는데, 이때 자식 관계에 놓인 Element 를 JSXChildren 라고 한다.
  • JSXChildren 은 0개 이상의 JSXChild 로 구성되기 때문에 존재하지 않을 수도, 여러 개가 존재할 수도 있다.
  • JSXChild 를 구성할 수 있는 요소의 목록은 아래와 같다.
  1. JSXText : {, }, <, > 를 제외한 나머지 문자열을 의미한다. JSX 문법과 혼동을 줄 수 있는 요소는 제외된다. (제외된 문자는 따옴표로 감싸서 문자열로 표현할 수 있다.)
  2. JSXElement
  3. JSXFragment
  4. { JSXChildExpression } : JSXChildExpression 는 JS 의 AssignmentExpression 을 의미한다.
// JSXText 가 JSXChild 로 구성된 JSXElement.
<>JSXText 입니다.</>
<>{ '{}, <>' }</> // 제외된 문자도 아래처럼 사용 가능하다

// JSXChildExpression, JS 의 AssignmentExpression 에 해당되는 문법도 사용 가능하다.
<>{() => 'foo'}</>
<>{() => <OtherComponent />}</> // = <><OtherComponent></>

✏️ JSXString

  • HTML 에서 사용 가능한 문자열을 쉽게 JSX 에서도 쓸 수 있도록 고안된 장치이다.
  • \, ", ' 로 구성된 문자열은 별도의 이스케이프 방식을 사용하여 변환해야 한다.

✒️ JSX 의 변환 과정

✏️ @babel/plugin-trasform-react-jsx

  • 기본적으로 JSX 는 ES 문법이 아니기 때문에 이를 표준 문법에 맞게 변환해주는 과정이 필요하다.
  • Meta 에서는 babel 내 @babel/plugin-trasform-react-jsx plugin 을 사용하여 JSX 를 ReactElement 로 변환하는 방식을 채택한다.

어떻게 babel 에서는 별도의 장치 없이 JSX 를 변환할 수 있게 된걸까?

  • React 17 이전에는 JSX 를 사용하는 모듈 최상단에 무조건 import React from 'react' 를 명시해야 했다.
  • 이는 JSX 가 React.createElement 함수로 변환되기 때문에 반드시 React 모듈을 참조해야 했기 때문이다.
  • 하지만 React 17 이후에는 JSX 를 모듈 내부에서 사용할 시, 트랜스 파일링 과정에서 'react/jsx-runtime' 모듈을 참조하도록 구조가 변경되었다.
  • 이 덕에 개발자는 더 이상 JSX 를 사용하는 모듈 최상단에 React 를 import 해주지 않아도 된다.
const Component = (
  <A option="a" key="b">
    Hello World
  </A>
);

// React 17 이전 변환 결과
// createElement(Component, props, children) 함수로 변환된 결과다.
var Component = React.createElement(
  A,
  { option: "a", key: "b" },
  "Hello World"
);

// React 17 이후 변환 결과
import { jsx as _jsx } from "react/jsx-runtime";

var Component = _jsx(
  A,
  {
    option: "a",
    children: "Hello World",
  },
  { key: "b" }
);
  • 기존에는 Children 을 가변 인자 (3번째 인자 이후) 로 받았으나 이제는 props 로 받는다.
  • 더 이상 props 에 key 를 포함하지 않고 별도의 인자로 넘거야 한다.
  • 함수형 컴포넌트에서는 더 이상 defaultProps 을 사용할 수 없다. (딱히 필요하지도 않았고..)

✏️ JSX 가 변환되는 특성을 사용하여 코드 리팩터링

  • 기존에는 props 의 값에 따라 다른 컴포넌트를 렌더링 하도록 조건부 연산자를 사용했으나, 책에서는 아래와 같은 리팩터링 방식을 제안했다.
  • 하지만 개인적인 의견으로는 굳이 createElement 를 사용하지 않고 아래처럼 렌더링 할 컴포넌트를 사전에 할당한 후 넘겨도 되지 않나 싶다.
// AS - IS : isHeading props 의 값에 따라 조건부 렌더링 진행
const TestHeading = ({ isHeading, children }: PropsWithChildren<PropsType>) => {
    return isHeading ? <h1>{children}</h1> : <p>{children}</p>
}

// Book's Solution, createElement 를 직접 사용하여 렌더링 주체 변경
const TestHeading = ({ isHeading, children }: PropsWithChildren<PropsType>) => {
    return createElement(isHeading ? 'h1' : 'p', {}, children)'
}

// My Solution : 굳이 createElement 을 사용하지 않고 렌더링 주체 (JSXElement) 변경
const TestHeading = ({ isHeading, children }: PropsWithChildren<PropsType>) => {
    const RenderComponent = isHeading ? 'h1' : 'p'
    return <RenderComponent>{children}</RenderComponent>
}

✒️ VDOM 과 React Fiber

✏️ DOM 과 브라우저 렌더링 과정

DOM 은 문서 (document) 와 문서 내부의 요소 (Element) 에 JS 로 접근할 수 있도록 설계된 Object 다.

브라우저에서 특정 화면을 보여주기 위한 렌더링 과정은 아래와 같이 작성할 수 있다.

  1. 서버로부터 받은 HTML 파일을 다운로드 하여 브라우저 렌더링 엔진이 이를 파싱하도록 한다.
  2. 파싱 결과로 나온 DOM 노드를 조립하여 DOM 트리를 구축한다.
  3. 서버로부터 CSS 파일을 인계 받는다면 이를 파싱하여 CSSOM 을 만들고, 조립하여 트리를 구축한다.
  4. 생성된 DOM 트리 내 요소 중에서 사용자에게 시각적으로 보여지는 요소를 선별한다.
  5. 선별된 DOM 노드를 순회하여 매칭되는 CSSOM 노드를 적용한다. 이렇게 합쳐진 트리를 Render Tree 라 한다.
  6. 렌더 트리의 루트부터 각 노드를 순회하며 해당 노드가 화면에 어느 위치에 놓여야 하는지를 계산한다. 이를 Layout 과정이라 한다.
  7. 레이아웃 과정이 완료되었다면 렌더 트리의 루트부터 순차적으로 화면에 페인팅을 시작한다. 이를 Painting 과정이라 부른다.

✏️ VDOM 의 탄생 배경

  1. 비싼 렌더링 비용
  • 특정 요소의 스타일 혹은 내부 요소의 변경이 일어날 경우 브라우저는 렌더링 과정을 재수행하게 된다.
  • 이러한 과정은 ReflowRepaint 로 나뉘는데, Reflow 의 경우 필연적으로 Repaint 작업까지 수행하기에 렌더링 비용이 비싸다.
  1. SPA 의 특수한 경우
  • 싱글 페이지 애플리케이션 (SPA) 의 경우 하나의 화면에서 각 요소를 다시 설계하고 그려야 하기 때문에 DOM 을 관리하는데 드는 비용이 높다.

이러한 문제점을 해결하기 위해 등장한 개념이 바로 가상 돔 (Virtual DOM) 이다.

react-dom 에서 관리하는 Virtual DOM Tree 를 생성하여 메모리에 적재하고, React 에서 DOM 의 변경 사항을 Trigger 할 경우 VDOM 에 우선 반영하여 변경에 대한 준비가 완료될 경우 실제 DOM Tree 에 변경 사항을 Update 한다.

  • VDOM 은 절대로 일반 DOM 을 수정하는 것보다 결코 빠르지 않다. 오히려 렌더링을 진행하기 위한 과정이 추가된 셈이라 상황에 따라서는 더 성능이 느릴 수 있다.
  • 최근 등장한 웹 프레임워크 (SolidJS, Svelte) 의 경우에는 실제 DOM 을 수정하는 방식을 채택하여 Virtual DOM 이 가진 한계를 타파하는 모습을 보였다.

✏️ Reconciliation

컴포넌트 내부에 변경 사항이 발생하여 리렌더링이 진행될 경우, 기존의 DOM Tree 와 새롭게 구축된 DOM Tree 간의 비교 사항을 알아야 할 필요가 있다.

두 트리 간의 변경 사항을 비교하고 만약 변경 사항이 존재한다면 이를 실제 DOM Tree 에 적용하는 과정을 거치는데, 이때 두 트리를 비교하는 과정을 재조정 (Reconciliation) 이라 한다.

다만 N 개의 노드가 존재하는 두 트리 간의 비교를 위해서는 O(n^3) 의 복잡도가 들기 때문에 React 에서는 몇 가지 가정을 통한 휴리스틱 알고리즘을 세워 복잡도를 O(n) 으로 낮췄다.

  1. Element 의 타입이 달라질 경우 해당 Element 는 서로 다른 트리를 구성한다.
  2. key props 가 변경될 경우 해당 Element 는 서로 다른 트리를 구성한다.

✏️ React Fiber 의 등장 계기

기존 재조정 알고리즘의 구조는 Stack 기반이었기 때문에 해당 스택에 필요한 작업을 적재시키고 순차적으로 이를 해결하는 방식을 채택했다.

과거에는 해당 Stack 에 들어간 여러 개의 작업을 하나로 묶어 동기적으로 이를 진행했기에 때문에 중간의 작업을 취소하거나 작업 간의 우선 순위를 변경할 수 없었고, 중간에 작업이 지연될 경우 브라우저 렌더링도 같이 지연되는 문제가 있었다.

즉, 여러 작업 간의 우선 순위를 깡그리 무시한 채 화면에 변경 사항을 적용하는 일련의 과정을 하나의 큰 태스크로 놓고 실행하던 것이 기존의 Stack Reconciliation 의 문제였다.

  • 사용자의 인터렉션에 기반한 화면 변경을 유발하는 작업이 짧은 시간 내에 다수 발생한다고 가정해보자. (Input 에 키워드를 입력하면 검색 목록이 나오는 경우)
  • API 호출에 대한 응답 처리는 비교적 후순위로 두어도 사용자의 UX 경험에는 지장이 없으나, Input 의 변경 사항은 빠르게 실행되어야 한다.
  • 이는 Call Stack 에 많은 양의 작업을 적재시킬 것이고, 우선 순위가 낮은 작업도 모두 완료가 되어야지만 브라우저 렌더링이 재개되기에 사용자로 하여금 UX 를 저하시키는 요인이 된다.

따라서 이러한 문제를 해결하기 위해 React 16 버전 이상부터는 기존의 Stack 방식이 아닌 React Fiber 라는 새로운 아키텍쳐가 도입되었다.

✏️ React Fiber Reconciliation

Fiber Reconciliation 은 각각의 작업에 대한 우선 순위를 매기고 이에 대한 일시 정지, 재가동, 우선 가동을 가능하도록 설계되었다. 이러한 방식을 incremental rendering 이라 한다.

또한 Fiber Reconciliation 과정은 전부 비동기로 동작하기 때문에 Stack Reconciliation 과는 달리 선택적으로 작업을 취합하여 진행할 수 있다.

✏️ React Fiber 구조 살펴보기

function FiberNode(
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode
) {
  // Instance
  this.tag = tag; // Fiber 의 종류를 의미 (FunctionalComponent, SuspenseComponent 등)
  this.key = key; // React 내 key 속성
  this.elementType = null;
  this.type = null;
  this.stateNode = null; // Fiber Node 와 연관된 실제 DOM 노드 및 컴포넌트 인스턴스 (클래스 컴포넌트일 경우) 를 의미

  // Fiber
  this.return = null; // 부모 Fiber Node
  this.child = null; // 부모 Fiber Node 에서 첫 번째로 가진 자식 노드
  this.sibling = null; // 자신의 바로 다음 형제 노드
  this.index = 0; // 자신의 형제들 중에서 몇 번째 순서인지를 나타냄

  this.ref = null; // DOM Node 혹은 컴포넌트 인스턴스의 상태 및 업데이트를 관리

  this.pendingProps = pendingProps; // Fiber Node 생성 당시에는 렌더링 작업이 종료되지 않았으므로 인계 받은 props 를 pendingProps 으로 관리.
  this.memoizedProps = null; // Render Phase 종료 이후 사용되었던 pendingProps 를 보관
  this.updateQueue = null; // 상태 업데이트
  this.memoizedState = null; // 함수형 컴포넌트 내에서 생성된 Hook list
  this.dependencies = null; // 컴포넌트 내부의 여러 의존성을 관리하는 field

  this.mode = mode; // 컴포넌트의 렌더링 모드를 설정

  // Effects
  this.flags = NoFlags; // Fiber Node 의 현재 Flag (Update, Mount, etc)
  this.subtreeFlags = NoFlags; // 하위 Fiber Node Tree 의 상태 Flag
  this.deletions = null; // 삭제 예정인 자식 노드를 담은 field

  this.lanes = NoLanes;
  this.childLanes = NoLanes;

  this.alternate = null;
}

✏️ React Fiber Tree 살펴보기

Fiber Tree 는 React 내부에서 두 개로 나뉘어 관리된다. 하나는 현재 모습을 담은 트리이며 다른 하나는 작업 중인 상태를 나타내는 트리다. 이러한 방식을 더블 버퍼링이라 한다.

변경 사항을 적용할 VDOM Tree 와 현재 화면에 보여지는 화면을 구성하는 VDOM Tree 를 둘 다 메모리에 적재하여 (Buffer) 변경 사항이 모두 완료되기 전까지는 이전에 구축한 트리를 보여줌으로서 두 트리를 교차하는 방식이다.

  • current : 현재 렌더링 중인 화면을 구성하는 VDOM 트리
  • workInProgress : Render Phase 에서 작업 중인 변경 사항을 적용하는 VDOM 트리

workInProgress 트리는 Render Phase 를 거쳐 Commit Phase 로 넘어갈 경우 포인터를 변경하여 current 트리로 변경한다.

✏️ Fiber 의 작업 순서 살펴보기

  1. beginWork 함수를 실행하여 상태가 변경된 컴포넌트를 찾고, 변경점을 찾았다면 작업을 수행한다.
  2. 작업이 완료되었을 때를 기준으로 형제 노드를 찾고, 만약 현제 노드가 없다면 부모 노드를 대상으로 작업을 수행한다.
  3. 작업이 완료된 Fiber Node 를 대상으로 completeWork 메서드를 실행하여 Node 의 tag 에 맞는 Element 를 생성하여 Commit Phase 에 넘긴다.
  4. 모든 작업이 완료되어 루트 노드가 완성되었다면 Commit Phase 에 진입하여 실제 DOM 에 변경 사항을 반영한다.

Fiber 가 Reconciliation 과정을 통해 실제 DOM 에 반영되는 프로세스는 이보다 훨씬 복잡하다

  • 추후 해당 내용은 별도의 시간을 들여 공부할 예정이다.
profile
항상 왜 이걸 써야하는지가 궁금한 사람

0개의 댓글