React useEffect 비동기 cleanup이 GPU를 죽이는 과정 — Pixi.js RenderTexture 실종 사건

React useEffect의 비동기 cleanup이 Pixi.js RenderTexture를 파괴하는 레이스 컨디션 버그를 추적합니다. 웹 브라우저에서는 정상인데 Vuplex WebView + 고사양 GPU에서만 재현되는 3중 함정의 원인과 해결법을 정리합니다.


QA에서 보고가 올라왔다.

“다시하기 누르면 유리창 레이어가 안 보여요. 창틀도 간헐적으로 사라져요.”

웹 브라우저에서 테스트하면 멀쩡하다. Galaxy Tab A에서도 정상이다. Galaxy Tab S에서만 터진다. 고사양 기기에서만.

첫 플레이는 괜찮다. “다시하기” 이후에만 발생한다.

2시간짜리 삽질이 될 줄 알았다. 결과적으로 3중 함정이었다.


증상 — QA가 보고한 것

증상상세
유리창(안개) 레이어 실종완전히 보이지 않음 — 터치해도 긁히지 않음
창틀 간헐적 실종타일 프레임이 투명해져서 히든씬이 바로 보임
정답 레이어는 정상동물 사진은 잘 보임
첫 플레이 정상다시하기 이후에만 발생

재현 조건을 정리하면 이렇다:

환경결과
웹 브라우저 (데스크톱)✅ 정상
Galaxy Tab A (Mali-G, 저사양)✅ 정상
Galaxy Tab S (Adreno/Xclipse, 고사양)재현

고사양 기기에서만 터진다는 게 첫 번째 의문이었다.


1차 수정과 실패 — zIndex 정렬 통일

처음에는 레이어 순서 문제를 의심했다.

PixiBackground.tsx에서 addChildAt(sprite, 0)으로 display list 기반 순서 제어를 하고 있었다. 다른 컴포넌트들은 sortableChildren = true + zIndex를 쓰고 있었고.

두 방식을 섞으면 마운트 타이밍에 따라 결과가 달라진다.

// PixiBackground.tsx — 수정 전
  sprite.visible = false;
- app.stage.addChildAt(sprite, 0);

// 수정 후
+ sprite.zIndex = -1;
+ app.stage.sortableChildren = true;
+ app.stage.addChild(sprite);

웹 브라우저에서의 배경 레이어 겹침 문제는 이걸로 해결됐다.

그런데 Vuplex에서 안개 레이어가 통째로 사라지는 문제는 여전했다.

zIndex는 범인이 아니었다. 더 깊은 곳에 진짜 원인이 있었다.


진짜 범인 — 비동기 cleanup 레이스 컨디션

결국 진짜 범인 — 비동기 cleanup 레이스 컨디션 문제의 범인은 이거였다
결국 진짜 범인 — 비동기 cleanup 레이스 컨디션 문제의 범인은 이거였다

“다시하기”를 누르면 내부에서 이런 일이 벌어진다:

[score.tsx] "다시하기" 클릭
  → reset(0) + nav("/play")
  → PlayPage 언마운트 → PixiStage cleanup 실행
  → PlayPage 새로 마운트 → PixiStage 새 Application 생성

문제는 PixiStage.tsx의 cleanup 코드였다:

// PixiStage.tsx — 문제의 코드
const cleanupPromise = init();  // init()은 async — Promise 반환

return () => {
  (async () => {
    destroyed = true;
    await cleanupPromise;  // ← init()이 끝나길 기다린 후에...
    // 그 안의 return 함수가 실행됨: app.destroy(true)
  })();
};

이 cleanup은 비동기다. React의 useEffect cleanup은 동기적으로 호출되지만, 내부에서 await를 사용하므로 실행 순서가 뒤집힌다.

그런데 여기에 더 치명적인 결함이 있었다.

