decide(base): Ref/PreRef 메소드 API 확정, 파일 분리, Tween GC 저장 구조 확인

- Ref API를 .Value(읽기 전용) + :Set(value)/:Callback(fn)/:Wait(thread?)
  세 메소드로 확정 — 전부 mutation 패턴이라 자기 자신을 반환해
  `if ref.Value then ref.Value else ref:Wait().Value` 관용구가 성립.
  :Set()이 대기자를 깨울 때 넘기는 resume 인자를 value에서 self로 정정
  (안 그러면 :Wait() 뒤 .Value 체이닝이 안 풀림).
- :Wait(thread?)의 thread 인자: 생략하면 coroutine.running()을 캡처해
  yield, 명시하면 등록만 하고 yield 없이 즉시 self 반환.
- Ref/PreRef를 1프리미티브-1파일 컨벤션에 맞춰 Ref.luau/PreRef.luau로
  분리(런타임은 공유), architecture.md 소스트리 갱신.
- Tween의 per-instance 저장소(inst로 weak-keyed된 릴레이션 안에 key별
  릴레이션이 중첩된 구조)가 이미 설계대로 GC-안전함을 확인, 이유를
  bind-system-plan.md/tween-plan.md에 명시.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
qwreey 2026-08-07 16:01:24 +09:00
parent 4dd7659620
commit 8ce3c11213
Signed by: qwreey
GPG key ID: D28DB79297A214BD
4 changed files with 116 additions and 23 deletions

View file

@ -132,7 +132,8 @@ quad/
│ │ └── Slot.luau # add/remove/clear 재조정 로직(추상 자식 참조 기준)
│ ├── LifetimeHandle.luau # Connected 계산 속성 "인터페이스"(타입/계약만)
│ ├── PerInstanceState.luau # per-instance 상태 저장 "인터페이스"
│ ├── Ref.luau # 범용 값 박스(.Value/:Wait()/콜백) + 그 위에 얹힌 CreatedRef(숫자 슬롯 참가자) 특수화 + PreRef(children 배열 전용, Modifier/Store 타입 차단, 호이스팅되는 pre-pass 특수화, `bind-system-plan.md` "PreRef 신설" 절)
│ ├── Ref.luau # 범용 값 박스(.Value 읽기 + :Set()/:Callback()/:Wait() 셋)와 그 위에 얹힌 CreatedRef(숫자 슬롯 참가자) 특수화
│ ├── PreRef.luau # Ref 런타임 재사용 + children 배열 전용, Modifier/Store 타입 차단, 호이스팅되는 pre-pass 특수화(별도 파일, `bind-system-plan.md` "PreRef 신설" 절, 2026-08-07 여섯 번째 세션에서 분리)
│ └── init.luau
└── quad-roblox/
├── wally.toml

View file

