[React] 고차 컴포넌트 (Higher-Order Components)

고차 컴포넌트 (Higher-Order Components)

여러 컴포넌트에 걸쳐서 같은 기능을 구현할 필요가 있을 때,
특히 외부 세계와 연동되는 같은 기능을 구현할 필요가 있을 때 사용되는 것이 고차 컴포넌트(HOC) 입니다.
고차 컴포넌트(HOC: Higher-Order Component)는 컴포넌트 로직을 재사용하기 위해 사용되는 테크닉을 말합니다.
HOC 는 React API 의 일부가 아닙니다. 조합이 용이한 React 의 특성으로부터 생겨난 패턴입니다.


구체적으로 말하자면, HOC 는 컴포넌트를 인자로 받아서 새로운 컴포넌트를 반환하는 함수입니다.

const EnhancedComponent = higherOrderComponent(WrappedComponent);

컴포넌트는 props 를 UI 로 변환하는 반면, HOC 는 컴포넌트를 다른 컴포넌트로 변환합니다.

HOC 는 Redux 의 connect, Relay 의 createContainer 와 같이, 서드파티 라이브러리에서 흔히 사용되고 있습니다.

// HOC는 컴포넌트를 인자로 받아 그 컴포넌트를 활용하는 다른 컴포넌트를 반환합니다.

const hoc = component => {
  return class extends React.Component {
    render() {
      return <Component />;
    }
  };
};

export default hoc(WrappedComponent);


횡단 관심사(Cross-Cutting Concerns)를 위해 HOC 사용하기

횡단 관심(Cross-Cutting Concerns)이란?

핵심관심기능에 공통적으로 들어가는 기능들을 모듈화해서 사용하는 것을 의미합니다.


컴포넌트는 리액트의 코드 재사용을 위한 기본적인 단위입니다.
하지만, 어떤 패턴은 전통적인 컴포넌트 구조에는 딱 맞지 않을 수 있습니다.

예를 들어, CommentList 라는 컴포넌트가 외부의 데이터를 받아와서 댓글 목록을 렌더링한다고 가정해봅시다:

class CommentList extends React.Component {
  constructor(props) {
    super(props);
    this.handleChange = this.handleChange.bind(this);
    this.state = {
      // "DataSource" is some global data source
      comments: DataSource.getComments()
    };
  }

  componentDidMount() {
    // Subscribe to changes
    DataSource.addChangeListener(this.handleChange);
  }

  componentWillUnmount() {
    // Clean up listener
    DataSource.removeChangeListener(this.handleChange);
  }

  handleChange() {
    // Update component state whenever the data source changes
    this.setState({
      comments: DataSource.getComments()
    });
  }

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

그 이후에, 비슷한 패턴으로 하나의 블로그 포스트를 받아오는 컴포넌트를 작성한다고 해봅시다:

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} />;
  }
}

CommentList 와 BlogPost 의 로직이 완전히 동일하지는 않습니다.
다시 말해, 이 둘은 각각 DataSource 의 다른 메소드를 호출하고 또 다른 결과물을 렌더링합니다.
하지만 대부분의 구현이 동일합니다:

이와 같은 패턴, 즉 DataSource 를 구독하고 setState 를 호출하는 작업을 끝없이 반복하는 대규모 앱을 상상해 보십시오.
로직을 한 곳에서 정의하고 여러 컴포넌트에 걸쳐 공유할 수 있는 추상화 수단이 필요합니다. 이 곳이 바로 HOC 가 활약하는 지점입니다.

컴포넌트를 생성하는 함수를 만들어서 CommentList 나 BlogPost 같은 컴포넌트들이 DataSource 를 구독하게 만들 수 있습니다.
이 함수는 자식 컴포넌트를 인자로 받는데, 이 컴포넌트는 구독한 데이터를 prop 으로 받습니다. 이 함수를 withSubscription 이라 부릅시다:

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

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

