리액트 HOC 집중 탐구 (2)


1부에서는 HOF(Higher Order Function)의 개념부터 시작해서 HOC(Higher Order Component)의 개념이 무엇인가와 어떤 상황에 이용될 수 있는지에 대해 알아보았다. 하지만 1부의 내용만 읽어서는 HOC를 실제 프로젝트에 어떻게 활용할 수 있는지에 대한 감이 잘 잡히지 않았을 것이다.

2부에서는 실제로 간단한 HOC를 만들어보고, 그 과정에서 발생할 수 있는 문제들과 이를 어떤 식으로 해결하는지, 그리고 지켜야할 컨벤션에 어떤 것들이 있는지에 대해 살펴보도록 하겠다.

기본 예제: window의 스크롤 추적하기

간단한 예제로 시작해보자. 먼저 윈도우의 스크롤 위치가 변경될 때마다 해당 위치를 화면에 출력하는 컴포넌트를 만들어보겠다. 이를 위해서는 컴포넌트가 mount 될 때와 unmount 될 때 windowscroll 이벤트 핸들러를 각각 등록, 해제해 주는 과정이 필요하다.

class WindowScrollTracker extends React.Component {
  state = {
    x: 0,
    y: 0
  };

  scrollHandler = () => {
    this.setState({
      x: window.pageXOffset,
      y: window.pageYOffset
    });
  };

  componentDidMount() {
    window.addEventListener("scroll", this.scrollHandler);
  }

  componentWillUnmount() {
    window.addEventListener("scroll", this.scrollHandler);
  }

  render() {
    return (
      <div>
        X: {this.state.x}, Y: {this.state.y}
      </div>
    );
  }
}

좀 번거롭긴 하지만, 그리 길지 않은 코드로 간단하게 구현할 수 있다. 하지만 만약 윈도우의 스크롤 위치가 변경될 때마다 반응해야 하는 컴포넌트가 여러 개 있다면 어떨까? 이 경우 동일한 형태의 로직이 각 컴포넌트에 중복해서 들어가게 될 것이다. 이러한 로직을 보통 횡단 관심사(Cross-Cutting Concerns)라고 하는데, HOC를 활용하면 코드 중복을 효율적으로 제거할 수 있다.

자, 그럼 위의 컴포넌트 코드를 활용해서 HOC를 만들어 보도록 하겠다. 무엇부터 해야 할까? 먼저 HOC의 정의가 무엇인지 다시 기억해보자.

const compY = HOC(compX);

HOC는 컴포넌트를 인자로 받아서 새로운 컴포넌트를 반환하는 함수이다. 즉, 가장 먼저 할 일은 함수를 만드는 일이다.

function withWindowScroll(WrappedComponent) {
  return class extends React.Component {
    // ...
  };
}

새롭게 반환되는 컴포넌트 클래스는 render 메소드를 제외하고는 처음에 만들었던 WindowScrollTracker와 동일하다. render 메소드에서는 withWindowScroll 함수에서 인자로 받은 WarppedComponent를 렌더링하면서, state로 관리하고 있는 xy 정보를 props로 내려주면 된다.

여기서 주의할 점은, x, y 외에도 WrappedComponent가 사용하는 자신만의 props가 있을 것이므로, 해당 props도 모두 전달해 주어야 한다는 점이다.

function withWindowScroll(WrappedComponent) {
  return class extends React.Component {
    // ... 나머지 코드는 WindowSizeTracker 와 동일
    render() {
      return (
        <WrappedComponent {...this.props} x={this.state.x} y={this.state.y} />
      );
    }
  };
}

자, 그럼 이 withWindowScroll HOC 함수를 이용해서 처음에 만들었던 WindowSizeTracker를 다시 정의해보자. 기존 로직에서 render 메소드만 남기고 모두 제거한 다음 HOC를 호출해서 새로운 컴포넌트를 정의하면 된다. 이제 render 메소드에서는 this.state 대신에 withWindowScroll통해 내려받은 props를 이용하면 된다.

function PositionTracker({ x, y }) {
  return (
    <div>
      X: {x}, Y: {y}
    </div>
  );
}

const WindowScrollTracker = withWindowScroll(PositionTracker);

이렇게 만들어진 HOC는 이제 윈도우의 스크롤 위치를 반영해야 하는 어떠한 컴포넌트에도 사용될 수 있다. 예를 들어, 스크롤이 최상단에 위치할 때만 "Top" 이라는 문자를 화면에 출력하는 컴포넌트가 있다면 다음과 같이 간단하게 구현할 수 있다.

