크롬 개발자 도구로 PixiJS 튕김 현상 없애는 법(feat. 메모리 누수)

soyeon·2024년 12월 26일
0

에러모음

목록 보기
4/4
post-thumbnail

기술 스펙
Angular v15
PixiJS v7.2.4
ionic/angular v7.2.2

들어가며

PixiJS 때문에 앱이 튕긴다고요?

나는 최근 반년 동안 회사에서 독특한 프로젝트의 개발을 전담했다.

요즘 트렌드인 LLM스파인 애니메이션을 결합한 게임 같은 인터랙티브 콘텐츠를 제공하는 프로젝트로, 가장 큰 특징은 이 모든 것이 웹에서 동작한다는 점이다.

처음부터 이 프로젝트를 맡았던 것은 아니었다. 이미 베타 버전으로 운영 중이던 서비스를 정식 출시로 발전시키는 작업을 맡게 되었고, 그 과정에서 UI를 전면적으로 수정하면서 기존의 다양한 이슈들과 마주쳤다.

그중에서 가장 심각하면서도 기술적 성장의 기회가 되었던 문제에 대해 회고하고자 한다.

문제 인식

이 서비스는 캐릭터의 스파인 애니메이션을 구현하기 위해 Pixi-spine을 사용했다.

베타 버젼에서는 AI 채팅방과 인터랙티브 콘텐츠를 아이오닉 모달로 띄우는 구조였으나, 대대적인 UI 개편 이후 홈 화면에서 캐릭터가 움직이며 사용자를 맞이하는 방식으로 변경되었다. 사용자와 캐릭터가 소통할 수 있는 인터랙티브 콘텐츠는 여전히 모달을 통해 제공되지만, 홈 화면에서 직접 실행되는 구조로 바뀌었다.

이 과정에서 홈 화면과 모달 모두에 PixiJS Application을 사용하는 Pixi 컴포넌트가 포함되었다.

여기서 두 가지 심각한 이슈가 발생했다.

첫 번째는 발열 문제였다.

앱을 사용할수록 기기의 발열이 심해진다는 보고가 있었다.

두 번째는 간헐적인 멈춤 및 다운 현상이었다.

Pixi 컴포넌트를 사용하는 아이오닉 모달을 여러 번 열고 닫을 때, 웹앱이 간헐적으로 멈추거나 다운되는 오류가 발생했다. 이 문제는 모달을 한두 번 열 때는 나타나지 않았지만, 최소 2~3번 이상 반복했을 때만 발생하는 특이한 현상이었다.

발열 문제 원인 분석 및 해결

성능이 Pixi.js 컴포넌트를 렌더링할 때마다 저하되는 것으로 보아 메모리 문제라고 판단했다.

  1. 에셋 용량 최적화하기

첫 번째 단계로 Pixi-spine 애니메이션의 에셋 용량을 최적화했다.

  • 이미지 크기를 줄이고 WebP 형식으로 변환하여 품질은 유지하면서 용량을 최적화했다.
  • 사용하지 않는 스파인 데이터(JSON 포맷)를 정리했다.

디자이너와 애니메이터의 도움으로 에셋 용량을 38MB -> 7MB로 줄였다. 그동안 38MB나 되는 이미지 에셋을 사용하고 있었다니 충격이었다.

  1. 백그라운드에 깔려있는 홈화면의 애니메이션이 계속 실행되고 있는 것을 방지

모달이 띄워져있거나, ionic으로 라우터 이동을 했을 때에도 DOM에는 홈 화면 컴포넌트가 계속 남아있다.

  • ionViewWillLeave , ionViewWillEnter 아이오닉 라이프사이클 훅을 사용하여 이 때에 애니메이션을 멈추고, 켜는 코드를 추가하였다.

  • 모달이 present되기 전에 애니메이션을 멈추도록 했다.

  • visibilitychange 이벤트를 활용하여 백그라운드에서는 애니메이션 렌더링을 하지 않도록 하였다

  @HostListener('window:visibilitychange', [])
  onVisibilityChange(): void {
    this.app.stage.renderable = !document.hidden;
  }

개발자 도구를 사용한 메모리 누수 체크하기

확실히 발열은 많이 줄어들었지만 웹앱이 튕기거나 멈추는 현상은 여전했다.

Pixi 컴포넌트를 모달로 띄우고 닫는 구간에서 메모리 누수가 있다고 판단했다.

나는 정확히 어디서 메모리가 누수되는지 확인하기 위해 구글의 개발자 도구의 성능 측정 툴을 사용하여 디버깅을 시도했다.

사용 방법은 아래의 문서에 잘 정리되어있다.

https://developer.chrome.com/docs/devtools/memory-problems?hl=ko

나는 이 문서를 토대로, 성능 녹화를 키고, pixi component 모달을 3번 열고 닫고 녹화 종료 버튼을 눌렀다.

그러면 도구가 성능 분석을 해주고 아래와 같은 그래프를 볼 수 있다.

js 힙 (47.0mb ~ 159 mb)
노드 (1343 ~ 2969)

  • js 힙 : 브라우저의 자바스크립트 엔진에 있는 메모리
  • 노드 : DOM 노드를 뜻한다.

