quad/.claude/base/typing-limits.md
qwreey 19cd046275
design: 손 트레이싱 6라운드 전량 처리·반영 — H-1~H-54 확정, M2/M3 설계 게이트 해소
사용자 요청("같이 하나하나 처리해나가보자. 질문 모드로 계속 물어보며")으로
발견 보고 `H-1`~`H-54`를 문항지로 만들지 않고 **갈래 선택이 필요한 것만 급한
순서로** 물어 전량 결정하고 `base/` 24개 문서에 반영했다. 결정과 근거는
`qa-request/pre-implementation-handtrace-round6-followup.md`가 소스이고,
진행 경위와 사용자 발언 원문은
`session/2026-08-24-01-handtrace-round6-resolution.md`.

**M2/M3를 막던 것이 전부 닫혔다** — 말단 핸들러 4종의 `setLength`/
`setOffsetSource` 미등록(`H-39`), `New(): Quad`가 닫힌 타입이라 `quad.Dispatch`가
타입에러인 것(`H-25`), `Effect`의 leaf 사망 cleanup 배선 부재(`H-11`),
`:List`의 좌표계 결함 둘(`H-1`/`H-2`).

## 구조가 바뀐 것 넷

- **`slot._elemIndex`**(물리 요소→인덱스 역방향 맵) 신설로 `indexOfRaw`가 O(1)
  기본 경로가 되고, `:List`의 `keyIndex`는 **단순 키 집합(`prevKeys`)**으로
  강등. 사용자 역제안 — *"raw* 가 층위를 알아야할 이유를 모르겠는 상태 …
  realElem->index 해시맵을 만들어주고, index 밀고 당기는 동작에서 이걸 같이
  업데이트해주는 편이"*. 맵을 `_elements`와 같은 층에 두니 층 분리가 오히려
  깨끗해졌다
- **`_mounted`가 "물리 인스턴스 유무"만 뜻하게 좁혀지고 `slot._physicalTarget`
  신설** — 상태가 셋이 됐다(미실체화/실체화/마운트). `raw*`는 부기를 실체화
  시점부터 항상 하고 `native*`만 가른다. 그래야 최초 population 중
  `getOffsetAt`이 성립해 `updateFn`의 `index`를 거기서 뽑을 수 있다
- **`Ref.Callbacks`가 해시맵 셋 + `:Uncallback`**(사용자 발견) — 해제가 O(1)이
  되고 `ref-plan.md`의 `#t` border 실측 항목이 폐기됐다
- **`blocker:Policy(emit)` 노출** — `Debounce`/`Throttle`이 emit을 안 쥐고 자기
  Blocker를 On/Off만 하는 정책이 된다. `Gate`엔 `Flush`/`Cancel`을 안 둔다

요소 타입 검증은 블랙리스트에서 **주입 술어 `isInst` 기반 화이트리스트**로
뒤집혔고(`H-40`), 주입 op이 둘 늘었다(`isInst`/`onDestroying` — 조합 폴백이
불가능해 미주입이면 에러).

## 사용자가 에이전트 갈래를 뒤집은 자리가 여럿

`H-1`(세 갈래가 전부 차선), `H-2`(*"부기 확정에서 length 를 확정해도 되는거
아님?"* — 부기와 물리 마운트를 분리하라는 되물음), `H-40`(브랜드 판정이
2026-08-21 인스턴스 브랜드 재작성 이후 성립 불가임을 지적), `H-33`(제 중첩
합성안이 unblock 시 디바운스 창을 새로 시작시켜 창이 안 끝난다는 지적).
`Ref.Callbacks` 해시맵화와 `Ref` 콜백의 `canExecute` 확인은 사용자가 먼저 발견.

## 반영 후 재검토 — `/code-review high` 7건 + 감사 9라운드 34건

**`/code-review high` 7건 중 셋이 이번 반영이 만든 회귀**였다 — 상태가 셋이
됐다고 산문에 쓰고 코드엔 경계 하나만 남겨 `Slot { frameA }` 생성자가
크래시하던 것, `native*`를 `_mounted`로 가리면서 그게 곧 파괴였다는 걸 놓쳐
영구 누수를 만든 것, "`:List`와 CRUD는 상호배타"라며 승인받은 분기가
**재마운트 경로를 안 봐서** 포탈을 깬 것. 셋 다 코퍼스 정합성 각도로는
구조적으로 안 보이는 종류라 *"`/code-review`는 감사자를 대체하지 않는다"*가
실측으로 재확인됐다. 같은 리뷰가 `H-11`의 두 결정이 서로 모순임을 잡아
재결정했다 — *"`EffectHandle`이 자기 `bindLifetime` 직후에 건다"*는 **그 호출부가
실재하지 않았고**, 사용자 판단으로 `bindLifetime`/`unbindLifetime`이 `isEffect`를
보고 직접 처리하는 것으로 바뀌었다.

