10. 부록 — quad 에러 읽는 법과 자주 밟는 함정
This content is not available in your language yet.
대상 독자: quad 에러를 빠르게 추적하려는 개발자 다루는 개념: 표면(surface) 블레임, 자주 밟는 함정 일곱과 해결책
-- 01장의 설정 모듈: quad_base에 quad_roblox를 설치하고 타입을 다시 내보낸다(시작하기 01 참고)local q = require("@game/ReplicatedStorage/Client/UI/Quad")1. quad의 에러는 “사용자 줄”을 가리키려 한다
섹션 제목: “1. quad의 에러는 “사용자 줄”을 가리키려 한다”에러 메시지의 첫 토큰은 QuadNNNN 식별자입니다(예: Quad0145 Slot:List: duplicate key "a"). 문구보다 번호로 찾는 편이 빠릅니다 — 레퍼런스 › 에러 코드에 번호마다 언제 나는지와 고치는 법이 있습니다. 번호가 없으면 quad 자신이 던진 에러가 아닙니다 — 엔진이 낸 에러, 여러분의 콜백이 던져 그대로 되던져진 에러, q.debug의 진단 줄(에러 아님) 중 하나입니다.
quad는 공개 표면 함수에 태그를 달아두고, 에러를 낼 때 스택에서 태그된 프레임을 걷어내 그 바깥의 사용자 줄을 blame합니다.
ReplicatedStorage.Client.UI.UserProfile:42: Quad0218 Event: handler for "Activated" must be a function (got table)기대할 수 있는 것
섹션 제목: “기대할 수 있는 것”- 메시지 형식이
주어: 이유로 고정돼 있고(받은 값을 말할 때만(got X)꼬리가 붙습니다), 메시지 리터럴은 raise하는 줄에 통째로 남아 있습니다(포맷 헬퍼를 쓰지 않습니다) — 눈에 띄는 조각을 소스에서grep하면 던진 자리가 바로 나옵니다. 보간은 값 부분에만 들어갑니다. - 대부분의 경우
파일:줄접두는 사용자의 선언 줄을 가리킵니다. - 어느 줄을 가리키는지의 규칙 — 인자를 직접 검증하는 표면은 늘 그 호출 줄을 가리킵니다(콜백 안에서 불렀다면 콜백 안의
그 줄). quad가 대신 불러 주는 핸들러를 여러 겹 거쳐야 판정되는 소수의 자리(선언의 디스패치 —
q.Elem "Frame" {}·q.Dispatch.drive, 그리고 핸들러 작성자용bindLifetime·getOffsetAt)만 콜백을 깨운 가장 바깥 진입 줄을 가리킵니다(아래 한계 2).
기대하면 안 되는 것 — 알려진 한계 셋
섹션 제목: “기대하면 안 되는 것 — 알려진 한계 셋”- C 프레임이 태그된 표면을 직접 부르면
파일:줄접두가 사라집니다 (pcall(quad함수, ...)처럼 quad 함수를 그대로 넘기는 경우). 메시지는 살아남습니다 — 접두가 필요하면pcall(function() ... end)으로 감싸세요. - 콜백 안에서 일어난 디스패치는 바깥 진입 줄이 blame됩니다.
Observer·Compute·Effect·slot:List의updateFn안에서q.Elem "Frame" {}을 만들거나q.Dispatch.drive처럼 최외곽 스캔으로 던지는 표면을 부르면, 콜백 안의 그 줄이 아니라 그 콜백을 깨운 바깥 줄(:Set·:Get·마운트 줄)이 blame됩니다. 인자 검증 계열(Slot:Add(nil)·q.Tag("")·q.Claim(...)안의 검사 등)은 콜백 안에서도 그 줄을 정확히 가리킵니다. - quad가 대신 읽거나 쓰는 자리에서 난 에러는 quad 내부 파일을 가리킬 수 있습니다. 계산 함수가 던졌거나 (정의되지 않은 동작인) yield를 해서
같은 세대의 재읽기가 막힌 State(
Quad0184)를 여러분의 코드가 아니라 quad가 읽으면 — 프로퍼티 바인딩,Animate, Operator,slot:List·:Single,Debounce의Time같은 자리 —파일:줄접두가StateHandler.luau같은 quad 파일을 가리킵니다.ref:Wait(thread)로 지금 코루틴을 등록한 채 같은 코루틴에서 그 Ref를 마운트해 난Quad0130도 같습니다. 메시지와 traceback은 그대로 나오니 traceback에서 여러분 파일의 가장 가까운 줄을 보세요 — 원인은 대개 그 State의 계산 함수(Quad0184)나Wait등록(Quad0130)입니다.
2. 자주 밟는 함정 일곱
섹션 제목: “2. 자주 밟는 함정 일곱”함정 1: nil-hole — 숫자 키 자리에 구멍 내기
섹션 제목: “함정 1: nil-hole — 숫자 키 자리에 구멍 내기”- 증상: 대개는 만드는 자리에서 곧바로 에러(아래 문구). 꼬리 구멍만 조용히 넘어가 그 자리의 값이 무시됩니다.
- 원인: 숫자 키 자리의
nil은 테이블을 sparse하게 만듭니다. 디스패치는 숫자 키 자리를 구멍 없는 시퀀스로 전제하고(순회에서 그 자리가 빠지고, 부기 쪽은 길이#에 기대는데 구멍 있는 테이블의#는 명세되지 않음) 따라서 배열 구멍은 정의되지 않은 동작(UB) 입니다. 실제 결과는 구멍의 위치에 따라 갈립니다 — 구멍 뒤에 값이 오면(1번 자리나 중간이 구멍) 위치 부기가 어긋나 그 자리에서 에러가 나고 (Quad0023 Bookkeeping.recompute: sourceList[1] is nil - a nil hole in the numeric-key part of props ({ a, nil, b })? fill the optional slot with q.None; …— 물음표까지가 사용자에게 하는 말이고 세미콜론 뒤는 핸들러 작성자용입니다), 꼬리 구멍은 우연히 통과합니다 — 선택적 값이 전부 빠져{ nil, nil, Text = "x" }가 되면 숫자 부분이 비어 있어 잡을 것이 없으므로 에러 없이 지나갑니다(그래도 계약 밖 — [2026-09-27 mock 실측]). - 해결책: 선택적 값 뒤에는 **
or None**을 붙입니다.
-- ❌ props.Modifier가 nil이면 배열에 구멍이 생긴다q.Elem "Frame" { props.Modifier, props.Ref, Size = UDim2.new() }
-- ✅q.Elem "Frame" { props.Modifier or q.None, props.Ref or q.None, Size = UDim2.new() }함정 2: Ref:Wait()에서 영원히 멈춤
섹션 제목: “함정 2: Ref:Wait()에서 영원히 멈춤”- 증상: 스레드가
myRef:Wait()에서 재개되지 않음. - 원인:
Ref:Wait()는 항상 다음:Set호출을 기다립니다 —Ref<T?>에서nil도 정당한 값이라 “이미 채워졌는지”를Wait안에서 판정할 수 없기 때문입니다. 이미 채워진 Ref에 걸면 새:Set이 없는 한 깨어나지 않습니다. - 해결책: 채워졌는지를 먼저 직접 확인합니다.
-- ✅ 이미 있으면 즉시, 없으면 다음 채워짐을 대기local inst = if myRef.Value then myRef.Value else myRef:Wait().Value값이 있어야만 진행되는 자리에서는 :Unwrap()이 더 짧습니다. 비어 있으면
호출한 줄에서 던집니다:
Quad0135 Ref:Unwrap: the Ref is empty (Value is nil) - not filled yet, or never placed또 Wait()는 양보 가능한 코루틴 안에서만 부를 수 있습니다 — 아니면
Quad0134 Ref: Wait() must be called from a yieldable coroutine (pass a thread to register a waiter without yielding).
함정 3: Ref를 Modifier 안에 넣기, 또는 문자 키로 넘기기
섹션 제목: “함정 3: Ref를 Modifier 안에 넣기, 또는 문자 키로 넘기기”- 증상:
Modifier에 넣으면Quad0057 Modifier: field "Ref" cannot hold a handler-layer value (Ref/Observer/Effect/Slot/Modifier/Spread/mapper descriptor)(세터 체인:Field로 넣어도 같습니다). 문자 키로 주면 평범한Ref는Quad0076 Dispatch: no handler matched key Ref (value: table, brand: Epoch, Ref) - a quad value at a string key: it belongs in a numeric (array) slot가,PreRef/PostRef는Quad0068 PreRef: must be an array item, not the value of a string key(주어만 바뀜)가 납니다 — 단 그 문자 키가 실제 반영 프로퍼티나 이벤트 이름이면 이 에러에 닿기 전에 프로퍼티·이벤트 핸들러가 값을 받아 다른 결과가 납니다(Name = ref는 번호 없는 타입 에러,Size = ref는 에러 없이Ref테이블이 대입되는 조용한 오염,Activated = ref는Quad0218— Ref 레퍼런스의 “숫자 키 자리에만 놓는다” 절; strict가 이 자리를 잡아 줍니다). - 원인:
Modifier는 여러 인스턴스에 재사용되는 스타일 가방이라, 단일 인스턴스에 바인딩되는 핸들러 층 값(Ref/Observer/Effect/Slot/Modifier/Spread)을 담을 수 없습니다. 그리고 이 값들은 props의 숫자 키 자리에 놓는 것이 계약입니다 —Ref = ...처럼 문자 키로 주면 어떤 핸들러도 그 키를 받지 않습니다.
-- ✅ 숫자 키 자리에 그대로local function CustomInput(props: { InputRef: q.Ref<TextBox?>? }) return q.Elem "TextBox" { props.InputRef or q.None, PlaceholderText = "...", }end함정 4: 이미 claim된 인스턴스에 다시 Claim
섹션 제목: “함정 4: 이미 claim된 인스턴스에 다시 Claim”- 증상:
Quad0029 Claim: … is already claimed by quad …(Claim이 1패스에서 잡습니다; 백엔드 쪽Quad0225 nativeClaim: …는 파괴된 인스턴스를 GC 전에 다시 claim하는 정의되지 않은 경로에서만 봅니다). - 원인: 한 인스턴스는 생애 동안 한 번만 claim됩니다.
q.Elem가 만든 인스턴스도 이미 claim된 상태이고, 부모를Claim할 때 매핑된 자식도 같이 claim되므로 그 자식에 다시 걸면 여기서 막힙니다. 자세한 계약은 07. Studio에서 만든 UI에 반응성 붙이기. - 이미 파괴된 인스턴스를 다시 쓰면 사촌 격 에러가 납니다 —
Quad0226 bindLifetime: this Instance is not claimed by quad - it was not created or claimed through quad, or it has already been destroyed (a destroyed Instance cannot be reused).
함정 5: Source에 Modifier를 담기
섹션 제목: “함정 5: Source에 Modifier를 담기”- 증상:
Quad0181 Source: cannot hold a Modifier as a Source value. - 해결책: 바뀌는 것은 스타일 필드이므로, 필드마다
Source를 두고Modifier가 그Source를 참조하게 합니다(그 방향은 정상입니다).
local themeColor = q.Source(Color3.fromRGB(255, 0, 0))
-- 빌더 체인local dynamicStyle = q.Modifier "Frame" ():BackgroundColor3(themeColor)
-- 초기 필드 테이블도 같은 값local dynamicStyle2 = q.Modifier "Frame" { BackgroundColor3 = themeColor }함정 6: State<Store>의 필드를 그냥 꺼내려 하기
섹션 제목: “함정 6: State<Store>의 필드를 그냥 꺼내려 하기”- 증상: 스토어를 통째로 갈아끼우는
currentStore:Set(newStore)를 넣었는데,currentStore.Hp같은 접근이nil이거나 타입 에러. - 원인: 중첩
State는 런타임에서 지원됩니다 —State<State<T>>는 프로퍼티 자리에서 재귀적으로 풀립니다(단 strict 타입은 한 겹만 받아Text = which처럼 타입 있는 자리에 놓으면 진단이 납니다 —:: any로 넘기거나 안쪽을Compute로 펴세요, [2026-09-27 실측]). 문제는State에 값의 필드를 꺼내는 dot-access가 없다는 것입니다.Store를 담은State의 필드를 반응형으로 읽으려면 명시적인 읽기 콤비네이터가 필요합니다. - 해결책 (a): 필드 읽기는
q.Operator.Indexed로. 결과 타입은 호출자가 직접 지정합니다(키에서 추론하지 않습니다).
local theme = q.Source({ Primary = "red", Size = 10 })local primary: q.State<string> = theme:Apply(q.Operator.Indexed<<string>>("Primary"))-- theme:Set({ Primary = "blue", Size = 12 }) → primary:Get() == "blue"값이 테이블이 아니면 읽는 시점에
Quad0127 Operator.Indexed: value is not a table (got number) - cannot read [Primary].
- 해결책 (b): 폼 리셋처럼 스토어를 새로 만들 이유가 없다면, 스토어를 바꾸지 말고 필드를 리셋하세요.
local function resetForm(store) store.Username:Set("") store.Age:Set(0) store.AgreedToTerms:Set(false)end- 곁가지 —
State를 돌려 값을 고르기.State<State>는 지원되지만, “둘 중 하나를 고르는” 계산은 고를State들을 의존성으로 넣고 값을:Get()해 돌려주는 것이 정식입니다.
local useBig, small, big = q.Source(false), q.Source("small"), q.Source("BIG")local label: q.State<string> = useBig:Compute(function(self, _, small: q.StateData<string>, big: q.StateData<string>) return if self:Get() then big:Get() else small:Get()end, small, big)State 자체를 돌려 State<State>로 고르면, 바깥은 안쪽 값을 직접 읽지(pull) 않으므로 안쪽이 지금
파동을 받았는지 모릅니다 — 같은 원천에서 한 번의 변경으로 선택과 안쪽 값이 함께 바뀌면, 빠진 가지의
(그 자체로는 옳은) 새 값이 한 번 쓰였다가 새 선택으로 바뀔 수 있습니다. 숫자 키 자리에서 그 값이 이미
다른 곳에 마운트된 Instance라면 숫자 키 자리든 Slot 원소 자리든 Quad0156이 납니다. 이건 결함이 아니라
의도된 구조입니다 — 위처럼 의존성으로 넣고 :Get()하면 한 파동이 한 번으로 접혀 한 번만 씁니다
(state:Compute).
함정 7: 같은 Slot에 수동 CRUD와 :List/:Single을 섞기
섹션 제목: “함정 7: 같은 Slot에 수동 CRUD와 :List/:Single을 섞기”- 증상: 어느 쪽을 먼저 썼든, 나중에 쓴 쪽이 즉시 에러로 막힙니다.
수동 CRUD(
:Add/:Remove/:Move/:Swap/:Extract/:Replace/:Splice/:Clear/:ExtractAll) 뒤에:List나:Single을 걸면Quad0149 Slot: cannot install :List/:Single/:Lazy on a manual Slot (one that used CRUD or was built as Slot{ ... }, even empty), 반대로:List/:Single을 먼저 건 뒤에 수동 CRUD를 부르면Quad0166 Slot: manual CRUD is not allowed after :List/:Single/:Lazy이 그 CRUD를 부른 줄에서 납니다. - 원인: 한 Slot은 태어날 때부터 수동 모드이거나
:List/:Single이 설치된 리스트 모드이고, 이 둘은 같이 못 씁니다 —Slot{ ... }로 원소를 하나라도(빈 배열이어도) 넘겨 만들었거나 CRUD를 한 번이라도 부르면 그 순간 수동으로 굳고,:List/:Single을 한 번 걸면 리스트로 굳습니다. 리스트 모드의 원소는updateFn이 데이터에서 매번 다시 만들어내는 파생값이라, 거기에 수동 CRUD로 끼어들면 어느 쪽이 진짜 목록인지 정의할 수 없기 때문입니다. - 해결책: 한 Slot은 시작할 때 둘 중 하나로 정하세요 — 데이터 배열이
있다면 처음부터
:List/:Single을 쓰고, 손으로 넣고 뺄 자리라면 끝까지 수동 CRUD만 쓰세요.
-- ❌ 수동으로 시작했다가 나중에 :List로 갈아타기local s = q.Slot()s:Add(q.Elem "Frame" {})s:List(items, function(ctx) return q.Elem "TextLabel" { Text = ctx.Item :: string } end) -- 에러
-- ✅ 데이터 배열이 있다면 처음부터 :Listlocal s2 = q.Slot()s2:List(items, function(ctx) return q.Elem "TextLabel" { Text = ctx.Item :: string } end)3. 디버깅 체크리스트
섹션 제목: “3. 디버깅 체크리스트”| 점검 항목 | 올바른 작성법 |
|---|---|
| 선택적 값 전달 | props.Modifier or q.None — 숫자 키 자리의 nil은 구멍이다 |
| 핸들러 층 값 자리 | Ref/PreRef/PostRef/Observer/Effect/Slot/Modifier/Spread는 숫자 키 자리 |
| Ref 대기 | if ref.Value then ref.Value else ref:Wait().Value, 또는 ref:Unwrap() |
| 프로퍼티 감시 | q.OnChange "Class" "PropertyName" (function(v) ... end) — 숫자 키 자리 |
| 어트리뷰트 삭제 | q.Attr { MyKey = q.None } — 그룹에서 이름을 빼는 것만으로는 지워지지 않는다 |
| 부모 붙이기 | Parent는 props가 아니다 — 만든 뒤 밖에서 inst.Parent = ... |
| 이벤트 콜백 | 엔진 인자만 온다(self 없음). self가 필요하면 PreRef로 인스턴스를 잡아둘 것 |
| 인스턴스가 아직 살아 있나 | q.Backend.isClaimed(inst) — quad가 만든(또는 Claim한) 인스턴스가 지금 quad 손에서 살아 있으면 참, quad가 죽였거나 처음부터 quad 것이 아니면 거짓. Destroying 처리 순서에 기대지 말 것(Deferred 시그널에서는 자손의 파괴 처리가 부모의 것보다 먼저 돌 수 있다) — 레퍼런스 |
Effect가 걸어 둔 비동기 콜백 |
콜백 첫 줄에 if not q.Backend.canExecute(self) then return end(self는 fn이 받은 핸들) — 핸들이 더는 돌 수 없게 된 뒤 cleanup이 연결을 끊기 전 틈에 온 호출을 건너뛴다. fn 안에서는 묻지 않는다(생성 자리의 첫 실행은 묶이기 전이라 늘 거짓) — 레퍼런스 |
| 조건부 자식 | 숫자 키 자리에 (if cond then q.Elem "TextLabel" { ... } else q.None)(12장·how-to 08의 (cond and child) or q.None도 같은 뜻) — 끝이 nil이면 안 된다, nil은 그 자리를 구멍으로 만든다 |
없는 프로퍼티 키(--!strict) |
q.Elem "TextLabel" { Bogus = 1 }의 진단은 Expected this to be 'number', but got '"Bogus"'(값 쪽에 긴 유니언 불일치 줄이 하나 더)입니다. “키가 숫자여야 한다”가 아니라 **“이 클래스에 그런 프로퍼티가 없다”**는 뜻입니다 — props 타입이 “아는 문자 키 + 숫자 키(자식)“라서 모르는 문자 키가 잘못된 숫자 키로 읽힙니다. Frame에 Activated를 준 것처럼 클래스가 틀린 이벤트도 같은 모양으로 뜹니다 |
데이터에서 만든 자식 여러 개(--!strict) |
프로퍼티와 자식을 한 표에 미리 섞어 q.Elem "Frame" (props)로 넘기면 타입이 맞지 않습니다(props 타입은 리터럴 자리에서만 추론됩니다). 자식만 평범한 배열로 만들어 Slot의 초기값으로 넘기고 프로퍼티는 리터럴에 둡니다 — local kids: { Instance } = {} → 반복문에서 table.insert(kids, q.Elem "TextButton" { … }) → q.Elem "Frame" { Name = "Toolbar", q.Slot<<Instance>>(kids) }. 데이터가 State로 바뀌는 목록이면 처음부터 :List를 씁니다(시작하기 14) |
파생 State의 결과 타입(--!strict) |
다른 값에 쓰이는 파생 State는 만드는 줄에 결과 타입을 적는다 — local label: q.State<string> = count:Compute(...). 주석 없이 받은 :Compute 결과는 그 변수를 쓰는 이후 코드 전체가 조용히 무검사다(있어야 할 진단이 안 뜨는 것이 증상 — label:NoSuchMethod()를 일부러 넣어 봐도 조용하면 이 구멍). 콜백 파라미터·반환 타입 주석으로는 메워지지 않는다. :Apply는 타입을 유지하는 연산자(Op.Sum·Op.Clamp 등)면 구멍이 없고, 타입을 바꾸거나 자기 상태를 가진 팩토리(Op.Not·Op.Indexed·q.Animate·q.Blocker·q.Debounce·q.Throttle)면 같은 구멍이 있다([2026-09-27 실측]; 시작하기 04의 접힌 설명) |
4. 막히면 어디로
섹션 제목: “4. 막히면 어디로”위에서 해결되지 않으면 GitHub 이슈로 알려주세요. 다음을 같이 적어주시면 빠르게 도움이 됩니다.
- quad 버전 (
q.Version) - blame 접두(
파일:줄)를 포함한 에러 메시지 전문 - 재현되는 가장 작은 props 테이블(최소 재현)
- Studio·실제 클라이언트·
luauCLI 중 어디서 발생하는지
그 전에 두 가지를 먼저 켜 보세요. q.debug = true는 치명적이지 않은 실수(모르는 옵션 키, 플러그인 덮어쓰기, 핸들러 우선순위 동률, Claim 디스크립터 이상)를 한 줄씩 찍어 줍니다 — 동작은 바뀌지 않습니다(q.debug). 컴포넌트 함수를 q.Traceback(fn, function(err, trace) … end)로 감싸면 던진 자리의 트레이스백(사용자 줄 포함)을 받을 수 있습니다(Fallback / Traceback).
이해 점검
섹션 제목: “이해 점검”`Source`에 `Modifier`를 담으면
테마가 바뀔 때 스타일을 통째로 갈아 끼우려고 Modifier를 Source에 담으면 어떻게 되나요?
-
Quad0181 Source: cannot hold a Modifier as a Source value로 막힙니다 — 바뀌는 것은 스타일 필드이므로 필드마다Source를 두고Modifier가 그Source를 참조하게 합니다 - 그대로 됩니다 —
Modifier도 평범한 값이라 담아서 숫자 키 자리에 놓으면 갈아 끼워집니다 - 반대 방향도 막히므로
Modifier의 필드에는 리터럴만 넣을 수 있습니다
Modifier가 Source를 참조하는 방향은 정상입니다 — 빌더 체인으로 써도, 초기 필드 테이블로 써도 같은 값이 나옵니다.
Quiz Progress
0 / 0 questions answered (0%)
0 correct
Your score
0%
0 / 0 correct
Answered: 0 / 0 (0%)