quad/.claude/research/bind-system-plan.md
qwreey 0dbbc3d0b1
설계 검증 라운드(2026-08-04) 결과 반영
2026-08-03 확정 사항 전체를 AskUserQuestion으로 하나씩 재검증. 대부분
그대로 확인됐으나 State 프리미티브 존재 여부(있어야 함으로 정정), Pipe
copy-on-write 후보(폐기, state(state) 조합으로 대체), Slot retract 시
동작(폐기로 확정) 등 실제 정정이 발생 — Store/State/Source 온톨로지가
다음 세션 최우선 열린 설계 스레드로 새로 부상.

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

26 KiB

Bind 시스템 — pluggable key/value 핸들러 (핵심 모델 확정, 세부 사항만 남음)

상태: research — 핵심 디스패치 모델(process/retract, 핸들러 4종 계약, Signal 미채택, Ref 역할)은 사용자 확인 완료로 사실상 확정. 남은 건 세부 시그니처(dependency array API, CreatedRef 모양) 뿐 — 이것들이 정리되면 base/로 승격 예정. 원본: .claude/initreq/raw-userinput.md "key와 value에 대한 바인드 연산은 pluggable 하도록 구성하기" / "스토어는 스토어를 저장 가능한가" / "Ref는 고민중" 절. v1의 문제점은 base/quad-v1-architecture.md ("ProcessQuadProperty" 하드코딩 디스패처), 참고 패턴은 .claude/initreq/tbox (레지스트리)와 Fusion/Vide 비교는 base/comparison-fusion-vide.md 참고.

문제

v1의 ProcessQuadProperty(.claude/initreq/quad/src/class.lua:134-214)는 숫자 키(children/style) vs 문자열 키(prop/event) vs __type 태그 테이블 (register/linker/style)을 하드코딩된 if/elseif 체인으로 구분한다. 새 특수 키 ([Attribute "X"], [Tag ""], PropertyChangedEvent "" 등)를 추가하려면 이 중앙 함수 자체를 고쳐야 한다 — 라이브러리로서 확장 불가능한 구조.

핸들러 계약 (확정 — 아래 "확정된 디스패치 모델" 절과 통합해서 읽을 것)

핸들러는 다음 4개를 제공하는 등록 가능한 객체:

  • isHandlable(key, value): boolean — 이 핸들러가 이 key/value 쌍을 처리할 수 있는지 판별하는 predicate. 부작용 없이, 빠르게 — tbox의 type-check/ constraint-check 분리 원칙(.claude/initreq/tbox/CLAUDE.md의 "타입 체크는 분기 선택에 쓰이므로 순수해야 함")을 그대로 적용: isHandlable은 오직 "이 핸들러가 맞는가" 판별에만 쓰이고, 실제 유효성 검사는 핸들러가 선택된 이후 별도 단계에서.
  • priority: number — 우선순위. 등록 순서(Fusion의 4단계 고정 stage, Vide의 action() 우선순위)보다 일반화된 열린 숫자 공간으로.
  • process(inst, key, value) — 실제 처리 수행(아래 "확정된 디스패치 모델" 절 참고). v1/기존 논의에서 "bind"라 부르던 것과 동일한 역할.
  • retract(inst, key, value) — 이전 처리를 무르는/멈추는 함수(아래 절, base/lifecycle-pattern.md 참고). 모든 핸들러가 의미 있게 구현할 필요는 없음(예: 일반 프로퍼티 핸들러는 보통 no-op).

디스패치는 등록된 핸들러를 우선순위 순으로 스캔하며 isHandlable을 호출, 첫 매치가 처리(Fusion의 SpecialKey 우선순위 스캔과 유사하되 4단계 고정이 아니라 열린 레지스트리). tbox의 TUnion 런타임 체커가 이미 이 "순서대로 스캔, 첫 매치 반환, 실패 정보는 클로저로 지연 생성" 패턴을 구현해뒀음(.claude/initreq/tbox/ src/schema/union.luau:48-68) — 에러 메시지는 즉시 문자열로 만들지 말고 매치 실패 시에만 클로저 호출.

확정된 디스패치 모델: process(inst, k, v) / retract(inst, k, v)

