React HOC 패턴

2017년 01월 30일


이번 강좌에서는 HOC 패턴과 recompose 라이브러리에 대해 알아봅니다.

HOC

HOC는 Higher Order Component의 약자로, 직역하면 고차 컴포넌트정도 됩니다.

함수형 프로그래밍에서 사용하는 고차함수의 개념을 React Component에 적용한 패턴이라 할 수 있습니다.

HOC의 목적은 크게 세 가지입니다.

  • 로직을 외부에서 추가
  • 로직을 분리해서 코드를 간결하게 만듦
  • 분리된 기능을 재사용

개념

HOC는 인자로 Component를 받아서 Component를 반환하는 함수입니다.

함수를 통해서 props를 조작하거나, lifecycle에 접근하는 등 다양한 동작을 추가합니다.

기존에 Component에 묶여있던 기능을 함수를 통해 추가할 수 있도록 재구성하고, 이 과정에서 분리된 함수를 재사용합니다.

구현

구현은 의외로 간단합니다. 함수로 컴포넌트를 감싸주기만 하면 됩니다.

감싸주는 과정에서 컴포넌트에 기능을 추가하는 것이 핵심입니다.

아래 예시는 아무 기능도 추가하지 않는 identity HOC와 메시지를 추가하는 addMsg HOC입니다.

const identity = (Component) => (props) => (
  <Component {...props} />
);

const addMsg = (Component) => (props) => (
  <Component {...props} msg=":P" />
);

const A = (props) => (<p>{props.msg}</p>);
const B = identity(A);
const C = addMsg(A);

<A/>     // <p><p>
<B/>     // <p><p>
<C/>     // <p>:P<p>

실제 사용 예시

HOC는 컴포넌트를 직접 변경할 필요 없이 외부에서 기능을 추가 할 수 있습니다.

때문에 컴포넌트에 기능을 추가하는 라이브러리에서는 이미 유용하게 사용하고 있습니다.

대표적인 예시로 react-redux가 있습니다. 여기서 사용하는 connect가 HOC입니다.

recompose

reompose는 자주 사용하는 HOC를 정리한 라이브러리입니다. 개발을 하면서 필요한 HOC는 대부분 recompose에서 제공합니다.

이번 강좌에서는 recompose를 통해 HOC를 실습해봅니다.

실습

우선 리액트 프로젝트를 준비한 뒤, recompose 라이브러리를 추가합니다.

개발서버 준비가 완료되면 다음 단계로 넘어가주세요.

create-react-app hoc-tutorial

cd hoc-tutorial
yarn add recompose
yarn start

props 다루기

HOC는 다양한 작업을 할 수 있습니다. 그 중 가장 빈번한 것이 바로 props를 다루는 작업입니다.

mapProps

mapProps는 컴포넌트에 전달되는 props를 조작할 수 있습니다. 다음과 같이 props에 msg를 추가합니다.

src/App.js

import React, { Component } from 'react';
import { mapProps }         from 'recompose';

const enhance = mapProps((props) => Object.assign({}, props, { msg: "Hello HOC" }));

class App extends Component {
  render() {
    return (
      <div>
        <p>{this.props.msg}</p>
      </div>
    );
  }
}

export default enhance(App);

recompose 없이 구현하면 다음과 같습니다.

src/App.js

import React, { Component } from 'react';

const mapProps = (xf) => (Component) => (props) => (
  <Component {...xf(props)}/>
);

const enhance = mapProps((props) => Object.assign({}, props, { msg: "Hello HOC" }));

class App extends Component {
  render() {
    return (
      <div>
        <p>{this.props.msg}</p>
      </div>
    );
  }
}

export default enhance(App);
props는 virtual DOM의 diff를 계산할 때 사용되기 때문에 직접 값을 변경해서는 안됩니다.
mapProps의 예시에서 볼 수 있는 것 처럼, 기존의 props를 변경하지 않고 새로운 props를 만들어서 사용합니다.

flattenProp

개발을 하다보면 props.data.msg처럼 데이터에 접근하기 위해 여러번의 참조가 필요한 경우가 있습니다. 매번 참조해서 접근할 수 있지만 props.msg처럼 평탄화 하면 좋을 것 같습니다.

