JiSoo's Devlog

[모던 리액트 Deep Dive] 2장 본문

Frontend/React

[모던 리액트 Deep Dive] 2장

지숭숭숭 2024. 4. 27. 15:02

JSX

XML과 유사한 내장형 구문으로 자바스크립트 표준의 일부는 아니다

반드시 트랜스파일러를 거쳐야 자바스크립트 런타임이 이해할 수 있는 의미 있는 자바스크립트 코드로 변환된다

JSX가 주로 사용되는 곳은 리액트 내부에서 반환하는 html과 자바스크립트 코드이지만 한정돼 있는 것은 X

 

◦ JSXElement

JSX를 구성하는 가장 기본 요소로 HTML의 요소와 비슷한 역할

JSXElement가 되기 위해 JSXOpeningElement, JSXClosingElement, JSXSelfClosingElement, JSXFragment 중 하나여야 한다

JSXElementName은 JSXElement의 요소 이름으로 쓸 수 있는 것을 의미한다

// JSXIdentifier
function Valid1() {
  return <$></$>
}
function Valid2() {
  return <_></_>
}
function Valid3() {
  return <1></1>
}

// JSXNamespacedName
function Valid4() {
  return <foo:bar></foo:bar>
}

// JSXMemberExpression
function Valid5() {
  return <foo.bar></foo.bar>
}

 

JSXAttributes

JSXElment에 부여할 수 있는 속성, 필수값 X

JSXSpreadAttributes : 자바스크립트의 전개 연산자와 동일한 역할

{...AssignmentExpression} : 자바스크립트 AssignmentExpression으로 취급되는 모든 표현식 존재 가능

JSXAttribute : 속성 나타내는 키와 값 짝으로 표현

 

 JSXChildren

JSXElement의 자식 값

JSX는 속성을 가진 트리 구조를 나타내기 위해 만들어져 JSX로 부모 자식 관계 표현 가능

function Valid1() {
  return <>{'{} <>'}</>
}
function Valid2() {
  return <>{(() => 'foo')()}</>
}

 

 JSXStrings

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

"큰따옴표로 구성된 문자열", '작은따옴표로 구성된 문자열', JSXText

JSX는 HTML처럼 \을 이스케이프 문자열로 처리하고 있지 않다

 

JSX 예제 ↓

const ComA = <A>안녕</A>
const ComB = <A />
const ComC = <A required />
const ComD = (
  <A>
    <B text="react" />
  </A>
)

 

 

@babel/plugin-transform-react-jsx 플러그인은 JSX 구문을 자바스크립트가 이해할 수 있는 형태로 변환한다

const ComA = <A required={true}>Hello</A>
const ComB = <>Hello</>
const ComB = (
  <div>
    <span>Hello</span>
  </div>
)

↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓

'use strict'

var ComA = React.createElement(
  A,
  {
    required: true,
  },
  'Hello',
)

var ComB = React.createElement(React.Fragment, null, 'Hello')
var ComC = React.createElement(
  'div',
  null,
  React.createElement('span', null, 'Hello'),
)

 


 

DOM웹페이지에 대한 인터페이스로 브라우저가 웹페이지의 콘텐츠와 구조를 어떻게 보여줄지에 대한 정보를 담고 있다

가상 DOM은 실제 브라우저의 DOM이 아닌 리액트가 관리하는 가상의 DOM을 의미한다

웹페이지가 표시해야 할 DOM을 일단 메모리에 저장하고 리액트가 실제 변경에 대한 준비가 완료됐을 때 실제 브라우저의 DOM에 반영한다

이렇게 DOM 계산을 메모리에서 계산하는 과정을 거치면 렌더링 과정을 최소화할 수 있고 브라우저와 개발자 부담을 덜 수 있다

 

가상 DOM과 렌더링 과정 최적화를 가능하게 해주는 것은  리액트 파이버

리액트 파이버는 리액트에서 관리하는 평범한 자바스크립트 객체로 웹 애플리케이션에서 발생하는 애니메이션, 레이아웃, 사용자 인터랙션에 올바른 결과물을 만드는 반응성 문제를 해결한다

파이버는 하나의 작업 단위로 구성

이런 작업 단위를 하나씩 처리하고 finishWork()로 작업 마무리하고 커밋해 실제 브라우저 DOM에 가시적인 변경 사항 처리

 

1. 렌더 단계에서 리액트는 사용자에게 노출되지 않는 모든 비동기 작업 수행, 파이버의 작업과 우선순위 지정, 중지, 버리기 등의 작업

2. 커밋 단계에서는 DOM에 변경 사항 반영을 위한 작업, commitWork()가 실행되는데 동기식으로 일어나고 중단도 가능

 

리액트 요소는 렌더링이 발생할 때마다 새롭게 생성되지만 파이버는 가급적이면 재사용된다

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

 

