React + Decorator + HOC = Fantastic!!

React + Decorator + HOC = Fantastic!!

지난 포스팅에서는 ES7의 Decorator 문법을 이용해 선언된 클래스와 그 프로퍼티들을 디자인 시간에 변경하는 법을 알아보았습니다. 그렇다면 리액트 컴포넌트와 Decorator가 만나면 어떤 시너지가 발생할까요?

만약 ES7의 Decorator에 대해 모르신다면 지난 포스팅을 읽고 오시는 걸 권장합니다. 이 포스팅은 독자들이 Decorator에 대해 이미 알고 있다고 가정하고 작성됐습니다.

Higher Order Component

리액트 공식 문서를 보면 Higher Order Component(이하 HOC)를 다음과 같이 설명하고 있습니다.

  1. 리액트 컴포넌트 로직을 재활용할 수 있는 고급 기법
  2. 리액트에서 공식적으로 제공하는 API가 아니라 단순히 아키텍쳐

이 설명으로는 HOC가 어떤 역할을 하는지 이해하기는 역부족이기 때문에 간단한 예제를 통해 HOC를 어떻게 작성하는지 알아보겠습니다.

function withSay(WrappedComponent) {  
  return class extends React.Component {
    say() {
      return 'hello'
    }

    render() {
      return (
        <WrappedComponent
          {...this.props}
          say={this.say} />
      )
    }
  }
}            

withSay 함수는 WrappedComponent를 인자로 받아 원하는 속성들을 결합해 새로운 컴포넌트를 반환합니다. 이렇게 만들어진 withSay 함수는 아래와 같이 사용 가능합니다.

@withSay
class withOutSay extends React.Component {  
  render() {
    return (
      <div>
        {this.props.say()}
      </div>
    )
  }
}      

withOutSay 컴포넌트는 say 메소드를 가지고 있지 않습니다. 하지만 withSay 함수를 사용하니 say 메소드를 사용할 수 있게 됐습니다. 이처럼 컴포넌트를 인자로 받아 입맛에 맞게 바꾼 뒤 새로운 컴포넌트로 반환하는 기법을 HOC라고 부릅니다.

그렇다면 HOC는 리액트에서 어떻게 사용을 해야 효율적일까요?

Cross Cutting Concerns

개발을 하다 보면 다음과 같은 상황에 직면하는 경우가 종종 있습니다.

  1. 개발 전반에 걸쳐 반복해서 등장하는 로직
  2. 그럼에도 불구하고 모듈화가 쉽지 않은 로직

예를 들어 방명록 작성, 게시글 작성, 게시글 스크랩을 하는 컴포넌트들에서 유저 인증에러 처리의 과정이 필요하다고 했을 때 어떻게 코드를 디자인해야 할까요? 컴포넌트와 직접적으로 연관이 없는 기능들이 컴포넌트와의 결합이 너무 강해 쉽게 모듈화를 시키지 못합니다.

ccc

그림 1. Cross Cutting Concerns의 예시

이렇듯 코드 디자인적인 측면에서 공통적으로 발생하지만 쉽게 분리를 시키지 못하는 문제를 Cross Cutting Concerns라고 합니다. 이 문제를 끌어안고 가면 프로젝트의 코드는 쉽게 스파게티가 되고 나중에는 유지 보수를 하기 힘들어집니다.

하지만 우리게에는 HOCDecorator가 있고 이를 이용해 이 문제를 쉽게 해결할 수 있습니다.

유저 인증 문제를 HOC로 해결

아래는 인증이 안된 유저에게 다른 페이지를 보여주는 코드입니다.

class TeamChat extends React.Component {  
  constructor() {
    super()
    this.state = {
      unAuthenticated: false
    }
  }

  componentWillMount() {
    if (!this.props.user) {
      this.setState({ unAuthenticated: true })
    }
  }

  render() {
    if (this.state.unAuthenticated) {
      return <UnAuthenticatedComponent />
    }
    return <div>I'm TeamChat</div>
  }
}

유저 인증을 전통적인 if-else 구문으로 구현했습니다. 당장 이 컴포넌트를 본다면 문제가 없어 보입니다. 어떻게 보면 정답처럼 보이기도 합니다. 하지만 유저 인증이 필요한 컴포넌트가 많아지면 상황이 달라집니다.

100개의 컴포넌트에서 위와 같은 방식으로 유저 인증을 하고 있는데 유저 인증을 하는 로직이 변경된 상황을 생각해 봅시다. 100개의 컴포넌트 모두 유저 인증 코드를 바꿔야 하는 상황에 직면하게 됩니다. 전부 다 바꾸는 것도 일이지만 실수로 몇 개의 컴포넌트를 수정하지 않을 확률이 농후합니다. 당장에는 간단하지만 잠재적 위험을 안고 있는 위 코드는 아래와 같이 수정되어야 합니다.

function mustToAuthenticated(WrappedComponent) {  
  return class extends React.Component {
    constructor() {
      super()
      this.state = {
        unAuthenticated: false
      }  
    }

    componentWillMount() {
      if (!this.props.user) {
        this.setState({ unAuthenticated: true })
      }
    }

    render() {
      if (this.state.unAuthenticated) {
        return <UnAuthenticatedComponent />
      }
      return <WrappedComponent {...this.props} />
    }      
  }
}

