티스토리 뷰

최근 참여한 프로젝트에서 리액트를 사용하고 있고 관습으로 클래스 컴포넌트를 사용하고 있었습니다. 저는 리액트 훅을 최근에 알게 되어 그걸 사용하고 싶었고 그 장점을 소개할 기회를 얻었습니다.

Hook은 무엇인가?

상태와 생명주기에 엮인 부수효과를 관리하는 새로운 방법입니다.

즉 기존의 this.statecomponentDidMount()등의 사용을 대체합니다.

믹스인, HOCHigher order component를 대체할 수 있습니다.

Redux, MobX와의 관계는?

거의 별개라고 할 수 있습니다.

MobX와 Redux 둘 다 상위 컴포넌트에서 스토어라는 트리 범위의 상태를 관리합니다. 이건 리액트에선 컨텍스트를 통해 관리할 수 있고, 훅의 useContext를 통해 컨텍스트도 접근할 수 있으므로 Redux와 MobX가 훅을 사용해 구현될 수도 있을 것입니다.

즉 Redux나 MobX는 컴포넌트간 상태 관리에 관한 것이고, 훅은 그것보단 기저, 하위 단계에서 동작한다고 볼 수 있습니다.

훅의 장점, 단점, 의문

장점 목록

  • 응집성과 조립 가능성을 얻음
  • Wrapper Hell을 방지함
  • 라이프사이클 메서드보다 단순함

단점 목록

  • 미지원 기능
  • 클래스에서 사용 불가
  • 훅 사용의 유의점
  • 의존성 증가

의문 목록

  • 리렌더링 없는 상태 변경
  • 여러 라이브러리와의 호환성
  • react-native 동작 가능성
  • TS 호환성

하나씩 설명해 보겠습니다.



응집성과 조립 가능성을 얻음

아래는 React Conf 2018에서 나온 코드 예시입니다. 같은 기능을 클래스와 훅으로 구현했습니다. 이 코드는 입력값과 컨텍스트를 2개씩 사용하고 창 너비를 출력, 창 제목을 바꾸는 기능을 담고 있습니다. 각각의 기능이 어떻게 분포되어 있는지 봐주시기 바랍니다. 귀찮으면 그냥 훑어보셔도 됩니다.

import React from "react";
import Row from "./row";
import { ThemeContext, LocaleContext } from "./context";

export default class Greeting extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      name: "Mary",
      surname: "Poppins",
      width: window.innerWidth,
    };
  }
    
  componentDidMount() {
    document.title =
      this.state.name + " " + this.state.surname;
    window.addEventListener("resize", this.handleResize);
  }
    
  componentDidUpdate() {
    document.title =
      this.state.name + " " + this.state.surname;
  }
    
  componentWillUnmount() {
    window.removeEventListener("resize", this.handleResize);
  }
    
  handleNameChange = e => {
    this.setState({ name: e.target.value });
  }
    
  handleSurnameChange = e => {
    this.setState({ surname: e.target.value });
  }
    
  handleResize = () => {
    this.setState({ width: window.innerWidth });
  }
    
  render() {
    return (
      <ThemeContext.Consumer>
        {theme => (
          <section className={theme}>
            <Row label="Name">
              <input
                value={this.state.name}
                onChange={this.handleNameChange}
              />
            </Row>
            <Row label="Surname">
              <input
                value={this.state.surname}
                onChange={this.handleSurnameChange}
              />
            </Row>
            <LocaleContext.Consumer>
              {locale => (
                <Row label="Language">{locale}</Row>
              )}
            </LocaleContext.Consumer>
            <Row label="Width">{this.state.width}</Row>
          </section>
        )}
      </ThemeContext.Consumer>
    );
  }
}
import React, {
  useState,
  useContext,
  useEffect,
} from "react";
import Row from "./row";
import { ThemeContext, LocaleContext } from "./context";

export default function Greeting(props) {
  const name = useFormInput("Mary");
  const surname = useFormInput("Poppins");
  const theme = useContext(ThemeContext);
  const locale = useContext(LocaleContext);
  const width = useWindowWidth();
  useDocumentTitle(name.value + " " + surname.value);

  return (
    <section className={theme}>
      <Row label="Name">
        <input {...name} />
      </Row>
      <Row label="Surname">
        <input {...surname} />
      </Row>
      <Row label="Language">{locale}</Row>
      <Row label="Width">{width}</Row>
    </section>
  );
}

