main

리액트 딥다이브 살펴보기 III

Log
15

리액트 딥다이브 1장의 3,4,5장을 읽으면서 따로 추가적으로 탐구하고 싶었던 부분은 아래와 같습니다.

  • 탐구하고 싶었던 부분
    • 클래스의 변수 정의 (private, public, 클래스 필드)
    • 리액트에서 클로저의 활용법
    • 리액트 렌더링 스택을 비우는 방식 (동기식 렌더링)

📌 클래스의 변수 정의 (private, public, 클래스 필드)

우아한 테크코스에 참여했을 때, 클래스에서 변수를 초기화하는 방법에 있어서 클래스 필드에 정의를 내리는 것과 constructor 내부에서 값을 지정하는 것에 대한 차이는 무엇일까?에 대한 고민이 있었습니다. 그래서 이 기회에 탐구를 해보고자 합니다.

public class Person{
	priavte String firstName = ""; // 클래스 필드에 클래스 멤버 생성
	public String getName() {
		return this.firstName;
	}
}

자바에서는 위와 같이 사용할 수 있는데, 이때 firstName이 생성된 곳을 클래스 필드라고 합니다.
자바와 비슷하게 자바스크립트에서도 아래처럼 정의할 수 있는데요,

class Person {
  firstName = 'Lee';
}
 
const person = new Person() // Person {firstname:"Lee"}

ES6+이후 javascript에서도 클래스 필드에 선언 할 수 있게 되었습니다.
클래스필드에 변수를 선언하면 인스턴스의 변수가 됩니다. 원래라면 아래와 같이 작성해야했습니다.

class Person {
  constructor() {
    this.firstName = 'Lee';
  }
}
 
const person = new Person() // Person {firstname:"Lee"}

따라서 클래스 필드는 객체지향프로그래밍에 더 가까운 언어들을 사용하는 개발자들을 위해서 허용하게 된 것이라고 볼 수 있습니다.
그런데 private 관련 변수들은 클래스 필드에 정의해주어야 사용할 수 있습니다.

class Person {
  #firstName
  lastName;
 
  constructor() {
    this.#firstName = 'Lee';
    this.lastName = 'Kim';
  }
}
 
class Person {
  #firstName = 'Lee';
  lastName = 'Kim';
}

따라서 private Field를 사용하기 위해서는 위와 같이 정의해주면 됩니다.
타입스크립트에서는 이를 어떻게 설정해주어야 할까요?

class Person {
    #firstName: string;
    lastName: string;
 
    constructor() {
        this.#firstName = 'Lee';
        this.lastName = 'Kim';
    }
}
 
class Person {
    #firstName: string = 'Lee';
    lastName: string = 'Kim';
}

아니면 아래와 같이 초기화와 변수 할당을 하고 싶다면 위와 같이 생략도 가능합니다. 이때 public이나 private도 동시에 설정해줄 수 있습니다.

class Person {
    constructor(private firstName: string, public lastName: string) {
        this.firstName = firstName;
        this.lastName = lastName;
    }
}

  • 정리
    • 클래스 필드는 자바스크립트에서는 어떤 변수들이 있는지 목록상으로 확인할 수 있는 것 같고, 클래스 필드는 타입스크립트에서 더 유용하게 쓰이는 것 같습니다.

📌 리액트에서 클로저의 활용법

책 내용에서도 리액트에서 useState가 클로저를 활용한 개념이다. 라고 나와있는데, 부가적인 설명과 공부? 를 추가적으로 기록해놓으면 좋을 것 같아 포스팅합니다. 리액트 개발자인 Dan AbramovuseState는 각 state때마다 사진을 찍는 것처럼 값을 기억하는 것이라고 했는데요,

function App() {
  const [state, setState] = useState(0);
 
  const onClickHandler = () => {
    setState(prev => prev + 1);
  }
 
  return(
    <div>
      <button onClick={onClickHandler}>버튼</button>
    </div>
  )
}

