Skip to content

07. 인스턴스를 손에 쥐기 — Ref

This content is not available in your language yet.

대상 독자: 06. 다시 위로 올려보내기를 끝낸 개발자 목표: 만들어진 인스턴스를 변수로 잡아 두고, 그것이 언제 손에 들어오는지 알기

지금까지 화면에 무언가를 반영하는 길은 프로퍼티와 표시(태그·속성)였습니다. 그런데 만들어진 실물 인스턴스 자신이 필요할 때가 있습니다 — 크기를 재거나, 이벤트 안에서 그 인스턴스를 직접 만지거나, 밖으로 꺼내 다른 코드에 넘길 때입니다. Ref는 그 인스턴스를 담아 두는 빈 상자입니다.


Ref를 props의 숫자 키 자리에 놓아 두면 quad가 만들어진 인스턴스를 그 상자에 넣어 줍니다.

카드의 버튼에 상자를 하나 놓습니다.

-- … 위쪽 코드에 이어집니다(card의 버튼을 이렇게 고칩니다)
const buttonRef = q.Ref<<TextButton?>>(nil)
const card = q.Elem "Frame" {
-- …생략…
q.Elem "TextButton" {
buttonRef, -- ← 숫자 키: 만들어진 버튼이 여기 담긴다
Text = "+ 1",
Activated = function()
count:Set(count:Get() + 1)
end,
},
}
print(buttonRef.Value.ClassName) --> "TextButton"

실행하면 출력 창에 TextButton이 찍힙니다.

q.Ref<<TextButton?>>(nil)의 꺾쇠 둘이 낯설 수 있습니다.

꺾쇠가 왜 둘인가요?

<<...>>는 Luau에서 타입 인자를 직접 지정하는 문법입니다. 홑화살괄호 하나(f<T>(x))는 비교 연산(f < T > (x))으로 파싱돼 버려서, Luau는 명시적 타입 인자를 두 겹으로 적습니다.

Ref에서 이 표기가 필요한 이유는 타입 파라미터가 초깃값에서 추론되기 때문입니다. q.Ref(nil)이라고만 쓰면 그 상자는 Ref<nil> — 영원히 nil만 담을 수 있는 상자가 됩니다. 그래서 담길 것을 직접 적어 넓힙니다.

const a = q.Ref(nil) -- Ref<nil> — 인스턴스를 담을 수 없다
const b = q.Ref<<TextButton?>>(nil) -- Ref<TextButton?> — 비어 있다가 버튼이 담긴다

Ref<nil>을 그대로 숫자 키 자리에 놓으면 타입 검사가 그 자리에서 막습니다(--!strict 기준). ?가 붙는 것은 처음엔 비어 있기 때문입니다. 숫자 키 자리에 놓은 Ref는 그 자리가 처리되기 전까지 nil이라, 사실상 항상 T? 모양입니다.

같은 표기가 뒤 장들에서도 계속 나옵니다 — 예를 들어 q.Slot<<Instance>>().

채워지는 시점은 “그 숫자 키 자리가 처리될 때”입니다. 위 예제에서는 q.Elem "TextButton" { ... }이 만들어지는 그 순간이고, 그래서 card가 완성된 뒤에는 이미 들어 있습니다. 반대로 말하면 그 앞줄에서는 아직 비어 있습니다 — 상자를 만든 직후부터 그 자리가 처리되기 전까지는 ref.Value가 nil입니다.


2. 먼저 채워지는 상자 — q.PreRef와 :Unwrap()

섹션 제목: “2. 먼저 채워지는 상자 — q.PreRef와 :Unwrap()”

가장 자주 만나는 자리는 같은 props 안에서 자기 인스턴스를 만지는 경우입니다. 버튼의 Activated 안에서 그 버튼 자신의 색을 바꾸는 것처럼요.