사용자가 직접 준 구체적인 모델 — 이 문서의 이전 초안보다 우선함. 아래가 실제로 구현할 모양:

  • 모든 핸들러는 대상 Instance를 직접, 항상 받는다. quad는 "인스턴스를 생성하고 그 인스턴스를 처리하는" 라이브러리다 — 다른 라이브러리가 만든 값(예: Store)을 그 인스턴스에 적용하도록 돕는 역할에 가깝다. 그래서 핸들러가 "나중에 생길 대상"을 비동기로 기다릴 필요 자체가 없음(아래 Ref 절 참고 — Ref는 다른 이유로 존재).
    • 보강(2026-08-04): inst가 항상 살아있는 엔진 객체(Roblox Instance)일 필요는 없음 — 특정 백엔드에서 실제 엔진 객체 생성/바인딩 비용이 비싸면 (예: 웹 DOM) 중간 표현으로 평범한 테이블을 만들고 나중에 그 테이블을 렌더링하는 것도 가능. 이건 core(base)가 신경 쓸 일이 아니라 각 최종 엔드포인트 백엔드(quad-roblox/quad-web 등)가 알아서 결정할 문제 — base 인터페이스는 "무언가를 inst로 받아 process/retract한다"는 계약만 지키면 됨, 그 inst의 실체가 뭔지는 백엔드 재량.
  • process(inst, k, v) — 우선순위 순으로 등록된 핸들러를 스캔, isHandlable(k,v)를 만족하는 최상위 핸들러가 실제 처리를 담당.
  • 예시: Tween의 store-bind 핸들러는 k는 무엇이든 받고 v가 Store인 경우를 잡아내는, 우선순위가 매우 높은 핸들러v가 store이면 그 값을 처리(구독)함. 이 핸들러 안에서:
    1. 지금 이 처리가 실행되어도 되는지 라이프타임(Connected)을 확인 — 확인 안 하면 이미 Destroy된 대상에 대해 처리가 실행되는 문제가 생김. GC가 결국 정리하긴 하지만, GC 되기 전에도 store 값이 업데이트될 수 있으므로 그 시점엔 그냥 Connected를 보고 무시(no-op).
    2. 처리해도 되면, 사용자가 넘긴 함수들을 거쳐 실제 값(realv)을 계산.
    3. realv를 들고 다시 process(inst, k, realv)를 재귀 호출 — 이게 바로 "store 바인드는 pluggable 바인드를 재실행하는 래핑"이라는 이 문서 이전 초안의 결론과 일치. realv가 store가 아니라면 자연히 Tween의 store-bind 핸들러 isHandlable을 통과 못 하고 우선순위상 다음 핸들러(일반 프로퍼티 세터 등)로 흘러감 — 무한 재귀 걱정 없음.
  • retract(inst, k, v) (이전 초안의 "cleanup", 이름 변경 근거는 base/lifecycle-pattern.md 참고) — 이전 처리를 무르는/멈추는 함수. 오직 "같은 key에 새 값이 들어와서 이전 처리를 갈아치우는" 시나리오에만 존재 — 인스턴스/바인드 전체가 Destroy될 때는 retract가 호출되지 않음(base/ lifecycle-pattern.md의 "quad는 라이프사이클 중간에 있지 않다" 원칙 참고).
    • 일반 프로퍼티는 애초에 "unset" 개념이 없음(nil로 셋하는 것도 그냥 셋 동작) — 그래서 프로퍼티 핸들러는 보통 retract가 필요 없음.
    • retract가 실제로 의미 있는 곳: Tag를 지운다, Attribute 엔트리 자체를 지운다, 실행 중인 Tween을 멈춘다 같은, "값을 새로 셋하는 것"과 "이전 상태를 명시적으로 되돌리는 것"이 다른 케이스.
    • store bind가 새 값으로 넘어갈 때 이전 핸들러의 retract(inst, k, v)를 한 번 호출해주면 됨.
  • 핸들러 내부 상태 저장: retract가 "이전에 생성한 것"(예: 실행 중이던 Tween 객체)에 접근하려면 상태를 어딘가에 저장해야 함 — inst를 키로 하는 weak-keyed 테이블에 k별로 저장(예: 생성된 Tween을 담아뒀다가 나중에 멈추거나 끝냄). base가 이걸 범용 유틸로 제공(base.perInstanceState(inst) 류, 정확한 이름/모양은 구현 단계에서 확정) — 모든 핸들러가 재사용, 각자 WeakMap을 새로 만들지 않음. base/lifecycle-pattern.md의 "생명 바인드 유틸"과 짝을 이루는 유틸.
  • 다른 값 변경을 추적하는 것도 process 함수의 정상 범위: 예를 들어 Slot 핸들러는 자기가 감시하는 값(배열/스토어)이 바뀌면 그에 따라 child를 갱신해야 함 — retract 시점엔 그 추적(구독)만 풀면 됨.

