quad/.claude/base/ref-plan.md
qwreey 8aeec7644f
docs: CLAUDE.md 4분할(매 세션 로드 1537→354줄), 워크플로 가짜 초록불 수정
## CLAUDE.md 분할
1537줄이라 (a) 사람이 검토 불가, (b) 공식 권장치(파일당 200줄) 7.7배
초과로 지침 준수도 자체가 저하, (c) 긴 파일 편집 시 에이전트 실수 증가.

  CLAUDE.md (39줄, 진입점)
  ├─ @.claude/conventions.md      언어/모델 관례 + 작업 방식
  ├─ @.claude/project-context.md  프로젝트 설명 + 문서 구조
  └─ @.claude/todos.md            지금 할 일
     .claude/session-summary.md   ← import 안 함(의도적, 온디맨드)

세션 히스토리 1231줄(전체의 80%)은 그 문서 스스로 "항상 읽을 필요 없음,
base/가 소스"라고 명시해온 색인이라 @import에서 뺐음. 내용 유실 없음
(1537→1647줄, 추가 헤더만큼 증가).

주의: @import는 컨텍스트를 줄이지 않음(전부 로드됨). 분할이 사는 건
사람 검토성 + 편집 정확도 + 파일 단위 자동생성 가능성.
CLAUDE.md 계열의 블록 HTML 주석은 주입 전 제거되므로 지시는 본문에 쓸 것.

## 워크플로 가짜 초록불 수정
첫 실측에서 감사 에이전트 6개 전원 실패했는데 converged:true가 나왔음
(전멸하면 fresh가 비어 "깨끗한 라운드"와 구분 불가). 감사 도구 최악의
실패 모드라 (1) 전멸이면 throw, (2) 반영 에이전트 실패 시 그 발견을
seen에서 빼 다음 라운드가 재시도하도록 수정.

## 부수
- 분할로 깨진 상호참조 20여 곳 정정(병렬 에이전트 3개).
  ref-plan.md:541의 사전 존재 오류(→ pre-implementation-audit 1-5)도 정정.
- doc-check.py: 새 파일 4개를 OURS에 등록(안 하면 깨진 참조가 WARN으로만
  잡힘), is_history()로 session-summary.md를 archive/와 같이 면제.
- doc-include-plan.md: 목적지가 통째로 생성되는 파일이 되면서 양방향
  마커 설계의 절반(목적지 마커)이 불필요해져 단방향 생성으로 단순화.

doc-check.py ERROR 0 유지.

미해결: session-summary.md 자동생성 미착수(91개 세션 파일 마커 삽입 필요),
orphan 인용 3건(modifier-plan.md:536, v1-compat-plan.md:50,
pre-implementation-audit.md:434 — 분할 이전부터 존재).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HvEKn9f67kkx2nGLG8PsP
2026-08-16 01:35:02 +09:00

62 KiB

Ref / PreRef / PostRef — 지연 없는 확정 값 박스

[2026-08-14 아홉 번째 세션] PostRef 확정·이 문서에 편입. base/lifecycle-hooks-plan.md(당시 research/)가 백로그 후보로만 들고 있던 스케치를 사용자가 확정("Pre-Post 둘을 지원 안 할 이유가 없고 구현 난이도가 아주 낮음") — 아래 "PostRef" 절 신설, 그 문서도 base/lifecycle-hooks-plan.md로 같이 승격됨. 같은 계열 안의 fire 순서(복수 PreRef끼리, 복수 PostRef끼리)는 배열 index 순서 그대로 보장 — 같은 세션에 이걸 잠깐 "미보장"으로 뒤집었다가 곧바로 철회했음, 그 왕복의 기록은 archive/preref-order-unguaranteed-withdrawn.md.

[2026-08-13 아홉 번째 세션] bind-system-plan.md에서 분리됨. 그 문서가 2989줄까지 불어나 사람이 검토하기 어렵고 한 곳의 실수가 미치는 범위가 너무 커진다는 사용자 지적에 따른 1단계 분할. 내용은 옮기기만 했고 결정은 하나도 안 바뀜.

상태: base — 확정. Dispatch/Brand와의 관계는 base/dispatch-core-plan.md(디스패치 코어)와 base/brand-plan.md 참고.

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

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

  • v1의 Frame "id" {} + Store.GetObject(id) 식 id 매핑은 폐기 확정 (base/architecture.md 5번 항목) — "비현실적"이라는 게 이유.
  • 하지만 "라이브러리가 자기 자신이 만들어낸 instance를 나중에 다루기 편하게" 하는 용도로 Ref는 여전히 필요. 구체 시나리오: 기존에 다른 라이브러리로 관리되던 instance를 당장 quad로 옮기지 않고, ref를 따서 그 안에 자식을 Parent로 마운트한다든가, 점진적으로 마이그레이션한다든가, 래퍼를 만든다든가 하는 다양한 용도.
  • Store는 이미 바깥에서 옵저빙 가능한 존재라 별도 취급 불필요 — Ref는 그와 달리 "원하는 객체 자체를 직접 얻어오는" 경로. 얻어진 뒤에 그 참조를 어디에 저장하고 어떻게 쓰는지는 라이브러리 책임 범위 밖(사용자 자유). 권장 관례(2026-08-12, use-after-destroy 검토에서 명문화): Ref는 이를 만든 컴포넌트 자신이 쓰거나 자식에게 넘겨 쓰는 용도가 관례 — React useRef와 같은 스코프 감각. 컴포넌트 경계를 넘어 위로 반출하거나 전역에 장기 보관하는 건 권장하지 않음 — Ref는 Destroy와 완전히 무관하게 동작하므로(아래 "Destroy와는 무관" 절), 관례를 벗어난 반출·장기보관은 use-after-destroy가 발생할 수 있는 사실상 유일한 자리가 됨. quad는 이 케이스에 런타임 안전망을 두지 않기로 확정 (research/framework-comparison-findings.md 3번 절 근거) — 대응은 이 관례를 지키는 것뿐, 위반 시 결과는 완전한 UB.
  • 바인드 방법: children을 배열 아이템으로 넣듯 Ref(default)(또는 :Callback(fn)을 미리 걸어둔 Ref(default):Callback(fn)) 인스턴스 자체를 숫자 키 슬롯에 그대로 넣는 방식 — (v=Ref) 매치 핸들러가 이걸 처리함. 별도 CreatedRef 래퍼 함수는 없음(2026-08-07 아홉 번째 세션, 사용자 확인) — Ref/PreRef가 이미 Compose식 Type(default) 팩토리 생성자로 확정됐으므로("생성자 스타일 확정" 절), 그 결과를 그대로 children 배열에 놓는 것 자체가 바인드 관용구. 원래 "CreatedRef 같은 이름 미정의 래퍼"로 서술했던 것은 Ref가 아직 "instance 얻는 통로"로 좁게 정의됐던 시절(2026-08-04)의 잔재였고, 2026-08-06 Ref 일반화 이후 래퍼 자체가 불필요해졌는데 이름만 남아있던 것을 이번에 정리함. [정정, 2026-08-07 세 번째 세션] 정확한 순서 보장(자식 마운트 전/후, 프로퍼티보다 먼저)은 위치와 PreRef 타입으로 갈렸음 — 아래 "phase 옵션 폐기 → 위치로 표현, PreRef 신설" 절이 최신, 원래 있던 "옵션({phase=...})으로 두 타이밍을 고른다"/"특수 처리 없는 평범한 참가자" 서술은 archive/ref-phase-option-reversed.md로 옮김.
  • 왜 값이 아니라 콜백인가: quad는 React처럼 렌더 함수가 계속 재실행되지 않음(플레인 함수를 한 번 호출해 트리를 만들고 끝) — 그래서 "채워졌는지 매 렌더마다 다시 확인"하는 모델 자체가 없고, useEffect 의존성 배열 같은 것도 없음. 즉 값이 채워지는 시점을 외부에서 알아낼 방법이 콜백(또는 폴링, 채택 안 함 — lifecycle-pattern.md에서 폴링 방식은 이미 기각된 패턴) 말고 없음. 값 자체를 나중에 다루고 싶으면 콜백 안에서 원하는 곳(외부 변수, self._button 같은 필드, Store 등)에 직접 대입해 캡쳐하면 됨 — component-composition-plan.md 31행 예제 참고. 즉 "값으로도 얻어진다"는 요구는 별도 API가 아니라 콜백이 이미 충족함.

Ref 일반화 — 엔진 instance 전용이 아니라 범용 값 박스 (2026-08-06 후속 세션)

