본문 바로가기

[리액트] 리액트 훅 기반 프론트엔드 개발 시 주의할 점

1년 넘게 react.js 로 챗봇 빌더 프론트엔드 작업을 하면서 다양한 과정을 거쳐왔었다.

 

 

2018년, 제일 처음 React.js로 챗봇 빌더 PoC를 할 때는 필수적인 불변성도 준수하지 못했을 정도로 어리석게 프로그래밍을 했었는데 2019년 정식 서비스 개발에 들어가고 리팩토링(이라 쓰고 실상은 갈아엎음;)을 진행하면서 현재 꽤 올바른 방향으로  프로그래밍을 해오고 있다.

 

 

하지만 아직 갈 길이 엄~~~청 멀어서 늘 새롭게 배우는 부분이 많아 충격과 자극을 받으며 하고 있는 만큼, 다음에 또 React.js 개발을 하게 된다면 다음고 같은 점들은 꼭 유의하면서 진행하고 싶어 미리 되짚어보면서 꼭 지키고 싶다.

 

 

1, 2차 고도화가 끝난 2019년 10월부터 클래스 컴포넌트를 리액트 16.08버전부터 새로 나온 Hook을 활용한 함수형 컴포넌트로 전환하는 작업을 했었다.

 

 

이 때 사실 잘못한 것이 클래스 컴포넌트에서 사용하던 Plain Object 형태의 state 구조를 그대로 사용하면 불편한 경우가 있었다.

 

 

예를 들어 블록 nested object가 엄청 많았던 state의 원래 구조를 그대로 useState의 초기값으로 설정헀던 것은 리액트는 Nested Object의 비교까지는 불가능해서 불변성이 깨지는 문제가 발견되었다.

(스터디/코어자바스크립트/01. 데이터 타입에서 적은 문제처럼...)

 

 

따라서 State는 항상 함께 바뀌는 경우에만 Object로 정의하고, 독립적으로 동작하는 상태값들은 왠만하면 분리하는 게 깔끔했다.

 

실제로 Document에서도 그렇게 권장한다: https://reactjs.org/docs/hooks-faq.html#should-i-use-one-or-many-state-variables

 

If you miss automatic merging, you could write a custom useLegacyState Hook that merges object state updates. However, we recommend to split state into multiple state variables based on which values tend to change together.

 

또한 한가지 더 고민을 했던 부분은 일반 생명주기에서의 처리를 useEffect나 useLayoutEffect로 전환할 때였다.

 

 

도큐먼트를 보고 useEffect를 어떻게 쓰는지, 각 변수나 표현식이 lifeCycle의 무엇에 해당하는지를 보고 적용했는데 함수형 컴포넌트를 클래스 컴포넌트처럼 생각하고 다룬 경우로 인해 의도한 대로 나오지 않아 당황했었던 적이 많았다.

 

 

첫번째로 직접 겪은 경험을 꺼내자면 원래 저장을 하면 어떤 action이 dispatch되고나서 제대로 성공했다면 setState 등의 처리를 진행하고 성공/실패 Alert를 띄워주는 사이클을 componentDidUpdate에서 별도의 분리없이 props를 확인하면서 한번에 처리하고 있었다.

 

 

그래서 코드가 if( ){  } else if( ){  } else if( ) {  } ....의 연속인 경우가 많았고 기능별로 분리도 되어 있지 않아 읽기가 힘들었다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
componentDidUpdate (prevProps, prevState) {
    const currentIntent = this.props.match.params.intent_name;
 
    if (prevProps.requestState.status !== this.props.requestState.status
        && this.reactiveTransaction.includes(this.props.requestState.status)) {
        const state = this.stateController();
        this.setState(state, _ => this.blockTransationAlert(this.props.requestState.status));
    }
 
    // 블록 이동 || 새 블록 초기화 시
    if (prevProps.match.params.intent_name !== currentIntent 
        || (currentIntent == "new" && this.props.intentId == "rollback")) {
        this.handleCurrentSetting();
    }
 
    // 블록 수정 --> edit State를 true로 변경 (새 블록 rollback 제외)
    if (prevProps.intentId !== "rollback" && this.props.intentId !== "rollback" 
    && prevState._id == this.state._id && JSON.stringify(prevState) !== JSON.stringify(this.state)) {
        this.props.dispatch(emitEditStatus());
    }
}
 

 

 

