리액트 HOC 집중 탐구 (1)


리액트가 정식으로 릴리즈된 지도 벌써 4년이 훌쩍 지났다. 얼마전 나온 16.0 버전에 이르기까지 내부적으로도 많은 발전이 있었고, 수 많은 리액트 관련 라이브러리들이 나오는 등 외부적으로도 많은 발전이 있었다. 지금은 거의 대세가 되어 버린 리액트이기에 장점을 나열하자면 지루한 내용이 되겠지만, 꼭 짚고 넘어가고 싶은 장점은 4년이 넘게 지나도록 핵심 개념이나 중요 API가 거의 그대로 유지되고 있다는 것이다. 나는 이것이 리액트가 처음부터 추구했던 가치가 시간이 지나도 퇴색되지 않을만큼 의미있다는 것을 입증하고 있다고 생각한다.

비록 리액트의 핵심 개념에는 큰 변화가 있지 않았지만, 리액트를 사용하는 방식에는 꽤 많은 변화가 있었다. 특히 2015년 등장한 리덕스로 인해 단일 불변 스토어 사용, 컴포넌트와 컨테이너의 구분, Redux-Thunk / Redux-Saga 등을 이용한 비동기 처리 등의 개념은 이제 거의 관용적인 패턴으로 자리잡았다고 볼 수 있다.

그리고 사실상의 표준으로 자리잡은 또 하나의 중요한 개념이 바로 Higher Order Component(이하 HOC)를 이용한 코드 재사용 패턴이다. 기존의 믹스인을 사용한 패턴이 점차 안티 패턴으로 인식되고, 리액트나 리덕스를 만든 유명 프로그래머들의 지지에 힘입어 이제 HOC는 코드 재사용을 위한 리액트의 표준 방식으로 자리잡았다. 하지만 다른 모든 패턴들과 마찬가지로, HOC는 모든 경우를 위한 만병통치약이 아니며 잘못 사용할 경우에는 오히려 기존의 믹스인 방식보다도 더 나쁜 코드를 만들어낼 수 있다. 특히 리액트에 처음 입문하는 사람들에게 HOC는 생소하고 어색한 개념이기 때문에, HOC의 핵심 개념을 제대로 이해하지 못한 채 잘못 사용하는 경우가 많다.

나름 리액트로 1년 넘게 꽤 큰 규모의 복잡한 어플리케이션을 만들어 오면서, 어느 정도 HOC에 대해 정리해볼 만큼의 경험이 쌓인 것 같다. 아직 부족하긴 하지만 개인적인 정리를 위해서, 또 경험의 공유를 위해서 야심차게 HOC 집중 탐구 시리즈를 시작해볼까 한다. 이 글에서는 HOC가 무엇인지부터 시작해서, 사용시 주의해야 할 점, 다른 패턴들과의 차이점 등에 대해서 상세하게 설명해 보도록 하겠다.

함수형 프로그래밍

리액트에서 가장 중요한 개념 중의 하나임에도 불구하고 개발자들이 주로 간과하는 부분이 있는데, 그건 바로 리액트가 함수형 프로그래밍을 지향한다는 것이다. 리액트의 공식 홈페이지에 함수형 프로그래밍이라는 단어가 정확하게 명시되어 있지 않기 때문에, 무슨 소리냐고 반문하는 사람도 있을 것이다. 하지만 리액트의 많은 곳에 함수형 프로그래밍의 철학이 묻어 있으며, 가장 인기있는 스토어 관리 라이브러리인 리덕스 또한 순수 함수와 불변 스토어를 강조하며 함수형에 가까운 프로그래밍 스타일을 권장하고 있다.

리액트는 조단 워크(Jordan Walke)가 개발했는데, 처음 만들 때 함수형 언어인 ML기반 언어들에서 많은 영향을 받았다고 한다. 또한 조단 워크는 페이스북에서 최근에 발표한 언어인 Reason을 개발하기도 했는데, 이 역시 ML 언어인 Ocaml을 기반으로 해서 자바스크립트에 친숙한 형식으로 발전시킨 언어이다. 사실 Reason의 개발이 리액트보다 먼저 시작되었고, 그 과정에서 리액트가 개발되었다고 한다. 즉, 어떻게 보면 리액트는 함수형 언어로 프론트엔드 개발을 하기 위한 중간 과정에서 나온 자바스크립트 라이브러리라고도 할 수 있는 것이다.

