HOC(Higher-Order Component, 고차 컴포넌트)

Inhye Choi·2023년 3월 9일
0
post-thumbnail

About

HOC(Higher-Order Component)는 컴포넌트를 받아서 새 컴포넌트를 반환하는 함수로 컴포넌트 로직을 재사용하기 위한 React의 고급 기술입니다. React API 일부가 아닌, 구성적 특성에서 나오는 패턴입니다.

  • Component는 props를 받아 UI로 변환하는 반면,
    Higher-Order Component는 컴포넌트를 다른 컴포넌트로 변환합니다.
  • 전달 받은 컴포넌트를 수정하지 않고, 상속을 사용하여 동작을 복사하지도 않습니다.
  • 원본 컴포넌트를 컨테이너 컴포넌트로 포장(Wrapping)하여 조합(compose)합니다.
  • 사이드 이펙트가 전혀 없는 순수 함수입니다.
const EnhancedComponent = higherOrderComponent(WrappedComponent);

Third-party React Library에서 HOC를 쓰는 예시

Cross-Cutting Concerns에 HOC 사용하기

Components는 React에서 코드 재사용의 기본 단위이지만, 일부 패턴의 경우 기존 방식의 컴포넌트에 완전히 적합하지는 않습니다.

CommentList 컴포넌트와 BlogPost 컴포넌트를 예를 들어보겠습니다.
CommentList는 컴포넌트 외부로부터 데이터를 구독하여 댓글 목록을 렌더링하는 컴포넌트입니다.

class CommentList extends React.Component {
  constructor(props) {
    super(props);
    this.handleChange = this.handleChange.bind(this);
    this.state = {
      // DataSource는 전역 데이터 소스 
      comments: DataSource.getComments()
    };
  }

  componentDidMount() {
    // 변화 감지를 위해 리스너를 추가
    DataSource.addChangeListener(this.handleChange);
  }

  componentWillUnmount() {
    // 리스너 제거
    DataSource.removeChangeListener(this.handleChange);
  }

  handleChange() {
    // 데이터 소스가 변경될때 마다 comments를 업데이트
    this.setState({
      comments: DataSource.getComments()
    });
  }

  render() {
    return (
      <div>
        {this.state.comments.map((comment) => (
          <Comment comment={comment} key={comment.id} />
        ))}
      </div>
    );
  }
}

BlogPost는 컴포넌트 외부로부터 데이터를 구독하여 블로그 목록을 렌더링하는 컴포넌트입니다.

class BlogPost extends React.Component {
  constructor(props) {
    super(props);
    this.handleChange = this.handleChange.bind(this);
    this.state = {
      blogPost: DataSource.getBlogPost(props.id)
    };
  }

  componentDidMount() {
    DataSource.addChangeListener(this.handleChange);
  }

  componentWillUnmount() {
    DataSource.removeChangeListener(this.handleChange);
  }

  handleChange() {
    this.setState({
      blogPost: DataSource.getBlogPost(this.props.id)
    });
  }

  render() {
    return <TextBlock text={this.state.blogPost} />;
  }
}

CommentListBlogPost 두 컴포넌트는 DataSource에서 서로 다른 메서드를 호출하며 다른 렌더링 결과를 보여주고 있습니다. 하지만 대부분의 구현 방식은 아래와 같이 비슷합니다.

  • 컴포넌트가 마운트되면, change 리스너를 DataSource에 추가
  • 리스너 안에서, 데이터 소스가 변경되면 setState를 호출
  • 컴포넌트가 마운트 해제되면 change 리스너를 제거

규모가 큰 애플리케이션에서 DataSource를 구독하고 setState를 호출하는 동일한 패턴이 계속해서 반복적으로 발생한다고 가정해봅시다. 그렇게 된다면 이 로직을 한 곳에서 정의하고 많은 컴포넌트에서 로직을 공유할 수 있게 하는 추상화가 필요하게 됩니다. 이러한 경우에 HOC를 사용하면 좋습니다.

DataSource를 구독하는 CommentListBlogPost같은 컴포넌트를 생성하는 함수를 아래와 같이 만들어 보겠습니다.

