Please enable JavaScript to view the comments powered by Disqus.

React Hooks API가 도입된 이유

React Hooks(이하 리액트 훅)은 함수형 컴포넌트로 클래스 컴포넌트를 대체하기 위한 목적으로 도입되었다. 훅이 있기 전의 함수형 컴포넌트는 자체 state(상태)를 가질 수 없었고 상위 컴포넌트로부터 props(값)을 전달받아 표현하는 컴포넌트(presentational or dumb component)로 사용되고 있었다. 구분하자면 클래스 컴포넌트는 컨트롤러, 함수형 컴포넌트는 유닛이었다.

하지만 React 16.8 이후 리액트 훅이 도입되면서 그런 구분이 사라졌다. 새로운 API를 통해 함수형 컴포넌트도 자체 상태를 가질 수 있으며 라이프사이클 메소드를 대체할 수 있는 방법이 생겼다.

React가 등장한 후 5년여 동안 많은 변화가 있었지만 컴포넌트를 작성하는 API에는 큰 변화가 없었다. 그런데 왜 갑자기 기존의 방법론을 뒤엎는 새로운 길을 제시한 것일까?

컴포넌트 로직 공유의 복잡함

리액트에서 컴포넌트 상태 관리 로직을 공유하기 위한 방법으로는 hoc(Higher-Order Components)render props가 있다. 하지만 두 패턴 모두 처음보면 다소 이해하기 어려우며 실무 활용을 위해서는 여러가지 예제 코드를 분석하고 직접 만들어보는 학습 과정이 필요하다. 특히 자바스크립트에서는 함수가 일급 객체여서 함수(리액트 컴포넌트)를 함수의 파라미터로 전달해서 활용하는 패턴이 익숙하지 않을 수 있다. 그리고 타입스크립트로 리액트를 사용한 사람이라면 hoc의 리턴 타입을 선언하는 일이 무척 까다로워서 고생했던 경험을 가진 사람이 많을 것이다.

하지만 이제 함수형 컴포넌트에서 커스텀 훅을 사용하면 hoc와 render props을 대체할 수 있다. 그리고 커스텀 훅은 특별한 것이 아니라 그냥 ‘함수’다. 예를 들어 input 요소의 상태를 관리하는 기능을 커스텀 훅으로 분리하면 아래와 같다.

import React, { useState, useEffect } from 'react';

function useChangeInput({ initialValue, onChange = () => {} }) {
  const [value, setValue] = useState('');

  const handleChange = value => {
    setValue(value);
    onChange(value);
  };

  useEffect(() => {
    setValue(initialValue);
    return () => {
      setValue('');
    };
  }, [initialValue]);
  
   
  return {    value,     handleChange,   };}

// 커스텀 훅을 사용할 컴포넌트
function SampleInput() {
  const {
    value,
    handleChange,
  } = useChangeInput({  // custom hook을 호출해서 상태 관리에 필요한 값을 가져온다    initialValue: '',
  });

  return (
    <input
      type="text"
      value={value}
      onChange={handleChange}
    />
  );
}

useChangeInput 커스텀 훅은 일반적인 함수면서 내부에 useEffect같은 훅 API를 사용하고 있다. 커스텀 훅을 사용할 때는 hoc처럼 컴포넌트를 함수로 래핑하는 과정이 필요없고 단순히 호출을 통해 필요한 상태와 액션을 컴포넌트에 연결(hook)만 하면 된다.

커스텀 훅이 Redux를 대체할 수 있다?

커스텀 훅의 사용 방식은 마치 Redux로 앱 상태 관리자를 따로 두면서 필요한 컴포넌트에 action과 state를 연결하는 방식과(connect hoc와 mapStateToProps, mapDispatchToProps) 유사하다. 게다가 리액트에서 useReducer API를 제공하고 있으니 리액트가 Redux가 제공하는 기능을 대체하려는 의도가 있는 것인가? 라고 생각할 수도 있다. 하지만 커스텀 훅은 싱글 인스턴스를 만들지 않으며 클로져처럼 동작한다. 커스텀 훅을 사용하는 컴포넌트마다 서로 다른 상태 값을 참조하게 되는 것이다. 여러 컴포넌트에서 상태를 공유하기 위해서는 Redux나 Context API, 컴포넌트 트리를 통해 props 전달하기 같은 방식을 여전히 사용해야 한다.