function useFormInput(initialValue) {
  const [value, setValue] = useState(initialValue);
  function handleChange(e) {
    setValue(e.target.value);
  }
  return {
    value,
    onChange: handleChange,
  };
}

function useDocumentTitle(title) {
  useEffect(() => {
    document.title = title;
  });
}

function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);
  useEffect(() => {
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener("resize", handleResize);
    return () => {
      window.removeEventListener("resize", handleResize);
    };
  });
}

출처: https://github.com/pomber/react-conf-2018-hooks-demo

얼핏 봐서 쉽게 알 수 있는 차이점은 역시 코드의 양입니다. 하지만 코드의 양보다 더 중요한 차이점은 각각의 기능들이 모여있다는 점입니다. 가장 간단한 name 입력값만 해도 클래스 예제는 초기값, 상태 반영 2군데, 핸들러 정의, value 지정, onChange 지정 등 건드릴 곳이 많습니다. 보통 코드를 짜는 시간보다 코드를 읽기 위해 이동하는 시간이 많다는 Jeff Atwood의 말을 생각해 볼 때, 훅을 사용한 예제가 생산성이 높다고 말할 수 있을 것입니다.

그것만이 아닙니다. refactorable smell이 나지 않나요? 결국 전부 함수이기에 함수 추출만 하면 재활용이 가능합니다. 기존 클래스에서 그렇게 하기엔 this가 꽤나 불편했겠죠.

Wrapper Hell을 방지함

ReactConf 2018

원래 재활용 요소를 분리하기 위한 리액트의 기존 방법은 HOC와 render props였습니다. 하지만 DOM이 너무 깊어지는 문제가 있었고, 매우 편한 방법이라고 보기에도 애매했습니다. 그 전에 있었던 믹스인은 편하긴 했지만 암시적인 코드를 파악하고 제어하기 어려웠다고 합니다.

훅은 이런 문제가 없고, 기능적인 면에서 대등하고 재사용성은 더 좋다보니 최근 들어 내부 구현은 훅으로 하고 훅을 사용한 호환용 HOC와 컴포넌트를 노출하는 라이브러리를 심심찮게 볼 수 있습니다.

라이프사이클 메서드보다 단순함

제가 처음 리액트를 접했을 땐 prop이 변경될 때 생성자나 componentDidMount가 호출되지 않는다는 게 부자연스럽게 느껴졌습니다. 확실하게 부자연스럽다고 인식한 건 derived state를 다뤄보고부터였습니다. 일반적으로 사용하던 componentDidMountcomponentDidUpdate 대신 생성자에서 props를 state에 복사합니다. 그리고 getDerivedStateFromProps에서 상태를 만들어줍니다. props와 state가 불일치하는 상황을 피할 때 필요한 과정입니다.


이 derived state를 얻는 데 비동기 작업이 필요할 경우 componentDidMountcomponentDidUpdate가 비슷해져야 하게 됩니다. 그리고 리액트 가이드는 이런 상황에서 차라리 key를 지정해 컴포넌트를 파괴하고 새로 만들라고 합니다.

shouldComponentUpdate는 최적화를 위해서만 존재하는데 사용에 필요한 노력 대비 이득이 별로 좋지 않습니다.


getDerivedStateFromPropsuseMemo훅으로 해결할 수 있습니다. shouldComponentUpdate는 훅의 의존성 배열로 해결됩니다. componentDidMount, componentDidUpdate, componentWillUnmountuseEffect 하나로 합쳐졌습니다.


useState, useEffect, useContext 3가지의 훅을 조합해서 나머지 훅을 파생하고 요구사항의 대부분을 해결한다는 게 저는 놀라웠습니다.



미지원 기능

현재 FAQ에 나온 바로는 getSnapshotBeforeUpdatecomponentDidCatch가 지원되지 않습니다. 전적으로 훅만 쓴다면 이것들이 장애가 되겠지만 훅은 클래스를 배척하려는 의도가 아니므로 클래스를 혼용하면 문제가 되지 않습니다.

또 다른 클래스로만 해결 가능한 경우가 있지 않은지 생각할 수 있습니다. 존재할 수 있으나 공식 문서는 그런 것이 발견되면 계속 보완할 예정이라고 합니다.

클래스에서 사용 불가

클래스 컴포넌트에선 훅을 못 쓰기 때문에 여러 컴포넌트에서 재사용할 코드를 짜는 경우 기존 클래스 컴포넌트에서도 사용할 수 있어야 한다면 HOC나 컴포넌트를 추가로 만들어야 하는 불편함이 존재합니다.

