useCallback & useMemo

박재성·2022년 4월 17일
0
post-thumbnail

프로그래밍을 하면서 재사용이라는 단어를 정말 많이 보았다. 프로그래밍을 처음 시작하면서, 함수의 개념을 배우면서 계속 봐왔던 단어가 재사용이다.

재사용의 정의

프로그래밍적이 아닌 재사용의 사회적인 의미는

한 번 사용된 제품을, 그대로 또는 제품이 있는 부품을 그대로 다시 사용하는 것을 말하는 환경 용어이다

다시 사용하는 것은 어떤 이점이 있을까?

만약에 칫솔이 모두 1회용품이라면, 지구는 칫솔로 가득찬 지구를 경험할 수 있었을 것이다. 한 번 쓴 칫솔을 재사용하기 때문에 우리는 너무 편한 양치질을 할 수가 있었다.

프로그래밍에서 재사용은 무엇일까?

일단 어떤 어플리케이션, 혹은 서비스를 만들 때 구성된 컴포넌트가 하나부터 열까지 전부 다를까라는 생각이 먼저 든다.

어떤 컴포넌트는 조금 같고, 조금 더 같거나, 완전 같을 수도 있고, 전부 다를 수도 있다. 전부 다른 경우는 재사용이 안되겠지만 같음이 있을 때, 복붙해서 사용하면 많이 편할 것이다.

복붙을 하다보면 이것도 너무 귀찮아서 함수의 개념을 사용하면 완벽한 재사용을 만들 수 있다.

이처럼 재사용은 프로그래밍에서 아주 중요한 문제이다. 리액트도 프로그래밍이고, 리액트에도 재사용이 매우 중요하다.

단순히 컴포넌트화 해서 사용하는 것이 아니라 해당 함수, 혹은 값을 저장해서 필요한 부분에서 사용하여 최적화 하는 방법을 Memoization이라고 한다.

Memoization이란 이전에 계산한 값을 캐싱해서 필요할 때마다 메모리에서 꺼내서 재사용하는 방법을 의미한다.

.### 캐싱이란

훅을 더 공부하기 전에 캐싱이 무엇인지 짚고 넘어가야한다. useMemo, useCallback 모두 캐싱을 이용해 최적화를 하는 방법이기 때문에 캐싱에 대한 사전 지식이 필요하다.

캐싱은 캐시라고 하는 좀 더 빠른 메모리 영역으로 데이터를 가져와서 접근하는 방식을 말한다.

캐시 메모리는 컴퓨터 시스템의 성능을 향상시키기 위해 cpu 칩 안에, 매우 비싼 메모리이다. 프로그램에서 직접 읽어 사용하는 것은 아니지만 대부분의 프로그램은 한 번 사용한 데이터를 다시 사용할 가능성이 높고, 그 주변의 데이터에 접근할 가능성이 높다. 이러한 특성을 활용하여 용량이 큰 데이터를 캐시 메모리에 불러 저장하고, 필요할 때 먼저 찾아서 쓸 수 있도록 한다. 때문에 더욱 높은 성능을 발휘할 수 있다.

일단 캐시는 다른 글에서 더 자세하게 공부해서 정리를 하겠다!

다시 hook으로 돌아와 Memoization을 사용한 useCallbackuseMemo에 대해 다뤄보겠다.

useMemo

useMemo는 만약 내가 10이라는 결과 값을 반복적으로 사용해야 하는데 이를 매번 새롭게 초기화하고 할당하는 것 보다 캐시에 memoization한 값을 사용하는 것이다.

useMemo의 기본적인 스켈레톤은 useEffect와 비슷하다. 대부분의 훅이 이와 같은 형태를 지니고 있다. 다만 useMemo는 return하는 값을 memoization 해서 상태를 저장한다.

useMemo(() => [
	return calculate()
}, [value])

useMemo example

왜 사용하는지 코드를 보며 이해해 보자.

간단한 계산기 어플리케이션을 제작해보자!

const hardCal = (num) => {
	console.log("어려운 계산기")
    for (let i = 0; i < 1000000; i++){}
    return num + 10000
}

const normalCal = (num) => {
	console.log("쉬운 계산기")
    return num + 1
}

const App = () => {
	const [hardNumber, sethardNumber] = useState(1)
    const [easyNumber, setEasyNumber] = useState(1)
    
    const hardNum = hardCal(hardNumber)
    const eassyNum = easyCal(easyNumber)
	return (
    	<div>
        	<h3>어려운 계산기</h3>
			<input
           	  type="number"
              value={hardNum}
              onChange={(e) => setHardNumber(e.target.value)}
            />
            <span>10000 + {hardNum}</span>
            <h3>쉬운 계산기</h3>
			<input
           	  type="number"
              value={easyNum}
              onChange={(e) => setEasyNumber(e.target.value)}
            />
            <span>{hardNum}</span>
        </div>
    )
}

어려운 계산기와 쉬운 계산기가 있는 어플리케이션을 만들었다. (별코딩님 짱)

어려운 계산기는 입력한 수에 10000을 더하고 쉬운 계산기는 1을 더한다. 어려운 계산기는 불필요한 반복문을 많이 돌기 때문에 시간이 오래 걸리는 단점이 있다. 쉬운 계산기에는 없다.

하지만 쉬운 계산기의 값을 변경시켜도 어려운 계산기가 실행되어 즉각적으로 뷰에 적용이 되지 않는 모습을 볼 수 있다.