그리고 Redux는 관련 라이브러리 생태계가 오랜 기간 유지되어 왔으며 미들웨어를 통해 확장할 수 있는 기능이 많다. 또 react-redux는 최신 버전에서 useSelector같은 함수형 컴포넌트를 위한 API도 제공하고 있다. 역할을 구분하자면 커스텀 훅은 UI 상호작용과 관련된 로컬 컴포넌트 상태 관리에, Redux는 앱의 핵심 상태를 관리하는 비지니스 로직을 관리하는 일로 나눌 수 있다.

  • 훅을 사용할 케이스

    • UI의 상태를 네트워크 또는 로컬 스토리지 등을 통해서 별도로 저장하거나 가져오지 않음
    • child가 아닌 다른 컴포넌트와 상태를 공유하지 않음
    • 계속 유지되지 않고, 일시적으로 존재할 상태(ex. input 필드의 입력)
  • redux를 사용할 케이스

    • 네트워크나 디바이스 API를 사용한 I/O
    • 상태의 저장, 불러오기
    • child가 아닌 컴포넌트와 상태 공유
    • 앱의 다른 파트와 공유해야 하는 비즈니스 로직, 데이터 처리

사이드 이펙트와 버그를 유발할 수 있는 라이프사이클 메소드

클래스 컴포넌트에는 라이프사이클 메소드가 제공된다. 대표적으로 componentDidMount, render, componentDidUpdate 메소드가 있으며 각각 서로 다른 시점에 호출되며 서로 다른 목적을 가진다.

react lifecycle diagram http://projects.wojtekmaj.pl/react-lifecycle-methods-diagram

라이프사이클 메소드는 러닝 커브를 높이는 역할을 함은 물론 의도하지 않은 사이드 이펙트와 버그를 발생할 가능성도 함께 높인다. 예를 들어 렌더링 여부를 결정하는 shouldComponentUpdate 라이프사이클은 최적화를 위해서 많이 사용되지만, 데이터 업데이트에 제대로 반응하지 않는 먹통 컴포넌트를 종종 만들어내는 원인이 되기도 된다.

  shouldComponentUpdate(prevProps, prevState) {
    if (isDeepEqual(this.props.data, prevProps.data)) {
      return false
    } else {
      return true
    }
  }

위의 예제에서는 업데이트된 data props의 내용이 같다면 렌더링을 추가로 실행하지 않는다. 의도하지 않은 버그를 방지하기 위해서는 저 컴포넌트의 복잡도를 낮춰서 컴포넌트가 업데이트되는 경우의 수를 가능한 한 적게 유지해야 할 것이다.

그리고 props를 기반으로 컴포넌트를 업데이트하려면 componentDidUpdate 라이프사이클을 사용하게 된다. 컴포넌트 사이즈가 조금만 커지면 저 라이프사이클 메소드에 props를 확인하는 if 문과 로직이 계속 추가되고 클래스의 메소드도 계속해서 추가된다.

  componentDidUpdate(prevProps, prevState) {
    if (this.props.id !== prevProps.id) {
      this.fetchData(id)
    }

    if (this.props.initialDate !== prevProps.initialDate) {
      this.setState({
        date: initialDate
      })
    }

    // 또 다른 사이드 이펙트 ...
  }

사이드 이펙트가 많아지면 자연스럽게 컴포넌트의 덩치가 커지고, render 메소드만으로는 이 컴포넌트가 어떻게 동작하는지 파악하기 어려워진다. 하지만 componentDidUpdate 들어갈 사이드 이펙트를 커스텀 훅으로 분리하면 컴포넌트의 복잡도를 낮출 수 있다. 그리고 라이프사이클을 사용하는 컴포넌트에 비해서 테스트코드 작성도 더 쉬워진다.

props와 state가 바뀌면 다시 렌더링 된다는 1개의 규칙

함수형 컴포넌트에서는 라이프사이클 메소드가 없으니 렌더링 규칙은 명확하다. 상위 컴포넌트에서 전달받는 props가 달라지거나 컴포넌트 안에서 useState로 관리하는 상태 값이 업데이트되면 컴포넌트가 다시 렌더링 된다. 그리고 특정 값이 변경되었을 때 사이드 이펙트를 발생시키는 useEffect, 캐싱을 지원하는 useMemo, 함수의 레퍼런스를 유지시켜주는 useCallback 등의 API를 사용해서 컴포넌트 렌더링을 제어 할 수 있다.

처음 사용하면 생각지 못하게 무한 렌더링에 빠지는 등 클래스 컴포넌트와는 다른 구현 방식에 익숙해지는 데 시간이 걸릴 수 있다. 하지만 어느 정도 적응이 되면 클래스 컴포넌트보다 간단한 문법, 값이 바뀌고 레퍼런스가 바뀌면 컴포넌트가 다시 렌더링 된다는 일관적인 규칙의 단순함이 좋아서 굳이 클래스 컴포넌트로 돌아가고 싶은 마음이 들지 않게 될 것이다.


참고 문서