**`quad-doc-auditor` 감사 루프는 9라운드에서 새 발견 0건으로 수렴**(라운드별
5→7→2→2→3→9→2→4→0, 각도와 목록은 followup의 E절). 한 턴에 하나씩 돌리고
라운드마다 각도를 바꿨다. 가장 많이 잡은 6라운드(9건)는 *"이 체크박스로 코드를
짜면 무엇이 나오는가"*를 물은 라운드였고, 그때 `ROADMAP.md`의 미완료 항목이
대거 stale인 게 드러났다(폐기된 `pos` 공식이 "확정"으로, 접두합 캐시 무효화
계약이 통째로 부재, `bindLifetime`이 "둘만 한다"고 적혀 `H-11`과 직접 모순).

**반복된 실패 패턴은 하나 — "고쳐야 할 자리가 N개인데 일부만 고쳤다"**:
배너를 달고 그 배너가 부정하는 문장을 안 고침(1라운드), `base/` 19개를 바꾸고
`.claude/README.md`를 한 줄도 안 고침(2라운드), 그 README를 고칠 때 11행 중
6행만(7·8라운드). 셋 다 핸드오버 체크리스트가 명시적으로 경고하는 항목이라,
규율이 없어서가 아니라 지켰는지 스스로 확인하지 않아서 생긴 실패다.

## 백로그 하나

`Fallback`/`Traceback` 중 생성된 부분 트리의 회수(`H-26`) — 그 둘이 슈가라
구현 시점에 같이 다룬다. 같이 확인된 것: `dispatch-core-plan.md`가 잔여 부기를
인스턴스 GC가 정리한다고 적은 문장은 **gcconn 불멸성과 양립하지 않는 틀린
안전망 주장**이라 삭제했다.

**M2 착수를 막는 설계 항목은 이제 없다** — 남은 건 `question.md` 2번(M2↔M3
양방향 의존, 마일스톤 순서)뿐이다. `doc-check.py` ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01Jjrec9xAS7TZstMx5gi3cm
2026-08-24 18:54:14 +09:00

31 KiB

타입 시스템의 한계와 그 대응 — quad 전역 규약

이 문서는 "Luau 타입 시스템이 quad 설계에 대해 못 해주는 것"을 한 군데 모은 확정 문서입니다. 각 한계마다 (a) 정확히 무엇이 안 되는가, (b) 그래서 우리가 코드/문서에서 뭘 해야 하는가, (c) 언제/어떻게 풀릴 전망인가를 적습니다.

왜 따로 모으는가: 이 한계들은 여러 base/ 문서에 흩어져 각자 캐비엇으로 붙어 있었고, 그러다 보니 (1) 새 API를 설계할 때 같은 벽에 매번 새로 부딪히고, (2) "이건 우리 설계 문제인가 Luau 문제인가"를 매번 다시 판정하고, (3) 나중에 Luau가 고쳐줬을 때 어디를 되돌려야 하는지 알 수 없었습니다. 2026-08-13 열세 번째 세션에 가장 큰 한 건 (아래 1번)이 확정되면서 같이 정리했습니다.


0. 대전제 — Luau의 한계를 우회하려고 타입/API를 비틀지 않는다

[2026-08-13 확정, 사용자]

"지금으로써 quad 프로젝트가 타입을 비틀어 해당 시도를 하는 건 전혀 적합하지 않고, 이것은 상위의 Luau의 현 한계다. RFC와 해당 이슈 해결의 수혜를 받게 될 때 해결될 이슈로써, 당장 우리가 할 수 있는 바 없다."

이 원칙이 아래 모든 항목에 우선합니다. 구체적으로:

  • API의 자연스러운 모양을 타입 사정으로 바꾸지 않는다. 리액티브 파생값 API가 Compute<U>(...) -> State<U> 모양인 건 본질이고, Luau가 지금 그걸 못 다룬다고 해서 반환 타입을 any로 열거나, API를 다른 모양으로 재설계하거나, 제네릭을 포기하고 타입별 코드를 생성하는 건 전부 하지 않습니다.
  • 이유는 "게을러서"가 아니라 "그게 더 비싸서"입니다. 지금 비틀어 놓으면 (a) 비튼 만큼 복잡도와 사용자 인체공학 손해가 영구히 남고, (b) Luau가 고쳐졌을 때 자동으로 수혜를 받는 게 아니라 비튼 걸 되돌리는 별도 마이그레이션이 필요해집니다. 아래 1번의 실측이 이걸 구체적으로 보여줍니다 — 지금 그대로 두면 Luau 쪽 수정만으로 코드 변경 없이 풀립니다.
  • 대신 반드시 하는 것: (1) 한계를 이 문서에 명시, (2) 사용자 코드가 취해야 하는 관례를 명시, (3) 추적 링크(RFC/이슈)를 남겨 나중에 되돌릴 지점을 알 수 있게 함.

예외: 그 한계를 우회하는 방법이 비트는 게 아니라 그냥 더 나은 설계인 경우엔 당연히 채택합니다(아래 1번의 "쪼개기"가 실제로 그런 사례 — 코드 생성 없이 타입 선언 두 개로 끝나고, 나중에 Luau가 고쳐져도 손해가 없음).


1. 재귀 제네릭이 다른 타입 인자로 자기를 반환하면 타입 안전성이 조용히 사라짐

실측 근거: audit/type-recursion-issue/(REPORT.md + spikes/의 재현 스파이크 — 개수는 폴더가 소스). 이게 이 문서에서 가장 크고, 가장 넓게 영향을 주는 항목입니다.

