[React][공식문서] 합성과 상속 (feat. children)

Gyuwon Lee·2022년 7월 4일
1
post-thumbnail

React 공식 튜토리얼을 바탕으로, 필요한 개념을 보충하여 학습한 기록입니다.

먼저, 공식문서의 제목은 "합성 vs 상속" 이지만, 읽어보면 각 방식이 어떤 원리인지 설명해주거나 장단점을 비교하는 내용은 아니다. 그저 거의 모든 상황에서 상속 대신 합성을 권장한다고 권고하고 있을 뿐이다.

따라서 본 글에서는 리액트가 기존에 지향해 온 컴포넌트 간 합성이 무엇인지 그 방식과 장점을 정리하고, 동일한 프로그램을 상속 방식으로 구현하면 어떤 비용이 발생하는지 알아볼 것이다.


1. 너도 알고 나도 아는 '그 방식'

리액트와 상속에 대해 참고한 문서에 따르면, 기본적인 상속 방식 에서 생기는 부모-자식 관계는 합성 방식 에서의 props로 구현된다고 한다. 부모가 갖고 있는 자원이 무엇이든지, 어떤 형태(객체, 함수, 원시값 등등)든지 리액트에서는 자식에게 props를 통해 넘겨줄 수 있다.

따라서 합성이란 부모 컴포넌트 내부에서 자식 컴포넌트를 포함하며, 부모가 자신의 자원을 props로 넘겨주는 방식을 말한다. 공식문서에서는 컴포넌트 합성에 대해 "컴포넌트가 자신의 출력에 다른 컴포넌트를 참조"하는 것이라고 설명하고 있다.

당연히 지금까지 그렇게 하고 있었다면, 그게 정상이다. 공식문서에서 Meta는 "Facebook에서 수천 개의 React 컴포넌트를 사용하지만, 컴포넌트를 상속 계층 구조로 작성을 권장할만한 사례를 아직 찾지 못했다" 라고 밝힌다. 즉 리액트를 사용하며 상속 방식으로 코드를 구현할 일은 없는 게 일반적이다.

import styled from "@emotion/styled";
import { PropsWithChildren } from "react";

const ContentSection = styled.section`
  height: 90vh;
  width: 90vw;
  max-width: 25rem;
  text-align: center;
`;

const ContentTemplate = ({ children }
                         : PropsWithChildren): JSX.Element => {
  return (
    <ContentSection id="content">
      {children}
    </ContentSection>;
  )
};

export default ContentTemplate;

예시 props.children 사용하기

현재 하고 있는 프로젝트 코드의 일부다. 위의 ContentTemplatefooter 영역과 구분하기 위해 임의로 만든 컴포넌트다. 인자로 { children }: PropsWithChildren 이 지정되어 있는 부분을 통해, 자신이 렌더링될 때 참조할 자식 컴포넌트를 props로 받아오고 있음을 알 수 있다. 이 컴포넌트가 다른 컴포넌트에서 어떻게 사용되고 있는지 보자.

import LoginTemplate from "../components/templates/LoginTemplate";
import FooterTemplate from "../components/templates/FooterTemplate";
import ContentTemplate from "../components/templates/ContentTemplate";

const Login = (): JSX.Element => {
  return (
    <>
      <ContentTemplate>
        <LoginTemplate />
      </ContentTemplate>
      <FooterTemplate />
    </>
  );
};

export default Login;

부모 요소인 Login 컴포넌트가 렌더링될 때, 자식 요소인 ContentTemplate 안에 있는 LoginTemplateContentTemplate 의 props.children에 넣어 전달해 준다.

리액트 합성 모델의 장점

위 예시에서 드러나는 장점은, 자식 요소가 부모 요소의 자원을 사용하기 위해서는 그저 props로 내려받기만 하면 될 뿐 별도의 선언 및 상속 과정이 필요없다는 것이다. 부모 요소에 import된 자원인 LoginTemplate 엘리먼트를, ContentTemplate 내부로 가져가 {children} 으로 명시되어 있는 자리에서 사용(소비)할 수 있다. 즉 부모 요소가 어떻게 생겼는지 알 필요 없이 자식 요소가 독립적으로 존재할 수 있다는 뜻이다.


2. 특수화

특수화 란, 일반적으로 사용되는 컴포넌트를 보다 구체적인 형태로 재사용하는 것을 뜻한다. 모든 대화를 포함하는 Dialog 를, '인사' 라는 구체적인 상황에만 사용되는 특수한 경우인 WelcomeDialog 로 만들 수 있는 것처럼 말이다.

이를 이해하기 위해 여기서는 잠시 클래스 개념으로 돌아가 상속 방식을 되짚어 보려고 한다. 여러 동물들을 클래스로 표현하기 위해, 틀이 되는 부모 클래스 Animal 을 만들고 이것을 상속하여 자식 클래스들을 만들어볼 것이다.

class Animal {
  constructor(name) {
    this.speed = 0;
    this.name = name;
  }
  run(speed) {
    this.speed = speed;
    alert(`${this.name} 은/는 속도 ${this.speed}로 달립니다.`);
  }
  stop() {
    this.speed = 0;
    alert(`${this.name} 이/가 멈췄습니다.`);
  }
}

let animal = new Animal("동물");

이제 각기 다른 자식 클래스 RabbitCat 을 만들어 보겠다.

