# `.claude/luau-test/` 첫 실측 결과 (2026-08-13 여섯 번째 세션) **배경**: 2026-08-09 열두 번째 세션에 스파이크를 만들기 시작한 이래 **처음으로 `luau`/`luau-analyze` 바이너리가 사용 가능해져 실제로 돌려본 결과**. 그동안 CLAUDE.md가 "M0 착수 전 남은 유일한 게이트"로 꼽아온 항목. 사용자 요청: "지금 상황에서 문제가 생겨 프로젝트의 구조 변경이 생기면 큰 작업인데, 이것이 더 큰 스파이크로 번지기 전에 미리 확인하고싶습니다." ## 런타임 스파이크 (`luau`) | 파일 | 판정 | 요지 | |---|---|---| | `01-two-pass-array-hash-order` | ✅ 통과 | 배열 파트 전체 → 해시 파트 순으로 처리됨. `Dispatch.drive`의 두 패스 계약과 `PreRef` 호이스팅이 기대는 바로 그 순서 | | `02-none-sentinel-vs-nil-holes` | ✅ 통과 | `nil` 소진 시 `#t`가 50→**49**로 무너지고 순회가 흐트러짐 / `None` 소진은 `#t`가 항상 50. 반대로 Ref 콜백 배열은 `None`을 쓰면 1000회 반복 후 **죽은 슬롯 1000개**가 그대로 남음 — 두 배열의 규칙이 서로 반대여야 한다는 2026-08-09 열한 번째 세션 정정이 정량적으로 확인됨 | | `03-recursive-store-bind-dispatch` | ✅ 통과 | StoreBind 재귀 재-dispatch, `None`→`nil` 흘러가기, 무한재귀 없이 종료 | | `04-dispatch-chain-retractFrom` | ✅ 통과 + **버그 재현** | 아래 별도 절 | | `05-store-state-diamond-propagation` | ✅ 통과 | 다이아몬드 의존성에서 `stateC` 재계산이 정확히 1회(중복 재계산 없음), invalidate는 2번 도달하지만 2번째가 즉시 중단 | | `06-component-boundary-nil-hole-props` | ✅ 통과 | `or None` 없으면 앞쪽 nil-hole로 `bad[1]`/`bad[2]`가 사라짐, 관용구 쓰면 항상 5칸 유지 | | `07-relate-weak-table-gc` | ✅ 통과(**이번에 보강 후**) | 아래 별도 절 | | `11-modifier-illegal-value-error` | ⚠️ 부분 | 대부분 의도된 가드 에러로 통과하나 "다른 Modifier" 케이스만 브랜드 판별이 크래시해 **엉뚱한 이유로 통과** — 수정 진행 | | `17-modifier-index-tableclone-chaining` | ❌ 크래시 | `attempt to call a number value`(44행)로 죽어 **아무것도 검증 못 함** — 수정 진행 | | `18-relate-mutual-cycle-gc` | ✅ 통과 | 아래 별도 절 | | `20-slot-splice-index-arithmetic` | ✅ 통과 | 11개 경계 케이스 전부 참조 구현과 일치(delta 양/음, 맨앞/맨끝, 전체 교체 등) | `10-roblox-studio-checks.server.luau`는 Studio 전용이라 이 라운드 범위 밖 (부분 결과는 `audit/gcconn-trick-verification.md`). ## `04` — 이번 세션 감사가 찾은 버그가 실측으로 재현됨 (가장 중요) 같은 스크립트가 두 시나리오를 돌림: `chains:SetStrong`을 `handler.process` **앞**에 두는 정상 설계와, **뒤**에 두는 음성 대조군. | 관측 지점 | 정상(수정본) | 음성 대조군(버그) | |---|---|---| | [1] 최초 마운트 후 체인 깊이 | **3** (기대) | **1** ← 인덱스 2/3 retractor 유실 | | [3] 바깥 store 재발행 후 옛 inner 구독 | **0** (끊김) | **1** ← 안 끊김 | | [4] 죽은 store를 건드렸을 때 | `w1` 유지 | **`STALE`로 덮어써짐** | 즉 이 버그는 "리소스가 좀 샌다" 수준이 아니라 **이미 버려진 store가 나중에 UI를 덮어쓰는** 증상까지 간다는 게 실측으로 확인됨. 수정 (`SetStrong` hoist + no-op 점유 마커)이 옳았다는 결정적 근거. ## `07` — 스파이크를 보강해야 실제 검증이 됐음 원래 3번 섹션이 "강하게 붙잡아둔 10개가 살아있는가"라는 **sanity check만** 하고 있었고, 헤더가 내세운 핵심 주장("inst가 죽으면 중첩된 것까지 전부 같이 GC")은 검증되지 않은 채였음. 파일 자신도 "Luau가 weak table 내부 엔트리 개수를 세는 표준 API를 안 줘서 직접 카운트 불가"라고 적어뒀는데 **그 전제가 틀렸음** — outer가 `__mode="k"`이므로 GC 후 죽은 엔트리는 `pairs` 순회에서 그냥 사라져 직접 셀 수 있음. 그래서 이번에 `relate._countEntries()`(테스트 전용)와 **weak-value canary 레지스트리**를 추가해 4번 섹션을 신설, 연쇄 GC를 직접 검증: ``` inst 5개만 살린 상태에서 살아남은 payload 수: 5 (기대 5 — 45개는 연쇄 GC) relate4의 살아있는 엔트리 총 개수: 5 (기대 5) 모든 inst 참조를 놓은 뒤 살아남은 payload 수: 0 (기대 0) relate4의 살아있는 엔트리 총 개수: 0 (기대 0) ``` **`base/bind-system-plan.md` "왜 GC-안전한가"와 `base/relate-plan.md` 전체가 기대고 있는 전제가 실측 확인됨** — quad의 GC-native 아키텍처 (명시적 Destroy 강제 없음, `bindLifetime`으로 매달아둔 자원이 inst와 함께 자동 소멸)가 실제로 성립함. ## `18` — `Relate` 상호 순환 경고가 실측 확인됨 `base/relate-plan.md` "위험한 패턴" 절이 공식 문서 인용(Luau에 ephemeron 없음)으로만 뒷받침되던 주장: ``` 1. 상호 강참조 순환: inst 살아있음 = true, value 살아있음 = true (GC 못 풂) 2. 한쪽을 weak-value로 낮춤: inst 살아있음 = false, value 살아있음 = false (풀림) ``` **추측이 아니라 실제로 GC가 안 되는 게 확인됨** — `Slot`의 `kSlotMap`/`slotOwner`를 둘 다 `SetWeak`로 낮춘 2026-08-12 열세 번째 세션 결정이 필수 조치였음이 입증됨. ## 타입 스파이크 (`luau-analyze`) — 판정 완료 | 파일 | 판정 | 요지 | |---|---|---| | `08-type-source-satisfies-state` | ✅ 부분통과 | 핵심 질문(`Source`를 `State` 자리에 그대로 넘기기)은 클린 통과 — 구조적 서브타이핑 성립. 별개로 `State`가 **자기 자신**을 다른 타입 인자로 재귀 참조하면 `Recursive type being used with different parameters` — M0에서 타입 선언 작성 시 유의할 좁은 제약 | | `09-type-modifier-overridden-subtype` | ✅ 통과 | 문서가 우려한 지점(`FrameModifier`↔`GuiObjectModifier`)이 그대로 재현 — `Apply`의 리턴 타입 불일치로 서브타입이 깨짐. fallback(`any`)은 정상. 확인하려던 걸 정확히 확인 | | `12-type-attribute-generic-key-narrowing` | ❌ 실패(**설계 영향 없음**) | 제네릭 키의 `T`가 이름별로 고정 안 되고 호출마다 독립 추론돼 narrowing이 전혀 강제 안 됨. **`attribute-plan.md`가 이미 이 결과를 fallback으로 예비**("안 되면 `BooleanAttribute` 같은 타입 패밀리가 유일하게 믿을 수 있는 정적 체크 경로") — 문서의 "[실측 필요]" 마커만 "확인됨: 안 됨"으로 갱신하면 됨 | | `13-type-ref-preref-subtype` | ✅ 통과 | `PreRef`가 `Ref` 자리에 대입 가능 — 진단 0건 | | `14-type-nilable-default-overload` | ⚠️ 부분통과 | 의도한 오용은 정확히 막지만 **정상 nilable 사용례까지 같이 막아** 현 스케치로는 채택 불가 | | `15-type-compute-trailing-deps-typepack` | ❌ 검증불가 | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 파일 전체가 파싱 실패. 다만 파서가 복구 후 낸 진단에서 **아래 1번 이슈**가 드러남 | | `16-type-store-key-typefunction` | ❌ 실패 | `type function` 스케치의 `types.newfunction` 시그니처가 설치된 버전의 실제 API와 안 맞음. 문서가 스스로 "API 이름이 다를 수 있다"고 예비해둔 케이스 | ## ⚠️ 실측으로 드러난 진짜 설계 이슈 — `:Compute(fn)`의 lazy 핸들 계약이 Luau 추론과 충돌 **이번 실측 라운드에서 나온 가장 중요한 발견.** 최소 재현으로 원인을 정확히 좁혔음(에이전트 보고를 액면 그대로 받지 않고 직접 검증): | 형태 | 진단 | |---|---| | 콜백이 `State`(lazy 핸들)를 받음, **무주석 인라인 람다** | ❌ 2건 | | 위 + `Get`을 `read`로 선언 | ❌ 여전 | | 위 + `Get: (self: State) -> T` 형태로 변경 | ❌ 여전 | | 콜백 **파라미터에 타입 주석**을 달면 | ✅ 0건 | | 콜백이 **raw 값 `T`** 를 받으면(`fn(v)`) | ✅ **0건** | 즉 **원인은 "콜백이 lazy `State` 핸들을 받는다"는 quad의 커링 계약 그 자체**임. `read`/`self` 표기 조정으로는 안 풀리고, raw 값을 넘기면 완벽히 추론됨. ```lua -- 지금 확정된 관용구 — 타입 추론 실패 state:Compute(function(s) return s:Get() * 2 end) -- 우회책 1: 파라미터 주석(가장 흔한 자리에 매번 타입을 써야 함) state:Compute(function(s: State) return s:Get() * 2 end) ``` **이건 `:Compute`만의 문제가 아님** — `Effect`/`Observer`/`Animate`/ `Operator` 카탈로그 등 "콜백이 lazy 핸들을 받는다"는 계약을 공유하는 API 전부에 걸림. 2026-08-07 일곱 번째 세션의 커링 스타일 확정, 2026-08-12 네 번째 세션의 "`:Get()` 누락 버그 전역 감사"가 전부 이 계약 위에 서 있음. **결정은 사용자 몫** — `.claude/question.md`에 올림. 선택지: 1. 계약 유지 + 파라미터 주석 필수(인체공학 손해, 가장 흔한 자리에 매번). 2. 콜백이 raw 값을 받도록 전환(추론은 완벽해지나 lazy/trailing-deps/ `previous` 설계 전반과 충돌 — **구조 변경 규모가 큼**). 3. 혼합(무주석은 raw, 명시적으로 lazy가 필요할 때만 별도 API). ## 그 외 — 스파이크 코드 결함이었던 것들 (전부 수정 완료) - **`17` 크래시의 원인이 실은 문서 결함이었음** — `modifier-plan.md`의 "데이터를 테이블에 직접 두고"가 "self 최상위 리터럴 키"로 읽힐 여지가 있었는데, 그렇게 하면 `__index`가 `rawget` 성공 시 안 불리므로 **같은 필드를 두 번째로 변환 함수와 함께 호출하는 순간 죽음**(`attempt to call a number value`). 그 재호출 패턴이 바로 문서 3·4번 절의 대표 용례라 실사용에서 즉시 터지는 경로였음 — `modifier-plan.md`에 경고 문단 추가하고, 필드를 내부 저장소에 두는 구조로 `17`을 재작성해 통과 확인. - `11`의 "다른 Modifier" 케이스가 브랜드 판별 크래시로 **엉뚱하게 통과**하던 것 수정 — 이제 의도된 가드 에러로 검증됨(16개 케이스 전원 통과). - `19`의 B/C 섹션을 현행 설계로 재작성(옛 `rawNew`+`owners`, 3분기 `claimOwner` 폐기 반영). **음성 대조군을 넣어** 옛 로직이 `Slot{a,a}`와 `Frame{slot,slot}`을 조용히 통과시키는 것까지 재현 확인. - `07`을 보강(위 절 참고). **최종 런타임 상태: 12개 전원 통과**(01/02/03/04/05/06/07/11/17/18/19/20), crash 0, FAIL 0. ## 스파이크 자체 수정이 더 필요한 것 (설계 문제 아님) - `13`: B 런타임 섹션이 A의 더미 스텁에 막혀 단독 실행 불가 — 분리 필요. - `15`: 음성 대조군을 별도 파일/블록으로 격리해 `SyntaxError`가 A/B/D 판정을 막지 않도록. - `16`: 설치된 버전의 `types.*` 실제 API 재확인 후 재시도. ## 결론 **런타임 설계는 전부 성립** — 검증된 주장마다 예외 없이 통과했고, 특히 GC-native 아키텍처의 핵심 전제(연쇄 GC)와 `Relate` 상호 순환 경고가 실측으로 확정됨. 이번 세션 감사가 찾은 버그도 음성 대조군으로 재현되어 **감사→수정 사이클이 실측으로 닫힘**. **타입 쪽에서 하나가 걸림** — `:Compute(fn)`의 lazy 핸들 계약이 Luau 양방향 추론과 충돌(위 절). 사용자가 우려한 "구조 변경이 생기면 큰 작업"에 해당할 수 있는 유일한 항목이고, **선택지 2를 고르면 실제로 큰 작업**이므로 M0 착수 전에 결정해두는 게 맞음. `modifier-plan.md`의 `__index` 저장 위치 모호성은 문서 결함이었고 이번에 수정 — 스파이크가 없었으면 M2/M6 구현 중에 터졌을 건이라, 실측 라운드 자체의 값어치를 보여준 사례.