무엇이 안 되는가

Compute<U>(self: State<T>, fn) -> State<U>처럼 자기 자신과 같은 이름의 재귀 타입을, 자기 타입 파라미터(T)와 다른 인자(U)로 다시 감싸서 반환하면, 반환 타입이 실제로는 해소되지 않고 Luau 내부의 Unifiable<Error>조용히 샙니다.

"조용히"가 핵심입니다 — 컴파일 에러가 나서 막히는 게 아니라, 진단 0건으로 통과한 뒤 그 결과에 대한 타입 체크만 사라집니다:

local s = n:Compute(function(x) return tostring(x:Get()) end) -- s는 State<string>이어야 함
local wrong: number = s:Get()   -- ❌이어야 하는데 에러 안 남

luau-analyze / luau-analyze --annotate / luau-lsp(새 솔버) 세 경로 전부 동일 — 도구 문제가 아니라 Luau 자체의 현 한계입니다.

정확한 경계 (오해 방지 — 이것들은 멀쩡함)

실측으로 좁힌 결과 "제네릭을 감싸서 반환하는 것" 자체는 문제가 아닙니다. 아래는 전부 정상 작동합니다:

패턴 상태
같은 인자로만 재귀(-> State<T>, U 없음) 정상
재귀 아닌 컨테이너에 담아 반환(-> Box<U>) 정상
감싸지 않고 그대로 반환(-> U) 정상
콜백 파라미터의 타입 추론 정상(아래 "쪼개기" 적용 시)
콜백 의 로직(원본 값을 어떻게 다루는지) 정상
명시 주석 이후 다운스트림 전체 정상
자기 이름을 다른 인자로 감싸 반환(State<T>State<U>) 이것만

즉 구멍은 정확히 "그 한 줄이 진짜 그 타입을 만드는가" 하나로 좁혀집니다.

그래서 우리가 하는 것 — ① 명시적 타입 바인딩 강제

파생 State를 만드는 자리마다 결과 타입을 : 주석으로 명시합니다.

-- ✅ 이렇게
local label: State<string> = count:Compute(function(c) return tostring(c:Get()) end)

-- ❌ 이렇게 두면 이후 코드 전체의 타입 체크가 사라짐
local label = count:Compute(function(c) return tostring(c:Get()) end)

주석이 그 한 줄의 RHS를 검증해주진 않지만(그게 위의 구멍), 다운스트림에는 정확히 바인딩됩니다 — 실측으로 확인: 잘못된 사용(local bad: number = label:Get())도, 없는 메소드 호출(label:NoSuchMethod())도 정상적으로 에러가 납니다. 그래서 "한 줄만 못 믿고 나머지 코드베이스 전체는 안전"한 상태가 됩니다.

이건 API 문서/예제/튜토리얼에도 그대로 반영해야 하는 관례입니다 (사용자가 무주석으로 쓰면 조용히 타입 안전성을 잃으므로) — research/documentation-plan.md/documentation-content-map.md가 문서 작성에 들어갈 때 이 관례를 초심자 트랙에 넣을 것.

그래서 우리가 하는 것 — ② 타입 선언은 "데이터부/메소드부" 쪼개기

위 1번과 별개로, 콜백 파라미터가 무주석일 때 추론이 안 되는 문제는 진짜로 풀립니다. 원인은 "로컬 제네릭 U를 가진 메소드가 자기를 재귀 참조하는 타입의 필드로 선언돼 있다"는 것이고, 그 필드가 참조하는 self 타입에서 자기 자신(Compute)을 빼면 됩니다:

export type StateData<T> = {
	Get: (self: StateData<T>) -> T,   -- 자기 자신만 재참조(Compute를 모름)
}
export type State<T> = StateData<T> & {
	Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
	-- self / 콜백 파라미터 둘 다 StateData<T>를 가리킴
}
  • 이러면 state:Compute(function(s) return s:Get() * 2 end)무주석으로 통과하고, s에 대한 타입 체크도 진짜로 살아있습니다 (없는 필드 접근하면 정상적으로 에러남).
  • 코드 생성이 필요 없습니다. State<T>는 여전히 진짜 제네릭이고, 손으로 쓰는 타입 선언이 하나 늘 뿐입니다. (한때 검토했던 "T별로 구워서 인라이닝"은 채택 안 함 — 0번 대전제 위반이고, 제네릭을 없애버려서 나중에 Luau가 고쳐져도 수혜를 못 받음.)
  • 캐비엇: 콜백이 받는 sStateData<T>Compute/With가 없습니다. 콜백 안에서 다시 s:Compute(...)를 부르는 자리 (:Apply의 factory가 대표적)는 이 방식으로 못 풀고 function(self: State<T>)처럼 파라미터 주석이 필요합니다 — 다만 :Apply는 이미 "이름 붙인 재사용 팩토리"를 권장하는 자리라 (tween-plan.mdAnimate, research/operator-sugar-plan.mdSum류) 그런 팩토리는 최상위 함수 선언이라 자연히 주석을 답니다.

그래서 우리가 하는 것 — ③ 선언 스타일: 인라인 대신 typeof(named function)