이때 return문은 외부함수라고 생각하고, stateonClickHandler는 내부값이라고 생각하면 클로저를 활용하고 있음을 알고 있습니다.
그런데 Dan Abramov는 리액트는 매 렌더링마다 새로운 state와 함수들을 정의하고 기억한다고 말한 적이 있습니다.
그럼 매번 함수의 참조 값이 달라지는 것인가? 에 대한 의문을 갖게 되었는데요, 정답은 맞다입니다.

function App() {
  const [state, setState] = useState(0);
 
  const onClickHandler = () => {
    // state가 0일때
    // setState(0 => 0 + 1);
    setState(prev => prev + 1);
  }
 
  return(
    <div>
      <button onClick={onClickHandler}>버튼</button>
    </div>
  )
}
 
function App() {
  const [state, setState] = useState(1);
 
  const onClickHandler = () => {
    // state가 1일때
    // setState(1 => 1 + 1);
    setState(prev => prev + 1);
  }
 
  return(
    <div>
      <button onClick={onClickHandler}>버튼</button>
    </div>
  )
}

위와 같이 매 렌더링 때마다 함수를 정의하고 리터문이 참조하는 함수의 참조값이 변하게 됩니다.(새로 정의됐으니까) 왜그럴까? 라고 생각해보면 매번 최신값인 prev를 함수가 참조해야하기때문에 렌더링이 되는 이유는 대체로 값이 변할 때입니다. 따라서 값이 변할 때마다, 함수는 항상 최신값을 참조해야하기 때문에 매번 렌더링 될 때마다 매번 다른 함수를 정의하고 기억합니다.

그렇다면 만약 state가 100까지 증가했다면, 100개의 매번 새로운 함수를 정의하고 참조했으니 100번 이 과정을 거쳤을텐데요,
그럼 리액트는 이 100개의 함수를 모두 기억하고 있을까요? (리액트는 매번 사진을 찍듯 기억한다고 했어서 이런 의문점을 갖게 되었습니다.)

React는 메모리 효율적으로 설계되었으며, 서로 다른 렌더링 중에 생성된 함수의 각 인스턴스에 대해 별도의 참조를 저장하지 않습니다. 대신 React는 함수에 대한 최신 참조만 기억합니다.

라고 하네요. 따라서 리액트는 매번 가장 최근 렌더링된 함수들을 정의하고, 참조한다고 합니다.🥹


📌 리액트 렌더링 스택을 비우는 방식 (동기식 렌더링)

16버전 이전에는 리액트가 동기식 렌더링 방식으로 이뤄졌었는데요, 이게 무슨말일까요?
리액트는 16버전 이전에 리액트는 콜스택 기반의 재귀호출 방식이었기 때문에 한번 렌더링을 하면 완료되기전까지 중단할 수 없었다고 합니다. 따라서 렌더링을 중단시키거나, 진행하고 있던 렌더링을 버리고 다시 시작할 수 없었습니다.

리액트에서는 이런 렌더링 과정을 재조정이라고 하는데요, 16버전 이전은 Stack reconciler라고 하고, 16버전 이후에는 fiber가 등장하면서 Fiber Reconciler라고 합니다.

그렇다면, Stack reconciler는 어떤 한계가 있었을까요?
Stack Reconciler는 아래의 과정을 통해 동기적으로 리렌더링을 처리했습니다.

  • 리렌더링 과정
    • DOMTreetop부터 시작해서 재귀 형식으로 모든 component.render()를 호출 하고
    • VirtualDOM에 변경된 사항을 확인하고,
    • 업데이트가 필요한 component를 파악하고
    • 해당 component와 모든 자식 component들도 업데이트가 필요한지 파악하고
    • component 하나씩 업데이트를 시작한다.

아래 코드를 업데이트한다고 생각해보겠습니다.

function Test() {
  return (
    <Parent>
      <ChildA />
      <ChildB />
      <ChildC />
    </Parent>
  );
}

그림으로 보자면 아래와 같습니다.
231204-160930
Stack이라는 이름답게 LIFO (Last-In-First-Out) 식으로 업데이트 됩니다.
자바스크립트의 콜스택과 비슷한 원리입니다. 동기적으로 화면을 업데이트하게 됩니다.
하지만 이 방식은 많은 component들을 동시에 업데이트 하거나, 특정 업데이트가 오래 걸리는 연산을 포함하는 경우
동기적으로 하나씩 업데이트를 처리한다는 특성상 사용자 입장에서 화면이 멈춘것 처럼 보일 수 있게 됩니다.
또한 한번 렌더링을 하게 되면 중단할 수 없다는 단점이 있습니다.

