본문 바로가기

FrontEnd/React

[React] useMemo와 useCallback 훅 이해하기

리액트 훅의 기본 원리

  리액트 훅 함수를 이해하려면 유효 범위(scope)에 대해서 알아야 한다.   리액트 함수 컴포넌트를 기준으로 볼때 컴포넌트 는 함수이기 때문에 중괄호 { } 안쪽의 범위를 블록범위라고 하며 블록범위 안쪽의 변수를 지역 변수라고한다.

 

 아래와 같이 local변수는 지역변수라고 할 수 있으며 return을 통해 함수를 벗어날 경우 local 변수는 소멸한다.

export default function UseOrCreate() {
    const local = 1
    return <p>{local}</p>
}

 

 상태와 캐시

  프로그래밍에서 상태(state)란 변수의 유효 범위와 무관하게 계속 유지(preserve)하는 값을 의미한다.  상태의 종류는 한번 설정 후 변경이 불가능한 '읽기전용(readonly)'의 개념을 가진 불변 상태(immutable state)와 반대로 값을 바꿀 수 있는 가변상태(mutable state)로 나뉜다.

 

  함수 컴포넌트는 '함수' 이므로 블록 범위({ }) 개념 때문에 상태를 가질 수없다.  함수 컴포넌트가 상태를 가질 수 있는 방법은 상태를 변경할 변수를 함수 블록 범위 밖에 선언해 블록 안의 영향을 받지 않게 하는 것이다. 

 

  이처럼 함수 컴포넌트 블록 밖에 선언한 변수를 전역 변수(global variable)라고 한다.

const global = 1
export default function UseOrCreate() {
    return <p>{global}</p>
}

 

  리액트 훅은 상태를 가질 수 없는 함수 컴포넌트에 상태를 갖고 있는 것처럼 동작할 수 있게 한다.  그리고 이런 개념을 이용해 캐시(cache)를 전역 변수 형태로 만들어 구현할 수 있다.  

 

 캐시 구현하기

  우선 cache로 사용할 변수를 전역변수로 선언한다.  Record 타입을 사용했는데 형태는 key, value로 사용할 수 있으며 제네릭이다.   useOrCreate 함수를 호출해 key존재 유무에 따라 cache에 callback함수를 통해 추가를 하거나 반환을한다.  

const cache: Record<string, any> = {}

// 제네릭 타입 <T> 
// 반환 타입과 콜백 함수에서 생성하는 값의 타입을 동적으로 지정할 수 있다.
// 이를 통해 다양한 타입의 데이터를 처리할 수 있다.
export const useOrCreate = <T>(key: string, callback: () => T): T => {
  if (!cache[key]) cache[key] = callback()
  return cache[key] as T
}

 

 캐시 사용하기

  구현한 캐시를 통해 코드를 추가한다.  useOrCreate 함수를 사용해 head, body란 이름으로 데이터를 캐시한다.  다만 CreateOrTest 컴포넌트가 렌더링 될 때마다 반복되어 생성되지 않고 컴포넌트가 생성될 때 한번만 생성된다.

 

  그 덕분에 코드 실행 시 중요 데이터는 캐시를 이용 했기 때문에 화면의 랜더링 속도가 빠른 것을 느낄 수 있다.

import {Title, Avatar} from '../components'
import * as D from '../data'
import {useOrCreate} from './useOrCreate'

export default function UseOrCreateTest() {
  // prettier-ignore
  const headTexts = useOrCreate<string[]>('headTexts',() => [
    'No.', 'Name', 'Job Title', 'Email Adress'
  ])
  const users = useOrCreate<D.IUser[]>('users', () =>
    D.makeArray(3).map(D.makeRandomUser)
  )

  const head = useOrCreate('head', () =>
    headTexts.map(text => <th key={text}>{text}</th>)
  )

  const body = useOrCreate('children', () =>
    users.map((user, index) => (
      <tr key={user.uuid}>
        <th>{index + 1}</th>
        <td className="flex items-center">
          <Avatar src={user.avatar} size="1.5rem" />
          <p className="ml-2">{user.name}</p>
        </td>
        <td>{user.jobTitle}</td>
        <td>{user.email}</td>
      </tr>
    ))
  )

  return (
    <div className="mt-4">
      <Title>UseOrCreateTest</Title>
      <div className="overflow-x-auto mt-4 p-4">
        <table className="table table-zebra table-compact w-full">
          <thead>
            <tr>{head}</tr>
          </thead>
          <tbody>{body}</tbody>
        </table>
      </div>
    </div>
  )
}

 