여기서 어떤 부분이 함수형에 가까운지를 하나하나 설명하기는 어려울 것 같지만, HOC를 설명하기 위해 꼭 하나 짚고 넘어가야할 특징이 있다. 이 부분이 앵귤러(Angular)나 뷰(Vue)같은 다른 라이브러리들과 가장 차별화되는 부분인데, 바로 컴포넌트가 (순수)함수라는 점이다.

컴포넌트는 (순수)함수이다

리액트의 컴포넌트는 기본적으로 함수이다. 그것도 순수 함수. 좀더 자세히 이야기하자면 입력값으로 props 를 받고, ReactElement 트리를 반환하는 순수 함수이다. 리액트는 실제로 순수 함수로 컴포넌트를 생성할 수 있으며, 리액트 공식 홈페이지에서도 볼드체로 다음과 같이 명시하고 있다.

All React components must act like pure functions with respect to their props.

"그럼 React.Component 를 상속받는 클래스 컴포넌트는 뭐란 말인가" 라고 궁금해할 사람들이 있을지도 모르겠다. 물론 컴포넌트의 상태를 관리하거나 생명주기(Life Cycle)에 훅(Hook)을 걸어 원하는 시점에 특정 함수를 실행하려면 순수 함수만으로는 구현이 어려울 것이다. 그렇기 때문에 처음 리액트가 나왔을 때는 createClass 함수를 이용하여 컴포넌트를 생성하도록 API가 설계되었다.

하지만 엄밀히 말하면 컴포넌트의 상태 관리는 함수의 클로저를 사용해서 관리할 수 있고 (이 경우 순수 함수는 아니다), 생명주기 관련 함수들은 맵(Map)의 형태로 묶거나, 컴포넌트 함수의 프라퍼티 형태로 관리할 수 있다. 즉, 컴포넌트가 함수라는 기본 개념을 유지한 채로도 얼마든지 어플리케이션을 만들 수 있는 것이다. 다만, 기존 객제지향 개발자들에게 함수형 프로그래밍은 익숙하지 않기 때문에 좀더 익숙한 클래스 형태의 API를 제공한 것이다. 클래스 컴포넌트의 경우에도 render 메소드 자체를 컴포넌트라고 여긴다면 컴포넌트가 함수라는 개념은 여전히 유효하다.

실제로 리액트의 컴포넌트는 다른 객체지향 방식의 컴포넌트와는 다르게 동작한다. 객체간의 직접 참조나 메소드 호출을 통해서 서로간의 메시지를 주고받는 것이 아니며, 데이터의 흐름은 함수 호출처럼 오직 부모(호출하는 함수)에서 자식(호출되는 함수)을 향해 단방향으로 진행된다. 즉, 복잡한 ReactElement 트리를 구성하기 위해 컴포넌트 내부에서 다른 컴포넌트를 함수처럼 호출해서 결과값(ReactElement)를 받은 후 조합해서 반환할 뿐인 것이다.

사실, 기존에 사용되던 믹스인에 대한 비판들은 단순히 리액트에서의 문제만은 아니다. 믹스인을 통한 코드 재사용 방식에 대한 문제점은 리액트를 굳이 언급하지 않더라도 기존 객체 지향 방식에서도 이미 존재하고 있었다. 물론 객체 지향으로도 이를 해결할 수 있는 방법이 없는 건 아니지만, 애초에 함수형 방식을 지향하는 라이브러리가 기존 객체 지향 방식의 문제까지 같이 끌어안고 해결하려는 것은 굳이 먼 길을 돌아가는 일일 것이다. 각자에게 맞는 옷이 있듯이, 함수형의 세계에서는 그 세계에 맞는 해결 방식이 있는 것이다.

Higher Order Function

사실 함수형 프로그래밍에 익숙한 사람들은 HOC라는 이름을 보았을 때 어떤 역할을 하는지를 바로 유추할 수 있었을 것이다. 함수형 프로그래밍에서는 Higher Order Function(이하 HOF)라는 아주 유사한 개념이 있기 때문이다. 이름에서 알 수 있듯이 HOC라는 이름은 바로 HOF에서 유래한 것이다. 그러니 HOC에 대해 알아보기 앞서, 먼저 HOF에 대해 알아보는 것이 도움이 될 것이다.

