리액트 핵심 요소 깊게 살펴보기 (2)

keemsebeen·2024년 5월 2일

2.3 클래스 컴포넌트와 함수 컴포넌트

클래스 컴포넌트

  • constructor() : 초기화되는 시점에 호출된다. 여기에 선언돼 있는 super()는 컴포넌트를 만드면서 상속받은 상위 컴포넌트, 즉 React.Component의 생성자 함수를 먼저 호출해 필요한 상위 컴포넌트에 접근할 수 있게 도와준다.
  • props : 함수에 인수를 넣는 것과 비슷하게, 컴포넌트에 특정 속성을 전달하는 용도로 쓰인다.
  • state : 클래스 컴포넌트 내부에서 관리하는 값을 의미한다. 이 값은 항상 객체여야만 한다.
  • 메서드 : 렌더링 함수 내부에서 사용되는 함수이다.
    <button onClick={() => this.handleClick()}/>
    다음과 같은 방법은 매번 렌더링이 일어날 때 마다 새로운 함수를 생성해 할당하게 되므로 최적화를 수행하기 어려워진다. 따라서 지양하는 것이 좋다.

클래스 컴포넌트의 생명주기 메소드

  1. 마운트 : 컴포넌트가 생성되는 시점
  2. 업데이트 : 이미 생성된 컴포넌트의 내용이 변경되는 시점
  3. 언마운트 : 컴포넌트가 더 이상 존재하지 않는 시점

render
리액트 클래스 컴포넌트의 유일한 필수 값이다. 마운트와 업데이트 사이에 일어난다.
따라서 어떠한 부수효과가 없어야한다. 항상 같은 입력값이 들어가면 같은 결과물을 반환해야 한다. 따라서 state를 직접 업데이트 하는 this.setState를 호출해서는 안된다.

componentDidMount
클래스 컴포넌트가 마운트되고 준비되는 즉시 실행되는 메소드이다.
this.setState()로 state 값을 변경하는 것이 가능하다. this.setState()를 호출했다면 state가 변경되고, 그 즉시 다시 한 번 렌더링을 시도하는데, 이 작업은 브라우저가 실제로 UI를 업데이트 하기 전에 실행되어 사용자가 변경되는 것을 눈치챌 수 없게 만든다.

componentDidUpdate
컴포넌트 업데이트가 일어난 이후 바로 실행된다. state나 props의 변화에 따라 DOM을 업데이트하는 등에 쓰인다. 여기서도 this.setState() 사용이 가능하다.

componentWillUnmonut
해당 메소드는 컴포넌트가 언마운트되거나 더 이상 사용되지 않기 직전에 호출된다.
메모리 누수나 불필요한 작동을 막기 위한 클린업 함수를 호출하기 위한 최적의 위치다.
API 호출을 취소하거나, setInterval,setTimeout으로 생성된 타이머를 지우는 등의 작업을 하는데 유용하다.

shouldComponentUpdate()
state나 props 변경으로 리액트 컴포넌트가 다시 리렌더링되는 것을 막고 싶을 때 사용한다. 해당 메소드를 활용하면 컴포넌트에 영향을 받지 않는 변화에 대해 정의할 수 있다.

static getDerivedStateFromProps()
가장 최근에 도입된 생명주기 메서드 중 하나로, 이제는 사라진 componentWillReceiveProps를 대체할 수 있는 메서드다. render()을 호출하기 직전에 호출된다. static으로 선언돼 있어 this에 접근할 수 없으며 반환되는 객체는 해당 객체의 state로 들어가게 된다.

getSnapShotBeforeUpdate()
componentWillUpdate()를 대체할 수 있는 메서드다. 이는 DOM이 업데이트되기 직전에 호출된다. 반환 값은 componentDitUpdate로 전달된다. DOM에 렌더링 되기 전 윈도우 크기를 조절하거나 스크롤 위치를 조정하는 등의 작업을 처리하는데 유용하다.

getDerivedStateFromError()
자식 컴포넌트에서 에러 상황이 발생했을 때 호출되는 에러 메서드다.
해당 메소드는 하위 컴포넌트에서 에러가 발생했을 경우에 어떻게 자식 리액트 컴포넌트를 렌더링할지 결정하는 용도로 제공되는 메서드이기 때문에 반드시 미리 정의해둔 state 값을 반환해야 한다. 부수효과 또한 발생시켜서는 안된다.

getDerivedStateFromError() , getSnapShotBeforeUpdate() , componentDidCatch() 메소드는 아직 리액트 훅으로 구현돼 있지 않기 때문에 사용을 원할 시 반드시 클래스 컴포넌트를 사용해야 한다.