실행결과

 

 캐시와 의존성 목록

  리액트 프레임워크 내부에서 관리되는 캐시된 값은 어떤 상황이 일어나면 값을 갱신해줘야 한다.  리액트 훅에서는 캐시를 갱신하는 요소를 의존성(dependency)라고 하며 이러한 의존성으로 구성된 배열을 의존성 목록(dependency list)라고 한다.

 

  의존성 목록 중 조건이 충족되면 캐시된 값을 갱신하고 해당 컴포넌트를 다시 렌더링해 변경사항을 반영한다.  다만 이런 캐시갱신이 필요없다면 의존성 목록을 빈 배열[] 로 사용하면 된다.

 

 함수 컴포넌트와 리액트 훅을 사용하는 이유

  리액트는 컴포넌트의 속성값이 변할 때 최신 값이 반영되도록 다시 렌더링한다.  하지만 컴포넌트 내부 로직에서는 컴포넌트가 다시 렌더링 되는 때를 탐지하기 어려워 클래스 기반 컴포넌트의 경우 다양한 메서드를 구현해 렌더링 엽루르 판단한다.

 

  반면 함수 컴포넌트에 리액트 훅을 사용 시 리액트 프레임워크가 의존성 목록에서 변한 값을 기준으로 판단하기 때문에 다시 렌더링하는 시점을 판단하기가 쉽기 때문에 클래스 기반 컴포넌트 처럼 메서드를 일일이 구현할 필요가 없어 개발이 수월하다.

 

데이터를 캐시하는 useMemo 훅

 react 패키지는 데이터를 캐시하는 용도로 useMemo 훅을 제공한다.   아래와 같이 선언문을 보면 제네릭 함수이며 의존성 목록의 타입은 DependencyList(읽기전용배열) 이다.  useMemo또한 의존성 목록에 있는 의존성이 변경될 때마다 콜백 함수를 자동으로 호출하여 의존성을 반영한다.

// useMemo 훅 선언문
function useMemo<T>(factory: () => T, deps: DependencyList | undefined): T;

// useMemo 훅 사용법
const 캐시된_데이터 = useMemo(콜백_함수, [의존성1, 의존성2, ...])
콜백_함수 = () => 원본_데이터

 

 기존 구현된 useOrCreate함수를 제거하고 useMemo를 통해 구현한 코드를 추가한다.  물론 결과화면은 동일하다.

import {useMemo} from 'react'
import {Title, Avatar} from '../components'
import * as D from '../data'

export default function Memo() {
  // prettier-ignore
  const headTexts = useMemo<string[]>(() => [
    'No.', 'Name', 'Job Title', 'Email Adress'
  ], [])
  const users = useMemo<D.IUser[]>(() => D.makeArray(3).map(D.makeRandomUser), [])

  const head = useMemo(
    () => headTexts.map(text => <th key={text}>{text}</th>),
    [headTexts]
  )

  const body = useMemo(
    () =>
      users.map((user, index) => (
        <tr key={user.uuid}>
          <th>{index + 1}</th>
          <td className="flex items-center">
            <Avatar src={user.avatar} size="1.5rem" />
            <p className="ml-2">{user.name}</p>
          </td>
          <td>{user.jobTitle}</td>
          <td>{user.email}</td>
        </tr>
      )),
    [users]
  )

  return (
    <div className="mt-4">
      <Title>Memo</Title>
      <div className="overflow-x-auto mt-4 p-4">
        <table className="table table-zebra table-compact w-full">
          <thead>
            <tr>{head}</tr>
          </thead>
          <tbody>{body}</tbody>
        </table>
      </div>
    </div>
  )
}

 

실행결과

 

