profiler와 performance로 성능 개선하기

dobby·2025년 12월 3일
post-thumbnail

다른 팀 프로젝트를 리팩토링하면서 한 성능개선 작업을 정리했습니다.

성능 개선시 활용되는 브라우저 도구들은 다음과 같다.

  • performance(성능): 웹 애플리케이션의 런타임 성능을 기록하고 분석할 수 있는 탭
  • profiler(프로파일러): React 개발자 도구의 일부로, React 컴포넌트의 렌더링 성능을 프로파일링한다.

profiler를 통해 컴포넌트 렌더링 횟수와 성능을 측정하고, performance로 메인 스레드 사용량을 알 수 있다.


Performance tab

웹 페이지의 로딩 성능, 렌더링 성능, 스크립트 실행 성능 등을 측정하고 분석하는데 사용된다.

네트워크, CPU, FPS 등을 측정할 수 있다.

녹화 버튼을 통해 특정 인터렉션 혹은 기능에 대한 성능을 측정해보면, 위의 사진처럼 표시된다.

위 사진은 Main Timeline 인데, 주로 웹사이트의 메인 스레드를 가리킨다.

이 메인 스레드에서 Javascript의 실행, 스타일 계산, 레이아웃 생성 등 웹사이트의 주요 동작들이 처리된다.

메인 스레드에서 얼마나 시간을 소요하는지를 확인할 수 있다.

Main 영역에서 소요되는 시간이 50ms를 초과하면 긴 작업으로 판단해 빨간색 태그를 붙여준다.


Profiler tab

profiler 기능은 react-dom 16.5 이상의 개발자 모드에서 지원한다.
프로덕션 프로파일링도 지원하는데, 다른 방법을 사용해야 한다.

조금 지난 문서이긴 한데, 공식문서에서 설명을 해주고 있다.

기본적으로 React는 두 단계로 작동한다.

  1. 렌더링 단계: DOM에 어떤 변경이 필요한지 결정하며, 호출한 render 후 결과를 이전 렌더링 결과와 비교한다.
  2. 커밋 단계: React가 변경사항을 적용하는 단계.(React DOM 노드 삽입, 업데이트, 제거) 여기서 componentDidMount , componentDidUpdate 와 같은 라이프사이클을 호출한다.

드래그 리렌더링 성능 개선

profiler로 리렌더 상태를 확인하고자 했다.

리렌더 성능에 가장 큰 부분을 차지할 기능은 윈도우 drag & drop이기 때문에, 이 부분을 살펴봤다.

4개의 윈도우를 띄우고 윈도우를 이동시켜봤다.
움직이는 윈도우 뿐만 아니라, 화면에 표시된 모든 윈도우들이 모두 함께 리렌더되고 있었다.

물론 바탕 화면(WallPaper)도 마찬가지였다.

여기서 바의 색상은 함수 실행의 시간을 의미한다.

  • 초록색: 렌더링 시간이 짧고 최적 (성능에 큰 영향을 미치지 않는다.)
  • 주황색: 렌더링 시간이 상대적으로 길고 최적화가 필요하다. 성능 저하의 원인이 될 수 있다.
  • 노란색: 주황색보다는 덜 심각하지만 여전히 시간이 소요되는 구간
  • 회색: 리렌더링이 발생하지 않았다는 의미
  • 회색 빗금바: 다시 렌더링되지 않은 컴포넌트

performance도 돌려봤다.

가장 큰 부분을 차지하는건 Event: mousedown 처리였다.

이벤트 처리와 관련 작업에 소요된 총 시간이 103.2ms로 Long Task 로 분류되었다.

그리고 내부를 확인해 보면 Function call 이 많은 시간을 차지했는데, 이 Function call 은 주로 React의 업데이트(렌더링, 커밋 등) 또는 데이터 처리와 관련된 작업이다.

run 작업도 보면, 함수 호출 내부에서 불필요하게 많은 컴포넌트가 리렌더링 되거나 복잡한 계산이 동기적으로 이루어졌을 가능성이 높다. 사실 console 출력도 동기적으로 이루어지기 때문에 콘솔 출력이 많아지면 성능에도 영향을 줄 수 있다.

위 performance와 profiler를 통해 마우스 이벤트에 대한 후속 작업에 리소스가 많이 쓰인다는걸 알았으니 이를 개선해보자.

현재 프로젝트는 마우스 이벤트는 리렌더에 직접적인 영향을 주고 있다. 그리고 리렌더 후 서버에 API 요청을 보내 처리한다.

가장 영향이 클 윈도우 리렌더링 이슈를 개선해볼까 한다.


원인 분석

profiler 사진의 오른쪽 사이드바를 보면, What caused this update? 를 볼 수 있다.
이 렌더링의 원인이 무엇인지를 알려주는데, WallPaper 때문이라고 한다.

그렇다면 WallPaper 컴포넌트를 살펴보자.

// wallpaper.tsx
const {
    windowList,
    handleFetchClose,
    handleFetchOpen,
    handleMove,
    handleResize,
    handleFocus,
    handleHide,
    handleShow,
  } = useWindowManager(isBoot);