src/Welcome.js

import React from 'react';

const Welcome = (props) => (<p>{props.data.msg}</p>);

export default Welcome;

src/App.js

import React, { Component } from 'react';

import Welcome from './Welcome';

class App extends Component {
  render() {
    const data = { msg: "Welcome!" };
    return (
      <div>
        <Welcome data={data}/>
      </div>
    );
  }
}

export default App;

위 경우는 사실 App에서 props를 전달할 때 수정할 수 있습니다. 하지만 외부 라이브러리에서 데이터를 바인딩 하는 것 처럼 직접 수정할 수 없는 경우도 많습니다.

이러한 경우에 HOC를 이용해서 데이터를 보정할 수 있습니다. 데이터 평탄화는 mapProps로 구현할 수 있지만, 자주 사용하는 기능이기 때문에 recompose에서는 flattenProp 함수를 제공합니다.

평탄화 하고 싶은 필드를 문자열로 전달합니다.

src/Welcome.js

import React           from 'react';
import { flattenProp } from 'recompose';

const enhance = flattenProp('data');
const Welcome = (props) => (<p>{props.msg}</p>);

export default enhance(Welcome);

최적화

recompose를 통해 React Component를 최적화 할 수 있습니다. 내부적으론 기존의 React에서 제공하는 기능을 그대로 사용하지만, 좀 더 간결하고 관리하기 편한 코드를 만들 수 있습니다.

pure

컴포넌트는 props가 갱신되면 항상 렌더링이 일어납니다. props의 실제 값이 바뀌지 않아도 props가 새로 전달되기만 하면 갱신이 일어납니다.

src/Counter.js

import React from 'react';

const Counter = (props) => {
  console.log(`Counter가 ${props.count}로 렌더링됩니다`);
  return (<p>::{props.count}::</p>);
};

export default Counter;

src/App.js

import React, { Component } from 'react';

import Counter from './Counter';

class App extends Component {

  state = { count: 0 };

  componentDidMount() {
    function* g() {
      while (true)
        yield* [1, 1, 2, 3, 4, 4, 5, 6, 7];
    }

    const iter   = g();
    const update = () => this.setState({ count: iter.next().value });
    setInterval(update, 1000);
  }

  render() {
    return (
      <div>
        <p>{this.props.msg}</p>
        <Counter count={this.state.count}/>
      </div>
    );
  }
}

export default App;

위 코드를 입력하고 개발도구 콘솔을 살펴보면 다음과 같이 출력됩니다.

Counter가 0로 렌더링됩니다
Counter가 1로 렌더링됩니다
Counter가 1로 렌더링됩니다
Counter가 2로 렌더링됩니다
Counter가 3로 렌더링됩니다
Counter가 4로 렌더링됩니다
Counter가 4로 렌더링됩니다
Counter가 5로 렌더링됩니다
Counter가 6로 렌더링됩니다
Counter가 7로 렌더링됩니다
Counter가 1로 렌더링됩니다
Counter가 1로 렌더링됩니다

별 문제 없어보이지만 1과 4에서 렌더링이 두 번 일어나고 있습니다. 값의 변경이 없을때는 렌더링을 다시 할 필요가 없습니다.

이럴때 사용할 수 있는 것이 PureComponent입니다. 클래스로 컴포넌트를 만들때 Component가 아닌 PureComponent를 상속합니다.

PureComponent로 만든 컴포넌트는 새로운 props가 전달되면 바로 렌더링하지 않고, 기존의 props와 비교해본 뒤 값의 변경이 있을때만 렌더링합니다.

src/Counter.js

import React from 'react';

class Counter extends React.PureComponent {
  render() {
    console.log(`Counter가 ${this.props.count}로 렌더링됩니다`);
    return (<p>::{this.props.count}::</p>);
  }
}

export default Counter;

코드를 갱신하고 개발도구 콘솔을 살펴보면 1과 4가 한 번씩만 출력되는 것을 확인할 수 있습니다.

PureComponent를 이용하면 실제 값이 변경된 경우에만 렌더링이 일어납니다.

