component, element 그리고 instance in React

률루랄라·2022년 6월 28일
0
post-thumbnail


React component 와 element 이미 익숙한 개념이다.
React Reconiliation을 정리하다보니 또한 여러번 나오는 개념이다.
문맥상 해당 글들을 이해하기에는 큰 어려움은 없으나 사실 위의 개념들은 서로 구분되는 개념들로써 정확한 의미 해석 및 문맥을 이해하려면 어떻게 분류되는 것인지 알 필요가 있다고 생각이 들어 리액트 공식문서에서 React Components, Elements, and Instances를 정리한 글이다.


React Components, Elements, and Instances

컴포넌트와, 그의 instance 그리고 element간의 구분은 헷갈리는 개념이다. 왜 그들은 모두 스크린에 그려진 어떤 것을 지칭하면서 다른 단어들로 사용되는가?


Managing the Instances

만약 React에 새로운 사람이라면, class형 컴포넌트와 instance를 접한적이 있을 것이다. 예를 들면, Button 컴포넌트를 만들기 위해 class를 만들었을 것이다. 어플리케이션이 작동할 때, 화면에는 해당 컴포넌트의 여러 instance들이 나타날 것이고, 각각의 instance들은 고유한 상태와 속성들을 가지고 있다. 이것이 전통적인 객체지향 UI 프로그래밍 (object-oriented UI programming)이다.

이 전통적인 UI 모델에서 child component instance를 만들고 지우는 것은 개발자의 책임 (몫)이다. 만약 Form 컴포넌트가 Button 컴포넌트를 render 하길 원한다면, Form 컴포넌트는 Button 컴포넌트 instance를 만들고 새로운 정보 (new props, state)에 대응하여 수동적으로 최신 상태를 유지해줘야 한다.

class Form extends TraditionalObjectOrientedView {
  render() {
    // Read some data passed to the view
    const { isSubmitted, buttonText } = this.attrs;

    if (!isSubmitted && !this.button) {
      // Form is not yet submitted. Create the button!
      this.button = new Button({
        children: buttonText,
        color: 'blue'
      });
      this.el.appendChild(this.button.el);
    }

    if (this.button) {
      // The button is visible. Update its text!
      this.button.attrs.children = buttonText;
      this.button.render();
    }

    if (isSubmitted && this.button) {
      // Form was submitted. Destroy the button!
      this.el.removeChild(this.button.el);
      this.button.destroy();
    }

    if (isSubmitted && !this.message) {
      // Form was submitted. Show the success message!
      this.message = new Message({ text: 'Success!' });
      this.el.appendChild(this.message.el);
    }
  }
}

위 코드는 프로그램 설계 언어 (pseudocode) 이지만 Backbone과 같이 object-oriented 방식으로 지속, 동일하게 동작하는 라이브러리로 composite (합성) UI를 작성할 때 대체로 작성되는 코드이다.

각 컴포넌트 instance는 그들의 DOM node와 children 컴포넌트들의 instance를 지속적으로 참조해야 하고 ( keep references to ) 알맞은 시기 (time)에 만들고, 업데이트하고 그리고 삭제해야한다. 컴포넌트의 상태(state)의 발생 가능성의 제곱만큼 코드 라인의 수는 늘어나고 부모가 그들의 children component instance에 직접적으로 접근이 가능해야하는 점은 미래에 이들을 분리 (decouple) 하기 어렵게 만든다.

즉, Button의 속성에 따라 (의존하여) 버튼이 스크린에 나타나고 없어지고 혹은 변화하는 등의 관리가 필요한데 해당 컴포넌트가 많으면 많아질수록 instance가 늘어나는 것 뿐만 아니라 instances를 조작하는 코드들도 늘어난다. 이말인즉슥, 관리할 코드가 늘어나고 비대해진 컴포넌트가 만들어져간다. 그렇기에 분리가 어려워진다.

그렇다면 리액트는 어떻게 다른가?


Elements describe the TREE

이 문제에 대해 리액트에서는 elements가 해결사로 등장한다. elements는 컴포넌트 instance 혹은 DOM node와 그들의 속성들에 대한 정보를 가지고있는 (describing) plain object다. 이 plain object는 컴포넌트 타입 (예를 들면 a Button or Form), 해당 컴포넌트 인스턴스나 DOM node의 속성 (예: color 등)과 해당 컴포넌트 인스턴스나 DOM node의 자식 elements에 대한 정보만 가지고 있다.

