아래와 같은 조건일때 컴포넌트를 다시 렌더링한다.
리액트는 state 하나가 변경되어도 해당 컴포넌트 전체를 리렌더링 한다.
또한 자식컴포넌트는 아무리 그대로라고 하더라도, 부모 컴포넌트의 state 하나가 변경되면 그 자식 컴포넌트까지 전부 리렌더링한다.
이러한 리액트의 특성상, 최적화를 고려하지 않고 작성한다면 정말 비효율적으로 메모리를 사용하는 앱이 될수 있다.
렌더링에 걸리는 시간을 최소화하는 것은 소비자 경험을 고려할 때 매우 중요한 부분이다.
최적화?
-렌더링 횟수 자체를 줄이는 것
렌더링/ 로딩 속도와 관련해서, 구글은 다음과 같은 기준을 제시한다.
LCP(Largest Contentful Paint) : 대부분의 주요 컨텐츠가 로드되어 표시되는 것에 걸리는 시간 (2.5초 이내)
FID(First Input Delay) : 최초의 사용자 입력을 처리하는 것에 걸리는 시간(0.1초 이내)
userData.js
export const userData = [
{
id: 1,
name: 'Leanne Graham',
email: 'Sincere@april.biz',
},
{
id: 2,
name: 'Ervin Howell',
email: 'Shanna@melissa.tv',
},
{
id: 3,
name: 'Clementine Bauch',
email: 'Nathan@yesenia.net',
},
{
id: 4,
name: 'Patricia Lebsack',
email: 'Julianne.OConner@kory.org',
},
{
id: 5,
name: 'Chelsey Dietrich',
email: 'Lucio_Hettinger@annie.ca',
},
{
id: 6,
name: 'Mrs. Dennis Schulist',
email: 'Karley_Dach@jasper.info',
},
{
id: 7,
name: 'Kurtis Weissnat',
email: 'Telly.Hoeger@billy.biz',
},
{
id: 8,
name: 'Nicholas Runolfsdottir V',
email: 'Sherwood@rosamond.me',
},
{
id: 9,
name: 'Glenna Reichert',
email: 'Chaim_McDermott@dana.io',
},
{
id: 10,
name: 'Clementina DuBuque',
email: 'Rey.Padberg@karina.biz',
},
]
UserList.jsx
import React, { useState } from 'react'
import UserItem from './UserItem'
import {userData} from '../constants/userData'
function UserList({users, onDelete}) {
return (
<div>
{users.map((user) => (<UserItem key={user.id} user={user} onDelete={onDelete}></UserItem>))}
</div>
)
}
export default UserList
UserAdd.jsx
import React from 'react'
function UserAdd({onAdd, onChange, userInput}) {
return (
<div>
<input name="name" onChange={onChange}></input>
<input name="email" onChange={onChange}></input>
<button onClick={() => onAdd(userInput)}>추가하기</button>
</div>
)
}
export default UserAdd
UserItem.jsx
import React from 'react'
function UserItem({user, onDelete}) {
return (
<div>
<p>{user.name}</p>
<p>{user.email}</p>
<button onClick={() => onDelete(user.id)}>제거하기</button>
</div>
)
}
export default UserItem
App.jsx
import UserList from './components/UserList';
import UserAdd from './components/UserAdd';
import { useMemo, useRef, useState } from 'react';
import { userData } from './constants/userData';
function App() {
const [userInput, setUserInput] = useState({name: '', email: ''});
const [users, setUsers] = useState(userData)
const nextId = useRef(11);
const onChange = e => {
const { name, value } = e.target;
setUserInput({...userInput, [name]: value});
};
const onAdd = (userInfo) => {
setUsers([...users, {...userInfo, id: nextId.current++}])
}
const onDelete = (userId) => {
setUsers(users.filter((user) => user.id !== userId))
}
return (
<div>
<UserList users={users} onDelete={onDelete}/>
<UserAdd onAdd={onAdd} onChange={onChange} userInput={userInput}/>
</div>
);
}
export default App;
최적화를 해보기에 앞서 성능을 측정해보자
성능 측정을 위한 도구로 Lighthose 가 있다.
페이지의 종합적인 성능을 측정하기 위한 수단으로 크롬의 확장프로그램인데,
콘솔을 켜서 설정을 선택하고 페이지 로드 분석을 진행하면
이런식으로 전체적인 성능 결과가 나온다.
인스타그램이 약 70점 정도를 받기 때문에, 70점 이상이라면 성능이 준수한 리액트 앱이다.
아래에서 새로고침과 비슷한 버튼을 누르고, 측정하고 싶은 동작을 완료한 뒤에 다시 빨간색 동그라미 버튼을 누르면, 메모리사용량을 확인할수 있따.
만약 아무런 동작도 안했는데 메모리가 늘어나는 현상이 발생한다면 메모리 누수가 발생하고 있는 것이므로, 확인할 필요가 있다.
React Developer Tools 를 사용해서 렌더링 소요시간을 측정할수 있다.
개발자 도구의 profiler 탭으로 들어가면 동그라미 버튼을 누르고 그 순간부터의 성능을 측정하는데, 초기 렌더링 부터의 성능을 측정하려면 그 옆의 새로고침처럼 생긴 버튼을 누르면 된다.
General 설정에서 Highlight updates when components render
를 체크하면 렌더링이 될 때마다 컴포넌트에 파란색으로 렌더링 되는 부분을 표시해준다.
특정함수가 실행된 결과값을 저장해주는 Hook
만약 [] 안에 넣은 값이 변화하지 않으면, 해당 함수는 다시 실행되지 않고, 원래 가지고 있던 결과값을 계속해서 사용하게 된다.
useMemo(실행할 함수, [변화하는지 지켜볼 값])
만약 아래와 같이 작성한다면, user가 변할 때에만 countUsers 라는 함수에 대해서 다시 결과를 내게 된다.
(users가 아니라 다른것이 set되는 경우에는 countUsers
를 다시 실행하지 않는 것)
const count = useMemo(() => countUsers(users),[users])
countUsers 라는 함수가 컴포넌트가 렌더링 될때마다 계속 실행이 된다..
input에 글을 입력하면 onchange함수때문에 컴포넌트가 리렌더링 되면서 콘솔에 찍히는 것을 확인할수 있다.
count 라는 변수를 useMemo
를 통해서 만들면
const count = useMemo(() => countUsers(users), [users]);
input에 글을 입력해도 콘솔에 찍히지 않는다. 다시 렌더링될 때에도 countUsers 함수의 결과값은 항상 저장해놓고 사용하기 때문에, 다시 해당함수를 실행하지 않는 것
useCallback
은 특정한 함수 그 자체를 저장해놓고자 할때 쓰는 Hook이다.
useMemo는 함수가 실행된 결과값을 저장하여 재사용하는것이고, useCallback
은 함수 그 자체를 저장해놓고 재사용하는 것이다.
특정 함수를 useCallback
으로 감싸주면 된다.
const onChange = useCallback(e => {
const { name, value } = e.target;
setUserInput({...userInput, [name]: value});
}, [userInput])
userInput 이 바뀔때만 onChange가 다시 선언되고, 그렇지 않으면 원래 선언된 함수를 사용하게 된다.
기본형태는 아래와 같다
const 함수명 = useCallback(저장하고자 하는 함수, [변화하는지 지켜볼 값])
예제를 한번 보면
import UserList from './components/UserList';
import UserAdd from './components/UserAdd';
import { useCallback, useMemo, useRef, useState } from 'react';
import { userData } from './constants/userData';
function App() {
const [userInput, setUserInput] = useState({name: '', email: ''});
const onChange = useCallback(e => {
const { name, value } = e.target;
setUserInput({...userInput, [name]: value});
}, [userInput])
const [users, setUsers] = useState(userData)
const nextId = useRef(11);
const onAdd = useCallback((userInfo) => {
setUsers([...users, {...userInfo, id: nextId.current++}])
}, [users])
const onDelete = useCallback((userId) => {
setUsers(users.filter((user) => user.id !== userId))
}, [users])
const countUsers = (users) => {
console.log("countUsers 실행");
return users.length;
};
const count = useMemo(() => countUsers(users), [users]);
return (
<div>
<UserList users={users} onDelete={onDelete}/>
<UserAdd onAdd={onAdd} onChange={onChange} userInput={userInput}/>
<div>전체 유저 수 : {count}</div>
</div>
);
}
export default App;
현재 거의 모든 함수에 useCallback을 사용했다.
이제 []안의 값이 변하지 않는 한, 함수를 다시 선언하지 않는다.
그런데 함수를 선언하는 것 자체는 메모리에 큰 영향을 주는 작업은 아니기 때문에, 위처럼만 사용할 경우 드라마틱한 성능향상은 없다.
useCallback
을 사용하는 진짜이유는 해당 함수를 props로 받는 컴포넌트의 재랜더링을 방지하기 위해서이다.
오 그럼 어지간한 함수, 변수값에다가 성능 개선을 위해서 useMemo, useCallback 쓰면 되겠네?
만능이 아니다.
모든 함수나 변수를 전부 useMemo, useCallback 을 사용하는 것은 적절한 사용 방법이 아니다.
[] 안에 명시한 값이 계속 변해서, 항상 다시 선언되는 변수이거나 함수라면? useMemo, useCallback을 많이 사용하는 것은 오히려 메모리 낭비이다.
재사용사지도 않는 값을 저장해놓는 셈이 되기 때문이다.
무리한 최적화는 오히려 원활하게 작동하는 리액트 앱을 망칠수도 있다. 충분히 깊이 고민하시고 최적화 함수를 사용해야한다.
React.memo
는 특정한 컴포넌트 전체에 대해서 props가 바뀌지 않는다면
다시 렌더링하지 않도록 설정해주는 함수이다.
export default 부분에서 컴포넌트 명을 React.memo
로 감싸주면 된다.
export default React.memo(컴포넌트)
이전에 만든 모든 컴포넌트를 React.memo 로 감싸보자.
export default React.memo(UserList)
export default React.memo(UserItem)
export default React.memo(UserAdd)
React.memo를 사용한 경우, 최초의 렌더링을 제외하고 모두 시간이 감소되 것을 확인할수 있다.
user 추가 / 제거를 하면 다시 UserItem, UserAdd 컴포넌트 모두 재랜더링 된다.
onChange, onAdd, onDelete 등이 users의 변화를 지켜보도록 되어있기 때문이다.
굳이 users 를 지켜보지 않아도, 현재 상태를 가비고 set을 시켜줄수 있다.
아래처럼 작성하면 prevState에 현재 상테를 가지고와서 set을 할수가 있다.
set스테이트명(prevState => ({...prevState, [name]: value}));
const onChange = useCallback(e => {
const { name, value } = e.target;
setUserInput(prevState => ({...prevState, [name]: value}));
}, []) // [] 안이 비었음! (즉, 해당 함수는 재선언될 일이 없음)
const onAdd = useCallback((userInfo) => {
setUsers(prevState => ([...prevState, {...userInfo, id: nextId.current++}]))
}, []) // [] 안이 비었음! (즉, 해당 함수는 재선언될 일이 없음)
const onDelete = useCallback((userId) => {
setUsers(prevState => (prevState.filter((user) => user.id !== userId)))
}, []) // [] 안이 비었음! (즉, 해당 함수는 재선언될 일이 없음)
[] 안이 비어있으므로 함수가 재 선언될 일이 없게 된다.
onChange
함수에 console.log를 추가해서 실행되는 빈도를 한번 살펴보자
const onChange = useCallback((e) => {
const { name, value } = e.target;
setUserInput((prevState) => ({ ...prevState, [name]: value }));
console.log("확인");
}, []);
onChange 함수는 새로운 글자가 입력될 때마다 실행이 된다.
글자를 입력하는 것에 맞춰서 관련 검색어 등을 가져오거나, 유효성 검사를 하기 위해서라면 useState를 쓰는 것은 불가피하다.
근데 사실 유저가 글자를 입력할 때마다 렌더링을 다시 하고, 유효성 검사를 하거나 관련 검색어를 가져오기 위한 요청을 하는 것은 너무 비효율적이다.
이를 위한 라이브러리로 lodash
가 존재하는데
yarn add lodash
lodash에는 debounce
라는 함수가 있어서, 아래와 같이 사용하면
debounce(함수, 시간)
연속된 동작이 끝난후 정확히 몇 초 후에 함수가 실행될 지를 지정할수 있다.
아래와 같이 onChange를 작성해보면
const onChange = useCallback(
debounce((e) => {
const { name, value } = e.target;
setUserInput((prevState) => ({ ...prevState, [name]: value }));
console.log("확인");
}, 500),
[]
);
이렇게 해주면 글자를 입력할 때마다 onchange 함수가 실행되는 것이 아니라 일정 시간이 지난 다음에 해당 함수가 실행되는 것을 확인할수 있다.
react-window
는 컨텐츠의 개수가 많을때, 해당 컨텐츠들을 한번에 화면에 표시하지 않고 사용자가 보는 화면 크기만큼만 렌더링을 하도록 도와주는 라이브러리이다.
관련해서 react-virtualized
라는 라이브러리도 존재하지만, react-window
는 해당 라이브러리에 비해서 windowing 구현에 필요한 최소한의 기능만 있어서 가볍다
yarn add react-window
react-window
는 리스트 요소들이 고정 크기인지, 가변 크기인지에 따라서 다른 컴포넌트를 사용한다.
import {FixedSizeList as List} from 'react-window
고정 크기라면 위와 같이 컴포넌트를 import 하면 되는데, 해당 컴포넌트는 리스트 요소들을 담기 위한, 유저가 보는 창(윈도우) 라고 이해하면 된다.
<List itemSize={아이템 하나의 세로 크기} itemCount={아이템 개수} height={윈도우의 세로 길이} width={윈도우의 가로 길이}>
{({ index }) => (
<Item data={data[index]}/>
)}
</List>
이제 해당 컴포넌트 안에 리스트 요소를 추가하면 된다. 추가할때는 List 컴포넌트가 자체적으로 index props 를 가지고 있기 때문에, 해당 index 번호를 받아서 요소를 표시하는 방식으로 작성해야한다.
(아이템 하나를 index와 함께 명시)
import React, { useState } from 'react'
import UserItem from './UserItem'
import { FixedSizeList as List } from 'react-window'
function UserList({ users, onDelete }) {
return (
<List itemSize={100} itemCount={80} height={330} width={300}>
{(
{ index, style }, // index, style 각각 인자로 받아서 적용
) => <UserItem key={users[index].id} user={users[index]} onDelete={onDelete} style={style}></UserItem>}
</List>
)
}
export default React.memo(UserList)
itemCount를 80으로 해놨기 때문에 데이터 베이스 역할을 하는 userData의 데이터 갯수가 80개가 아닐경우에는 스크롤을 내리다가 id를 찾을수 없어서 오류를 뱉게된다!
import React from "react";
function UserItem({ user, onDelete, style }) {
return (
<div style={style}>
<p>{user.name}</p>
<p>{user.email}</p>
<button onClick={() => onDelete(user.id)}>제거하기</button>
</div>
);
}
export default React.memo(UserItem);
이렇게 보여지는 화면의 크기가 변경되었다.
만약 요소들이 리스트 형태라면 위와 같이 최적화가 가능할수 있지만, 만약 아예 컴포넌트 그 자체가 너무 크다면 어떻게 가능할까?
리액트는 SPA이기 때문에, 초기 로딩 과정에서 모든 컴포넌트를 불러오는 특성을 가진다.
그렇다면 컴포넌트의 크기가 커지면 커질수록, 초기 로딩 시간이 매우 길어지게 되는 단점을 가지고 있다.
이를 해결하는 방법은 특정 컴포넌트는 나중에 필요할때 불러오도록 분리하는 것이다.
react의 내장 lazy기능에 대해서 알아보자.
react 18버전 이후에는 내장 lazy 기능이 정말 강력해져서 굳이 해당 모듈을 사용하지 않아도 된다.
lazy 함수를 사용해서 import 한다.
const UserList = lazy(() => import("./components/UserList"));
그러면 해당 컴포넌트는 동적으로 필요한 순간에 불러와진다.
이제 Suspense 라는 컴포넌트로 해당 컴포넌트를 감싸준다. (lazy로 불러오는 컴포넌트는 항상 Suspense 안에 있어야한다.)
<Suspense fallback={<>Loading 중..</>}>
<UserList users={users} onDelete={onDelete} />
</Suspense>
fallback 부분에 스켈레톤 ui를 추가한다면 로딩되는동안 보여줄수 있을 것이다.
렌더링 시간이 많이 줄었다.
특정한 컴포넌트가 버튼을 클릭했을때만 보인다고 가정해보자.
그런 경우라면 아직 버튼을 클릭하기 전인데 해당 컴포넌트를 전부불러올 필요가 없으므로, 유저가 버튼을 클릭하려는 순간부터 컴포넌트를 불러오게 되면 어떻게 될까?
react-lazy-with-preload
라이브러리를 추가로 설치해주세요.
yarn add react-lazy-with-preload
원래 preload는 loadble/components 라는 모듈에만 있는 기능이라, 이를 react 내장 lazy에서도 사용할수 있도록 만들어놓은 모듈이다.
lazyWithPreload 라는 함수를 불러와서 아래처럼 컴포넌트를 import 해준다.
import { lazyWithPreload } from "react-lazy-with-preload";
const UserList = lazyWithPreload(() => import("./components/UserList"));
클릭했는지 여부를 저장하기 위한 state를 하나 선언하고
const [isClicked, setIsClicked] = useState(false);
jsx를 아래처럼 작성한다.
<Suspense fallback={<>Loading 중..</>}>
{isClicked && <UserList users={users} onDelete={onDelete} />}
</Suspense>
<button onClick={() => setIsClicked(true)} onMouseOver={() => UserList.preload()}>
UserList 표시해보자!
</button>
버튼에 MouseOver가 되는 순간 컴포넌트를 불러온다!
커서를 올려서 버튼 색이 변하는 순간! 컴포넌트를 불러오는 동작을 수행하게되고 클릭하면 지연없이 렌더링이 되는것이다
store/slices/counterSlice.js
import { createSlice } from "@reduxjs/toolkit";
const initialState = {
value: 0,
};
export const counterSlice = createSlice({
name: "counter",
initialState,
reducers: {
increment: (state) => {
state.value = state.value + 1;
},
decrement: (state) => {
state.value = state.value - 1;
},
},
});
export const { increment, decrement } = counterSlice.actions;
export default counterSlice.reducer;
store/index.js
import { combineReducers, configureStore } from "@reduxjs/toolkit";
import { createLogger } from "redux-logger";
import counterReducer from "./slices/counterSlice";
const logger = createLogger();
const rootReducer = combineReducers({
counter: counterReducer,
});
const store = configureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(logger),
});
export default store;
app.jsx
import { Provider } from "react-redux";
import Counter from "./components/Counter";
import store from "./store";
function App() {
return (
<Provider store={store}>
<Counter />
</Provider>
);
}
export default App;
components/Counter.jsx
import { useSelector } from "react-redux";
import { useState } from "react";
export default function Counter() {
const count = useSelector((state) => {
console.log("셀렉트 또 하나요?");
return state.counter.value;
});
const [isClicked, setIsClicked] = useState(false);
return (
<div>
<p>{count}</p>
<button onClick={() => setIsClicked(!isClicked)}>
다시 렌더해봅시다
</button>
</div>
);
}
예제를 만들어놓고 다시 렌더해봅시다
버튼을 클릭하면 렌더링 될때마다, counter value를 가져오는 작업을 계속 하게 된다.
redux는 reselect
라고 해서, useSelector
로 가져온 값을 저장해놓고 사용하기 위한 라이브러리가 별도로 존재한다.
redux toolkit 은 해당 모듈을 기반으로 만들어진 createSelector
라는 함수를 기본적으로 내장해서 가지고 있다.
export const memoizedSelector = createSelector(
(state) => state.리듀서키이름.필요한값, // state 에서 필요한 값 가져오기
(state) => state.리듀서키이름.필요한값, // state 에서 필요한 값 가져오기
..., // 필요한만큼 가져오면 됨
(가져온값, 가져온값...) => 가공한 값 // 가져온 값을 가공해서 리턴 (이 리턴값을 저장해놓고 사용하게 됨, 가공할 필요가 없다면 그냥 그대로 리턴해도 됨)
);
이렇게 만들어 넣고, 필요한 컴포넌트에서
const 값 = useSelector(memoizedSelector)
라고 해서 createSelector 에서 가공한 값을 가져오면 된다.
이번에는 counterSlice.js 에 아래 코드를 추가해서 createSelector를 사용해본다.
import { createSelector, createSlice } from "@reduxjs/toolkit";
const counterSelector = (state) => {
return state.counter.value;
};
export const memoizedCounterSelector = createSelector(
counterSelector, // state 에서 필요한 값 가져오기
(value) => {
console.log("다시 가져오나요?");
return value;
} // 가져온 값을 가공해서 리턴 (이 리턴값을 저장해놓고 사용하게 됨, 가공할 필요가 없다면 바로 value 라고 적어도 됨)
);
Counter 컴포넌트에 useSelector를 사용한다.
import { useSelector } from "react-redux";
import { useState } from "react";
import { memoizedCounterSelector } from "../store/slices/counterSlice";
export default function Counter() {
const count = useSelector(memoizedCounterSelector);
const [isClicked, setIsClicked] = useState(false);
return (
<div>
<p>{count}</p>
<button onClick={() => setIsClicked(!isClicked)}>
다시 렌더해봅시다
</button>
</div>
);
}
이제 처음에 useSelector가 실행되면서, 가져온 값을 저장해놓기 때문에, 아무리 버튼을 클랙해서 다시 컴포넌트가 렌더링 될 때에도 memoizedCounterSelector에 있는 함수가 다시 실행되지 않는것을 확인 할수 있다.
useSelector 로 값을 가져오면서 가공하는 절차가 복잡하거나 (자원 소모량이 많거나), 값을 가져온 후에 해당 값을 변경할 필요가 없는 경우 (다시 연산/렝더링 할 필요가 없는 경우) dpsms createSelector를 이용해 주는 것이 좋다!