자세히 살펴보니 노드 그래프가 점진적으로 상승하는 패턴이 보였다.

하지만 어쩌라고..? 이 데이터만으로는 정확한 문제 지점을 파악하기 어려웠다.

그래서 성능 탭 옆의 메모리 탭으로 이동해 힙 스냅샷을 찍어보기로 했다.

이는 모달이 DOM 트리에서 정상적으로 제거되더라도 자바스크립트 코드 어딘가에서 해당 노드를 여전히 참조하고 있어 가비지 컬렉션이 제대로 이루어지지 않는 부분을 확인하기 위해서였다.

이처럼 DOM 트리에서 노드가 삭제되어도 자바스크립트에서 해당 노드를 계속 참조하는 현상메모리 누수의 대표적인 원인이다.

힙 스냅샷을 만들고 클래스 필터에 detached를 입력하면 이렇게 나온다.

여기서 WebGL2RenderingContext모달을 띄운 횟수만큼 쌓여져 있는 것을 알 수 있었다. 그리고 이게 메모리에서 차지하는 비율이 제일 높았다

WebGL을 쓰는 컴포넌트는 webGL을 기반하는 PixiJS를 사용하는 픽시 컴포넌트 밖에 없다.
개발자 도구를 통해, 모달이 닫혀서 Pixi 컴포넌트가 destroy 되어도 WebGL context가 사라지지 않고 계속 쌓이고 있다는 것을 확인할 수 있었다.

깃헙 이슈를 체크하자

모달이 닫힐 때 제대로 pixi app을 destroy하지 못한것일까?
나는 코드를 다시 확인해보았다.

  ngOnDestroy(): void {
    this.pixiApp.destroy(true);
    this.animation?.destroy();
    this.background?.destroy();
    //...
  }

이미 모달이 닫힐 때 Pixi 컴포넌트가 destroy 되면서 Pixi Application과 관련 노드들을 destroy하고 있었다.

이게 무슨 일 일까? 이럴 땐 해당 라이브러리 깃헙에 들어가 이슈를 뒤져보는게 빠르다. 분명히 비슷한 이슈를 겪은 사람들이 있을 것이다.

나는 금방 비슷한 현상을 겪는 사람들을 찾을 수 있었다.
https://www.notion.so/un7qi3/15b21925b141806c9467ed01812caaee?pvs=4#16721925b1418021b80ff38ba674e8ff

이 사람도 컴포넌트를 마운트 해제할 때마다 pixi application을 destroy해도 WebGL context가 지워지지 않고 계속 쌓인다고 한다.

이 현상은 비교적 최신 버젼인 v7에서도 발생하고 있다는 코멘트가 보인다.(우리도 v7를 쓰고 있다.)

단순히 라이브러리에서 제공하는 destroy 메서드로는 해결할 수 없는 사항으로 보인다.

해결 방법

처음에는 단순히 Pixi Application에서 사용한 모든 텍스처를 삭제하고 WebGL 컨텍스트까지 제거하면 문제가 해결될 것 같았다. 그러나 텍스처를 삭제하는 것은 불가능했다.

그 이유는 홈 화면에서 사용하는 Pixi 컴포넌트의 텍스처와 모달에서 사용하는 텍스처가 동일하기 때문이다. 모달을 닫은 후 텍스처를 삭제하면 홈 화면의 텍스처까지 함께 사라져버리는 문제가 발생한다. 참고로, PixiJS는 텍스처로 사용되는 assets을 전역적으로 관리한다.

따라서 선택지는 하나뿐이었다. 모달을 닫을 때 Pixi Application을 destroy하지 않고, 하나의 Pixi Application을 지속적으로 재사용하는 방식으로 전환해야 했다.

이를 구현하기 위해 앵귤러 서비스 파일을 하나 생성하고, 여기에서 Pixi Application을 생성한 뒤 싱글톤(singleton)으로 관리하는 방식을 채택했다. 이렇게 하면 Application을 여러 컴포넌트에서 안전하게 재사용할 수 있다.

pixiAnimation.service.ts

private app: Application | null = null;
  
  
  initApp(container: HTMLElement) {
    if (!this.app) {
      this.app = new Application({
        backgroundAlpha: 0,
        autoDensity: true,
        resolution: window.devicePixelRatio,
        antialias: false,
        resizeTo: container,
        powerPreference: 'low-power',
      });
    }
    container.appendChild(this.app.view as HTMLCanvasElement);
    return this.app;
  }

initApp 메서드를 만들었다.

  1. Pixi Application이 생성되지 않았을 경우, Application을 생성하고 멤버 변수에 담는다.
  2. 변수로 받은 컨테이너 노드 안에 해당 Application을 추가한다. (이건 pixi.js의 기본 사용방법이다.)
  3. 이 메서드를 호출할 때마다 이미 만들어진 Application을 반환한다.

이렇게 하면은 Pixi 컴포넌트를 띄울 때마다 같은 Pixi Application을 사용할 수 있게된다.

pixi.component.ts

