[Design Patterns] HOC 패턴

·2024년 2월 15일
0

patterns

목록 보기
10/11
post-thumbnail

부트캠프에서 refresh token 확인을 위해 고차 컴포넌트를 사용했던 기억이 있다. 그 땐 HOC, 고차 컴포넌트란 용어 자체로 인해 낯설고 어려웠던 기억이...

HOC 패턴

종종 여러 컴포넌트에서 같은 로직을 사용해야 하는 경우가 있다. 이런 로직은 컴포넌트의 스타일 시트를 설정하는 것 일 수도 있고, 권한을 요청하거나 전역 상태를 추가하는 것 일 수도 있다.

같은 로직을 여러 컴포넌트에서 재사용하는 방법 중 하나로 고차 컴포넌트 패턴을 활용하는 방법이 있다. 이 패턴은 앱 전반적으로 재사용 가능한 로직을 여러 컴포넌트들이 쓸 수 있게 해준다.
고차 컴포넌트란 다른 컴포넌트를 받는 컴포넌트를 뜻한다. HOC는 인자로 넘긴 컴포넌트에게 추가되길 원하는 로직을 가지고 있다. HOC는 로직이 적용된 엘리먼트를 반환하게 된다.

여러 컴포넌트에게 동일한 스타일을 적용하고 싶다고 가정하자.
로컬 스코프에 style 객체를 직접 만드는 대신, HOC가 style 객체를 만들어 컴포넌트에게 전달하도록 한다.

function withStyles(Component) {
  return props => {
    const style = { padding: '0.2rem', margin: '1rem' }
    return <Component style={style} {...props} />
  }
}

const Button = () => <button>Click me!</button>
const Text = () => <p>Hello World!</p>

const StyledButton = withStyles(Button)
const StyledText = withStyles(Text)

Button 컴포넌트와 Text 컴포넌트를 수정한 StyledButton과 StyledText 컴포넌트를 만들었다. 두 컴포넌트 모두 withStyles HOC로부터 스타일링 로직이 적용되었다.

이전 Container/Presentational 패턴에서 강아지 이미지 예제를 살펴보자.
이 예제는 강아지 사진 목록을 API로부터 받아와 렌더링하고 있다.

// index.js

import { render } from "react-dom";
import DogImages from "./DogImages";
import "./styles.css";

function App() {
  return (
    <div className="App">
      <h1>
        Browse Dog Images{" "}
        <span role="img" aria-label="emoji">
          🐕
        </span>
      </h1>
      <DogImages />
    </div>
  );
}

render(<App />, document.getElementById("root"));
// DogImages.js

import useDogImages from "./useDogImages";

export default function DogImages() {
  const dogs = useDogImages();

  return dogs.map((dog, i) => <img src={dog} key={i} alt="Dog" />);
}

위 예제에서 사용자 경험을 개선해보자. 데이터를 받아오는 중에 "Loading..."이라는 메세지를 화면에 보여주고 싶다. DogImages에 직접 기능을 구현하는 대신 고차 컴포넌트 패턴을 활용할 것이다.

withLoader라는 HOC를 만들어보자. HOC는 컴포넌트를 인자로 받아 컴포넌트를 반환해야 한다.
아래 예제에서는 데이터 로딩이 끝나고나서 보여져야 할 엘리먼트를 받는다.

일단 최소로 동작하는 버전의 widhLoader를 구현해보자.

// withLoader.js

function withLoader(Element) {
  return props => <Element />
}

위 예시는 단순히 인자로 전달된 Element를 그대로 반환하고 있는데, 데이터가 불려지는 중인지 여부를 Element에 넘겨야 한다.

withLoader를 재사용 가능하게 하기 위해 강아지 사진 API를 하드코딩 하지 않고 withLoader의 인자로 전하도록 한다. 이 로더 함수에서는 API 응답을 받기 전까진 Loading... 메세지를 출력하게 될 것이다.

// withLoader.js

import { useEffect, useState } from "react";