결정: Ref는 "quad가 만든 instance를 얻는 통로"로 좁게 남지 않고, **아무 사용자 값이나 담을 수 있는 범용 "채워지길 기다리는 값 박스"**로 확장한다. 위 "코루틴 기반 대기 지원 여부는 미정"이었던 항목은 이걸로 해소됨(더 이상 열린 질문 아님).

  • object-ref/function-ref로 나누지 않음. React의 useRef가 DOM 노드든 임의의 사용자 값이든(함수 포함, ref.current?.()로 호출하는 imperative-handle 패턴 포함) 같은 API로 다루는 것과 동일한 선례 — 두 개념으로 쪼개면 사용자가 "이번엔 어느 쪽을 써야 하나" 매번 판단해야 해서 나쁨. 엔진 instance도 그냥 "사용자 값의 한 종류"일 뿐.
  • 구체 유스케이스: 자식 컴포넌트가 비싸고 온디맨드로만 필요한 계산 (예: 클릭 위치 기준 컨텍스트 메뉴를 그리기 위한 clip bounds 계산)을 부모에 노출하고 싶을 때, 매 변경마다 push하는 대신 부모가 필요할 때만 ref.Value?.()처럼 호출하는 함수를 Ref에 담아 넘기는 패턴 — React의 imperative handle과 동일한 이유(비싼 연산이라 온디맨드가 맞음, 값이 최신인지 아닌지도 애매해짐).
  • API 모양: .Value(읽기 전용 필드) + :Set(value) -> Ref<T>(쓰기) + :Callback(fn) -> Ref<T>(콜백 등록, 복수 허용) + :Wait(thread?) -> Ref<T> (coroutine 컨텍스트에서 사용 — 렌더 함수 바디 안에서 return 위에 바로 못 씀, 그래서 콜백도 같이 필요) 세 메소드로 확정(2026-08-07 여섯 번째 세션). :Set/:Callback/:Wait 전부 mutation 패턴이라 자기 자신 (Ref<T>)을 반환store.key:Set(value)류 "값을 바꾸는 연산엔 : 체이닝 허용" 원칙(base/store-plan.md의 "Store 값 설정 문법" 절)의 자연스러운 재적용. 이 self-반환 덕에 Luau의 if-표현식과 결합해 흔한 관용구를 한 줄로 쓸 수 있음(사용자 제시 예):
    local t = if ref.Value
      then ref.Value
      else ref:Wait().Value
    
    • 콜백은 이미 채워져 있으면 등록 즉시 그 값으로 1회 호출됨 — nil/미설정 상태여도 그 상태 그대로 호출. React의 useEffect가 매번 .current 존재 여부부터 체크하는 것과 같은 이유, Ref가 자식으로 전달되는 경우 채워지는 시점이 더 늦어질 수 있어서 "이미 채워졌는지" 확인이 항상 필요함. :Wait()의 대기자 리스트와 콜백 리스트는 같은 구조 재사용 가능(발화 후 해당 인덱스만 nil로 소진 — 아래 구현 디테일 참고, [재정정, 2026-08-09 열한 번째 세션] None이 아니라 nil이 맞음, 바로 아래 캐비엇 참고).
    • .Value는 이 테이블의 평범한 hash 필드로 직접 저장하지 않고 __index 메타메소드로 구현함(2026-08-09 열한 번째 세션 보강) — Ref 객체 자신이 곧 콜백/대기자 배열(숫자 키로 색인)이라, .Valueself.Value = v로 그냥 얹으면 그 값 자체가 이 테이블의 hash 파트에 같이 걸림. T가 함수나 스레드 타입일 수 있는데(Ref는 범용 값 박스, 위 "object-ref/function-ref로 나누지 않음" 참고), for i, v in self do 같은 일반화 순회가 배열 파트뿐 아니라 hash 파트도 함께 훑으므로 이 경우 .Value가 콜백/대기자 처리 루프에 잘못 걸려 type(v)로 오분류될 위험이 생김. __index로 실제 저장 위치를 배열과 분리해두면 이 충돌 자체가 안 생김.
    • :Wait(thread?)thread 인자(2026-08-07 여섯 번째 세션, 사용자 제안, 확정): 생략(nil)하면 coroutine.running()으로 호출 중인 코루틴 자신을 캡처해 대기자로 등록하고 그 자리에서 coroutine.yield()자기 자신을 정지시킴(값이 채워지면 재개). 명시적으로 다른 thread를 넘기면 그 thread를 대기자로 등록만 하고 정지 없이 즉시 self를 반환 — 코루틴 역학상 coroutine.yield()는 지금 실행 중인 코루틴만 정지시킬 수 있고, 어딘가 이미 정지해 있는 남의 thread를 여기서 대신 정지시킬 수는 없기 때문(그 thread는 이미 정지 상태). 이 표면의 용도: 사용자가 직접 관리하는 스케줄러가 이미 만들어 둔(어딘가 다른 지점에서 정지시킨) thread 하나를 Ref에 등록해두고, 등록한 코드 자신은 블록되지 않고 계속 진행하고 싶은 경우. 구현은 정말 단순함 — threadnil이면 yield, 있으면 yield 안 함.
    • 구현 디테일(2026-08-07 세 번째 세션 제안, 여섯 번째 세션에서 resume payload 정정, 열한 번째 세션에서 소진 방식 최종 확정): 값이 새로 :Set()될 때, 같은 배열 하나를 for i, v in <배열> do ... end로 한 번만 순회하면서 type(v) == "thread":Wait()가 만든 대기자로 보고 coroutine.resume(v, self) (즉 값이 아니라 Ref 자기 자신을 resume 인자로 넘김 — 위 self-반환 관용구가 :Wait()의 yield 경로에서도 그대로 성립하게 하기 위해, coroutine.yield()의 리턴값이 곧 self가 되도록 정정. 세 번째 세션 원안은 value를 넘기는 것으로 적혀 있었으나 이러면 ref:Wait().Value가 안 풀려서 정정) 후 **[i] = nil**로 소진(아래 "왜 None이 아니라 nil인가" 참고), 아니면 일반 콜백 함수로 보고 그냥 v(value)(콜백은 여전히 원래 값을 직접 받음, 소진 안 함, 계속 유지)로 분기하면 됨 — 대기자/콜백을 서로 다른 배열로 나눌 필요 없이 값 타입 하나로 분기 가능 (type(v) == "thread" → 대기자, type(v) == "function" → 콜백, nil → 빈 슬롯이라 스킵). 새 콜백/대기자 등록은 table.insert가 아니라 비어있는(=nil인) 첫 슬롯을 선형 탐색해 재사용하는 등록 함수로 함(아래 참고) — 소진된 슬롯이 실제로 비므로 등록이 그 자리를 되찾아 쓸 수 있음.
    • None이 아니라 nil인가(2026-08-09 열한 번째 세션, 최종 정정) — 2026-08-07 열 번째 세션에 None으로 바꿨던 것은 이 배열에는 안 맞는 처방이었음, 되돌림. None을 도입한 원래 근거(구멍 있는 정수 키가 해시 파트로 튀어 순회 순서가 깨짐, table.insert#t가 구멍 있는 테이블에서 미정의 동작)는 순서가 실제로 중요한 배열 (PreRef pre-pass, Length/Offset의 sourceList1..N 고정 범위로 도는 for 루프라 구멍이 있으면 안 됨)에는 맞는 처방이지만, Ref의 콜백/대기자 배열은 애초에 순서가 중요하지 않다(어느 게 먼저 fire되든 전부 fire되기만 하면 됨) — 일반화 for i,v in tbl do는 구멍이 있어도 순서가 뒤섞여도 모든 엔트리를 빠짐없이 방문하므로 "순서 보장이 깨진다"는 문제 자체가 이 배열엔 없음. 오히려 None을 쓰면 소진된 슬롯이 영원히 non-nil로 채워진 채 남아 :Wait() 호출마다 배열이 끝없이 길어지는 새 문제가 생김(등록이 항상 끝에만 추가되고 예전 슬롯을 재사용 못 함) — nil로 지우면 다음 등록이 그 빈 슬롯을 재사용할 수 있어 배열 크기가 동시 대기자 수만큼만 유지됨. table.insert#t 문제도 table.insert를 아예 안 쓰고 빈 슬롯을 선형 탐색해 넣는 등록 함수로 우회하면 됨(None이 필요했던 이유 자체가 없어짐). 결론: 순서가 안 중요하고 슬롯 재사용이 필요한 배열(Ref 콜백/대기자)은 nil 소진, 순서가 중요한 배열 (PreRef pre-pass 소진 슬롯, Length/Offset sourceList)은 계속 None — 두 패턴이 서로 다른 문제를 풀고 있었을 뿐, 하나로 통일할 이유가 없었음.
    • 주의(문서화 대상, 방어 로직 없음): 이미 죽은(완료/에러난) thread를 :Wait(thread)에 넘기면 나중에 coroutine.resume이 에러남 — 이건 다른 UB 케이스들과 같은 결로 라이브러리가 방어하지 않고 호출부 책임으로 둠.
  • 제네릭 시그니처(2026-08-07 확정): Ref<T>(T) -> Ref<T> — 단일 타입 파라미터. React useRef<T, U=T>(U): T|U류 "초기값 타입과 최종 타입을 분리"하는 2파라미터 설계도 검토했으나(예: Ref<<HTMLDivElement>>(null)HTMLDivElement|null), Luau 솔버로는 명시된 타입 파라미터 하나와 인자에서 추론되는 다른 타입 파라미터가 만드는 합집합이 깔끔하게 풀리지 않고 미해소 제네릭 변수가 결과 타입에 남는 것으로 확인(사용자가 직접 Luau 플레이그라운드류로 확인) — Source<T> satisfies State<T>State<Modifier> 차단 검증 항목(research/pre-implementation-audit.md) 에서 이미 반복 확인된 "Luau 제네릭 솔버는 복잡한 조합에서 잘 안 풀린다"는 패턴과 같은 결. 단일 파라미터로 단순화하면 이 위험 자체가 없음 — 대신 초기값만으로 좁은 타입이 추론되는 문제(Ref(nil)Ref<nil>로 좁혀짐)는 Ref<<Obj?>>(nil)처럼 명시적 제네릭 적용(f<<T>>(...) 패턴, .claude/initreq/tbox/CLAUDE.md:40-41 선례)으로 타입을 넓혀 풀면 됨 — React useRef<HTMLDivElement>(null)도 명시적 타입 인자 없이는 같은 문제를 겪으므로 이미 널리 받아들여진 UX, quad가 새로 감수하는 트레이드오프 아님.
  • children 배열에 넣으면 dispatch가 자동으로 채워주는 것과의 관계: 이 절의 Ref가 그 범용 프리미티브 자체 — 위 "바인드 방법" 절대로 Ref 인스턴스를 children 배열 숫자 슬롯에 그대로 놓으면 됨(quad가 만든 instance에 한정된 경우). 정확한 타이밍 보장은 옵션 값이 아니라 위치 기반 + PreRef 타입으로 표현됨 — 아래 "phase 옵션 폐기 → 위치로 표현, PreRef 신설" 절이 최신.
  • 해소됨 — 반복 재설정 가능(one-shot 아님), 사용자 확정. React에서도 자식이 재생성되는 경우 같은 방식(ref가 다시 채워짐)을 씀 — 예: 마우스 호버/무브 시 current 확인 후 라벨 위치를 결정하는 라벨 컨테이너 하나를 두고 라벨 내용만 스왑해가며 Ref를 재사용하는 패턴. 이런 고급 패턴은 조심할 게 많지만 그건 라이브러리가 아니라 사용자가 신경 쓸 몫. 따라서 콜백은 "발화 후 소진"이 아니라 매 :Set()마다 다시 불림 — 소진되는 건 :Wait()가 만드는 개별 대기자(coroutine 재개는 본질적으로 1회성)뿐, 콜백 리스트 자체는 유지됨.
  • ⚠️ Ref는 의도적으로 lazy가 아니고 :Compute 파생을 지원하지 않음 — State와의 이 차이가 중요함. (예전엔 Store가 Ref와 비슷한 것도 겸해서 지원한 적이 있었는데, State의 lazy 재계산 모델과 Ref의 즉시 get/set 모델이 섞여서 좋지 않았음 — 그 경험에서 나온 의도적 분리.) Ref는 그냥 "지금 뭐가 들어있나/누가 채워주길 기다리나"만 다루는 즉시 값 박스이고, 파생값이 필요하면 Store/State(:With+:Compute)를 쓸 것 — 둘을 섞으려 하지 말 것.
  • [해소됨, 2026-08-08 다섯 번째 세션] 위 정의 확장을 감안해도 Ref 이름은 그대로 확정 — "지연 없는 확정된 값 박스"라는 정의가 leaf로 담기는 용도/leaf에 바인딩하는 용도 둘 다에 여전히 맞아 더 나은 대안이 없다는 결론, 용어 정리 대상에서 제외됨.

Ref의 retract — State<Ref> 재바인드 시 이전 Ref에 nil (2026-08-12 여덟 번째 세션, TagHandler와 같은 메커니즘 재사용)

[2026-08-13 열네 번째 세션] 하강 diff 재디스패치 반영 완료. "이전 클로저가 먼저 불려 언바인딩, 그 다음 process가 바인딩"이라는 두 단계 자체는 그대로이고, 그걸 일으키는 주체만 바뀌었음(래핑 핸들러의 선행 retractFromDispatch.process의 핸들러 선비교). 클로저가 받는 값의 타입도 이제 계약으로 보장됨(항상 Ref이거나 nil). 상세는 base/dispatch-core-plan.md "Dispatch 체인" 절, 옛 모델 원문은 archive/dispatch-hintvalue-model-reversed.md.

배경: Ref는 이미 "일반 프로퍼티/Modifier 필드/Store 값 어디든 자유롭게 들어감"(아래 "동적 경로 가드" 절)이 확정돼 있어 — State<Ref>가 실제로 가능하고, 그러면 Store 값이 refA에서 refB로 바뀌는 경우가 생김. 이때 refA가 계속 "확정된 값(대개 이전 inst)"을 들고 있으면, 그 자리가 이제 refB로 넘어갔다는 걸 모르는 코드가 refA.Value를 계속 유효하다고 믿는 조용한 버그가 남음 — PreRef 재사용 버그(위 절)와 같은 클래스의 문제.

메커니즘 — retractor가 매번 불린다는 전제 위에서 언바인딩 전담 (2026-08-12 열한 번째 세션 정정, 2026-08-13 다섯 번째/열네 번째 세션에 서술 갱신). 이전 process가 반환한 클로저는 store 값이 바뀔 때마다(핸들러 타입이 그대로여도) 무조건 불림 — 같은 핸들러면 Dispatch.process가 그 자리 클로저에 새 값을 넘기고 곧바로 process를 다시 부르기 때문(base/dispatch-core-plan.md "Dispatch 체인" 절 (A) 분기). 그래서 refA→refB 전환은 이전 클로저가 nextValue=refB로 먼저 불려 refA를 언바인딩하고, 그 다음 process(inst,k,refB,index)refB를 바인딩하는 두 단계로 자연히 갈림 — process가 old-vs-new diff를 따로 계산할 필요가 없어짐(그 일을 클로저가 매번 정확히 대신 해줌). process 쪽엔 여전히 Relate가 필요 — "spurious하게 같은 Ref가 재발행되면 재통지 skip"이라는 dedup은 process가 "이전에 뭐가 있었는지"를 알아야 하는데, 그건 인자로 안 들어오고(클로저가 받는 건 다음 값이지 이전 값이 아님) 오직 여러 호출을 가로지르는 저장소로만 알 수 있음 (base/dispatch-core-plan.md "핸들러 내부 상태 저장" 절이 이런 경우엔 Relate가 여전히 맞다고 한 그 사례):

local relate = Relate()  -- Ref-leaf handler 전용, (inst,k)별 마지막으로 바인딩한 Ref 기억 —
                          -- process의 spurious 재바인딩 dedup 전용(클로저 캡처로는 대체 불가)

RefLeafHandler.isHandlable(inst, k, v) = isRef(v) and not isPreRef(v) and not isPostRef(v)
    -- [2026-08-14 열두 번째 세션 정정] PostRef 도입(아홉 번째 세션) 당시 이 자리가
    -- 안 갱신돼 있었음 — 아래 "타입/판별" 절의 최종 공식과 일치시킴

function RefLeafHandler.process(inst, k, v, index)
    local old = relate:GetStrong(inst, k)
    if old ~= v then  -- 이미 같은 Ref가 이 자리를 차지 중이면 재통지 skip
        bindLifetime(inst, v)  -- v가 이미 다른 자리에 살아있으면 여기서 즉시 error —
                                -- 이중 배치 방지("이중 배치 방지" 절 참고), 별도 Relate 불필요
        v:Set(inst)
    end
    relate:SetStrong(inst, k, v)
    return function(nextValue)
        -- nextValue는 nil이거나 같은 핸들러가 곧 처리할 새 Ref(타입 보장됨) — v는
        -- 이 process 호출이 만든 클로저가 직접 캡처(Relate 재조회 불필요)
        if nextValue ~= v then
            unbindLifetime(v)  -- 점유 해제 — 이후 v는 다른 자리에 다시 bindLifetime 가능
            v:Set(nil)  -- 매 :Set()마다 콜백 재통지되는 기존 Ref 규칙(위 "해소됨 —
                        -- 반복 재설정 가능" 항목)을 그대로 재사용, 새 알림 경로 아님
            -- [정정, 2026-08-13 감사] relate 정리는 반드시 이 분기 *안*에 있어야
            -- 함 — 밖에 두면 spurious 재발행(nextValue == v)에서도 기록이
            -- 지워져, 곧바로 이어지는 process가 `old ~= v`를 항상 참으로 보고
            -- `v:Set(inst)`를 재실행함(콜백 헛 재통지). 즉 아래 dedup 항목이
            -- 약속한 "spurious면 둘 다 스킵"이 성립을 안 했음.
            if relate:GetStrong(inst, k) == v then relate:SetStrong(inst, k, nil) end
        end
    end
end
  • retractor가 언바인딩 전담, process는 바인딩 전담 — 겹치는 diff 로직이 없음. nextValue == v(같은 Ref 객체가 스스로 재발행된 spurious한 경우)만 둘 다 스킵해 콜백이 nilinst로 헛되이 두 번 안 불리게 함.
  • children 배열 리터럴 Ref도 같은 코드 경로를 그대로 씀 — 그 경우 이전 클로저가 (StoreBind 경로가 아니라 이 리터럴 구성 자체가 처음이므로) 아예 없고 relate:GetStrong(inst,k)nil이라 process가 바로 v:Set(inst)로 끝남. "1회성 리터럴 구성"과 "반복 재바인드"가 하나의 구현으로 자연히 커버됨, 케이스 분기 불필요.
  • 타입: 비-nilable T도 정당한 용도(사용자 확인, 2026-08-12 여덟 번째 세션)Ref는 "채워지길 기다리는 박스"뿐 아니라 "이미 확정된 값을 여기저기서 부작용 없이 읽는" 용도로도 쓰일 수 있어 Ref<T>(T가 non-nilable)를 계속 지원할 이유가 있음. 위 언바인딩(old:Set(nil))이 실제로 발생하는 자리는 Store/Modifier 필드에 놓여 재바인드/retract가 가능한 Ref이므로, 그 자리에 놓을 Ref호출자가 직접 Ref<<T?>>(...)로 명시할 것 — 이미 있는 "초기값이 nil이면 명시적 제네릭 적용으로 타입을 넓힌다"는 관용구(위 "제네릭 시그니처" 절)를 그대로 재사용하는 것뿐, 새 타입 규칙 추가 아님. 프레임워크가 자동으로 감지해 넓혀주지 않음 — non-nilable T로 선언해놓고 Store/Modifier 자리에 놓으면 런타임에 .Value가 타입과 어긋나게 될 수 있는 caller 책임의 UB(Luau 타입은 런타임에 지워짐, 다른 UB 케이스들과 같은 결).
  • Destroy와는 무관 — 별도 처리 없음(사용자 확정). Ref의 언바인딩은 오직 위 재바인드/retract 경로에서만 일어나고, 대상 Instance가 Destroy()되는 것과는 별개 — Ref 자신은 Destroy를 감지하지도, 반응하지도 않음. Ref<Frame?>가 이미 Destroy된 Frame을 계속 들고 있는 채로 남는 건 정상적으로 가능하고, 그 이후 읽고 쓰는 건 그냥 UB(라이브러리가 방어 안 함 — :Wait(thread)에 이미 죽은 thread를 넘기는 기존 UB와 같은 결). Destroy 시점에 실제로 정리가 필요하면 Effect(내부적으로 bindLifetime/ Observer 위에서 동작, 또는 Roblox가 Destroy 시 알아서 Disconnect해주는 이벤트 안에 로직을 두는 기존 관례)를 쓰도록 문서가 유도할 것 — Ref 자신에 Destroy-awareness를 얹는 건 오버엔지니어링.

이중 배치 방지 — question.md 0-W 해소, (a) 선택 (2026-08-14 열한 번째 세션)

같은 Ref 객체를 두 자리에 동시에 놓으면 뒤에 놓은 자리가 앞 자리의 바인딩을 조용히 지우는 문제(Frame1{r}/Frame2{r}처럼 같은 r을 두 Frame의 children 배열에 각각 리터럴로 놓으면 — Ref는 항상 children 배열 아이템으로 놓이므로 k는 문자열 "Ref"가 아니라 그 자리의 배열 인덱스(숫자) — r:Set(inst1) 다음 r:Set(inst2)가 에러 없이 덮어씀, inst1 자리가 나중에 retract되면 r:Set(nil)inst2의 정당한 값까지 지움)를 즉시 error로 막기로 확정SlotclaimOwner, PreRef/PostRef_fired, Attribute의 이름 claim과 같은 급의 방어를 Ref에도 채택.

메커니즘 — 새 Relate를 안 만들고 bindLifetime/unbindLifetime을 그대로 재사용. bindLifetime(inst, value)은 이미 자기 내부에 "이 value가 이미 다른 곳에 살아있는 바인딩을 갖고 있으면 즉시 error"라는 가드를 갖고 있음(base/lifecycle-pattern.mdcanBound 게이트) — Ref가 바인딩될 때마다 이 가드를 그대로 통과시키면 이중 배치가 저절로 막힘. 위 RefLeafHandler.processbindLifetime(inst, v)/unbindLifetime(v) 호출이 그것 — 실제 바인딩이 일어나는 분기(old ~= v)에서만 걸어서 spurious 재발행(같은 v가 다시 오는 경우)엔 안 걸림, 실제 언바인딩이 일어나는 분기(nextValue ~= v)에서만 풀어서 그 뒤 다른 자리에 재바인딩 가능.

기존 dedup용 relate와는 별개 관심사relate는 "이 슬롯에 마지막으로 뭐가 있었는지"(spurious 재발행 dedup)를 기억하고, bindLifetime은 "이 Ref 객체가 지금 어딘가에 살아있게 물려 있는지"(이중 배치 방지)를 판정함. 서로 다른 축이라 하나가 다른 하나를 대체 못 함 — 계속 둘 다 필요.

children 배열 리터럴 경로도 같은 코드를 그대로 타므로 자동으로 커버됨 (Frame1{r}/Frame2{r}가 원래 문제였던 그 케이스) — 리터럴 구성은 old가 항상 nil이라 매번 bindLifetime이 불리고, 두 번째 자리에서 r이 이미 살아있는 바인딩을 갖고 있으니 그 즉시 에러.

question.md의 원 형제 프리미티브 대조 표(Ref 행 "없음")는 해소로 갱신, 상세는 archive/question-resolved.md.

phase 옵션 폐기 → 위치로 표현, PreRef 신설 (2026-08-07 세 번째

세션 — 이 절이 당시 쓰던 CreatedRef(fn, ...) 래퍼 이름 자체도 이후 아홉 번째 세션에서 폐기됨, 위 "바인드 방법" 절 참고)

children 배열에 놓는 Ref에 {phase="created"|"mounted"} 옵션으로 두 타이밍을 고르게 하던 것 자체를 없앤다. base/dispatch-core-plan.md의 "확정된 디스패치 모델" 절에 새로 추가된 두 패스 보장(배열 파트는 index 순서대로, 그 다음 해시 파트) 덕분에, 같은 인스턴스 안에서 일반 Ref 다른 children보다 앞/뒤 어디에 놓느냐가 이미 "그 형제가 마운트되기 전/후"를 그대로 결정함 — 각 자식은 자기 서브트리까지 전부 동기적으로 마운트를 끝내야 다음 형제로 넘어가므로, "마지막에 놓기"만으로 "모든 자식 마운트 후" 의미가 공짜로 나옴. 별도 옵션 문법을 유지할 이유가 없어짐. (아래 PreRef는 이 위치-의존 규칙의 예외 — 위치 영향을 아예 안 받고 호이스팅됨, 해당 절 참고.)

단, "프로퍼티/이벤트 세팅보다도 먼저"는 위치만으론 못 푼다. 배열 파트가 해시 파트보다 항상 먼저 처리된다는 보장은 그 인스턴스의 최초 props 테이블에 리터럴로 존재하는 항목에 한정됨 — Modifier를 거쳐 flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store를 거쳐 나중에 도착하는 값은 애초에 이 최초 스캔 자체를 벗어난 시점(process/retract 재귀 경로)에 도착하므로 이 보장 밖. 그런데 "프로퍼티보다 먼저 채워져야 한다"가 실제로 필요한 이유가 있음 — quad-roblox 이벤트는 self(Instance)를 안 주기로 확정했으니(아래 절) self 접근은 Ref로 해야 하는데, Roblox 이벤트 중 일부(ChildAdded/DescendantAdded/Changed류)는 유저 인터랙션을 기다리지 않고 setup 도중 프로퍼티 대입/Parent 세팅 자체의 부작용으로 동기적으로 발화할 수 있음 — 이때 이벤트 핸들러가 아직 안 채워진 self-ref를 읽으면 터짐.

해결: 이 케이스만 별도 타입 PreRef로 분리.

  • 구현은 Ref 그대로 재사용(같은 .Value/:Set()/:Callback()/ :Wait() API) — 브랜드 태그만 다른 nominal 타입. 런타임 코드 중복 없음. 소스 파일은 분리(2026-08-07 여섯 번째 세션): Ref는 이제 그 자체로 충분히 완결된 프리미티브고 PreRef도 "children 배열 전용, 위치 무관 호이스팅"이라는 특이한 제약을 가진 별개 프리미티브라, 기존 프리미티브당 1파일 컨벤션(modifier-plan.md/slot-plan.md류, Blocker/Effect를 같은 이유로 분리한 2026-08-07 네 번째 세션과 같은 판단)을 따라 Ref.luau/PreRef.luau 두 파일로 쪼갬 — 런타임 로직은 여전히 공유 (PreRef.luauRef.luau를 그대로 불러다 브랜드 태그만 얹음), 파일 분리는 순수 조직 문제라 위 재사용 결정과 상충 없음. base/architecture.md 소스트리에 반영 완료.
  • 오직 children 배열의 리터럴 아이템으로만 놓을 수 있다Modifier 필드 값으로도, Source/Store 값으로도 들어갈 수 없게 타입으로 차단.
    • Modifier 필드로 막는 이유: 거기 들어가면 flatten 후 해시 파트로 존재하게 돼 "배열 파트가 먼저"라는 보장 자체를 벗어남. 게다가 Modifier는 여러 인스턴스에 재사용되는 값인데 PreRef는 정의상 "이 인스턴스 하나의 construction 훅"이라 애초에 공유할 이유가 없음 — 허용해도 얻는 유스케이스가 없는 오버엔지니어링.
    • Source/Store 값으로 막는 이유: Store 값은 항상 process/retract 재귀 경로로 도착하는데, 그 경로는 정의상 최초 배열 스캔보다 나중(또는 아예 스캔 밖)이라 "프로퍼티보다 먼저"를 구조적으로 만족시킬 방법이 없음 — State<Modifier>를 막기로 한 것(modifier-plan.md 7번, 2026-08-09 세션부터 isModifier 기반 명시적 error)과 정확히 같은 원칙의 재적용.
  • PreRef는 배열 안 위치의 영향을 안 받는다 — 호이스팅. 일반 Ref와 달리, 같은 인스턴스의 배열 파트 안에서 다른 children/Ref보다 뒤에 적었어도 그것들보다 먼저 fire됨(자바스크립트 함수 선언 호이스팅과 같은 느낌으로 문서화). 이유: PreRef의 존재 목적 자체가 "이 인스턴스에 뭐가 됐든 일어나기 전에" 채워지는 것인데, 단순 위치 기반 순서만 따르면 그보다 앞선 형제(다른 child)가 먼저 마운트되면서 그 형제가 부모에 Parent될 때 부모의 ChildAdded류가 동기 발화할 수 있어 PreRef가 막으려는 문제가 그대로 재현됨. 그래서 base 드라이버는 위 두 패스(배열→해시) 루프를 돌기 전에 별도의 작은 pre-pass로 배열 파트를 훑어 PreRef 항목만 먼저 전부 fire하고, 그 다음 나머지(children/일반 Ref/프로퍼티/이벤트)를 평소처럼 두 패스로 처리하면 됨 — 이 pre-pass는 오직 PreRef 타입만 골라내므로 범위가 좁고, "확정된 디스패치 모델" 절의 두 패스 계약과 별개로 그 앞에 얹히는 것.
    • 복수 PreRef 간 순서(2026-08-07 아홉 번째 세션 확정, 2026-08-14 아홉 번째 세션 재확인) — 새 규칙 불필요, 배열 index 순서 그대로 보장. 같은 인스턴스에 PreRef가 여럿 있으면, 이 pre-pass는 위 "props 순회 순서" 절이 이미 확정해둔 "배열 파트는 index 순서대로" 계약을 그대로 재사용해 리터럴 순서대로 fire함 — 서로 다른 우선순위/ 순서 개념을 별도로 만들 필요 없음(호이스팅은 "PreRef 전체 대 나머지"에만 적용되는 규칙이지, "PreRef끼리"에는 적용될 게 없음 — PreRef끼리는 그냥 평범한 배열 순회). 아래 PostRef도 같은 규칙.
      • 왜 보장까지 하는가(2026-08-14 아홉 번째 세션, 한 번 미보장으로 뒤집었다가 철회) — 잠깐 "계열 안 순서에 의존하는 코드가 생겨선 안 된다"는 이유로 미보장으로 갔었으나 같은 세션에 되돌림. 결정적 반례는 사용자가 든 FastQuery(...) -> PreRef류 조합: 어떤 팩토리가 PreRef를 반환하며 뭔가를 미리 해결해두면, 배열에서 그 뒤에 오는 OnCreated가 "그게 이미 끝났다"를 전제로 동작할 수 있음 — 위치로 선후를 표현하는 이건 잘못된 구조가 아니라 이 배열이 원래 제공하는 정당한 합성 방식임(children 마운트 순서가 위치로 정해지는 것과 같은 성격). 비용도 0 — pre-pass가 어차피 index 순서로 훑고, base/dispatch-core-plan.md가 이미 배열 파트 index 순서를 백엔드 이식성 때문에 명시적 계약으로 못박아 뒀으므로 여기서 새로 약속하는 게 하나도 없음(=미래 구현 자유를 내주는 것도 없음). 상세한 양쪽 논거는 archive/preref-order-unguaranteed-withdrawn.md.
      • 다만 스타일 권고는 남김: 순서에 의존해야 할 정도로 두 훅이 얽혀 있으면 대개 하나의 훅 안에서 순서대로 부르는 게 의도가 더 잘 드러남 — 보장은 하되, 위 FastQuery처럼 "앞의 것이 뒤의 것의 전제를 만들어주는" 명시적 합성이 아니면 기대지 말 것.
    • 호이스팅의 실제 구현 = "물리적 재배치"가 아니라 "완전히 별도의 선행 스캔"(2026-08-07 아홉 번째 세션 후속, 사용자 질문에 답변). Dispatch.drive(inst, flattened)는 같은 flattened 배열을 두 번 순회한다([2026-08-14 아홉 번째 세션] 그 뒤에 postRefList 소비가 하나 더 붙지만, 그건 배열 재순회가 아니라 pre-pass가 만들어둔 짧은 목록 하나를 도는 것 — 아래 "PostRef" 절) — (1) pre-pass: 배열 파트 전체를 index 순서대로 훑으며 isPreRef(v)인 슬롯을 찾아 그 자리에서 fire하고 즉시 **flattened[i] = ProcessedPreRef**로 소진(nil이 아님, 2026-08-07 열 번째 세션 정정: nil로 지우면 그 순간 테이블이 "구멍 있는" 상태가 되어 이어지는 (2)의 순회 순서 보장 자체가 깨질 위험이 있음 — 정확히 이 pre-pass가 의존하는 바로 그 보장이라 치명적. [주의, 2026-08-09 열한 번째 세션] Ref 자신의 콜백/대기자 배열은 이 이유가 적용되지 않아 nil 소진으로 되돌아갔음(위 "Ref 일반화" 절 참고) — 여기 PreRef pre-pass는 순서 보장이 실제로 필요한 별개 케이스라 실재하는 센티널 소진이 계속 맞음, 두 사례를 혼동하지 말 것). [정정, 2026-08-14 두 번째 세션] 소진 값은 None이 아니라 전용 센티널 ProcessedPreRef(단일 {}, None과 같은 급의 유니크 키 — 사용자 제안). 옛 설계는 소진 값을 None으로 뭉뚱그려 "그 자리가 원래부터 빈 자리"였던 경우(props.Ref or None)와 "한때 PreRef였다가 방금 fire되어 소진된 자리"를 구별 못 했고, 그 결과 아래 "Length/Offset" 계약(base/dispatch-core-plan.md, 2026-08-09 여섯 번째 세션 확정)이 "이 위치를 처음 매치한 Handler가 setLength/setOffsetSource 등록 책임을 진다"고 못박아 놨는데도 None 소진 슬롯은 정의상 어떤 Handler도 안 거쳐서(아래 참고) "그럼 누가 그 등록을 실제로 호출하는가"가 문서 어디에도 없는 갭이었음 (2026-08-14 첫 번째 세션 조사에서 발견). ProcessedPreRef로 소진처를 분리하면 이 갭이 구조적으로 사라짐 — 아래 ProcessedPreRefHandler 참고. (2) 그 다음에야 비로소 평소의 배열→해시 두 패스가 같은 테이블을 다시 순회 — 이때 ProcessedPreRef로 소진된 슬롯은 정상 Dispatch.process 경로를 그대로 탄다(아래 ProcessedPreRefHandler가 매치, [정정] 예전엔 None이라 두 패스 루프 자신이 if v == None then continue end로 직접 건너뛰고 어떤 Handler도 안 거쳤으나, 지금은 일부러 정상 경로를 태워 Length/Offset 등록 책임을 기존 계약에 특수 취급 없이 그대로 얹음). 진짜로 원래부터 빈 자리인 None(props.Ref or None 등)은 여전히 두 패스 루프가 직접 건너뜀 — 두 센티널이 이제 서로 다른 경로를 타므로 혼동 금지. "호이스팅"은 PreRef를 배열의 맨 앞으로 물리적으로 옮기는 게 아니라, PreRef 전용 선행 루프가 통째로 먼저 끝난 뒤에야 나머지 처리가 시작된다는 뜻 — 그래서 소스에서 마지막 child로 적었어도 무조건 다른 모든 처리보다 먼저 fire됨. PreRef 슬롯을 소진시키는 게 단순 최적화가 아니라 정확성 요건인 이유: 아래 "동적 경로 가드" Handler가 (v=PreRef)를 매치하면 무조건 error를 던지므로, pre-pass가 슬롯을 안 지우면 두 번째(정상) 패스가 이미 정당하게 처리된 그 PreRef를 Dispatch.process로 다시 넘기게 되고, 그러면 이 가드 Handler가 엉뚱하게 매치되어 정상적인 PreRef 사용에도 에러가 터짐 — 소진은 이 오탐을 막기 위해 반드시 필요(ProcessedPreRefisPreRef(v)가 거짓이라 이 가드 Handler와는 애초에 안 겹침).
    • ProcessedPreRefHandler — 소진된 슬롯이 Length/Offset에 "0 기여"를 등록하는 전담 Handler (2026-08-14 두 번째 세션, 사용자 제안 — 위 갭의 해소).
      ProcessedPreRefHandler.priority = <매우 높음, NoneHandler와 동급>
      ProcessedPreRefHandler.isHandlable(inst, k, v) = (v == ProcessedPreRef)
      function ProcessedPreRefHandler.process(inst, i, v)
          Dispatch.setLength(inst, i, 0)
          Dispatch.setOffsetSource(inst, i, None)
          return function() end  -- no-op retract, 이 자리는 fire가 끝나
                                  -- 되돌릴 상태 자체가 없음
      end
      
      isHandlablev == ProcessedPreRef만 잡으므로 배열 파트 전용(해시 파트엔 이 센티널이 등장할 경로 자체가 없음). 이걸로 base/ dispatch-core-plan.md의 "Length/Offset" 절이 이미 확정해둔 "이 위치를 처음 매치한 Handler가 등록 책임을 진다"는 계약을 특수 취급 없이 그대로 만족시킴 — 매치되는 Handler 자신이 곧 등록자라 "누가 등록하는가"라는 질문 자체가 안 생김. 반환하는 retract는 하드코딩된 no-op인데, 이건 "PreRef는 취소 개념이 없다" 절(아래)이 말하는 것과 같은 이유 — fire가 이미 실행한 부작용은 되돌릴 수 없으므로 이 자리가 dispatch 체인에 실제로 올라가 있어도([정정] 예전 서술과 달리 이제는 올라가 있음 — 아래 참고) retract가 할 일이 없는 것뿐.
    • **명확화(2026-08-09 열한 번째 세션, 확인 질문에 답변) — NoneHandler.isHandlable(inst,k,v) = (v == None)k 타입을 전혀 안 가리므로 숫자 키(k=number)에서도 이론상 매치될 수 있어 보이지만, 실제로 문제 안 되는 이유는 위에서 이미 확정한 그대로다: 배열 파트의 None애초에 Dispatch.process 자체를 절대 안 탄다(두 패스 루프가 Dispatch.process 호출 전에 자기 스스로 if v == None then continue end로 걸러냄). NoneHandlerDispatch.process를 거쳐야만 매치될 기회를 얻으므로, 배열 파트의 None이 거기 아예 도달하지 않는 이상 k=number 조합으로 NoneHandler가 실제로 매치되는 경우는 없음 — "재전파 없이 무시된다"가 정확한 설명.
    • M0 스파이크 검증 항목 갱신(2026-08-07 열 번째 세션): 위 "props 순회 순서" 절은 {a=1, 2, b=3}구멍 없는 테이블에서 배열 파트가 해시 파트보다 먼저 나온다는 것만 실측 확인됨(2026-08-07 세 번째 세션). 같은 세션에서 사용자가 직접 {[1]=1,[2222]=2222, [211]=211,...}키가 듬성듬성한(sparse) 테이블을 REPL로 실측해, 그런 테이블은 순회 순서가 index 오름차순이 전혀 아님(해시 버킷 순서)을 확인함 — 그래서 위 pre-pass는 (nil이 아니라) 실재하는 센티널(ProcessedPreRef, 2026-08-14 두 번째 세션 이전엔 None)로 소진해 테이블을 "구멍 없이 촘촘한" 상태로 계속 유지하는 전략으로 이 위험을 원천 회피함(검증 불필요, 애초에 구멍을 안 만드므로). 여전히 M0에서 검증해야 하는 건 다른 케이스: props.Modifier/ props.Ref를 caller가 안 넘겨 생기는 리터럴 nil-hole({nil, ref, child}, research/pre-implementation-audit.md 1-5)은 caller가 직접 쓰는 raw Lua 리터럴이라 프레임워크가 None으로 대신 못 채워줌 — 이번 REPL 실측으로 그 케이스의 실제 위험도가 이전 서술("뒤 항목까지 무시될 수 있음", 국소적 피해로 서술돼 있었음)보다 훨씬 큼이 드러남: 구멍이 하나만 생겨도 테이블 전체가 해시 파트 취급으로 넘어가 그 인스턴스의 배열 파트 전체가 순서 보장을 잃을 수 있음. M0 스파이크에서 반드시 실측하고, 심각하면 "이런 nil-hole은 raw 리터럴로 하지 말고 항상 props.Modifier or Modifier()처럼 non-nil을 보장하라"는 컨벤션 문서화까지 검토할 것.
    • pre-pass는 어디 사는가 — Dispatch.drive(inst, flattened) 자신, 새 함수 불필요(2026-08-07 아홉 번째 세션, 사용자 제안 검토 후 확정). Dispatch.drive가 이미 (inst, flattened)를 받아 배열→해시 두 패스를 도는 함수로 확정돼 있으므로, 그 앞에 좁은 pre-pass 한 줄을 얹는 것만으로 충분 — Handler.process와 이름이 겹치는 새 Dispatch.process(inst, flatten, prerefs)류 함수를 따로 만들 필요가 없음(그 이름은 이미 다른 뜻으로 쓰이는 Dispatch.process(inst,k,v) 오케스트레이터와 겹쳐서 안 좋음). flatten(nonFlatten) -> flatten 함수 자체에 얹는 방안은 검토 후 기각 — flatten은 Modifier 값을 합치는 순수 변환(현재 inst를 안 받음). 원래 근거는 "archive/existing-instance-bind-rejected.md가 다루던 '이미 마운트된 Instance 재바인드 시 Default→실값 flatten을 다시 해야 하는가' 질문이 열려있어 flatten이 한 인스턴스 생애주기 동안 여러 번 재호출될 가능성이 있다"였고, [2026-08-14 세션] 그 재바인드 기능 자체가 기각되며 이 위험은 사라졌지만 결론(기각)은 유지 — flatten은 여전히 inst를 모르는 순수 변환이라 fire 지점으로 부적절함 — 여기에 PreRef fire를 얹으면 재바인드마다 PreRef가 또 fire되어 "이 인스턴스 하나의 construction 훅"이라는 PreRef의 정의 자체가 깨짐. Dispatch.drive는 최초 마운트 시 정확히 한 번만 불리는 게 이미 전제라 이 위험이 없음.
    • 동적 경로로 도착한 PreRef는 런타임에도 명시적으로 에러 (2026-08-07 아홉 번째 세션, 사용자 제안 채택) — 아직 문서화 안 돼 있었음, 지금 확정. 위 "Modifier 필드로 막는 이유"/"Source/Store 값으로 막는 이유" 절은 타입 차단만 다뤘음 — Luau 타입은 런타임에 지워지므로(:Peek/Overridden/버그로 타입을 우회해 PreRef가 Modifier나 Store 값으로 실제로 흘러들어오는 경우), 런타임에도 방어가 필요함. 전용 Handler를 하나 등록: { priority = HANDLER_PRIORITY_FALLBACK, isHandlable = function(inst,k,v) return isPreRef(v) end, process = function(inst,k,v) error("PreRef는 children 배열 리터럴에만 놓을 수 있음") end }k 타입은 안 가림(숫자든 문자열이든 isPreRef(v)만 보고 매치). NoneHandler와 같은 결의 "한 값 종류만 전담하는 Handler" 패턴 재사용, 새 메커니즘 아님. [2026-08-14 열한 번째 세션] 우선순위는 HANDLER_PRIORITY_FALLBACK(무조건 매치하는 하드 블록이 아니라 Tag/Attribute와 같은 "base가 소유하지만 백엔드/특정 자리에서 평범한 우선순위로 자기 핸들러를 등록하면 덮어쓸 수 있는" 자리 — 지금은 그 자리를 아무도 안 가져가서 항상 이 가드가 매치돼 에러가 나지만, 나중에 named 자리 바인드 같은 실제 기능이 확정되면 base 가드를 건드리지 않고 그 기능의 Handler를 평범한 우선순위로 하나 등록하는 것만으로 자연히 우선함, base/dispatch-core-plan.md의 "base가 소유하는 핸들러와 주입되는 엔진 op" 절). 이 Handler는 Dispatch.process/getHandler의 정상 우선순위 스캔에 등록되는 반면(pre-pass처럼 그 밖에서 도는 게 아님), 리터럴 배열의 PreRef는 pre-pass가 fire와 동시에 해당 슬롯을 소진([정정, 2026-08-14 두 번째 세션] None이 아니라 ProcessedPreRef 처리, 위 "호이스팅의 실제 구현" 절)해 이 가드 Handler(isPreRef(v)만 매치)에는 다시 노출되지 않게 하므로(정상 두 패스 스캔 자체엔 ProcessedPreRefHandler를 통해 여전히 노출됨 — "스캔에 안 걸림"이 아니라 "이 가드에 안 걸림"이 정확한 설명), 이 Handler가 실제로 매치되는 경우는 오직 "타입이 막았어야 했는데 어떻게든 동적으로 새어들어온" 버그 케이스뿐 — 그래서 no-op이 아니라 즉시 error.
    • PreRef는 "취소"라는 개념이 없다 — 1회용, 재사용은 즉시 error (2026-08-12 여섯 번째 세션, 사용자 제안 채택). Ref가 "다른 값으로 교체되면 retract로 취소됨"이라는 의미의 취소를 가질 수 있는 건 정상 우선순위 스캔의 (inst,k) 디스패치 체인에 실제로 참여해서임 — Dispatch.retractFrom이 그 체인을 대상으로 동작함. [정정, 2026-08-14 두 번째 세션] "그 체인에 올라간 적이 없다"는 근거는 더 이상 정확하지 않음ProcessedPreRefHandler 신설로 소진된 슬롯도 이제 정상 Dispatch.process 경로를 타 체인에 올라감(위 "호이스팅의 실제 구현" 절). "취소 개념이 없다"는 결론 자체는 그대로 유효하지만 이유가 바뀜: 체인에 없어서가 아니라, 그 자리의 retract가 하드코딩된 no-op이기 때문 — PreRef의 fire는 fn(inst)를 실제로 실행하는 부작용이라 애초에 "되돌릴 상태"가 없고, 그래서 체인에 올라가 있어도 retract가 할 일 자체가 없음. 진짜 위험은 취소가 아니라 재사용: 이미 한 번 fire된 PreRef 객체를 두 번째 construction의 children 배열에 다시 놓으면, 거기서 등록하는 :Callback(fn)이 "이미 채워져 있으면 즉시 1회 호출"이라는 규칙(위 "Ref 일반화" 절) 때문에 의도한 새 인스턴스가 아니라 첫 번째 fire 때 남은 stale .Value로 조용히 호출됨 — 에러도 안 나고 엉뚱한 값을 들고 실행되는, 디버깅하기 아주 어려운 버그. State/:With를 "clone 빌더가 아니라 매번 새 노드"로 확정했던 원칙(2026-08-07 세 번째 세션, ":With도 새 State 노드")과 같은 클래스의 문제이자 같은 해법.
      • 구현: pre-pass가 첫 fire 때 해당 PreRef 객체에 내부 플래그 (_fired = true)를 세팅. pre-pass가 배열을 훑다 isPreRef(v)인 슬롯을 만났는데 그 객체가 이미 _fired면, fire하지 않고 그 자리에서 즉시 error("PreRef는 1회용 — 이미 다른 construction에 쓰인 PreRef를 재사용할 수 없음, 매번 새로 만들 것"). 위 "동적 경로 가드" Handler(정상 두 패스에서 매치)와는 별개 코드 경로 — 이 가드는 pre-pass 자신 안에, _fired가 아닌 정상 fire는 그대로 통과.
      • 관용구: Slot:ListupdateFn처럼 반복 호출되는 자리에서 PreRef가 필요하면 호출마다 새 PreRef()를 만들 것 — 클로저에 캡처해 여러 construction에 걸쳐 재사용하지 말 것. (참고: Slot 자체는 요소 타입으로 Ref/PreRef를 이미 금지하고 있어(위 "요소 타입 제약" 절, slot-plan.md) 이 관용구가 실제로 문제되는 자리는 updateFn 안에서 호출하는 컴포넌트 함수 내부뿐임.)
  • 일반 Ref는 계속 Modifier/Store 어디든 자유롭게 들어감 — Store를 통해 나중에 도착하는 Ref는 그냥 도착한 그 순간 처리하면 됨, phase 개념 자체가 필요 없음("만난 순간 처리"로 충분).
  • quad v1의 OnCreated 특수 DI 키는 이식하지 않는다. Ref():Callback(function(inst) end)를 children 배열에 넣는 것만으로 완전히 대체됨(여러 개 등록도 자연히 지원, 별도 특수 키 불필요) — v1 대비 빠진 기능처럼 보이지 않도록 이 대체 관계를 문서에 남겨둠.
  • :Wait()는 PreRef에도 그대로 유효해야 함. PreRef 자신의 fire는 항상 동기적이지만, :Wait()를 호출하는 코드가 task.spawn이 아니라 순수 coroutine로 실행 중이었다면(Roblox task 스케줄러의 순서 보장이 없는 컨텍스트) 호출 시점에 아직 안 채워져 있어 실제로 yield-resume이 필요한 경우가 생김 — "항상 동기적이니 :Wait()는 즉시 리턴할 것"이라고 단정해 구현을 특수화하면 안 됨, 그냥 보통 Ref와 동일한 대기자 리스트/coroutine.yield 구현을 그대로 씀. 문서화 필요: "채워졌는지 먼저 확인, 없으면 :Wait()" 방어적 패턴을 권장 관용구로 명시(콜백이 "이미 채워져 있으면 즉시 1회 호출"하는 것과 대칭되는, 값이 없을 수도 있다는 걸 항상 코드가 스스로 확인해야 한다는 Ref 전체의 일관된 원칙).
  • 프로퍼티/이벤트가 항상 children/Ref보다 나중에 세팅된다는 사실 자체는 "고치지" 않는다 — 두 패스 순서를 뒤집거나 재배치하는 시도는 오버엔지니어링으로 판단해 안 함(이걸 원하면 애초에 PreRef를 쓰면 됨). 이 결정과 이유는 나중에 quadnomicon 콘텐츠로 문서화 예정 (research/documentation-content-map.md 후보로 메모). [보강, 2026-08-14 아홉 번째 세션] "두 패스가 전부 끝난 뒤"라는 타이밍은 이제 아래 PostRef가 제공함 — 그건 두 패스의 순서를 건드리는 게 아니라 그 뒤에 얹히는 것이라 이 결정과 상충하지 않음.

PostRef — 두 패스가 전부 끝난 뒤 fire, PreRef의 거울상 (2026-08-14 아홉 번째 세션 확정)

확정. research/lifecycle-hooks-plan.md(현 base/lifecycle-hooks-plan.md) ② 절이 "지금은 구현 안 함, 백로그 후보"로 남겨뒀던 스케치를 사용자가 착수 선택지 (a)(pre-pass 공동 수집 + 두 패스 뒤 postRefList 소비)로 확정 — 근거는 "Pre/Post 둘을 지원 안 할 이유가 없고, 구현 난이도가 아주 낮음". 이걸로 OnRendered도 같이 채택됨 (base/lifecycle-hooks-plan.md).

정의: PostRefPreRef와 마찬가지로 Ref 런타임을 그대로 재사용하고 브랜드 태그만 다른 nominal 타입(.Value/:Set/:Callback/:Wait 동일, base/brand-plan.md). 다른 점은 fire 시점 하나뿐PreRef가 "이 인스턴스에 뭐가 됐든 일어나기 "이라면, PostRef는 "이 인스턴스의 배열 파트(children/Ref)와 해시 파트(프로퍼티/이벤트)가 전부 끝난 뒤".

세 Ref의 타이밍 대조(이게 전부임):

타입 fire 시점 계열 안 상대 순서
PreRef 두 패스보다도 먼저(호이스팅 pre-pass) 배열 index 순서 보장
일반 Ref 정해진 시점 없음 — 그 값이 dispatch에 도착한 순간(배열 위치/Store 도착 시점에 따라 달라짐) (해당 없음)
PostRef 두 패스가 전부 끝난 뒤 배열 index 순서 보장(= postRefList push 순서)
  • 일반 Ref의 "언제 채워지는지 모른다"는 건 결함이 아니라 원래 계약임 — 그래서 "이미 채워졌는지 먼저 확인, 없으면 :Wait()/:Callback()"이 Ref 전체의 관용구로 이미 확정돼 있음(위 "Ref 일반화" 절). PreRef/PostRef는 그 관용구를 안 써도 되도록 시점을 계약으로 고정한 두 특수 케이스.
  • 계열 안 순서를 둘 다 보장하는 근거는 위 PreRef 절의 같은 항목 참고(FastQuery(...) -> PreRef류 조합, 비용 0, 이미 있는 배열 파트 index 순서 계약의 귀결) — PostRefpostRefList도 그 index 순서 스윕이 만드는 목록이라 push 순서 = 배열 순서.

보장 범위 — 무엇이 끝나 있고 무엇이 안 끝나 있는가(중요)

PostRef가 fire될 때 끝나 있는 것:

  • 이 인스턴스의 모든 자식, 그리고 그 자식들의 서브트리 전체. 배열 파트는 "각 자식이 자기 서브트리까지 전부 동기적으로 마운트를 끝내야 다음 형제로 넘어간다"가 이미 계약이고(위 "phase 옵션 폐기" 절), Slot도 마운트 시점에 자기 초기 요소를 그 자리에서 주입하며(base/slot-plan.md), State<Frame>류 store-bind도 최초 값을 그 자리에서 동기적으로 처리함 (base/dispatch-core-plan.md) — 즉 배열 파트가 끝난 시점엔 정적으로 선언된 트리가 전부 완성돼 있음. 사용자 표현 그대로 "중간 for문에서 모든 Slot/State<Frame>/Frame 요소의 마운트가 처리되므로, 바로 뒤에서 실행하면 모든 트리가 완성된 이후".
  • 이 인스턴스의 모든 프로퍼티/이벤트(해시 파트) — PreRef가 존재하는 이유였던 "이벤트가 setup 도중 동기 발화" 문제의 반대편 끝.

끝나 있지 않은(문서화 필수, 이름만 보고 오해하기 쉬움):

  • 이 인스턴스 자신이 부모에 붙는 것(.Parent 대입) — 부모가 이 인스턴스를 자기 배열 파트에서 처리하는 건 이 drive끝난 뒤임 (Frame { Frame {...} }처럼 리터럴로 중첩하면 안쪽 Frame 호출이 먼저 완결되어야 바깥 Frame의 props 테이블이 완성됨). 즉 PostRef자기 아래(서브트리)의 완성만 보장하고, 자기 위(조상 체인)는 아직 없을 수 있음 — "화면에 올라간 시점"이 아님. OnRendered라는 이름이 React componentDidMount(DOM 삽입 후)처럼 읽힐 수 있으므로 이 차이를 base/lifecycle-hooks-plan.md와 사용자 문서에 명시할 것.
  • 나중에 동적으로 도착하는 것들 — Store를 통해 뒤늦게 바뀌는 값, Slot:Add/:List로 나중에 추가되는 요소는 정의상 이 drive 밖의 사건이라 당연히 포함 안 됨.

메커니즘 — pre-pass 한 스윕으로 PreRef/PostRef 둘 다 처리

새 전체 순회를 추가하지 않는 게 핵심(사용자 제안). Dispatch.drive의 기존 pre-pass가 이미 배열 파트를 index 순서로 한 번 훑고 있으므로:

  1. pre-pass(기존 루프에 분기 하나 추가):
    • isPreRef(v)면 지금까지처럼 그 자리에서 즉시 fire하고 flattened[i] = ProcessedPreRef로 소진.
    • isPostRef(v)아직 fire하지 않고, 이 Dispatch.drive 호출 하나에만 로컬인 평범한 배열 postRefList에 그 인스턴스를 push한 뒤 즉시 flattened[i] = ProcessedPostRef로 소진. 1회용 재사용 가드 (_fired)도 이 시점에 세팅 — "슬롯이 소진되는 시점"과 "재사용이 막히는 시점"을 PreRef와 동일하게 맞춤(실제 콜백 fire와 시점이 갈리는 건 아래 3번뿐).
    • postRefListRelate 같은 별도 저장소가 아님 — 이 함수 콜스택 안에서만 살면 되므로 그냥 로컬 테이블.
  2. 정상 두 패스(배열 → 해시)가 평소대로 돎. ProcessedPostRef로 소진된 슬롯은 ProcessedPreRef완전히 대칭적으로 정상 Dispatch.process 경로를 타고 아래 전담 Handler에 매치됨.
  3. 두 패스가 끝난 뒤 Dispatch.drivepostRefList를 순회하며 각 PostRef를 fire. 추가 비용은 전체 배열 재순회가 아니라 실제 PostRef 개수만큼의 순회뿐. push 순서 그대로 돌면 되고, 그게 곧 배열 index 순서라 위 표의 보장이 자동으로 성립(별도 정렬 불필요).

ProcessedPostRefHandlerProcessedPreRefHandler의 거울상, 새 규칙 없음

ProcessedPostRefHandler.priority = <매우 높음, ProcessedPreRefHandler와 동급>
ProcessedPostRefHandler.isHandlable(inst, k, v) = (v == ProcessedPostRef)
function ProcessedPostRefHandler.process(inst, i, v)
    Dispatch.setLength(inst, i, 0)
    Dispatch.setOffsetSource(inst, i, None)
    return function() end  -- no-op retract, PreRef와 같은 이유(되돌릴 상태가 없음)
end

ProcessedPreRefHandler(위 절)와 한 글자 차이 — "이 위치를 처음 매치한 Handler가 setLength/setOffsetSource 등록 책임을 진다"는 base/ dispatch-core-plan.md "Length/Offset" 절의 계약을 특수 취급 없이 그대로 만족시킴. ProcessedPostRefNone이 아니라 전용 센티널(단일 {})인 이유는 ProcessedPreRef와 동일 — "원래부터 빈 자리(None)"와 구별돼야 등록 책임 소재가 분명해지고, 배열에 구멍이 안 생김.

동적 경로 가드 Handler도 거울상으로 하나 더

PreRef와 똑같이, PostRefchildren 배열의 리터럴 아이템으로만 놓을 수 있음 — Modifier 필드/Source/Store 값으로는 타입으로 차단(이유도 동일: flatten되면 해시 파트로 존재하게 돼 "배열 파트" 전제를 벗어나고, Store 경로로 뒤늦게 도착한 값은 "이 인스턴스의 construction 훅"이라는 정의 자체를 만족시킬 수 없음). 타입은 런타임에 지워지므로 정상 우선순위 레지스트리에 { priority = HANDLER_PRIORITY_FALLBACK, isHandlable = isPostRef(v), process = error("PostRef는 children 배열 리터럴에만 놓을 수 있음") } Handler를 등록(k 타입 안 가림 — PreRef의 "동적 경로 가드" 절과 완전히 같은 이유로 HANDLER_PRIORITY_FALLBACK, 2026-08-14 열한 번째 세션) — pre-pass가 이미 소진시키므로 이게 매치되면 곧 타입 차단을 우회한 버그라는 뜻.

1회용, 재사용은 즉시 errorPreRef와 같은 _fired 플래그를 그대로 재사용(위 "PreRef는 '취소'라는 개념이 없다" 절과 같은 근거: 이미 fire된 객체를 다시 놓으면 stale .Value로 콜백이 조용히 잘못 호출됨). "취소 개념이 없다"도 동일 — 체인엔 올라가지만 그 자리 retract가 하드코딩된 no-op이라 되돌릴 상태가 없음.

타입/판별: isPostRefisPreRef와 같은 층위의 가장 구체적인 항등 체크이고, isRef가 그 위에 얹히는 상위 개념 — Dispatch/Leaf.luau의 일반 Ref 매치는 이제 isRef(v) and not isPreRef(v) and not isPostRef(v). 상세는 base/brand-plan.md.

대표 유스케이스(사용자 제시)ChildAdded 같은 이벤트에서 나중에 들어오는 것만 처리하고 싶을 때, PostRef의 콜백이 mounted = true 같은 boolean 플래그를 세워두고 핸들러가 그 플래그를 먼저 보게 하는 패턴. 초기 construction 중에 발생한 이벤트와 그 이후 동적으로 들어온 것을 사용자 코드가 스스로 구분할 수 있게 해주는, PreRef만으론 표현이 안 되던 자리임.