// 이 함수는 구독한 데이터를 prop으로 전달받는 자식 컴포넌트를 파라미터로 받아서,
function withSubscription(WrappedComponent, selectData) {
  // 새로운 컴포넌트를 반환하는데, 
  return class extends React.Component {
    constructor(props) {
      super(props);
      this.handleChange = this.handleChange.bind(this);
      this.state = {
        data: selectData(DataSource, props)
      };
    }

    componentDidMount() {
      // 구독을 담당하고
      DataSource.addChangeListener(this.handleChange);
    }

    componentWillUnmount() {
      DataSource.removeChangeListener(this.handleChange);
    }

    handleChange() {
      this.setState({
        data: selectData(DataSource, this.props)
      });
    }

    render() {
      // 래핑된 컴포넌트를 새로운 데이터로 렌더링하는 컴포넌트입니다.
      // 컴포넌트에 추가 props를 모두 전달하는 것에 주목하세요.
      return <WrappedComponent data={this.state.data} {...this.props} />;
    }
  };
}

이렇게 구현된 withSubscription 함수는 아래와 같이 사용합니다.

const CommentListWithSubscription = withSubscription(
  CommentList,
  (DataSource) => DataSource.getComments()
);

const BlogPostWithSubscription = withSubscription(
  BlogPost,
  (DataSource, props) => DataSource.getBlogPost(props.id)
);

withSubscription는 일반 함수이기 때문에 원하는만큼 파라미터를 추가할 수 있습니다. 예를 들어 래핑된 컴포넌트로부터 HOC를 더 격리시키기 위해 data prop 이름을 설정할 수 있게 만들 수 있습니다. 혹은 shouldComponentUpdate 설정을 위한 파라미터를 받게 하거나 데이터 소스를 설정하는 파라미터를 받게할 수도 있습니다. HOC가 컴포넌트 정의 방법을 완전히 제어할 수 있기 때문에 이런 작업이 모두 가능합니다.

컴포넌트와 마찬가지로 withSubscription과 래핑된 컴포넌트 간 계약(contract)은 완전히 props 기반입니다. 이렇게하면 래핑된 컴포넌트에 동일한 props를 제공한다면 다른 고차 컴포넌트를 쉽게 변경할 수 있습니다. 예를 들어, 데이터를 가져오는 라이브러리를 변경하는 경우 유용하게 사용할 수 있습니다.

원본 컴포넌트를 바꾸지말고, 조합(Composition)하세요.

HOC 내부에서 컴포넌트의 프로토타입을 수정(또는 변경) 하고 싶은 유혹을 뿌리치세요.

function logProps(InputComponent) {
  InputComponent.prototype.componentDidUpdate = function(prevProps) {
    console.log('Current props: ', this.props);
    console.log('Previous props: ', prevProps);
  };
  // The fact that we're returning the original input is a hint that it has
  // been mutated.
  return InputComponent;
}

// EnhancedComponent will log whenever props are received
const EnhancedComponent = logProps(InputComponent);

변형했을 때 발생하는 문제

  1. 입력된 컴포넌트를 확장된(enhanced) 컴포넌트와 별도로 재사용 할 수 없다는 것
  2. componentDidUpdate를 변형하는 EnhancedComponent에 또 다른 HOC를 적용하면 첫 번째 HOC의 기능은 무시됩니다. 이 HOC는 생명주기 메서드가 없는 함수 컴포넌트에서도 작동하지 않습니다.

HOC를 변형하는 것은 추상적인 개념입니다. Consumer는 HOC가 다른 HOC와 충돌하지 않도록 HOC가 어떻게 구현되는지 알아야 합니다.

function logProps(WrappedComponent) {
  return class extends React.Component {
    componentDidUpdate(prevProps) {
      console.log('Current props: ', this.props);
      console.log('Previous props: ', prevProps);
    }
    render() {
      // 들어온 component를 변경하지 않는 container입니다. Good!
      return <WrappedComponent {...this.props} />;
    }
  }
}

위 HOC는 충돌 가능성을 피하면서 프로토타입을 직접 변경하는 버전과 동일하게 작동합니다. 이 방법은 클래스 컴포넌트와 함수 컴포넌트에서도 동일하게 작동합니다. 그리고 순수 함수이기 때문에 다른 HOC와 같이 조합하거나 심지어 자체적으로 조합할 수 있습니다.

HOC와 Container Component라 불리는 패턴이 유사하다고 느낄 수 있습니다. Container Component는 high-level과 low-level 관심사를 분리하는 전략 중 하나입니다. 컨테이너는 구독 및 state 같은 것을 관리하고 UI 렌더링 같은 것을 처리하는 컴포넌트에 props를 전달합니다. HOC는 컨테이너를 그 구현체 중 일부에 사용하고 있습니다. HOC는 매개변수화된 컨테이너 컴포넌트 정의로 생각할 수 있습니다.