[2026-08-15 추가, 근거: audit/type-recursive-issue-with-typeof/REPORT.md] ①②와 별개로 추가 검증된 선언 스타일 규약입니다 — ①의 "명시 바인딩 강제" 원칙을 대체하지 않고, 그 위에 얹히는 보강입니다.

재귀 메소드(Compute 등)를 타입 안에 인라인 제네릭 시그니처로 직접 쓰지 않고, 이름 붙은 top-level 함수로 선언한 뒤 typeof(그함수)로 필드 타입만 참조하면:

-- ✅ 이렇게 (인라인 대신 이름 붙은 함수 + typeof)
local function Compute<T, U>(self: State<T>, func: (State<T>) -> U): State<U>
	return nil :: any
end
type State<T> = {
	Get: typeof(Get),
	Compute: typeof(Compute),
}

-- ❌ 이렇게 두면 반환 타입이 `Unifiable<Error>`로 샘(①이 다루는 원래 문제)
type State<T> = {
	Get: (self: State<T>) -> T,
	Compute: <U>(self: State<T>, func: (State<T>) -> U) -> State<U>,
}

LHS 명시 주석 없이도 다운스트림이 정확히 타이핑됩니다 — 체이닝 깊이 50, 타입이 바뀌는 체이닝(number→boolean→string→number), 콜백 안에서 self를 다시 호출하는 중첩까지 전부 실측 확인(위 REPORT 2~3절). 비용 없음(50단 체이닝 luau-analyze 0.02초 안팎, hover 타입 크기도 깊이와 무관하게 일정).

바뀌지 않는 것:

  • 콜백 파라미터는 여전히 명시 주석 필요(function(a0: State<T>)) — ②의 "쪼개기"가 다루던 문제는 별개이고, typeof가 대신 풀어주지 않습니다. 무주석이면 자유 타입 변수(a0: unknown에 가까움). [2026-08-15 추가 확인, audit/type-recursive-issue-try-callback/] 사용자 요청으로 이 캐비엇을 뚫을 방법이 있는지 type function/메타테이블/ 오버로드/제네릭 디폴트 등 20개 formulation으로 재시도했으나 quad의 state:Compute(fn) 단일 호출 모양을 유지한 채로는 여전히 못 뚫음(REPORT TL;DR 참고). 부수적으로 원인 이해가 한 단계 더 정확해짐 — 이 실패는 "재귀 자기참조 특유의 문제"가 아니라 **"제네릭이 관여하는 함수 호출의 인자로 넘긴 함수 리터럴엔 Luau가 컨텍스트 타입을 전파하지 않는다"**는 더 일반적인 한계임(재귀가 전혀 없는 Map<T,U>(arr:{T}, fn:(T)->U)류 콜백도 무주석이면 동일하게 샘). 재귀는 이 위에 얹혀 실패 양상을 "완전 무주석"에서 "구조적 duck-typing 오염"(정상 호출조차 타입에러)으로 악화시키는 부가 요인. T를 명시 중간 변수로 먼저 고정하거나 재사용 가능한 monomorphize 헬퍼 함수를 거치면 실제로 추론이 살아나는 것도 확인했지만(재귀형·quad 실제 계약·중첩 self 호출까지 검증), 둘 다 state:Compute(fn)(콜론 호출 = state.Compute(state, fn) 단일 호출)을 "먼저 특수화된 함수를 얻고 그걸 다시 호출하는" 2단계 체인으로 바꿔야만 작동해 0번 대전제로 채택 안 함. [2026-08-15 후속 조사, 같은 폴더 REPORT 11번 절] /code-review 지적으로 이중 꺾쇠 명시 인스턴스화(state:Compute<<T,U>>(fn), 코퍼스에 이미 AttributeKey<<T>>류로 존재하는 문법)도 재검토 — leaf(비-중첩) 호출에 한해 콜백 파라미터 무주석 추론이 실제로 성립하고 sound함을 확인(②쪼개기 + Get 필드의 명시 read modifier + 매 호출 T·U 전부 명시가 함께 필요, 셋 중 하나라도 빠지면 스퓨리어스 진단이나 duck-typing 함정으로 되돌아감). 이 역시 채택 안 함 — 매 호출마다 이미 self로부터 결정되는 T까지 중복 명시해야 하고 U(콜백을 쓰기도 전에 그 반환 타입)를 미리 선언해야 하는 부담이 지금의 "파라미터에 타입 하나만 주석" 관례보다 크고, :Apply 중첩 자기호출(§1②가 이미 캐비엇 건 자리)엔 여전히 안 통해 규칙이 하나로 안 줄고 오히려 둘로 늘어나며, 부분 인스턴스화(<<U만>>)는 이름이 아니라 선언 순서로 바인딩돼 이 재귀형에서 아예 내부 에러로 깨짐. 상세 근거·모든 formulation은 REPORT 11번 절.
  • 명시 LHS 오타입 그 줄 자체는 여전히 못 잡습니다(wrong: State<boolean> = test:Compute(...)처럼 대입 줄 자체에 틀린 타입을 달면 그 줄은 통과 — 다운스트림에서만 잡힘). ①이 이미 명시한 구멍과 정확히 같은 종류로, 이 규약이 그 구멍을 메워주지 않습니다 — "명시 바인딩 관례를 완화할 수 있다"는 뜻이 절대 아닙니다.
  • 옛 솔버는 ③의 typeof(Compute) 선언 자체를 거부합니다(①이 이미 겪는 것과 같은 문제 — --solver=old에서 "Recursive type being used with different parameters"로 선언 시점에 막힘, 실측: type-recursive-issue-with-typeof/spikes/ 19-oldsolver-crosscheck-rejects-typeof.luau). ③을 관례로 채택해도 §9의 "M0 실착수 때 실제 에디터 환경(luau-lsp)에서 새 솔버 확정" 전제는 그대로 유효 — ③이 이 요구사항을 없애주지 않습니다.

