Web: 최적화와 React.memo, useMemo 알아보기

Heechan
HcleeDev
Published in
13 min readJul 8, 2022

--

Photo by Lucrezia Carnelos on Unsplash

회사에서 대화 중에 반 농담으로 자주 하는 얘기가 있다. 어차피 요즘 컴퓨터 성능 다들 좋아서 웹 애플리케이션을 너무 최적화할 이유까지는 없다… 사실 어느정도는 맞는 말이라 어지간한 기능을 개발할 때는 그런 것까지 신경 안써도 일반적으로 문제는 없을 것이긴 하다. 심지어 우리는 카카오, 네이버처럼 99% 이상의 유저를 위한 서비스를 개발하는 것도 아니라 더 그렇게 생각하는 것 같다.

하지만 가끔 계산이 많은 기능을 만들 때는 아무래도 신경이 쓰이기도 하고, 실제로 Store의 여러 프로퍼티가 엄청 바뀌는 경우에는 뭔가 버벅이는 느낌을 받을 때도 있다. 그리고 앞으로 커리어적으로 생각해도 최적화 과정은 필요할 것 같아서 배워야 겠다는 생각이 들었다.

따라서 이번주는 React에서 최적화를 위해 사용되는 useMemo와 useCallback Hook에 대해 간단히 알아보겠다.

최적화와 메모이제이션

우리가 할 수 있는 최적화는 불필요한 계산을 최소화하는 것을 의미한다.

학부 시절에 데이터 구조를 배울 때도, 알고리즘 개론을 들을 때도 Big-O Notation을 이용해 현재 구조가 작업을 할 때 얼마나 많은 계산을 부르게 되는지, 같은 작업을 더 효율적으로 할 수 있는 방법을 찾는 것이 메인 컨텐츠였던 기억이 난다.

React에서 한 번 더 생각해야 하는 점은, 웹 페이지는 우리가 알고리즘을 배울 때 고려하는 계산만 하는게 아니라는 점이다.

웹 페이지 하나가 만들어질 때는 DOM Tree의 구성, 레이아웃 잡기, 페인팅하기 등의 다양한 작업이 이뤄진다. 업데이트 상황이 오면 컴포넌트를 다시 랜더링하고, 그때 레이아웃 및 페인팅 과정을 또 계산해야 하는 경우가 잦다.

그래서 React의 성능을 점검할 때는 컴포넌트 자체의 리랜더링이 불필요하게 반복되고 있지 않은지, 그리고 내부 로직이 쓸데없이 다시 만들어지거나 복잡한 계산을 반복하고 있지는 않은지 생각해야 한다.

쓸데없이 같은 계산을 반복하게 하지 않게 할 수 있는 방법은 무엇일까?

당연히 결과를 기억하는거다.

흔히 알고 있는 Caching을 떠올려보자. 메모리에서 어떤 값을 가져오고자 할 때, 맨날 메모리나 디스크에서 가져오기엔 시간이 오래 걸릴테니 최근에 접근한 값이나 자주 사용한 값을 캐시에 저장해두고 사용한다.

React의 최적화에서는 메모이제이션(Memoization)이라고 부르는 개념을 활용한다. 이 Memoization은 Caching의 일종이라고 볼 수 있다. Memoization은 특정 함수가 동작하고 반환된 그 결과물을 캐싱하는 경우를 말한다.

Memoization에서 결과값을 특정하기 위한 조건은 함수와 Parameter다. 기본적으로 같은 Parameter를 넣으면 같은 결과가 나오는 함수라는 기대가 있어야 겠지만…

function sum(a, b) {
return a + b;
}

간단한 함수지만 Memoization을 적용해보자면, 처음 누군가 sum(1, 2)를 호출했다면 sum을 a: 1, b: 2와 호출했고, 3이 반환되었다 라는 정보를 기록해둔다.

그러고 다시 반복되어서 sum(1, 2) 를 부르면 기록된 정보를 살피고, “sum 을 1과 2와 함께 불렀으면 3이 나오지!”라고 굳이 계산 과정을 거치지 않을 수 있다. 물론 다른 경우에 sum(2, 3) 이런 호출이 생기면 이 함수는 다시 돌아갈 것이다.

아무튼 Memoization 상황에서는 어떤 함수를 어떤 매개변수와 함께 불렀는지 여부로 캐싱을 진행한다고 생각할 수 있다.

React.memo

첫 번째는 컴포넌트 자체를 메모이제이션하는 React.memo다.

일단 아무 컴포넌트나 하나 만들어서 리랜더링을 시켜보겠다.

function App() {
const [state, setState] = useState<number>(0);
return (
<>
<p>state</p>
<button onClick={() => setState(state + 1)}>click</button>
<MemoTest str={"안녕!"} />
</>
);
}
...interface Props {
str: string;
}

const MemoTest = ({str}: Props) => {
return <div>{str}</div>;
};

