diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index 5d6ba00..ae86998 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -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 diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 0a678b7..3629f1a 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -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`(쓰기) + + `:Callback(fn) -> Ref`(콜백 등록, 복수 허용) + `:Wait(thread?) -> Ref` + (coroutine 컨텍스트에서 사용 — 렌더 함수 바디 안에서 `return` 위에 바로 + 못 씀, 그래서 콜백도 같이 필요) **세 메소드로 확정(2026-08-07 여섯 번째 + 세션)**. `:Set`/`:Callback`/`:Wait` 전부 **mutation 패턴이라 자기 자신 + (`Ref`)을 반환** — `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) -> Ref` — 단일 타입 파라미터.** React `useRef(U): T|U`류 "초기값 타입과 최종 타입을 분리"하는 2파라미터 설계도 검토했으나(예: `Ref<>(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 후 해시 파트로 diff --git a/.claude/research/tween-plan.md b/.claude/research/tween-plan.md index 872fd6c..1b949a0 100644 --- a/.claude/research/tween-plan.md +++ b/.claude/research/tween-plan.md @@ -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) 시엔 호출되지 않는다 diff --git a/CLAUDE.md b/CLAUDE.md index ada599e..6f208b7 100644 --- a/CLAUDE.md +++ b/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 착수 우선순위 자체는 그대로.