시도했지만 채택 안 함 — setmetatable<{...}, {__index: typeof(...)}>: 콜백 파라미터 자동 추론까지 노리고 Modifier__index+table.clone 체이닝(§7)과 같은 계열로 확장을 시도했으나, quad의 실제 계약(콜백이 self 핸들 자체를 받음)에서 콜백 반환 타입이 self의 원래 T와 다르면 (= Compute가 존재하는 이유 그 자체) 올바른 대입에도 모순되는 진단 두 개가 동시에 남는 Luau 0.733 솔버 버그를 만남 — setmetatable 없이 순수 typeof만 쓰면 같은 시나리오가 깨끗이 통과하므로, typeof 자체가 아니라 setmetatable 경로 특정 문제로 좁혀짐. 최소 재현 9줄: REPORT 5-3절.

영향 범위

API 선언 스타일 파라미터 추론 반환 타입 안전성(LHS 무주석 시)
state:Compute(fn) 인라인 + 쪼개기 쪼개기로 자동 해결 명시 바인딩 필요
state:Compute(fn) typeof(named fn)(③) 콜백 파라미터 명시 주석 필요 무주석이어도 안전(다운스트림)
state:With(...) 인라인 + 쪼개기 쪼개기로 해결(이형 dep 포함) 명시 바인딩 필요
state:Apply(factory) 인라인 factory 파라미터 주석 필요 명시 바인딩 필요
Effect(fn, ...deps) 해당 없음(자유 함수) 해당 없음(반환이 재귀 타입 아님)
state:Observer(fn) 해당 없음(로컬 제네릭 없음) 해당 없음(EffectHandle 반환)
tween:Mapped(fn) 인라인 제네릭 메소드 조용히 통과(아래 H-24)
tween:Mapped(fn) typeof(named fn)(③) 콜백 파라미터 명시 주석 필요 안전

[2026-08-24 추가, 6라운드 손 트레이싱 H-24 — 실측] Tween<T>:Mapped가 이 표에서 빠져 있었다. 확정된 시그니처 tween:Mapped(fn: (T) -> U): Tween<U>는 위 1번이 "이것만" 문제라고 못박은 모양(Foo<T> 안에서 -> Foo<U>)과 글자 그대로 같은데, 이 문서도 base/tween-plan.md도 그 사실을 몰랐다(양쪽 grep 0건).

실측:

--!strict
export type Tween<T> = { Value: T, Mapped: <U>(self: Tween<T>, fn: (T) -> U) -> Tween<U> }
local t = (nil :: any) :: Tween<number>
local mapped = t:Mapped(function(x: number) return tostring(x) end)
local wrong: number = mapped.Value   -- Tween<string>.Value는 string → 에러여야 정상

luau-analyze진단 0건(조용히 통과). 같은 검사를 ③(typeof(named function) 선언)으로 바꾸면 TypeError: Expected this to be 'number', but got 'string'으로 정상적으로 잡힌다 — 즉 기존 완화책이 그대로 통하는데 아무도 적용을 지시하지 않고 있었다. 새 설계 결정은 필요 없다.

(§6의 type function을 거친 값과는 다른 문제다 — Mappedtype function을 안 거치는 순수 제네릭 메소드다. 같은 각도로 최근 확정 표면을 훑어봤고 state:Gate(setup)/EpochMap/Effect전부 이 패턴이 아니라 무관했다 — 셋 다 제네릭 self를 다른 타입 인자로 반환하지 않는다.)

state:With(...)/state:Apply(factory)도 원리상 ③으로 같은 이득을 받을 것으로 예상되나(둘 다 Compute와 같은 "재귀 자기 반환" 모양), 아직 개별 실측은 안 함 — 실제로 base pseudocode에 ③을 반영할 때 같이 확인할 것.

Effect/Observer는 이 문제와 무관합니다 — 한때 0-Y가 "같은 lazy 핸들 계약을 공유하니 같이 걸린다"고 서술했으나 실측 결과 아니었음 (각각 자유 함수라서, 그리고 로컬 제네릭 반환이 없어서).

