리액트 톺아보기2 - 최적화

이지영·2025년 1월 10일

일반적인 웹 서비스 최적화 방법에는 여러 가지가 있다.

  1. 서버의 응답 속도 개선
  2. 이미지, 폰트, 코드 파일 등의 정적 파일 로딩 개선
  3. 불필요한 네트워크 요청 줄이기
  4. 캐싱 활용
  5. HTTP 요청 최적화
  6. CDN(콘텐츠 전송 네트워크)사용
  7. 비동기 로딩: JavaScript파일을 비동기적으로 로딩하여 페이지 로딩 속도 개선
  8. 데이터베이스 최적화
  9. 서버 성능 향상

등등이 있다.

React App내부의 최적화 방법은 어떤것이 있을까?

  1. 컴포넌트 최적화
  • React.memo사용: 불필요한 리렌더링을 방지하기 위해 React.memo를 사용하여 컴포넌트를 최적화한다.
  • useMemo 및 useCallback활용: 계산 비용이 큰 값을 메모이제이션하고, 함수를 메모이제이션하여 자식 컴포넌트에 props로 전달할 때 불필요한 렌더링을 줄인다.
React.memo: 함수형 컴포넌트에서 사용되며 props의 변경 여부에 따라 리렌더링 제어
useMemo: 주어진 값이 변경되지 않는 한 특정 계산 결과를 메모이제이션하여 성능을 최적화하는 훅이다.
  1. 코드 분할
  • React.lazy와 Suspense: 동적 import를 사용하여 코드 스플리팅을 구현하고 필요한 시점에만 컴포넌트를 로드한다.
  • 라우팅 기반 코드 분할: React Router 와 함께 lazy loading을 사용하여 각 페이지에 필요한 코드만 로드한다.
  1. 상태 관리 최적화
  • 전역 상태 관리: Redux, mobX context API를 사용하여 전역 상태를 효율적으로관리한다. 필요없는 컴포넌트가 리렌더링 되지 않도록 상태를 적절히 분리한다.
  • Local State 사용: 필요한 경우에만 지역 상태를 사용하여 리렌더링을 최소화한다.
  1. 이벤트 핸들러 최적화
  • 이벤트 핸들러를 클래스 메서드로 정의하거나 useCallback훅을 사용하여 메모이제이션 한다.
  1. 이미지 및 자원 최적화
  • 이미지 포맷을 최적화하고, 필요없는 자원은 제거한다.
  • Lazy loading을 통해 필요할 때만 이미지를 로드한다.

위와 같은 여러가지 React App 내부를 최적화 하는 방법들이 있다.

useMemo 에 대해서 자세히 알아보기

Memoization이란?
반복적으로 수행되는 동일한 연산이 있을 때
최초의 결과값을 메모리에 저장하고 다시 그 연산이 필요해지면 그 결과값을 돌려주는 방법이다.

즉, "메모이제이션"기법을 기반으로 불 필요한 연산을 최적화하는 리액트 훅이다.


TodoList의 전체 수, 완료된 수, 미완료 수 를 예시로
useMemo를 적용해보자

먼저 getAnalyzedData 함수를 이용하여 카운트 값들을 리턴해주자

const getAnalyzedData = () => {
    const totalCount = todos.length;
    const doneCount = todos.filter((item) => item.isDone).length;
    const notDoneCount = totalCount - doneCount;
    return {
      totalCount,
      doneCount,
      notDoneCount,
    };
  };
  const { totalCount, doneCount, notDoneCount } = getAnalyzedData();

이렇게 하면 filter는 배열의 모든 요소를 한번씩 다 순회하기 떄문에 TodoList가 많아질수록 시간이 많이 걸린다.

즉, 리스트 컴포넌트가 리렌더링될 때마다 계속 getAnalyzedData()함수가 호출이 된다.

useMemo를 적용해보면

const { totalCount, doneCount, notDoneCount } = useMemo(()=>{
	const totalCount = todos.length;
  	const doneCount = todos.filter((item)=>item.isDone).length;
  	const notDoneCount = totalCount - doneCount;
  
  	return {
    	totalCount,
      	doneCount,
      	notDoneCount
    }
},[todos])

이전에는 검색을 할 때마다 컴포넌트가 리렌더링 되면서 불필요하게 호출되던 함수들이
todos의 상태가 변경됨에 따라서 리렌더링이 되어 불필요한 리렌더링이 사라졌다.

이번에는 React.memo에 대해서 알아보자

React.memo 는 컴포넌트를 인수로 받아 최적화된 컴포넌트로 만들어서 반환한다.
const MemoizedComponent = memo(Component);
반환값은 최적화된 컴포넌트이다, 또한 Props를 기준으로 메모이제이션 된다.

즉, 부모컴포넌트가 리렌더링 되더라도 자신이 받고있는 props가 변경되지 않으면 다시는 리렌더링이 발생하지 않도록 메모이제이션 되기 때문에 불필요한 리렌더링이 방지되어서 자동으로 최적화가 된다.

