quad/.claude/base/relate-plan.md
qwreey 2df591851f
fix(attribute,slot): retract 전면 재설계, 소유권 레지스트리 통합, 코퍼스 감사
- Attribute: AttributeKeyHandler.retract를 완전 no-op으로 재정정 —
  지우는 건 오직 명시적 None/nil(process)로만, 그룹이 사라진 이름을
  자동으로 SetAttribute(nil) 안 해줌(Ref의 "Destroy 무관" 철학과 통일).
  단 사라진 이름의 StoreBind 구독은 retractUnder로 끊어 리소스 누수 방지.
- Slot: slotOwner를 elementOwner로 일반화해 top-level Dispatch/nested
  Add 경로가 같은 소유권 레지스트리를 쓰도록 통합(이중 마운트 gap 폐쇄).
  bindLifetime을 top-level 전용으로 축소(nested는 _elements 강참조로
  transitively 생존).
- 일반 규칙 신설(bind-system-plan.md): retract의 v는 타입 미보장이라
  내용을 보려면 isX(v) 가드 필수, retract 안에서 process 호출은
  retractUnder 체인 추적을 꼬는 UB.
- and/or 삼항 관용구 전면 금지(기존 "항상-truthy면 예외" 조항 폐기),
  코퍼스 전체 실제 코드 6곳을 if-then-else로 교체.
- 7-에이전트 코퍼스 감사로 stale 서술 다수 정정: retract-always-fires
  정정 전파 누락(bind-system-plan.md Tag 예시), Tween research→base
  승격 반영 누락(architecture.md/ui-shorthand-plan.md), Relate API
  인자 개수 버그(slot-plan.md), pre-implementation-audit.md 열린 항목
  개수 오류(question.md/CLAUDE.md).
- 백로그: Attribute 자동 unset용 :Apply 유틸 아이디어를
  research/operator-sugar-plan.md에 추가(착수 안 함).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVG74qV2nQVykhvMRQW2UC
2026-08-12 18:39:45 +09:00

131 lines
8.3 KiB
Markdown

# Relate — inst와 임의의 값을 weak하게 엮는 범용 릴레이션 프리미티브
**상태**: base — 2026-08-08 세션에서 신설, 확정. `base/bind-system-plan.md`
"핸들러 내부 상태 저장"과 `base/lifecycle-pattern.md``bindLifetime`/
`canExecute` 양쪽이 필요로 했던 "`inst`를 weak 키로 하는 저장소"가 지금까지
`base.perInstanceState(inst)`라는 이름만 있고 인터페이스가 미정인 placeholder로
남아있던 것 — 이번에 독립 프리미티브로 정식 승격, `perInstanceState`라는
이름/모양은 폐기.
## 왜 필요한가
Store-bind 핸들러(Tween 등)가 "이전에 만든 것"(실행 중인 Tween, gchold
Connection, gchold 배열 등)에 `retract`/`bindLifetime` 시점에 다시 접근하려면
그 값들을 `inst`에 매달아 저장해야 함. `inst`가 죽으면 이 저장물도 자동으로
같이 죽어야(GC-native, `base/lifecycle-pattern.md` 원칙) 하므로 바깥 키(`inst`)는
weak여야 함 — 그런데 그 안에 담기는 값은 경우에 따라 **강하게 붙잡아야
하는 것**(실행 중인 Tween 인스턴스, gcconn — 안 붙잡으면 존재 이유가 없어짐)과
**약하게만 참조해도 되는 것**(캐시성 값)이 둘 다 있음 — 이 둘을 하나의 테이블
`__mode`로는 표현 못 함(Luau/Lua 테이블의 weak 모드는 테이블 전체 단위).
## 왜 자동으로 강하게 들지 않는가 — 엔진이 결정할 일
**Relate 자신은 `inst`도 `value`도 자동으로 홀드하지 않는다** — 어느 쪽을
얼마나 강하게 들지는 호출부(주로 `quad-roblox`)가 명시적으로 결정해야
함(2026-08-08 세션, 사용자 확정). 자동으로 결정해버리면 weak 키가 참조하는
값이 그 키를 다시 강하게 참조하는 사이클(예: 프로퍼티 핸들러가 만든 클로저가
`inst`를 업밸류로 캡쳐한 채로 그 클로저 자신이 `inst`에 매달린 strong 저장소에
들어가는 경우)이 너무 쉽게 생김 — 엔진 객체의 실제 생명주기를 아는 쪽
(`quad-roblox`)만이 "이 값은 strong으로 둬도 안전하다"를 판단할 수 있음.
그래서 `Relate`는 판단을 안 하고 **`SetWeak`/`SetStrong`으로 호출부가 매번
명시**하게 만드는 얇은 표면만 제공.
## 위험한 패턴 — 서로 다른 두 `Relate`의 상호 강참조 순환 (2026-08-12 열세/열네 번째 세션, `Slot` 설계 중 발견)
위 26-28행이 말하는 "값이 자기 키를 다시 참조하는" 자기참조(예:
`Dispatch.setLength``observer` 클로저가 `inst`를 캡처, `Ref.Value=inst`,
**[확인, 2026-08-12 세션 후속] `slot._mountedInst = physicalTarget`
같은 패턴** — `bindLifetime(physicalTarget, slot)`이 내부적으로
`physicalTarget`을 키로 `slot`을 강하게 붙잡는 단일 `relate`고,
`slot._mountedInst`는 그 값(`slot`)이 자기 키(`physicalTarget`)를 다시
참조하는 필드일 뿐이라 GC 안전 — `base/slot-plan.md` "재귀 메커니즘"
절)는 **단일 `Relate` 안에서** 일어나는 한 안전함 — 그 `Relate`의 키(`inst`)가
테이블 *바깥에서* 독립적으로 reachable한지만 판별하면 되기 때문. 하지만
**서로 다른 두 `Relate`가 서로의 키를 상대방 값으로 강하게 제공하는
상호 순환**(예: `RelateA[inst]=value`(강)와 `RelateB[value]=inst`(강)가
동시에 존재)은 완전히 다른, 더 위험한 모양 — `inst`의 reachability
판별이 `value`의 reachability에 의존하고 그 반대도 마찬가지라 판별
자체가 순환됨.
**[확인, 2026-08-12 열네 번째 세션] Luau는 이 순환을 못 풂 — ephemeron
테이블이 없음, 복잡성 때문에 Lua 5.2 기능을 도입 안 한 것으로 공식
문서에 명시됨(출처: https://luau.org/compatibility/ "Lua 5.2" 섹션의
"Ephemeron tables" 항목).** 이건 Lua 5.2+가 ephemeron을 도입해서 풀려던
바로 그 사례라, Luau에서 위와 같은 두-`Relate` 상호 순환을 만들면
**둘 다 GC가 안 되는 실제 메모리 누수**가 됨 — "혹시 몰라서 피한다"가
아니라 확정된 필수 규칙.
**규칙**: 어떤 값(`inst` 아닌 임의 객체, 예: `Slot`)을 다른 `Relate`
바깥 키로 쓰고 싶어지면(예: "이 값이 지금 어느 `inst`에 묶여있는가"
역조회), 그 값 자체가 `inst`로 되돌아가는 강한 back-reference를 갖고
있는지 먼저 확인할 것 — 갖고 있다면 **두 `Relate` 중 최소 한쪽은
`SetWeak`로 낮추고, 실제 GC 앵커는 `bindLifetime`/`unbindLifetime`
하나로 통일**할 것(구체 사례는 `base/slot-plan.md` "Slot과 Store
바인드의 관계" 절 참고 — `kSlotMap`(inst→slot)/`slotOwner`(slot→inst)가
정확히 이 패턴이었음).
## API (확정)
```lua
Relate() -> relate -- 생성자, 싱글톤 아님
relate:SetStrong(inst: any, key: any, value: any) -- value를 강하게 보관
relate:GetStrong(inst: any, key: any): any?
relate:SetWeak(inst: any, key: any, value: any) -- value를 약하게만 참조
relate:GetWeak(inst: any, key: any): any?
```
- **`inst`(첫 인자)는 항상 weak** — 이 자유도는 아예 안 열어둠. 지금까지
나온 어떤 유스케이스도 "`inst` 쪽을 strong으로 두고 싶다"가 없었고, 열어두면
"`Relate`가 실수로 엔진 객체를 영구히 붙잡는" 사고 가능성만 늘어남.
`Weak`/`Strong`은 오직 **`value`의 보관 방식**을 가리킴.
- **비싱글톤 — 생성 가능한 값(`Ref`/`Store`/`Modifier`와 같은 프리미티브
컨벤션)**. 각 핸들러 모듈이 자기 톱레벨에 `local relate = Relate()`
하나씩 두고 재사용 — 서로 다른 `Relate` 인스턴스라 `key` 네이밍이 모듈
간에 겹칠 걱정이 없음(모듈 하나가 감당할 key 개수는 보통 한두 개뿐이라
`Relate()`를 여러 개 만드는 비용은 무시할 만함).
## 실제 구조 (확정, 2026-08-08 세션)
```
{ [inst(weak)]: { StrongMap: {[key]: value}?, WeakMap: {[key]: value(weak)}? }? }
```
- **바깥 테이블 하나**: `inst`로 weak-keyed(`__mode = "k"`), 값은 `{ StrongMap?, WeakMap? }`
형태의 서브테이블.
- **`StrongMap`/`WeakMap`은 각각 lazy 생성** — `Relate()` 호출 시점엔 아무
것도 미리 안 만듦. `inst`당 서브테이블도, 그 안의 `StrongMap`/`WeakMap`도
**`SetWeak`/`SetStrong`이 처음 불릴 때 인덱싱해보고 없으면 그때 생성**.
이유(사용자 확정, 성능 근거): Luau가 정적 분석으로 포인터 해싱을 캐싱해서
같은 자리에서 여러 번 인덱싱하는 건 이미 꽤 싸지지만, **테이블 생성
자체(array+hash part 초기화)는 상대적으로 비쌈** — 안 쓸 `inst`/모드
조합에 대해 테이블을 미리 만들어두는 건 순수 낭비.
- **`WeakMap`의 메타테이블은 항상 같은 객체를 재사용**(`{__mode = "v"}`류
하나를 모듈 로드 시 한 번만 만들어두고, 모든 `WeakMap` 생성에 그 객체를
그대로 `setmetatable`) — 메타테이블 내용이 매번 똑같으니 매번 새로 만들
이유가 없음. `StrongMap`은 메타테이블 자체가 필요 없어 그냥 `{}`.
- `GetWeak`/`GetStrong`은 각각 대응하는 서브맵이 아직 안 만들어졌으면(=한
번도 `Set`된 적 없음) 그냥 `nil` 반환 — 서브맵을 만들 필요 없음(읽기가
쓰기를 유발하면 안 됨).
**M2 착수 시 실측 확인**: 위 lazy 생성 전략과 `WeakMap` 공유 메타테이블
재사용이 실제 Luau에서 기대한 만큼 이득인지, `SetStrong`/`SetWeak`을 아주
자주 왕복 호출하는 핫패스(예: 매 프레임 store-bind 재실행)에서 서브테이블
존재 체크 자체가 새 비용이 되지는 않는지 — base 설계에는 영향 없는 순수
구현 최적화 문제.
## 대체하는 것
- `base/bind-system-plan.md` "핸들러 내부 상태 저장" 절의 `base.perInstanceState(inst)`
placeholder — Tween 등 핸들러가 `retract` 대상을 저장하는 용도, `SetStrong`으로.
- `base/lifecycle-pattern.md``bindLifetime`/`canExecute` — gcconn/gchold를
`Relate``SetStrong`으로 저장(둘 다 존재 이유가 "안 죽는 것"이므로
strong).
## 이름
`Relate` — 사용자 확정("이름은 Relate 괜찮아요? ... 좋습니다", 2026-08-08
세션). 다른 프리미티브(`Source`/`Ref`/`Store`/`Modifier`/`Effect`/`Blocker`)와
같은 "타입 이름이 곧 생성자" 컨벤션 그대로.