Store 바인드는 특수 경우인가, 아니면 pluggable 바인드를 재실행하는 래핑인가

사용자 원 메모: "스토어 바인드는 특수 경우로 둘지, 아니면 다른 pluggable 바인드를 재실행하는 래핑으로 쓸지 생각해봐야함... 충분히 확장 가능하게 둘 수 있음."

확정: 래핑 쪽. 위 "확정된 디스패치 모델" 절 참고 — store 바인드 핸들러도 다른 핸들러와 동일한 isHandlable/priority/process/retract 계약을 따르되, process가 내부적으로 "실제 값이 바뀔 때마다 (원래 key, 새 value)로 process(inst,k,realv)를 재귀 호출"하는 식으로 구현됨. 이러면 store 값 자체가 어떤 타입이든(원시값, 인스턴스, 심지어 다른 store) 상관없이 동일한 재귀적 디스패치로 처리 가능 — 아래 "store가 store를 저장 가능한가"와 직결.

Slot이 store 바인드로 넘어오는 경우, pluggable 처리기에 retract 핸들러가 필요하다는 점(부모가 slot을 정리하고 다시 process하는 방식)도 이 래핑 방식과 자연스럽게 맞음 — research/slot-plan.md 참고.

Store가 Store를 저장 가능한가

사용자 원 메모: "슬롯을 스토어처럼 생각 가능하다면 이건 가능하다고 봐야하는가? 아니면 아예 다른 값으로 둬야 하는가? table/number 같은 프리미티브 타입이나 ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플러그를 만드는 걸로."

이 문서의 제안: "Store 안의 값이 Store"인 경우도 그냥 하나의 (key,value) 쌍일 뿐이고, 그 값 타입(Store)을 인식하는 핸들러가 pluggable 레지스트리에 등록되어 있으면 됨 — 위 "재실행 래핑" 방식과 동일한 메커니즘으로 커버됨. 별도 특수 케이스 코드 불필요.

Ref — 도입 확정, 단 용도는 재정의됨

중요한 정정: Ref는 Tween이 대상을 얻기 위해 필요한 게 아님(Tween 핸들러도 process(inst,k,v)처럼 항상 대상 Instance를 직접 받으므로 — 위 "확정된 디스패치 모델" 참고, research/tween-plan.md도 이에 맞춰 갱신됨). Ref의 진짜 용도는 다름:

  • v1의 Frame "id" {} + Store.GetObject(id) 식 id 매핑은 폐기 확정 (base/architecture.md 5번 항목) — "비현실적"이라는 게 이유.
  • 하지만 "라이브러리가 자기 자신이 만들어낸 instance를 나중에 다루기 편하게" 하는 용도로 Ref는 여전히 필요. 구체 시나리오: 기존에 다른 라이브러리로 관리되던 instance를 당장 quad로 옮기지 않고, ref를 따서 그 안에 자식을 Parent로 마운트한다든가, 점진적으로 마이그레이션한다든가, 래퍼를 만든다든가 하는 다양한 용도.
  • Store는 이미 바깥에서 옵저빙 가능한 존재라 별도 취급 불필요 — Ref는 그와 달리 "원하는 객체 자체를 직접 얻어오는" 경로. 얻어진 뒤에 그 참조를 어디에 저장하고 어떻게 쓰는지는 라이브러리 책임 범위 밖(사용자 자유).
  • 바인드 방법: 특수 처리 없이, children을 배열 아이템으로 넣듯 CreatedRef 같은 값을 숫자 키 슬롯에 넣는 방식(정확한 이름/시그니처는 미정, 예: [1] = CreatedRef(function(inst) ... end)) — child와 동일한 층위에서 process(inst,k,v) 디스패치를 그대로 타게 함. 즉 Ref도 pluggable 핸들러 레지스트리의 평범한 참가자.
  • 코루틴 기반 "채워질 때까지 대기" 지원 여부는 여전히 미정(별도 확인 필요 없이 구현 우선순위 낮음 — 필요성이 명확해지면 그때 추가).
  • 콜백 호출 시점 확정: 생성 직후(construction 시점)와 트리 마운트 후(Parent 세팅 완료) 둘 다 지원 — 옵션으로 선택(CreatedRef(fn, {phase="created"| "mounted"})류, 정확한 API 이름은 구현 단계에서 확정).