언제 풀리는가 — 지금 그대로 두면 자동으로 풀림

  • Luau RFC: relax-recursive-type-restriction — 완화 근거로 "This pays for itself in the considerable gain in expressivity gained for users of the type system"을 명시. RFC가 직접 드는 "지금은 거부되는" 예시가 Promise<T>.andThen: <U>(self: Promise<T>, callback: (T) -> Promise<U>) -> Promise<U>로, 우리 Compute와 글자 그대로 같은 모양입니다.
  • 완화 메커니즘은 순수 내부 변경("타입 별칭을 진짜 type function처럼 취급해 lazy expansion") — 사용자 문법 변경 없음. 즉 지금 우리가 쓰는 선언 그대로 두면, Luau 쪽이 고쳐지는 순간 코드 변경 없이 올바르게 풀립니다.
  • 추적: luau-lang/luau#2380 ("Allow recursive generic types to differ", 2026-08-13 기준 열려 있음). 이게 닫히면 이 절의 ①(명시 바인딩 강제)을 재검증하고, 불필요해지면 관례를 풀 것.
  • 참고 — 옛 솔버는 이 패턴을 선언 시점에 거부했습니다(--solver=old). 새 솔버는 선언을 받아주지만 위처럼 조용히 새는 중간 상태입니다. 즉 RFC의 완화가 선언 검사까지는 들어왔고 인스턴스화까지는 아직인 것으로 보입니다.

미리 대비해 둘 것 — 없음

RFC가 순수 내부 변경이고 우리 선언이 이미 그 대상 모양이므로, "미래에 자연히 등록되도록 플레이스홀더를 심어두는" 종류의 작업은 필요 없다는 게 실측 결론입니다. 오히려 지금 뭔가 심어두는 게 0번 대전제 위반입니다.


2. Modifier.Overridden의 서브타입 합성은 정적 체크 포기

근거: luau-test/done/09-type-modifier-overridden-subtype.luau

FrameModifierGuiObjectModifier의 서브타입이어야 자연스러운데, 필드 setter 메소드의 반환 타입이 각각 자기 자신이라 같은 이름 필드끼리 반환 타입이 갈려 구조적 서브타이핑이 깨집니다 — 실측으로 재현 확인.

우리가 하는 것: Modifier.Overridden의 시그니처를 (...: any): any류로 느슨하게 열어 정적 체크를 포기(fallback이 정상 작동함도 같이 확인됨). 상세는 base/modifier-plan.md 9-2번 절.


3. AttributeKey<<T>> 제네릭 키의 값 타입 narrowing은 안 됨

근거: luau-test/done/12-type-attribute-generic-key-narrowing.luau

[AttributeKey<<T>> "name"] = value에서 T가 이름별로 고정되지 않고 호출마다 독립 추론돼 narrowing이 전혀 강제되지 않음 — 실측으로 "안 됨" 확정.

우리가 하는 것: base/attribute-plan.md가 이미 예비해둔 fallback을 채택 — 정적 체크가 필요하면 BooleanAttribute 같은 타입 패밀리가 유일하게 믿을 수 있는 경로.


4. Source(default)/Ref(default)의 nilable 캐비엇은 타입으로 못 막음

근거: luau-test/done/14-type-nilable-default-overload.luau

default 생략이 T가 nilable일 때만 안전하다는 캐비엇을 함수 오버로드(교차 타입)로 막으려던 스케치는, 의도한 오용은 정확히 막지만 정상 nilable 사용례까지 같이 막아 채택 불가.

우리가 하는 것: 타입으로 강제하지 않고 문서 경고(UB)로 유지 — base/bind-system-plan.md의 해당 절. 새 설계 결정이 필요한 항목은 아님(대안이 이미 존재).


5. store.key 레코드 필드 타이핑(type function) — 검증 완료

[2026-08-15 확정, 근거: luau-test/done/16-type-store-key-typefunction.luau, audit/type-recursive-issue-with-typeof/REPORT.md 6-1절]

Store<T>{[K]: Source<V>} 합성을 Luau type function으로 하는 설계(pre-implementation-audit.md 1-10)는 설계와 실측 둘 다 확정입니다. 원래 스파이크가 깨졌던 이유는 설계 문제가 아니라 types.newfunction의 API 버전 드리프트였습니다 — 시그니처가 (parameters: {head: {type}?, tail: type?}, returns: {head: {type}?, tail: type?}?, generics: {type}?): type로 parameters/returns 둘 다 레코드를 받는데, 원래 스파이크는 배열({ ty })을 그대로 넘기고 있었습니다. self 파라미터는 types.newtable()이 돌려주는 뮤터블 핸들 자기 자신을 그대로 참조하면 됩니다(나중에 setproperty로 채워도 핸들이라 소급 반영됨).

수정 후 ProcessStoreType<{ty:string, count:number}>가 정확히 {ty: Source<string>, count: Source<number>} 구조를 만족하고, 음성 대조군(틀린 타입 Get/Set, 존재하지 않는 메소드) 전부 정확히 에러납니다. 에러 메시지도 t1 where t1 = { Get: (t1) -> string, ... }처럼 <Cycle> 없이 한 단계로 표기돼 아래 1번의 typeof 간접참조보다 오히려 읽기 쉽습니다.