componentDidCatch
자식 컴포넌트에서 에러가 발생했을 때 실행되며 getDerivedStateFromError에서 에러를 잡고 state를 결정한 이후에 실행된다. getDerivedStateFromError와 동일한 error, 어떤 컴포넌트가 에러를 발생시켰는지 정보를 가지고 있는 Info 두 가지 인수를 받는다.

getDerivedStateFromError, componentDidCatch는 에러 경계 컴포넌트를 만들기 위한 목적으로 많이 사용된다. 모든 에러를 잡아낼 수 있는 것은 아니고 이를 찾지 못하면 에러는 throw된다.

클래스 컴포넌트의 한계

  1. 데이터의 흐름을 추적하기 어렵다.
  2. 애플리케이션 내부 로직의 재사용이 어렵다.
  3. 기능이 많아질수록 컴포넌트의 크기가 커진다.
  4. 클래스는 함수에 비해 상대적으로 어렵다.
  5. 코드 크기를 최적화하기 어렵다.

함수 컴포넌트

render 내부에서 필요한 함수를 선언할 때 this 바인딩을 조심할 필요도 없으며, state는 객체가 아닌 각각의 원시값으로 관리되어 훨씬 사용하기가 편해졌다.

함수 컴포넌트 vs 클래스 컴포넌트

생명주기 메서드의 부재
클래스 컴포넌트의 생명주기 메서드가 함수 컴포넌트에서는 존재하지 않는다.
함수 컴포넌트는 props를 받아 단순히 리액트 요소만 반환하는 함수인 반면, 클래스 컴포넌트는 render 메소드가 있는 React.Component를 상속받아 구현하는 자바스크립트 클래스이기 때문이다.

useEffect는 state를 활용해 동기적으로 부수효과를 만드는 메커니즘이지 생명주기를 위한 훅이 아니다.

함수 컴포넌트와 렌더링된 값
클래스 컴포넌트는 props의 값을 항상 this로부터 가져온다. this가 가르키는 객체(컴포넌트의 인스턴스)는 변경 가능한 값이다. 따라서 생명주기 메서드가 변경된 값을 읽을 수 있게된다.

함수 컴포넌트는 렌더링이 일어날 때마다 그 순간의 값인 props와 state를 기준으로 렌더링된다. props와 state가 변경된다면, 다시 한번 그 값을 기준으로 함수가 호출된다.

2.4 렌더링은 어떻게 일어나는가?

리액트도 브라우저와 마찬가지로 이 렌더링 작업을 위한 자체적인 렌더링 프로세스가 있으며, 이를 수행하는 것은 곧 리액트를 이해하는 첫걸음으로 볼 수있다.

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

리액트의 렌더링이 일어나는 이유

  1. 최초 렌더링 : 사용자가 최초에 진입하면 당연히 렌더링해야 할 결과물이 필요하다. 리액트는 브라우저에 이 정보를 위해 최초 렌더링을 수행한다.
  2. 리렌더링 : 최초 렌더링을 제외한 모든 렌더링을 의미한다.
    1. 클래스 컴포넌트의 setState가 실행되는 경우
    2. 클래스 컴포넌트의 forceUpdate가 실행되는 경우
    3. 함수 컴포넌트의 useState()의 두번째 요소인 setter가 실행되는 경우
    4. 함수 컴포넌트의 useReducer()의 두번째 요소인 dispatch가 실행되는 경우
    5. 컴포넌트의 key props가 변경되는 경우
    6. props가 변경되는 경우
    7. 부모 컴포넌트가 렌더링될 경우

리액트의 렌더링 프로세스
렌더링이 어떤 과정을 거쳐 수행되는지 살펴보자!

업데이트가 필요할 경우, 클래스 컴포넌트는 클래스 내부의 render() 함수를 실행하게 되고, 함수 컴포넌트의 경우에는 FunctionComponent() 그 자체를 호출한 뒤 그 결과물을 저장한다.

렌더링 결과물은 JSX문법으로 구성돼 있고, 이것이 자바스크립트로 컴파일되면서 React.createElement()를 호출하는 구문으로 변환된다. createElement는 브라우저의 UI 구조를 설명할 수 있는 일반적인 자바스크립트 객체를 반환한다.

function Hello(){
	return (
		<TestComponent a={35} b="yceffort">
			안녕하세요
		</TestComponent>
	)
}

위의 JSX 문법은 다음과 같은 React.createElement를 호출해서 변환된다.

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

결과물은 다음과 같다.

{type : TestComponent, props: {a : 35, b : "yceffort", children : "안녕하세요"}}

렌더링 프로세스가 실행되면서 각 컴포넌트의 렌더링 결과물을 수집한 다음, 리액트의 새로운 트리인 가상 DOM과 비교해 실제 DOM에 반영하기 위해 모든 변경 사항을 차례차례 수집한다.