반대로 Hooks의 useEffect는 각 State마다 나눠서 독립적으로 기능을 수행하니 꽤 편리하면서도 초기 설계가 빡셌던거 같다.

 

 

또한 Hooks에서는 prevProps, nextProps 개념이 없으니 기존에 이전 값이랑 비교하는 로직에서는 문제가 되었다.

 

 

이를 위해 useRef 기반의 usePrevious라는 custom Hook을 만들어 사용했다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
import { useRef, useEffect } from 'react';
 
function usePrevious (value) {
    const ref = useRef();
    
    useEffect(_ => {
      ref.current = JSON.parse(JSON.stringify(value));
    });
 
    return ref.current;
}
 
export default usePrevious;

 

또는 왠만하면 별도의 state를 추가하여 사용하기도 하였다.

 

 

특히 블록 저장 버튼 활성화  여부를 판단하는데 원래는 state가 변했는지를 비교해서 수정 버튼을 활성화했다면 이젠 useSave Custom Hook에서 이를 처리하도록 기능을 분리했다.

 

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
31
import { useEffect, useState } from 'react';
import { useDispatch } from "react-redux";
 
import { emitEditStatus, initEditStatus } from "../actions/edit";
import usePrevious from './usePrevious';
 
const useSave = _ => {
  const dispatch = useDispatch();
  const [flag, setFlag] = useState(false);
 
  const prevFlag = usePrevious(flag);
 
  useEffect(
    _ => {
      if (!prevFlag && flag) dispatch(emitEditStatus());
      else if (prevFlag && !flag) dispatch(initEditStatus());
    },
    [flag]
  );
 
  const doSave = _ => setFlag(true);
  const undoSave = _ => setFlag(false);
 
  return [
    flag,
    doSave,
    undoSave
  ]
}
 
export default useSave;
 

 

두번째로, useEffect가 componentDidMount, componentDidUpdate, componentWillUnmount를 수행할 수 있다는 점을 알고 썼어도 의도한대로 동작하지 않는 경우가 있었다.

 

 

가장 많이 겪었던 원인은 바로 클래스 컴포넌트의 this는 사실 mutable하다는 점 때문에 this.state나 this.props가 가장 최근에 setState(props의 경우엔 최근에 업데이트된) 한 값으로 설정된다는 점이었다.

 

요청이 진행되고 있는 상황에서 클래스 컴포넌트가 다시 렌더링 된다면 this.props 또한 바뀐다. showMessage메서드가 “새로운” props의 user를 읽는 것이다.

 

 

이해가 쉬웠던 글:  https://overreacted.io/ko/how-are-function-components-different-from-classes/

 

함수형 컴포넌트와 클래스, 어떤 차이가 존재할까?

전혀 다른 '포켓몬'이라고 할 수 있다.

overreacted.io

 

반대로 함수형 컴포넌트는 클로저에 의해 props 값은 render() 시의 값을 기억하기 때문에 외부에서 이 값을 변경해도 영향을 받지 않는다.

 

 

이 점을 간과하고 클래스 컴포넌트를 함수형 컴포넌트로 바꾼다면 의도한 대로 동작하지 않을 수 있는 경우가 다수있을 것이다.

 

 

또한 지금까지와는 다르게 성능 개선에 초첨을 두고 개선을 했었다.

 

 

Lazy load, action batch, Tree shaking, Code split 등과 함께 클래스 컴포넌트의 성능개선을 공부하면서 PureComponent의 사용과 , componentShouldUpdate 통과 여부를 설정함으로써 랜더링 횟수를 줄여나갔다.

 

 

TIP. react-devtool의 profiler를 확인해가면서 진행하면 더욱 쉽다.

 

 

Hooks 기반 함수형 컴포넌트로 전환했을 때는 componentShouldUpdate를 대신한 useEffect의 두번째 인자로만 제어하면 된다고 생각하고 진행했었는데 Hook은 매번 함수를 새로 생성하기 때문에 Hook자체가 변하면 어쩔 수 없이 다시 랜더링 되서 사실 클래스 컴포넌트보다 느린건가 생각했었다.

 

(PureComponent와 똑같은 기능을 하는 React.memo를 적용해도 되지만 역시 남용하면 되려 비용이 증가할 수 있다.)

 

 

그런데! 최근에 react 공식 문서를 다시 보니 그렇지는 않은 것 같다.

 

 