이렇게 하면 버튼을 클릭할 때마다 state 가 업데이트되면서 App 이 리랜더링된다. 당연하게도 그 하위 컴포넌트들도 리랜더링이 될 수 밖에 없다. 선량한 MemoTest 는 어떤 상태에 의해 변하는 녀석이 아님에도 연대책임으로 리랜더링이 되게 된다.

지금이야 워낙 간단한 컴포넌트라 MemoTest 리랜더링이 되더라도 전혀 부담이 없지만, 만약 뭔가 계산이 많이 필요한 컴포넌트라면 이렇게 연대책임을 무는 것이 좋을 리가 없다.

이럴 때 MemoTest에 React.memo를 씌워줄 수 있다.

function App() {
const [state, setState] = useState<number>(0);
return (
<>
<p>state</p>
<button onClick={() => setState(state + 1)}>click</button>
<MemoTest str={"안녕!"} />
</>
);
}
...interface Props {
str: string;
}

const MemoTest = ({str}: Props) => {
return <div>{str}</div>;
};
export default React.memo(MemoTest);

이런 식으로 React.memo로 한번 감싸주면 App 이 리랜더링되더라도 MemoTest 는 딱히 리랜더링되지 않게 된다.

React.memo를 사용하면 어떤 컴포넌트를 어떤 Props와 함께 불렀는지 기록하고, 같은 경우에는 이전에 기록해둔 컴포넌트 결과값을 반환한다. Memoization 방식이다.

위 경우에는 MemoTeststr: "안녕" 이라는 같은 Props로 계속 불리고 있기 때문에 리랜더링 과정을 거치지 않고 이전에 기록해둔 컴포넌트를 그대로 사용하게 될 것이다.

React.memo는 한 컴포넌트를 같은 프로퍼티로 리랜더링하는 경우가 빈번할 때 사용해야 한다. 무조건 사용한다고 좋은 것이 아니다.

만약 프로퍼티가 계속 변경되는 컴포넌트를 React.memo로 해봤자 괜히 프로퍼티를 비교하는 과정만 추가되지 실속은 없다. 그리고 Memoization을 위해 계속 기록을 해야 하므로 매번 만들어진 컴포넌트들을 기록하는 공간만 조금씩 더 차지하게 될 것이다.

예를 들어 아래와 같은 경우에는 겉보기엔 같은 프로퍼티를 넣는 것 같아도, 실제로는 리랜더링이 계속 일어난다.

function App() {
const [state, setState] = useState<number>(0);
const handleClick = () => console.log('click');
return (
<>
<p>state</p>
<button onClick={() => setState(state + 1)}>click</button>
<MemoTest handleClick={handleClick} />
</>
);
}
...interface Props {
handleClick: () => void;
}

const MemoTest = ({handleClick}: Props) => {
return <div onClick={handleClick}}>눌러</div>;
};
export default React.memo(MemoTest);

이렇게 되면 App 이 리랜더링될 때마다 handleClick 은 새로운 함수로 정의된다. 이런 함수는 원시 타입이 아니기 때문에 리랜더링마다 새로운 주소에 할당된다. React.memo는 이런 함수의 내용을 꼼꼼히 살펴보는게 아니라, 주소값만 살펴보기 때문에 MemoTest 도 계속 리랜더링이 되게 된다. 이럴 때가 괜히 프로퍼티 비교만 하게 되고 소득은 없는 경우가 된다.

useMemo

컴포넌트에 적용할 수 있는 React.memo와 달리, 복잡한 계산의 결과 을 Memoization해 최적화하기 위한 useMemo Hook이 있다. Hook이니까 함수형 컴포넌트 내에서만 사용할 수 있다.

활용하는 법은 아래와 같다.