파이버 트리는 리액트 내부에 두 개가 존재

하나는 현재 모습을 담은 파이버 트리

다른 하나는 작업 중인 상태를 나타내는 workInProgress 트리

파이버의 작업이 끝나면 단순히 포인터만 변경해 workInProgress 트리를 현재 트리로 바꾸는데 이 기술이 더블 버퍼링

더블 버퍼링은 커밋 단계에서 수행된다

현재 UI 렌더링을 위한 트리인 current를 기준으로 모든 작업들이 시작되고 업데이트가 발생하면 파이버는 새로 받은 데이터로 새로운 workInProgress 트리를 빌드한 후 다음 렌더링에 이 트리를 사용하며 이 트리가 UI에 최종적으로 렌더링 되어 반영되면 current가 workInProgress로 변경된다

 

리액트 컴포넌트에 대한 정보를 1:1로 가지는 게 파이버

파이버는 리액트 아키텍처 내부에서 비동기로 이뤄지는데 실제 브라우저 DOM에 반영하는 것은 동기적으로 일어나야 하고 처리할 작업이 많아 화면에 불완전하게 표시될 수 있는 가능성이 높아 이런 작업을 메모리상에서 먼저 수행해 최종적인 결과물만 실제 브라우저 DOM에 적용하는 것이다

 

가상 DOM과 리액트의 핵심은 브라우저의 DOM을 더욱 빠르게 반영하는 게 아니라 바로 값으로 UI를 표현하는 것

 


 

클래스 컴포넌트

class Component extends React.Component {
  render() {
    return <h2>Component</h2>
  }
}

클래스 컴포넌트를 만들려면 클래스를 선언하고 만들고 싶은 컴포넌트를 extends 해야 한다

extends 구문에는 React.Component, React.PureComponent 클래스를 넣을 수 있다

 

◦ constructor() : 컴포넌트의 state 초기화 가능

◦ props : 컴포넌트에 특정 속성을 전달하는 용도로 사용

◦ state : 클래스 컴포넌트 내부에서 관리하는 값을 의미하는데 이 값은 항상 객체여야 하고 이 값에 변화가 있을 때마다 리렌더링 발생

◦ 메서드 : 렌더링 함수 내부에서 사용되는 함수

◦ 렌더링 함수 내부에서 함수 새롭게 만들어 전달

<button onClick={() => this.handleClick()})증가</button>

이 방법은 매번 렌더링이 일어날 때마다 새로운 함수를 생성해 할당하게 되므로 최적화 수행이 어렵다

 

생명주기 메서드가 실행되는 시점

- 마운트 : 컴포넌트가 마운팅되는 시점

- 업데이트 : 이미 생성된 컴포넌트의 내용이 변경되는 시점

- 언마운트 : 컴포넌트가 더 이상 존재하지 않는 시점

 

render()

생명주기 메서드 중 하나로 리액트 클래스 컴포넌트의 유일한 필수 값으로 항상 쓰인다

컴포넌트가 UI를 렌더링 하기 위해 쓰인다

마운트와 업데이트 과정에서 일어난다

이 함수는 입력값이 들어가면 항상 같은 결과물을 반환해야 한다

render() 내부에서 state를 직접 업데이트하는 this.setState를 호출해서는 안된다

 

componentDidMount()

마운트 되고 준비가 됐으면 그다음으로 호출되는 생명주기 메서드

this.setState()로 state 값을 변경하게는 게 가능

일반적으로 state를 다루는 것은 생성자에서 하는 것이 좋다

 

componentDidUpdate()

컴포넌트 업데이트가 일어난 후 바로 실행된다

일반적으로 state나 props의 변화에 따라 DOM을 업데이트하는 등에 쓰인다

여기서도 this.setState 사용 가능

 

componentWillUnmount()

컴포넌트가 언마운트되거나 더 이상 사용되지 않기 직전에 호출된다

메모리 누수나 불필요한 작동을 막기 위한 클린업 함수를 호출하기 위한 최적의 위치

이 메서드 내에서는 this.setState 호출 불가

이벤트를 지우거나 API 호출 취소, 타이머 삭제 등의 작업에 유용하다

 

shouldComponentUpdate()

state나 props의 변경으로 리액트 컴포넌트가 리렌더링 되는 것을 막고 싶을 때 사용

 

pureComponent는 state 값에 대해 얕은 비교를 수행해 결과가 다를 때만 렌더링을 수행한다

state가 객체와 같이 복잡한 구조의 데이터 변경은 감지하지 못하기 때문에 제대로 작동하지 않는다

 

static getDerivedStateFromProps()

render를 호출하기 직전에 호출된다

static으로 선언돼 있어 this에 접근 불가

 

