[React] 클래스형 컴포넌트와 함수형 컴포넌트의 차이

thru·2024년 4월 11일
1

함수형과 함수는 다르다.


소개

자바스크립트에서 컴포넌트는 일반적으로 클래스나 함수, 둘 중 하나의 방법으로 만든다. 리액트도 마찬가지로 컴포넌트가 클래스와 함수 두 가지로 나뉘는데, 무의식적으로 1:1 매칭이 된다고 생각하고 있었다. 자바스크립트의 클래스와 함수는 어떤 차이를 가지는 지 알아본 뒤, 리액트의 컴포넌트는 어떤 방식으로 구현되었는지 확인해본다.


컴포넌트

컴포넌트란 어떠한 구성요소에서 구분할 수 있는 가장 작은 단위로, 복잡하게 구성되어있는 요소들을 나누어 효과적으로 관리하기 위해서 사용한다. 컴포넌트는 독립성, 모듈성, 재사용성의 3가지 원칙을 근간으로 한다. 컴포넌트 간 의존을 최소화해 독립적이면서 추가/제거가 용이한 모듈로 작동하고 재사용이 가능해야 된다는 의미이다.

프론트엔드는 상호작용 기능이 중요해짐에 따라 웹 앱의 복잡도가 증가하면서 비교적 최근에 컴포넌트 시스템이 정립되었다. 최종적으로 HTML에 데이터를 출력하는게 목적이기 때문에 DOM 요소와 연결하고 변형하는 기능이 컴포넌트의 주요 역할이다.

코드 상에서 컴포넌트는 객체를 생성할 수 있는 요소인 생성자로 나타나고, 복제되어 만들어진 객체는 인스턴스라고 한다. 자바스크립트에서 객체 생성자는 대표적으로 두 가지이다.

생성자 함수

함수는 앞에 new 키워드와 함께 호출하면 인스턴스를 만드는 알고리즘이 동작한다. 먼저 함수의 prototype 속성을 상속하는 새로운 객체를 만들고, 함수에 this로 바인딩해서 실행한다.

함수의 prototype 속성은 일반적으로 말하는 객체의 프로토타입과는 다르다. 객체의 프로토타입은 프로토타입 체인으로 연결된 객체를 말하고, prototype 속성은 함수 자신과 대응되는 프로토타입 역할의 객체이다.

함수가 명시적으로 반환하는 값이 없는 경우 해당 객체를 반환한다. 기존 반환 값이 있는 경우 그 값을 그대로 반환하고 새로운 객체는 버려지기 때문에 보통 생성자 함수에는 반환 값을 지정하지 않는다. 함수 본문에서 this에 새로운 속성을 할당하는 로직을 작성하면 원하는 속성을 인스턴스에 추가할 수 있다.

관례적으로 생성자 함수는 첫 글자를 대문자로 작성해 일반 함수와 구분한다. 반드시 new를 붙여야 인스턴스를 반환하므로 new.target를 이용해서 안전성을 높이기도 한다.

function Card(id) {
  if (!new.target) return new Card(id);
  
  this.element = null;
  this.id = id;
}

// ??
Card.prototype.mount = function () {
  this.element = document.querySeletor(this.id);
};

const myCard = new Card("#card");
myCard.mount();

코드를 보면 일반 값 기반 속성과 달리 함수 속성인 메서드는 생성자 함수 내부 this에 추가하지 않고 prototype 속성에 추가하고 있다. 이는 자바스크립트라는 프로토타입 기반 환경에서 유리한 방식이기 때문이다. 아래 코드처럼 일반 속성에 메서드를 추가해도 작동은 한다.

function Card(id) {
  if (!new.target) return new Card(id);
  
  this.element = null;
  this.id = id;
  this.mount = function () { /**@생략 */ };
}

이 경우, 인스턴스가 생성될 때마다 메서드도 새로 생성되어 할당된다. prototype의 경우, 현재 객체에 없는 속성은 프로토타입으로 연결된 객체에서 탐색하므로 하나의 메서드만 생성해도 동작한다. 대신 프로토타입 체인을 따라 메서드를 탐색하는 시간이 추가된다.

이렇게 보면 trade-off 관계로 보이지만 자바스크립트 엔진은 프로토타입 최적화가 구현되어 탐색 시간 소모가 크지 않다. 또한 언어 자체도 this나 클로져처럼 문맥을 활용하는 기능이 기본으로 존재해 프로토타입과 연계되도록 설계되어있다.

this는 프로토타입에 설정된 메서드에 실행 시점의 문맥을 전달해 각 인스턴스에 맞게 메서드를 실행할 수 있게 한다. 클로져는 선언 시점의 문맥을 저장하는 기본 기능이다. 인스턴스마다 메서드를 선언하는 것보다 클로져를 활용하는 게 메모리를 덜 사용하고 간단하다.


생성자 함수의 기본 사용 예시를 설명하려던 것 뿐인데 프로토타입에 엔진 최적화, this의 역할까지 나왔다. 자바스크립트 입문자에겐 큰 진입장벽으로 느껴진다. 또한 일반 함수와 사실상 동일한 구조를 사용하고 있어 코드를 볼 때 구분이 쉽게 이루어지지 않을 가능성이 있다. 프로토타입 메서드 부분은 가독성이 좋지 않게 보이기도 한다. 때문에 자바스크립트는 객체 생성만을 위한 문법인 클래스를 EcmaScript 2015에 도입했다.

클래스

클래스는 타 프로그래밍 언어에서 객체를 만들기 위해 사용되는 문법이다. 자바스크립트에 도입되었던 시점에는 거의 생성자 함수의 문법적 설탕(Syntactic sugar) 역할만 수행했다.

문법적 설탕: 사람이 읽기 쉽게 디자인된 문법으로 동작은 언어의 기존 기능을 그대로 사용한다. 자바스크립트의 클래스도 내부 동작은 생성자 함수와 대부분 동일하다.

다만 EcmaScript 2022처럼 최근에 와서는 private이나 static처럼 클래스 전용 기능도 추가되어서 단순 문법적 설탕으로 보기엔 어려워졌다.

클래스가 가장 쉽게 만들어주는 것은 상속이다. 프로토타입을 직접 연결하는 과정이 필요한 생성자 함수와 달리 extendssuper 키워드만 사용하면 된다.

class NameCard extends Card {
  name;
  constructor(id, name) {
    this.super(id);  /**@ 부모 클래스의 생성자를 실행 */
    this.name = name;
  }
  
  render() {
    this.element.innerText = this.name;
  }
}

같은 코드를 생성자 함수로 작성하면 아래처럼 된다.

function NameCard(id, name) {
  Card.call(this, id);
  this.name = name;
}

NameCard.prototype.render = function () { /**@생략 */ }
Object.setPrototypeOf(NameCard.prototype, Card.prototype);

정확히는 상속이 아닌 위임이다. 상속은 부모의 속성을 복제하는 것이고 위임은 실행 책임을 다른 객체로 전달하는 것이다. 생성자 함수 파트에서 언급한 것처럼 매 인스턴스마다 새로 메서드를 생성하면 상속이 되지만, 이점이 없다.

기능은 같지만 코드를 봤을 때 이해하기 어렵다. 또 프로토타입을 동적으로 수정하는 건 엔진의 프로토타입 최적화를 중단시키므로 성능 문제가 발생할 수 있다.

결론적으로 컴포넌트 생성에 있어 클래스가 생성자 함수의 상위호환이라고 생각한다.


리액트 컴포넌트

과거 리액트에서는 클래스 컴포넌트를 주로 사용했다. 리액트 16.8 버전부터 Hook이 도입되면서 함수 컴포넌트가 주력으로 쓰이기 시작했다. 이전 버전에도 함수 컴포넌트가 존재는 했지만 템플릿을 반환하는 기능밖에 없어 간단한 컴포넌트에만 사용할 수 있었다고 한다.

위 파트의 결론에서 유추할 수 있지만 함수 컴포넌트생성자 함수 기반아니다. 코드 작성자 입장에서 함수를 작성하기 때문에 함수를 붙여 부르는 것으로 추측된다. 함수 컴포넌트가 어떤 방식으로 동작하는지 알아보려면 먼저 클래스 컴포넌트를 간단히 살펴보는 게 도움된다.

클래스 컴포넌트

클래스 컴포넌트는 기본 리액트 Component를 상속하고 작성한다. Component는 예상과는 달리 클래스가 아닌 생성자 함수로 작성되어있다.

/**@ ReactBaseClasses.js */

function Component(props, context, updater) {
  this.props = props;
  this.context = context;
  this.refs = emptyObject;
  this.updater = updater || ReactNoopUpdateQueue;
}

Component.prototype.setState = function (partialState, callback) {
  if (
    typeof partialState !== 'object' &&
    typeof partialState !== 'function' &&
    partialState != null
  ) {
    throw new Error( /**@생략 */ );
  }

  this.updater.enqueueSetState(this, partialState, callback, 'setState');
};

이는 extends가 프로토타입 체인을 이어주는 역할을 하기에 프로토타입이 있는 객체라면 모두 대상이 될 수 있기 때문이다.

내부에는 몇 가지 속성과 setState 메서드가 정의되어있다. state와 생애주기 메서드들은 상속받는 컴포넌트에서 정의해서 사용한다.

class Card extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      name
    };
  }
  
  render() {
    return (
      <div>{this.state.name}</div>
    );
  }
}

생애주기

🔽 리액트의 생애주기 메서드

리액트 18 버전으로 입문한 개발자들도 이 생명주기 이름은 보았을텐데, 유래가 바로 클래스 컴포넌트의 생애주기 메서드 이름이다. 컴포넌트에 관련 메서드를 정의하면 해당 생애주기에 코드를 실행시킬 수 있다.

생애주기 메서드는 리액트의 스케쥴러인 workLoop 내부에서 존재 여부를 확인 후 실행된다.

/**@ ReactFiberWookLoop.js */

