quad/.claude/audit/gcconn-trick-verification.md
qwreey-agent-selene 8b57cfbb3c
qa: 구현 전 QA 1라운드 결과를 base/에 전량 반영
`.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>
2026-08-18 19:39:03 +09:00

9.5 KiB

gcconn 트릭 — 부분 실측 검증 결과

상태: 부분 확인(2026-08-13). 10-roblox-studio-checks.server.luau(현재 luau-test/rewrite-required/)의 공식 스크립트가 아니라 사용자가 별도로 작성한 저수준 검증 스크립트로 확인됨 — bindLifetime/unbindLifetime 자체의 이중 바인딩 로직과 Part B/C는 여전히 미검증. 아래 "아직 확인 안 된 것"이 전부 해소되기 전까진 공식 10 파일을 완주한 것으로 치지 말 것.

[2026-08-14 다섯 번째 세션] 아래 실측 결과 자체는 전부 그대로 유효하고, 오히려 더 중요해졌음bindLifetime/canExecute/unbindLifetime 재정정으로 canExecute(value)value 쪽 릴레이션에 복사된 gcconn의 .Connected를 직접 읽는 것이 leaf 경로 생존 판정의 전부가 됐기 때문 (base/lifecycle-pattern.md의 "bindLifetime/canBound/canExecute/ unbindLifetime — 확정" 절). [재정정, 2026-08-14 열한 번째 세션] canBound는 폐기되지 않고 별도 진입점으로 재도입됨 — 이중 바인딩 게이트(bindLifetime/ Observer:Subscribe())는 canBound, State emit 전파 게이팅만 canExecute(판정 로직은 비공개 헬퍼 하나를 공유, base/lifecycle-pattern.md의 "canBound vs canExecute" 절) — 공식 10 파일은 이 재분리도 반영해 재작성해야 함, 계속 rewrite-required/에 있음. 역전 경위는 archive/canexecute-inst-arg-reversed.md(추가된 절 포함).

배경

base/lifecycle-pattern.mdbindLifetime/canExecute 구현은 두 가지 Roblox 엔진 의존 가정에 기대고 있고, 둘 다 문서만으로는 확정할 수 없어 Studio 실측이 필요했음:

  1. GetPropertyChangedSignal("ClassName")은 절대 발화하지 않는다(엔진이 ClassName을 변경 불가능한 프로퍼티로 취급하므로 — 콜백 클로저가 gchold를 업밸류로 캡처해 살려두는 용도로만 씀).
  2. RBXScriptConnection.Connectedinst:Destroy() 시점에 GC를 기다릴 필요 없이 동기적으로 즉시 false로 바뀐다 — canExecute가 이 값 하나로 생존을 판단하므로, 이게 틀리면 설계 전체가 무너짐.

luau-test/README.md는 이 스파이크(10의 A 섹션)가 실패하면(신호 발화 또는 재바인딩 게이트 실패) "gcconn 트릭 전체를 재검토해야 하는 심각한 발견"이라고 못 박아둔 상태였음.

실측 방법

Studio에서 실행된 사용자 자작 스크립트(공식 10 파일이 아님) — 요지:

  • weak = setmetatable({}, {__mode="v"})에 target을 담아 GC 생존 여부 관찰.
  • inst:GetPropertyChangedSignal("ClassName"):Connect(...)로 gcconn 트릭과 동일한 신호를 구독, 콜백 클로저가 target을 업밸류로 캡처.
  • Connection을 변수로 안 잡는 경우(Test 1)와 잡는 경우(Test 2) 둘 다 확인.
  • triggerGC()로 GC 완료를 간접 관찰(기법 상세는 gc-trigger-helper.server.luau, luau-test/not-run/ — 이 헬퍼는 그대로 not-run/에 있음).

확인된 것

  1. ClassName PropertyChangedSignal 미발화 — 6 epoch(각 GC 사이클) 동안, 그리고 Destroy() 전후 모두 warn이 한 번도 안 뜸. 트리거 조건 1(신호 발화)은 회피 확인.
  2. 연결이 살아있는 동안 콜백 클로저가 캡처한 값이 GC 안 됨 — Test 1/2 둘 다 weak[1]이 6 epoch 내내 살아있음. lifecycle-pattern.md의 "gcconn/gchold는 Instance 생성 시점에 만든다" 주장과 일치. [2026-08-14 세 번째 세션] 이 스크립트가 실제로 업밸류로 캡처한 값이 target(=Instance) 자체였다는 점에서, 새 모델이 요구하는 **"클로저가 gchold뿐 아니라 inst까지 캡처해 userdata 동일성을 고정한다"**는 조치의 전반부도 같이 뒷받침됨 — 다만 "같은 엔진 객체를 다시 얻었을 때 userdata가 동일한가" 자체는 이 스크립트가 확인한 바 없음(아래 미확인 목록).
  3. Connection.ConnectedDestroy() 직후 동기적으로 false로 전환 — GC를 기다리지 않고 즉시 확인됨(Test 2). canExecute(value)의 유일한 하드 의존성이 실측으로 확인됨. [2026-08-14 다섯 번째 세션] 재정정 이후 이 항목의 무게가 더 커짐 — canExecute는 이제 value 쪽 릴레이션에 복사된 gcconn의 .Connected 하나만 보고 leaf 경로 생존을 판정하므로(inst를 조회하는 경로가 아예 없음), 이 전환이 즉발이 아니면 죽은 inst에 처리를 시도하는 것을 막을 방법이 없음.
  4. Destroy 이후 GC를 한 번 더 돌리면 클로저가 캡처했던 값이 실제로 수거됨conn 변수 자체는 스크립트 스코프에 여전히 남아있어도 (Connection 객체 자체는 안 죽음), disconnect되면 콜백의 upvalue 참조는 놓아준다는 것도 확인 — 메모리 누수로 안 남는다는 근거.

아직 확인 안 된 것

  • 이중 바인딩 게이트 + unbind/Destroy 후 재바인딩 허용bindLifetime/ unbindLifetime 로직 자체는 이 스크립트에 없음(순수 GC/Connection 메커니즘만 테스트함). [2026-08-14 열한 번째 세션 재갱신, 2026-08-18 방향 정정] 게이트는 canBound(value)이고(if not canBound(v) then error(...) endcanBound 참 = "지금 묶어도 됨", canExecute는 emit 게이팅 전용으로 분리되며 둘은 서로의 부정 — lifecycle-pattern.md "canBound vs canExecute" 절), 검증해야 할 명제는 안 바뀜: (a) 살아있는 바인딩을 가진 값을 다시 bindLifetime하면 error, (b) unbindLifetime(value) 후에는 통과, (c) inst가 Destroy된 뒤에도 통과(모델이 명시적으로 허용). 전부 미해소이고, 공식 10 파일은 재작성 후에야 이걸 확인할 수 있음(현재 luau-test/rewrite-required/).
  • bindLifetime이 복사해둔 gcconn만으로 판정이 성립하는가[2026-08-14 다섯 번째 세션 신규] canBound/canExecuteinst를 안 받고 BindData:GetWeak(value, "gcconn") 하나로 생존을 판정하는 경로 자체는 아직 실측된 적 없음(위 3번은 gcconn을 inst 쪽에서 직접 들고 있는 형태로 확인한 것). weak 릴레이션에 복사해둔 참조가 gchold 사망 후 기대대로 비워지는지도 같은 항목.
  • Instance userdata 포인터 동일성[2026-08-14 다섯 번째 세션 신규] "Lua 쪽 강참조를 안 들고 있으면 나중에 같은 엔진 객체에서 다른 userdata가 나올 수 있다"는 전제(gcconn/gchold를 Instance 생성 시점에 만들기로 한 이유, inst-키 Relate 전체가 여기 기대고 있음)는 아직 미검증.
  • Part B (Attribute의 Instance 참조 타입 지원), Part C (CollectionService 태그/GetTagged 왕복) — 미실행.
  • inst 자체를 __mode="k" weak key로 쓰는 경로 — 실제 Relate 구조(base/relate-plan.md)는 바깥 테이블이 inst를 weak key로 잡는데, 이 스크립트는 __mode="v" + 정수 리터럴 키만 썼음. inst가 다른 곳에서 안 참조되면 relate 엔트리(gcconn/gchold/value 전체)가 실제로 죽는 경로는 미검증 — canExecute.Connected만으로 정확히 동작하는 한 정합성보다는 메모리 누수 방지 쪽 문제라 상대적으로 덜 치명적이지만, 완전히 별개 항목이니 열어둘 것. weak-key-on-table 자체는 07-relate-weak-table-gc.luau가 순수 luau CLI에서 이미 확인했음 — Instance가 plain table과 동일하게 weak key로 동작하는지는 아직 별개로 미확인.

부수 발견 — Roblox Studio에서도 GC 완료를 간접 관찰 가능

07-relate-weak-table-gc.luau의 기존 서술("Roblox 실제 게임 스크립트 환경에는 collectgarbage()가 노출되지 않아 GC 타이밍 검증이 불가능, 그래서 순수 luau CLI에서만 해야 함")은 "명시적 collectgarbage() API가 없다"는 부분은 여전히 맞지만, "그래서 Studio에서 GC 검증 자체가 불가능하다"는 결론은 이번에 반증됨 — weak-value 테이블에 canary를 넣어두고 할당 압력(table.create 반복)을 걸며 task.wait로 기다리면, incremental GC가 실제로 완료되는 시점을 간접 관찰 가능함(사용자가 이번 검증에서 확인). 07의 docstring과 이 기법 자체는 gc-trigger-helper.server.luau(luau-test/not-run/)로 분리해뒀음 — Studio 기반 스파이크(10 등)가 GC 완료를 기다려야 할 때 그 파일의 waitForGC를 그대로 복붙하면 됨.

다음 확인 시 참고

[2026-08-14 다섯 번째 세션 갱신] 공식 10은 그대로 돌리면 안 됨 — A 섹션이 폐기된 모델(canBound, bindLifetime.Subscribed 세팅, 2-인자 canExecute)을 검증 중이라 luau-test/rewrite-required/에 있음. 순서는 A 섹션 재작성 → Studio 실행.

  • 이 문서의 "확인된 것"과 겹치는 A 섹션 앞부분(ClassName 신호 미발화, Destroy 시 Connected 즉시 전환)은 다시 안 봐도 됨 — 단 재작성 시 이 두 검증은 반드시 남길 것(새 모델에서 canExecute의 유일한 근거).
  • 반드시 실제로 돌려봐야 하는 것은 위 "아직 확인 안 된 것" 전부 — canExecute 게이트 3케이스(a/b/c), value 쪽 복사 gcconn만으로의 판정, Instance userdata 동일성, 그리고 손 안 댄 Part B/C.