diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 64f649f..556fa6a 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -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`, 포지셔널 인자 지양 **사용자 확인 완료, 상세 방향 확정.** 후보로 검토했던 두 방식 모두 기각: diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index a8c2672..3e42e78 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -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로 전달, "다중 루트" 개념 자체는 폐기) — 더 이상 열린 질문 아님, 이 문서가 diff --git a/CLAUDE.md b/CLAUDE.md index a28b2c3..50ec0cc 100644 --- a/CLAUDE.md +++ b/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가 애초에 키 종류를 구분 안 하므로).