function App() {
const items = useState([]);
const convertedItems = useMemo(
items.map(item => {
...item,
additionalData: somethingHardCalc(item)
}, [items]
)
...
}

useMemo 에는 두 개의 인자가 들어간다. 첫 번째는 우리가 구동하기 원하는 함수를 하나 넣으면 된다. 위에서는 items 에 들어있는 데이터를 변환해주는 함수를 만들었다. somethingHardCalc 는 뭔가 복잡한 계산을 의미하고자 넣어둔 이름의 함수다.

그리고 두 번째 인자는 useEffect 를 사용할 때처럼 deps를 설정할 수 있다. 여기서는 [items] 인데, items 가 변경될 때만 첫 번째 인자에 들어있는 함수를 동작시킨다고 보면 된다.

useMemo 를 사용하지 않는다고 생각해보자.

function App() {
const items = useState([]);
const convertedItems = items.map(item => {
...item,
additionalData: somethingHardCalc(item)
}
...
}

convertedItems 는 논리상 items 가 변경되어야만 동작해야 하는데, 만약 App 이 모종의 사유로 리랜더링이 되면 items 가 변경되든 말든 계속 불필요한 계산을 계속하게 될 것이다.

const convertedItems = useMemo(
items.map(item => {
...item,
additionalData: somethingHardCalc(item)
}, [items]
)

다시 useMemo 를 가져왔다. 이 useMemo 도 Memoization을 하는데, 이때는 useMemo 자체에 대해서 Memoization이 일어난다고 생각하면 된다.

useMemo 라는 함수와, 그 프로퍼티인 items 값이 뭐가 들어오는지 확인하고, 처음보는 값이면 첫 번째 인자의 함수를 구동해 그 값을 기록한다. 만약 이전에 본 값이면 계산은 생략하고 전에 기록해둔 값을 돌려주는 방식으로 복잡한 계산을 줄일 수 있다.

React.memo와 useMemo의 차이는 어디에 활용되는가 인 것 같다.

React.memo의 경우에는 컴포넌트를 받아 컴포넌트를 반환한다.

useMemo의 경우에는 값을 계산하는 과정을 최적화해 값을 반환받는다.

물론 컴포넌트도 값이라면 값이기에 useMemo 안에 넣을 수 있긴 하지만… 이는 기본적으로 함수형 컴포넌트 안에서 쓰이는 Hook이기 때문에 굳이 그렇게 해야 하나 싶긴 하다.

useMemo vs useState + useEffect

개인적으로 useEffect 도 비슷한거 아닌가? 라는 생각을 했다. 어차피 계산을 해서 값을 할당하는 것이라면, 아래처럼 써도 되는 것이 아닌가 싶었다.

const [convertedItems, setConvertedItems] = useState([]);useEffect(() => {
...계산과정...
setConvertedItems(result);
}, [items]);

이렇게 해도 거의 비슷한거 아닌가… 하는 생각을 했다.

이런 방식이 더 효과적인 경우도 있고, 아닌 경우도 분명히 있는 것 같다.

useMemo 의 경우에는 랜더되는 동안 한번 계산하고 나면 끝이지만, useEffect 의 경우에는 랜더된 후 함수를 호출해, 다시 setConvertedItems 를 부르고, 그로 인해 한 번의 리랜더링이 더 발생할 수 있다. 따라서 이 경우에는 useMemo 가 좀 더 효과적이라고 생각된다.

useEffect 가 더 효과적인 경우도 있다. useMemo 는 랜더링 과정 중에, useEffect 는 랜더링이 끝나고 나서 발동된다고 한다.

useMemo 가 만약 오래 걸리는 작업을 많이 갖고 있다면, 랜더링 과정 중에 시간을 좀 오래 잡게 되고, 사용자 입장에서는 화면 구성이 좀 느려진다고 생각할 수 있을 것 같다. 하지만 useEffect 는 랜더링 후 돌아가므로 사용자 입장에서는 화면이 빨리 구성된다고 느낄 수는 있다. 몇번 리랜더링이 더 되면서 정보가 업데이트되기는 하겠지만… 사용자 경험이 제일 중요하니까…

비동기 작업을 할 때도 useMemo 보다는 useState + useEffect 조합이 더 유용할 수 있다. useMemo 는 랜더링 작업 중에 돌아가기 때문에, 그때 비동기 작업의 결과값이 적절히 반영되는 것을 기대하는 것은 무리다. 그냥 비동기 작업의 결과값이 들어가야 하는 곳은 undefined 나 기본값으로 설정되고 랜더 과정이 흘러갈 것이다.

비동기 작업의 결과값은 side effect로서 컴포넌트에 영향을 미친다. 그런 side effect를 관리하는 것은 랜더링 페이즈가 아닌 useEffect 에서 진행하는 것이 맞다.

React 공식 문서에도 설명되어있는 점이다. 랜더링 된 후 컴포넌트를 조작하다가 생기는 side effect는 useEffect 에서 관리할 일이지 useMemo 가 신경쓸 것이 아니라고 설명해주고 있다.

따라서 무조건 최적화라고 useMemo 같은 Hook을 남발하지 말고 상황에 맞게 적절한 기능을 사용해야 할 것이다.

결론

이번 글에서 설명한 React.memo, useMemo와 자주 묶이는 useCallback, 그리고 또 최근 React 18로부터 나온 useEvent라는 Hook도 최적화를 위해 사용된다. 다음주에는 그거에 대해서 쓰면 될 것 같기도 하다.

그리고 말이 최적화지 막 쓴다고 최적화가 되는 것은 아니다. 다른 계산 과정이 끼어들어가는 것이고, 기록을 위한 메모리도 잡아야 한다. 최적화는 공짜로 이뤄질 수가 없다.

그래도 그 중에서 어떻게 효율적으로 이 최적화 기능들을 사용할 수 있을지 고민하고 잘 사용해야 할 것이다.

참고한 것

--

--

Heechan
HcleeDev

Junior iOS Developer / Front Web Developer, major in Computer Science