Team React 소속인 Andrew Clark에 따르면 react-fiber-architecture의 목표는 incremental rendering 기법을 사용해서 Fiber Reconciler가 지정한 우선순위 대로 렌더링을 일어나게 하는 것입니다.

state 변경으로 업데이트(re-rendering)가 필요한 상황이되면, 어떻게 업데이트할지 결정해야하는데 Andrew Clark은 하나의 업데이트를 work라고 부릅니다. 그리고 어떤 work를 실행해야할지 결정하는 과정을 scheduling이라고 표현한다.

Stack Reconciler는 업데이트할 component를 찾으면 stack에 넣고 하나씩 업데이트를 했다면, Fiber Reconciler는 업데이트할 component들의 우선순위를 파악해서 점수를 매기게 됩니다. 우선순위는 사전에 작성해둔 카테고리에 따라 부여됩니다. 그러기 위해서는 4가지 일을 할 수 있어야 합니다.

  • work를 중지하고, 필요 시 다시 시작할 수 있어야 한다.
  • 다른 종류의 work들에게 우선순위를 부여할 수 있어야 한다.
  • 이미 완료된 work를 재사용 할 수 있어야 한다.
  • work가 더이상 필요 없게 되면 버릴 수 있어야 한다.

예를들어, 우선순위 3점짜리 work를 하고있는데 갑자기 20점짜리 work가 들어오면, 3점짜리를 중지하고, 20점짜리를 실행하고, 다시 3점짜리를 시작해야합니다. 이를 위해 work를 조금 더 세밀하게 쪼갤 필요를 느꼈고, 이 work들이 자잘한 단위로 쪼개진 것을 fiber라고 합니다. fiber들의 변동사항들이 모여서 work를 만드는 것입니다.


Fiber는 component와 component의 input / output정보를 갖고있는 JavaScript 객체입니다.
react-reconciler의 소스코드를 보면 실제로 자바스크립트 객체로 구현되어있습니다. 이 객체는, 담당하는(?) React Element의 위치를 DOM Tree내에서 표현하게 됩니다. 아래와 같은 React Component가 있다고 가정해보겠습니다.

<ComponentA>
  <ComponentB>
    <ComponentC>
      <ComponentD />
    </ComponentC>
  </ComponentB>
  <ComponentE>
    <ComponentF />
    <ComponentG />
  </ComponentE>
</ComponentA>

위에 나타난 React Component를 Fiber를 활용한 DOM Tree로 변경하면 아래와 같은 그림이 됩니다.

231204-161837

Fiber Reconcilerqueue를 사용해서 화면을 업데이트하게됩니다. update queue의 소스코드는 여기서 확인할 수 있습니다.

queue는 두가지 종류가 있는데, 하나는 current queue, 다른 하나는 work-in-progress queue라고 부릅니다. current queue는 이름에서 유추할 수 있듯 현재 화면에 보이는 상태를 나타내고, work-in-progress queue는 화면을 업데이트하기 위해 fiber node를 처리하는 queue입니다. Fiber가 JavaScript 객체이기 때문에, work-in-progress queue의 fiber들은 commit되기 전까지 비동기적으로 자유롭게 mutate가 가능합니다. 그림으로 보자면 이런 느낌과 같습니다.

231204-162024

초록색으로 표시된 node들이 업데이트가 필요한 React Component입니다. Fiber Reconciler가 업데이트를 감지하면, 변동사항이 바로 화면에 적용되는 것이 아닙니다. 현재 화면을 나타내는 cureent queue를 복제해서 work-in-progress queue를 생성하고, 해당 work-in-progress queue에 변동사항을 반영합니다. work-in-progress queue의 node들의 mutation이 완료되면, commit되어 해당 work-in-progress queue가 새로운 current queue가 됩니다.


김다은 이모지
Daeun Kim
Junior Frontend Engineer