React 리팩토링: HOC - Higher Order Components


중복 코드

어떤 프로그램을 만들든지, 중복 코드는 자주 나타난다. React 또한 마찬가지로 각 React 컴포넌트에서 중복되는 로직과 라이프사이클을 만날 수 있다. 보통 JS에서 클래스 간 중복 로직은 mixin을 통해 해결하는 경우가 많은데, ES6 class문법에서 React는 mixin API를 지원하지 않는다. (물론 직접 class의 prototype을 수정하는 mixin을 구현하거나, React.createClass의 mixin을 이용할 수는 있다.) 그래서 이런 중복 코드를 다른 방식으로 풀어내고 해결하는 방법을 우선 먼저 찾아봐야 하는데, 그 전에 일단 왜, 도대체 왜 mixin을 지원하지 않는지부터 알아보자.

참고: React-No Mixins

Mixin은 죽었다.

React의 committer이자 Redux의 창시자인 Dan Abramov의 "Mixins Are Dead. Long Live Composition"라는 글을 보면, mixin은 매우 많이 사용되는 패턴임은 분명하지만, 사실 누군가가 "mixin방식은 이거야!"라고 표준을 만들어준 것이 아니고, ES6 class 문법에도 없는 mixin을 React에서 ES6용 API로 굳이 지원할 필요는 없다고 설명하고 있다. 또한 mixin자체가 코드의 재사용을 위한 비상 탈출구 같은, 정석보다는 우회에 가까운 방식이며 mixin을 별생각 없이 사용하기 전에 먼저 구성 자체를 쉽고 합리적으로 만드는 것이 우선순위라고 한다.

결론적으로 mixin은 React(혹은 ES6 class)와 잘 어울리지 않는다. mixin은 복잡하고, 암묵적이며 충돌을 일으킨다. mixin의 문제점을 더 자세히 알고 싶다면 Dan Abramov의 위 글을 읽어보길 바란다.

Higher Order Components

Higher Order Components의 시초는 아마 Sebastian Markbåge의 Github gist 코드일 것이다. (이 코드가 15년 2월쯤 작성되었는데, Dan Abramov의 글이 15년 3월에 작성된 것으로 보아 아마 Sebastian Markbåge의 코드를 본 후 작성한 것 같다.) Sebastian Markbåge의 코드에서 High Order Components의 컨셉을 아주 쉽게 파악할 수 있다. 간단하다. React의 컴포넌트를 한번 감싼 새로운 컴포넌트를 만들어 반환하는 것이다. 이 컴포넌트를 감싸주는 함수를 Enhance(혹은 Enhancer)라 부르고, 인자로 내부로 들어갈 컴포넌트를 받는다.

Enhance

enhance = (ComposedComponent) => {
    return class extends React.Component {
        // 생성자 및 기타 공통 라이프 사이클
        constructor() {...}
        componentDidMount() {...}
        //...

        // 내부 컴포넌트를 렌더링
        render() {
            return <ComposedComponent {...this.props} />;
        }
    }
}

Higher Order Component

class MyComponent extends React.Component {
  // 주입 받은 props 활용

  // 실제 렌더링
  render() {
    return <div>My Component</div>;
  }
}

export default Enhance(MyComponent);

내부 컴포넌트가 되는 MyComponent는 Enhance를 통해 자신의 라이프사이클의 제어를 wrapper에게 맡기고 동시에 자신만의 라이프사이클을 따로 활용할 수 있으며, 속성과 데이터를 주입받는다. 또 mixin과 대비되는 중요한 점은, 내부 컴포넌트 혹은 인스턴스의 로직을 덮어쓰는 일이 없다는 것이다.

이 HOC는 어떤 컴포넌트에도 동일하게 적용할 수 있고, 재사용성이 높으며, 특히 횡단 관심사(cross-cutting concerns)같은 공통 기능(로깅, 트래킹, 공통 에러처리 등)에 강력하다.

react-reduxconnect가 HOC의 가장 대표적인 예이다.

HOC 적용해보기 layerEnhancer

React 컴포넌트를 통해 아래와 같은 레이어 팝업을 렌더링한다고 생각해보자. Layer는 React와 관련 없는 외부 라이브러리를 사용하였으며, React Component는 레이어 내부의 컨텐츠만 렌더링한다.

