DevLog @ 2026.03.14

안녕하세요, @Lilia-Chen입니다.

최근 AIRI의 VRM / Three.js 런타임, 즉 AIRI의 웹·데스크톱·모바일 앱이 공유하는 3D 스테이지를 작업해 왔습니다. 오늘 DevLog는 2026년 3월 8일에 열고 3월 12일에 머지한 #1194에 관한 이야기입니다.

이야기는 단순합니다. VRM 스테이지가, 생애주기 실수가 렌더링 버그나 성능 버그, 혹은 "영원히 로딩 중" 같은 무작위 버그로 위장하기 너무 쉬운 지점에 이르렀던 것입니다.

그래서 이 작업은 정리이자 재설계이자 디버깅 일기가 되었습니다.

작업 전반에 걸쳐 리뷰와 도움을 준 @neko@Makito에게도 감사드립니다.

또한 이번이 stage-tamagotchi 런타임을 처음으로 제대로 훑어본 경험이었습니다. 스테이지 디버깅이 곧 단일 컴포넌트의 문제가 아니게 되면서, Eventa와 injeca에 대해 예상보다 훨씬 많이 배우게 됐습니다.

애초에 왜 이 코드를 건드렸나

작업을 시작할 무렵 이미 VRM 스테이지 주변에 버그가 뭉쳐 있었습니다:

  • VRM 인스턴스가 겹치거나, 옛 모델이 정말로 사라지지 않은 것처럼 동작할 수 있었습니다.
  • 스테이지가 loading 상태에 갇힐 수 있었습니다.
  • 서로 다른 VRM 모델을 반복해서 불러오면 GPU와 메모리 사용량이 건강하지 않은 영역까지 올라갈 수 있었습니다.
  • 깊은 해제(deep disposal)와 자원 소유권이 일관되지 않아, 어느 씬이 실제로 현재 모델을 "소유" 하는지 알기 어려웠습니다.

디버깅을 시작하자 실패 양상이 더 이상해졌습니다. 개발 환경에서는 특정 버튼을 처음 클릭하는 것만으로도 씬의 일부가 다시 마운트되고, 다시 loading으로 돌아가 그대로 갇혀 버릴 수 있었습니다.

첫 진단: 더 나은 생애주기 관리가 필요했다

이전에는 런타임 동작이 우연히 정해진 Vue 컴포넌트 수명에 너무 많이 의존했습니다:

  • 마운트는 어쩌면 로드,
  • 언마운트는 어쩌면 파괴,
  • 리마운트는 어쩌면 전부 재구축,
  • 그리고 두 씬이 거의 동시에 같은 상태를 건드리면 마지막에 쓴 쪽이 "이깁니다".

그건 설계가 아닙니다. 다음 리마운트까지 버티는 것일 뿐입니다.

한동안은 그런 구성도 동작하는 것처럼 보일 수 있습니다. 하지만 여기에

  • 메인 스테이지,
  • 설정 미리보기 씬,
  • HMR,
  • 비동기 모델 로딩,
  • object URL,
  • 캐시된 GPU 자원,
  • 그리고 창 간 동작

이 더해지면 전체가 극도로 취약해집니다. 그래서 기본 설계 목표는 이렇게 정해졌습니다:

  1. 씬 소유권을 명시적으로 만든다.
  2. 모델 교체를 명시적으로 만든다.
  3. 해제가 이유를 인식하게 만든다.
  4. 뒤늦게 도착한 비동기 작업이 무해하게 만든다.
  5. 추측하지 않고 무슨 일이 있었는지 확인할 수 있을 만큼 런타임을 관측 가능하게 만든다.

창 단위 VRM 캐시 설계

첫 구조 변경 중 하나는 분리된(detached) VRM 캐시였습니다.

같은 창의 같은 씬이 일시적으로 언마운트되고 다시 마운트된다면, 매번 전체 파싱·컴파일 비용을 치르는 대신 분리해 둔 VRM 인스턴스를 재사용할 수 있어야 합니다.

핵심 모양은 대략 이렇습니다:

ts
interface ManagedVrmCacheState {
  detachedByScope: Record<string, ManagedVrmInstance | undefined>
}

ManagedVrmInstance는 현재 분리된 런타임 묶음을 담습니다:

  • VRM,
  • Group,
  • AnimationMixer,
  • 이모트 컨트롤러,
  • modelSrc,
  • 그리고 scopeKey.

scopeKeywindow.location.href에서 파생되고, 캐시 상태는 모듈 상태에 살아 있습니다. 개발 중에는 import.meta.hot.data도 포함합니다. 실제로 이것이 뜻하는 바는:

  • 각 브라우저 창이 자신의 캐시 상태를 갖고,
  • 각 라우트 스코프가 자신의 분리 슬롯을 갖고,
  • HMR이 모듈을 다시 불러올 때마다 캐시를 자동으로 날려 버리지 않는다는 것입니다.