function TopStatus({ y }) {
  return <div>{y === 0 && "It's on Top!!"}</div>;
}

const WindowScrollTopStatus = withWindowScroll(TopStatus);

HOC 커스터마이징 1 : 파라미터 추가하기

이제, 위의 예제를 좀 더 발전시켜보자. 만약, withWindowScroll를 사용하는 컴포넌트가 스크롤 이벤트를 throttle 시켜서 사용하고 싶다면 어떻게 해야 할까? 컴포넌트마다 원하는 wait 값이 다를 수 있으므로, 추가적인 인자를 받아야만 처리할 수 있을 것이다. 그럼 두 번째 인자를 객체 형태로 받아서, 원하는 커스텀 값을 받을 수 있도록 기존 코드를 수정해 보자.

import { throttle } from "lodash-es";

function withWindowScroll(WrappedComponent, { wait = 0 } = {}) {
  return class extends React.Component {
    // 나머지 코드는 동일

    // 코드를 단순화하기 위해, wait 값이 0인 경우에도 throttle 함수를 이용하도록 하자.
    scrollHandler = throttle(() => {
      this.setState({
        x: window.pageXOffset,
        y: window.pageYOffset
      });
    }, wait);

    render() {
      return (
        <WrappedComponent {...this.props} x={this.state.x} y={this.state.y} />
      );
    }
  };
}

이제, 다음과 같이 각 컴포넌트가 원하는 throttle 값을 넘겨서 사용할 수 있게 되었다.

const WindowScrollTracker = withWindowScroll(PositionTracker, { wait: 30 });
const WindowScrollTopStatus = withWindowScroll(TopStatus, { wait: 100 });

HOC 커스터마이징 2: props mapper

현재 withWindowScroll 함수 통해 반환되는 컴포넌트는 x, y를 props 로 주입받고 있다. 이 때 동일한 이름의 props를 주입시켜 주는 또 다른 HOC를 중첩해서 사용한다면 어떻게 될까? 예를 들어 마우스의 위치를 x, y라는 이름으로 주입시켜 주는 withMousePosition 라는 HOC를 다음과 같이 사용한다고 가정해보자.

const SinglePositionTracker = withMousePosition(
  withWindowScroll(PositionTracker)
);

이 경우, withWindowScroll을 통해 주입된 x, ywithMousePosition에서 주입되는 x, y에 의해 덮어씌워질 것이다. 이렇듯, 중첩된 HOC의 props 이름이 충돌할 수 있다는 점이 HOC의 단점으로써 종종 언급되곤 하는데, 이는 mapper 함수를 제공함으로써 간단하게 해결될 수 있다.

아마 많은 사람들에게 익숙할 react-redux 라이브러리의 connect 함수에서 사용하는 mapStateToProps을 떠올려 보면 쉽게 이해할 수 있을 것이다. connect 함수의 역할 중 하나는 스토어에 저장된 state를 컴포넌트로 주입시켜 주는 것인데, 이 때 mapStateToProps 함수를 이용해 컴포넌트가 사용하고 싶은 state만 선택할 수 있고 원하는 props의 이름도 지정할 수 있다.

const mapStateToProps = state => ({
  userName: state.user.name,
  userScore: state.user.score
});

const ConnectedComponent = connect(mapStateToProps)(MyComponent);

이렇게 하면 MyComponent에서는 전체 state 대신 userNameuserScore 라는 이름의 props 만을 전달받게 된다. 마찬가지 방식으로 HOC 함수를 호출할 때, 주입받고 싶은 props를 객체로 반환하는 mapper 함수를 전달할 수 있도록 하면 이름 충돌을 해결할 수 있게 된다. withWindowScroll, withMousePosition 함수에 이를 적용해보면 다음과 같이 사용할 수 있을 것이다.

function PositionTracker({ scrollX, scrollY, mouseX, mouseY }) {
  return (
    <div>
      ScrollX: {scrollX}, ScrollY: {scrollY}, mouseX: {mouseX}, mouseY: {mouseY}
    </div>
  );
}

const windowScrollOptions = {
  wait: 30,
  mapProps: ({ x, y }) => ({
    scrollX: x,
    scrollY: y
  })
};

const mousePositionOptions = {
  mapProps: ({ x, y }) => ({
    mouseX: x,
    mouseY: y
  })
};

const EnhancedPositionTracker = withMousePotision(
  withWindowScroll(PositionTracker, windowScrollOptions),
  mousePositionOptions
);