this.app = this.pixiAnimationService.initApp(this.elementRef.nativeElement);

하지만 여기서 또 다른 문제가 발생했다.

처음 Pixi Application을 생성한 후 이를 컨테이너 노드에 추가하고 반환했는데, Pixi 컴포넌트가 단순히 모달에서만 사용되는 것이 아니라 홈 화면에서도 항상 사용되고 있었기 때문이다.

초기에 홈 화면의 컨테이너 노드에 Pixi Application을 추가하면, 이후 모달에서 Pixi 컴포넌트를 띄울 때 애니메이션이 보이지 않는 문제가 발생했다. 이는 Pixi Application이 모달의 컨테이너 노드가 아닌, 홈 화면의 컨테이너 노드에 남아있기 때문이었다.

결과적으로, 상황에 따라 Pixi Application을 홈 화면 컨테이너와 모달 컨테이너 사이에서 동적으로 이동시킬 필요가 있었다.

pixiAnimation.service.ts

  setResizeTo(container: HTMLElement): void {
    if (this.app && this.currentContainer !== container) {
      this.app.resizeTo = container;
      this.currentContainer = container;
      container.appendChild(this.app.view as HTMLCanvasElement);
    }
  }

만일 initApp 메서드를 실행했을 때, 현재 컨테이너 노드와 인자로 받은 컨테이너가 다를 경우,

인자로 받은 컨테이너를 기준으로 resize와 appendChild를 하도록 했다.

  initApp(container: HTMLElement) {
    if (!this.app) {
      this.app = new Application({
        backgroundAlpha: 0,
        autoDensity: true,
        resolution: window.devicePixelRatio,
        antialias: false,
        resizeTo: container,
        powerPreference: 'low-power',
      });
      this.homeContainer = container;
    }
    this.setResizeTo(container);
    container.appendChild(this.app.view as HTMLCanvasElement);
    return this.app;
  }

맨 처음에 보게되는 페이지가 home화면이기 때문에 처음에 pixi.app을 만들 때 homeContainer 변수에 home 화면의 컨테이너를 담도록 했다.

  restoreToHomeContainer(): void {
    if (this.homeContainer) {
      this.setResizeTo(this.homeContainer);
    } else {
      console.log('Home container is not initialized!');
    }
  }

그리고 Pixi 컴포넌트가 destroy 될 때(=모달이 닫힐 때) 다시 홈화면의 컨테이너 노드에 pixi app을 집어넣는 방식으로 했다.

이렇게 하면 Pixi Application 한 개로 여러 페이지에 사용할 수 있게 된다.

하지만 Application에 추가되는 animation과 viewport는 매번 destroy해주도록 했다.

왜냐하면 지금은 같은 애니메이션과 배경을 사용해도, 추후에 다른 애니메이션을 사용할 가능성이 있기 때문이고,

홈 화면 - 모달 각각에서 쓰인 애니메이션과 뷰포트는 싱글톤보다는 그 때 그 때 인스턴스를 만들어야 제어하기 편하기 때문이다. 싱글톤으로 관리하면 모달에서 쓰인 애니메이션과 뷰포트 옵션이 그대로 남아 홈 화면에 영향을 준다.

  ngOnDestroy(): void {
    this.characterService.restoreToHomeContainer();
    this.viewport?.destroy();
    this.animation?.destroy();
    //...
  }

결과 검증



WebGL2RenderingContext가 지속적으로 쌓이던 현상이 사라졌고, 이에 따른 메모리 사용량 감소를 확인할 수 있었다.

  • JS 힙: 약 47.0MB에서 90.1MB로, 34% 감소
  • DOM 노드 수: 약 647에서 1800으로, 45.6% 감소

메모리 사용량이 크게 줄어들었으며, 모달을 수십 번 실행하고 닫아도 튕기는 현상이 발생하지 않았다. 그리고 앱이 멈추는 문제도 해결되어 안정성을 크게 향상시킬 수 있었다.

노드 그래프 상에서 여전히 메모리 누수가 일부 존재하고 있으나 현재로서는 긴급한 문제를 해결하는 것이 우선이라서 릴리즈 이후 여유가 생길 때 추가적으로 디버깅을 진행하려고 한다.

마치며

이번 과정은 구글 크롬 개발자 도구를 활용하여 처음으로 메모리 문제를 직접 해결한 경험이었다.

이를 통해 단순히 보기 좋은 코드를 작성하는 것 뿐만아니라, 성능도 고려하는 기회를 가질 수 있었다.

고통스러운(?) 여정이었지만, 그만큼 큰 보람과 성취감을 느낄 수 있었다.
서비스의 가장 큰 버그를 해결하는 것은 개발자로서 기쁜 일이다. 이번 경험을 통해 프론트엔드 개발자로서 한 단계 성장할 수 있었다^^

  • 메모리 누수 문제를 추적하려면 반드시 힙 스냅샷을 통해 참조 상태를 확인해야 한다!
  • 여러 컴포넌트가 동일한 자원을 공유하는 경우에는 싱글톤 패턴을 고려해보자

모두 즐개발합시다^^

profile
공부중

0개의 댓글