예를들어 헤더의 경우
컴포넌트 리스트가 추가되거나, 체크박스를 클릭할때마다 리렌더링이 발생하는데,
헤더는 단순날짜만 출력해주므로, 리렌더링이 불필요하게 발생한다.

export default memo(Header);

헤더 컴포넌트를 export 해줄 때 memo한 값을 export 해주면
불필요한 리렌더링을 방지해줄 수 있다.

마찬가지로 ListItem도 momo를 하여, 해당 체크박스만 체크했을때에는
관련 없는 나머지 리스트들이 리렌더링 되지 않도록

export default memo(TodoItem);

메모이제이션을 해주어 불필요한 리렌더링을 방지한다.
하지만 확인을 해보면 제대로 기타 다른 리스트도 계속해서 리렌더링이 되는것을 볼 수 있다.

이유는

const TodoItem = ({ id, isDone, content, date, onUpdate, onDelete }) => {

TodoItem의 onUpdate와 unDelete때문이다.

이유는 App 컴포넌트에 작성된 onUpdate함수와 onDelet 함수는 매 렌더링 시마다 새롭게 생성된다.
이로인해 TodoItem 컴포넌트는 props가 변경된 것으로 인식하고, 그 결과로 리렌더링이 발생한다.

리렌더링을 방지하기 위해서는 두 가지 방법이 있는데
해당 함수를 useMemo하기, 또는 아래와 같은 방법을 사용할 수 있다.

export default memo(TodoItem), (prevProps, nextProps)=>{
  // 반환값에 따라 Props가 바뀌었는지 안바뀌었는지를 판단
  // T -> Props가 바뀌지 않음 -> 리렌더링 x
  // F -> Props가 바뀜 -> 리렌더링 o
  if(prevProps.id !== nextProps.id) return false;
  if(prevProps.isDone !== nextProps.isDone) return false;
  if(prevProps.content !== nextProps.content) return false;
  if(prevProps.date !== nextProps.data) return false;

  return true;
};

컴포넌트를 인수로 받아 해당 컴포넌트의 메모이제이션 같은 추가적인 기능을 덧붙여 기능이 추가된 새로운 컴포넌트를 반환해주는 memo 같은 메서드들을 고차 컴포넌트(HDC) 라고 부른다.
복잡한 리액트 앱을 구현할때 자주 쓰인다.

고차 컴포넌트란 다른 컴포넌트를 받는 컴포넌트를 뜻한다
HOC는 인자로 넘긴 컴포넌트에게 추가되길 원하는 로직을 가지고있다.
HOC는 로직이 적용된 엘리먼트를 반환하게 된다.

useCallback

위의 useMemo와 같은 경우 props의 이름이 바뀌게되면
컴포넌트에 memo메서드를 적용할 때마다 모든 props를 일일히 비교하도록 코드를 써야해서 불편할 수 있다.
게다가 props의 이름이 바뀌게 되면 콜백함수안의 porps도 일일히 다 바꿔줘야한다.
또한 props가 추가되면 콜백함수의 props도 추가해줘야하고
등등 좀 귀찮을 경우가 있다.

이럴때에는 차라리 해당하는 onDelete와 onUpdate를 최적화하는 것이 조금 더 좋은 방법이 될 수 있다.

const onDelete = useCallback((targetId)=>{
  dispatch({
  	type: "DELETE",
    targetId: targetId,
  })
},[])

위의 형태로 useCallback으로 onDelete함수를 메모이제이션 해주면
마운트 시 한번만 호출되므로 다시 호출되지 않아 최적화가 된다.
또한 memo메서드를 사용하면 하나하나 작성했던 코드도 아래 형태로 간단하게 바꿀 수 있다.

export default memo(TodoItem);

그럼 언제 최적화를 하면 좋을까?

  1. 기능 구현 -> 최적화 순서가 일반적인 방법이다.

그럼 어떤것들을 최적화해야할까?

모든 것을 최적화하면 안되고 꼭 최적화가 필요한 것들을 골라서 최적화 해야한다.
이유는 memo메서드도 최적화를 위해 연산이 필요하다.
즉, 단순한 헤더와 같은 컴포넌트는 복잡한 계산을 요하지 않기 때문에 최적화가 꼭 필요하지 않다.
TodoItem컴포넌트처럼 유저의 행동에 따라서 렌더링 수가 많아질 수 있는 컴포넌트라던가
함수를 많이 가지고 있어서 무거운 컴포넌트의 경우는 최적화가 필요할 수 있다.

즉, 단순한 컴포넌트 외에 복잡한 컴포넌트를 최적화 하면 좋다.

언제 useMemo와 useCallback을 사용할까

좀 더 자세히 들여다 보면,
최적화에는 비용이 있기 마련이며 무조건 유익한 것은 아니다.
useMemo와 useCallback을 사용함으로써 발생되는 비용과 해택을 살펴보자.

const dispense = React.useCallback((candy) => {
	setCandies((allCandis) => allCandies.filter((c) => c != candy)); },[]);
}

위는 useCallback을 사용했다.
아래는 성능 최적화를 하지 않은 기본 코드이다.