export default function withLoader(Element, url) {
  return (props) => {
    const [data, setData] = useState(null);

    useEffect(() => {
      async function getData() {
        const res = await fetch(url);
        const data = await res.json();
        setData(data);
      }

      getData();
    }, []);

    if (!data) {
      return <div>Loading...</div>;
    }

    return <Element {...props} data={data} />;
  };
}
  1. withLoaderuseEffect 훅에서 url로 API를 호출하여 데이터를 받아오고 있다.
    응답이 오기 전까지 반환되는 엘리먼트는 Loading... 텍스트를 출력하고 있다.
  2. 데이터를 받아오고 나면 data 상태를 초기화하게 되므로 인자로 전달되었던 컴포넌트가 화면에 노출된다.

DogImages.js에서 더 이상 DogImages 컴포넌트를 직접 export할 필요 없이, withLoader HOC로 감싸진 DogImages 컴포넌트를 export하면 된다.
또한 withLoader HOC는 인자로 데이터를 요청할 url도 받고 있으므로 두 번째 인자에 url을 전달해주면 된다.

// DogImages.js

export default withLoader(
  DogImages,
  'https://dog.ceo/api/breed/labrador/images/random/6'
)

고차 컴포넌트 패턴은 동일 로직을 여러 컴포넌트들에 제공할 수 있게 해준다.
withLoader HOC는 컴포넌트와 url에서 받아오는 데이터에 대해서는 관여하지 않는다. 컴포넌트가 유효하고 API 엔드포인트도 정상인 경우 단순히 API 호출을 통해 받아온 데이터를 넘길 뿐이다.

Composing

여러 고차 컴포넌트를 조합할 수도 있다.
위의 예제에서 DogImages 컴포넌트에 마우스를 올리면 "Hovering!" 이라는 텍스트 박스가 나타나도록 하고 싶다고 해보자.
hovering이라는 prop을 제공하는 HOC를 만들어야 한다.
DogImages에서 이 prop을 기준으로 텍스트 노출 여부를 결정하면 된다.

// DogImages.js

import withLoader from "./withLoader";
import withHover from "./withHover";

function DogImages(props) {
  return (
    <div {...props}>
      {props.hovering && <div id="hover">Hovering!</div>}
      <div id="list">
        {props.data.message.map((dog, index) => (
          <img src={dog} alt="Dog" key={index} />
        ))}
      </div>
    </div>
  );
}

export default withHover(
  withLoader(DogImages, "https://dog.ceo/api/breed/labrador/images/random/6")
);
// withHover.js

import { useState } from "react";

export default function withHover(Element) {
  return props => {
    const [hovering, setHover] = useState(false);

    return (
      <Element
        {...props}
        hovering={hovering}
        onMouseEnter={() => setHover(true)}
        onMouseLeave={() => setHover(false)}
      />
    );
  };
}

DogImages 엘리먼트는 이제 withHoverwithLoader에서 제공하는 prop을 사용할 수 있다. 따라서 "Hovering!"이라는 텍스트는 이 값을 기준으로 노출하면 된다.

Hooks

몇몇 상황에서는 HOC 패턴을 React의 훅으로 대체할 수 있다.
위에서 구현한 withHover HOC를 useHover 훅으로 리팩토링 해보자.
고차 컴포넌트를 사용하는 대신 엘리먼트에 mouseOver, mouseLeave 이벤트 핸들러를 추가할 것이다. 또 HOC처럼 엘리먼트를 반환할 수 없으니 ref를 반환하여 이벤트 핸들러를 추가할 엘리먼트를 지정할 수 있도록 한다.

// useHover.js

import { useState, useRef, useEffect } from "react";

export default function useHover() {
  const [hovering, setHover] = useState(false);
  const ref = useRef(null);

  const handleMouseOver = () => setHover(true);
  const handleMouseOut = () => setHover(false);

  useEffect(() => {
    const node = ref.current;
    if (node) {
      node.addEventListener("mouseover", handleMouseOver);
      node.addEventListener("mouseout", handleMouseOut);

      return () => {
        node.removeEventListener("mouseover", handleMouseOver);
        node.removeEventListener("mouseout", handleMouseOut);
      };
    }
  }, [ref.current]);

  return [ref, hovering];
}