그럼 이제 withWindowScroll 함수도 조금 수정해서, mapProps 함수를 지원할 수 있도록 해 보자.

import { throttle, identity } from "lodash-es";

function withWindowScroll(
  WrappedComponent,
  { wait = 0, mapProps = identity } = {}
) {
  return class extends React.Component {
    render() {
      const { x, y } = this.state;
      const passingProps = mapProps({ x, y });

      return <WrappedComponent {...this.props} {...passingProps} />;
    }
  };
}

mapProps를 이용하면 연산을 통해 전혀 다른 데이터를 props로 전달할 수도 있다. 예를 들어 위에서 구현했던 TopStatus의 경우 스크롤의 Y값이 0인지의 여부만 알면 되는데, 이 경우 mapProps를 활용하면 다음과 같이 더 효율적으로 구현할 수 있다.

class TopStatus extends React.PureComponent {
  render() {
    return <div>{this.props.isScrollOnTop && "It's on Top!!"}</div>;
  }
}

const ScrollTopStatus = withWindowScroll(TopStatus, {
  wait: 30,
  mapProps: ({ y }) => ({
    isScrollOnTop: y === 0
  })
});

위의 코드에서 TopStatusPureComponent인 것을 볼 수 있을 것이다. 사실 TopStatus는 스크롤 Y값이 0인지 아닌지의 여부만 확인하면 되는데, 기존 구현에서는 스크롤 값이 변경될 때마다 항상 새로운 props를 전달받기 때문에 불필요한 렌더링이 계속해서 발생하고 있었다. 변경된 코드에서는 isScrollOnTop 값이 변경될 때에만 props로 전달받으므로, PureComponent를 이용해 불필요한 렌더링을 방지할 수 있다.

HOC 중첩 - compose

위에서 withMousePositionwithWindowScroll를 중첩해서 사용하는 경우 코드를 다시 한 번 살펴보자.

const EnhancedPositionTracker = withMousePotision(
  withWindowScroll(PositionTracker, windowScrollOptions),
  mousePositionOptions
);

함수의 호출과 각각의 인자값이 중첩되어 있어 한눈에 알아보기가 힘들 것이다. 만약 여기서 몇가지 HOC를 더 중첩해서 사용하게 된다면, 코드는 점점 알아보기 힘들게 된다.

이러한 문제를 해결하기 위해 리액트에서는 HOC를 위한 컨벤션을 제안하고 있다. 컨벤션은 react-redux의 connect 함수와 같은 형태라고 볼 수 있는데, 아래에서 볼 수 있듯이 connect 함수는 사실 HOC가 아니고 HOC를 반환하는 함수이다.

// 추가 인자가 적용된 HOC를 생성한다
const enhance = connect(
  mapStateToProps,
  mapDispatchToProps
);

// HOC는 하나의 인자(컴포넌트)만 받는다.
const EnhancedComponent = enhance(MyComponent);

즉, HOC 함수가 항상 하나의 인자(컴포넌트)만을 받을 수 있도록 하고, 이를 위한 또다른 함수를 제공하는 것이다. 이 형식에 맞게 withWindowScroll의 API를 변경하면 다음과 같을 것이다.

const windowScrollOptions = {
  wait: 30,
  mapProps: ({ y }) => ({
    isScrollOnTop: y === 0
  })
};

const enhance = withWindowScroll(windowScrollOptions);

const EnhancedComponent = enhance(MyComponent);

이렇게 모든 HOC가 하나의 동일한 인자만을 받게 되면, Component => Component 와 같은 형식이 되므로 lodash의 flow나 redux의 compose 함수 등 함수의 조합을 도와주는 라이브러리를 이용해서 좀 더 우아하게 중첩된 HOC를 처리할 수 있다. 예를 들어, 기존의 중첩 HOC 예제에서 react-redux의 connect 함수까지 더해 총 세 개의 HOC를 중첩해서 사용한다고 해 보자. 이 경우 compose 함수를 활용한다면 다음과 같이 간결하게 구현할 수 있다.

import { compose } from "redux";
import { connect } from "react-redux";

// ...

const enhance = compose(
  withMousePosition(mousePositionOptions),
  withWindowScroll(windowScrollOptions),
  connect(
    mapStateProps,
    mapDispatchToProps
  )
);

const EnhancedComponent = enhance(MyComponent);