사실 클릭은 버튼이 다 만들어진 뒤에 일어나므로, 이벤트 핸들러 안이라면 평범한 Ref도 그때는 이미 채워져 있습니다. 문제는 같은 props를 처리하는 도중에 읽는 자리입니다 — 평범한 Ref는 자기 숫자 키 자리가 처리될 때 채워지므로, 그보다 먼저 처리되는 자리에서는 아직 비어 있을 수 있습니다. q.PreRef는 그 인스턴스에 아무 일도 일어나기 전에 먼저 채워지므로 위치를 따질 필요가 없습니다. 아래는 가장 흔한 이벤트 핸들러 자리로 모양만 먼저 봅니다.

-- … 위쪽 코드에 이어집니다(1절의 buttonRef를 PreRef로 바꿉니다)
const buttonRef = q.PreRef<<TextButton?>>(nil)
-- … 그리고 card의 숫자 키 자리, 버튼을 이렇게 고칩니다
q.Elem "TextButton" {
buttonRef,
Text = "+ 1",
BackgroundTransparency = 0,
Activated = function()
count:Set(count:Get() + 1)
buttonRef:Unwrap().BackgroundTransparency = 0.5 -- 가드 없이
end,
},

실행하면 버튼을 누를 때 숫자가 오르면서 버튼이 반투명해집니다 — BackgroundTransparency가 0에서 0.5로 바뀝니다.

같은 props 안에서 자기 인스턴스를 쓸 때는 PreRef를 권합니다. 숫자 키 자리의 위치와 무관하게 먼저 채워지므로, 이벤트 핸들러든 프로퍼티 계산이든 그 상자가 비어 있는 것을 볼 일이 없습니다.

:Unwrap()은 그 확신을 코드로 적는 방법입니다. 담긴 값을 그대로 돌려주되 타입에서 nil만 벗겨 주고, 정말 비어 있으면 부른 줄을 blame하며 던집니다.

Quad0135 Ref:Unwrap: the Ref is empty (Value is nil) - not filled yet, or never placed

규약이지 강제가 아닙니다. :Unwrap()은 “여기서는 반드시 차 있다”를 아는 자리에서만 쓰고, 확신이 없으면 if ref.Value then으로 가드하세요.

PreRef는 일회용입니다. 한 번 채워진 PreRef를 다른 인스턴스에 다시 놓으면 그 자리에서 던집니다 — 인스턴스마다 새로 만드세요(3절의 접힘 “쓰던 Ref를 다른 인스턴스에 다시 써도 되나요?“가 그 이야기입니다).


3. 채워질 때 반응하기 — :Callback과 :Wait

섹션 제목: “3. 채워질 때 반응하기 — :Callback과 :Wait”

상자가 언제 차는지를 신경 써야 할 때가 있습니다. 대개는 그 상자를 만든 쪽과 채우는 쪽이 다를 때입니다.

ref:Callback(fn)은 값이 담길 때 부를 함수를 등록합니다. 등록하는 그 자리에서 지금 값으로 한 번 즉시 불리고, 그 뒤로 채워질 때마다 다시 불립니다.

-- … 위쪽 코드에 이어집니다(여기서는 다시 평범한 Ref로 두고, 카드를 만들기 전에 등록합니다 — §2에서 넣은 BackgroundTransparency 두 줄은 그대로 둡니다)
const buttonRef = q.Ref<<TextButton?>>(nil)
buttonRef:Callback(function(inst)
print("상자:", if inst then inst.ClassName else "nil")
end)
const card = q.Elem "Frame" {
-- …생략…
q.Elem "TextButton" { buttonRef, Text = "+ 1", --[[ …BackgroundTransparency 두 줄과 Activated는 §2 그대로… ]] },
}

실행하면 상자: nil이 먼저 찍히고(등록 시점엔 비어 있으니까), 카드가 만들어지면서 상자: TextButton이 이어서 찍힙니다.

