Skip to content

Hooks API

김민섭 edited this page Dec 28, 2020 · 8 revisions

이 프로젝트의 주된 목적은 다양한 Hooks API를 사용해 보는 것이었다.

굳이 따지자면 렌더링 최적화를 위한 Hooks API이다.

Tetris가 이러한 목적에 부합한 프로젝트라고 판단한 이유는

사용자의 key 조작과, 일정한 시간주기로 drop되는 tetromino의 특성이

수많은 Re-Rendering을 일으킬 것이라고 생각했기 때문이다.

예상대로 어느정도 구현이 된 테트리스 게임에서는 불필요한 Re-Rendering이 많이 일어났고, 이를 해결해보았다.

이에대한 정리와 함께 이 프로젝트에서 사용된 Hooks API들에 대해 정리하게 되었다.

참고자료 :

심층 분석: React Hook은 실제로 어떻게 동작할까?

React Hooks는 어떻게 function component를 다시 그릴까?

1️⃣ useState

useState는 기본적으로 상태와, 상태를 변경할 수 있는 setter함수를 리턴해준다.

이것의 원리는 자바스크립트의 클로저를 이해해야한다.

클로저는 간단히 말해서 내부함수에서 외부의 변수를 참조할 수 있게되는 현상인데 이때 사용되는 원리는 스코프 체이닝이다.

참고자료를 보면 useState는 아래와 같이 구현할 수 있다.