class Rabbit extends Animal {
  hide() {
    alert(`${this.name} 이/가 숨었습니다!`);
  }
}

class Cat extends Animal {
  punch() {
    alert(`${this.name} 이/가 솜방망이를 휘둘렀습니다!`);
  }
}

여기서 RabbitCat 은 각각 Animal 의 특수한 경우라고 볼 수 있다. 공통적으로 Animal 이므로 runstop 기능을 가지면서도, Rabbithide 라는 고유한 특성을 갖고 Cat 역시 punch 라는 고유한 특성을 갖는다.

위 예시에서 바로 보여지듯, 이는 클래스 상속을 통해 특수화를 구현한 경우다. 특수화를 통해 Rabbit 은 '숨었다' 는 메시지를 출력할 수 있고 Cat 은 '솜방망이를 휘둘렀다' 는 메시지를 출력할 수 있게 되었다.

하지만 코드의 양이 엄청나게 많아지고, 토끼나 고양이뿐만 아니라 말, 소, 강아지 등 무수히 많은 동물들의 특성을 하나하나 다 묘사해야 한다고 생각해 보자. 모든 동물에 대해 대응되는 자식 클래스를 각각 전부 만드는 것은 다소 비효율적일 뿐더러, 수많은 클래스가 생성되는 데서 오는 자원의 소모도 걱정해야 할 것이다. 만약 run 기능만 필요하고 stop 기능은 필요없다거나, 그 반대의 경우가 생기면 어떡해야 하는가?

합성과 상속의 차이

리액트의 합성 모델을 사용하면 이러한 문제를 해결하고 재사용성을 향상시킬 수 있다.

const Animal = (props) => {
  const {name, action} = props;
  const [speed, setSpeed] = useState(0);
  
  return (
  	<button onClick={action}>{name}</button>
  )
}

이제 Animal 컴포넌트에게 props로 어떤 값을 넘겨주느냐에 따라서 이 컴포넌트는 각기 다른 동물을 나타낼 수 있다.

const AnimalList = () => {
  const rabbit = {
    name: "rabbit",
    action() {
      alert(`${this.name} 이/가 숨었습니다!`);
    }
  }
  
  const cat = {
    name: "cat",
    action() {
      alert(`${this.name} 이/가 솜방망이를 휘둘렀습니다!`);
    }
  }
  
  return (
    <Animal name={rabbit.name} action={rabbit.action}/>
    <Animal name={cat.name} action={cat.action}/>
  )
}

이렇게 만들어 두면 어떨까?

위 예제의 경우 컴포넌트를 구현한 것이고, 앞선 클래스 방식의 예제는 클래스에 메소드를 구현해둔 것이므로 작동이 완전히 동일하지는 않다.

그렇지만 위 코드만으로도 어떤 차이를 드러내고자 한 것인지 조금은 와닿길 바란다. 첫 번째 차이점은 특수화의 방향이 반대라는 것이다. 무슨 뜻이냐면, 클래스 상속의 경우 더 일반적인 경우인 부모 컴포넌트를 먼저 만들어두고 이를 상속받아 자식 컴포넌트로 특수한 경우를 표현하지만, 합성 방식의 경우 조금 더 특수한 컴포넌트의 내부에서 일반적인 컴포넌트가 참조(렌더링)되고 props로 내용을 구성하게 된다.

import Animal from ...

const Rabbit = () => {
  const action = () => {
    alert(`${this.name} 이/가 숨었습니다!`);
  }
  
  return <Animal name="rabbit" action={action} />
}
  
const Cat = () => {
  const action = () => {
    alert(`${this.name} 이/가 솜방망이를 휘둘렀습니다!`);
  }
  
  return <Animal name="cat" action={action} />
}

위 예시에서 부모 요소인 RabbitCat 은 각각 자식 요소로 렌더링하는 Animal 에게 자신의 자원(action)을 전달하고 있다.

여기서 함께 볼 수 있는 중요한 차이점은 합성 방식의 경우 부모 요소의 자원을 전부 포함하여 확장할(extend) 필요가 없다는 점이다. 위 코드에서 자식 요소가 소비하고자 하는 자원만 props로 골라서 받아올 수 있다.

만약 Animal 클래스를 확장해서 RabbitCat 을 만들었다면, RabbitCat 의 생김새는 각각의 고유한 기능을 제외하고 닮아 있어야 한다. 하지만 컴포넌트 합성으로 구현한 경우 RabbitCat 은 완전히 다르게 생겼으면서도 동일하게 Animal 컴포넌트를 렌더링할 수 있다.


TL;DR: 합성 합시다

합성 방식을 사용하면 코드 과사용을 방지하고 컴포넌트 분리를 용이하게 할 수 있다. 앞서 말했듯 부모 요소가 어떻게 생겼는지 알 필요 없이 필요한 자원만 받아다 쓸 수 있으므로, 자식 요소가 독립적으로 존재할 수 있기 때문이다.

하지만 상속 방식의 경우 부모부터 최상위 조상까지의 모든 컴포넌트들을 순차적으로 참조하며 거슬러 올라가야 하기 때문에, 자식 요소에게는 불필요한 코드까지 모두 포함하게 되거나 참조 관계에 따라서는 어느 시점에서 컴포넌트 분리가 불가능해질 수 있다.

그러므로 지금까지 해오던 대로 props를 사용한 합성 방식을 사용하면 되겠다!


참조

profile
하루가 모여 역사가 된다

0개의 댓글