참고 — 1번(재귀 제네릭 반환 leak)과의 관계: type function으로 Compute<U>: State<U>처럼 자기 자신을 재귀 호출하는 것(1번 문제 자체를 이 메커니즘으로 우회하는 것)은 별도로 시도해봤으나 막다른 길이었습니다 — 제네릭 인자가 아직 구체화되지 않은 채로 type function이 자기 자신을 호출하면 stack overflow로 즉시 크래시합니다(type function은 구체 타입에 대해서만 동작하는 실행 모델이라, RFC가 별칭에 주려는 "진짜 lazy expansion"과 다름 — types 라이브러리에 지연 적용을 표현하는 API 자체가 없음). 이 항목(레코드 필드 합성)과 1번은 서로 다른 문제이고, type function이 도와주는 건 이쪽뿐입니다. 상세는 audit/type-recursive-issue-with-typeof/REPORT.md 6-2절.


6. type function을 거친 값은 이후 제네릭 self 메소드 체이닝이 조용히 깨짐

[2026-08-19 신설, 근거: quad-types-plan.md, 실측 luau-test/23]

무엇이 안 되는가

값의 정적 타입이 한 번이라도 type function을 거치면(설령 그 type function이 입력을 그대로 반환하는 순수 패스스루라도), 그 뒤 그 값에 제네릭 self 파라미터를 쓰는 메소드(<Self, P>(self: Self, ...) -> Self & P류, AddPlugin이 실제 사례)를 부르면 진단이 조용히 깨집니다:

type function CheckVersion(t: type): type
	return t -- 순수 패스스루 — 재구성 없음
end

local checked: CheckVersion<Quad> = ... -- 여기까진 정상
local extended = checked:AddPlugin(somePlugin) -- 여기서 깨짐:
-- TypeError: Expected this to be exactly 'P & Self', but got 'P & Self'
-- (양쪽이 글자 그대로 같은, 의미 없는 진단)

핵심은 "재구성"이 아니라 "이력"입니다types.newtable()로 새로 조립한 타입만 문제인 게 아니라, return t로 원본을 그대로 돌려줘도 똑같이 깨집니다. 값이 type function 호출을 거쳤다는 사실 자체가 이후 제네릭 self 추론을 방해하는 것으로 보입니다(정확한 내부 메커니즘은 미상 — 솔버가 type function 출력을 "불투명한" 타입으로 취급해 self 단일화에 필요한 정보를 잃는 것으로 추정됨).

그래서 우리가 하는 것

검증/변형용 type function원본 타입을 절대 반환하지 않는다 — 성공 시에도 트리비얼한 마커(types.singleton(true) 등)만 반환하고, 그 결과를 원본과 완전히 격리된 별도 필드로만 노출합니다:

-- ✅ 원본 T는 type function을 한 번도 안 거침
type CheckedQuad<T, Pattern> = T & { __versionCheck: CheckVersion<T, Pattern> }

local checked: CheckedQuad<Quad, "0.0.0"> = ...
local _ = checked.__versionCheck -- 강제 평가(아래 캐비엇 참고)
local extended = checked:AddPlugin(somePlugin) -- 안 깨짐 — checked의 T 부분은 순수함

quad-types-plan.md의 "CheckedQuad<T, Pattern>" 절에 전체 배선과 실측 과정이 있습니다 — ①error() 대신 print+types.never, ②검증은 함수 본문 로컬 타입 별칭이 아니라 리턴/필드 타입 표현식 자체에 박아 넣어야 호출부마다 재평가됨, ③(이 항목) 원본을 절대 반환하지 않고 별도 필드로 격리, 세 가지가 함께 필요합니다. [2026-08-19 후속] 실제 버전 매칭 로직(CheckVersion)은 quad에 종속되지 않은 별도 패키지 type-version-check로 분리됐지만, 이 항목이 다루는 "패스스루도 이력만으로 오염된다" 함정과 그 회피(별도 가상 필드 격리)는 그대로 유효합니다.

언제 마주치는가

AddPlugin처럼 제네릭 self 파라미터를 쓰는 메소드가 있는 타입에, type function 기반 검사/변형을 적용하려는 모든 자리 — quad에서는 지금 quad-typesCheckedQuad<T, Pattern>이 유일한 실사례지만, 앞으로 비슷한 "타입 레벨 게이트 + 체이닝 가능한 API" 조합을 설계할 때마다 재발할 수 있는 일반 패턴입니다. 아래 §8 체크리스트에 항목 추가.


7. 성립이 확인된 것 (안심해도 되는 것)

한계만 모아두면 "타입이 다 안 되는구나"로 오독되기 쉬워서 같이 적습니다. 아래는 실측으로 통과 확인된 것들이라 다시 의심하지 말 것:

  • Source<T>State<T>를 구조적으로 만족(서브타입으로 그대로 넘길 수 있음) — luau-test/done/08. 단 단방향 의존을 유지해야 함(State<T>Source를 참조하면 안 됨 — 두 제네릭 별칭의 상호 재귀는 솔버가 취약한 패턴).
  • PreRef<T>Ref<T> 자리에 대입 가능luau-test/13 A섹션. [2026-08-14 아홉 번째 세션] PostRef<T>도 완전히 같은 관계(같은 Ref 런타임 재사용, 브랜드 태그만 다름 — base/ref-plan.md의 "PostRef" 절) — 13은 지금 rewrite-required/라 재작성할 때 PostRef도 같이 커버할 것.
  • Modifier의 제네릭 __index + table.clone 체이닝luau-test/done/17.
  • 콜백 파라미터/본문의 타입 체크(1번의 쪼개기 적용 시) — 진짜 살아있음.

