배경
🧩
개발 •  • 읽는데 10분 소요

React의 고차 컴포넌트(HOC) 패턴, 이제는 Vue에도 적용해보자

고차 컴포넌트(Higher-order component) 패턴을 이용하면, 믹스인(Mixin)과는 또 다른 방법으로 재사용되는 코드를 여러 컴포넌트 간에 공유할 수 있습니다.

#Vue
#React
#Front-End


개비스콘 믹스인과 HOC은 여러 컴포넌트 간 공유되는 로직을 관리하는 방법들입니다.

Vue에서 컴포넌트 간에 공유되는 코드를 재사용할 수 있는 방법을 얘기해보라면, 가장 대표적으로 믹스인(Mixin)을 이야기 할 수 있을 것 같습니다. 공식 홈페이지에서도 믹스인을 첫 번째 방법으로 이야기 할 만큼 대표적인 예시죠.

믹스인을 사용하는 법은 간단합니다. 믹스인 객체를 Vue의 인스턴스 속성과 동일하게 작성하고, 원하는 곳에서 임포트(import)해서 사용하면 되죠.

사실 제가 여태까지 알고 있었던 컴포넌트 간 코드 공유 방법은 믹스인이 전부였습니다. 하지만 새 회사에서 새로운 프로젝트를 접하면서, 고차 컴포넌트(HOC, Higher-order component) 패턴이라는 것을 알게 되었습니다. (HOC는 어떤 라이브러리나 NPM 패키지 같은 것들이 아니랍니다!)

사실 HOC는 React에서 파생된 패턴입니다. 그 근거를 React의 HOC 문서에서 쉽게 확인할 수 있습니다.

고차 컴포넌트(HOC, higher-order component)는 컴포넌트 로직을 재사용하기 위한 React의 고급 기술입니다. HOC는 컴포넌트를 취하여 새로운 컴포넌트를 반환하는 함수입니다.

그리고 조금만 스크롤을 내려보면 HOC가 등장하게 된 배경 역시 확인할 수 있었는데, 이 내용이 흥미로웠습니다.

이전에는 크로스 커팅 문제를 제어하기 위해 믹스인 사용을 권장했습니다. 하지만 믹스인의 사용으로 얻는 이점보다 더 많은 문제를 일으킨다는 것을 깨달았습니다.

믹스인이 문제를 일으킨다니, 그 내용이 궁금하시지 않나요? 그래서 이번 포스트에서는 믹스인의 사용으로 생길 수 있는 문제점과, HOC 패턴의 원리, 그리고 Vue에서는 이를 어떻게 적용할 수 있는지에 대해 알아보도록 하겠습니다.

우선 믹스인을 써보자

우선 믹스인에 대해 이야기 하기 전에 믹스인이라는 용어가 낯선 분들도 계실 것 같습니다. 위키피디아에서는 아래와 같이 믹스인을 설명하고 있습니다.

믹스인은 객체지향 프로그래밍 언어에서 다른 클래스의 부모일 필요 없이 다른 클래스에서 사용될 수 있는 메소드들이 정의된 클래스이다.

상속 관계에서 자식 컴포넌트가 부모 컴포넌트의 멤버 변수나 메소드를 가져다 쓸 수도 있다는 것은 아마 다들 아실 겁니다. 이 때 믹스인은 상속 관계에서 부모의 역할을 하는 클래스라고 설명할 수 있겠네요. 우리는 여러 컴포넌트에서 공유되는 코드를 미리 믹스인에 정의해놓고, 상속받는 클래스에서 이를 가져다 쓰는 방법을 통해 불필요한 반복을 줄일 수 있었습니다.

아래는 Vue에서 믹스인을 사용하는 예시입니다.

// mixin 객체 생성
var myMixin = {
  data() {
    return {
      name: 'Mixin',
    };
  },
  created() {
    this.hello();
  },
  methods: {
    hello() {
      console.log('hello from mixin!');
    },
  },
};

export default myMixin;

