React Components, Elements, and Instances

2020년 03월 05일

해당 글은 React 공식 블로그의 글을 번역하고 개인적으로 이해한 부분을 조금 덧붙여 쓴 것입니다.

인스턴스 관리하기

전형적인 객체지향 UI 프로그래밍의 방식:

클래스로 컴포넌트를 만들어서 그 인스턴스를 사용하여 화면에 렌더링한다. 그 각각의 인스턴스들은 고유한 속성(own properties)과 로컬 상태(local state)를 갖고 있다.

이런 전통적인 UI 모델에서는 인스턴스를 생성하고 삭제하는 것을 개발자가 모두 신경써야 한다. 만약 Form 컴포넌트가 있고 그 안에서 Button 컴포넌트를 렌더링하고 싶다고 하자. 의사 코드(pseudocode)로 표현하면 이렇다.

class Form extends TraditionalObjectOrientedView {
  render() {
    // 뷰에서 넘겨 받은 데이터를 읽는다
    const { isSubmitted, buttonText } = this.attrs

    if (!isSubmitted && !this.button) {
      // 폼이 아직 제출되지 않은 상태이다. 버튼을 만들자.
      // 버튼 인스턴스를 생성한다.
      this.button = new Button({
        children: buttonText,
        color: 'blue',
      })
      this.el.appendChild(this.button.el)
    }

    if (this.button) {
      // 버튼이 보여지고 있는 상태이다. 텍스트를 업데이트하자.
      // 버튼 인스턴스의 속성을 업데이트 한다.
      this.button.attrs.children = buttonText
      this.button.render()
    }

    if (isSubmitted && this.button) {
      // 폼이 제출되었다. 버튼을 삭제하자.
      // 버튼 인스턴스를 삭제한다.
      this.el.removeChild(this.button.el)
      this.button.destroy()
    }

    if (isSubmitted && !this.message) {
      // 폼이 제출되었다. 성공 메세지를 보여주자.
      // 메세지 인스턴스를 생성한다.
      this.message = new Message({ text: 'Success' })
      this.el.appendChild(this.message.el)
    }
  }
}

의사코드이긴 하지만 Backbone과 같은 객체지향적인 라이브러리를 사용할 때 이런 식으로 코드를 쓸 것이다.

각각의 컴포넌트 인스턴스는 DOM 노드(e.g. this.message.el, this.button.el)를 계속 참조해야 하고, 적정한 타이밍에 자식 컴포넌트의 인스턴스를 생성, 업데이트, 삭제해줘야 한다.

이러한 방식은 컴포넌트가 가질 수 있는 상태 수에 따라 코드가 급증할 수 있고, 부모가 자식 컴포넌트의 인스턴스에 직접적으로 접근가능함에 따라 디커플링하기가 어려워진다.

리액트는 어떤 차이점이 있을까?

리액트에서는 리액트 엘리먼트가 위와 같은 상황을 해결해준다. 즉, 코드 양을 줄여주고, 부모와 자식 컴포넌트 간 디커플링을 쉽게 해준다.

엘리먼트가 트리를 묘사한다

리액트 엘리먼트(element)를 엘리먼트라고 쓰겠다.

TL;DR

요약하자면 엘리먼트는 인스턴스가 아니라 React에게 어떤 것을 화면에 나타내고 싶은지 알려주는 디스크립션이다. 엘리먼트는 파싱될 필요가 없으며 순회하기도 쉽고, 실제 DOM 노드보다 훨씬 가볍다. 그냥 객체이기 때문에.

엘리먼트가 무엇을 묘사하든, 엘리먼트들끼리 중첩되거나 섞일 수 있으며, 이 사실 때문에 컴포넌트들은 서로 디커플링될 수 있다. 컴포넌트 조합을 통해서 is-ahas-a 관계를 명확히 나타낼 수 있다.


일단 엘리먼트에 대해 중요한 사실이 몇 가지 있다.

  1. 엘리먼트는 인스턴스가 아니다. 엘리먼트는 이뮤터블한 plain object이다.
  2. 엘리먼트는 컴포넌트 인스턴스나 DOM 노드에 관한 정보를 묘사하고 있다.

    엘리먼트는 타입(type)과 속성(props) 2가지 필드로 구성된다.

    // An Element describing DOM element
    {
     type: 'button',
     props: {
       className: 'button button-blue',
       children: {
         type: 'b',
         props: {
           children: 'OK!'
         }
       }
     }
    }

    위 코드를 보면 엘리먼트가 그저 평범한 객체라는 것을 알 수 있다. 이 평범한 객체는 type과 props라는 속성을 갖고 있다. type으로 이것이 DOM 노드인지, 아니면 컴포넌트 인스턴스인지 알려준다. 그리고 props로 해당 객체가 갖고 있는 속성들, 클래스네임이나 자식 엘리먼트 등을 나타낸다. 위 엘리먼트는 아래처럼 단순한 HTML을 나타낸다.

    <button class="button button-blue">
     <b>OK!</b>
    </button>
  3. 엘리먼트의 타입 값은 string이나 ReactClass이고, props의 값은 Object이다. 타입 값이 string 이면 DOM 노드를 나타내는 것이고, ReactClass이면 컴포넌트 인스턴스를 나타내는 것이다.

    // An Element describing Component
    {
     type: Button,
     props: {
       color: 'blue',
       children: 'OK!'
     }
    }
  4. 컴포넌트를 묘사하는 엘리먼트도, DOM 노드를 묘사하는 엘리먼트도 모두 같은 엘리먼트이다. 그들은 중첩될 수 있고 섞일 수 있다.

    // DangerButton은 Button 컴포넌트 타입이지만 다른 속성 값을 가진다.
    const DangerButton = ({ children }) => ({
     type: Button,
     props: {
       color: 'red',
       children: children,
     },
    })
    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>
    )

    여기서 Button은 특정한 속성을 갖는 DOM <button> 이다.

    여기서 DangerButton은 특정한 속성을 갖는 Button이다.

    DeleteAccount는 <div> 안에 Button과 DangerButton을 포함하고 있다.

