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:
parent
4dd7659620
commit
8ce3c11213
4 changed files with 116 additions and 23 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 후 해시 파트로
|
||||
|
|
|
|||
|
|
@ -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) 시엔 호출되지 않는다
|
||||
|
||||
|
|
|
|||
40
CLAUDE.md
40
CLAUDE.md
|
|
@ -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 착수 우선순위 자체는 그대로.
|
||||
|
|
|
|||
Loading…
Reference in a new issue