위의 예시를 보시면, Vue 인스턴스의 속성을 그대로 가진 객체를 생성하는 것으로 만들 수 있습니다.

// Component
import { myMixin } from "./mixin";

export default {
  mixins: [myMixin];
}

그리고는 믹스인을 사용할 .vue 파일의 <script> 태그에서 이를 임포트 하고, mixins 속성에 다음과 같은 믹스인들을 사용하겠다라고 명시적으로 선언합니다. 그러면 믹스인의 속성들을 그대로 컴포넌트에서 사용할 수 있게 되죠.

믹스인이 왜 문제가 되는가

믹스인 자체는 유용한 기능입니다. 하지만 개발자들은 프로젝트의 크기가 커질수록, 믹스인이 의도하지 않은 문제를 유발할 수 있다는 것을 깨달았습니다.

믹스인을 불러오는 컴포넌트에서는 믹스인에 미리 정의된 속성들을 사용할 수 있습니다. 이 때문에 믹스인과 컴포넌트 사이에는 종속성이 생기게 되죠. 즉, 컴포넌트 내에서 명시적으로 선언되지 않은 속성을 참조할 수 있게 되는 것입니다.

// Component
import { myMixin } from "./mixin";

export default {
  mixins: [myMixin];

  created: function() {
    // `this.name`은 이 컴포넌트에서 선언되어 있지는 않지만,
    // 믹스인에서는 선언되었을 수도 있습니다.
    console.log(this.name);
  }
}

잠시 위의 코드를 살펴볼까요. this.name은 위의 컴포넌트에서는 선언되지 않은 변수입니다. 하지만 myMixin이라는 믹스인에서는 this.name을 선언했을 수도 있죠. 저 this.name이라는 변수가 올바르게 선언된 변수인지 확인하려면 해당 믹스인과 연관된 파일들을 직접 열어서 확인할 수 밖에 없습니다.

위처럼 간단한 구조라면… 뭐 까짓거 파일 하나 열어보고 말죠. 하지만 믹스인이 또 다른 믹스인을 참조하고 있는 구조라면 어떨까요? 아니면 컴포넌트 내에서 함수를 하나 정의했는데, 알고보니 믹스인에서도 동명의 함수가 정의되어 사용되고 있었다면? 그래서 함수를 고치려고 보니 이미 수많은 컴포넌트가 해당 믹스인을 참조하고 있어서 섣불리 손을 댈 수가 없다면?

이러한 문제점이 발생하는 원인은 컴포넌트와 달리 믹스인은 별다른 계층 관계를 갖고 있지 않기 때문입니다. 이를 임포트하는 모든 컴포넌트에서 속성들이 혼합되어 사용되죠. 결국 프로젝트가 커질수록, 복잡성은 감당할 수 없을 정도로 늘어나게 될겁니다.

HOF와 HOC

그럼 HOC 패턴은 어떤 방법을 통해 공통된 로직을 관리하고 있을까요?

사실 HOC는 함수를 인자로 받아 함수를 리턴하는 함수고차함수(HOF, higher-order function)로부터 온 것입니다. HOC라는 이름 역시 여기에서부터 유래된 것이죠. 마찬가지로, HOC는 컴포넌트를 인수로 받아 컴포넌트를 리턴하는 함수를 의미합니다.

우선 JavaScript로 구현한 HOF를 통해 그 원리를 알아보도록 합시다.

function addAndLog(x, y) {
  var result = x + y;
  console.log('result:', result);
  return result;
}

function multiplyAndLog(x, y) {
  var result = x * y;
  console.log('result:', result);
  return result;
}

위의 예시에서 addAndLog, multiplyAndLog는 각각 두 개의 인자 x, y를 더한 후 출력, 곱한 후 출력하는 함수입니다. 두 함수에서 공통되는 부분을 찾을 수 있나요? 네, 바로 결과를 출력하는 console.log 부분입니다.

