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

20 KiB
Raw Blame History

추가 프리미티브 필요성 조사 — 다른 프레임워크 대비 갭 분석

상태: 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.mdSlotadd/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.luaumap/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 공개 APIprocess/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()가 하는 "값만 갱신"과 동일 효과를, renderFnitemState: 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>/... 기준(읽기 전용 참고 레포).