공식 문서에서 설명하듯 클래스와 비교해서 클로저의 성능은 아주 크리티컬하지는 않고, 심지어 다음 관점에서는 Hook이 더 효율적일 수도 있다고 설명한다.

 

 

- Hook은 클래스 인스턴스 생성과 이벤트 핸들러를 바인딩하는 과정이 없어 오버헤드가 발생하지 않는다.

- Hook은 HOC, render props, context를 사용하는 코드처럼 일반적으로 depth가 많은 컴포넌트 트리를 필요로 하지 않기 때문에 리액트가 할 일이 줄어든다.

 

 

출처: https://reactjs.org/docs/hooks-faq.html#how-to-avoid-passing-callbacks-down

 

Hooks FAQ – React

A JavaScript library for building user interfaces

reactjs.org

 

첫번째는 javascript가 실행되는 과정에 기반한 거라 그럴수 있다고 생각했지만 두번째 관점에는 사실 처음엔 동의하기가 힘들었다.

 

챗봇 의도를 등록하거나 분석과 같은 페이지에서는 꽤 복잡한 컴포넌트 트리를 구성해봤었기 떄문에 Hook을 써도 매한가지라고 느꼈기 때문이었다.

 

 

그런데 지금 생각해보니 클래스 컴포넌트에서는 복잡한 트리가 나왔지만 예전에 구현했던 건 싹 잊고 처음부터 Hook으로 설계했었다면 Custom Hook을 사용하여 더 깔끔한 코드가 나왔을 수도 있겠다 싶다.

 

 

아래는 추가적으로 더 효율적으로 Hooks를 만드는 데 도움이 되는 문구들이다.

 

 

1. useRef 사용

- useCallback, useMemo로 똑같은 함수의 반복적인 재생성을 막을 수도 있는데, 랜더링동안에는 업데이트를 하지 않고 이벤트가 발생하거나 등의 특정한 시점부터 갱신해서 쓰고 싶다면 useRef로 ref를 등록하여 사용할 수 있다.

 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function Form() {
  const [text, updateText] = useState('');
  const textRef = useRef();
 
  useEffect(() => {
    textRef.current = text; // Write it to the ref
  });
 
  const handleSubmit = useCallback(() => {
    const currentText = textRef.current; // Read it from the ref
    alert(currentText);
  }, [textRef]); // Don't recreate handleSubmit like [text] would do
 
  return (
    <>
      <input value={text} onChange={e => updateText(e.target.value)/>
      <ExpensiveTree onSubmit={handleSubmit} />
    </>
  );
}

출처: React 공식 Document

 

 

2. useState에 함수를 넘길 때에는 실행해서 넘기지 말기

- useState에 함수를 인자로 넘길수도 있는데 이 때 함수를 실행한 채로 넘기면 랜더링 때마다 실행되니 첫 랜더링 때에만 호출하고 싶다면 함수로 감싸서 넘기자.

 

 

1
2
3
4
5
6
7
function Table(props) {
  // ⚠️ createRows() is called on every render
  const [rows, setRows] = useState(createRows(props.count));
  // ✅ createRows() is only called once
  const [rows, setRows] = useState(() => createRows(props.count));
  // ...
}

출처: React 공식 Document

 

 

비슷하게 useRef(func())도 매 랜더링마다 실행될테니 설정하고 싶은 순간에 설정할 수 있는 함수를 만들어서 넘기자 :)

 

 

이 성능 개선을 실험해보고 싶어 챗봇 빌더의 로그인 화면에서 Profiler로 불필요한 랜더링을 확인하고 적용해보았다.

 

 

<!-- 코드 추가 예정 -->

 

 

결론

1. state는 분리해서 쓰기

2. useEffect를 사용하실 때에는 함수형 컴포넌트의 특징을 이해하고 쓰기

3. 기존의 LifeCycle을 대체하지 못할 경우 별도의 state로 분리하거나 custom Hook 만들어서 쓰기

4. 훅의 성능 개선은 React.memo만 있지 않다 :) 공식 문서나 다양한 레퍼런스를 찾아서 공부하자

 

 

Hook을 사용할 때 유용한 더 많은 정보를 알고 싶을 때에는 공식 FAQ에서 확인할 수 있다.

 

 

https://reactjs.org/docs/hooks-faq.html

 

Hooks FAQ – React

A JavaScript library for building user interfaces

reactjs.org