withWindowScroll 함수에서 이를 지원하는 방법은 어렵지 않다. 기존의 컴포넌트를 반환하던 로직을 함수로 한 번 더 감싸면 된다.

function withWindowScroll({ wait = 0, mapProps = identity } = {}) {
  return function(WrappedComponent) {
    return class extends React.Component {
      // 클래스 코드는 기존과 동일
    };
  };
}

디버깅 - Display Name

마치기 전에 한 가지만 더 언급해야 할 것 같다. 어떻게 보면 HOC의 단점 중의 하나라고 볼 수 있는데, 위의 예제와 같이 코드를 작성할 경우 실제 HOC에 의해 반환되는 컴포넌트는 이름 없는 익명 컴포넌트가 된다. 이를 리액트 개발자 도구를 통해 확인해 보면 다음과 같이 보일 것이다.

devtools-before

이처럼 _class3, _class2 같은 이름으로는 디버깅 할 때 어떤 컴포넌트인지 정확한 정보를 알기가 쉽지 않다. 그래서 리액트에서는 HOC에서 반환하는 컴포넌트에 대해 displayName을 지정하도록 컨벤션을 정해놓고 있다.

위의 그림에서 react-redux의 connect 함수가 반환하는 컴포넌트는 Connect(_class3) 라는 이름을 갖고 있는 것이 보일 것이다. 이렇게 대문자로 시작하는 HOC의 이름과 괄호 안에 적용되는 컴포넌트의 displayName 을 합쳐서 표시하는 것이 컨벤션이다. withWindowScroll 함수에 적용하려면 다음과 같이 작성하면 된다.

// 컴포넌트의 displayName이 지정되어 있을 수도 있고, 아닌 경우도 있기 때문에
// 이런 헬퍼 함수가 별도로 필요하다.
function getDisplayName(WrappedComponent) {
  return WrappedComponent.displayName || WrappedComponent.name || "Component";
}

function withWindowScroll({ wait = 0, mapProps = identity } = {}) {
  return function(WrappedComponent) {
    return class extends React.Component {
      // 현재 static 필드 문법은 Stage 2 상태이다.
      // 사용할 수 없는 환경에서는 외부에서 클래스의 static 멤버를 직접 지정하면 된다.
      static displayName = `WithWindowScroll(${getDisplayName(
        WrappedComponent
      )})`;

      // 나머지 코드는 기존과 동일
    };
  };
}

뭔가 코드가 좀 더 복잡해진 것 같긴 한데, 사실 이를 지키지 않아도 상관은 없다. 하지만 추후 디버깅을 쉽게 하기 위해서는 컨벤션을 따르는 것이 도움이 될 것이다. 이렇게 수정하고 나면 개발자 도구에서 다음과 같이 보이게 된다.

devtools-after

2부를 마치며

2부에서는 간단한 HOC로 시작해서 조금씩 기능을 추가해 가면서 몇가지 기법들과 컨벤션들에 대해서 설명해 보았다. 여기서 다룬 내용만 잘 숙지하고 있으면, 실제 프로젝트에서 HOC를 활용하는 데에 크게 문제가 없을 것이다.

사실, 지금까지는 대부분 HOC를 어떻게 활용하는지와 장점에 대해서만 주로 설명했지만 HOC에는 여러가지 단점도 있다. shallow 렌더링을 중첩된 HOC를 테스트 하는 경우에도 추가적인 작업이 필요하고, WrappedComponent의 정적 멤버들을 사용하려고 할 때에도 추가작업이 필요하며, 렌더링 시에 WrappedComponent 에서 필요한 정보를 전달받아 처리하기도 쉽지 않다.

이런 이유로 인해 최근에는 Render Props에 대한 관심이 높아지고 있는데, 얼마전 리액트 공식 홈페이지에도 Render Props 관련 문서가 추가되었다. 심지어 16.3.0에서 새롭게 변경된 Context API가 Render Props 형식을 사용하면서 더 힘을 얻기도 했다.

(관심있는 분들은 이전에 위클리에서 번역했던 글 - Render Props을 이용하자 - 을 읽어보면 좋을 것 같다)

하지만, 개인적으로 Render Props는 단지 HOC보다 더 나은 개념이 아니며, 두 기법은 각각의 장단점을 갖고 있다고 생각한다. HOC를 적절히 잘 사용한다면 리액트에서의 중복 코드를 깔끔하게 제거할 수 있으며, 컴포넌트 조합을 통해 좀더 유연한 구조를 만들 수 있을 것이다.

김동우2018.03.02
Back to list