@ -95,7 +95,12 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들
멈추거나 끝냄). **base가 이걸 범용 유틸로 제공**(`base.perInstanceState(inst)`
류, 정확한 이름/모양은 구현 단계에서 확정) — 모든 핸들러가 재사용, 각자
WeakMap을 새로 만들지 않음. `base/lifecycle-pattern.md`의 "생명 바인드 유틸"과
짝을 이루는 유틸.
짝을 이루는 유틸. **왜 GC-안전한가(2026-08-07 여섯 번째 세션, 명시화)**:
구조가 "`inst`로 weak-keyed된 바깥 릴레이션 안에 `k`별 안쪽 릴레이션이
중첩된" 모양이라, `inst`가 죽어 weak table 엔트리가 통째로 사라지는
순간 그 안에 중첩된 `k`별 Tween 인스턴스 릴레이션도 같이 GC됨 — 별도
cleanup 로직 불필요(Tween 핸들러가 여기 담아두는 실제 Tween 인스턴스도
자동으로 같이 죽는 것까지 포함, `research/tween-plan.md` 참고).
- **다른 값 변경을 추적하는 것도 process 함수의 정상 범위**: 예를 들어 Slot
핸들러는 자기가 감시하는 값(배열/스토어)이 바뀌면 그에 따라 child를
갱신해야 함 — `retract` 시점엔 그 추적(구독)만 풀면 됨.
@ -215,24 +220,58 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플
`ref.Value?.()`처럼 호출하는 함수를 Ref에 담아 넘기는 패턴 — React의
imperative handle과 동일한 이유(비싼 연산이라 온디맨드가 맞음, 값이
최신인지 아닌지도 애매해짐).
- **API 모양**: `.Value`(get/set) + `:Wait()`(coroutine 컨텍스트에서
사용 — 렌더 함수 바디 안에서 `return` 위에 바로 못 씀, 그래서 콜백도
같이 필요) + 콜백 등록(복수 허용, 이미 채워져 있으면 등록 즉시 그
값으로 1회 호출 — nil/미설정 상태여도 그 상태 그대로 호출. React의
`useEffect`가 매번 `.current` 존재 여부부터 체크하는 것과 같은 이유,
Ref가 자식으로 전달되는 경우 채워지는 시점이 더 늦어질 수 있어서
"이미 채워졌는지" 확인이 항상 필요함). `:Wait()`의 대기자 리스트와
콜백 리스트는 같은 구조 재사용 가능(발화 후 해당 인덱스만 nil 처리,
Luau의 일반화 for는 성긴 배열도 잘 순회함).
- **구현 디테일(2026-08-07 세 번째 세션, 사용자 제안)**: 값이 새로
`:Set()`될 때, 같은 배열 하나를 `for i, v in <배열> do ... end`
한 번만 순회하면서 `type(v) == "thread"``:Wait()`가 만든 대기자로
보고 `coroutine.resume(v, value)``[i] = nil`(1회성 소진), 아니면
일반 콜백 함수로 보고 그냥 `v(value)`(소진 안 함, 계속 유지)로
분기하면 됨 — 대기자/콜백을 서로 다른 배열로 나눌 필요 없이 값 타입
하나로 분기 가능. 새 콜백/대기자 등록은 `table.insert`로 끝(빈
인덱스가 있어도 없어도 상관없이 다음 슬롯에 들어감, 성긴 배열이어도
일반화 `for`가 계속 잘 순회하므로 압축(compact)할 필요도 없음).
- **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-semantics.md` 190행)의 자연스러운 재적용.
이 self-반환 덕에 Luau의 `if`-표현식과 결합해 흔한 관용구를 한 줄로
쓸 수 있음(사용자 제시 예):
```luau
local t = if ref.Value
then ref.Value
else ref:Wait().Value
```
- 콜백은 이미 채워져 있으면 등록 즉시 그 값으로 1회 호출됨 — nil/미설정
상태여도 그 상태 그대로 호출. React의 `useEffect`가 매번 `.current`
존재 여부부터 체크하는 것과 같은 이유, Ref가 자식으로 전달되는 경우
채워지는 시점이 더 늦어질 수 있어서 "이미 채워졌는지" 확인이 항상
필요함. `:Wait()`의 대기자 리스트와 콜백 리스트는 같은 구조 재사용
가능(발화 후 해당 인덱스만 nil 처리, Luau의 일반화 for는 성긴 배열도
잘 순회함).
- **`:Wait(thread?)``thread` 인자(2026-08-07 여섯 번째 세션, 사용자
제안, 확정)**: 생략(`nil`)하면 `coroutine.running()`으로 호출 중인
코루틴 자신을 캡처해 대기자로 등록하고 그 자리에서 `coroutine.yield()`
**자기 자신을 정지**시킴(값이 채워지면 재개). 명시적으로 다른 thread를
넘기면 **그 thread를 대기자로 등록만 하고 정지 없이 즉시 `self`
반환** — 코루틴 역학상 `coroutine.yield()`는 지금 실행 중인 코루틴만
정지시킬 수 있고, 어딘가 이미 정지해 있는 남의 thread를 여기서 대신
정지시킬 수는 없기 때문(그 thread는 이미 정지 상태). 이 표면의
용도: 사용자가 직접 관리하는 스케줄러가 이미 만들어 둔(어딘가 다른
지점에서 정지시킨) thread 하나를 Ref에 등록해두고, 등록한 코드 자신은
블록되지 않고 계속 진행하고 싶은 경우. 구현은 정말 단순함 — `thread`
`nil`이면 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`
(1회성 소진), 아니면 일반 콜백 함수로 보고 그냥 `v(value)`(콜백은
여전히 원래 값을 직접 받음, 소진 안 함, 계속 유지)로 분기하면 됨 —
대기자/콜백을 서로 다른 배열로 나눌 필요 없이 값 타입 하나로 분기
가능. 새 콜백/대기자 등록은 `table.insert`로 끝(빈 인덱스가 있어도
없어도 상관없이 다음 슬롯에 들어감, 성긴 배열이어도 일반화 `for`
계속 잘 순회하므로 압축(compact)할 필요도 없음).
- **주의(문서화 대상, 방어 로직 없음)**: 이미 죽은(완료/에러난) 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)`
@ -298,8 +337,17 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
채워진 self-ref를 읽으면 터짐.
**해결**: 이 케이스만 별도 타입 `PreRef`로 분리.
- **구현은 `Ref` 그대로 재사용**(같은 `.Value`/`:Wait()`/콜백 API) —
브랜드 태그만 다른 nominal 타입. 런타임 코드 중복 없음.
- **구현은 `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.luau`가 `Ref.luau`를 그대로 불러다 브랜드 태그만 얹음), 파일
분리는 순수 조직 문제라 위 재사용 결정과 상충 없음. `base/architecture.md`
소스트리에 반영 완료.
- **오직 children 배열의 리터럴 아이템으로만 놓을 수 있다** — **Modifier
필드 값으로도, Source/Store 값으로도 들어갈 수 없게 타입으로 차단.**
- Modifier 필드로 막는 이유: 거기 들어가면 flatten 후 해시 파트로

View file

@ -71,7 +71,11 @@ Ref나 네임스페이스드 조회가 필요하지 않음. Tween의 store-bind
값(v)을 받으므로 여기서 선택된 동작을 구현. `retract`가 접근해야 할 "이전에
생성한 실제 Tween 객체"는 `base/bind-system-plan.md`가 말하는 base 제공
범용 유틸(`inst`를 키로 하는 weak-keyed per-instance 상태 저장소)에 담아두면
됨.
됨. **GC 확인(2026-08-07 여섯 번째 세션, 사용자 제안 검증)**: 이 저장소는
`inst`로 weak-keyed된 바깥 릴레이션 안에 `k`별 안쪽 릴레이션이 중첩된
구조라, `inst`가 죽으면 그 안에 담긴 Tween 인스턴스 릴레이션도 별도
정리 로직 없이 같이 GC됨 — `base/bind-system-plan.md`의 "핸들러 내부
상태 저장" 절 "왜 GC-안전한가" 참고.
## `retract`는 완전 소멸(Destroy) 시엔 호출되지 않는다

View file

@ -850,3 +850,43 @@ setter를 단발로 직접 호출하는 흔한 경로는 여전히 mutable이라
결정 자체는 늘지 않았음 — 단, M3 체크리스트에 `Blocker.luau` 항목이
하나 추가된 것과, 위 Effect/Observer 미해결 항목은 M3~M4 착수 전에
확인해야 함.
## 2026-08-07 여섯 번째 세션 — Ref/PreRef 메소드 API 확정, 파일 분리, Tween GC 저장 구조 확인
사용자가 메모 형태로 두 가지를 던짐: (1) Tween 인스턴스를 per-instance
저장소에 담는 구조가 실제로 GC-안전한지, (2) Ref가 이제 충분히 완결된
프리미티브이니 PreRef와 파일을 분리하고, `:Set`/`:Callback`/`:Wait`
세 메소드로 API를 굳히자는 제안(전부 mutation 패턴이라 자기 자신을
반환). 둘 다 검증 후 반영 완료:
- **Tween per-instance 저장소는 이미 확정된 구조 그대로 GC-안전함**
`inst`로 weak-keyed된 바깥 릴레이션 안에 `k`별 안쪽 릴레이션이 중첩된
모양이라(`base.perInstanceState(inst)`), `inst`가 죽으면 중첩된 Tween
인스턴스 릴레이션도 별도 정리 없이 같이 GC됨 — 새 결정 아니라 기존
설계(`bind-system-plan.md` "핸들러 내부 상태 저장" 절)의 확인, "왜
GC-안전한가" 설명만 명시적으로 추가.
- **Ref API가 `.Value`(읽기 전용) + `:Set(value)`/`:Callback(fn)`/
`:Wait(thread?)`(전부 self 반환)로 확정.** self-반환 덕에
`if ref.Value then ref.Value else ref:Wait().Value` 관용구가 성립 —
이걸 성립시키려고 `:Set()``coroutine.resume`할 때 넘기는 인자를
기존 문서(세 번째 세션 원안)의 `value`에서 **`self`**로 정정함(안
그러면 `:Wait()`의 yield 리턴값에 `.Value`를 체이닝할 방법이 없었음).
`:Wait(thread?)``thread` 인자는 생략 시 `coroutine.running()`
캡처해 진짜로 yield하고, 명시적으로 넘기면 그 thread를 등록만 하고
yield 없이 즉시 `self` 반환(코루틴 역학상 남의 thread를 여기서 대신
정지시킬 수 없어서) — 사용자가 직접 관리하는 스케줄러가 이미 어딘가서
정지시켜 둔 thread를 등록만 해두고 호출부는 안 블록되고 싶은 유스케이스.
콜백은 여전히 raw 값을 받음(Ref 자신이 아니라).
- **파일 분리**: `Ref`는 그 자체로 완결된 프리미티브, `PreRef`도 "children
배열 전용, 위치 무관 호이스팅"이라는 특이한 제약을 가진 별개
프리미티브라 기존 1프리미티브-1파일 컨벤션(Blocker/Effect 분리와
같은 이유)을 따라 `Ref.luau`/`PreRef.luau`로 쪼갬 — 런타임은 여전히
공유(`PreRef`가 `Ref`를 재사용, 브랜드 태그만 다름), `base/architecture.md`
소스트리에 반영 완료.
- 전부 `base/bind-system-plan.md`(Ref/PreRef 절)와 `research/tween-plan.md`
반영 완료. `.claude/question.md`엔 이미 반영돼 있던 "Ref 이름 자체는
용어 정리 대상" 항목과 모순 없음(이번 세션은 메소드 이름만 확정, Ref라는
타입 이름 자체는 여전히 가칭).
**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 이미
설계된 것의 세부 마무리라 M0 착수 우선순위 자체는 그대로.