// init()이 cleanup fn을 반환하지만, 호출하는 곳이 없다!
const cleanupPromise = init();
// cleanupPromise는 cleanup 함수를 resolve하는 Promise
// await cleanupPromise → cleanup fn을 받기만 하고 호출 안 함!

결과: app.destroy(true)가 영원히 실행되지 않는다. 다시하기 할 때마다 구 Application이 누적되고, GPU 메모리가 쌓이다가 드라이버가 이전 context를 강제 해제하면서 새 앱의 RenderTexture도 함께 무효화된다.


시퀀스 다이어그램 — 레이스 컨디션의 전체 그림

아래 다이어그램은 “다시하기”를 눌렀을 때 4개의 엔티티 사이에서 벌어지는 시간순 흐름이다.

React useEffect 비동기 cleanup 레이스 컨디션 시퀀스 다이어그램 — React Router, Old Pixi App, GPU WebGL, New Pixi App 4개 엔티티 간 비동기 destroy가 RenderTexture framebuffer를 무효화하는 과정

다이어그램에 등장하는 4개 엔티티의 역할부터 정리하자:

엔티티역할
React Router페이지 전환을 트리거하는 주체. “다시하기” 클릭 시 구 PlayPage를 언마운트하고 신 PlayPage를 마운트한다
Old Pixi App언마운트되는 구 페이지의 Pixi Application. useEffect cleanup에서 destroy(true)를 호출해야 하지만, 비동기 await 때문에 지연된다
GPU (WebGL)실제 렌더링을 수행하는 GPU. RenderTexture의 framebuffer를 관리하며, context가 파괴되면 모든 framebuffer가 무효화된다
New Pixi App새로 마운트되는 페이지의 Pixi Application. GPU에 RenderTexture를 생성하지만, 이미 구 앱의 destroy가 예약되어 있다는 사실을 모른다

이제 시간순으로 흐름을 따라가 보자:

1단계 — React Router가 구 페이지를 언마운트한다. useEffect cleanup이 동기적으로 호출되지만, 내부의 await cleanupPromise가 실행을 지연시킨다.

2단계 — Old Pixi App이 비동기 cleanup에 진입한다. destroyed = true 플래그만 세우고, 실제 destroy(true)는 아직 실행되지 않은 채 await 상태에 머문다.

3단계 — 이 틈을 타서 New Pixi App이 마운트된다. 새 Application을 생성하고 GPU에 RenderTexture(fogMask, fullFog)를 할당받는다. ✅ 이 시점에서는 정상이다.

4단계 — 여기가 핵심이다. Old Pixi App의 await가 드디어 완료되고, destroy(true)가 호출된다. 🔴 이때 구 앱의 WebGL context가 정리되면서…

5단계 — GPU가 framebuffer를 무효화한다. 고사양 GPU(Adreno/Xclipse)는 context 간 리소스를 엄격하게 격리하므로, 구 context에 연결된 framebuffer뿐 아니라 같은 WebGL context를 공유하던 리소스까지 영향을 받는다.

6단계 — New Pixi App이 렌더링을 시도하지만, GPU에서 돌아오는 건 빈 프레임이다. 💀 이것이 “좀비 텍스처” — CPU 메모리의 Texture 객체는 살아있지만, 가리키는 GPU 주소는 이미 죽었다.

핵심은 3단계와 4단계의 순서다. 동기적 cleanup이었다면 1→2(destroy 완료)→3→… 순서로 진행되어 문제가 없었을 것이다. 비동기 await가 이 순서를 1→2(대기)→3→4 로 뒤집으면서 레이스 컨디션이 발생한다.


반전 — 고사양 GPU가 더 잘 터지는 이유

고사양 GPU가 더 잘 터지는 이유 원인이 이거였다니… 자괴감
고사양 GPU가 더 잘 터지는 이유 원인이 이거였다니… 자괴감

여기서 의문이 남는다. 왜 특정 기기에서만 재현되나?