메인 스테이지와 설정 미리보기는 같은 modelSrc를 가리킬 수 있지만, 같은 씬 생애주기에 속하지는 않습니다. 전역 낙관적 캐시는 소유권을 아주 빠르게 애매하게 만듭니다. 창 단위이고 스코프 키로 구분되는 캐시는 훨씬 추론하기 쉽습니다.

캐시 API는:

  • takeManagedVrmInstance
  • stashManagedVrmInstance
  • clearManagedVrmInstance

그리고 해제 정책은 이유 기반입니다:

  • component-unmount에서는 가능하면 보관(stash),
  • model-switch에서는 적극적으로 파괴,
  • 캐시 항목이 밀려나거나 무효해지면 깊은 해제.

마지막 항목이 중요합니다. 캐시는 이름만 점잖게 바꾼 메모리 누수가 아닙니다. 인스턴스를 안전하게 재사용할 수 없다면 반드시 죽어야 합니다.

VRM 로딩을 경쟁 안전하게 만들기

캐시가 생기고 나니 로딩도 더 규율 있어져야 했습니다.

기존 문제는 단순했습니다. 비동기 로드가 순서와 다르게 끝날 수 있었던 것이죠. 사용자가 모델을 빠르게 전환하거나, 다른 로드가 진행 중인데 씬이 다시 마운트되고 있으면, 뒤늦은 작업이 늦게 도착해 활성 씬을 변형시킬 수 있었습니다.

그래서 이제 로딩 파이프라인은 요청 시퀀스를 갖고 다닙니다:

ts
const requestId = invalidatePendingLoads()
if (!isLoadRequestCurrent(requestId))
  // eslint-disable-next-line no-useless-return
  return

이 패턴이 VRM 로딩 흐름 전반에 등장합니다:

  • 씬을 기다린 뒤,
  • 캐시에서 읽은 뒤,
  • VRM을 불러온 뒤,
  • 대기 애니메이션을 불러온 뒤,
  • 인스턴스를 커밋하기 전.

로드가 낡은 것이 되면 결과는 커밋되지 않고 해제됩니다.

이로써 VRM 로딩의 개념적 흐름도 훨씬 명시적으로 바뀌었습니다:

text
load -> validate -> commit

캐시 히트도 같은 규칙을 따릅니다. 분리된 인스턴스 재사용은 검증을 통과한 뒤에만 허용됩니다. 캐시된 인스턴스가 더 이상 건강하지 않으면 파괴하고 로더는 일반 경로로 되돌아갑니다.

ThreeScene 생애주기 관리 재작업

그다음 더 큰 작업이 시작됐습니다. ThreeScene 자체에 생애주기 모델이 필요했습니다.

이 리팩터 전에는 ThreeScene, TresCanvas, OrbitControls, 카메라 상태, VRMModel 사이의 의존 관계가 실재하긴 했지만 너무 암묵적이었습니다. HMR 때문이든 다른 갱신 경로 때문이든 서브트리가 다시 마운트되면 그 느슨한 조율이 무너질 수 있었습니다.

이전의 어지러운 모습입니다:

ThreeScene lifecycle before

그리고 지금은 단계별로 이렇게 움직입니다:

ThreeScene lifecycle after

재설계에서 도입한 핵심 아이디어는 몇 가지입니다:

  • 명시적인 scenePhase,
  • 바인딩 트랜잭션 깊이,
  • 단계와 트랜잭션 상태에서 파생되는 변형 락,
  • 그리고 VRM 모델 준비 상태와 씬 준비 상태의 더 명확한 분리.

ThreeScene은 이제 다음과 같은 단계를 추적합니다:

  • pending
  • loading
  • binding
  • mounted
  • no-model
  • error

중요한 준비 신호가 최소 두 개 있습니다:

  • VRMModel이 로드되어 부트스트랩 데이터를 만들어 냈다.
  • OrbitControls가 실제 카메라와 렌더러가 뒷받침하는 DOM 요소에 접근할 수 있게 됐다.

이 두 신호는 서로 다른 순서로 도착할 수 있으므로, ThreeScene이 바인딩 트랜잭션을 통해 이들을 조율합니다.

흐름은 대략 이렇습니다:

  1. VRMModelloadStart를 발생시켜 바인딩 사이클을 시작합니다.
  2. VRMModel이 이후 부트스트랩 데이터와 loaded를 발생시킵니다.
  3. OrbitControls가 독립적으로 orbitControlsReady를 발생시킵니다.
  4. 바인딩이 실제로 완료될 수 있게 되면 ThreeScenebinding에 들어가 부트스트랩 상태를 적용하고, 다음 틱에 컨트롤을 갱신하고, 트랜잭션을 닫고, 최종 단계를 확정합니다.