어랏...? 할 수 있지만 당연한 이야기이다.

리랜더링이라는 의마가 함수를 호출하는 것인데, 함수를 호출한다는 것은 함수 내에 선언된 변수나 함수가 초기화 된다는 것이다. hardNumeasyNum이 초기화 되기 때문에 새로 랜더링 될 때마다 전부 cal함수를 호출하는 것이다.

아무리 생각해도 이는 옳지 않다. 내가 원치 않는 값이 지속적으로 초기화 되어 값을 할당하는 것은 메모리 낭비이다.

그래서 useMemo를 사용해 특정 값에 변화가 있을 때만 해당 값을 초기화하고, 아닐 때는 memoization한 값을 사용 하는 것이다.

예시를 살펴보자!

    // const hardNum = hardCal(hardNumber)
    
    const hardNum = useMemo(() => {
    	return hardCal(hardNum)
    }, [hardnum])
    

이제 어려운 계산기는 쉬운 계산기를 동작했을 때 hardCal이 불리지 않는다.

확장

useMemo를 공부하니 이런 생각이 들었다. 저번 프로젝트에서 로그인을 진행하고 userId를 가져다 써야할 일이 있었는데, 그 때마다 http호출을 하는 것은 절대 절대 아니고, 부모 컴포넌트가 랜더링 될 때마다 고정된 userId를 계속 받아오는 것을 useMemo로 처리하면 더 좋은 방법이지 않을까 생각한다.

그런데 이걸 어떻게 증명해야 하는지 모르겠다. 리팩토링을 진행하면서 수정을 해봐야겠다.

useCallback

useCallback은 useMemo와 결이 비슷하다. useMemo는 memoization을 이용해 값을 캐싱해, 컴포넌트가 처음 랜더링 되거나 특정 값이 변경될 때 새롭게 값을 초기화 하고 할당하는 함수였다.

차이가 있다면 함수를 저장하는 것이다.

컴포넌트가 다시 랜더링 될 때 컴포넌트 내부 변수와 함수는 초기화를 진행한다. 위에서 봤던 이유와 같이 모든 변수와 함수가 모든 상황에서 새롭게 초기화 되고 할당되는 것이 그리 좋은 현상은 아닐 것이다. 어떤 경우에서는 그 값을 고유하게 저장을 해야 할 상황이 온다. 특정한 값을 저장할 때는 useMemo, 함수를 저장할 때는 useCallback을 사용한다.

useMemo와 비슷한 개념이니 코드 예시를 살펴보자!

useCallback example

useEffect를 사용해서 someFunction이 변경 되었을 때만 console.log가 실행 되도록 코드를 구성했다. 이 코드는 정말 올바르게 작동할까?

const App = () => {
	const [number, setNumber] = useState(0)
    
    const someFunction = () => {
    	console.log(`someFunc: number: ${number}`)
    }
    
    useEffect(() => {
    	console.log("someFunction이 변경되었습니다.")
    }, [someFunction])
	return (
    	<div>
        	<input
              type="number"
              value={number}
              onChange={() => setNumber(e.target.value)}
            />
            <br/>
            <button onClick={someFunction}>Call someFunc</button>
        </div>
    )
}

내가 괜히 묻는 것이 아니다. 안되기 때문이다. 어랏..이라고 할 수 있지만 이 이유는 자바스크립트 타입 때문이다. 관련한 내용은 다른 포스팅에 잘 기록이 되어있다. 간단하게 이야기 하면 함수는 객체 타입이기 때문에 같은 함수라도 새로 할당했을 때 새로운 메모리에 참조되기 때문에 다른 값이기 때문이다.

그래서 useEffect가 실행이 된다.

내가 원하는 결과가 나오지 않았다. 그래서 useEffect가 아니라 useCallback을 사용한다.

원하는 방식으로 고쳐보자!

const someFunction = useCallback(() => {
	console.log(`someFunc: number: ${number}`)
}, [])

이제 컴포넌트가 처음 실행 되었을 때 useEffect내 코드를 실행하고 number를 up해도 console이 찍히지 않는다.

그런데 버튼을 클릭했을 때 올바르게 number값이 불러와지지 않는다. 왜냐하면 someFunction을 memoization 했을 때 당시의 number 값이 0이기 때문에 다음에 함수를 호출해도 0이 출력 되는 것이다.

한 가지를 잊었다. dependency를 입력하지 않았다. number를 입력하면 number 값이 변화할 때마다 새롭게 함수를 정의해서 memoization을 진행할 것이다.

const someFunction = useCallback(() => {
	console.log(`someFunc: number: ${number}`)
}, [number])

주의사항!!

useCallback과 useMemo를 코딩할 때는 부모 컴포넌트의 렌더링을 항상 신경을 써야한다. 해당 컴포넌트의 렌더링만 신경 쓴다면 어디서 일어나는지 모르는 일들이 무수히 일어날 수 있기 때문에 부모 컴포넌트가 리렌더링되는 것을 함상 고민하고 염두한 상태로 리액트 구조를 짜야한다.

결론

프로젝트를 하면서 느꼈던 똥코드를 어떻게 고칠 수 있을지 이제 좀 깨달아가는 것 같다. useEffect가 계속 실행되는걸 막지 못했는데 바로 시도해봐야겠다!!

profile
개발, 정복

0개의 댓글