function workLoopConcurrent() {
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

function performUnitOfWork(unitOfWork: Fiber): void {
  /**@생략 */
    next = beginWork(current, unitOfWork, entangledRenderLanes);
  /**@생략 */
}

/**@ ReactFiberBeginWork.js */
function beginWork( /**@생략 */ ) {
  /**@생략 */
  switch (workInProgress.tag) {
    case FunctionComponent: {
    /**@생략 */
    case ClassComponent: {
      const Component = workInProgress.type;
      const unresolvedProps = workInProgress.pendingProps;
      const resolvedProps = resolveClassComponentProps(
        Component,
        unresolvedProps,
        workInProgress.elementType === Component,
      );
      return updateClassComponent(
        current,
        workInProgress,
        Component,
        resolvedProps,
        renderLanes,
      );
    }
    /**@생략 */
  }
}
    
function updateClassComponent( /**@생략 */ )
  /**@생략 */
    if (shouldUpdate) {
      if (typeof instance.componentDidUpdate === 'function') {
        workInProgress.flags |= Update;
      }
      if (typeof instance.getSnapshotBeforeUpdate === 'function') {
        workInProgress.flags |= Snapshot;
      }
    }
  /**@생략 */
}

클래스 컴포넌트를 업데이트할 때 인스턴스의 메서드가 정의되어 있으면 workInProgress에 플래그를 설정한다. workInProgress는 일반적으로 말하는 Virtual DOM의 노드인 Fiber인데 이후 reconcile 과정에서 처리된다.

함수 컴포넌트

리액트의 함수 컴포넌트는 JSX를 반환하는 점에서 생성자 함수라기 보단 render 메서드가 분리되어 나온 것과 유사하다. 클래스 컴포넌트 속성이었던 state나 생애주기 메서드들은 Hook으로 떨어져 나와서 따로 주입하는 형태로 변경되었다.

생성자 함수도 클래스도 아니라면 인스턴스를 어떻게 생성하는지 궁금할 것이다. 함수 컴포넌트는 인스턴스가 아닌 일반 객체를 반환한다. 이 객체가 유명한 React Element이고 내부 속성으로 함수 객체와 JSX를 파싱한 html 정보 객체를 갖는다.

리액트가 클래스나 생성자 함수가 아닌 일반 객체를 사용한 이유는 상속이 필요없었기 때문으로 추측된다.
Facebook에서는 수천 개의 React 컴포넌트를 사용하지만, 컴포넌트를 상속 계층 구조로 작성을 권장할만한 사례를 아직 찾지 못했습니다. -리액트 공식문서

React Element는 인스턴스처럼 작동하지 않고 사실상 Fiber로의 정보 전달에 사용된다. Fiber는 초기 reconcile 과정에서 React Element에 저장된 정보를 자신의 속성에 저장한다. 여기엔 개발자가 작성한 함수 객체의 참조값도 포함하므로 리렌더링 과정에서 활용할 수 있다.

Hooks

클래스 컴포넌트에 있던 props, state, context, refs 및 생애주기와 같은 React 개념은 훅으로 책임이 이동했다. 클래스 컴포넌트의 구조에선 상태 관련 로직 공유를 위해 컴포넌트 계층의 변화가 생겨나기도 하고, 생애주기 메서드는 각자 분리되어있어 서로 관련된 코드가 쪼개지는 문제가 있었다고 한다. 이를 useStateuseEffect 같은 훅으로 해결했다.

함수 컴포넌트에선 생애주기 메서드가 존재하지 않으므로 스케쥴러에서 속성을 검사하는 방식으로 실행할 수 없다. 대신 Fiber가 훅의 리스트를 저장해서 기능을 수행한다.

// The work-in-progress fiber. I've named it differently to distinguish it from
// the work-in-progress hook.
let currentlyRenderingFiber: Fiber = (null: any);
// Hooks are stored as a linked list on the fiber's memoizedState field. The
// current hook list is the list that belongs to the current fiber. The
// work-in-progress hook list is a new list that will be added to the
// work-in-progress fiber.
let currentHook: Hook | null = null;
let workInProgressHook: Hook | null = null;

function mountWorkInProgressHook(): Hook {
  const hook: Hook = {
    memoizedState: null,

    baseState: null,
    baseQueue: null,
    queue: null,

    next: null,
  };

  if (workInProgressHook === null) {
    // This is the first hook in the list
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // Append to the end of the list
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}

컴포넌트가 마운트될 때 업데이트가 일어나는 FibermemoizedState에 훅이 링크드 리스트의 형태로 삽입된다. 연결된 훅들은 이후 reducer 과정에서 순차적으로 처리된다.

이름이 memoizedState인 것처럼 Fiber에는 useStateuseReducer같은 상태관련 훅이 연결된다. useEffect로 나타나는 Side-Effect는 Virtual DOM을 순회하면서 다른 큐에 담아 저장하다가 commit 과정에서 소비된다.


결론

리액트의 함수 컴포넌트는 직접적으로 인스턴스를 생성하지 않고 인스턴스 역할을 하는 Fiber에 정보를 전달한다.


참조

profile
프론트 공부 중

0개의 댓글