modal-layer

다음 코드는 React 레이어 컴포넌트들이 공통으로 가진 중복코드이다.

constructor(props) {
    super(props);

    this.layer = null;
}

shouldComponentUpdate(nextProps) {
    const {isActive} = nextProps;

    if (isActive) {
        this.layer.show();
    } else {
        this.layer.hide();
    }

    return isActive;
}

componentDidMount() {
    const {contentElement} = this.refs;
    const parentNode = contentElement.parentNode;

    this.layer = new Layer(parentNode, {contentElement});

    // 레이어의 외부(dimmed) 영역을 클릭하였을 때 레이어가 사라지는 이벤트 핸들러
    this.layer.dimm.addEventListener('click', () => {
        // closeXXLayer는 actionCreator로 redux store의 XXLayer.isActive를 false로 바꾼다.
        this.props.closeXXLayer();
    });

    this.layer.hide();
}

componentWillUnmount() {
    this.layer.destroy();
}

우선 위 중복코드들을 HOC의 Enhancer형태로 추출한다.

//layerEnhancer.js
export default ComposedComponent =>
  class extends Component {
    static displayName = `HOC:Layer[${ComposedComponent.displayName ||
      ComposedComponent.name}]`;

    constructor(props) {
      super(props);

      this.layer = null;
    }

    shouldComponentUpdate(nextProps) {
      const { isActive } = nextProps;

      if (isActive) {
        this.layer.show();
      } else {
        this.layer.hide();
      }

      return isActive;
    }

    componentDidMount() {
      const { contentElement } = this.refs;
      const parentNode = contentElement.parentNode;

      this.layer = new Layer(parentNode, { contentElement });

      // 레이어의 외부(dimmed) 영역을 클릭하였을 때 레이어가 사라지는 이벤트 핸들러
      this.layer.dimm.addEventListener("click", () => {
        // closeXXLayer는 actionCreator로 redux store의 XXLayer.isActive를 false로 바꾼다.
        this.props.closeXXLayer();
      });

      this.layer.hide();
    }

    componentWillUnmount() {
      this.layer.destroy();
    }

    render() {
      return <ComposedComponent {...this.props} />;
    }
  };

이렇게 분리하고 보니 몇 가지 문제가 있다.

  1. refs.contentElement가 없다. 때문에 layer들의 컨테이너 역할을 하는 parentNode도 없다.
  2. this.props.closeXXLayer는 컴포넌트 자신을 닫아주는 actionCreator로 각 레이어마다 다를 텐데 어떻게 처리해야 할까?
  3. 각 레이어가 자신만의 props를 가지는데 어떻게 처리해야 할까? 예를 들어 리스트 레이어는 자신이 그려야 할 리스트의 내용을 props로 받아야 하고 form레이어는 form 필드 구성과 submit 등의 동작들을 주입 받아야 한다.

각 레이어별로 redux store에 대한 개별 connect가 필요한 상황이 발생하였다. 그런데 앞서 짧게 언급한 것처럼 react-redux의 connect 역시 HOC로 구성되어있다. 그렇다면 HOC는 단순하게 컴포넌트를 wrapping하는 것이니, 체이닝이 가능할 것이고, 그럼 connect 전에 먼저 LayerEnhancer로 HOC를 만들면 되지 않을까? (한번 wrapping하나 두번 wrapping하나 문제가 없을 것 같다.)

그리고 refs.contentElementReactDOM.findDOMNodeAPI를 통해 해결할 수 있을 것으로 보인다.

해결의 실마리를 찾은 것 같다. 아래와 같은 구조를 사용해보자.

connect(
  mapStateToProps,
  mapDispatchToProps
)(layerEnhancer(MyLayer));

layerEnhancer.js

export default ComposedComponent =>
  class extends Component {
    static displayName = `HOC:Layer[${ComposedComponent.displayName ||
      ComposedComponent.name}]`;

    static propTypes = {
      closeLayer: PropTypes.func,
      isActive: PropTypes.bool
    };

    constructor(props) {
      super(props);

      this.layer = null;
    }

    shouldComponentUpdate(nextProps) {
      const { isActive } = nextProps;

      if (isActive) {
        this.layer.show();
      } else {
        this.layer.hide();
      }

      return isActive;
    }

    componentDidMount() {
      const contentElement = findDOMNode(this.refs.contentElement);
      const parentNode = contentElement.parentNode;

      this.layer = new Layer(parentNode, { contentElement });

      this.layer.dimm.addEventListener("click", () => {
        this.props.closeLayer();
      });

      this.layer.hide();
    }

    componentWillUnmount() {
      this.layer.destroy();
    }

    render() {
      return <ComposedComponent {...this.props} ref="contentElement" />;
    }
  };