element는 실제 인스턴스는 아니다. 대신, element는 개발자가 화면에 표현하고 싶은 것을 React에 전달하는 방식이다. 우리는 element에 그 어떤 method를 실행할 수 없다. element는 그냥 (just) type: (string | ReactClass) and props: Object라는 두개의 fields를 가진 불변성의 설명을 담고있는 객체일 뿐이다.

DOM Elements

element의 type이 string 이라면 DOM node에서 해당 tag 이름으로 대변되고 element의 props는 해당 속성dp 해당한다. 아래 예제 코드는 React가 render할 것에 대한 예제 코드이다.

{
  type: 'button',
  props: {
    className: 'button button-blue',
    children: {
      type: 'b',
      props: {
        children: 'OK!'
      }
    }
  }
}

위 예제 코드는 element를 나타내는 코드로서 다음 예제의 html을 plain object로서 나타낸 방법일 뿐이다.

<button class='button button-blue'>
  <b>
    OK!
  </b>
</button>

위 두 예제에서 볼 수 있듯이 element는 nested 될 수 있다. 컨벤션에 따르면 element tree를 생성할때, 자식 element들은 그들을 가지고 있는 element의 children props으로 명시해줘야 한다.

여기서 중요한 것은 child와 parent elements들은 모두 단지 설명 (description)일뿐 실제 instance는 아니다 라는 점이다. 우리가 child와 parent elements들을 만들 때 화면에 나타나는 것들과는 무관하다. 우리는 자유롭게 그들을 만들고 치워버릴수 있고 이는 큰 영향이 없다.
(실제 화면 적용에 중요한 역할을 하는 것은 instance라는 뜻으로 이해)

parsed 될 필요가 없는 React element는 횡단하기에 쉽고 (쉽게 순회가 가능하다? 쉽게 순회하며 조작이 쉽다? 파싱 과정이 없기에) 단지 그저 객체이기에 실제 DOM element보다 훨씬 가볍다.

Component Elements

하지만, elements의 type은 React 컴포넌트에 상승하여 함수 혹은 클래스가 될 수 있다.

{
  type: Button,
  props: {
    color: 'blue',
    children: 'OK!'
  }
}

이것은 리액트의 핵심 아이디어다.

컴포넌트를 묘사하는 element 또한 DOM node를 묘사하는 element와 같이 element다. 그들은 서로 nested될 수도 mixed 될 수 있다.

이 기능 (feature)은 개발자로 하여금 DangerButton컴포넌트를 Button 컴포넌트가 DOM에 <button>, <div>를 렌더링할지 혹은 완전히 다른것을 렌더링 할지에 대한 걱정없이 특정 color 속성 값을 갖는 Button 컴포넌트로서 정의가 가능케 해준다.

const DangerButton = ({ children }) => ({
  type: Button,
  props: {
    color: 'red',
    children: children
  }
});

또한 DOM elements와 component elements를 하나의 single element tree안에 mix하고 match 시킬 수 있다.

