`.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>
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.md의 bindLifetime/canExecute 구현은 두 가지
Roblox 엔진 의존 가정에 기대고 있고, 둘 다 문서만으로는 확정할 수 없어
Studio 실측이 필요했음:
GetPropertyChangedSignal("ClassName")은 절대 발화하지 않는다(엔진이ClassName을 변경 불가능한 프로퍼티로 취급하므로 — 콜백 클로저가gchold를 업밸류로 캡처해 살려두는 용도로만 씀).RBXScriptConnection.Connected는inst: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/에 있음).
확인된 것
ClassNamePropertyChangedSignal 미발화 — 6 epoch(각 GC 사이클) 동안, 그리고Destroy()전후 모두warn이 한 번도 안 뜸. 트리거 조건 1(신호 발화)은 회피 확인.- 연결이 살아있는 동안 콜백 클로저가 캡처한 값이 GC 안 됨 — Test 1/2
둘 다
weak[1]이 6 epoch 내내 살아있음.lifecycle-pattern.md의 "gcconn/gchold는 Instance 생성 시점에 만든다" 주장과 일치. [2026-08-14 세 번째 세션] 이 스크립트가 실제로 업밸류로 캡처한 값이target(=Instance) 자체였다는 점에서, 새 모델이 요구하는 **"클로저가gchold뿐 아니라inst까지 캡처해 userdata 동일성을 고정한다"**는 조치의 전반부도 같이 뒷받침됨 — 다만 "같은 엔진 객체를 다시 얻었을 때 userdata가 동일한가" 자체는 이 스크립트가 확인한 바 없음(아래 미확인 목록). Connection.Connected가Destroy()직후 동기적으로false로 전환 — GC를 기다리지 않고 즉시 확인됨(Test 2).canExecute(value)의 유일한 하드 의존성이 실측으로 확인됨. [2026-08-14 다섯 번째 세션] 재정정 이후 이 항목의 무게가 더 커짐 —canExecute는 이제value쪽 릴레이션에 복사된 gcconn의.Connected하나만 보고 leaf 경로 생존을 판정하므로(inst를 조회하는 경로가 아예 없음), 이 전환이 즉발이 아니면 죽은inst에 처리를 시도하는 것을 막을 방법이 없음.- 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(...) end—canBound참 = "지금 묶어도 됨",canExecute는 emit 게이팅 전용으로 분리되며 둘은 서로의 부정 —lifecycle-pattern.md"canBoundvscanExecute" 절), 검증해야 할 명제는 안 바뀜: (a) 살아있는 바인딩을 가진 값을 다시bindLifetime하면 error, (b)unbindLifetime(value)후에는 통과, (c)inst가 Destroy된 뒤에도 통과(모델이 명시적으로 허용). 전부 미해소이고, 공식10파일은 재작성 후에야 이걸 확인할 수 있음(현재luau-test/rewrite-required/). bindLifetime이 복사해둔 gcconn만으로 판정이 성립하는가 — [2026-08-14 다섯 번째 세션 신규]canBound/canExecute가inst를 안 받고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.