아카이빙/React

리액트 화면 최적화를 하는 17가지 방법

biz-ninza 2024. 12. 24. 19:53

화면 최적화를 하려다가 내공이 부족하여 

지난시간에는 리액트의 라이프사이클과 훅에 대해서 파고들었다.

다시 돌아와 화면 최적화해 대해 생각해보자

 

 

 

목차

1. 화면 최적화란?

2. 첫째, '유저가 관심있는 화면만' 먼저 보여주기

3. 둘째, 불필요한 리렌더링을 막기

4. 셋째, 기타 잡(?) 스킬

 

 

1. 화면 최적화

 

프론트엔드, 특히 리액트에서 화면 최적화란 무엇을 의미할까 

리액트는 내 관점에서 동적 렌더링 그리고 컴포넌트 기반 설계가 강력한 장점이다.

 

그렇다면 사용자에게 빠르게 화면을 보여주는 것. 

그리고 컴포넌트를 잘 나누어 계층구조를 만들어 코드의 가독성과 유지보수성을 높이는 것.

이 두가지로 생각해볼 수 있겠다.

 

오늘은 전자, 즉 어떻게 사용자에게 빠르고 부드러운 화면을 보여줄 것인가에 집중을 해보자.

 

CRP (Critical Rendering Path)

 

웹 브라우저가 웹 페이지를 출력하기 위해서는 서버로부터 HTML 파일을 다운받아서 사용자의 화면에 그려내기까지 많은 과정을 거치는데 이 과정을 CRP 라고 한다.

 

기본적으로 브라우저의 렌더링엔진이 HTML 을 파싱하며 DOM 객체 를 만들고 파싱과정에서 link 나 style 태그를 만나면 잠시 멈추고 CSS 를 파싱하며 CSSOM 을 생성한다. 또한 script 태그를 만나면 잠시 멈추고 JS엔진에게 제어권을 넘겨서 자바스크립트 파싱과 실행을 한다. (이후에 다시 렌더링엔진에게 주도권이 넘어옴. 브라우저 내부원리에는 향후 다시 되짚어볼 기회가 있을 것)

 

모든 작업이 마치면 웹페이지에 렌더링될 Render Tree 를 만들고 Layout (렌더트리를 순회하며 픽셀값 계산), Paint (픽셀들로 실제 변환) 하여 유저에게 보여주는 화면이 디스플레이 된다.

 

 

CRP 를 이해하면 어디서 병목이 생기는지 파악하여 최적화를 진행할 수 있다. 

 

일반적으로 layout 과 Paint 에서 리소스를 많이 잡아먹는다. 리액트의 경우에는 상태가 바뀔 경우 Reflow 와 Repaint 를 진행하므로 리소스를 많이 잡아먹게 될테니 이 점이 특히 중요하겠지.

 

 

즉, 빠르게 화면을 보여준다는 것은 두가지 관점에서 생각해볼 수 있을 것 같다.

 

첫째는 '유저가 관심있는 화면만' 먼저 보여주기

둘째는 불필요한 리렌더링을 막기

 

 

 

2. '유저가 관심있는 화면만' 먼저 보여주기

 

[ 1. 렌더링 부하 분산 ] 

 

Next.js 같은 SSR 프레임워크를 사용하면 서버에서 HTML+CSS 를 한 번에 가져오기 때문에 (이후 JS가 실행되므로 만지진 못하지만 바로 보이기는 함), HTML DOM root 만 가져오고 JS를 통해서 전체 DOM을 그리고 나서 보여주는 CSR 보다 초기 로딩 속도가  빠르다.

 

한편 Next.js 는 SSG (Static Site Generation) 도 지원하는데 변경이 잦지않은 페이지는 빌드시점에 HTML 를 생성하는 방식을 혼용해서 서버 부하를 감소시키며 더욱 빠른 초기 로딩 속도를 경험시킬 수도 있겠다.

 

또한 필요한 JS만 로드하거나 JS실행범위를 줄여 하이드레이션을 최적화하면 더~욱 좋겠다

 

 

[ 2. bundle 최적화 및 코드 스플리팅 ]

 

