decide(tween): 값-레벨 Tween<T> 래퍼로 재설계, pre-implementation-audit 1-1 해소

독립 Dispatch 핸들러("v가 Store인 아무 k나 잡는 우선순위 최상위 핸들러")
모델을 PropertyHandler가 소비하는 값-레벨 래퍼(Tween<T>)로 전환. State/
Source 언랩(범용 StoreBind)과 "이 값이 트윈 대상인가" 판단을 분리해,
일반 반응형 프로퍼티 바인딩이 Tween 파일을 거쳐가는지 불명확했던 구조적
모호함(pre-implementation-audit.md 1-1)을 해소.

- Tween.Value는 plain T만(반응성은 바깥 :Compute가 전담, 이중 경로 방지)
- hasBeenSet+활성 엔진 트윈을 3-상태 릴레이션 슬롯(RobloxTween|true|nil)
  하나로 통합, 첫 세팅은 항상 애니메이션 없이 스냅
- 활성 트윈 정리 후에만 새 값 세팅(순서 뒤바뀌면 값이 덮어써질 위험)
- 타입은 T'=T|Tween<T> 치환만으로 기존 T|State<T> 모양에 자동 통합
- useTween은 :Apply(Animate(...))로 해소, 새 옵션 필드 불필요
- PropertyHandler가 항상 매치되는 유일한 핸들러가 되어 Tween↔프로퍼티
  handler-switch에 의존하던 retract 케이스가 사라짐

구 모델은 archive/tween-special-bind-key-reversed.md로 보존. 코퍼스
전체(bind-system-plan.md/architecture.md/modifier-plan.md/ROADMAP.md/
question.md/README.md/attribute-plan.md)의 stale Tween 참조 동기화,
CLAUDE.md 세션 요약 추가.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
qwreey 2026-08-10 00:53:26 +09:00
parent fc43e1f267
commit 7466e9216b
Signed by: qwreey
GPG key ID: D28DB79297A214BD
11 changed files with 574 additions and 192 deletions

View file

@ -54,7 +54,7 @@
| 문서 | 내용 | 우선순위 |
|---|---|---|
| `tween-plan.md` | 트윈을 Store 밖 특수 bind key로 처리, 기본 오버라이드는 Cancel, 트윈 옵션 값 모양(TweenInfo vs 편의 필드)은 신규 열린 논의 | 중 — 세부 옵션만 남음 |
| `tween-plan.md` | **[2026-08-10 세션 재설계]** 트윈을 값-레벨 `Tween<T>` 래퍼(PropertyHandler가 소비)로 전환, 구 특수 bind key 모델은 `archive/tween-special-bind-key-reversed.md`로 이전. 3-상태 릴레이션 슬롯(`RobloxTween\|true\|nil`)으로 hasBeenSet 억제, `T'=T\|Tween<T>` 타입 치환, `useTween``:Apply`/`:Compute`로 해소. 기본 오버라이드는 Cancel, 트윈 옵션 값 모양(TweenInfo vs 편의 필드)만 남음 | 중 — 세부 옵션만 남음 |
| `existing-instance-bind-plan.md` | 이미 생성된 인스턴스 재바인드 — 착수 안 하되 "미지원" 확정도 안 함, 열린 가능성 유지 | 하 — v2 초기 스코프 제외 |
| `debug-tooling-plan.md` | 실물 Instance→코드 위치 역추적 Studio 플러그인(`quad-debug`) — 채널 실현 가능성(BindableEvent/Function 크로스 컨텍스트)까지 실측 검증 완료, 세부 API 이름·구현만 남음 | 하 — 사용자가 "quad 개발 완료 전엔 착수 못 함"으로 직접 후순위 지정, base 설계 시 훅 확장 지점만 고려 |
| `documentation-plan.md` | 문서 사이트 구조(초심자/api/심화/`quadnomicon` 4축, 백엔드별 트랙 분리) + UI 네이밍 컨벤션·Store 부작용 패턴·권장 이벤트 핸들링 3개 세부 문서 뼈대 | 하 — 착수 시점 미정, 구조/스코프만 합의된 상태 |
@ -80,6 +80,7 @@
| `observer-cleanup-contract-rejected.md` | **[기각됨, 2026-08-09 코퍼스 정리 신설]** `Observer` 자체에 React `useEffect`식 cleanup 반환 계약을 추가하는 안 — 클로저로 이미 충분해 기각, `Effect`가 opt-in 상위 계층으로 이 패턴을 제공 |
| `keyed-collection-state-method-rejected.md` | **[기각됨, 2026-08-09 코퍼스 정리 신설]** 키 기반 동적 컬렉션 재조정 프리미티브를 `state:Keyed(...)` State 메소드로 두려던 초안 — Source 미사용 컴포넌트가 접근 못 한다는 반례로 철회, 현재는 자유 함수로 확정 |
| `debug-channel-replicatedstorage-rejected.md` | **[기각됨, 2026-08-09 코퍼스 정리 신설]** quad-debug 채널을 `ReplicatedStorage`에 자동 생성하던 초안 — 게임 트리 오염 부작용으로 기각, quad 모듈 자신의 트리+`CollectionService` 태그로 대체 |
| `tween-special-bind-key-reversed.md` | **[역전됨, 2026-08-10 신설]** 구 Tween 모델(`[Tween(key,tweenData...)] = storeValue` 특수 bind key, 우선순위 최상위 Dispatch 핸들러) — 값-레벨 `Tween<T>` 래퍼 모델로 완전히 대체됨(`research/tween-plan.md`) |
## 참고

View file