첫 번째 인수는 감싸지는 컴포넌트입니다. 두 번째 인수로 주어진 함수는 DataSource 와 props 를 이용해 필요한 데이터를 받아옵니다.

CommentListWithSubscription 와 BlogPostWithSubscription 가 렌더링될 때, CommentList 와 BlogPost 에는 DataSource 로부터 받은 가장 최신의 데이터가 data prop 으로 전달될 것입니다:

// This function takes a component...
function withSubscription(WrappedComponent, selectData) {
  // ...and returns another component...
  return class extends React.Component {
    constructor(props) {
      super(props);
      this.handleChange = this.handleChange.bind(this);
      this.state = {
        data: selectData(DataSource, props)
      };
    }

    componentDidMount() {
      // ... that takes care of the subscription...
      DataSource.addChangeListener(this.handleChange);
    }

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

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

    render() {
      // ... and renders the wrapped component with the fresh data!
      // Notice that we pass through any additional props
      return <WrappedComponent data={this.state.data} {...this.props} />;
    }
  };
}

HOC 는 입력받은 컴포넌트를 수정하지도, 상속받지도 않는다는 사실에 주목하십시오.
대신, HOC 는 원래의 컴포넌트를 다른 컴포넌트로 감싸는 식으로 합성합니다.
HOC 는 부작용을 갖지 않는 순수 함수입니다.

이게 전부입니다! 감싸진 컴포넌트는 바깥쪽 컴포넌트로부터 새로운 prop 을 포함한 모든 prop 을 건네받으며,
여기에서는 결과를 렌더링하기 위해 data 라는 prop 을 받았습니다. HOC 는 데이터가 왜 사용되는지,
어떻게 사용되는지에 대해서는 관심을 두지 않으며, 감싸진 컴포넌트는 데이터가 어디서 왔는지는 신경쓰지 않습니다.

withSubscription 은 일반적인 함수이기 때문에, 인자의 개수는 원하는 대로 정할 수 있습니다.
예를 들어, HOC 와 감싸지는 컴포넌트를 좀 더 분리시키기 위해 data prop 의 이름을 설정할 수 있도록 만들 수도 있습니다.
또는 shouldComponentUpdate 를 설정하는 인자를 받거나, 데이터 소스를 설정하는 인자를 받을 수도 있을 것입니다.
HOC 가 컴포넌트를 어떻게 정의할 지 완전히 제어할 수 있기 때문에 위와 같은 작업이 모두 가능합니다.

일반적인 컴포넌트와 마찬가지로, withSubscription 과 감싸진 컴포넌트 사이의 연결은 모두 prop 을 통해서 이루어집니다.
이렇게 함으로써 한 HOC 를 같은 props 를 제공하는 다른 HOC 로 쉽게 교체할 수 있습니다.
예를 들어 데이터를 외부로부터 가져오는 라이브러리를 교체할 때 이 특성이 유용할 수 있습니다.


컴포넌트를 변경하지 말고 합성을 사용하도록 합시다.

HOC 안에서 컴포넌트의 프로토타입을 (혹은 무엇이든) 변경하고픈 유혹에 빠져서는 안됩니다.

// 메소드를 바꾸면 편하지 않을까라는 생각이 든다면 절대 네버..
// 건드는 순간 더 어려워질 수 있습니다.

function logProps(InputComponent) {
  InputComponent.prototype.componentWillReceiveProps = function(nextProps) {
    console.log("Current props: ", this.props);
    console.log("Next props: ", nextProps);
  };
  // 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);

이러한 방식에는 몇 가지 문제가 있습니다.
먼저 입력된 컴포넌트가 EnhancedComponent 와는 별개로 다른 곳에서 재사용될 수 없다는 문제가 있습니다.
더욱이, componentWillReceiveProps 를 재차 변경하는 다른 HOC 를 EnhancedComponent 에 적용하면,
처음에 적용한 HOC 의 기능이 덮어씌워진다는 문제가 있습니다!
또한 이 HOC 는 라이프사이클 메소드가 없는 함수 컴포넌트에는 작동하지 않을 것입니다.

컴포넌트를 변경하는 HOC 는 잘못된 추상화입니다 — 다른 HOC 들과의 충돌을 피하려면 그들이 어떻게 구현되어있는지를 알아야만 합니다.

컴포넌트를 변경하는 대신, HOC 는 합성(입력 컴포넌트를 컨테이너 컴포넌트로 감싸는 방법)을 사용해야 합니다.

function logProps(WrappedComponent) {
  return class extends React.Component {
    componentWillReceiveProps(nextProps) {
      console.log("Current props: ", this.props);
      console.log("Next props: ", nextProps);
    }
    render() {
      // Wraps the input component in a container, without mutating it. Good!
      return <WrappedComponent {...this.props} />;
    }
  };
}

위 HOC 는 컴포넌트를 변경하는 버전과 같은 기능을 하지만 잠재적인 충돌의 위험을 피하고 있습니다.
또한 클래스 컴포넌트든 함수 컴포넌트든 할 것 없이 잘 동작합니다.
그리고 순수 함수이기 때문에, 다른 HOC 혹은 심지어 자기 자신과도 합성을 할 수 있습니다.

아마 여러분들께서는 HOC 패턴과 컨테이너 컴포넌트라 불리는 패턴 사이의 유사점을 눈치채셨을 겁니다.
컨테이너 컴포넌트는 고수준 관심사와 저수준 관심사에 대한 책임의 분리를 위한 전략의 일부분입니다.
컨테이너는 구독과 상태 같은 것들을 관리하고, UI 를 렌더링하는 컴포넌트에 prop 을 전달합니다.
HOC 는 그 구현 속에 컨테이너를 사용하고 있습니다. HOC 는 ‘컨테이너 컴포넌트의 파라미터화된 정의다’라 생각해도 좋습니다.


관례: HOC 와 무관한 prop 은 감싸진 컴포넌트에 넘기세요.

HOC 는 컴포넌트에 기능을 추가합니다.
그러므로 컴포넌트의 사용법을 극단적으로 바꾸어서는 안 됩니다.
HOC 로부터 반환된 컴포넌트는 감싸진 컴포넌트와 유사한 사용법을 갖도록 하는 것이 일반적입니다.

HOC 는 그 자신의 관심사와 관계없는 props 를 감싸진 컴포넌트에 그대로 넘겨야 합니다.
대부분의 HOC 는 아래와 유사한 render 메소드를 갖습니다:

render() {
  // Filter out extra props that are specific to this HOC and shouldn't be
  // passed through
  const { extraProp, ...passThroughProps } = this.props;

  // Inject props into the wrapped component. These are usually state values or
  // instance methods.
  const injectedProp = someStateOrInstanceMethod;

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

이 관례를 통해 최대한 유연하고 재사용 가능한 HOC 를 만들 수 있습니다.

[예제 1]

// hocs의 네이밍은 with로 시작하는 것이 관례입니다.
import React from "react";

import { Redirect } from "react-router-dom";

export default function(WrappedComponent) {
  return class extends React.Component {
    render() {
      if (localStorage.getItem("token")) {
        return <WrappedComponent {...this.props} />;
        // 이 예시에는 <WrappedComponent /> 안에 {...this.props}를 넘겨줘야 HOC로부터 반환된 컴포넌트는 감싸진 컴포넌트와 유사한 사용법을 가질수 있습니다.
      } else {
        return <Redirect to="login" />;
      }
    }
  };
}


관례: 합성을 최대한 활용하세요.

모든 HOC 가 똑같이 생긴 것은 아닙니다.
때때로 HOC 는 하나의 인자, 즉 감싸지는 컴포넌트만을 인자로 받습니다.

const NavbarWithRouter = withRouter(Navbar);

하지만 보통의 경우, HOC 는 추가로 인자를 받습니다.
아래의 Relay 예제는, 컴포넌트의 데이터 의존성을 설정하기 위한 설정 객체를 인자로 받습니다.

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

HOC 의 가장 일반적인 모양은 이렇게 생겼습니다.

// React Redux's `connect`
const ConnectedComment = connect(
  commentSelector,
  commentActions
)(CommentList);

이게 무슨 뜻일까요? 나누어서 보면, 어떻게 돌아가고 있는 건지 알아보기 쉽습니다.

// connect is a function that returns another function
const enhance = connect(
  commentListSelector,
  commentListActions
);
// The returned function is a HOC, which returns a component that is connected
// to the Redux store
const ConnectedComment = enhance(CommentList);

다시 말해서, connect 는 HOC 를 반환하는 고차 함수입니다!

이 방식은 혼란스럽고 불필요해 보일 수 있지만, 유용한 특징을 가지고 있습니다.
connect 가 반환하는 것과 같은 단일 인자 HOC 의 모양은 Component => Component 와 같습니다.
(컴포넌트를 입력받아 컴포넌트를 반환함) 입력 형태와 출력 형태가 같은 함수는 여러 개를 합성하기 굉장히 쉽습니다.

// Instead of doing this...
const EnhancedComponent = withRouter(
  connect(commentSelector)(WrappedComponent)
);

// ... you can use a function composition utility
// compose(f, g, h) is the same as (...args) => f(g(h(...args)))
const enhance = compose(
  // These are both single-argument HOCs
  withRouter,
  connect(commentSelector)
);
const EnhancedComponent = enhance(WrappedComponent);

(이와 같은 특성은 connect 및 다른 enhancer 스타일의 HOC 가 decorator 로 사용되어질 수 있도록 합니다.
Decorator 는 실험적으로 제안된 JavaScript 의 기능입니다.)


관례: 원활한 디버깅을 위해 displayName 도 감싸주세요.

HOC 에 의해 만들어진 컨테이너 컴포넌트는 다른 컴포넌트와 마찬가지로 React Developer Tools 에서 볼 수 있습니다.
디버깅을 쉽게 하기 위해, HOC 에 의해 반환되는 컴포넌트의 displayName 을 설정해주는 것이 좋습니다.

널리 사용되는 방법은 감싸지는 안쪽 컴포넌트의 displayName 을 HOC 의 이름으로 감싸는 것입니다.
예를 들어 HOC 의 이름이 withSubscription 이고 감싸지는 컴포넌트의 displayName 이 CommentList 이면,
WithSubscription(CommentList)와 같은 displayName 을 써주면 됩니다.

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 를 막 시작한 사람들에게는 명확히 와닿지 않을 주의사항이 몇 가지 있습니다.


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

React 의 diffing 알고리즘(reconciliation)은 컴포넌트가 같은 지 판별하고
이를 이용해서 이미 존재하는 서브트리를 갱신할 지, 또는 서브트리를 날려버리고 새로운 것을 마운트할 지를 결정합니다.
만약 render 메소드에서 반환된 컴포넌트가 이전에 반환된 컴포넌트와 동일하다면 (===)
React 는 서브트리를 새로운 것과 비교해 재귀적으로 갱신합니다.
만약 두 개가 같지 않다면, 이전의 서브트리는 완전히 언마운트(unmount)됩니다.

일반적으로, 여러분이 이런 문제를 직접 고민할 필요는 없습니다.
하지만 HOC 에 대해서는 이 문제가 중요한데, render 메소드 안에서는 HOC 를 적용할 수 없다는 것을 뜻하기 때문입니다.

render() {
  // A new version of EnhancedComponent is created on every render
  // EnhancedComponent1 !== EnhancedComponent2
  const EnhancedComponent = enhance(MyComponent);
  // That causes the entire subtree to unmount/remount each time!
  return <EnhancedComponent />;
}

이 문제는 비단 성능에 관한 것만은 아닙니다
컴포넌트를 다시 마운트하면 해당 컴포넌트와 그 자식 컴포넌트들의 상태(state)를 잃어버리게 됩니다.

위와 같이 하는 대신, HOC 를 컴포넌트 정의의 바깥에서 적용해서 반환되는 컴포넌트가 단 한 번만 만들어지도록 하세요.
그러면 여러 번 렌더링을 하더라도 컴포넌트의 동일성(identity)이 유지될 수 있습니다. 대개 이것이 우리가 원하는 결과입니다.

그럴 일은 잘 없겠지만 HOC 를 동적으로 적용하고 싶은 경우에도, 다른 라이프사이클 메소드나 생성자에서 HOC 를 적용할 수 있습니다.


정적 메소드는 반드시 복사되어야 합니다.

React 컴포넌트에 정의한 정적 메소드는 유용하게 쓸 수 있습니다.
예를 들어, Relay 컨테이너는 getFragment 라는 정적 메소드를 이용해 GraphQL 프래그먼트를 조합하도록 합니다.

그런데 HOC 를 컴포넌트에 적용하면, 원래 컴포넌트는 컨테이너 컴포넌트에 의해 감싸지게 됩니다.
즉, 새로운 컴포넌트는 원래 컴포넌트가 가지고 있던 정적 메소드를 하나도 갖고 있지 않게 됩니다.

// Define a static method
WrappedComponent.staticMethod = function() {
  /*...*/
};
// Now apply a HOC
const EnhancedComponent = enhance(WrappedComponent);

// The enhanced component has no static method
typeof EnhancedComponent.staticMethod === "undefined"; // true

이 문제를 해결하기 위해, 컨테이너 컴포넌트를 반환하기 전에 정적 메소드를 직접 복사할 수 있습니다.

function enhance(WrappedComponent) {
  class Enhance extends React.Component {
    /*...*/
  }
  // Must know exactly which method(s) to copy :(
  Enhance.staticMethod = WrappedComponent.staticMethod;
  return Enhance;
}

하지만, 위와 같은 방법을 사용하려면 정확히 어떤 메소드를 복사할 지 미리 알고 있어야 합니다.
대신 hoist-non-react-statics 를 사용해서 React 내장 정적 메소드가 아닌 모든 정적 메소드를 자동으로 복사하게 만들 수 있습니다.

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

정적 메소드를 직접 export 해서 사용하는 방법도 가능합니다.

// Instead of...
MyComponent.someFunction = someFunction;
export default MyComponent;

// ...export the method separately...
export { someFunction };

// ...and in the consuming module, import both
import MyComponent, { someFunction } from "./MyComponent.js";


Ref 는 전달되지 않습니다.

모든 prop 을 안쪽 컴포넌트에 전달하는 것이 HOC 의 관례입니다만, ref 를 전달하는 것은 불가능합니다.
이것은 ref 가 진짜 prop 이 아니고, key 와 마찬가지로 React 에 의해 특별 취급되기 때문입니다.
만약 여러분이 HOC 에서 반환하고자 하는 컴포넌트에 ref 를 추가한다면,
그 ref 는 안쪽 컴포넌트가 아니라 가장 바깥쪽의 컨테이너 컴포넌트에 대한 인스턴스를 가리키게 될 것입니다.

이 문제를 해결하려면, React 16.3 버전에서 추가된 React.forwardRef API 를 사용하면 됩니다.


별도 - 클래스 속성 : defaultProps

컴포넌트 클래스의 defaultProps 속성을 통해 prop 의 기본값을 지정해줄 수 있습니다.
이 기본값은 undefined prop 을 대신하지만, null prop 은 대신하지 않습니다.

class CustomButton extends React.Component {
  // ...
}

CustomButton.defaultProps = {
  color: "blue"
};

만약 props.color 를 넘겨주지 않는다면, 기본값인 ‘blue’가 대신 사용됩니다:

render() {
  return <CustomButton /> ; // props.color will be set to blue
}

만약 props.color 로 null 을 넘겨준다면, 그대로 null 이 사용됩니다:

render() {
  return <CustomButton color={null} /> ; // props.color will remain null
}