HOF는 함수를 인자로 받아서 새로운 함수를 반환하는 함수이다. 한 문장에 '함수'라는 단어가 세 번이나 나와서 난해하게 느껴질 수도 있는데, 다음과 같이 코드로 표현해보면 이해가 좀더 쉬울 것이다.

const fy = HOF(fx);

즉, fx를 인자로 받아서 fy를 반환하는 함수라고 볼 수 있다. 사실 자바나 C#처럼 함수가 일급객체가 아닌 언어에서는 이해하기 힘든 개념일 수도 있지만, 자바스크립트와 같이 함수가 일급객체인 언어에서는 나름 익숙한 패턴이다. Lodash와 같은 함수형 라이브러리에 익숙하다면 많이 사용해 보았을 _.throttle, _.debounce, _.partial, _.flip, _.once 등의 함수들도 모두 HOF이다. 예를 들어 _.partial 함수는 다음과 같이 기존 함수의 인자를 고정시킨 새로운 함수를 반환해준다.

const add = (v1, v2) => v1 + v2;

const add3 = _.partial(add, 3);
const add5 = _.partial(add, 5);

console.log(add3(10)); // 13
console.log(add5(10)); // 15

HOF의 장점은 함수에 기능을 추가하는 코드를 재사용 할 수 있다는 것이다. 만약 add 함수를 이용해서 partial 함수가 없이 add3add5 함수를 만드려면 다음과 같이 직접 두 개의 함수를 만들어야 할 것이다.

const add3 = v => add(v + 3);
const add5 = v => add(v + 5);

하지만 HOF를 이용하면 기능 단위로 새로운 함수를 만들어내는 코드를 재사용할 수 있게 된다. 위의 예제는 너무 간단해서 체감이 잘 안될 수도 있지만, 좀더 복잡한 형태의 HOF인 경우 많은 양의 중복 코드를 제거할 수 있을 것이다.

이해를 돕기 위해 위의 _.partial 함수를 직접 구현해 보도록 하자. 구현을 쉽게 하기 위해 인자 2개를 받는 함수의 첫번째 인자만 고정하도록 제한하면, 다음과 같이 간단하게 구현할 수 있다. 단순한 형태의 경우 화살표 함수를 사용하는 것이 더 직관적이므로, 화살표 함수를 사용해서 구현해 보았다.

const partial = (f, v1) => v2 => f(v1, v2);

const add3 = partial(add, 3);
const add5 = partial(add, 5);

console.log(add3(10)); // 13
console.log(add5(10)); // 15

위의 _.partial 예제와 동일하게 동작하는 것을 볼 수 있을 것이다.

다음으로 넘어가기 전에 좀더 유용한 HOF를 만들어보자. 앞서 말했듯이 함수의 클로저를 이용하면 내부 상태를 갖는 함수를 만들 수 있다. 간단한 예로 함수의 반환값을 0부터 계속 누적시켜서 적용된 값을 반환하도록 하는 HOF를 만들어 보자. 복잡한 형태에서는 화살표 함수에 익숙하지 않으면 더 알아보기가 어려울 수도 있으므로 여기서부터는 function 키워드를 사용하도록 하겠다.

function acc(f) {
  let v = 0;
  return function() {
    v = f(v);
    return v;
  };
}

const acc3 = acc(add3);

console.log(acc3()); // 3
console.log(acc3()); // 6
console.log(acc3()); // 9

HOF를 이용하면 부수효과를 만들어낼 수도 있다. 간단한 예제로, 결과값을 반환하기 전에 console.log 콘솔에 로그를 출력하도록 하는 HOF를 만들어보자. 이번에도 구현을 간단하게 하기 위해 인자 하나를 받는 함수만 이용하도록 제한하도록 하겠다.

function logger(f) {
  return function(v) {
    const result = f(v);
    console.log(result);
    return result;
  };
}

const add3Log = logger(add3);

console.log(add3Log(10)); // 13, 13
console.log(add3Log(15)); // 18, 18

실행 결과 로그가 두 번씩 찍히는 것을 볼 수 있다. 즉, 내부적으로 로그를 남긴 후 결과값도 제대로 반환해준다는 것을 확인할 수 있다.

지금껏 3개의 HOF를 직접 만들었다. 이제 이들 HOF를 한 번에 조합해서 새로운 함수를 만들어보자. partial, acc, logger 를 모두 사용하면 다음과 같이 0부터 시작해서 3씩 더한 결과값을 누적시키면서 로그를 남기는 함수를 만들 수 있을 것이다.