기다렸다가 그 줄에서 이어서 쓰고 싶다면 ref:Wait()이 있습니다. 다만 성질이 하나 있습니다 — :Wait()은 언제나 다음번 채워짐을 기다립니다. 이미 차 있어도 기다립니다. 그래서 관용구는 둘을 합친 모양입니다.

-- … 위쪽 코드에 이어집니다
task.spawn(function()
const inst = if buttonRef.Value then buttonRef.Value else buttonRef:Wait().Value
print("버튼 준비됨:", inst.ClassName)
end)

실행하면 상자가 이미 차 있으면 그 줄에서 곧바로 찍히고, 아직이면 채워지는 순간 찍힙니다. :Wait()을 인자 없이 부르려면 yield 할 수 있는 코루틴 안이어야 해서 task.spawn으로 감쌌습니다.

왜 이미 차 있어도 기다리나요?

Ref<T?>에서는 nil도 정당한 값이라 “차 있는가”를 :Wait 쪽에서 판정할 수 없기 때문입니다.

--!strict으로 올릴 때는 위 관용구의 마지막 .Value 대신 :Wait():Unwrap()을 쓰면 nil까지 벗겨집니다.

컴포넌트(12장)에서는 부모가 만든 Ref를 자식이 채우고, 부모가 이 두 방법으로 기다리는 모양이 흔합니다.

쓰던 Ref를 다른 인스턴스에 다시 써도 되나요?

하나의 Ref는 한 자리에만 놓습니다. 앞 인스턴스가 살아 있는 채로 같은 Ref를 둘째 자리에 놓으면 그 자리에서 던집니다.

Quad0232 bindLifetime: value is already bound to another Instance

문제는 앞 인스턴스를 Destroy()한 뒤입니다. 그때는 에러 없이 다시 놓입니다. 그래서 “재사용해도 되나 보다” 하고 넘어가기 쉬운데, 그 사이가 함정입니다.

Ref는 담긴 인스턴스가 Destroy()돼도 스스로 비워지지 않습니다. Ref는 대상의 파괴를 감지하지도, 반응하지도 않습니다 — 마지막으로 담긴 것을 그대로 계속 가리킵니다. 그래서 새 인스턴스가 아직 안 만들어진 구간에서 if ref.Value then을 쓰면 죽은 옛 인스턴스를 잡습니다. 가드가 통과하는데 값은 쓰레기인, 가장 찾기 어려운 종류의 버그입니다.

PreRef/PostRef는 아예 일회용이라 그 시도 자체를 막습니다.

Quad0069 PreRef: already used - a PreRef is one-shot (used up once quad places it, fired or not); make a new one for each instance

결론은 하나입니다 — Ref는 인스턴스마다 새로 만듭니다. 목록처럼 인스턴스가 계속 갈리는 자리라면 Ref를 돌려 쓰지 말고, 항목을 만드는 그 자리에서 하나씩 만드세요.


4. 전부 끝난 뒤에 받는 상자 — q.PostRef

섹션 제목: “4. 전부 끝난 뒤에 받는 상자 — q.PostRef”

PreRef의 거울이 하나 더 있습니다. q.PostRef는 그 인스턴스의 자식과 프로퍼티가 전부 끝난 뒤 채워집니다 — 자기 아래의 완성된 서브트리를 재는 코드(레이아웃 측정 등)가 그 자리입니다. 채워지는 기준은 그 인스턴스의 숫자 키·문자 키 처리가 끝난 시점이지 부모에 붙는 시점이 아닙니다.

q.PostRef를 PreRef와 같은 자리(숫자 키)에 놓고 :Callback으로 받으면 됩니다.