기기GPUWebGL context 관리
Galaxy Tab SAdreno/Xclipse (고사양)멀티 context 허용, context 간 리소스 격리 엄격
Galaxy Tab AMali-G (저사양)context 재사용, 리소스 공유 관대

고사양 GPU는 WebGL context를 엄격하게 관리한다. 구 context에 바인딩된 framebuffer가 새 context에서 즉시 무효화된다.

저사양 GPU는 내부적으로 context를 재사용하면서 같은 framebuffer가 우연히 살아남을 수 있다.

저사양에서 잘 되는데 고사양에서 안 된다” — 이 역설적인 상황이 시스템 내부를 들여다보게 만드는 트리거였다.

왜 RenderTexture만 영향받나?

Pixi.js 오브젝트 타입별 GPU 리소스 의존도가 다르다:

오브젝트GPU 리소스destroy 영향
Sprite + 일반 Texture텍스처 업로드만 (이미지 → GPU)새 앱에서 re-upload → 복구 가능
RenderTextureGPU framebuffer 직접 생성context 변경 시 framebuffer 무효화 → 복구 불가
Graphics (erase blend)draw call 기반RenderTexture에 렌더링 → 같이 무효화

안개 레이어는 RenderTexture.create() + blendMode: "erase" 조합이다. 이 RenderTexture의 framebuffer가 무효화되면, app.renderer.render()를 호출해도 빈 프레임을 렌더링한다.

왜 웹 브라우저에서는 정상인가?

데스크톱 브라우저는:

  1. GPU 프로세스가 별도로 관리되어 context 전환이 안정적
  2. React SPA에서 같은 탭 내 라우팅은 동일 GPU 프로세스 내에서 처리
  3. Vuplex WebView는 Android System WebView(Chromium) 기반이며, 네이티브 앱 내 WebView의 WebGL context 라이프사이클은 일반 브라우저 탭과 다르게 관리된다 — context 복원이 보장되지 않는다

3번째 함정 — Assets 글로벌 캐시의 좀비 텍스처

새벽까지 3번째 함정 — Assets 글로벌 캐시의 좀비 텍스처 삽질하다 녹초가 된 모습
새벽까지 3번째 함정 — Assets 글로벌 캐시의 좀비 텍스처 삽질하다 녹초가 된 모습

cleanup을 고친 뒤에도 문제가 남았다. 창틀(default_window.webp)이 투명하게 렌더링되고, 안개 회복(heal) 애니메이션도 작동하지 않았다.

원인은 Pixi.js Assets글로벌 캐시였다:

[첫 플레이]
Assets.load("default_window.webp")
  → CPU 디코딩 → GPU 업로드 → Texture 객체 생성
  → Assets 내부 캐시에 저장 → ✅ 렌더링 정상

[다시하기 — 구 앱 정리]
app.destroy(true)
  → WebGL context 정리 → GPU 텍스처 메모리 해제
  → ⚠️ 하지만 Assets 캐시의 Texture 객체는 그대로!

[다시하기 — 신 앱 마운트]
Assets.load("default_window.webp")
  → 캐시에 동일 URL 존재 → 캐시된 Texture 반환 (GPU 재업로드 안 함!)
  → 🔴 이 Texture의 GPU 데이터는 이미 해제됨 → 빈 렌더링

CPU 메모리에는 Texture 객체가 살아있지만 GPU 주소값은 무효화된 상태 — 좀비 텍스처(Zombie Texture) 현상이다.

왜 모든 텍스처가 아니라 일부만?

텍스처재렌더 기회결과
fogMaskTexture (긁기용 안개)사용자 긁을 때마다 재호출GPU 재업로드 후 정상 복구
fullFogTexture (힐용 안개)attach() 시 1회만 렌더, 이후 없음영원히 빈 상태
default_window.webp (창틀)Sprite에 직접 할당, 자동 재업로드 의존GPU 드라이버에 따라 투명 렌더링

최종 수정 — 방어 코드 4중 장벽

