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:
qwreey 2026-08-06 18:45:28 +09:00
parent 2dcdebdee1
commit f1ca156789
Signed by: qwreey
GPG key ID: D28DB79297A214BD
3 changed files with 112 additions and 23 deletions

View file

@ -265,10 +265,12 @@ SyntheticEvent만 주는 것과 같은 모양).
충분함. quad는 어차피 라이프사이클 끝까지 바인딩을 들고 있으므로 충분함. quad는 어차피 라이프사이클 끝까지 바인딩을 들고 있으므로
(`base/lifecycle-pattern.md`, rbvm 선례 — GC-native), Destroy되면 (`base/lifecycle-pattern.md`, rbvm 선례 — GC-native), Destroy되면
해당 Connection도 자연히 같이 정리됨 — 별도 Disconnect 관리가 애초에 해당 Connection도 자연히 같이 정리됨 — 별도 Disconnect 관리가 애초에
불필요. 동적으로 Connect/Disconnect를 반복해야 하는 최적화 케이스가 불필요. **[정정, 2026-08-06 후속 세션] 동적으로 Connect/Disconnect를
실제로 생기면, 그건 Ref로 얻은 Instance를 갖고 사용자 코드가 직접 반복하고 싶은 케이스는 Ref로 수동 처리하는 대신 store-bind로 네이티브
처리하면 됨(사용자가 실사용 케이스로 확인한 바로도 이런 니즈는 지원하기로 확정** — 아래 "이벤트도 store-bind 가능 — `false`
드묾 — 드문 케이스를 위해 구조 전체를 복잡하게 만들 이유 없음). disconnect" 절 참고. 엔지니어링 비용이 예상보다 훨씬 낮다는 게 나중에
확인됨(기존 store-bind 재실행 래핑을 그대로 재사용, 새 디스패치
메커니즘 불필요).
**일반화**: 이 논거의 핵심은 Roblox에 국한되지 않는 원칙으로 정리됨 — **일반화**: 이 논거의 핵심은 Roblox에 국한되지 않는 원칙으로 정리됨 —
"엔진이 네이티브로 콜백에 뭘 주든, quad는 그걸 감싸지 않고 그대로 "엔진이 네이티브로 콜백에 뭘 주든, quad는 그걸 감싸지 않고 그대로
@ -277,6 +279,28 @@ SyntheticEvent만 주는 것과 같은 모양).
문서가 아니라 quad-roblox 로컬 결정 — 다른 백엔드 구현체를 만들 때 문서가 아니라 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`, 포지셔널 인자 지양 ## 여러 Store 값을 묶어 파생값 만들기 — `:With` + `:Compute`, 포지셔널 인자 지양
**사용자 확인 완료, 상세 방향 확정.** 후보로 검토했던 두 방식 모두 기각: **사용자 확인 완료, 상세 방향 확정.** 후보로 검토했던 두 방식 모두 기각:

View file

@ -1,8 +1,9 @@
# Modifier 설계 (정적 merge, immutable 체이닝) # Modifier 설계 (정적 merge, immutable 체이닝)
**상태**: base — 핵심 메커니즘(런타임 plug 아님/정적 merge, immutable **상태**: base — 핵심 메커니즘(런타임 plug 아님/정적 merge, immutable
값+clone 기반 체이닝, 이중 setter)은 2026-08-04 세션 채팅 논의로 확정. 남은 값+clone 기반 체이닝, 이중 setter)은 2026-08-04 세션 채팅 논의로 확정.
건 getter 정확한 이름뿐(구현 단계). Modifier가 컴포넌트 경계를 어떻게 **Getter는 별도로 안 만들기로 확정(2026-08-06 후속 세션)** — 아래 "4.
Setter는 리터럴 값과 변환 함수 둘 다 받음" 절 참고. Modifier가 컴포넌트 경계를 어떻게
통과하는지(named parameter로 전달, multi-root 개념 폐기)는 별개 문제로 통과하는지(named parameter로 전달, multi-root 개념 폐기)는 별개 문제로
**[정정] `research/component-composition-plan.md`는 2026-08-04 세션에 수렴 **[정정] `research/component-composition-plan.md`는 2026-08-04 세션에 수렴
완료돼 `base/component-composition-plan.md`로 승격됨 — 이 문서는 완료돼 `base/component-composition-plan.md`로 승격됨 — 이 문서는
@ -67,30 +68,68 @@ mutable하게 구현하면 같은 modifier 레퍼런스를 공유하는 형제
오염 버그를 원천 차단하는 쪽이 라이브러리 복잡도/사용자 편의 양쪽에서 오염 버그를 원천 차단하는 쪽이 라이브러리 복잡도/사용자 편의 양쪽에서
낫다고 판단 — **immutable 기본으로 확정**. 낫다고 판단 — **immutable 기본으로 확정**.
### 4. Setter는 리터럴 값과 변환 함수 둘 다 받음 ### 4. Setter는 리터럴 값과 변환 함수 둘 다 받음, 별도 Getter는 없음
`:FontSize(value)`(리터럴) / `:FontSize(function(current) return `:FontSize(value)`(리터럴) / `:FontSize(function(current) return
current*1.2 end)`(변환 함수) 둘 다 지원 — 한 줄로 끝내고 싶을 때는 콜백, current*1.2 end)`(변환 함수) 둘 다 지원 — 한 줄로 끝내고 싶을 때는 리터럴,
여러 줄로 풀어쓰고 싶을 때는 현재 값을 getter로 꺼내 계산 후 리터럴로 이전 값을 바탕으로 계산하고 싶을 때는 변환 함수 하나로 충분.
다시 넣는 스타일 둘 다 인체공학상 필요하다고 판단.
**Getter는 만들지 않기로 확정(2026-08-06 후속 세션).** 애초에 getter가
필요했던 유일한 이유가 "현재 값을 꺼내서 여러 줄에 걸쳐 계산한 뒤 리터럴로
다시 넣는" 멀티라인 스타일이었는데, `:FontSize(function(old) ... end)`
변환 함수 하나가 그 케이스를 인라인으로 완전히 커버함 — 별도 `:Get(key)`/
dot-access 겸용 여부를 고민할 이유 자체가 없어짐(모양을 정하는 대신
개념을 없애는 걸로 해소).
변환 함수는 State의 `:Compute`처럼 lazy State 핸들을 넘길 필요가 없음(*필드가 변환 함수는 State의 `:Compute`처럼 lazy State 핸들을 넘길 필요가 없음(*필드가
순수 데이터인 일반적인 경우에 한해* — 필드가 State일 때의 예외는 아래 참고). 순수 데이터인 일반적인 경우에 한해* — 필드가 State일 때의 예외는 아래 참고).
계산 비용 자체가 없는 순수 데이터라면 콜백엔 그냥 raw 현재 값을 즉시 넘기면 계산 비용 자체가 없는 순수 데이터라면 콜백엔 그냥 raw 현재 값을 즉시 넘기면
충분(State의 self-lazy-핸들 문제와는 다른 카테고리). 충분(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` 같은 별도 래퍼는 불필요해 보임 — 데이터를 테이블에 **내부 구현**: `__real` 같은 별도 래퍼는 불필요해 보임 — 데이터를 테이블에
직접 두고 메소드는 공유 메타테이블 `__index`로 붙이면, `table.clone` 직접 두고 메소드는 공유 메타테이블 `__index`로 붙이면, `table.clone`
메타테이블까지 그대로 복사해주는 Luau 동작 덕분에 클론해도 체이닝이 안 메타테이블까지 그대로 복사해주는 Luau 동작 덕분에 클론해도 체이닝이 안
끊김. flatten도 그 테이블 필드를 직접 읽으면 됨. 끊김. flatten도 그 테이블 필드를 직접 읽으면 됨.
**Getter 정확한 모양은 미정** — `mod:Get("FontSize")` 같은 전용 메소드로 **런타임은 클래스별 코드 없이 base에 딱 하나만 있으면 됨(2026-08-06 후속
할지, 아니면 Store/DI 관습처럼 dot-access(`mod.fontSize`) 자체가 읽기 세션, 핵심 통찰).** `mod:FontSize(14)``mod.FontSize(mod, 14)`로 풀리는
경로를 겸하게 해서 별도 `:Get()`이 아예 불필요하게 할지는 구현 단계에서 문법 설탕이고, `mod.FontSize``FontSize`가 리터럴 키로 안 박혀있으니
확정. **다만 getter의 동작 자체은 확정**: 필드가 State면 getter 호출이 `__index(self, key)`가 잡음 — 그러니 `__index`**어떤 key가 오든**
곧 관측이라 그 순간 계산되어 확정된(더 이상 반응하지 않는) 값이 반환됨 — key를 클로저에 캡쳐한 `function(self, arg) local clone = table.clone(self)
`base/bind-system-plan.md`의 "관측해야 실체화된다" 전역 원칙 그대로 적용 ... 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 여부로 분기 ### 4-1. 필드가 State일 수도 있음 — Setter가 State/plain 여부로 분기
@ -108,10 +147,9 @@ plain이냐에 따라 setter 내부 동작이 갈려야 함** — 새 개념이
| **State** | **리터럴** | clone 후 **State를 통째로 리터럴로 덮어씀 — 의도적으로 반응성이 끊김**(Store의 "부분 오버레이 없음, 전체 교체" 원칙과 같은 결) | | **State** | **리터럴** | clone 후 **State를 통째로 리터럴로 덮어씀 — 의도적으로 반응성이 끊김**(Store의 "부분 오버레이 없음, 전체 교체" 원칙과 같은 결) |
| **State** | **함수** | clone 후 `field:Compute(fn)`으로 **새 파생 State**를 만들어 대입 — 반응성 유지, State의 기존 `:Compute` 메커니즘에 그대로 위임 | | **State** | **함수** | clone 후 `field:Compute(fn)`으로 **새 파생 State**를 만들어 대입 — 반응성 유지, State의 기존 `:Compute` 메커니즘에 그대로 위임 |
즉 함수형 셋터는 필드가 State일 때 반응성을 보존하고, 리터럴 셋터(혹은 즉 함수형 셋터는 필드가 State일 때 반응성을 보존하고, 리터럴 셋터는 그
getter로 꺼내 계산 후 리터럴로 다시 넣는 멀티라인 스타일)는 그 순간 값을 순간 값을 확정시켜 반응성을 끊음 — 이 차이는 사용자가 인지하고 골라 쓰는
확정시켜 반응성을 끊음 — 이 차이는 사용자가 인지하고 골라 쓰는 것으로 것으로 문서화.
문서화.
### 4-2. Modifier는 소유권/유일성 제약이 없음 ### 4-2. Modifier는 소유권/유일성 제약이 없음
@ -162,8 +200,9 @@ State에 담기면 그 값이 반응형으로 바뀔 수 있다는 뜻이 되어
## 열린 질문 (`.claude/question.md`에도 취합) ## 열린 질문 (`.claude/question.md`에도 취합)
- Getter 정확한 이름/모양(`:Get(key)` vs dot-access 겸용) — 후순위, 구현 - **[해소됨]** Getter 정확한 이름/모양 — 2026-08-06 후속 세션에서 getter
단계에서 다른 세부 API 이름들과 같이 확정 가능. 자체를 안 만들기로 확정(위 "4. Setter는..." 절 참고), 더 이상 열린
질문 아님.
- Modifier가 컴포넌트 경계를 어떻게 통과하는지는 **[정정] 이미 - Modifier가 컴포넌트 경계를 어떻게 통과하는지는 **[정정] 이미
`base/component-composition-plan.md`에서 해소됨**(named parameter로 `base/component-composition-plan.md`에서 해소됨**(named parameter로
전달, "다중 루트" 개념 자체는 폐기) — 더 이상 열린 질문 아님, 이 문서가 전달, "다중 루트" 개념 자체는 폐기) — 더 이상 열린 질문 아님, 이 문서가

View file

@ -364,3 +364,29 @@ Ref 일반화 — 네 가지 다 확정, 실제 base 문서에 반영 완료.**
모델과 섞여서 안 좋았던 경험에서 나온 의도적 분리). Ref 정의 자체가 모델과 섞여서 안 좋았던 경험에서 나온 의도적 분리). Ref 정의 자체가
넓어졌으니 용어 정리 때 이름도 같이 재검토 대상. `question.md` 넓어졌으니 용어 정리 때 이름도 같이 재검토 대상. `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가 애초에 키 종류를 구분 안 하므로).