React useEffect 비동기 cleanup이 GPU를 죽이는 과정 — Pixi.js RenderTexture 실종 사건
📚 React 프론트엔드 삽질기 시리즈 (9편)
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 레이스 컨디션

“다시하기”를 누르면 내부에서 이런 일이 벌어진다:
[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개의 엔티티 사이에서 벌어지는 시간순 흐름이다.

다이어그램에 등장하는 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 | WebGL context 관리 |
|---|---|---|
| Galaxy Tab S | Adreno/Xclipse (고사양) | 멀티 context 허용, context 간 리소스 격리 엄격 |
| Galaxy Tab A | Mali-G (저사양) | context 재사용, 리소스 공유 관대 |
고사양 GPU는 WebGL context를 엄격하게 관리한다. 구 context에 바인딩된 framebuffer가 새 context에서 즉시 무효화된다.
저사양 GPU는 내부적으로 context를 재사용하면서 같은 framebuffer가 우연히 살아남을 수 있다.
“저사양에서 잘 되는데 고사양에서 안 된다” — 이 역설적인 상황이 시스템 내부를 들여다보게 만드는 트리거였다.
왜 RenderTexture만 영향받나?
Pixi.js 오브젝트 타입별 GPU 리소스 의존도가 다르다:
| 오브젝트 | GPU 리소스 | destroy 영향 |
|---|---|---|
| Sprite + 일반 Texture | 텍스처 업로드만 (이미지 → GPU) | 새 앱에서 re-upload → 복구 가능 |
| RenderTexture | GPU framebuffer 직접 생성 | context 변경 시 framebuffer 무효화 → 복구 불가 |
| Graphics (erase blend) | draw call 기반 | RenderTexture에 렌더링 → 같이 무효화 |
안개 레이어는 RenderTexture.create() + blendMode: "erase" 조합이다. 이 RenderTexture의 framebuffer가 무효화되면, app.renderer.render()를 호출해도 빈 프레임을 렌더링한다.
왜 웹 브라우저에서는 정상인가?
데스크톱 브라우저는:
- GPU 프로세스가 별도로 관리되어 context 전환이 안정적
- React SPA에서 같은 탭 내 라우팅은 동일 GPU 프로세스 내에서 처리
- Vuplex WebView는 Android System WebView(Chromium) 기반이며, 네이티브 앱 내 WebView의 WebGL context 라이프사이클은 일반 브라우저 탭과 다르게 관리된다 — context 복원이 보장되지 않는다
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();
});
방어 포인트 정리
| # | 시나리오 | 방어 |
|---|---|---|
| 1 | init 진행 중 cleanup | destroyed = true → init 완료 시 즉시 destroy |
| 2 | init 완료 후 cleanup | appInstance 동기적 destroy |
| 3 | destroy 후 캐시 오염 | Assets.reset() — 좀비 텍스처 방지 |
| 4 | GPU 업로드 타이밍 | requestAnimationFrame 지연 재렌더 |
교훈 체크리스트

Pixi.js 레이어링
-
addChildAt()와zIndex를 섞지 말 것 —sortableChildren = true쓰면 전부 zIndex로 통일 - 모든 Pixi 오브젝트에 명시적 zIndex 부여 — 기본값 0에 의존하면 마운트 타이밍에 따라 결과가 달라진다
React + GPU 리소스
-
useEffectcleanup 안에서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 이펙트)
📚 React 프론트엔드 삽질기 시리즈 (9편)
- 1. Vite 6.x 프록시에서 PATCH만 CORS 에러? 소문자 메서드 함정과 해결법
- 2. React Admin DataProvider 커스터마이징 삽질기
- 3. BE 응답 래퍼 언래핑 패턴 — API 200인데 왜 에러?
- 4. React useEffect 비동기 cleanup이 GPU를 죽이는 과정 — Pixi.js RenderTexture 실종 사건
- 5. shadcn init 실행했더니 프라이머리 컬러가 검정으로 — CSS 변수 덮어쓰기 트러블슈팅
- 6. Framer Motion whileInView 애니메이션이 스크린샷에서 사라지는 이유와 해결법
- 7. react-hook-form + Zod 연동에서 겪는 실전 함정 6가지 — 에러가 안 뜨는 이유부터 타입 불일치까지
- 8. Refine useCustom config.query가 정수를 보장하지 않는 함정 — 타입은 number인데 왜 400이야?
- 9. 패키지 설치 후 Invalid hook call? Vite 캐시 무효화가 답이다