컨벤션

1. 래핑된 컴포넌트를 통해 관련없는 Props 전달하기

HOC는 컴포넌트에 기능을 추가합니다. HOC는 정의를(Contract)를 과감하게 변경해서는 안됩니다. 반환된 컴포넌트는 래핑된 컴포넌트와 비슷한 인터페이스가 있어야합니다.
특정 관심사와 관련이 없는 props를 활용해야합니다.
대부분의 HOC에는 다음과 같은 렌더링 메서드가 포함되어 있습니다.

render() {
  // 이 HOC에만 해당되므로 추가된 props는 필터링해서 이 HOC에 전달되지 않도록 합니다.
  const { extraProp, ...passThroughProps } = this.props;

  // 래핑된 컴포넌트에 props를 주입합니다. 이 Props는 일반적으로 state 값 또는 Instance method 입니다.
  const injectedProp = someStateOrInstanceMethod;

  // Pass props to wrapped component
  return (
    <WrappedComponent
      injectedProp={injectedProp}
      {...passThroughProps}
    />
  );
}

2. 조합 가능성(Composability) 끌어올리기

모든 HOC가 똑같이 생기지는 않았습니다. 때때로 단일 인수로 래핑된 컴포넌트만 받을 때도 있습니다.

const NavbarWithRouter = withRouter(Navbar);

보통 HOC는 추가 인수를 받습니다. 아래 예시는 Relay 라이브러리 예시로 컴포넌트의 데이터 의존성을 명시하기 위한 config 오브젝트를 함께 받고 있습니다.

const CommentWithRelay = Relay.createContainer(Comment, config);

HOC의 가장 일반적인 사용은 다음과 같습니다. 아래는 Redux의 connect의 사용 예입니다.

const ConnectedComment = connect(commentSelector, commentActions)(CommentList);

위의 형태를 분해하면 좀 더 이해하기 쉽습니다.

// connect는 다른 함수를 반환하는 함수 입니다.
const enhance = connect(commentListSelector, commentListActions);
// 반환된 함수는 Redux store에 연결된 컴포넌트를 반환하는 HOC입니다. 
const ConnectedComment = enhance(CommentList);

다르게 말하면 connect는 HOC를 반환하는 HOC입니다.

이 형태는 혼란스럽거나 불필요하게 보일 수 있지만 매우 유용한 속성입니다. connect함수에 의해 반환된 것과 같은 단일 인수 고차 컴포넌트는 Component => Component 특징을 가지고 있습니다. 출력 타입이 입력 타입과 동일한 함수는 정말 쉽게 조합할 수 있습니다.

// 이렇게 하는 대신에,
const EnhancedComponent = withRouter(connect(commentSelector)(WrappedComponent))

//  함수 컴포지션 유틸리티를 사용할 수 있습니다.
// compose(f, g, h)는 (...args) => f(g(h(...args)))와 같습니다.
const enhance = compose(
  // 둘 다 단일 매개변수의 HOC입니다.
  withRouter,
  connect(commentSelector)
)
const EnhancedComponent = enhance(WrappedComponent)

3. 디버깅을 쉽게 하기 위한 디스플레이 네임 작성 방법

다른 컴포넌트와 마찬가지로 HOC로 만든 컨테이너 컴포넌트도 React Developer Tools에 표시됩니다. 디버깅을 쉽게 하려면 HOC의 결과임을 알리는 디스플레이 네임을 선택하세요.

가장 일반적인 방법은 HOC의 이름으로 내부 컴포넌트명을 감싸는 방법입니다. 예를 들어, HOC의 이름이 withSubscription이고, HOC 내부의 컴포넌트의 이름이 CommentList인 경우, 디스플레이 네임은 WithSubscription(CommentList)을 사용하는 것입니다.

function withSubscription(WrappedComponent) {
  class WithSubscription extends React.Component {/* ... */}
  WithSubscription.displayName = `WithSubscription(${getDisplayName(WrappedComponent)})`;
  return WithSubscription;
}

function getDisplayName(WrappedComponent) {
  return WrappedComponent.displayName || WrappedComponent.name || 'Component';
}

주의 사항

React가 처음이라면 HOC 사용 시 알아차리기 어려운 주의 사항 몇 가지가 있습니다.

1. render 메서드 안에서 HOC를 사용하지 마세요.

