React Hooks 이해하기 (2)

곽태욱·2021년 1월 18일
4

React 이해하기

목록 보기
3/4

React Hooks 공식 문서
https://ko.reactjs.org/docs/hooks-intro.html

앞서 살펴본 기본적인 3가지 hook 이외에도 React에는 다양한 hook이 존재한다.

useReducer

useState와 역할은 비슷한데, useState가 제공하는 기능 이외에도 상태 업데이트 로직을 외부 함수로 분리할 수 있는 기능을 제공한다. 그래서 상태를 새롭게 업데이트하는 로직이 복잡할 때 상태 업데이트 로직을 컴포넌트 외부에 따로 정의할 수 있다.

useStateuseReducer의 차이는 위 그림과 같다. 상태를 관리한다는 목적은 동일한데 상태를 업데이트하는 방법이 약간 다르다. useReducer는 미리 정의된 reducer 함수에 action과 이전 상태값을 입력값으로 넣어서 새로운 상태를 계산한다는 차이점이 있다.

코드 상 차이는 아래와 같다. 아래 코드는 상태를 업데이트하는 동일한 로직을 각각 useStateuseReducer로 구현했다.

import { useReducer, useState } from 'react'

function reducer(prevState, action) {
  switch(action.type) {
    case 'ADD':
      return prevState + action.value
    case 'MINUS':
      return prevState - action.value
    ...
  }
}

function HookComponent() {
  const [state1, setState] = useState(initialValue1)
  const [state2, dispatch] = useReducer(reducer, initialValue2)

  function handleClick() {
    setState(prev => reducer(prev, { type: 'ADD', value: 1 }))
    dispatch({ type: 'ADD', value: 1 })
  }
  
  ...
}

useRef

useRef도 상태를 관리한다는 점에선 useState와 비슷하다. useRef는 객체의 레퍼런스를 반환하는데 해당 객체는 컴포넌트가 마운트되고 언마운트될 때까지 유지된다. useState와 비슷하게 초기값을 설정할 수도 있다.

가장 큰 차이점은 useState는 반환되는 함수(e.g. setState)를 호출하면 render 함수 실행 후 reconcilation이 일어나지만, useRef는 따로 상태 변경 함수가 없고 직접 .current의 상태를 변경하기 때문에 변경 사항이 자동으로 브라우저 화면에 반영되지 않는다는 점이다.

하지만 컴포넌트 지역 변수와 다르게 useRef 상태는 컴포넌트가 언마운트될 때까지 유지되기 때문에, 여러 이유로 인해 해당 컴포넌트 render 함수가 실행되면 useRef 변경 사항이 화면에 반영될 수 있다. 이 현상은 아래 코드에서 확인할 수 있다. #CodeSandbox

import { useRef, useState } from 'react'

function ComponentWithState() {
  const [count, setCount] = useState(0)
  const countRef = useRef(0)
  
  return (
    <div>
      <div>useState count: {count}</div>
      <div>useRef count: {countRef.current}</div>
      
      {/* 변경 사항이 자동으로 화면에 반영된다. */}
      <button onClick={() => setCount((prev) => prev + 1)}>
        useState 상태 업데이트
      </button>
      
      {/* 변경 사항이 자동으로 화면에 반영되지 않는다. 상태는 유지된다. */}
      <button onClick={() => reference.current++}>
        useRef 상태 업데이트
      </button>
    </div>
  )
}
      
export default ComponentWithState 

그래서 useRef는 주로 자동으로 화면을 렌더링하지 않아도 되는 상태를 관리할 때 사용한다. 화면 렌더링과 관련이 없는 상태를 useState로 관리하면 render 함수가 불필요하게 실행되기 때문이다. 예를 들면 주로 DOM 노드의 레퍼런스를 관리할 때 사용한다.