이 부분을 HOF로 고쳐보도록 하죠. 우선 HOF 역할을 할 함수에는 공통으로 사용되는 로직을 넣고, 파라미터로는 개별로 사용되는 로직을 넣어야 합니다.

function withLogging(wrappedFunction) {
  // 이 함수는 x, y를 인자로 가지는 익명함수를 리턴합니다.
  return function (x, y) {
    // 그리고 익명함수에서는 인수로 전달한 wrappedFunction을 실행시키죠.
    var result = wrappedFunction(x, y);
    // 그리고 결과를 로깅합니다.
    console.log('result:', result);
    return result;
  };
}

withLogging 함수는 실행 시 내부에 있는 익명함수를 리턴합니다. 이 익명함수에는 파라미터로 전달한 wrappedFunction의 값을 인식해서 로깅하는 로직이 들어있죠.

function add(x, y) {
  return x + y;
}

function multiply(x, y) {
  return x * y;
}

function withLogging(wrappedFunction) {
  return function (x, y) {
    var result = wrappedFunction(x, y);
    console.log('result:', result);
    return result;
  };
}

// `addAndLog(1,2)`를 실행시키면 `result:3`이 출력됩니다!
var addAndLog = withLogging(add);

// `multiplyAndLog(3,4)`를 실행시키면 `result:12`가 출력됩니다!
var multiplyAndLog = withLogging(multiply);

어떤가요? 코드는 좀 길어지긴 했지만 확장성에 있어서는 훨씬 유연한 코드가 되었습니다.

HOC를 Vue에 적용하기

사실 이 패턴은 컴포넌트에도 동일하게 적용됩니다. 함수 대신 컴포넌트가 자리를 대신하고 있다는 차이 정도죠. 그럼 Vue를 이용해 하나씩 내용을 구현해 보도록 합시다.

<!-- App.vue -->
<template>
  <div id="app">
    <Post />
    <Comment />
  </div>
</template>

<script>
  import Post from './components/Post';
  import Comment from './components/Comment';

  export default {
    name: 'app',
    components: {
      Post,
      Comment,
    },
  };
</script>

위의 AppPostComment라는 두 개의 컴포넌트를 렌더링하고 있습니다.

<!-- Post.vue -->
<template>
  <div>{{ data }}</div>
</template>

<script>
  import axios from 'axios';

  export default {
    data() {
      return {
        data: null,
      };
    },

    async created() {
      const response = await axios.get(
        'https://jsonplaceholder.typicode.com/posts/1'
      );
      this.data = response.data;
    },
  };
</script>

Post 컴포넌트는 위와 같이 생겼고,

<!-- Comment.vue -->
<template>
  <div>{{ data }}</div>
</template>

<script>
  import axios from 'axios';

  export default {
    data() {
      return {
        data: null,
      };
    },

    async created() {
      const response = await axios.get(
        'https://jsonplaceholder.typicode.com/comments?postId=1'
      );
      this.data = response.data;
    },
  };
</script>

Comment 컴포넌트는 위와 같이 생겼습니다. axios로 데이터를 받아와서 보여주는 아주 간단한 코드입니다. 두 개의 컴포넌트는 axios로 받아오는 데이터의 URL만 다를 뿐 나머지 코드는 똑같은 것을 확인할 수 있습니다. 이렇게 반복되는 코드를 이제 HOC를 이용해 공통 로직으로 뽑아봅시다.

// withRequest.js
import Vue from 'vue';

