본문 바로가기

개념/React

[React] 메모이제이션(Memoization) 무엇이고, 언제 어떻게 쓸까?

본 글은 다음의 글을 의역하고 추가한 글입니다.

https://www.freecodecamp.org/news/memoization-in-javascript-and-react/

 


 

이번 글은 메모이제이션에 대해 알아보려 합니다. 

메모이제이션(memoization)은 무거운 연산 과정을 더 효율적으로 계선해주는 기술입니다.

 

이 글에서 메모이제이션이 무엇이고, 어떤 곳에 사용할때 좋을지에 대해 이야기 해볼 것입니다.

자바스크립트와 리액트에서의 예시도 나와있습니다.😊

 

 


💡 메모이제이션이란?

 

프로그래밍에서 메모이제이션이란 애플리케이션을 더욱 효율적이고 빠르게 만들어주는 최적화 기술입니다.

 

메모이제이션이 최적화 할 수 있는 원리는 바로 연산의 결과를 캐시에 저장해 놓고, 

다음번에 동일한 연산이 필요할 때에 캐시로 부터 알아오기 때문입니다.

 

메모이제이션의 동장을 두가지로 단순하게 나누자면 다음의 두가지 일로 바꿀 수 있습니다.

 

1. 함수의 결과를 캐시에 저장하는 일

2. 함수로 하여금 캐시에 힐요한 연산의 결과가 이미 저장되어 있는지 체크하는 일

 

캐시는 쉽게 말하자면, 미래에 데이터가 필요한 순간에 더 빠르게 작업을 수행할 수 있도록 데이터를 담고 있는 임시 데이터 저장소입니다. 

 

메모이제이션은 우리 코드의 속도를 더 높여줄 단순하지만 강력한 방법입니다.

특히, 반복적이고 무거운 연산을 담는 함수에 있어서는 그 효과가 더 탁월합니다.

 


 

🤼‍♀️ 메모이제이션은 어떻게 동작하는가?

 

자바스크립트의 메모이제이션은 다음의 두가지 원리에 의존합니다.

 

  • 클로저(Closure): 클로저란 함수와 함수가 선언된 어휘적 환경(lexical environment)의 조합입니다. 클로저에 관한 글은 여기에서 확인할 수 있습니다. 
  • 고차함수: 고차함수란 함수를 인자로 받거나 함수를 리턴하는 함수를 말합니다. 대표적인 고차함수의 예시는 여기에서 확인할 수 있습니다.

📌 자바스크립트 메모이제이션 예시

 

메모이제이션이란 알쏭달쏭한 개념을 분명하게 이해하기 위해서 우리가 가장 쉽게 접할 수 있는 예시는 클래식한 피보나치의 수열 연산입니다.

 

피보나치의 수열이란 0으로 시작하여, 두번째 숫자인 1 이후로, 이전 숫자와 전전숫자의 합을 요소로 갖는 숫자의 세트입니다. 다음은 피보나치 수열의 모습입니다.

 

 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, ...

 

만약 우리가 피보나치의 수열에서 n번째 요소를 리턴하는 함수를 만들어야 한다고 가정해봅시다.

각각의 요소들은 이전 두 요소의 합이란 것을 이용하여 재귀를 사용하여 문제를 해결하면 다음과 같습니다.

 

const fib = n => {
  if (n <= 1) return 1
  return fib(n - 1) + fib(n - 2)
}

 

만약 재귀함수가 낯설게 느껴지신다면, 간단히 말해, 함수 안에서 함수자기자신을 다시 호출하는 함수를 말합니다.

재귀함수는 종료조건으로 인해 무한 반복을 막을 수 있습니다. (위 코드의 예시에서는 if(n <= 1))이 base case에 해당합니다.)

 

만약 우리가 fib(5)를 호출한다면, 우리의 코드는 이런 식으로 실행됩니다.

 

 

위 그림을 잘 보시면 fib(5) 한번을 실행했을 뿐인데, 사실 fib(0), fib(1), fib(2) 그리고 fib(3)이 중복되어 여러번 호출되는 것을 볼 수 있습니다. 이 상황이 딱! 메모이제이션이 필요한 상황입니다.

 

