canExecute(inst,value) 2-인자 시그니처를 폐기하고 canExecute(value)로 정정. 2-인자는 증상이었고 원인은 2026-08-08 세션이 .Subscribed에 "leaf 바인딩 생존"이라는 두 번째 의미를 겹쳐 얹은 것 — .Subscribed는 전역 :Subscribe() 전용 필드이고 bindLifetime과 무관함. bindLifetime이 바인딩 시점에 inst의 gcconn 참조를 value 쪽 Relate로 복사해두면 생존을 value 하나로 물을 수 있음. - canBound(handle) 폐기 → canExecute(value)로 통합(이중 바인딩 게이트 겸함) - unbindLifetime도 1-인자로 축소 — 호출부가 _mountedInst를 되짚을 필요 없어져 "홀더가 갈아치워지면 해제가 빗나가는" 잠재 버그 클래스 소멸(slot-plan 5곳) - gcconn/gchold를 lazy 생성에서 Instance 생성 시점으로 전환, 클로저가 inst까지 캡처 — Instance userdata 포인터 동일성은 inst-키 Relate 전체의 전제였음 (relate-plan에 "전제" 절 + "안전히 유지되면 항상 SetWeak" 일반 규칙 신설) - canExecute의 실제 호출부를 State 전파 루프로 명시(구독자 weak + 발화마다 게이팅) — 이게 코드로 한 번도 안 적힌 게 오류가 여섯 세션 살아남은 이유 역전 원문/오염 경로/교훈은 archive/canexecute-inst-arg-reversed.md. luau-test/10은 폐기된 모델을 검증 중이라 rewrite-required/로 이동. 부수: 3~4차 세션이 남겨둔 CLAUDE.md 세션 히스토리 항목과 4차 세션 로그 파일도 미커밋 상태여서 같이 실림. doc-check ERROR 1건(CLAUDE.md:1156 → session/2026-08-14-03-lifecycle-hooks-plan.md)은 그 파일이 디스크 어디에도 없어서 남음 — 3차 세션 쪽에서 채워야 함. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 KiB
2026-08-14 다섯 번째 세션 — canExecute(value) 1-인자로 정정, Subscribed 오염 제거, gcconn을 Instance 생성 시점으로
발단: 사용자가 base/lifecycle-pattern.md를 읽다가 한 줄에서 멈춤 —
"canExecute 가 왜 inst 를 받음? 이상하네. observer 등의 execute 가능 유무는
이미 바운딩된 inst 가 존재해야하는거 아녔음? 에이전트가 그냥 잘못 말했나."
1라운드 — 문서가 뭐라고 적혀 있었나
읽기만 요청받아 확인한 결과:
base/lifecycle-pattern.md는canExecute(inst, value)2-인자를 확정으로 적어두고 있었고, "2026-08-08 세션 재정정"이라는 배너까지 달려 있었음. 근거는 "Observer 자신의 바인딩 생존(Subscribed)과inst자체 생존(gcconn)은 독립적인 두 조건이라 하나의 opaque handle로 뭉치면 구별 못 함".- 그런데
canExecute의 실제 호출부가 코퍼스 어디에도 코드로 없었음.dispatch-core-plan.md의StoreBind.process의사코드에도 없고,bind-system-plan.md의state:Observer(fn)절은 "발화 시 canExecute로 게이팅됨"이라고 서술만 함.dispatch-core-plan.md는 아예 "핸들러가 직접 canExecute를 재구현할 필요 없음 — Observer가 이미 자기Subscribed상태로 게이팅됨"이라고 적어 호출부를 없는 것처럼 만들어놨음. - 그리고
bindLifetime은value에inst참조를 아무것도 안 남기고value.Subscribed = true만 세팅하고 있었음 → 문서가 주장하는 "owning leaf가 죽으면 no-op"이value만 가지고는 성립 불가능한 구조.
이 지점까지 정리해 보고하고, (a) 호출부 누락된 문서 갭이거나 (b) 원래 1-인자였는데 어디선가 오염됐거나 둘 중 하나라고 추정.
2라운드 — 사용자가 정확한 모델을 직접 서술
"또 에이전트가 실수한듯. Observer 의 바인딩은 Subscribed=true 안 함. 정확히 Subscribed 는 글로벌 사이드에서 사용하기 위함이라고 명시를 내가 여러번 했음. 다시한번 말하지만, Subscribed 는 bindLifetime 과 일절 이해관계가 엮이지가 않아."
사용자가 못박은 bindLifetime의 계약 2줄:
- Observer는 bind 계약이 유효한 중에는
inst만큼은 살 것이 보장된다. - Observer는
inst가 살아있는지 확인할 수 있는 방법이 제공된다.
2번이 핵심 — "확인할 방법을 value가 제공받는다"는 게 canExecute가
inst 없이 성립하는 이유이고, 옛 설계는 이 절반을 통째로 빠뜨린 채
inst를 인자로 받아 때우고 있었던 것.
사용자가 제시한 Roblox 구현 (원문 요지)
inst -> gchold가 먼저 존재한다. Instance는 엔진 객체가 아니라 엔진
객체를 가리키는 포인터(userdata)라, Lua가 참조를 안 들면 지워지고 나중에
.Parent 등으로 다시 얻으면 다른 포인터가 나올 수 있다. 따라서:
local nop = false or function(...) end -- local이라 인라인 안 됨
local gchold = {nil}
local gcconn = inst:GetPropertyChangedSignal("ClassName"):Connect(function()
nop(gchold, inst)
end)
gchold[1] = gcconn
"이럼, inst 의 Destroy 이전 까지 inst 의 userdata 포인터는 유일하고, gchold 도 생명주기 상 유지됨. gcconn 도 유지돼. 이게 우리의 Instance 생성 시 바로 할 일이 돼."
그리고 InstData:SetWeak(inst, "gchold"/"gcconn", ...) — "이미 gchold 는
안전히 유지된다는 점. 안전히 유지된다면 항상 weak 를 써, 안 그럼
gc 에선 예상하기 힘들어지는 버그가 발생하기 쉬움."
bindLifetime(inst, v)은 그 위에서:
local gchold = InstData:GetWeak(inst, "gchold")
gchold[v] = true -- 계약 1 (해시 멤버십 — 제거를 O(1)로)
BindData:SetWeak(v, "gchold", gchold)
BindData:SetWeak(v, "gcconn", InstData:GetWeak(inst, "gcconn")) -- 계약 2
"여기서 생각해볼 점은. gcconn 도 자동으로 제거된다는 점임. gcconn 의
클로저가 죽으면 gchold 가 죽고 gcconn 을 강참조 하는건 없으니까" — 즉
inst가 Destroy되면 weak 항목이 스스로 비워져 canExecute가 자연히
거짓이 됨(그 전 구간은 .Connected == false가 커버).
unbindLifetime은 그 역이고 inst를 안 받음. canExecute도 안 받음:
local gcconn = BindData:GetWeak(v, "gcconn")
if gcconn ~= nil and gcconn.Connected then return true end
return v.Subscribed == true -- 글로벌 등록 경로
마지막으로 호출부: "이 이후, state 전파가 이걸 실행할지 말지를 canExecute 로 담당하도록 emit 이 등록되는거임. 클로저로써, state 안에 weak 로 들어가있지." — 1라운드에서 "코드로 한 번도 안 나온다"고 지적한 바로 그 자리가 여기서 확정됨.
또 "Observer 에 콜백이 등록 되자 마자 바로 호출되는건 observer 자체 원래
그런거고 이거랑 연관 없음" — 최초 1회 실행은 게이팅 대상이 아님(기존
slot-plan.md 주석과 일치).
3라운드 — 이중 바인딩 게이트, canBound 폐기
사용자가 이어서: ObserverHandler(leaf)와 Subscribe 둘 다 먼저
canExecute를 본다. 참이면 "이미 사용중인 옵저버"라 에러 —
"좀더 명확하게, Subscribed 필드를 읽어서 글로벌인지, 리프인지 알려줘."
Subscribe는 .Subscribed = true + 전역 하드테이블 [v]=true,
Unsubscribe는 그 역.
여기서 canBound(handle) 폐기가 따라나옴 — "아직 안 묶였는가"와
"지금 실행 가능한가"가 같은 질문이고, canBound의 내부 근거로 지목돼
있던 .Subscribed 겸용이 애초에 오염이었으므로 정의 자체가 성립 안 함.
부수 효과: 죽은 바인딩의 재사용은 허용(Destroy됐거나 unbind된 값은
게이트를 통과해 다른 inst에 다시 걸림). 막는 건 살아있는 이중 바인딩뿐.
확인 질문 2개 (사용자 스니펫의 오타)
bindLifetime안의BindData:GetWeak(inst, "gchold")—InstData에서 읽어야 맞지 않나? → "내 실수임. InstData 에서 가져오고 BindData에 넣는거 맞음"unbindLifetime의...[1] = nil— 넣을 땐 해시(gchold[v]=true)였는데 지울 때 배열 인덱스? → "도 실수 맞음. 해시로 지우면 돼"
오염 경로 추적 (archive에 보존)
- 2026-08-07 (더 오래된 초안):
bind-system-plan.md의:Subscribe()절에 이미 올바른 1-인자 모양이 있었음 —if self.Subscribed then return true end/if self.Connection then return self.Connection.Connected end. 즉 "값 자신이 자기 커넥션을 들고 있다"가 원래 그림이었음. - 2026-08-08 (다섯 번째 세션): "재정정"이라는 이름으로
(inst, value)2-인자가 들어옴..Subscribed에 "leaf 바인딩 생존"이라는 두 번째 의미를 겹쳐 얹은 게 원인 — 그 순간 leaf 경로 생존을value에게 물을 방법이 사라져서inst조회가 유일한 경로가 됨. 2-인자는 증상이지 원인이 아니었음. - 2026-08-09 (여섯 번째 세션): 그 위에
canBound를 세우고, *"이 내부 플래그는canExecute가 이미 보는.Subscribed필드 그 자체"*라고 명문화하며 오염을 고착.
왜 여섯 세션을 살아남았나 — 호출부가 한 번도 의사코드로 안 적혔기
때문. 진짜 호출부(State 전파 루프)엔 inst가 없어서 2-인자 시그니처는
거기서 호출 자체가 불가능했는데, 아무도 그 코드를 써보지 않았음.
doc-check.py가 잡을 수 있는 종류가 아님(문서 참조는 전부 정상이었음).
일반 교훈: 계약(시그니처)을 정할 때 호출부를 최소 하나는 의사코드로 같이 적어둘 것. "어디선가 게이팅됨"은 검증이 안 되는 문장.
부수 확정 — gcconn을 Instance 생성 시점으로 올린 것의 의미
사용자의 userdata 포인터 논거가 bindLifetime을 넘어 inst를 키로 쓰는
모든 Relate의 전제임을 확인 — userdata가 회수되고 재생성되면
elementOwner/nameClaims/Tag 참조카운트 항목이 전부 조용히 미아가 됨
(크래시가 아니라 "부기가 없던 일이 되는" 오작동). 그래서 gcconn 셋업은
바인딩 유무와 무관하게 Instance 생성 시 무조건 실행되어야 하고, 이걸
base/relate-plan.md에 "전제" 절로 신설.
대가: 클로저가 inst를 캡처하므로 quad가 만든 Instance는 참조를 놓는
것만으로는 회수 안 되고 Destroy가 유일한 절단면이 됨. 다만 실질적으로
새 제약은 아님 — 실제 바인딩이 하나라도 걸리면 그 Observer 클로저가
어차피 inst를 캡처해 같은 순환이 생기므로(예: StoreBind.process),
"아무것도 안 걸린 Instance"까지 같은 규칙으로 통일한 것뿐. 이 판단을
lifecycle-pattern.md에 명시적으로 남김.
또 사용자의 *"안전히 유지된다면 항상 weak 를 써"*를 relate-plan.md에
일반 규칙으로 승격 — 근거는 성능이 아니라 디버깅 가능성(강참조가 둘이면
"이 값의 수명이 어디서 끝나는가"의 답이 둘이 되어 한쪽만 지웠을 때 조용한
누수가 남음).
반영 결과
base/lifecycle-pattern.md— 시그니처/구현 전면 재작성, (0) Instance 생성 시점 셋업 / (1) bind·unbind·canExecute / (2) 전역 경로 / (3)canBound폐기 / (4) 실제 호출부 5개 절로 재구성.archive/canexecute-inst-arg-reversed.md신설 — 역전 원문 + 오염 경로 + "호출부를 안 적어서 못 잡았다"는 교훈.base/bind-system-plan.md—canBound절 역전,:Subscribe()절 스케치 정정,state:Observer(fn)절에 실제 호출부 명시화.base/dispatch-core-plan.md—unbindLifetime1-인자 2곳, "Observer가 자기Subscribed로 게이팅" 근거 정정.base/relate-plan.md— "전제 —inst키의 동일성은 공짜가 아니다", "다른 곳에서 안전하게 유지되는 것은 항상SetWeak" 두 절 신설.base/effect-plan.md/base/slot-plan.md(호출부 5곳 + 주석 2곳) /base/architecture.md/base/store-semantics.md/research/pre-implementation-audit.md— 시그니처·근거 정정.luau-test(10을rewrite-required/로) /audit/gcconn-trick-verification.md/ROADMAP.md/question.md/.claude/README.md/CLAUDE.md— 동기화.
새로 연 설계 질문 없음. question.md의 canExecute 이름 3순위 항목은
이름 논의라 그대로 열려 있음(시그니처는 이번에 확정).