모던 리액트 딥다이브 Week2 - Chapter2

지코·2025년 9월 12일
3

FE STUDY

목록 보기
3/4
post-thumbnail
본 포스팅 시리즈는 📚'모던 리액트 Deep Dive'를 주간 별로 1장씩 공부하며
* 새롭게 알게 된 것들
* 평소 알고 있다고 생각했지만 이번에 제대로 알게 된 것들
* 궁금한 부분에 대해 딥다이브한 것들
등을 기재하기 위해 시작되었다.

📖 2장. 리액트 핵심 요소 깊게 살펴보기


JSX란? (p.116-117)

  • 흔히 개발자들이 알고 있는 XML과 유사한 내장형 구문이다.
  • 페이스북에서 독자적으로 개발했기 때문에, 이른바 ECMAScript라고 불리는 자바스크립트 표준의 일부는 아니다.
  • JSX 내부에 트리 구조로 표현하고 싶은 다양한 것들을 작성해두고, 트랜스파일링 과정을 거쳐 자바스크립트(ECMAScript)가 이해할 수 있는 코드로 변경하는 것이 목표이다.

브라우저 렌더링 과정에 대하여⭐️ (p. 129)

출처: Render-tree Construction, Layout, and Paint

  1. 브라우저는 사용자가 요청한 주소를 방문해 HTML 파일을 다운로드한다.
  2. 브라우저의 렌더링 엔진은 HTML을 파싱해 DOM 노드로 구성된 트리(DOM)를 만든다.
  3. CSS 파일을 만나면 해당 CSS 파일 다운로드 후, 브라우저 렌더링 엔진이 이 CSS도 파싱해 CSS 노드로 구성된 트리(CSSOM)를 만든다.
  4. 브라우저는 DOM 노드를 순회하는데, 사용자 눈에 보이는 노드만 방문한다. 이는 트리를 분석하는 과정에 걸리는 시간을 단축하기 위함이며, display: none 과 같은 속성이 적용되어 있어 사용자 화면에 보이지 않는 요소는 방문하지 않는다.
  5. 눈에 보이는 노드를 대상으로 해당 노드에 대한 CSSOM 정보를 찾고, 찾은 스타일 정보를 노드에 적용한다. DOM 노드에 CSS를 적용하는 과정은 다음과 같이 크게 두 가지로 나눌 수 있다.
  • 레이아웃(layout, reflow): 각 노드가 브라우저 화면의 어느 좌표에 정확히 나타나야 하는지 계산하는 과정.
  • 페인팅(painting): 레이아웃 단계를 거친 노드에 색과 같은 실제 유효한 모습을 그리는 과정.

가상 DOM을 위한 아키텍처, 리액트 파이버란? (p.133)

리액트 파이버는 리액트에서 관리하는 평범한 자바스크립트 객체이다. 파이버들은 파이버 재조정자가 관리하는데, 이 파이버 재조정자가 하는 일은 다음과 같다.

  • 가상 DOM과 실제 DOM을 비교해 변경 사항을 수집한다.
  • 변경 사항이 있다면, 변경에 관련된 정보를 가지고 있는 파이버를 기준으로 화면에 렌더링을 요청한다.

파이버 가 할 수 있는 일들은 다음과 같다.

  • 작업을 작은 단위로 분할하고 쪼갠 다음, 우선순위를 매긴다.
  • 이러한 작업을 일시 중지하고 나중에 재개할 수 있다.
  • 이전에 했던 작업을 재사용하거나, 필요하지 않은 경우에는 폐기할 수 있다.

이러한 모든 과정은 비동기로 일어난다❗️

과거 리액트의 조정 알고리즘은 스택 알고리즘으로 이뤄져 있었기 때문에, 스택에 렌더링에 필요한 작업들이 쌓이고 이 스택이 빌 때까지 동기적으로 작업이 이루어지는 형태였다. 자바스크립트의 싱글 스레드라는 특징으로 인해, 이 동기 작업은 중단될 수 없었다. 이는 리액트의 비효율성으로 이어졌다.

이러한 기존 렌더링 스택의 비효율성을 타파하기 위해 파이버 라는 개념이 탄생하게 되었다.

리액트 파이버 트리 (p.139)

♾️ 더블 버퍼링
: 리액트 파이버의 작업이 끝나면 단순히 포인터를 변경해, workInProgress 트리를 현재 트리로 변경한다.

  • 더블 버퍼링을 위해 트리가 두 개 존재한다.
  • 더블 버퍼링은 커밋 단계에서 수행된다.

출처: An Introduction to React Fiber - The Algorithm Behind React

  1. current 트리에 업데이트가 발생하면 파이버는 리액트에서 새로 받은 데이터로 새로운 workInProgress 트리를 빌드하기 시작한다.
  2. 트리 빌드 작업이 끝나면, 다음 렌더링에 이 workInProgress 트리를 사용한다.
  3. 이 트리가 UI에 최종적으로 렌더링되어 반영되면, current가 이 workInProgress로 변경된다.

클래스 컴포넌트에서의 this에 대하여 (p. 147)

자바스크립트에서 this 는 함수를 어떻게 호출하느냐에 따라 값이 달라진다.

클래스 컴포넌트에서 이벤트 핸들러 메서드를 일반 함수로 정의하면, 리액트가 이 메서드를 이벤트 리스너로 호출할 때 this 는 클래스 인스턴스를 가리키지 않고 undefined가 된다. this 에 전역 객체가 바인딩되기 때문이다.

class MyComponent extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
  }

  incrementCount() {
    this.setState((prev) => ({ count: prev.count + 1 }));
  }

  render() {
    return <button onClick={this.incrementCount}>Click</button>;
  }
}

onClick에서 this.incrementCount를 호출할 때, 이벤트 핸들러로서 단순히 함수 참조가 전달된다. 이때 호출 컨텍스트가 MyComponent 인스턴스가 아니라 전역 객체이기 때문에, thisundefined가 된다.

constructor(props) {
  super(props);
  this.state = { count: 0 };

  // this를 클래스 인스턴스에 강제 바인딩
  this.incrementCount = this.incrementCount.bind(this);
}

이 문제를 해결하기 위해 생성자에서 bind 메서드를 사용하여 this 를 명시적으로 인스턴스에 바인딩해야 한다.

incrementCount = () => {
  this.setState((prev) => ({ count: prev.count + 1 }));
};

생성자에서 바인딩하는 게 번거롭다면, 메서드를 화살표 함수로 변경하는 방법도 있다. 화살표 함수는 자신을 둘러싼 스코프의 this 를 자동으로 사용하므로 bind가 필요 없다.

🌐 정리

클래스 컴포넌트에서 메서드가 this 에 접근해야 한다면

  1. constructor에서 this 를 클래스 인스턴스에 바인딩한다.
  2. 메서드를 화살표 함수로 구현한다.

클래스 컴포넌트의 생명주기 메서드 (p. 149)

♾️ render()

  • 리액트 클래스 컴포넌트의 유일한 필수 값으로 항상 쓰인다.
  • 컴포넌트가 UI를 렌더링하기 위해 사용된다.
  • 마운트(mount)업데이트(update) 과정에서 렌더링이 일어난다.
  • 항상 순수해야 하며, 부수 효과가 없어야 한다.
    • 따라서 render() 내부에서 state를 직접 업데이트할 수 없다.

♾️ componentDidMount()

  • 컴포넌트가 마운트되고 준비되는 즉시 실행된다.
  • componentDidMount() 내부에서 state를 직접 업데이트하는 것이 가능하다.
    • 하지만 성능 문제가 발생할 수 있기 때문에, 일반적으로 state를 다루는 것은 생성자에서 하는 것이 좋다.
    • state를 변경하는 작업을 하려면, 꼭 componentDidMount() 에서 할 수 밖에 없는 작업일 경우에만 하는 것이 좋다.

♾️ componentDidUpdate()

  • 컴포넌트 업데이트가 일어난 후 바로 실행된다.
  • 일반적으로 state나 props의 변화에 따라 DOM을 업데이트하기 위해 사용한다.
  • componentDidUpdate() 내부에서 state를 변경하는 것이 가능하다.
    • 하지만 적절한 조건문으로 감싸야 this.setState() 함수가 계속해서 호출되는 일을 방지할 수 있다.