const dispense = (candy) => {
	setCandies((allCandies) => allCandies.filter((c)=>c!candy))
};

두 개의 코드 중 어떤 코드의 성능이 좋을까?
정답은 기본 코드이다.

왜 useCallback의 사용이 더 나쁠까?

리엑트를 사용하다보면 인라인 함수가 성능에 문제가 될 수 있기에
useCallback을 사용해 성능을 개선해야한다는 말을 많이 들었따.
그런데 어떻게 useCallback을 안쓰는 것만 못할 수 있을까?

리액트르 떠나서 생각해야 할 부분이 있다.
모든 라인에 있는 코드는 실행이 될 때 비용을 수반한다.

해당 useCallback의 예제를 조금 바꿔보자

const dispense = (candy) => {
	setCandies((allcandies) => allCandies.filter((c)=> c!== candy));
};
const dispenseCallback React.useCallback(dispense,[]);

두 개의 예시에서 dispense함수는 같은 일을 수행하지만 useCallback버전의 예시가 더 많은 일을 하고있다.
useCallback버전은 함수를 정의하는 일 뿐만 아니라 다양한 일(프로퍼티의 셋팅/논리적인 표현식의 실행)을 위해 배열([])을 정해해줘야한다.

그래서 두 개의 예시에서 컴포넌트가 매순간 렌더링 될때마다 메모리에 함수를 정ㅇ의하게 되며 useCallback이 어떻게 사용되는지에 따라 메모리에 더 많은 함수가 정의될 수 있다

두 번째의 React.useCallback(dispense, [])의 경우는
dispense는 매번 새로운 참조를 가지므로, dispenseCallback은 dispense의 참조를 메모이제이션 하지만, dispense 자체는 렌더링될 떄마다 새로 생성된다.

dispense가 매번 새로 생성되기 때문에, idspenseCallback이 메모이제이션되어도 자식 컴포넌트가 dispense를 props로 받을 경우 불필요한 리렌더링이 발생할 수 있다.

useMemo는 어떻게 다르면서도 비슷할까?

useMemo는 어떤 타입의 값이든 메모이제이션의 사용을 가능케 한다는 부분을 제외하곤 useCallback과 비슷하다.
useMemo는 값을 리턴하는 함수를 받고 해당 함수의 리턴 값이 필요할 떄만 사용딘다
(보통 dependency배열안의 종속값들이 렌더시에 변화할때마다 한 번 발생한다.)
그래서 만약에 initialCandies배열이 렌더될때마다 다시 만들기 싫다면 아래와 같이 만들 수 있다.

const initialCandies = ['snickers','skittles','twix','milky way'];

const initialCandies = React.useMemo(
	() => ['snickers','skittles','twix','milky way'],[]
)

initialCandies배열이 렌더시에 다시 만들어지는 문제는 해결하기는 했디만 이렇게 해서 발생하는 효율을 그렇게 좋지 않는다.
오히려 코드가 좀 저 복잡해질 뿐이다. 사실 useMemo를 사용하는게 더 비효율적일 수 있다.
함수를 호출하면서 코드가 메모리에 할당되기때문에 좀 더 비효율 적일 수 있다.

그럼 위의 예시를 어떻게 하면 더 효율적으로 수정할 수 있을까?

const initialCandies = ['snickers','skittles','twix','milky way'];

function CandyDispenser(){
	const initialCandies = ['snickers','skittles','twix','milky way']
    const [candeis, setCandies] = React.useState(initialCandies)
    }

그런데 항상 이렇게 할 수 있는게 아니다
왜냐하면 함수에서 쓰이는 값들은 props를 통해 내려온걸수도 있고 함수 안에서 선언되어야하는 변수일 수도 있기 때문이다.

즉, 성능 개선은 공짜가 아니다 항상 비용이 들기 마련이고 성능 개선을 함으로써 얻어지는 이득이 꼭 그 비용을 상쇄할 수 있는건 아니다.

그러므로 최적화는 책임감을 가지고 사용해야한다.

그렇다면 언제 useMemo와 useCallback을 사용해야할까?

useMemo와 useCallback이 훅으로 만들어진건 여러 이유가 있다.
1. 참조 동일성(Referential equality)
2. 비용이 많이 드는 계산

사용사례:

  • useUemo: 비용이 많이 드는 계산을 메모이제이션하며, 의존성 배열에 지정된 값이 변경될 떄만 계산을 수행한다. 예를 들어 데이터 목록을 필터링하거나 정렬하는 경우, 입력 값이 변경되지 않으면 이전 계산 결과를 재사용하여 성능을 최적화할 수 있다.
  • useCallback: 함수 내에서 비용이 많이 드는 작업을 수행하는 경우 이를 메모이제이션하면 같은 함수가 반복적으로 생성되는 것을 방지할 수 있다. 그러나 주의할 점은 useCallback이 단순히 메모이제이션을 제공하는 것이므로 내부에서 비용이 많이 드는 작업이 있다면 그 작업의 최적화가 필요할 수 있다.

0개의 댓글