메모이제이션을 사용하면, 같은 값을 두번이상 반복하여 재계산할 필요가 없습니다. 그냥 각각의 연산을 저장해두고 필요할때 같은 값을 꺼내서 리턴시키기만 하면 됩니다.

 

메모이제이션을 사용하면 우리의 함수는 이렇게 표현할 수 있습니다.

const fib = (n, memo) => {
    memo = memo || {} //처음실행될때 memo가 없다면 memo를 빈객체로 만들고, 반복시행되면 memo를 받아서 할당

    if (memo[n]) return memo[n] //memo의 n번째 index가 있다면 값을 리턴
    
    //memo[n]이 없다면
    if (n <= 1) return n //0번째 1번째 일땐 0과 1을 리턴
    return memo[n] = fib(n-1, memo) + fib(n-2, memo) //지금 수=전 수 + 전전 수
}

 

위 함수에서 가장 처음 하는 일은 memo 오브젝트를 인자로 전달받았는지 확인하는 일입니다. 

만약 memo가 전달되지 않았다면, 빈 오브젝트를 memo로 세팅합니다.

 

memo = memo || {}

 

그 후, 우리는 메모에 인자로 받은 n이 키값으로 memo에 들어있는지 확인합니다. 만약 fib[n] 이 있다면, 그것을 리턴합니다. 

여기에서 메모이제이션의 마법이 벌어집니다. memo에 값을 저장하면 중복되는 재귀를 실행시키지 않아도 되는 것이죠!

 

if (memo[n]) return memo[n]

 

만약 우리가 memo에 값을 갖고 있지 않다면, 우리는 fib를 재귀로 또한번 호출합니다. 하지만 이번에는 키와 값이 들어있는 memo객체를 전달하며 호출하는 것이죠. 그렇기때문에, 함수는 이전 호출때 memoization된 값을 불러올 수 있는 것입니다.

때문에 반드시 리턴을 하기 전에 결과값을 캐시에 저장해야한다는 사실에 유의합니다.

 

return memo[n] = fib(n-1, memo) + fib(n-2, memo)

 

여기까지 잘 보셨나요?

단순한 재귀함수 코드에 단 두줄을 더했을 뿐인데, 우리 함수의 성능은 혁신적으로 향상했습니다. 이것이 바로 메모이제이션의 힘입니다.

 


 

📌 리액트 메모이제이션 예시

 

리액트에서 우리는 메모이제이션을 활용해서 불필요한 리랜더링 막고 어플리케이션을 최적화 시킬 수 있습니다.

 

리액트에서 컴포넌트들은 다음의 두가지 요인으로 인해 리랜더링됩니다: 

  • state의 변화
  • props의 변화

이것이 바로 우리가 불필요하게 화면을 다시 띄우는 일을 막기위해 "캐시"에 저장해야하는 정보입니다.

 

하지만, 바로 코드를 살펴보기 전에, 몇가지 알아둬야 할 개념에 대해 먼저 설명하겠습니다.

 


Pure Component

 

React는 클래스 컴포넌트, 함수 컴포넌트 두가지를 지원합니다. 함수 컴포넌트는 jsx를 리턴하는 일반 자바스크립트 함수입니다.

그리고 클래스 컴포넌트는 render라는 메서드 안에서 jsx를 리턴하는 React.Component의 인스턴스입니다.

 

 

그렇다면 Pure Component란 무엇일까? 

Pure Component는 함수 프로그래밍 패러다임에서의 순수함수 개념에 기반합니다. 순수함수는 다음과 같은 특성을 갖습니다.

  • 오직 값의 입력만이 반환 값에 영향을 줍니다.
  • 같은 값을 받으면 항상 같은 값을 리턴합니다.

같은 방식으로, 리액트 컴포넌트는 같은 state와 props라면 같은 결과를 리턴하기에 "순수"하다고 봅니다.

 

순수 함수 컴포넌트는 다음과 같이 생겼습니다.

 

// Pure component
export default function PureComponent({name, lastName}) {
  return (
    <div>My name is {name} {lastName}</div>
  )
}

PureComponent는 두가지 props를 전달받습니다. 그리고 그 두가지 props를 랜더합니다.

만약 props가 이전과 동일하다면 랜더되는 결과 역시 동일 하겠지요!

 

그럼 이번에는 랜더링하기 전에 무작위한 숫자를 prop에 더하는 작업을 추가한 컴포넌트를 생각해 볼겠습니다.