수정 A: PixiStage.tsx — 동기적 destroy + Assets 캐시 초기화

// 수정 후 — closure 변수로 동기적 destroy 보장
let destroyed = false;
let appInstance: Application | null = null;
let ro: ResizeObserver | null = null;

const init = async () => {
  const app = new Application();
  try {
    await app.init({ /* options */ });
  } catch (e) {
    console.warn("[PixiStage] app.init failed:", e);
    return;
  }

  // 방어 1: init 도중 cleanup이 호출된 경우 → 즉시 파괴
  if (destroyed) {
    app.destroy(true);
    return;
  }

  appInstance = app;  // closure에 저장
  // ... setup ...
};

init();

return () => {
  destroyed = true;
  ro?.disconnect();

  // 방어 2: init 완료된 경우 → 동기적으로 즉시 파괴
  if (appInstance) {
    queueMicrotask(() => {
      try { appInstance!.destroy(true); } catch { /* noop */ }
      // 방어 3: destroy 후 Assets 글로벌 캐시 초기화
      try { Assets.reset(); } catch { /* noop */ }
    });
    appInstance = null;
  }

  setApp(null);
};

수정 B: TileFogOverlay.tsx — fullFogTexture 지연 재렌더

renderCoverToFullTexture();  // 즉시 렌더

// 방어 4: 1프레임 뒤 재렌더로 GPU 업로드 완료 보장
requestAnimationFrame(() => {
  renderCoverToFullTexture();
  renderLayerToTexture();
});

방어 포인트 정리

#시나리오방어
1init 진행 중 cleanupdestroyed = true → init 완료 시 즉시 destroy
2init 완료 후 cleanupappInstance 동기적 destroy
3destroy 후 캐시 오염Assets.reset() — 좀비 텍스처 방지
4GPU 업로드 타이밍requestAnimationFrame 지연 재렌더

교훈 체크리스트

다시는 교훈 체크리스트 실수를 반복하지 않겠다는 다짐
다시는 교훈 체크리스트 실수를 반복하지 않겠다는 다짐

Pixi.js 레이어링

  • addChildAt()zIndex를 섞지 말 것 — sortableChildren = true 쓰면 전부 zIndex로 통일
  • 모든 Pixi 오브젝트에 명시적 zIndex 부여 — 기본값 0에 의존하면 마운트 타이밍에 따라 결과가 달라진다

React + GPU 리소스

  • useEffect cleanup 안에서 await 후 destroy 금지 — 새 컴포넌트가 이미 GPU 리소스를 생성한 뒤에 구 리소스가 정리되어 충돌
  • app.destroy() 후 반드시 Assets.reset() — 글로벌 캐시의 좀비 텍스처 방지
  • RenderTexture는 일반 Texture와 다르다 — GPU framebuffer에 직접 의존, context 변경 시 복구 불가
  • 1회만 렌더되는 RenderTexture에는 requestAnimationFrame 지연 재렌더를 보험으로

WebView 특수성

  • Vuplex WebView는 브라우저와 다르다 — Android WebView의 WebGL context 관리는 일반 브라우저보다 엄격
  • 고사양 GPU(Adreno, Xclipse)에서 context 격리가 강력 — 저사양에서 “우연히” 동작하는 코드는 시한폭탄
  • “다시하기” = 전체 재마운트라면, 가능하면 앱을 재사용하고 stage만 초기화하는 패턴이 안전

zIndex 맵 (참고)

-1    PixiBackground      (배경 이미지)
 0    MatchBoard hidden    (히든씬 — 타일 뒤 이미지)
50    TileFogOverlay       (안개/유리창 레이어 — RenderTexture)
51    TileFogOverlay full  (전체 안개 — RenderTexture)
100   MatchTile container  (타일/창틀)
300   PixiAniObject        (애니메이션 오브젝트)
3000  SpotLottie           (Lottie 이펙트)
4000  EffectLayer          (O/X 이펙트)