본문 바로가기

React

TIL no.57 - React 성능 최적화를 고려한 컴포넌트 만들기

React 의 성능을 생각하며 코드를 짜기 이전에 알아야 할 리액트의 특성에 대해 간단하게 정리했습니다.

 

React , 이 친구는

React 는 생명주기 즉, Lifecycle 을 가지고 있습니다. 컴포넌트가 실행되거나 업데이트될 때, 또는 제거될 때 사이클에 따라서 특정 이벤트가 발생합니다.

 

컴포넌트가 처음 실행될 때 mount 되었다고 말합니다. 

그 이후에 props 또는 state 값이 변경되면 update 가 발생합니다.

컴포넌트가 dom 상에서 제거될 때 실행할 함수를 mount 해제 부분에 작성합니다.

 

리액트의 클래스형 컴포넌트를 사용했을 때는, 이러한 상황별로 

componentWillMount

componentDidMount

shouldComponentUpdate

componentWillUpdate

componentDidUpdate

componentWillUnmount

위의 메서드를 사용해서 라이프 사이클을 관리합니다.

 

리액트의 함수형 컴포넌트에서는 데이터에 대해서 라이프 사이클이 진행되며 클래스형 컴포넌트에서 사용한 메서드들을 이용하는 대신에  useEffect 사용해서 라이프 사이클을 관리합니다. 데이터에 따라서 여러 개의 useEffect 를 사용할 수 있습니다.

 

React 는 처음에 화면에 렌더링이 완료된 후에 useEffect 가 비동기 처리로 실행됩니다.

데이터를 외부에서 불러오고 값을 넣어주는 side effect 들을 useEffect 안에서 처리해줍니다.

 

useEffect 는 화면을 처음에 렌더링할 때 수행하며,

import { useEffect } from 'react';

function HaneulComponent() {
	useEffect(() => {
    // 실행할 함수
    })
}

위와 같이 의존성 배열 부분을 제공하지 않으면 매 렌더링마다 사이드 이펙트를 발생시킵니다.

 

import { useEffect } from 'react';

function HaneulComponent() {
	useEffect(() => {
    // 실행할 함수
    }, [])
}

위와 같이 의존성 배열 부분에 빈 배열을 넣어주었을 때는 첫 렌더링 이후에 한 번만 사이드 이펙트를 발생시킵니다.

  

import { useEffect } from 'react';

function HaneulComponent({prop}) {
	const [state, setState] = useState('')
	useEffect(() => {
    // 실행할 함수
    }, [prop, state])
}

위와 같이 의존성 배열에 state 나 props 를 넣어주면 안에 넣은 값들이 변할 때마다 사이드 이펙트를 발생시킵니다.

의존성 배열 부분에 state 나 props 를 넣어서 조건부 렌더링을 발생시키는 것입니다.

**side effect : 리액트 컴포넌트가 화면에 렌더링된 이후에 비동기로 처리되어야 하는 부수적인 효과들을 흔히 Side Effect라고 합니다. 대표적인 예로 어떤 데이터를 가져오기 위해서 외부 API를 호출하는 경우, 일단 화면에 렌더링할 수 있는 것은 먼저 렌더링하고 실제 데이터는 비동기로 가져와서 넣어지기 때문에 useEffect 를 통해 side effect 함수를 관리합니다.

 

함수형 컴포넌트에서는 화면 렌더링 -> useEffect 로 인한 state props 업데이트 -> 언마운트  를 할 때,

언마운트는 아래와 같이 useEffect 안에 return 함수문을 작성하여 화면이 사라질 때 side effect 를 남기지 않고 수거합니다.

useEffect(() => {
	// side effect...
    
    return () => {
    // side effect...
    }
}, [deps])

return 문으로 콜백 함수를 넘겨 side effect 를 정리하는 함수는 메모리 누수 방지를 위해 UI에서 컴포넌트를 제거하기 전에 수행되거나 여러 번 렌더링 되는 컴포넌트라면 다음 effect 가 수행되기 전에 이전 effect 를 정리합니다.

 