useRef는 DOM 노드의 레퍼런스를 관리할 때도 사용되는데 만약 어떤 DOM 노드의 ref prop에 useRef 반환값을 넣어주면 React는 해당 값의 .current에 해당 DOM 노드의 레퍼런스를 넣어준다. 그리고 해당 DOM 노드가 변경될 때마다 React가 자동으로 .current을 업데이트해준다. #React

여기서 'DOM 노드의 변경'은 무엇을 의미할까? 해당 DOM 노드의 생성-삭제?

import { useRef } from 'react'

function ComponentWithReference() {
  const elementRef = useRef<HTMLElement | null>(null)
  
  return (
    <div>
      {/* input 노드가 변경될 때마다 elementRef.current가 자동으로 변경된다 */}
      <input ref={elementRef} />
    </div>
  )
}
      
export default ComponentWithReference 

useCallback

PureComponent(순수 컴포넌트)나 React.memo로 감싸진 컴포넌트는 props의 얕은 비교를 통해 렌터링 여부를 결정한다. 그리고 JavaScript는 함수 내용이 동일해도 생성된 시점이 다르면 다른 함수라고 판단한다. 만약 함수 컴포넌트에서 정의한 함수를 그대로 이런 컴포넌트의 props로 전달하면, 매 컴포넌트 렌더링 시 해당 함수가 재정의되기 때문에 순수 컴포넌트 props의 얕은 비교가 무의미해질 수 있다.

그래서 useCallback은 함수 컴포넌트의 props나 state가 변경됐을 때만 컴포넌트 내부에 정의된 함수를 재정의하기 위해 등장했다. 의존성 배열 원소를 얕게 비교했을 때 기존과 동일하면 useCallback은 기존에 정의된 함수를 반환한다.

언제

useCallback을 이용해 함수를 정의해야 할 때는 아래와 같다.

  • React.memo로 감싸진 컴포넌트나 PureComponent에 props로 전달되는 함수
  • 다른 hook의 의존성 배열에 포함되는 함수

이외의 경우에는 아래 표를 기준으로 사용할지 말지 결정한다.

useCallback 사용 XuseCallback 사용 O
새로운 메모리 할당 및 함수 생성 비용의존성 배열 얕은 비교 비용, 가독성 저하 가능성

이유

클래스 컴포넌트를 사용할 땐 메소드를 재정의한다는 개념이 없었던 거 같은데 왜 함수 컴포넌트는 useCallback이라는 개념이 필요할까? 왜 함수 컴포넌트는 props나 state가 변경될 때마다 컴포넌트 내부에 정의된 함수를 매번 재정의하는 것일까?

클래스 컴포넌트

import { Component } from 'react'

class ClassComponent extends Component {
  constructor(props) {
    super(props)
    this.state = { state1: 0 } // 상태 초기값 설정
    this.handleClick = this.handleClick.bind(this) // 메소드 바인딩
  }
  
  handleClick() {
    console.log(this.props.id) // props 접근
    console.log(this.state.state1) // 상태 접근
    this.setState(...) // 상태 업데이트
    ...
  }
  
  render() {
    return (
      <button onClick={this.handleClick}>
        클릭 버튼
      </button>
    );
  }
}

위 그림과 같이 클래스 컴포넌트의 메소드는 this.statethis.props를 통해 props와 state 값을 얻는다. 그래서 컴포넌트가 마운트된 후 props나 state가 변해도 handleClick 메소드를 재정의할 필요가 없다. 왜냐하면 컴포넌트가 마운트된 후 언마운트될 때까지this.statethis.props는 항상 동일한 메모리 주소에 존재하기 때문이다.

함수 컴포넌트

function FunctionComponent({ id }) {
  const [state1, setState1] = useState(0) // 상태 초기값 설정
  const [state2, setState2] = useState(0) // 상태 초기값 설정
  
  const handleClick = useCallback(() => {
    console.log(id) // props 접근
    console.log(state1) // 상태 접근
    console.log(state2) // 상태 접근
    setState(prev => prev + 1) // 상태 업데이트
  }, [id, state1, state2])
  
  return (
    <button onClick={handleClick}>
      클릭 버튼
    </button>
  )
}