그렇다면 비록 같은 props가 들어와도 그 결과는 매번 다를 것이고, 이런 컴포넌트는 impure component라고 볼 수 있습니다.

 

// Impure component
export default function ImpurePureComponent({name, lastName}) {
  return (
    <div>My "impure" name is {name + Math.random()} {lastName + Math.random()}</div>
  )
}

pure component, impure component의 예시 두가지를 클래스 컴포넌트로 동일하게 작성하면 아래와 같습니다.

 

// Pure component
class PureComponent extends React.Component {
    render() {
      return (
        <div>My "name is {this.props.name} {this.props.lastName}</div>
      )
    }
  }

export default PureComponent
// Impure component
class ImpurePureComponent extends React.Component {
    render() {
      return (
        <div>My "impure" name is {this.props.name + Math.random()} {this.props.lastName + Math.random()}</div>
      )
    }
  }

export default ImpurePureComponent

 


Pure Component Class : 클래스 컴포넌트에서 메모이제이션

 

class pure components를 위해서 리액트는 메모이제이션을 위해 PureComponent라는 기본 클래스를 제공합니다.

 

클래스 컴포넌트들을 만들기 위해선 React.PureComponent 클래스를 인스턴스로 떠와야합니다. React.PureComponent는 몇몇 성능 향상과 랜더 최적화를 위한 장치들이 들어있습니다.

이는 리액트가 props와 state의 얕은 비교를 통해 shouldComponentUpdate() 메서드를 시키기 때문입니다.

 

이제 예시를 한 번 보겠습니다. 아래 코드엔 카운터 기능을 수행하는 클래스 컴포넌트가 존재합니다. 컴포넌트는 숫자를 더하고 빼는 기능을 하는 버튼들이 들어있습니다. 또한, prop으로 string 타입의 name을 전달하는 자식 컴포넌트도 존재합니다. 

import React from "react"
import Child from "./child"

class Counter extends React.Component {
    constructor(props) {
      super(props)
      this.state = { count: 0 }
    }

    handleIncrement = () => { this.setState(prevState => {
        return { count: prevState.count - 1 };
      })
    }

    handleDecrement = () => { this.setState(prevState => {
        return { count: prevState.count + 1 };
      })
    }

    render() {
      console.log("Parent render")

      return (
        <div className="App">

          <button onClick={this.handleIncrement}>Increment</button>
          <button onClick={this.handleDecrement}>Decrement</button>

          <h2>{this.state.count}</h2>

          <Child name={"Skinny Jack"} />
        </div>
      )
    }
  }

  export default Counter

 

자식 컴포넌트는 그냥 받은 prop을 랜더하기만 하는 pure component입니다.

 

import React from "react"

class Child extends React.Component {
    render() {
      console.log("Skinny Jack")
      return (
          <h2>{this.props.name}</h2>
      )
    }
  }

export default Child

 

위 두가지 컴포넌트를 콘솔로그에 찍어보면, 매 랜더링될 때마다 콘솔 메세지가 찍히는 것을 확인할 수 있습니다.

그렇다면 과연 증가버튼 감소버튼을 누르면 어떤 일이 일어날지 예측할 수 있나요?

우리의 콘솔은 다음과 같습니다.

 

 

자식 컴포넌트는 매번 같은 prop를 받고 있음에도 계속 리랜더링되고 있는 모습을 확인할 수 있습니다.

prop으로 전달받고 있는 name이란 값은 변경되지 않은채 계속 "Skinny Jack"일 뿐인데 말입니다.

 

이 상황에서 메모이제이션을 사용해서 최적화하기위해서, 우리는 자식 컴포넌트를 아래와 같이 React.PureComponent 클래스를 떠온 인스턴스로 만들어줄 수 있습니다.

 

import React from "react"

class Child extends React.PureComponent {
    render() {
      console.log("Skinny Jack")
      return (
          <h2>{this.props.name}</h2>
      )
    }
  }

export default Child

 

이렇게 선언해주고 버튼을 누르면 콘솔창은 아래와 같습니다.

 

 

자식 컴포넌트의 첫 랜더링이후 prop이 바뀌지 않는 한, 불필요한 랜더링을 진행하지 않습니다. 아주 쉽죠! ;)

 