8. 새 타입/API를 설계할 때 체크리스트

  1. 자기 이름을 다른 타입 인자로 감싸 반환하는가?(Foo<T> 안에서 -> Foo<U>) → 1번 한계에 걸림. 설계를 바꾸지 말고(0번 대전제), 메소드를 인라인 대신 이름 붙은 함수 + typeof로 선언(1번 ③)하고, 그래도 명시 바인딩 관례는 그대로 문서에 같이 적을 것 — ③은 ①을 대체하지 않음. ⚠️ ③은 반환 타입 안전성(LHS 무주석 다운스트림)만 고쳐줍니다 — 콜백 파라미터 자동 추론이 같이 필요하면 이것만으론 부족하니 바로 아래 2번도 같이 볼 것.
  2. 로컬 제네릭을 가진 메소드가 재귀 타입의 필드인가? → 콜백 파라미터 자동 추론이 필요하면 1번의 "쪼개기"(XxxData<T> / Xxx<T> 분리)를, 반환 타입 안전성이 우선이면 1번 ③(typeof)을 적용할 것 — 필요하면 병행 가능(개별 검증은 아직 안 됨, 위 "영향 범위" 표 참고). setmetatable<{...}, {__index: typeof(...)}>로 확장해 두 이득을 한 번에 얻으려 하지 말 것 — quad의 self-핸들 콜백 계약에서 솔버 버그를 만남(1번 ③의 "시도했지만 채택 안 함" 참고). 콜백 파라미터 무주석 자동 추론 자체가 필요하면(쪼개기로 해결 안 되는 자리, 예: :Apply의 factory) audit/ type-recursive-issue-try-callback/이 전방위로 재시도했지만 quad가 채택할 만한 방법은 못 찾음(REPORT 결론) — 지금은 명시 주석 관례를 유지할 것.
  3. 제네릭 키로 값 타입을 좁히려 하는가? → 3번, 안 됨. 타입 패밀리를 쓸 것.
  4. 서브타입 관계인 두 타입을 합성하려 하는가? → 2번, 메소드 반환 타입이 갈리면 깨짐.
  5. 타입으로 오용을 막으려 하는가? → 4번 사례처럼 정상 사용례까지 막는 경우가 흔함. 막기 전에 정상 사용례를 반드시 같이 테스트할 것.
  6. 위 어디에도 안 걸리는데 안 되는 것 같다luau-test/에 스파이크를 추가하고 실측할 것. 추론만으로 "된다/안 된다"를 확정하지 말 것 — 이 문서의 항목 중 여러 개가 "된다고 믿었다가 실측에서 뒤집힌" 것들입니다.
  7. type function으로 검사/변형한 값에 제네릭 self 메소드 (<Self,P>(self:Self,...)류)를 나중에 부를 계획인가? → 6번 한계에 걸림. 그 type function이 원본 타입을 조금이라도 반환하면(패스스루 포함) 안 됨 — 검사 결과는 원본과 절대 안 섞이는 별도 필드로 격리하고, 원본 타입 자체는 type function을 아예 거치지 않게 할 것.

실측 방법 주의: luau-analyze가 진단 0건이어도 타입이 제대로 해소됐다는 뜻이 아닙니다(1번이 정확히 그 사례). luau-analyze --annotate로 추론된 실제 타입을 눈으로 확인하고, 가능하면 "일부러 틀린 타입에 대입해서 진짜 에러가 나는지" 음성 대조군을 같이 둘 것.


9. 미해결 / 추적 중

  • [2026-08-19 설정 완료] 에디터(luau-lsp)의 솔버 설정 — luau-analyze CLI는 새 솔버가 기본값이지만 luau-lsp옛 솔버가 기본값 (LuauSolverV2=false)이라 같은 코드에 다른 진단이 남. luau-lsp 바이너리(1.69.0)를 직접 설치해 luau-lsp analyze --flag:LuauSolverV2=true/false로 실측 — 새 솔버가 필요하다는 결론 재확인, quad/.vscode/settings.json"luau-lsp.fflags.enableNewSolver": true를 반영·커밋 완료(tbox도 같은 설정 확인). 실제 VSCode 세션에서 이 설정이 반영되는지 육안 확인만 사람 몫으로 남음(HUMAN_TODO.md 6번).
  • luau-lang/luau#2380 — 닫히면 1번 관례 재검증(③ 포함).
  • state:With(...)/state:Apply(factory)에 1번 ③(typeof) 개별 실측Compute에서만 확인됐고, base pseudocode에 실제로 반영할 때 같이 확인할 것.
  • setmetatable+typeof(genericFn<<T>>()) 조합의 모순 진단 버그(1번 ③ "시도했지만 채택 안 함") — quad는 이 formulation을 안 쓰기로 해서 quad 쪽에서 더 팔 필요는 없지만, Luau 0.733의 실제 솔버 버그로 보이므로 이미 알려진 이슈인지 확인 후 업스트림 제보 검토(최소 재현 9줄, audit/type-recursive-issue-with-typeof/spikes/ 08-metatable-BUG-contradictory-diagnostics.luau).