캡슐화

컴포넌트는 엘리먼트 트리를 캡슐화한다. 예를 들어 Button 컴포넌트만 보면 실제로 렌더링할 것이 HTML의 button 태그인지 아니면 그냥 div인지 전혀 다른 것인지 알 수 없다.

리액트 코드는 이런 컴포넌트들의 조합으로 이루어지기 때문에 실제로 어떤 엘리먼트 트리를 리턴할 지는 보이지 않는다.

React는 이것을 구현할 때 가장 밑에 깔려있는 DOM 태그 엘리먼트가 나올 때까지 계속적으로 해당 컴포넌트가 무엇을 리턴할 것인지 묻는다.

위의 DeleteAccount를 예시로 들면 그렇다. 리액트가 DeleteAccount에게 무엇을 리턴할 것이냐고 묻는다. 그러면 div 태그에 밑에 p 태그, DangerButton, Button 컴포넌트를 자식으로 리턴할 것이라고 할 것이다. 그러면 또 p 태그의 자식이 무엇인지 묻는다. 알아내고 나면 그 다음에는 DangerButton이 무엇을 리턴할지 묻는다. DangerButton은 Button 타입이고 Yep이라는 스트링을 자식으로 리턴할 것이라고 한다. 그러면 Button이 뭘 리턴할지 묻는다…

React는 이런 식으로 X is Y 라는 디스크립션에 대하여 what is Y 라는 질문을 반복한다. 그렇게해서 최종적으로 어떤 엘리먼트 트리가 output이 될 것인지를 알아내는 것이다. 리액트 컴포넌트에서는 props가 input이고 엘리먼트 트리가 output이 된다.

이렇게 리턴된 엘리먼트 트리는 여러 엘리먼트들이 중첩, 섞여있는 구조이다. 이런 특성들로 인해 내부적인 DOM 구조에 의존하지 않고 UI를 독립적으로 조합할 수 있도록 한다.

우리는 일일이 인스턴스를 만들고, 업데이트하고, 삭제할 필요가 없다. 그냥 무엇을 화면에 나타내고 싶은지 컴포넌트들로 나타내면(=엘리먼트로 설명해주면) 리액트는 인스턴스를 알아서 관리한다. 맨 위에서 예시로 들었던 Form 컴포넌트를 리액트 식으로 나타내면 이렇다.

const Form = ({ isSubmitted, buttonText }) => {
  if (isSubmitted) {
    // 폼이 제출되었다. 메세지 엘리먼트를 리턴하자.
    return {
      type: Message,
      props: {
        text: 'Success',
      },
    }
  }
  // 폼이 제출되지 않았다. 버튼 엘리먼트를 리턴하자.
  return {
    type: Button,
    props: {
      children: buttonText,
      color: 'blue',
    },
  }
}

하향식 Reconciliation

위에서 설명한 바와 같이, 리액트는 최상위 컴포넌트들부터 최하위 컴포넌트에게 도달할 때까지 계속적으로 질문하며 최종적으로 어떤 엘리먼트 트리를 나타낼 것인지 알아낸다.

이렇게 엘리먼트 트리를 도출하는 과정에서 Reconciliation이라는 재조정 알고리즘을 사용하는데, 간단하게 말해서 기존의 트리와 새로 나타내야 하는 트리를 비교해서 최소한의 연산으로 효과적으로 UI를 갱신하는 알고리즘이다. 즉 어떤 점이 전과 다른지 비교(diffing)해서 업데이트해야 하는 부분을 효과적으로 캐치하고 트리를 업데이트하는 것이다.

이 재조정과정은 ReactDOM.render()나 setState()를 호출했을 때 시작된다. 리액트 렌더러(e.g. react-dom)는 리액트가 재조정 과정을 통해 알아낸 DOM 트리 결과를 최소한의 DOM 노드 업데이트로 해낸다.

리액트에서는 앱의 크기가 너무 커졌을 때 트리의 특정 부분은 props가 변경되지 않았다면 디핑 알고리즘을 적용하지 말라고 설정할 수도 있다. 그래서 props가 이뮤터블일 때 굉장히 연산이 빨라질 수 있고 최소한의 노력으로 최적화하기가 용이하다.


마치며

이 글에서 막상 엘리먼트나 컴포넌트는 많이 언급했지만 인스턴스에 대해서는 별로 언급이 없었다. 그 이유는 실제로 리액트가 알아서 인스턴스를 케어하기 때문에 OOP에 비해 개발자가 이를 신경쓸 일이 적기 때문이다. 리액트에서 개발자는 인스턴스를 직접 생성할 일이 없고, 부모 컴포넌트의 인스턴스가 자식 컴포넌트 인스턴스에게 직접 접근할 일도 거의 없다. 필드 포커싱 등 그렇게 해야할 때가 가끔 있기는 하지만 리액트에서 바람직한 방식은 아니다. 그리고 인스턴스는 클래스 컴포넌트에게만 존재하고 함수형 컴포넌트에는 존재하지 않는데, 최근에는 함수형 컴포넌트로만 코딩하는 사례가 늘고 있어 더욱 그렇지 않을까.

Ref

리액트 Docs