여러 Store 값을 묶어 파생값 만들기 — :With + :Compute, 포지셔널 인자 지양

사용자 확인 완료, 상세 방향 확정. 후보로 검토했던 두 방식 모두 기각:

  • 암묵적 자동 추적(Vide식 ambient stack) 기각 — "함수 실행 중과 끝 사이를 확인하고 부작용이 필요"한 방식이라 Lua에서 깔끔한 방법이 아니라고 판단.
  • 명시적 디펜던시 배열 + 포지셔널 인자(Store.Combine({a,b}, function(av,bv) ...))도 기각 — 두 가지 이유: (1) 팩토리 함수로 store-bind 처리기를 쉽게 못 만들어줌, (2) 여러 팩토리를 체이닝하면 인자 순서가 꼬일 수 있고, 타입 표기도 어려워짐.

채택 방향: :With(...)로 필요한 의존성을 모으고, 그 뒤 :Compute(function() ... end)에서 with한 값을 포지셔널 인자로 받지 않고 클로저로 직접 읽는다 (정확히 어떤 방식으로 "직접 읽는지"는 아래 열린 질문 — :fromState 후보 참고).

Unix 파이프에서 영감 받은 스트림 지향 — 원래 의도, 기술적 난이도 미확정

중요한 배경: quad는 원래 이 파이프라인/스트림 개념에서 영감을 받아 만들어짐. 이상적으로는 store에서 한 값을 추적(track)하면 "State"가 나오고, 거기에 compute를 적용하면 또 다른 "State"가 나오는 식 — Unix의 (cat a; cat b) | while read ...처럼, State끼리 자유롭게 합성/파이핑 가능한 것이 최종 목표. :With의 두 번째 인자(b)도 다른 :Compute의 결과물(State)을 그대로 받을 수 있어야 이상적.

미해결 긴장 관계: 이걸 구현하는 두 갈래 방식이 있고 어느 쪽이 맞는지 아직 결정 안 됨:

  1. Compute 체인이 항상 자기 자신을 mutable하게 바꾼다 — 엔지니어링 비용은 낮지만, 다른 코드가 나중에 그 체인 뒤에 새 compute를 붙이는(다른 소비자가 동일 State에 독립적으로 파생값을 추가하는) 것이 불가능해짐 — 공유/합성이 깨짐.
  2. 명시적 State:fromState(state)류의 비-mutating 생성자 — 합성은 안전해지지만 엔지니어링 비용이 더 큼(정확히 얼마나 큰지 미확정).

이건 base/architecture.md의 "복사 구현 지양, 팩토리 함수로 대체" 원칙과 같은 축의 문제 — 옵션 2가 그 원칙과 더 잘 맞아 보이지만, 실현 가능성 자체가 아직 검증 안 됨.

Store/State/Source 온톨로지 — 진행 중인 설계 스레드 (2026-08-04 검증 라운드에서 새로 열림)

이 절은 아직 결론난 설계가 아니다 — 검증 라운드 중 사용자가 실시간으로 설계를 전개하며 나온 내용을 그대로 기록. 다음 세션에서 이어서 다룰 것. base/store-semantics.md의 "State 프리미티브는 실제로 필요하다" 정정과 직결됨.

핵심 온톨로지:

  • Source — 실제 값이 존재하고 변경될 수 있는 단일 지점(v1의 "값의 근원").
  • Store — source들의 집합체. store.a/store "a"처럼 키로 접근하면 그 source를 감싼 새 State를 매번 만들어 반환(state가 store에 캐시되어 재사용되는 게 아님 — source만 store에 귀속된 유일한 실체).
  • State — source(또는 다른 state)의 결과를 캐싱만 하는 존재, 자기 고유의 독립적 value 개념이 없음. state(state)로 기존 state의 결과를 받아 새 state를 만들어 분기 가능 — 이게 사실상 Unix 파이프 영감의 "State끼리 합성 가능"이라는 원래 목표를 구현하는 방식.
  • :With(...)/:Compute(fn)은 실제로는 state 위의 연산 — store "key1":With(store "key2"):Compute(function(key1) return key1 + store.key2.value end)처럼, with한 값을 fn이 클로저로 직접 읽는 모양.

