Modifier 마무리(Getter 제거, __index 런타임 통찰) + 이벤트 store-bind 확정
- Modifier Getter 아예 안 만들기로 확정 — :FontSize(function(old)->new)가 유일한 use case를 이미 인라인으로 커버 - old는 항상 "현재 저장된 그대로"(plain/State 구분 없이) 넘긴다는 원칙 명문화 - func(state)->state 세 번째 셋터 모양은 불필요하다고 검토 후 기각 - Modifier는 핸들러 계층(Ref/Slot)을 몰라도 되는 순수 데이터 merge 레이어로 확정 - Modifier 런타임은 base에 제네릭 __index 하나로 충분 — 클래스별 타입 생성기는 정적 타입 체크 전용, 런타임과 무관하다는 점 명시 - 이벤트도 store-bind 가능하도록 확정 (기존 재실행 래핑 재사용, false를 disconnect 센티널로) — quad-roblox 로컬 - CLAUDE.md 핸드오버 5번 항목 갱신 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
2dcdebdee1
commit
f1ca156789
3 changed files with 112 additions and 23 deletions
|
|
@ -265,10 +265,12 @@ SyntheticEvent만 주는 것과 같은 모양).
|
|||
충분함. quad는 어차피 라이프사이클 끝까지 바인딩을 들고 있으므로
|
||||
(`base/lifecycle-pattern.md`, rbvm 선례 — GC-native), Destroy되면
|
||||
해당 Connection도 자연히 같이 정리됨 — 별도 Disconnect 관리가 애초에
|
||||
불필요. 동적으로 Connect/Disconnect를 반복해야 하는 최적화 케이스가
|
||||
실제로 생기면, 그건 Ref로 얻은 Instance를 갖고 사용자 코드가 직접
|
||||
처리하면 됨(사용자가 실사용 케이스로 확인한 바로도 이런 니즈는
|
||||
드묾 — 드문 케이스를 위해 구조 전체를 복잡하게 만들 이유 없음).
|
||||
불필요. **[정정, 2026-08-06 후속 세션] 동적으로 Connect/Disconnect를
|
||||
반복하고 싶은 케이스는 Ref로 수동 처리하는 대신 store-bind로 네이티브
|
||||
지원하기로 확정** — 아래 "이벤트도 store-bind 가능 — `false`로
|
||||
disconnect" 절 참고. 엔지니어링 비용이 예상보다 훨씬 낮다는 게 나중에
|
||||
확인됨(기존 store-bind 재실행 래핑을 그대로 재사용, 새 디스패치
|
||||
메커니즘 불필요).
|
||||
|
||||
**일반화**: 이 논거의 핵심은 Roblox에 국한되지 않는 원칙으로 정리됨 —
|
||||
"엔진이 네이티브로 콜백에 뭘 주든, quad는 그걸 감싸지 않고 그대로
|
||||
|
|
@ -277,6 +279,28 @@ SyntheticEvent만 주는 것과 같은 모양).
|
|||
문서가 아니라 quad-roblox 로컬 결정 — 다른 백엔드 구현체를 만들 때
|
||||
참고할 만한 템플릿 정도로만 취급.
|
||||
|
||||
## 이벤트도 store-bind 가능 — `false`로 disconnect (2026-08-06 후속 세션)
|
||||
|
||||
**결정**: 이벤트 핸들러 값으로 State를 넘기는 것(reactive하게 콜백을
|
||||
바꿔치기/해제하는 것)을 지원한다. quad-roblox 로컬 결정, base 변경 없음.
|
||||
|
||||
**엔지니어링 비용이 낮은 이유**: 이미 확정된 "Store 바인드는 pluggable
|
||||
바인드를 재실행하는 래핑"(위 절, `process`가 값이 바뀔 때마다
|
||||
`process(inst,k,realv)`를 재귀 호출) + "재실행 래핑이 `retract`도 같이
|
||||
호출한다"(Slot이 이미 이 조합을 씀, 같은 절)는 두 메커니즘이 이미 있음.
|
||||
이벤트 핸들러가 할 일은 딱 하나: `process`에서 `:Connect()`한 Connection을
|
||||
per-instance 저장소에 기억해두고, `retract`에서 그걸 `:Disconnect()`하는
|
||||
것 — 새 디스패치 메커니즘 발명 필요 없이 기존 4종 계약(`isHandlable`/
|
||||
`priority`/`process`/`retract`)만 제대로 구현하면 됨.
|
||||
|
||||
**`false`로 disconnect, `nil` 아님.** `nil`은 Lua 테이블에서 "키가 아예
|
||||
없음"과 구별이 안 됨(`pairs`에서도 안 보임) — "명시적으로 꺼짐"이라는
|
||||
신호를 값으로 전달하기엔 부적합. 대신 `false`(Luau에서 실재하는 싱글톤
|
||||
타입)를 "연결 없음" 센티널로 씀: `process(inst,k,false)`가 들어오면
|
||||
`retract`가 하던 일(기존 Connection 해제)만 하고 새로 Connect 안 함.
|
||||
이벤트인지 여부는 값이 아니라 키(리플렉션으로 판별)로 결정되므로, 다른
|
||||
boolean 프로퍼티 핸들러와 `(k, false)` 매칭이 겹칠 위험 없음.
|
||||
|
||||
## 여러 Store 값을 묶어 파생값 만들기 — `:With` + `:Compute`, 포지셔널 인자 지양
|
||||
|
||||
**사용자 확인 완료, 상세 방향 확정.** 후보로 검토했던 두 방식 모두 기각:
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
# Modifier 설계 (정적 merge, immutable 체이닝)
|
||||
|
||||
**상태**: base — 핵심 메커니즘(런타임 plug 아님/정적 merge, immutable
|
||||
값+clone 기반 체이닝, 이중 setter)은 2026-08-04 세션 채팅 논의로 확정. 남은
|
||||
건 getter 정확한 이름뿐(구현 단계). Modifier가 컴포넌트 경계를 어떻게
|
||||
값+clone 기반 체이닝, 이중 setter)은 2026-08-04 세션 채팅 논의로 확정.
|
||||
**Getter는 별도로 안 만들기로 확정(2026-08-06 후속 세션)** — 아래 "4.
|
||||
Setter는 리터럴 값과 변환 함수 둘 다 받음" 절 참고. Modifier가 컴포넌트 경계를 어떻게
|
||||
통과하는지(named parameter로 전달, multi-root 개념 폐기)는 별개 문제로
|
||||
**[정정] `research/component-composition-plan.md`는 2026-08-04 세션에 수렴
|
||||
완료돼 `base/component-composition-plan.md`로 승격됨 — 이 문서는
|
||||
|
|
@ -67,30 +68,68 @@ mutable하게 구현하면 같은 modifier 레퍼런스를 공유하는 형제
|
|||
오염 버그를 원천 차단하는 쪽이 라이브러리 복잡도/사용자 편의 양쪽에서
|
||||
낫다고 판단 — **immutable 기본으로 확정**.
|
||||
|
||||
### 4. Setter는 리터럴 값과 변환 함수 둘 다 받음
|
||||
### 4. Setter는 리터럴 값과 변환 함수 둘 다 받음, 별도 Getter는 없음
|
||||
|
||||
`:FontSize(value)`(리터럴) / `:FontSize(function(current) return
|
||||
current*1.2 end)`(변환 함수) 둘 다 지원 — 한 줄로 끝내고 싶을 때는 콜백,
|
||||
여러 줄로 풀어쓰고 싶을 때는 현재 값을 getter로 꺼내 계산 후 리터럴로
|
||||
다시 넣는 스타일 둘 다 인체공학상 필요하다고 판단.
|
||||
current*1.2 end)`(변환 함수) 둘 다 지원 — 한 줄로 끝내고 싶을 때는 리터럴,
|
||||
이전 값을 바탕으로 계산하고 싶을 때는 변환 함수 하나로 충분.
|
||||
|
||||
**Getter는 만들지 않기로 확정(2026-08-06 후속 세션).** 애초에 getter가
|
||||
필요했던 유일한 이유가 "현재 값을 꺼내서 여러 줄에 걸쳐 계산한 뒤 리터럴로
|
||||
다시 넣는" 멀티라인 스타일이었는데, `:FontSize(function(old) ... end)`
|
||||
변환 함수 하나가 그 케이스를 인라인으로 완전히 커버함 — 별도 `:Get(key)`/
|
||||
dot-access 겸용 여부를 고민할 이유 자체가 없어짐(모양을 정하는 대신
|
||||
개념을 없애는 걸로 해소).
|
||||
|
||||
변환 함수는 State의 `:Compute`처럼 lazy State 핸들을 넘길 필요가 없음(*필드가
|
||||
순수 데이터인 일반적인 경우에 한해* — 필드가 State일 때의 예외는 아래 참고).
|
||||
계산 비용 자체가 없는 순수 데이터라면 콜백엔 그냥 raw 현재 값을 즉시 넘기면
|
||||
충분(State의 self-lazy-핸들 문제와는 다른 카테고리).
|
||||
|
||||
**`old`는 항상 "현재 저장된 그대로" 넘김 — 일관된 원칙.** 필드가 plain이면
|
||||
raw 값, State면 State 핸들 그 자체(아래 4-1 표의 "State + 함수" 행이
|
||||
`field:Compute(fn)`으로 위임하는 것과 동일 — `:With`/`:Compute`의 self가
|
||||
이미 raw 값이 아니라 State 핸들로 통일된 것과 같은 결). 별도 변환/정규화
|
||||
없이 그냥 지금 들고 있는 걸 그대로 준다는 원칙 하나로 이 절과 4-1절 표가
|
||||
전부 설명됨.
|
||||
|
||||
**별도 `func(state) -> state` 인자 모양은 불필요(검토 후 기각).** "여러
|
||||
Compute를 합치고 싶다"는 동기였는데, 이미 두 가지로 다 커버됨: (1) 여러
|
||||
계산을 합치고 싶으면 변환 함수 본문 안에서 다른 함수를 그냥 호출하면
|
||||
됨(평범한 함수 합성, 새 계약 불필요), (2) 필드 자체를 State로 만들고
|
||||
싶으면 리터럴 자리에 State를 직접 넘기면 됨(위 4-1 표 "State" 행). 즉
|
||||
"함수가 State를 반환"하는 세 번째 모양이 커버할 새 유스케이스가 없음.
|
||||
|
||||
**내부 구현**: `__real` 같은 별도 래퍼는 불필요해 보임 — 데이터를 테이블에
|
||||
직접 두고 메소드는 공유 메타테이블 `__index`로 붙이면, `table.clone`이
|
||||
메타테이블까지 그대로 복사해주는 Luau 동작 덕분에 클론해도 체이닝이 안
|
||||
끊김. flatten도 그 테이블 필드를 직접 읽으면 됨.
|
||||
|
||||
**Getter 정확한 모양은 미정** — `mod:Get("FontSize")` 같은 전용 메소드로
|
||||
할지, 아니면 Store/DI 관습처럼 dot-access(`mod.fontSize`) 자체가 읽기
|
||||
경로를 겸하게 해서 별도 `:Get()`이 아예 불필요하게 할지는 구현 단계에서
|
||||
확정. **다만 getter의 동작 자체은 확정**: 필드가 State면 getter 호출이
|
||||
곧 관측이라 그 순간 계산되어 확정된(더 이상 반응하지 않는) 값이 반환됨 —
|
||||
`base/bind-system-plan.md`의 "관측해야 실체화된다" 전역 원칙 그대로 적용
|
||||
(아래 참고).
|
||||
**런타임은 클래스별 코드 없이 base에 딱 하나만 있으면 됨(2026-08-06 후속
|
||||
세션, 핵심 통찰).** `mod:FontSize(14)`는 `mod.FontSize(mod, 14)`로 풀리는
|
||||
문법 설탕이고, `mod.FontSize`는 `FontSize`가 리터럴 키로 안 박혀있으니
|
||||
`__index(self, key)`가 잡음 — 그러니 `__index`가 **어떤 key가 오든** 그
|
||||
key를 클로저에 캡쳐한 `function(self, arg) local clone = table.clone(self)
|
||||
... end`류 함수를 즉석에서 만들어 리턴하기만 하면 끝. 즉 `:FontSize`/
|
||||
`:Round`/앞으로 생길 어떤 필드 이름이든 전부 이 **하나의 제네릭 `__index`
|
||||
구현**이 처리 가능 — 필드별로 미리 등록된 메소드가 하나도 없어도 됨.
|
||||
**중요한 결론**: 위 "FrameModifier 타입" 문제(클래스별로 flat 타입을 생성기로
|
||||
뽑아야 하는 것)는 순전히 **정적 타입 체크**를 위한 것이고, **런타임
|
||||
구현에는 아무 영향 없음** — quad-roblox의 클래스별 코드 생성이 늘어나도
|
||||
런타임 쪽 코드량은 절대 안 늘어남. 그리고 이 `__index` 메커니즘 자체는
|
||||
Roblox API에 전혀 의존 안 하는 순수 Lua 테이블 조작이라, "base는 인터페이스만,
|
||||
구현은 백엔드 팩토리가 주입"(`base/bind-system-plan.md`) 원칙과 무관하게
|
||||
**Modifier의 체이닝 엔진 자체는 quad-base에 완결된 구현으로 그대로
|
||||
존재해도 됨** — 주입할 엔진별 구현이 애초에 없음.
|
||||
|
||||
**Modifier는 핸들러 계층을 모름 — 순수 데이터 merge 레이어.** Modifier는
|
||||
`process`/`retract`/`isHandlable` 핸들러 레지스트리보다 아래층(위 "1.
|
||||
런타임 pluggable 핸들러 아님" 참고)이라, Ref/Slot 같은 핸들러 계층
|
||||
개념을 아예 몰라도 됨 — 필드에 Ref나 Slot 같은 값이 들어가도 Modifier
|
||||
입장에선 그냥 flatten해서 최종 props 테이블에 얹을 뿐. 권장하는 사용법은
|
||||
아니지만(Modifier는 스타일링 데이터 지향이니), 막을 이유도 없음 — 방어
|
||||
로직 없는 UB로 남겨둠(오늘 세션 내내 반복된 "드문 오용까지 방어하려고
|
||||
구조 복잡하게 안 만든다" 원칙과 동일).
|
||||
|
||||
### 4-1. 필드가 State일 수도 있음 — Setter가 State/plain 여부로 분기
|
||||
|
||||
|
|
@ -108,10 +147,9 @@ plain이냐에 따라 setter 내부 동작이 갈려야 함** — 새 개념이
|
|||
| **State** | **리터럴** | clone 후 **State를 통째로 리터럴로 덮어씀 — 의도적으로 반응성이 끊김**(Store의 "부분 오버레이 없음, 전체 교체" 원칙과 같은 결) |
|
||||
| **State** | **함수** | clone 후 `field:Compute(fn)`으로 **새 파생 State**를 만들어 대입 — 반응성 유지, State의 기존 `:Compute` 메커니즘에 그대로 위임 |
|
||||
|
||||
즉 함수형 셋터는 필드가 State일 때 반응성을 보존하고, 리터럴 셋터(혹은
|
||||
getter로 꺼내 계산 후 리터럴로 다시 넣는 멀티라인 스타일)는 그 순간 값을
|
||||
확정시켜 반응성을 끊음 — 이 차이는 사용자가 인지하고 골라 쓰는 것으로
|
||||
문서화.
|
||||
즉 함수형 셋터는 필드가 State일 때 반응성을 보존하고, 리터럴 셋터는 그
|
||||
순간 값을 확정시켜 반응성을 끊음 — 이 차이는 사용자가 인지하고 골라 쓰는
|
||||
것으로 문서화.
|
||||
|
||||
### 4-2. Modifier는 소유권/유일성 제약이 없음
|
||||
|
||||
|
|
@ -162,8 +200,9 @@ State에 담기면 그 값이 반응형으로 바뀔 수 있다는 뜻이 되어
|
|||
|
||||
## 열린 질문 (`.claude/question.md`에도 취합)
|
||||
|
||||
- Getter 정확한 이름/모양(`:Get(key)` vs dot-access 겸용) — 후순위, 구현
|
||||
단계에서 다른 세부 API 이름들과 같이 확정 가능.
|
||||
- **[해소됨]** Getter 정확한 이름/모양 — 2026-08-06 후속 세션에서 getter
|
||||
자체를 안 만들기로 확정(위 "4. Setter는..." 절 참고), 더 이상 열린
|
||||
질문 아님.
|
||||
- Modifier가 컴포넌트 경계를 어떻게 통과하는지는 **[정정] 이미
|
||||
`base/component-composition-plan.md`에서 해소됨**(named parameter로
|
||||
전달, "다중 루트" 개념 자체는 폐기) — 더 이상 열린 질문 아님, 이 문서가
|
||||
|
|
|
|||
26
CLAUDE.md
26
CLAUDE.md
|
|
@ -364,3 +364,29 @@ Ref 일반화 — 네 가지 다 확정, 실제 base 문서에 반영 완료.**
|
|||
모델과 섞여서 안 좋았던 경험에서 나온 의도적 분리). Ref 정의 자체가
|
||||
넓어졌으니 용어 정리 때 이름도 같이 재검토 대상. `question.md`의
|
||||
관련 항목은 해소됨으로 갱신.
|
||||
|
||||
**5. Observer 이름 확정, Ref/Source/Store 생성자 스타일, "독립 프리미티브 vs
|
||||
파생 데이터" 원칙, Modifier 세부 마무리 — 전부 확정, base 문서 반영 완료.**
|
||||
- `Observer`로 확정(`ObserverHandle` 아님) — `:Connect()`→`Connection`과
|
||||
같은 기존 명명 관례. PA님 코드의 동명 클래스와는 무관, 각주로 구분.
|
||||
- **생성자 스타일**: `Source(default)`/`Ref(default)`/`Store({defaults})`
|
||||
— Kotlin Compose식 "타입 이름 자체가 팩토리 함수". Ref만 예외였던 이유
|
||||
없었음(단순 명세 공백).
|
||||
- **일반 원칙 신설**: 독립 존재 가능한 프리미티브(Source/Ref/Store/
|
||||
Modifier, `Type(args)` 자유 함수 생성자) vs 원천에 종속된 파생 데이터
|
||||
(State/Observer, 원천에 대한 메소드로만 얻어짐) — `state:Observer(fn)`가
|
||||
메소드고 자유 함수가 없는 더 근본적인 이유로 연결(`store-semantics.md`).
|
||||
- **Modifier 마무리**: (a) Getter를 아예 안 만들기로 확정 —
|
||||
`:FontSize(function(old)->new)`가 유일했던 use case를 인라인으로 커버.
|
||||
(b) `old`는 항상 "현재 저장된 그대로"(plain이면 raw, State면 State
|
||||
핸들) 넘김 — `:Compute`의 self와 같은 결. (c) `func(state)->state`라는
|
||||
세 번째 인자 모양은 불필요(함수 합성 + State 직접 대입으로 이미 커버).
|
||||
(d) Modifier는 핸들러 계층(Ref/Slot 등)을 몰라도 됨 — 순수 데이터
|
||||
merge 레이어라 UB로 흘려보내도 문제없음. (e) **런타임 구현은 base에
|
||||
제네릭 `__index` 하나면 충분** — `mod:FontSize(...)`가 `__index(self,
|
||||
"FontSize")`로 잡히므로 클래스별 런타임 코드 불필요, FrameModifier류
|
||||
타입 생성기는 순전히 정적 타입 체크만을 위한 것. (f) 이벤트도
|
||||
store-bind 가능하도록 확정 — 기존 재실행 래핑 재사용, `false`를
|
||||
disconnect 센티널로 씀(`nil`은 테이블에서 사라져서 부적합) —
|
||||
`bind-system-plan.md`. Modifier가 이벤트 키를 담아도 되는지는 (d)로
|
||||
자동 해소(Modifier가 애초에 키 종류를 구분 안 하므로).
|
||||
|
|
|
|||
Loading…
Reference in a new issue