React의 Custom Hook 리팩토링

byron1st·2020년 12월 13일
0

React Hook 이란

객체지향언어(Object-Oriented Programming, OOP)에서 클래스(Class)는 데이터의 정의와 그 데이터를 대상으로 동작하는 함수(Method, 이하 메소드)들의 정의로 구성된다. 그리고 클래스 외부에서 접근할 수 있는 함수들을 제한함으로써, 내부 데이터와 함수들의 복잡성을 감춘다(encapsulation, 캡슐화). 그렇기에 잘 테스트가 된 클래스는 안심하고 반복적으로 재사용되며, 소프트웨어를 구성하는 든든한 빌딩 블록이 된다. 이러한 크고 작은 클래스들이 계층 구조로, 또는 그래프 구조로 서로 구성되어 최종적으로 하나의 소프트웨어가 된다.

이러한 관점에서 React Hook 은 React로 개발된 앱의 클래스라고 볼 수 있다. 이는 언어의 문법적 관점에서 클래스를 말하는 것이 아니라, 내부 데이터와 메소드들을 캡슐화한 구성요소라는 관점에서 말하는 것이다. React Hook 중 가장 단순한 기본 Hook 인 useState 를 예로 들어보자.

const [index, setIndex] = useState<number>(0)

위의 코드에서 useState 라는 Hook 은 index 라는 number 타입의 데이터와 이 데이터를 업데이트 하는 메소드를 제공한다. useState는 React Fiber 의 구현을 이용한 복잡한 내부 업데이트 과정을 캡슐화하여 데이터와 이 데이터에 대한 간단한 업데이트 메소드 만을 외부로 내보낸 것이다.

마찬가지로, 우리 개발자들도 useState, useEffect, useCallback 등의 Hook 들을 이용하여, 우리만의 Hook 을 만들 수 있다. useState 로 데이터를 정의하고, 이 데이터 값이 결정되는 로직을 Hook 함수 파라미터들과 useEffect 를 통해 정의하며, 메소드들을 정의하고(일부는 useCallback 을 이용하여 캐싱하며), 이들 중 외부로 내보낼 데이터와 메소드들을 결정 함으로써, 복잡하며, 반복적으로 사용되는 데이터들과 데이터들에 대한 로직을 캡슐화할 수 있다. 즉, React의 Custom Hook은 React 앱을 구성하는 재사용 가능한 빌딩 블록이자, 클래스들이라고 할 수 있다.

React에서 Hook의 도입 의도는 명확하다. 오류를 많이 부를 수 있는, 직관적이지 못한 React 컴포넌트 라이프사이클 컨트롤은 프레임워크에 맡기고, 개발자는 React 컴포넌트의 데이터, 그리고 그 데이터들에 대한 컨트롤에 집중하라는 것이다. 어떤 데이터를 어떻게 변경하고 표현할지 정의하면, 나머지는 프레임워크에서 일괄적으로 수행하겠다는 의지 표명이라고 할 수 있다. 개발 자유도가 떨어진 것은 분명 불만을 부를 수 있는 부분이나, 좀 더 직관적이고 일관된 코드를 가질 수 있다는 점은 긍정적이라고 할 수 있다.

Custom Hook의 리팩토링

처음 개발할 때는 useCallback, useMemo, useRef가 무엇인지, 어디에 쓰는지 잘 와닿지않았다. 그래서 일단 useState, useEffect, useContext를 이용하여 개발을 진행했다. Custom Hook이 20개가 넘어가고, 이를 동시에 사용하는 웹 앱과 모바일 앱의 개발이 어느정도 완료되어 가는데, 모바일 앱 론칭이 뒤로 밀리면서, 운 좋게 시간이 좀 생겼다. 그래서 이 기회에 useCallback, useMemo, useRef와 같은 고급(?) 기능을 적극 활용해서 시간에 쫓겨 난개발 되어 있는 Custom Hook 들에 대한 리팩토링을 하기로 하였다.

useCallbackuseMemo

Custom Hook 함수 내에 정의된 함수와 변수들은 Custom Hook을 포함한 React 컴포넌트가 리렌더링 될 때 모두 새로 선언된다.

이 함수들은 컴포넌트가 리렌더링 될 때 마다 새로 만들어집니다.
(18. useCallback 을 사용하여 함수 재사용하기 참조)