const acc3Log = logger(acc(partial(add, 3)));

acc3Log(); // 3
acc3Log(); // 6
acc3Log(); // 9

함수형 프로그래밍은 기본적으로 작은 단위의 범용적인 함수를 만들고 이들을 조합해 가면서 프로그램을 만들어나가는 방식을 취한다. HOF는 이러한 기능 단위의 함수들을 조합해서 재사용할 수 있는 패턴을 제공함으로써 함수형 프로그래밍에서 아주 중요한 역할을 하고 있다.

Higher Order Component

앞서 잠깐 언급했듯이, HOC는 바로 HOF에서 유래한 단어이다. 즉, 컴포넌트를 인자로 받아서 컴포넌트를 반환하는 함수를 뜻한다. 위에서 언급한 HOF의 정의와 동일하게 표현하자면, 다음과 같이 표현할 수 있을 것이다.

const compY = HOC(compX);

사실 HOC라는 이름에는 약간의 허점이 있다. HOF가 함수를 인자로 받아 함수를 반환하는 함수(문장에 함수가 세 번 들어가고 있음에 주목)라면 HOC는 컴포넌트를 인자로 받아 컴포넌트를 반환하는 컴포넌트인 것이 자연스러울 것이다. 하지만 HOC는 사실 컴포넌트가 아닌 함수를 지칭한다. 이 이름에 대한 비판의 목소리도 있지만, HOF와 유사한 개념이라는 것이 더 강조되는 효과도 있으므로, 관대하게 넘어가도록 하자.

그럼 이 HOC를 어떤 식으로 사용할 수 있을까? 이왕 HOF에서 시작했으니 앞의 예제를 그대로 컴포넌트에 적용해 보도록 하자. 먼저 partial 함수처럼, Props를 고정한 형태의 컴포넌트를 반환하게 할 수 있을 것 같다. 한번 만들어보자.

function withProps(Comp, props) {
  return function(ownProps) {
    return <Comp {...props} {...ownProps} />;
  };
}

partial 의 예제와 다른 점이 몇가지 있는데, 먼저 컴포넌트는 열거된 인자가 아닌 props라는 객체를 입력값으로 받는다는 점이다. 즉, 특정 props를 고정하고 싶다면 해당 Props를 객체로 받은 다음, 반환되는 컴포넌트의 Props에 합쳐주는 식으로 구현해야 한다. 또하나의 다른점은 컴포넌트는 단순히 값을 반환하는 것이 아닌 ReactElement 를 반환한다는 점이다. 그러므로 반환되는 컴포넌트는 인자로 넘어온 컴포넌트를 ReactElement화 시켜서(JSX 이용) 반환해 주어야 한다.

이제, 이 withProps HOC를 사용하는 예제를 살펴보자.

function Hello(props) {
  return (
    <div>
      Hello, {props.name}. I am {props.myName}
    </div>
  );
}

const HelloJohn = withProps(Hello, { name: "John" });
const HelloMary = withProps(Hello, { name: "Mary" });

const App = () => (
  <div>
    <HelloJohn myName="Kim" />
    <HelloMary myName="Lee" />
  </div>
);

Hello 컴포넌트는 props로 namemyName 을 입력받는다. withProps 를 이용해 name 을 고정한 형태의 컴포넌트인 HelloJohn 과 HelloMary 컴포넌트를 만들어내면, 이들 컴포넌트는 myName 만 넘겨주어도 미리 고정된 name 값을 이용할 수 있게 된다.

한가지만 하면 아쉬우니, 위의 logger 도 HOC에 맞게 다시 구현해보자. 컴포넌트는 결과값을 로그로 남기는 것에 큰 의미가 없으니, 넘겨받은 props를 콘솔에 출력하도록 하겠다. withProps 를 만들때와 마찬가지로 Props와 반환값에만 유의하면 된다. logger는 굳이 props를 받을 필요가 없으니, 좀더 간단하게 구현할 수 있다.

function logger(Comp) {
  return function(props) {
    console.log(props);
    return <Comp {...props} />;
  };
}

HOF와 마찬가지로 HOC도 여러개의 HOC를 동시에 조합해서 하나의 컴포넌트를 만들어낼 수 있다. 자, 그럼 이제 위의 두 HOC를 조합해서 사용해보자.