useEffect 훅에서 컴포넌트에 이벤트 핸들러를 추가하고 hovering 상태의 값을 상황에 맞게 초기화한다. refhovering 모두 훅에서 반환되어야 한다. 그래야 마우스가 올라갔는지 여부를 알고 싶은 엘리먼트에 이벤트를 추가할 수 있고, hovering 값을 참고하여 텍스트를 노출할 수 있기 때문이다.

DogImages 컴포넌트를 감싸는 대신 useHover 훅을 직접 사용하여 기능을 구현할 수 있다.

// DogImages.js

import withLoader from "./withLoader";
import useHover from "./useHover";

function DogImages(props) {
  const [hoverRef, hovering] = useHover();

  return (
    <div ref={hoverRef} {...props}>
      {hovering && <div id="hover">Hovering!</div>}
      <div id="list">
        {props.data.message.map((dog, index) => (
          <img src={dog} alt="Dog" key={index} />
        ))}
      </div>
    </div>
  );
}

export default withLoader(
  DogImages,
  "https://dog.ceo/api/breed/labrador/images/random/6"
);

일반적으로 React의 훅은 HOC 패턴을 완전 대체할 수 없다.
React 문서에서는 "대부분의 경우에서 React의 훅은 트리가 깊어지는 상황을 줄일 수 있다."라고 이야기하고 있다.
HOC 패턴을 사용하면 컴포넌트의 트리가 깊어지는 경향이 있다.

<withAuth>
  <withLayout>
    <withLogging>
  		<Component />
  	</withLogging>
  </withLayout>
</withAuth>

컴포넌트 내에서 훅을 직접 사용하여 더 이상 컴포넌트를 래핑하지 않아도 된다.

고차 컴포넌트를 활용하면 동일한 로직을 한 군데 구현하여 여러 컴포넌트에 제공할 수 있다.
훅은 컴포넌트의 내부에서 특정한 동작을 추가할 수 있게 해주지만, HOC에 비해 버그를 발생시킬 확률을 증가시킨다.

사용사례

HOC

  • 앱 전반적으로 동일하며 커스터마이징 불가능한 동작이 여러 컴포넌트에 필요한 경우
  • 컴포넌트가 커스텀 로직 추가 없이 단독으로 동작할 수 있어야 하는 경우

Hooks

  • 공통 기능이 각 컴포넌트에서 쓰이기 전에 커스터마이징이 되어야 하는 경우
  • 공통 기능이 앱 전반적으로 쓰이는 것이 아닌 하나 혹은 몇 개의 컴포넌트에서 요구되는 경우
  • 해당 기능이 기능을 쓰는 컴포넌트에게 여러 프로퍼티를 전달해야 하는 경우

사례 분석

몇몇 라이브러리들은 기본적으로 HOC 패턴에 의존하며 릴리즈 후에 훅이 지원되었다. Apollo Client가 적합한 예시이다.

Apollo Client를 사용하기 위한 방법 중 하나는 graphql() 이라는 HOC를 사용하는 것이다.

import React from "react";
import "./styles.css";

import { graphql } from "react-apollo";
import { ADD_MESSAGE } from "./resolvers";

class Input extends React.Component {
  constructor() {
    super();
    this.state = { message: "" };
  }

  handleChange = (e) => {
    this.setState({ message: e.target.value });
  };

  handleClick = () => {
    this.props.mutate({ variables: { message: this.state.message } });
  };

  render() {
    return (
      <div className="input-row">
        <input
          onChange={this.handleChange}
          type="text"
          placeholder="Type something..."
        />
        <button onClick={this.handleClick}>Add</button>
      </div>
    );
  }
}

export default graphql(ADD_MESSAGE)(Input);

