`.claude/pre-implementation-qa.md`(사용자가 base/ 확정 문서를 문항으로 재심사한 결과)를 실제 문서에 반영하고, 그 문서를 qa-request/로 옮기며 1라운드임을 파일명·제목에 명시(2라운드는 새 파일). 그대로 구현하면 반대로 돌던 것 2건: - canBound의 판정 방향이 이름과 반대였음 → canBound(v) == not isBoundAlive(v), 게이트는 전부 `if not canBound(v) then error(...)`. canExecute와는 값이 같은 게 아니라 서로의 부정이고, 그게 오히려 이름 분리의 명분이 됨(옛 근거 "값이 항상 같다"는 폐기). - gcconn/gchold 보관이 SetStrong으로 적혀 있었음 → SetWeak. 근거 문장까지 틀렸던 것이라 같이 교체(그대로 짰으면 두-Relate 상호 강참조 누수). 설계가 바뀐 것: - Dispatch.drive의 None 스킵 분기 폐기 → NoneHandler는 재귀 전담, NilHandler 신설(k=number and v==nil 말단이 setLength/setOffsetSource 등록). 깨진 전제는 "배열 파트의 None은 process를 안 탄다". - Length/Offset 등록 책임이 "처음 매치한 Handler" → 말단 Handler. - 이벤트 disconnect 센티널 false → None/nil. - Ref 내부 구조를 .Callbacks 분리 + 평범한 .Value 필드로 단순화, RefLeafHandler에 빠져 있던 type(k)=="number" 추가(leaf는 배열 전용). - :List reconcile의 nil 리턴은 다시 파괴가 기본, 값 교체와 PopOnly(가칭)만 비파괴. - base 소유 Fallback Handler 등록 주체를 백엔드 팩토리 → quad-base 자신으로 재역전(백엔드 미로드 시 안내 에러 경로가 안 돌았음). - "이벤트 콜백 시그니처는 Luau가 검증 못 한다"가 거짓임이 사용자 반례로 확인 → onchange-plan.md의 파생 근거까지 교체(결론은 유지). 이름/표면: DI → D(Declarative) 확정 및 전수 반영, New 커링 + D는 전량 코드 생성, Attribute.Merged/Overridden 둘 다 제공, Quad.debug 신설, store "key" 문자열 커링 기각(→ store:GetDynamic). 판단이 갈리던 4건(PopOnly 채택 / D-7 재역전 / NoneHandler·NilHandler 역할 분담 / 동적 키 경로)은 사용자에게 물어 확정. 커밋 전 검증: quad-doc-auditor 1패스가 1건, 사용자가 돌린 `/code-review high`가 10건을 더 잡아 전부 반영(ROADMAP이 SL-3 역전을 안 따라오던 것, 설계 갭 2건은 새 열린 질문으로 등록). doc-check.py ERROR 0. Co-authored-by: qwreey <me@qwreey.moe>
16 KiB
Relate — inst와 임의의 값을 weak하게 엮는 범용 릴레이션 프리미티브
상태: base — 2026-08-08 세션에서 신설, 확정. base/dispatch-core-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으로 호출부가 매번
명시하게 만드는 얇은 표면만 제공.
전제 — inst 키의 동일성은 공짜가 아니다 (2026-08-14 다섯 번째 세션 명시화)
Relate가 inst를 키로 쓴다는 설계 전체가 "같은 엔진 객체를 가리키는
Instance 값은 항상 같은 키다"를 전제하는데, Roblox에선 이게 자동으로
보장되지 않는다. Roblox의 Instance 값은 엔진 객체 자체가 아니라 엔진
객체를 가리키는 userdata 포인터라, Lua 쪽에서 아무도 참조를 안 들고 있으면
그 userdata가 회수될 수 있음 — 이후 같은 엔진 객체를 .Parent/
:GetChildren() 등으로 다시 얻으면 다른 userdata가 나올 수 있고, 그러면
이전 userdata를 키로 저장해둔 항목 전체가 조용히 미아가 됨(elementOwner,
nameClaims, Tag 참조 카운트 등 inst-키 릴레이션 전부 해당 — 크래시가
아니라 "부기가 그냥 없던 일이 되는" 조용한 오작동이라 더 위험).
해결은 이미 있는 것으로 됨 — quad는 자기가 만든 Instance마다 생성 즉시
gcconn 트릭을 걸고, 그 클로저가 inst를 캡처한다(base/lifecycle-pattern.md의
"gcconn/gchold는 Instance 생성 시점에 만든다" 절). 이 강참조 하나가
Destroy 전까지 userdata 동일성을 고정해주므로, 모든 inst-키 Relate가
그 위에서 성립함. 즉 gcconn 셋업은 bindLifetime 전용 배관이 아니라
Relate 전체의 전제 조건이고, 그래서 바인딩 유무와 무관하게 Instance 생성
시점에 무조건 실행된다(옛 lazy 생성에서 이번에 전환된 이유).
따름 정리 — quad 바깥에서 온 Instance를 Relate 키로 쓰는 건 UB. quad가
만들지 않은 Instance는 이 셋업을 안 거쳤을 수 있으므로, 그런 스코프를
여는 설계를 한다면 "키로 쓰기 전에 gcconn 셋업을 먼저 건다"를 같이 설계해야
함. [2026-08-14 세션] 이 UB를 실제로 건드릴 뻔했던 유일한 기능(이미
생성된 인스턴스 재바인드)은 기각됐으므로
(archive/existing-instance-bind-rejected.md) 지금 열려 있는 경로는 없음
— 이 따름 정리는 앞으로 비슷한 제안이 나올 때의 판단 기준으로만 유지.
일반 규칙 — 다른 곳에서 안전하게 유지되는 것은 항상 SetWeak (2026-08-14 다섯 번째 세션)
값의 생존이 이미 다른 경로로 보장돼 있다면 릴레이션은 약하게만 잡는다.
예: gchold는 gcconn 클로저가 업밸류로, gcconn은 gchold[1]이 각각
붙잡고 있으므로 InstData/BindData 쪽은 전부 SetWeak
(base/lifecycle-pattern.md 구현 스케치).
이유는 성능이 아니라 디버깅 가능성 — 같은 값을 강참조로 두 번 잡으면
"이 값의 실제 수명이 어디서 끝나는가"의 답이 둘이 되어, 한쪽만 지웠을 때
안 죽는 조용한 누수가 생김. 강참조는 "여기가 이 값의 유일한 생존 근거"인
자리에만 두고, 나머지는 전부 weak로 두면 그 질문의 답이 항상 하나로 유지됨.
SetStrong을 쓰기 전에 "이 값을 붙잡는 다른 근거가 이미 있는가"를 먼저
확인할 것 — 있으면 SetWeak가 맞다.
부수 효과 — 이 규칙을 지키면 아래 "상호 강참조 순환" 위험이 구조적으로
안 생긴다. 그 위험은 값이 강하게 보관될 때만 성립하는데(값이 자기 키를
되참조하는 경로가 강해야 ephemeron 부재가 문제됨), SetWeak면 릴레이션
쪽에서 시작하는 강한 경로 자체가 없음. lifecycle-pattern.md의
InstData/BindData가 실제 사례 — gchold가 value를 키로 강하게 잡고
value가 BindData의 키이기도 하지만, BindData 쪽 보관이 weak라
순환이 성립하지 않음.
위험한 패턴 — 서로 다른 두 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,k)별 마지막 Slot)/slotOwner(slot→inst)가
정확히 이 패턴이었음. [2026-08-13 다섯 번째 세션 이후] 그 두 Relate는
지금은 존재하지 않음 — kSlotMap은 Handler 계약이 클로저 반환으로 바뀌며
불필요해져 삭제됐고 slotOwner는 elementOwner(전부 SetWeak)로
일반화됐으므로, Slot 쪽엔 이 위험 패턴이 더 이상 남아있지 않음. 이 절은
일반 규칙으로 계속 유효하고, Slot은 그 규칙이 실제로 적용됐던
역사적 사례로만 인용).
API (확정)
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()를 여러 개 만드는 비용은 무시할 만함).
언제 Relate를 쓰고 언제 쓰면 안 되는가 — 체크리스트 (2026-08-13 여섯 번째 세션 신설)
Handler 계약이 "process가 자기 retract 클로저를 반환"으로 바뀌면서
(base/dispatch-core-plan.md "핸들러 계약" 절) Relate가 필요한 범위가
크게 줄었음 — 이 전환기에 양방향으로 실수가 나왔어서 기준을 못박아 둠.
쓰지 말 것 — 클로저 캡처로 충분한 경우: 이 process 호출이 만든
것을 그 호출이 반환한 클로저가 나중에 정리하는 단발성 handoff.
local observer = ... / local connection = ...를 로컬로 두고 반환
클로저가 upvalue로 캡처하면 끝 — relate:SetStrong(inst,k,x) 후
relate:GetStrong(inst,k)로 되찾아오는 왕복이 통째로 불필요.
2026-08-13 다섯 번째 세션에 kSlotMap(위치별 마지막 Slot),
kTagMap(위치별 마지막 Tag), Attribute의 groupState가 전부 이 이유로
삭제됨. 새로 Relate를 하나 만들고 싶어지면 먼저 "이거 그냥 클로저가
캡처하면 되는 것 아닌가?"를 물어볼 것.
써야 할 것 — 하나의 클로저 수명을 넘어서는 상태:
- 여러 위치가 하나의 자원을 공유할 때의 참조 카운트 —
Tag의tagNameMap(이름 → 그 이름을 걸고 있는 위치 집합). - 여러
process호출을 가로지르는 dedup 기록 —Ref의 "이 자리에 마지막으로 바인딩한 Ref"(클로저가 받는 인자는 다음 값이지 이전 값이 아니라서 캡처로 대체 불가). - 소유권/멤버십 전역 판정 —
Slot의elementOwner. - "언제까지 실행돼도 되는가" —
bindLifetime/canExecute(base/lifecycle-pattern.md). 애초에 클로저 수명과 무관한 질문.
쓸 때 같이 지킬 것:
- 정리 조건을 실제 정리와 묶을 것.
Relate엔트리를 지우는 코드가 "실제로 물러날 때"라는 조건 밖에 있으면, spurious 재발행에서 기록만 날아가 dedup이 조용히 무력화됨 —RefLeafHandler가 실제로 이 버그를 냈음(ref-plan.md"Ref의 retract" 절). - weak하다고 "언젠간 알아서 사라진다"에 기대지 말 것. 값이
SetWeak이어도 언제 사라지는지는 GC 타이밍이라, 그 전에 같은 키를 다시 쓰려는 코드가 비결정적으로 실패함 —Slot의destroySlotTree가 자식 소유권을 명시적으로 반납하지 않고 GC에 맡기고 있던 게 이 사례 (base/slot-plan.md"요소 소유권" 절). 명시적으로 만든 기록은 명시적으로 지울 것. - 서로 다른 두
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/dispatch-core-plan.md"핸들러 내부 상태 저장" 절의base.perInstanceState(inst)placeholder — Tween 등 핸들러가retract대상을 저장하는 용도,SetStrong으로.base/lifecycle-pattern.md의bindLifetime/canBound/canExecute— gcconn/gchold를Relate의 **SetWeak**으로 저장. [정정, 2026-08-18 구현 전 QA] 옛 서술은SetStrong("둘 다 존재 이유가 '안 죽는 것'이므로 strong")이었는데 근거까지 통째로 틀렸다 — 둘의 생존은 gcconn 클로저의 upvalue와gchold[1]이 이미 보장하므로, 위 "다른 곳에서 안전하게 유지되는 것은 항상SetWeak" 절의 규칙이 그대로 적용된다. strong으로 구현하면gchold가value를 강하게 잡고BindData가value를 키로gchold를 강하게 잡는 모양이 되어, 이 문서가 경고하는 두-Relate상호 강참조 순환(Luau에 ephemeron이 없어 실제 누수)에 정확히 걸린다. 구현 스케치는 이미 전부SetWeak으로 적혀 있었고(base/lifecycle-pattern.md) 이 요약 줄만 어긋나 있었음.
이름
Relate — 사용자 확정("이름은 Relate 괜찮아요? ... 좋습니다", 2026-08-08
세션). 다른 프리미티브(Source/Ref/Store/Modifier/Effect/Blocker)와
같은 "타입 이름이 곧 생성자" 컨벤션 그대로.