사용자들은 빠르고 즉각적인 사용자 경험을 원한다. 100ms 이하의 응답 지연은 사용자로 하여금 즉각적인 반응으로 느끼게 한다. 반면, 100ms~300ms의 응답 지연은 지연이 어느정도 느껴지기 마련이다.
사용자 경험의 향상을 위해 리액트는
고차 컴퍼넌트(Higher Order Component, HOC)
React.memo()
를 제공한다. 컴포넌트를 래핑(wrapping)함으로써 컴포넌트를 메모이징(Memoizing)하고 불필요한 리렌더링을 피할 수 있다.
이 글은 언제
React.memo()
가 성능을 향상시키고, 언제 불필요한지 설명할 것이다.
DOM 업데이트를 결정할 때, 리액트는 먼저 컴포넌트를 렌더링한 뒤, 이전 렌더링 결과와 비교한다. 만약 렌더링 결과가 다르다면 그 때 DOM 업데이트를 한다.
이전 렌더링 결과와 현재의 렌더링 결과를 비교하는 것은 빠르다. 하지만 어떤 상황에선 이 속도를 더 높일 수 있다.
컴포넌트가 React.memo()
로 래핑 되었을 때, 리액트는 컴포넌트를 렌더링하고 결과를 메모이징(Memoizing)한다. 다음 렌더링전에 props가 같다면, 리액트는 메모이징(Memoizing)된 내용을 재사용한다.
예시:
export function Movie({ title, releaseDate }) {
return (
<div>
<div>Movie title: {title}</div>
<div>Release date: {releaseDate}</div>
</div>
);
}
const MemoizedMovie = React.memo(Movie);
export { MemoizedMovie as Movie };
React.memo(Movie)
는 메모이징(Memoizing)된 새로운 컴포넌트 MemoizedMovie
를 리턴한다.
단 한가지의 차이점을 제외하고, 기존의 Movie
컴포넌트와 같은 렌더링 결과를 낸다.
MemoizedMovie
의 렌더링 결과는 메모이징 되어있다. 만약 title
이나 releaseDate
같은 props
가 변경 되지 않는다면 다음 렌더링 때 메모이징 된 결과를 사용한다.
// First render - MemoizedMovie이 호출됨.
<MemoizedMovie
title="Heat"
releaseDate="December 15, 1995"
/>
// Second render - MemoizedMovie 호출 x.
<MemoizedMovie
title="Heat"
releaseDate="December 15, 1995"
/>
메모이징 된 결과를 재사용함으로써 성능상의 이점을 얻을 수 있다.
virtual DOM
이 달라진 부분을 체크하지 않는다.기본적으로 React.memo()
는 props 혹은 props 객체를 비교할 때 얕은(shallow) 비교를 한다.
props 비교를 직접 커스터마이징 하고 싶다면 두번째 인수로 비교 함수를 지정해주면 된다.
React.memo(Component, [areEqual(prevProps, nextProps)]);
areEqual(prevProps, nextProps)
함수는 prevProps
와 nextProps
가 같다면 true
를 반환할 것이다.
function moviePropsAreEqual(prevMovie, nextMovie) {
return prevMovie.title === nextMovie.title
&& prevMovie.releaseDate === nextMovie.releaseDate;
}
const MemoizedMovie = React.memo(Movie, moviePropsAreEqual);
React.memo()
를 사용할까?React.memo()
를 사용하기 가장 좋은 경우는 함수형 컴퍼넌트가 같은 props로 자주 렌더링 될거라 예상될 때이다.
흔한 경우가 부모 컴포넌트에 의해 자식 컴포넌트가 같은 props로 렌더링 될 때이다.
예시: MovieViewsRealtime
실시간으로 업데이트되는 조회수를 가진 컴포넌트
function MovieViewsRealtime({ title, releaseDate, views }) {
return (
<div>
<Movie title={title} releaseDate={releaseDate} />
Movie views: {views}
</div>
);
}
이 어플리케이션은 매초마다 서버에서 데이터를 폴링(polling)해서 MovieViewsRealtime
컴포넌트의 views
prop을 업데이트한다.
// Initial render
<MovieViewsRealtime
views={0}
title="Forrest Gump"
releaseDate="June 23, 1994"
/>
// After 1 second, views is 10
<MovieViewsRealtime
views={10}
title="Forrest Gump"
releaseDate="June 23, 1994"
/>
// After 2 seconds, views is 25
<MovieViewsRealtime
views={25}
title="Forrest Gump"
releaseDate="June 23, 1994"
/>
// etc
views
prop이 업데이트 될 때 마다 MoviewViewsRealtime
컴포넌트가 리렌더링 된다. 이때 Movie
컴퍼넌트 또한 title
이나 releaseData
가 이전과 똑같아도 리렌더링 된다.
바로 이 상황에 React.memo()
가 딱이다!
불필요한 리렌더링을 방지하기 위해 React.memo()
로 메모이징을 해보자
function MovieViewsRealtime({ title, releaseDate, views }) {
return (
<div>
<MemoizedMovie title={title} releaseDate={releaseDate} />
Movie views: {views}
</div>
)
}
title
혹은 releaseDate
props가 같다면, 리액트는 MemoizedMovie
를 리렌더링 하지 않을 것이다. 이렇게 MovieViewsRealtime
컴포넌트의 성능을 향상시킬 수 있다.
컴포넌트가 같은 props로 자주 렌더링 되거나, 무겁고 비용이 큰 연산이 있는 컴포넌트의 경우,
React.memo()
로 래핑할 필요가 있다.
profiling을 통해
React.memo()
를 적용했을 때의 이점을 측정하자.
만약 컴포넌트가 무겁지 않고 같은 props로 자주 렌더링 되지 않는다면
React.memo()
는 필요치 않다.
성능적으로 이점이 없다면 사용하지 않도록 하자.
성능 관련 변화가 잘못 적용된다면, 오히려 성능 악화를 불러 일으킬 수 있다.
React.memo()
현명하게 사용하자!
클래스 컴포넌트를 React.memo()
로 래핑하는 것은 바람직하지 않다. 만약 필요하다면, PureComponent
를 확장해서 사용하거나 shouldComponentUpdate()
를 커스터마이징해서 구현하는 것이 옳다.
렌더링될 때 props가 다른 경우가 대부분인 컴포넌트를 생각해 볼 때, 메모이제이션(Memoization)의 이점은 없다.
설령 props가 계속 변하는 컴포넌트를 React.memo()
로 래핑할지라도, 리액트는 다음의 2가지 작업을 수행한다.
당신은 성능 이점도 얻지 못한 채, 비교함수만 실행하게 될 것이다!
함수 객체는 오직 자신에게만 동일하다. (일반 객체와 같은 원칙을 따른다.)
다음 예를 보자.
function sumFactory() {
return (a, b) => a + b;
}
const sum1 = sumFactory();
const sum2 = sumFactory();
console.log(sum1 === sum2); // => false
console.log(sum1 === sum1); // => true
console.log(sum2 === sum2); // => true
함수 sum1
과 sum2
두 함수 모두 두 숫자를 더해주는 함수이다. 그러나 sum1
과 sum2
는 다른 함수 객체이다.
부모 컴퍼넌트가 자식 컴퍼넌트의 콜백 함수를 정의할 때, 새 함수 객체 인스턴스가 생성된다. 이것이 어떻게 메모이제이션을 막는지 보고, 고쳐보자.
Logout
컴포넌트는 콜백 prop인 onLogout
을 갖는다.
function Logout({ username, onLogout }) {
return (
<div onClick={onLogout}>
Logout {username}
</div>
);
}
const MemoizedLogout = React.memo(Logout);
콜백함수를 prop으로 받는 컴포넌트는 메모이제이션을 적용할 때 주의를 기울여야 한다. 부모 컴포넌트가 매 렌더링 때마다 다른 콜백 함수의 인스턴스를 넘겨줄 수 있기 때문이다.
function MyApp({ store, cookies }) {
return (
<div className="main">
<header>
<MemoizedLogout
username={store.username}
onLogout={() => cookies.clear('session')}
/>
</header>
{store.content}
</div>
);
}
동일한 username
이 전달 되더라도, MemoizedLogout
은 새로운 onLogout
콜백 때문에 리렌더링을 하게 된다.
Memoization is broken!
이를 해결하기 위해서는, onLogout
prop은 매번 같은 콜백 함수 인스턴스를 받아야 한다. useCallback()
을 적용해서 콜백 인스턴스를 렌더링 사이에서 지켜보자.
const MemoizedLogout = React.memo(Logout);
function MyApp({ store, cookies }) {
const onLogout = useCallback(
() => cookies.clear('session'),
[cookies]
);
return (
<div className="main">
<header>
<MemoizedLogout
username={store.username}
onLogout={onLogout}
/>
</header>
{store.content}
</div>
);
}
useCallback(() => { cookies.clear() }, [])
는 항상 같은 함수 인스턴스를 반환한다. (cookies 값이 바뀌지 않는다면)MemoizedLogout
의 메모이제이션이 정상 작동 될 것이다!
말그대로, 리액트는 성능개선의 도구로 메모이제이션을 사용한다. 대부분의 상황에서 리액트는 메모이제이션된 컴포넌트의 렌더링을 피하지만, 당신은 그것에 의존해선 안된다.
hooks를 사용하는 컴포넌트는 메모이제이션을 달성하기 위해 자유롭게 React.memo()
를 사용할 수 있다.
단, 리액트는 state 값이 바뀐다면 언제든 리렌더링 된다. 설령 컴포넌트가 React.memo()
로 래핑되었다 할지라도...
React.memo()
는 함수형 컴포넌트를 메모이제이션을 하게 해 주는 훌륭한 도구다. 올바르게 적용 된다면 변경되지 않은 동일한 prop에 대해 리렌더링을 하는 것을 막을 수 있다.
다만, 콜백 함수를 prop으로 사용하는 컴포넌트에서 메모이징을 할 때 주의하자. 그리고 확실히 하자. 렌더링 사이에서 이전과 동일한 콜백 함수 인스턴스를 넘기는지.
마지막으로, 메모이제이션의 이점을 확인하기 위해 profiling을 사용하는 것을 잊지 말자.
한줄 요약:
React.memo()
컴포넌트의 props가 바뀌지 않았다면, 리렌더링 하지 않도록 설정하여 성능 최적화를 해주는 착한 녀석.