getSnapShotBeforeUpdate()

DOM이 업데이트되기 직전에 호출된다

DOM에 렌더링되기 전에 윈도우 크기를 조절하거나 스크롤 위치를 조정하는 등의 작업을 처리하는데 유용

 

getDerivedStateFromError()

자식 컴포넌트에서 에러가 발생했을 때 호출되는 에러 메서드

error를 인수로 받고 static 값을 반환해야 한다

 

componentDidCatch

자식 컴포넌트에서 에러가 발생했을 때 실행되며 에러를 잡고 state를 결정한 이후에 실행된다

두 개의 인수를 받는데 error, 어떤 컴포넌트가 에러를 발생시켰는지 정보를 가진 info를 받는다

 

getDerivedStateFromError()와 componentDidCatch() 메서드는 ErrorBoundary 에러 경계 컴포넌트를 만들기 위해 사용

ErrorBoundary를 여러 개 선언해 컴포넌트별로 에러 처리르 다르게 적용하면 에러가 발생한 컴포넌트 트리 영역만 별도로 처리해 애플리케이션 전체에 에러가 전파되어 표시되는 것을 방지할 수 있다

 

클래스 컴포넌트의 한계

- 데이터의 흐름 추적이 어렵다

- 애플리케이션 내부 로직의 재사용 어렵다

- 기능이 많아질수록 컴포넌트 크기가 커진다

- 클래스는 함수에 비해 상대적으로 어렵다

- 코드 크기를 최적화하기 어렵다

- 핫 리로딩을 하는 데 상대적으로 불리하다

→ 클래스 컴포넌트를 완전히 대신할 수 있는 기존의 무상태 함수 컴포넌트에 상태를 더할 수 있는 훅을 출시

 

 

함수 컴포넌트

props를 받아 단순히 리액트 요소만 반환하는 함수이기 때문에 생명주기 메서드가 존재하지 않는다

함수 컴포넌트는 렌더링이 일어날 때마다 그 순간의 값인 props와 state를 기준으로 렌더링 된다

 


브라우저에서 렌더링이란 HTML과 CSS 리소스를 기반으로 웹페이지에 필요한 UI를 그리는 과정

리액트의 렌더링은 브라우저가 렌더링에 필요한 DOM 트리를 만드는 과정

 

리액트의 렌더링

모든 컴포넌트들이 자신들의 props와 state의 값을 기반으로 어떻게 UI를 구성하고 어떤 DOM 결과를 브라우저에 제공할 것인지 계산하는 일련의 과정

 

렌더링 발생 시점

● 최초 렌더링

● 리렌더링

  ◦ setState 실행 : 컴포넌트의 상태 변화

  ◦ forceUpdate 실행 : 렌더링을 수행하는 render이 만약 state나 props가 아닌 다른 값에 의존하고 있어 리렌더링을 자동으로 실행할 수 없다면 forceUpdate를 실행해 리렌더링 가능

render 내에서 forceUpdate가 사용되면 무한 루프에 빠지므로 내부에서 사용 X

  ◦ useState()의 두 번째 요소인 setter 실행

  ◦ useReducer()의 두 번째 요소인 dispatch 실행

  ◦ key props 변경 : key는 명시적으로 선언돼 있지 않아도 사용 가능한 특수 props

  ◦ props 변경

  ◦ 부모 컴포넌트 렌더링 : 부모 컴포넌트가 리렌더링되면 자식 컴포넌트도 무조건 리렌더링!!

const arr = [1, 2, 3]

export default function App() {
  return (
    <ul>
      {arr.map((index) => (
        <li key={index}>{index}</li>
      ))}
    </ul>
  )
}

리액트에서 key는 리렌더링이 발생하는 동안 형제 요소들 사이에서 동일한 요소 식별하는 값

 

렌더링 프로세스

렌더링 프로세스가 시작되면 컴포넌트의 루트부터 내려가면서 업데이트가 필요하다고 지정된 모든 컴포넌트를 찾는다

업데이트가 필요한 컴포넌트 발견하면

클래스 컴포넌트라면 클래스 내부의 render() 함수 실행

함수 컴포넌트라면 FunctionComponent() 호출 뒤에 결과물 저장

 

렌더링 결과물은 JSX 문법으로 구성돼 있고 자바스크립트로 컴파일되면서 React.createElement()를 호출하는 구문으로 변환

createElement는 브라우저 UI구조를 설명할 수 있는 자바스크립트 객체를 반환

function Hello() {
  return (
    <TextComponent a={35} b="yceffort">
      안녕
    </TextComponent>
  )
}

↓ ↓ ↓ ↓ ↓

function Hello() {
  return React.createElement(
    TestComponent,
    { a: 35, b: 'yceffort' },
    '안녕',
  )
}