리액트에서 컴포넌트는 자신의 state가 변경되거나, 부모에게서 받는 props가 변경되었을 때마다 리렌더링 됩니다.

컴포넌트가 렌더링 될 때마다 내부에 선언되어 있던 표현식(변수, 또다른 함수 등)도 매번 다시 선언되어 사용됩니다.

렌더링이 각각의 컴포넌트마다 여러 번 발생되고 그렇기 때문에 필요없는 렌더링이 생겨나서 성능이 저하될 수 있습니다.

 

작은 프로젝트를 할 때에는 컴포넌트들이 많지 않기도 할 뿐더러 크기가 작기 때문에 성능을 개선한 코드가 큰 효과를 나타낼 수는 없겠지만 프로젝트의 사이즈가 커질수록 필요없는 렌더링이 성능에 문제를 일으키기 때문에 성능 최적화에 대해 생각하며 코드를 짜는 것이 중요해질 것 입니다. 리액트에서 컴포넌트의 성능을 개선하는 방법을 알아보겠습니다.

 

 

React.memo 

React에서는 성능 개선을 위한 하나의 도구로 메모이제이션을 사용합니다.

대부분의 상황에서 React는 메모이징 된 컴포넌트의 리렌더링을 피할 수 있지만, 렌더링을 막는 목적으로 메모이제이션에 의존하면 안됩니다.

 

props의 값이 빈번하게 바뀌지 않는 컴포넌트에서 React.memo를 사용한다면 props가 달라지지 않았을 경우, 리렌더링을 하지 않고 기존의 컴포넌트 코드를 재사용함으로써 성능을 향상시킬 수 있습니다.

React.memo()는 함수형 컴포넌트에 적용되어 같은 props에 같은 렌더링 결과를 제공한다.

React.memo()를 사용하기 가장 좋은 케이스는 함수형 컴포넌트가 같은 props로 자주 렌더링 될거라 예상될 때입니다.

같은 props 를 여러 번 유지하다가 어쩌다 props 가 바뀌는 컴포넌트라면 React.memo 를 사용해 같은 props 인 경우에 발생하는 여러 번의 렌더링을 방지하며 바뀔 때만 렌더링 해줄 수 있습니다. 함수형 컴포넌트에서 같은 props일 경우 같은 렌더링 결과를 제공함으로써 리렌더링을 막는 것입니다..

 

메모이징 한 결과를 재사용 함으로써, React에서 리렌더링을 할 때 가상 DOM에서 달라진 부분을 확인하지 않아 성능상의 이점을 누릴 수 있다. 일반적으로 부모 컴포넌트에 의해 하위 컴포넌트가 같은 props 이지만 리렌더링을 발생하는 경우가 있습니다.

이 경우 React.memo를 사용해 불필요한 렌더링을 막을 수 있는 것 입니다.

컴포넌트가 같은 props로 자주 렌더링되거나, 무겁고 비용이 큰 연산이 있는 경우, React.memo()로 컴퍼넌트를 래핑할 필요가 있습니다.

 

React.memo를 리렌더링 방지에 목적을 두고 사용하면 버그를 유발할 수 있으며, 성능 최적화에만 사용하는 것을 권장하고 있습니다.

 

***react developer tools을 크롬 확장 프로그램으로 설치하여 이를 이용한 profiling을 통해 React.memo()를 적용하여 개선된 부분을 확인할 수 있습니다.

만일 성능적인 개선이 없다면 무조건적으로 메모이제이션을 사용하는 것은 좋지 않을 수도 있습니다.

 

 

useMemo

useMemo 는 메모리제이션된 값을 반환합니다.

useMemo를 사용하면 의존성 배열에 넘겨준 값이 변경되었을 때만 메모리제이션된 값을 다시 계산한다.