미해결 세부 사항:

  • :Compute 캐싱/무효화 전략 — 매번 새로 계산할지, 캐싱해두고 무효화 플래그로 관리할지 미정. 후보: 값이 바뀌었는데 듣는 소비자가 없으면 연산은 미루고 invalid=true만 세워두고, 필요해질 때(듣는 사람이 생기거나 값을 읽을 때) 실제로 연산하고 true→캐싱 후 false로 되돌리는 dirty-flag 방식. "store가 state를 만드느냐 아니면 저장하느냐"의 문제와 직결 — 사용자 판단은 "만든다" 쪽(저장한다고 하면 한 곳에서 :Compute를 붙이면 다른 소비자도 전부 그 compute된 값을 읽게 되어버리는 오염 문제 발생).
  • emit 필요 여부 — store가 값 변경 시 관련된 모든 state에 emit해야 하는 구조가 맞는지 확신은 없지만, 그 외의 방법이 안 보인다는 게 사용자 현재 판단. state(from) / state() -> (state, setState) 같은 팩토리 모양도 후보로 언급됨(React의 useState류 페어 반환과 유사).
  • Luau 타입 시스템 제약store "key" 같은 커링 호출로 state<T>T를 정확히 추론하려면 오버로드 함수 타입((("a") -> number) | (("b") -> boolean))이 필요한데, Luau는 문자열 리터럴 인자를 자동으로 as const 취급하지 않아서 타입이 좁혀지지 않는 문제가 있음. store.states.a처럼 필드 접근으로 우회하거나, store.a가 바로 state를 반환하고 state.value = x로 설정 가능하게 하는 대안도 검토됐으나, 후자는 "다른 source로부터 파생된 state에 value를 직접 설정하면 안 된다"는 문제와 충돌(store가 실제 값을 담는 유일한 주체여야 함). state<Mapped>(compute 결과)가 제대로 바인딩 안 됐을 때 생기는 타입 문제는 일단 UB로 두기로 함.
  • Pipe(quad2-try 후보)는 사실상 폐기 쪽으로 기움 — 별도 Pipe 타입에 소유권/버전 가드를 넣어 재설계하는 대신, State 자체를 파이핑 결합체로 보고 state(state)로 분기하는 쪽이 엔지니어링상 더 쉬워 보인다는 게 사용자의 최신 판단(2026-08-03 라운드의 "Pipe COW가 유력 후보"보다 우선함).

quad2-try 리서치 결과 (완료) — 이전 시도에서 뭘 가져오고 뭘 버릴지

.claude/initreq/quad2-try/out/quad-core에 정확히 이 문제(Unix 파이프 영감의 State/스트림)를 다뤘던 이전 시도가 있었음. 조사 결과 요약:

확인된 죽은 접근 — 절대 반복하지 말 것:

  • OOP 상속(Base:Extends) 구조Source/State/Pipe/Store/Event/ Action+8개 서브타입 전체에 퍼져 있었음 — 모든 서브클래스 생성자마다 self._super._constructor(self, ...)를 수동으로 호출해야 하고(빼먹기 쉬움, 컴파일러가 검증 안 함), private/protected는 _ 접두사 관례일 뿐 실제 캡슐화가 전혀 없었으며, Base:IsInstance가 수동 유지되는 _proto/_super 연결 리스트를 순회하는 런타임 전용 타입 체크라 Luau 정적 타입 시스템이 전혀 못 봄. 사용자가 우려한 그대로 확인됨 — 상속 기반 설계 금지.
  • --& 커스텀 파서 시도는 완전히 죽은 코드였음 — 6개 파일에 156줄의 주석 기반 타입/가시성 어노테이션이 있었지만, 이걸 실제로 소비할 도구 (quad-gen, quad-lang)는 둘 다 완전히 빈 디렉토리였음. 오타(@clsas@class로 못 고침)가 안 잡힌 채 남아있었고, 같은 주석 마커 아래 전혀 다른 Lua5.1-호환 트랜스파일러 지시어까지 섞여 있었음 — 파서가 한 번도 제대로 동작한 적 없다는 명백한 증거. 확인대로 반복 금지.
  • Slot은 이 시도에서도 사실상 빈 스텁이었음 — Insert의 실제 구현부가 전부 주석 처리되어 있고, Notify()도 빈 함수. 심지어 구 v1(quad-2)의 DEV_CHANGELOG.txt에도 "TODO: slot 기능 구현"이 마지막까지 미완료로 남아있었음 — 가져올 게 전혀 없음, research/slot-plan.md의 from-scratch 설계를 그대로 진행하면 됨(재조사 불필요).
  • 다른 서브패키지(quad-roblox/quad-gtk/quad-lang/quad-gen/quad-compat/ quad-debug/quad-docs)는 전부 파일이 0개인 빈 디렉토리 — quad-core 밖엔 참고할 게 없음.
  • Store:Pipe/Store:Value 연동이 담긴 유일한 두 예제 콜사이트(slot.luau:31-41)조차 존재하지 않는 Store:Value 메서드를 호출하는 등 실제로 동작 검증된 적이 없는 죽은 스크래치 코드였음 — 이 프로토타입은 끝까지 실사용 검증을 통과한 적이 없음.

건질 만한 것 (인체공학/아이디어만, 코드는 아님):

  • store:Pipe(key):Compute(fn) 같은 왼쪽에서 오른쪽으로 읽히는 파이프 문법 자체는 목표로 유지할 가치가 있음.
  • Pipe가 mutate-vs-fromState 긴장 관계에 제시한 절충안 — "체이닝된 Compute/Add/... 호출은 자신이 액션 리스트의 유일한 '끝(tip)'일 때만 공유 배열에 그대로 append(뮤테이션), 이미 다른 코드가 그 지점 이후로 체인을 확장해버렸다면 배열을 복사한 뒤 새 Pipe 객체를 반환"하는 copy-on-write 방식 — 이건 이 문서의 "mutate-in-place vs fromState" 긴장을 실제로 풀어보려 한 유일한 시도라 quad-v2에서 제대로 다시 설계해볼 만한 후보. 단, 원본은 "내가 지금 유일한 tip인가" 체크에 소유권/버전 관리가 전혀 없어서 경쟁 상황에 취약했고 테스트/실사용 검증도 없었음 — 그대로 베끼지 말고, 같은 아이디어를 소유권 가드를 제대로 넣어 재설계할 것.
  • Depend(...) 액션 — 계산값에는 관여하지 않고 오직 "이 소스가 바뀌면 다시 계산하라"는 추가 의존성만 등록하는 값-투명(value-transparent) no-op 액션. 작지만 깔끔한 아이디어라 이름 그대로 채택할 만함.
  • 흥미로운 발견: 스크래치 파일(out/asdf)에 남아있던 더 이전 버전의 파이핑 스케치가 정확히 Pipe(store.background):With(store.transparency, globalStore.test):Compute(fn) 모양이었음 — 실제 구현으로 넘어가며 :Depend()+포지셔널 인자로 바뀌었지만, :With(...) 네이밍은 사용자가 이번 라운드에서 다시 요청한 것과 정확히 일치 — 우연이 아니라 원래 지향점이었던 것으로 보임, :With 이름 채택에 힘을 실어줌.

종합: 이 프로토타입은 사실상 죽은 시도가 맞음(확인됨) — Depend/:With 네이밍은 quad-v2 설계에 그대로 살려볼 가치가 있는 아이디어로 남지만, Pipe의 copy-on-write 절충안은 2026-08-04 검증 라운드에서 사실상 폐기 쪽으로 재평가됨 (위 "Store/State/Source 온톨로지" 절 참고 — State 자체가 파이핑 결합체이고 state(state)로 분기하는 쪽이 더 간단하다는 사용자의 최신 판단).