graphql() HOC를 사용하면 고차 컴포넌트로 감싸는 것으로 컴포넌트에서 사용 가능한 데이터를 만들어낼 수 있다. 지금도 이 라이브러리를 사용할 수 있지만 몇 가지 단점도 존재한다.

컴포넌트가 여러 리졸버에 접근해야 하는 경우 graphql() HOC를 여러 번 중첩해 사용해야 한다. 여러 HOC를 겹쳐 사용하는 코드는 데이터가 어떻게 전달되는지 파악하기 어렵다. 또 HOC를 사용하는 순서가 중요해지면 리팩토링 과정에 버그를 만들어내기 쉽다.

React에 훅이 추가된 이후 Apollo도 훅을 자체적으로 지원하기 시작했다. graphql() HOC를 사용하는 대신 개발자는 훅을 사용하여 직접 데이터에 접근할 수 있게 되었다.

아래 예제는 graphql() HOC를 사용하던 이전 예제와 동일하지만, useMutation 훅을 사용하여 데이터에 접근하고 있다.

import { useState } from "react";
import "./styles.css";

import { useMutation } from "@apollo/react-hooks";
import { ADD_MESSAGE } from "./resolvers";

export default function Input() {
  const [message, setMessage] = useState("");
  const [addMessage] = useMutation(ADD_MESSAGE, {
    variables: { message }
  });

  return (
    <div className="input-row">
      <input
        onChange={(e) => setMessage(e.target.value)}
        type="text"
        placeholder="Type something..."
      />
      <button onClick={addMessage}>Add</button>
    </div>
  );
}

useMutation 훅을 활용하여 컴포넌트가 데이터에 접근하기 위해 필요한 코드들을 일부 줄일 수 있다.
또한 여러 리졸버를 사용할 때 HOC를 조합해야 했던 것과 달리 여러 훅을 사용하면 되는 구조이므로 불필요한 코드들을 많이 줄일 수 있게 되었다.
컴포넌트가 데이터를 어떻게 받아오는지 조금 더 쉽게 파악할 수 있게 되었으며, 개발자가 컴포넌트를 리팩토링할 때 더 수월하게 여러 조각으로 나눌 수 있게 되었다.

장점, 단점

장점

고차 컴포넌트를 사용하면 한 곳에 구현한 로직들을 여러 컴포넌트에서 재사용할 수 있다.
동일 구현을 여러 군데에 직접 구현하며 버그를 만들어낼 확률을 줄일 수 있다.
로직을 한 곳에서 관리하여 코드를 DRY하면서 관심사의 분리도 적용할 수 있게 되었다.

단점

HOC가 반환하는 컴포넌트에 전달하는 props의 이름이 겹칠 수 있다.

function withStyles(Component) {
  return props => {
    const style = { padding: '0.2rem', margin: '1rem' }
    return <Component style={style} {...props} />
  }
}

const Button = () = <button style={{ color: 'red' }}>Click me!</button>
const StyledButton = withStyles(Button)

withStyles HOC는 style이라는 prop을 엘리먼트에 전달하고 있다. 하지만 Button 컴포넌트는 이미 style이라는 prop을 가지고 있다. 이 경우 덮어쓰게 된다.
HOC를 만들 땐 이런 상황을 고려해야 하며 prop 병합을 통해 해결할 수 있다.

function withStyles(Component) {
  return props => {
    const style = {
      padding: '0.2rem',
      margin: '1rem',
      ...props.style
    }

    return <Component style={style} {...props} />
  }
}

const Button = () = <button style={{ color: 'red' }}>Click me!</button>
const StyledButton = withStyles(Button)

HOC를 여러 번 조합하여 사용하는 경우 모든 prop이 안에서 병합되므로 어떤 HOC가 어떤 props에 관련이 있는지 파악하기 어렵다. 따라서 앱의 디버깅이나 규모를 키울 때 방해가 될 수 있다.

< 출처 : https://patterns-dev-kr.github.io/design-patterns/hoc-pattern/ >

profile
개발을 개발새발 열심히➰🐶

0개의 댓글