quad/.claude/base/onchange-plan.md
qwreey 19cd046275
design: 손 트레이싱 6라운드 전량 처리·반영 — H-1~H-54 확정, M2/M3 설계 게이트 해소
사용자 요청("같이 하나하나 처리해나가보자. 질문 모드로 계속 물어보며")으로
발견 보고 `H-1`~`H-54`를 문항지로 만들지 않고 **갈래 선택이 필요한 것만 급한
순서로** 물어 전량 결정하고 `base/` 24개 문서에 반영했다. 결정과 근거는
`qa-request/pre-implementation-handtrace-round6-followup.md`가 소스이고,
진행 경위와 사용자 발언 원문은
`session/2026-08-24-01-handtrace-round6-resolution.md`.

**M2/M3를 막던 것이 전부 닫혔다** — 말단 핸들러 4종의 `setLength`/
`setOffsetSource` 미등록(`H-39`), `New(): Quad`가 닫힌 타입이라 `quad.Dispatch`가
타입에러인 것(`H-25`), `Effect`의 leaf 사망 cleanup 배선 부재(`H-11`),
`:List`의 좌표계 결함 둘(`H-1`/`H-2`).

## 구조가 바뀐 것 넷

- **`slot._elemIndex`**(물리 요소→인덱스 역방향 맵) 신설로 `indexOfRaw`가 O(1)
  기본 경로가 되고, `:List`의 `keyIndex`는 **단순 키 집합(`prevKeys`)**으로
  강등. 사용자 역제안 — *"raw* 가 층위를 알아야할 이유를 모르겠는 상태 …
  realElem->index 해시맵을 만들어주고, index 밀고 당기는 동작에서 이걸 같이
  업데이트해주는 편이"*. 맵을 `_elements`와 같은 층에 두니 층 분리가 오히려
  깨끗해졌다
- **`_mounted`가 "물리 인스턴스 유무"만 뜻하게 좁혀지고 `slot._physicalTarget`
  신설** — 상태가 셋이 됐다(미실체화/실체화/마운트). `raw*`는 부기를 실체화
  시점부터 항상 하고 `native*`만 가른다. 그래야 최초 population 중
  `getOffsetAt`이 성립해 `updateFn`의 `index`를 거기서 뽑을 수 있다
- **`Ref.Callbacks`가 해시맵 셋 + `:Uncallback`**(사용자 발견) — 해제가 O(1)이
  되고 `ref-plan.md`의 `#t` border 실측 항목이 폐기됐다
- **`blocker:Policy(emit)` 노출** — `Debounce`/`Throttle`이 emit을 안 쥐고 자기
  Blocker를 On/Off만 하는 정책이 된다. `Gate`엔 `Flush`/`Cancel`을 안 둔다

요소 타입 검증은 블랙리스트에서 **주입 술어 `isInst` 기반 화이트리스트**로
뒤집혔고(`H-40`), 주입 op이 둘 늘었다(`isInst`/`onDestroying` — 조합 폴백이
불가능해 미주입이면 에러).

## 사용자가 에이전트 갈래를 뒤집은 자리가 여럿

`H-1`(세 갈래가 전부 차선), `H-2`(*"부기 확정에서 length 를 확정해도 되는거
아님?"* — 부기와 물리 마운트를 분리하라는 되물음), `H-40`(브랜드 판정이
2026-08-21 인스턴스 브랜드 재작성 이후 성립 불가임을 지적), `H-33`(제 중첩
합성안이 unblock 시 디바운스 창을 새로 시작시켜 창이 안 끝난다는 지적).
`Ref.Callbacks` 해시맵화와 `Ref` 콜백의 `canExecute` 확인은 사용자가 먼저 발견.

## 반영 후 재검토 — `/code-review high` 7건 + 감사 9라운드 34건

