decide(base): None 센티널, Dispatch 네이밍, Brand 판별 메커니즘 확정
- Modifier 필드를 인라인 키/setter로 명시적으로 지우는 `None` 센티널 확정 — merge는 안 바뀌고, 디스패치 쪽 NoneHandler가 Tween store-bind와 같은 재귀 재디스패치로 처리(base 드라이버/개별 핸들러 시그니처 불변). - Dispatch.getHandler/process/addHandler/drive로 오케스트레이터 이름 공식화, isHandlable에 inst 추가, canExecute 시그니처를 (handle)->boolean 으로 정정(zero-arg 클로저 폐기). - isState를 Brand 공유 레지스트리로 일반화해 isObserver/isSource/isTag 등 10종 판별자로 확장, isSource 별도 필요하다고 정정. - Tag/Attribute retract 불필요함을 확인, 전용 문서(tag-plan.md/ attribute-plan.md) 신설. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
71729db816
commit
d947abf17a
11 changed files with 658 additions and 122 deletions
|
|
@ -36,7 +36,9 @@
|
|||
| `component-composition-plan.md` | 컴포넌트=플레인 함수, State/Source 읽기·쓰기 경계, Source가 State를 구조적으로 만족 — modifier/Ref 컴포넌트 경계 통과까지 전부 확정, 남은 건 API 이름뿐. **[2026-08-07 정리]** 폐기된 `StoreSource` 프록시 설계로의 역전 이력은 본문에서 빼고 `archive/store-source-proxy-reversed.md` 포인터로 압축 |
|
||||
| `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` 참고 |
|
||||
| `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-07 여덟 번째 세션 신설]** `[Tag "Name"] = boolean` — `CollectionService` 얇은 래퍼, `process`가 add/remove 전부 처리, `retract` 불필요 |
|
||||
| `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** `[Attribute "Name"]` — `SetAttribute(name, nil)`이 네이티브 지우기라 `None` 센티널과 가장 깔끔하게 맞아떨어짐, `retract` 불필요. 타입 파라미터화 이름(`Attribute<T>` vs `BooleanAttribute`류)만 미확정 |
|
||||
|
||||
## `reference/` — 온디맨드 참고 자료 (2026-08-07 신설)
|
||||
|
||||
|
|
|
|||
72
.claude/base/attribute-plan.md
Normal file
72
.claude/base/attribute-plan.md
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# Attribute 특수 키 — 타입 파라미터화, `SetAttribute(name, nil)` 네이티브 지우기
|
||||
|
||||
**상태**: base(메커니즘/`None`/`retract` 동작은 확정) — 타입 파라미터화
|
||||
이름만 미확정. `[Attribute "Name"]` DI 키의 존재 자체는 `architecture.md`
|
||||
4번 항목에서 이미 확정. UICorner 숏핸드/Tween처럼 별도 전용 문서가 없던 걸
|
||||
2026-08-07 여덟 번째 세션에 메꿈("1 프리미티브 1 파일" 관례를 Tag/Attribute
|
||||
에도 적용해야 한다는 사용자 지적) — `bind-system-plan.md`의 "Attribute
|
||||
특수 키 — 타입 파라미터화" 절(2026-08-06 신설) 내용을 그대로 옮기고, 오늘
|
||||
논의한 `None`/`process`/`retract` 동작을 추가.
|
||||
|
||||
## 문제 — 타입 있는 값이라 Luau가 좁혀줄 방법이 필요
|
||||
|
||||
Roblox Attribute는 Instance/Tag와 달리 실제로 **타입이 있는 값**
|
||||
(string/boolean/number/Color3/UDim/UDim2/Vector2/Vector3/CFrame/Instance
|
||||
참조 등 제한된 프리미티브 집합, 테이블 등 복합 타입은 지원 안 함)이라, 그냥
|
||||
`[Attribute "name"] = value`로 두면 `value`의 타입을 Luau가 좁혀줄 방법이
|
||||
없음. 커스텀/복합 데이터(테이블 등)는 애초에 Attribute가 지원을 안 하므로
|
||||
Ref(직접 참조 획득) 쪽으로 빠지는 게 맞고, Attribute는 프리미티브 전용으로
|
||||
남기면 된다는 게 사용자 판단 — Value 오브젝트가 역사적으로 Attribute의
|
||||
대안(테이블/참조를 담는 용도)으로 나온 배경이지만, 지금은 Roblox Attribute가
|
||||
Instance 참조 타입도 지원해서 `ObjectValue` 없이도 Ref 용도로 Attribute를
|
||||
그대로 쓸 수 있다는 점을 사용자가 짚음(`research/debug-tooling-plan.md`의
|
||||
"Value 오브젝트 기각, Attribute로 확정" 결정과 같은 방향 — Instance 타입
|
||||
지원까지 감안하면 그 결정의 근거가 한층 더 탄탄해짐).
|
||||
|
||||
**후보 두 가지 (미확정)**:
|
||||
- `[Attribute<<boolean>> "name"] = true` (리터럴 또는 store-bind 값) —
|
||||
제네릭 파라미터로 타입을 명시하는 제네릭 생성자 스타일.
|
||||
- `[BooleanAttribute "name"] = true` — 타입별로 이름이 다른 정적 생성자
|
||||
패밀리(`StringAttribute`/`NumberAttribute`/`Color3Attribute`/
|
||||
`InstanceAttribute` 등).
|
||||
|
||||
**소견(확정 아님, 검토 필요)**: 이 선택은 이미 확정된 DI 인스턴스 생성
|
||||
패턴(`bind-system-plan.md` "인스턴스 생성 / 이벤트 네이밍 인체공학" 절)과
|
||||
구조적으로 똑같은 문제 — 그때도 "제네릭 하나로 다 커버할지 vs 타입별 정적
|
||||
필드로 나눌지" 고민이 있었고, 결론은 **둘 다**(`new<ClassName>(className)`
|
||||
제네릭 생성자 + 자주 쓰는 ~25개는 정적 필드로 미리 바인딩)였음. Attribute도
|
||||
같은 모양을 재사용하면 자연스러울 가능성 — `Attribute<T>("name")` 제네릭을
|
||||
기본으로 두고, 실사용 빈도가 압도적으로 높을 `Boolean`/`Number`/`String`/
|
||||
`Instance` 정도만 `BooleanAttribute`/`NumberAttribute`/`StringAttribute`/
|
||||
`InstanceAttribute` 같은 지름길로 정적 바인딩하는 절충. 사용자 확인 전
|
||||
소견일 뿐 — `.claude/question.md`에 반영, 사용자 판단 필요.
|
||||
|
||||
## 메커니즘, `None`, `retract` — 전부 확정 (2026-08-07 여덟 번째 세션)
|
||||
|
||||
타입 파라미터화 이름과 무관하게 런타임 동작은 확정:
|
||||
|
||||
- `process(inst, k, v)` — `inst:SetAttribute(name, v)`가 사실상 전부.
|
||||
**Attribute는 `None`의 가장 깔끔한 사례** — Roblox API 자체가
|
||||
`SetAttribute(name, nil)`을 "그 Attribute 엔트리를 지운다"는 뜻으로
|
||||
네이티브 지원하므로, `None → nil` 재디스패치(`base/bind-system-plan.md`의
|
||||
`None` 센티널 절)가 도착했을 때 handler가 **아무 특별 처리도 없이**
|
||||
`inst:SetAttribute(name, nil)`을 그대로 호출하면 끝 — UICorner 숏핸드처럼
|
||||
"만들어둔 자식을 수동으로 찾아 지우는" 로직조차 필요 없음.
|
||||
- **`retract` 불필요** — Tag와 같은 이유: 값이 뭐든(실제 값/`nil`) 항상
|
||||
같은 `AttributeHandler`가 이 키를 계속 담당(핸들러 *타입*이 안 바뀜).
|
||||
`retract`가 의미 있는 유일한 패턴("매치되는 핸들러 타입 자체가 바뀜",
|
||||
Tween↔일반 프로퍼티가 실사례)에 해당 안 함 — `bind-system-plan.md`
|
||||
"확정된 디스패치 모델" 절이 한때 Attribute도 retract 필요 예시로 들었던
|
||||
걸 여기서 바로잡음.
|
||||
- store-bind 가능(일반 프로퍼티와 동일하게 취급, `Store<T>`/`State<T>`
|
||||
값도 받음).
|
||||
|
||||
## 패키지 배치
|
||||
|
||||
UICorner 숏핸드/Tween/Tag와 같은 판단 재사용 — `quad-roblox` 코어에 직접
|
||||
포함, 별도 opt-out 패키지로 안 쪼갬.
|
||||
|
||||
## 열린 질문 (`.claude/question.md`에도 취합)
|
||||
|
||||
- 타입 파라미터화 이름(`Attribute<T>` 제네릭 vs `BooleanAttribute`류 정적
|
||||
패밀리 vs 절충) — 위 "문제" 절 참고, 다음 세션 사용자 판단 필요.
|
||||
|
|
@ -24,12 +24,20 @@ v1의 `ProcessQuadProperty`(`.claude/initreq/quad/src/class.lua:134-214`)는
|
|||
|
||||
핸들러는 다음 4개를 제공하는 등록 가능한 객체:
|
||||
|
||||
- `isHandlable(key, value): boolean` — 이 핸들러가 이 key/value 쌍을 처리할
|
||||
수 있는지 판별하는 predicate. **부작용 없이, 빠르게** — tbox의 type-check/
|
||||
constraint-check 분리 원칙(`.claude/initreq/tbox/CLAUDE.md`의 "타입 체크는
|
||||
분기 선택에 쓰이므로 순수해야 함")을 그대로 적용: `isHandlable`은 오직
|
||||
"이 핸들러가 맞는가" 판별에만 쓰이고, 실제 유효성 검사는 핸들러가
|
||||
선택된 *이후* 별도 단계에서.
|
||||
- `isHandlable(inst, key, value): boolean` — 이 핸들러가 이 inst/key/value
|
||||
조합을 처리할 수 있는지 판별하는 predicate. **부작용 없이, 빠르게** —
|
||||
tbox의 type-check/constraint-check 분리 원칙(`.claude/initreq/tbox/
|
||||
CLAUDE.md`의 "타입 체크는 분기 선택에 쓰이므로 순수해야 함")을 그대로
|
||||
적용: `isHandlable`은 오직 "이 핸들러가 맞는가" 판별에만 쓰이고, 실제
|
||||
유효성 검사는 핸들러가 선택된 *이후* 별도 단계에서. **`inst`도 받음
|
||||
(2026-08-07 여덟 번째 세션 정정, 원래 `(key,value)`뿐이었음)** —
|
||||
`process`/`retract`는 처음부터 항상 `inst`를 받았는데("모든 핸들러는
|
||||
대상 Instance를 직접, 항상 받는다", 아래 "확정된 디스패치 모델" 절)
|
||||
`isHandlable`만 예외였던 게 애초에 약간의 불일치. 지금 당장 `inst`에
|
||||
따라 매치 여부가 갈리는 케이스는 없지만, 나중에 필요해지면(다른
|
||||
백엔드에서 인스턴스 종류별로 매치가 달라져야 하는 경우 등) 핸들러
|
||||
계약 자체를 깨는 breaking change가 되므로 지금 넣어두는 게 훨씬 쌈 —
|
||||
사용자 판단으로 확정.
|
||||
- `priority: number` — 우선순위. 등록 순서(Fusion의 4단계 고정 stage, Vide의
|
||||
action() 우선순위)보다 일반화된 **열린 숫자 공간**으로.
|
||||
- `process(inst, key, value)` — 실제 처리 수행(아래 "확정된 디스패치 모델"
|
||||
|
|
@ -62,8 +70,12 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들
|
|||
엔드포인트 백엔드(`quad-roblox`/`quad-web` 등)가 알아서 결정할 문제 —
|
||||
base 인터페이스는 "무언가를 inst로 받아 process/retract한다"는 계약만
|
||||
지키면 됨, 그 inst의 실체가 뭔지는 백엔드 재량.
|
||||
- `process(inst, k, v)` — 우선순위 순으로 등록된 핸들러를 스캔, `isHandlable(k,v)`를
|
||||
만족하는 최상위 핸들러가 실제 처리를 담당.
|
||||
- `process(inst, k, v)` — 우선순위 순으로 등록된 핸들러를 스캔,
|
||||
`isHandlable(inst,k,v)`를 만족하는 최상위 핸들러가 실제 처리를 담당.
|
||||
**이 "스캔+실행" 오케스트레이터는 `Dispatch.process`로, 순수 스캔
|
||||
부분은 `Dispatch.getHandler`로 이름이 공식화됨**(아래 `None` 센티널
|
||||
절, 2026-08-07 여덟 번째 세션) — 이 절에서는 개념 설명이라 편의상
|
||||
그냥 `process`로 계속 씀.
|
||||
- 예시: Tween의 store-bind 핸들러는 **`k`는 무엇이든 받고 `v`가 Store인 경우를
|
||||
잡아내는, 우선순위가 매우 높은 핸들러** — `v`가 store이면 그 값을 처리(구독)함.
|
||||
이 핸들러 안에서:
|
||||
|
|
@ -72,11 +84,13 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들
|
|||
결국 정리하긴 하지만, GC 되기 전에도 store 값이 업데이트될 수 있으므로
|
||||
그 시점엔 그냥 `Connected`를 보고 무시(no-op).
|
||||
2. 처리해도 되면, 사용자가 넘긴 함수들을 거쳐 실제 값(`realv`)을 계산.
|
||||
3. **`realv`를 들고 다시 `process(inst, k, realv)`를 재귀 호출** — 이게 바로
|
||||
"store 바인드는 pluggable 바인드를 재실행하는 래핑"이라는 이 문서 이전
|
||||
초안의 결론과 일치. `realv`가 store가 아니라면 자연히 Tween의 store-bind
|
||||
핸들러 `isHandlable`을 통과 못 하고 우선순위상 다음 핸들러(일반 프로퍼티
|
||||
세터 등)로 흘러감 — 무한 재귀 걱정 없음.
|
||||
3. **`realv`를 들고 다시 `Dispatch.process(inst, k, realv)`를 재귀 호출**
|
||||
(오케스트레이터 이름 공식화는 아래 `None` 센티널 절 참고, 2026-08-07
|
||||
여덟 번째 세션) — 이게 바로 "store 바인드는 pluggable 바인드를
|
||||
재실행하는 래핑"이라는 이 문서 이전 초안의 결론과 일치. `realv`가
|
||||
store가 아니라면 자연히 Tween의 store-bind 핸들러 `isHandlable`을
|
||||
통과 못 하고 우선순위상 다음 핸들러(일반 프로퍼티 세터 등)로 흘러감
|
||||
— 무한 재귀 걱정 없음.
|
||||
- **`retract(inst, k, v)`** (이전 초안의 "cleanup", 이름 변경 근거는
|
||||
`base/lifecycle-pattern.md` 참고) — 이전 처리를 무르는/멈추는 함수. **오직
|
||||
"같은 key에 새 값이 들어와서 이전 처리를 갈아치우는" 시나리오에만 존재** —
|
||||
|
|
@ -84,9 +98,17 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들
|
|||
lifecycle-pattern.md`의 "quad는 라이프사이클 중간에 있지 않다" 원칙 참고).
|
||||
- 일반 프로퍼티는 애초에 "unset" 개념이 없음(`nil`로 셋하는 것도 그냥 셋
|
||||
동작) — 그래서 프로퍼티 핸들러는 보통 `retract`가 필요 없음.
|
||||
- `retract`가 실제로 의미 있는 곳: **Tag를 지운다, Attribute 엔트리 자체를
|
||||
지운다, 실행 중인 Tween을 멈춘다** 같은, "값을 새로 셋하는 것"과
|
||||
"이전 상태를 명시적으로 되돌리는 것"이 다른 케이스.
|
||||
- **`retract`가 실제로 의미 있는 유일한 패턴은 "같은 키에 대해 매치되는
|
||||
핸들러 *타입 자체*가 사이클마다 바뀌는 경우"** (2026-08-07 여덟 번째
|
||||
세션, 정정) — 예: 이전엔 Tween 핸들러가 매치돼 애니메이션이 실행
|
||||
중이었는데, 다음 값이 더 이상 Tween 대상이 아니게 되어 일반
|
||||
PropertyHandler로 매치가 넘어가는 경우, 이전 Tween을 멈추는 게
|
||||
`retract`의 일. **Tag/Attribute는 여기 해당 안 함** — 처음엔 이
|
||||
둘도 예시로 들었으나, 실제로는 UICorner 숏핸드와 같은 패턴(값의
|
||||
참/거짓/nil 여부와 무관하게 항상 같은 핸들러가 계속 담당하고,
|
||||
추가/제거를 전부 `process` 자신이 처리)이라 핸들러 교체 자체가 안
|
||||
일어나서 `retract`가 발화할 조건이 생기지 않음 — 구체 설계는
|
||||
`base/tag-plan.md`/`base/attribute-plan.md`.
|
||||
- store bind가 새 값으로 넘어갈 때 이전 핸들러의 `retract(inst, k, v)`를
|
||||
한 번 호출해주면 됨.
|
||||
- **핸들러 내부 상태 저장**: `retract`가 "이전에 생성한 것"(예: 실행 중이던
|
||||
|
|
@ -135,6 +157,92 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들
|
|||
항목 — `research/pre-implementation-audit.md`가 짚은 "실제 Luau로
|
||||
부딪혀본 적 없는 것" 범주와 같은 급이라 신중하게 다룸).
|
||||
|
||||
### `None` 센티널 — Tween store-bind와 같은 재귀 재디스패치 패턴 재사용 (2026-08-07 여덟 번째 세션)
|
||||
|
||||
`modifier-plan.md` "2-1"절의 "인라인 키로 modifier 필드를 명시적으로
|
||||
지우기" 문제 — raw 저장 계층(Modifier 필드/인라인 props/`Peek`)에서 쓰는
|
||||
`None` 센티널이 실제로 인스턴스에 반영될 때 base가 뭘 하는지가 이 문서의
|
||||
층위. 결론: **새 메커니즘이 아니라 위 "확정된 디스패치 모델"의
|
||||
Tween store-bind 핸들러(65-79행)와 완전히 같은 모양의 핸들러 하나 추가.**
|
||||
|
||||
```
|
||||
NoneHandler.priority = <매우 높음>
|
||||
NoneHandler.isHandlable(inst, k, v) = (v == None)
|
||||
NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출
|
||||
```
|
||||
|
||||
- **매치 predicate는 `isHandlable`** — `canExecute`가 아님. 둘은 완전히
|
||||
다른 개념이라 혼동하지 말 것: `isHandlable(k,v)`는 KV 매치 predicate(핸들러
|
||||
계약 4종 중 하나, 이 절에서 다루는 것), `canExecute`는 인자로 받은 특정
|
||||
바인딩/등록 하나가 "지금 살아있어서 실행돼도 되는가"만 보는 별개의
|
||||
라이프타임 게이트(`base/lifecycle-pattern.md` "생명 바인드 유틸" 절) —
|
||||
KV 매치와 무관.
|
||||
`NoneHandler.isHandlable`은 `v == None`(센티널 자체)을 잡는 것이지
|
||||
`v == nil`이 아님 — 진짜 `nil`은 애초에 테이블 순회로 나올 수 없다는 게
|
||||
이 문제의 출발점이었으므로, 매치 대상은 항상 `None` 마커.
|
||||
`Dispatch.process(inst, k, nil)`로 재귀 호출하는 순간 `None`은 더 이상
|
||||
존재하지 않고 진짜 `nil`이 되므로, 다음 우선순위 스캔은 자연히 키 `k`를
|
||||
원래 담당하던 핸들러(프로퍼티/이벤트/UI shorthand 등)로 흘러감 — Tween의
|
||||
store-bind 핸들러가 `realv`를 들고 재귀하면 자연히 다음 핸들러로 좁혀지는
|
||||
것과 정확히 같은 원리, 무한루프 걱정도 동일하게 없음.
|
||||
- **`Dispatch.process`/`Handler.process` 이름 겹침 — 소유자 네임스페이싱으로
|
||||
해소, 새 이름 발명 안 함 (2026-08-07 여덟 번째 세션 후속).** 원래
|
||||
"확정된 디스패치 모델" 절은 "스캔+실행"과 "매치된 핸들러 자신의 처리
|
||||
로직" 둘 다 그냥 `process`라고 불러서 이름이 겹쳤음 — 이제 두 계층을
|
||||
명시적으로 분리:
|
||||
- `Dispatch.getHandler(inst,k,v): Handler?` — 순수 스캔(`handler.isHandlable(inst,k,v)`+
|
||||
`priority`), 부작용 없음.
|
||||
- `Dispatch.process(inst,k,v)` — 오케스트레이터: `getHandler` 호출 →
|
||||
이전에 이 키를 담당하던 핸들러와 다르면 이전 핸들러의 `retract` 호출 →
|
||||
새로 매치된 핸들러의 `.process` 호출. **재귀 재디스패치(Tween/일반
|
||||
store-bind/`NoneHandler`)는 전부 이 `Dispatch.process`를 다시 부르는
|
||||
것** — 원래 있던 재귀 관례 그대로, 새로 바뀐 것 없음.
|
||||
- `Dispatch.addHandler(handler: Handler)` — 핸들러를 우선순위 레지스트리에
|
||||
등록. `Dispatch.process`/`getHandler`와 마찬가지로 base엔 인터페이스만
|
||||
있고, quad-roblox의 concrete Handler들(PropertyHandler/EventHandler/
|
||||
UICornerHandler/TagHandler/AttributeHandler 등)은 팩토리가 `BaseModule`을
|
||||
뮤테이션하는 시점에 이걸로 등록됨(아래 "base 유틸은 인터페이스" 절과
|
||||
같은 패턴, 새 메커니즘 아님).
|
||||
- Handler 자신의 필드는 계속 `process`/`retract`(이미 확정된 이름,
|
||||
`question.md`에 "특별한 문제 없음"으로 못박혀 있어 재검토 대상 아님) —
|
||||
겹침은 실제 런타임 충돌이 아니라 프로즈 표기 문제였을 뿐이라, 항상
|
||||
소유자를 명시(`Dispatch.process` vs `handler.process`)하는 것으로 해소.
|
||||
- **base 드라이버 루프 자신의 이름은 `Dispatch.drive(inst, flattened)`로
|
||||
확정** — 이미 위 "props 순회 순서" 절이 이걸 비공식적으로 "base
|
||||
디스패치 드라이버"라고 불러왔던 걸 그대로 동사화(`apply`는 "Dispatch를
|
||||
뮤테이션해서 결과를 낸다"는 어감이라 기각 — 사용자 판단). `inst`와
|
||||
flatten된 props 테이블을 받아 배열 파트(children/Ref) 먼저, 해시
|
||||
파트(프로퍼티/이벤트) 나중으로 두 패스 순회하며 각 `(k,v)`에
|
||||
`Dispatch.process`를 호출하는 게 이 함수의 본체.
|
||||
- **`v=nil`이 구체적으로 뭘 뜻하는지는 핸들러마다 다름, `None` 자신은
|
||||
"리셋"이 아님** — 일반 프로퍼티는 "`nil`로 셋하는 것도 그냥 셋 동작"이라
|
||||
사실상 그대로 두는 것과 다름없고, UICorner 같은 숏핸드 핸들러는 만들어둔
|
||||
자식 Instance를 실제로 지우는 것까지 포함 — 구체 예시는
|
||||
`base/ui-shorthand-plan.md`/`base/tag-plan.md`/`base/attribute-plan.md`.
|
||||
`None`은 **"이 조합 단계에서 나는 이 필드를 세팅 안 한다"**는 뜻이고,
|
||||
그걸 받은 실제 핸들러가 무엇을 할지는 각자 몫. 개별 프로퍼티/이벤트/UI
|
||||
shorthand 핸들러의 `process` 시그니처는 안 바뀜 — 이들은 원래도 `v`가
|
||||
State 계산 결과로 `nil`이 되는 경우를 처리할 수 있어야 했으므로(일반
|
||||
반응형 케이스), `None`은 그 기존 경로에 도달하는 방법 하나가 늘어난 것뿐.
|
||||
**구현 디테일 캐비엇**: `None→nil`이 Roblox의 nil을 허용 안 하는 타입
|
||||
프로퍼티(Color3/number 등)에 도달하면 `inst[k] = nil`은 런타임 에러 —
|
||||
PropertyHandler 자신이 `v == nil`이면 셋을 건너뛰는 방어를 갖고 있어야
|
||||
함(None 자체의 문제가 아니라 PropertyHandler 구현 디테일, M9/M10로 미룸).
|
||||
- **retract와는 무관** — `retract`는 "같은 키를 다른 *핸들러 타입*이
|
||||
넘겨받는" 시나리오 전용(아래 정정된 "확정된 디스패치 모델" 절)이지
|
||||
"`v`가 `nil`이 됨"과는 다른 문제. `None → nil` 재디스패치는 항상
|
||||
`Dispatch.process` 경로로만 흐름 — `NoneHandler` 자신도 `retract`가
|
||||
딱히 할 일이 없음(재귀 호출 자체가 이미 process이므로).
|
||||
- **M2 착수 시 확인할 것 (`pre-implementation-audit.md` 우선순위1
|
||||
"이전에 실제로 매치됐던 핸들러 추적" 항목에 추가)**: "이 키를 지금 누가
|
||||
담당 중인가" bookkeeping은 바깥 순회 루프(`Dispatch.drive`)가 아니라
|
||||
`Dispatch.process` 호출 자체 내부에서 갱신돼야 함. 안 그러면 값이 계속
|
||||
`None`으로 유지되는 매 사이클마다 "1차 매치는 `NoneHandler`, 재귀 호출 뒤
|
||||
실제 담당은 다른 핸들러"로 바깥 루프가 오판해 불필요한 `retract`를 반복
|
||||
호출할 위험이 있음 — `Dispatch.process`가 재귀 호출 시에도 자기 자신을
|
||||
통해 담당자 기록을 갱신하게만 해두면(`Dispatch.drive`가 별도로 기록 안
|
||||
하고 `Dispatch.process` 내부에 위임) 자연히 해소됨.
|
||||
|
||||
## Store 바인드는 특수 경우인가, 아니면 pluggable 바인드를 재실행하는 래핑인가
|
||||
|
||||
사용자 원 메모: "스토어 바인드는 특수 경우로 둘지, 아니면 다른 pluggable 바인드를
|
||||
|
|
@ -142,10 +250,11 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들
|
|||
|
||||
**확정**: 래핑 쪽. 위 "확정된 디스패치 모델" 절 참고 — store 바인드 핸들러도
|
||||
다른 핸들러와 동일한 `isHandlable`/`priority`/`process`/`retract` 계약을
|
||||
따르되, `process`가 내부적으로 "실제 값이 바뀔 때마다 (원래 key, 새 value)로
|
||||
`process(inst,k,realv)`를 재귀 호출"하는 식으로 구현됨. 이러면 store 값
|
||||
자체가 어떤 타입이든(원시값, 인스턴스, 심지어 다른 store) 상관없이 동일한
|
||||
재귀적 디스패치로 처리 가능 — 아래 "store가 store를 저장 가능한가"와 직결.
|
||||
따르되, 자신의 `process`가 내부적으로 "실제 값이 바뀔 때마다 (원래 key, 새
|
||||
value)로 `Dispatch.process(inst,k,realv)`를 재귀 호출"하는 식으로 구현됨.
|
||||
이러면 store 값 자체가 어떤 타입이든(원시값, 인스턴스, 심지어 다른 store)
|
||||
상관없이 동일한 재귀적 디스패치로 처리 가능 — 아래 "store가 store를 저장
|
||||
가능한가"와 직결.
|
||||
|
||||
Slot이 store 바인드로 넘어오는 경우, pluggable 처리기에 `retract` 핸들러가
|
||||
필요하다는 점(부모가 slot을 정리하고 다시 process하는 방식)도 이 래핑 방식과
|
||||
|
|
@ -454,9 +563,9 @@ SyntheticEvent만 주는 것과 같은 모양).
|
|||
바꿔치기/해제하는 것)을 지원한다. quad-roblox 로컬 결정, base 변경 없음.
|
||||
|
||||
**엔지니어링 비용이 낮은 이유**: 이미 확정된 "Store 바인드는 pluggable
|
||||
바인드를 재실행하는 래핑"(위 절, `process`가 값이 바뀔 때마다
|
||||
`process(inst,k,realv)`를 재귀 호출) + "재실행 래핑이 `retract`도 같이
|
||||
호출한다"(Slot이 이미 이 조합을 씀, 같은 절)는 두 메커니즘이 이미 있음.
|
||||
바인드를 재실행하는 래핑"(위 절, 핸들러의 `process`가 값이 바뀔 때마다
|
||||
`Dispatch.process(inst,k,realv)`를 재귀 호출) + "재실행 래핑이 `retract`도
|
||||
같이 호출한다"(Slot이 이미 이 조합을 씀, 같은 절)는 두 메커니즘이 이미 있음.
|
||||
이벤트 핸들러가 할 일은 딱 하나: `process`에서 `:Connect()`한 Connection을
|
||||
per-instance 저장소에 기억해두고, `retract`에서 그걸 `:Disconnect()`하는
|
||||
것 — 새 디스패치 메커니즘 발명 필요 없이 기존 4종 계약(`isHandlable`/
|
||||
|
|
@ -1155,8 +1264,8 @@ copy-on-write 절충안은 2026-08-04 검증 라운드에서 사실상 폐기
|
|||
|
||||
## 확정된 것 (더 이상 열린 질문 아님)
|
||||
|
||||
- **핸들러 계약**: `isHandlable(k,v)` + `priority` + `process`(구 `bind`) +
|
||||
`retract`(구 `cleanup`) 4종 조합으로 확정 — tbox식 6-hook 세분화는 지금은
|
||||
- **핸들러 계약**: `isHandlable(inst,k,v)` + `priority` + `process`(구
|
||||
`bind`) + `retract`(구 `cleanup`) 4종 조합으로 확정 — tbox식 6-hook 세분화는 지금은
|
||||
안 함. 실제 구현하며 부족한 지점이 보이면 그때 hook 추가(점진적 확장).
|
||||
- **Signal 클래스**: 안 만듦, 콜백 + `Connected` 계산 속성만(`base/
|
||||
lifecycle-pattern.md`).
|
||||
|
|
@ -1245,71 +1354,86 @@ Service` 기반으로 구현)로 두면 됨 — 별도 `On` 모듈/필드 접근
|
|||
- **Store/State 전파 모델, 라이프사이클 — 둘 다 재검토 후 기존 확정 유지**
|
||||
(위 "Store/State/Source 온톨로지" 절의 "PA님 코드와의 교차검증" 참고).
|
||||
|
||||
## Attribute 특수 키 — 타입 파라미터화 (2026-08-06, 신규 논의)
|
||||
## Tag/Attribute 특수 키 — 전용 문서로 분리됨 (2026-08-07 여덟 번째 세션)
|
||||
|
||||
**상태**: 미확정, 사용자가 이번에 새로 제기 — 이전에 기록된 적 없음
|
||||
(`architecture.md` 4번 항목의 `[Attribute "Name"]`은 특수 DI 키의 존재만
|
||||
확정했을 뿐, 타입을 어떻게 표현할지는 다룬 적 없었음).
|
||||
`base/tag-plan.md`/`base/attribute-plan.md`로 이동 — 이 절이 다루던 타입
|
||||
파라미터화 문제(`[Attribute<<boolean>> "name"]` vs `[BooleanAttribute
|
||||
"name"]`)뿐 아니라 `None`/`process`/`retract` 동작까지 확정 반영됨.
|
||||
UICorner 숏핸드/Tween처럼 "1 프리미티브 1 파일" 관례를 따라야 한다는
|
||||
지적으로 분리.
|
||||
|
||||
**문제**: Roblox Attribute는 Instance/Tag와 달리 실제로 **타입이 있는
|
||||
값**(string/boolean/number/Color3/UDim/UDim2/Vector2/Vector3/CFrame/
|
||||
Instance 참조 등 제한된 프리미티브 집합, 테이블 등 복합 타입은 지원 안
|
||||
함)이라, 그냥 `[Attribute "name"] = value`로 두면 `value`의 타입을 Luau가
|
||||
좁혀줄 방법이 없음. 커스텀/복합 데이터(테이블 등)는 애초에 Attribute가
|
||||
지원을 안 하므로 Ref(직접 참조 획득) 쪽으로 빠지는 게 맞고, Attribute는
|
||||
프리미티브 전용으로 남기면 된다는 게 사용자 판단 — Value 오브젝트가
|
||||
역사적으로 Attribute의 대안(테이블/참조를 담는 용도)으로 나온 배경이지만,
|
||||
지금은 Roblox Attribute가 Instance 참조 타입도 지원해서 `ObjectValue`
|
||||
없이도 Ref 용도로 Attribute를 그대로 쓸 수 있다는 점을 사용자가 짚음
|
||||
(`research/debug-tooling-plan.md`의 "Value 오브젝트 기각, Attribute로
|
||||
확정" 결정과 같은 방향 — Instance 타입 지원까지 감안하면 그 결정의 근거가
|
||||
한층 더 탄탄해짐).
|
||||
## `Brand` — 런타임 nominal 타입 판별 통합 메커니즘, `isState`를 일반화 (2026-08-07 여덟 번째 세션)
|
||||
|
||||
**후보 두 가지**:
|
||||
- `[Attribute<<boolean>> "name"] = true` (리터럴 또는 store-bind 값) —
|
||||
제네릭 파라미터로 타입을 명시하는 제네릭 생성자 스타일.
|
||||
- `[BooleanAttribute "name"] = true` — 타입별로 이름이 다른 정적 생성자
|
||||
패밀리(`StringAttribute`/`NumberAttribute`/`Color3Attribute`/
|
||||
`InstanceAttribute` 등).
|
||||
**배경**: `isState`(2026-08-07 다섯 번째 세션 확정, `:Peek<<T>>(key):
|
||||
T|State<T>|nil`가 돌려주는 raw union을 사용자 코드가 분기하려면 판별
|
||||
수단이 필요했음)와 똑같은 필요가 quad의 다른 branded 타입에도 전부
|
||||
적용됨 — `Observer`/`Effect`/`Tag`/`Attribute`/`Tween`/`Blocker`/`Store`/
|
||||
`Source`/`Slot`/`None`까지, Handler 구현(`isHandlable`에서 "이 값이
|
||||
Tween인가/Store인가" 판별)과 사용자 코드 양쪽에서 반복적으로 필요해질
|
||||
수단이라 `isState` 하나만 만들고 끝내지 않고 전체를 일관된 메커니즘으로
|
||||
통합(component-composition-plan.md 4번 절이 이미 "`isSource`류 판별자로
|
||||
(`isObserver`와 동일한 패턴)"라고 이 방향을 예견해뒀던 것과 맞아떨어짐).
|
||||
|
||||
**소견(확정 아님, 검토 필요)**: 이 선택은 이미 확정된 DI 인스턴스 생성
|
||||
패턴(위 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절)과 구조적으로 똑같은
|
||||
문제 — 그때도 "제네릭 하나로 다 커버할지 vs 타입별 정적 필드로 나눌지"
|
||||
고민이 있었고, 결론은 **둘 다**(`new<ClassName>(className)` 제네릭
|
||||
생성자 + 자주 쓰는 ~25개는 정적 필드로 미리 바인딩)였음. Attribute도 같은
|
||||
모양을 재사용하면 자연스러울 가능성 — `Attribute<T>("name")` 제네릭을
|
||||
기본으로 두고, 실사용 빈도가 압도적으로 높을 `Boolean`/`Number`/`String`/
|
||||
`Instance` 정도만 `BooleanAttribute`/`NumberAttribute`/`StringAttribute`/
|
||||
`InstanceAttribute` 같은 지름길로 정적 바인딩하는 절충. 단 이건 사용자
|
||||
확인 전 소견일 뿐 — `.claude/question.md`에 반영, 다음 세션에서 사용자
|
||||
판단 필요.
|
||||
|
||||
## `isState(x): boolean` — State/Source 판별 predicate (2026-08-07 다섯 번째 세션)
|
||||
|
||||
**배경**: `base/modifier-plan.md` 9번 절의 `:Peek<<T>>(key): T|State<T>|nil`
|
||||
(Modifier 필드를 확정하지 않고 raw 그대로 읽는 접근자)이 나오면서, 사용자
|
||||
코드가 그 결과를 State/plain으로 분기하려면 판별 수단이 필요해짐 — 같은
|
||||
필요가 실은 새로 생긴 게 아니라 Modifier의 함수형 setter(`modifier-plan.md`
|
||||
4-1번, "현재 필드가 State냐 plain이냐"로 동작이 갈림)가 지금까지도 내부적으로
|
||||
풀어야 했던 문제인데 그 판별 방법 자체가 문서에 명시된 적이 없었음 — 이번에
|
||||
`isState`로 명문화하며 그 구멍도 같이 메움.
|
||||
|
||||
**Source도 같이 잡힘, 별도 `isSource` 불필요** — Source가 State를 구조적으로
|
||||
만족(위 "Source가 State를 만족함" 관련 내용은 `base/store-semantics.md`
|
||||
참고)하므로, `isState(source) == true`가 자연스러운 동작이고 그걸로 충분함.
|
||||
|
||||
**구현: weak-key 레지스트리, duck-typing 아님.**
|
||||
**구현: 공유 weak-key 레지스트리 하나 + 테이블 아이덴티티를 태그로
|
||||
사용(문자열 아님).**
|
||||
|
||||
```
|
||||
local Brand = {}
|
||||
local registry = setmetatable({}, {__mode = "k"})
|
||||
-- State/Source를 만드는 모든 생성 지점(Source(...), :With(...), :Compute(fn) 등)에서:
|
||||
registry[newHandle] = true
|
||||
-- predicate:
|
||||
|
||||
function Brand.set(x, tag) registry[x] = tag end
|
||||
function Brand.get(x) return registry[x] end -- nil이면 quad가 모르는 값
|
||||
|
||||
-- 각 브랜드는 고유 테이블(빈 테이블이어도 됨) — 문자열 리터럴 아님
|
||||
local ObserverTag, EffectTag, TagTag, AttributeTag, TweenTag, BlockerTag,
|
||||
StateTag, SourceTag, StoreTag, SlotTag = {}, {}, {}, {}, {}, {}, {}, {}, {}, {}
|
||||
|
||||
-- 각 타입의 모든 생성 지점(Observer(...), Source(...), :With(...), Tag(...) 등)에서:
|
||||
Brand.set(newHandle, ObserverTag)
|
||||
```
|
||||
|
||||
**문자열 대신 테이블 아이덴티티를 태그로 쓰는 이유(사용자 제안)** —
|
||||
Luau의 인터닝된 문자열 비교도 이미 사실상 O(1) 포인터 비교라 성능 차는
|
||||
무시할 만하지만, **오타 안전성**이 실질적 이득: 태그가 오타난 문자열
|
||||
리터럴("Oberver")이면 등록/조회 양쪽이 조용히 어긋나는데, 테이블
|
||||
레퍼런스는 잘못된 변수를 참조하면 즉시 드러나거나 최소한 진짜 다른 값이
|
||||
되어 헷갈릴 여지가 없음.
|
||||
|
||||
**`isX`는 `Brand`를 직접 노출 안 하고 각자 얇은 wrapper로 감쌈** —
|
||||
단순 항등인 경우(`isObserver(x) = Brand.get(x) == ObserverTag`)와, 상위
|
||||
관계(subtype)가 있어 집합 멤버십이 필요한 경우(`isState`)로 갈림:
|
||||
|
||||
```
|
||||
local function isState(x)
|
||||
return registry[x] == true
|
||||
local t = Brand.get(x)
|
||||
return t == StateTag or t == SourceTag -- Source가 State를 구조적으로 만족
|
||||
end
|
||||
local function isSource(x)
|
||||
return Brand.get(x) == SourceTag
|
||||
end
|
||||
```
|
||||
|
||||
**정정 — `isSource`는 별도로 필요함, 다섯 번째 세션의 "불필요" 서술을
|
||||
뒤집음(2026-08-07 여덟 번째 세션).** 그때는 "State면 충분한 용도"만
|
||||
염두에 뒀지만, `Source`는 State보다 진짜로 더 많은 능력(`:Set`/`:Emit`)을
|
||||
가진 진짜 서브타입이라 "이 값이 (읽기 전용이 아니라) 쓰기도 되는
|
||||
원천인가"를 알아야 하는 코드는 `isState`만으론 부족함 — `isSource`를
|
||||
별도로 제공, `isState`는 여전히 `{State, Source}` 둘 다 통과시킴(상위
|
||||
개념이니까 당연히). `component-composition-plan.md` 4번 절이 이미
|
||||
`isSource`가 존재한다고 가정하고 있었던 것과도 이걸로 정합됨(그동안 두
|
||||
문서가 서로 모순돼 있었음). `base/modifier-plan.md`의 "별도 `isSource`
|
||||
불필요" 서술도 같이 정정 대상.
|
||||
|
||||
**`None`은 이 레지스트리에 안 들어감 — 싱글턴이라 항등 비교로 충분.**
|
||||
`Observer`/`Store`처럼 인스턴스가 여러 개 생기는 타입과 달리 `None`은
|
||||
quad 전체에서 딱 하나만 존재하므로 weak table 조회보다 `x == None`
|
||||
레퍼런스 비교가 더 싸고 정확함. 다만 `Brand.get(x)`가 "quad가 아는 모든
|
||||
값의 태그를 답해주는 범용 introspection 창구"(quad-debug 같은 도구가
|
||||
"이 값이 뭐냐"를 물어볼 단일 창구) 역할까지 겸하게 하려면 `None`도
|
||||
빠지면 안 되므로, `Brand.get`이 내부적으로 `x == None`을 먼저 확인하는
|
||||
특수 분기를 하나 두고 그 뒤에 일반 레지스트리 조회로 폴백 — `isNone`은
|
||||
바로 이 특수 분기의 실제 구현체가 됨(별도로 새로 만들 것 없음).
|
||||
|
||||
**duck-typing(예: `type(x) == "table" and x.Compute ~= nil`)을 쓰지 않는
|
||||
이유**: `Peek`가 돌려주는 `T`는 Modifier 필드에 들어갈 수 있는 임의의
|
||||
값(테이블, Roblox userdata 등)이라 — 우연히 비슷한 모양의 필드/메소드를
|
||||
|
|
@ -1317,10 +1441,21 @@ end
|
|||
인덱싱 자체에서 에러를 던지므로 duck-typing이 `pcall`로 감싸야 하는 지저분한
|
||||
엔지니어링이 되거나 최악의 경우 그냥 엔진이 죽는 상황까지 생길 수 있음.
|
||||
weak-key 레지스트리는 rbvm 네임스페이스 추적(`base/lifecycle-pattern.md`)과
|
||||
같은 이미 확정된 패턴 재사용이라 새 아이디어 아님 — weak 키라 등록된 State/
|
||||
Source가 GC되면 레지스트리 엔트리도 자동으로 사라짐(살려두는 목적의 강참조
|
||||
같은 이미 확정된 패턴 재사용이라 새 아이디어 아님 — weak 키라 등록된 값이
|
||||
GC되면 레지스트리 엔트리도 자동으로 사라짐(살려두는 목적의 강참조
|
||||
레지스트리인 Observer의 `:Subscribe` 레지스트리와는 반대 성격).
|
||||
|
||||
**Luau 타입 narrowing은 자동으로 안 됨 — 명시적 `::` 캐스팅 필요(사용자
|
||||
확인, Luau가 원래 그렇게 동작함).** `isX(v)`가 참이어도 Luau 컴파일러가
|
||||
`v`의 정적 타입을 알아서 좁혀주진 않음(TypeScript의 `x is T`류 사용자
|
||||
정의 타입 가드를 Luau가 지원 안 함) — `if isState(v) then local s = v ::
|
||||
State<any> ... end`처럼 런타임 검증 뒤 명시적 캐스팅을 붙이는 게 실제
|
||||
패턴. 여전히 duck-typing/`pcall`보다 훨씬 안전하니 가치는 있음, 다만
|
||||
"자동 narrowing"을 기대하면 안 됨.
|
||||
|
||||
**이름은 전부 가칭 — `Brand`/`ObserverTag`류 포함 용어 정리 대상,
|
||||
`.claude/question.md`에 반영.**
|
||||
|
||||
## 남은 열린 질문 (`.claude/question.md`에도 취합)
|
||||
|
||||
이 문서의 핵심 설계 질문은 2026-08-04 세 라운드(전파 모델/`:Compute`/State
|
||||
|
|
|
|||
|
|
@ -127,9 +127,33 @@ GC에 묶이지 않음 — v1이 여기저기서 `PropertyChangedSignal`에 연
|
|||
**base가 범용 유틸로 제공할 것: 임의의 클로저/구독을 실제 Roblox 객체의
|
||||
생명주기에 바인드하는 도구** — 내부적으로 v1/rbvm이 쓰던 "connect 트릭"(어떤
|
||||
신호에든 연결해서 참조를 죽을 때까지만 붙잡아두는 것)을 그대로 재사용. 이
|
||||
도구로 바인드된 옵저버는 `canExecute: () -> boolean` 같은 predicate 람다를
|
||||
가질 수 있어서, `Connected`가 false면 실행 자체를 건너뛸 수 있음(죽은 대상에
|
||||
대한 처리 시도 방지, 위 원칙과 직결).
|
||||
도구로 바인드된 옵저버는 `canExecute` predicate로 게이팅되어, 살아있지 않으면
|
||||
실행 자체를 건너뛸 수 있음(죽은 대상에 대한 처리 시도 방지, 위 원칙과 직결).
|
||||
|
||||
**`canExecute`의 시그니처는 `(handle) -> boolean`이지 `() -> boolean`이
|
||||
아님(2026-08-07 여덟 번째 세션, 정정)** — 처음엔 "바인딩마다 클로즈오버된
|
||||
zero-arg 람다"로 적었으나, 그러면 등록마다 클로저를 새로 만들어야 해서
|
||||
아래 "base 유틸은 인터페이스, 실제 구현은 백엔드 팩토리가 주입" 절의 패턴
|
||||
(base는 타입만 갖고, quad-roblox가 `BaseModule`을 뮤테이션해서 실 구현체를
|
||||
채워넣음)과 잘 안 맞음 — 그 패턴이 성립하려면 `canExecute`는 **quad-roblox가
|
||||
한 번만 주입하는 공유 함수**여야 하고, 그러려면 "어떤 등록을 확인할지"를
|
||||
가리키는 인자(`handle`, 아래 gchold 스케치의 Connection이 이 역할)가 있어야
|
||||
함. base 입장에선 `handle`은 `any`(엔진마다 실체가 다를 수 있음).
|
||||
|
||||
**quad-roblox 구현 스케치(rbvm 패턴 재사용, base 결정 아님 — 참고용)**:
|
||||
Instance당(꼭 하나일 필요는 없지만 보통 그게 싸서 하나로 감) weak-keyed
|
||||
per-instance 저장소(`base.perInstanceState(inst)`)에 "gchold" 배열을 둠.
|
||||
그 배열엔 절대 발화하지 않도록 골라 만든 신호에 연결한 Connection을
|
||||
넣는데, 이 Connection의 콜백 클로저 안에 실제로 살려두고 싶은 옵저버를
|
||||
업밸류로 캡쳐해둠(콜백은 안 불려도 클로저 자체가 살아있는 한 업밸류는
|
||||
안 죽음) — `inst`가 GC되면 gchold 배열째로 같이 죽으므로 옵저버도 자연히
|
||||
GC됨(`base/bind-system-plan.md` "핸들러 내부 상태 저장" 절의 weak-keyed
|
||||
중첩 구조와 같은 원리). `canExecute(handle)`은 이 Connection(또는 이를
|
||||
감싼 핸들)을 받아 `.Connected`를 확인하는 정도로 구현될 것.
|
||||
**미확인 세부사항**: 옵저버 → Connection 역참조를 별도 weak 릴레이션으로
|
||||
둘지, 아니면 그냥 Observer 테이블 안 평범한 필드로 넣을지(정적 해싱된
|
||||
필드 접근이 weak 테이블 조회보다 싸서 후자가 나을 수 있음) — quad-roblox
|
||||
구현 단계에서 실측 확인 필요, base 설계에 영향 없음.
|
||||
|
||||
이건 `base/bind-system-plan.md`의 "핸들러 내부 상태 저장" 유틸과 짝을
|
||||
이루는 별도 유틸 — 하나는 "상태를 어디에 저장할지"(weak-keyed per-instance
|
||||
|
|
|
|||
|
|
@ -46,22 +46,46 @@ Lua 테이블 리터럴은 배열 파트/해시 파트 사이에 소스 텍스
|
|||
않으므로 "순서상 나중이 이긴다"는 단일 규칙만으로는 구현 불가 — 반드시 두
|
||||
규칙으로 쪼개야 함.
|
||||
|
||||
### 2-1. 인라인 키로 modifier 필드를 명시적으로 "지우기"는 아직 안 풀림 — `None` 센티널 후보만 메모 (2026-08-07 세 번째 세션, 미확정)
|
||||
### 2-1. 인라인 키로 modifier 필드를 명시적으로 "지우기" — `None` 센티널로 확정 (2026-08-07 여덟 번째 세션)
|
||||
|
||||
**문제**: `{ Override = nil, mod }`처럼 인라인 키로 modifier가 주는 값을
|
||||
**문제**: `{ TextColor3 = nil, mod }`처럼 인라인 키로 modifier가 주는 값을
|
||||
명시적으로 취소하고 싶어도, Lua 테이블 리터럴에서 `키 = nil`은 그 키
|
||||
자체가 아예 존재하지 않는 것과 구별이 안 됨(`pairs`에서도 안 보임) — 그래서
|
||||
위 2번 "인라인은 무조건 우선" 규칙이 실제로 작동할 근거(인라인 키가
|
||||
존재한다는 사실 자체)가 사라지고, `mod`가 주는 `Override` 값이 그대로
|
||||
새어나옴.
|
||||
존재한다는 사실 자체)가 사라지고, `mod`가 주는 값이 그대로 새어나옴.
|
||||
|
||||
**후보(미확정)**: 이벤트 store-bind에서 이미 쓴 "`nil` 대신 실재하는
|
||||
센티널 값" 패턴(`false`로 disconnect, `base/bind-system-plan.md` "이벤트도
|
||||
store-bind 가능" 절) 재사용 — `None`(가칭) 프리미티브를 만들어
|
||||
`{ Override = None, mod }`로 쓰면 flatten 로직이 `None`을 만난 인라인 키를
|
||||
"명시적으로 지움"으로 해석해 modifier 쪽 값을 덮어씀. 상세 설계(타입,
|
||||
flatten 내부 표현, State 필드에도 같은 문제가 적용되는지 등)는 다음
|
||||
세션에서 이어감 — 지금은 문제와 방향성만 기록.
|
||||
**결론 — `None`은 raw 저장 계층에만 존재하는 실재값 센티널, merge/setter는
|
||||
전혀 안 바뀜.** 이벤트 store-bind의 "`nil` 대신 실재하는 센티널"
|
||||
(`false`로 disconnect, `base/bind-system-plan.md` "이벤트도 store-bind
|
||||
가능" 절)과 같은 발상이지만, 처리 위치가 다름 — merge 단계가 아니라
|
||||
**디스패치 단계**에서 풀린다:
|
||||
|
||||
- **`{ TextColor3 = None, mod }`도, `mod:TextColor3(None)`도 둘 다 지원.**
|
||||
Modifier setter/Override/인라인 props 테이블은 `None`을 그냥 평범한 raw
|
||||
값으로 저장·교체할 뿐 특별 취급이 전혀 없음 — 애초에 문제였던 건 "`nil`이
|
||||
테이블에 존재하는 값으로 표현이 안 된다"는 것뿐이라, 표현 가능한 실재
|
||||
센티널만 있으면 기존 merge 규칙("인라인 키 존재 시 무조건 우선",
|
||||
`Override`의 "뒤 인자가 필드 단위로 이김")이 손댈 것 없이 그대로 작동함.
|
||||
구현 비용이 사실상 0이라 인라인 키/setter 둘 다 여는 데 주저할 이유가
|
||||
없음(2026-08-07 여덟 번째 세션 확정) — setter로 받으면 "특정 필드만 지우는
|
||||
재사용 가능한 modifier 조각"(9-1번의 스타일 프리셋 opt-out 시나리오)도
|
||||
공짜로 됨.
|
||||
- **`:Peek<<T>>(key)`의 반환 타입이 `T | State<T> | None | nil`로 확장됨** —
|
||||
`Peek`은 raw 저장값을 그대로 읽으므로(9번 절 "현재 저장된 그대로 넘김"
|
||||
원칙) `None`을 다른 값처럼 있는 그대로 돌려줌. "필드가 아예 안 채워짐"
|
||||
(`nil`)과 "명시적으로 지워짐"(`None`)은 raw 계층에서 계속 구별됨.
|
||||
- **실제 "지우기" 동작은 디스패치 쪽 `NoneHandler`가 담당** — merge가 끝난
|
||||
뒤 최종 flatten 결과에 `None`이 남아있으면, base 드라이버가 그 키를 어떻게
|
||||
처리하는지는 새 개념이 아니라 이미 확정된 디스패치 모델 그대로다.
|
||||
상세는 `base/bind-system-plan.md`의 "`None` 센티널 — Tween store-bind와
|
||||
같은 재귀 재디스패치 패턴 재사용" 절 참고 — 핵심만 요약하면 `NoneHandler`도
|
||||
Tween의 store-bind 핸들러와 완전히 같은 모양(`isHandlable`이 `v == None`을
|
||||
잡고, `process`가 `v`를 진짜 `nil`로 바꿔 `process(inst, k, nil)`을 재귀
|
||||
호출)이라 base 드라이버 자체엔 `None`을 아는 코드가 한 줄도 안 들어감 —
|
||||
개별 프로퍼티/이벤트/UI shorthand 핸들러도 자기 시그니처에 `None`이 안
|
||||
나옴(원래도 있어야 했던 "`v`가 `nil`일 때" 처리를 재사용할 뿐). 구체 예시는
|
||||
`base/ui-shorthand-plan.md`의 UICorner 숏핸드 절(nil 받으면 만들어둔 자식
|
||||
제거, 단 `retract`가 아니라 `process` 쪽 로직).
|
||||
|
||||
### 3. Immutable 값 + clone 기반 체이닝
|
||||
|
||||
|
|
@ -445,9 +469,12 @@ setter 표면과 read 표면이 헷갈리고, 타이핑 이득도 메소드 방
|
|||
**`isState(x): boolean` 필요 — `base/bind-system-plan.md`에 정의**.
|
||||
`Peek`가 raw union을 돌려주므로 사용자 코드가 State/plain을 분기하려면
|
||||
판별 수단이 필요함(Source가 State를 구조적으로 만족하므로 `isState`가
|
||||
Source도 같이 잡아줌 — 별도 `isSource` 불필요). 상세 근거/구현 방식은
|
||||
`bind-system-plan.md`의 `isState` 절 참고 — 요지만: duck-typing 대신
|
||||
weak-key 레지스트리 기반, 그리고 이 판별 로직 자체는 새로 만드는 게
|
||||
Source도 같이 잡아줌 — **[2026-08-07 여덟 번째 세션 정정] `isSource`도
|
||||
별도로 존재함**, `:Set`/`:Emit` 같은 Source 전용 능력이 있는지 알아야
|
||||
하는 코드를 위해 필요하다고 판단 정정됨). 상세 근거/구현 방식은
|
||||
`bind-system-plan.md`의 `Brand`/`isState` 절 참고 — 요지만: duck-typing
|
||||
대신 weak-key 레지스트리 기반(quad의 다른 branded 타입 전부와 공유하는
|
||||
통합 메커니즘으로 일반화됨), 그리고 이 판별 로직 자체는 새로 만드는 게
|
||||
아니라 4-1번 setter 분기가 이미 내부적으로 해야 하는 걸 public 유틸로
|
||||
승격하는 것뿐.
|
||||
|
||||
|
|
|
|||
|
|
@ -45,9 +45,11 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류
|
|||
|
||||
**사용자 확인 완료**: base가 `LifetimeHandle` 추상화(생명주기/`Connected`
|
||||
계산 속성)를 소유하는 게 맞다고 확정. 추가로 명확해진 것 — **store 바인드가
|
||||
수행하는 "처리된 값을 다시 `process(inst,k,realv)`로 넘기는" 재실행 로직
|
||||
자체도 base가 한 번만 구현**해야 함(모든 백엔드/핸들러가 각자 재구현하면 안
|
||||
됨). 근거: "모든 곳에서 다시 구현하는 건 나쁘니까." → `base/bind-system-plan.md`의 "확정된 디스패치 모델" 절이 바로 이 base 제공 로직.
|
||||
수행하는 "처리된 값을 다시 `Dispatch.process(inst,k,realv)`로 넘기는" 재실행
|
||||
로직 자체도 base가 한 번만 구현**해야 함(모든 백엔드/핸들러가 각자
|
||||
재구현하면 안 됨). 근거: "모든 곳에서 다시 구현하는 건 나쁘니까." →
|
||||
`base/bind-system-plan.md`의 "확정된 디스패치 모델"/`Dispatch` 네이밍 절이
|
||||
바로 이 base 제공 로직.
|
||||
|
||||
부수적으로 확인된 것:
|
||||
- **Store 자체의 연산은 더 단순해져도 됨** — v1의 `:Add`/`:With`/`:Tween` 같은
|
||||
|
|
|
|||
46
.claude/base/tag-plan.md
Normal file
46
.claude/base/tag-plan.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# Tag 특수 키 — `CollectionService` 얇은 래퍼
|
||||
|
||||
**상태**: base — `[Tag "Name"] = true` DI 키의 존재 자체는 `architecture.md`
|
||||
4번 항목에서 이미 확정. 이 문서는 UICorner 숏핸드(`base/ui-shorthand-plan.md`)/
|
||||
Tween(`research/tween-plan.md`)처럼 별도 전용 문서가 없던 걸 2026-08-07
|
||||
여덟 번째 세션에 메꾼 것 — Tag/Attribute도 "1 프리미티브 1 파일" 관례
|
||||
(Blocker/Effect/Ref/PreRef 분리 선례)를 따라야 한다는 사용자 지적으로 신설.
|
||||
새 설계 내용은 없음 — 이미 여기저기 흩어져 있던 결정을 한 곳에 모으고,
|
||||
오늘 논의한 `None`/`process`/`retract` 동작을 반영.
|
||||
|
||||
## 값 모양
|
||||
|
||||
`[Tag "Name"] = boolean | State<boolean>` — store-bind 가능(일반 프로퍼티와
|
||||
동일하게 취급). PA님의 `EventDrivenProgramming/Observer.luau`
|
||||
`subscribeTaggedInstance`도 얇은 `CollectionService` 래퍼일 뿐이라
|
||||
(`bind-system-plan.md` "PA님 코드와 대조" 절) **Instance 태그는
|
||||
`CollectionService` 직접 사용 그대로 유지** — 별도 자체 태그 시스템(v1이
|
||||
검토했던 것 같은) 안 만듦.
|
||||
|
||||
## 메커니즘 — 새 아키텍처 개념 불필요
|
||||
|
||||
`isHandlable`이 `[Tag "Name"]` 모양의 키를 매칭하는 `TagHandler` 하나로
|
||||
충분:
|
||||
|
||||
- `process(inst, k, v)` — `v`가 참이면(`true`) `CollectionService:AddTag(inst,
|
||||
name)`, 거짓/`nil`이면 `RemoveTag(inst, name)`. `None → nil` 재디스패치
|
||||
(`base/bind-system-plan.md`의 `None` 센티널 절)가 그대로 이 경로를 탐 —
|
||||
`nil`을 "태그 없음"으로 자연스럽게 해석하면 되므로 특별 처리 불필요.
|
||||
- **`retract` 불필요** — 값이 `true`/`false`/`nil` 무엇이든 항상 같은
|
||||
`TagHandler`가 이 키를 계속 담당(핸들러 *타입*이 안 바뀜), 추가/제거를
|
||||
전부 `process` 자신이 처리. `retract`는 "매치되는 핸들러 타입 자체가
|
||||
바뀌는" 경우에만 의미 있다는 게 확정된 원칙(`bind-system-plan.md`
|
||||
"확정된 디스패치 모델" 절, Tween↔일반 프로퍼티가 그 유일한 실사례) —
|
||||
Tag는 여기 해당 안 됨. **처음엔 이 문서 없이 "확정된 디스패치 모델"
|
||||
절이 Tag를 retract 필요 예시로 잘못 들었던 걸 여기서 바로잡음.**
|
||||
|
||||
## 패키지 배치
|
||||
|
||||
UICorner 숏핸드/Tween과 같은 판단 재사용 — 작고 항상 켜져 있어도 비용이
|
||||
무시할 만한 기능은 `quad-roblox` 코어에 직접 포함(`base/ui-shorthand-plan.md`
|
||||
"패키지 배치" 절 참고, 별도 opt-out 패키지로 안 쪼갬).
|
||||
|
||||
## 열린 질문
|
||||
|
||||
없음 — 값 모양/메커니즘/retract 여부 전부 확정. 이름 자체(`Tag`)는 이미
|
||||
쓰기 시작한 v1/PA님 관례와 일치해 특별히 재검토 대상 아님.
|
||||
|
|
@ -69,6 +69,26 @@ Roblox Instance 이름과 맞춘 `UICorner`/`UIPadding`(+`UIPaddingOffset`)/
|
|||
않음. 사용자가 직접 만든 `UICorner`를 quad가 멋대로 건드리는 부작용을
|
||||
피하기 위함.
|
||||
|
||||
### `v`가 `nil`인 경우 — `process`가 직접 자식 제거, `retract`는 관여 안 함 (2026-08-07 여덟 번째 세션)
|
||||
|
||||
`modifier-plan.md`의 `None` 센티널(`base/bind-system-plan.md`의
|
||||
`NoneHandler` 재귀 재디스패치 절 참고)이 최종적으로 이 Handler의
|
||||
`process(inst, k, nil)`을 호출하는 구체 사례 — 이 Handler에서 "`v`가
|
||||
`nil`"은 만들어둔 `_quad_corner`류 자식이 있으면 그냥 지우는 것으로 확정.
|
||||
일반 프로퍼티 핸들러와 달리 이 숏핸드는 실제 Instance를 만들어 붙이는
|
||||
쪽이라 "`nil` = 셋 안 함"이 곧 "만들어둔 게 있으면 치운다"는 뜻이 됨.
|
||||
|
||||
- **이건 `retract`가 아니라 `process` 자신의 로직** — `retract`는 "이
|
||||
키를 다른 핸들러가 넘겨받는" 시나리오 전용(`bind-system-plan.md` "확정된
|
||||
디스패치 모델" 절)이지, 같은 핸들러가 값이 바뀌어서 자기 산출물을
|
||||
정리하는 것과는 다른 문제. 값이 나중에 다시 숫자로(`2`→`nil`→`3`처럼)
|
||||
바뀌면 `process`가 다시 자식을 만들면 그만이라 `retract` 쪽에 별도로
|
||||
구현할 게 없음.
|
||||
- **캐비엇**: 이 왔다갔다가 잦으면(예: 반응형 State가 `nil`과 숫자 사이를
|
||||
자주 토글) 매번 Instance 생성/제거 비용이 그대로 듦 — Tween처럼 무거운
|
||||
API는 아니지만 공짜도 아니므로, 잦은 토글이 예상되는 값을 이 숏핸드에
|
||||
직접 물리는 건 문서화 시점에 캐비엇으로 명시할 것(지금은 메모만).
|
||||
|
||||
## store-bind — 이 숏핸드도 지원, Tween만큼 무겁게 안 가도 됨
|
||||
|
||||
v1에서도 `Corner`/`PaddingAll`/`Scale`은 store 값으로 바인드 가능했음
|
||||
|
|
|
|||
|
|
@ -91,6 +91,18 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만**
|
|||
Effect 핸들이 leaf 부착과 `:Subscribe()` 중 이미 어느 한쪽으로
|
||||
바인딩됐는지 표시하는 내부 플래그 이름(`base/bind-system-plan.md`
|
||||
"이중 바인딩 금지" 절) — 동작은 확정, 이름만 가칭.
|
||||
- **`None`/`NoneHandler`(3순위, 사소함, 2026-08-07 여덟 번째 세션 추가)**:
|
||||
인라인 키/Modifier setter로 필드를 명시적으로 지우는 센티널과, 그걸
|
||||
`nil`로 바꿔 재디스패치하는 base 내장 핸들러 이름 —
|
||||
`modifier-plan.md` "2-1"절/`bind-system-plan.md`의 `None` 센티널
|
||||
절에서 동작은 확정, 이름만 다른 가칭들과 같이 재검토 대상.
|
||||
- **`Brand`(3순위, 사소함, 2026-08-07 여덟 번째 세션 추가)**: 런타임
|
||||
nominal 타입 판별 통합 메커니즘(`Brand.set`/`Brand.get`, `isState`를
|
||||
10종 branded 타입 전부로 일반화) — `bind-system-plan.md`의 `Brand`
|
||||
절에서 동작/구현 방식은 확정, "OOP 인스턴스의 클래스명을 얻는 느낌"을
|
||||
전달할 더 나은 이름이 있는지가 열린 질문(사용자가 직접 제기) — `Tag`는
|
||||
이미 quad-roblox의 `CollectionService` 래퍼로 쓰여서 이름 충돌, 후보로
|
||||
"type namespace"류를 사용자가 검토했으나 미확정.
|
||||
- **"프로바이더"(3순위, 사소함)**: `base/module-lifecycle-plan.md`가
|
||||
"provider"라고 불러온, `isHandlable`로 참여 여부를 결정하고 우선순위대로
|
||||
스캔되는 pluggable 참가자 개념 — 정확한 이름을 "provider"/"processor"/
|
||||
|
|
@ -126,6 +138,12 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만**
|
|||
- **`canExecute`/`Connected`의 실제 구현 방식(Parent==nil vs Connection.
|
||||
Connected vs Destroying 플래그)이 미확정인데 이미 Slot/Observer/store-bind
|
||||
retract 전역에 재사용 확정됨** — M2/M3 착수 전 실측 필요 — 우선순위1-6.
|
||||
**(2026-08-07 여덟 번째 세션 보강)** 시그니처는 `(handle) -> boolean`으로
|
||||
확정(zero-arg 클로저 아님, `base/lifecycle-pattern.md` 참고)됐고
|
||||
rbvm식 gchold 스케치(weak per-instance 배열에 절대 안 발화하는
|
||||
Connection을 넣어 그 클로저 업밸류로 Observer를 살려두는 방식)도
|
||||
후보로 적어뒀지만, 여전히 스케치 단계 — Observer→Connection 역참조를
|
||||
weak 릴레이션으로 둘지 평범한 필드로 둘지 포함, 실측은 그대로 필요.
|
||||
- **~~`LifetimeHandle` 인터페이스가 M8에 배치돼 있지만 M4/M6이 이미 그걸
|
||||
필요로 함(로드맵 순서 역전)~~ — 반영 완료(2026-08-07 세 번째 세션)**:
|
||||
`LifetimeHandle`/`PerInstanceState` 인터페이스(타입만)를 `ROADMAP.md`
|
||||
|
|
@ -137,15 +155,6 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만**
|
|||
|
||||
### 3. 낮은 우선순위
|
||||
|
||||
- **`None`(가칭) 센티널 프리미티브 — 미확정, 2026-08-07 세 번째 세션
|
||||
신설.** `{ Override = nil, mod }`처럼 인라인 키로 modifier가 주는 값을
|
||||
명시적으로 지우고 싶어도 Lua 테이블 리터럴의 `키 = nil`은 키가 아예
|
||||
없는 것과 구별이 안 돼서 "인라인이 modifier보다 무조건 우선"이라는
|
||||
기존 merge 규칙(`modifier-plan.md` 2번)이 이 케이스에선 실제로 작동을
|
||||
안 함. `false`를 이벤트 disconnect 센티널로 쓴 선례처럼 실재하는 값
|
||||
`None`을 도입하는 방향만 나왔고 상세(타입, flatten 내부 표현, State
|
||||
필드에도 같은 문제가 적용되는지)는 미정 — `modifier-plan.md` "2-1"절
|
||||
참고, M7(Modifier) 착수 전 확인.
|
||||
- `research/existing-instance-bind-plan.md` — 스코프 논의만 필요, 구현
|
||||
착수를 막지 않음.
|
||||
- **v1 `objectListClass.__newIndex` 오타 기능의 재현 테스트 필요** —
|
||||
|
|
|
|||
171
CLAUDE.md
171
CLAUDE.md
|
|
@ -991,3 +991,174 @@ setter를 단발로 직접 호출하는 흔한 경로는 여전히 mutable이라
|
|||
|
||||
**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 순수
|
||||
설계 확정이라 M0 착수 우선순위 자체는 그대로.
|
||||
|
||||
## 2026-08-07 여덟 번째 세션 — `None` 센티널 확정(인라인 필드 지우기), `NoneHandler`가 Tween store-bind와 같은 재귀 재디스패치임을 확인
|
||||
|
||||
세 번째 세션에서 "미확정"으로 메모만 남겨뒀던 `None` 센티널
|
||||
(`{ Override = nil, mod }`처럼 인라인 키로 modifier 값을 명시적으로
|
||||
지우고 싶어도 Lua 테이블의 `키 = nil`이 "키 없음"과 구별 안 되는 문제)을
|
||||
사용자가 "이거 결정할 게 진짜 있냐"고 다시 제기해 라이브로 짧게 논의,
|
||||
확정까지 감. 전부 `base/modifier-plan.md`(2-1번)/`base/bind-system-plan.md`
|
||||
(신규 절)/`base/ui-shorthand-plan.md`(신규 절)에 반영 완료:
|
||||
|
||||
- **merge/setter 쪽은 아무것도 안 바뀜** — `None`은 raw 저장 계층의 그냥
|
||||
평범한 실재값이라, 기존 merge 규칙("인라인 키 존재 시 무조건 우선",
|
||||
`Override`의 "뒤 인자가 필드 단위로 이김")이 손댈 것 없이 그대로 작동함.
|
||||
처음엔 "merge 시점에 키를 지운다"는 새 분기가 필요하다고 잘못 생각했다가,
|
||||
"값을 표현만 할 수 있으면 기존 규칙이 이미 다 해줌"이라는 걸로 정정.
|
||||
인라인 props 테이블 키(`{ TextColor3 = None, mod }`)와 Modifier setter
|
||||
인자(`mod:TextColor3(None)`) 둘 다 지원 — 메커니즘이 완전히 같아 구현
|
||||
비용 거의 0("어차피 무료로 얻어지는거 아님?" — 사용자), 후자 덕에
|
||||
"특정 필드만 지우는 재사용 가능한 modifier 조각" 패턴도 공짜로 됨.
|
||||
`:Peek()` 반환 타입도 `T | State<T> | None | nil`로 확장(raw 계층에서
|
||||
`None`을 있는 그대로 돌려줌 — Peek은 확정 안 하고 그대로 넘긴다는 기존
|
||||
9번 절 원칙 그대로).
|
||||
- **실제 "지우기"는 디스패치 단계에서, 새 메커니즘 없이 풀림 — 핵심
|
||||
발견.** 처음엔 "우선순위 최상단에서 값을 그냥 nop 처리"로 생각했다가,
|
||||
사용자가 "그럼 process에 특수 로직이 들어간다"고 지적하며 더 나은 안을
|
||||
직접 제시: `NoneHandler`라는 평범한 pluggable 핸들러 하나를 추가 —
|
||||
`isHandlable`이 `v == None`을 잡고, `process`가 `v`를 진짜 `nil`로 바꿔
|
||||
**`process(inst, k, nil)`을 재귀 호출**. 이게 바로 이미 확정돼 있던 Tween
|
||||
store-bind 핸들러(`bind-system-plan.md` "확정된 디스패치 모델" 절,
|
||||
`v`가 Store면 `realv`를 계산해 `process(inst,k,realv)`로 재귀)와
|
||||
**완전히 같은 패턴**이라는 걸 확인 — 새 아키텍처 개념이 하나도 안 늘어남.
|
||||
base 드라이버 자체(`process(inst,k,v) -> getHandler(inst,k,v).process(...)`)는
|
||||
`None`을 전혀 모르는 순수 제네릭 그대로 유지, 개별 프로퍼티/이벤트/UI
|
||||
shorthand 핸들러 시그니처도 `None`이 안 나옴(원래 있어야 했던 "`v`가
|
||||
`nil`인 경우" 처리를 재사용할 뿐).
|
||||
- **`None`의 의미는 "리셋"이 아니라 "이 조합 단계에서 이 필드를 세팅
|
||||
안 함"** — 실제로 `v=nil`을 받은 핸들러가 뭘 할지는 핸들러마다 다름(일반
|
||||
프로퍼티는 사실상 그대로 두는 것과 다름없고, UICorner 숏핸드처럼 실제
|
||||
Instance를 만들어 붙이는 핸들러는 그 자식을 지움). 구체 사례로
|
||||
`ui-shorthand-plan.md`에 UICorner 절 신설 — `process(inst,k,nil)`이
|
||||
만들어둔 `_quad_corner`류 자식을 직접 지움(이건 `retract`가 아니라
|
||||
`process` 자신의 로직 — `retract`는 "다른 핸들러가 키를 넘겨받는" 별개
|
||||
시나리오 전용, 이미 확정돼 있던 원칙 재확인), 값이 자주 `nil`↔숫자로
|
||||
토글되면 생성/제거 비용이 매번 든다는 캐비엇도 명시.
|
||||
- **M2(디스패치 엔진) 착수 시 확인할 것 하나 새로 생김** — "이 키를 지금
|
||||
누가 담당 중인가" bookkeeping이 바깥 순회 루프가 아니라 `process` 호출
|
||||
자체 내부에서 갱신돼야 함. 안 그러면 값이 계속 `None`으로 유지되는 매
|
||||
사이클마다 "1차 매치는 `NoneHandler`, 재귀 호출 뒤 실제 담당은 다른
|
||||
핸들러"로 바깥 루프가 오판해 불필요한 `retract`가 반복 호출될 위험 —
|
||||
`ROADMAP.md` M2에 반영, `pre-implementation-audit.md`의 "이전 매치
|
||||
핸들러 추적" 항목과 같은 부류라 새 우선순위 등급 없이 거기 흡수.
|
||||
|
||||
**부수 정리**: `question.md`에서 "미확정"이던 `None` 항목을 해소로
|
||||
제거하고, 이름 자체(`None`/`NoneHandler`)만 다른 가칭들과 같이 용어
|
||||
정리 대상(3순위)으로 새로 추가. `ROADMAP.md` M7 체크박스를 "확정 완료"로
|
||||
갱신.
|
||||
|
||||
**같은 세션 후속 — `Dispatch` 함수 네이밍 정리, `canExecute` 시그니처 정정,
|
||||
Tag/Attribute 전용 문서 신설.** None 논의를 파고들다 디스패치 엔진 자체의
|
||||
용어가 여러 군데서 흔들리고 있다는 게 드러나 바로 이어서 정리함. 전부
|
||||
`base/bind-system-plan.md`/`base/lifecycle-pattern.md`/`base/tag-plan.md`
|
||||
(신규)/`base/attribute-plan.md`(신규)/`ROADMAP.md`에 반영 완료:
|
||||
|
||||
- **제 실수 정정 — `canExecute`와 `isHandlable`은 다른 개념.**
|
||||
`isHandlable(k,v)`는 KV 매치 predicate(핸들러 계약 4종 중 하나),
|
||||
`canExecute`는 특정 바인딩 하나가 "지금 살아있어 실행돼도 되는가"만
|
||||
보는 별개의 라이프타임 게이트(`lifecycle-pattern.md`) — `NoneHandler`가
|
||||
구현해야 하는 건 `isHandlable`이지 `canExecute`가 아님, 앞서 잘못 쓴
|
||||
문장을 고침.
|
||||
- **`Dispatch.getHandler`/`Dispatch.process`/`Dispatch.addHandler`/
|
||||
`Dispatch.drive`로 이름 공식화.** 원래 "확정된 디스패치 모델" 절은
|
||||
"스캔+실행"과 "매치된 핸들러 자신의 처리"를 둘 다 그냥 `process`라고
|
||||
불러 이름이 겹쳤던 게 혼동의 원인이었음 — `Dispatch.getHandler(inst,k,v):
|
||||
Handler?`(순수 스캔)와 `Dispatch.process(inst,k,v)`(오케스트레이터:
|
||||
getHandler → 이전 담당자 다르면 그 `retract` → 새 핸들러의 `.process`)로
|
||||
분리, Handler 자신의 필드는 계속 `process`/`retract`(이미 확정된 이름,
|
||||
재검토 대상 아님) — 겹침은 소유자 표기(`Dispatch.process` vs
|
||||
`handler.process`)로 해소, 새 이름 발명 안 함. **`Dispatch.addHandler(handler)`**
|
||||
도 신설 — concrete Handler를 우선순위 레지스트리에 등록하는 것도
|
||||
결국 quad-roblox가 `BaseModule`을 뮤테이션하는 시점에 해줘야 하는
|
||||
일이라(기존 "base 유틸은 인터페이스, 백엔드가 주입" 패턴과 같은 모양).
|
||||
**배열→해시 두 패스 순회 드라이버 자신은 `Dispatch.drive(inst,
|
||||
flattened)`로 확정** — 문서가 이미 이걸 비공식적으로 "base 디스패치
|
||||
드라이버"라 불러왔던 걸 그대로 동사화(`apply`는 기각 — "Dispatch를
|
||||
뮤테이션해서 결과를 낸다"는 어감이라 안 맞는다는 사용자 판단).
|
||||
- **`canExecute` 시그니처 정정: `(handle) -> boolean`, zero-arg 아님.**
|
||||
`lifecycle-pattern.md`가 원래 `canExecute: () -> boolean`(바인딩마다
|
||||
클로즈오버된 람다)으로 적어뒀던 걸 정정 — 그러면 등록마다 클로저를
|
||||
새로 만들어야 해서 "base는 인터페이스만, quad-roblox가 `BaseModule`
|
||||
뮤테이션으로 실 구현 주입"이라는 이미 확정된 패턴과 안 맞음. 공유
|
||||
함수 하나가 되려면 "어떤 등록을 볼지" 가리키는 인자가 필요 —
|
||||
`canExecute(handle: LifetimeHandle): boolean`으로 확정. **quad-roblox
|
||||
구현 스케치(참고용, base 결정 아님)**: rbvm 패턴 재사용 — Instance당
|
||||
weak-keyed per-instance 저장소에 "gchold" 배열을 두고, 절대 발화 안
|
||||
하는 신호에 연결한 Connection의 콜백 클로저 안에 살려두고 싶은
|
||||
Observer를 업밸류로 캡쳐(콜백은 안 불려도 클로저의 업밸류는 안 죽음,
|
||||
`inst`가 GC되면 gchold 배열째로 같이 죽음). `canExecute(handle)`은 이
|
||||
Connection(류)의 `.Connected`를 확인. **미확인 세부사항으로 남긴 것**:
|
||||
Observer→Connection 역참조를 별도 weak 릴레이션으로 둘지 그냥 Observer
|
||||
테이블 안 평범한 필드로 넣을지(정적 해싱 필드 접근이 더 쌀 수 있음) —
|
||||
quad-roblox 구현 단계에서 실측 필요.
|
||||
- **`Tag`/`Attribute`도 UICorner/Tween처럼 전용 문서가 있어야 한다는
|
||||
지적 — 맞아서 `base/tag-plan.md`/`base/attribute-plan.md` 신설.**
|
||||
둘 다 이미 `architecture.md`/`ROADMAP.md` M10에 파일로는 계획돼
|
||||
있었지만 "1 프리미티브 1 파일" 관례(Blocker/Effect/Ref/PreRef 분리
|
||||
선례)를 따르는 전용 설계 문서가 없었음 — 흩어져 있던 내용(Attribute의
|
||||
타입 파라미터화 논의 등)을 모으고, 오늘 확정된 `None`/`process`/
|
||||
`retract` 동작을 반영. **핵심 발견**: Tag/Attribute 둘 다 UICorner
|
||||
숏핸드와 같은 패턴(값이 뭐든 항상 같은 핸들러가 계속 담당, 추가/제거를
|
||||
`process` 자신이 처리)이라 **retract가 필요 없음** — "확정된 디스패치
|
||||
모델" 절이 원래 Tag/Attribute를 retract 필요 예시로 들었던 게 잘못이었음,
|
||||
바로잡고 "retract가 의미 있는 유일한 패턴은 매치되는 핸들러 *타입*
|
||||
자체가 사이클마다 바뀌는 경우(Tween↔일반 프로퍼티가 실사례)"로 좁힘.
|
||||
Attribute는 특히 깔끔한 사례 — Roblox `SetAttribute(name, nil)` 자체가
|
||||
네이티브하게 "지움"이라 `None→nil` 재디스패치가 특별 처리 없이 그대로
|
||||
맞아떨어짐.
|
||||
- `.claude/README.md`에 두 신규 문서 반영, `ROADMAP.md` M2/M10 체크박스
|
||||
갱신(`Dispatch` 4개 함수, `canExecute` 시그니처, Tag/Attribute 문서
|
||||
참조).
|
||||
|
||||
**같은 세션 세 번째 후속 — `canExecute` 옵션 하나 더 검토 후 확정 유지,
|
||||
`Brand` 통합 판별 메커니즘 신설(`isState`를 10종으로 일반화), `isHandlable`도
|
||||
`inst`를 받도록 정정.** 전부 `base/bind-system-plan.md`(`Brand` 절, 핸들러
|
||||
계약 절)/`base/modifier-plan.md`/`ROADMAP.md`/`question.md`에 반영 완료:
|
||||
|
||||
- **`canExecute`를 "각 핸들 타입이 직접 구현"(`Observer.canExecute`)할지
|
||||
"공유 함수"(`canExecute(any)->boolean`)로 할지 재확인 — 공유 함수 유지,
|
||||
솔직한 이유까지 명시.** `Observer` 자체는 quad-base 레벨(엔진 무관)
|
||||
타입인데 liveness 체크(Connection 기반)는 본질적으로 엔진 종속적이라,
|
||||
`Observer.canExecute`가 직접 구현하면 base/roblox 분리 원칙이 깨지거나
|
||||
결국 내부적으로 공유 함수를 다시 호출하는 얇은 래퍼가 될 뿐 — 어느
|
||||
쪽이든 공유 함수 쪽이 낫다는 결론 재확인(추가 논의 없이 유지).
|
||||
- **`Brand` 신설 — `isState`(다섯 번째 세션)를 quad의 다른 branded 타입
|
||||
전부(`Observer`/`Effect`/`Tag`/`Attribute`/`Tween`/`Blocker`/`Store`/
|
||||
`Source`/`Slot`)로 일반화.** 공유 weak-key 레지스트리 하나(`Brand.set`/
|
||||
`Brand.get`) + **문자열이 아니라 테이블 아이덴티티를 태그로 사용**
|
||||
(사용자 제안 — Luau 인터닝 문자열도 이미 O(1) 포인터 비교라 성능 차는
|
||||
없지만, 오타 안전성이 실질 이득: 잘못된 변수 참조는 즉시 드러나지만
|
||||
오타난 문자열 리터럴은 조용히 어긋남). `isX`는 `Brand`를 감싼 얇은
|
||||
wrapper — 단순 항등(`isObserver`)과 집합 멤버십이 필요한 경우(`isState`
|
||||
= `{State,Source}`)로 갈림. **`None`만 예외 — 싱글턴이라 레지스트리
|
||||
없이 `x == None` 항등 비교가 더 싸고 정확**, 대신 `Brand.get`이 범용
|
||||
introspection 창구(quad-debug 용도) 역할까지 겸하도록 `None`을 특수
|
||||
분기로 앞단에서 걸러줌 — `isNone`이 그 분기의 실제 구현체.
|
||||
- **정정 — `isSource`는 별도로 필요함, 다섯 번째 세션의 "불필요" 서술을
|
||||
뒤집음.** 그땐 "State면 충분한 용도"만 봤지만 `Source`는 State보다
|
||||
진짜 더 많은 능력(`:Set`/`:Emit`)을 가진 서브타입이라 "쓰기도 되는
|
||||
원천인가"를 알아야 하는 코드엔 `isState`만으론 부족 — `isSource` 별도
|
||||
제공, `isState`는 여전히 `{State,Source}` 둘 다 통과. `component-
|
||||
composition-plan.md` 4번 절이 애초에 `isSource`가 존재한다고 가정해둔
|
||||
것과도 이걸로 정합됨(그동안 두 문서가 서로 모순돼 있었음, 이번에 발견).
|
||||
- **Luau 타입 narrowing은 자동으로 안 됨 — 사용자가 직접 확인, 명시적
|
||||
`::` 캐스팅 필요.** `isX(v)`가 참이어도 Luau가 TypeScript의 `x is T`
|
||||
같은 사용자 정의 타입 가드를 지원 안 해서 `v`의 정적 타입을 자동으로
|
||||
안 좁혀줌 — `if isState(v) then local s = v :: State<any> ... end`처럼
|
||||
런타임 검증 뒤 명시적 캐스팅이 실제 패턴. 여전히 duck-typing보다 훨씬
|
||||
안전하니 가치는 있지만 자동 narrowing을 기대하면 안 됨.
|
||||
- **`isHandlable`도 `inst`를 받도록 확정 — `(inst,key,value): boolean`,
|
||||
원래 `(key,value)`였던 걸 정정.** `process`/`retract`는 처음부터
|
||||
`inst`를 항상 받았는데(핸들러 계약 원 원칙) `isHandlable`만 예외였던
|
||||
게 애초에 약간의 불일치 — 지금 당장 `inst`로 매치가 갈리는 케이스는
|
||||
없지만, 나중에 필요해지면 핸들러 계약 자체를 깨는 breaking change가
|
||||
되므로 지금 넣어두는 게 훨씬 쌈. `Dispatch.getHandler`가 스캔 중
|
||||
`handler.isHandlable(inst,k,v)`로 호출하도록 갱신.
|
||||
- `ROADMAP.md` M2에 `Brand.luau` 체크박스 신설, `Handler.luau`/M7의
|
||||
`isState` 항목 갱신. `question.md`에 `Brand` 이름(용어 정리 대상,
|
||||
"OOP 클래스명을 얻는 느낌"에 맞는 더 나은 이름 필요 — `Tag`는 이미
|
||||
quad-roblox에서 다른 뜻으로 쓰여서 충돌) 반영.
|
||||
|
||||
**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 순수
|
||||
설계 확정이라 M0 착수 우선순위 자체는 그대로.
|
||||
|
|
|
|||
50
ROADMAP.md
50
ROADMAP.md
|
|
@ -50,14 +50,38 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
|
||||
## M2 — 디스패치 엔진
|
||||
|
||||
- [ ] `Dispatch/init.luau`(`process`/`retract` 엔진, `isHandlable` 우선순위 스캔)
|
||||
- [ ] `Handler.luau`(핸들러 계약 타입)
|
||||
- [ ] `Dispatch/init.luau` — `Dispatch.getHandler(inst,k,v): Handler?`(순수
|
||||
스캔, `isHandlable`+`priority`) / `Dispatch.process(inst,k,v)`(오케
|
||||
스트레이터: getHandler → 이전 담당자와 다르면 그 `retract` → 새
|
||||
핸들러의 `.process`) / `Dispatch.addHandler(handler)`(레지스트리
|
||||
등록, quad-roblox가 팩토리 뮤테이션 시점에 호출) / `Dispatch.drive(inst,
|
||||
flattened)`(배열→해시 두 패스 순회하며 각 `(k,v)`에 `process` 호출 —
|
||||
`bind-system-plan.md`의 `None` 센티널 절, 2026-08-07 여덟 번째 세션에
|
||||
네이밍 확정). "이 키를 지금 누가 담당 중인가" bookkeeping은
|
||||
`Dispatch.drive`가 아니라 `Dispatch.process` 호출 자체 내부에서
|
||||
갱신할 것(재귀 재-process 시에도 자연히 갱신되게 — 안 그러면 재귀
|
||||
재디스패치를 쓰는 케이스(Tween store-bind, `NoneHandler`)에서 매
|
||||
사이클 불필요한 `retract`가 반복 호출될 위험)
|
||||
- [ ] `Handler.luau`(핸들러 계약 타입: `isHandlable(inst,k,v)`/`priority`/
|
||||
`process`/`retract` — `isHandlable`도 `inst`를 받도록 확정, 2026-08-07
|
||||
여덟 번째 세션 정정)
|
||||
- [ ] `Brand.luau`(공유 weak-key 레지스트리, `Brand.set(x,tag)`/
|
||||
`Brand.get(x)` — `isState`뿐 아니라 `isObserver`/`isEffect`/`isTag`/
|
||||
`isAttribute`/`isTween`/`isBlocker`/`isSource`/`isStore`/`isSlot`
|
||||
전부의 기반. `isNone`만 예외로 레지스트리 없이 `x == None` 항등
|
||||
비교 — `bind-system-plan.md`의 `Brand` 절, 2026-08-07 여덟 번째
|
||||
세션 신설)
|
||||
- [ ] `LifetimeHandle.luau`/`PerInstanceState.luau` **인터페이스만**(타입
|
||||
계약, 실 구현 없음 — quad-roblox 실 구현은 M8) — 원래 M8에만
|
||||
있었으나 M4(StoreBind의 `Connected` 확인)/M6(Slot의 `canExecute`)이
|
||||
이미 이 인터페이스를 전제로 서술돼 있어 로드맵 순서가 역전돼
|
||||
있었음(`pre-implementation-audit.md` 우선순위1-9, `question.md` 2번
|
||||
— 2026-08-07 세 번째 세션에 반영)
|
||||
— 2026-08-07 세 번째 세션에 반영). **`canExecute`는 `(handle:
|
||||
LifetimeHandle) -> boolean`으로 확정**(바인딩마다 클로저 만드는
|
||||
zero-arg가 아니라, quad-roblox가 한 번만 주입하는 공유 함수 — "base
|
||||
유틸은 인터페이스, 백엔드가 주입" 패턴과 맞춰야 해서.
|
||||
`base/lifecycle-pattern.md`의 gchold 스케치 절, 2026-08-07 여덟 번째
|
||||
세션 정정)
|
||||
- [ ] mock 대상 테스트
|
||||
|
||||
## M3 — Store/State/Source
|
||||
|
|
@ -119,12 +143,15 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
- [ ] `:Apply(factory)` 팩토리 함수 체이닝(`modifier-plan.md` 8번, 예약 키
|
||||
`Apply`가 제네릭 `__index` 필드 setter와 안 겹치는지 확인)
|
||||
- [ ] `:Peek<<T>>(key): T|State<T>|nil` 필드 읽기 접근자 +
|
||||
`isState(x): boolean`(weak-key 레지스트리 기반, quad-base 공용
|
||||
유틸 — `modifier-plan.md` 9번, `bind-system-plan.md`의 `isState` 절)
|
||||
- [ ] 인라인 키로 modifier 필드를 명시적으로 지우는 문제 확인 — `None`
|
||||
(가칭) 센티널 프리미티브 도입 여부(`modifier-plan.md` 2-1번, 아직
|
||||
미정 — 착수 전 사용자 확인 필요, 확정 안 되면 이번 마일스톤은
|
||||
스킵하고 다음으로 미뤄도 됨)
|
||||
`isState(x)`/`isSource(x): boolean`(`Brand` 공유 레지스트리 기반 —
|
||||
`modifier-plan.md` 9번, `bind-system-plan.md`의 `Brand` 절, M2의
|
||||
`Brand.luau`에 이미 구현돼 있어야 함)
|
||||
- [ ] 인라인 키/setter로 modifier 필드를 명시적으로 지우는 `None`(가칭)
|
||||
센티널(`modifier-plan.md` 2-1번, `Peek` 반환 타입에 `None` 추가) +
|
||||
이를 `nil`로 재디스패치하는 base 내장 `NoneHandler`
|
||||
(`bind-system-plan.md`의 `None` 센티널 절, M2 dispatch 엔진의
|
||||
"이전 매치 핸들러 추적" 항목과 함께 구현 — Tween store-bind 핸들러와
|
||||
동일한 재귀 재디스패치 패턴이라 새 메커니즘 아님) — 확정 완료
|
||||
|
||||
## M8 — Ref
|
||||
|
||||
|
|
@ -153,8 +180,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
## M10 — Event / Attribute / Tag
|
||||
|
||||
- [ ] `Handlers/Event.luau`(`ReflectionService` 기반 자동 판별)
|
||||
- [ ] `Handlers/Attribute.luau`
|
||||
- [ ] `Handlers/Tag.luau`(`CollectionService`)
|
||||
- [ ] `Handlers/Attribute.luau`(`base/attribute-plan.md` — 메커니즘/`None`/
|
||||
`retract` 불필요 확정, 타입 파라미터화 이름만 착수 전 확인)
|
||||
- [ ] `Handlers/Tag.luau`(`CollectionService`, `base/tag-plan.md` — 전부 확정)
|
||||
|
||||
## M11 — Tween
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue