--- title: DevLog @ 2026.03.14 category: DevLog date: 2026-03-14 excerpt: | PR #1194 이야기 — AIRI 의 VRM 3D 스테이지를 디버깅하고 생애주기를 다시 설계하며, 창 단위 캐시를 도입하고 ThreeScene 관측 가능성의 첫 토대를 놓은 과정입니다. preview-cover: light: "@assets('/en/blog/DevLog-2026.03.14/assets/cover-light.avif')" dark: "@assets('/en/blog/DevLog-2026.03.14/assets/cover-dark.avif')" --- 안녕하세요, [@Lilia-Chen](https://github.com/Lilia-Chen) 입니다. 최근 AIRI 의 VRM / Three.js 런타임, 즉 AIRI 의 웹·데스크톱·모바일 앱이 공유하는 3D 스테이지를 작업해 왔습니다. 오늘 DevLog 는 2026년 3월 8일에 열고 3월 12일에 머지한 [#1194](https://github.com/moeru-ai/airi/pull/1194) 에 관한 이야기입니다. 이야기는 단순합니다. VRM 스테이지가, 생애주기 실수가 렌더링 버그나 성능 버그, 혹은 "영원히 로딩 중" 같은 무작위 버그로 위장하기 너무 쉬운 지점에 이르렀던 것입니다. 그래서 이 작업은 정리이자 재설계이자 디버깅 일기가 되었습니다. 작업 전반에 걸쳐 리뷰와 도움을 준 [@neko](https://github.com/nekomeowww) 와 [@Makito](https://github.com/sumimakito) 에게도 감사드립니다. 또한 이번이 `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 } ``` 각 `ManagedVrmInstance` 는 현재 분리된 런타임 묶음을 담습니다: - `VRM`, - 그 `Group`, - `AnimationMixer`, - 이모트 컨트롤러, - `modelSrc`, - 그리고 `scopeKey`. `scopeKey` 는 `window.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](/en/blog/DevLog-2026.03.14/assets/ThreeScene-before.avif) 그리고 지금은 단계별로 이렇게 움직입니다: ![ThreeScene lifecycle after](/en/blog/DevLog-2026.03.14/assets/ThreeScene-after.avif) 재설계에서 도입한 핵심 아이디어는 몇 가지입니다: - 명시적인 `scenePhase`, - 바인딩 트랜잭션 깊이, - 단계와 트랜잭션 상태에서 파생되는 변형 락, - 그리고 VRM 모델 준비 상태와 씬 준비 상태의 더 명확한 분리. `ThreeScene` 은 이제 다음과 같은 단계를 추적합니다: - `pending` - `loading` - `binding` - `mounted` - `no-model` - `error` 중요한 준비 신호가 최소 두 개 있습니다: - `VRMModel` 이 로드되어 부트스트랩 데이터를 만들어 냈다. - `OrbitControls` 가 실제 카메라와 렌더러가 뒷받침하는 DOM 요소에 접근할 수 있게 됐다. 이 두 신호는 서로 다른 순서로 도착할 수 있으므로, `ThreeScene` 이 바인딩 트랜잭션을 통해 이들을 조율합니다. 흐름은 대략 이렇습니다: 1. `VRMModel` 이 `loadStart` 를 발생시켜 바인딩 사이클을 시작합니다. 2. `VRMModel` 이 이후 부트스트랩 데이터와 `loaded` 를 발생시킵니다. 3. `OrbitControls` 가 독립적으로 `orbitControlsReady` 를 발생시킵니다. 4. 바인딩이 실제로 완료될 수 있게 되면 `ThreeScene` 이 `binding` 에 들어가 부트스트랩 상태를 적용하고, 다음 틱에 컨트롤을 갱신하고, 트랜잭션을 닫고, 최종 단계를 확정합니다. 이로써 `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/core` 가 `vite:afterUpdate` 에 반응하는 HMR 경로를 등록한다는 점이었습니다. 이는 `.vue` 나 `.ts` 변경에만 국한되지 않습니다. UnoCSS 가 `__uno.css` 를 재생성하는 것도 서브트리 리마운트를 촉발할 수 있습니다. 특정 버튼을 처음 클릭하는 것만으로도 개발 중 스테이지가 불안정해질 수 있었던 이유가 이것으로 설명됐습니다. 새 클래스가 CSS 갱신을 만들고, 그것이 다시 Three 씬의 일부를 리마운트한 것이죠. 하지만 그건 아직 진짜 교착이 아니었습니다. 실제 교착은 로딩 UI 자체가 원인이었습니다. 스테이지 페이지는 `WidgetStage` 를 `v-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 ``` 즉 설정으로 이동한 뒤에도 메인 페이지의 씬이 라우터 트리에 살아 있을 수 있었습니다. 결과적으로 공유 상태를 상대로 `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](https://github.com/moeru-ai/airi/pull/1194) 부터 시작하세요. VRM 관련 이슈는 [#1173](https://github.com/moeru-ai/airi/issues/1173) 에서 계속 추적하고 있습니다.