♾️ componentWillUnmount()

  • 컴포넌트가 언마운드(unmount) 되거나 더 이상 사용되지 않기 직전에 호출된다.
  • 주로 메모리 누수나 불필요한 작동을 막기 위한 클린업 함수를 호출하는 데 사용한다.
  • componentWillUnmount() 내부에서는 state 변경이 불가능하다.

♾️ shouldComponentUpdate()

  • state나 props의 변경으로 리액트 컴포넌트가 리렌더링되는 것을 막기 위해 사용한다.
  • 일반적으로 state의 변화에 따라 컴포넌트가 리렌더링되는 것이 당연하므로, 이 메서드를 사용하는 것은 특정한 성능 최적화 상황에서만 고려해야 한다.

♾️ static getDerivedStateFromProps()

  • 이제는 사라진 componentWillReceiveProps() 를 대체할 수 있는 메서드이다.
  • 다음에 올 props를 바탕으로 현재의 state를 변경하고 싶을 때 사용할 수 있다.
  • render() 를 호출하기 직전에 호출되며, 모든 render() 실행 시에 호출된다.
  • static으로 선언되어 있어 this 에 접근할 수 없다.

♾️ getSnapShotBeforeUpdate()

  • 이제는 사라진 componentWillUpdate() 를 대체할 수 있는 메서드이다.
  • 리액트 훅으로 구현되어 있지 않기 때문에, 반드시 클래스 컴포넌트에서만 사용 가능하다.
  • DOM에 렌더링되기 전에 윈도우 크기를 조절하거나, 스크롤 위치를 조정하는 등의 작업을 처리하는 데 유용하다.
  • DOM이 업데이트되기 전에 호출된다.

🌐 정리

출처: React Lifecycle Diagram

클래스 컴포넌트의 한계 (p.162)

1️⃣ 데이터의 흐름을 추적하기 어렵다.

생명주기 메서드는 실행되는 순서가 있지만 클래스에서 작성할 때는 메서드의 순서를 맞춰줘야 하는 것은 아니기 때문에 개발자가 주의를 기울이지 않는다면 생명주기 메서드의 순서와 상관 없이 코드가 작성되어 있을 것이고, state의 흐름을 추적하기가 매우 어렵게 된다.

2️⃣ 애플리케이션 내부 로직의 재사용이 어렵다.

컴포넌트 간에 중복되는 로직이 있고 이를 재사용하고 싶은 경우에는, 주로 컴포넌트를 또 다른 고차 컴포넌트로 감싸거나 props를 넘겨주는 방식을 사용하게 된다.

하지만 이 경우 공통 로직이 많아질수록 이를 감싸는 고차 컴포넌트 내지는 props가 많아지는 래퍼 지옥(wrapper hell)에 빠져들 위험성이 커진다는 심각한 단점이 있다.

3️⃣ 기능이 많아질수록 컴포넌트의 크기가 커진다.

컴포넌트 내부 로직 증가 ➡️ 데이터 흐름 복잡도 증가 ➡️ 생명주기 메서드 사용 증가 ➡️ 컴포넌트의 크기 기하급수적으로 증가 📈

4️⃣ 클래스는 함수에 비해 상대적으로 어렵다.

클래스는 비교적 뒤늦게 나온 개념이라 자바스크립트 개발자에게 클래스보다는 함수가 더 익숙하며, 자바스크립트 환경에서는 함수에 비해 클래스의 사용이 비교적 어렵고 일반적이지 않다.

5️⃣ 코드 크기를 최적화하기 어렵다.

클래스 컴포넌트는 최종 결과물인 번들 크기를 줄이는 데도 어려움을 겪는다. 사용하지 않는 메서드도 빌드 시 트리 쉐이킹이 되지 않고 번들에 그대로 포함되기 때문에, 번들링을 최적화하기에 클래스 컴포넌트는 불리한 조건임을 알 수 있다.

6️⃣ 핫 리로딩을 하는 데 상대적으로 불리하다.

🔄 핫 리로딩(hot reloading)
: 코드에 변경 사항이 발생했을 때 앱을 다시 시작하지 않고서도 해당 변경된 코드만 업데이트해 변경 사항을 빠르게 적용하는 기법.

