Vol. 5 — 비파괴 언마운트와 소유권 공리: 포탈이 프리미티브 없이 나오는 이유
작성 목적: 프레임워크 아키텍트 및 고급 엔지니어를 위한 기술 해설서 관련 소스:
quad-base/src/Slot/Raw.luau,quad-base/src/Slot/Owner.luau,quad-base/src/Slot/Handler.luau,quad-base/src/Bookkeeping.luau
[!CAUTION] 이 권은 재조정 엔진의 저수준 수명 동작을 다룹니다. 애플리케이션을 만들려고 quad를 배우는 중이라면 Getting Started부터 보십시오.
1. 소유권 공리
섹션 제목: “1. 소유권 공리”컴포넌트 기반 UI에서 **부착(트리에 보이는가)**과 **수명(메모리에 존재하는가)**은 자주 한 덩어리로 취급됩니다. 조건부 컴포넌트가 언마운트되면 그 상태까지 같이 버려지고, 그래서 “트리 밖 다른 위치에 그리되 죽이지는 않는” 요구가 생기면 별도의 합성 프리미티브(포탈)가 필요해집니다.
quad는 이 둘을 분리합니다:
소유권 공리: 자원을 만든 쪽이 그 수명을 정한다. 마운트 자리는 가시성만 정한다.
이건 Slot만의 규칙이 아니라 quad 전역 철학입니다 — Ref는 Destroy와 무관하고, Attr는 명시적 None으로만 지워집니다. **“이전 값을 지울지는 그 값을 만든 쪽이 정한다”**가 그 셋의 공통 문장입니다.
어디까지가 비파괴인가 — 정확한 범위
섹션 제목: “어디까지가 비파괴인가 — 정확한 범위”“quad는 절대 파괴하지 않는다”는 틀린 요약입니다. 비파괴는 두 자리에서 성립합니다:
- 자식 배열 위치의
State<Slot>교체. 그 자리를 맡는SlotHandler의 retractor는unmountSlotTree를 부릅니다 — 파괴가 아니라 언마운트입니다. 맨State를 Slot 요소로 넣었을 때 quad가 대신 만들어주는 래퍼 Slot도Owned = false라 같은 취급입니다. Owned = false로 만든 Slot. 요소를 “외부에서 빌려온 것”으로 보고, 밀려나면 언마운트만 합니다.
반대로 :List/:Single의 기본값은 Owned = true이고, 이 경우 데이터에서 빠진 요소는 파괴됩니다. 이건 실수가 아니라 기본값의 의도입니다 — 리스트가 만들어낸 것은 리스트가 치웁니다.
-- 설치 경로는 프로젝트 구성에 따라 다르다(00-installation 참고)local Quad = require(<quad-base 모듈 경로>)local QuadRoblox = require(<quad-roblox 모듈 경로>).QuadRobloxlocal q = Quad:UseProvider(QuadRoblox) -- quad-roblox 백엔드 설치: D/Tween/Animate/OnChange가 생긴다local D = q.D2. 파괴 대 언마운트: 뒤집힌 결정
섹션 제목: “2. 파괴 대 언마운트: 뒤집힌 결정”2.1 옛 모델
섹션 제목: “2.1 옛 모델”초기 v2에서는 :List/:Single의 재조정이 교체 시 rawRemove를 불렀습니다 — 제거와 파괴가 한 몸이었습니다.
2.2 그 모델이 만든 증상
섹션 제목: “2.2 그 모델이 만든 증상”- 껍데기만 남는 서브트리:
State<Slot?>를nil로 지웠다가 같은 Slot 객체를 다시 넣으면, 그 사이에 자식 Instance들이 이미Destroy된 상태라 두 번째 등장부터 조용히 빈 트리가 나왔습니다. 당시 권고가 “같은 Slot을 왕복시키지 말 것”이었을 만큼 실제로 밟는 자리였습니다. - 포탈이 불가능:
stateSlot:Get()으로 Slot을 뽑아두고 다른 값을Set한 뒤 그 Slot을 다른 곳에 넣는 시나리오가 원리적으로 막혔습니다 — 뽑아둔 참조는Set직후 이미 파괴된 Slot이었습니다.
2.3 역전 근거
섹션 제목: “2.3 역전 근거”결정적인 이유는 위 증상 자체가 아니라 일관성이었습니다:
State<Frame>이 이미 그렇게 동작합니다.child:Set(otherFrame)을 해도 quad가 이전Frame을Destroy해주지 않고 그냥 트리에서 내려올 뿐인데,State<Slot>만 다르게 동작할 이유가 없습니다.- “만든 쪽이 지운다”는 위 §1의 공리가
Ref/Attr에서 이미 확정돼 있었습니다.
그리고 막고 있던 건 소유권 규칙이 아니라 “제거 = 파괴”라는 재조정의 선택 하나였습니다. 필요한 부품(Extract/Splice의 비파괴 제거, claimOwner/releaseOwner의 소유권 이양, 재마운트를 이미 지원하던 attachSlot)은 전부 이미 있었고, 포탈은 그 위에 아무것도 더 얹지 않고 나왔습니다.
2.4 지금의 분리
섹션 제목: “2.4 지금의 분리”rawUnmount(self, index, deferPhysical?):releaseOwner로 소유권을 놓고, (배치가 아니면)nativeExtract로 물리 트리에서 뗀 다음vacate를 부릅니다.vacate는 그 자리를 0/None으로 리셋하는 게 아니라 자리 자체를 지웁니다 —_elements에서table.remove, 역맵 갱신, 부기 배열 한 칸 당김, 그리고 게이트를 존중하는 재계산.q.dispose(value): 명시적 파괴. 그 값을 아직 어떤 Slot이나 마운트 위치가 쥐고 있으면 거부하고 에러를 냅니다(무엇을 먼저 해야 하는지까지 메시지에 적힙니다). 소유자가 없을 때만destroySlotTree또는nativeDispose로 내려갑니다.
3. 포탈이 공짜로 나온다
섹션 제목: “3. 포탈이 공짜로 나온다”언마운트가 서브트리를 그대로 두므로, 포탈에 전용 프리미티브가 필요 없습니다.
마운트 자리 A (가방 창) 마운트 자리 B (핫바 칸)┌──────────────────────────────┐ ┌──────────────────────────┐│ State<Slot?> a │ │ State<Slot?> b ││ └── ItemView (마운트됨) │ │ └── (비어 있음) │└──────────────┬───────────────┘ └─────────────▲────────────┘ │ 1. A에서 떼기: │ 2. B에 붙이기: │ a:Set(nil) │ b:Set(itemView) │ → unmountSlotTree │ → attachSlot └───────────────────[ ItemView ]───────────┘ (메모리에 살아 있음)a:Set(nil)이 나가면 SlotHandler의 retractor가 돌아 서브트리를 물리 트리에서 떼고(nativeExtract — .Parent = nil), 옛 위치의 부기를 비우고, unbindLifetime + releaseOwner로 소유권을 놓습니다. ItemView의 Instance 트리·이벤트 연결·내부 Source는 그대로입니다. 이어서 b:Set(itemView)가 나가면 claimOwnerAt이 새 소유자를 잡고 attachSlot이 새 부모 아래로 flush합니다.
맨 State를 넣으면 quad가 래퍼를 만든다
섹션 제목: “맨 State를 넣으면 quad가 래퍼를 만든다”D.Frame { q.Slot<<Instance>> { myInstanceState }, -- 맨 State 요소는 Slot():Single(state, nil, { Owned = false })로 감싸진다}이 래퍼는 Owned = false이므로 myInstanceState가 다른 Instance로 바뀌어도 이전 Instance는 살아 있고, 외부 변수에 붙들거나 다른 슬롯으로 옮길 수 있습니다. 최종 파괴 책임은 그걸 만든 쪽에 있습니다.
보장되지 않는 것
섹션 제목: “보장되지 않는 것”Set으로 덮어쓰기 전에 이전 값을 직접Destroy하는 건 UB입니다. 순서는 항상Set(언마운트)이 먼저, 정리가 나중입니다.State<Frame>에서 먼저frame:Destroy()하고Set하는 것과 같은 문제입니다.- 뽑아둔 Slot을 아무도 안 들고 있으면 그냥 GC 대상입니다 — “언젠가 다시 붙겠지”가 아니라 참조를 들고 있어야 살아 있습니다.
4. 해제 순서 불변식: None 먼저, 0은 그 다음
섹션 제목: “4. 해제 순서 불변식: None 먼저, 0은 그 다음”옛 소유자에게서 위치를 반납할 때는 순서가 계약입니다. 이건 rawUnmount의 꼬리가 아니라 Dispatch 표면의 해제 경로(setEmpty)이고, 잎 핸들러들이 “마운트 안 하는 위치”를 등록할 때도 같은 본문을 씁니다:
q.Dispatch.setOffsetSource(ownerKey, position, q.None) -- 1. 발행 채널을 먼저 끊는다q.Dispatch.setLength(ownerKey, position, 0) -- 2. 길이를 0으로 접고 재계산순서가 뒤집히면 안 되는 이유: setLength는 끝에서 재계산을 돌립니다. 먼저 부르면 아직 등록돼 있는 옛 Source에 헛된 :Set이 날아갑니다. 두 줄이 한 함수(setEmpty)로 묶여 있는 것도 여러 종단 지점에서 순서가 어긋나지 않게 하기 위해서입니다.
5. 요약 — quad가 실제로 보장하는 것
섹션 제목: “5. 요약 — quad가 실제로 보장하는 것”| 상황 | quad의 동작 |
|---|---|
자식 위치의 State<Slot> 교체 |
unmountSlotTree — 이전 Slot은 살아 있고 다시 붙일 수 있다 |
맨 State 요소의 값 교체 |
래퍼가 Owned = false — 이전 Instance는 살아 있다 |
:List/:Single 기본값(Owned = true)에서 키가 빠짐 |
그 요소는 파괴된다 |
:List/:Single에 Owned = false |
언마운트만, 파괴 없음 |
q.dispose(value) |
아직 소유돼 있으면 거부(에러), 아니면 파괴 |
| 아무도 안 들고 있는, 뗀 값 | 평범한 Lua GC |
포탈은 이 표의 첫 두 줄에서 그냥 따라 나오는 결과이지, 별도의 기능이 아닙니다.