Counter가 0로 렌더링됩니다
Counter가 1로 렌더링됩니다
Counter가 2로 렌더링됩니다
Counter가 3로 렌더링됩니다
Counter가 4로 렌더링됩니다
Counter가 5로 렌더링됩니다
Counter가 6로 렌더링됩니다
Counter가 7로 렌더링됩니다
Counter가 1로 렌더링됩니다

최적화는 했지만 Functional Component를 Class Component로 변경해야 했고, 부가적으로 props를 통해 접근했던 데이터를 this.props를 통해 접근하도록 수정해야 했습니다.

실제 렌더링 로직은 바뀐 것이 없지만, 최적화 로직을 추가하기 위해 렌더링 코드까지 다시짜야했습니다.

이럴때 대안으로 사용할 수 있는 것이 pure HOC입니다.

src/Counter.js

import React    from 'react';
import { pure } from 'recompose';

const Counter = (props) => {
  console.log(`Counter가 ${props.count}로 렌더링됩니다`);
  return (<p>::{props.count}::</p>);
};

export default pure(Counter);

위 코드를 적용한 뒤 개발도구 콘솔을 살펴보면 최적화가 적용돼있습니다.

Counter가 0로 렌더링됩니다
Counter가 1로 렌더링됩니다
Counter가 2로 렌더링됩니다
Counter가 3로 렌더링됩니다
Counter가 4로 렌더링됩니다
Counter가 5로 렌더링됩니다
Counter가 6로 렌더링됩니다
Counter가 7로 렌더링됩니다
Counter가 1로 렌더링됩니다

shouldUpdate

좀 더 정교하게 props 값의 변화에 의존하는 최적화가 필요한 경우가 있습니다. 예를들어 전달되는 props의 일부만 렌더링에 사용한다면, 렌더링에 사용하지 않는 props가 갱신됐다 해서 다시 렌더링을 할 필요가 없습니다.

다음과 같이 코드를 변경하면, 렌더링에 사용되는 데이터는 count뿐이지만, loading의 값이 변하기 때문에 count가 변하지 않아도 렌더링이 일어납니다.

import React, { Component } from 'react';

import Counter from './Counter';

class App extends Component {

  state = { 
    loading: true, 
    count: 0
  };

  componentDidMount() {
    function* g() {
      while (true)
        yield* [
          { loading: true,  count: 0 },
          { loading: false, count: 1 },
          { loading: true,  count: 1 },
          { loading: false, count: 2 }
        ];
    }

    const iter   = g();
    const update = () => this.setState(iter.next().value);
    setInterval(update, 1000);
  }

  render() {
    return (
      <div>
        <Counter {...this.state}/>
      </div>
    );
  }
}

export default App;

위 코드를 적용하고 개발도구 콘솔을 살펴보면 다음과 같이 최적화가 깨집니다.

Counter가 0로 렌더링됩니다
Counter가 1로 렌더링됩니다
Counter가 1로 렌더링됩니다
Counter가 2로 렌더링됩니다
Counter가 0로 렌더링됩니다
Counter가 1로 렌더링됩니다
Counter가 1로 렌더링됩니다

이는 근본적으로 loading을 전달하지 않는 방법으로 해결할 수 있지만, 여기서는 React의 shouldComponentUpdate를 사용합니다.

shouldComponentUpdate는 Class Component의 메서드입니다. shouldComponentUpdate는 새로운 props를 받아 렌더링을 다시할지 결정하는 불리언을 반환합니다.

Counter를 다음과 같이 갱신하면 문제를 해결할 수 있습니다.

src/Counter.js

import React from 'react';

class Counter extends React.Component {
  shouldComponentUpdate(nextProps) {
    return this.props.count !== nextProps.count;
  }

  render() {
    console.log(`Counter가 ${this.props.count}로 렌더링됩니다`);
    return (<p>::{this.props.count}::</p>);
  }
}

export default Counter;

이제 개발도구 콘솔을 살펴보면 최적화가 적용된 걸 확인할 수 있습니다.

Counter가 0로 렌더링됩니다
Counter가 1로 렌더링됩니다
Counter가 2로 렌더링됩니다
Counter가 0로 렌더링됩니다
Counter가 1로 렌더링됩니다
Counter가 2로 렌더링됩니다