이렇게 window 와 관련된 함수와 데이터를 상위 컴포넌트인 WallPaper 컴포넌트에서 관리해 내려주고 있었다.

그래서 window의 상태(위치)가 바뀔 때마다 windowList 가 재생성되면서 wallpaper가 함께 리렌더 되던 것이다.

wallpaper가 리렌더되면서 자식 컴포넌트도 같이 리렌더링되는 문제였다.
이전 팀은 전역 상태를 거의 사용하지 않고 지역 상태 위주로 상태들을 관리해줬다.

그렇기에 상위에서 상태를 관리하고, 이를 자식으로 props drilling으로 전달해주고 있었다.

이로 인해 발생한 리렌더링 문제인데, 대부분이 custom hook으로 상태가 관리되고 있었기에 이를 어떻게 개선할지 고민이 됐다.

최대한 코드를 많이 안건들고 싶었는데, 윈도우에 대한 상태를 다른 컴포넌트들로 props drilling 없이 전달하기 위해선 전역 상태로 관리해주는 방법밖에 없었다.

싱글톤도 생각해봤는데, 상태가 불변성을 제공하지 않으면 리렌더가 필요한 곳이 렌더링이 안되는 문제가 발생하기 때문에 subscribe, publish를 만들어줘야 한다.

전역 상태가 그 역할을 대신 해주는데, 굳이 그래야 하나? 라는 생각이 들었다.
거기다 다음 할 일이 또 있었기 때문에 그리 많은 시간을 투자하지 못한다는 점도 컸다.

그래서 빠르게 작업할 수 있는 전역 상태를 활용해 window를 관리해주는걸로 결정했다.

마침 다른 팀원이 zustand 로 전역 상태를 관리하도록 해줘서, 그대로 zustand를 사용하기로 했다.

window 상태들 추리기

전역으로 윈도우 상태를 관리하기로 했으니, 윈도우 상태값들이 어떤게 있는지를 정리해야 한다.

export interface Window {
  id: number;
  title: string;
  x: number;
  y: number;
  z: number;
  width: number;
  height: number;
  isHidden: boolean;
}

마침 type으로 정의된 윈도우가 있고, windowList 도 이 타입들로 관리되고 있어서 그대로 사용하면 될 것 같았다.

이 타입들을 가지고 useWindowStore 를 만들어주고, 상태를 사용하는 컴포넌트에서 호출해주도록 했다.

  // wallpaper.tsx
  const {
    windowList,
    handleFetchClose,
    handleFetchOpen,
    handleMove,
    handleResize,
    handleFocus,
    handleHide,
    handleShow,
  } = useWindowManager(isBoot);

useWindowManager 훅에서 상태 업데이트와 관련 핸들러를 모두 다루고 있어서 분리 실행하기 어려웠다.

useWindowManager 는 내부 로직이 다음과 같이 나뉘어진다.

  • sse 데이터 전달받기 및 window 업데이트
  • window 이벤트 핸들러

이를 관련 비즈니스 로직별로 훅을 분리하기로 했다.

  • useWindowState : sse 데이터 전달 및 전달받은 데이터로 window 업데이트
  • useWindowActions : window 관련 이벤트 핸들러 관리

이후 해당 훅이 필요한 컴포넌트에서 직접 호출해주도록 하고, props drilling을 최소화해주었다.

// wallpaper.tsx
{isBoot && (
  <WindowManager
    isBoot={isBoot}
  />
)}
{isBoot && (
  <TaskBar
    onRestart={onRestart}
    onShutdown={onShutdown}
  />
)}

마지막으로 윈도우 내부에서 출력되는 어플리케이션을 메모이제이션 시켜주었다.

리렌더링 확인해봤을 때, Memo 컴포넌트와 FileList 컴포넌트가 실행됐는데, 변경된 상태가 없음에도 리렌더링이 발생하고 있었다.

그리고 이를 살펴보니 사이드바에 적힌 원인은 부모가 리렌더되어 리렌더되었다는 것이었다.

그래서 윈도우 내부에서 실행되는 어플리케이션 컴포넌트에 모두 React.memo 를 적용시켜줬다.

결과

before와 동일하게 가장 높게 측정된 부분을 가져왔다.

IconButton 굉장히 마음에 안드는데, memo를 적용했음에도 결과가 똑같아서 그냥 제거하고 그대로 뒀다.

profiler 기준 render time이 107.5ms로 측정되던 렌더링 시간을 9.2ms로 줄였다.
before, after 모두 측정 시 가장 높은 값을 가져온 것이며, 약 91% 개선시켰다.

performance도 돌려봤다.

확실히 Long Task가 사라진걸 확인할 수 있다.

그리고 똑같이 position API가 요청될 때의 mousedown 이벤트의 내부 작업을 확인해보면, Fucntion call 이 97.7ms → 43.4ms로 약 55% 개선된 것을 확인할 수 있다.

profile
성장통을 겪고 있습니다.

0개의 댓글