클래스 컴포넌트에선 이렇게 React.PureComponent의 인스턴스를 만들어서 부모 컴포넌트의 변화에 영향을 받지 않는 순수한 클래스 컴포넌트를 만들 수 있지만, 함수 컴포넌트는 당연히  React.PureComponent 클래스를 extend해올 수 없습니다.

 

대신, 리액트는 memoization을 다룰 수 있는 HOC(Higher-Order Coponents, 고차 컴포넌트)와 두가지 Hook을 제공합니다.

 

 


Memo 고차 컴포넌트(HOC) 이용하기 : 함수 컴포넌트에서 메모이제이션 1️⃣

 

만약 우리가 이전 pure component로 바꾸기 전의 클래스 컴포넌트 코드를 함수 컴포넌트로 바꾼다면 아래와 같습니다.

 

import { useState } from 'react'
import Child from "./child"

export default function Counter() {

    const [count, setCount] = useState(0)

    const handleIncrement = () => setCount(count+1)
    const handleDecrement = () => setCount(count-1)

    return (
        <div className="App">
            {console.log('parent')}
            <button onClick={() => handleIncrement()}>Increment</button>
            <button onClick={() => handleDecrement()}>Decrement</button>

            <h2>{count}</h2>

            <Child name={"Skinny Jack"} />
        </div>                    
    )
}

 

import React from 'react'

export default function Child({name}) {
console.log("Skinny Jack")
  return (
    <div>{name}</div>
  )
}

 

여기까지 하면 여전히 부모 컴포넌트가 랜더링될 때 아무 관련없는 자식 컴포넌트도 매번 리랜더링이 되는 문제가 동일하게 발생합니다.

이를 해결하기 위해 우리는 자식 컴포넌트 상위에 memo라는 컴포넌트로 감싸줄 수 있습니다. 아래의 코드를 봅니다.

 

import React from 'react'

export default React.memo(function Child({name}) {
console.log("Skinny Jack")
  return (
    <div>{name}</div>
  )
})

HOC, 고차 컴포넌트는 자바스크립트의 고차함수와 비슷합니다. 고차 함수는 말했듯이 함수를 인자로 받거나 함수를 리턴하는 함수를 말합니다. 

리액트의 HOC은 컴포넌트를 prop으로 가져오고, 그를 조작해서 컴포넌트 자체는 바꾸지 않으면서 조작합니다.

컴포넌트를 감싸는 컴포넌트(wrapper component)라고 보면됩니다. 

 

위 코드의 예시에선 memo가 React.PureComponent 와 비슷한 역할을 합니다. 컴포넌트를 감싼 memo컴포넌트의 영향으로 불필요한 리랜더를 수행하지 않게됩니다. 

 

메모이제이션을 활용하여 React.PureComponent처럼 prop이 변하지 않는한, 부모 컴포넌트의 변화화 무관하게 랜더링되도록 할 수 있는 것입니다.

 

 


useCallback Hook을 이용하기 : 함수 컴포넌트에서 메모이제이션 2️⃣

 

중요하게 짚고 넘어가야 할 것은 메모는 만약 하위 컴포넌트에 전달되는 prop이 함수인 경우 무효하다는 점입니다.

이 현상을 경험해 보기위해 이전 코드에서 약간 바꾼 아래의 코드를 봅시다.

이번엔 prop으로 전달하는 값이 단순히 string이 아니라 string을 콘솔에 찍는 함수입니다.

 

import { useState } from 'react'
import Child from "./child"

export default function Counter() {

    const [count, setCount] = useState(0)

    const handleIncrement = () => setCount(count+1)
    const handleDecrement = () => setCount(count-1)

    return (
        <div className="App">
            {console.log('parent')}
            <button onClick={() => handleIncrement()}>Increment</button>
            <button onClick={() => handleDecrement()}>Decrement</button>

            <h2>{count}</h2>

            <Child name={console.log('Really Skinny Jack')} />
        </div>                    
    )
}

 

import React from 'react'

export default React.memo(function Child({name}) {
console.log("Skinny Jack")
  return (
    <>
        {name()}
        <div>Really Skinny Jack</div>
    </>
  )
})

 

