decide(attribute): group Attribute(...) primitive, AttributeKey rename, weak-cache identity

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>
This commit is contained in:
qwreey 2026-08-11 18:22:54 +09:00
parent 1f56c75978
commit 6216f12f37
Signed by: qwreey
GPG key ID: D28DB79297A214BD
12 changed files with 436 additions and 69 deletions

View file

@ -40,8 +40,8 @@
| `effect-plan.md` | **[2026-08-07 신설, 여섯 번째 세션에 확정]** `Effect(fn, state?)``state` 없으면 설치 1회+leaf 사망 시 확정 정리, 있으면 내부적으로 `state:Observer(...)`를 조합해 재실행+cleanup 체이닝(React `useEffect` 동형). Observer와의 관계 해소 완료 | | `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` 아님) | | `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` | | `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 여덟 번째 세션 신설]** `[Attribute "Name"]` — `SetAttribute(name, nil)`이 네이티브 지우기라 `None` 센티널과 가장 깔끔하게 맞아떨어짐, `retract` 불필요. 타입 파라미터화 이름(`Attribute<T>` vs `BooleanAttribute`류)만 미확정 | | `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** 단일 키 `[AttributeKey<T> "Name"]`(구 `Attribute<T>`)`SetAttribute(name, nil)`이 네이티브 지우기라 `None` 센티널과 가장 깔끔하게 맞아떨어짐, `retract` 불필요. **[2026-08-11 아홉 번째 세션]** 여러 Store를 한 번에 attribute로 묶는 그룹 `Attribute(...)` 프리미티브 신설(`Tag`와 동형 array-part 값 객체, `Merged`로 헤테로지니어스 Store 합성), 이름 충돌 방지로 단일 키를 `AttributeKey`로 리네임(잠정). **[같은 세션 후속]** `AttributeKey(name)`이 이름별 weak 캐시로 동등성 보장하도록 확정되며, 그룹 Handler는 자기 완결형 재구현 대신 메모이즈된 키로 기존 단일 키 경로에 재귀 위임하는 걸로 개정(중복 구현 제거) |
| `onchange-plan.md` | **[2026-08-10 세션 신설]** `OnChange(name)``GetPropertyChangedSignal` 바인딩 전용 DI 키, `Attribute`와 달리 제네릭 타입 파라미터 없음(콜백 타입은 인라인 명시, 이벤트 바인딩과 같은 급 트레이드오프). 전부 quad-roblox(`Handlers/OnChange.luau`), `State<function>`은 기존 이벤트 store-bind 메커니즘 재사용 | | `onchange-plan.md` | **[2026-08-10 세션 신설]** `OnChange(name)``GetPropertyChangedSignal` 바인딩 전용 DI 키, `Attribute`와 달리 제네릭 타입 파라미터 없음(콜백 타입은 인라인 명시, 이벤트 바인딩과 같은 급 트레이드오프). 전부 quad-roblox(`Handlers/OnChange.luau`), `State<function>`은 기존 이벤트 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`가 그 위에 얹힘 | | `relate-plan.md` | **[2026-08-08 신설]** `Relate``inst`를 weak 키로 하는 범용 릴레이션 프리미티브(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`, 비싱글톤 생성자). 구 `base.perInstanceState(inst)` placeholder를 대체·정식 승격, `lifecycle-pattern.md``bindLifetime`/`canExecute`가 그 위에 얹힘 |
## `reference/` — 온디맨드 참고 자료 (2026-08-07 신설) ## `reference/` — 온디맨드 참고 자료 (2026-08-07 신설)

View file

@ -26,12 +26,16 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo
테이블을 계속 쌓는 방식, `reference/quad-v1-architecture.md` 참고)은 폐기. 테이블을 계속 쌓는 방식, `reference/quad-v1-architecture.md` 참고)은 폐기.
store 바인드에 대한 변경은 "전체 변경"으로 간주(UB 아님, 문서화된 의미론) — store 바인드에 대한 변경은 "전체 변경"으로 간주(UB 아님, 문서화된 의미론) —
부분 복사/오버레이가 필요하면 팩토리 함수로 필요한 곳만 명시적으로 복사. 부분 복사/오버레이가 필요하면 팩토리 함수로 필요한 곳만 명시적으로 복사.
4. **PA님 스타일 DI 키 계속 지원**: `[Attribute "Name"]` 같은 특수 바인드 키, 4. **PA님 스타일 DI 키 계속 지원**: `[AttributeKey "Name"]`(구 `Attribute`,
store 컴퓨티드 바인드도 가능해야 함(`retract`, 구 cleanup, 2026-08-11 아홉 번째 세션에 그룹 `Attribute(...)`와 이름 충돌 방지로
`base/lifecycle-pattern.md` 참고). **[정정, 2026-08-08 세 번째 세션]** 리네임) 같은 특수 바인드 키, store 컴퓨티드 바인드도 가능해야 함
`Tag`는 더 이상 `[Tag ""] = true` 해시 파트 DI 키가 아님 — array-part (`retract`, 구 cleanup, `base/lifecycle-pattern.md` 참고). **[정정,
값 객체(`Tag(...)`)로 재설계됨, `base/tag-plan.md` 참고 2026-08-08 세 번째 세션]** `Tag`는 더 이상 `[Tag ""] = true` 해시 파트
(`archive/tag-hash-key-model-reversed.md`에 구 모델 보존). DI 키가 아님 — array-part 값 객체(`Tag(...)`)로 재설계됨,
`base/tag-plan.md` 참고(`archive/tag-hash-key-model-reversed.md`에 구
모델 보존). **[2026-08-11 아홉 번째 세션]** `Attribute(...)`도 여러
Store를 한 번에 attribute로 묶는 array-part 값 객체로 신설(`Tag`와
동형), `base/attribute-plan.md` 참고.
5. **id 기반 전역 조회 폐지, Tag 시스템으로 대체.** v1의 `Store.GetObject(id)`/ 5. **id 기반 전역 조회 폐지, Tag 시스템으로 대체.** v1의 `Store.GetObject(id)`/
`Frame "id" {}`류는 더 이상 없음 — "id 매핑이 비현실적"이라는 게 이유. `Frame "id" {}`류는 더 이상 없음 — "id 매핑이 비현실적"이라는 게 이유.
네임스페이싱 문제는 있지만 별도 네임스페이스 개념을 추가하면 라이브러리 네임스페이싱 문제는 있지만 별도 네임스페이스 개념을 추가하면 라이브러리
@ -138,6 +142,7 @@ quad/
│ ├── Blocker.luau # 값 기반 emit 지연/합치기(`base/blocker-plan.md`), State/Source와 밀접 연관돼 같은 위치 │ ├── Blocker.luau # 값 기반 emit 지연/합치기(`base/blocker-plan.md`), State/Source와 밀접 연관돼 같은 위치
│ ├── Modifier.luau # flatten-before-dispatch, immutable 체이닝, 제네릭 `__index` 필드 setter 합성 + `:Apply`/`:Peek`/`Overridden`(`base/modifier-plan.md`) │ ├── Modifier.luau # flatten-before-dispatch, immutable 체이닝, 제네릭 `__index` 필드 setter 합성 + `:Apply`/`:Peek`/`Overridden`(`base/modifier-plan.md`)
│ ├── Tag.luau # 값 타입+immutable clone 체이닝(`Tag(...)`/`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`), CollectionService 글루는 quad-roblox Handlers/Tag.luau(`base/tag-plan.md`, 2026-08-08 세 번째 세션) │ ├── Tag.luau # 값 타입+immutable clone 체이닝(`Tag(...)`/`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`), CollectionService 글루는 quad-roblox Handlers/Tag.luau(`base/tag-plan.md`, 2026-08-08 세 번째 세션)
│ ├── Attribute.luau # 그룹 값 타입+API(`Attribute(store1, store2, ...)`/`Merged`, `Tag`와 동형) — `SetAttribute` 글루는 quad-roblox Handlers/Attribute.luau(`base/attribute-plan.md`, 2026-08-11 아홉 번째 세션). 단일 키(`AttributeKey<<T>>`)는 값 타입 레이어 없이 quad-roblox 단독 소속(아래)
│ ├── Tween.luau # 값 타입만(`Tween(opts)` 팩토리, `isTween`/`TweenTag`) — 엔진 무관, 독립 Dispatch 핸들러 아님. 실제 애니메이션 처리는 quad-roblox Handlers/Property.luau 내부 분기(`research/tween-plan.md`, 2026-08-10 세션 재설계) │ ├── Tween.luau # 값 타입만(`Tween(opts)` 팩토리, `isTween`/`TweenTag`) — 엔진 무관, 독립 Dispatch 핸들러 아님. 실제 애니메이션 처리는 quad-roblox Handlers/Property.luau 내부 분기(`research/tween-plan.md`, 2026-08-10 세션 재설계)
│ ├── Effect.luau # `Effect(fn, state?)` — state 없으면 설치1회+leaf사망시 정리, 있으면 State.Observer를 조합해 재실행(`base/effect-plan.md`) │ ├── Effect.luau # `Effect(fn, state?)` — state 없으면 설치1회+leaf사망시 정리, 있으면 State.Observer를 조합해 재실행(`base/effect-plan.md`)
│ ├── Dispatch/ │ ├── Dispatch/
@ -159,8 +164,9 @@ quad/
├── Handlers/ ├── Handlers/
│ ├── Property.luau # 일반 프로퍼티 세팅 + `isTween(realv)` 분기(3-상태 릴레이션 슬롯 `RobloxTween|true|nil`, hasBeenSet 억제, override 정책) — 구 `Handlers/Tween.luau`(높은 우선순위 store-bind 핸들러)는 폐기(`archive/tween-special-bind-key-reversed.md`) │ ├── Property.luau # 일반 프로퍼티 세팅 + `isTween(realv)` 분기(3-상태 릴레이션 슬롯 `RobloxTween|true|nil`, hasBeenSet 억제, override 정책) — 구 `Handlers/Tween.luau`(높은 우선순위 store-bind 핸들러)는 폐기(`archive/tween-special-bind-key-reversed.md`)
│ ├── Event.luau # ReflectionService 기반 자동 판별 │ ├── Event.luau # ReflectionService 기반 자동 판별
│ ├── OnChange.luau # `OnChange(name)` DI 키 팩토리+Handler, `GetPropertyChangedSignal` 바인딩(`base/onchange-plan.md`, 2026-08-10 세션) │ ├── OnChange.luau # `OnChange(name)` DI 키 팩토리+Handler, `GetPropertyChangedSignal` 바인딩 + 이름별 weak 캐시(`AttributeKey`와 동일 기법, `base/onchange-plan.md`, 2026-08-10 세션)
│ ├── Attribute.luau │ ├── AttributeKey.luau # 단일 키(`AttributeKey<<T>>(name)`/`BooleanAttribute`류) DI 키 팩토리+Handler, `SetAttribute`/`None` 지우기 + 이름별 weak 캐시(동등성 보장, `base/attribute-plan.md` "동등성" 절)
│ ├── Attribute.luau # 그룹(`Attribute(store1, store2, ...)`) process/retract — 이름 집합 diff만 자체 로직, 실제 `SetAttribute`/구독은 메모이즈된 `AttributeKey(name)``Dispatch.process`/`retractUnder`에 재귀 위임(단일 키 경로 재사용, 중복 구현 없음) — 값 타입/API는 quad-base Attribute.luau(`base/attribute-plan.md`)
│ ├── Tag.luau # CollectionService 글루만(process/retract) — 값 타입/API는 quad-base Tag.luau(`base/tag-plan.md`) │ ├── Tag.luau # CollectionService 글루만(process/retract) — 값 타입/API는 quad-base Tag.luau(`base/tag-plan.md`)
│ ├── Slot.luau # base Slot 재조정 로직의 실제 적용/해제(Instance Parent 조작) │ ├── Slot.luau # base Slot 재조정 로직의 실제 적용/해제(Instance Parent 조작)
│ └── InstanceChild.luau # k:number, v:Instance — 중첩 인스턴스 자식(예: Frame { Frame {} }) │ └── InstanceChild.luau # k:number, v:Instance — 중첩 인스턴스 자식(예: Frame { Frame {} })