const withRequest = (component) => {
  return Vue.component('withRequest', {
    render(createElement) {
      return createElement(component)
    }
  }
}

이제 HOC 역할을 할 파일을 만들었습니다. HOC의 이름은 with라는 접두어와 함께 prop의 이름을 붙이는 것이 관례라고 합니다. 이런 네이밍을 통해 HOC가 사용되는 컴포넌트에서 해당 prop의 출처를 명확하게 추적할 수 있습니다.

지금 만든 컴포넌트에는 아직 기능이 구현되어 있지는 않지만, 외부의 리퀘스트를 받아오는 기능을 붙일 거니까 withRequest.js라고 하죠.

현재 withRequest의 동작 원리는 다음과 같습니다.

  • component를 인수로 받고 Vue.component를 리턴하는 함수인데,
  • Vue.component로 리턴되는 새 컴포넌트의 이름은 "withRequest"이고,
  • 새 컴포넌트의 렌더 함수에는 인수로 받은 component가 그대로 렌더링된다

그럼 withRequest를 이용해, App.vue 파일에서 사용한 컴포넌트를 감싸봅시다.

<!-- App.vue -->
<template>
  <div id="app">
    <Post />
    <Comment />
  </div>
</template>

<script>
  import Post from './components/Post';
  import Comment from './components/Comment';
  import { withRequest } from './hocs/withRequest';

  export default {
    name: 'app',
    components: {
      Post: withRequest(Post),
      Comment: withRequest(Comment),
    },
  };
</script>

위처럼 렌더링되는 컴포넌트를 HOC로 감싸고 난 후, 개발자 도구로 검사를 해보니 아래와 같은 결과가 나왔습니다.

계층

각 컴포넌트가 WithRequest라는 컴포넌트 안에 감싸져 들어가있는 것을 확인할 수 있습니다.

PostComment 컴포넌트에서 공통되는 로직은 axios로 리퀘스트를 요청하는 부분입니다. 이렇게 중복되는 로직을 withRequest 컴포넌트 안에 만들어 보도록 하겠습니다.

export const withRequest = (url) => (component) => {
  return Vue.component('withRequest', {
    data() {
      return {
        fetchedData: null,
      };
    },
    async created() {
      const response = await axios.get(url);
      this.fetchedData = response.data;
    },
    render(createElement) {
      return createElement(component, {
        props: {
          data: this.fetchedData,
        },
      });
    },
  });
};

withRequest은 파라미터로 urlcomponent를 받도록 만들었습니다. 함수의 인자를 (url, component)로 써도 무방하긴 한데, (url) => (component) => {...} 이런 식으로 함수에서 함수를 리턴하게 만드는 이유는 이 방법이 HOC를 연쇄적으로 여러 개 이어서 사용할 때 확장성 측면에서 좀 더 낫기 때문입니다.

Vue.component 내부에는 공통된 로직인 created() 메소드를 구현하고, 이 값을 component에 넘겨주기 위해 임시로 fetchedData라는 변수를 만들었습니다. 그리고 render 함수에서 컴포넌트에 propsdata: fetchedData를 다시 전달하죠.

<!-- Post.vue, Comment.vue -->
<template>
  <div>{{ data }}</div>
</template>

<script>
  export default {
    props: ['data'],
  };
</script>

외부에서 데이터를 가져오는 로직이 HOC 쪽으로 빠졌기 때문에, PostComment 컴포넌트는 내용이 엄청 홀쭉해졌습니다.

// App.vue
const postUrl = 'https://jsonplaceholder.typicode.com/posts/1';
const commentUrl = 'https://jsonplaceholder.typicode.com/comments?postId=1';

export default {
  name: 'app',
  components: {
    Post: withRequest(postUrl)(Post),
    Comment: withRequest(commentUrl)(Comment),
  },
};

withRequest를 사용하는 쪽에서는 urlcomponent를 파라미터로 전달해 주는 방법을 씁니다.

props$emit 전달하기

<!-- App.vue -->
<template>
  <div id="app">
    <Post :id="1" />
    <Comment @clicked="alert" />
  </div>
</template>

그럼 withRequest로 감싸진 컴포넌트들은 propsemit 처리를 어떻게 해야 할까요?

우선 Post 컴포넌트에 :id="1"이라는 prop을 전달해 봅시다.

// Post.vue
export default {
  props: ['id', 'data'],
};

우선은 Post 컴포넌트 내에서 받을 propsid를 명시해줍니다.

// withRequest.js
export const withRequest = (url) => (component) => {
  return Vue.component('withRequest', {
    props: [...component.props],
    render(createElement) {
      return createElement(component, {
        props: {
          ...this.$props,
        },
      });
    },
  });
};

그리고 HOC를 위와 같이 바꾸면 Post 컴포넌트에서 props로 받겠다고 한 변수들이 들어가게 되는데, 그 원리는 아래와 같습니다.

  • 파라미터로 받은 componentpropswithRequest가 대신 받고, spread 연산자(...)로 이를 복사합니다.
  • withRequest가 받은 props를 다시 render 메소드의 데이터 객체로 넣습니다.

마찬가지로 emit 역시 비슷한 원리로 동작하게 만들 수 있습니다. 위의 예시에서 언급한 Comment 컴포넌트를 작동하게 하려면 아래와 같이 설정하면 됩니다.

// withRequest.js
export const withRequest = (url) => (component) => {
  return Vue.component('withRequest', {
    render(createElement) {
      return createElement(component, {
        on: {
          ...this.$listeners,
        },
      });
    },
  });
};

이 외에도 다른 속성들인 $slot, $attr 역시 전달할 수 있습니다. 자세한 방법은 데이터 객체에 관한 Vue의 공식 문서를 참조하세요.

마무리

이번 포스트를 작성해야겠다고 마음을 먹게 된 계기는 Vue에서 HOC 패턴을 적용하는 것과 관련된 한글 자료가 충분하지 않았기 때문이었습니다. HOC 패턴이 React 진영에서 건너온 것이라 그런지 아직 Vue에서는 활성화가 되지 않은 모양입니다.

자료 조사를 하면서 React HOC와 관련된 글을 많이 읽어보게 되었습니다. 특히 믹스인의 암묵적인 의존성으로 인한 불편함에 대해서는 저도 공감을 많이 했습니다. 제 개인적인 생각이지만 믹스인은 아무 컴포넌트에서나 가져다 쓸 수 있는 느낌인데 반해, HOC는 작성된 로직에 따라서 컴포넌트를 분배해주는 느낌 을 받았습니다. 처음에 등장했던 개비스콘 짤을 그렇게 만든 이유가 이러한 이유 때문입니다. 컴포넌트를 리턴하는 함수라는 점에서 팩토리 패턴 도 떠올랐구요.

이 외에도 HOC는 함수라는 특성 덕분에 할 수 있는 기능들이 무지하게 많다는 것도 깨달았습니다. 가령 Vue.component를 리턴하기 전에 로직을 붙여서, 컴포넌트가 렌더링 되기 전에 사용자 정의의 전처리를 할 수 있는 것처럼 말이죠. 이 외에도 유용한 사용법들이 많을 것 같습니다.

하지만 HOC가 믹스인의 단점을 해결할 수 있는 무조건적인 해답은 아닙니다. HOC도 중첩의 단계가 깊어질수록 데브툴에서 알아보기가 힘들다는 문제가 있죠. HOC는 컴포넌트 간에 공유되는 코드 재사용 방법을 좀 더 HOF스럽게 풀어내는 하나의 방법일 뿐이죠. 이렇듯 완벽한 관리를 위한 해답은 아직까지도 풀지 못 한 숙제로 남아있답니다.

지금까지 HOC의 등장 배경과 함께 Vue에서 사용하는 방법에 대해 알아보았습니다. 저 역시도 이번 주제에 대해서 많이 아는 편이 아니어서 글을 쓰는 데 많은 시간이 걸렸습니다. 혹시라도 이해하기 어렵거나 틀린 부분이 있다면 댓글로 남겨주시면 감사하겠습니다.

이 포스트가 유익하셨다면?




프로필 사진

👨‍💻 정종윤

글 쓰는 것을 좋아하는 프론트엔드 개발자입니다. 온라인에서는 재그지그라는 닉네임으로 활동하고 있습니다.


Copyright © 2024, All right reserved.

Built with Gatsby