🌱 ABOUT ME

Front-End라는 거대한 산을 오르는 산악회(1명으로 구성됨) 올바른 등반을 위해 잘못된 정보의 태클, 환영합니다! 👍

Today
Yesterday
Total
  • [React] Favor composition over inheritance
    Front-End/ETC 2025. 3. 21. 18:33
    🍀목차
    Inheritance(상속) : 개념과 장단점
    Composition(합성) : 상속을 보완하는 방법
    균형 : IS-A와 HAS-A의 이해와 적절한 적용

     

     

    글의 목적

     

     

    https://ko.react.dev/reference/react/Component
    https://ko.react.dev/reference/react/Component

     

     React 컴포넌트는 클래스와 함수로 정의할 수 있다. 현재는 함수 컴포넌트가 권장되나, 여전히 클래스 컴포넌트도 지원되고 있다.

     

     그렇다면, 왜 함수 컴포넌트로의 변화가 일어났을까?

     React v16.8에 도입된 Hooks에 React 팀의 고민이 담겨있다. Hooks는 고차 컴포넌트(HOC, Higher Order Component)Render Props 패턴을 사용하지 않고도, 상태 로직을 추출하여 재사용할 수 있게 해 주었다. 이로써, 보다 간결하고 유연한 컴포넌트를 만들 수 있게 되었다.

     

     이러한 변화는 객체지향 프로그래밍(OOP, Object-Oriented Programming)의 설계 원칙인 "Favor composition over inheritance : 상속보다는 합성을 사용하라"와도 관련이 깊다. 

    📌 Favor composition over inheritance
     상속보다는 객체를 조합하여 필요한 기능을 구현하는 방법(Composition, 합성)을 선호하라는 원칙. 합성은 상속에 비해 유연성, 확장성, 낮은 결합도 등에 있어 강점을 가지고 있다. 

     

     

     하지만 모든 기능과 재사용이 합성만으로 해결되는 것은 이상적이다. 복잡한 현실 환경에서는 상속을 완전히 배제할 수 없다. 그렇기에 상속과 조합을 독립적인 개념보다는 상호 보완적인 관계로 이해해야 한다. 

     

     이 글에서는 React의 변화를 중심으로 상속과 합성 각각의 장단점을 살펴보고, 이를 어떻게 적절히 적용할 수 있는지 알아보려고 한다.

     

     

     

    Inheritance(상속) : 개념과 장단점

    공식 문서

     

     

     React 컴포넌트를 클래스로 정의하려면, 내장 Component 클래스를 상속받고 필수적으로 render 메서드를 정의해야 한다. 

    // https://ko.react.dev/reference/react/Component#defining-a-class-component
    import { Component } from 'react';
    
    class Greeting extends Component {
      render() {
        return <h1>Hello, {this.props.name}!</h1>;
      }
    }

     

     클래스 컴포넌트는 render 메서드 외에도 생명주기 메서드들이 존재한다. 조금은 낯서니, 대표적인 메서드들을 익숙한 Hooks와 비교해 보자.

    • constructor(props) : 클래스 컴포넌트가 화면에 추가(마운트)되기 전에 실행된다. state를 선언하고 클래스 메서드를 클래스 인스턴스에 바인딩할 수 있다.
      • cf. 함수 컴포넌트에는 constructor와 정확히 동등한 것은 없다. state를 선언하려면 useState를 호출한다. 
    class Counter extends Component {
      constructor(props) {
      	// super(props)를 호출하지 않으면 constructor가 실행되는 동안 this.props가 undefined이 되어 버그 발생 가능
        super(props);
        // this.state를 직접 할당할 수 있는 유일한 위치, 다른 모든 메서들은 this.setState() 사용
        this.state = { counter: 0 };
      }
        // ...
      }
      
      // 함수 컴포넌트로 만들어 보자...
      
    import { useState } from 'react';
    
    function Counter() {
    	const [counter, setCounter] = useState(0);
        // ...
    }
    • componentDidMount() : 화면에 추가(마운트)될 때 React가 호출하는 메서드. 데이터 페칭 시작, 구독 설정, DOM 노드를 조작하는 등의 사이드 이펙트를 실행한다. 일반적으로 변경 사항 처리를 위해 componentDidUpdate, 수행 작업을 cleanup 하기 위해 componentWillUnmount도 같이 구현한다.
      • cf. 함수 컴포넌트에서는 useEffect를 사용한다. 브라우저 페인트 전에 코드를 실행하는 것이 중요한 경우는 useLayoutEffect를 사용한다.
    // https://ko.react.dev/reference/react/Component#componentdidmount
    class ChatRoom extends Component {
      state = {
        serverUrl: 'https://localhost:1234'
      };
    
      componentDidMount() {
        this.setupConnection();
      }
    
      componentDidUpdate(prevProps, prevState) {
        if (
          this.props.roomId !== prevProps.roomId ||
          this.state.serverUrl !== prevState.serverUrl
        ) {
          this.destroyConnection();
          this.setupConnection();
        }
      }
    
      componentWillUnmount() {
        this.destroyConnection();
      }
    
      // ...
    }
    
     
      // 함수 컴포넌트로 만들어 보자...
    
    import { useState, useEffect } from 'react';
    
    function ChatRoom({ roomId }) {
      const [serverUrl, setServerUrl] = useState('https://localhost:1234');
    
      useEffect(() => {
        const connection = createConnection(serverUrl, roomId);
        connection.connect();
        return () => {
          connection.disconnect();
        };
      }, [serverUrl, roomId]);
    
      // ...
    }

     

     

      예시 코드를 보면, 클래스 컴포넌트를 상속받음으로써 다양한 기능을 활용할 수 있다. 특히 생명주기 메서드는 모든 컴포넌트가 비슷한 구조로 관리되기에, 부모 클래스에 정의된 메서드를 재사용하기도 편하다.

     

     그러나 이는 강한 결합도를 의미하기도 한다. 구조적으로 자식 클래스는 부모 클래스를 의존하게 되어, 부모 클래스의 변경에 취약해질 수 있다. 이게 상속의 첫 번째 단점이다.

     

    1️⃣ 자식 클래스는 부모 클래스를 의존한다( 변경이 까다롭다). 

     

    2️⃣ JavaScript의 this 키워드는 혼란스럽다.

    : 일반적인 객체지향 언어에서는 this가 항상 해당 크래스의 인스턴스를 가리키지만, JavaScript에서는 호출되는 방식에 따라 this가 동적으로 결정된다. 이로 인해 상속을 사용할 때 의도치 않은 문제가 발생할 수 있었다.

     

    3️⃣ 관련 없는 로직이 섞일 수 있다.

    : componentDidMount - componentDidUpdate- componentWillUnmount 같은 메서드들은 각자 하는 일이 다름에도 한데 묶여 행동하는 것처럼 느껴졌다. 반면, Hook은 서로 비슷한 것을 하는 작은 함수로 분리해 관리할 수 있다(useEffect). 직관적이고 테스트하기 쉬운 구조를 제공하는 것.

     

    4️⃣ 캡슐화가 약화될 수 있다.

    : 상속을 사용하면 부모 클래스의 내부 구현 세부 사항이 자식 클래스에 노출된다. 캡슐화는 객체의 내부 상태와 구현을 외부에 감추고, 정해진 인터페이스를 통해서만 상호작용하는 방식이다. 그러나 상속을 사용하면 자식 클래스가 부모 클래스의 메서드를 오버라이딩하여 재정의가 가능해진다. 이로 인해 의도치 않게 상위 클래스의 구현이 하위 클래스에 노출된다.

     

    5️⃣ 커스터마이징이 어렵다.

    : protected, private 등의 접근 제어자로 자식 클래스의 재정의나 접근을 막을 수 있으나 이는 확장성이 떨어지며 변경이 어렵게 된다는 단점이 존재한다. 때때로 부모의 기능을 보완하거나 수정할 필요가 있는데 캡슐화를 강화하기 위해 접근 제어자로 감추는 것이 오히려 커스텀이 어렵게 만들 수 있다. 그리고 자식 클래스는 여전히 부모의 public 인터페이스에 의존하게 되어, 부모 클래스 내부 로직에 구조적으로 의존하고 있다.

     

     

     

    Composition(합성) : 상속을 보완하는 방법

     

     Sidebar, Dialog와 같은 박스 역할의 컴포넌트는 어떤 자식 엘리먼트가 들어올지 미리 예상할 수 없다.

    그렇기에 children prop을 사용하여 자식 엘리먼트를 출력에 그대로 전달하는 경우가 많은데, 이는 대표적인 합성의 예시라고 할 수 있다.

    // https://ko.legacy.reactjs.org/docs/composition-vs-inheritance.html#containment
    function FancyBorder(props) {
      return (
        <div className={'FancyBorder FancyBorder-' + props.color}>
          {props.children}
        </div>
      );
    }

     

    또한 더 "구체적인(하위)" 컴포넌트가 "일반적인(상위)" 컴포넌트를 렌더링 하고 props를 통해 내용을 구성하는 것도 가능하다.

     예를 들어, Dialog의 특수 케이스인 WelcomeDialog를 합성을 통해 구현해 보자.

    function Dialog(props) {
      return (
        <FancyBorder color="blue">
          <h1 className="Dialog-title">
            {props.title}
          </h1>
          <p className="Dialog-message">
            {props.message}
          </p>
        </FancyBorder>
      );
    }
    
    function WelcomeDialog() {
      return (
        <Dialog
          title="Welcome"
          message="Thank you for visiting our spacecraft!" />
      );
    }

     

     

    그리고 이러한 합성은 클래스로 정의된 컴포넌트에서도 동일하게 적용된다.

    function Dialog(props) {
      return (
        <FancyBorder color="blue">
          <h1 className="Dialog-title">
            {props.title}
          </h1>
          <p className="Dialog-message">
            {props.message}
          </p>
          {props.children}
        </FancyBorder>
      );
    }
    
    class SignUpDialog extends React.Component {
      constructor(props) {
        super(props);
        this.handleChange = this.handleChange.bind(this);
        this.handleSignUp = this.handleSignUp.bind(this);
        this.state = {login: ''};
      }
    
      render() {
        return (
          <Dialog title="Mars Exploration Program"
                  message="How should we refer to you?">
            <input value={this.state.login}
                   onChange={this.handleChange} />
            <button onClick={this.handleSignUp}>
              Sign Me Up!
            </button>
          </Dialog>
        );
      }
    
      handleChange(e) {
        this.setState({login: e.target.value});
      }
    
      handleSignUp() {
        alert(`Welcome aboard, ${this.state.login}!`);
      }
    }

     

     

    리스코프 치환 원칙
    리스코프 치환 원칙(Liskov Substitution Principle)

     

     위키피디아의 Composition over inheritance 항목에서는 리스코프 치환 원칙(LSP, Liskov Substitution Principle)도 언급된다. LSP는 "하위 타입은 언제나 상위 타입으로 교체할 수 있어야 한다"는 원칙을 말한다. LSP를 위반하면 서로 치환할 수 없다는 사실을 처리하기 위한 복잡한 로직이 시스템에 추가되며, 이는 아키텍처의 복잡도를 증가시킨다.

     

     상속에서의 LSP는 자식 클래스는 부모 클래스를 대체할 수 있어야 한다는 것인데, 만약 자식 클래스가 부모 클래스의 동작을 변경하거나 예기치 않게 동작한다면, 이는 LSP 위반이 된다.

     상속에서 LSP를 지키기 어려운 경우, 합성이 권장된다. WelcomeDialog와 Dialog의 관계를 하위 타입과 상위 타입으로 본다면, WelcomeDialog는 Dialog를 사용할 뿐, 동작을 변경하지 않는다. WelcomeDialog가 Dialog를 대체해도 전혀 문제가 없기 때문에 자연스럽게 LSP를 준수하게 된다.

     

     이뿐만 아니라, 상속에서 발생하는 높은 결합도는 함수를 독립적으로 분리하고 동작하도록 함으로써 낮출 수 있다. 또 다른 단점이었던 캡슐화 약화도 함수는 클로저 등을 사용해 외부에서 함부로 접근하지 못하도록 보호할 수 있다.

    이처럼 합성 방식은 유연하고 확장성 있는 개발에 큰 강점이 있다.

     

     단점은 불필요한 작은 컴포넌트가 많아질 수 있다는 것이다. 컴포넌트를 분리하려는 목적과 이유에 대해 신중히 고민하지 않으면, 관리해야 할 컴포넌트의 수가 많아져 구조가 복잡해질 수 있다. 모든 기능을 작은 단위로 분리하는 것이 항상 좋은 선택은 아니기에, 지나치게 세분화된 구조가 되지 않도록 경계해야 한다. 

     

     

     

    균형 : IS-A와 HAS-A의 이해와 적절한 적용

     

     상속을 사용해야 할지, 합성을 사용해야 할지 결정하는 데 있어 관계를 살펴보는 것이 중요하다.

    객체 지향 프로그래밍에는 IS-AHAS-A라는 두 가지 중요한 관계가 존재한다.

    • IS-A 관계 : "A는 B이다"의 관계. 주로 상속을 통해 구현된다. Car은 Vehicle을 상속받고, Car is-a Vehicle 관계가 성립한다. 강한 결합도, 의존성 증가, 부족한 확장성을 가지나 객체 간의 관계가 명확하다면 단단한 설계가 가능하다.
    class Vehicle {
      move() {
        console.log("moving...");
      }
    }
    
    class Car extends Vehicle {
      drive() {
        console.log("driving...");
      }
    }
    
    const myCar = new Car();
    myCar.move(); // 부모(Vehicle)의 메서드 사용 가능
    myCar.drive(); // 자식(Car)의 메서드
    • HAS-A 관계 : "A는 B를 포함한다"의 관계. 주로 합성을 통해 구현된다. 특정 객체가 다른 객체를 속성(property)으로 포함하거나 사용하는 방식이다. Car 클래스가 Engine을 포함하다면 Car has-a Engine 관계가 성립한다. 특정 객체의 내부 구현이 변경되더라도 다른 객체에 영향을 미치지 않기에 느슨한 결합을 만들며, Car은 다양한 Engine들과 조합할 수 있어 확장성도 높다. 
    class Engine {
      start() {
        console.log("Engine starting...");
      }
    }
    
    class Car {
      constructor() {
        this.engine = new Engine(); // Engine 객체를 포함
      }
    
      drive() {
        console.log("Car is being driven");
        this.engine.start(); // 엔진 객체 메서드 호출 가능
      }
    }
    
    const myCar = new Car();
    myCar.drive();

     

     요소들의 관계가 IS-A인지 모호하다면 합성 방식을 사용해 보는 것이 방법이 될 수 있다. 

    상속과 합성의 선택은 단순 기능 구현 측면보다 관계를 어떻게 정의하고 설계할 지에 대한 것이라고 느껴졌다. 각 장/단점을 잘 이해하고, 구성요소들의 관계를 파악해 상황에 맞게 적절히 적용한다면 좋은 설계가 될 수 있을 것 같다. 

     

     관련하여, React 팀이 합성을 선호하고, 함수 컴포넌트 사용을 권장하는 이유는 여러 측면에서 컴포넌트들의 관계는 IS-A보다 HAS-A에 가깝기 때문이라고도 이해해 볼 수 있는 시간이었다.

     

     

     

    참고자료

    https://legacy.reactjs.org/blog/2019/02/06/react-v16.8.0.html

    https://en.wikipedia.org/wiki/Composition_over_inheritance

    https://ko.legacy.reactjs.org/docs/composition-vs-inheritance.html

    https://f-lab.kr/insight/understanding-inheritance-20241205

    댓글

🍀 Y0ungZ dev blog.
스크롤 버튼