**`/code-review high` 7건 중 셋이 이번 반영이 만든 회귀**였다 — 상태가 셋이
됐다고 산문에 쓰고 코드엔 경계 하나만 남겨 `Slot { frameA }` 생성자가
크래시하던 것, `native*`를 `_mounted`로 가리면서 그게 곧 파괴였다는 걸 놓쳐
영구 누수를 만든 것, "`:List`와 CRUD는 상호배타"라며 승인받은 분기가
**재마운트 경로를 안 봐서** 포탈을 깬 것. 셋 다 코퍼스 정합성 각도로는
구조적으로 안 보이는 종류라 *"`/code-review`는 감사자를 대체하지 않는다"*가
실측으로 재확인됐다. 같은 리뷰가 `H-11`의 두 결정이 서로 모순임을 잡아
재결정했다 — *"`EffectHandle`이 자기 `bindLifetime` 직후에 건다"*는 **그 호출부가
실재하지 않았고**, 사용자 판단으로 `bindLifetime`/`unbindLifetime`이 `isEffect`를
보고 직접 처리하는 것으로 바뀌었다.

**`quad-doc-auditor` 감사 루프는 9라운드에서 새 발견 0건으로 수렴**(라운드별
5→7→2→2→3→9→2→4→0, 각도와 목록은 followup의 E절). 한 턴에 하나씩 돌리고
라운드마다 각도를 바꿨다. 가장 많이 잡은 6라운드(9건)는 *"이 체크박스로 코드를
짜면 무엇이 나오는가"*를 물은 라운드였고, 그때 `ROADMAP.md`의 미완료 항목이
대거 stale인 게 드러났다(폐기된 `pos` 공식이 "확정"으로, 접두합 캐시 무효화
계약이 통째로 부재, `bindLifetime`이 "둘만 한다"고 적혀 `H-11`과 직접 모순).

**반복된 실패 패턴은 하나 — "고쳐야 할 자리가 N개인데 일부만 고쳤다"**:
배너를 달고 그 배너가 부정하는 문장을 안 고침(1라운드), `base/` 19개를 바꾸고
`.claude/README.md`를 한 줄도 안 고침(2라운드), 그 README를 고칠 때 11행 중
6행만(7·8라운드). 셋 다 핸드오버 체크리스트가 명시적으로 경고하는 항목이라,
규율이 없어서가 아니라 지켰는지 스스로 확인하지 않아서 생긴 실패다.

## 백로그 하나

`Fallback`/`Traceback` 중 생성된 부분 트리의 회수(`H-26`) — 그 둘이 슈가라
구현 시점에 같이 다룬다. 같이 확인된 것: `dispatch-core-plan.md`가 잔여 부기를
인스턴스 GC가 정리한다고 적은 문장은 **gcconn 불멸성과 양립하지 않는 틀린
안전망 주장**이라 삭제했다.

**M2 착수를 막는 설계 항목은 이제 없다** — 남은 건 `question.md` 2번(M2↔M3
양방향 의존, 마일스톤 순서)뿐이다. `doc-check.py` ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01Jjrec9xAS7TZstMx5gi3cm
2026-08-24 18:54:14 +09:00

8.4 KiB

OnChange 특수 키 — GetPropertyChangedSignal 바인딩

상태: base — 2026-08-10 세션에서 확정. quad-roblox 전용(값 타입/API 레이어 없음, Attribute와 같은 패키지 배치). [2026-08-11 아홉 번째 세션 후속] AttributeKey와 동일한 이름별 weak 캐시로 OnChange(a) == OnChange(a) 동등성도 확정 — 아래 "확정" 절 참고.

문제

이벤트 바인딩은 이미 평범한 문자열 키 + reflection(GetEventsOfClass)으로 확정돼 있음(bind-system-plan.md의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절) — inst[key]가 이미 RBXScriptSignal이라 그냥 Connect하면 됨. GetPropertyChangedSignal(name)은 이 패턴이 그대로 안 통함: 프로퍼티 이름을 인자로 받아 별도 메소드 호출로 시그널을 얻어야 하고, 그 프로퍼티 이름은 이미 "값 세팅" 키 네임스페이스(Frame.Position = x)와 겹침 — 값 타입만으론 "세팅"과 "변경 리스닝"을 구분할 방법이 없어서 별도 마커가 필요함.

