diff --git a/.claude/README.md b/.claude/README.md index 1e058f9..a768b8d 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -30,7 +30,7 @@ | `architecture.md` | quad-v2 전체 아키텍처 확정 사항 요약(제일 먼저 볼 문서) | | `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식 | | `store-semantics.md` | Store는 부작용 허용이 기본. State는 Store 위의 조합 가능한 캐시 레이어로 실제로 필요함(2026-08-04 정정) — 온톨로지 핵심 메커니즘은 2026-08-04 2차 라운드에서 확정, 최신 상세는 `base/bind-system-plan.md` | -| `bind-system-plan.md` | pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드). **[2026-08-11 세션, 여섯 번째]** `Dispatch.setLength`/`setOffsetSource`의 owner 키가 물리 Instance로 한정될 필요 없음을 명시(Slot-in-Slot 재귀의 근거) — 같은 절 `recompute`의 off-by-one 버그 발견·수정(`offset`이 자기 자신을 포함해 누적되던 것), 재진입 방지 가드는 검토 후 기각(`Source⊇State` 단방향 원칙과 같은 카테고리의 UB로 명명, 각 Slot이 독립 `bk`를 가져 nesting만으로는 재진입 경로 자체가 없음을 확인) | +| `bind-system-plan.md` | pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드). **[2026-08-11 세션, 여섯 번째]** `Dispatch.setLength`/`setOffsetSource`의 owner 키가 물리 Instance로 한정될 필요 없음을 명시(Slot-in-Slot 재귀의 근거) — 같은 절 `recompute`의 off-by-one 버그 발견·수정(`offset`이 자기 자신을 포함해 누적되던 것), 재진입 방지 가드는 검토 후 기각(`Source⊇State` 단방향 원칙과 같은 카테고리의 UB로 명명, 각 Slot이 독립 `bk`를 가져 nesting만으로는 재진입 경로 자체가 없음을 확인). **[2026-08-12 열한 번째 세션, 전면 정정]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff"는 틀렸음 — `retract`는 store 재발행마다(핸들러 타입 무관) 항상 불림, `v`는 대체 값 자체일 수 있어 `nil`로 가정 금지. `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류로 설계돼 있었음이 드러나 한 세션에 전부 정정(`archive/retract-always-fires-reversed.md`) | | `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정 | | `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설. **[2026-08-11 세션]** `updateFn(item, index: number, offset: Source, prev: T?, userdata: UD?): (T|nil, UD?)`로 시그니처 확정(`Slot.Offset`도 `Length`처럼 공개 필드로 신설) — `LayoutOrder` 등은 Slot이 자동으로 안 세팅, `index`/`offset` raw 값만 전달하고 실제 반영은 `updateFn`이 "버림/다시 그림/source만 갱신" 세 갈래로 직접 처리(재사용 Source에 미리 `Set` 후 결국 다시 그리면 무의미한 연산이 되므로). **[2026-08-11 세션, 여섯 번째]** `Slot:Single(state, updateFn)` 확정(`:List` 위의 순수 sugar) — Slot-in-Slot 중첩도 확정, 요소 타입 제약에서 `Slot` 배제 해제(`T = Instance | Slot`), `Dispatch.setLength`/`setOffsetSource`를 Slot 자신을 owner 키로 재사용하는 재귀 `attachSlot`(새 프리미티브 없음), 파괴는 재귀 `Clear()` 대신 flat `destroySlotTree`+명시적 `unbindLifetime`. `Slot(initial?: {T})` 생성자 부활(순수 `:Add` sugar) + `_crudUsed`↔`_listed` 상호 배타 가드 신설. `base/bind-system-plan.md`의 `recompute` off-by-one 버그도 이 세션에 같이 수정됨. **[2026-08-11 세션, 일곱 번째]** 반응형 raw 요소(`Slot:Add`가 `State`/`Source`도 받음) 확정 — 새 메커니즘 아니라 `isState(element)`면 내부적으로 `Slot():Single(element)`(nested Slot)를 대신 삽입하는 순수 sugar(최초 검토했던 별도 position-keyed StoreBind 구독 안은 `None`/Length/Move-Swap 문제로 기각). `Slot:Single(state, updateFn?)`도 `updateFn` 선택 인자화(기본값 identity)로 이 sugar를 지지. `:List`의 `reconcile`도 nested-Slot을 반환하는 아이템의 `.Length`만큼 다음 형제 `index`가 건너뛰도록 `pos` 커밋 공식 수정 | | `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정. **[2026-08-07 다섯 번째 세션 추가]** `:Apply(factory)` 팩토리 체이닝, `Overridden`(구 `Merge`→`Override`, 2026-08-08 세션에서 이름까지 확정) 값 결합+성능 기준, `:Peek`/`isState` 필드 읽기까지 전부 확정(`Peek`/`isState`는 이름만 용어 정리 라운드까지 잠정) | @@ -39,8 +39,8 @@ | `blocker-plan.md` | **[2026-08-07 신설]** `Blocker` — 여러 Source를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게, State 마일스톤(M3)과 함께 개발. 메커니즘+이름 확정 | | `effect-plan.md` | **[2026-08-07 신설, 여섯 번째 세션에 확정]** `Effect(fn, state?)` — `state` 없으면 설치 1회+leaf 사망 시 확정 정리, 있으면 내부적으로 `state:Observer(...)`를 조합해 재실행+cleanup 체이닝(React `useEffect` 동형). Observer와의 관계 해소 완료 | | `ui-shorthand-plan.md` | **[2026-08-07 `research/`에서 승격]** `UICorner`/`UIPadding`/`UIScale` 인라인 편의 키 — 이름(v1 `Corner`/`PaddingAll`/`Scale`에서 Modifier 필드명과 안 겹치게 `UI` 프리픽스로 확정)·메커니즘(Handler)·패키지 배치(quad-roblox 코어)·store-bind 가능성까지 전부 확정. 이미지 라운드 트릭(`RoundSize`)은 드롭 — `archive/ui-shorthand-roundsize-dropped.md` 참고. `v=nil`이면 `process` 자신이 만든 자식 제거(`retract` 아님) | -| `tag-plan.md` | **[2026-08-08 세 번째 세션 재설계]** `Tag(...)` — array-part 값 객체, `Modifier`와 같은 immutable clone 체이닝(`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`), `CollectionService` 글루만 quad-roblox. 이제 `retract`가 의미 있음(타입이 바뀌면 전체 삭제, 같은 Tag끼리는 `process`가 diff). 구 해시 파트 boolean 모델은 `archive/tag-hash-key-model-reversed.md` | -| `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** 단일 키 `[AttributeKey "Name"]`(구 `Attribute`) — `SetAttribute(name, nil)`이 네이티브 지우기라 `None` 센티널과 가장 깔끔하게 맞아떨어짐, `retract` 불필요. **[2026-08-11 아홉 번째 세션]** 여러 Store를 한 번에 attribute로 묶는 그룹 `Attribute(...)` 프리미티브 신설(`Tag`와 동형 array-part 값 객체, `Merged`로 헤테로지니어스 Store 합성), 이름 충돌 방지로 단일 키를 `AttributeKey`로 리네임(잠정). **[같은 세션 후속]** `AttributeKey(name)`이 이름별 weak 캐시로 동등성 보장하도록 확정되며, 그룹 Handler는 자기 완결형 재구현 대신 메모이즈된 키로 기존 단일 키 경로에 재귀 위임하는 걸로 개정(중복 구현 제거) | +| `tag-plan.md` | **[2026-08-08 세 번째 세션 재설계, 2026-08-12 열한 번째 세션 메커니즘 정정]** `Tag(...)` — array-part 값 객체, `Modifier`와 같은 immutable clone 체이닝(`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`), `CollectionService` 글루만 quad-roblox. `retract`가 이전 Tag가 걸었던 이름을 이름별 참조 카운트 맵에서 빼고(다른 위치가 겹쳐 쓰면 실제 `RemoveTag`는 skip), `process`가 새 Tag의 이름을 등록 — 여러 위치가 같은 이름을 겹쳐 가져도(웹 `className`류 합집합) 안전. 구 해시 파트 boolean 모델은 `archive/tag-hash-key-model-reversed.md`, 구 `assert(v==nil)` 메커니즘은 `archive/retract-always-fires-reversed.md` | +| `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** 단일 키 `[AttributeKey "Name"]`(구 `Attribute`) — `SetAttribute(name, nil)`이 네이티브 지우기라 `None` 센티널과 가장 깔끔하게 맞아떨어짐. **[2026-08-11 아홉 번째 세션]** 여러 Store를 한 번에 attribute로 묶는 그룹 `Attribute(...)` 프리미티브 신설(`Tag`와 동형 array-part 값 객체, `Merged`로 헤테로지니어스 Store 합성), 이름 충돌 방지로 단일 키를 `AttributeKey`로 리네임(잠정). **[같은 세션 후속]** `AttributeKey(name)`이 이름별 weak 캐시로 동등성 보장하도록 확정되며, 그룹 Handler는 자기 완결형 재구현 대신 메모이즈된 키로 기존 단일 키 경로에 재귀 위임하는 걸로 개정(중복 구현 제거). **[2026-08-12 열 번째 세션]** 그룹/직접 쓰기가 같은 이름을 동시에 관리하는 충돌을 막기 위해 그룹은 공개 캐시 대신 `rawNew(name)` 전용 키+소유권 `Relate`로 전환. **[열한 번째 세션]** `retract`가 store 재발행마다 항상 불린다는 정정에 맞춰 `AttributeKeyHandler.retract`에 `v==nil` 가드 추가(더 이상 "retract 불필요" 아님, no-op일 뿐), 그룹의 "남아있는 이름" 위임도 매번 `retractUnder`를 먼저 부르도록 정정(체인 누수 방지) | | `onchange-plan.md` | **[2026-08-10 세션 신설]** `OnChange(name)` — `GetPropertyChangedSignal` 바인딩 전용 DI 키, `Attribute`와 달리 제네릭 타입 파라미터 없음(콜백 타입은 인라인 명시, 이벤트 바인딩과 같은 급 트레이드오프). 전부 quad-roblox(`Handlers/OnChange.luau`), `State`은 기존 이벤트 store-bind 메커니즘 재사용. **[2026-08-11 아홉 번째 세션 후속]** `AttributeKey`와 동일한 이름별 weak 캐시로 `OnChange(a) == OnChange(a)` 동등성 보장 | | `relate-plan.md` | **[2026-08-08 신설]** `Relate` — `inst`를 weak 키로 하는 범용 릴레이션 프리미티브(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`, 비싱글톤 생성자). 구 `base.perInstanceState(inst)` placeholder를 대체·정식 승격, `lifecycle-pattern.md`의 `bindLifetime`/`canExecute`가 그 위에 얹힘 | | `tween-plan.md` | **[2026-08-12 세션, `research/`에서 승격]** 값-레벨 `Tween` 래퍼(PropertyHandler가 소비, 구 특수 bind key 모델은 `archive/tween-special-bind-key-reversed.md`). 3-상태 릴레이션 슬롯(`{Tween,Value}\|true\|nil`), `T'=T\|Tween` 타입 치환. 옵션 값 모양은 `Info: TweenInfo?` 우선+편의 필드 폴백, override는 `Tween.Cancel`(기본)/`Tween.Finish` 2값. `Animate(info)`는 `Tween` opts를 `T\|State`로 받아 `:Apply`로 꽂는 sugar. 자연완료 시 per-instance 북키핑은 정리 안 해도 됨으로 확정(목표값 도달 상태라 부작용 없음, Completed 이벤트 구독 장치는 오버엔지니어링으로 판단). `initValue`는 사용자가 직접 처리(에이전트 범위 제외) | @@ -85,6 +85,7 @@ | `debug-channel-replicatedstorage-rejected.md` | **[기각됨, 2026-08-09 코퍼스 정리 신설]** quad-debug 채널을 `ReplicatedStorage`에 자동 생성하던 초안 — 게임 트리 오염 부작용으로 기각, quad 모듈 자신의 트리+`CollectionService` 태그로 대체 | | `tween-special-bind-key-reversed.md` | **[역전됨, 2026-08-10 신설]** 구 Tween 모델(`[Tween(key,tweenData...)] = storeValue` 특수 bind key, 우선순위 최상위 Dispatch 핸들러) — 값-레벨 `Tween` 래퍼 모델로 완전히 대체됨(`base/tween-plan.md`) | | `onchange-per-property-codegen-rejected.md` | **[기각됨, 2026-08-10 신설]** `OnChange.PropertyName` 프로퍼티별 정적 코드 생성 — Attribute의 정적 지름길과 달리 (클래스 수 × 프로퍼티 수) 규모로 폭발해 기각, `OnChange(name)` 단일 팩토리로 대체 | +| `retract-always-fires-reversed.md` | **[역전됨, 2026-08-12 열한 번째 세션 신설]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff" — 실제로는 `retract`가 store 재발행마다 항상 불림(핸들러 타입 무관). `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류 위에서 설계돼 있었음이 드러나 한 세션에 전부 정정 | ## 참고 diff --git a/.claude/archive/retract-always-fires-reversed.md b/.claude/archive/retract-always-fires-reversed.md new file mode 100644 index 0000000..ccf1c8d --- /dev/null +++ b/.claude/archive/retract-always-fires-reversed.md @@ -0,0 +1,79 @@ +# [역전됨] "핸들러 타입이 안 바뀌면 retract 없이 process가 diff" — `retract`는 store 재발행마다 항상 불림으로 정정 + +**역전 일시**: 2026-08-12 (열한 번째 세션). **원 확정 일시**: 2026-08-07 +(여덟 번째 세션, `bind-system-plan.md`의 일반 retract 계약 절)~2026-08-09 +(Tag의 `assert(v==nil)` 명시화). +**현재 유효한 설계**: `base/bind-system-plan.md`의 일반 retract 계약 +절(`retract(inst,k,v)` 항목), `base/tag-plan.md`/`base/attribute-plan.md` +"이름 소유권"/"메커니즘" 절, `base/bind-system-plan.md`의 "`Ref`의 retract" +절, `base/slot-plan.md` "Slot과 Store 바인드의 관계" 절이 최종 소스. + +## 역전된 사례 — 원래 무엇을 확정했었나 + +**"retract가 실제로 의미 있는 유일한 패턴은 같은 키에 대해 매치되는 +핸들러 *타입 자체*가 사이클마다 바뀌는 경우"** (2026-08-07 여덟 번째 +세션 정정 당시 확정, `bind-system-plan.md`) — 예로 `Tag(...)`↔`nil`을 +들며, "같은 Tag끼리 바뀌는 diff는 process가 담당"(즉 `Tag(A)→Tag(B)` +같은 동일 핸들러 타입 전환에서는 `retract`가 아예 안 불린다)고 명시. +`base/tag-plan.md`는 이 전제를 코드에 그대로 반영해 `TagHandler.retract`에 +`assert(v == nil, "TagHandler.retract는 v가 nil일 때만 불려야 함")`을 +넣었고(2026-08-09 열한 번째 세션 "명시화"), 이후 2026-08-12 여덟/아홉 +번째 세션에 `Ref`/`Slot`도 같은 전제("핸들러 타입이 안 바뀌면 retract +없이 process가 diff")를 그대로 이어받아 각자 `assert(v==nil)`을 두고 +`process` 안에서 old-vs-new diff를 계산하는 설계로 확정됐었음. + +## 역전된 이유 + +`Attribute`의 이름 소유권 설계를 논의하다 사용자가 지적: `Tag`도 서로 +다른 배열 위치의 `Tag(...)`가 같은 이름을 겹쳐 가질 수 있는데 +(`Frame { Tag("a"), Tag("a","b") }`, 웹 `className`과 같은 합집합 +시맨틱), 한 위치의 diff만으로 다른 위치가 아직 쓰는 이름을 지워버리는 +참조 카운트 버그가 날 수 있다는 문제 제기에서 시작. 이를 풀기 위해 +retract 쪽에서 "새로 들어올 값이 이 이름을 여전히 필요로 하는가"를 +확인하는 설계(`Tag:Contains()` 힌트)를 사용자가 제안했는데, 이게 +성립하려면 **`retract`가 `v=Tag`(nil이 아닌 대체 값)를 받는 경우가 +실제로 있어야 함** — 기존 `assert(v==nil)`과 정면으로 모순. + +`bind-system-plan.md`의 "확정된 디스패치 모델" 절(2026-08-04 원문)을 +다시 대조하니, `Dispatch/StoreBind.luau`는 재-dispatch 전에 **무조건** +`Dispatch.retractUnder(inst,k,self,realv)`를 부른다고 이미 명시돼 +있었음 — "핸들러 타입이 안 바뀌면 생략"이라는 조건은 그 문서 어디에도 +없었음. 즉 2026-08-07 세션의 "retract가 의미 있는 유일한 패턴" 서술이 +자기 문서의 다른 절과 처음부터 모순돼 있었고, `Tag`의 `assert(v==nil)`을 +액면 그대로 믿고 거꾸로 일반 규칙을 잘못 추론한 게 오류의 실제 출처 +(2026-08-09 "명시화" 세션에서도 이 모순이 안 걸림). 사용자가 직접 +"어떤 값이든 덮여 쓰여지는 즉시 retract를 실행하는 거로 두기로 했었다 — +전체 process 트랙을 retract하고 리빌드한다는 맥락"이라고 확인하며 +확정. + +## 정정된 이해 + +- `retract(inst,k,v)`는 store 바인드가 재발행될 때마다(핸들러 타입이 + 안 바뀌어도) 항상 불림. `v`는 `nil`일 수도, 그 자리를 대체하는 새 + 값 자체일 수도 있음 — **`retract` 안에서 `v`를 절대 `nil`로 + 가정하면 안 됨.** +- 대부분의 핸들러(일반 PropertyHandler, `NoneHandler`, UICorner + 숏핸드 등)는 이 반복 호출에서 실제로 할 일이 없어 `retract`가 + 사실상 no-op — "타입이 안 바뀌면 아예 안 불린다"가 아니라 "불리지만 + 몸체가 비어 있어도 된다"가 정확한 표현. +- `Tag`/`Ref`/`Slot`/`Attribute`처럼 여러 위치가 하나의 실제 리소스를 + 공유하거나 값 자체가 정리가 필요한 상태를 들고 있는 핸들러는, `v`를 + 힌트 삼아 "곧 다시 필요해질 것"이면 실제 엔진 호출만 skip하는 + 방식으로 대응 — `retract`가 이전 기여 제거(힌트로 skip 가능), + `process`가 새 기여 등록을 전담하는 분업이 자연히 나옴, `process` + 쪽에 별도 old-vs-new diff가 더 이상 필요 없어짐. + +## 영향받은 문서 (이 세션에 모두 정정 완료) + +- `base/bind-system-plan.md` — 일반 retract 계약 절, `NoneHandler` 절, + "`Ref`의 retract" 절. +- `base/tag-plan.md` — `TagHandler` 메커니즘 전면 재작성(`kTagMap`/ + `tagNameMap` 참조 카운트). +- `base/slot-plan.md` — "Slot과 Store 바인드의 관계" 절, `destroySlotTree` + 호출이 `process`에서 `retract`로 이동. +- `base/attribute-plan.md` — `AttributeKeyHandler.retract`에 `v==nil` + 가드 추가, 그룹의 이름 집합 diff가 "남아있는 이름도 먼저 + `retractUnder`" 방식으로 정정(체인 누수 방지). +- `base/ui-shorthand-plan.md`, `base/tween-plan.md` — 결론(해당 핸들러의 + `retract`는 no-op)은 안 바뀌었으나, "retract가 아예 안 불린다"는 + 근거 서술만 정정. diff --git a/.claude/base/attribute-plan.md b/.claude/base/attribute-plan.md index 1766cb7..50a1c9b 100644 --- a/.claude/base/attribute-plan.md +++ b/.claude/base/attribute-plan.md @@ -164,9 +164,15 @@ function AttributeKeyHandler.process(inst, k, v) end function AttributeKeyHandler.retract(inst, k, v) + -- [정정, 2026-08-12 열한 번째 세션] retract는 이 k가 store 재발행으로 + -- 다시 process될 때도 매번 불림(핸들러 타입이 안 바뀌어도) — + -- bind-system-plan.md 일반 retract 계약 절 참고. v가 nil이 아니면 + -- 곧바로 process가 재확정하므로(같은 k가 계속 살아있는 한 항상 + -- 자기 자신이 다시 매치됨) 여기서 SetAttribute/소유권 반납을 할 + -- 이유가 없음 — v가 nil일 때만(진짜 이 이름이 사라지는 경우) 실행. local name = k.Name local map = owners:GetStrong(inst) - if map and map[name] == k then + if map and map[name] == k and v == nil then map[name] = nil inst:SetAttribute(name, nil) end @@ -183,17 +189,21 @@ end 만들어 이 맵에 캐싱하고 그 키로 위임, 이미 맵에 있으면(이전 사이클에 이미 관리 중이던, 즉 "남아있는" 이름) **그 캐싱된 같은 객체를 그대로 재사용**해서 위임. -- **왜 이러면 "새 Attribute 셋 비교"가 저절로 맞아떨어지는지(사용자 확인, - 2026-08-12 열 번째 세션)**: 그룹이 이름 집합을 diff할 때 — **남아있는 - 이름은 캐싱된 같은 키 객체로 재위임하므로 `owners` 맵에서 `current == k`가 - 성립해 통과, `retract` 자체가 안 불림**(값만 갱신) — **사라진 이름만 - `Dispatch.retractUnder`가 그 이름 전용 키를 타서 `retract`가 불리고 - `nil`화됨.** 새로 들어온 이름은 `rawNew`로 갓 만든 키라 `owners`에 없어 - 그냥 새로 클레임. 즉 "진짜 새 셋과 비교해 사라진 것만 nil화, 나머지는 - 건드리지 않고 갱신"이 diff 로직을 하나도 안 고치고 위 `process`/`retract` - 구현만으로 자연히 나옴 — **캐시가 그룹 값 교체를 넘어 계속 유지돼야만 - 성립**(매 교체마다 키를 새로 만들면 남아있는 이름도 `owners`엔 옛 - 객체가 남아있어 새 객체와 비교 시 오탐 충돌이 남). +- **[정정, 2026-08-12 열한 번째 세션] "남아있는 이름은 retract 자체가 안 + 불린다"는 예전 서술은 틀렸음** — `AttributeKeyHandler.retract`는 이제 + `v==nil`일 때만 실제로 뭔가 하므로(위 "메커니즘, `None`, `retract`" 절 + 정정분), 남아있는 이름에 대해서도 `retractUnder`를 불러도 안전하고 — + 오히려 **불러야 함**: `Dispatch.process`가 매번 체인 꼬리에 새 항목을 + 쌓기만 하지 스스로 옛 항목을 안 지우므로(popping은 `retractUnder` + 자신의 일), 남아있는 이름을 `retractUnder` 없이 `Dispatch.process`만 + 반복 호출하면 그룹 값이 교체될 때마다 같은 `(inst,key)` 체인에 옛 + `AttributeKeyHandler` 항목이 계속 쌓이는 누수가 생김. **그래서 그룹의 + diff는 사라진/남아있는 이름 모두 자기 캐싱된 키로 먼저 + `Dispatch.retractUnder(inst, key, nil, newSourceOrNil)`를 부른 뒤에만 + `Dispatch.process`를 부름** — 새로 들어온 이름만 예외(옛 체인이 없으니 + retractUnder 없이 바로 process). `AttributeKeyHandler.retract`가 `v`가 + non-nil이면 즉시 return하는 얇은 함수라 이 추가 호출의 비용은 무시할 + 수준. - **패키지 경계**: `AttributeKey` 자체가 이미 quad-roblox 소속(Tag와 달리 base/roblox로 안 쪼갬, 아래 "패키지 배치" 절)이고 그룹의 실제 위임 로직도 이미 roblox 쪽 글루라 `rawNew` 호출이 새 역의존을 안 만듦 — @@ -254,14 +264,28 @@ Dispatch 재진입 없이 직접 `SetAttribute`+수동 per-field StoreBind 구 attribute 이름 → 그 이름 전용 키 객체 맵"**(구 "이름 문자열 집합" — 이름 존재 여부뿐 아니라 그때 쓴 키 객체 자체까지 같이 들고 있어야 위 "이름 소유권" 절의 동일 객체 재사용이 성립)과 새 값의 키 집합을 diff: - - 사라진 이름만, 그 이름이 맵에 들고 있던 **그 전용 키 객체**로 - `Dispatch.retractUnder(inst, key)` — 그 이름이 살아있는 동안 만들어졌던 - 원래 체인과 정확히 같은 슬롯을 가리켜 정리됨. 맵에서도 그 이름 제거. - - 남아있는 이름은 **맵에 이미 캐싱된 같은 키 객체를 그대로 재사용**, - 새로 들어온 이름은 `rawNew(name)`로 갓 만든 키를 맵에 새로 캐싱 — - **값 비교 없이 전부** `Dispatch.process(inst, key, source)`로 넘김, - 작성자가 직접 `[AttributeKey<> name] = source`를 쓴 것과 거의 같은 - 경로를 타므로(단, 공개 캐시가 아니라 그룹 전용 키를 씀) `source`가 + - **[정정, 2026-08-12 열한 번째 세션] 사라진 이름뿐 아니라 남아있는 + 이름도 먼저 `Dispatch.retractUnder(inst, key, nil, source)`를 부른 + 뒤에야 `Dispatch.process`를 부름** — 그 이름이 맵에 들고 있던 **그 + 전용 키 객체**로, 그 이름이 살아있는 동안 만들어졌던 원래 체인과 + 정확히 같은 슬롯을 가리켜 정리(팝)됨. `retractUnder` 없이 + `Dispatch.process`만 반복 호출하면 체인이 매번 새 항목을 쌓기만 해서 + (팝은 `retractUnder`의 일) 같은 키 자리에 옛 `AttributeKeyHandler` + 항목이 계속 누적되는 진짜 누수가 생기므로, "값이 안 바뀌었으니 + retract 생략"은 성립 안 함 — `AttributeKeyHandler.retract`가 `v` + non-nil이면 즉시 return하는 얇은 함수라(위 "이름 소유권" 절) 이 + 호출 자체는 사실상 공짜. + - 사라진 이름: `Dispatch.retractUnder(inst, key, nil, nil)` — 뒤이어 + `process` 안 부름, 맵에서도 그 이름 제거. + - 남아있는 이름: `Dispatch.retractUnder(inst, key, nil, source)` → + 바로 `Dispatch.process(inst, key, source)` — **맵에 이미 캐싱된 같은 + 키 객체를 그대로 재사용**. + - 새로 들어온 이름: 옛 체인이 없으니 `retractUnder` 없이 바로 + `rawNew(name)`로 갓 만든 키를 맵에 새로 캐싱하고 + `Dispatch.process(inst, key, source)`. + - **값 비교 없이 전부 재위임** — 작성자가 직접 + `[AttributeKey<> name] = source`를 쓴 것과 거의 같은 경로를 + 타므로(단, 공개 캐시가 아니라 그룹 전용 키를 씀) `source`가 `State`/`Source`면 `Dispatch/StoreBind`가 알아서 언랩+구독까지 다 해줌(그룹 Handler가 따로 구독 관리 안 함). - **값 비교(`:Get()`으로 old/new 비교)는 하지 않음** — State 계약("값은 diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index a074450..8d2b4c9 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -115,43 +115,51 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 lifecycle-pattern.md`의 "quad는 라이프사이클 중간에 있지 않다" 원칙 참고). - 일반 프로퍼티는 애초에 "unset" 개념이 없음(`nil`로 셋하는 것도 그냥 셋 동작) — 그래서 프로퍼티 핸들러는 보통 `retract`가 필요 없음. - - **`retract`가 실제로 의미 있는 유일한 패턴은 "같은 키에 대해 매치되는 - 핸들러 *타입 자체*가 사이클마다 바뀌는 경우"** (2026-08-07 여덟 번째 - 세션, 정정) — 예: `Tag(...)`↔`nil` 사이에서 핸들러 타입 자체가 - 바뀌므로 `retract`가 의미 있어짐(전체 삭제), 같은 Tag끼리 바뀌는 - diff는 `process`가 담당(`base/tag-plan.md`, 2026-08-08 세 번째 세션 - — array-part 값 객체 재설계 이후, 구 모델은 `archive/ - tag-hash-key-model-reversed.md`). **Attribute(단일 키 직접 쓰기 경로)는 - 여기 해당 안 함** — UICorner 숏핸드와 같은 패턴(값의 참/거짓/nil - 여부와 무관하게 항상 같은 핸들러가 계속 담당, 추가/제거를 전부 - `process` 자신이 처리)이라 핸들러 교체 자체가 안 일어남 — - `base/attribute-plan.md`. **[추가, 2026-08-12 열 번째 세션] 단, 그룹 - `Attribute(...)`가 개별 이름을 놓을 때는 그 이름 전용 키 객체의 체인 - 자체가 통째로 정리되는 거라 `retract`가 실제로 불림** — "핸들러 - 타입이 안 바뀌면 retract 없이 process"라는 이 절의 원칙과 안 어긋남, - 그룹이 이름을 잃는 건 "그 이름 전용 키가 이 인스턴스를 더 이상 - 관리 안 하게 됨"이라 오히려 `Tag(...)→nil`과 같은 급의 "완전히 - 사라짐" 케이스임 — `attribute-plan.md` "이름 소유권" 절 참고. - **[정정, - 2026-08-10 세션] Tween도 더 이상 여기 해당하지 않음** — 원래는 이 - 패턴의 대표 예시("Tween 핸들러가 매치돼 애니메이션 실행 중이었는데 - 값이 더 이상 Tween 대상이 아니게 되어 일반 PropertyHandler로 매치가 - 넘어가는 경우")였으나, Tween이 독립 Dispatch 핸들러가 아니라 - PropertyHandler가 소비하는 값-레벨 래퍼(`Tween`)로 재설계되며 - 매치되는 핸들러가 항상 PropertyHandler 하나뿐이 되어 이 케이스 - 자체가 사라짐 — 트윈 취소/전환은 이제 PropertyHandler 내부의 - 3-상태 릴레이션 슬롯으로 처리(`base/tween-plan.md`, `archive/ - tween-special-bind-key-reversed.md`). **[추가, 2026-08-12 여덟 번째 - 세션] `Ref`도 `Tag`와 같은 결** — `State`가 `refA`에서 `refB`로 - 바뀌는 건 둘 다 같은 Ref-leaf handler가 매치하므로 `retract`가 아니라 - `process`의 diff가 담당(이전 Ref를 `:Set(nil)`로 언바인딩), `retract`는 - 그 자리가 Ref이길 아예 그만둘 때만 — 아래 "`Ref`의 retract" 절 참고. - **[추가, 2026-08-12 아홉 번째 세션] `Slot`도 같은 패턴, 단 diff가 아니라 - identity 비교** — `State`이 `slotA→slotB`로 바뀌는 것도 같은 - SlotHandler가 매치하므로 `process`가 처리. `Tag`/`Ref`처럼 세밀한 diff - 대신 "같으면 완전 무시, 다르면 이전 것 통째로 폐기 후 새로 마운트"(Slot은 - portal 없이 폐기만 하기로 이미 확정돼 있어서) — `slot-plan.md` "Slot과 - Store 바인드의 관계" 절 참고. + - **[전면 정정, 2026-08-12 열한 번째 세션] `retract`는 "핸들러 타입이 + 바뀔 때만" 불리는 게 아니라, **store 바인드가 재발행될 때마다(값이 + 뭐로 바뀌든) 항상 불림** — 위 "확정된 디스패치 모델" 절이 처음부터 + 말해온 그대로: `StoreBind`는 재-dispatch 전에 **무조건** + `Dispatch.retractUnder(inst,k,self,realv)`를 부르고, `retractUnder`는 + `keep` 바로 다음 항목에게 그 `realv`를(그 다음 항목들에겐 `nil`을) + 넘기며 체인을 통째로 걷어낸 뒤에야 `Dispatch.process`가 다시 매치를 + 시도해 새 체인을 쌓음. 즉 **"핸들러가 안 바뀌면 retract 없이 process가 + diff"라는 이전 서술은 틀렸음** — `Tag`의 옛 + `assert(v == nil, "TagHandler.retract는 v가 nil일 때만 불려야 함")`을 + 액면 그대로 믿고 거꾸로 일반 규칙을 추론한 게 오류의 출처(2026-08-07 + 여덟 번째 세션 정정 당시엔 안 걸렸던 부분, `bind-system-plan.md` 자기 + "확정된 디스패치 모델" 절과 실제로 모순돼 있었음). 이 오류는 + `base/tag-plan.md`(원 출처)와, 이번 대화에서 그걸 그대로 이어받은 + `Ref`/`Slot`/`Attribute` 세 곳 전부에 퍼져 있었음 — 전부 정정 + 완료(각 문서의 해당 절 참고). **[아카이브, `archive/ + retract-always-fires-reversed.md`]**. + - **정정된 원칙 — 대부분의 핸들러는 이 반복 호출에서 실제로 할 일이 + 없어(일반 프로퍼티처럼 값을 그냥 덮어쓰면 끝이라 "unset" 개념 자체가 + 없음) `retract`가 사실상 no-op일 뿐, "타입이 안 바뀌면 retract가 아예 + 안 불린다"는 뜻이 아님.** `Tag`/`Ref`/`Slot`/`Attribute`처럼 **여러 + 위치가 하나의 실제 리소스(엔진 attribute/tag/mounted 서브트리 등)를 + 공유하거나, 값 자체가 정리가 필요한 상태를 들고 있는** 핸들러는, + `retract`가 매번 불려도 **"이전 값이 지금 들어오는 새 값(`v`)과 + 사실상 같은지/그 새 값이 여전히 이 자원을 필요로 하는지"를 `v`를 + 힌트 삼아 판단해 실제 엔진 호출만 skip**하는 방식으로 대응해야 함 — + `Tag`의 `Contains` 힌트, `Ref`/`Slot`의 identity 비교가 그 예. **`v`를 + 반드시 `nil`로 가정하면 절대 안 됨**(대체하는 새 값 그 자체일 수 + 있음) — 새 핸들러를 짤 때 `retract` 안에서 `v`의 타입을 방어적으로 + 확인할 것(2026-08-12 열한 번째 세션, 실제로 `Tag(...)`/`Ref`/`Slot` + 설계 전부에서 이 확인이 빠져 있었음이 드러남). + - **자연스러운 분업**: 여러 위치가 자원을 공유하는 핸들러는 대개 + "`retract`가 이전 기여를 걷어내고(실제 해제는 `v` 힌트로 skip + 가능), `process`가 새 기여를 등록한다"는 모양으로 깔끔히 갈림 — + `process` 쪽에 별도 old-vs-new diff가 필요 없어짐(그 diff를 `retract`가 + 이미 통째로, 매번 정확하게 해주므로). `Tag(...)`↔`nil`, `Attribute`의 + 그룹이 이름을 놓는 경우도 이 분업의 자연스러운 특수 케이스일 뿐, 별도 + 패턴이 아님 — 상세 구현은 `base/tag-plan.md`/`base/attribute-plan.md` + "이름 소유권" 절, `Ref`는 아래 "`Ref`의 retract" 절, `Slot`은 + `slot-plan.md` "Slot과 Store 바인드의 관계" 절 참고. + - Tween은 이 패턴과 무관 — 독립 Dispatch 핸들러가 아니라 PropertyHandler가 + 소비하는 값-레벨 래퍼(`Tween`)라 매치되는 핸들러가 항상 + PropertyHandler 하나뿐(2026-08-10 세션 재설계) — 트윈 취소/전환은 + PropertyHandler 내부의 3-상태 릴레이션 슬롯으로 처리(`base/tween-plan.md`, + `archive/tween-special-bind-key-reversed.md`). - store bind가 새 값으로 넘어갈 때 이전 핸들러의 `retract`를 호출해주면 됨 — **정확한 전파 메커니즘은 아래 "Dispatch 체인" 절 참고**(재귀 재-dispatch에서 여러 단계가 겹칠 때 어느 슬롯에 뭘 추적하는지가 @@ -292,11 +300,13 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출 프로퍼티(Color3/number 등)에 도달하면 `inst[k] = nil`은 런타임 에러 — PropertyHandler 자신이 `v == nil`이면 셋을 건너뛰는 방어를 갖고 있어야 함(None 자체의 문제가 아니라 PropertyHandler 구현 디테일, M9/M10로 미룸). -- **retract와는 무관** — `retract`는 "같은 키를 다른 *핸들러 타입*이 - 넘겨받는" 시나리오 전용(아래 정정된 "확정된 디스패치 모델" 절)이지 - "`v`가 `nil`이 됨"과는 다른 문제. `None → nil` 재디스패치는 항상 - `Dispatch.process` 경로로만 흐름 — `NoneHandler` 자신도 `retract`가 - 딱히 할 일이 없음(재귀 호출 자체가 이미 process이므로). +- **`retract`는 여기서 할 일이 없음** — **[정정, 2026-08-12 열한 번째 + 세션]** `retract`는 store 재발행마다 항상 불리지만(위 "확정된 디스패치 + 모델"/일반 retract 계약 절 정정분 참고), `NoneHandler`는 `v==None`을 + 매치했을 때 재귀 호출로 곧바로 `Dispatch.process(inst,k,nil)`을 + 부르는 게 전부고 자기 자신이 들고 있는 별도 상태가 없어서(`Relate` 등 + 전혀 안 씀) `retract`가 불려도 정리할 게 없음 — 일반 프로퍼티 핸들러가 + `retract`를 no-op으로 두는 것과 같은 이유. - **[해소됨, 2026-08-08 세 번째 세션]** "이 키를 지금 누가 담당 중인가" bookkeeping — `pre-implementation-audit.md` 우선순위1 "이전에 실제로 매치됐던 핸들러 추적" 항목이 여기서 다시 언급됐던 것. 아래 "Dispatch @@ -1005,11 +1015,13 @@ tween-plan.md`도 이에 맞춰 갱신됨). Ref의 진짜 용도는 다름: `refB`로 넘어갔다는 걸 모르는 코드가 `refA.Value`를 계속 유효하다고 믿는 조용한 버그가 남음 — `PreRef` 재사용 버그(위 절)와 같은 클래스의 문제. -**메커니즘 — `TagHandler`(`base/tag-plan.md`)와 정확히 같은 패턴 재사용, -새 장치 아님.** `Dispatch`의 일반 규칙("핸들러 *타입*이 안 바뀌면 retract -없이 process만 다시" — `refA→refB`는 둘 다 같은 Ref-leaf handler가 -매치하므로 여기 해당)을 그대로 따르면, `refA→refB` 전환은 `retract`가 -아니라 **`process` 자신이 이전 값을 기억해뒀다가 diff**해야 함: +**메커니즘 — `retract`가 매번 불린다는 전제 위에서 언바인딩 전담 +(2026-08-12 열한 번째 세션 정정).** `Dispatch.retractUnder`는 store 값이 +바뀔 때마다(핸들러 타입이 그대로여도) 무조건 불림 — 위 "확정된 디스패치 +모델"/일반 retract 계약 절 참고. 그래서 `refA→refB` 전환도 `retract(inst,k, +refB)`가 먼저 불려 `refA`를 언바인딩하고, 그 다음 `process(inst,k,refB)`가 +`refB`를 바인딩하는 두 단계로 자연히 갈림 — `process`가 old-vs-new diff를 +따로 계산할 필요가 없어짐(그 일을 `retract`가 매번 정확히 대신 해줌): ```lua local relate = Relate() -- Ref-leaf handler 전용, (inst,k)별 마지막으로 바인딩한 Ref 기억 @@ -1018,34 +1030,32 @@ RefLeafHandler.isHandlable(inst, k, v) = isRef(v) and not isPreRef(v) function RefLeafHandler.process(inst, k, v) local old = relate:GetStrong(inst, k) - if old and old ~= v then - old:Set(nil) -- 이전 Ref 언바인딩 — 매 :Set()마다 콜백 재통지되는 - -- 기존 Ref 규칙(위 "해소됨 — 반복 재설정 가능" 항목)을 - -- 그대로 재사용, 새 알림 경로 아님 - end + if old == v then return end -- 이미 같은 Ref가 이 자리를 차지 중(retract가 + -- 방금 손 안 댄 경우) — 콜백 재통지 없이 no-op v:Set(inst) relate:SetStrong(inst, k, v) end function RefLeafHandler.retract(inst, k, v) - assert(v == nil, "Ref 자리가 더 이상 Ref가 아니게 될 때만 불림 — TagHandler와 같은 이유") local old = relate:GetStrong(inst, k) - if old then old:Set(nil) end - relate:SetStrong(inst, k, nil) + if old and old ~= v then -- v는 nil일 수도, 대체하는 새 Ref 자체일 수도 있음 — + -- 어느 쪽이든 old와 다르면 old는 확실히 이 자리를 잃음 + old:Set(nil) -- 매 :Set()마다 콜백 재통지되는 기존 Ref 규칙(위 "해소됨 — + -- 반복 재설정 가능" 항목)을 그대로 재사용, 새 알림 경로 아님 + relate:SetStrong(inst, k, nil) + end + -- old == v(같은 Ref 재발행) → 아무 것도 안 함, 곧 process도 no-op으로 스킵 end ``` -- **`retract`는 이 자리가 Ref이길 아예 그만둘 때만 불림**(Store 값이 Ref가 - 아닌 다른 것으로 바뀌어 다른 Handler가 매치되는 경우) — `refA→refB`처럼 - Ref끼리 바뀌는 흔한 경우는 위 `process`의 diff가 담당. `retract`와 - `process` 양쪽 다 결국 `old:Set(nil)` 하나로 귀결되므로 실질적으로 - "언바인딩 로직은 하나, 트리거 경로만 둘"인 구조 — Tag의 diff/전체삭제 - 분리와 동형. +- **`retract`가 언바인딩 전담, `process`는 바인딩 전담** — 겹치는 diff + 로직이 없음. `old == v`(같은 Ref 객체가 스스로 재발행된 spurious한 + 경우)만 둘 다 스킵해 콜백이 `nil`→`inst`로 헛되이 두 번 안 불리게 함. - **children 배열 리터럴 `Ref`도 같은 코드 경로를 그대로 씀** — 그 경우 - `relate:GetStrong(inst,k)`가 애초에 `nil`(그 자리에 처음 오는 값)이라 - `old`가 없어 언바인딩 분기를 안 타고 바로 `v:Set(inst)`로 끝남. 즉 - "1회성 리터럴 구성"과 "반복 재바인드"가 하나의 구현으로 자연히 커버됨, - 케이스 분기 불필요. + `retract`가 (StoreBind 경로가 아니라 이 리터럴 구성 자체가 처음이므로) + 아예 안 불리고 `relate:GetStrong(inst,k)`도 `nil`이라 `process`가 바로 + `v:Set(inst)`로 끝남. "1회성 리터럴 구성"과 "반복 재바인드"가 하나의 + 구현으로 자연히 커버됨, 케이스 분기 불필요. - **타입: 비-nilable `T`도 정당한 용도(사용자 확인, 2026-08-12 여덟 번째 세션)** — `Ref`는 "채워지길 기다리는 박스"뿐 아니라 "이미 확정된 값을 여기저기서 부작용 없이 읽는" 용도로도 쓰일 수 있어 `Ref`(T가 diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index d2b40a5..f3f0394 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -198,15 +198,15 @@ Slot이 store 바인드로 들어오는 경우, pluggable 처리기에 `retract` > 동작이 '부모 위임' 잠정안에서 '폐기(옮기지 않음)'로 확정") 참고. 이 문단은 > 검토 과정의 히스토리로만 남겨둠, 현재 유효한 동작 아님. -**[정정, 2026-08-12 아홉 번째 세션] 위 "store 바인드 핸들러가 이전 slot을 -`retract`하고 다시 `process`하는 사이클" 서술은 부정확했음 — `Ref`의 -retract를 검토하며 발견한 것과 같은 오류.** `base/bind-system-plan.md`의 -일반 계약("핸들러 *타입*이 안 바뀌면 `retract` 없이 `process`가 diff -담당", `Tag`/`Ref`가 실제 선례)을 그대로 적용하면, `State`이 -`slotA→slotB`로 바뀌는 것도 둘 다 같은 SlotHandler가 매치하는 경우라 -**`retract`가 아니라 `process` 자신이 처리해야 함** — `retract`는 그 -자리가 아예 Slot이길 그만둘 때만. 메커니즘은 `Ref`(`bind-system-plan.md` -"`Ref`의 retract" 절)와 같은 모양의 `Relate` 기반 diff: +**[전면 정정, 2026-08-12 열한 번째 세션] 위 "핸들러 타입이 안 바뀌면 +retract 없이 process가 diff 담당"이라는 전제 자체가 틀렸음** — 실제로는 +`Dispatch.retractUnder`가 store 재발행마다(핸들러가 그대로여도) **항상** +먼저 불림(`base/bind-system-plan.md` "확정된 디스패치 모델"/일반 retract +계약 절, 2026-08-12 열한 번째 세션 전면 정정 참고). 그래서 `State`이 +`slotA→slotB`로 바뀔 때도 **`retract(inst,k,slotB)`가 먼저 불려 `slotA`를 +폐기하고, 그 다음 `process(inst,k,slotB)`가 `slotB`를 마운트**하는 두 +단계로 자연히 갈림 — `retract`가 "이전 것 정리", `process`가 "새 것 +마운트" 전담: ```lua local relate = Relate() -- SlotHandler 전용, (inst,k)별 마지막으로 마운트한 Slot 기억 @@ -214,33 +214,33 @@ local relate = Relate() -- SlotHandler 전용, (inst,k)별 마지막으로 마 function SlotHandler.process(inst, k, slotValue) local old = relate:GetStrong(inst, k) if old == slotValue then - return -- 이미 같은 바인딩 — no-op, 다시 빠지고 다시 들어가지 않음 - end - if old then - destroySlotTree(old) -- 폐기, 옮기지 않음 — 아래 "확정" 절 그대로 + return -- 이미 같은 바인딩(retract가 방금 손 안 댄 경우) — no-op end attachSlot(slotValue, inst, inst, k) relate:SetStrong(inst, k, slotValue) end function SlotHandler.retract(inst, k, v) - assert(v == nil, "Slot 자리가 더 이상 Slot이 아니게 될 때만 불림") local old = relate:GetStrong(inst, k) - if old then destroySlotTree(old) end - relate:SetStrong(inst, k, nil) + if old and old ~= v then -- v는 nil일 수도, 대체하는 새 Slot 자체일 수도 있음 + destroySlotTree(old) -- 폐기, 옮기지 않음 — 아래 "확정" 절 그대로 + relate:SetStrong(inst, k, nil) + end + -- old == v(같은 Slot 재발행) → 아무 것도 안 함, 곧 process도 no-op으로 스킵 end ``` - **같은 바인딩이면 완전히 무시하는 게 이 자리에선 효율 문제가 아니라 - 정합성 문제** — `Tag`/`Ref`의 diff는 값이 같아도 기껏해야 헛계산만 - 하고 넘어가지만, Slot은 아래 "확정" 절대로 "폐기, 옮기지 않음"(portal + 정합성 문제** — Slot은 아래 "확정" 절대로 "폐기, 옮기지 않음"(portal 없음)이 이미 정책으로 확정돼 있어서, 이 no-op 가드가 없으면 **재귀 재 emit이 있을 때마다 마운트된 서브트리 전체가 파괴됐다 다시 만들어짐** (자식들이 들고 있던 스크롤 위치/포커스/애니메이션 상태 전부 유실) — - Tag의 diff가 막으려던 "깜빡임" 문제보다 훨씬 파괴적인 버전. + Tag의 `Contains` 힌트가 막으려던 "깜빡임" 문제보다 훨씬 파괴적인 버전. `store.key:Set(sameSlotAgain)`처럼 사용자가 실수로 같은 객체를 다시 emit하거나, 상위 `:Compute`가 재계산됐는데 결과가 우연히 같은 Slot - 레퍼런스인 경우 등이 실제로 이 경로를 탈 수 있음. + 레퍼런스인 경우 등이 실제로 이 경로를 탈 수 있음. `retract`가 `old ~= v` + 체크로 이걸 막고, `process`도 `old == slotValue` 체크로 대칭적으로 막음 + — 둘 다 스킵돼야 완전한 no-op. - Slot 핸들러 자신이 감시 중인 값(배열/스토어)이 바뀔 때 child를 갱신하는 추적(구독)도 `base/bind-system-plan.md`가 말하는 "process 함수가 다른 값 변경을 추적해도 됨" 범위에 속하고, `retract` 시점엔 그 추적만 풀면 됨 — diff --git a/.claude/base/tag-plan.md b/.claude/base/tag-plan.md index 1c2753a..595a00f 100644 --- a/.claude/base/tag-plan.md +++ b/.claude/base/tag-plan.md @@ -2,7 +2,9 @@ **상태**: base — 2026-08-08 세 번째 세션에서 값 모양을 전면 재설계(구 모델은 `archive/tag-hash-key-model-reversed.md`에 원문·역전 이유 보존). -새 결정만 반영, 열린 질문 없음. +2026-08-12 열한 번째 세션에 `TagHandler`의 `process`/`retract` 메커니즘을 +참조 카운트 기반으로 전면 정정(옛 버전은 `archive/ +retract-always-fires-reversed.md`). 새 결정만 반영, 열린 질문 없음. ## 왜 재설계됐나 @@ -50,54 +52,101 @@ Tag("selected") or nil end)`처럼 그냥 `nil`을 리턴하면 됨. `None` 센 이건 nil-hole 문제라 Tag만의 특수 규칙이 아니라 `props.Modifier`/ `props.Ref`와 같은 일반 array-part 관용구.) -## 메커니즘 — `TagHandler`, retract가 이제 의미 있어짐 +## 메커니즘 — `TagHandler`, `retract`가 이제 의미 있어짐 구 모델과 달리 **핸들러 타입이 사이클마다 바뀔 수 있음**(`Tag(...)` ↔ `nil`, 값이 `Tag`가 아니게 되면 `TagHandler.isHandlable`이 더 이상 안 맞음) — 그래서 `retract`가 실제로 필요해짐(`bind-system-plan.md` "확정된 디스패치 모델" 절의 일반 원칙 그대로). +**[전면 정정, 2026-08-12 열한 번째 세션] 아래는 이전 버전(단일 `relate`, +`assert(v==nil)`, "Tag(A)→Tag(B)는 retract 안 불림")을 대체함 — 그 버전은 +두 가지를 놓쳤음:** + +1. **`retract`는 실제로 store 재발행마다(핸들러 타입이 안 바뀌어도) 항상 + 불림** — `bind-system-plan.md`의 "확정된 디스패치 모델" 절이 처음부터 + 말해온 대로 `StoreBind`가 재-dispatch 전에 무조건 `Dispatch.retractUnder`를 + 부르기 때문. "Tag(A)→Tag(B)는 retract 안 불림"이라는 옛 서술은 틀렸음 + (상세 근거는 `bind-system-plan.md` 일반 retract 계약 절, `archive/ + retract-always-fires-reversed.md`). +2. **서로 다른 배열 위치의 두 `Tag(...)`가 같은 이름을 겹쳐 가질 수 + 있음**(`Frame { Tag("a"), Tag("a","b") }`류, 웹 `className="a a a"`와 + 같은 합집합 시맨틱) — 한 위치의 diff만 보고 `RemoveTag`를 부르면 다른 + 위치가 아직 그 이름을 쓰고 있어도 지워버리는 참조 카운트 버그가 + 생김(사용자 지적, 2026-08-12 열한 번째 세션). + +**둘 다 같은 해법으로 풀림**: `Tag`는 **immutable**이고(모든 연산이 +clone을 반환) 내부에 State 같은 걸 담지도 않는 **항상 확정 상태인 말단 +값**(Tween과 같은 결) — 그래서 `State`가 진짜로 다른 내용을 내놓을 +때마다 **항상 물리적으로 다른 `Tag` 객체**가 나옴. 이 사실 덕분에, 이름별로 +"어떤 `Tag` 객체들이 지금 이 이름을 걸고 있는가"를 집합으로 추적하면 +`retract`(이전 객체가 이 이름을 놓음)/`process`(새 객체가 이 이름을 걺)가 +겹치는 이름/겹치는 위치 양쪽 다 자동으로 올바르게 처리됨: + ```lua -local relate = Relate() -- TagHandler 전용, 이전에 반영한 Tag 값 저장 +local kTagMap = Relate() -- {[inst(weak)] = {[k]: Tag}} — 위치별 마지막으로 반영한 Tag +local tagNameMap = Relate() -- {[inst(weak)] = {[tagName]: {[Tag]: true}}} — 이름별 현재 걸고 있는 Tag들 TagHandler.priority = <일반> TagHandler.isHandlable(inst, k, v) = isTag(v) -- Brand 기반, array-part 전용 -function TagHandler.process(inst, k, v) - local old = relate:GetStrong(inst, k) - -- diff: old에 있고 v에 없는 이름만 RemoveTag, v에 있고 old에 없는 이름만 AddTag - -- (모두 지웠다 다시 붙이지 않음 — 랙/스타일 깜빡임 방지가 이 diff의 존재 이유) - relate:SetStrong(inst, k, v) +function TagHandler.retract(inst, k, newv) + local oldv = kTagMap:GetStrong(inst, k) + if not oldv then return end + local newvIsTag = isTag(newv) -- newv는 nil일 수도, 대체하는 새 Tag 자체일 수도 있음 + for name in oldv:Names() do + local holders = tagNameMap:GetStrong(inst, name) -- 이미 등록됐으므로 항상 있음 + holders[oldv] = nil + if next(holders) == nil and not (newvIsTag and newv:Contains(name)) then + inst:RemoveTag(name) -- 곧 process가 재확정할 이름이면 실제 호출은 skip(깜빡임 방지) + end + end end -function TagHandler.retract(inst, k, v) - assert(v == nil, "TagHandler.retract는 v가 nil일 때만 불려야 함") - local old = relate:GetStrong(inst, k) - if old then for name in old:Names() do CollectionService:RemoveTag(inst, name) end end - relate:SetStrong(inst, k, nil) +function TagHandler.process(inst, k, v) + for name in v:Names() do + local holders = tagNameMap:GetStrong(inst, name) + if not holders then + holders = {} -- strong map — Tag가 살아있는 동안 소유 목록도 살아있어야 함 + tagNameMap:SetStrong(inst, name, holders) + end + if next(holders) == nil then + inst:AddTag(name) + end + holders[v] = true + end + kTagMap:SetStrong(inst, k, v) end ``` -- **`Tag(A) → Tag(B)`(같은 핸들러, 타입 안 바뀜)**: `retract`는 아예 안 - 불림 — `Dispatch`의 "핸들러가 안 바뀌면 retract 없이 process만 다시" - 원칙 그대로(`bind-system-plan.md` "Dispatch 체인" 절). **diff는 여기, - `process` 안에서만** 일어남 — 전체 삭제 후 재생성하면 스타일이 순간 - 전부 사라졌다 다시 붙어 랙/깜빡임을 유발하므로(사용자 지적), 반드시 - 이전 값과 diff. -- **`Tag(A) → nil`(핸들러가 TagHandler → 없음으로 바뀜)**: `retract`가 - 불림. **[명시화, 2026-08-09 열한 번째 세션] 전체 삭제는 정확히 - `v == nil`일 때만 맞는 동작 — "v를 안 봐도 된다"가 아니라 "v가 항상 - nil로 들어온다는 걸 알고 있으니 별도 분기가 필요 없다"가 정확한 - 표현.** Tag 값을 담는 키에서 TagHandler가 더 이상 매치 안 되는 유일한 - 경로가 값이 `nil`이 되는 것(`None → nil` 재디스패치 포함)이라 이 - 전제가 깨지지 않는 한 위 구현처럼 `v`를 실제로 분기 안 해도 항상 - 옳음 — 위 pseudocode에 `assert(v == nil, ...)`을 추가해 이 전제를 - 코드에도 드러냄. `Handler.retract`가 여전히 `(inst,k,v)` 3-인자를 - 받는 건 계약 일관성 때문이지(다른 핸들러는 `v`를 실제로 씀) Tag가 - 그걸 필수로 요구해서가 아님. +- **`AddTag`는 온전히 `process`, `RemoveTag`는 온전히 `retract`** — 서로 + 겹치는 diff 계산이 없음. `retract`가 이전 `Tag`(`oldv`)가 걸었던 이름 + 전부를 소유 목록에서 빼되(항상 실행), 그 결과 목록이 비었을 때 **실제 + `RemoveTag` 호출만** "새로 들어올 `newv`가 그 이름을 여전히 Contains하는가"로 + 힌트를 줘서 skip — 소유 목록 자체는 항상 최신 객체로 갱신되므로(정확히 + `oldv`를 빼고 `v`를 넣는 두 단계), 이름이 살아남는 경우에도 stale + 레퍼런스가 안 남음. `process`는 `v`가 새로 거는 이름 전부를 무조건 + 등록(소유 목록이 비어있던 경우에만 실제 `AddTag`) — 자기 나름의 old-vs-new + diff가 전혀 필요 없음(그 일을 `retract`가 매번 정확히 해줌). +- **`Tag(A)→Tag(B)`(같은 위치, 내용만 바뀜)**: `retract(inst,k,B)`가 먼저 + 불려 `A`가 걸었던 이름 중 `B`에 없는 것만 실제로 `RemoveTag`, 남은 건 + 힌트로 skip — 그 다음 `process(inst,k,B)`가 `B`의 이름 전부를 등록(이미 + 걸려있던 이름은 `AddTag`가 no-op으로 재확인만 됨, 소유 목록엔 `B`가 새로 + 등록). 결과적으로 실제 `RemoveTag`/`AddTag` 호출은 진짜 변경된 이름에만 + 일어남 — 스타일 깜빡임 방지라는 원래 목적은 그대로 달성. +- **`Tag(A)→nil`**: `retract(inst,k,nil)`만 불림(값이 `Tag`가 아니게 돼 + `process`는 매치 자체가 안 됨) — `newvIsTag=false`라 힌트가 항상 + 거짓이 되어 `A`가 걸었던 이름 전부가 무조건 실제로 `RemoveTag`됨(다른 + 위치가 그 이름을 계속 쓰고 있지 않다면). +- **여러 위치가 같은 이름을 겹쳐 가지는 경우**(`Frame { Tag("a"), Tag("a","b") }`): + 두 위치가 서로 다른 `k`로 각자 독립적으로 `process`/`retract`를 타지만, + `tagNameMap["a"]`는 **양쪽 위치의 `Tag` 객체를 모두 담는 하나의 공유 + 집합** — 한쪽이 "a"를 잃어도 다른 쪽 객체가 집합에 남아있으면 실제 + `RemoveTag`가 안 불림. 웹 `className`처럼 손실 없는 합집합이 정확히 + 나옴. - **`retract`가 자기 위임 대상까지 수동으로 안 쫓아가도 됨** — `Dispatch.retractUnder`가 체인 전체를 알아서 훑어주므로 TagHandler는 - 자기 자원(위 `relate` 저장분)만 정리하면 됨. 상세 메커니즘은 + 자기 자원(위 두 릴레이션)만 정리하면 됨. 상세 메커니즘은 `bind-system-plan.md` "Dispatch 체인" 절. ## 패키지 배치 — base는 값+API, roblox는 process/retract 글루 diff --git a/.claude/base/tween-plan.md b/.claude/base/tween-plan.md index 97aade1..fcc59e9 100644 --- a/.claude/base/tween-plan.md +++ b/.claude/base/tween-plan.md @@ -150,21 +150,23 @@ StoreBind가 State/Source 레이어를 전부 풀어낸 뒤의 값: **GC-안전성은 기존과 동일** — `Relate`가 `inst`로 weak-keyed되어 있어 `inst`가 죽으면 이 슬롯(엔진 Tween 객체+`Value` 포함)도 별도 정리 로직 -없이 같이 GC됨. `retract`는 이 케이스에서 거의 안 불림 — 아래 절 참고. +없이 같이 GC됨. **[정정, 2026-08-12 열한 번째 세션]** `retract`는 store +재발행마다 항상 불리지만(`bind-system-plan.md` 일반 retract 계약 절 +정정분 — "거의 안 불림"이었던 원 서술은 틀렸음), PropertyHandler의 +`retract`는 몸체가 no-op이라 실질적으로 하는 일이 없음 — 아래 절 참고. ### 왜 `retract`가 더 이상 필요 없는가 — Dispatch 체인 관점의 결과적 단순화 기존 모델에선 "Tween 핸들러가 매치되어 애니메이션이 실행 중이었는데, 다음 값이 더 이상 Tween 대상이 아니게 되어 일반 PropertyHandler로 -핸들러 *타입*이 바뀌는" 경우가 `base/bind-system-plan.md`가 서술하는 -"`retract`가 실제로 의미를 갖는 유일한 패턴"의 대표 예시였음. 새 모델에선 +핸들러 *타입*이 바뀌는" 경우가 이 문제의 대표 예시였음. 새 모델에선 **매치되는 Dispatch 핸들러가 항상 PropertyHandler 하나뿐**(Tween 여부는 -값 내부 분기일 뿐 핸들러 매치 자체엔 영향 없음) — 이 시나리오 자체가 -Dispatch 레벨에서 사라짐. 트윈 취소/전환은 위 3-상태 저장 로직으로 -PropertyHandler 내부에서 처리 — Tag가 이미 하고 있는 "diff는 `process` -자신이 담당" 패턴과 같은 모양이라 새 개념 아님. (PropertyHandler의 +값 내부 분기일 뿐 핸들러 매치 자체엔 영향 없음) — "핸들러 *타입*이 +바뀌는" 시나리오 자체가 Dispatch 레벨에서 사라짐. 트윈 취소/전환은 위 +3-상태 저장 로직으로 PropertyHandler 내부에서 처리. (PropertyHandler의 `retract` 필드 자체는 여전히 정의해둬야 함 — "필드 생략 불가" 규칙은 -예외 없는 일반 규칙 — 다만 실제로 호출될 일이 이 경로에선 사실상 없음.) +예외 없는 일반 규칙 — **매번 불리긴 하지만** 몸체가 no-op이라 실질적 +동작이 없음, 일반 프로퍼티는 애초에 "unset" 개념이 없어서.) ### 타입 대수: `T' = T | Tween` — Modifier/State/Source에 새 타입 기계 불필요 diff --git a/.claude/base/ui-shorthand-plan.md b/.claude/base/ui-shorthand-plan.md index 23945bf..65ccf2b 100644 --- a/.claude/base/ui-shorthand-plan.md +++ b/.claude/base/ui-shorthand-plan.md @@ -89,12 +89,14 @@ Modifier 타입의 메소드 목록에 끼워 넣도록 챙기면 됨, 새로 일반 프로퍼티 핸들러와 달리 이 숏핸드는 실제 Instance를 만들어 붙이는 쪽이라 "`nil` = 셋 안 함"이 곧 "만들어둔 게 있으면 치운다"는 뜻이 됨. -- **이건 `retract`가 아니라 `process` 자신의 로직** — `retract`는 "이 - 키를 다른 핸들러가 넘겨받는" 시나리오 전용(`bind-system-plan.md` "확정된 - 디스패치 모델" 절)이지, 같은 핸들러가 값이 바뀌어서 자기 산출물을 - 정리하는 것과는 다른 문제. 값이 나중에 다시 숫자로(`2`→`nil`→`3`처럼) - 바뀌면 `process`가 다시 자식을 만들면 그만이라 `retract` 쪽에 별도로 - 구현할 게 없음. +- **이건 `retract`가 아니라 `process` 자신의 로직** — **[정정, 2026-08-12 + 열한 번째 세션]** `retract`는 store 재발행마다 항상 불리지만(핸들러 + 타입이 안 바뀌어도, `bind-system-plan.md` 일반 retract 계약 절 정정분 + 참고), 이 Handler는 `process(inst,k,v)` 자체가 `v`가 `nil`이든 숫자든 + 전부 완결적으로 처리하므로(있으면 지우거나 만들거나) `retract`가 할 일이 + 없어 no-op이면 충분 — 일반 프로퍼티 핸들러가 `retract`이 필요 없는 것과 + 같은 이유. 값이 나중에 다시 숫자로(`2`→`nil`→`3`처럼) 바뀌면 `process`가 + 다시 자식을 만들면 그만이라 `retract` 쪽에 별도로 구현할 게 없음. - **캐비엇**: 이 왔다갔다가 잦으면(예: 반응형 State가 `nil`과 숫자 사이를 자주 토글) 매번 Instance 생성/제거 비용이 그대로 듦 — Tween처럼 무거운 API는 아니지만 공짜도 아니므로, 잦은 토글이 예상되는 값을 이 숏핸드에 diff --git a/.claude/session/2026-08-12-11-retract-always-fires-correction.md b/.claude/session/2026-08-12-11-retract-always-fires-correction.md new file mode 100644 index 0000000..84d2078 --- /dev/null +++ b/.claude/session/2026-08-12-11-retract-always-fires-correction.md @@ -0,0 +1,96 @@ +# 2026-08-12 열한 번째 세션 — "retract는 항상 불림" 전면 정정, Tag 참조 카운트 재설계 + +## 배경 + +열 번째 세션(Attribute 이름 소유권)에 이어, 사용자가 같은 클래스의 문제가 +`Tag`에도 있을 수 있다고 지적: 서로 다른 배열 위치의 `Tag(...)`가 같은 +이름을 겹쳐 가질 수 있는데(`Frame { Tag("a"), Tag("a","b") }`, 웹 +`className="a a a"`와 같은 합집합 시맨틱), 한 위치의 diff만으로 다른 +위치가 아직 쓰는 이름을 지워버리는 참조 카운트 버그가 날 수 있다는 +문제 제기. + +## 논의 전개 — 근본 오류 발견까지 + +1. Claude가 처음 위치별 `{[k]: true}` set(참조 카운트)을 제안 → 사용자 + 동의. +2. 사용자가 후속 질문: "retract에서 새롭게 들어온 v가 Tag라면 Contains를 + 확인해서 재사용될 거면 RemoveTag 안 함" — 이게 성립하려면 `retract`가 + `v=Tag`(nil 아닌 값)를 받는 경우가 있어야 함. Claude가 처음엔 이걸 + 기존 `assert(v==nil)`과 모순된다고 판단해 "메커니즘을 바꾸자는 + 거냐"고 되물음. +3. 사용자가 재확인: "정확히 저는 어떤 값이든 덮여 쓰여지는 즉시 retract를 + 실행하는 거로 두기로 했었어요 — 전체 process 트랙을 retract하고 + 리빌드한다는 맥락이었어요." +4. Claude가 `bind-system-plan.md`의 "확정된 디스패치 모델" 절(2026-08-04 + 원문)을 재확인 — `Dispatch/StoreBind.luau`는 재-dispatch 전에 + **무조건** `Dispatch.retractUnder`를 부른다고 이미 명시돼 있었음. + "핸들러 타입이 안 바뀌면 생략"이라는 조건은 어디에도 없었음 — 2026-08-07 + 여덟 번째 세션에 추가된 "retract가 의미 있는 유일한 패턴" 서술이 + 자기 문서 다른 절과 처음부터 모순돼 있었고, `Tag`의 + `assert(v==nil)`을 액면 그대로 믿고 거꾸로 일반 규칙을 잘못 추론한 게 + 오류의 실제 출처였음이 드러남. + +## 파급 효과 확인 + +이 오류가 **이번 대화에서 만든 `Ref`(여덟 번째 세션)/`Slot`(아홉 번째 +세션)/`Attribute`(열 번째 세션) 설계 전부에 그대로 이어받아져 있었음** — +셋 다 `assert(v==nil)`을 두고 "같은 핸들러 타입이면 process가 diff"라는 +전제로 설계돼 있었기 때문. 사용자의 지적("retract는 v가 다른 값일 수 +있는데, diff하는 모든 곳에서 is로 잘 테스트하고 있는지 봐야할듯")대로 +전수 감사·수정 진행. + +## 정정된 일반 원칙 + +`retract(inst,k,v)`는 store 재발행마다(핸들러 타입이 안 바뀌어도) 항상 +불림 — `v`는 `nil`일 수도 대체하는 새 값 자체일 수도 있음, `retract` +안에서 `v==nil`을 가정하면 안 됨. 대부분의 핸들러(PropertyHandler, +`NoneHandler`, UICorner 숏핸드)는 이 반복 호출에서 실제로 할 일이 없어 +no-op이면 충분 — "타입 안 바뀌면 아예 안 불린다"가 아니라 "불리지만 +몸체가 비어 있어도 된다"가 정확한 표현. 여러 위치가 하나의 실제 +리소스(엔진 attribute/tag/mounted 서브트리 등)를 공유하는 핸들러는 +`retract`가 이전 기여 제거(엔진 호출은 `v` 힌트로 skip 가능), +`process`가 새 기여 등록을 전담하는 분업이 자연히 나옴 — `process` 쪽에 +별도 old-vs-new diff가 더 이상 필요 없어짐(그 일을 `retract`가 매번 +정확히 대신 해줌). + +## 각 파일 정정 내역 + +- **`base/tag-plan.md`**: `TagHandler` 메커니즘 전면 재작성 — 단일 + `relate`(이름 집합)를 `kTagMap`(위치→Tag)+`tagNameMap`(이름→Tag + set) 두 릴레이션으로 분리. `retract`가 이전 Tag의 이름들을 무조건 + `tagNameMap`에서 빼되, 실제 `RemoveTag`는 "새로 들어올 Tag가 그 + 이름을 여전히 Contains하는가" 힌트로 skip. `process`는 새 Tag의 + 이름을 무조건 등록(집합이 비어있던 경우만 실제 `AddTag`) — 자기 + diff 불필요. `AddTag`는 온전히 `process`, `RemoveTag`는 온전히 + `retract`로 완전히 분업. 여러 위치가 같은 이름을 겹쳐 가지는 경우도 + 공유 `tagNameMap` 집합으로 자동 해결. +- **`base/bind-system-plan.md`**: 일반 retract 계약 절 전면 재작성(위 + "정정된 일반 원칙" 그대로). `Ref`의 retract 절 — `assert(v==nil)` + 제거, "언바인딩은 retract 전담, 바인딩은 process 전담"으로 재설계(둘 + 다 `old==v`/`old~=v` identity 체크로 spurious 재발행 시 콜백 이중 + 발화 방지). `NoneHandler` 절의 "retract와 무관" 근거도 정정(불리긴 + 하지만 할 일이 없어서 no-op). +- **`base/slot-plan.md`**: "Slot과 Store 바인드의 관계" 절 — `destroySlotTree` + 호출이 `process`에서 `retract`로 이동(`old~=v`면 폐기), `process`는 + 마운트만 전담(`old==slotValue`면 no-op). +- **`base/attribute-plan.md`**: `AttributeKeyHandler.retract`에 + `v==nil` 가드 추가(그렇지 않으면 매 store 재발행마다 attribute가 + 잠깐 nil로 flicker). 그룹의 "남아있는 이름" 위임도 `retractUnder` + 없이 `Dispatch.process`만 반복 호출하면 체인이 매번 새 항목을 + 쌓기만 해서(팝은 `retractUnder`의 일) 누적 누수가 생기는 걸 발견 — + 남아있는 이름도 먼저 `retractUnder`(같은 캐싱된 키로) 부른 뒤에만 + `process`하도록 정정. +- **`base/ui-shorthand-plan.md`, `base/tween-plan.md`**: 결론(해당 + 핸들러의 `retract`는 no-op)은 안 바뀌었으나 "retract가 아예 안 + 불린다"는 근거 서술만 정정. +- **`archive/retract-always-fires-reversed.md`** 신설 — 원문·역전 + 이유·영향받은 문서 전부 보존. +- **`README.md`** — 위 파일들 요약 라인 + 아카이브 표에 새 항목 추가. + +## 남은 것 + +전수 감사는 base/ 전체 `retract` 언급 파일(관련 없는 `relate-plan.md`/ +`module-lifecycle-plan.md`/`lifecycle-pattern.md`/`onchange-plan.md` +등)까지 훑었으나, `onchange-plan.md`의 `OnChangeHandler`(Connection +disconnect/reconnect 패턴)는 애초에 공유 리소스가 없어 이 문제에 +해당 안 됨 — 정정 불필요 확인만 하고 넘어감. diff --git a/CLAUDE.md b/CLAUDE.md index fffb0cf..1ad7c0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -555,3 +555,27 @@ no-op 가드는 Tag/Ref보다 Slot에서 훨씬 중요함(가드 없으면 재 claimant 타입 없이 AttributeKey 객체 identity 자체를 재사용하는 더 적은 부품의 설계. 기존 diff 로직(사라진 이름만 nil, 남은/새 이름은 갱신)은 그대로 맞물림, 캐시가 그룹 값 교체를 넘어 영속돼야 한다는 조건만 명시. + +**2026-08-12 열한 번째 세션 — "retract는 항상 불림" 전면 정정, `Tag` +참조 카운트 재설계** (`session/2026-08-12-11-retract-always-fires-correction.md`) +`Tag`도 Attribute와 같은 참조 카운트 문제(서로 다른 위치의 `Tag(...)`가 +같은 이름을 겹쳐 가질 수 있음, 웹 `className` 합집합)가 있다는 사용자 +지적에서 출발 — 논의 중 사용자가 "retract가 v=Tag(nil 아님)를 받는 +경우"를 전제로 설계를 제안했고, 이게 기존 `assert(v==nil)`과 모순됨을 +Claude가 지적했으나, 사용자가 "덮여 쓰여지는 즉시 retract 실행, 전체 +트랙을 retract하고 리빌드하는 맥락"이라고 재확인. `bind-system-plan.md` +자기 "확정된 디스패치 모델" 절(2026-08-04 원문)을 재대조하니 `StoreBind`가 +재-dispatch 전에 무조건 `retractUnder`를 부른다고 이미 명시돼 있었음 — +"핸들러 타입이 안 바뀌면 retract 생략"이라는 2026-08-07 정정 서술이 +자기 문서와 처음부터 모순돼 있었고, `Tag`의 `assert(v==nil)`을 액면 +그대로 믿고 거꾸로 일반 규칙을 잘못 추론한 게 오류의 출처였음이 드러남. +**이 오류가 이번 대화에서 만든 `Ref`/`Slot`/`Attribute` 설계 전부에도 +그대로 이어받아져 있었음** — 전부 한 세션에 정정: `retract`는 store +재발행마다 항상 불리고 `v`는 대체 값 자체일 수 있음(`nil` 가정 금지), +"이전 기여 제거는 `retract`, 새 기여 등록은 `process`"로 분업하면 +`process`의 별도 diff가 필요 없어짐. `Tag`는 `kTagMap`(위치→Tag)+ +`tagNameMap`(이름→Tag set) 참조 카운트로 재설계(`AddTag`는 온전히 +`process`, `RemoveTag`는 온전히 `retract`, `Contains` 힌트로 flicker +방지). `Attribute`의 그룹 위임도 "남아있는 이름"에서 `retractUnder`를 +생략하면 체인이 계속 쌓이는 누수를 추가로 발견·정정. 역전 사례는 +`archive/retract-always-fires-reversed.md`에 원문·근거·영향 범위 보존.