클래스 컴포넌트는 최초 렌더링 시에 instance를 생성하고 그 내부에서 state 값을 관리하는데, 이 instance 내부에 있는 render() 함수를 수정하게 되면 이를 반영할 수 있는 방법은 오직 instance를 새로 만드는 것 뿐이라 instance 내부 state 값은 초기화될 수 밖에 없다.

반면 함수 컴포넌트는 state를 함수가 아닌 클로저에서 저장해 두므로 함수가 다시 실행돼도 해당 state를 잃지 않고 다시 보여줄 수 있게 된다.

클래스 컴포넌트와 함수 컴포넌트의 차이: 렌더링된 값에 대하여 (p.168-171)

import React from 'react'

interface Props {
  user: string
}

// 함수 컴포넌트로 구현한 setTimeout 예제
export function FunctionalComponent(props: Props) {
  const showMessage = () => {
    alert('Hello ' + props.user)
  }
  
  const handleClick = () => {
    setTimeout(showMessage, 3000)
  }
  
  return <button onClick={handleClick}>Follow</button>
}

// 클래스 컴포넌트로 구현한 setTimeout 예제
export class ClassComponent extends React.Component<Props, {}> {
  private showMessage = () => {
    alert('Hello ' + this.props.user)
  }

  private handleClick = () => {
    setTimeout(this.showMessage, 3000)
  }
  
  public render() {
    return <button onClick={this.handleClick}>Follow</button>
  }
}

위 코드에서 FunctionalComponent와 ClassComponent는 같은 작업을 하고 있다. 버튼을 클릭한 지 3초 내에 props를 변경하면 어떻게 될까?

ClassComponent의 경우에는 3초 뒤에 변경된 props를 기준으로 메세지가 뜨고, FunctionalComponent는 클릭했던 시점의 props 값을 기준으로 메세지가 뜬다.

클래스 컴포넌트는 props의 값을 항상 this 로부터 가져오는데, this 가 가리키는 객체의 멤버는 변경 가능한 값이기 때문에 render 메서드를 비롯한 리액트의 생명주기 메서드가 변경된 값을 읽을 수 있게 된다.

함수 컴포넌트는 props를 인수로 받기 때문에, 컴포넌트는 그 값을 변경할 수 없고 해당 값을 그대로 사용하게 되며 state도 마찬가지이다. 함수 컴포넌트는 렌더링될 때마다 그 순간의 값인 props와 state를 기준으로 렌더링된다.

리액트에서의 렌더링이란, (p.172)

렌더링은 브라우저에서도 사용되는 용어이며, 브라우저에서의 렌더링과 리액트에서의 렌더링의 의미가 다르다.

브라우저에서의 렌더링이란, 쉽게 말해 HTML과 CSS 리소스를 기반으로 웹페이지에 필요한 UI를 그리는 과정을 의미한다.

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

🤔 왜 리액트에서 배열에 key props를 추가해야 하는가? (p.174)

리액트에서 key 는 리스트 렌더링 시 각 요소를 고유하게 식별하기 위해 사용된다.

리렌더링이 발생하면 current fiber 트리와 workInProgress fiber 트리 사이에서 어떠한 컴포넌트에 변경이 있었는지 구별해야 하는데, 이 두 트리 사이에서 같은 컴포넌트인지를 구별하는 값이 바로 key 이다.

이 변경사항을 구별하는 작업은 리렌더링이 필요한 컴포넌트를 최소화해야 하므로 ⭐️반드시 필요한 작업⭐️이다.
key 가 올바르게 지정되어 있으면 리액트는 해당 컴포넌트를 동일한 것으로 인식해 불필요한 언마운트/마운트를 방지하고, 컴포넌트의 상태를 안정적으로 유지한다.
반대로 key 를 인덱스로 지정하거나 중복되게 설정하면 리스트 재정렬 시 성능 저하와 상태 꼬임 문제가 발생할 수 있다.

🤔 key 를 index로 넣으면 안되는 이유는?

{items.map((item) => (
  <Todo key={item.id} text={item.text} />
))}

key 를 인덱스로 설정했을 때 아이템 순서가 바뀌거나 중간에 요소가 삽입/삭제되면, 실제로는 다른 아이템임에도 key 값이 같아지면서 잘못된 DOM과 state를 재사용하게 되는 문제가 발생한다.

