Adds Attribute(store1, store2, ...) as a Tag-shaped array-part value
object that projects one or more Stores' named Source fields onto
native Roblox attributes in one binding, with .Merged for combining
heterogeneous Stores. Rejected: a bare [Attribute] = Store{...} hash
slot (only one slot per instance, can't merge heterogeneous stores)
and making Attribute a Store subtype (would let Store<T>'s T be a
Store again, colliding with the existing "handler-layer values can't
live in Source" rule). Verified the value-slot aggregation in .Merged
doesn't reopen the earlier "layered Store" rejection (archive/
context-rejected.md) since it's an explicit one-time author-time
composition, not an implicit read-time parent-chain fallback.
Renamed the existing single-key constructor Attribute<<T>>(name) to
AttributeKey<<T>>(name) to remove the name collision with the new
group primitive (provisional per existing OnChange/OnChangeKey
precedent; final bikeshed still queued in question.md).
Follow-up: AttributeKey(name) now memoizes through a name-keyed weak
table, guaranteeing AttributeKey(a) == AttributeKey(a) while any
strong reference (e.g. a live Dispatch chain entry) keeps it alive.
This let the group Attribute handler drop its originally-planned
self-contained SetAttribute/subscription logic and instead recurse
into the existing single-key AttributeKey dispatch path per field,
reusing its None/retract/store-bind handling instead of duplicating
it — the group handler's own state shrinks to just a prior-name-set
diff for deciding which keys to retractUnder. The same memoization
was applied to OnChangeKey for the same reason (pure name-to-key
mapping, no other varying state), confirmed safe even once
State<function> callbacks are involved.
Reflected across base/attribute-plan.md, base/onchange-plan.md,
base/architecture.md (source tree + Brand isX list), ROADMAP.md M2/M10,
question.md, README.md, and the luau-test type-narrowing spike.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
9.6 KiB
2026-08-11 아홉 번째 세션 — 그룹 Attribute(...) 프리미티브 신설, 단일 키
AttributeKey로 리네임
사용자가 "attribute를 store로 설정 가능하게 두면 어떨까"라는 아이디어를
자유 서술 메모로 제기하며 시작. 이미 확정된 단일 키
[Attribute<<T>> "name"] = value(base/attribute-plan.md) 모델은 필드
하나하나를 개별 DI 키로 나열해야 해서, Store 전체를 한 번에 attribute로
투영하고 싶은 요구를 못 채움 — 사용자가 스스로 네 가지 형태
([Attribute] = Store{...} 해시파트 단일슬롯 / Attribute를 Store
서브타입으로 / Attribute(Store) 프리미티브 / Slot처럼 제공)를 트레이드오프와
함께 나열하고 의견을 구함.
1라운드 — 형태 결정
[Attribute] = Store {...}해시파트 단일슬롯 기각: 인스턴스당 슬롯이 하나뿐이라, 헤테로지니어스 Store 여러 개(스타일 Store + 상태 Store 등)를 합칠 방법이 구조적으로 없음.- Attribute가 Store를 상속(IS-A) — 기각:
Store<T>의T가 다시 Attribute(=Store)일 수 있게 되어, 이미 확정된 "핸들러 계층 값은 Source에 못 들어감"(Store<T>의T는 Modifier 불가와 같은 이유) 제약과 부딪히는 "Store 안에 Store"를 실제로 만들어냄. - 채택 —
Tag와 동형인 array-part 값 객체:Attribute(store1, store2, ..., {plain=1})생성자 +Attribute.Merged(a,b,...). 여러 개를 그냥 나란히 놔도(Frame { Attribute(a), Attribute(b) }) 각자 자기 키만 반영하므로 헤테로지니어스 합성 문제가Tag처럼 자연히 풀림.
2라운드 — 메커니즘: AttributeChanged 남발 방지, 자기 완결형 Handler
사용자가 직접 지적: State<Attribute>(그룹 값 자체가 :Compute로
스왑되는 경우)까지 오면, 재처리 때마다 전부 지웠다 다시 set하면
GetAttributeChangedSignal 남발. 해법으로 사용자가 Tag의 diff 패턴을
그대로 재사용하자고 제안 — (inst, index) 릴레이션에 이전 키 이름 집합을
저장해두고, 사라진 키만 지우고 남은/새 키는 값 비교 없이 그냥 다 set(값
비교까지 하려면 :Get()이 필요한데 그 부작용을 감수할 이유가 없다고
판단, "단순히 nil 했다 새 값으로 덮는다만 줄이기").
이어서 이게 "그룹 재처리"에서만 의미 있고, 필드 하나만 바뀌는 흔한
경우(그룹 자체는 안 바뀜)는 그 키 전용 구독이 직접 SetAttribute 호출로
끝나 이 diff 경로를 아예 안 거친다는 점을 정리(어시스턴트).
추가로 어시스턴트가 제안: 내부에서 단일 키 AttributeKey<<T>>(name) DI
키 객체를 재합성해서 Dispatch에 재진입시키면, 매 처리마다 새로 만들어지는
키 객체의 참조 동등성 때문에 Dispatch의 (inst,k)별 핸들러 체인 추적이
깨질 위험 — 그룹 핸들러는 TagHandler처럼 자기 완결형으로(직접
SetAttribute 호출 + Dispatch/StoreBind로 개별 필드 구독) 가는 게
낫다고 제안. 사용자 확인: "내부적으로 SetAttribute 실행 하나뿐이라 자기
완결이 아주 쉬움, 그걸 위해 모듈화 할 것도 없고" — 동의, 별도 재진입
경로 자체를 안 만드는 걸로 확정.
retract는 Tag와 동일하게 자기가 쓴 키 전부 SetAttribute(name, nil)로
정리하는 걸로 확정(어시스턴트 제안, "소진돼도 방치해도 부작용 없다"는
사용자의 최초 메모와는 다른 방향 — Roblox Instance 풀링/재사용 시 이전
값이 새는 게 실제 버그라는 근거로 제시, 사용자 동의: "5는 네 말이
맞아보여").
3라운드 — Attribute.Merged가 "레이어드 Store 기각"과 충돌 안 하는지
사용자가 스스로 짚음: Attribute.Merged가 여러 Store의 Source 슬롯을
가져와 자기 맵에 넣는 구조인데, 예전에 Context 대안으로 나왔던 "레이어드
Store"(archive/context-rejected.md)를 기각한 적이 있어서 이것도 같은
문제 아닌지. 사용자 자체 결론: "값을 넣기 위한 것이 레이어된다 보긴
애매하고, 목적성이 있는 객체의 레이어라서 문제는 안 보임" — 이건 Modifier의
Overridden과 유사한 사례라는 판단. 어시스턴트가 검증: 기각됐던 레이어드
Store는 읽는 시점의 암묵적 부모 체인 자동 폴백(디버깅 불투명성)이
문제였지, Attribute.Merged는 작성 시점에 명시적으로 한 번 Source
참조를 평탄한 맵으로 모으는 것뿐이라 그 기각 사유(범용 컨테이너의 암묵
런타임 폴백)가 적용 안 됨 — 확인 완료.
4라운드 — 이름 충돌: 단일 키를 AttributeKey로 리네임
기존 단일 키 생성자 이름이 Attribute<<T>>였는데, 그룹 프리미티브도
Attribute(...)라는 같은 이름을 쓰면 "Store 받는 애인지 단일 키인지"
해석이 갈림. 사용자가 명시: "지금 그대로 두면 Attribute가 스토어 받는
애인지 단일키인지 해석 여지가 갈리니, 임시 네이밍으로 AttributeKey로
둬야할듯" — OnChange/OnChangeKey(함수 이름과 반환 타입 이름이 분리된
기존 전례)와 대칭되는 이름이라 어시스턴트도 동의. 다른 용어 정리
항목들과 달리 이건 "임시"가 아니라 지금 바로 코드/문서 전체에 적용 —
최종 이름 확정만 대기열(question.md)에 남김, 표기 자체의 모호성은
지금 없앰. [BooleanAttribute "name"]류 정적 패밀리는 Attribute라는
이름을 쓰지 않으므로 리네임 대상 아님.
5라운드 — 이름별 weak 캐시로 동등성 보장, 메커니즘 재개정
문서 반영 직후 사용자가 새 아이디어 제기: AttributeKey "aaa"가 GC
전까지 이름별 weak 캐시로 항상 같은 객체를 리턴하게 만들면 어떤가 —
구현은 cache[name] 조회 후 없으면 생성+저장하는 단순한 엔지니어링,
비용도 크지 않음. 이러면 AttributeKey "aaa" == AttributeKey "aaa"가
보장되고, 사용 중엔 항상 같은 게 리턴됨. 사용자 스스로 Tag와 대조:
Tag는 내부 이름 목록 자체가 매번 달라지는 게 핵심이라 동등성 비교가
의미 없지만, AttributeKey는 이름 외 다른 가변 정보가 없는 순수 매핑이라
캐싱/동등성 비교가 말이 됨.
확정: AttributeKey(name)(및 BooleanAttribute 등 정적 패밀리 —
캐시 키는 순수 문자열 name뿐, 제네릭 T는 런타임 무영향이라 무시)이
값만 weak인 캐시(setmetatable(cache, {__mode="v"}))를 거치도록 확정.
GC 타이밍: 뭔가(Dispatch의 체인 저장 등)가 강하게 붙들고 있는 동안은
캐시도 살아있어 동일 이름 재호출 시 항상 같은 객체, 아무도 안 붙들면
자연히 풀리는데 그 시점엔 이전 identity를 참조하는 곳이 없어 문제 없음.
이게 4라운드에서 정한 "그룹 Handler는 자기 완결형, Dispatch 재진입
없음" 결정 자체를 뒤집음 — 그 결정의 근거였던 "매번 새 키 객체라
Dispatch의 (inst,k) 체인 추적이 깨질 위험"이 캐시로 사라지므로,
어시스턴트가 메커니즘을 재개정: 그룹 Handler는 이제 자기만의
SetAttribute/구독 로직을 만들지 않고, 메모이즈된 AttributeKey(name)로
Dispatch.process/Dispatch.retractUnder에 각 필드를 그대로 재귀
위임 — 작성자가 직접 [AttributeKey<<T>> name] = source를 쓴 것과
완전히 같은 경로를 태워 단일 키의 None/retract/store-bind 로직을
100% 재사용(중복 구현 제거). 그룹 Handler에 남는 자기 로직은 사실상
"이전 이름 집합과의 diff"뿐. OnChangeKey도 같은 모양(이름→키, 다른
가변 정보 없음)이라 같은 기법이 그대로 적용 가능하다는 점도 기록(급하지
않음, 지금 뭘 풀어주는 캐치는 아직 없음).
6라운드 — OnChangeKey에도 같은 캐시 적용
5라운드 문서화에서 "OnChangeKey도 같은 기법 적용 가능하나 급하지 않음"으로
남겨뒀던 걸 사용자가 바로 확정지음: OnChange가 State<function>이
되더라도(캐시는 키 객체 identity만 다루지 바인딩된 콜백/값과는 무관이라)
문제없이 작동할 것이고, OnChangeKey "a" == OnChangeKey "a"가 외부에
관찰 가능해지는 것도 의도적으로 허용해도 되는 동작이라 문제 없다고 직접
판단 — 같이 반영. base/onchange-plan.md에 AttributeKey와 동일한
이름별 weak 캐시 적용을 확정 절로 추가.
반영된 파일
base/attribute-plan.md(그룹 Attribute(...) 절 신규, 단일 키 전체
AttributeKey로 리네임)/base/architecture.md(소스 트리에 base
Attribute.luau + roblox AttributeKey.luau/Attribute.luau 추가,
4번 항목 갱신)/base/onchange-plan.md/base/bind-system-plan.md/
base/ui-shorthand-plan.md(전부 Attribute<<T>> 언급을 AttributeKey<<T>>로
치환)/.claude/README.md/.claude/question.md(용어정리 대기열에
Attribute/AttributeKey 항목 추가, 기존 "해소됨" 타입파라미터화
항목에도 리네임 각주).
다음 세션이 할 일: 안 바뀜(ROADMAP.md M0부터, luau-test 결과
확인 우선) — 이번 세션도 순수 설계 확정이라 M0 착수 우선순위 자체는
그대로.