quad/.claude/base/relate-plan.md
qwreey-agent-selene 4be9373124
design: New()의 내부 구성 확정 — InitXxx 팩토리 체이닝 + Relate 기반 멱등 Init 가드
InitRoblox(Module) backend 주입 패턴을 quad-base 자기 내부(Dispatch 등)에도
대칭 적용 — 각 서브시스템이 InitXxx(module)로 module을 뮤테이션. 서브시스템
간 호출 순서 문제는 각 InitXxx 파일 톱레벨에 Relate() 하나를 두고 module을
weak key 삼아 인스턴스별 완료 여부를 기록해 require처럼 멱등하게 만들어
해소(relate-plan.md 체크리스트에도 용례 추가). module-lifecycle-plan.md에
"New()의 내부 구성" 절 신설, architecture.md/dispatch-core-plan.md/
ROADMAP.md(M1 체크리스트)에서 상호 참조.

핸드오버 감사 루프 4라운드(무발견 1회로 수렴) — 라운드 1~2는 절 인용
사각지대·상호참조 누락·SetWeak/SetStrong 일관성을 잡았고, 라운드 3~4는 그
수정 자체가 남긴 커밋 개수/날짜 오기, 원문 인용 파라프레이즈 등을 추가로
잡음. 부수로 session/ 기록 공백(2026-08-18/19 다수 커밋에 원문 누락)을
발견해 이번 세션분만 session/2026-08-19-01-*.md로 남김 — 과거 공백 처리는
사용자 확인 대기.

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-19 01:16:26 +09:00

16 KiB

Relate — inst와 임의의 값을 weak하게 엮는 범용 릴레이션 프리미티브

상태: base — 2026-08-08 세션에서 신설, 확정. base/dispatch-core-plan.md의 "핸들러 내부 상태 저장"과 base/lifecycle-pattern.mdbindLifetime/ 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 자신은 instvalue도 자동으로 홀드하지 않는다 — 어느 쪽을 얼마나 강하게 들지는 호출부(주로 quad-roblox)가 명시적으로 결정해야 함(2026-08-08 세션, 사용자 확정). 자동으로 결정해버리면 weak 키가 참조하는 값이 그 키를 다시 강하게 참조하는 사이클(예: 프로퍼티 핸들러가 만든 클로저가 inst를 업밸류로 캡쳐한 채로 그 클로저 자신이 inst에 매달린 strong 저장소에 들어가는 경우)이 너무 쉽게 생김 — 엔진 객체의 실제 생명주기를 아는 쪽 (quad-roblox)만이 "이 값은 strong으로 둬도 안전하다"를 판단할 수 있음. 그래서 Relate는 판단을 안 하고 SetWeak/SetStrong으로 호출부가 매번 명시하게 만드는 얇은 표면만 제공.

전제 — inst 키의 동일성은 공짜가 아니다 (2026-08-14 다섯 번째 세션 명시화)

Relateinst를 키로 쓴다는 설계 전체가 "같은 엔진 객체를 가리키는 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 클로저가 업밸류로, gcconngchold[1]이 각각 붙잡고 있으므로 InstData/BindData 쪽은 전부 SetWeak (base/lifecycle-pattern.md 구현 스케치).

이유는 성능이 아니라 디버깅 가능성 — 같은 값을 강참조로 두 번 잡으면 "이 값의 실제 수명이 어디서 끝나는가"의 답이 둘이 되어, 한쪽만 지웠을 때 안 죽는 조용한 누수가 생김. 강참조는 "여기가 이 값의 유일한 생존 근거"인 자리에만 두고, 나머지는 전부 weak로 두면 그 질문의 답이 항상 하나로 유지됨. SetStrong을 쓰기 전에 "이 값을 붙잡는 다른 근거가 이미 있는가"를 먼저 확인할 것 — 있으면 SetWeak가 맞다.

부수 효과 — 이 규칙을 지키면 아래 "상호 강참조 순환" 위험이 구조적으로 안 생긴다. 그 위험은 값이 강하게 보관될 때만 성립하는데(값이 자기 키를 되참조하는 경로가 강해야 ephemeron 부재가 문제됨), SetWeak면 릴레이션 쪽에서 시작하는 강한 경로 자체가 없음. lifecycle-pattern.mdInstData/BindData가 실제 사례 — gcholdvalue를 키로 강하게 잡고 valueBindData의 키이기도 하지만, BindData 쪽 보관이 weak라 순환이 성립하지 않음.