↓ ↓ ↓ ↓ ↓

{type: TestComponent props: {a: 35, b: 'yceffort', children: "안녕"}}
// 결과물

 

렌더링 프로세스가 실행되면서 이런 과정을 거쳐 결과물을 수집하고 새로운 트리인 가상 DOM과 비교해 실제 DOM에 반영하기 위한 모든 변경 사항 수집

리액트의 재조정

재조정 과정이 끝나면 모든 변경 사항을 하나의 동기 신퀀스로 DOM에 적용해 변경된 결과물이 보이도록 한다

 

리액트의 렌더링은 렌더 단계와 커밋 단계로 분리되어 실행된다

 

렌더 단계

컴포넌트를 렌더링하고 변경 사항 계산하는 모든 작업

렌더링 프로세스에서 컴포넌트를 실행해 이전 가상 DOM과 비교하고 변경이 필요한 컴포넌트를 체크하는 단계

비교하는 것은 type, props, key

 

커밋 단계

렌더 단계의 변경 사항을 실제 DOM에 적용해 사용자에게 보여주는 과정

 

리액트의 렌더링이 일어난다고 해서 무조건 DOM 업데이트가 일어나는 것은 아니다!

렌더링 수행 후 커밋 단계까지 갈 필요가 없다면 생략 가능

리액트의 렌더링은 꼭 가시적인 변경이 일어나지 않아도 발생할 수 있다

렌더 단계에서 변경 사항을 감지할 수 없다면 커밋 단계가 생략되어 브라우저의 DOM 업데이트가 일어나지 않을 수 있다

 

리액트의 렌더링은 항상 동기식으로 작동했기 때문에 과정이 길어질수록 애플리케이션 성능 저하로 이어지고 그 시간만큼 브라우저의 다른 작업을 지연시킬 가능성이 있다

동시성 렌더링은 렌더 단계가 비동기로 작동해 특정 렌더링의 우선순위를 낮추거나 필요하면 중단, 재시작하거나 포기도 가능하다

이를 통해 브라우저의 동기 작업을 차단하지 않고 백그라운드에서 새로운 리액트 트리 준비가 가능해 매끄러운 사용자 경험 가능

 

컴포넌트 렌더링 작업은 별도로 렌더링을 피하기 위한 조치가 없는 이상 하위 모든 컴포넌트에 영향을 미친다

부모가 변경됐다면 props가 변경됐는지에 상관없이 무조건 자식 컴포넌트도 리렌더링

상위 컴포넌트, 즉 루트에서 렌더링 작업이 일어난다는 것은 하위 모든 컴포넌트의 리렌더링을 트리거한다는 뜻

memo를 추가하면 부모 컴포넌트가 변경돼도 렌더링 되지 않는다

 


메모이제이션꼭 필요한 곳에 골라서 해야 한다!!

가벼운 작업 자체는 메모이제이션 하는 것보다 매번 작업을 수행해 결과를 반환하는 게 더 빠를 수 있다

메모이제이션은 값을 비교하고 렌더링 또는 재계산이 필요한지 확인하는 작업, 이전에 결과물을 저장해 두었다가 다시 꺼내와야 하는 두 가지 비용이 있다

 

항상 메모이제이션은 신중하게 접근해야 하며 섣부른 최적화는 항상 경계해야 한다

메모이제이션으로 인한 성능 개선이 렌더링보다 낫지 않다면 결국 안 하느니만 못하는 상황을 마주하게 된다

일단 애플리케이션을 어느 정도 만든 후에 개발자 도구나 useEffect를 사용해 실제로 어떻게 렌더링이 일어나는지 확인하고 필요한 곳에서만 최적화하는 것이 옳다

 

섣부른 최적화인지 여부와 관계없이 해당 컴포넌트가 렌더링이 자주 일어나며 그 사이에 비싼 연산이 포함돼 있고 자식 컴포넌트도 많이 가진다면 memo나 다른 메모이제이션 방법을 사용하는 게 이점이 있을 때가 있다

 

리액트는 이전 렌더링 결과를 다음 렌더링과 구별을 위해 저장해둬야 한다

우리가 memo로 지불해야 하는 비용은 props에 대한 얕은 비교뿐인 것!

memo를 하지 않았을 때 발생할 수 있는 문제는

- 렌더링 비용

- 컴포넌트 내부의 복잡한 로직의 재실행

- 위 두 가지 모두가 모든 자식 컴포넌트에서 반복 실행

- 리액트가 구 트리와 신규 트리 비교

memo를 하지 않았을 때 치러야 할 잠재적인 위험 비용이 더 크다

 

시간적 여유가 있다면 메모이제이션을 지양하는 자세 갖기

시간적 여유가 없다면 의심스러운 곳에는 먼저 다 적용해 보기

 

 

 

728x90