그렇지만 클래스도 this 바인딩을 어느 정도로 유지할지에 따라 메서드 구문이 달라지는 것도 있고, minifier가 메서드를 잘 줄여주지 못하는 등 JS 클래스는 나름의 아쉬운 점이 존재합니다. 객체 모델링을 편히할 수 있다는 장점을 누린다면 클래스는 그 역할을 다 했다고 생각합니다. 더 중요한 가치는 이펙트의 안전하고 편리한 조립이라고 생각하기에 클래스에서 못 쓴다고 훅을 기피할 이유가 있을까 싶습니다.

훅 사용의 유의점

훅은 어떠한 조건문으로도 감싸지 않고 항상 순서대로 호출해야 한다든가, 다양한 종류의 훅을 다시 배워야 한다든가 하는 복잡한 점이 존재합니다.

useState로 기본 자료형을 그대로 상태로 만들 수 있는 점도 좋긴 하지만 개발자 도구에서 만든 상태의 이름까지는 보여주지 않습니다. 그 부분이 불만이어서 object를 쓴다고 하면 {...prevState}와 같이 새로 만들어야만 합니다. 물론 사소하고 바람직한 제약이지만 이런 사소한 부분들에 항상 이게 최선인지 고민하면서 학습 시간을 소모하기 마련입니다.

의존성 증가

useState같은 함수를 제공하지 않으면 함수 컴포넌트를 단독으로 실행할 수 없다는 점이 염려될 수 있습니다. 사실 JSX가 React.createComponent라서 의미가 없지 않나 싶지만 굳이 그래야하는 경우를 찾자면 단위 테스트가 있을 것입니다.

테스트의 경우 리액트 훅(Hooks): 제 테스트는 어떻게 되나요? | 매일 성장하기 - 김용균에 나온 대로 할 수 있습니다.



리렌더링 없는 상태 변경

this를 쓰지 못하면 문제가 생기지 않을까 염려할 수 있습니다. 가능한 것 중 하나가 리렌더링하지 않는 상태 변경이고요. 또한 콜백을 매번 재생성하기에 성능에 괜찮을지 의심이 들 수 있습니다.

일단 콜백은 useCallback을 사용할 수 있고, 이펙트도 마찬가지로 의존성을 지정해 성능 하락 방지가 가능합니다. 그리고 최후 수단으로는 useRef라는 샛길이 존재합니다.

오히려 react-hook-form은 훅을 이용해서 최소한의 리렌더링을 달성합니다. 이게 되는 이유는 리렌더링 방지에 있어 훅이 더 편리한 점이 있기 때문입니다. 가끔 render 메서드 안에서 화살표 함수를 콜백으로 넘겨주는 경우가 있었을 것입니다. 하지만 그건 매번 함수가 재생성되기에 리렌더링을 일으킬 수 있고 귀찮기에 의존성 검사를 생략하는 것 뿐입니다. 훅은 이 의존성 검사 구문이 더 간결하고, 그러다보니 쓰게 됩니다.

여러 라이브러리와의 호환성

redux는 redux-react-hook, MobX의 경우 mobx-react-lite를 의존성에 추가해야 합니다. 데코레이터 등은 훅을 사용하는데 필요조건인 함수 컴포넌트와 문제가 생기진 않을 듯 합니다.

또한 material-ui의 내부 코드와 예제는 이미 훅으로 교체된 상태입니다. 리액트 버전을 올릴 수 없는 게 아닌 한 실사용성은 괜찮아 보입니다.

react-native 동작 가능성

TS 호환성

강좌 글이 나올 정도면 무리없지 않을까 생각합니다. FAQ에도 타입스크립트를 지원한다고 적혀있습니다.

실제 vscode 환경에서 써본 경험으론 훅이 함수이기에 인자와 리턴 값에 대한 타입 힌트 경험이 좋았습니다. 컴포넌트는 props에 대한 타입 힌트가 뜨지 않아 render props보다 훅이 더 타입스크립트 친화적으로 보입니다.



마치며

리액트는 훅으로 전부 바꾸기를 권장하지 않습니다. 결국 어떤 이유로든 훅을 쓰지 않는 것도 타당합니다. 그리고 여기 없는 문제가 많을 가능성도 충분합니다. 그저 이 글이 누군가에게 유용한 정보가 되길 바랍니다.

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함