@ -0,0 +1,68 @@
# [역전됨] Tween = 우선순위 최상위 store-bind Dispatch 핸들러(`[Tween(key,tweenData...)] = storeValue`) — 값-레벨 `Tween<T>` 래퍼로 대체됨
**역전 일시**: 2026-08-10. **원 확정 일시**: 2026-08-04(로드맵 인수인계
라운드 전후, "확정된 방향: 트윈을 Store/반응 그래프 밖에 둔다" 최초 작성).
**현재 유효한 설계**: `research/tween-plan.md`(전면 재작성됨)가 최종 소스.
이 파일은 능동적으로 참고할 필요 없음(구현에 안 씀) — 왜 "Tween이 곧
범용 store-bind 핸들러"였던 모델에서 "Tween은 PropertyHandler가 소비하는
값-레벨 래퍼"로 넘어갔는지가 `quadnomicon` 소재로 가치 있어서 사유·원문을
통째로 보존해둔 것.
## 역전된 사례 — 원래 무엇을 확정했었나
**메커니즘**: Tween을 `[Tween(key, tweenData...)] = storeValue` 형태의
특수 bind key로 제공. `k`는 무엇이든 받고 `v`가 Store(반응형 값)인 경우를
잡아내는, **우선순위가 매우 높은 Dispatch 핸들러**. 처음 실행될 때는
그냥 바인드로 필드를 쓰지만, 이후에는 store 값을 핸들해서 바뀔 때마다
트윈을 처리:
```
[Tween(key, tweenData...)] = storeValue
```
핸들러 내부에서: (1) 라이프타임(`Connected`) 확인, (2) 사용자가 넘긴
함수들을 거쳐 실제 값(`realv`) 계산, (3) `Dispatch.retractUnder`로 자기
밑을 정리한 뒤 `realv`를 들고 `Dispatch.process(inst, k, realv)`를 재귀
호출 — "store 바인드는 pluggable 바인드를 재실행하는 래핑"이라는 원칙의
구체 사례.
**override 정책**: 기본값 Cancel, 나머지 세 옵션(오버라이드/삭제 후
재시작/끝점 이동 후 재시작)은 `retract(inst, k, v)`가 이전 값을 받아
처리 — 이 부분은 새 모델에서도 그대로 유지됨(PropertyHandler 내부
로직으로 위치만 이동).
## 왜 역전됐나
`research/pre-implementation-audit.md` 우선순위1-1이 지적한 구조적
모호함이 출발점 — 이 문서 전체에서 "`v`가 store인 값을 구독해 `realv`
재귀 process하는" 범용 메커니즘의 유일한 구체 예시가 항상 "Tween"으로만
등장했음. 그런데 Tween(실제 애니메이션, override/cancel 정책)은 명백히
더 좁고 별개인 기능이라, `Frame { BackgroundColor3 = store.color }`처럼
애니메이션 없이 그냥 반응형으로 값만 바뀌길 원하는 가장 흔한 케이스가
(a) 결국 이름은 "Tween"인 파일을 거쳐가며 "애니메이션 없음"으로 처리되는
건지, (b) 각 핸들러가 범용 `Dispatch/StoreBind.luau` 유틸을 독립적으로
써야 하는 건지 문서가 정하지 않은 상태로 남아있었음.
2026-08-10 세션에서 사용자가 직접 제기한 재설계 방향("Tween 프리미티브를
`V`에 넣는 식, 최종 Property가 알아서 `V``isTween`이면 트윈 넣도록")으로
해소 — State/Source 언랩(범용 StoreBind)과 "이 값이 트윈 대상인가" 판단을
완전히 분리해, 후자를 Dispatch 우선순위 경쟁이 아니라 PropertyHandler
내부의 평범한 값 분기로 옮김.
## 대체 모델과의 비교
| | 구 모델(우선순위 최상위 Dispatch 핸들러) | 신 모델(값-레벨 `Tween<T>` 래퍼) |
|---|---|---|
| 매치 방식 | `isHandlable(inst,k,v) = isState(v)` — Tween이 범용 StoreBind 역할까지 겸함 | 범용 `StoreBind`가 State/Source를 언랩, `Tween` 여부는 `realv`를 받은 PropertyHandler가 직접 판단 |
| "애니메이션 없는 일반 반응형 바인딩"의 정체 | 불명확(이름이 Tween인 파일을 거쳐가는지 문서가 안 정함) | 명확함 — 그냥 `Dispatch/StoreBind.luau`, Tween과 완전히 무관 |
| 핸들러 타입 전환 | Tween↔프로퍼티 핸들러 사이에서 실제로 바뀜 → `retract`가 이 케이스의 대표 예시였음 | 항상 PropertyHandler 하나만 매치 → 이 `retract` 케이스 자체가 사라짐, 전환은 3-상태 릴레이션 슬롯으로 내부 처리 |
| 트윈 대상 값 타입 | Store 전체(`T`뿐 아니라 임의 반응형 값) | `Tween<T> = {Value: T, ease...}``Value`는 plain `T`만, 반응성은 바깥 `:Compute`가 전담 |
| 진입 애니메이션 억제 | 별도 논의 없음 | 3-상태 슬롯(`RobloxTween\|true\|nil`)의 `hasBeenSet` 분기로 자동 해결 |
부수적으로, 이 역전은 Tag가 이미 겪었던 것과 같은 종류의 단순화 —
"핸들러 *타입*이 실제로 안 바뀌면 `retract`가 필요 없어진다"는 결론을
Tween에도 적용한 셈. Tag 역전(`archive/tag-hash-key-model-reversed.md`)이
"핸들러 타입이 안 바뀐다는 전제가 실사용에서 깨졌다"는 방향이었다면,
Tween 역전은 반대로 "핸들러 타입을 애초에 안 바뀌게 재설계해서 전제
자체를 성립시켰다"는 방향 — 같은 `retract`/핸들러-전환 문제를 서로
반대 방향에서 접근한 두 사례로 대비해볼 만함(quadnomicon 소재).

View file

@ -138,6 +138,7 @@ quad/
│ ├── Blocker.luau # 값 기반 emit 지연/합치기(`base/blocker-plan.md`), State/Source와 밀접 연관돼 같은 위치
│ ├── 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 세 번째 세션)
│ ├── 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`)
│ ├── Dispatch/
│ │ ├── init.luau # process/retract 엔진, isHandlable 우선순위 스캔, `chains`(inst,k별 핸들러 체인)+`retractUnder`(`bind-system-plan.md` "Dispatch 체인" 절, 2026-08-08 세 번째 세션)
@ -156,13 +157,13 @@ quad/
├── RobloxFactory.luau # BaseModule 뮤테이션, 재호출 가드(같은 팩토리=무시/다른=에러)
├── LifetimeHandle.luau # bindLifetime/canExecute 실제 구현 — GetPropertyChangedSignal("ClassName") 연결 트릭으로 gcconn 확보, Relate:SetStrong으로 gcconn/gchold 저장(`base/lifecycle-pattern.md`). Relate 자체는 순수 Lua라 quad-roblox 쪽 재구현 없음(quad-base 그대로 재사용)
├── Handlers/
│ ├── Property.luau
│ ├── 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 기반 자동 판별
│ ├── Attribute.luau
│ ├── Tag.luau # CollectionService 글루만(process/retract) — 값 타입/API는 quad-base Tag.luau(`base/tag-plan.md`)
│ ├── Tween.luau # 높은 우선순위 store-bind 핸들러
│ ├── Slot.luau # base Slot 재조정 로직의 실제 적용/해제(Instance Parent 조작)
│ └── InstanceChild.luau # k:number, v:Instance — 중첩 인스턴스 자식(예: Frame { Frame {} })
├── Animate.luau # `Animate(condOrOpts, opts?)` 편의 콤비네이터 — `:Apply`/`:Compute`/`Tween{...}` 조합, base 프리미티브 아님(`research/tween-plan.md`)
├── DI/
│ └── init.luau # 제네릭 생성자 + ~25개 정적 필드(UIInstances)
└── init.luau

View file

@ -61,9 +61,10 @@ Instance 참조 타입도 지원해서 `ObjectValue` 없이도 Ref 용도로 Att
- **`retract` 불필요** — Tag와 같은 이유: 값이 뭐든(실제 값/`nil`) 항상
같은 `AttributeHandler`가 이 키를 계속 담당(핸들러 *타입*이 안 바뀜).
`retract`가 의미 있는 유일한 패턴("매치되는 핸들러 타입 자체가 바뀜",
Tween↔일반 프로퍼티가 실사례)에 해당 안 함 — `bind-system-plan.md`
"확정된 디스패치 모델" 절이 한때 Attribute도 retract 필요 예시로 들었던
걸 여기서 바로잡음.
`Tag(...)`↔`nil`이 실사례 — 2026-08-10 세션부터 Tween은 더 이상 이
패턴의 예시가 아님, `research/tween-plan.md`)에 해당 안 함 —
`bind-system-plan.md` "확정된 디스패치 모델" 절이 한때 Attribute도
retract 필요 예시로 들었던 걸 여기서 바로잡음.
- store-bind 가능(일반 프로퍼티와 동일하게 취급, `Store<T>`/`State<T>`
값도 받음).

View file

@ -48,8 +48,9 @@ v1의 `ProcessQuadProperty`(`.claude/initreq/quad/src/class.lua:134-214`)는
**`retract` 필드 자체는 생략 불가, no-op이라도 항상 정의할 것(2026-08-08
세션, 확정)** — `Dispatch.process`(아래 "확정된 디스패치 모델" 절)는
담당 핸들러 *타입*이 바뀔 때 이전 핸들러의 `retract`를 nil 체크 없이
무조건 호출함. 필드를 생략한 핸들러가 나중에(드물더라도, 예: Tween↔
프로퍼티 교체) 실제로 담당이 바뀌는 순간 `attempt to call a nil value`
무조건 호출함. 필드를 생략한 핸들러가 나중에(드물더라도, 예: `Tag(...)`
`nil` 교체 — `base/tag-plan.md`) 실제로 담당이 바뀌는 순간 `attempt to
call a nil value`로
바로 크래시 — "의미 있게 구현할 필요 없음"은 "구현이 사소해도 됨"이라는
뜻이지 "필드를 안 둬도 안전하다"는 뜻이 아님. 새 핸들러를 짤 때 이 필드가
없으면 리뷰/린트에서 걸러내야 함(M2 착수 시 확인 목록에 추가).
@ -84,9 +85,9 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들
부분은 `Dispatch.getHandler`로 이름이 공식화됨**(아래 `None` 센티널
절, 2026-08-07 여덟 번째 세션) — 이 절에서는 개념 설명이라 편의상
그냥 `process`로 계속 씀.
- 예시: Tween의 store-bind 핸들러는 **`k`는 무엇이든 받고 `v`가 Store인 경우를
잡아내는, 우선순위가 매우 높은 핸들러** — `v`가 store이면 그 값을 처리(구독)함.
이 핸들러 안에서:
- 예시: `Dispatch/StoreBind.luau`(범용, 엔진 무관)는 **`k`는 무엇이든 받고
`v`가 State/Source인 경우를 잡아내는, 우선순위가 매우 높은 핸들러** —
`v`가 반응형이면 그 값을 처리(구독)함. 이 핸들러 안에서:
1. 지금 이 처리가 실행되어도 되는지 라이프타임(`Connected`)을 확인 —
확인 안 하면 이미 Destroy된 대상에 대해 처리가 실행되는 문제가 생김. GC가
결국 정리하긴 하지만, GC 되기 전에도 store 값이 업데이트될 수 있으므로
@ -98,10 +99,15 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들
아래 "Dispatch 체인" 절, 2026-08-08 세 번째 세션 — 오케스트레이터
이름 공식화는 아래 `None` 센티널 절 참고, 2026-08-07 여덟 번째
세션) — 이게 바로 "store 바인드는 pluggable 바인드를 재실행하는
래핑"이라는 이 문서 이전 초안의 결론과 일치. `realv`가 store가
아니라면 자연히 Tween의 store-bind 핸들러 `isHandlable`을 통과 못
하고 우선순위상 다음 핸들러(일반 프로퍼티 세터 등)로 흘러감 — 무한
재귀 걱정 없음.
래핑"이라는 이 문서 이전 초안의 결론과 일치. `realv`가 반응형이
아니라면 자연히 `StoreBind``isHandlable`을 통과 못 하고 우선순위상
다음 핸들러(일반 프로퍼티 세터 등)로 흘러감 — 무한 재귀 걱정 없음.
**[정정, 2026-08-10 세션]** 이 예시는 원래 "Tween의 store-bind
핸들러"였으나, Tween이 독립 Dispatch 핸들러가 아니라 PropertyHandler가
소비하는 값-레벨 래퍼(`Tween<T>`)로 재설계되며(`research/
tween-plan.md`, `archive/tween-special-bind-key-reversed.md`) 이
자리의 대표 예시에서 빠짐 — `NoneHandler`(아래 절)가 지금은 이
패턴의 남은 대표 예시.
- **`retract(inst, k, v)`** (이전 초안의 "cleanup", 이름 변경 근거는
`base/lifecycle-pattern.md` 참고) — 이전 처리를 무르는/멈추는 함수. **오직
"같은 key에 새 값이 들어와서 이전 처리를 갈아치우는" 시나리오에만 존재** —
@ -111,18 +117,23 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들
동작) — 그래서 프로퍼티 핸들러는 보통 `retract`가 필요 없음.
- **`retract`가 실제로 의미 있는 유일한 패턴은 "같은 키에 대해 매치되는
핸들러 *타입 자체*가 사이클마다 바뀌는 경우"** (2026-08-07 여덟 번째
세션, 정정) — 예: 이전엔 Tween 핸들러가 매치돼 애니메이션이 실행
중이었는데, 다음 값이 더 이상 Tween 대상이 아니게 되어 일반
PropertyHandler로 매치가 넘어가는 경우, 이전 Tween을 멈추는 게
`retract`의 일. **Attribute는 여기 해당 안 함** — UICorner 숏핸드와
같은 패턴(값의 참/거짓/nil 여부와 무관하게 항상 같은 핸들러가 계속
담당, 추가/제거를 전부 `process` 자신이 처리)이라 핸들러 교체 자체가
안 일어남 — `base/attribute-plan.md`. **[정정, 2026-08-08 세 번째
세션] Tag는 더 이상 여기 해당하지 않음** — array-part 값 객체로
재설계되며(`base/tag-plan.md`, 구 모델은 `archive/
tag-hash-key-model-reversed.md`) `Tag(...)`↔`nil` 사이에서 핸들러
타입 자체가 바뀌므로 `retract`가 의미 있어짐(전체 삭제), 같은 Tag끼리
바뀌는 diff는 `process`가 담당.
세션, 정정) — 예: `Tag(...)`↔`nil` 사이에서 핸들러 타입 자체가
바뀌므로 `retract`가 의미 있어짐(전체 삭제), 같은 Tag끼리 바뀌는
diff는 `process`가 담당(`base/tag-plan.md`, 2026-08-08 세 번째 세션
— array-part 값 객체 재설계 이후, 구 모델은 `archive/
tag-hash-key-model-reversed.md`). **Attribute는 여기 해당 안 함**
UICorner 숏핸드와 같은 패턴(값의 참/거짓/nil 여부와 무관하게 항상
같은 핸들러가 계속 담당, 추가/제거를 전부 `process` 자신이 처리)이라
핸들러 교체 자체가 안 일어남 — `base/attribute-plan.md`. **[정정,
2026-08-10 세션] Tween도 더 이상 여기 해당하지 않음** — 원래는 이
패턴의 대표 예시("Tween 핸들러가 매치돼 애니메이션 실행 중이었는데
값이 더 이상 Tween 대상이 아니게 되어 일반 PropertyHandler로 매치가
넘어가는 경우")였으나, Tween이 독립 Dispatch 핸들러가 아니라
PropertyHandler가 소비하는 값-레벨 래퍼(`Tween<T>`)로 재설계되며
매치되는 핸들러가 항상 PropertyHandler 하나뿐이 되어 이 케이스
자체가 사라짐 — 트윈 취소/전환은 이제 PropertyHandler 내부의
3-상태 릴레이션 슬롯으로 처리(`research/tween-plan.md`, `archive/
tween-special-bind-key-reversed.md`).
- store bind가 새 값으로 넘어갈 때 이전 핸들러의 `retract`를 호출해주면
됨 — **정확한 전파 메커니즘은 아래 "Dispatch 체인" 절 참고**(재귀
재-dispatch에서 여러 단계가 겹칠 때 어느 슬롯에 뭘 추적하는지가
@ -141,8 +152,12 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들
명시화)**: 구조가 "`inst`로 weak-keyed된 바깥 릴레이션 안에 `k`별 안쪽
릴레이션이 중첩된" 모양이라, `inst`가 죽어 weak table 엔트리가 통째로
사라지는 순간 그 안에 중첩된 `k`별 Tween 인스턴스 릴레이션도 같이 GC됨 —
별도 cleanup 로직 불필요(Tween 핸들러가 여기 담아두는 실제 Tween 인스턴스도
자동으로 같이 죽는 것까지 포함, `research/tween-plan.md` 참고).
별도 cleanup 로직 불필요(PropertyHandler가 여기 담아두는 실제 엔진 Tween
인스턴스도 자동으로 같이 죽는 것까지 포함). **[정정, 2026-08-10 세션]**
Tween은 더 이상 별도 "Tween 핸들러"가 아니라 PropertyHandler 내부
로직이므로, 이 슬롯이 실제로 담는 값은 `RobloxTween | true | nil`
3-상태(첫 세팅 여부까지 같은 슬롯에 통합) — 상세는 `research/
tween-plan.md` "3-상태 저장" 절 참고.
- **다른 값 변경을 추적하는 것도 process 함수의 정상 범위**: 예를 들어 Slot
핸들러는 자기가 감시하는 값(배열/스토어)이 바뀌면 그에 따라 child를
갱신해야 함 — `retract` 시점엔 그 추적(구독)만 풀면 됨.
@ -153,9 +168,9 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들
둬야 할 문제가 아니라 오작동하는 handler/provider(`quad-roblox` 등) 쪽
버그로 간주 — **사용자 확정**("입력된 값이 다시 입력되면 무한루프
빠지겠지만, 그건 막기 힘들고 유저가 내기도 힘들어. 아예 quad-roblox나
프로바이더가 잘못 짠 코드일테니까"). Tween의 store-bind 재귀 케이스(위
78-79행)처럼 자연히 좁혀지는 경우가 일반적이고, 일반 사용자가 만들어낼 수
있는 상황이 아니라고 판단해 별도 가드 없이 진행.
프로바이더가 잘못 짠 코드일테니까"). `StoreBind`의 재귀 케이스(위 절)처럼
자연히 좁혀지는 경우가 일반적이고, 일반 사용자가 만들어낼 수 있는 상황이
아니라고 판단해 별도 가드 없이 진행.
- **props 순회 순서는 base 디스패치 드라이버가 명시적으로 두 단계로
고정한다 — 배열 파트(숫자 키, children/Ref류) 먼저, 해시 파트(문자열 키,
@ -177,13 +192,13 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들
항목 — `research/pre-implementation-audit.md`가 짚은 "실제 Luau로
부딪혀본 적 없는 것" 범주와 같은 급이라 신중하게 다룸).
### `None` 센티널 — Tween store-bind와 같은 재귀 재디스패치 패턴 재사용 (2026-08-07 여덟 번째 세션)
### `None` 센티널 — StoreBind와 같은 재귀 재디스패치 패턴 재사용 (2026-08-07 여덟 번째 세션, 예시는 2026-08-10 세션에 StoreBind로 정정)
`modifier-plan.md` "2-1"절의 "인라인 키로 modifier 필드를 명시적으로
지우기" 문제 — raw 저장 계층(Modifier 필드/인라인 props/`Peek`)에서 쓰는
`None` 센티널이 실제로 인스턴스에 반영될 때 base가 뭘 하는지가 이 문서의
층위. 결론: **새 메커니즘이 아니라 위 "확정된 디스패치 모델"의
Tween store-bind 핸들러(65-79행)와 완전히 같은 모양의 핸들러 하나 추가.**
`StoreBind` 핸들러(위 절)와 완전히 같은 모양의 핸들러 하나 추가.**
```
NoneHandler.priority = <매우 높음>
@ -209,8 +224,8 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출
이 문제의 출발점이었으므로, 매치 대상은 항상 `None` 마커.
`Dispatch.process(inst, k, nil)`로 재귀 호출하는 순간 `None`은 더 이상
존재하지 않고 진짜 `nil`이 되므로, 다음 우선순위 스캔은 자연히 키 `k`
원래 담당하던 핸들러(프로퍼티/이벤트/UI shorthand 등)로 흘러감 — Tween의
store-bind 핸들러가 `realv`를 들고 재귀하면 자연히 다음 핸들러로 좁혀지는
원래 담당하던 핸들러(프로퍼티/이벤트/UI shorthand 등)로 흘러감 —
`StoreBind` 핸들러가 `realv`를 들고 재귀하면 자연히 다음 핸들러로 좁혀지는
것과 정확히 같은 원리, 무한루프 걱정도 동일하게 없음.
- **`Dispatch.process`/`Handler.process` 이름 겹침 — 소유자 네임스페이싱으로
해소, 새 이름 발명 안 함 (2026-08-07 여덟 번째 세션 후속).** 원래
@ -222,7 +237,7 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출
- `Dispatch.process(inst,k,v)` — 오케스트레이터: `getHandler` 호출 →
매치된 핸들러를 `(inst,k)` 체인 꼬리에 push → 그 핸들러의 `.process`
호출. **"이전 핸들러와 다르면 retract"라는 diff는 `Dispatch.process`
자신의 일이 아님** — 재귀/래핑 핸들러(Tween/일반 store-bind/
자신의 일이 아님** — 재귀/래핑 핸들러(`StoreBind`/
`NoneHandler`)가 재-dispatch 전에 스스로 `Dispatch.retractUnder(inst,
k, self, newV)`를 먼저 불러 자기 밑을 정리하는 책임을 짐(정확한
메커니즘·기각된 대안은 아래 "Dispatch 체인" 절 참고 — 전역 소유자
@ -277,7 +292,7 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출
만들 수 있는 것)로 바꿔야 하는지 검토 후 **기각, 지금 형태(모듈 require로
바로 닿는 flat 탑레벨 함수) 유지로 확정**:
- **재귀 재-dispatch가 요구하는 필연**Tween/`NoneHandler`/`Dispatch/
- **재귀 재-dispatch가 요구하는 필연**`NoneHandler`/`Dispatch/
StoreBind.luau` 전부 자기 `process` 안에서 다시 `Dispatch.process(inst,k,
realv)`를 호출함(위 "확정된 디스패치 모델"/"`None` 센티널" 절). 이게
성립하려면 Dispatch가 `canExecute`/`bindLifetime`(`base/
@ -288,11 +303,11 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출
- **순환참조로 보이는 건 착시 — 실제로는 단방향.** "Handler"라는 말이 두
가지를 가리켜서 헷갈릴 수 있음: (a) `Handler.luau`의 **타입 계약**
(`isHandlable`/`priority`/`process`/`retract` 시그니처만 있는 순수 leaf,
Dispatch를 몰라도 됨) vs (b) `StoreBind.luau`/`Tween.luau`처럼 그 계약을
Dispatch를 몰라도 됨) vs (b) `StoreBind.luau`처럼 그 계약을
**구현하는 concrete 값 모듈**(재귀호출 위해 Dispatch를 require함). 의존
방향은 항상 한쪽으로만 흐름 — `Handler.luau`(leaf) ← `Dispatch/init.luau`
(`addHandler(h: Handler)`가 `Handler` 타입만 참조) ← `StoreBind.luau`/
`Tween.luau`(재귀호출 위해 Dispatch를 참조). `Handler.luau` 자신이
(`addHandler(h: Handler)`가 `Handler` 타입만 참조) ← `StoreBind.luau`
(재귀호출 위해 Dispatch를 참조). `Handler.luau` 자신이
Dispatch를 되받아 참조하는 일이 없으니 타입 레벨에서도 사이클이 안 생김.
런타임에서도 마찬가지 — 어떤 handler의 `process`든 실제로 *호출*되는
시점은 컴포넌트가 렌더되는 시점이라, 그때는 이미 Dispatch 모듈 require가
@ -304,9 +319,11 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출
`.claude/question.md`가 2026-08-08 세션에 "quad-base/quad-roblox 중
어디 사는지 미확인"으로 남겨뒀던 항목, 이 결론으로 해소: quad-base,
`Dispatch/Leaf.luau`, `Dispatch.addHandler`로 등록). quad-roblox의
Property/Event/Tween 핸들러도 **같은** `Dispatch.addHandler` 레지스트리에
Property/Event 핸들러도 **같은** `Dispatch.addHandler` 레지스트리에
등록됨 — base 기본 핸들러와 backend 핸들러가 별도 경로로 안 갈리고
전부 하나의 우선순위 스캔을 공유.
전부 하나의 우선순위 스캔을 공유. **[정정, 2026-08-10 세션]** Tween은
더 이상 별도로 등록되는 핸들러가 아님 — Property 핸들러 내부에서
소비되는 값-레벨 래퍼로 재설계됨(`research/tween-plan.md`).
- **모듈 재생성(`New()`)과의 관계 — 새 설계 불필요, 이미 있는 선례로 자연히
풀림.** v1처럼 `require`를 감싸 `Init(QuadId?)`로 격리 인스턴스를 만드는
방식은 안 씀(위 "확정된 것" 절 — id 기반 조회 자체가 Ref로 대체되며
@ -323,7 +340,7 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출
### Dispatch 체인 — 재귀 재-dispatch의 retract 전파, `Dispatch.retractUnder` (2026-08-08 세 번째 세션)
**문제**: Tween/`NoneHandler`/`StoreBind`처럼 자기 `process` 안에서
**문제**: `NoneHandler`/`StoreBind`처럼 자기 `process` 안에서
`Dispatch.process(inst,k,realv)`를 다시 부르는 래핑 핸들러가 있으면, 같은
`(inst,k)`에 대해 "지금 누가 담당 중인가"를 슬롯 하나로 추적하는 순간
깨짐 — 래핑 핸들러 A 자신의 생명주기(예: StoreBind의 Observer 구독)와,
@ -395,10 +412,13 @@ end
중 한 번 나왔으나 기각(전체 삭제 vs 부분 diff를 갈라야 하는 핸들러가
있어서, `base/tag-plan.md` 참고). 다만 `v`가 실제로 필요한지는
핸들러마다 다름 — Tag는 구조상 retract가 "더 이상 매치 안 될 때만"
불리므로 `v`를 안 봐도 항상 전체 삭제가 맞음(무조건), Tween 같은
경우는 자기 `Relate` 저장분만 보고 `Cancel`하면 되니 역시 `v`를 꼭
안 봐도 됨 — `v`는 "계약상 항상 주어지지만 안 쓰는 핸들러가 있어도
됨" 정도로 이해할 것.
불리므로 `v`를 안 봐도 항상 전체 삭제가 맞음(무조건) — `v`는 "계약상
항상 주어지지만 안 쓰는 핸들러가 있어도 됨" 정도로 이해할 것.
**[정정, 2026-08-10 세션]** 원래 두 번째 예시로 들었던 Tween(자기
`Relate` 저장분만 보고 `Cancel`하면 되니 `v`를 꼭 안 봐도 됨)은 더
이상 유효한 예시가 아님 — PropertyHandler가 항상 매치되는 유일한
핸들러가 되어 이 `retract` 경로 자체가 사실상 안 쓰임(`research/
tween-plan.md`).
- **순환은 UB, 방어 로직 없음** — Handler 간 순환 참조(A가 B를 부르고
B가 다시 A로 돌아오는 것)는 재귀 호출이 안 끝나 바로 스택오버플로가
나므로 애초에 일어날 수 없는 구조(각 핸들러는 최대 한 번씩만 그
@ -676,9 +696,10 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플
## Ref — 도입 확정, 단 용도는 재정의됨
**중요한 정정**: Ref는 Tween이 대상을 얻기 위해 필요한 게 아님(Tween 핸들러도
`process(inst,k,v)`처럼 항상 대상 Instance를 직접 받으므로 — 위 "확정된 디스패치
모델" 참고, `research/tween-plan.md`도 이에 맞춰 갱신됨). Ref의 진짜 용도는 다름:
**중요한 정정**: Ref는 Tween이 대상을 얻기 위해 필요한 게 아님(트윈을
실제로 처리하는 PropertyHandler도 `process(inst,k,v)`처럼 항상 대상
Instance를 직접 받으므로 — 위 "확정된 디스패치 모델" 참고, `research/
tween-plan.md`도 이에 맞춰 갱신됨). Ref의 진짜 용도는 다름:
- v1의 `Frame "id" {}` + `Store.GetObject(id)` 식 id 매핑은 폐기 확정
(`base/architecture.md` 5번 항목) — "비현실적"이라는 게 이유.
@ -2017,8 +2038,11 @@ T|State<T>|nil`가 돌려주는 raw union을 사용자 코드가 분기하려면
수단이 필요했음)와 똑같은 필요가 quad의 다른 branded 타입에도 전부
적용됨 — `Observer`/`Effect`/`Tag`/`Attribute`/`Tween`/`Blocker`/`Store`/
`Source`/`Slot`/`None`까지, Handler 구현(`isHandlable`에서 "이 값이
Tween인가/Store인가" 판별)과 사용자 코드 양쪽에서 반복적으로 필요해질
수단이라 `isState` 하나만 만들고 끝내지 않고 전체를 일관된 메커니즘으로
Store인가/Tag인가" 판별, 또는 PropertyHandler의 `process` 내부에서
"이 값이 Tween인가" 판별 — 2026-08-10 세션부터 `isTween``isHandlable`
아니라 값-레벨 분기에서만 쓰임, `research/tween-plan.md` 참고)과 사용자
코드 양쪽에서 반복적으로 필요해질 수단이라 `isState` 하나만 만들고
끝내지 않고 전체를 일관된 메커니즘으로
통합(component-composition-plan.md 4번 절이 이미 "`isSource`류 판별자로
(`isObserver`와 동일한 패턴)"라고 이 방향을 예견해뒀던 것과 맞아떨어짐).

View file

@ -85,9 +85,9 @@ Lua 테이블 리터럴은 배열 파트/해시 파트 사이에 소스 텍스
- **실제 "지우기" 동작은 디스패치 쪽 `NoneHandler`가 담당** — merge가 끝난
뒤 최종 flatten 결과에 `None`이 남아있으면, base 드라이버가 그 키를 어떻게
처리하는지는 새 개념이 아니라 이미 확정된 디스패치 모델 그대로다.
상세는 `base/bind-system-plan.md`의 "`None` 센티널 — Tween store-bind와
상세는 `base/bind-system-plan.md`의 "`None` 센티널 — StoreBind와
같은 재귀 재디스패치 패턴 재사용" 절 참고 — 핵심만 요약하면 `NoneHandler`
Tween의 store-bind 핸들러와 완전히 같은 모양(`isHandlable`이 `v == None`
`StoreBind` 핸들러와 완전히 같은 모양(`isHandlable`이 `v == None`
잡고, `process``v`를 진짜 `nil`로 바꿔 `process(inst, k, nil)`을 재귀
호출)이라 base 드라이버 자체엔 `None`을 아는 코드가 한 줄도 안 들어감 —
개별 프로퍼티/이벤트/UI shorthand 핸들러도 자기 시그니처에 `None`이 안
@ -309,7 +309,7 @@ predicate(`Brand` 절)를 State/Source 쪽에도 적용해 **런타임에 직접
`isModifier`면 캐싱 전에 `error`). 새 체크 지점을 여러 곳에 흩는 게
아니라, "값이 State/Source의 값으로 확정되는" 이미 존재하는 몇 안
되는 지점에 `isModifier` 검사 한 줄씩 얹는 것뿐.
- **Slot/Tag/Attribute/Tween 등 다른 핸들러 계층 값은 여전히 아무
- **Slot/Tag/Attribute 등 다른 핸들러 계층 값은 여전히 아무
문제 없이 State/Source에 담길 수 있음 — Modifier만의 예외임을
명확히.** (사용자 확인: "slot은 당연히 가능함, retract도 되는 애고
런타임 값이라") 이 값들은 전부 정상적으로 `process`/`retract`
@ -319,7 +319,14 @@ predicate(`Brand` 절)를 State/Source 쪽에도 적용해 **런타임에 직접
없음. Modifier만 유독 문제인 건 Modifier가 애초에 dispatch 경로를
아예 안 타는 유일한 존재(1번 절)라서, State/Source에 담기는 순간
"재귀 재-dispatch로 처리"할 대상 자체가 없어지기 때문 — 이 구분이
왜 Modifier만 막고 나머지는 다 허용하는지의 핵심 근거.
왜 Modifier만 막고 나머지는 다 허용하는지의 핵심 근거. **[정정,
2026-08-10 세션] `Tween`은 이 그룹에서 빠짐** — Tween이 독립 Dispatch
핸들러(`process`/`retract`를 가진 dispatch 참가자)에서 PropertyHandler가
소비하는 값-레벨 래퍼로 재설계되며(`research/tween-plan.md`), `Tween<T>`
이제 `process`/`retract`가 없는 순수 raw 데이터 값 — `None`과 같은
분류. State/Source에 `Tween<T>`가 담기는 것 자체는 여전히 문제없이
허용되지만(위 타입 대수 절 참고), 그 이유는 "재귀 dispatch 참가자라서"가
아니라 "그냥 raw 값이라서"로 바뀜.
- **`Store<T>``T`는 Modifier가 될 수 없음(`base/store-semantics.md`
"따름정리" 절)도 이 결정을 그대로 물려받음** — Source가 State를
구조적으로 만족하므로 별도로 다시 논증할 필요 없이 동일하게 적용됨.
@ -541,6 +548,27 @@ Source도 같이 잡아줌 — **[2026-08-07 여덟 번째 세션 정정] `isSou
아니라 4-1번 setter 분기가 이미 내부적으로 해야 하는 걸 public 유틸로
승격하는 것뿐.
### 10. `Tween<T>`와의 타입 합성 — `T' = T | Tween<T>` 치환만으로 해결 (2026-08-10 세션)
`research/tween-plan.md`가 값-레벨 `Tween<T>` 래퍼로 재설계되며, 프로퍼티류
Modifier 필드 setter가 트윈 값도 받을 수 있어야 하는지가 자연히 따라오는
질문이었음 — **답은 "이미 있는 `T | State<T>` 필드 타입 모양에 새 케이스를
추가할 필요가 없다"** — 위 4번 절이 확정한 필드 타입 모양(리터럴 `T` 또는
`State<T>`)에서 "이 필드의 `T`" 자체를 `T' = T | Tween<T>`로 치환하면
자동으로 `T | Tween<T> | State<T | Tween<T>>`가 나옴. 즉 `FrameModifier`
타입 생성 스크립트가 `Position` 필드를 만들 때 그냥 `T`를 `UDim2 |
Tween<UDim2>`로 바꿔서 기존 setter 시그니처 생성 로직에 그대로 넣으면 됨 —
Modifier의 제네릭 `__index`/`table.clone` 런타임(위 "런타임은 클래스별
코드 없이" 절)에도 `Tween` 인지 로직을 전혀 추가할 필요 없음(setter는
어차피 값을 그대로 baked 저장할 뿐, 그 값이 `Tween<T>`인지는 나중에
PropertyHandler가 판단).
`Tween<T>`가 Modifier 필드로 담기는 것도, `State<Tween<T>>`처럼 State/Source
값으로 담기는 것도 둘 다 아무 문제 없음 — 7번 절의 "핸들러 계층 값 →
error" 규칙에 안 걸림(`Tween<T>`는 `process`/`retract`를 가진 dispatch
참가자가 아니라 `None`처럼 순수 raw 데이터 값, 위 7번 절 "Slot/Tag/Attribute
등" 목록에서 Tween을 뺀 정정 참고).
## 열린 질문 (`.claude/question.md`에도 취합)
- **[해소됨]** Getter 정확한 이름/모양 — 2026-08-06 후속 세션에서 getter

View file

@ -138,10 +138,12 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만**
정해두면 싼 지연리스크) + 2개 단순화후보. 전체는 그 문서 참고, 특히
사용자 판단이 필요한 것 위주로 요약:
- **Tween.luau가 "범용 store-bind 캐치올 핸들러"의 유일한 예시로 서술됨**
일반 반응형 프로퍼티 바인딩(`BackgroundColor3 = store.color`, 애니메이션
없음)이 결국 이름은 "Tween"인 파일을 거쳐가는 건지, 아니면 별도 범용
`Handlers/StoreBind.luau`가 있어야 하는 건지 확정 필요 — 우선순위1-1.
- **[해소됨, 2026-08-10 세션]** Tween.luau가 "범용 store-bind 캐치올
핸들러"의 유일한 예시로 서술됨 — Tween을 독립 Dispatch 핸들러에서
값-레벨 래퍼(`Tween<T>`, PropertyHandler가 소비)로 재설계해 해소.
범용 State/Source 언랩은 `Dispatch/StoreBind.luau` 하나뿐, Tween 여부
판단은 완전히 별개(`research/tween-plan.md` 전면 재작성, 우선순위1-1
해소).
- **[해소됨, 2026-08-09 세션]** `State<Modifier>`와 Ref/Slot이 Modifier
필드에 들어가는 것 — 이제 둘 다 `isX` predicate 기반 명시적 `error`
통일(`base/modifier-plan.md` 4번/7번 절, `base/store-semantics.md`
@ -266,7 +268,7 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만**
| Batch(lexical) 기각, Context(+레이어드 Store) 기각 | `archive/batch-rejected.md`, `archive/context-rejected.md` |
| Fusion/Vide 비교 리서치(주의: 일부 서술은 이후 라운드에서 뒤집힘, 문서 내 정정 표시 참고) | `reference/comparison-fusion-vide.md` |
| v1 내부 동작 스냅샷 | `reference/quad-v1-architecture.md` |
| 트윈 오버라이드(기본값 Cancel), 세부 옵션만 남음 | `research/tween-plan.md` |
| 트윈 — 값-레벨 `Tween<T>` 래퍼로 구조 확정(2026-08-10), 오버라이드 기본값 Cancel, 옵션 값 모양·옵션 키 이름만 남음 | `research/tween-plan.md` |
| quad2-try(폐기된 이전 시도) 리서치 — OOP 상속/커스텀 파서/Slot 스텁/`Pipe` COW 전부 죽은 접근으로 확인, 반복 조사 금지 | `base/bind-system-plan.md` |
---

View file

@ -36,7 +36,17 @@ M0 착수 전에 확인**. 우선순위 2는 지금 결정해두면 싼데 안
## 우선순위 1 — M0~M4 착수 전 확인 권장
### 1-1. Tween이 "범용 store-bind 캐치올 핸들러"의 유일한 예시로 쓰여, 일반 반응형 프로퍼티 바인딩과 혼동될 위험
### 1-1. Tween이 "범용 store-bind 캐치올 핸들러"의 유일한 예시로 쓰여, 일반 반응형 프로퍼티 바인딩과 혼동될 위험 — [해소됨, 2026-08-10 세션]
**해소**: 아래 "제안"과 같은 방향(별개 핸들러로 분리)이 아니라, 그보다
더 근본적인 재설계로 해소됨 — Tween 자체가 더 이상 독립 Dispatch
핸들러가 아니라 PropertyHandler가 소비하는 값-레벨 래퍼(`Tween<T>`)로
바뀌어, "일반 store-bind와 Tween-store-bind가 같은 핸들러인지"라는
질문 자체가 성립하지 않게 됨 — 범용 State/Source 언랩은 `Dispatch/
StoreBind.luau` 하나뿐이고 Tween은 그 아래에서 나온 `realv`
PropertyHandler가 직접 판별. 상세는 `research/tween-plan.md`(전면
재작성), 구 모델은 `archive/tween-special-bind-key-reversed.md`. 아래는
원래 발견 당시 기록.
**위치**: `base/bind-system-plan.md` "확정된 디스패치 모델" 절 67-79행 —
"Tween의 store-bind 핸들러는 **`k`는 무엇이든 받고 `v`가 Store인 경우를

View file

@ -1,57 +1,228 @@
# Tween / 애니메이션 플러깅 (기본값 확정, 옵션 키 이름·값 모양만 남음)
# Tween / 애니메이션 플러깅 (구조 재확정 — 값-레벨 `Tween<T>` 래퍼, 옵션 값 모양만 남음)
**상태**: research — 방향은 뚜렷하게 잡혀 있고(라이브러리가 트윈을 직접
구현하지 않는다), `retract` 순서/오버라이드 기본값(Cancel)도 확정됨. 남은 건
기본값 외 나머지 오버라이드 동작을 고르는 옵션 키의 정확한 이름/시그니처와,
트윈 옵션을 어떤 값 모양으로 받을지(아래 "트윈 옵션 값 모양" 절, 신규)
정도. **중요 — 놓치기 쉬운 포인트**: `retract`는 Destroy(완전 소멸) 시엔
호출되지 않는다(아래 "`retract`는 완전 소멸 시엔 호출되지 않는다" 절) —
Tween 오버라이드 로직을 짤 때 "인스턴스가 파괴될 때도 이 코드가 실행될
것"이라고 가정하면 틀림. 원본:
**상태**: research — **2026-08-10 세션에서 구조 전체가 재설계됨.** 기존
"`v`가 Store인 아무 `k`나 잡는 우선순위 최상위 Dispatch 핸들러" 모델은
`research/pre-implementation-audit.md` 1-1이 지적한 구조적 모호함("애니메이션
없는 일반 반응형 프로퍼티 바인딩도 결국 이름이 Tween인 파일을 거쳐가는가")을
명확히 답하지 못했음 — 대체된 새 모델(`Tween<T>`를 PropertyHandler가
소비하는 값-레벨 래퍼로 두는 것)이 이 모호함을 구조적으로 해소함, 아래
"새 모델" 절부터가 최종 소스. **구 모델(특수 bind key `[Tween(key,
tweenData...)] = storeValue`)은 `archive/tween-special-bind-key-reversed.md`
이전됨** — 원문/역전 사유는 거기 보존, 이 문서는 새 모델만 서술.
남은 건 옵션 값 모양(`TweenInfo` 그대로 vs 편의 필드)과 override 정책
옵션 키의 정확한 이름/시그니처뿐 — M11 착수 시 확정. 원본:
`.claude/initreq/raw-userinput.md` "트윈은 어떻게 할 것이냐" / "스토어 값은
항상 먼저 캐치한다" / "네임스페이스드 객체" 절. Fusion의 Tween/Spring이
반응 그래프 안에 있는 설계는 명시적 반면교사 — [정정: 절 제목이 실제와
달랐음] `reference/comparison-fusion-vide.md`의 "Fusion" 절 마지막 불릿
("Tween/Spring이 State그래프 안의 1급 노드") 참고.
반응 그래프 안에 있는 설계는 명시적 반면교사 — `reference/
comparison-fusion-vide.md`의 "Fusion" 절 마지막 불릿("Tween/Spring이
State그래프 안의 1급 노드") 참고.
## 확정된 방향: 트윈을 Store/반응 그래프 밖에 둔다
## 확정된 방향: 트윈을 Store/반응 그래프 밖에 둔다 (변경 없음)
렌더 라이브러리가 트윈을 직접 구현하는 건 처음부터 디자인이 잘못된 접근 —
렌더링 엔진(Roblox `TweenService`)의 애니메이션 관리를 전혀 안 따르게 되기
때문. 대신:
```
[Tween(key, tweenData...)] = storeValue
```
형태의 **특수 bind key**로 제공. 처음 실행될 때는 그냥 바인드로 필드를 쓰지만,
이후에는 store 값을 핸들해서 바뀔 때마다 트윈을 처리. 아니면 사용자가 직접
태그를 얻어 관리하게 둠(둘 다 허용 가능한 경로로 열어둘 것).
때문. quad는 트윈을 반응 그래프에 1급 노드로 편입시키지 않고, 트윈 대상
값을 감싸는 얇은 값-레벨 래퍼(아래)로만 취급한다.
**왜 이게 중요한가(Fusion 리서치에서 확인된 근거)**: Fusion은 Tween/Spring을
`GraphObject`(1급 반응 노드, `timeliness="eager"`)로 만들어서 Computed의 입력으로
자유롭게 합성 가능하게 했지만, 그 대가로 (1) 매 프레임 틱하는 외부 클럭
소스(Stopwatch/ExternalTime)를 반응 그래프에 통합해야 했고, (2) eager 재계산
노드가 무효화/전파 로직과 경쟁하게 됐고, (3) 트윈-입력 간 별도의 교차 lifetime
체크 기계장치가 필요해졌다. quad가 트윈을 특수 bind key로 빼면 이 세 가지
체크 기계장치가 필요해졌다. quad가 트윈을 값-레벨 래퍼로 빼면 이 세 가지
복잡도를 전부 피할 수 있다 — 대신 트윈된 값이 Computed류의 추가 입력으로
자유롭게 합성되진 못한다는 걸 감수(Fusion 대비 유일한 손해).
자유롭게 합성되진 못한다는 걸 감수(Fusion 대비 유일한 손해, 아래 새 모델에서도
그대로 유지됨 — `Tween.Value`는 plain `T`만 받고 자체 반응 경로를 안 둠).
## 정정: Ref 불필요 — 핸들러는 항상 대상 Instance를 직접 받는다
## 새 모델: `Tween<T>` 값-레벨 래퍼, PropertyHandler가 소비 (2026-08-10 세션, 핵심 재설계)
**이전 초안의 전제가 틀렸음.** Tween 핸들러도 `base/bind-system-plan.md`
"확정된 디스패치 모델"을 그대로 따르는 store-bind 핸들러 중 하나 — `process(inst,
k, v)`가 항상 대상 Instance(`inst`)를 직접 받으므로, 트윈 대상을 얻기 위해
Ref나 네임스페이스드 조회가 필요하지 않음. Tween의 store-bind 핸들러는 "`k`는
무엇이든, `v`가 Store인 것"을 잡아내는 우선순위 매우 높은 핸들러로 등록되고,
`inst`는 이미 파라미터로 주어짐. (Ref 자체는 도입되지만 전혀 다른
용도 — `base/bind-system-plan.md`의 Ref 절 참고.)
**동기**: 기존 모델("`k`는 무엇이든, `v`가 Store인 경우를 잡는 우선순위
매우 높은 핸들러")은 State/Source 언랩(범용 StoreBind)과 Tween(실제
애니메이션, 좁고 별개인 기능)을 같은 Dispatch 핸들러 하나로 뭉쳐서,
"`Frame { BackgroundColor3 = store.color }`처럼 애니메이션 없이 그냥
반응형으로만 바뀌길 원하는 흔한 케이스가 결국 이름은 Tween인 파일을
거쳐가는지"가 문서로 답이 안 됐음.
## `retract`(구 cleanup)로 확정된 오버라이드 시맨틱
**해소**: State/Source 언랩(`Dispatch/StoreBind.luau`, 범용·엔진무관 —
`k`/`v`의 실제 타입과 무관하게 그냥 `isState(v)`만 보고 `realv`까지
재귀적으로 풀어냄)은 완전히 별개로 유지하고, **"이 값이 트윈 대상인가"는
최종 소비자(PropertyHandler)가 `realv`를 다 풀어낸 뒤 직접 판단**한다 —
별도 Dispatch 핸들러/우선순위 경쟁이 아니라, PropertyHandler 함수 내부의
평범한 분기.
**스토어 값은 항상 먼저 캐치한다** — 그래야 `retract` 호출이 가능(이름 변경
근거는 `base/lifecycle-pattern.md`). 이전 트윈을 취소하고 새 트윈을 만드는 게
맞지만, "취소" 시점의 동작이 여러 갈래로 갈릴 수 있음:
```lua
Tween(opts: {Value: T, ease..., onOverride?...}) -> Tween<T>
```
`Store({...})`와 같은 "`Type(args)`가 테이블 인자를 받는 팩토리" 컨벤션 —
Lua 문법상 `Tween{Value=target, ease=...}`처럼 괄호를 생략해 호출.
**PropertyHandler.process(inst,k,realv)의 새 로직** — `realv`는 이미
StoreBind가 State/Source 레이어를 전부 풀어낸 뒤의 값:
1. `isTween(realv)`가 거짓이면 — 기존과 동일하게(아래 "3-상태 저장" 참고,
`hasBeenSet` 여부만 갱신하고) 즉시 세팅.
2. `isTween(realv)`가 참이면 — 아래 "3-상태 저장" 절의 분기를 따름.
### `Tween.Value`는 plain `T`만 받음 — 내부에 별도 반응 경로를 안 둠
처음엔 `Tween.Value``T | State<T>`를 받아야 하나(내부에 자체 Observer를
걸어 값이 바뀔 때마다 트윈을 재시작) 검토했으나 **불필요로 확정** — 이미
바깥 `:Compute`가 소스 State 변경마다 새 `Tween{Value=v,...}` 테이블을
통째로 재생성해 StoreBind 재귀 재-dispatch 경로를 타므로, `Tween`
내부에 또 다른 반응 경로를 만들 이유가 없음. "같은 일 하는 두 번째 경로를
만들지 않는다"는 이 프로젝트가 Effect의 deps/Ref의 대기 경로 등에서 이미
여러 번 적용한 원칙과 정확히 같은 결. **`Tween<T> = {Value: T, ease...,
onOverride?...}`로 확정** — `Value` 필드는 항상 plain `T`.
### 3-상태 저장 — `RobloxTween | true | nil` (릴레이션 슬롯 하나로 `hasBeenSet` 통합)
처음엔 "첫 세팅 여부(`hasBeenSet: boolean`)"와 "실행 중인 엔진 Tween
객체"를 별도 필드로 저장하려 했으나, **하나의 릴레이션 슬롯으로 통합**
`relate:GetStrong(inst,k)`가 돌려주는 값의 3가지 상태:
- **`nil`** — 이 `(inst,k)`가 이번 `inst`에서 한 번도 process된 적 없음
(첫 세팅).
- **`true`** — 최소 한 번 세팅된 적 있음(직전 값이 plain이었든 `Tween<T>`
였든 무관), 지금은 활성 엔진 Tween 없음.
- **실제 엔진 `TweenBase` 인스턴스** — 지금 애니메이션이 진행 중, 새 값을
처리하기 전에 먼저 정리해야 함.
**분기**:
1. **`prev == nil`(첫 세팅)** — `realv``Tween<T>`든 plain이든 무관하게
**애니메이션 없이 즉시 `Value`(또는 plain 값)로 세팅**, 슬롯엔 `true`
저장. 엔진 기본값(예: Frame 기본 `Position`)에서 목표값으로 날아오는
"첫 마운트 진입 애니메이션" 버그를 이걸로 방지.
2. **`prev == true`(세팅된 적 있음, 활성 트윈 없음)**:
- `realv`가 plain 값 → 즉시 세팅, 슬롯은 `true` 유지.
- `realv``Tween<T>` → 이제 정상적으로 애니메이션 시작(현재 인스턴스
프로퍼티 값에서 자연스럽게 출발), 슬롯에 새로 만든 엔진 Tween 객체
저장.
3. **`prev`가 엔진 Tween 객체(활성 트윈 있음)**:
- **먼저 override 정책(기본 Cancel, 아래 절)에 따라 이전 트윈을 정리 —
반드시 그 정리가 끝난 뒤에 새 값을 세팅한다.** 순서가 뒤바뀌면
이전 트윈의 다음 인터폴레이션 프레임이 방금 세팅한 값을 덮어쓸
위험이 있음(엔진 트윈은 비동기로 계속 프로퍼티를 갱신 중이므로).
- 정리 후: `realv`가 plain 값이면 (정리 결과로 프로퍼티에 남은 현재
값 위에) 즉시 덮어쓰기 + 슬롯 `true`. `realv``Tween<T>`면 (같은
현재 값에서) 새 트윈 시작 + 슬롯을 새 엔진 Tween 객체로 갱신.
- plain 값이 들어와 진행 중인 트윈을 끝내는 경우, 기존 override
정책의 4가지 옵션(Cancel/Override/Delete-restart/Move-to-end-restart,
아래 절)은 원래 Tween→Tween 전환을 염두에 둔 것이라 Tween→plain
전환에는 사실상 전부 "멈추고 그 자리에서 즉시 덮어쓴다"로 수렴하는
것으로 보임 — 별도 5번째 옵션이 필요해 보이진 않으나 **확정은 아님,
M11 착수 시 재확인**.
**GC-안전성은 기존과 동일** — `Relate``inst`로 weak-keyed되어 있어
`inst`가 죽으면 이 슬롯(엔진 Tween 객체 포함)도 별도 정리 로직 없이 같이
GC됨. `retract`는 이 케이스에서 거의 안 불림 — 아래 절 참고.
### 왜 `retract`가 더 이상 필요 없는가 — Dispatch 체인 관점의 결과적 단순화
기존 모델에선 "Tween 핸들러가 매치되어 애니메이션이 실행 중이었는데,
다음 값이 더 이상 Tween 대상이 아니게 되어 일반 PropertyHandler로
핸들러 *타입*이 바뀌는" 경우가 `base/bind-system-plan.md`가 서술하는
"`retract`가 실제로 의미를 갖는 유일한 패턴"의 대표 예시였음. 새 모델에선
**매치되는 Dispatch 핸들러가 항상 PropertyHandler 하나뿐**(Tween 여부는
값 내부 분기일 뿐 핸들러 매치 자체엔 영향 없음) — 이 시나리오 자체가
Dispatch 레벨에서 사라짐. 트윈 취소/전환은 위 3-상태 저장 로직으로
PropertyHandler 내부에서 처리 — Tag가 이미 하고 있는 "diff는 `process`
자신이 담당" 패턴과 같은 모양이라 새 개념 아님. (PropertyHandler의
`retract` 필드 자체는 여전히 정의해둬야 함 — "필드 생략 불가" 규칙은
예외 없는 일반 규칙 — 다만 실제로 호출될 일이 이 경로에선 사실상 없음.)
### 타입 대수: `T' = T | Tween<T>` — Modifier/State/Source에 새 타입 기계 불필요
지금 프로퍼티류 필드가 열려 있는 자리(Modifier setter, Ref, Store/Source
필드)는 전부 `T | State<T>` 모양 하나로 통일돼 있음. 여기서 "이 필드의
`T`" 자체를 `T' = T | Tween<T>`로 치환하면 자동으로 `T | Tween<T> |
State<T | Tween<T>>`가 나옴 — Modifier/State/Source/StoreBind 코드엔
`Tween` 인지 로직을 전혀 안 넣어도 됨(StoreBind는 원래도 페이로드 타입에
무관하게 `isState`만 보고 언랩하는 opaque한 구조였음). `Tween<T>`를 실제로
해석하는 코드는 여전히 PropertyHandler 하나에만 존재.
**핸들러 계층 UB 체크와도 안 부딪힘** — `Tween<T>``Ref`/`Observer`/
`Slot`류처럼 `process`/`retract`를 가진 dispatch 참가자가 아니라 `None`/
`Tag`처럼 순수 raw 데이터 값(별도 `TweenTag` Brand)이라, Modifier 필드/
`State<Modifier>`가 막는 "핸들러 계층 값" 규칙(`base/modifier-plan.md`)에
안 걸림 — 그 문서가 원래 Tween을 Slot/Tag/Attribute와 같은 "dispatch
참가자" 그룹으로 분류해뒀던 건 부정확했던 것으로 이번에 정정(아래
"패키지 경계" 절 참고).
## `useTween`(트윈 우회) — 해소됨, 새 옵션 필드 불필요
이전엔 `Tween{useTween=state<boolean>}`처럼 `Tween` 생성자 안에 별도
옵션 필드를 두는 방향으로 열려 있었으나, 값-레벨 래퍼 모델에선 **이미
있는 `state:Apply(factory)`/`:Compute`만으로 공짜로 풀림** — 새 필드
불필요:
```lua
-- reduceMotion: State<boolean>
Position = mySource:Apply(Animate(reduceMotion, {ease = ...}))
```
`Animate(reduceMotion, opts)`는 커링 팩토리로, 개념상 다음과 같은 모양:
```lua
return function(state)
return state:Compute(function(v)
if reduceMotion:Get() then
return v
else
return Tween{Value = v, ease = opts.ease}
end
end)
end
```
`reduceMotion`이 바뀌면 `:Compute`가 재계산되어 StoreBind가 자연히 새
`realv`(plain 또는 Tween-wrapped)로 재-dispatch — PropertyHandler는 평소처럼
그 값만 보고 처리하면 됨, 우회 로직을 따로 알 필요 없음. **`Animate`
base 프리미티브가 아니라 quad-roblox가 제공하는 자유 함수 조합기**(아래
"패키지 경계" 절) — `Modifier:Apply(Boldify(10))` 커링 패턴과 완전히
같은 모양이라 base에 새로 추가할 게 없음.
## 초기 진입 애니메이션(`initValue`) — 여전히 별개 문제, 위 hasBeenSet과 상충 방향 주의
`initValue`는 여전히 미확정(2026-08-09 세션 결론 유지: "필요성 낮은
쪽으로 기움", 완전 폐기는 아님). 다만 이번 세션에서 **"3-상태 저장"의
1번 분기(`hasBeenSet`)가 "첫 세팅은 무조건 애니메이션 없이 스냅"을
기본 동작으로 확정**했으므로, 나중에 `initValue`(다이얼로그가 아래에서
위로 슬라이드-인하는 것처럼 첫 마운트에도 애니메이션을 원하는 경우)가
실제로 필요해지면 **이 억제 동작을 어떻게 명시적으로 우회할지**(예:
릴레이션 슬롯에 `nil` 대신 다른 초기 상태를 미리 심어두는 옵션)까지
같이 설계해야 함 — 지금은 새 결정 없이 이 긴장 관계만 기록해둠.
## `Animate` 콤비네이터 — quad-roblox 유틸(base 아님)
`Animate(condOrOpts, opts?)`류 팩토리를 quad-roblox가 제공, `:Apply`
체이닝해서 쓰는 용도. 상세 시그니처는 미확정(예: `Animate({ease=...,
useAnimate=state<boolean>})`처럼 조건과 옵션을 하나의 테이블로 합치는
안도 검토 가치 있음 — 확정 아님, M11에서 정리). 핵심은 **base
프리미티브가 아니라는 것** — `Tween<T>` 값 타입/`isTween`만
base(`quad-base/Tween.luau`)에 있고, `Animate`는 이미 있는 `:Apply`/
`:Compute`/`Tween{...}`를 조합한 quad-roblox 레벨 편의 함수라 나중에
이름/모양을 자유롭게 바꿔도 base 계약에 영향이 없음 — 저비용
고효율(사용자 표현) 엔지니어링으로 판단.
## 패키지 경계 — `Tag`가 이미 밟은 것과 같은 분리 (2026-08-10 세션 확정)
- **quad-base**: `Tween.luau` — 값 타입(`Tween(opts)` 팩토리, `isTween`
predicate/`TweenTag` Brand)만. 엔진 무관.
- **quad-roblox**: `Handlers/Property.luau`(기존 프로퍼티 세팅 로직에
`isTween` 분기 + 3-상태 릴레이션 저장 + override 정책 추가) +
`Animate.luau`(편의 콤비네이터, 신규).
- **기존 `Handlers/Tween.luau`(독립 Dispatch 핸들러 파일) 자체는 더
이상 필요 없음** — `base/architecture.md` 소스트리 갱신 완료.
## `retract`(구 cleanup)로 확정된 오버라이드 시맨틱 — Tween↔Tween 전환에서는 그대로 유지
**이 절의 4가지 옵션은 안 바뀜 — 다만 "Dispatch의 `retract` 호출"이 아니라
"PropertyHandler 내부 로직이 참고하는 정책"으로 위치만 이동했다는 점에
유의.** 이전 트윈을 취소하고 새 트윈을 만드는 게 맞지만, "취소" 시점의
동작이 여러 갈래로 갈릴 수 있음:
1. 키 밸류를 받으면, 그로 인해 생성된 트윈을 얻어서 **멈춰버리기**.
2. 트윈 뒤에 **삭제하지 않고 오버라이드**(새 트윈이 이전 트윈의 현재 값에서
@ -66,43 +237,11 @@ Ref나 네임스페이스드 조회가 필요하지 않음. Tween의 store-bind
동작과 일치.
이 기본값 외 나머지 세 동작(오버라이드/삭제 후 재시작/끝점 이동 후 재시작)은
라이브러리가 강제하지 않고, `[Tween(key, tweenData, {onOverride=...})]`처럼
키 설정으로 사용자가 고를 수 있게 열어둠 — `retract(inst, k, v)`가 이전
값(v)을 받으므로 여기서 선택된 동작을 구현. `retract`가 접근해야 할 "이전에
생성한 실제 Tween 객체"는 `base/relate-plan.md`가 정의하는 `Relate`(`inst`를
weak 키로 하는 범용 릴레이션 프리미티브, 옛 가칭 `base.perInstanceState`
대체)에 담아두면 됨. **GC 확인(2026-08-07 여섯 번째 세션, 사용자 제안
검증)**: 이 저장소는
`inst`로 weak-keyed된 바깥 릴레이션 안에 `k`별 안쪽 릴레이션이 중첩된
구조라, `inst`가 죽으면 그 안에 담긴 Tween 인스턴스 릴레이션도 별도
정리 로직 없이 같이 GC됨 — `base/bind-system-plan.md`의 "핸들러 내부
상태 저장" 절 "왜 GC-안전한가" 참고.
라이브러리가 강제하지 않고, `Tween{Value=..., ease=..., onOverride=...}`처럼
`Tween` 생성 시 넘긴 옵션으로 사용자가 고를 수 있게 열어둠 — PropertyHandler가
위 3-상태 저장의 3번 분기에서 이 옵션을 참고해 구현.
## `retract`는 완전 소멸(Destroy) 시엔 호출되지 않는다
`base/lifecycle-pattern.md`의 핵심 원칙: quad는 자신이 만든 Instance를 생명주기
끝까지 그대로 들고 있는 소유자라, Destroy 이후에 실행해야 할 정리 로직이 없다
— 오히려 Destroy된 대상에 `:Cancel()`/`:Stop()` 같은 메서드를 호출하면 에러남
(대상이 죽으면 그 대상에 묶인 Tween도 함께 죽은 상태가 되므로). 따라서
**`retract`는 "같은 key에 새 값이 들어와 이전 트윈을 갈아치울 때"만 호출되고,
Destroy 시점엔 아무 것도 안 함(라이프타임 `Connected` 체크로 처리 자체를
멈추는 것만으로 충분).**
**메모 — `retract``canExecute`는 서로 다른 문제를 다룬다, 나중에 quadnomicon
급에서 제대로 설명 필요.** "그럼 값 교체가 아니라 값을 계속 관측하는 쪽
(예: `state:Observer(fn)`으로 Tween을 건 경우)은 Destroy 시 어떻게
정리되는가?"라는 질문이 자연스럽게 따라오는데, 이건 `retract`의 영역이
아니라 `canExecute`(라이프타임 predicate, `base/lifecycle-pattern.md`
"생명 바인드 유틸" 절)의 영역이다 — Destroy되면 `retract` 호출 없이 그냥
`canExecute`가 false가 되어 이후 처리 시도 자체가 조용히 no-op된다.
store-bind 일반(Tween 포함)도 같은 결이라 실제로는 이미 일관되게 명시돼
있지만(`base/bind-system-plan.md` "확정된 디스패치 모델" 절), "왜 이
경로엔 retract를 쓰고 저 경로엔 canExecute를 쓰는가"라는 내부 구조상의
이유는 quadnomicon 콘텐츠로 풀어서 설명할 필요가 있음(`research/
documentation-content-map.md` 심화 콘텐츠 후보에 메모) — 지금은 이 메모만
남겨두고 상세 설명은 나중 문서화 단계로 미룸.
## 트윈 옵션 값 모양 — TweenInfo 그대로 vs 편의 필드 (신규, 열린 논의)
## 트윈 옵션 값 모양 — TweenInfo 그대로 vs 편의 필드 (여전히 열린 논의)
**아직 논의 시작 단계 — 나중에 더 다룰 주제로만 남겨둠.** Roblox의
`TweenInfo.new(time, easingStyle, easingDirection, repeatCount, reverses,
@ -114,66 +253,37 @@ delayTime)`는 순수 포지셔널 생성자인데, Luau엔 named call 문법이
재사용하고 싶은 경우엔 상관없지만, 대부분의 흔한 케이스(길이/이징만
바꾸고 싶음)에서 매번 포지셔널 생성자를 마주해야 함.
2. **편의 필드로 개별 인자를 받고 기본값을 제공** — 예:
`{Time=0.3, Style=Enum.EasingStyle.Quad, Reverses=false, ...}`처럼
`Tween{Value=..., Time=0.3, Style=Enum.EasingStyle.Quad, Reverses=false, ...}`처럼
이름 붙은 키로 받고 흔한 기본값(예: `Time=0.2`, `Style=Quad`,
`Direction=Out`)을 채워줌. 명시적으로 `TweenInfo`가 이미 있어서
재사용하고 싶다는 케이스에도 열어두면(예: `Info = someTweenInfo`
필드로), 둘 다 지원 가능.
**현재 소견(확정 아님)**: 2번(편의 필드 + 기본값)이 흔한 사용 경험상 더
낫다는 쪽으로 기움 — 대부분의 호출에서 named call이 없는 `TweenInfo.new`
가독성 문제를 피할 수 있고, 기본값 덕에 짧은 호출도 가능해짐. 다만
구체적인 필드 이름/기본값/`TweenInfo` 재사용 경로의 정확한 문법은 아직
확정 아님 — 나중 논의 대상으로 남김.
낫다는 쪽으로 기움 — 이번 세션의 모든 예시(`Tween{Value=..., ease=...}`)도
자연스럽게 이 방향을 가정하고 있음. 다만 구체적인 필드 이름/기본값/
`TweenInfo` 재사용 경로의 정확한 문법은 아직 확정 아님 — 나중 논의 대상으로
남김.
## 초기 진입 애니메이션(`initValue`)과 트윈 우회(`useTween`) — 둘 다 미확정 (2026-08-09 세션)
## 네임스페이스드 객체 (더 이상 유효한 관심사 아님)
**아직 논의 시작 단계 — 나중에 더 다룰 주제로만 남겨둠.** 사용자가 두
실사용 시나리오에서 출발한 제안:
1. **`Tween{initValue=...}`(검토 후 필요성 낮은 쪽으로 기움)** — 동기:
다이얼로그가 아래에서 위로 올라오며 열리는 것처럼, 처음 마운트될 때
시작 위치에서 목표값으로 자연스럽게 움직이는 "enter" 애니메이션을
내고 싶은데, 지금 구조로는 첫 마운트 시 이미 목표값이 프로퍼티에
세팅된 채로 시작해 트윈이 걸리지 않음. `initValue`로 먼저 그 값을
세팅해두고 그 다음 실제 목표값으로 트윈을 트리거하면 될 것 같다는
아이디어. **사용자가 스스로 재검토하며 "필요 없는 것 같다"는 쪽으로
기움** — 정확한 반례(재process/재마운트 시 `initValue`를 매번 다시
적용하면 안 되는데 "최초 1회"를 무엇으로 판별할지가 또 다른 설계
문제가 될 수 있음)는 다음 세션에서 확인 필요, 완전 폐기는 아직 아님.
2. **`Tween{useTween = state<boolean>}`(필요성은 확인, 모양은 미정)** —
동기: `Visible`로 여닫는 다이얼로그가 항상 아래에서 위로 슬라이드
올라와야 하는 게 기본이지만, 특정 상황(접근성 설정으로 애니메이션을
완전히 끄고 싶은 경우 등)엔 트윈을 건너뛰고 즉시 목표값으로 스냅해야
함 — 트윈을 무시하고 값을 그냥 세팅할 방법이 필요하다는 문제의식.
제안 동작: 트윈이 실행되려는 시점에 먼저 `useTween:Get()`을 읽어서
`false`면 진행 중이던 트윈을 전부 죽이고 목표값으로 즉시 `Set`,
`true`(또는 생략 시 기본값)면 평소대로 트윈.
**결론(미확정)**: `initValue`는 필요 없어 보이지만(사용자 소견) 완전
폐기는 아직 안 함. `useTween`류 트윈 우회 필요성은 실사용 근거가
뚜렷해 유지될 가능성이 높지만, **정확한 옵션 키 모양과 문서화 방식이
아직 전혀 안 정해짐**(사용자 강조: "어떤 모양을 주냐가, 문서화 어떤
식으로 하냐가 엄청 중요한듯") — 위 "트윈 옵션 값 모양" 절의 편의 필드
논의와 함께 M11 착수 전 나중 세션에서 마저 정리.
## 네임스페이스드 객체 (성능상 이유로 보류)
트윈 대상을 이름으로 찾는 별도 네임스페이스는 성능상 별로라고 판단 —
CollectionService를 쓰는 게 나아 보이지만, 트윈 전용 네임스페이스가 따로 있을
필요가 있는지는 미정(단, 위 정정으로 이 절 자체의 필요성이 낮아짐 — 핸들러가
이미 대상을 직접 받으므로 "나중에 이름으로 찾아서 트윈"할 필요 자체가 잘
없을 수 있음).
기존 모델(핸들러가 대상을 이름으로 찾아야 하는 가능성)을 염두에 두고
열어뒀던 절 — 새 모델에서는 PropertyHandler가 `inst`를 항상 직접
받으므로(다른 모든 핸들러와 동일) 이 문제 자체가 성립하지 않음. 절 자체는
과거 기록으로만 남김, 실행할 내용 없음.
## 열린 질문 (`.claude/question.md`에도 취합)
- 기본값(Cancel)은 확정됨. 남은 건 나머지 세 동작(오버라이드/삭제 후 재시작/
끝점 이동 후 재시작)을 선택하는 옵션 키의 정확한 이름/시그니처 — 구현
단계에서 확정.
- 트윈 옵션 값 모양(위 "트윈 옵션 값 모양" 절, 신규) — `TweenInfo` 그대로
받을지 편의 필드+기본값으로 받을지, 소견은 후자 쪽이지만 확정 아님.
- 자연 완료(Completed) 시 per-instance 북키핑 정리 여부 —
`research/pre-implementation-audit.md` 2-10번 참고, M11 착수 시 확정.
- `initValue`(진입 애니메이션)/`useTween`(트윈 우회) — 위 절 참고,
후자는 필요성은 확인됐으나 모양/문서화 미정, 전자는 필요성 자체가
낮은 쪽으로 기움.
끝점 이동 후 재시작)을 선택하는 옵션 키의 정확한 이름/시그니처, 그리고
Tween→plain 전환에 5번째 옵션이 필요한지 — 구현 단계에서 확정.
- 트윈 옵션 값 모양(위 절) — `TweenInfo` 그대로 받을지 편의 필드+기본값으로
받을지, 소견은 후자 쪽이지만 확정 아님.
- `Animate` 콤비네이터의 정확한 시그니처(조건/옵션 분리 vs 통합) — M11에서
정리.
- 자연 완료(Completed) 시 per-instance 북키핑 정리 여부(3-상태 슬롯을
`true`로 되돌리는 시점) — `research/pre-implementation-audit.md` 2-10번
참고, M11 착수 시 확정.
- `initValue`(진입 애니메이션) — 위 절 참고, 필요성 자체가 낮은 쪽으로
기움, 완전 폐기는 아님. 필요해지면 hasBeenSet 억제 동작과의 상충을
같이 풀어야 함.

114
CLAUDE.md
View file

@ -2542,3 +2542,117 @@ Instance에 직접 `.Parent =` 대입으로 자식을 넣거나 빼면 `lengthLi
**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터, luau-test 결과 확인
우선) — 이번 세션은 순수 문서 갭 보강이라 우선순위엔 영향 없음.
## 2026-08-10 두 번째 세션 — Tween 구조 전면 재설계: 독립 Dispatch 핸들러 →
값-레벨 `Tween<T>` 래퍼, `pre-implementation-audit.md` 1-1 완전 해소
사용자가 "트윈도 타입 문제가 있다 — 키 타입을 어떻게 하냐, Property
setter가 더 분발해서 `V``isTween`이면 트윈 넣는 게 낫지 않냐"고
제기하며 시작된 긴 단일 스레드. 기존 확정 모델(`[Tween(key,
tweenData...)] = storeValue`, `v`가 Store인 아무 `k`나 잡는 우선순위
최상위 Dispatch 핸들러, 2026-08-04부터 확정)이 실은
`research/pre-implementation-audit.md` 우선순위1-1이 이미 지적해뒀던
구조적 모호함("애니메이션 없는 일반 반응형 프로퍼티 바인딩도 결국
이름이 Tween인 파일을 거쳐가는가")을 안고 있었다는 걸 사용자 제안이
정확히 겨냥한 것으로 드러나, 세션 내내 살을 붙여 완전히 재설계까지
감. 구 모델은 `archive/tween-special-bind-key-reversed.md`로 이전,
`research/tween-plan.md`는 전면 재작성됨 — 상세 근거는 그 두 문서가
최종 소스, 여기는 결정 흐름만 요약.
**핵심 재설계**: State/Source 언랩(범용 `Dispatch/StoreBind.luau`, `k`/`v`
타입 무관)과 "이 값이 트윈 대상인가" 판단을 완전히 분리 — 후자는 별도
Dispatch 핸들러/우선순위 경쟁이 아니라, **PropertyHandler가 `realv`
다 풀어낸 뒤 직접 하는 값-레벨 분기**(`isTween(realv)`)로 옮김. `Tween(opts:
{Value: T, ease...}) -> Tween<T>`는 `Store({...})`와 같은 `Type(args)`
테이블 팩토리. 이 전환 하나로 우선순위1-1이 구조적으로 성립 불가능해짐
— 범용 반응형 바인딩과 Tween이 애초에 같은 핸들러를 놓고 경쟁할 지점
자체가 없어짐.
**세션 중 순서대로 다듬어진 세부 결정들**(전부 최종적으로 `research/
tween-plan.md`에 반영):
1. **`Tween.Value`는 plain `T`만, 자체 반응 경로 없음** — 처음엔 `Value`
`T|State<T>`를 받아 내부에 별도 Observer를 걸어야 하나 검토했으나,
바깥 `:Compute`가 소스 변경마다 `Tween{Value=v,...}`를 통째로 재생성해
StoreBind 재귀를 타므로 불필요함을 확인 — "같은 일 하는 두 번째 경로를
안 만든다" 원칙 재적용, `Tween<T> = {Value: T, ease...}`로 확정.
2. **3-상태 릴레이션 슬롯으로 `hasBeenSet`과 활성 엔진 트윈 저장을 통합**
`relate:GetStrong(inst,k)``RobloxTween | true | nil` 중 하나:
`nil`=이 키 첫 세팅(애니메이션 없이 즉시 스냅, 기본값→목표값으로
날아오는 진입 애니메이션 버그 방지), `true`=세팅된 적 있음/활성 트윈
없음(정상 애니메이션 시작 가능), 엔진 객체=활성 트윈 있음(override
정책대로 정리 먼저). 사용자가 직접 "hasBeenSet은 어차피 트윈에만
쓰이니 트윈 저장 슬롯 하나로 합치자"고 제안해 확정.
3. **활성 트윈이 있는데 plain 값이 들어오는 경우의 순서 규칙 신설**
먼저 override 정책대로 이전 트윈을 정리(멈추거나 끝냄)하고, **그
정리가 끝난 뒤에만** 새 값을 세팅. 순서가 뒤바뀌면 이전 트윈의 다음
인터폴레이션 프레임이 방금 세팅한 값을 덮어쓸 위험이 있어서 — 사용자가
직접 짚은 시퀀싱 버그.
4. **타입 대수: `T' = T | Tween<T>` 치환만으로 해결, 새 타입 기계 불필요**
지금 프로퍼티류 필드가 전부 `T | State<T>` 모양으로 통일돼 있는데,
여기서 "이 필드의 `T`" 자체를 `T' = T | Tween<T>`로 치환하면 자동으로
`T | Tween<T> | State<T | Tween<T>>`가 나옴 — Modifier/State/Source/
StoreBind 코드엔 `Tween` 인지 로직이 전혀 안 들어감(StoreBind는 원래도
페이로드 타입에 무관하게 `isState`만 봄), 타입 생성 스크립트가 필드
타입 문자열만 바꾸면 끝. 사용자가 직접 대수적으로 도출.
5. **`useTween` 우회 — 새 옵션 필드 없이 해소.** 이전엔
`Tween{useTween=state<boolean>}`처럼 별도 필드가 필요하다고 열어뒀으나,
2026-08-07 일곱 번째 세션에 확정된 `state:Apply(factory)` sugar 위에
`someState:Apply(Animate(reduceMotion, opts))`처럼 조건부로 `Tween{...}`
씌우거나 안 씌우는 `:Compute` 팩토리 하나로 공짜로 풀림 — 새 base
메커니즘 불필요.
6. **`Animate` 콤비네이터는 quad-roblox 유틸, base 프리미티브 아님** —
`Tween<T>` 값 타입/`isTween`만 base(`quad-base/Tween.luau`)에 있고,
`Animate`는 이미 있는 `:Apply`/`:Compute`/`Tween{...}`를 조합한 편의
함수라 나중에 이름/모양을 자유롭게 바꿔도 base 계약에 영향 없음 —
사용자 표현으로 "저비용 고효율 엔지니어링".
7. **패키지 경계는 Tag가 이미 밟은 분리를 그대로 재사용** — quad-base:
`Tween.luau`(값 타입만). quad-roblox: `Handlers/Property.luau`(isTween
분기+3-상태 저장+override 정책 흡수, 기존 독립 `Handlers/Tween.luau`
폐기) + `Animate.luau`(신규).
8. **부수 발견 — `retract`가 Tween 경로에서 사실상 필요 없어짐.** 기존
모델에서 "Tween↔프로퍼티 핸들러 타입 교체"가 `retract`가 실제로
의미를 갖는 유일한 대표 예시였는데, 새 모델에선 매치되는 Dispatch
핸들러가 항상 PropertyHandler 하나뿐이라 이 케이스 자체가 사라짐 —
트윈 취소/전환은 PropertyHandler 내부의 3-상태 슬롯 로직으로 대체(Tag가
이미 하는 "diff는 process 자신이 담당" 패턴과 같은 모양). `retract`
필드 자체는 "생략 불가" 일반 규칙이라 여전히 정의는 해두되, 실제
호출은 거의 없어짐. Tag(핸들러 타입이 실제로 바뀌게 재설계되어
`retract`가 필요해진 사례)와 Tween(핸들러 타입이 안 바뀌게 재설계되어
`retract` 필요성이 사라진 사례)을 서로 반대 방향 사례로 archive 문서에
대비해둠 — quadnomicon 소재.
9. **`Tween<T>`의 핸들러 계층 분류 정정** — `base/modifier-plan.md`
원래 Tween을 Slot/Tag/Attribute와 같은 "dispatch 참가자"(State/Source에
담겨도 재귀 재-dispatch가 그대로 처리해주는 부류)로 묶어뒀는데, 이제
`Tween<T>``process`/`retract`가 없는 순수 raw 데이터 값이라 `None`
같은 분류로 정정 — Modifier 필드/`State<Modifier>`가 막는 "핸들러
계층 값 → error" 규칙에 안 걸린다는 결론은 안 바뀜(그냥 raw 값이라서로
근거가 바뀜).
10. **`initValue`(진입 애니메이션)와 hasBeenSet의 긴장 관계를 기록만
해둠** — hasBeenSet이 "첫 세팅은 무조건 스냅"을 기본 동작으로
확정했으므로, 나중에 `initValue`(다이얼로그 슬라이드-인 등)가 실제로
필요해지면 이 억제 동작을 명시적으로 우회하는 방법까지 같이 설계해야
함 — 새 결정 없이 상충 관계만 `research/tween-plan.md`에 남김.
**여전히 열려있는 것**(M11 착수 시 확정): override 정책 4가지 중 기본값
Cancel 외 세 옵션의 정확한 키 이름/시그니처, Tween→plain 전환에 5번째
옵션이 필요한지, 트윈 옵션 값 모양(`TweenInfo` 그대로 vs 편의 필드 — 소견은
후자), `Animate`의 정확한 시그니처(조건/옵션 분리 vs 통합).
**반영된 파일**: `research/tween-plan.md`(전면 재작성, 최종 소스),
`archive/tween-special-bind-key-reversed.md`(신규, 구 모델 원문+역전
사유), `base/bind-system-plan.md`(9곳 — "확정된 디스패치 모델"의 대표
예시를 Tween에서 StoreBind로, `retract` 필요 패턴 예시를 Tag로 교체,
"Dispatch는 프리미티브가 아니다"/"Dispatch 체인" 절의 핸들러 목록에서
Tween 제거, `None` 센티널 절 예시 갱신, Ref/Brand 절 문구 정정),
`base/architecture.md`(소스트리 — `quad-base/Tween.luau` 신설,
`quad-roblox/Handlers/Tween.luau` 삭제하고 `Handlers/Property.luau`
설명에 흡수, `Animate.luau` 신설), `base/modifier-plan.md`(핸들러 계층
분류에서 Tween 제외 + 신규 "10. `Tween<T>`와의 타입 합성" 절),
`research/pre-implementation-audit.md`(우선순위1-1 해소 표시),
`ROADMAP.md`(M11 전면 재작성, M2/M7 체크박스 갱신), `.claude/question.md`/
`.claude/README.md`(참조 동기화).
**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터, luau-test 결과 확인
우선) — 이번 세션도 순수 설계 확정/문서 정리라 M0 착수 우선순위 자체는
그대로. M11 착수 시점이 오면 위 "여전히 열려있는 것" 목록부터 확인.

View file

@ -73,7 +73,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
네이밍 확정). "이 키를 지금 누가 담당 중인가" bookkeeping은
`Dispatch.drive`가 아니라 `Dispatch.process` 호출 자체 내부에서
갱신할 것(재귀 재-process 시에도 자연히 갱신되게 — 안 그러면 재귀
재디스패치를 쓰는 케이스(Tween store-bind, `NoneHandler`)에서 매
재디스패치를 쓰는 케이스(`StoreBind`, `NoneHandler`)에서 매
사이클 불필요한 `retract`가 반복 호출될 위험)
- [ ] `Handler.luau`(핸들러 계약 타입: `isHandlable(inst,k,v)`/`priority`/
`process`/`retract` — `isHandlable``inst`를 받도록 확정, 2026-08-07
@ -137,7 +137,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
bind-system-plan.md` "Dispatch는 프리미티브가 아니다" 절)
- [ ] `chains`(Relate 기반, `{[inst(weak)]={[k]={handler,handler,...}
(strong 순서 배열)}}`) + `Dispatch.retractUnder(inst,k,keep,v)`
재귀 재-dispatch(StoreBind/Tween/NoneHandler)의 retract를 다단
재귀 재-dispatch(StoreBind/NoneHandler)의 retract를 다단
체인까지 정확히 전파(2026-08-08 세 번째 세션, `base/
bind-system-plan.md` "Dispatch 체인" 절 — `pre-implementation-audit.md`
1-2번 "이전 핸들러 추적" 항목 해소). `Dispatch.process`가 매치될
@ -282,8 +282,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
(이름 확정, `modifier-plan.md` 2-1번, `Peek` 반환 타입에 `None` 추가) +
이를 `nil`로 재디스패치하는 base 내장 `NoneHandler`
(`bind-system-plan.md`의 `None` 센티널 절, M2 dispatch 엔진의
"이전 매치 핸들러 추적" 항목과 함께 구현 — Tween store-bind 핸들러와
"이전 매치 핸들러 추적" 항목과 함께 구현 — `StoreBind` 핸들러와
동일한 재귀 재디스패치 패턴이라 새 메커니즘 아님) — 확정 완료
- [ ] 프로퍼티류 필드 타입에 `T' = T | Tween<T>` 치환 반영(타입 생성
스크립트가 `Position: UDim2` 자리를 `UDim2 | Tween<UDim2>`로 만들면
끝, Modifier 런타임/`__index` 자체엔 변경 없음 — `modifier-plan.md`
10번, 2026-08-10 세션, `research/tween-plan.md`)
## M8 — Ref
@ -344,9 +348,28 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
## M11 — Tween
- [ ] `research/tween-plan.md` 남은 옵션 이름 확정(구조는 이미 확정)
- [ ] `Handlers/Tween.luau`(높은 우선순위 store-bind 핸들러, 기본 오버라이드
Cancel)
**[2026-08-10 세션, 구조 재설계]** 독립 Dispatch 핸들러 모델에서 값-레벨
`Tween<T>` 래퍼 모델로 전환 — 상세는 `research/tween-plan.md`(전면
재작성), 구 모델은 `archive/tween-special-bind-key-reversed.md`.
- [ ] `quad-base/Tween.luau`(값 타입만 — `Tween(opts)` 팩토리, `isTween`/
`TweenTag` Brand, `Value: T` plain만 받고 State 재귀 없음)
- [ ] `Handlers/Property.luau``isTween(realv)` 분기 추가(기존
`Handlers/Tween.luau` 독립 핸들러는 폐기) + 3-상태 릴레이션 슬롯
(`RobloxTween | true | nil` — `nil`=첫 세팅, `true`=세팅됨/트윈
없음, 엔진 객체=활성 트윈) + 첫 세팅은 무조건 애니메이션 없이
스냅(hasBeenSet 억제) + 활성 트윈 정리는 override 정책 완료 후에만
새 값 세팅(순서 뒤바뀌면 트윈 다음 프레임이 방금 세팅한 값을 덮어씀)
- [ ] override 정책 4가지(기본 Cancel/Override/Delete-restart/
Move-to-end-restart) 중 기본값 외 옵션 키 이름/시그니처 확정,
Tween→plain 전환에 5번째 옵션이 필요한지 확인
- [ ] `research/tween-plan.md` "트윈 옵션 값 모양" 확정(TweenInfo 그대로
vs 편의 필드+기본값 — 소견은 후자)
- [ ] `quad-roblox/Animate.luau`(편의 콤비네이터 — `:Apply`로 체이닝,
`useTween` 우회는 이걸로 자연히 커버되어 별도 옵션 필드 불필요,
정확한 시그니처는 M11에서 확정)
- [ ] `initValue`(진입 애니메이션) 필요성 재검토 — 필요해지면 hasBeenSet
억제 동작과의 상충부터 풀 것(`research/tween-plan.md` 참고)
## 특정 마일스톤에 안 묶이고 병행 가능