quad/.claude/research/additional-primitives-plan.md
qwreey 67438a172e
research: Context 난이도 판정 완료 — 기각 권고, 레이어드 Store 대안 확정
서브에이전트 조사: 동기 콜스택 한정 버전은 구현 난이도 낮지만(Fusion
Contextual 이식 가능), quad가 정상 패턴으로 확정한 Slot 비동기 추가에서
조용히 defaultValue로 폴백하는 함정 있음 + quad-debug의 "모든 연결은
선언된 그래프" 철학과 충돌. Roblox Luau는 thread-local이 없어 완전 자동
버전은 플랫폼 한계로 사실상 불가(Node AsyncLocalStorage/Python contextvars와
동일 문제). 대안 비교 결과 레이어드 Store(__index 델리게이션, Modifier/
Source가 이미 쓰는 패턴과 동일 계열)가 서브트리 오버라이드 가치를 명시적
전달 철학·비동기 안전성 유지하며 대부분 재현 — 최종 권고안으로 채택.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 22:04:54 +09:00

296 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 추가 프리미티브 필요성 조사 — 다른 프레임워크 대비 갭 분석
**상태**: research — 조사 완료(2026-08-06), 사용자 판단 대기. 설계/구현 결정은
전혀 안 됨, 이 문서는 "뭐가 빠졌을 수 있는지" 후보를 정리한 것뿐.
## 배경
사용자 질문: "다른 독립 프리미티브나 종속 파생 데이터는 뭐가 더 필요할 것
같나요. 이것만으로 이 프로젝트는 충분하다 생각해요?" — 지금까지 확정된
독립 프리미티브(`Source`/`State`/`Store`/`Ref`/`Observer`/`Modifier`/`Slot`/
`DI`)만으로 충분한지, 웹 프레임워크나 실제 Roblox 개발 관점에서 솔직하게
재검토해달라는 요청.
## 조사 방법
서브에이전트 2개를 병렬로 띄워 서로 다른 각도로 조사:
1. **웹 프레임워크 서베이** — React/Vue 3/Solid/Svelte 5/MobX 등 주류
반응형 프레임워크 기준, quad 프리미티브 집합에 빠진 개념이 있는지 일반
지식 기반 평가.
2. **Roblox 생태계 소스 기반 조사** — Fusion(`initreq/fusion/src`),
Vide(`initreq/vide/src`), quad v1(`initreq/quad/src`), PA님 실 프로덕션
코드(`initreq/artworks`)를 직접 읽고 파일:라인 근거로 검증.
두 조사 모두 이미 있는 `research/framework-comparison-findings.md`(quad vs
Fusion/Vide/react-lua 강점/약점 비교)와는 다른 질문 — 그 문서는 "같은
개념을 quad가 얼마나 잘 구현했는가"를 다뤘고, 이 문서는 "개념 자체가
통째로 없는 게 있는가"를 다룸.
## 결론 요약
| 후보 | 판정 | 심각도 |
|---|---|---|
| 키 기반 동적 컬렉션 재조정 | **진짜 빈 자리** | 높음 — 가장 시급 |
| Effect/Watch(자동 cleanup 포함 사이드이펙트) | 진짜 빈 자리 | 중간 |
| Batch/Transaction | 부분적 빈 자리(이미 문서화된 churn 문제와 직결) | 중~낮 |
| Context(트리 하위 암묵 전파) | **기각 권고**(난이도 판정 완료, 2026-08-06) — 레이어드 Store로 대체 | - |
| Untrack/Peek | 빈 자리 아님 | - |
| Suspense/비동기 경계 | 빈 자리 아님(문서화 문제로 재분류) | - |
| Error Boundary | 빈 자리 아님 | - |
| Readonly wrapper | 빈 자리 아님 | - |
두 에이전트가 서로 독립적으로 **키 기반 리스트/컬렉션 재조정**을 가장 크고
명확한 빈 자리로 지목했다는 점이 이 조사에서 가장 신뢰도 높은 결론.
## 1. 키 기반 동적 컬렉션 재조정 — 진짜 빈 자리, 최우선 검토 대상
**무엇인가**: 데이터 배열(인벤토리, 리더보드, 채팅로그처럼 삽입/삭제/
재정렬되는 목록)을 UI로 렌더링할 때, 이전 렌더 결과와 새 데이터를
**정체성(key) 기준으로 diff**해서 변경분만 생성/갱신/파괴하는 프리미티브.
React `key` prop, Vue `v-for :key`, Solid `<For>`가 이 층위.
**Roblox 선례에 명확히 존재함**:
- Fusion `State/ForPairs.luau:46-89` — key/value 쌍마다 독립 `SubObject`
만들어 안 바뀐 키는 재계산을 건너뜀. `ForKeys.luau`도 같은 `For` 코어
위에서 파라미터만 바꿔 변형.
- Vide `indexes.luau:11-119`, `values.luau:11-131` — 매 effect마다
`scopes` 맵을 순회, 새 항목은 `branch()` 생성, 사라진 항목은
`present(false)``destroy()`, 남은 항목은 값만 갱신(재생성 없음).
**quad엔 없음(확인 완료)**: `base/slot-plan.md``Slot``add`/`remove`/
`clear`/`get`/`set` CRUD를 지원하는 **뮤터블 배열**일 뿐, "입력 데이터를
주면 알아서 diff해서 CRUD를 호출해주는" 계층이 없음. `base/*.md` 전체를
grep해도 `ForPairs`/`ForKeys`/`keyed`/`diffing`류 언급 전무. quad v1에도
`diff`/`reconcile`류 헬퍼는 없었음(grep 확인) — v1 사용자도 이 문제를
프레임워크 밖에서 손으로 풀어왔다는 정황.
**실전 영향**: 지금 구조로 동적 리스트를 만들려면 "이전 렌더된 항목을
어딘가 들고 있다가 새 데이터와 직접 비교해 Slot의 add/remove를 손으로
호출"하는 로직을 화면마다 재발명해야 함 — Fusion/Vide가 라이브러리
차원에서 없애준 보일러플레이트(어떤 항목이 "같은 항목"인지, 순서가 바뀐
항목을 삭제+재생성할지 in-place로 옮길지)가 quad엔 그대로 남음. Slot
자체는 "부모는 데이터 테이블만 다루면 됨"이라는 좋은 저수준 기반이라,
그 위에 diff 알고리즘 한 겹만 얹으면 되는 구조적으로 자연스러운 확장으로
보임.
**PA님 코드 정황 증거**: artworks엔 실제 UI 화면 코드가 없어(백엔드/OOP/
데이터스토어 패턴 위주) 직접 증거는 못 찾았지만, `Utility/Array.luau`
`map`/`filter`/`insert`/`isEqual` 같은 범용 배열 유틸을 팀이 별도로
만들어 쓰고 있었다는 점 자체가 "배열 반복 작업을 프리미티브로 뽑아내는"
습관이 있는 팀이라는 간접 방증.
**열린 질문**: Slot의 확장 옵션으로 넣을지, 별도 최상위 프리미티브(가칭
`Keyed`/`ForEach`/`List`)로 분리할지 — 설계 자체가 전혀 없는 상태. quad의
"명시적 의존성" 철학(`:With`)과는 직교하는 문제라 `:With`/`:Compute` 확장이
아니라 Slot 쪽 확장이 자연스러워 보인다는 게 조사 에이전트 소견이지만
확정 아님.
## 2. Effect/Watch(자동 cleanup 포함) — 진짜 빈 자리, 중간 심각도
React `useEffect`/Vue `watchEffect`/Solid `createEffect`는 "이펙트
재실행 전/dispose 시 이전 cleanup을 프레임워크가 자동으로 불러준다"는
계약을 가짐. quad `state:Observer(fn)`는 무효화 신호만 주고 cleanup 자동
관리가 없음 — `fn`이 매번 뭔가를 새로 구독/생성한다면 이전 것을 정리하는
책임이 전적으로 사용자 코드에 있음.
`process`/`retract` 쌍이 정확히 이 문제(이전 처리를 무르고 새로 처리)를
풀지만, 이건 base가 소유한 KV 핸들러 계층에 갇힌 내부 메커니즘이지 사용자가
임의의 부수효과(`RunService.Heartbeat` 구독, 폴링 타이머 등)에 쓸 수 있는
공개 API가 아님. 이미 증명된 내부 패턴을 사용자 레벨 `Effect(fn)`(반환값을
다음 실행 전 cleanup으로 호출)로 얇게 노출하는 정도로, 큰 설계 변경 없이
채울 수 있는 자리로 보임.
*참고*: Fusion `Cleanup`/`doCleanup`, Vide `cleanup()`은 "명시적 dispose
콜백 등록 리스트" 모델이라 quad의 GC-native 철학과 정면 충돌하고 이미
`base/comparison-fusion-vide.md`에서 반면교사로 다뤄짐 — 여기서 제안하는
건 그것과 달리 *cleanup 콜백을 자동으로 호출해주는 것*(dispose 리스트
직접 관리가 아님)이라 같은 문제가 아님, 혼동하지 말 것.
## 3. Batch/Transaction — 부분적 빈 자리, 이미 문서화된 문제와 직결
quad의 push-invalidate/pull-recompute 모델은 batching을 상당 부분 공짜로
줌 — `Get()`이 호출되기 전까진 `Set()`을 연달아 해도 재계산이 안 일어남.
다만 이미 문서에 자기진단된 예외가 있음: **store-bind 이벤트 핸들러는
무효화 신호를 받는 즉시 pull**(`bind-system-plan.md` "자주 재계산되는
State에 이벤트를 직접 물리면... churn 비용"). 즉 여러 Source가 `:With`
물린 파생 State에 store-bind 핸들러가 붙어 있으면, 여러 `Set()`을 순서대로
실행하는 도중 중간 상태마다 핸들러가 여러 번 재실행되는 게 이미 확인된
시나리오. `Batch(function() ... end)`류 opt-in 유틸은 이론적 완결성이
아니라 **이미 확인된 실제 버그 클래스를 막는 것**이라 가치 있음 — 다만 새
프리미티브라기보다 dispatch 엔진에 얹는 얇은 유틸 수준.
Vide `batch.luau:4-21`가 정확히 이 역할(여러 `source:set()`을 하나의
flush로 묶음).
## 4. Context(트리 하위 암묵 전파) — 난이도 판정 완료, 기각 권고 (2026-08-06)
Fusion `Utility/Contextual.luau:28-88`(코루틴 키 weak table push-pop), Vide
`context.luau:14-72`(scope 그래프 조회)가 대응 개념. 서브에이전트에게 구현
난이도 평가를 위임한 결과(상세 근거는 아래 "진행 중 논의" 절의 Context
서브섹션 참고), **동기적 저작 트리 안에서만 작동하는 얕은 버전은 구현
난이도가 낮지만(Fusion 코드를 사실상 그대로 이식 가능), quad가 이미
정상 패턴으로 확정한 "Slot에 이벤트 핸들러/코루틴에서 비동기로 자식이
추가되는 경우"엔 조용히 기본값으로 폴백하는 함정이 생김**을 확인 —
Roblox Luau에 thread-local/async-context-propagation 훅이 없어 완전
자동화는 사실상 불가능(플랫폼 한계, 구현 노력의 문제가 아님). Node
`AsyncLocalStorage`/Python `contextvars`가 같은 문제를 "자동"이 아니라
"async 경계마다 명시적 캡처+재진입"으로 푸는 것과 동일한 결론.
**기각 권고 근거**: 얕은 버전조차 (a) quad가 스스로 정상 패턴으로 확정한
Slot 비동기 추가에서 가장 먼저 깨지고, (b) `research/debug-tooling-plan.md`
"모든 연결은 선언된 그래프여야 한다"는 quad-debug 철학과 충돌하는 안 보이는
채널을 만듦. 대신 **레이어드 Store**(자식 Source 모음이 자기한테 없는 키는
부모로 `__index` 폴백 — Modifier/Source가 이미 쓰는 델리게이션 패턴과 동일
계열)가 Context의 핵심 가치(서브트리별 오버라이드)를 명시적 전달 철학과
비동기 안전성을 유지하면서 대부분 재현함 — 오버라이드 Store 참조는 여전히
prop으로 명시 전달해야 하지만, 이건 `component-composition-plan.md`가 이미
확정한 비용이라 새로 감수하는 게 아님. Roblox `require()` 캐싱 싱글톤(단일
전역, 서브트리 오버라이드 불가)은 오버라이드가 필요 없는 단순 케이스에는
여전히 충분.
## 빈 자리 아닌 것으로 확인된 것들
- **Untrack/Peek**(Solid `untrack()`, Vue `toRaw`): quad는 Vide식 암묵
추적을 기각하고 `:With(...)` 명시적 의존성 선언을 택함 — "읽었지만
추적 안 하고 싶다"는 필요 자체가 안 생김(`:With`에 안 넣으면 그게 곧
untracked read). Vide `untrack()`은 암묵 추적 전용 문제라 quad엔 애초에
적용 안 됨.
- **Suspense/비동기 경계**: `Ref:Wait()`(coroutine 대기) + 처음엔 nil인
Source로 부분 커버되지만, **quad 컴포넌트가 한 번만 실행된다**는 전제와
부딪히는 함정이 있음 — 렌더 함수 최상단의 `if loading then return
Spinner end`류는 마운트 시점 단 한 번만 평가되고 데이터 도착 후
재평가 안 됨. Slot + Observer 조합으로 실제 구현은 가능하나 1급 패턴이
아니라서, 새 코어 프리미티브보다는 **"render-once 함정" 문서화
우선순위 문제**로 재분류(`research/documentation-plan.md`의 권장 패턴
문서 부류에 속함, React 습관 개발자가 특히 잘 빠질 실수).
- **Error Boundary**: quad 컴포넌트는 평범한 Lua 함수 호출이라, 리스트
개별 아이템 생성 주변에 `pcall(MyComp, props)`를 감싸는 것만으로 React
Error Boundary와 같은 격리 효과를 프레임워크 지원 없이 얻음.
- **Readonly wrapper**: `component-composition-plan.md`가 이미 "Source
직접 전달은 좁은 케이스에 한정, 일반적으론 State + callback이 기본"으로
못박아둬서 캡슐화 깨짐 문제 자체가 대부분 상황에서 안 생김.
- **Fusion `Observer`/`Attribute`**: quad `state:Observer(fn)` +
`bind-system-plan.md`의 Attribute 논의로 이미 커버 중, 신규 아님.
- **디바운스/스로틀**: Fusion/Vide/v1 어디에도 공개 프리미티브로 없음 —
세 레포 모두 없다는 것 자체가 "quad도 굳이 안 만들어도 된다"는 정황.
## 제안 우선순위 (결정 아님, 검토 순서 제안)
1. **키 기반 컬렉션 재조정** — 실전 영향이 가장 크고, 설계가 완전히
빈 상태라 가장 먼저 사용자와 상의할 가치.
2. **Effect 공개 API**`process`/`retract`가 이미 증명한 패턴을 얇게
노출하는 정도라 구현 비용 낮음.
3. **Batch** — 이미 문서화된 churn 문제의 직접 해법, opt-in 유틸 수준.
4. **Context** — 기각 권고(2026-08-06 난이도 판정 완료). 서브트리 오버라이드가
실제로 필요해지면 Context가 아니라 **레이어드 Store**를 검토할 것.
## 진행 중 논의 (2026-08-06 후속 세션)
메인 브랜치에 병합된 `pre-implementation-audit.md`의 1-7번("Slot의
`add`/`remove`/`clear` CRUD 의미론이 정의돼 있지 않음")과 정확히 맞물리는
타이밍이라, 키 기반 컬렉션 재조정을 Slot 설계에 바로 접목해보는 논의 시작.
### 키 기반 컬렉션 재조정 — 설계 스케치 (확정 아님)
사용자가 처음부터 "Slot의 상위 요소"로 만들고 싶다고 명시 — 두 가지 프레이밍을
제시함: (a) Slot을 만들어주는 팩토리 함수, (b) State의 "터미널 연산"(스트림의
`.collect()`류)으로 Slot을 얻는 것(`state:?() -> Slot`).
**두 프레이밍 중 (b)가 quad의 기존 원칙과 더 잘 맞음** — 2026-08-06 세션에서
이미 확정된 "독립 프리미티브 vs 원천 종속 파생 데이터" 원칙(`state:Observer(fn)`가
메소드고 자유 함수가 아닌 이유와 같은 논리, `store-semantics.md`)을 그대로
적용하면: 키 기반 Slot은 그 뒤에 있는 State 없이는 존재 의미가 없는 **파생
데이터**이므로, 자유 함수 팩토리(`Type(args)` 패턴)가 아니라 **State의
메소드**로 두는 게 일관적. 가칭 `state:Keyed(keyFn, renderFn) -> Slot`.
**메커니즘 스케치**(전부 이미 확정된 조각들의 재조합 — 새 마운트/디스패치
장치 불필요):
- 내부적으로 `state:Observer(fn)`과 동형 — `fn`이 무효화 신호를 받을 때마다
`Get()`으로 새 테이블을 pull하고, 이전에 본 key 집합과 diff.
- 새 key → `renderFn(key, itemState)` 호출 후 `Slot:add(...)`.
- 사라진 key → `Slot:remove(...)`(기존 확정 시맨틱대로 `retract`=폐기, 옮기지
않음 — `slot-plan.md`와 자동으로 일관됨).
- 유지되는 key → Slot 조작 없이 그 항목의 `itemState`에만 새 값을 반영(재생성
안 함) — Fusion `SubObject`/Vide `values()`가 하는 "값만 갱신"과 동일 효과를,
`renderFn``itemState: State<V>`를 받는 것만으로 자연스럽게 얻음(항목 값이
바뀌어도 renderFn 자체는 재호출 안 되고, `itemState`를 구독한 리프만
갱신됨).
- 소유권: 반환된 Slot은 기존 "엄격한 단일 마운트" 규칙 그대로 적용 — 새 규칙
불필요.
- 패키지 경계: diff 알고리즘(순수 데이터 로직)은 `quad-base`, 실제 Instance
생성/제거는 기존 Slot 핸들러 경로 그대로 재사용 — `slot-plan.md`가 이미
확정한 base/roblox 분리와 동일 패턴.
**열린 세부**: Fusion은 `ForPairs`/`ForKeys`/`ForValues` 3종으로 나뉘는데,
quad는 `renderFn(key, itemState)`가 항상 key+itemState를 다 주고 안 쓰는
쪽은 그냥 무시하게 하는 **1종 통합안**이 단순화 후보로 보임(3개로 쪼갤
근거가 약해 보임 — `pre-implementation-audit.md`의 "단순화 후보" 렌즈와
같은 결). `keyFn` 생략 시 입력이 이미 맵이면 맵 key를 그대로 identity로
쓰는 것도 자연스러운 기본값 후보. 이름(`Keyed`/`Each`/`List`/`ToSlot`)은
용어 정리 라운드로 이월.
**다음 단계**: 사용자 피드백 반영해 스케치 다듬고, 이견 없으면 M6(Slot)
착수 시점에 `slot-plan.md`/`bind-system-plan.md`에 정식 반영.
### Context — 구현 난이도 판정 완료 (2026-08-06)
사용자 확인: Context는 React `useContext`(Provider가 위에서 값을 심고,
하위 어디서든 prop 없이 읽는 패턴)와 같은 개념 맞음. 구현 난이도가 채택
여부를 사실상 결정한다는 사용자 판단에 따라 서브에이전트에게 난이도 평가를
위임했고, 결과 수렴 완료 — **위 "4. Context" 절이 최종 결론**. 요지만
재정리:
- **난이도 등급**: 동기적 저작 트리에 한정한 얕은 버전 = **낮음**(Fusion
`Contextual`의 "코루틴을 키로 하는 weak table push-pop"을 그대로 이식
가능, quad-base 단독으로 완결, quad-roblox 분리조차 불필요). 완전
자동(비동기 Slot 추가까지 자동 전파) = **매우 높음, 사실상 불가**
Roblox Luau에 thread-local/async-context-propagation 훅이 없어서 quad가
소유하지 않는 임의의 콜백 경계(`Signal:Connect`, `task.spawn`, Promise)를
가로챌 방법이 없음. 이건 quad 설계의 문제가 아니라 플랫폼 자체의 한계.
- **사용자의 직관이 정확히 맞았음**: 동기 콜스택 부분은 싸지만, Slot에
나중에(이벤트 핸들러/코루틴에서) 비동기로 자식이 추가되는 케이스에서
Fusion 방식은 **에러 없이 조용히 `defaultValue`로 폴백**함 — "테마가
가끔 기본값으로 보인다"는 형태의 원인 추적 어려운 버그를 만듦(Vide
방식은 폴백 대신 에러를 던지지만, 애초에 이 시나리오를 지원 대상으로
설계한 적이 없어 참고할 답이 없기는 마찬가지). Node
`AsyncLocalStorage`/Python `contextvars`도 "자동"이 아니라 "async 경계마다
명시적으로 값을 캡처해서 재진입"하는 방식으로 같은 문제를 풀고 있어,
이식해도 결국 "prop drilling 비용"이 "매 async 경계 캡처+재진입 보일러
플레이트 비용"으로 자리만 옮김.
- **기각 이유(효용 대비 비용)**: 얕은 버전조차 채택할 가치가 낮음 — (1)
`base/slot-plan.md`가 정상 패턴으로 명시한 Slot 비동기 추가에서 가장
먼저, 가장 조용히 깨짐, (2) `research/debug-tooling-plan.md`의 "모든
연결은 선언된 그래프여야 한다"는 quad-debug 철학과 충돌하는 안 보이는
채널을 만듦.
- **대안 비교 결과**: Store 병합(난이도 매우 낮음, 그러나 drilling 자체는
안 없앰) < 타입 안전 서브셋 전달(난이도 낮음, 하지만 트리 전파 문제와는
별개 축이라 해결책이 아님) < **레이어드 Store**(난이도 낮음~중간, Context의
핵심 가치인 "서브트리별 오버라이드" 대부분 재현하면서 명시적 전달
철학·비동기 안전성 유지 Modifier의 제네릭 `__index` 트릭,
Source가 State를 만족시키는 `__index` 델리게이션과 같은 이미 검증된
패턴 계열이라 구현 리스크도 낮음) **레이어드 Store가 최종 권고안.**
사용자가 스스로 "어려울 같다" 짐작했던 것과 달리, quad2-try에서
금지한 `Class:Extend()` 행위(메소드) 상속과는 다른 층위(순수 데이터
조회의 `__index` 폴백일 ) 실제로는 낮은 난이도로 판정됨 다만
Luau 타입 레벨에서 "자식 부모의 나머지 " 구조적으로 표현하는
부분은 M0가 이미 검증 대상으로 잡은 `Source<T> satisfies State<T>`
솔버 위험과 비슷한 급이라, 도입한다면 같은 스파이크에 끼워 검증하는
합리적.
- **결론**: Context라는 이름의 범용 프리미티브는 채택하지 않음. 테마/로케일
서브트리 오버라이드가 실제로 필요해지는 시점에 레이어드 Store를 별도
후보로 검토(지금 착수 우선순위 낮음, 기반 컬렉션 재조정이 여전히
시급).
## 참고: 조사에 사용한 소스 근거
- Fusion: `State/ForPairs.luau`, `State/ForKeys.luau`,
`Utility/Contextual.luau`, `Graph/Observer.luau`, `Instances/Attribute.luau`,
`Memory/doCleanup.luau`
- Vide: `indexes.luau`, `values.luau`, `context.luau`, `batch.luau`,
`action.luau`, `untrack.luau`, `cleanup.luau`
- quad v1: `store.lua`, `tracker.lua`, `class.lua`(diff/reconcile/keyed
계열 헬퍼 없음, grep 확인)
- artworks: `EventDrivenProgramming/Observable.luau`, `Utility/Array.luau`,
`GlobalDataStorage/request.luau`, `DeclarativeProgramming/DeclarativeInstance.luau`
경로는 모두 `.claude/initreq/<repo>/...` 기준(읽기 전용 참고 레포).