HOC를 이용해 확장이 용이한 유저 인증 로직이 탄생했습니다!! 이렇게 만들어진 HOC는 아래와 같이 적용이 가능합니다.

@mustToAuthenticated
class TeamChat extends React.Component {  
  render() {
    return <div>I'm TeamChat</div>
  }
}

@mustToAuthenticated
class UserChat extends React.Component {  
  render() {
    return <div>I'm UserChat</div>
  }
}        

기존의 코드와 비교했을 때 코드가 훨씬 간단해진 것을 확인할 수 있습니다. 비단 코드만 간단해진 것뿐만 아니라 아래와 같은 추가 효과를 기대할 수 있습니다.

  1. 유저 인증 로직이 컴포넌트와 분리가 되어 자신이 맡은 역할에만 집중할 수 있습니다.
  2. 유저 인증 로직이 바뀌어도 코드를 수정해야 할 곳은 하나의 컴포넌트뿐입니다.

예시로 작성한 HOC는 최소한의 코드로만 작성된 예시입니다. 실제 제품에서 사용되기 위해서는 몇 가지 고려해야 할 사항이 있는데 이는 리액트 공식 문서를 참고해주세요.

i18n 컴포넌트를 HOC로 작성

채널 서비스는 한국어, 영어, 일본어를 지원하기 때문에 번역 기능이 필요했습니다. 초기에는 번역 서비스를 아래와 같이 구현했습니다.

@connect(state => ({
  locale: getLocale(state)
})
class Channel extends React.Component {  
  render() {
    const local = this.props.locale
    const translate = TranslateService.get(locale)
    return (
      <div>
        <div>{translate.title}</div>
        <div>{translate.description}</div>
      </div>
    )
  }
}

처음에는 위와 같은 방식으로 번역 서비스를 구현하는 것이 괜찮았습니다. 하지만 번역을 제공해야 하는 컴포넌트가 많아지면 많아질수록 중복되는 코드가 많아지는 것을 보고 아래과 같이 HOC를 이용해 코드의 중복을 제거했습니다.

function withTranslate(WrappedComponent) {

  @connect(state => ({
    locale: getLocale(state)
  }))
  class DecoratedComponent extends React.Component {
    render() {
      const locale = this.props.locale
      const translate = TranslateService.get(locale)

      return (
        <WrappedComponent
          {...this.props}
          translate={translate} />
      )
    }
  }
}

이렇게 작성된 HOC는 아래와 같이 사용이 가능합니다.

@withTranslate
class Channel extends React.Component {  
  render() {
    const translate = this.props.translate
    return (
      <div>
        <div>{translate.title}</div>
        <div>{translate.description}</div>
      </div>
    )
  }
}

HOC의 작성 방법은 예시로 작성한 두 개의 HOC에서 크게 벗어나지 않습니다. 이를 응용해 자신의 프로젝트에 맞는 코드를 작성해보세요.

중첩 가능한 HOC

HOC는 여러 개를 중첩해서 사용할 수 있습니다.. 예를 들어 유저 인증과 i18n 서비스를 동시에 제공하고 싶을 때 두 HOC를 중첩해서 사용하면 됩니다.

@mustToAuthenticated
@withTranslate
class Channel extends React.Component {  
  render() {
    return (
      <div>
        <div>{`Hello!! ${this.props.user.name}`</div>
        <div>{translate.title}</div>
        <div>{translate.description}</div>
      </div>
    )
  }
}

마무리

이상으로 리액트에서 HOC를 사용할 수 있는 상황과 작성 방법을 알아보았습니다. 본 포스팅에서 다루지는 않았지만 만능처럼 소개한 HOC에도 몇 가지 단점은 존재합니다.

  1. Component Unit Test를 할 때 문제가 있을 수 있습니다.
  2. HOC를 몇 개 중첩하면 디버깅이 힘들 수 있습니다.
  3. WrappedComponent에 직접적으로 ref를 달 수 없어 우회 방법을 사용해야 합니다.
  4. 비동기 작업과 같이 사용하다 보면 예상치 못한 결과를 만날 수 있습니다.

하지만 이러한 단점에도 불구하고 상속을 제공하지 않은 리액트에서 HOC는 많은 문제를 효율적으로 해결해주는 단비와 같은 존재입니다. 유명한 리액트 라이브러리들(react-redux, redux-form 등)은 이미 예전부터 HOC를 사용해 사용자들에게 편의를 제공해 왔습니다. 이러한 라이브러리들과 자신의 프로젝트가 직면하고 있는 문제에 맞는 HOC를 작성해 같이 사용한다면 우아하고 아름다운 설계에 한층 더 다가간 프로젝트를 발견할 수 있습니다.

마지막으로 한 문장을 남기고 본 포스팅을 마치도록 하겠습니다.

React + Decorator + HOC = Fantastic!!

본 포스팅은 2017 리액트 서울에서 발표한 내용입니다. 발표 자료발표 영상을 확인해보세요.