이번 글에는 useMemo
useCallback
이 뭐하는 놈들인지 어떨 때 사용하게 될 지 공부해보고자 합니다. 아무래도 state
는 상태관리를 하기 위해서는 필수적인 요소기 때문에 사실 익숙해 있었습니다. useEffect
역시도 특정 값에 대한 트리거로 사용했었구요. useEffect
도 이 후 트리거성으로 사용하기보다 서버로부터 값을 동기화할때만 사용하는 것이 가장 바람직하다는 것을 알게 되었죠. 물론 트리거로 사용하는 방법이 올바르지 않다는 것은 아닙니다. 프론트엔드 개발에서는 렌더링의 횟수가 잦아지는 것은 성능과 직결되는 문제고 이를 기피해야 합니다. 때문에 불필요하게 트리거 작동이 많은 것은 결국 리렌더링을 야기시키겠다라는 의미겠죠. 정리하자면 hook에는 이러한 기능이 있고 다양하게 활용할 수 있지만 사용을 최소화해야한다. 라는 성능최적화적인 마인드를 탑재하고 본론으로 넘어가봅시다.
useMemo
는 연산된 값이 컴포넌트 업데이트 시에도 변화가 없을 시 재연산 하지 않고 동일한 값을 사용하기 위한 react hook
입니다.useEffect
와 동일하게 두개의 인자를 받을 수 있습니다.첫번째 인자로는 콜백함수를 받고 두번째 인자는 의존성배열(dependency Array)을 받습니다.
const Card = ({ width,height }) => {
// const z = useMemo(() => compute(x, y), [x, y]);
const located = locate(width,height);
const [ count, setCount ] = useState(0);
const onClick = () => {
setCount(count + 1)
}
return (
<div>
<button onClick={onClick}>추가</button>
<span>좌표 : {located}</span>
</div>
);
}
Card
라는 컴포넌트는 부모컴포넌트로부터 width 와 height 라는 props를 전달받습니다. 그리고 locate라는 함수에서 width,height를 인자로 받아 아주 복~~~잡한 연산을 하고 있습니다.
button
을 클릭할 경우 setCount
가 실행되고 Card
가 업데이트됩니다.count
이 새로운 값으로 변경되고 located
도 다시 함수를 통해 값이 연산될 것입니다.locate()
는 렌더링에 영향을 줄만큼 아주 복~~~잡한 연산을 하는 함수입니다. count
값의 변화로 인해 전혀 상관없는 located
의 값 역시 새로이 연산되고 있었습니다. 이 외에도 다른 state
가 존재한다면 located
를 위한 복잡한 연산은 더 잦아질 것입니다.이를 어떻게 해결할 수 있을까요?
import { useMemo } from "react"; // *
const Card = ({ width,height }) => {
const located = useMemo(() => { // *
locate(width, height);
}, [width, height]);
const [ count, setCount ] = useState(0);
const onClick = () => {
setCount(count + 1)
}
return (
<div>
<button onClick={onClick}>추가</button>
<span>좌표 : {located}</span>
</div>
);
}
useMemo
내에서 locate함수를 실행시키도록 변경해주었습니다.located
의 값을 재연산 하지 않게 됩니다.before 의 코드에서는 located
를 위한 복잡한 연산이 진행되기 때문에 상태가 업데이트될 때마다 매번 렌더링이 지연될 것입니다.
한편 after 의 코드에서는 Card
컴포넌트의 최초 렌더링 이 후 props
가 변하지 않는 이상 빠르게 렌더링될 것입니다.
🙋♂️ 어, 그럼 연산값을 가진 모든 변수를 useMemo로 관리하면 되나요?
useMemo
을 비롯한 hook 역시 캐시에 접근하기 위한 api함수기 때문에 당연히 메모리를 사용합니다. 고로 복잡하고 무거운 연산이 아닌 값에 대해 useMemo
를 사용하는 것은 배보다 배꼽이 더 큰 상황이라고 할 수 있습니다.🤷♂️ 복잡하고 무거운 연산을 판단하는 기준이 뭐에요?
1ms 이상
을 예시로 하여금 상당한 양의 연산을 판단했습니다.console.time('filter array');
const visibleTodos = filterTodos(todos, tab);
console.timeEnd('filter array');
console.time
console.timeEnd
메서드를 통해 코드의 실행 시간을 확인할 수 있습니다.하지만 여기서 간과하지 말아야할 점은 우리의 시스템이 사용자들의 시스템보다 빠를 수 있다는 것 입니다. 때문에 상대적으로 낮은 성능의 시스템에서 테스트를 거쳐 모든 사용자에게 원활한 서비스를 제공해야 합니다.
이를 위해chrome
에서 제공하는 CPU 스로틀링 옵션을 이용할 수 있습니다. 스로틀링 이란 기기의 CPU가 지나치게 과열되었을 때 클럭과 전압의 성능을 낮추어 열을 줄이는 기능을 말합니다. chrome
에서 자체적으로 성능을 제어할 수 있는 기능을 제공하나 봅니다. hook 공부하다가 스로틀링까지;
뿐만 아니라 개발환경에서 측정한다고 정확한 결과가 나오는 것도 아닙니다. 개발 환경에서 strict mode
를 사용할 경우 두번의 렌더링이 발생할 수 있고 배포와 다른 결과를 초래할 수 있다고 합니다. 공식 문서에서는 프로덕션 앱 사용을 권장하고 있습니다. 고려해야할 부분이 상당히 많네요..--;
프로덕션 앱은 useMemo
를 실전에 도입하게 될 때 좀 더 알아보도록 하겠습니다.
useMemo
은 복잡한 연산의 결괏값을 재사용할 때 사용하는 hook
입니다. 하지만 연산비용을 고려하지 않고useMemo
를 남용하여 무분별한 재활용의 사례가 실제로 많다고 합니다.useMemo
가 적용된 레퍼런스는 재활용을 위해 가비지컬렉션에 포함되지 않게 됩니다. 필자도 useMemo
의 용법을 알고 프로젝트에 적용해보려 했으나 생각보다 useMemo
가 도입되기 위한 조건을 만족하는 경우는 극히 드물었습니다. 우선 useMemo
를 도입할만큼의 무겁고 복잡한 연산이 없었습니다. 렌더링 속도에 영향을 줄만큼 복잡한 연산이라면 일반적으로 useEffect
를 이용한 비동기 처리를 먼저 고려하게 되기도 하구요.
결론적으로 useMemo
는 컴포넌트 내에서 복잡한 연산을 요구하는 결과값을 동기적으로 할당해야하고 다른 값에 의해 불필요한 재연산을 야기하는 경우 에 사용을 고려해 볼 수 있다고 정리할 수 있습니다.
이번엔 useCallback
에 대해 알아볼 차례입니다.
useMemo
는 복잡한 연산의 결과값에 대한 재활욜을 위한 hook이라면 useCallback
은 정의된 함수를 재활용하기 위한 hook 이라고 할 수 있습니다. 공식 문서에서는 컴포넌트가 업데이트되는 과정에서 함수 정의를 캐시할 수 있는 hook 이라 말하고 있습니다. useCallback
역시 재활용을 목적으로 하기 때문에 useMemo
처럼 도입에 대한 명확한 근거가 필요합니다. 잠깐! 근거를 알아보기 전에 우리는 javascript
의 함수 동등성 에 대해 짚고 넘어가야 합니다.
const add1 = () => console.log(‘ㅎㅇ’);
const add2 = () => console.log(‘ㅎㅇ’);
console.log(add1 === add2) // console : false
add1
와 add2
는 같은 결과값을 리턴하지만 비교했을 때 불일치로 판단합니다. 원시값이아닌 참조값(객체,배열,함수)을 할당할 경우 해당 식별자는 해당 값의 메모리주소값을 가지기 때문입니다. 고로 add1 === add2
는 각각 다른 메모리영역의 주소값을 비교하게 됩니다. 이를 참조 비교라고 합니다. javascript가 함수의 동등함을 판단하는 기준을 알아야 useCallback
을 사용하는 이유에 대해서 더 명확하게 알 수 있습니다.
import {useState,useEffect} from 'react'
import axios from 'axios'
const page = ({ regionId }) => {
const [ roomList, setRoomList ] = useState<RoomType[]>([]);
const getRoomListApi = () => {
axios.get(`room/${regionId}`)
}
useEffect(() => {
getRoomListApi().then((res) => {
setRoomList(res.data.room)
});
},[getRoomList])
}
예시에서 page
컴포넌트에서는 부모컴포넌트로부터 받은 regionID
를 api통신 url로 사용하고 있습니다. 그리고 useEffect
의 의존성 배열에 getRoomList
를 넣어주었습니다. 컴포넌트 업데이트 시 regionId
에 의해 getRoomList
가 변경될 경우에만 useEffect
내 콜백함수를 실행 하도록 의도한 것을 볼 수 있습니다.
여기서 javascript 함수 동등성을 몰랐다면 의도와 다른 동작을 하게 되고 원인을 파악하기 힘들었을 것입니다.
useEffect
는 getRoomList
의 이전 값과 현재 값을 비교할 것입니다.getRoomList
가 참조하는 함수는 매번 새로운 메모리 영역을 할당하기 때문에 getRoomList
역시 매번 다른 메모리주소값을 가지게 됩니다.javascript
함수 동등성에 따라 getRoomList
의 전 후를 비교할 시 regionId
의 변화와 관계없이 false
로 판단합니다.🤷♂️그래서 어쨌다고?
useEffect
의 콜백함수는 컴포넌트 업데이트 시 매번 실행될 것입니다. regionId
가 변하지 않는 데도 불구하고요.🤦♂️그러면 의존성 배열에 regionId
를 넣으면 되잖아요?
useEffect(() => {
getRoomListApi().then((res) => {
setRoomList(res.data.room)
});
},[regionId])
regionId
로 바꾸면 비로소 의도에 맞게 작동할 것입니다. 또 getRoomList
보다 더 직관적이기도 하구요.useCallback
을 사용하는 진짜 이유를 알아보겠습니다. import {useState,useEffect} from 'react'
import axios from 'axios'
const page = ({ regionId }) => {
const [ roomList, setRoomList ] = useState([]);
const getRoomListApi = () => {
axios.get(`room/${regionId}`)
}
useEffect(() => {
getRoomListApi().then((res) => {
setRoomList(res.data.room)
});
},[regionId]) // regionId 로 변경
}
의존성 배열을 regionId
로 변경해 콜백함수가 의도에 맞게 동작하게 되었습니다. 여기서 useEffect
를 사용하는 이유는 regionId
의 변화에 따라 함수를 실행시켜 메모리 누수를 방지 하기 위함입니다. 우리는 같은 이유로 useCallback
을 이용해 getRoomListApi
도 재활용하고자 합니다.
import {useCallback,useState,useEffect} from 'react'
import axios from 'axios'
const page = ({ regionId }) => {
const [ roomList, setRoomList ] = useState([]);
const getRoomListApi = useCallback(()=> {
axios.get(`room/${regionId}`)
},[regionId])
useEffect(() => {
getRoomListApi().then((res) => {
setRoomList(res.data.room)
});
},[getRoomListApi])
}
useCallback
을 이용해 getRoomListApi
를 생성했습니다. 의존성 배열을 이용해 컴포넌트가 업데이트될 때 regionId
의 값이 동일할 경우getRoomListApi
를 재활용됩니다. 이로써 불필요한 메모리 사용을 방지할 수 있습니다.
물론 useCallback
도 함수이기 때문에 추가적인 메모리 사용이 있고 메모리 비용을 고려하여 도입해야 합니다. 하지만 공식 문서에서는 useCallback
을 남용하는 것에 대해 useMemo
에 비해 크게 주의하지 않는데요.
뇌피셜
개인적인 생각은useCallback
은 함수 자체를 캐싱하기 때문에useCallback
이 제 역할을 하지 않더라도 손실이 크지 않아서가 아닐까 생각합니다. 반면에useMemo
의 경우 함수의 호출 결과 를 캐싱하기 때문에useCallback
에 비해 적절하게 사용했느냐에 따라 하이리턴이 발생하지 않을까 싶습니다.
물론 useCallback
역시 무차별적으로 사용되면 코드 가독성을 해칠 수 있습니다.
useMemo
와 useCallback
에 대해 공부해봤습니다. 두 hook 의 주된 사용 이유는 재활용을 위한 성능 최적화를 위해서 입니다. 하지만 이 역시 적절하게 사용하지 않으면 성능에 부정적인 영향을 끼칠 것입니다. 그렇기 때문에 우리는 state
와 hook
이 꼭 필요한 지에 대해 신중하게 고민하고 사용을 최소화해야 한다고 생각합니다.