콜백 함수를 캐시하는 useCallback 훅

 컴포넌트 내의 변수를 캐시하기 위해 useMemo훅을 사용한다.  그런데 useMemo훅은 함수까지 캐시할 수 없다.  함수 또한 컴포넌트 안에서 캐시하지 않으면 컴포넌트가 렌더링 될 때마다 계속 다시 생성되어 비 효율적이다.

 

 이런 부분을 고려해 react는 useCallback 훅을 제공하며 사용하는 방법은 다음과 같다.  사용개념은 useMemo와 같으며 useCallback훅은 콜백 함수를 캐시한다.

// 선언문
function useCallback<T extends (...args: any[]) => any>(callback: T, deps:DependencyList): T;

// 사용법
const 캐시된_콜백_함수 = useCallback(원본_콜백_함수, 의존성 목록)

 

 

 useClassback 훅의 타입 변수 T는 '(...args: any) => any'라는 타입 제약(type constraint)이 걸려있다.  이는 타입 변수T는 함수여야 함을 의미한다. 


 권장하지 않는 예를 보면 callback함수가 항상 새로 만들어 지므로 useCallback 훅을 사용하는 의미가 퇴색된다.

 권장하는 예를 보면 callback함수의 타입과 매개변수 부분이 ...args: any[]) => any 여기에 합당하다.

// 권장하지 않는 사용 예
const onClick = useCallback(() => alert('button clicked'), [])

// 권장하는 사용 예
const callback = () => alert('button clicked')
const onClick = useCallback(callback, [])

 

 useCallback 훅을 사용해보기 위해 코드를 추가한다.  하지만 이대로라면 어떤 버튼을 클릭했는지까지는 알 수없다.  이를 개선하기 위해 고차 함수를 사용해야 한다.

import {useMemo, useCallback} from 'react'
import {Title} from '../components'
import {Button} from '../theme/daisyui'
import * as D from '../data'

export default function Callback() {
  const onClick = useCallback(() => alert('button clicked'), [])

  const buttons = useMemo(
    () =>
      D.makeArray(3)
        .map(D.randomName)
        .map((name, index) => (
          <Button
            key={index}
            onClick={onClick}
            className="btn btn-primary normal-case btn-wide btn-xs">
            {name}
          </Button>
        )),
    [onclick]
  )
  return (
    <section className="mt-4">
      <Title>Callback</Title>
      <div className="flex justify-evenly mt-4">{buttons}</div>
    </section>
  )
}

 

실행결과

 

 고차 함수 사용하기

  함수형 언어에서는 함수와 변수를 차별하지 않기 때문에 함수는 다른 함수의 매개변수나 반환값으로 사용될 수 있다.

 고차함수(higher-order function)란 다른 함수를 반환하는 함수를 의미한다.

 

 리액트에서 고차함수는 콜백 함수에 추가로 정보를 전달하려고 할 때 주로 사용한다.  하단의 코드로 보았을 때 alert(`${name} clicked`) 라는 함수를 다시 바환하므로 고차 함수이다.

//고차 함수 예
const onClick = useCallback((name: string) => () => alert(`${name} clicked`), [])

 

  리액트 프로그래밍에서 고차함수를 구현하는 이유함수의 타입 불일치를 해결하기 위해서이다.

// onClick 이벤트 속성
// 타입 불일치로 name 변수를 전달할 수 없다.
() => alert(`${name} clicked`)

// 고차 함수 사용으로 타입 일치
(name: string) => () => alert(`${name} clicked`)

 

  고차함수를 확인하기 위해 코드를 추가한다.  아래 onClick함수에서 보면 함수에 함수를 포함시킨 고차함수로 구현됨을 확인할 수 있다.

import {useMemo, useCallback} from 'react'
import {Title} from '../components'
import {Button} from '../theme/daisyui'
import * as D from '../data'

export default function Callback() {
  const onClick = useCallback((name: string) => () => alert(`${name} clicked`), [])

  const buttons = useMemo(
    () =>
      D.makeArray(3)
        .map(D.randomName)
        .map((name, index) => (
          <Button
            key={index}
            onClick={onClick(name)}
            className="normal-case btn btn-primary btn-wide btn-xs">
            {name}
          </Button>
        )),
    [onclick]
  )
  return (
    <section className="mt-4">
      <Title>Callback</Title>
      <div className="flex mt-4 justify-evenly">{buttons}</div>
    </section>
  )
}

 

실행결과