재계산하는 함수가 아주 간단하다면 성능상의 차이는 아주 미미하겠지만 만약 재계산하는 로직이 복잡하다면 불필요하게 비싼 계산을 하는 것을 막을 수 있다.

 

useMemo로 전달된 함수는 렌더링 중에 실행된다는 것을 기억하세요. 통상적으로 렌더링 중에는 하지 않는 것을 이 함수 내에서 하지 않아야 합니다. 예를 들어, 사이드 이펙트는 useEffect에서 하는 일이지 useMemo에서 하는 일이 아닙니다.

각각의 역할에 맞게 사용하는 것은 당연합니다.

import { useMemo } from 'react';

function countUsersInRoom(users, room) {
  return users.filter(user => user.room === room).length;
}

function App() {
  const [users, setUsers] = useState([]);
  const count = useMemo(() => countUsersInRoom(users), [users]);

return (
    <>
      <UserList users={users} />
      <div>채팅 인원 : {count}</div>
    </>
  );
}

export default App;

위의 코드를 보시면 count 가 users 에 변화가 있을 때만 렌더링 되어야 하는데 users 에 변화가 없어도 함수를 다시 읽기 때문에 낭비를 하고 있습니다. countUsersRoom 함수는 users 에 변화가 있을 때만 렌더링 되어야 함으로 useMemo로 메모이제이션을 한 후 , deps 배열에 users 가 변화할 때만 다시 계산하도록 관리할 수 있습니다.

 

useCallback

useMemo 는 특정 결과값을 재사용 할 때 사용하는 반면, useCallback 은 특정 함수를 새로 만들지 않고 재사용하고 싶을때 사용합니다.

useCallback 은 메모리제이션된 콜백을 반환합니다.

useCallback은 콜백의 메모이제이션된 버전을 반환할 것입니다. 그 메모이제이션된 버전은 콜백의 의존성이 변경되었을 때에만 변경됩니다. 

 

 useCallback(fn, deps) 은   useMemo(() => fn, deps) 와 같습니다.

 

만약 하위 컴포넌트가 React.memo() 같은 것으로 최적화 되어 있고 그 하위 컴포넌트에게 callback 함수를 props로 넘길 때, 상위 컴포넌트에서 useCallback 으로 함수를 선언하는 것이 유용하다라는 의미이다. 함수가 매번 재선언되면 하위 컴포넌트는 넘겨 받은 함수가 달라졌다고 인식하기 때문입니다.

함수는 오로지 자기 자신만이 동일하기 때문에 상위 컴포넌트에서 callback 함수를 (같은 함수이더라도) 재선언한다면 props로 callback 함수를 넘겨 받는 하위 컴포넌트 입장에서는 props가 변경 되었다고 인식하게 됩니다. 그렇기 때문에 상위 컴포넌트에서 useCallback으로 콜백 함수를 감싸놓으면 함수가 매번 재선언되지는 않기 때문에 React.memo 로 감싸져 있는 하위 함수 컴포넌트에서 불필요한 렌더링을 하지 않을 것입니다.

 

 

처음에 React 를 사용할 때는 내장된 React hook으로 state 와 props 를 관리하기 위해 간단히 useState 나 useEffect 를 간단하게 사용했었습니다. 이제 React 에 점차 익숙해가고 있으니 그저 되게만 하는 코드가 아닌 보다 나은 효율성을 생각하며 코드를 짜야 한다고 생각했습니다. 일단 되게 한 뒤에 코드를 최적화 하는 것도 방법이지만 처음부터 필요에 의한 렌더링만 동작하도록 짠다면 더 효율적일 것이라고 생각했습니다. 

같은 값을 받아와서 변하지 않아도 되는데 변하는 컴포넌트를 보면서 어떤 방법을 사용해 최적화 할 수 있는지 공부하면서 간단히 기록해보았습니다.