저번시간에 배열 렌더링까지 했다. 이렇게 해서 끝난거 같지만 실제로는 문제가 조금 있다. 바로 리렌더링이다. 리액트는 컴포넌트를 다음과 같은 총 4가지 경우에 업데이트한다.
그니까 지금 현재 상황으로 봤을때 input에 값을 입력하면 App.js에 있는 inputs라는 상태값이 변경된다. 이러면 App.js가 리렌더링되고 마찬가지로 그 자식은 CreateUser.js와 UserList.js도 리렌더링되는 사태가 일어난다.
만약 배열값이 100개, 1000개로 많다면 input값을 입력할때마다 새로 불러오고, 새로 불러오면 부하가 엄청 발생하겠지? 그래서 이번시간에는 성능 최적화를 하는 방법에 대해 공부해보려고 한다.
크롬 웹 스토어에 가면 React Developer Tools이 존재하는데 이걸 이용하면 리렌더링되는 상황을 눈으로 직접 볼 수 있다.
그리고 나서 크롬 개발자 도구를 연 다음에 보면 Components 탭과 Profiler 탭이 새로 생긴 것을 볼 수 있을 것이다. 그 중에서 먼저 Components 탭을 보자.
App.js가 가진 State도 보이며 CreateUser를 클릭해보면 App.js로 부터 넘겨받는 props도 보인다. 자.. 그리고 설정 아이콘을 클릭해보면 아래처럼 나오는데 Highlight updates when components render를 체크해주자.
그리고 나서 input을 입력해보자. 무슨 줄같은게 생기지 않는가? 이게 바로 업데이트가 일어나고 있다는 증거이다. input을 입력했으면 input 상태만 변해야 하는데 배열 상태까지 리렌더링 되고 있다는 것이다. 😲😲
Profiler를 보면 더 자세히 알 수 있는데.. input을 입력하기 전에 start profiling을 누르고 입력하자.
입력을 다 했으면 stop을 눌러서 종료해주자. 그럼 결과가 나타난다. 참고로 username에다가 111을 입력한 결과이다.
input을 입력했는데 모든 곳에서 시간차는 나지만 어쨌든 리렌더링이 일어나고 있다는 것을 알 수 있다.
useEffect라는 Hook을 사용하면 컴포넌트가 마운트됐을때(처음 나타났을때), 언마운트 됐을때(사라질때), 그리고 업데이트 될 때(특정 props가 바뀔 때) 특정 작업을 처리하도록 설정할 수 있다.
User 컴포넌트가 마운트 될때, 즉 처음 시작할때 콘솔에 출력되도록 하는 코드이다.
// UserList.js
function User({ user, onRemove, onToggle }) {
useEffect(() => { // ✅
console.log('컴포넌트가 화면에 나타남');
}, []);
return (
<div>
<b
style={{
color: user.active ? 'green' : 'black',
cursor: 'pointer',
}}
onClick={() => onToggle(user.id)}
>
{user.username}
</b>{' '}
<span>({user.email})</span>
<button onClick={() => onRemove(user.id)}>삭제</button>
</div>
);
}
user가 총 3개니까 3번 나타나게 된다. 그리고나서는 input을 입력해봐도, 배열을 생성하거나 수정하거나 삭제를 해봐도 콘솔이 다시 출력되지는 않는다.
이처럼 useEffect는 마운트 될 때 딱 한번 실행하도록 할 때 쓸 수 있다. 주로 이런 상황은 아래와 같을 때 많이 이용한다.
컴포넌트가 화면에서 사라질때 콘솔에서 출력되도록 추가했다. 한번 user를 삭제해보자. 잘 나타나는가?
function User({ user, onRemove, onToggle }) {
useEffect(() => {
console.log('컴포넌트가 화면에 나타남');
return () => { // ✅
console.log('컴포넌트가 화면에서 사라짐');
};
}, []);
...(생략)...
언마운트 될 때 주로 하는 작업은 아래와 같다.
마지막으로 특정 값이 업데이트 될 때만 리렌더링 되도록 설정할 수 있다. 지금까지는 useEffect의 두번째 매개변수로 빈배열만 넣어줬다면 이번에는 값을 넣어보도록 하겠다.
다음은 빈 배열에다가 user를 넣어준 코드이다.
useEffect(() => {
console.log(user);
}, [user]); // ✅
이제 user가 새로 추가되거나 바뀔때면 바뀐 user값이 콘솔에 찍힐것이다. 즉, 특정값이 업데이트된 이후 실행이 된다는 것이다. 만약 user가 바뀌기 전에 user값도 확인하고 싶다면 끝맺임 함수를 쓰면 된다.
useEffect(() => {
console.log('user 값이 설정됨');
console.log(user); // 바뀐 이후의 값
return () => {
console.log('user 값이 바뀌기 전');
console.log(user); // 바뀌기 전에 값
};
}, [user]);
user 값을 클릭해서 변경시켜보면 return이 먼저 실행된다음 업데이트가 된 이후 위에 코드가 실행되는 것을 알 수 있다.
🚨 만약 useEffect에서 등록된 함수에서 props로 받아온 값을 참조하거나 혹은 useState로 관리하고 있는 값을 참조할때는 빈 배열안에 해당 변수를 꼭 넣어줘야 한다. 그렇게 해야만 그 값이 최신의 값을 가지고 있게 된다. 또한 오류가 나지는 않지만 나중에 경고가 나타날 수 있으니 그냥 습관적으로 넣어주면 된다.
마지막으로 배열을 생략하면 어떻게 될까? 이때는 마운트, 언마운트, 업데이트 될때마다 계속 실행된다.
useEffect(() => {
console.log(user);
});
컴포넌트가 마운트되고 나서 호출되며, user값이 변경되도 호출되며, 언마운트되면서도 호출되는... 즉, useEffect를 안 쓴거나 마찬가지.
useMemo Hook을 사용하면 렌더링하는 과정에서 특정 값이 바뀌었을때만 연산을 실행하고 원하는 값이 바뀌지 않았다면 이전에 연산했던 결과를 다시 사용할 수 있다.
예를 들어, 우리가 활성 사용자 수를 카운트하는 기능을 만든다고 해보자.
// App.js
function countActiveUser(users) {
console.log('활성 사용자 수를 세는중...');
return users.filter((user) => user.active).length;
}
(...생략...)
const count = countActiveUser(users);
return (
<>
<CreateUser
username={username}
email={email}
onChange={onChange}
onCreate={onCreate}
/>
<UserList users={users} onRemove={onRemove} onToggle={onToggle} />
<div>활성 사용자 수: {count}</div>
</>
);
이제 user를 클릭하면 active가 변할테고 그럼 활성 사용자 수가 변경되는 것을 볼 수 있을 것이다. 하지만 문제가 있다. 만약 input 상태가 바뀔때도 App.js가 리렌더링 되기 때문에 countActiveUser도 매번 다시 실행될 것이다. 우리는 users 상태가 변경될때만 countActiveUser를 실행시키도록 하고 싶다.
이럴때 useMemo를 사용하면 된다. useEffect랑 사용법은 비슷하지?
const count = useMemo(() => countActiveUser(users), [users]);
useCallback은 useMemo와 상당히 비슷한 함수이다. 이 Hook을 사용하면 이벤트 핸들러 함수를 필요할때만 실행시킬 수 있다.
우리가 구현한 onCreate, onRemove, onToggle 같은 함수는 users가 변경될때만 리렌더링해주면된다. 마찬가지로 onChange 함수는 inputs가 변경될때만 리렌더링 해주면 된다.
사용법 역시 간단하다. useCallback으로 감싸주고 2번째 매개변수 deps에다가는 이 함수가 참조하는 상태값을 넣어주면 된다.
const onChange = useCallback(e => {
const { name, value } = e.target;
setInputs({
...inputs,
[name]: value,
});
}, [inputs]);
const onCreate = useCallback(() => {
const user = {
id: nextId.current,
username,
email,
};
setUsers(users.concat(user));
setInputs({
username: '',
email: '',
});
nextId.current += 1;
}, [username, email, users]);
const onRemove = useCallback((id) => {
setUsers(users.filter((user) => user.id !== id));
}, [users]);
const onToggle = useCallback((id) => {
setUsers(
users.map((user) =>
user.id === id ? { ...user, active: !user.active } : user
)
);
}, [users]);
만약 넣는거를 깜빡하면...? 🤔 이 함수 내부에서 해당 상태값 혹은 props를 참조할 때, 가장 최신 상태를 참조하는게 아니라 이전에 컴포넌트가 처음 만들어질 때 상태를 참조하게 된다.
✍ 빈 배열안에 뭐를 넣어야 할지 아직도 헷갈린다면 해당 함수에서 참조하는 변수는 그냥 기본적으로 넣는다고 생각하면 된다. 나도 처음에는 헷갈렸는데 이렇게 생각하고 나니까 한결 이해하기 편해졌다.
마지막 하이라이트는 React.memo이다. 지금까지 Hook으로 거의 다 해결이 됐는데 아직 미해결인부분이 바로 부모가 리렌더링 되면 자식도 리렌더링이 된다는 것이다.
이 부분은 React.memo를 사용하면 쉽게 해결이 가능하다. 사용법도 쉽다.
export default React.memo(CreateUser);
export default React.memo(UserList);
이게 무슨의미가 있냐면 바로 props가 바뀌었을때만 리렌더링 해준다는 것이다. 즉, input입력을 할때마다 App.js에서 inputs 상태값이 바뀌게 될텐데 이때 useCallback 때문에 inputs와 onChange만 리렌더링이 일어나게 된다. 따라서 UserList와 User는 App.js에서 변화가 발생해도 React.memo때문에 users와 관련된 props에 변화가 일어나지 않는한 리렌더링이 일어나지 않는다.
아래는 한번 input을 입력해보고 변화가 어디서 있어나는지 개발자 도구로 테스트해본 결과이다.
user관련해서는 전혀 변화가 일어나지 않는것을 볼 수 있다.
하지만 아직까지 해결되지 않은 문제가 있다. user 이름을 클릭하면 active 속성이 바뀐다. 그럼 onToggle도 바뀔 것이다. 그러면 props로 전달받는 UserList와 User도 바뀔 것이다. 즉, 유저 하나를 바꿨는데 유저 리스트가 전부 리렌더링되는 어처구니 없는 상황이 발생하는 것이다.
또한 user 값이 바뀌는데 onCreate 함수때문에 CreateUser 컴포넌트가 영향을 받는 문제도 있다.
React.memo를 사용할때 두번째 파라미터로 prpsAreEqual를 사용할 수 있는데 여기서는 전, 후 props를 가져와서 비교해서 True면 리렌더링하지 않고, False를 반환하면 리렌더링 되게 할 수 있다.
export default React.memo(
UserList,
(prevProps, nextProps) => nextProps.users === prevProps.users
);
가령 UserList에서 이전 users와 지금 넘어온 users와 비교해서 다를때만 리렌더링하도록 설정해줄 수 있다.
setUsers에서 함수형 업데이트를 사용하면 이전값(users)를 불러와서 바로 업데이트 시켜줄 수 있다. 따라서 더 이상 useCallback에다가 users를 넣지 않아도 된다.
const onCreate = useCallback(() => {
const user = {
id: nextId.current,
username,
email,
};
setUsers((users) => users.concat(user));
setInputs({
username: "",
email: "",
});
nextId.current += 1;
}, [username, email]);
const onRemove = useCallback((id) => {
setUsers((users) => users.filter((user) => user.id !== id));
}, []);
const onToggle = useCallback((id) => {
setUsers((users) =>
users.map((user) =>
user.id === id ? { ...user, active: !user.active } : user
)
);
}, []);
결론적으로 특정 user만 클릭하면 그 user만 업데이트 되는 것을 확인할 수 있다.
성능최적화에 대해 알아봤는데 너무 어렵다.. 너무 복잡하고 할게 많다..는 느낌을 받을 수 있다. 그런데 성능최적화는 말그대로 성능이 좀 느려진다 싶을때 사용하면 된다. 너무 무조건 사용하라는 뜻은 아니다.