사용자 요청("같이 하나하나 처리해나가보자. 질문 모드로 계속 물어보며")으로
발견 보고 `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
11 KiB
Fallback/Traceback — 컴포넌트 에러 격리 유틸
상태: base — 확정(2026-08-14 세션). research/에서 신설(사용자 제안) →
luau 스파이크로 xpcall/debug.traceback 배선
실측(같은 날 두 번째 세션) → Fallback/Traceback 분리·정확한 제네릭
시그니처·err: any 확정(같은 날 세 번째 세션, 사용자 확정)까지 한 흐름 —
base/로 승격. 구현 우선순위는 여전히 맨 뒤(아래 "우선순위" 절), 승격은
설계가 다 정해졌다는 뜻이지 지금 만든다는 뜻이 아님.
동기
컴포넌트마다 개별적으로 pcall을 직접 감싸는 건 실용적이지 않음 — 매
호출 자리마다
local ok, result = pcall(MyComp, props); if not ok then ... end를
손으로 반복해 쓰는 건 번거롭고 빠뜨리기도 쉬움. 대신 컴포넌트
함수 하나를 받아서 "에러 나면 자동으로 플레이스홀더를 그려주는 버전"으로
바꿔주는 아주 단순한 유틸이면 충분함 — 클린업 동작(언마운트/리소스 해제)이
목적이 아니라, 실제 에러가 났을 때 디버깅이나 프로덕션 유저 리포트를
편하게 만드는 게 유일한 목적.
왜 새 프리미티브가 아닌가
research/additional-primitives-plan.md가 이미 "Error Boundary는 빈
자리 아님 — pcall(MyComp, props)만으로 React Error Boundary와 같은
격리 효과를 프레임워크 지원 없이 얻는다"고 확정해둔 결론을 뒤집는 게
아니라, 그 결론 위에 얹는 순수 슈가(그 문서를 다시 열 필요 없음) —
Operator 콤비네이터(research/operator-sugar-plan.md)가 :Compute/
:Apply 위에 얹힌 것과 같은 관계. Fallback/Traceback 둘 다 original을
호출하고 결과를 그대로 돌려주는 순수 함수일 뿐, 디스패치/Store/Handler
계층에 아무것도 새로 안 만듦.
API — 왜 둘로 나뉘는가
Fallback<OkComp, ErrComp, Args...>(
base: (Args...) -> OkComp,
onError: (err: any) -> ErrComp
) -> (Args...) -> (OkComp | ErrComp)
Traceback<OkComp, ErrComp, Args...>(
base: (Args...) -> OkComp,
onError: (err: any, trace: string) -> ErrComp
) -> (Args...) -> (OkComp | ErrComp)
Fallback—pcall기반, 가벼움,onError엔err만 넘어감(trace 없음).Traceback—xpcall+debug.traceback기반,onError엔err와 함께trace: string이 항상(옵셔널 아님) 넘어감.- 왜 플래그 하나로 안 합쳤는가: quad는 이미 이런 갈림을 별도 타입/함수로
가르는 쪽을 택해왔음(
Ref/PreRef가 같은 예) — 항상xpcall+debug.traceback비용을 물지 않아도 되는 가벼운 경로를 자연스럽게 분리해 둘 수 있고,onError의 시그니처 자체가 달라서(trace 유무) 타입으로도 둘을 구분하는 게 더 정확함. OkComp/ErrComp를 하나로 합친Comp가 아니라 독립 제네릭으로 둔 이유: 원래 컴포넌트와 에러 플레이스홀더가 다른 컴포넌트 타입일 수 있고, 래핑된 함수의 실제 반환 타입은 정확히OkComp | ErrComp유니온이기 때문(사용자 확정).onError자신이 추가 컨텍스트를 캡처하려고 커링된 클로저인 건 완전히 자유 —Fallback/Traceback은 여기 관여하지 않음(아래 예시).
local SafeWidget = Fallback(Widget, function(err)
return ErrorPlaceholder { Message = err }
end)
-- 추가 컨텍스트가 필요하면 onError 쪽에서 그냥 커링
local function makeErrorHandler(context)
return function(err, trace)
return ErrorPlaceholder { Message = err, Context = context, Trace = trace }
end
end
local SafeWidget2 = Traceback(Widget, makeErrorHandler(someContext))
-- 호출부는 원래 컴포넌트 대신 그대로 씀
Frame { SafeWidget{ ... }, SafeWidget2{ ... } }
err: any임을 반드시 문서화 — 흔한 함정
Lua/Luau의 error()는 문자열이 아닌 임의의 값(테이블 등)을 던질 수
있음 — Fallback/Traceback 둘 다 err를 any로 그대로 전달하고
어떤 가공도 안 함. error(msg)를 레벨 지정 없이(Luau 기본 level=1)
호출하면 err가 문자열이더라도 quad가 아무것도 안 붙였는데 Luau가
자동으로 "파일:줄: " 위치 접두를 붙여서 옴 — error(msg, 0)으로
호출해야 접두 없는 순수 메시지가 옴. 다들 err를 string으로 가정하고
코드를 짜는 게 제일 흔한 실수라 문서화에서 최우선으로 경고할 것(가공은
Fallback/Traceback이 대신해주지 않음 — 가공까지 대신해주면 그게 또
다른 매직이라는 원칙, onError 구현 몫으로 완전히 열어둠).
메커니즘 스케치
function Fallback(base, onError)
return function(...)
local ok, resultOrErr = pcall(base, ...)
if ok then
return resultOrErr
end
return onError(resultOrErr)
end
end
function Traceback(base, onError)
return function(...)
local trace: string? = nil
local ok, resultOrErr = xpcall(base, function(err)
trace = debug.traceback(nil, 2)
return err
end, ...)
if ok then
return resultOrErr
end
return onError(resultOrErr, trace :: string)
end
end
Traceback의 debug.traceback(nil, 2) 배선(클로저 업밸류가 xpcall
리턴 이후에도 정상적으로 보이는지, 중첩 호출에서도 실패 지점까지
스택을 정확히 담는지)과 err: any(테이블 에러도 손실 없이 통과하는지)는
luau 스파이크로 실측 확인됨 — audit/fallback-xpcall-verification.md
참고(스크립트: audit/fallback-xpcall-spike.luau).
research/debug-tooling-plan.md가 이미 확인해둔 선례(Vide/Fusion 둘 다
xpcall+debug.traceback으로 에러 나는 순간에만 스택을 찍는
패턴)를 그대로 재사용 — 새 트레이싱 메커니즘을 발명하지 않음.
⚠️ 미해결 — 실패 이전에 생성된 부분 트리는 회수되지 않는다 (2026-08-24 신설, 6라운드 손 트레이싱 H-26)
상태: 백로그. Fallback/Traceback 자체가 슈가라 그 둘을 구현할 때 같이
다룬다(사용자 판단, 2026-08-24: "의도적으로 error 를 사용하고자 하는 경우
항상 컴포넌트들이 쌓이거든. 이건 후행에서 더 다뤄보도록 백로깅해줘.
fallback/traceback 자체가 슈거라서, 그 때 가서 생각해도 될듯").
무엇이 문제인가: quad는 자기가 만든 Instance마다 생성 즉시 gcconn을 걸고 그
클로저가 inst를 캡처하므로(base/lifecycle-pattern.md의 "(0)" 절)
참조를 놓는 것만으로는 회수되지 않고 반드시 Destroy로만 회수된다. 이
모델은 "만든 Instance는 언젠가 반드시 Destroy된다"를 전제하는데, 컴포넌트가
자기 리터럴을 만드는 도중 예외를 던지면 그때까지 완성된 형제/자손은 트리에
붙지도, Destroy되지도 않은 채 예외에 실려 스코프를 빠져나간다.
local function Broken(props) error("bug!") end
local function Parent(props)
return Frame {
Frame { Text = "child A" }, -- (1) 완주 — gcconn/gchold 확정
Broken {}, -- (2) error
Frame { Text = "child C" }, -- (3) 도달 안 함
}
end
local SafeParent = Fallback(Parent, function(err) return Frame { Text = tostring(err) } end)
Lua 테이블 생성자는 원소를 좌→우로 완전히 평가하므로 child A는 실제
Instance로 완성되고 gcconn이 걸린다. 바깥 Frame(...) 호출은 인자 테이블조차
완성 못 해 호출되지 않는다. Fallback은 pcall(base, ...) 하나라 그
존재를 알 방법이 없고, child A는 (a) 어떤 지역 변수에도 안 남고 (b) 세팅된
적 없고 (c) 아무도 모르므로 — 자기 gcconn↔gchold 순환만이 그를 살려두고 그걸
끊는 유일한 수단(Destroy)을 부를 주체가 없다. 참조 0개인데 세션 끝까지 안
죽는다. 실패 지점 앞에 중첩 서브트리가 있으면 그 전체가 대상이다.
왜 Fallback/Traceback이 대표 사용처인가: 이 문제는 이 둘 전용이 아니라
"부분 실패 후 아무도 Destroy를 안 부르는 모든 경로"의 일반적 위험인데,
그 경로를 계속 살려두는 것을 존재 이유로 삼는 게 정확히 이 둘이다.
예외가 밖으로 나가 아무것도 안 그려지는 기본 경로는 서술 정정으로 끝났지만
(같은 날 base/dispatch-core-plan.md에서 잔여 부기가 인스턴스 GC로 정리된다고
적은 틀린 안전망 주장을 삭제했다), 이쪽은 앱이 계속 도는 걸 약속하는
자리라 층위가 다르다.
구현 시 검토할 갈래(지금 정하지 않음):
New/Dispatch.drive가 "이번 construction에서 만든 것" 목록을 쌓고pcall실패 시 역순Destroy— 누수를 실제로 닫지만 정상 경로에도 부기 비용이 붙고, 중첩 구성 경계를 어떻게 잡을지가 또 문제다.- gcconn 자기순환 재고(한 번도 Parent 안 된 채 참조가 끊기면 GC 가능하게) —
lifecycle-pattern.md(0)의 핵심 트레이드오프를 건드리므로 재설계 규모가 크다. - UB로 명문화 + 컴포넌트 저작자에게 "생성 전에 검증부터" 권고.
참고 — 인접 사례는 이미 인정돼 있다: materializeSlotTree의 예외 시
Blocker가 켜진 채 남는 갭은 사용자 판단으로 이미 *"마운트 도중 예외는 quad가
복구를 보장하지 않는 상태"*로 정리됐다(base/slot-plan.md). 다만 그건
마운트 경로에 한정된 국소적 결과다.
패키지 배치
quad-base — base를 그냥 호출하고 결과를 그대로 돌려주는 순수 함수라
Store/Dispatch 어디에도 안 걸림, 엔진 지식이 전혀 필요 없음. Operator
콤비네이터와 같은 결(사용자 확정).
이름
Fallback/Traceback 확정 — 낱개 함수 둘뿐이라 Tag/Attribute류
네임스페이스가 필요했던 것과 달리 충돌 표면이 작다고 판단, 용어 정리
대기열에 안 올리고 바로 점유(사용자 확정).
우선순위
형제 백로그 항목들(quad-mock/quad-debug/문서 사이트/Operator)과
동급, 맨 뒤 — "quad 개발 상당 부분 끝난 뒤"로 사용자가 못박은 후순위
(.claude/todos.md 4번). 이 문서가 base/로 승격된 건 설계가 다
확정됐다는 뜻이지, 구현 착수 순서가 앞당겨졌다는 뜻이 아님 — Operator처럼
순수 슈가라 없어도 quad 기능상 완전함(pcall을 직접 쓰면 되므로).