const MyReact = (function() {
  let _val // 모듈 스코프 안에 state를 잡아놓습니다.
  return {
    render(Component) {
      const Comp = Component()
      Comp.render()
      return Comp
    },
    useState(initialValue) {
      _val = _val || initialValue // 매 실행마다 새로 할당됩니다.
      function setState(newVal) {
        _val = newVal
      }
      return [_val, setState]
    },

useState는 값과 setter함수를 return하는데, 이때 포인트는 _val이 useState안에서 선언하고 사용하는 것이 아니라

외부의 스코프에 존재하는 _val을 사용한다는 것이다.

이렇게해야 클로저 현상을 이용해야 매번 재할당된 _val을 return할 수 있다.

아래와 같이 useState에서 선언된 _val을 값의 형태로 내보낼 경우

자연스럽게 초기값으로만 참조되기 때문에 setState가 원하는대로 동작하지 않게 된다.

function useState(initialValue) {
  var _val = initialValue
  // state() 함수 없음
  function setState(newVal) {
    _val = newVal
  }
  return [_val, setState] // _val를 그대로 노출
}
var [foo, setFoo] = useState(0)
console.log(foo) // 0 출력
setFoo(1) 
console.log(foo) // 0 출력 

완벽히 Hooks API의 useState의 내부가 위와같이 구현된 것은 아니지만 (위의 예제는 싱글톤 형태이므로 하나의 state에 대해서만 유요한 상태)

useState의 원리는 클로저를 이용한 것이라는 것을 알게되었다.

2️⃣ useEffect

useEffect는 비동기로 처리되기 때문에 useState에 비해 클로저 문제가 발생할 가능성이 더 높다고 한다.

참고자료에서는 아래와 같이 useEffect를 묘사하고 있다.

 let _deps // 의존성 배열
 useEffect(callback, depArray) {
      const hasNoDeps = !depArray
      const hasChangedDeps = _deps ? !depArray.every((el, i) => el === _deps[i]) : true
      if (hasNoDeps || hasChangedDeps) {
        callback()
        _deps = depArray
      }
    },

useEffect의 원리는 배열을 돌며 변화에 따라 callback호출 제어권을 주는 것이다.

만약 의존성 배열이 없거나, 있다면 변화가 일어났을 경우에만 callback을 호출하도록 하여

부수효과를 발생시킨다.

이 역시 싱글톤 패턴으로 작성된 임의의 코드기 때문에 원리를 이해하는 정도로 참고하고,

참고자료에서 제공하는 좀 더 hooks에 가까운 긴 코드를 분석해 보는 것이 좋겠다.

3️⃣ useRef

참고자료 :

useRef()가 순수 자바스크립트 객체라는 의미를 곱씹어보기

React에서 ref는 DOM요소에 직접적인 접근을 할 때사용된다.

javascript에서와 같이 getElementById나 querySelector를 통해서도 접근할 수 있는데 왜 ref라는 것을 사용할까?

공식문서에 따르면 이 useRef의 목적은 .current 프로퍼티를 이용하여 가변값을 유지하는데에 있다.

나는 단순히 React는 내부적으로 Virtual Dom을 사용하는데,

직접 돔 조작을 할 경우 이 virtual Dom에 반영이 안되니까 가변값이 유지가 되지 않나보네 정도로 생각하였다.

전혀 잘못 생각한건 아니지만, useRef는 훨씬 더 큰 의미가 내포되어있었다.

그러고보면 createRef가 있는데 useRef라는 훅을 따로 만든 이유가 분명히 있을 것이었다.

useRef는 사실 Dom을 선택할 때만 사용할 수 있는 것이아니라 일반적인 변수를 선언할때도 사용 할 수가 있다.

함수형 컴포넌트는 반복해서 재실행되며 렌더링이 되는데, useState를 통한 상태값이 변경될 때에도 계속해서 함수는 재실행된다.

어떠한 가변값에 대해 기대하는 것이 리렌더링이라면 상관없지만, 그렇지 않은 경우라면 값을 어떻게 선언해야할까?

그에대한 답이 useRef이다.

useState나 useContext로 선언하는 방법, const나 let으로 선언하는 방법, 컴포넌트 외부에 선언하는 방법등은 모두 각각의 문제를 가지고 있다.

변화에 따라 리렌더링이 된다던지, 재할당으로 인한 메모리 문제라던지, 재사용성이 확 떨어진다던지 말이다.

결론적으로 useRef는 클래스의 인스턴스 프로퍼티와 같다고 생각면 된다고 한다.

컴포넌트 내부에서 관리하는 변수인데, 값이 바뀔 때마다 렌더링이 필요하면 useState 를 쓰면 되고 아닐 경우 useRef 를 써야한다고 생각하면 되겠다.

4️⃣ 최적화를 위한 옵션들

참고자료 :

React 최적화, useMemo, useCallback, React.memo

이 기능들은 사실상 Tetris 게임을 구현해보게된 계기라고 할 수 있다.

사실 최근 진행했던 프로젝트에서도 렌더링 최적화를 고려하지 않았던것은 아니다.

최적화를 진행하기 위해 다양한 자료를 접하였는데, 공통적으로 하는 말이 '최적화는 미리 하는 것이 아니라, 문제가 발생하였을 때 하는 것이 좋다.'였다.

마침 Dead Line이 있는 프로젝트였기 때문에, 문제가 발생하기 전까지는 최적화를 뒤로 미루기로 결정이 되었었다.

끝날때까지 별다른 이슈가 없어서 최적화에 대한 필요성을 느끼지 못하고 해당 프로젝트는 마무리가 되었었다.

본론으로 돌아와서 글의 초반에 언급했듯이 Tetris게임은 실질적인 문제가 발생하는 것과는 별개로 최적화를 연습하기 좋은 예제라고 판단하였다.

useCallback과 useMemo, React.memo는 가장 대표적인 최적화 옵션들이다.

학습을 하며 이 세가지 모두 결론적으는 Memoization을 기반으로 동작한다는 것을 알게 되었다.

Memoziation이란 쉽게 말해서 값을 메모리에 저장해 둠으로써 중복되는 연산을 제거하는 기술이다.

그렇다면 각각이 무엇을 메모리에 저장해두고 재사용하는지 알면, 상황에 맞게 사용할 수 있겠다.

📌 React.memo

먼저, React.memo의 경우 props가 바뀌지 않는다면 리렌더링을 막아준다.

이 프로젝트에서는 게임판의 한 칸을 차지하는 Cell에 대해서 React.memo를 사용해 주엇다.

const Cell = ({ type }) => (
  <StyledCell type={type} color={TETROMINOS[type].color} />
);

export default React.memo(Cell);

바로 이 부분인데 게임의 한 칸은 테트로미노가 채워지기 전까지는 늘 type이 동일하며, 채워진 이후에는 게임이 끝날때까지 변할일이 없다.

따라서, 테트로미노가 한 칸 움직일때마다 리렌더링 될 필요가 없는 것이다.

결론적으로 React.memo는 렌더링된 결과 자체를 Memoizing하고 있다가, props의 변경이 없는 경우에 재사용하는 것이다.

(여기서 컴포넌트를 재사용한다는 표현이 조금 애매한 것 같긴하지만... 함수형 컴포넌트의 경우 함수를 재호출하지 않는다고 생각하면 되겠다.)

React.memo는 props의 변경이 자주일어나는 컴포넌트에 사용하면 오히려 성능악화를 초래할 수 있으므로,

props의 변경이 자주일어나지 않는 컴포넌트에 사용하는 것이 적절하다.

📌 useMemo와 useCallback

useMemo는 프로젝트 내에서 직접 사용하지는 않았지만, 최적화 옵션으로 항상 언급되므로 학습해보았다.

기본적으로 React.memo와 사용방법을 제외하고는 매우 흡사하다고 한다.

React.memo가 컴포넌트의 결과를 Memoizing한 것과 달리,

useMemo는 함수의 결과 값을 Memoizing한다.

useCallback은 함수 자체를 Memoizing한다.

결국 useMemo와 useCallback의 차이는 값을 반환하는지, 함수를 반환하는지 차이이다.

프로젝트에서 useCallback은 여러곳에서 사용하였는데 대표적인 예시는 아래와 같다.

  const resetPlayer = useCallback(() => {
    setPlayer({
      pos: { x: STAGE_WIDTH / 2 - 2, y: 0 },
      tetromino: randomTetromino().shape,
      collided: false,
    });
  }, []);

player가 조종하는 현재 테트로미노를 reset하는 함수인데, 늘 같은 pos, tetromino, collided로 reset시킨다.

해당함수는 늘 같은 역할을 하므로, 이를 담고있는 컴포넌트가 re-rendering 될 때 새롭게 함수가 정의될 필요가 없다.

(물론 이것이 사용자에게 유의미한 차이로 받아들여지려면 어느정도 규모여야 할지 아직 잘 모르겠다... 하지만, 이러한 상황이 최적화 옵션을 사용하기 좋은 상황인 것 같기는 하다.)

학습을 하고 사용해 보기까지 하였지만, 역시 적재적소에 사용하지 않고 무분별하게 사용하면 오히려 성능이 악화될 수 있다는 점이 마음에 걸린다.

이와같은 최적화 문제는 개인의 판단보다는 꾸준히 학습하고 여러사람의 의견을 들어보는 것이 좋을 것 같다는 생각이 들었다.

마치며

앞선 Hooks API들을 자세히 공부할때는 꽤나 많은 도움이 되었다는 생각이 들었지만,

최적화 부분에 있어서는 여전히 찝찝한 마음이 남았다.

When to useMemo and useCallback 를 읽어보면 (물론 나는 한글로 읽었다...)

확실히 최적화 작업은 신중하게 해야한다고 말하고 있다.

불필요한 렌더링에 대해서도 React는 충분히 빠른 퍼포먼스를 내고 있기 때문에, 다른 측면에 더욱 힘쓰는 것이 낫다고 한다...

번들링 파일 압축 또는 Splitting 등으로 서비스의 품질을 개선하는 것은 득이 되면 되었지 잃을건 없다고 생각하는데

위에서 살펴본 옵션들은 잘못하면 오히려 독이 된다고 하니... 더 많은 프로젝트를 하면서 유의미한 차이를 경험해 보고 싶다는 생각이 들었다.

Clone this wiki locally