이 방법은 PureComponent와 마찬가지로 클래스 하나에 렌더링 로직과 최적화 로직이 함께 있고 최적화를 적용하기 위해 코드의 많은 부분을 수정해야했습니다.

비슷한 문제를 pure HOC로 해결했던 것 처럼, shouldUpdate HOC를 이용해 해결할 수 있습니다.

import React from 'react';
import { shouldUpdate } from 'recompose';

const enhance = shouldUpdate((props, nextProps) => props.count !== nextProps.count);

const Counter = (props) => {
  console.log(`Counter가 ${props.count}로 렌더링됩니다`);
  return (<p>::{props.count}::</p>);
};

export default enhance(Counter);

이런식으로 컴포넌트에 추가적인 기능이 필요할때 기존의 코드를 수정하지 않고 HOC를 이용할 수 있습니다. 최적화 로직은 대부분 렌더링 로직과 관련이 없기 때문에 분리하기 쉽습니다.

분기

좀 더 나아가 로딩을 처리해보겠습니다. Counter에 전달되는 loading를 이용해서 참일 경우 로딩화면을 보여줍니다.

이제 loading의 값도 렌더링에 영향을 주기 때문에 최적화 로직은 제거합니다. 코드를 적용하면 값이 바뀌는 사이사이 로딩화면이 렌더링됩니다.

src/Counter.js

import React from 'react';

const Counter = (props) => {
  if (props.loading) {
    return (<p>LOADING</p>);
  } else {
    console.log(`Counter가 ${props.count}로 렌더링됩니다`);
    return (<p>::{props.count}::</p>);
  }
};

export default Counter;

지금은 Counter에서만 loading을 사용하고 있지만, 다른 컴포넌트에서도 비슷한 로직으로 로딩을 처리할 수 있습니다.

이 로직을 분리해서 재사용할 수 있게 만들면 좋을 것 같습니다. branch와 renderComponent를 이용하면 로직을 간단하게 분리할 수 있습니다.

renderComponent

renderComponent는 글로 설명하기보단 코드를 보는게 이해가 쉽습니다. 컴포넌트를 받아 HOC를 반환하는데, 이 HOC는 처음 만들어질 때 사용된 컴포넌트를 렌더링하도록 인자로 받은 컴포넌트를 조작합니다.

import React from 'react';
import { renderComponent } from 'recompose';

const Hello = (props) => (<p>Hello</p>);
const World = (props) => (<p>World</p>);

const intercept = renderComponent(Hello);
const GuessWhat = intercept(World);
<GuessWhat/> // <p>Hello</p>

이렇게 renderComponent는 렌더링 될 컴포넌트를 가로채 다른 컴포넌트를 렌더링합니다.

branch

branch는 조건을 검사해서 HOC를 적용하는 함수입니다. 다음 3개의 인자를 순서대로 받습니다.

  • props의 조건을 검사하는 함수
  • 조건이 참일 시 컴포넌트에 적용할 HOC
  • 조건이 거짓일 시 컴포넌트에 적용할 HOC(생략시 HOC를 적용하지 않습니다)

branch와 renderComponent를 이용하면 로딩처리를 분리할 수 있습니다. props.loading 통해 분기하고, renderComponent를 통해 로딩화면을 보여줍니다.

src/Counter.js

import React from 'react';
import { branch, renderComponent } from 'recompose';

const Loading = (props) => (<p>LOADING</p>);

const withLoading = branch(
  (props) => props.loading,
  renderComponent(Loading)
);

const Counter = (props) => {
  console.log(`Counter가 ${props.count}로 렌더링됩니다`);
  return (<p>::{props.count}::</p>);
};

export default withLoading(Counter);

로딩처리를 withLoading HOC로 분리했습니다. 로딩처리는 빈번하기 때문에 HOC를 파일로 추려내 여러곳에서 재사용하기 좋습니다.

합성

데이터 로딩을 시뮬레이션 해봤으니, 이제 데이터 전송에 실패한 경우도 시뮬레이션 해봅니다. 로딩과 마찬가지로 전송이 실패한 경우에는 다른 화면을 보여줍니다.