확정

  • OnChange(propertyName): OnChangeKey — 프로퍼티 이름을 감싸는 특수 키 팩토리, AttributeKey(name)/Tag(...)와 같은 패턴(AttributeKey는 구 Attribute — 2026-08-11 아홉 번째 세션에 여러 Store를 묶는 그룹 Attribute(...) 프리미티브가 신설되며 이름 충돌 방지로 리네임됨, base/attribute-plan.md 참고). 사용 예: Frame { [OnChange "Position"] = function(v: UDim2) ... end }.
  • 제네릭 타입 파라미터 없음 — OnChange<<T>> 같은 타입 파라미터화는 안 함. 콜백 파라미터 타입은 호출부가 인라인으로 직접 명시 (function(v: UDim2) ... end) — Luau가 그 타입이 실제 프로퍼티 타입과 일치하는지 검증해주지 않음. [근거 교체, 2026-08-18 구현 전 QA — 결론은 그대로] 옛 근거는 "이벤트 바인딩은 콜백 시그니처를 Luau가 검증 못 하는 대가를 받아들인다는 결정과 같은 급" 이었는데, 그 전제가 거짓이다(이벤트는 props 타입의 필드D 생성기가 콜백 타입을 정확히 줄 수 있음 — base/bind-system-plan.md의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절). 진짜 이유는 OnChange(name)이 이름을 인자로 받는 팩토리라 그 경로가 없다는 것 — 필드가 아니므로 생성기가 미리 타입을 찍어둘 자리가 없고, 프로퍼티별로 전량 생성하는 안은 이미 기각돼 있다(아래 항목). 그래서 AttributeKey<<T>>처럼 제네릭으로 맞추려는 시도만 남는데, 그건 호출부가 매번 타입을 두 번 적게 만들 뿐이라 채택 안 함.
  • 기각안 — 프로퍼티별 정적 OnChange.PropertyName 전량 코드 생성: archive/onchange-per-property-codegen-rejected.md 참고. Attribute의 "제네릭 + 자주 쓰는 것만 정적 지름길" 절충과 겉보기엔 비슷해 보이지만 규모가 다른 문제라 기각.
  • 패키지 경계: 전부 quad-robloxHandlers/OnChange.luauOnChange(name) 키 팩토리와 Handler를 같이 둠. [정정, 2026-08-13 열네 번째 세션] 예전엔 "단일 키 AttributeKey.luau와 같은 배치"라고 적었으나 그 AttributeKey는 같은 세션에 quad-base로 옮겨갔음(부기가 엔진 지식을 요구하지 않아서, base/attribute-plan.md "패키지 배치" 절) — OnChange가 quad-roblox에 남는 이유는 그것과 달리 GetPropertyChangedSignal 자체가 로직이라 "한 줄 op 주입"으로 줄어들지 않기 때문 (base/dispatch-core-plan.md "base가 소유하는 핸들러와 주입되는 엔진 op" 절의 분할 기준). GetPropertyChangedSignal 자체가 Roblox 엔진 API라 base에 둘 이유가 없음 — Tag처럼 백엔드 무관한 값/API 레이어가 따로 있는 경우와 다름.
  • process(inst,k,v,index): 먼저 v == nil이면 Connect를 건너뛰고 no-op 클로저를 반환하고, 아니면 inst:GetPropertyChangedSignal(name):Connect( function() v(inst[name]) end)그 Connection을 :Disconnect()하는 클로저를 반환. 일반 Handlers/Event.luau와 같은 결(Connection 관리뿐, 새 메커니즘 없음).
    • [정정, 2026-08-24 6라운드 손 트레이싱 H-27] 그 v == nil 얼리리턴이 빠져 있었다. 이 문서는 스스로 "일반 Handlers/Event.luau와 같은 결"이라 결론냈는데, base/event-plan.md가 확정한 그 "같은 결"의 핵심이 정확히 (k=이벤트키, v=nil)을 받으면 기존 Connection 해제만 하고 새로 Connect하지 않는다이다. 없으면 Frame { [OnChange "Position"] = someState }에서 someState:Set(None)으로 콜백을 끌 때, NoneHandlerNilHandler를 거쳐 이 핸들러가 v == nil로 다시 매치되어(매치가 키 기반이라 nil도 잡고, NilHandlertype(k) == "number" 전용이라 여기 안 걸린다) Connect(function() nil(inst.Position) end)실제로 심긴다. 그 순간엔 아무 일도 안 일어나고, 나중에 inst.Position이 실제로 바뀌면 **attempt to call a nil value**로 터진다 — 즉 "콜백을 끈다"는 동작이 실제로는 "나중에 터질 Connection을 새로 심는" 동작이 된다. base/dispatch-core-plan.md는 같은 종류의 방어를 PropertyHandler에는 이미 명시적으로 요구하고 있다("v == nil이면 셋을 건너뛰는 방어") — OnChange만 빠져 있었다. [정정, 2026-08-13 다섯 번째 세션] 원래는 별도 retract(inst,k,v) 필드가 Disconnect를 담당한다고 적혀 있었으나, Handler 계약이 process 1-메소드로 합쳐지며 그 로직이 반환 클로저로 이동 — connectionprocess의 로컬 변수를 클로저가 upvalue로 그대로 캡처하므로 별도 Relate 저장/재조회가 필요 없음(dispatch-core-plan.md "핸들러 내부 상태 저장" 절).
  • State<function> 지원 — 새 메커니즘 없음. 이미 확정된 "이벤트도 store-bind 가능" 메커니즘(base/event-plan.md)이 OnChange 키에도 그대로 적용됨 — OnChangeHandlerprocess(와 그 반환 클로저)만 구현하면 되고, v가 State/Source면 범용 Dispatch/StoreBind.luau가 알아서 언랩+재귀 재-dispatch해서 process를 다시 호출해줌. OnChange 전용 분기 불필요.
  • OnChange(name)AttributeKey와 같은 이름별 weak 캐시 적용 (2026-08-11 아홉 번째 세션 후속)AttributeKey<<T>>(name)이 이름만으로 캐시되는 것과 정확히 같은 모양(이름 → 키, 다른 가변 정보 없음)이라 같은 기법 그대로 재사용(base/attribute-plan.md "동등성" 절). State<function>이 되더라도 문제 없이 작동하고(캐시는 키 객체 자체의 identity만 다루지, 그 키에 바인딩된 값/콜백과는 무관), OnChange "a" == OnChange "a"가 외부에서 관찰 가능해지는 것도 의도적으로 허용해도 되는 동작 — 문제 없음(사용자 확인). Handlers/OnChange.luauOnChange(name) 팩토리에 AttributeKey와 동일한 캐시 구현.

다른 특수 키와의 대조

소스 값 타입 패키지 경계
이벤트(MouseButton1Click = fn) inst[key]가 이미 Signal 콜백 — [2026-08-18 정정] 타입 검증됨(props 타입의 필드라 D 생성기가 콜백 시그니처를 찍어줌) 판별은 quad-roblox(Handlers/Event.luau), 타입은 D 생성기
AttributeKey(name) 주입된 setAttribute op 값(제네릭 또는 정적 타입 패밀리로 타입 파라미터화) quad-base(키+Handler, 2026-08-13 열네 번째 세션 재배치) / 엔진 op만 백엔드
OnChange(name) GetPropertyChangedSignal(name) 콜백, 타입 미검증(제네릭 없음) quad-roblox(Handlers/OnChange.luau)

OnChange가 Attribute처럼 제네릭화되지 않은 이유는 위 "확정" 절의 정정된 근거대로 이름을 인자로 받는 팩토리라 타입을 미리 찍어둘 필드가 없기 때문 — "콜백을 받는다"는 성질이 이벤트에 가깝다는 분류 자체는 그대로지만, 이벤트 쪽은 필드라서 타입이 나온다는 게 2026-08-18에 확인됐으므로 그 유사성이 근거가 되지는 못한다.