성능은 UX에 직접적인 영향을 미치는 중요한 요소이다.
앱의 렌더링이 느리거나 버벅거리는 경우, 사용자는 반응이 둔해지거나 최악에는 사용을 중단할 수 있다. 따라서 렌더링 최적화를 통해 불필요한 리렌더링을 줄이고 성능을 향상시켜 사용자에게 원활하고 빠른 경험을 제공해야 한다.
불필요한 리렌더링은 CPU 및 메모리 자원을 낭비할 수 있다.
리액트는 Virtual DOM을 사용하여 효율적인 업데이트를 수행하지만, 여전히 컴포넌트의 렌더링 작업은 자원을 소모한다. 이를 위해 불필요한 리렌더링을 줄여서 자원을 절약하고 애플리케이션의 전체 성능을 향상시킬 수 있다.
앱에서 데이터를 가져오는 네트워크 요청은 비용이 큰 작업이다.
불필요한 리렌더링으로 인해 동일한 데이터를 다시 불러오는 경우, 네트워크 비용과 응답 시간이 낭비될 수 있다. 렌더링 최적화를 통해 변경이 없는 경우에는 네트워크 요청을 생략하고 이전 데이터를 재사용해야 한다.
컴포넌트의 리렌더링은 Side effect 를 발생시킬 수 있다.
예를 들어, 네트워크 요청이나 상태 변경과 같은 작업이 리렌더링마다 반복적으로 실행된다면, 예기치 않은 동작이 발생할 수 있다. 렌더링 최적화를 통해 변경이 없는 경우에는 불필요한 부작용을 방지하고 예상된 동작을 유지해야 한다.
최적화를 하기 전에 React 또는 React Native 가 리렌더링을 하는 조건을 알고 있어야 한다.
리렌더링의 조건은 다음과 같다.
위에서 나열한 리렌더링 조건에 의해 불필요한 리렌더링을 하는 경우가 꽤 많다. 따라서, 이런 경우를 막기 위해 렌더링 최적화가 필요한 것이다.
React.memo
는 리액트의 HOC(Higher Order Componenet, 고차 컴포넌트) 로서, 컴포넌트를 메모이제이션 한다.
잠깐! 메모이제이션(Memoization) 이란,
컴퓨터 프로그램이 동일한 계산을 반복해야 할 때, 이전에 계산한 값을 메모리에 저장함으로써 동일한 계산의 반복 수행을 제거하여 프로그램 실행 속도를 빠르게 하는 기술이다.
즉, 부모 컴포넌트로부터 받은 props
가 같다면, 메모이제이션 해둔 컴포넌트를 그대로 가져온다.
다음 예시를 보자.
import React, { useState } from "react";
import Light from "./Light";
const SmartHome = () => {
const [kitchenOn, setKitchenOn] = useState(false);
const [bathOn, setBathOn] = useState(false);
const toggleKitchen = () => {
setKitchenOn(!kitchenOn);
};
const toggleBath = () => {
setBathOn(!bathOn);
};
return (
<div>
<button onClick={toggleKitchen}>
주방
<Light on={kitchenOn}></Light>
</button>
<button onClick={toggleBath}>
욕조
<Light on={bathOn}></Light>
</button>
</div>
);
}
// Light.jsx
import React from "react";
export const Light = ({ on }) => {
console.log({ on });
return <div>{on ? "💡" : "⬛"}</div>;
}
위 코드는 onClick 이벤트인 toggleKitchen 과 toggleBath 둘 중 하나만 클릭해도 모든 상태가 갱신되어 Light 컴포넌트 무조건! 리렌더링 된다.
why? 부모 컴포넌트가 리렌더링되면서 동시에 부모로부터 받는 props가 변경되니 자식 컴포넌트 역시 리렌더링 되기 때문이다.
만약에 onClick 이벤트에 따라 원하는 컴포넌트만 리렌더링 하고 싶다면, Light 컴포넌트를 다음과 같이 변경하면 된다.
// List.jsx
import React from "react";
const Light = ({ on }) => {
console.log({ on });
return <div>{on ? "💡" : "⬛"}</div>;
}
export default React.memo(Light);
이 경우, on 프로퍼티가 변하지 않는 이상 리렌더링이 되지 않기 때문에 이제는 onClick 이벤트 둘 중 하나만 클릭하면 해당 클릭으로 인해 변하는 useState
만 반영되어 해당 state
를 props
로 가지는 Light 컴포넌트만 리렌더링이 된다.
🔔 참고1
콜백함수를 포함한 참조 타입의 객체를 props
로 전달하는 경우 메모이제이션이 되지 않는다.
다음 예제를 보자.
import { useState } from 'react';
import Button from './Button';
const App = () => {
const [num, setNum] = useState(0);
console.log("APP RUNNING!");
const btnOnClickHandler = () => setNum(0);
return (
<div className="App">
<div onClick={() => setNum(num + 1)}>{num}</div>
<Button onClick={btnOnClickHandler} />
</div>
);
}
// Button.jsx
import React from 'react';
const Button = props => {
console.log("BUTTON RUNNING!");
return <button onClick={props.onClick}>RESET!</button>;
};
export default React.memo(Button);
과연 자식 컴포넌트인 Button 컴포넌트는 React.memo
로 메모이제이션 했는데도 리렌더링이 되지 않을까?
결론은, 리렌더링이 된다.
컴포넌트 안에서 선언된 인라인 함수는 리렌더링할 때 마다 새로운 함수를 생성한다. 즉, 새로 생성 된 함수는 참조 타입이기 때문에 할당받았던 주소가 바뀌게 되고, 이를 전달받은 자식 컴포넌트는 참조 타입의 주소가 바뀌었기 때문에 사실상 동일한 기능과 return을 하는 함수임에도 불구하고 props
가 달라졌구나! 라고 인지한다.
따라서 리렌더링없이 콜백함수와 같은 참조 타입을 props
로 전달하고 싶을 땐 아래에서 서술 할 useCallback
또는 useMemo
와 함께 사용하면 된다.
🔔 참고2
React.memo
를 사용할 땐, 되도록 같은 props
로 자주 렌더링을 시도하는 경우나 혹은 컴포넌트 자체가 무겁거나 비용이 큰 연산이 있는 경우에 사용하도록 한다.
React.memo
가 고차 컴포넌트(HOC) 였다면, useCallback
과 뒤에서 서술할 useMemo
는 리액트의 Hook 이다.
useCallback
은 이름에서 알 수 있듯이, 콜백함수를 메모이제이션하기 위해 사용하는 최적화 관련 Hook 함수이다.
즉, 어떠한 콜백함수를 메모리에 저장 해놓고, 리렌더링을 원하는 조건에 도달 전까지 리렌더링 없이 계속 그 함수를 꺼내서 사용할 수 있다.
다음은 문법이다.
const memoizedCallback = useCallback(() => {
const Func = () => { // 1. 저장 할 함수
...
}
}, []) // 2. 의존성 배열
핵심은, 의존성 배열안에 담긴 값이 다른 값으로 변하지 않는 이상 Func() 함수는 메모리에 저장되어 리렌더링과 상관없이 다시 생성되지 않고 재사용할 수 있다.
다음 예제를 보자.
import React, {useState} from 'react';
import {Button, TextInput, View} from 'react-native';
import List from './List';
const App = () => {
const [string, setString] = useState('');
const [dark, setDark] = useState(false);
const getItems = () => {
return string;
};
const theme = {
backgroundColor: dark ? '#333' : '#fff',
color: dark ? '#fff' : '#333',
};
return (
<View style={theme}>
<TextInput onChangeText={(number: string) => setString(number)} />
<Button title="button" onPress={() => setDark(prevDark => !prevDark)} />
<List getItems={getItems} />
</View>
);
};
// List.tsx
import React from 'react';
const List = ({getItems}) => {
console.log(getItems);
};
export default React.memo(List);
TextInput 박스에 값이 변할 때만 List 컴포넌트가 리렌더링 되길 원하지만, theme를 바꾸귀 위해 button을 클릭하면 List 컴포넌트까지 렌더링이 된다.
이는 위에서도 잠깐 언급했지만, 리렌더링이 되면 함수는 참조 타입이기 때문에 props
로 받아 사용하는 자식 컴포넌트는 주소값이 바뀌었기 때문에 props
가 바뀌었다고 인지하기에 리렌더링이 일어난다.
따라서, 위와 같이 함수가 재생성되지 않도록 메모이제이션하기 위해 useCallback
훅이 필요한 것이다.
아래는 useCallback
을 적용한 예시이다.
const getItems = useCallback(() => {
return string;
}, [string]);
getItems 함수를 useCallback
으로 감쌌다. 이는 string 이 setString 함수에 의해 값이 변하지 않는 이상 재생성없이 동일한 함수를 반환한다.
🔔 참고
의존성 배열이 빈배열([]
) 일 경우, useEffect
훅에서 자주 사용 하다보니 헷갈리는 경우가 잦다.
빈 배열은 컴포넌트가 최초로 렌더링될 때 딱! 1번만 실행한다는 뜻이다. 리렌더링도 적용되지 않는다. 최초 딱 1번이다.
그걸 useCallback
에 사용하면 렌더링 시 함수 생성을 딱! 1번만 한다는 소리다.
즉, 함수 생성하고 더 이상 함수를 사용 못하는 게 아니다. 헷갈리지 말자.
앞서 설명한 useCallback
과 매우 비슷한 훅이다.
다만, useCallback
은 '함수' 를 메모이제이션하고, useMemo
는 '함수의 값' 을 메모이제이션한다는 차이점이 있다.
문법은 다음과 같다.
const memoizedValue = useMemo(() => {
() => {
... // 1. 메모이제이션 대상
}
}, []); // 2. 의존성 배열
차이점이 정말 없다.
useCallback
: useCallback(fn, [deps])
useMemo
: useMemo(() => fn, [deps])
더 구체적으로 말하자면, useCallback
역할을 useMemo
에서 다 커버가 가능하다.
다음 예제를 보자.
import React, {useEffect, useState} from 'react';
import {Button, Text, TextInput, View} from 'react-native';
export const MemoTest = () => {
const [input, setInput] = useState('');
const [isKorea, setIsKorea] = useState(true);
const location = {
country: isKorea ? '한국' : '일본',
};
useEffect(() => {
console.log('useEffect 호출되는지 확인 용도!');
}, [location]);
return (
<View>
<TextInput onChangeText={(text: string) => setInput(text)} />
<Text>나라: {location.country}</Text>
<Button title="Update" onPress={() => setIsKorea(!isKorea)} />
</View>
);
};
위 예제는 Input 박스의 값이 변할 때 마다 MemoTest 컴포넌트 자체가 전부 렌더링이 되어버린다. 즉, isKorea와 같은 리렌더링에 필요없는 상태가 함께 재생성이 되어버린다.
이를 위해 다음과 같이 useMemo
를 적용해보자.
import React, {useMemo, useEffect, useState} from 'react';
import {Button, Text, TextInput, View} from 'react-native';
export const MemoTest = () => {
const [input, setInput] = useState('');
const [isKorea, setIsKorea] = useState(true);
const location = useMemo(() => {
return {
country: isKorea ? '한국' : '일본',
};
}, [isKorea]);
useEffect(() => {
console.log('useEffect 호출되는지 확인 용도!');
}, [location]);
return (
<View>
<TextInput onChangeText={(text: string) => setInput(text)} />
<Text>나라: {location.country}</Text>
<Button title="Update" onPress={() => setIsKorea(!isKorea)} />
</View>
);
};
콘솔을 찍어보면 알겠지만, Input 박스에 값을 아무리 바꿔도 useEffect
은 재호출되지 않고 콘솔 역시 찍히지 않는다.
이처럼 특정 값을 return 받을 때 또는 하나의 연산에서 for 문을 극단적으로 999999번 돌려야할 때와 같이 값을 메모이제이션하여 최적화가 필요할 때 사용하는 최적화 관련 훅이다.