본 포스팅 시리즈는 📚'모던 리액트 Deep Dive'를 주간 별로 1장씩 공부하며
* 새롭게 알게 된 것들
* 평소 알고 있다고 생각했지만 이번에 제대로 알게 된 것들
* 궁금한 부분에 대해 딥다이브한 것들
등을 기재하기 위해 시작되었다.
출처: Render-tree Construction, Layout, and Paint
display: none
과 같은 속성이 적용되어 있어 사용자 화면에 보이지 않는 요소는 방문하지 않는다. 리액트 파이버는 리액트에서 관리하는 평범한 자바스크립트 객체이다. 파이버들은 파이버 재조정자가 관리하는데, 이 파이버 재조정자가 하는 일은 다음과 같다.
파이버
가 할 수 있는 일들은 다음과 같다.
이러한 모든 과정은 비동기로 일어난다❗️
과거 리액트의 조정 알고리즘은 스택 알고리즘으로 이뤄져 있었기 때문에, 스택에 렌더링에 필요한 작업들이 쌓이고 이 스택이 빌 때까지 동기적으로 작업이 이루어지는 형태였다. 자바스크립트의 싱글 스레드라는 특징으로 인해, 이 동기 작업은 중단될 수 없었다. 이는 리액트의 비효율성으로 이어졌다.
이러한 기존 렌더링 스택의 비효율성을 타파하기 위해 파이버
라는 개념이 탄생하게 되었다.
♾️ 더블 버퍼링
: 리액트 파이버의 작업이 끝나면 단순히 포인터를 변경해, workInProgress 트리를 현재 트리로 변경한다.
- 더블 버퍼링을 위해 트리가 두 개 존재한다.
- 더블 버퍼링은 커밋 단계에서 수행된다.
출처: An Introduction to React Fiber - The Algorithm Behind React
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 인스턴스가 아니라 전역 객체이기 때문에, this
는 undefined가 된다.
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
에 접근해야 한다면
this
를 클래스 인스턴스에 바인딩한다.render()
마운트(mount)
와 업데이트(update)
과정에서 렌더링이 일어난다.render()
내부에서 state를 직접 업데이트할 수 없다.componentDidMount()
componentDidMount()
내부에서 state를 직접 업데이트하는 것이 가능하다.componentDidMount()
에서 할 수 밖에 없는 작업일 경우에만 하는 것이 좋다.componentDidUpdate()
componentDidUpdate()
내부에서 state를 변경하는 것이 가능하다. this.setState()
함수가 계속해서 호출되는 일을 방지할 수 있다. componentWillUnmount()
언마운드(unmount)
되거나 더 이상 사용되지 않기 직전에 호출된다. componentWillUnmount()
내부에서는 state 변경이 불가능하다.shouldComponentUpdate()
getDerivedStateFromProps()
componentWillReceiveProps()
를 대체할 수 있는 메서드이다. render()
를 호출하기 직전에 호출되며, 모든 render()
실행 시에 호출된다.this
에 접근할 수 없다. getSnapShotBeforeUpdate()
componentWillUpdate()
를 대체할 수 있는 메서드이다. 생명주기 메서드는 실행되는 순서가 있지만 클래스에서 작성할 때는 메서드의 순서를 맞춰줘야 하는 것은 아니기 때문에 개발자가 주의를 기울이지 않는다면 생명주기 메서드의 순서와 상관 없이 코드가 작성되어 있을 것이고, state의 흐름을 추적하기가 매우 어렵게 된다.
컴포넌트 간에 중복되는 로직이 있고 이를 재사용하고 싶은 경우에는, 주로 컴포넌트를 또 다른 고차 컴포넌트로 감싸거나 props를 넘겨주는 방식을 사용하게 된다.
하지만 이 경우 공통 로직이 많아질수록 이를 감싸는 고차 컴포넌트 내지는 props가 많아지는 래퍼 지옥(wrapper hell)에 빠져들 위험성이 커진다는 심각한 단점이 있다.
컴포넌트 내부 로직 증가 ➡️ 데이터 흐름 복잡도 증가 ➡️ 생명주기 메서드 사용 증가 ➡️ 컴포넌트의 크기 기하급수적으로 증가 📈
클래스는 비교적 뒤늦게 나온 개념이라 자바스크립트 개발자에게 클래스보다는 함수가 더 익숙하며, 자바스크립트 환경에서는 함수에 비해 클래스의 사용이 비교적 어렵고 일반적이지 않다.
클래스 컴포넌트는 최종 결과물인 번들 크기를 줄이는 데도 어려움을 겪는다. 사용하지 않는 메서드도 빌드 시 트리 쉐이킹이 되지 않고 번들에 그대로 포함되기 때문에, 번들링을 최적화하기에 클래스 컴포넌트는 불리한 조건임을 알 수 있다.
🔄 핫 리로딩(hot reloading)
: 코드에 변경 사항이 발생했을 때 앱을 다시 시작하지 않고서도 해당 변경된 코드만 업데이트해 변경 사항을 빠르게 적용하는 기법.
클래스 컴포넌트는 최초 렌더링 시에 instance를 생성하고 그 내부에서 state 값을 관리하는데, 이 instance 내부에 있는 render()
함수를 수정하게 되면 이를 반영할 수 있는 방법은 오직 instance를 새로 만드는 것 뿐이라 instance 내부 state 값은 초기화될 수 밖에 없다.
반면 함수 컴포넌트는 state를 함수가 아닌 클로저에서 저장해 두므로 함수가 다시 실행돼도 해당 state를 잃지 않고 다시 보여줄 수 있게 된다.
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를 기준으로 렌더링된다.
렌더링은 브라우저에서도 사용되는 용어이며, 브라우저에서의 렌더링과 리액트에서의 렌더링의 의미가 다르다.
브라우저에서의 렌더링이란, 쉽게 말해 HTML과 CSS 리소스를 기반으로 웹페이지에 필요한 UI를 그리는 과정을 의미한다.
이와 비교하여 리액트에서의 렌더링이란, 리액트 애플리케이션 트리 안에 있는 모든 컴포넌트들이 현재 자신들이 가지고 있는 props와 state의 값을 기반으로 어떻게 UI를 구성하고, 이를 바탕으로 어떤 DOM 결과를 브라우저에 제공할 것인지 계산하는 일련의 과정을 의미한다.
리액트에서 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
로 사용해야 한다❗️
1️⃣ 리액트는 컴포넌트의 루트에서부터 차근차근 아래쪽으로 내려가면서 업데이트가 필요하다고 지정돼 있는 모든 컴포넌트를 찾는다.
2️⃣ 업데이트가 필요하다고 지정돼 있는 컴포넌트를 발견하면
render()
함수를 실행해 결과물을 저장.3️⃣ 렌더링 결과물은 보통 JSX 문법으로 구성되어 있기 때문에, JS로 컴파일되면서 React.createElement()를 호출하는 구문으로 변환된다.
4️⃣ 각 컴포넌트의 렌더링 결과물을 수집한 다음, 리액트의 새로운 트리인 가상 DOM과 비교해 실제 DOM에 반영하기 위한 모든 변경 사항을 수집한다.
5️⃣ 이러한 재조정(Reconciliation)
과정이 끝나면, 모든 변경 사항을 하나의 동기 시퀀스로 DOM에 적용해 변경된 결과물이 보이게 된다.
리액트의 렌더링은 렌더 단계와 커밋 단계라는 총 두 단계로 분리되어 실행된다.
렌더 단계(Render Phase)는 컴포넌트를 렌더링하고 변경 사항을 계산하는 모든 작업을 말한다.
type
, props
, key
를 체크.커밋 단계(Commit Phase)는 렌더 단계의 변경 사항을 실제 DOM에 적용해, 사용자에게 보여주는 과정을 말한다. 이 단계가 끝나면 브라우저의 렌더링이 발생한다.
🤔 리액트의 렌더링이 발생한다고 해서 실제 DOM 업데이트가 항상 일어나는 것은 아니다.
렌더링을 수행하며 변경 사항을 계산했는데 아무 것도 감지되지 않았다면, 커밋 단계는 생략될 수 있다. 즉, 리액트의 렌더링은 꼭 가시적인 변경이 일어나지 않아도 발생할 수 있다❗️
memo
를 사용하면 어떠한 상황에서도 리렌더링 규칙을 무시하는가? (p.182)React.memo
는 컴포넌트가 받는 props가 바뀌지 않았다면 렌더링 결과를 재사용하여 컴포넌트의 렌더링 과정을 건너뛰게 하는 고차 컴포넌트(HOC)이다.
하지만 이는 리렌더링 규칙 자체를 무시하는 것은 아니다❗️
React.memo
로 감싸진 컴포넌트는 이전 props와 새 props를 얕게 비교하고, 변동이 없을 경우 렌더링을 건너뛰고 이전 결과를 재사용한다. 이 경우 커밋 단계도 건너뛰어 실제 DOM 업데이트도 발생하지 않는다.따라서 React.memo
는 props 변화가 없을 때 렌더 단계와 커밋 단계를 생략하는 최적화 도구이지, 리렌더링 규칙을 완전히 무시하는 기능이 아니다.
state, context, props 변경 등 리액트의 리렌더링 규칙이 적용되는 상황에서는 여전히 렌더링이 발생한다.