그래서 Custom Hook 에서 내보낸 메소드를 useEffect의 deps 리스트에 넣으면, useEffect 가 리렌더링 마다 실행되는 아름다운(?) 광경을 볼 수 있다. 아마 이를 의도한 사람은 아무도 없을 것이다.
(이 실수를 나는 자주 했다. Custom Hook 의 메소드를 React 컴포넌트의 useEffect에서 사용하고, eslint 가 자동으로 deps 리스트에 이 함수를 추가해버린다. 이 때문에, useEffect가 리렌더링 마다 실행되고, 이 useEffect가 또 리렌더링을 호출함으로써, 무한 루프에 빠지고 CPU 사용량이 만땅을 찍게 된다. 개발 중에 갑자기 맥북이 이륙을 하기 시작하면, 이 실수를 한거다.)

함수를 선언하는 것 자체는 CPU에 부담을 주는 일은 아니다. 변수 또한 마찬가지다. 하지만, 이 Custom Hook 을 사용하는 React 컴포넌트 개발 시 의도치않은 버그를 줄이고 싶다면, 명시적으로 이 함수, 이 변수가 언제 ‘재선언’ 되어야 하는지 명시할 수 있을 것이다. 이 때 사용될 수 있는 것이 변수의 경우 useMemo, 함수의 경우 useCallback 이다.

useCallback 사용

개인적으로는 나 자신을 믿지 않고, 요즘 사용자들이 메모리가 부족해서 죽진 않는다고 생각해서, export 되는 메소드들은 되도록 useCallback을 사용하도록 리팩토링 했다. 또한, 부수적으로 export 되는 함수의 로직과 useEffect 내에서 사용되는 로직이 동일한 경우, useCallback으로 해당 로직을 캐싱하고, 이를 두 곳에서 모두 사용할 수 있게 되었다.

기존:

useEffect(() => {
  // 데이터 fetch
  fetch(‘http://...).then(data => setData(data)).catch(err => console.log(err))
}, [])

const reload = () => {
  // 데이터 fetch
  fetch(‘http://...).then(data => setData(data)).catch(err => console.log(err))
}

리팩토링:

// 데이터 fetch 코드는 한곳에 선언
const loadData = useCallback(() => fetch(‘http://...).then(data => setData(data)).catch(err => console.log(err)), [])

useEffect(() => {
  loadData()
}, [])

const reload = () => {
  loadData()
}

useMemo 사용

나는 useMemo는 배열 데이터를 filter, map, reduce 등을 사용하여 쓰기 쉽게 가공해서 저장해놓았던 변수들에 적용했다. (서버에서 가공해서 데이터를 주면 좋겠지만, 서버 API는 범용성과 확장성을 위해 일반적인 수준으로 개발되어 있어서, 앱의 특정 페이지에 맞는 데이터 구조를 위해 앱 내에서 데이터를 필터링 하거나 reduce 하여 사용하는 부분이 꽤 있다.)

이 외의 일반 변수들은 사실 useMemo로 캐싱하는 비용보다 그냥 매번 재선언하는 비용이 더 쌀 것 같다. useMemo가 결국 CPU 비용을 메모리 비용으로 전환하는 것이라는 얘기를 어디서 봤었는데, 이 때문에, 확실히 CPU 비용이 높을 것이라고 생각되는 계산이 아닌 이상, useMemo를 적용하진 않았다.

Custom Hook의 코드 구조

일반적으로 클래스의 코드 구조는 1) 상수 선언, 2) 변수(데이터) 선언, 3) 공개 함수(메소드) 선언, 4) 비공개 함수 선언으로 구성된다. Custom Hook에서도 비슷한 구조를 따르기로 했다.

우선, 상수에 해당하는 값이 있을 경우, 최상단에 배치한다. 근데 딱히 상수로 여겨질 값들은 없었다. 그 다음에 변수들이 따라오는데, 우선 useRef 로 선언되는 값들이 최상단에 위치했다. 그리고 useStateuseReducer로 이루어진 변수들(Custom Hook에서 관리하는 데이터라고 볼 수 있다.)을 배치하여, 이 Custom Hook의 존재 의의인 데이터가 무엇인지 정의한다. 그리고 그 다음에는 useMemo가 캐싱하는 값들, 그리고 useCallback이 캐싱하는 함수들이 뒤따른다. 여기까지 오면, 이 Custom Hook이 제공하는 데이터들, 그리고 함수들의 정의가 끝나게 된다. Custom Hook을 사용하는 사용자들의 경우, 여기까지만 봐도 이 Hook을 이해할 수 있다.

이후에는 useEffect와 비공개 함수들이 온다. useEffect는 데이터 변경으로 이루어지는 Side Effect를 정의하는데, 나는 대부분 서버로부터 데이터를 fetch 해오는데 사용한다.

deps 리스트