리액트 프로젝트를 빌드할 때 불필요한 주석, 경고 메세지, 공백 등이 있을 수 있다. 이를 제거하여 파일크기를 최소화할 뿐 아니라 모든 브라우저에게 JSX 문법이나 최신 JS 문법이 원활하게 실행되도록 트렌스파일하는 작업 혹은 정적파일을 위한 경로 설정을 해주는 것이 webpack 이다. 

 

webpack은 모든 JS파일과 CSS 파일을 하나로 합쳐서 bundle 파일을 생성하게 되는데 이 때 SplitChunks 라는 기능을 적용하여 ode_modules 에서 불러온 파일, 용량이 큰 파일, 여러 파일 간에 공유된 파일 등 자주 바뀌지 않는 코드를 따로 분리시켜놓음으로서 캐싱의 효과를 제대로 누릴 수 있게 한다.

 

이렇게 파일을 분리하는 작업을 코드 스플리팅이라고 하는데 코드 비동기 로딩을 통해 JS함수, 객체, 컴포넌트를 처음에 불러오지않고 필요한 시점에 불러와서 사용할 수 있다.

import('./notify').then(result => result.default());

 

위와같은 import 함수를 사용하면 Promise 를 반환하는데 이렇게 import 를 함수로 사용하는 문법을 이용하면 notify 관련코드들은 따로 분리된 chunks를 만들어서 쓸 수 있다
(2020년 기준으로 JS문법 stage-3 에 있고 웹팩에서도 지원하고 있음)

 

다만 이런 방식을 쓴다면 컴포넌트를 불러온 후 따로 state 에 넣어 관리를 해야하는데 React.lazy 를 사용하게 되면 state 선언 없이도 바로 코드 스플리팅이 가능하다

const LazyComponent = React.lazy(() => import('./LazyComponent'));

 

이외에 Suspense 컴포넌트(코드 스플링된 컴포넌트를 로딩하도록 발동시키며 로딩 끝나지 않았을때 보여주는 UI 설정), SSR을 지원하며 좀 더 편하게 코드 스플리팅을 해주는 Loadable Components 라이브러리는 생략한다.

 

 

[ 3. preloading, prefetching ]

// Preloading
<link rel="preload" href="style.css" as="style">

// Prefetching
<link rel="prefetch" href="nextPage.js">

현재 페이지에 필요한 리소스를 미리 로드하는 preloading

사용자가 요청할 가능성이 높은 리소스를 미리 로드하는 prefetching

 

 

[ 4. 화면에 보이는 영역만 로드 ]

 

이미지와 같은 렌더링이 오래걸리는 컨텐츠는 HTML5에서 적용된 loading 속성으로 화면에 보일때만 로드하게 한다.

<img src="large-image.jpg" loading="lazy" alt="Example" />

 

 

 

[ 5. 페이징과 infinite scroll ]