1. 컴포넌트 내부의 state가 엉뚱한 아이템에 연결됨.
2. 리스트 재렌더링 시 UI 깜빡임이나 애니메이션 꼬임 발생.
3. DOM 노드가 불필요하게 교체되거나 그대로 남음 → 버그 유발.
따라서 항상 아이템을 고유하게 식별할 수 있는 값을 key 로 사용해야 한다❗️


리액트의 렌더링 프로세스 (p.176)

1️⃣ 리액트는 컴포넌트의 루트에서부터 차근차근 아래쪽으로 내려가면서 업데이트가 필요하다고 지정돼 있는 모든 컴포넌트를 찾는다.

2️⃣ 업데이트가 필요하다고 지정돼 있는 컴포넌트를 발견하면

  • 클래스 컴포넌트: render() 함수를 실행해 결과물을 저장.
  • 함수 컴포넌트: 함수 컴포넌트 그 자체를 호출해 결과물을 저장.

3️⃣ 렌더링 결과물은 보통 JSX 문법으로 구성되어 있기 때문에, JS로 컴파일되면서 React.createElement()를 호출하는 구문으로 변환된다.

4️⃣ 각 컴포넌트의 렌더링 결과물을 수집한 다음, 리액트의 새로운 트리인 가상 DOM과 비교해 실제 DOM에 반영하기 위한 모든 변경 사항을 수집한다.

5️⃣ 이러한 재조정(Reconciliation) 과정이 끝나면, 모든 변경 사항을 하나의 동기 시퀀스로 DOM에 적용해 변경된 결과물이 보이게 된다.

렌더 단계와 커밋 단계 (p. 177)

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

렌더 단계(Render Phase)컴포넌트를 렌더링하고 변경 사항을 계산하는 모든 작업을 말한다.

  • 렌더링 과정에서 컴포넌트를 실행한 결과와 가상 DOM을 비교.
  • 변경이 필요한 컴포넌트를 체크.
    • 구체적으로는 type, props, key 를 체크.
    • 이 중 하나라도 변경된 것이 있다면 변경이 필요한 컴포넌트로 체크.

커밋 단계(Commit Phase)렌더 단계의 변경 사항을 실제 DOM에 적용해, 사용자에게 보여주는 과정을 말한다. 이 단계가 끝나면 브라우저의 렌더링이 발생한다.

🤔 리액트의 렌더링이 발생한다고 해서 실제 DOM 업데이트가 항상 일어나는 것은 아니다.

렌더링을 수행하며 변경 사항을 계산했는데 아무 것도 감지되지 않았다면, 커밋 단계는 생략될 수 있다. 즉, 리액트의 렌더링은 꼭 가시적인 변경이 일어나지 않아도 발생할 수 있다❗️

🤔 리액트의 memo를 사용하면 어떠한 상황에서도 리렌더링 규칙을 무시하는가? (p.182)

React.memo컴포넌트가 받는 props가 바뀌지 않았다면 렌더링 결과를 재사용하여 컴포넌트의 렌더링 과정을 건너뛰게 하는 고차 컴포넌트(HOC)이다.

하지만 이는 리렌더링 규칙 자체를 무시하는 것은 아니다❗️

  • 부모 컴포넌트가 리렌더링되면 자식 컴포넌트도 기본적으로 리렌더링 과정을 거친다.
  • React.memo 로 감싸진 컴포넌트는 이전 props와 새 props를 얕게 비교하고, 변동이 없을 경우 렌더링을 건너뛰고 이전 결과를 재사용한다. 이 경우 커밋 단계도 건너뛰어 실제 DOM 업데이트도 발생하지 않는다.

따라서 React.memoprops 변화가 없을 때 렌더 단계와 커밋 단계를 생략하는 최적화 도구이지, 리렌더링 규칙을 완전히 무시하는 기능이 아니다.
state, context, props 변경 등 리액트의 리렌더링 규칙이 적용되는 상황에서는 여전히 렌더링이 발생한다.

Reference

📚 모던 리액트 Deep Dive

profile
꾸준함이 무기

0개의 댓글