위험한 패턴 — 서로 다른 두 Relate의 상호 강참조 순환 (2026-08-12 열세/열네 번째 세션, Slot 설계 중 발견)

위 26-28행이 말하는 "값이 자기 키를 다시 참조하는" 자기참조(예: Dispatch.setLengthobserver 클로저가 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 계약이 클로저 반환으로 바뀌며 불필요해져 삭제됐고 slotOwnerelementOwner(전부 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를 하나 만들고 싶어지면 먼저 "이거 그냥 클로저가 캡처하면 되는 것 아닌가?"를 물어볼 것.

써야 할 것 — 하나의 클로저 수명을 넘어서는 상태:

  • 여러 위치가 하나의 자원을 공유할 때의 참조 카운트 — TagtagNameMap(이름 → 그 이름을 걸고 있는 위치 집합).
  • 여러 process 호출을 가로지르는 dedup 기록Ref의 "이 자리에 마지막으로 바인딩한 Ref"(클로저가 받는 인자는 다음 값이지 이전 값이 아니라서 캡처로 대체 불가).
  • 소유권/멤버십 전역 판정SlotelementOwner.
  • "언제까지 실행돼도 되는가"bindLifetime/canExecute (base/lifecycle-pattern.md). 애초에 클로저 수명과 무관한 질문.
  • "이 인스턴스에 이미 했는가"류 인스턴스별 멱등 가드(2026-08-19 신설) — New()가 만드는 각 module 인스턴스별로 InitXxx(module)가 이미 실행됐는지 기록하는 것도 여러 호출 지점(다른 InitXxx가 자기 의존성으로 호출하는 경우 포함)을 가로질러야 해서 클로저 캡처로 대체 불가 — base/module-lifecycle-plan.md의 "New()의 내부 구성" 절.

쓸 때 같이 지킬 것:

  • 정리 조건을 실제 정리와 묶을 것. Relate 엔트리를 지우는 코드가 "실제로 물러날 때"라는 조건 에 있으면, spurious 재발행에서 기록만 날아가 dedup이 조용히 무력화됨 — RefLeafHandler가 실제로 이 버그를 냈음(ref-plan.md "Ref의 retract" 절).
  • weak하다고 "언젠간 알아서 사라진다"에 기대지 말 것. 값이 SetWeak 이어도 언제 사라지는지는 GC 타이밍이라, 그 전에 같은 키를 다시 쓰려는 코드가 비결정적으로 실패함 — SlotdestroySlotTree가 자식 소유권을 명시적으로 반납하지 않고 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/WeakMapSetWeak/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.mdbindLifetime/canBound/canExecute — gcconn/gchold를 Relate의 **SetWeak**으로 저장. [정정, 2026-08-18 구현 전 QA] 옛 서술은 SetStrong("둘 다 존재 이유가 '안 죽는 것'이므로 strong")이었는데 근거까지 통째로 틀렸다 — 둘의 생존은 gcconn 클로저의 upvalue와 gchold[1]이 이미 보장하므로, 위 "다른 곳에서 안전하게 유지되는 것은 항상 SetWeak" 절의 규칙이 그대로 적용된다. strong으로 구현하면 gcholdvalue를 강하게 잡고 BindDatavalue를 키로 gchold를 강하게 잡는 모양이 되어, 이 문서가 경고하는 두-Relate 상호 강참조 순환(Luau에 ephemeron이 없어 실제 누수)에 정확히 걸린다. 구현 스케치는 이미 전부 SetWeak으로 적혀 있었고(base/lifecycle-pattern.md) 이 요약 줄만 어긋나 있었음.

이름

Relate — 사용자 확정("이름은 Relate 괜찮아요? ... 좋습니다", 2026-08-08 세션). 다른 프리미티브(Source/Ref/Store/Modifier/Effect/Blocker)와 같은 "타입 이름이 곧 생성자" 컨벤션 그대로.