리액트가 실행될 때 가장 많은 CPU 자원을 사용하는 것은 렌더링입니다. 리액트는 UI 라이브러리 이기 때문에, 프로그램이 실행되는 동안에 화면을 그리고 또 그립니다. 리액트는 데이터와 컴포넌트 함수로 화면을 그립니다. 그 과정에서 대부분의 연산은 컴포넌트 함수의 실행과 가상돔에서 발생합니다. 여기서 데이터는 컴포넌트의 속성값과 상태값을 말합니다.
속성값이나 상태값이 변경되면, 리액트가 자동으로 컴포넌트 함수를 이용해서 화면을 다시 그립니다. 정말 단순하지만 중요한 리액트의 역할입니다. 사실 이 첫번째 단계를 생략할 수도 있습니다. 컴포넌트 단계에서는 렌더링이 필요한지 판단하는 과정이 하나 더 있습니다. 리액트 memo 함수를 이용하는 것입니다. 리액트 memo를 사용 시, 속성값의 변경이 없다면 이전 렌더링 결과를 재사용 할 수 있습니다. 만약 렌더링이 필요하다고 판단되면 컴포넌트 함수를 호출해서 새로운 가상돔을 만들고, 이전에 만들었던 가상돔과 비교해서 변경점을 찾습니다. 그리고 변경된 부분을 실제 돔에 반영합니다.
렌더링 속도를 개선하기 위해 각 단계에서 우리가 할 수 있는 일은 무엇인지 알아보겠습니다. 그런데, 평상시에는 성능 최적화를 고려하지 말고 편하게 코딩하시는 것이 좋을 것 같습니다. 대부분의 웹페이지는 성능을 고려하지 않고 코딩을 해도 문제없이 잘 돌아갑니다. 어쩌다 한번 성능이슈가 생기면 그때 고민해도 늦지 않습니다.
컴포넌트의 속성값이나 상태값이 변경되면, 리액트는 그 컴포넌트를 다시 그릴 준비를 합니다. 만약 아래의 코드처럼 리액트 memo 함수로 감싼 컴포넌트라면 두번째 인자로 전달하는 속성값 비교 함수가 호출이 됩니다.
이 속성값 비교 함수는, 이전/이후 속성값을 매개변수로 받아서 참 또는 거짓을반환합니다. 만약 참을 반환하면 이전 렌더링 결과를 재사용합니다. 그렇지 않고 거짓을 반환하면 컴포넌트 함수를 실행해서 가상돔을 업데이트하고 변경된 부분만 실제 돔에 반영합니다.
만약이렇게 속성값 비교 함수를 입력하지 않으면 , 얕은 비교를 수행하는 기본 함수가 실행이 됩니다. 컴포넌트를 React.memo 함수로 감싸지 않았다면, 항상 거짓을 반환하는 비교 함수가 실행이 되는 것과 같습니다. 이 때는 속성값이 변경되지 않아도 부모 컴포넌트가 렌더링 될 때마다 자기 자신도 렌더링이 됩니다. 속성값 변경 함수가 거짓을 반환하더라도 속성값이 변경되지않았다면 실제 돔도 변경되지 않을 것이기 때문에, 대부분 문제가 되지는 않습니다.
하지만 렌더링 성능이 중요한 상황에서는 이렇게 memo 함수를 사용해서 컴포넌트 함수의 실행과 가상돔의 계산을 생략할수 있기 때문에 렌더링 성능상의 이점이 있습니다.
function MyComponent(props) {
// ...
}
function isEqual(prefProps, nextProps) {
// true 또는 false를 반환
}
React.memo(MyComponent, isEqual)
이전에 상태값은 불변 객체로 관리해주면 좋다고 말씀을 드렸습니다. 그 이유는 아래와 같습니다. 오브젝트나 배열 객체가 있는 경우에 서로 비교하려면 값을 하나하나 비교해 주어야 합니다. 하지만 불변 객체로 관리하는 경우에는 === 연산자를 통해서 그 객체가 변했는지 아닌지를 검증할 수 있습니다.
const state = {};
const newState = {};
state === {...state, ...{name: 'horong'}}; // false
상태값을 불변객체로 관리하기위해서 사용되는 매우 편리한 연산자가 있습니다. 전개연산자 입니다. 기호로는 ... 형태로 점이 3개가 붙어있습니다. 배열과 오브젝트에서 전개연산자를 사용하는 방법은 아래와 같습니다.
전개연산자 사용법 - 배열
[...[1, 2, 3], 4, 5] // [1,2,3,4,5]
[...[1, 2, 3], ...[4, 5]] // [1,2,3,4,5]
전개연산자 사용법 - 오브젝트
{...{id: 'horong'}, name: 'hong'} // {id: "horong", name: "hong"}
{...{id: 'horong'}, ...{name: 'hong'}} // {id: "horong", name: "hong"}
따라서 상태값을 변경하는 경우에는 전개연산자를 아래와 같이 사용할 수 있습니다.
// props = {}
const text = 'hello';
const props = {...props, text: text};
// props = []
const numbers = [1, 2, 3]
const props = [...props, ...numbers];
리액트는 얕은 비교를 사용하기 때문에 === 연산자를 사용합니다. 따라서 위와같이 불변객체를 사용하는 방법으로 속성값과 상태값을 관리해야합니다. 그렇지 않으면 불필요한 렌더링이 발생할 수있습니다.
Parent 컴포넌트가 상태값 변경에 의해서 렌더링을 할때, 자식 컴포넌트도 렌더링을 합니다. 그런데, Child 컴포넌트가 React.memo 함수를 사용한것만 가지고 자식 컴포넌트의 속성값이 변경되지 않았다고 해서 자식 컴포넌트가 변경되지 않을 것이라고 생각하기가 쉽습니다. 실제로 아래와 같은 코드에서는 Parent 컴포넌트가 렌더링 될 때마다, onChange 함수가 새로 생성되게 됩니다. 따라서 속성값이 계속 변경됩니다.
function Parent() {
const [v1, setV1] = useState();
return <Child onChange={(v) => {setV1(v);}}>
}
이러한 부분을 개선하기 위해서는 useCallback 훅을 사용하면 됩니다. 지금은 onChangeCallback 안에서 상태값이나 속성값이 사용되지 않았기 때문에 빈 배열을 이용했습니다. 따라서 이 함수는 한번 생성된 값으로 고정이 됩니다.
function Parent() {
const [v1, setV1] = useState();
const onChangeCallback = useCallback(v => {
// ...
setV1(v);
}, [])
return <Child onChange={onChangeCallback}>
}
그런데, 꼭 함수만 이렇게 새로운 객체로 인식을 하는것이 아닙니다. 배열이나 오브젝트 객체에 있어서도 아래와같이 컴포넌트에 직접 전달을 한다면, 매번 새로운 객체로 인식을 합니다.
function Parent() {
return <Child data={[1, 2, 3]}>
}
절대 변하지않는데이터라면 아래와 같이 변수를 따로 빼서 정의하는 것만으로 해결이 됩니다.
function Parent() {
return <Child data={DATA}>
}
const DATA = [1, 2, 3];
하지만 값이 변경될 가능성이 있는 데이터라면, 필요할 때만 객체가 변경될 수 있도록 useMemo 훅을 사용해주면 됩니다.
function Parent() {
const [maxValue, setMaxValue] = useState(0);
const data = useMemo(() => DATA.filter(e => e<=maxValue), [
maxValue
]);
return <Child data={data}>
}
const DATA = [1, 2, 3];
지금까지 말씀드린 useMemo, useCallback, React.memo 와 같은 것들을 성능을 걱정해서 미리 사용하는 것을 추천하지 않습니다. 성능을 최적화하는 코드는 가독성이 안좋고, 유지 보수 비용도 증가하기 때문입니다. 성능이슈가 발생 했을 때, 해당하는 부분의 코드만 최적화하는 것을 추천드립니다.
지금까지는 값이 너무 많이 변경되어서 성능에 영향을 미치는 경우를 설명을 드렸다면, 이제는 값이 변경 되어야 하는데 변경이 되지 않아서 문제가 되는 경우를 알아보겠습니다.
아래의 코드를 보시면 데이터를 추가하는데 기존의 dataList 상태값 객체에 새로운 객체를 덮어쓰는 것이 아니라 변경을 하고 있습니다. 이런 경우에는 리액트가 동일한 객체로 인식하게 되기 때문에 화면에 새롭게 추가된 데이터를 확인할 수 없습니다.
function Parent() {
const [data, setData] = useState('');
const [dataList, setDataList] = useState([]);
function addData(data) {
dataList.push(data);
}
return (
<>
<button onClick={addData}}>데이터 추가</button>
{dataList.map(data => (
<p>{data}</p>
))}
</>
)
}
상태값을 불변 객체로 관리하지 않으면 이런문제가 발생합니다. 그러므로 아래와같이 불변객체로 관리하는 방법을 사용하여 dataList에 데이터를 추가해야 합니다.
function Parent() {
const [data, setData] = useState('');
const [dataList, setDataList] = useState([]);
function addData(data) {
dataList.setDataList([...dataList, data]);
}
return (
<>
<button onClick={addData}}>데이터 추가</button>
{dataList.map(data => (
<p>{data}</p>
))}
</>
)
}
리액트는 아래와같이 2번째 돔을 바꾸는 순간 그 이후에 돔도 바뀌었다고 인식을 합니다. 그 이유는 순서정보를 사용하기 때문입니다. 만약 2번째 돔을 제거하면 2번째 돔이 제거되었다 라고 인식하는게 아니라, 2번째 돔이 3번째 돔으로 변경되고, 3번째 돔은 사라졌다고 인식하여 두개의 요소를 변경하게 됩니다.
// 변경 전
<div>
<p>요소1</p>
<p>요소2</p>
<p>요소3</p>
</div>
// 변경 후
<div>
<p>요소1</p>
<p>요소3</p>
</div>
이렇게 인식하지 않고 2번째 돔만 변경이 되었다고 인식하게 하기 위해서는 key 속성을 추가해야합니다. 아래와 같이 키 속성을 입력해두면 해당하는 키 값에 따라서 변경 여부를 체크하여 렌더링 합니다.
// 변경 전
<div>
<p key="c1">요소1</p>
<p key="c2">요소2</p>
<p key="c3">요소3</p>
</div>
// 변경 후
<div>
<p key="c1">요소1</p>
<p key="c3">요소3</p>
</div>
그런데 위와같은 방식을 사용할 때에 주의할 점이 있습니다. Array.map 함수로 랜더링을 하는 경우가 많은데, 이 때, index 라는 순서정보를 가진 숫자를 key로 지정하지말고. 렌더링 할 데이터의 아이디 값으로 해주는 것이 좋습니다.
만약에 index 값으로 key를 입력했을 경우에는 배열 중간에 원소를 추가하거나, 삭제하는 경우, 또는 원소의 순서를 변경하는 경우에 비효율적으로 렌더링하게 됩니다. 따라서, 배열의 끝에서만 원소를 추가하거나 삭제하면서 원소의 순서를 변경하지 않는 경우에만 적합합니다.
출처 : 실전 리액트 프로그리맹 - 인프런 (추천합니다 !!)
[Frontend/React] 22. 리덕스의 구조 - 액션, 미들웨어 (0) | 2020.10.06 |
---|---|
[Frontend/React] 21. 리덕스란 무엇인가? (0) | 2020.10.06 |
[Frontend/React] 19. useEffect 활용법 (0) | 2020.10.05 |
[Frontend/React] 18. 재사용성을 고려한 컴포넌트의 분리 (0) | 2020.10.05 |
[Frontend/React] 17. 가독성을 높이는 조건부 렌더링 방법 (0) | 2020.10.05 |