-- 새 예시: 별도 스크립트
const doneRef = q.PostRef<<Frame?>>(nil)
doneRef:Callback(function(inst)
if inst then
print("자식 수:", #inst:GetChildren())
end
end)
const card = q.Elem "Frame" {
doneRef,
q.Elem "TextLabel" { Text = "A" },
q.Elem "TextLabel" { Text = "B" },
}

실행하면 자식 수: 2가 찍힙니다(등록 시점의 nil 한 번은 위 가드가 걸러 냅니다).

그때 Parent도 정해져 있나요?

계약은 “자식이 전부 붙었다”까지입니다 — 이 인스턴스가 부모에 붙었는지는 계약이 아닙니다. 불리는 그 순간 Parent는 아직 모르는 상태입니다: 위처럼 리터럴로 중첩해 만들었다면 대개 nil이지만, 이미 트리에 있던 인스턴스를 넘겨받았다면 처음부터 차 있습니다. 게다가 그 뒤로 Parent가 영영 바뀌지 않을 수도 있으므로, “나중에 Parent가 설정되면 그때 하자”고 기다리는 코드는 한 번도 안 돌 수 있습니다. 그러니 화면에 실제로 올라간 뒤라야 나오는 값(AbsoluteSize 등)을 여기서 읽지 마세요. 그건 Ref 쪽 이야기가 아니라 엔진 프로퍼티를 관측하는 쪽 이야기입니다.

PreRef와 마찬가지로 일회용이고, 숫자 키 자리에 리터럴로 놓아야 합니다.


상자가 채워지는 시점

평범한 q.Ref와 q.PreRef는 각각 언제 채워지나요?

  • 둘 다 그 인스턴스가 부모에 붙는 순간 채워지고, 차이는 담기는 값의 타입뿐입니다
  • Ref는 그 숫자 키 자리가 처리될 때, PreRef는 그 인스턴스에 아무 일도 일어나기 전에 채워집니다
  • Ref는 만들어지는 즉시, PreRef는 자식과 프로퍼티가 전부 끝난 뒤에 채워집니다

Ref는 그 자리가 처리되기 전까지 비어 있어 앞줄에서는 nil이고, PreRef는 숫자 키 위치와 무관하게 먼저 채워지므로 같은 props 안에서 자기 인스턴스를 쓸 때 권합니다. 자식과 프로퍼티가 전부 끝난 뒤에 채워지는 것은 q.PostRef입니다.

`:Callback`과 `:Wait`

ref:Wait()의 성질로 옳은 것은 무엇인가요?

  • 이미 차 있으면 그 줄에서 곧바로 담긴 값을 돌려줍니다
  • 이미 차 있어도 언제나 다음번 채워짐을 기다립니다
  • 부르는 그 자리에서 지금 값으로 한 번 즉시 불리고, 그 뒤로 채워질 때마다 다시 불립니다

Ref<T?>에서는 nil도 정당한 값이라 “차 있는가”를 :Wait 쪽에서 판정할 수 없어 언제나 다음 채워짐을 기다립니다. 그래서 관용구는 상자를 먼저 보고 비어 있을 때만 기다리는 모양이 됩니다. 등록 자리에서 한 번 즉시 불리는 것은 :Callback(fn) 쪽입니다.

`Ref`를 돌려 쓰면

담겼던 인스턴스를 Destroy()한 뒤 같은 Ref를 다른 인스턴스 자리에 다시 놓으면 어떻게 되나요?

  • 앞 인스턴스가 파괴될 때 상자가 비워졌으므로 안전하게 재사용됩니다
  • 에러 없이 다시 놓이지만, 새 인스턴스가 담기기 전까지 .Value는 죽은 옛 인스턴스를 가리킵니다
  • 한 번 채워진 상자는 일회용이라 다시 놓는 그 자리에서 던집니다

Ref는 담긴 인스턴스가 Destroy()돼도 스스로 비워지지 않아, 그 구간에서 if ref.Value then은 통과하는데 값은 쓰레기입니다. 일회용이라 그 시도 자체를 막는 것은 PreRef/PostRef 쪽이고, 앞 인스턴스가 살아 있는 채로 같은 Ref를 둘째 자리에 놓으면 그때는 던집니다. 그래서 Ref는 인스턴스마다 새로 만듭니다.


Quiz Progress

0 / 0 questions answered (0%)

0 correct