사용자 제기 — doc-check.py가 정규식으로 결정론적 판정을 하는데 표기가 흔들리면 문제가 커지니, 정규식을 늘리기보다 "예상 가능 범위"를 컨벤션으로 좁히는 게 싸지 않냐. 실측해보니 날짜 표기는 이미 100% 균일해서 고칠 게 없었고(강제 장치 없이), 드리프트는 절 인용 쪽이었다 — WARN 86건 중 78건(91%)이 절 참조 불일치. 핵심은 그 78건이 코퍼스가 지저분한 게 아니라 **검사기가 못 읽는 것**이었다는 점이다. 이 코퍼스는 `**볼드**` 줄을 하위 절로 쓰는데 headings()가 `#`만 봤다. ## 동작 변화 (문서 정정으로만 보이지만 게이트가 바뀐다) - 절 참조 불일치가 **WARN → ERROR**. 이제 절 인용 오류가 커밋을 막는다. - 절 인식이 `#` 헤딩 + `**볼드**` 절로 확장. 단 볼드는 **빈 줄 다음이나 리스트 항목 머리**만 인정 — 문단이 줄바꿈되며 우연히 줄머리에 걸린 강조를 절로 오인하던 걸 커밋 전 감사가 잡아 조였다. - 인용 길이 상한 60→160자. 60자를 넘으면 매칭 자체가 안 걸려 검사에서 **조용히** 빠져나갔음(위양성보다 나쁜 구멍). - 비교를 공백 무시로(줄바꿈 인용 대응), 선두 장식·상태/날짜 태그 정규화, `initreq/` 대상 인용은 절 검사 면제(읽기 전용 외부 원본). ## 규약 `conventions.md`에 "문서 표기 규약" 절 신설 — 절 인용 규약(의역 금지, 헤딩은 부분문자열/볼드는 앞부분일치, 태그로 닫히는 볼드 캐비엇, blockquote 함정), 세션은 산문 서수 말고 파일 ID로 지칭. 날짜 마커 라벨 어휘 닫기는 사용자 판단 으로 기각(기계 검사 대상이 아니라 읽는 쪽 판단 재료). ## 결과 절 참조 불일치 78 → 0. 36건은 검사기 수정으로 사라졌고(애초에 위양성), 42건은 인용을 실제 절 제목으로 손으로 고쳤다. 마지막 15건은 서브에이전트 3개에 병렬 위임해 추적 — **설계 서술이 유실된 건은 0건**, 대부분 코드 주석·본문 산문· 주제명처럼 애초에 절이 아닌 걸 절로 인용해온 것이었다. 부수로 드러나 같이 고친 것: onchange-plan이 9차 분할 때 일부러 안 옮긴 절을 잘못된 파일로 가리키던 것, brand-plan이 이미 이행된 정정을 "정정 대상"이라 부르던 것, ROADMAP의 blockquote가 인용 줄바꿈 때문에 깨져 있던 것, pre-implementation-audit의 해소된 항목이 "아직 안 고침" 절에 남아 있던 것 (사용자 결정으로 "이미 고침"으로 이동). 커밋 전 감사 4라운드(에이전트 8개)를 돌렸고, 발견 추이는 2→2→1→0이다. 매 라운드 발견이 "직전 라운드 수정이 만든 새 결함"이었던 게 특징 — 규약을 세우는 커밋이 그 규약의 첫 위반자가 된다는 걸 실측으로 확인했다. 상세는 .claude/session/2026-08-16-03-doc-check-section-convention.md. 부수: __pycache__를 .gitignore에 추가하고 추적 해제(32e9db0에 실수로 딸려 들어가 있었음). quad-doc-auditor에 작업 트리를 바꾸는 git 명령 금지 규약 추가 — 감사자가 git stash를 걸어 메인 세션 스테이지가 반복적으로 풀렸다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011zk7XHkSfiBfdPLZUQHdZf
8.1 KiB
이벤트 바인딩 — self 미전달, false로 disconnect
[2026-08-13 아홉 번째 세션]
bind-system-plan.md에서 분리됨. 사용자가 "이벤트 연결은 다른 base 문서가 되어야 할 듯"이라고 직접 지목한 부분. 내용은 옮기기만 했고 결정은 하나도 안 바뀜.
상태: base — 확정.
여기 없는 이벤트 관련 결정 하나: 이벤트 네이밍 인체공학
(On.EventName 도트액세스를 안 쓰고 평범한 문자열 키 + reflection을
쓰기로 한 것)은 인스턴스 생성 관례와 한 절에 섞여 있어
base/bind-system-plan.md "인스턴스 생성 / 이벤트 네이밍 인체공학"
절에 그대로 뒀음 — 쪼개면 인스턴스 생성 쪽 서술이 반토막 나서.
GetPropertyChangedSignal 바인딩은 별도 문서 base/onchange-plan.md.
이벤트 핸들러는 self(Instance)를 받지 않는다 — 확정 (2026-08-06)
결정: v1의 function(self, ...) 관습(self/this로 이벤트 대상
Instance를 넘겨주는 것, .claude/reference/quad-v1-architecture.md 참고 —
실제로 event.lua의 Bind가 func(self or this, ...)로 넘겨줌)은
채택하지 않는다. quad-roblox의 이벤트 핸들러는 엔진이 네이티브로
주는 이벤트 인자만 받는다(React의 onXxx가 DOM 노드가 아니라
SyntheticEvent만 주는 것과 같은 모양).
근거:
- Ref가 이미 이 자리를 채움. "생성 직후/마운트 후 ref 채우기"가 되는 순간 Instance 접근이 필요하면 클로저 캡처로 해결됨(위 Ref 절) — self는 그와 중복되는 두 번째 채널일 뿐이고, 두 채널이 있으면 "어느 쪽이 authoritative냐"는 질문이 항상 따라붙음.
- thin wrapper를 제공하면 엔지니어링 구조 자체가 바뀜. self로 얻는
값이 mutable한 재바인드 가능 wrapper라면, 그건 Modifier의 정적
flatten(
base/modifier-plan.md)과 항상 경쟁하는 두 번째 쓰기 경로가 생긴다는 뜻 — flatten된 뒤엔 wrapper 쪽에서 "이전 modifier가 뭐였는지" 재구성할 방법이 없음. Modifier 핸들러가 KV 매치 기반이라는 걸 감안하면, wrapper 값을 처리하려면 핸들러가 "이게 flatten된 정적 값이냐, 아니면 언제든 바뀔 수 있는 wrapper냐"를 매번 분기해야 함 — 오버엔지니어링이고 hot path(매process호출)에 분기 비용이 붙음. 반대로 raw Instance를 그대로 주는 선택지도 있지만, 그러면 quad가 스스로 지양하는 "quad가 모르는 직접 mutate 경로"를 공식 API로 만들어주는 셈이라 (3)과 충돌. - 디버깅 관점에서 더 결정적. quad-debug의 가치 제안이 "무엇이
무엇에 연결됐는가"를 선언된 반응형 그래프로 추적하는 것인데
(
research/debug-tooling-plan.md), self로 얻은 Instance를 이벤트 핸들러 안에서 직접 mutate하는 경로는 그 그래프 밖 —base/ purity-and-effects-plan.md의 "결론: 입력받은 store만 처리하는 함수가 좋은 컴포넌트" 절이 세운 이식성 원칙과도 같은 결. - 성능/GC: self를 넘겨주려면 원본 콜백을 클로저로 한 번 더 감싸야
함(
event:Connect(function(...) func(self, ...) end)) — Connect마다 불필요한 클로저 할당 비용이 들고, 최적화에도 GC 흐름에도 좋을 게 없음. self가 없으면 사용자가 준 함수를 그대로:Connect에 넘기면 충분함. quad는 어차피 라이프사이클 끝까지 바인딩을 들고 있으므로 (base/lifecycle-pattern.md, rbvm 선례 — GC-native), Destroy되면 해당 Connection도 자연히 같이 정리됨 — 별도 Disconnect 관리가 애초에 불필요. [정정, 2026-08-06 후속 세션] 동적으로 Connect/Disconnect를 반복하고 싶은 케이스는 Ref로 수동 처리하는 대신 store-bind로 네이티브 지원하기로 확정 — 아래 "이벤트도 store-bind 가능 —false로 disconnect" 절 참고. 엔지니어링 비용이 예상보다 훨씬 낮다는 게 나중에 확인됨(기존 store-bind 재실행 래핑을 그대로 재사용, 새 디스패치 메커니즘 불필요).
일반화: 이 논거의 핵심은 Roblox에 국한되지 않는 원칙으로 정리됨 — "엔진이 네이티브로 콜백에 뭘 주든, quad는 그걸 감싸지 않고 그대로 호출해줘도 무방하다"는 것. 다만 이벤트 등록 자체가 quad-roblox에만 있는 개념이라(다른 백엔드는 이벤트 모델이 다를 수 있음) 이건 base 문서가 아니라 quad-roblox 로컬 결정 — 다른 백엔드 구현체를 만들 때 참고할 만한 템플릿 정도로만 취급.
이벤트도 store-bind 가능 — false로 disconnect (2026-08-06 후속 세션)
결정: 이벤트 핸들러 값으로 State를 넘기는 것(reactive하게 콜백을 바꿔치기/해제하는 것)을 지원한다. quad-roblox 로컬 결정, base 변경 없음.
엔지니어링 비용이 낮은 이유: 이미 확정된 "Store 바인드는 pluggable
바인드를 재실행하는 래핑"(위 절, 핸들러의 process가 값이 바뀔 때마다
Dispatch.process(inst,k,realv)를 재귀 호출) + "재실행 래핑이 retract도
같이 호출한다"(Slot이 이미 이 조합을 씀, 같은 절)는 두 메커니즘이 이미 있음.
이벤트 핸들러가 할 일은 딱 하나: process에서 :Connect()한 Connection을
process의 로컬 변수로 들고, 반환하는 retract 클로저가 그걸 upvalue로
캡처해 :Disconnect()하는 것 — 새 디스패치 메커니즘 발명 필요 없이 기존
계약(isHandlable/priority/process)만 제대로 구현하면 됨([2026-08-13
다섯 번째 세션] 예전엔 별도 retract 필드 + per-instance Relate
저장소였으나, 클로저 캡처로 저장소 자체가 불필요해짐).
false로 disconnect, nil 아님. nil은 Lua 테이블에서 "키가 아예
없음"과 구별이 안 됨(pairs에서도 안 보임) — "명시적으로 꺼짐"이라는
신호를 값으로 전달하기엔 부적합. 대신 false(Luau에서 실재하는 싱글톤
타입)를 "연결 없음" 센티널로 씀: process(inst,k,false)가 들어오면
retract가 하던 일(기존 Connection 해제)만 하고 새로 Connect 안 함.
이벤트인지 여부는 값이 아니라 키(리플렉션으로 판별)로 결정되므로, 다른
boolean 프로퍼티 핸들러와 (k, false) 매칭이 겹칠 위험 없음.
quad가 미는 기본 패턴은 아님 — 부차적 옵션. 저빈도 UI 이벤트(클릭류)를 조건부로 켜고 끄고 싶은 흔한 케이스는 사실 이 메커니즘 없이도 됨 — 핸들러 하나를 계속 연결해두고 안에서 분기하면 끝:
MouseButton1Click = function()
if not store.enabled:Get() then return end
...
end
이 "핸들러 하나 + 내부 분기" 패턴이 Connect/Disconnect 자체가 없어서 더
싸고, Roblox/React 어디서든 이미 익숙한 관용구라 기본 권장 패턴.
store-bind 방식이 실제로 값어치 있는 지점은 고빈도 신호(Heartbeat/
RenderStepped/마우스 무브처럼 안 쓸 때 Connection을 살려두는 것 자체가
낭비인 경우)나, 단순 on/off가 아니라 로직 자체가 바뀌는 드문 케이스.
자주 재계산되는 State에 이벤트를 직접 물리면 매 재계산마다 Disconnect+
Connect가 도는 숨은 churn 비용도 있음(Store Set은 dedup 안 함,
base/store-plan.md) — 그래서 남용하지 말라는 캐비엇.
그래도 일관성 있게 지원은 해둠. "저빈도엔 필요 없다"가 "그러니 예외로
빼고 못 하게 막자"로 이어질 이유는 없음 — 프로퍼티/태그/어트리뷰트가
전부 store-bind되는데 이벤트만 특별 취급해서 뺄 근거가 약하고, 구현
비용도 낮으니(위 "엔지니어링 비용이 낮은 이유" 참고) 일관되게 지원해두는
쪽을 택함. 그냥 "이런 것도 가능하다" 정도로 존재하고, quad가 이 패턴을
적극 권장하진 않는다는 톤으로 문서화(research/documentation-plan.md
3번 "권장 이벤트 핸들링 패턴" 문서에 이 대조까지 반영 예정).