클래스 컴포넌트의 this.setState와 달리, 함수 컴포넌트에서 state를 갱신하는 것은 기존 상태와 병합하는 것이 아니라 기존 상태를 대체하는 것입니다. #React

함수 컴포넌트에서 사용되는 props와 useState의 state는 상태 관리의 편의를 위해 값 또는 불변 객체로 관리된다. 그래서 함수 컴포넌트에선 상태를 변경하는 함수(e.g. setState)를 호출할 때마다 props와 state에 새로운 값 또는 객체 레퍼런스가 할당된다.

그래서 컴포넌트가 마운트된 후 1번과 같이 props나 state가 변하면 handleClick 함수를 재정의해야 한다. 왜냐하면 함수 컴포넌트에서 props나 state는 계속 변화하는 값 또는 불변 객체를 가리키는 레퍼런스이기 때문이다. 기존 정의된 함수엔 기존 상태의 주소가 저장되어 있기 때문에 새로운 상태에 접근할 수 없다.

만약 기존 값이 저장된 메모리 주소 100번과 200번의 값을 변경하면 기존 정의된 함수에서도 새로운 상태에 접근할 수 있다. 하지만 React는 props와 state를 그렇게 관리하지 않기 때문에 새로운 함수를 재정의하는 것이다.

우리는 의존성 배열(dependency array)을 통해 useCallback에게 함수를 재정의해야 하는 시점을 알려줄 수 있다.

그림에서 명시된 메모리 주소는 실제와 다를 수 있습니다.

여담

주의 : Function.prototype.bind를 render 메소드에서 사용하면 컴포넌트가 렌더링할 때마다 새로운 함수를 생성하기 때문에 성능에 영향을 줄 수 있습니다. #React

사실 클래스 컴포넌트도 render 함수 안에서 메소드를 bind해서 매 render 함수를 실행할 때마다 메소드를 재정의할 수 있다. 하지만 그럴 경우 매 렌더링 시마다 불필요하게 동일한 내용의 메소드를 재정의하기 때문에 React는 constructor 함수에서 메소드를 bind 하는 것을 권장하고 있다.

useMemo

useMemo는 함수 컴포넌트 내부에서 동일한 입력일 때 동일한 결과값을 가지는 계산의 불필요한 실행을 방지하기 위해 등장했다.

React는 특정 컴포넌트의 상태가 업데이트되거나 구독 중인 context의 value가 변경되면 해당 컴포넌트와 그의 모든 자식 컴포넌트의 render 함수를 실행하는데, 이때 컴포넌트 내부 변수도 다시 계산돼서 정의된다. 만약 변수 계산 과정이 상당히 오래 걸리는데 계산 결과는 동일하면 매 렌더링 시마다 굳이 다시 계산할 필요가 없을 것이다.

그래서 이때 useMemo를 사용해서 동일한 의존성 배열 값을 가질 땐 계산을 건너뛰고 기존에 계산한 값을 재사용하면 좋다.

위의 useCallback의 경우와 비슷하게, PureComponent나 React.memo로 감싸진 컴포넌트에 객체를 props로 전달할 땐 객체의 불변성을 보장하기 위해 useMemo를 사용해야 한다. useMemo를 사용하지 않고 그냥 전달하면 매 컴포넌트 렌더링 시 새로운 레퍼런스의 객체가 생성되기 때문에 순수 컴포넌트 props의 얕은 비교가 무의미해질 수 있다.

useMemo는 기본적으로 바로 이전 값만 메모이제이션하기 때문에 여러 개의 이전 값을 메모이제이션할 필요가 있으면 직접 구현해야 한다.