대부분의 버그는 deps 리스트를 잘못 정의하는데서 온다. 갑자기 리렌더링이 무한히 반복되거나, 의도한 대로 값이 업데이트 되지 않는 원인은 deps 리스트를 잘못 정의한 것인 경우가 거의 90% 인 듯 싶다. 반드시 eslint의 exhaustive-deps 룰을 활성화 시키도록 하자.

그리고 deps 리스트에 primitive type 변수가 아닌, 객체나 함수가 할당될 경우, 해당 객체나 함수가 언제 '재선언'되는지 명확히 해야 한다. 객체 내부 값이 변경되어도, 객체 자체는 변경된게 아니라서 의도한대로 useEffectuseCallback, useMemo가 동작하지 않을 수 있다. 함수도 일종의 객체이기 때문에, 함수가 재선언이 되는지, 아니면 이미 선언된 함수를 재사용하는지를 명확히해서 deps 리스트에 추가해야 한다. useCallback의 활용은 의도치 않은 함수 객체의 재선언을 막고 명시적으로 선언 시점을 정의해줌으로써 이런 실수를 좀 줄여준다.

useRef 를 이용한 마운트 여부 체크

"Can't perform a React state update on an unmounted component"

"Can't perform a React state update on an unmounted component" Warning은 React 컴포넌트가 언마운트 되었음에도, 해당 컴포넌트의 State를 업데이트하려 할 때 발생한다. 이를 피하기 위한 일반적인 코드 패턴은 다음과 같다.

const mounted = useRef(true)

useEffect(() => () => {
  mounted.current = false
}, [])

const update = () => {
  if (mounted.current) {
    setValue(...)
  }
}

일단 나는 이번 리팩토링에서 위의 코드 패턴을 모든 Custom Hook에 삽입했다. 해당 Warning을 완전히 잡기 위해서는 Custom Hook 들 뿐만 아니라, 각 React 컴포넌트들에서 마운트 여부를 체크해야 할 것이다.

Custom Hook 에서의 에러처리

Custom Hook에서 에러가 발생하는 위치는 크게 두 군데가 있다. 첫번째는 useEffect 내에서 발생하는 에러고, 두번째는 메소드에서 발생하는 에러다. 난 useEffect 에서 발생하는 에러는 많이들 사용하는 다음과 같은 코드 패턴으로 처리한다.

function useMyHook(): [{..., error}, {...}]{ 
  ...
  const [error, setError] = useState<Error>()
  ...
  
  useEffect(() => {
    const _fetch = async () => {
      try {
        ...
      } catch(err) {
        setError(err)
      }
    }
      
    _fetch()
  }, [])
  ...
  
  return [{..., error}, {...}]
}

useEffect 에서 발생한 에러는 즉각적인 처리가 힘들기 때문에, 일단 State 에 해당 에러를 할당한 후, 이 State 값을 내보냄으로써, 해당 Custom Hook을 사용하는 React 컴포넌트에서 처리하도록 한다.

반면에, 메소드에서 발생하는 에러는 위와 같이 처리하지 않고 그냥 에러를 Throw 하도록 내버려둔다. 이는, 해당 메소드를 사용하는 React 컴포넌트에서 처리하기 위함인데, 나 같은 경우 메소드 실행 시 발생한 에러는 보통 Snackbar 등을 통해 처리하기 때문이다.

두 방법 모두 원칙은 에러는 해당 Hook 을 사용하는 React 컴포넌트에서 처리하도록 하는 것이다.

리팩토링 경험을 바탕으로 세운 Custom Hook 개발 규칙

이번 리팩토링을 통해 정한 나의 Custom Hook 개발 규칙 다음과 같다.

  1. useRef 를 통해 마운트 관리를 하고, 모든 set... 함수는 마운트 되었을 때만 호출되도록 한다.
  2. useCallback 은 내보내는 메소드(exported method)에 사용한다.
  3. useMemofilter, reduce, map 등을 통해 State의 값을 가공하는 경우 사용한다. 그 외에는 되도록 사용을 지양한다.
  4. useEffect, useCallback, useMemo의 deps 리스트는 항상 더블체크를 한다.

사실 Custom Hook 뿐만 아니라, React 컴포넌트에 대한 개발 규칙이 될 수도 있겠다. React 컴포넌트를 개발하며 쌓은 경험으로 Custom Hook 들을 만들게 되었고, Custom Hook 들을 만들고 개선하며 배운 것들을 가지고 이제 React 컴포넌트들을 개선할 예정이다. 모든 소프트웨어 개발이 바로 이런 순환적인 배움과 적용의 연속이 아닐까 싶다.

profile
Hyperledger Fabric, React/React Native, Software Architecture

0개의 댓글