이러한 과정을 리액트 파이버에 다뤘던 리액트의 재조정이라고 한다. 이런 과정이 끝나면 모든 변경 사항을 하나의 동기 시퀀스로 DOM에 적용해 변경된 결과물이 보이게 된다.

렌더와 커밋
렌더 단계는 컴포넌트를 렌더링하고 변경 사항을 계산하는 모든 작업을 말한다. render() 또는 return 이 결과와 이전 가상 DOM을 비교하는 과정을 거쳐 변경이 필요한 컴포넌트를 체크하는 단계다. type,props,key 셋 중 하나라도 변경되면 변경이 필요한 컴포넌트로 체크해둔다.

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

리액트의 렌더링이 일어난다고 무조건 DOM 업데이트가 일어나는 것은 아니다. 렌더링을 수행했으나 커밋단계까지 갈 필요가 없다면 커밋단계는 생략될 수 있다.
(렌더 단계에서 변경 사항을 감지할 수 없다면 커밋 단계가 생략되어 브라우저의 DOM 업데이트가 일어나지 않을 수 있다.)

리액트의 렌더링은 항상 동기식으로 작동했다. 그러나 리액트 18에서는 의도된 우선순위로 컴포넌트를 렌더링해 최적화할 수 있는 비동기 렌더링(동시성 렌더링)이 도입됐다.

렌더 단계가 비동기로 작동해 특정 렌더링의 우선순위를 낮추거나, 필요하다면 중단하거나 재시작하거나, 따라서는 포기할 수도 있다.

2.5 컴포넌트와 함수의 무거운 연산을 기억해 두는 메모이제이션

주장 1: 섣부른 최적화는 독이다. 꼭 필요한 곳에만 메모이제이션을 추가하자
꼭 필요한 곳을 신중히 골라 메모이제이션해야 한다는 입장이다.

메모이제이션에도 비용이 든다. 값을 비교하고 렌더링 또는 재계산이 필요한지 확인하는 작업, 이전 결과물을 저장해 두었다가 꺼내와야 한다는 두가지 비용이 있다. 따라서 메모이제이션은 신중하게 접근해야 하며 섣부른 최적화는 경계해야 한다.

왜냐하면, 이전 결과를 캐시로 저장해 미래에 더 나은 성능을 위해 메모리를 차례대로 점유하게 된다. 렌더링도 비용이지만 메모리에 저장하는 것도 마찬가지로 비용이다. 따라서 어느정도 만든 후에 개발자 도구나 useEffect를 사용해 실제로 어떻게 렌더링이 일어나고 있는지 확인하고 필요한 곳에서만 최적화하는 것이 옳다.

주장 2: 렌더링 과정의 비용은 비싸다. 모조리 메모이제이션해 버리자
앞선 주장과 현재 주장 모두 공통적으로 해당되는 사항은, 메모이제이션을 하는 것이 성능에 도움이 된다. 이점이 있을 때가 분명히 있다는 점이다.

  • memo를 컴포넌트의 사용에 따라 잘 살펴보고 일부에만 적용하는 방법
  • memo를 일단 그냥 다 적용하는 방법

memo
첫 번째가 가장 이상적인 상황이다. 그러나 잘못된 컴포넌트에 이뤄진 최적화, 즉 렌더링 비용이 저렴하거나 사실 별로 렌더링이 안되는 컴포넌트에 memo를 썼을 때 역으로 지불해야 하는 비용을 생각해보자. 이는 결국 props에 대한 얕은 비교가 발생하면서 지불해야 하는 비용이다.

메모이제이션을 위해서는 CPU와 메모리를 사용해 이전 렌더링 결과물을 저장해 둬야하고, 리렌더링할 필요가 없다면 이전 결과물을 사용해야 한다.(재조정 알고리즘)

반면, memo를 하지 않았을 때는 다음과 같은 문제가 발생한다.

  • 렌더링을 함으로써 발생하는 비용
  • 컴포넌트 내부의 복잡한 로직의 재실행
  • 위 두가지 모두가 모든 자식 컴포넌트에서 반복해서 일어남
  • 리액트가 구 트리와 신규 트리를 비교

언뜻봐도 memo를 하지 않았을 때 위험 비용이 더 크다는 사실을 알 수 있다.

useMemo, useCallback
useMemo와 useCallback을 사용해 의존성 배열을 비교하고, 필요에 따라 값을 재계산하는 과정과 이러한 처리 없이 값과 함수를 매번 재생성하는 비용 중에서 무엇이 더 저렴한지 매번 계산해야한다.
props로 넘겼을 때 참조 투명성을 유지하기 위해서는 useCallback, useMemo을 사용하는 것이 좋다.

profile
프론트엔드 공부 중인 김세빈입니다. 👩🏻‍💻

0개의 댓글