src/App.js

import React, { Component } from 'react';

import Counter from './Counter';

class App extends Component {

  state = { 
    loading: true,
    fail: false,
    count: 0
  };

  componentDidMount() {
    function* g() {
      while (true)
        yield* [
          { loading: true,  fail: false, count: 0 },
          { loading: false, fail: false, count: 1 },
          { loading: true,  fail: false, count: 1 },
          { loading: false, fail: false, count: 2 },
          { loading: true,  fail: false, count: 2 },
          { loading: false, fail: true,  count: 2 }
        ];
    }

    const iter   = g();
    const update = () => this.setState(iter.next().value);
    setInterval(update, 1000);
  }

  render() {
    return (
      <div>
        <Counter {...this.state}/>
      </div>
    );
  }
}

export default App;

로딩과 마찬가지로 HOC를 만들어서 분기합니다.

src/Counter.js

import React from 'react';
import { branch, renderComponent } from 'recompose';

const Loading = (props) => (<p>LOADING</p>);

const withLoading = branch(
  (props) => props.loading,
  renderComponent(Loading)
);

const Fail = (props) => (<p>ERROR!</p>);

const withFail = branch(
  (props) => props.fail,
  renderComponent(Fail)
);

const Counter = (props) => {
  console.log(`Counter가 ${props.count}로 렌더링됩니다`);
  return (<p>::{props.count}::</p>);
};

export default withFail(withLoading(Counter));

여러개의 HOC를 적용하기 위해선 위 코드와 같이 HOC 호출을 중첩해야합니다. 이런 경우 compose 함수를 이용해 여러 HOC를 하나의 HOC로 합성할 수 있습니다.

compose를 통해 함수를 합성하면 가장 오른쪽에 있는 HOC가 먼저 적용되고 점점 왼쪽에 있는 HOC가 적용됩니다.

src/Counter.js

import React from 'react';
import { branch, compose, renderComponent } from 'recompose';

const Loading = (props) => (<p>LOADING</p>);

const withLoading = branch(
  (props) => props.loading,
  renderComponent(Loading)
);

const Fail = (props) => (<p>ERROR!</p>);

const withFail = branch(
  (props) => props.fail,
  renderComponent(Fail)
);

const Counter = (props) => {
  console.log(`Counter가 ${props.count}로 렌더링됩니다`);
  return (<p>::{props.count}::</p>);
};

export default compose(withFail, withLoading)(Counter);

위 코드는 withLoading이 적용된 뒤 withFail이 적용됩니다.

지금은 두 HOC가 별개의 역할을 하기 때문에 순서가 중요하지 않지만, 한 HOC에서 props를 변경하고, 다른 HOC에서 변경된 props로 작업을 하는 것 처럼 순서가 중요한 경우가 있습니다.

이런 경우 compose의 동작이 헷갈리는데, 오른쪽에 있는 HOC가 먼저 적용되지만 왼쪽에 있는 HOC가 먼저 props에 영향을 끼칩니다.

import { compose, mapProps } from 'recompose';

const a = mapProps((props) => ({ msg: 'a' }));
const b = mapProps((props) => ({ msg: 'b' }));

const C = (props) => (<p>{props.msg}</p>);
const D = compose(a, b)(C); // a(b(C))

<D/> // <p>b</p>

이런 경우 D가

b

로 렌더링됩니다. C가 D로 변하는 과정을 살펴보면 다음과 같습니다.

초기값 >> props를 받아 컴포넌트 C를 렌더링
b 적용 >> props를 받아 props.msg를 b로 바꾼 뒤 컴포넌트 C를 렌더링
a 적용 >> props를 받아 props.msg를 a로 바꾸고 props.msg를 b로 바꾼 뒤 컴포넌트 C를 렌더링

결과적으로 compose를 이용하면 HOC는 오른쪽 부터 적용되고, HOC에 의한 props의 변경은 왼쪽부터 일어납니다.

코드관리

HOC로 로직을 분리하다보면 자연스럽게 컴포넌트의 기능이 여러파일로 나눠집니다. 이 나눠진 파일을 관리하는 방법에 대해서 알아봅니다.

