Skip to content

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).

기대하면 안 되는 것 — 알려진 한계 셋

섹션 제목: “기대하면 안 되는 것 — 알려진 한계 셋”
  1. C 프레임이 태그된 표면을 직접 부르면 파일:줄 접두가 사라집니다 (pcall(quad함수, ...)처럼 quad 함수를 그대로 넘기는 경우). 메시지는 살아남습니다 — 접두가 필요하면 pcall(function() ... end)으로 감싸세요.
  2. 콜백 안에서 일어난 디스패치는 바깥 진입 줄이 blame됩니다. Observer·Compute·Effect·slot:List의 updateFn 안에서 q.Elem "Frame" {}을 만들거나 q.Dispatch.drive처럼 최외곽 스캔으로 던지는 표면을 부르면, 콜백 안의 그 줄이 아니라 그 콜백을 깨운 바깥 줄(:Set·:Get·마운트 줄)이 blame됩니다. 인자 검증 계열(Slot:Add(nil)·q.Tag("")·q.Claim(...) 안의 검사 등)은 콜백 안에서도 그 줄을 정확히 가리킵니다.
  3. 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)입니다.

함정 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).

  • 증상: 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) -- 에러
-- ✅ 데이터 배열이 있다면 처음부터 :List
local s2 = q.Slot()
s2:List(items, function(ctx) return q.Elem "TextLabel" { Text = ctx.Item :: string } end)

점검 항목 올바른 작성법
선택적 값 전달 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의 접힌 설명)

위에서 해결되지 않으면 GitHub 이슈로 알려주세요. 다음을 같이 적어주시면 빠르게 도움이 됩니다.

  • quad 버전 (q.Version)
  • blame 접두(파일:줄)를 포함한 에러 메시지 전문
  • 재현되는 가장 작은 props 테이블(최소 재현)
  • Studio·실제 클라이언트·luau CLI 중 어디서 발생하는지

그 전에 두 가지를 먼저 켜 보세요. 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