View file

@ -1,20 +1,26 @@
# Attribute 특수 키 — 타입 파라미터화, `SetAttribute(name, nil)` 네이티브 지우기 # Attribute — 단일 키(`AttributeKey`)와 그룹(`Attribute(...)`) 두 프리미티브
**상태**: base — 메커니즘/`None`/`retract` 동작뿐 아니라 타입 파라미터화도 **상태**: base — 단일 키 메커니즘/`None`/`retract` 동작과 타입 파라미터화는
**둘 다 채택으로 확정**(2026-08-09 열한 번째 세션, 아래 참고). `[Attribute 전부 확정(2026-08-09 열한 번째 세션). **[2026-08-11 아홉 번째 세션 추가]**
"Name"]` DI 키의 존재 자체는 `architecture.md` Store 여러 개를 한 번에 attribute로 묶어 바인드하는 그룹 `Attribute(...)`
4번 항목에서 이미 확정. UICorner 숏핸드/Tween처럼 별도 전용 문서가 없던 걸 프리미티브 신설, 이름 충돌 방지를 위해 기존 단일 키 생성자를
2026-08-07 여덟 번째 세션에 메꿈("1 프리미티브 1 파일" 관례를 Tag/Attribute `Attribute<<T>>``AttributeKey<<T>>`로 리네임(잠정 확정 — 최종 이름은
에도 적용해야 한다는 사용자 지적) — `bind-system-plan.md`의 "Attribute 여전히 `.claude/question.md` 용어정리 대기열). `[AttributeKey "Name"]`(구
특수 키 — 타입 파라미터화" 절(2026-08-06 신설) 내용을 그대로 옮기고, 오늘 `[Attribute "Name"]`) DI 키의 존재 자체는 `architecture.md` 4번 항목에서
논의한 `None`/`process`/`retract` 동작을 추가. 이미 확정. UICorner 숏핸드/Tween처럼
별도 전용 문서가 없던 걸 2026-08-07 여덟 번째 세션에 메꿈("1 프리미티브 1
파일" 관례를 Tag/Attribute에도 적용해야 한다는 사용자 지적) —
`bind-system-plan.md`의 "Attribute 특수 키 — 타입 파라미터화" 절(2026-08-06
신설) 내용을 그대로 옮기고, 논의한 `None`/`process`/`retract` 동작을 추가.
## 문제 — 타입 있는 값이라 Luau가 좁혀줄 방법이 필요 ## 단일 키 — `AttributeKey<<T>>` (구 `Attribute<<T>>`)
### 문제 — 타입 있는 값이라 Luau가 좁혀줄 방법이 필요
Roblox Attribute는 Instance/Tag와 달리 실제로 **타입이 있는 값** Roblox Attribute는 Instance/Tag와 달리 실제로 **타입이 있는 값**
(string/boolean/number/Color3/UDim/UDim2/Vector2/Vector3/CFrame/Instance (string/boolean/number/Color3/UDim/UDim2/Vector2/Vector3/CFrame/Instance
참조 등 제한된 프리미티브 집합, 테이블 등 복합 타입은 지원 안 함)이라, 그냥 참조 등 제한된 프리미티브 집합, 테이블 등 복합 타입은 지원 안 함)이라, 그냥
`[Attribute "name"] = value`로 두면 `value`의 타입을 Luau가 좁혀줄 방법이 `[AttributeKey "name"] = value`로 두면 `value`의 타입을 Luau가 좁혀줄 방법이
없음. 커스텀/복합 데이터(테이블 등)는 애초에 Attribute가 지원을 안 하므로 없음. 커스텀/복합 데이터(테이블 등)는 애초에 Attribute가 지원을 안 하므로
Ref(직접 참조 획득) 쪽으로 빠지는 게 맞고, Attribute는 프리미티브 전용으로 Ref(직접 참조 획득) 쪽으로 빠지는 게 맞고, Attribute는 프리미티브 전용으로
남기면 된다는 게 사용자 판단 — Value 오브젝트가 역사적으로 Attribute의 남기면 된다는 게 사용자 판단 — Value 오브젝트가 역사적으로 Attribute의
@ -25,11 +31,13 @@ Instance 참조 타입도 지원해서 `ObjectValue` 없이도 Ref 용도로 Att
지원까지 감안하면 그 결정의 근거가 한층 더 탄탄해짐). 지원까지 감안하면 그 결정의 근거가 한층 더 탄탄해짐).
**확정(2026-08-09 열한 번째 세션) — 둘 다 채택**: **확정(2026-08-09 열한 번째 세션) — 둘 다 채택**:
- `[Attribute<<boolean>> "name"] = true` (리터럴 또는 store-bind 값) — - `[AttributeKey<<boolean>> "name"] = true` (리터럴 또는 store-bind 값) —
제네릭 파라미터로 타입을 명시하는 제네릭 생성자 스타일. 기본/범용 경로. 제네릭 파라미터로 타입을 명시하는 제네릭 생성자 스타일. 기본/범용 경로.
- `[BooleanAttribute "name"] = true` — 타입별로 이름이 다른 정적 생성자 - `[BooleanAttribute "name"] = true` — 타입별로 이름이 다른 정적 생성자
패밀리(`StringAttribute`/`NumberAttribute`/`Color3Attribute`/ 패밀리(`StringAttribute`/`NumberAttribute`/`Color3Attribute`/
`InstanceAttribute` 등). 실사용 빈도가 높은 몇 개만 지름길로. `InstanceAttribute` 등). 실사용 빈도가 높은 몇 개만 지름길로. **이름은
`Attribute`가 아니라 이미 타입별로 갈라져 있어 아래 그룹 `Attribute(...)`
겹치지 않음 — 리네임 대상 아님.**
**근거**: 이미 확정된 DI 인스턴스 생성 패턴(`bind-system-plan.md` "인스턴스 **근거**: 이미 확정된 DI 인스턴스 생성 패턴(`bind-system-plan.md` "인스턴스
생성 / 이벤트 네이밍 인체공학" 절)과 구조적으로 똑같은 문제라 같은 결론 생성 / 이벤트 네이밍 인체공학" 절)과 구조적으로 똑같은 문제라 같은 결론
@ -39,7 +47,7 @@ Instance 참조 타입도 지원해서 `ObjectValue` 없이도 Ref 용도로 Att
호출부가 타입을 어떻게 명시하느냐(제네릭 파라미터 vs 이름)뿐이라 어느 호출부가 타입을 어떻게 명시하느냐(제네릭 파라미터 vs 이름)뿐이라 어느
쪽을 쓰든 런타임 동작에 차이 없음. 쪽을 쓰든 런타임 동작에 차이 없음.
**[실측 필요, M0/M10]** `[Attribute<<boolean>> "name"] = value`처럼 DI **[실측 필요, M0/M10]** `[AttributeKey<<boolean>> "name"] = value`처럼 DI
키 제네릭 파라미터로 `=``value`의 타입까지 실제로 좁혀지는지는 키 제네릭 파라미터로 `=``value`의 타입까지 실제로 좁혀지는지는
미검증 — Luau 솔버가 이 조합을 못 풀면 `value``any`로 남을 수 있음. 미검증 — Luau 솔버가 이 조합을 못 풀면 `value``any`로 남을 수 있음.
단, **타입 추론이 안 되더라도 런타임 동작에는 영향 없음**(순수 정적 단, **타입 추론이 안 되더라도 런타임 동작에는 영향 없음**(순수 정적
@ -47,7 +55,51 @@ Instance 참조 타입도 지원해서 `ObjectValue` 없이도 Ref 용도로 Att
되면 `BooleanAttribute` 같은 정적 타입 패밀리 쪽이 사실상 유일하게 되면 `BooleanAttribute` 같은 정적 타입 패밀리 쪽이 사실상 유일하게
믿을 수 있는 정적 체크 경로가 됨. 믿을 수 있는 정적 체크 경로가 됨.
## 메커니즘, `None`, `retract` — 전부 확정 (2026-08-07 여덟 번째 세션) ### 동등성 — 이름별 weak 캐시로 `AttributeKey(name) == AttributeKey(name)` 보장 (2026-08-11 아홉 번째 세션 후속)
**확정**: `AttributeKey<<T>>(name)`(및 `BooleanAttribute(name)` 등 정적
패밀리 전부 — 아래 참고)는 내부적으로 이름별 weak 캐시를 거침:
```lua
local cache = setmetatable({}, { __mode = "v" }) -- 값만 weak
local function AttributeKey(name)
local cached = cache[name]
if cached then return cached end
local real = -- 진짜 생성(Brand 부여 등)
cache[name] = real
return real
end
```
- **캐시 키는 순수 문자열 `name`뿐, 제네릭 파라미터 `T`는 안 씀**
`T`는 런타임에 아무 영향 없는 순수 정적 타입 트릭(위 "근거" 절의
"내부 구현은 완전히 동일" 그대로)이라, `AttributeKey<<boolean>>("Enabled")`
`AttributeKey<<number>>("Enabled")`는 실제로 **완전히 같은 런타임
객체**를 돌려받음(호출부에서 다른 정적 타입으로 캐스팅될 뿐). 같은
이유로 `BooleanAttribute("Enabled")`도 같은 캐시를 공유해 동일 객체를
반환해야 함 — "내부 구현이 완전히 동일하다"는 기존 확정이 객체
identity 수준까지 이제 실제로 보장됨.
- **값만 weak라서 "쓰는 도중엔 항상 같은 게 리턴, 다 쓰고 나면 자연히
풀림"**: 어딘가(Dispatch의 `(inst,k)`별 핸들러 체인 등)가 이 키
객체를 강한 참조로 붙들고 있는 동안은 캐시 엔트리도 계속 살아있어
같은 이름으로 다시 호출해도 항상 같은 객체가 나옴. 아무도 안 붙들게
되면(그 이름의 attribute 바인딩이 완전히 retract됨) GC가 캐시 엔트리를
걷어가고, 그 다음에 같은 이름을 다시 부르면 새 객체가 생기는데 —
이 시점엔 이전 객체를 참조하는 곳이 아무도 없었으므로 identity가
달라져도 문제 될 게 없음.
- **`Tag`는 이 기법이 안 맞음**(비교 확인) — `AttributeKey(name)`
"이름 → 키" 외에 다른 가변 정보가 없는 순수 매핑이라 이름만으로
캐시가 성립하지만, `Tag(...)`의 값은 내부 이름 목록 자체가 매번
달라지는 게 핵심(`:Added`/`:Removed`로 계속 다른 집합을 표현)이라
"캐시할 안정적인 키"가 애초에 없음 — 동등성 비교/캐싱이 의미가 없는
이유가 이거.
- **[반영 완료] `OnChange(name)`도 같은 모양**(이름 → 키, 다른 가변
정보 없음)이라 같은 기법 그대로 적용 — `State<function>`이 되더라도
캐시는 키 객체 identity만 다루므로 문제 없고, `OnChange "a" == OnChange
"a"`가 외부에 관찰되는 것도 의도적으로 허용 가능한 동작(사용자 확인).
`base/onchange-plan.md` "확정" 절 참고.
### 메커니즘, `None`, `retract` — 전부 확정 (2026-08-07 여덟 번째 세션)
타입 파라미터화 이름과 무관하게 런타임 동작은 확정: 타입 파라미터화 이름과 무관하게 런타임 동작은 확정:
@ -59,7 +111,7 @@ Instance 참조 타입도 지원해서 `ObjectValue` 없이도 Ref 용도로 Att
`inst:SetAttribute(name, nil)`을 그대로 호출하면 끝 — UICorner 숏핸드처럼 `inst:SetAttribute(name, nil)`을 그대로 호출하면 끝 — UICorner 숏핸드처럼
"만들어둔 자식을 수동으로 찾아 지우는" 로직조차 필요 없음. "만들어둔 자식을 수동으로 찾아 지우는" 로직조차 필요 없음.
- **`retract` 불필요** — Tag와 같은 이유: 값이 뭐든(실제 값/`nil`) 항상 - **`retract` 불필요** — Tag와 같은 이유: 값이 뭐든(실제 값/`nil`) 항상
같은 `AttributeHandler`가 이 키를 계속 담당(핸들러 *타입*이 안 바뀜). 같은 `AttributeKeyHandler`가 이 키를 계속 담당(핸들러 *타입*이 안 바뀜).
`retract`가 의미 있는 유일한 패턴("매치되는 핸들러 타입 자체가 바뀜", `retract`가 의미 있는 유일한 패턴("매치되는 핸들러 타입 자체가 바뀜",
`Tag(...)`↔`nil`이 실사례 — 2026-08-10 세션부터 Tween은 더 이상 이 `Tag(...)`↔`nil`이 실사례 — 2026-08-10 세션부터 Tween은 더 이상 이
패턴의 예시가 아님, `research/tween-plan.md`)에 해당 안 함 — 패턴의 예시가 아님, `research/tween-plan.md`)에 해당 안 함 —
@ -68,12 +120,122 @@ Instance 참조 타입도 지원해서 `ObjectValue` 없이도 Ref 용도로 Att
- store-bind 가능(일반 프로퍼티와 동일하게 취급, `Store<T>`/`State<T>` - store-bind 가능(일반 프로퍼티와 동일하게 취급, `Store<T>`/`State<T>`
값도 받음). 값도 받음).
## 패키지 배치 ## 그룹 `Attribute(...)` — 여러 Store를 한 번에 attribute로 (2026-08-11 아홉 번째 세션 신설)
### 동기
Store 필드 여러 개를 각각 `[AttributeKey<<T>> "name"] = store.name`으로
나열하는 건, 이미 이름 붙은 typed Source 모음(Store)이 있는 상황에서
번거로움 — Store의 타입 체크/reactive 인프라를 attribute에도 그대로
재활용하고 싶다는 요구에서 출발.
### 검토했다 기각한 대안
- **`[Attribute] = Store {...}` (해시파트 단일 슬롯)**: 인스턴스당 이
키 슬롯이 하나뿐이라, 헤테로지니어스한 Store 여러 개(예: 스타일
Store + 상태 Store)를 한 인스턴스에 동시에 반영할 방법이 구조적으로
없음 — 기각.
- **`Attribute`를 Store의 서브타입/확장으로**: Attribute가 Store를
상속(IS-A)하면 `Store<T>``T`가 다시 Attribute(=Store)일 수 있게
되어, 이미 확정된 제약("핸들러 계층 값은 Source에 못 들어감" —
`store-semantics.md``Store<T>``T`는 Modifier 불가 규칙과 같은
이유)과 부딪히는 "Store 안에 Store"를 실제로 만들어냄. Attribute는
Store를 **참조(HAS-A)**만 해야지 **상속(IS-A)**하면 안 됨 — 기각.
### 채택안 — `Tag`와 동형인 array-part 값 객체
```
Attribute(store1, store2, ..., {plain = "table도 됨"}) -- 생성자, 여러 개 받음
Attribute.Merged(a, b, ...): Attribute -- Tag.Merged와 동일 이유(헤테로지니어스 합성)
Frame { Attribute(styleStore), Attribute(stateStore) } -- 여러 개 나란히 둬도 각자 자기 키만 반영(Tag와 동일)
```
`Attribute.Merged`가 내부적으로 하는 일은 각 Store에서 이름 붙은 `Source`
슬롯을 그대로 가져와 자기 자신의 key→Source 맵에 넣는 것 — 아래 "레이어드
Store 기각과 안 부딪히나" 참고.
### 메커니즘 — 메모이즈된 키로 기존 단일 키 경로에 재귀 위임
**[2026-08-11 아홉 번째 세션 후속, 개정]** 최초안은 "자기 완결형 Handler,
Dispatch 재진입 없이 직접 `SetAttribute`+수동 per-field StoreBind 구독"
이었으나, 위 "동등성" 절의 이름별 weak 캐시가 확정되며 그 회피 이유
자체가 없어짐 — `AttributeKey(name)`을 몇 번을 다시 불러도 항상 같은
객체가 나오므로, Dispatch의 `(inst, k)`별 핸들러 체인 추적이 깨질
위험이 없음. 그래서 그룹 Handler는 **자기만의 SetAttribute/구독 로직을
새로 만들지 않고, 각 필드를 기존 단일 키 `AttributeKey` 경로에 그대로
재귀 위임** — `None`/`retract`/store-bind 전부 이미 확정된 단일 키
메커니즘을 100% 재사용, 중복 구현 없음:
- **그룹 값이 (재)할당될 때**(마운트 시점, 또는 `:Compute`가 그룹 값
자체를 교체하는 드문 경우 — 흔한 필드 하나 변경과는 다른 경로, 아래
참고): `(inst, index)`(array-part 위치, `Tag``relate:GetStrong(inst,k)`
동일 키잉 — `k`는 배열 인덱스)로 찾은 릴레이션에 저장된 **"이전에 쓴
attribute 이름 문자열 집합"**과 새 값의 키 집합을 diff:
- 사라진 이름만 `Dispatch.retractUnder(inst, AttributeKey(name))`
메모이즈된 키라 그 이름이 살아있는 동안 만들어졌던 원래 체인과
정확히 같은 슬롯을 가리켜 정리됨.
- 남아있거나 새로 들어온 이름은 **값 비교 없이 전부**
`Dispatch.process(inst, AttributeKey(name), source)`로 넘김 — 작성자가
직접 `[AttributeKey<<T>> name] = source`를 쓴 것과 완전히 같은 경로를
타므로, `source``State`/`Source`면 `Dispatch/StoreBind`가 알아서
언랩+구독까지 다 해줌(그룹 Handler가 따로 구독 관리 안 함).
- **값 비교(`:Get()`으로 old/new 비교)는 하지 않음** — State 계약("값은
항상 선언된 Compute 재실행 결과, 캐시 비교 금지", `store-semantics.md`
"하드 경계" 절)과 어긋나고, 릴레이션 키를 문자열 이름 집합(존재 여부)만
들고 있으면 충분해서 굳이 값까지 비교할 이유가 없음.
- **필드 하나만 바뀌는 흔한 경우**(`storeA.foo:Set(v)`, 그룹 자체는
안 바뀜)는 위 그룹 재처리를 아예 거치지 않음 — 마운트 시 이미 걸린
단일 키 `AttributeKeyHandler`의 store-bind 구독이 바로
`SetAttribute("foo", v)`를 호출(그룹 재진입 없이 그 경로 스스로).
**`AttributeChanged`/`GetAttributeChangedSignal` 남발 걱정 없음** —
키 집합이 안 바뀌는 한 diff 로직 자체가 안 돎.
- **`retract`**: 자기가 쓴 키 전부 `Dispatch.retractUnder(inst,
AttributeKey(name))`로 정리 — 단일 키의 `SetAttribute(name, nil)`
그대로 실행되므로 결과적으로 `Tag`와 동일하게 확실히 청소됨. Roblox는
Instance 풀링/재사용이 흔해서, 이전 컴포넌트가 남긴 attribute가
재사용된 Instance에 방치되면 selector/스타일시트 로직에 실제 버그가
됨. ("소진돼도 부작용 없으니 방치해도 된다"는 초안은 기각 — Tag의
확실한 청소 원칙과 통일.)
**그룹 Handler에 남는 자기 로직은 사실상 "이름 집합 diff"뿐** — 실제
`SetAttribute` 호출/`None` 처리/store-bind 구독은 전부 기존 단일 키
경로가 그대로 담당. `TagHandler``CollectionService` 호출을 직접 하는
것과 달리, 여기서는 그 실행 자체를 위임한다는 점이 다름(Attribute만
"이미 완성된 재사용 가능한 단일 키 경로"가 있어서 가능한 차이 — Tag는
애초에 이름 하나짜리 단일 키 대응물이 없음).
### `Attribute.Merged`가 레이어드 Store 기각과 안 부딪히나 — 점검, 안 부딪힘
`archive/context-rejected.md`가 기각한 "레이어드 Store"는 **읽는 시점에
부모 체인을 암묵적으로 거슬러 올라가는 자동 폴백**(`__index` 체인, "이
값이 왜 이거지"를 추적하려면 부모 체인을 다 훑어야 하는 디버깅 불투명성)이
문제였음. `Attribute.Merged`는:
- **작성 시점에 명시적으로 한 번** 여러 Store의 `Source` 참조를 평탄한
자기 맵으로 모으는 것 — 런타임 암묵 폴백 체인이 없음(읽을 때 부모를
거슬러 올라가지 않음, 그냥 자기 맵을 봄).
- 범용 값 컨테이너의 레이어링이 아니라, `Modifier``Overridden`("이미
계산된 걸 합침")과 같은 **목적성 있는 값 객체의 조립** — dispatch에
참여하기 위해 만들어진 전용 값 객체지, "아무 값이나 담는 컨테이너"가
레이어링되는 게 아님.
기각 사유(범용 컨테이너의 암묵적 런타임 폴백)가 이 케이스엔 적용 안 됨 —
새 primitive 추가에 문제없음.
### 패키지 배치 — Tag와 동일 원칙
값 타입+API(`Attribute(...)`/`Merged`)는 quad-base(엔진 무관, 순수 데이터+
연산). `SetAttribute` 실제 호출 글루만 quad-roblox.
## 패키지 배치 (단일 키 `AttributeKey`)
UICorner 숏핸드/Tween/Tag와 같은 판단 재사용 — `quad-roblox` 코어에 직접 UICorner 숏핸드/Tween/Tag와 같은 판단 재사용 — `quad-roblox` 코어에 직접
포함, 별도 opt-out 패키지로 안 쪼갬. 포함, 별도 opt-out 패키지로 안 쪼갬.
## 열린 질문 (`.claude/question.md`에도 취합) ## 열린 질문 (`.claude/question.md`에도 취합)
- 타입 파라미터화 이름(`Attribute<T>` 제네릭 vs `BooleanAttribute`류 정적 - **이름은 잠정 확정, 최종 확정은 대기열**: 겹침 방지를 위해 그룹 값은
패밀리 vs 절충) — 위 "문제" 절 참고, 다음 세션 사용자 판단 필요. `Attribute`, 단일 키는 `AttributeKey<<T>>`로 코드/문서 전체 통일해서
당장의 해석 모호성은 없앴음 — 그래도 최종 이름은 다른 가칭들(`State`/
`DI`→`D`/`Slot`/`canExecute`/`Brand`)과 함께 `.claude/question.md` 용어정리
대기열에 있음, 나중에 한꺼번에 재검토.

View file

@ -2224,10 +2224,13 @@ DI 키로 확정(2026-08-10 세션).** 이벤트는 `inst[key]`가 이미 Signal
## Tag/Attribute 특수 키 — 전용 문서로 분리됨 (2026-08-07 여덟 번째 세션) ## Tag/Attribute 특수 키 — 전용 문서로 분리됨 (2026-08-07 여덟 번째 세션)
`base/tag-plan.md`/`base/attribute-plan.md`로 이동 — 이 절이 다루던 타입 `base/tag-plan.md`/`base/attribute-plan.md`로 이동 — 이 절이 다루던 타입
파라미터화 문제(`[Attribute<<boolean>> "name"]` vs `[BooleanAttribute 파라미터화 문제(`[AttributeKey<<boolean>> "name"]`(구 `Attribute<<boolean>>`)
"name"]`)뿐 아니라 `None`/`process`/`retract` 동작까지 확정 반영됨. vs `[BooleanAttribute "name"]`)뿐 아니라 `None`/`process`/`retract` 동작까지
UICorner 숏핸드/Tween처럼 "1 프리미티브 1 파일" 관례를 따라야 한다는 확정 반영됨. UICorner 숏핸드/Tween처럼 "1 프리미티브 1 파일" 관례를 따라야
지적으로 분리. 한다는 지적으로 분리. **[2026-08-11 아홉 번째 세션]** `attribute-plan.md`
여러 Store를 한 번에 attribute로 묶는 그룹 `Attribute(...)` 프리미티브(`Tag`와
동형)가 추가되며, 단일 키 생성자는 이름 충돌 방지로 `AttributeKey<<T>>`
리네임됨.
## `Brand` — 런타임 nominal 타입 판별 통합 메커니즘, `isState`를 일반화 (2026-08-07 여덟 번째 세션) ## `Brand` — 런타임 nominal 타입 판별 통합 메커니즘, `isState`를 일반화 (2026-08-07 여덟 번째 세션)

View file

@ -1,7 +1,9 @@
# `OnChange` 특수 키 — `GetPropertyChangedSignal` 바인딩 # `OnChange` 특수 키 — `GetPropertyChangedSignal` 바인딩
**상태**: base — 2026-08-10 세션에서 확정. quad-roblox 전용(값 타입/API **상태**: base — 2026-08-10 세션에서 확정. quad-roblox 전용(값 타입/API
레이어 없음, `Attribute`와 같은 패키지 배치). 레이어 없음, `Attribute`와 같은 패키지 배치). **[2026-08-11 아홉 번째
세션 후속]** `AttributeKey`와 동일한 이름별 weak 캐시로
`OnChange(a) == OnChange(a)` 동등성도 확정 — 아래 "확정" 절 참고.
## 문제 ## 문제
@ -16,7 +18,10 @@
## 확정 ## 확정
- **`OnChange(propertyName): OnChangeKey`** — 프로퍼티 이름을 감싸는 DI 키 - **`OnChange(propertyName): OnChangeKey`** — 프로퍼티 이름을 감싸는 DI 키
팩토리, `Attribute(name)`/`Tag(...)`와 같은 패턴. 사용 예: 팩토리, `AttributeKey(name)`/`Tag(...)`와 같은 패턴(`AttributeKey`는 구
`Attribute` — 2026-08-11 아홉 번째 세션에 여러 Store를 묶는 그룹
`Attribute(...)` 프리미티브가 신설되며 이름 충돌 방지로 리네임됨,
`base/attribute-plan.md` 참고). 사용 예:
`Frame { [OnChange "Position"] = function(v: UDim2) ... end }`. `Frame { [OnChange "Position"] = function(v: UDim2) ... end }`.
- **제네릭 타입 파라미터 없음 — `OnChange<<T>>` 같은 타입 파라미터화는 안 - **제네릭 타입 파라미터 없음 — `OnChange<<T>>` 같은 타입 파라미터화는 안
함.** 콜백 파라미터 타입은 호출부가 인라인으로 직접 명시 함.** 콜백 파라미터 타입은 호출부가 인라인으로 직접 명시
@ -25,15 +30,17 @@
Luau가 검증 못 하는 대가를 받아들인다"는 결정(`bind-system-plan.md` "이벤트 Luau가 검증 못 하는 대가를 받아들인다"는 결정(`bind-system-plan.md` "이벤트
바인딩 — `On.EventName` 도트액세스 안 씀" 절, "타입 안전성을 어느 정도 바인딩 — `On.EventName` 도트액세스 안 씀" 절, "타입 안전성을 어느 정도
포기하는 대가")과 같은 급의 트레이드오프라 새로 정당화할 것 없음 — 오히려 포기하는 대가")과 같은 급의 트레이드오프라 새로 정당화할 것 없음 — 오히려
`Attribute<<T>>`처럼 제네릭으로 정확히 맞추려는 시도는 이벤트 키보다 더 `AttributeKey<<T>>`처럼 제네릭으로 정확히 맞추려는 시도는 이벤트 키보다 더
엄격한 걸 요구하는 셈이라 일관성이 깨짐. 엄격한 걸 요구하는 셈이라 일관성이 깨짐.
- **기각안 — 프로퍼티별 정적 `OnChange.PropertyName` 전량 코드 생성**: - **기각안 — 프로퍼티별 정적 `OnChange.PropertyName` 전량 코드 생성**:
`archive/onchange-per-property-codegen-rejected.md` 참고. Attribute의 `archive/onchange-per-property-codegen-rejected.md` 참고. Attribute의
"제네릭 + 자주 쓰는 것만 정적 지름길" 절충과 겉보기엔 비슷해 보이지만 "제네릭 + 자주 쓰는 것만 정적 지름길" 절충과 겉보기엔 비슷해 보이지만
규모가 다른 문제라 기각. 규모가 다른 문제라 기각.
- **패키지 경계: 전부 quad-roblox**`Handlers/OnChange.luau``OnChange(name)` - **패키지 경계: 전부 quad-roblox**`Handlers/OnChange.luau``OnChange(name)`
키 팩토리와 Handler를 같이 둠(`Attribute.luau`와 같은 배치, base 쪽 값 키 팩토리와 Handler를 같이 둠(단일 키 `AttributeKey.luau`와 같은 배치,
타입 파일 없음). `GetPropertyChangedSignal` 자체가 Roblox 엔진 API라 base에 base 쪽 값 타입 파일 없음 — 그룹 `Attribute.luau`는 값 타입 자체가
quad-base 소속이라 다름, `base/attribute-plan.md` 참고).
`GetPropertyChangedSignal` 자체가 Roblox 엔진 API라 base에
둘 이유가 없음 — Tag처럼 백엔드 무관한 값/API 레이어가 따로 있는 경우와 둘 이유가 없음 — Tag처럼 백엔드 무관한 값/API 레이어가 따로 있는 경우와
다름. 다름.
- **`process(inst,k,v)`**: `inst:GetPropertyChangedSignal(name):Connect(function() - **`process(inst,k,v)`**: `inst:GetPropertyChangedSignal(name):Connect(function()
@ -46,13 +53,22 @@
구현하면 되고, `v`가 State/Source면 범용 `Dispatch/StoreBind.luau`가 알아서 구현하면 되고, `v`가 State/Source면 범용 `Dispatch/StoreBind.luau`가 알아서
언랩+재귀 재-dispatch해서 `process`를 다시 호출해줌. `OnChange` 전용 분기 언랩+재귀 재-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.luau`
`OnChange(name)` 팩토리에 `AttributeKey`와 동일한 캐시 구현.
## 다른 특수 DI 키와의 대조 ## 다른 특수 DI 키와의 대조
| | 소스 | 값 타입 | 패키지 경계 | | | 소스 | 값 타입 | 패키지 경계 |
|---|---|---|---| |---|---|---|---|
| 이벤트(`MouseButton1Click = fn`) | `inst[key]`가 이미 Signal | 콜백, 타입 미검증 | quad-roblox(`Handlers/Event.luau`) | | 이벤트(`MouseButton1Click = fn`) | `inst[key]`가 이미 Signal | 콜백, 타입 미검증 | quad-roblox(`Handlers/Event.luau`) |
| `Attribute(name)` | `SetAttribute`/`GetAttribute` | 값(제네릭 또는 정적 타입 패밀리로 타입 파라미터화) | quad-roblox(`Handlers/Attribute.luau`) | | `AttributeKey(name)` | `SetAttribute`/`GetAttribute` | 값(제네릭 또는 정적 타입 패밀리로 타입 파라미터화) | quad-roblox(`Handlers/AttributeKey.luau`) |
| `OnChange(name)` | `GetPropertyChangedSignal(name)` | 콜백, 타입 미검증(제네릭 없음) | quad-roblox(`Handlers/OnChange.luau`) | | `OnChange(name)` | `GetPropertyChangedSignal(name)` | 콜백, 타입 미검증(제네릭 없음) | quad-roblox(`Handlers/OnChange.luau`) |
`OnChange`가 Attribute처럼 제네릭화되지 않은 이유는 "콜백을 받는다"는 `OnChange`가 Attribute처럼 제네릭화되지 않은 이유는 "콜백을 받는다"는

View file

@ -59,7 +59,7 @@ Roblox Instance 이름과 맞춘 `UICorner`/`UIPadding`(+`UIPaddingOffset`)/
이미 예시로 든 `mod:UICorner(8)`은 이 특수 키를 flatten해서 props에 이미 예시로 든 `mod:UICorner(8)`은 이 특수 키를 flatten해서 props에
꽂아넣는 사탕 문법일 뿐, 실제 처리는 이 Handler가 함 — Modifier를 안 거치고 꽂아넣는 사탕 문법일 뿐, 실제 처리는 이 Handler가 함 — Modifier를 안 거치고
`Frame { UICorner = 8 }`처럼 순수 인라인 키로 직접 써도(v1처럼) 동일하게 `Frame { UICorner = 8 }`처럼 순수 인라인 키로 직접 써도(v1처럼) 동일하게
작동함, `architecture.md``[Attribute "Name"]`류 특수 키와 같은 층위. 작동함, `architecture.md``[AttributeKey "Name"]`류 특수 키와 같은 층위.
자동 생성된 자식은 기존 관례대로 `_`/`QUAD_` 접두어 네이밍 자동 생성된 자식은 기존 관례대로 `_`/`QUAD_` 접두어 네이밍
(`research/debug-tooling-plan.md` 9번, v1의 `_quad_round`류 그대로 재사용). (`research/debug-tooling-plan.md` 9번, v1의 `_quad_round`류 그대로 재사용).

View file

@ -1,6 +1,6 @@
--!strict --!strict
--[[ --[[
검증 대상: `[Attribute<<boolean>> "name"] = value`처럼 제네릭 파라미터로 검증 대상: `[AttributeKey<<boolean>> "name"] = value`처럼 제네릭 파라미터로
타입을 명시하는 특수 DI 키를 테이블 리터럴에 쓸 때, `=` 뒤 `value`의 타입을 명시하는 특수 DI 키를 테이블 리터럴에 쓸 때, `=` 뒤 `value`의
타입이 실제로 그 제네릭 파라미터로 좁혀지는지 — Luau 타입 솔버가 타입이 실제로 그 제네릭 파라미터로 좁혀지는지 — Luau 타입 솔버가
"이 계산된 키의 제네릭 인스턴스에 따라 옆 값의 타입이 달라진다"는 "이 계산된 키의 제네릭 인스턴스에 따라 옆 값의 타입이 달라진다"는
@ -23,10 +23,10 @@
그대로 확인 가능함. 그대로 확인 가능함.
]] ]]
-- SpecialKey<T> — Attribute<<T>>(name)이 반환하는 "타입이 실린 키" 흉내 -- SpecialKey<T> — AttributeKey<<T>>(name)이 반환하는 "타입이 실린 키" 흉내
type SpecialKey<T> = { __attributeKeyBrand: T } type SpecialKey<T> = { __attributeKeyBrand: T }
local function Attribute<T>(name: string): SpecialKey<T> local function AttributeKey<T>(name: string): SpecialKey<T>
return (nil :: any) :: SpecialKey<T> return (nil :: any) :: SpecialKey<T>
end end
@ -39,7 +39,7 @@ type HomogeneousParams = {
} }
local homo: HomogeneousParams = { local homo: HomogeneousParams = {
[Attribute("Enabled")] = true, -- 이건 당연히 통과해야 함 [AttributeKey("Enabled")] = true, -- 이건 당연히 통과해야 함
} }
-- ===== 시도 2: 이질적(heterogeneous) — 한 테이블에 boolean/number Attribute를 섞음 ===== -- ===== 시도 2: 이질적(heterogeneous) — 한 테이블에 boolean/number Attribute를 섞음 =====
@ -50,11 +50,11 @@ local homo: HomogeneousParams = {
-- 뭉개짐, (c) 테이블 타입 자체를 선언하는 시점에 에러) -- 뭉개짐, (c) 테이블 타입 자체를 선언하는 시점에 에러)
local mixedProps: { [SpecialKey<any>]: any } = {} -- 일단 any로 도피한 버전(항상 통과할 것) local mixedProps: { [SpecialKey<any>]: any } = {} -- 일단 any로 도피한 버전(항상 통과할 것)
mixedProps[Attribute("Enabled")] = true mixedProps[AttributeKey("Enabled")] = true
mixedProps[Attribute("Count")] = 5 mixedProps[AttributeKey("Count")] = 5
-- 진짜 물어볼 질문: 개별 대입 표현식 하나만 놓고 봤을 때, Luau가 -- 진짜 물어볼 질문: 개별 대입 표현식 하나만 놓고 봤을 때, Luau가
-- `Attribute<T>(name)`의 제네릭 인스턴스화 결과로 옆의 값 리터럴 타입을 -- `AttributeKey<T>(name)`의 제네릭 인스턴스화 결과로 옆의 값 리터럴 타입을
-- 체크/추론해주는지 — 함수 호출 결과 타입과 그 옆 대입값 사이의 관계는 -- 체크/추론해주는지 — 함수 호출 결과 타입과 그 옆 대입값 사이의 관계는
-- "인덱스 시그니처"가 아니라 그냥 "함수 반환 타입에 맞는 변수 대입" -- "인덱스 시그니처"가 아니라 그냥 "함수 반환 타입에 맞는 변수 대입"
-- 문제로 좁혀서 아래처럼 직접 테스트: -- 문제로 좁혀서 아래처럼 직접 테스트:
@ -64,12 +64,12 @@ local function setAttributeTyped<T>(key: SpecialKey<T>, value: T)
-- 이 스파이크는 타입 추론 자체만 봄 -- 이 스파이크는 타입 추론 자체만 봄
end end
setAttributeTyped(Attribute("Enabled"), true) -- T=boolean으로 추론돼 통과해야 함 setAttributeTyped(AttributeKey("Enabled"), true) -- T=boolean으로 추론돼 통과해야 함
setAttributeTyped(Attribute("Count"), 5) -- T=number로 추론돼 통과해야 함 setAttributeTyped(AttributeKey("Count"), 5) -- T=number로 추론돼 통과해야 함
setAttributeTyped(Attribute("Enabled"), 5) -- <- 여기가 핵심: T=boolean인데 5(number)를 넘김. setAttributeTyped(AttributeKey("Enabled"), 5) -- <- 여기가 핵심: T=boolean인데 5(number)를 넘김.
-- 이게 타입 에러로 잡히면(기대하는 결과) "제네릭 키 함수 호출 패턴"은 -- 이게 타입 에러로 잡히면(기대하는 결과) "제네릭 키 함수 호출 패턴"은
-- 최소한 함수 인자 형태로는 잘 작동한다는 뜻 — 그럼 테이블 리터럴 -- 최소한 함수 인자 형태로는 잘 작동한다는 뜻 — 그럼 테이블 리터럴
-- `{[Attribute<<T>>(name)] = value}` 안에서도 Luau가 "이건 사실 -- `{[AttributeKey<<T>>(name)] = value}` 안에서도 Luau가 "이건 사실
-- 위 setAttributeTyped 호출과 같은 형태"로 취급해주는지가 다음 질문. -- 위 setAttributeTyped 호출과 같은 형태"로 취급해주는지가 다음 질문.
print("런타임 실행은 의미 없음 — luau-analyze/luau-lsp 진단만 확인할 것") print("런타임 실행은 의미 없음 — luau-analyze/luau-lsp 진단만 확인할 것")
@ -80,7 +80,7 @@ print(homo, mixedProps)
1. `setAttributeTyped(Attribute("Enabled"), 5)` 줄에서 실제로 타입 1. `setAttributeTyped(Attribute("Enabled"), 5)` 줄에서 실제로 타입
에러가 나는가? — 나면 "함수 인자 형태의 제네릭 키+값 연동"은 에러가 나는가? — 나면 "함수 인자 형태의 제네릭 키+값 연동"은
Luau가 지원한다는 뜻. Luau가 지원한다는 뜻.
2. 위가 통과한다면, 그 다음으로 `mixedProps[Attribute("Enabled")] = 2. 위가 통과한다면, 그 다음으로 `mixedProps[AttributeKey("Enabled")] =
5`처럼 **인덱스 대입 문법**으로도 같은 체크가 되는지 직접 추가해 5`처럼 **인덱스 대입 문법**으로도 같은 체크가 되는지 직접 추가해
실험해볼 것(이 파일엔 일부러 안 넣어둠 — `{[SpecialKey<T>]: T}`류 실험해볼 것(이 파일엔 일부러 안 넣어둠 — `{[SpecialKey<T>]: T}`류
제네릭 인덱스 시그니처를 실제로 선언할 수 있는지부터 luau-lsp가 제네릭 인덱스 시그니처를 실제로 선언할 수 있는지부터 luau-lsp가

View file

@ -52,7 +52,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해
| `09-type-modifier-overridden-subtype.luau` (타입체크 전용) | `FrameModifier <: GuiObjectModifier`처럼 서브타입 관계인 Modifier를 `Overridden`으로 섞을 때 타입이 통과하는지 | `modifier-plan.md` 9-2번, ROADMAP M7 | | `09-type-modifier-overridden-subtype.luau` (타입체크 전용) | `FrameModifier <: GuiObjectModifier`처럼 서브타입 관계인 Modifier를 `Overridden`으로 섞을 때 타입이 통과하는지 | `modifier-plan.md` 9-2번, ROADMAP M7 |
| `10-roblox-studio-checks.server.luau` (Studio 전용) | (A) `bindLifetime`/`unbindLifetime`/`canExecute`/`canBound`의 gcconn 트릭 + 이중 바인딩 게이트(Destroy 시 Connected 전환 포함), (B) Attribute의 Instance 참조 타입 지원, (C) CollectionService 태그/GetTagged 왕복 | `lifecycle-pattern.md`, `bind-system-plan.md` "이중 바인딩 금지", CLAUDE.md 2026-08-06 세션, `debug-tooling-plan.md` | | `10-roblox-studio-checks.server.luau` (Studio 전용) | (A) `bindLifetime`/`unbindLifetime`/`canExecute`/`canBound`의 gcconn 트릭 + 이중 바인딩 게이트(Destroy 시 Connected 전환 포함), (B) Attribute의 Instance 참조 타입 지원, (C) CollectionService 태그/GetTagged 왕복 | `lifecycle-pattern.md`, `bind-system-plan.md` "이중 바인딩 금지", CLAUDE.md 2026-08-06 세션, `debug-tooling-plan.md` |
| `11-modifier-illegal-value-error.luau` | Modifier 필드에 Ref/PreRef/Observer/Effect/Slot/Modifier가 들어오면 즉시 error, State/Source가 확정하는 값이 Modifier면 즉시 error(2026-08-09 세션에 "UB"에서 전환된 규칙) | `modifier-plan.md` "핸들러 계층 값 즉시 error" 절 + 7번 절 | | `11-modifier-illegal-value-error.luau` | Modifier 필드에 Ref/PreRef/Observer/Effect/Slot/Modifier가 들어오면 즉시 error, State/Source가 확정하는 값이 Modifier면 즉시 error(2026-08-09 세션에 "UB"에서 전환된 규칙) | `modifier-plan.md` "핸들러 계층 값 즉시 error" 절 + 7번 절 |
| `12-type-attribute-generic-key-narrowing.luau` (타입체크 전용) | `[Attribute<<T>> "name"] = value`처럼 제네릭 DI 키를 쓸 때 `value`의 타입이 실제로 `T`로 좁혀지는지 — base 문서 자신이 "미검증"이라 명시한 항목 | `attribute-plan.md` "[실측 필요, M0/M10]" (2026-08-09 열한 번째 세션 신설) | | `12-type-attribute-generic-key-narrowing.luau` (타입체크 전용) | `[AttributeKey<<T>> "name"] = value`(구 `Attribute<<T>>`)처럼 제네릭 DI 키를 쓸 때 `value`의 타입이 실제로 `T`로 좁혀지는지 — base 문서 자신이 "미검증"이라 명시한 항목 | `attribute-plan.md` "[실측 필요, M0/M10]" (2026-08-09 열한 번째 세션 신설) |
| `13-type-ref-preref-subtype.luau` | (A, 타입) `PreRef<T>``Ref<T>`를 구조적으로 만족하는지, (B, 런타임) `isRef`/`isPreRef` 합성이 재정정대로 동작하는지(`isRef(preRefInstance)`가 이제 `true`) + Leaf 핸들러가 `isRef(v) and not isPreRef(v)`로 명시적으로 좁혀야 하는 이유 | `bind-system-plan.md``Brand` 절(2026-08-09 열한 번째 세션 재정정) | | `13-type-ref-preref-subtype.luau` | (A, 타입) `PreRef<T>``Ref<T>`를 구조적으로 만족하는지, (B, 런타임) `isRef`/`isPreRef` 합성이 재정정대로 동작하는지(`isRef(preRefInstance)`가 이제 `true`) + Leaf 핸들러가 `isRef(v) and not isPreRef(v)`로 명시적으로 좁혀야 하는 이유 | `bind-system-plan.md``Brand` 절(2026-08-09 열한 번째 세션 재정정) |
| `14-type-nilable-default-overload.luau` (타입체크 전용) | `Source(default)`/`Ref(default)`의 `default` 생략이 `T`가 nilable일 때만 안전하다는 캐비엇을, 함수 오버로드(교차 타입)로 실제로 타입 레벨에서 막을 수 있는지 | `bind-system-plan.md` "[보강, 2026-08-09 열한 번째 세션]" 절 | | `14-type-nilable-default-overload.luau` (타입체크 전용) | `Source(default)`/`Ref(default)`의 `default` 생략이 `T`가 nilable일 때만 안전하다는 캐비엇을, 함수 오버로드(교차 타입)로 실제로 타입 레벨에서 막을 수 있는지 | `bind-system-plan.md` "[보강, 2026-08-09 열한 번째 세션]" 절 |
| `15-type-compute-trailing-deps-typepack.luau` (타입체크 전용) | `:Compute(fn, ...)`의 trailing deps를 `fn`에 위치 인자(lazy State 핸들)로도 노출하는 확장, 최종 시그니처 `fn(self, previous?, ...deps)` — 이형(heterogeneous) 다중 deps를 제네릭 타입 팩(`U...`)으로 표현 가능한지, `previous?`가 팩 앞(정정된 순서)에서만 통과하고 팩 뒤(옛 순서)에서는 막히는지 | `bind-system-plan.md` "trailing deps를 fn에 lazy positional 인자로도 노출" 절(2026-08-11 후속 세션, 순서는 같은 날 세 번째 세션에 정정) | | `15-type-compute-trailing-deps-typepack.luau` (타입체크 전용) | `:Compute(fn, ...)`의 trailing deps를 `fn`에 위치 인자(lazy State 핸들)로도 노출하는 확장, 최종 시그니처 `fn(self, previous?, ...deps)` — 이형(heterogeneous) 다중 deps를 제네릭 타입 팩(`U...`)으로 표현 가능한지, `previous?`가 팩 앞(정정된 순서)에서만 통과하고 팩 뒤(옛 순서)에서는 막히는지 | `bind-system-plan.md` "trailing deps를 fn에 lazy positional 인자로도 노출" 절(2026-08-11 후속 세션, 순서는 같은 날 세 번째 세션에 정정) |

View file

@ -138,6 +138,15 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만**
"`Tag`가 이미 이 뜻으로 쓰이고 있어서 충돌"이라는 이유로 `Brand` "`Tag`가 이미 이 뜻으로 쓰이고 있어서 충돌"이라는 이유로 `Brand`
대안 이름 후보에서 제외됐다는 점은 참고할 것 — 두 이름이 같은 코퍼스 대안 이름 후보에서 제외됐다는 점은 참고할 것 — 두 이름이 같은 코퍼스
안에서 공존 가능한지도 같이 검토 대상. 안에서 공존 가능한지도 같이 검토 대상.
- **`Attribute`/`AttributeKey`(3순위, 사소함, 2026-08-11 아홉 번째 세션
추가)**: 여러 Store를 한 번에 attribute로 묶는 그룹 프리미티브
(`Attribute(store1, store2, ...)`, `Tag`와 동형)가 신설되면서, 기존
단일 키 생성자 `Attribute<<T>>("name")`를 이름 충돌 방지를 위해
`AttributeKey<<T>>`로 잠정 리네임함(`OnChange`/`OnChangeKey`처럼 함수
이름과 반환 타입 이름이 분리된 기존 전례와 대칭) — 해석 모호성 자체는
이미 없앴으니 급하지 않지만, 최종 이름은 여전히 이 목록의 다른
가칭들과 함께 검토 대상. `base/attribute-plan.md` "그룹 `Attribute(...)`"
절 참고.
### 2. 구현 착수 직전 감사 결과 (2026-08-06 신설, M0 착수 전 확인 권장) ### 2. 구현 착수 직전 감사 결과 (2026-08-06 신설, M0 착수 전 확인 권장)
@ -228,11 +237,12 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만**
패턴)** — `research/documentation-plan.md`(뼈대만). 정식 백로그 항목으로 패턴)** — `research/documentation-plan.md`(뼈대만). 정식 백로그 항목으로
올릴지, 착수 시점을 언제로 볼지 사용자 판단 필요. 올릴지, 착수 시점을 언제로 볼지 사용자 판단 필요.
- **[해소됨, 2026-08-09 열한 번째 세션]** Attribute 특수 키 타입 - **[해소됨, 2026-08-09 열한 번째 세션]** Attribute 특수 키 타입
파라미터화 — `[Attribute<<boolean>> "name"]` 제네릭 스타일과 파라미터화 — `[AttributeKey<<boolean>> "name"]`(구 `Attribute<<boolean>>`,
`[BooleanAttribute "name"]` 타입별 정적 생성자 패밀리 **둘 다 채택으로 2026-08-11 아홉 번째 세션에 그룹 `Attribute(...)`와의 이름 충돌 방지로
확정**(내부 구현 동일, 호출부 표기만 다름). `base/attribute-plan.md` 리네임) 제네릭 스타일과 `[BooleanAttribute "name"]` 타입별 정적 생성자
참고 — 제네릭 파라미터가 `=` 뒤 값 타입까지 좁혀주는지는 M0/M10에서 패밀리 **둘 다 채택으로 확정**(내부 구현 동일, 호출부 표기만 다름).
실측 필요(안 돼도 런타임엔 영향 없음). `base/attribute-plan.md` 참고 — 제네릭 파라미터가 `=` 뒤 값 타입까지
좁혀주는지는 M0/M10에서 실측 필요(안 돼도 런타임엔 영향 없음).
- **v1 하위호환(compat) 레이어 — `quad-roblox-v1-compat`** - **v1 하위호환(compat) 레이어 — `quad-roblox-v1-compat`**
`research/v1-compat-plan.md`(신규, 2026-08-06, 두 차례 후속 논의로 수렴). `research/v1-compat-plan.md`(신규, 2026-08-06, 두 차례 후속 논의로 수렴).
방향 확정: v1을 그대로 병행 실행 + 경계에서만 `state:Observer()`(lazy 방향 확정: v1을 그대로 병행 실행 + 경계에서만 `state:Observer()`(lazy

View file

@ -0,0 +1,140 @@
<!-- quad-v2 세션 로그 원문. -->
<!-- 이 파일은 quadnomicon 개발로그 소재용 원자료로, 당시 논의(시행착오 포함)를 그대로 보존함. -->
<!-- 현재 유효한 설계는 이 파일이 아니라 base//research//archive/가 최종 소스 — 이 파일 안의 판단이 이후 세션에서 뒤집혔을 수 있음. -->
## 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 착수 우선순위 자체는
그대로.

View file

@ -410,3 +410,15 @@ CLAUDE.md가 세션 로그 누적으로 3196줄까지 불어나 컨텍스트 성
해왔음을 `README.md`/`question.md` 대조로 확인(반영 누락 없음 — archive 해왔음을 `README.md`/`question.md` 대조로 확인(반영 누락 없음 — archive
이전 전 처리 불필요). 새 서사가 CLAUDE.md에 다시 쌓이지 않도록 "이 절을 이전 전 처리 불필요). 새 서사가 CLAUDE.md에 다시 쌓이지 않도록 "이 절을
갱신하는 방법" 절을 세션 히스토리 맨 위에 명문화. 갱신하는 방법" 절을 세션 히스토리 맨 위에 명문화.
**2026-08-11 아홉 번째 세션 — 그룹 `Attribute(...)` 프리미티브, 단일 키 `AttributeKey`로 리네임, 이름별 weak 캐시**
(`session/2026-08-11-09-attribute-group-primitive.md`)
여러 Store를 한 번에 attribute로 묶는 `Attribute(store1, store2, ...)`
`Tag`와 동형인 array-part 값 객체로 신설(`Merged`로 헤테로지니어스 합성,
retract는 Tag처럼 확실히 청소). 이름 충돌 방지로 기존 단일 키
`Attribute<<T>>``AttributeKey<<T>>`로 즉시 리네임(용어정리 대기열이
아니라 지금 바로 적용, 최종 이름만 대기열에 남김). **후속**: `AttributeKey(name)`
이름별 weak 캐시로 동등성(`AttributeKey(a) == AttributeKey(a)`)을 보장하도록
확정되며, 최초안이던 "그룹 Handler 자기 완결형(Dispatch 재진입 없음)"을
뒤집고 메모이즈된 키로 기존 단일 키 경로에 재귀 위임하는 걸로 재개정
(중복 구현 제거) — `base/attribute-plan.md` 등 관련 문서 전체 반영 완료.

View file

@ -80,17 +80,23 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
여덟 번째 세션 정정) 여덟 번째 세션 정정)
- [ ] `Brand.luau`(공유 weak-key 레지스트리, `Brand.set(x,tag)`/ - [ ] `Brand.luau`(공유 weak-key 레지스트리, `Brand.set(x,tag)`/
`Brand.get(x)``isState`뿐 아니라 `isObserver`/`isEffect`/`isTag`/ `Brand.get(x)``isState`뿐 아니라 `isObserver`/`isEffect`/`isTag`/
`isAttribute`/`isTween`/`isBlocker`/`isSource`/`isStore`/`isSlot`/ `isAttributeKey`/`isAttribute`/`isTween`/`isBlocker`/`isSource`/
`isRef`/`isPreRef`/`isModifier`(2026-08-07 열 번째 세션 추가 — 원래 `isStore`/`isSlot`/`isRef`/`isPreRef`/`isModifier`(2026-08-07 열 번째
태그 목록에서 빠져있었음. **[정정, 2026-08-09 열한 번째 세션]** 세션 추가 — 원래 태그 목록에서 빠져있었음. **[정정, 2026-08-09
`isRef`/`isPreRef`는 `isState`처럼 상위-하위 관계로 재정정됨 — 열한 번째 세션]** `isRef`/`isPreRef`는 `isState`처럼 상위-하위 관계로
`isPreRef`가 가장 구체적인 항등, `isRef`는 그 위에 얹혀 재정정됨 — `isPreRef`가 가장 구체적인 항등, `isRef`는 그 위에 얹혀
`isPreRef``true`로 통과시킴(PreRef가 Ref 런타임을 재사용하는 `isPreRef``true`로 통과시킴(PreRef가 Ref 런타임을 재사용하는
것과 정합). `(v=Ref)` children leaf 매치 핸들러는 이제 것과 정합). `(v=Ref)` children leaf 매치 핸들러는 이제
`isRef(v) and not isPreRef(v)`로 명시적으로 좁혀야 함. `isModifier` `isRef(v) and not isPreRef(v)`로 명시적으로 좁혀야 함. `isModifier`
여전히 단순 항등, 상위 개념 없음) 전부의 기반. `isNone`만 예외로 여전히 단순 항등, 상위 개념 없음. **[정정, 2026-08-11 아홉 번째
레지스트리 없이 `x == None` 항등 비교 — `bind-system-plan.md` 세션]** `isAttribute` 하나였던 게 `isAttributeKey`(단일 키 DI 키
`Brand` 절, 2026-08-07 여덟 번째 세션 신설) predicate, 해시파트 `k`를 판별)와 `isAttribute`(그룹 값 predicate,
array-part `v`를 판별, `isTag`와 같은 결)로 분리됨 — 그룹
`Attribute(...)` 프리미티브 신설로 같은 이름이 서로 다른 두
대상(키 vs 값)을 가리키게 돼서 갈라짐, `base/attribute-plan.md`
참고) 전부의 기반. `isNone`만 예외로 레지스트리 없이 `x == None`
항등 비교 — `bind-system-plan.md``Brand` 절, 2026-08-07 여덟
번째 세션 신설)
- [ ] `Relate.luau`(전체가 quad-base, 순수 Lua — `base/relate-plan.md`) — - [ ] `Relate.luau`(전체가 quad-base, 순수 Lua — `base/relate-plan.md`) —
`Relate()` 비싱글톤 생성자, `:SetWeak`/`:GetWeak`/`:SetStrong`/`:GetStrong`. `Relate()` 비싱글톤 생성자, `:SetWeak`/`:GetWeak`/`:SetStrong`/`:GetStrong`.
`inst`(첫 인자)는 항상 weak, `StrongMap`/`WeakMap` 서브테이블은 lazy `inst`(첫 인자)는 항상 weak, `StrongMap`/`WeakMap` 서브테이블은 lazy
@ -438,9 +444,21 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
- [ ] `Handlers/Event.luau`(`ReflectionService` 기반 자동 판별) - [ ] `Handlers/Event.luau`(`ReflectionService` 기반 자동 판별)
- [ ] `Handlers/OnChange.luau`(`OnChange(name)` DI 키 팩토리+Handler, - [ ] `Handlers/OnChange.luau`(`OnChange(name)` DI 키 팩토리+Handler,
`GetPropertyChangedSignal` 바인딩 — 제네릭 없이 콜백 타입은 인라인 `GetPropertyChangedSignal` 바인딩 — 제네릭 없이 콜백 타입은 인라인
명시, `base/onchange-plan.md`, 2026-08-10 세션 확정) 명시, 이름별 weak 캐시로 `OnChange(a) == OnChange(a)` 동등성 보장
- [ ] `Handlers/Attribute.luau`(`base/attribute-plan.md` — 메커니즘/`None`/ (`AttributeKey`와 동일 기법), `base/onchange-plan.md`, 2026-08-10
`retract` 불필요 확정, 타입 파라미터화 이름만 착수 전 확인) 세션 확정·2026-08-11 아홉 번째 세션 후속(캐시))
- [ ] `Handlers/AttributeKey.luau`(단일 키 `AttributeKey<<T>>(name)`/
`BooleanAttribute`류 DI 키 팩토리+Handler — 메커니즘/`None`/`retract`
불필요 확정, 이름별 weak 캐시로 동등성 보장, 타입 파라미터화 이름만
착수 전 확인, `base/attribute-plan.md`)
- [ ] `Attribute.luau`(quad-base — 그룹 값 타입+API: `Attribute(store1,
store2, ...)`/`Merged`, `Tag`와 동형 array-part 값 객체,
`base/attribute-plan.md`)
- [ ] `Handlers/Attribute.luau`(quad-roblox — 그룹 process/retract, 이전
이름 집합과의 diff만 자체 로직, 실제 `SetAttribute`/store-bind
구독은 메모이즈된 `AttributeKey(name)``Dispatch.process`/
`retractUnder`에 재귀 위임(단일 키 경로 재사용, 중복 구현 없음),
`base/attribute-plan.md`)
- [ ] `Tag.luau`(quad-base — 값 타입+immutable clone 체이닝: `Tag(...)`/ - [ ] `Tag.luau`(quad-base — 값 타입+immutable clone 체이닝: `Tag(...)`/
`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`, `base/tag-plan.md` `:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`, `base/tag-plan.md`
— 2026-08-08 세 번째 세션 array-part 값 객체로 재설계, 구 해시 파트 — 2026-08-08 세 번째 세션 array-part 값 객체로 재설계, 구 해시 파트