const HelloJohn = withProps(logger(Hello), { name: "John" });
const HelloMary = withProps(logger(Hello), { name: "Mary" });

이제, HelloJohnHelloMary 는 렌더링을 할때마다 현재 넘겨받은 props를 콘솔에 출력하게 된다. 위의 App 컴포넌트를 렌더링해 보면 각 컴포넌트의 namemyName props가 모두 콘솔에 출력되는 것을 확인할 수 있을 것이다.

HOC로 할 수 있는 것들

지금껏 HOC의 개념을 익히기 위해 간단한 HOC들을 작성해 보았는데, 이런 단순한 형태의 HOC만 가지고는 그리 유용하다는 느낌이 들지 않을 것이다. 하지만 HOC로 할 수 있는 일들은 이보다 훨씬 다양하며, 기존의 믹스인 방식에서 할 수 있던 거의 모든 것들을 할 수 있다. 가장 많이 쓰이는 형태가 아마 스토어와 컴포넌트를 연결시켜 주는 HOC일 것이다. 일단 가장 널리 쓰이는 react-redux의 connect 함수 이를 위한 함수인데, 엄밀히 HOC를 생성해주는 헬퍼 함수라고 할 수 있다. connect 함수는 스토어의 상태를 Props으로 주입시켜주는 mapStateToProps 와 액션 생성 함수를 스토어의 dispatch와 연결시켜 props로 주입시켜 주는 mapDispatchToProps 를 인자로 받아서 새로운 HOC를 반환한다.

import { connect } from "react-redux";
import { PersonComponent } from "./person";

const mapStateToProps = state => ({
  name: state.name,
  age: state.age
});

const mapDispatchToProps = {
  setName: name => {
    type: "SET_NAME", name;
  },
  setAge: age => {
    type: "SET_AGE", age;
  }
};

// HOC 생성
const connectHOC = connect(
  mapStateToProps,
  mapDispatchToProps
);

// HOC가 적용된 컴포넌트 생성
const ConnectedComponent = connectHOC(PersonComponent);

이 외에도 몇가지 HOC로 할 수 있는 중요한 기능들을 나열하면 다음과 같다.

  • 생명주기 메소드 주입
  • State 및 이벤트 핸들러 주입
  • Props 변환 및 주입
  • Render 함수 확장

생명주기 메소드나 State를 다루기 위해서는 함수 컴포넌트가 아닌 클래스 컴포넌트를 이용해야 하는데, 다음 글에서 이들을 이용해 좀더 유용한 HOC를 만들어보도록 하겠다. 만약 함수 컴포넌트만으로 위의 기능들을 구현하고 싶은 분들이 있다면 Recompose 라이브러리를 확인해보기 바란다. Recompose는 자칭 리액트 컴포넌트를 위한 Lodash로서, 범용적으로 쓰일 수 있는 HOC의 모음이라고 할 수 있다. Recompose의 withStatelifecycle 함수를 사용하면 함수 컴포넌트 만으로도 State나 생명주기 함수들을 주입할 수 있다. 또한 수많은 유용한 HOC 헬퍼를 제공하고 있기 때문에, API를 한번 훑어보기만 해도 HOC가 어떻게 사용될 수 있는지 파악하는 데에 큰 도움이 될 것이다.

1부 정리

이상으로 HOC에 대한 기본 개념과 간단한 사용법에 대해 살펴보았다. 보통은 기존 믹스인으로 구현한 코드를 어떻게 HOC 변경하는지를 보여주면서 설명을 많이 하는데, 이 글에서는 일부러 함수형 프로그래밍에 대한 설명부터 시작해서 HOF를 거쳐 HOC까지 개념을 확장해 보았다. 개인적으로 리액트가 함수형 프로그래밍을 지향한다는 사실을 계속 염두에 두고 있는 것이 좀더 자연스러운 코드를 작성하는 데에 도움이 될 것이라 생각하기 때문이다. 어쩌다보니 HOF에 대한 설명이 HOC보다 길어진 느낌이지만, HOC에 대한 내용은 2부에서 좀더 상세히 다룰테니, 양해를 바란다.

2부에서는 좀더 유용한 HOC를 만들어보면서 실제 HOC가 사용되는 패턴들과 사용시 주의해야 할 점들에 대해 좀더 자세하게 알아보도록 하겠다.

김동우2017.11.17
Back to list