이로써 ThreeScene, 카메라 상태, 컨트롤 사이의 상호작용도 훨씬 추론하기 쉬워졌습니다. 카메라는 씬이 실제로 상호작용 가능해지기 전에 존재할 수 있습니다. OrbitControls는 씬이 완전히 마운트되기 전에 만들어질 수 있습니다. 다만 사용자에게 노출되는 변형은 바인딩 구간이 끝날 때까지 차단됩니다.

여기서 sceneMutationLocked가 등장합니다. 데이터베이스적 의미의 강한 락이 아니라 런타임 조율 락입니다. 씬이 완전히 마운트되지 않았거나 바인딩 트랜잭션이 아직 열려 있다면, UI 변형이 씬을 안정된 것으로 취급해서는 안 된다는 뜻입니다.

그 락은 설정 패널의 쓰기를 비활성화하거나 지연시키고, 컨트롤이 너무 일찍 활성화되지 않게 하는 데 쓰입니다.

모델 선택기도 정리가 필요했다

ThreeScene을 고치던 중, 모델 선택기와 미리보기 경로에도 자체적인 생애주기 문제가 있다는 걸 발견했습니다.

거기에는 별개의 문제가 두 개 있었습니다.

미리보기 씬 정리

미리보기 렌더러 경로는 VRM 미리보기를 위해 오프스크린 WebGLRenderer를 만들고 있었는데, 정리 경로가 충분히 강하지 않았습니다.

미리보기 해체를 명시적으로 만들어 해결했습니다:

  • 애니메이션 액션 중지,
  • 미리보기 VRM 깊은 해제,
  • 미리보기 씬 정리,
  • 렌더러 해제,
  • 컨텍스트 손실 강제,
  • object URL 해제,
  • 오프스크린 캔버스 크기를 0으로.

모델 URL 수명과 경쟁 보호

스테이지 모델 URL 로직도 필요 이상으로 취약했습니다.

이전에는 갱신 중 선택된 URL이 잠깐 undefined가 될 수 있었는데, 그것만으로도 렌더러에서 불필요한 해체·재로드 사이클이 촉발됐습니다.

URL 교체와 해제를 더 규율 있게 만들어 해결했습니다:

  • 선택된 모델을 안정된 상태로 취급,
  • 다음 URL이 실제로 준비됐을 때만 URL 교체,
  • 요청 시퀀스로 비동기 갱신 보호,
  • 옛 blob URL을 성급하게가 아니라 신중하게 해제.

죽지 않던 버그: TresCanvas 크기 = 0

이 모든 작업을 마치고 나면 스테이지가 드디어 loading에 갇히지 않을 거라 기대했습니다.

여전히 갇혔습니다.

그 시점에 다시 추적으로 돌아가 렌더 경로를 더 과감하게 해부하기 시작했습니다. 증상은 TresCanvas가 결코 정말로 준비 상태가 되지 않는 것이었고, 결국 크기 관련 실패로 드러났습니다. 캔버스 경로가 사실상 0x0 렌더 영역을 보고 있었던 것입니다.

이걸 분리해 내는 데 시간이 좀 걸렸습니다.

중요한 단서 하나는, 개발 환경에서 @tresjs/corevite:afterUpdate에 반응하는 HMR 경로를 등록한다는 점이었습니다. 이는 .vue.ts 변경에만 국한되지 않습니다. UnoCSS가 __uno.css를 재생성하는 것도 서브트리 리마운트를 촉발할 수 있습니다. 특정 버튼을 처음 클릭하는 것만으로도 개발 중 스테이지가 불안정해질 수 있었던 이유가 이것으로 설명됐습니다. 새 클래스가 CSS 갱신을 만들고, 그것이 다시 Three 씬의 일부를 리마운트한 것이죠.

하지만 그건 아직 진짜 교착이 아니었습니다.

실제 교착은 로딩 UI 자체가 원인이었습니다.

스테이지 페이지는 WidgetStagev-show="!isLoading"으로 감싸고 있었습니다. 즉 스테이지가 로딩을 벗어나기를 기다리는 동안 TresCanvas의 부모가 display: none이 된다는 뜻입니다. 그런데 Tres는 부모 요소로부터 크기를 측정합니다. 부모가 숨겨져 있으면 측정된 크기는 0x0입니다. 크기가 0x0으로 머물면 @ready는 결코 발생하지 않습니다. @ready가 발생하지 않으면 스테이지는 결코 로딩을 벗어나지 못합니다.

그래서 교착은 이런 모양이었습니다:

text
loading 시작
-> 부모가 display:none 이 됨
-> TresCanvas 가 0x0 으로 측정
-> @ready 가 발생하지 않음
-> 씬이 mounted 에 도달하지 못함
-> 로딩 오버레이가 사라지지 않음

진짜 원인이 분명해지고 나니 수정은 복잡하지 않았습니다:

  • 스테이지를 DOM에 마운트된 상태로 유지하고,
  • 로딩 UI를 그 위의 오버레이 레이어로 옮기고,
  • 사라질 수 있는 부모에 의존하게 두지 말고 Screen을 통해 TresCanvas에 명시적인 너비와 높이를 준다.

이 변경으로 이번 디버깅 세션 전체에서 가장 짜증스러웠던 "여전히 멈춘다" 버그 하나가 드디어 사라졌습니다.

마지막 회귀: 웹도 깨졌다

데스크톱 쪽 문제 대부분을 고친 뒤 웹 앱으로 돌아갔더니 곧바로 또 다른 회귀를 발견했습니다. 이번 증상은 달랐습니다. VRM 설정 페이지가 잠긴 것처럼 보였고, 쓰기 락이 결코 풀리지 않는 것 같았습니다.

sceneMutationLocked를 가리키는 증상이었지만, 진짜 근본 원인은 ThreeScene 안이 아니었습니다. apps/stage-web/src/App.vue에 있었습니다.

앱이 여전히 이걸 쓰고 있었습니다:

vue
<KeepAlive :include="['IndexScenePage', 'StageScenePage']">
  <component :is="Component" />
</KeepAlive>

즉 설정으로 이동한 뒤에도 메인 페이지의 씬이 라우터 트리에 살아 있을 수 있었습니다. 결과적으로 공유 상태를 상대로 ThreeScene 인스턴스가 둘이나 계속 돌고 있을 수 있었습니다:

  • 메인 페이지 씬,
  • 그리고 설정 미리보기 씬.

둘 다 자기 씬 단계와 변형 상태를 보고하고 있었으니 락 의미가 혼란해졌습니다. 설정 페이지 관점에서는 락이 끝까지 정리되지 않는 것처럼 보였던 것이죠.

해결책은 그 KeepAlive 래퍼를 제거하는 것뿐이었습니다. 숨은 씬이 실제로 살아 있지 않게 되자 락 의미가 다시 일관되어졌습니다.

추적에 Eventa 활용하기

이 PR에서 특별히 원했던 부분이 추적(tracing)입니다.

현재 추적 작업은 아직 꽤 기초적이지만, VRM 스테이지를 순전히 직감과 console.log로 디버깅해야 했던 것보다는 이미 훨씬 낫습니다.

추적 레이어는 이제 @proj-airi/stage-ui-three 안에 있고, Eventa를 이벤트 계약으로 씁니다. 성능 쪽으로는 렌더러 정보 스냅샷, 히트 테스트 readback 타이밍, 프레임별 VRM 업데이트 분해 같은 것을 기록합니다. 생애주기 쪽으로는 load와 dispose, 캐시의 take / stash / clear, 씬 단계 변화, 트랜잭션 begin / end / reset을 추적합니다. 데스크톱에서는 이 이벤트들이 Eventa를 통해 간단한 진단 뷰로 전달됩니다.

여기서 앞으로의 TODO는 ThreeScene을 위한 제대로 된 관측 도구를 만드는 것입니다:

  • 더 나은 생애주기 introspection,
  • 더 나은 성능 타임라인,
  • 더 나은 자원 회계와 씬 상관관계,
  • 그리고 3D 런타임을 위한 훨씬 완전한 O11y 표면.

맺으며

그래서 #1194는 실제로 무엇을 했을까요?

  • 메모리 누수와 해제 경로를 정리했습니다.
  • 창 단위 VRM 재사용 캐시를 도입했습니다.
  • 비동기 로딩의 경쟁 가능성을 줄였습니다.
  • ThreeScene에 더 명시적인 생애주기 모델을 부여했습니다.
  • TresCanvas size=0 로딩 교착을 고쳤습니다.
  • 웹의 KeepAlive 회귀를 드러냈습니다.
  • 이 런타임을 위한 첫 쓸 만한 추적 경로를 마련했습니다.

무엇보다, 느슨하게 엮여 있던 동작 더미를 이제 제가 설명하고, 추론하고, 디버깅할 수 있는 것으로 바꿔 놓았습니다.

특히 추적과 앞으로의 ThreeScene O11y 도구 쪽으로 개선할 것이 아직 많이 남았지만, 최소한 이제 이 런타임에는 다시 주인이 있는 느낌입니다.

코드를 직접 읽어 보고 싶으시면 #1194부터 시작하세요. VRM 관련 이슈는 #1173에서 계속 추적하고 있습니다.