확정된 것 (더 이상 열린 질문 아님)

  • 핸들러 계약: isHandlable(k,v) + priority + process(구 bind) + retract(구 cleanup) 4종 조합으로 확정 — tbox식 6-hook 세분화는 지금은 안 함. 실제 구현하며 부족한 지점이 보이면 그때 hook 추가(점진적 확장).
  • Signal 클래스: 안 만듦, 콜백 + Connected 계산 속성만(base/ lifecycle-pattern.md).
  • Ref: 도입 확정(위 절 참고), 용도는 "id 기반 조회 대체"가 아니라 "외부 관리 instance를 점진적으로 다루기 위한 직접 참조 획득".

base 유틸은 인터페이스, 실제 구현은 백엔드 팩토리가 주입 (2026-08-04 보강)

base/lifecycle-pattern.md가 말하는 "범용 유틸"(per-instance 상태 저장소, 생명 바인드 유틸)은 base가 직접 구현하는 게 아니라 인터페이스만 정의inst는 base 입장에선 any일 수 있음(다른 엔진일 수도 있으므로). 실제 구현은 RobloxFactory(BaseModule) 같은 팩토리 함수가 BaseModule을 뮤테이션해서 그 안에 실 구현체(canExecute 등)를 채워넣는 방식 — 사용자는 quad-base/quad-roblox를 각각 import해서 const quad = RobloxFactory(QuadBase) 세 줄 정도로 직접 조립하면 됨(별도 번들 quad 패키지로 재수출할 필요 없음, 필요하면 만들어도 됨).

열린 질문: RobloxFactory를 같은 BaseModule에 여러 번 호출하면 어떻게 되어야 하는가 — 이미 초기화됐으면 무시(rbvm의 InitNamespace류 가드와 유사하되, "라이브러리마다 수동 init" 패턴과는 다름)하는 쪽으로 기울어짐. 서로 다른 두 곳에서 같은 base를 require해서 RobloxFactoryAnotherFactory(가상의 예)를 각각 실행하는 경우처럼 충돌 가능성이 있는 시나리오가 향후 모듈 스코핑(New(), base/architecture.md 13번) 논의를 다시 촉발할 수 있음 — 지금은 열어만 둠.

인스턴스 생성 / 이벤트 네이밍 인체공학 (2026-08-04 검증 라운드에서 새로 나온 열린 질문)

Quad "Frame"처럼 문자열로 인스턴스 종류를 지정하는 방식은 타입 추론이 어려움(위 온톨로지 절의 Luau 오버로드 문제와 같은 원인). PA님 DI 스타일은 DI.Frame/DI.TextLabel처럼 필드 접근으로 만들어서 자동완성이 자연스럽게 됨 — 목록에 없는 타입은 DI.New<<Frame>> "Frame"류로 폴백. 이벤트도 Event "" 대신 On...류 이름으로 필드 접근하면 타입/자동완성이 쉬워질 수 있음. 아직 방향 결정 안 됨 — 다음 세션에서 다룰 것.

남은 열린 질문 (.claude/question.md에도 취합)

  • :Computewith한 값을 정확히 어떻게 읽는가 — 클로저로 직접 캡처하는 방향은 확정(위 온톨로지 절), 캐싱/무효화 전략(dirty-flag 등)과 emit 필요 여부는 아직 미정.
  • CreatedRef(가칭)의 정확한 함수/옵션 이름 — children 배열에 아이템으로 넣는다는 방향과 생성/마운트 두 시점 모두 지원한다는 것은 확정, 정확한 API 이름만 남음.
  • process() 호출마다 우선순위 스캔 비용 — 실제 구현/벤치마크 단계에서 확인 필요(디자인 자체는 확정됐으므로 더 이상 사용자 확인 대상 아님, 구현 검증 대상).
  • Store가 Store를 담는 경우의 실제 소유권(누가 내부 Store를 destroy하는가) — 이 문서의 "재실행 래핑" 제안이 맞다면 자연히 바깥 Store bind가 내부 Store의 라이프타임도 감싸게 될 텐데, 이중 해제(double-dispose) 방지가 필요한지 확인. 단, base/lifecycle-pattern.md의 "destroy 시점엔 아무것도 안 함" 원칙상 이중 해제 자체가 걱정할 필요 없는 개념일 수도 있음 — 재검토 필요.
  • RobloxFactory 중복 호출/충돌 시나리오, 인스턴스 생성·이벤트 네이밍 인체공학 — 위 두 절 참고, 둘 다 새로 열린 질문.