const DeleteAccount = () => ({
  type: 'div',
  props: {
    children: [{
      type: 'p',
      props: {
        children: 'Are you sure?'
      }
    }, {
      type: DangerButton,
      props: {
        children: 'Yep'
      }
    }, {
      type: Button,
      props: {
        color: 'blue',
        children: 'Cancel'
      }
   }]
});

jsx로 변환하면 다음과 같다.

const DeleteAccount = () => (
  <div>
    <p>Are you sure?</p>
    <DangerButton>Yep</DangerButton>
    <Button color='blue'>Cancel</Button>
  </div>
);

위처럼 mix and matching은 오로지 합성을 통해서만 상속과 합성 관계를 모두 표현할 수 있기에 컴포넌트들을 서로를 분리 (decouple) 되는것에 도움을 준다.

  • Button은 구체적인 속성을 갖는 DOM <button>이다.
  • DangerButton은 구체적인 속성을 갖는 Button이다.
  • DeleteAccount<div>안에 ButtonDangerButton을 포함한다.

Components Encapsulate Element Trees

리액트가 함수 혹은 클래스 type의 element를 마주치면, 해당 component에 주어진 해당 props에 맞게 어떤 element가 렌더링 되는지 묻는다.
(When React sees an element with a function or class type, it knows to ask that component what element it renders to, given the corresponding props.)

다음과 같은 element를 리액트가 마주하면

{
  type: Button,
  props: {
    color: 'blue',
    children: 'OK!'
  }
}

리액트는 Button (as Component element)에게 무엇을 렌더링하는지 묻는다. Button은 다음 element를 반환한다.

{
  type: 'button',
  props: {
    className: 'button button-blue',
    children: {
      type: 'b',
      props: {
        children: 'OK!'
      }
    }
  }
}

리액트는 페이지의 모든 컴포넌트와 일치하는 DOM tag elements를 알아낼때까지 위의 일련의 과정을 계속 반복한다.

리액트는 마치 "X는 Y이다."에 대해 온 세계의 모든 것을 다 알 때까지 계속 "Y는 어떤 것"인지 물어보는 어린 아이와 같다.

class Form extends TraditionalObjectOrientedView {
  render() {
    // Read some data passed to the view
    const { isSubmitted, buttonText } = this.attrs;

    if (!isSubmitted && !this.button) {
      // Form is not yet submitted. Create the button!
      this.button = new Button({
        children: buttonText,
        color: 'blue'
      });
      this.el.appendChild(this.button.el);
    }

    if (this.button) {
      // The button is visible. Update its text!
      this.button.attrs.children = buttonText;
      this.button.render();
    }

    if (isSubmitted && this.button) {
      // Form was submitted. Destroy the button!
      this.el.removeChild(this.button.el);
      this.button.destroy();
    }

    if (isSubmitted && !this.message) {
      // Form was submitted. Show the success message!
      this.message = new Message({ text: 'Success!' });
      this.el.appendChild(this.message.el);
    }
  }
}

위 class는 리액트에서는 다음과 같이 작성될 수 있다.

const Form = ({ isSubmitted, buttonText }) => {
  if (isSubmitted) {
    // Form submitted! Return a message element.
    return {
      type: Message,
      props: {
        text: 'Success!'
      }
    };
  }

  // Form is still visible! Return a button element.
  return {
    type: Button,
    props: {
      children: buttonText,
      color: 'blue'
    }
  };
};

모든 elements 가 가진 DOM elements 를 알게 되기 떄문에 React는 해당 DOM elements 들을 적절한 때에 create, update, destroy한다. 따라서 Form UI modeling을 위와 같이 간단하게 구현할 수 있게 되는 것이다.

위 예제 코드처럼 props는 그저 컴포넌트의 input이고 컴포넌트는 element tree를 리턴한다.

리턴된 element tree는 DOM nodes의 정보를 갖는 (describing DOM nodes) elements와 다른 컴포넌트의 정보를 갖는 (describing other components.) element를 모두 포함한다. 이는 개발자 (혹은 리액트 사용자)로 하여금 그들의 내부 DOM 구조에 의존하지 않는 독립적인 부분의 UI를 조합(compose)하게 해준다.

우리는 (리액트 사용자는) 리액트가 instance들을 만들고, 업데이트하고 삭제하도록 위임 (let) 한다. 우리는 (리액트 사용자) 그것을 (instance) 컴포넌트로부터 반환된 element로써 설명(describe)한다. (아마 개발자는 instance에서 관심이 분리되고 그저 어떤 UI를 그릴지, 그것도 독립적으로, 리액트에 알려주기만 하면 되는데 그 방법은 컴포넌트 작성이고 리액트는 그 컴포넌트에서 리턴된 element로서 소통한다는 뜻인듯) 그 후 리액트는 instance들을 managing 하는 것을 담당한다.

Components Can Be Classes or Functions

위에서 살펴본 Form, Message 그리고 Button 코드들은 모두 리액트 컴포넌트들이다. 그들은 위 예제처럼 함수로 또는 React.Component의 상속자로서 class로 작성될 수 있다. 리액트 컴포넌트가 선언 (작성) 될 수 있는 방법은 다음 세 방법과 같다.

// 1) props의 함수로서
const Button = ({ children, color }) => ({
  type: 'button',
  props: {
    className: 'button button-' + color,
    children: {
      type: 'b',
      props: {
        children: children
      }
    }
  }
});

// 2) React.createClass() factory를 사용해서
const Button = React.createClass({
  render() {
    const { children, color } = this.props;
    return {
      type: 'button',
      props: {
        className: 'button button-' + color,
        children: {
          type: 'b',
          props: {
            children: children
          }
        }
      }
    };
  }
});

// 3) descending React.Component를 상속받은 ES6 class로서
class Button extends React.Component {
  render() {
    const { children, color } = this.props;
    return {
      type: 'button',
      props: {
        className: 'button button-' + color,
        children: {
          type: 'b',
          props: {
            children: children
          }
        }
      }
    };
  }
}

클래스형 컴포넌트는 함수형 컴포넌트보다 조금 더 powerful하다. 클래스형 컴포넌트는 해당 DOM node가 만들어지거나 지워질 때 일부 로컬 상태 (state)를 저장하고 custom 로직을 수행할 수 있다.

함수형 컴포넌트는 클래스형 컴포넌트 덜 powerful하나 더 simple하고 하나의 render() method를 가지고 있는 클래스형 컴포넌트와 동일하게 작동한다. 리액트 팀은 class에서만 사용가능한 기능이 필요하지 않는한 함수형 컴포넌트 사용을 권장한다.

Top-Down Reconciliation

ReactDOM.render({
  type: Form,
  props: {
    isSubmitted: false,
    buttonText: 'OK!'
  }
}, document.getElementById('root'));

만약 위 코드를 실행하면 어떤 일이 일어날까?

리액트는 Form 컴포넌트에 주어진 props에 대해 어떤 element tree를 반환할지 물어볼 것이다.
(React will ask the Form component what element tree it returns, given those props.)
리액트는 작성된 컴포넌트들을 이해하는 과정을 더 간단한 원어들로(primitive as original language 서서히 개선, 개량(refine)할 것이다.

// 개발자가 React에 주어진 props와 함께 Form 컴포넌트를 그려달라고 함
{
  type: Form,
  props: {
    isSubmitted: false,
    buttonText: 'OK!'
  }
}

// Form 컴포넌트가 리액트에 return한 값
{
  type: Button,
  props: {
    children: 'OK!',
    color: 'blue'
  }
}

// Button 컴포넌트가 return한 값. 하위에 더 이상의 children이 없는것으로 사료되어 React는 더 물어볼 것이 없음
{
  type: 'button',
  props: {
    className: 'button button-blue',
    children: {
      type: 'b',
      props: {
        children: 'OK!'
      }
    }
  }
}

위의 일련의 과정을 리액트에서는 reconciliation 이라고 불리는데 이 과정은 우리가 ReactDOM.render() 혹은 setState()을 호출할 때 시작된다. reconciliation이 끝나면 리액트는 최종 DOM tree가 어떻게 구성되어있는지 알게 되고 react-dom 혹은 react-native 같은 renderer가 DOM nodes를 업데이트하기 위해 필요한 최소한의 변화들을 적용한다.

이런 점진적 (gradual) 정제과정은 React 어플리케이션이 최적화하기 쉬운 이유중 하나이다. 만약 컴포넌트 트리가 리액트가 능률적으로 방문 (visit. 아마 위 과정을 모두 훑어보는 것을 의미하는듯)하기에 너무 커진다면, props의 변화가 없다면 이 정제과정 (refining process)과 tree의 특정 부분을 비교 (diffing)하는 것을 건너뛰라고 말할 수 있기 때문이다.
만약 props들이 immutable, 불변하다면 props의 변화유무에 대해 계산하는 것은 굉장히 빠를 것이고 그렇기에 리액트와 불변성은 함께 시너지를 발휘하고 최소한의 노력(effort)으로 큰 최적화(optimization)를 제공할 수 있다.

이 글을 지금까지 보면서 알 수 있듯이, 컴포넌트와 element에 대해서는 많은 설명이 있지만 instance는 그렇지 않다. 그 이유는 다른 대부분의 object-oriented UI 프레임워크에 비해 리액트에서 instance는 그 중요도가 크지 않다.

클래스형 컴포넌트만 instance를 가지고 있고 우리는 절대 직접적으로 instance를 생성하지 않는다: 대신 리액트가 생성하는 일을 해준다. (maybe not only create, but update and destroy). 비록 부모 컴포넌트 instance가 자식 컴포넌트 instance에 접근할 수 있는 mechanism(ref를 말하는듯)이 존재하지만, 그 mechanism은 오직 꼭 필요한 행위(action)를 위해서만 사용되어야하고 일반적으로는 사용을 피해야한다. (ex: fields에 focus on을 하는 등)

리액트는 모든 클래스 컴포넌트에 대한 인스턴스를 만드는 것을 대신 해주기에 우리는 컴포넌트들 methods와 로컬 state와 함께 object-oriented 방식으로 작성할수 있지만 그것 외에도 instance는 리액트 프로그래밍 모델에서 그렇게 중요하지 않고 React에 의해 알아서 manage 된다.


정리

element는 우리가 화면에 나타내고 싶은 것을 DOM nodes나 다른 컴포넌트로서 설명을 하는 plain object이다. element는 다른 element를 자신들의 props로 가지고 있을 수 있다. React element를 생성하는 것은 저렴하다. 한번 element가 만들어지면 절대 불변이다 (never mutated).

컴포넌트는 여러 다른 방법으로 선언이 가능하다. 컴포넌트는 render() 메소드를 내포하는 클래스일 수 있다. 대안으로 더 간단한 방법은 함수로 정의되는 것이다. 두 경우 모두 props를 input으로 받고 element tree를 output으로서 반환한다.

만약 컴포넌트가 props들을 input으로 받는다면 (receives), 이는 특정 부모 컴포넌트가 해당 타입과 props와 함께 element를 리턴했기 때문이다. 그렇기에 React에서 props는 부모에서 자식으로 한방향으로 flow한다는 이유이다.

instance는 우리가 작성하는 컴포넌트 class안의 this로 대변되는 것이다. instance는 로컬 state를 보관하고 라이프사이클 이벤트들에 반응하기에 유용하다.

함수형 컴포넌트는 instances가 아예 없다. 클래스형 컴포넌트는 instances를 갖지만 리액트가 알아서 해주기에 우리는 직접 컴포넌트 instance를 만들 필요가 없다.

최종적으로 elements를 만들기 위해서는 React.createElement(), JSX 혹은 element factory helper를 사용한다.
실제 코딩에 element를 plain objects로 작성하는 것을 권장하지 안흔ㄴ다. 그저 내부적으로 elements는 plain objects라는 것만 알아두자.

정리 in my words

결국 React element는 기존 OOP UI model의 문제점을 개선하기 위함이라고 볼 수 있을 것 같다.
컴포넌트가 많아질수록 instance도 많아지고 이는 각 instance에 대한 로직 (instance 조작)이 많아진다는 결론을 유추 가능하다. 이렇게 되면 하나의 컴포넌트가 수행해야할 일들이 많아질 수 있다는 뜻인데 결론적으로 부모와 자식의 분리가 어렵다는 뜻으로 볼 수 있다. 더 나아가 왜 hooks와 함수형 컴포넌트를 리액트 팀에서 권장하는지에 대한 힌트로도 볼 수 있다. 결국 부모와 자식의 분리뿐 아니라 로직과 ui의 분리도 어렵다고 볼 수 있지 않을까?

따라서 리액트에서는 element라는 개념을 활용했다. element는 그저 화면에 그려질 것들을 props와 type으로서 DOM node를 설명하는 것이다. 즉 다시말해 element는 그저 DOM node의 정보 객체라고 볼 수 있다. 또한 element는 다른 elements를 props로 가지고 있을 수 있는데 그렇기 떄문에 tree 구조가 될 수 있다. 이는 바꿔말해 우리가 컴포넌트를 작성하면 React가 실제 DOM tree와 같은 위계질서를 갖는 구조로 파악할 수 있다는 뜻이다. 또한 element의 타입은 element이거나 컴포넌트 일 수 있는데 이는 기존의 DOM node와 React component를 mix하고 nest하는 구조로 만들 수 있다는 것이다.

또한 reconciliation을 통해 react는 모든 dom tree를 완벽하게 알게되고, 리액트는 렌더링과 제거되는 부분을 적절하게 알고 따로 진행한다. 그리고 우리 작성한 elements에는 DOM tree에 전달할 정보만 담고 있으면 된다. 실제 Dom의 구조를 활용하는게 아니라 elements를 활용해서 나머지 로직들은 다른곳에서 제어하고, 필요한 정보만 독립적으로 UI로 관리 할 수 있도록 하여 기존의 UI모델링을 분리해서 간단하게 구현 할 수있게 된다.

결론의 결론은 element는 DOM tree에게 전달할 정보를 가지고 있는 순수 객체라고 보면 된다.
1. element는 화면에 나타낼 DOM tree에 대한 정보를 가지고 있는 순수 객체
2. component는 props를 input으로 받아 DOM Node를 출력하는, 리액트로 만들어진 앱을 이루는 최소한의 단위
3. instance 클래스로 선언된 Component에서만 갖는 것.

DOM Node와 element 더 알아보기

profile
💻 소프트웨어 엔지니어를 꿈꾸는 개발 신생아👶

0개의 댓글