최적화와 성능 개선

최적화 해보기

1) 리액트의 렌더링 방식

아래와 같은 조건일때 컴포넌트를 다시 렌더링한다.

  • props 가 변경될때
  • state 가 변경될 때
  • 부모 컴포넌트가 리렌더링 될때

리액트는 state 하나가 변경되어도 해당 컴포넌트 전체를 리렌더링 한다.

또한 자식컴포넌트는 아무리 그대로라고 하더라도, 부모 컴포넌트의 state 하나가 변경되면 그 자식 컴포넌트까지 전부 리렌더링한다.

이러한 리액트의 특성상, 최적화를 고려하지 않고 작성한다면 정말 비효율적으로 메모리를 사용하는 앱이 될수 있다.

렌더링에 걸리는 시간을 최소화하는 것은 소비자 경험을 고려할 때 매우 중요한 부분이다.

최적화?
-렌더링 횟수 자체를 줄이는 것

  • 다시 렌더링되는 부분을 최소화하는 것
  • 변수/함수를 다시 선언하지 않고 재사용하는 것 (메모리 사용 최소화)

렌더링/ 로딩 속도와 관련해서, 구글은 다음과 같은 기준을 제시한다.

  • LCP(Largest Contentful Paint) : 대부분의 주요 컨텐츠가 로드되어 표시되는 것에 걸리는 시간 (2.5초 이내)

  • FID(First Input Delay) : 최초의 사용자 입력을 처리하는 것에 걸리는 시간(0.1초 이내)

2) 간단한 예제 만들기

  • 유저 정보를 표시하는 앱을 작성해놓고, 최적화는 위함 함수를 살펴본다.

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;

3) 성능 측정하기

최적화를 해보기에 앞서 성능을 측정해보자

  • 종합성능측정

성능 측정을 위한 도구로 Lighthose 가 있다.

페이지의 종합적인 성능을 측정하기 위한 수단으로 크롬의 확장프로그램인데,

콘솔을 켜서 설정을 선택하고 페이지 로드 분석을 진행하면

이런식으로 전체적인 성능 결과가 나온다.

인스타그램이 약 70점 정도를 받기 때문에, 70점 이상이라면 성능이 준수한 리액트 앱이다.

  • 메모리 측정
    메모리 사용량은 크롬의 성능(Performance) 탭에서 측정할수 있다.

아래에서 새로고침과 비슷한 버튼을 누르고, 측정하고 싶은 동작을 완료한 뒤에 다시 빨간색 동그라미 버튼을 누르면, 메모리사용량을 확인할수 있따.

만약 아무런 동작도 안했는데 메모리가 늘어나는 현상이 발생한다면 메모리 누수가 발생하고 있는 것이므로, 확인할 필요가 있다.

  • 렌더링 소요시간 측정

React Developer Tools 를 사용해서 렌더링 소요시간을 측정할수 있다.

개발자 도구의 profiler 탭으로 들어가면 동그라미 버튼을 누르고 그 순간부터의 성능을 측정하는데, 초기 렌더링 부터의 성능을 측정하려면 그 옆의 새로고침처럼 생긴 버튼을 누르면 된다.

  • 렌더링 확인하기
    react Developer Tool 에서 특정 컴포넌트가 렌더링될 때 표시해주는 기능을 가지고 있다. 최적화를 했다면 특정 컴포넌트가 정말로 다시 렌더링되지 않는지 확인해야하는데, 그 때 이 기능을 사용하면 된다.

General 설정에서 Highlight updates when components render를 체크하면 렌더링이 될 때마다 컴포넌트에 파란색으로 렌더링 되는 부분을 표시해준다.

useMemo와 useCallback

1) useMemo

특정함수가 실행된 결과값을 저장해주는 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 함수의 결과값은 항상 저장해놓고 사용하기 때문에, 다시 해당함수를 실행하지 않는 것

2) useCallback

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 사용하기

1) React.memo

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를 사용한 경우, 최초의 렌더링을 제외하고 모두 시간이 감소되 것을 확인할수 있다.

2) 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)))
  }, []) // [] 안이 비었음! (즉, 해당 함수는 재선언될 일이 없음)

[] 안이 비어있으므로 함수가 재 선언될 일이 없게 된다.

최적화를 위한 라이브러리

1) lodash 사용하기

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 함수가 실행되는 것이 아니라 일정 시간이 지난 다음에 해당 함수가 실행되는 것을 확인할수 있다.

2) react- window 사용하기

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);

이렇게 보여지는 화면의 크기가 변경되었다.

3) lazy loading 사용하기

만약 요소들이 리스트 형태라면 위와 같이 최적화가 가능할수 있지만, 만약 아예 컴포넌트 그 자체가 너무 크다면 어떻게 가능할까?

리액트는 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를 추가한다면 로딩되는동안 보여줄수 있을 것이다.

렌더링 시간이 많이 줄었다.

4) preLoad?

특정한 컴포넌트가 버튼을 클릭했을때만 보인다고 가정해보자.
그런 경우라면 아직 버튼을 클릭하기 전인데 해당 컴포넌트를 전부불러올 필요가 없으므로, 유저가 버튼을 클릭하려는 순간부터 컴포넌트를 불러오게 되면 어떻게 될까?

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가 되는 순간 컴포넌트를 불러온다!

커서를 올려서 버튼 색이 변하는 순간! 컴포넌트를 불러오는 동작을 수행하게되고 클릭하면 지연없이 렌더링이 되는것이다

redux 최적화하기

1) 예제

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>
  );
}

2) createSelector 활용하기

예제를 만들어놓고 다시 렌더해봅시다 버튼을 클릭하면 렌더링 될때마다, 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를 이용해 주는 것이 좋다!

profile
개발자 꿈나무

0개의 댓글