function InfiniteScroll() {
  useEffect(() => {
    window.addEventListener('scroll', loadMore);
  }, []);

  const loadMore = () => {
    // api 페칭
  };

데이터를 가져올 때 페이징 처리를 하거나 스크롤할때 조금씩 데이터를 가져오는 방법도 있겠다.

 


[ 6. virtualization - react-virtualized ]

import React from 'react';
import { List } from 'react-virtualized';

// 각 행을 어떻게 랜더링할지 정의
const rowRenderer = ({ index, key, style }) => (
  <div key={key} style={style}>
    Row {index}
  </div>
);

const MyList = () => (
  <List
    width={300}
    height={300}
    rowHeight={30}
    rowCount={1000}
    rowRenderer={rowRenderer}
  />
);

export default MyList;

리스트들이 많다면 스크롤 전에는 보이지 않음에도 렌더링이 이루어지는 것이 비효율적이다.

리스트 컴포넌트에서 스크롤되기 전에 보이지 않는 컴포넌트는 렌더링하지않고 크기만 차지하게끔 할 수 있다.

그리고 스크롤되면 해당 스크롤 위치에서 보여야할 컴포넌트를 자연스럽게 렌더링 시킨다.

 

infinite scroll 은 스크롤 이벤트가 있을 때 추가 데이터를 페칭하는 것이고 React-virtualized 는 화면에 보이는 항목만 렌더링해서 성능을 최적화하는 것이다.

 

 

 

3. 불필요한 리렌더링을 막기

리랜더링은 state, props 변경에서 대부분 유발된다는 것을 지난시간에 알아보았다

 

 

[ 1. 전역상태 관리 ]

 

컴포넌트 트리 전체에 걸쳐 전역상태를 공유하기 위해 사용되는 React 내장기능인 Context API를 사용시 상태가 변경되면 이를 참조하고 있는 모든 컴포넌트가 리랜더링된다.

Redux 나 Recoil 을 쓰면 필요한 데이터만, 변경된 상태에만 의존하는 컴포넌트가 렌더링된다.

 

 

[ 2. 파생상태 관리 ]

 

*파생상태 : 기존 상태를 통해 계산할 수 있는 값

 

price 와 tax 라는 상태를 통해 구매가격인 total 을 계산할 때

const [total, setTotal] = useState(price+tax) 를 쓰게 된다면 price 가 변경될 경우 당장 쓰지도 않는 total도 매번 계산해 메모리에 저장하며 리렌더링을 유발할 수 있다. 

 

const total = price + tax 로 필요한 시점에 계산하자

 

 

[ 3. React Query 캐싱 ]

 

React Query는 서버 상태 관리를 자동화해주는 라이브러리다.

서버에서 가져온 데이터를 클라이언트 메모리에 저장하여 재사용할 수 있다.

동일한 키를 가진 요청은 이미 캐싱된 데이터를 반환하며 서버 데이터가 변경되면 자동으로 최신 데이터로 업데이트한다.

 

import { useQuery } from 'react-query';
import axios from 'axios';

const fetchData = async () => {
  const { data } = await axios.get('https://api.example.com/items');
  return data;
};

function MyComponent() {
  const { data, isLoading, error } = useQuery('items', fetchData); 

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <ul>
      {data.map(item => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

useQuery의 첫번째 인자는 캐시를 위한 고유 식별자, 두번째 인자는 데이터를 가져오는 함수

데이터가 변경되면 새로 가져온다는 점에서 로컬 스토리지와는 사용용도가 다르고 여러 컴포넌트에서 동일한 데이터를 공유할 수 있다.

 

 

[ 4. key 속성 최적화 ]

 

React는 key를 기준으로 DOM 변경 사항을 추적한다. 잘못된 key를 사용하면 불필요한 DOM 변경으로 성능 저하된다.

// 잘못된 key 예제
const items = ['apple', 'banana', 'cherry'];
<ul>
  {items.map(item => (
    <li key={Math.random()}>{item}</li> // 매번 새로운 Key 생성 → DOM 업데이트 증가.
  ))}
</ul>;

// 올바른 key 예제
const items = ['apple', 'banana', 'cherry'];
<ul>
  {items.map((item, index) => (
    <li key={index}>{item}</li>
  ))}
</ul>;

 

 

[ 5. useState 의 함수형 업데이트 ]

 

const [todos, setTodos] = useState();

useState에 abc() 를 넣으면 리렌더링될때마다 abc 함수가 호출되지만 abc 만 넣으면 컴포넌트가 처음 렌더링 될때만 실행된다.

 

한편, 상태변경시 setTodos(todos+1)  과 같이 상태를 넣어 직접 변경한다고 생각해보자
리액트의 상태업데이트는 비동기적으로 처리되는데 setCount(todos+1) 를 연속적으로 실행하다보면 이전 상태를 정확히 반영하지 않을 수도 있다.


setTodos(prev => prev+1) 과 같이 세터에 상태 업데이트를 어떻게 할지 정의해주는 업데이트 함수를 넣을 수도 있는데 이를 함수형 업데이트라고 한다.

이러한 함수형 업데이트를 사용하면 이전 상태를 안전하게 참조할 수 있다.

 

[ 6. React.memo, useCallback, useReducer, useMemo ]

 

  • React.memo : 컴포넌트를 메모이제이션. props가 변하지 않으면 컴포넌트를 재렌더링하지 않음.
  • useMemo : 계산 결과를 메모이제이션.
  • useCallback : 함수를 메모이제이션하여 불필요한 함수 생성 방지.
  • useReducer : 복잡한 상태 관리를 단순화하여 불필요한 렌더링 방지 (상태 업데이트 로직을 Reducer 함수로 분리하여 상태와 관련없는 컴포넌트를 렌더링하지 않는다)

* reducer : 상태(state)와 액션(action)을 기반으로 새로운 상태를 반환하는 순수 함수. 추후 딥다이브 예정

 

 

[ 7. 불변성 유지 ]

 

기존의 값을 직접 수정하지 않으면서 새로운 값을 만들어 내는 것을 불변성을 지킨다고 한다. 

아예 새로운 객체를 만들어 필요한 부분을 교체해주어야 이를 감지하고 React.memo 나 useCallback 을 사용했을 떄 porps 가 바뀌었는지 여부를 알아내서 리랜더링 성능을 최적화할 수 있다. 

[...array, new:'1'] 

 

const obj = {
  name: "Alice",
  details: {
    age: 25,
    city: "Wonderland",
  },
};

const shallowCopy = { ...obj }; // 얕은 복사

shallowCopy.name = "Bob"; // 1차 속성 변경 → 원본 영향 없음
console.log(obj.name); // "Alice"

shallowCopy.details.city = "Narnia"; // 내부 객체의 속성 변경 → 원본도 영향받음
console.log(obj.details.city); // "Narnia"

참고로 spread 연산자 (...문법)을 사용해서 객체나 배열 내부의 값을 복사할 때는 shallow copy를 하게 된다.

즉 내부의 값이 완전히 새로 복사되는 것이 아니라 가장바깥쪽에 있는 값만 복사된다 (=1차 속성만 복사)

내부 객체는 복사하지 않고 참조를 공유하는 것이므로 주의해야한다.

// somewhere.deep.array에 5를 추가하려면
let someObject = {
	...object,
    somewhere: {
    	...object.somewhere,
        deep: {
        	...object.somewhere.deep,
            array: object.somewhere.deep.array.concat(5)
        }
    }
};

깊은 중첩 구조에서 한편 불변성을 유지하면서 상태를 변경하려면 위와 같이 코드가 길어진다

// immer은 불변성 관리를 쉽게 해주는 라이브러리
import produce from 'immer';

const newObject = produce(object, (draft) => {
  draft.somewhere.deep.array.push(5);
});

Immer 라이브러리를 쓰면 draft 상태를 제공하여 간편하게 불변성을 유지하며 상태를 간단히 변경할 수 있다

 

 

 

4. 기타 잡(?) 스킬

 

[ 1. 한번에 fetch 요청 ]

axios.all([axios.get('/user'), axios.get('/posts')]).then(
  axios.spread((user, posts) => {
    console.log(user.data, posts.data);
  })
);

위와같이 여러 API 요청을 병렬적으로 처리하여 성능을 최적화하고 코드 가독성을 향상시킬 수 있다.

 

 

[ 2. 이미지 크기 조정 및 압축 ]

 

WebP는 구글이 개발한 이미지 포맷이다. 시각적인 화질은 유지하되 JPEG/PNG 보다 더 작은 파일 크기로 압축하는데 이 이미지 형태로 서버에서 받아 렌더링하는 방법도 있다. 브라우저가 리소스를 다운하는데 필요한 시간도 단축된다

 

 

[ 3. CSS 애니메이션 사용 ]

 

JS로 애니메이션을 구현하게 되면 CPU를 사용하며 DOM 요소를 직접 수정하게 된다. 

한편 CSS 로 애니메이션을 쓰면 GPU 가속을 활용하여 뛰어난 렌더링 성능을 보인다 (transform, opacity 등)

 

 

[ 4. CDN ]
CDN(Content Delivery Network) 는 사용자와 가까운 서버에서 리소스를 제공하는 인프라적인 방식이므로 컴포넌트 최적화와는 거리가 멀 수 있다. 하지만 어쨋든 CDN을 사용하면 컴포넌트가 의존하는 리소스(이미지, CSS)가 더 빠르게 로드된다.

 

 

 

 

Thanks to...

 

https://velog.io/@yyjjvv/%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%EB%A0%8C%EB%8D%94%EB%A7%81-%EA%B3%BC%EC%A0%95

 

'아카이빙 > React' 카테고리의 다른 글

Life cycle method 와 Hooks 완전정리  (1) 2024.12.19