useMemouseCallback를 사용하기 전엔 해당 값-함수가 컴포넌트 바깥에서 정의될 수 있는지 확인해야 한다. 변수 계산이나 함수 정의는 최대한 컴포넌트 바깥에서 해결하고, 컴포넌트 porps나 state에 의존하는 값-함수일 때만 컴포넌트 내부에서 useMemouseCallback를 이용해 정의한다.

언제

useMemo를 이용해 값을 메모이제이션해야 할 때는 아래와 같다.

  • React.memo로 감싸진 컴포넌트나 PureComponent에 props로 전달되는 객체
  • 다른 hook의 의존성 배열에 포함되는 객체
  • 동일한 입력일 때 동일한 결과값을 가지는데 오래 걸리는 계산

이외의 경우에는 아래 표를 기준으로 비용을 잘 비교해서 useMemo를 사용할지 말지 결정한다.

useMemo 사용 XuseMemo 사용 O
값 재계산 비용의존성 배열 얕은 비교 비용, 가독성 저하 가능성

메모이제이션

동일한 입력을 줬을 때 동일한 결과값이 나오는데 굳이 결과값을 다시 계산해야 하는가? 그럴 필요가 없기 때문에 이런 경우에 값을 메모이제이션한다. 메모이제이션 개념은 기존의 사용한 값을 따로 저장한다는 점에서 캐싱과 비슷하다.

순수 함수

useMemo는 의존성 배열의 항목을 얕게 비교해서 모두 동일하면 계산을 건너뛰고 이전에 계산된 결과값을 그대로 사용한다. 따라서 동일한 의존성 배열 항목을 줬을 때 동일한 결과값이 나온다는 것이 보장되어야 하기 때문에 값을 계산하는 함수는 부작용이 없는 순수 함수여야 한다.

클로저

function createClosure(param) {
  const privateVar = param;
  return (num) => privateVar + num;
}

const closure = createClosure(5);
closure(10);  // 15

const closure2 = createClosure(10);
closure2(20);  // 30

이전 계산 결과는 클로저 개념을 이용해서 함수 내부에 메모이제이션 할 수 있다. 위와 같이 클로저는 함수 내부에 private 변수를 가질 수 있는 함수라고 생각하면 된다. 그래서 이전 결과를 함수 내부에 저장할 수 있다.


아래 3개 hook은 사용한 경험이 적어서 나중에 쓸 예정

useImperativeHandle

상위 컴포넌트가 하위 컴포넌트 ref에 접근할 수 있는 기능을 제공한다.

useLayoutEffect

useLayoutEffectuseEffect의 원리와 사용 방법이 동일하지만 함수가 실행되는 시점이 다르다. useEffect는 DOM 갱신과 화면 그리기가 모두 완료된 후 실행되지만, useLayoutEffect는 DOM을 갱신하고 브라우저가 화면을 그리기 전에 동기적으로 실행된다.

서버 측 렌더링 (SSR)

server-side rendering(next.js)에서 실행하면 경고가 뜬다. 왜? 언제?

브라우저의 화면 렌더링

브라우저는 어떻게 동작하는가? #NaverD2

  1. HTML 파일을 파싱해서 HTML DOM 트리 생성
  2. CSS 파일을 파싱해서 CSSOM 트리 생성
  3. DOM과 CSSOM을 합쳐서 Render 트리 생성
  4. Render 트리 각 노드의 레이아웃 계산 (스케치)
  5. Render 트리 각 노드를 화면에 그리기 (색칠)

브라우저가 화면을 렌더링하는 과정에서 useEffect는 5번 이후 실행되지만, useLayoutEffect는 4번과 5번 사이에서 동기적으로 실행된다.

useDebugValue

사용자 지정(custom) Hook의 디버깅을 도와준다.

profile
이유와 방법을 알려주는 메모장 겸 블로그. 블로그 내용에 대한 토의나 질문은 언제나 환영합니다.

0개의 댓글