EntityLayer.js

class EntityLayer extends Component {
  static propTypes = {
    entity: PropTypes.object,
    isActive: PropTypes.bool,
    closeLayer: PropTypes.func
  };

  render() {
    return (
      <div>
        어떤 entitity 정보를 레이어 팝업으로 보여준다.
        <hr />
        {JSON.stringify(this.props.entity)}
      </div>
    );
  }
}

const mapStateToProps = state => {
  const { currentId, isActive } = state.layers.entityLayer;

  return {
    entity: state.entities[currentId],
    isActive
  };
};

export default connect(
  mapStateToProps,
  {
    closeLayer: closeEntityLayer
  }
)(
  layerEnhancer(EntityLayer) // 오타 수정 2016-06-29
);

여기서 한가지 기억해야 할 점으로 보통 react-redux의 구조에서는 connect가 최상위 wrapper가 되어야 한다. 즉, connect -> layerEnhancer -> EntityLayer 순서로 wrapping되어야 한다. layerEnhancer에서 사용하는 closeLayerisActiveconnect에서 주입 받아 활용한다.

위 처럼 HOC를 활용하는 리팩토링을 통해 수많은 레이어들의 라이프 사이클 관련 중복 코드를 제거할 수 있었으며, 각 레이어들은 모두 자신의 컨텐츠 렌더링과 layer의 open/close를 같이 담당했었는데 이젠 자신의 컨텐츠 렌더링만 담당하게 되면서 각 레이어 컴포넌트들의 역할이 더 명확해지는 효과를 얻었다.

HOC는 특별한 React Component에만 적용되는 것이 아니고, 기존의 구조를 덮어쓰거나, 제약사항을 만들지 않는다. 그리고 어렵지 않다. 이제 React를 사용하는 프로젝트에 HOC를 적극적으로 활용해보자.

추가 설명 (2016-06-29 업데이트)

closeEntityLayer는 actionCreator로 함수입니다. 단순히 store에 저장되어있는 layers.entityLayer.isActive값을 false로 바꿔줄 수 있도록 action을 반환하는 순수 함수입니다. 좀더 정확히 코드로 보여드리자면 아래와 같습니다.

function closeEntityLayer() {
  return {
    type: "CLOSE_ENTITY_LAYER"
  };
}

ActionCreator는 보통 component class 외부의 개별 파일로 따로 관리를 합니다. closeEntityLayer라는 함수는 actions/entityLayer.js 라는 파일에 아래처럼 작성하고,

export const CLOSE_ENTITY_LAYER = "CLOSE_ENTITY_LAYER";

export const closeEntityLayer = () => ({
  type: CLOSE_ENTITY_LAYER
});

containers/entityLayer.js에서 import하여 사용합니다.

import { closeEntityLayer } from "../actions/entityLayer";

그리고 action객체를 받아아서 store에 저장하는 reducer도 개별 파일로 따로 작성합니다. (대게는 reducers/entityLayer.js파일로 저장합니다.)

위 레이어의 closeEntityLayer 함수의 리턴값을 reducer에서 받아서 아래와 같이 처리합니다.

import { CLOSE_ENTITY_LAYER } from "../actions/entityLayer";

export const entityLayer = (state = {}, action) => {
  switch (action.type) {
    case CLOSE_ENTITY_LAYER:
      return {
        isActive: false
      };
    //...기타 또 다른 액션들에 대한 case
    default:
      return state;
  }
};

reducers/index.js

// 여러 가지 reducer들을 import...
import {entityLayer} from './entityLayer.js';

export default combine({
   //...store에 저장될 값들을 합친다.
   layers: combine({
      //...entity레이어를 제외한 기타 다른 레이어들의 상태
      entityLayer
   });
});
이민규2016.06.24
Back to list