이대로 하면 전달받는 prop은 매번 동일한 문장을 찍는 함수이고,  React.memo 고차컴포넌트로  감쌌음에도 콘솔로그는 다음과 같이 찍힙니다.

 

 

이렇게 콘솔창에 불필요한 리랜더링이 계속되는 이유는, 부모 컴포넌트가 리랜더링될 때마다 새로운 함수가 만들어지기 때문입니다.

당연히 새로운 함수가 만들어지게 되면 새로운 prop을 전달받은 것이기 때문에 자식 컴포넌트는 당연히 리랜더링을해야하는 것이지요.

 

memo의 기능은 이루어지나 사실상 무효한 상태입니다. 이 문제를 해결하기 위해서, 리액트는 useCallback이라는 훅을 제공합니다.

우리는 이를 활용하여 코드를 아래와 같이 고칠 수 있습니다.

 

import { useState, useCallback } from 'react'
import Child from "./child"

export default function Counter() {

    const [count, setCount] = useState(0)

    const handleIncrement = () => setCount(count+1)
    const handleDecrement = () => setCount(count-1)

    return (
        <div className="App">
            {console.log('parent')}
            <button onClick={() => handleIncrement()}>Increment</button>
            <button onClick={() => handleDecrement()}>Decrement</button>

            <h2>{count}</h2>

             <Child name={ useCallback(() => {console.log('Really Skinny Jack')}, [])  } />
        </div>                    
    )
}

 

이제 child 컴포넌트의 리랜더링 문제가 해결되었습니다.

 

useCallback이 하는 일은 부모 컴포넌트가 리랜더링 될지라도 함수를 들고 기억하고 있는 일입니다. 그렇게 되면, 자식 컴포넌트는 전달되는 함수가 달라지지 않는 한 리랜더링 되지 않습니다.

 

이렇게 useCallback을 사용하기 위해서는 간단히 useCallback훅의 첫번째 인자로 우리가 전달할 함수를 작성하고,

두번째 인자로, 함수의 변화에 trigger역할을 할 변수를 배열의 형태로 넣을 수 있습니다. 이 배열을 종속성 배열이라고 합니다.

이 배열 안에 변수가 변경되면, 함수 역시 변경된 것으로 보고 리랜더링 합니다.(useEffect와 완전 비슷합니다.)

 

const testingTheTest = useCallback(() => { 
    console.log("Tested");
  }, [a, b, c]);

 


useMemo Hook을 이용하기 : 함수 컴포넌트에서 메모이제이션 3️⃣

 

useMemo는 useCallback과 아주 유사항 훅이지만, 함수를 캐싱하는 대신에, 함수의 리턴값을 캐싱합니다.

 

아래의 예시에서 useMemo는 숫자 2를 캐싱합니다.

 

const num = 1
const answer = useMemo(() => num + 1, [num])

 

반면 아래의 useCallback은 ()=>num+1이라는 함수 자체를 캐싱할 것입니다.

 

const num = 1
const answer = useCallback(() => num + 1, [num])

 

useMemo는 memo 고차 컴포넌트와 비슷한 방법으로 사용할 수 있으나,

useMemo종속성배열을 갖는 훅이며,

memo는 props를 사용하여 제한적으로 컴포넌트를 업데이트시키는 함수를 인자로 받는 고차컴포넌트입니다.

 

추가로, useMemo는 랜더 사이사이에서 리턴된 값만을 캐싱하는 반면, memo는 랜더 사이사이 전체 컴포넌트를 캐싱한다는 차이가 있습니다.

 


🧐 메모이제이션을 사용할 때에...

 

리액트에서 메모이제이션은 꼭 알아두어야 하는 좋은 도구이지만, 어디에나 꼭 써야하는 것은 아닙니다. 

이 도구들(memo HOC, useCallback hook, useMemo hook)은 무거운 연산을 필요로 하는 함수나 task를 다룰 때에만 유용합니다.

 

우리는 항상 배경지식으로 이 세가지 해결책을 코드에 적용할 수 있다는 점을 알아두긴 하되, 리랜더링이 상대적으로 무겁지 않다면, 다른 방법으로 해결하거나, 혹은 그냥 두는 방향을 선택해야 합니다.

 

이 주제와 관련하여 Kent C.Dodds의 글을 참고하길 권합니다. 

 

쏙 꺼내서 쓰기


관련글