네이밍

HOC는 보통 enhance 혹은 withXXX로 이름짓습니다. 가장 많이 사용하는 네이밍은 withXXX입니다.

예를들어 react-router 라이브러리는 주소와 history에 대한 HOC로 withRouter를 제공합니다.

HOC 관리

분리된 HOC중 자주 사용하는 것은 따로 디렉터리를 만들어 관리하는게 좋습니다. 유용하게 사용할 수 있습니다.

├── components/
└── hoc/
    ├── withLoading.js
    └── withFail.js

Component-Container 패턴

Component-Container 패턴은 HOC와는 별개로 Component에 데이터를 바인딩 하는 코드를 Container로 분리해서 관리하는 방법입니다.

이는 Dumb Component(Component)와 Smart Component(Container)를 분리해야 한다는 아이디어에서 나왔습니다.

react-redux처럼 컴포넌트에 데이터 바인딩을 하는 상황에 사용할 수 있습니다. Component와 Container를 분리하기 때문에 데이터 바인딩이 일어나는 코드가 무엇인지 명확합니다.

├── components/
│   └── A.js
└── containers/
    └── A.js

components/A.js

import React from 'react';

const A = (props) => (<p>{props.msg}</p>);

export default A;

containers/A.js

import React from 'react';
import { flattenProp, withProps } from 'recompose';

import A from 'components/A';

const enhance = withProps(() => ({ msg: 'Hello Recompose' }))

export default enhance(A);

카트리지 패턴

카트리지 패턴은 제가 지은 이름입니다. 독자적으로 만들긴 했는데 아마 누가 이미 만들어서 잘 쓰고 있을겁니다.

이 방식은 하나의 컴포넌트에 관련된 디렉터리(카트리지)를 만들고, 렌더링을 담당할 Component와 그 밖에 로직을 담당하는 HOC로 분리해서 관리합니다.

└── Cartridge/
    ├── Component.js
    ├── enhance.js
    └── index.js

Cartridge/Component.js

import React from 'react';

const A = (props) => (<p>{props.msg}</p>);

export default A;

Cartridge/enhance.js

const enhance = withProps(() => ({ msg: 'Hello Recompose' }))

export default enhance;

Cartridge/index.js

import Component from './Component';
import enhance   from './enhance';

export default enhance(Component);

Component-Container 패턴은 렌더링과 데이터 바인딩을 분리하는 개념이지만, 카트리지 패턴은 렌더링과 그 밖에 모든 기능을 분리하는 개념이기 때문에 좀 더 일반적으로 사용할 수 있습니다.

카트리지 패턴은 데이터의 바인딩이 추상화 되어있을때 유용합니다. Component-Container 패턴에 비해서 데이터 바인딩이 일어나는 곳이 명시적이지 않기 때문에 데이터의 흐름과 바인딩의 구현이 자주 바뀌는 상황이라면 적합하지 않습니다.

이 방법은 여러가지 로직을 분리하면서도 하나의 컴포넌트에 관련된 기능은 하나의 디렉터리에서 관리할 수 있다는 것과, 새로운 기능을 추가하거나 기존의 기능을 변경할때 간편하다는 장점이 있습니다.

마무리

이번 강좌를 잘 이해하셨으면 이전에 비해서 세련된 코드를 작성할 수 있으실겁니다. 로직이 깔끔하게 분리된 코드는 훨씬 읽기편하고 관리도 쉽습니다. 나중에 React로 개발하실 일이 있으시면 꼭 HOC를 적용해보시는걸 추천합니다.

이 글에서 살펴본 recompose는 빙산에 일각에 지나지 않습니다. recompose 문서를 살펴보시면 state나 lifecycle에 대한 HOC 등 여러가지 기능을 찾아보실 수 있습니다.

또, recompose도 HOC에 비하면 아주 작은 부분이고, HOC도 함수의 응용에 비하면 작은 부분입니다.

결과적으로 이번 강좌에서 알아본 HOC는 함수형 프로그래밍을 React Component 특화되게 사용한 것에 지나지 않습니다. 이번 기회에 함수형 프로그래밍에 관심을 가져주셨으면 하는 작은 바람이 있습니다.