render() {
  // render가 호출될 때마다 새로운 버전의 EnhancedComponent가 생성됩니다.
  // EnhancedComponent1 !== EnhancedComponent2
  const EnhancedComponent = enhance(MyComponent);
  // 때문에 매번 전체 서브트리가 마운트 해제 후 다시 마운트 됩니다.
  return <EnhancedComponent />;
}

Reconciliation라고 불리는 React의 Diffing 알고리즘은 컴포넌트의 개별성(identity)을 가지고 기존 하위 트리를 업데이트할지 아니면 버리고 새 하위 트리를 마운트할지 결정합니다. render에서 반환된 컴포넌트가 이전에 렌더링 된 컴포넌트와 동일한 경우(===), React는 새로운 하위 트리와 비교하여 재귀적으로 하위 트리를 업데이트합니다. 동일하지 않으면 이전 하위 트리가 완전히 마운트 해제됩니다.

일반적인 상황에서는 이런 부분을 고려할 필요가 없습니다. 그러나, HOC의 경우, 컴포넌트의 render 메서드 안에서는 이런 이유 때문에 컴포넌트에 HOC를 적용할 수 없기 때문에 주의해주세요.

여기서 문제는 성능에만 있는 것이 아닙니다. 컴포넌트를 다시 마운트하면 해당 컴포넌트와 모든 하위 컴포넌트의 state가 손실됩니다.대신 컴포넌트의 외부에 HOC를 정의해서 결과 컴포넌트가 딱 한 번만 생성될 수 있게 하세요. 그러고 나면 render 간에 개별성이 일관성 있게 유지될 것입니다.

HOC를 동적으로 적용해야 하는 드문 경우에는, 컴포넌트의 생명주기 메서드 또는 생성자 내에 작성 할 수 있습니다.

2. 정적 메서드는 반드시 따로 복사하세요.

React 컴포넌트에 정적 메서드를 정의하는 것이 유용할 때도 있습니다. 예를 들어 Relay 컨테이너는 GraphQL 구성을 용이하게 하기 위해 정적 메서드 getFragment를 노출합니다.

컴포넌트에 HOC를 적용하면, 기존 컴포넌트는 컨테이너의 컴포넌트로 감싸집니다. 즉, 새 컴포넌트는 기존 컴포넌트의 정적 메서드를 가지고 있지 않습니다.

// 정적 함수를 정의합니다
WrappedComponent.staticMethod = function() {/*...*/}
// HOC를 적용합니다
const EnhancedComponent = enhance(WrappedComponent);

// EnhancedComponent에는 정적 메서드가 없습니다.
typeof EnhancedComponent.staticMethod === 'undefined' // true

이 문제를 해결하려면 매서드를 반환 하기 전에 컨테이너에 복사합니다.

function enhance(WrappedComponent) {
  class Enhance extends React.Component {/*...*/}
  // 복사 할 메서드를 정확히 알아야 합니다.
  Enhance.staticMethod = WrappedComponent.staticMethod;
  return Enhance;
}

그러나 복사해야 할 메서드를 정확히 알아야 할 필요가 있습니다. hoist-non-react-statics를 사용하여 모든 non-React 정적 메서드를 자동으로 복사할 수 있습니다.

import hoistNonReactStatic from 'hoist-non-react-statics';
function enhance(WrappedComponent) {
  class Enhance extends React.Component {/*...*/}
  hoistNonReactStatic(Enhance, WrappedComponent);
  return Enhance;
}

또 다른 해결 방법은 정적 메서드를 컴포넌트와 별도로 내보내는 것입니다.

// 이렇게 하는 대신, 
MyComponent.someFunction = someFunction;
export default MyComponent;

// 메서드를 각각 내보내고
export { someFunction };

// 불러오는 모듈에서 두 개를 모두 import합니다.
import MyComponent, { someFunction } from './MyComponent.js';

3. Ref는 전달되지 않습니다.

HOC는 모든 props를 래핑된 컴포넌트에 전달하는 것이 원칙이지만, refs에는 동작하지 않습니다. 이는 React에서 ref가 실제 prop이 아닌 key처럼 특별하게 취급되기 때문입니다. 컴포넌트가 HOC의 결과인 엘리먼트에 ref를 추가하는 경우, ref는 래핑된 컴포넌트가 아닌 가장 바깥쪽 컨테이너 컴포넌트의 인스턴스를 나타냅니다.

이 문제의 해결 방법은 React.forwardRef API를 사용하는 것입니다.(React 16.3에 도입)

참고 링크

https://reactjs.org/docs/higher-order-components.html

0개의 댓글