[React] 컴포넌트 렌더링 최적화
React를 사용하면 렌더링 성능에 대해 고민할 수 밖에 없다. 최적화를 위해선, React 컴포넌트의 리렌더링 조건을 먼저 복기하자!
- 본인의 state가 변경될 때
- 부모 컴포넌트로부터 받아오는 props가 변경될 때
- 부모 컴포넌트가 리렌더링 될 떄
- shouldComponentUpdate(), forceUpdate() 등 강제 업데이트 메서드 호출
지금까지 나도 state, props를 단순히 데이터를 다루기 위해 적용하다보니, 데이터의 할당이나 위치가 부적절했을때 불필요한 리렌더링이 유발되었을 거라고 생각한다.
리렌더링을 하면 컴포넌트를 reflow & repaint 를 함에 따른 브라우저 성능 저하가 발생할 뿐만 아니라, 다른 이슈도 동반된다.
함수형 컴포넌트의 경우, 리렌더링을 할 경우 내부 로직들이 재호출되기 때문에 그만큼 불필요한 컴포넌트들의 호출을 최소화해야 한다.
그렇기에, React에서 렌더링을 최적화하는 좀 더 효율적인 설계와 기능들에 대해 한 번 정리해보고자 한다.
💙 렌더링 최적화하는 8가지 기법
1. 적절한 위치에 state 선언
const DataPage = () => {
const [list, setList] = useState(LIST)
return (
<div>
<h1>페이지 컴포넌트</h1>
<DataList listProps={list} />
</div>
)
}
const DataList = ({ listProps }) => {
const [list, setList] = useState(LIST)
return (
<div>
<h2>리스트 컴포넌트</h2>
// 1) 최상위 컴포넌트의 props로 맵핑
{listProps.map(e => <DataItem data={e} />)}
// 2) 본인 컴포넌트의 state로 맵핑
{list.map(e => <DataItem data={e} />)}
</div>
)
}
const DataItem = ({ data }) => {
return (
<div>
<h3>아이템 컴포넌트</h3>
<h4>{data.title}</h4>
<img src={data.img} />
<p>{data.description}</p>
</div>
)
}
위처럼, 페이지, 리스트, 아이템 컴포넌트가 부모 - 자식관계를 이루고 있는 예시를 살펴보자.
LIST라는 static data를 가져와 state로 관리하고 이를 리스트 컴포넌트에서 렌더링하려고 할 때, 방법은 2가지가 있을 것이다.
* 1) 페이지 컴포넌트에서 props로 리스트 컴포넌트에 전달, 2) 리스트 컴포넌트가 state로 저장해서 사용
어떤 방법을 사용할지는 여건에 따라 달라질 수 있다. 만약, 페이지 컴포넌트에서 리스트 데이터를 참조한다면 여기서 state로 저장하는 게 나을 것이다.
하지만, 그런 경우가 아니라면 오히려 이 리스트 데이터가 갱신됬을 때, 페이지 컴포넌트 내 리스트 외 다른 컴포넌트들까지 리렌더링을 유발할 수 있다.
그렇기 때문에, 리스트 컴포넌트가 직접 state에 저장하고 이를 아이템 컴포넌트로 맵핑하는 2)번 방법이 최적화 측면에서 유리할 것이다.
2. 객체 타입의 state 변형하지 않기
// 생성자 함수
<Component prop={new Obj('x')} />
// 객체 리터럴
<Component prop={{ key: 'x' }} />
이처럼, 새로운 객체를 생성해서 props로 자식 컴포넌트에 전달하면 문제가 발생할 수 있다.
부모 컴포넌트가 다른 요인으로 리렌더링되면, 해당 props 객체는 값은 같지만 참조주소가 다른 새로운 객체로 인식되기 때문에 이 역시 자식 컴포넌트의 리렌더링을 유발한다.
그렇기에 데이터의 변형을 상위에서 하는 것보단, state 그대로 컴포넌트로 전달에서 하위에서 이를 가공하는 것이 유리하다.
// 안좋은 예
function UserList() {
{...}
const getResult = useCallback((score) => {
if (score <= 70) {
return { grade: "D" };
} else if (score <= 80) {
return { grade: "C" };
} else if (score <= 90) {
return { grade: "B" };
} else {
return { grade: "A" };
}
}, []);
return(
<div>
{users.map((user) => {
return (
<Item key={user.id} user={user} result={getResult(user.score)} />
);
})}
</div>
)
export default memo(UserList);
function Item({ user, result }) {
console.log("Item component render");
return (
<div className="item">
<div>이름: {user.name}</div>
<div>나이: {user.age}</div>
<div>점수: {user.score}</div>
<div>등급: {result.grade}</div>
</div>
);
}
export default Item;
result라는 가공된 객체를 별도의 props로 전달하기 때문에, <UserList> 컴포넌트가 다른 요인으로 리렌더링 될 때마다 이 result 객체가 얽인 <Item> 컴포넌트들도 같이 리렌더링되는 비효율성이 생긴다.
// 좋은 예
function UserList() {
{...}
return(
<div>
{users.map((user) => {
return (
<Item key={user.id} user={user} />
);
})}
</div>
)
export default memo(UserList);
function Item({ user }) {
console.log("Item component render");
const getResult = useCallback((score) => {
if (score <= 70) {
return { grade: "D" };
}
if (score <= 80) {
return { grade: "C" };
}
if (score <= 90) {
return { grade: "B" };
} else {
return { grade: "A" };
}
}, []);
const { grade } = getResult(user.score);
return (
<div className="item">
<div>이름: {user.name}</div>
<div>나이: {user.age}</div>
<div>점수: {user.score}</div>
<div>등급: {grade}</div>
</div>
);
}
export default memo(Item);
이처럼, <UserList> 컴포넌트는 각 user 데이터 그대로만 props로 내려주고, 데이터 가공을 <Item> 에서 진행한다.
3. 컴포넌트 맵핑 시 key 값의 중요성
React에서는 컴포넌트들을 맵핑할 때, 고유의 key 값을 부여할 것을 강제하고 있다. 이 때, key값에 index를 사용하는것을 지양해야 한다.
리스트 데이터에 아이템이 추가/삭제 되었을 때를 생각하면 그 이유를 알 수 있다.
아이템이 중간에서 추가/삭제되면, 그 이후의 아이템 컴포넌트들의 index가 바뀌므로 key값이 바뀜에 따라 리렌더링이 발생하게 된다.
또한, 이러한 반복적인 데이터 조작으로 index가 순간적으로 꼬이면서 오류를 유발할 수 있다.
가급적이면 데이터의 id 등 고유값을 key에 넣기를 권장하나, 아래와 같은 경우엔 index를 써도 무방하다.
- 배열과 각 요소가 수정, 삭제, 추가 등의 기능이 없는 단순 렌더링만 담당하는 경우
- id로 쓸만한 unique 값이 없을 경우
- 정렬 혹은 필터 요소가 없어야 함
4. shouldComponentUpdate(), React.PureComponent - 클래스형 컴포넌트
class ReactComponent extends Component {
constructor() {
this.state = { data: null }
}
shouldComponentUpdate(nextProps, nextState) {
return nextState.data !== this.state.data
}
render() {
return <p>{this.state.data}</p>
}
}
shouldComponentUpdate() 는 클래스형 컴포넌트에서 리렌더링의 조건을 거는 메서드이다.
부모 컴포넌트의 리렌더에 따라 자식 컴포넌트의 리렌더도 불가피할 때, 여기에 조건을 부여하여 부합하지 않으면 false를 반환해서 리렌더링을 방지할 수 있다.
* shouldComponentUpdate() 가 false를 반환하면 render() 메서드가 호출되지 않는다.
class Pure extends React.PureComponent<PureProps, PureStates> {
constructor(props: AuthProps) {
super(props);
this.state = {};
}
render() {
return (
<div> React.PureComponent </div>
);
}
}
이와 비슷하게, 클래스형 컴포넌트는 PureComponent 컴포넌트를 제공하며, 이는 불필요한 리렌더링을 자동적으로 방지한다.
Pure는 이미 shouldComponentUpdate() 가 적용되어 있어, props와 state를 얕은 비교하여 변경된 부분이 있을때만 true를 반환한다고 한다.
5. React.memo()
React.memo() 는 컴포넌트를 랩핑하여 메모이제이션 하고, props가 바뀌지 않으면 리렌더링을 방지하는 함수이다.
const FunctionalComponent = React.memo(({...props}) => {
return (
//html tag
)
}, (prevProps, nextProps) => {
if('리 렌더링 해야하는 조건') {
return false;
}
return true;
})
기본적인 문법은 위와 같다. 컴포넌트를 React.memo() 로 감싸며, 첫 번째 인자는 컴포넌트, 두 번째 인자는 콜백함수(리렌더 조건)을 받는다.
다음은 사용 예시이다. <UserList> 의 리스트 state가 수정되어도, 이미 렌더링된 <UserItem> 은 리렌더되지 않도록 React.memo()로 방지한 모습이다.
// UserList.jsx
import { useState } from "react";
import UserItem from "components/section/examples/example5/UserItem";
import Button from "components/atom/Button";
function UserList() {
console.log("UserList component render");
const [users, setUsers] = useState([
{
id: 0,
name: "Kim",
age: 27,
score: 80,
},
{
id: 1,
name: "Jo",
age: 25,
score: 70,
},
]);
const addUser = () => {
setUsers([
...users,
{
id: 2,
name: "Jung",
age: 30,
score: 90,
},
]);
};
return (
<div>
<Button
value="새 유저 생성"
disabled={users.length >= 3}
onClick={addUser}
/>
{users.map(user => {
return <UserItem key={user.id} user={user} />;
})}
</div>
);
}
export default UserList;
// UserItem.jsx
import React from "react";
function UserItem({ user }) {
console.log(`UserItem (id: ${user.id}) component render`);
return (
<div className="user-item">
<div>이름: {user.name}</div>
<div>나이: {user.age}</div>
<div>점수: {user.score}</div>
</div>
);
}
export default React.memo(UserItem);
React.memo() 는 Hooks가 아닌 일종의 HOC(고차 컴포넌트)이다.
그렇기에 클래스형 & 함수형 컴포넌트 모두 적용 가능하며, 함수형 컴포넌트에서 shouldComponentUpdate() 메서드의 대안으로 제시되는 솔루션이다.
6. useMemo()
컴포넌트 내 어떤 함수가 값을 리턴하는데 많은 시간이 소요된다면, 이 컴포넌트가 리렌더링 될 때마다 함수호출에 많은 시간이 소요될 것이다.
또, 그 함수의 리턴값을 자식 컴포넌트가 참조한다면, 해당값이 변경될 때마다 리렌더링이 발생될 것이다.
useMemo() 는 이런 경우 사용되는 Hooks로, CPU 소모가 심한 함수들을 캐싱하기 위해 사용된다.
// UserList.jsx
import { useState } from "react";
import Average from "components/section/examples/example8/Average";
import UserItem from "components/section/examples/example8/UserItem";
import Button from "components/atom/Button";
function UserList() {
console.log("UserList component render");
const [text, setText] = useState("");
const [users, setUsers] = useState([
{
id: 0,
name: "Kim",
age: 27,
score: 80,
},
{
id: 1,
name: "Jo",
age: 25,
score: 70,
},
]);
const average = (function () {
console.log("calculate average. It takes long time !!");
return users.reduce((result, user) => {
return result + user.score / users.length;
}, 0);
})();
const addUser = () => {
setUsers([
{
id: 2,
name: "Jung",
age: 30,
score: 90,
},
...users,
]);
};
return (
<div>
<div>
<input
type="text"
value={text}
placeholder="아무 내용이나 입력하세요."
onChange={event => setText(event.target.value)}
/>
</div>
<Button
value="새 유저 생성"
disabled={users.length >= 3}
onClick={addUser}
/>
<Average average={average} />
{users.map(user => {
return <UserItem key={user.id} user={user} />;
})}
</div>
);
}
export default UserList;
위 예제의 average 함수를 보자. users 유저들의 점수 데이터의 평균을 반환하는 일종의 computed state(계산된 상태값) 이다.
이는 유저수가 많아질수록 연산에 많은 리소스가 소요되기에, 컴포넌트가 리렌더링 될 때마다 호출된다는 비효율성이 발생한다.
useMemo(()=> func, [input_dependency])
이를 최적화하기 위해 useMemo() Hooks를 사용한다.
첫 번째 인자는 캐싱하는 함수이며, 두 번째 인자는 [dependency array] 로 여기에 포함된 값이 바뀌어야 해당 함수를 재호출한다.
통상, dependency 에는 캐싱함수와 연관된 props, state 등을 넣어준다.
const average = useMemo(() => {
console.log("calculate average. It takes long time !!");
return users.reduce((result, user) => {
return result + user.score / users.length;
}, 0);
}, [users]);
위는, average 함수를 useMemo() Hooks를 통해 캐싱한 예제이다.
7. useCallback()
useCallback() 역시 useMemo()와 같은 매커니즘으로 렌더링 최적화에 활용된다.
useMemo() 가 특정 리턴값을 메모이징했다면, useCallback() 은 props로 넘겨주는 함수 자체를 메모이징한다.
보통, 자식 컴포넌트의 조작을 통해 부모 컴포넌트의 state를 조작할 때 이벤트 핸들러로 사용될 함수를 props로 넘기는 경우가 많다.
이 때, 상위 컴포넌트의 state가 갱신되고, 이에 따라 이벤트 핸들러 함수 역시 재호출이 되기 때문에 이를 props로 받는 자식 컴포넌트 역시 불필요한 리렌더링이 발생한다.
const Root = () => {
const [isClicked, setIsClicked] = useState(false);
const _onClick = useCallback(() => {
setIsClicked(true); },
[]);
// dependency가 없으므로 Root component가 렌더링 되는 최초에 한번만 생성되며 이후에는 동일한 참조 값을 사용한다.
return (
<>
<Child onClick={_onClick}/>
<Child onClick={_onClick}/>
...
<Child onClick={_onClick}/>
</>
);
};
// Root와 Child가 여러번 렌더링 되더라도 onClick props으로 전달되는 _onClick 함수는 한번만 생성되므로 계속해서 동일 참조 값을 가진다.
const Child = ({onClick}) => {
return <button onClick={onClick}>Click Me!</button>
};
위 예시를 보면, <Child> 컴포넌트의 클릭 이벤트 핸들러 함수인 _onClick을 useCallback() 으로 메모이제이션 한 모습이다.
본래는, _onClick() 메서드로 인해 isClicked가 토글링되면 <Root>가 리렌더링 되고, 이에 따라 _onClick() 메서드 역시 재호출되어야 하지만,
useCallback() Hooks로 메모이징했고 dependency가 없기 때문에 최초 렌더링 이외에는 재호출이 일어나지 않는다.
* useCallback() 은 이벤트 핸들러 함수들에 기본적으로 적용되어도 유용할 것 같다고 생각된다.
8. useState의 함수형 업데이트
// 예시) 삭제 함수
const onRemove = useCallback(
id => {
setTodos(todos.filter(todo => todo.id !== id));
},
[todos],
);
// 예시) 함수형 업데이트 후
const onRemove = useCallback(id => {
setTodos(todos => todos.filter(todo => todo.id !== id));
}, []);
위는 todos 리스트를 삭제하는 onRemove() 메서드의 예시이다. 본래, useCallback으로 선언했고, todos가 dependency로 들어가있다.
하지만, setState() 함수에 새로운 값이 아닌, 상태 업데이트를 정의하는 함수를 넣어주면 dependency가 필요없게 되므로 onRemove() 함수의 불필요한 재호출을 최소화할 수 있다.
이번 포스팅을 작성하면서 생각보다 React와 함수형 컴포넌트의 렌더링에 대해 더 깊은 이해가 된 것 같다.
컴포넌트가 리렌더링 될 때마다 내부의 프로퍼티와 메서드가 재선언/재할당 되기 때문에, 불필요한 부분을 최소화해야 함이 이해되었다.
우선적으로, 화면구성에 필요한 최소한의 값만 state, props로 다뤄야 하며, 참조값들은 useRef()를 사용하거나 메모이제이션 Hooks들을 적용해야한다고 생각했다.
또한, 가장 큰 이슈는 아무래도 부모 컴포넌트의 리렌더에 따라 자식 컴포넌트들도 리렌더링된다는 점일 것이다.
그렇기에, 조건부로 리렌더링하는 shouldComponentUpdate()나 React.memo() 와 같은 함수들을 자식 컴포넌트에 적용하거나,
부모 컴포넌트가 props로 넘겨주는 객체(혹은 함수)들을 적절하게 전달해서 데이터 갱신 외 리렌더링을 방지해야 한다.
무엇보다, state를 적절한 위치에 두어 하위 컴포넌트의 리렌더링을 최소화하거나, state가 변형되는 지점을 적절히 선정하는 등 컴포넌트 트리의 설계 역시 중요함을 절실히 느낀 계기였다.
📎 출처
- [렌더링 이해 및 최적화] MinuKang 님의 블로그 : https://medium.com/vingle-tech-blog/react-%EB%A0%8C%EB%8D%94%EB%A7%81-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0-f255d6569849
- [렌더링 최적화 기법] cocoder16 님의 블로그 : https://cocoder16.tistory.com/36
- [렌더링 최적화 기법] shin6403 님의 블로그 : https://velog.io/@shin6403/React-%EB%A0%8C%EB%8D%94%EB%A7%81-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94%ED%95%98%EB%8A%94-7%EA%B0%80%EC%A7%80-%EB%B0%A9%EB%B2%95-Hooks-%EA%B8%B0%EC%A4%80
- [shouldComponentUpdate, React.PureComponent] dolarge 님의 블로그 : https://velog.io/@dolarge/Pure-Component%EB%9E%80