Skip to content

08. 관측하기 — Observer와 Effect

This content is not available in your language yet.

대상 독자: 07. 인스턴스를 손에 쥐기를 끝낸 개발자 목표: 값이 바뀔 때 화면 밖에서 무언가 하고, 뒤처리가 필요한 일까지 붙이기

지금까지 값이 흘러가 닿는 곳은 프로퍼티뿐이었습니다. 그런데 값이 바뀔 때 로그를 찍거나, 소리를 내거나, 서버로 보내야 할 때가 있습니다. 화면 밖으로 나가는 이 두 길이 Observer와 Effect입니다.


1. 값이 바뀔 때 화면 밖에서 — :Observer

섹션 제목: “1. 값이 바뀔 때 화면 밖에서 — :Observer”

:Observer(fn)은 한 노드를 관측합니다. 라벨의 숫자 키 자리에 한 덩이를 더합니다.

-- … card 안의 라벨에 이어집니다
q.Elem "TextLabel" {
-- …프로퍼티 생략…
Text = countText,
-- 숫자 키: 이 라벨이 살아 있는 동안 count를 관측한다
count:Observer(function(target: q.StateData<number>) -- 숫자 키 자리에선 파라미터 타입을 적어 주세요(아래 접힘)
print("카운트가", target:Get(), "가 되었습니다")
end),
},
왜 파라미터에 타입을 적나요?

독립 문장 const o = count:Observer(function(target) … end)에서는 strict가 target의 타입을 알아내지만, 이렇게 생성자 테이블의 숫자 키 자리에 인라인으로 넣으면 그 자리의 타입이 여러 팔의 유니언이라 콜백 파라미터가 unknown으로 굳습니다 — print처럼 아무 값이나 받는 곳에 넘기면 진단이 없어 통과하지만, target:Get() + 1이나 필드 읽기는 막히고, 반대로 틀린 타입을 써도 잡히지 않습니다. target: q.StateData<number>처럼 적으면 두 문제가 다 사라집니다. 같은 한계가 인라인 Compute에도 있어 그쪽은 지역 변수로 빼는 것을 권합니다(시작하기 10).

실행하면 만들어지는 즉시 한 줄이 찍히고, 그 뒤로 버튼을 누를 때마다 한 줄씩 더 찍힙니다. 콜백이 받는 것은 값이 아니라 관측 대상의 핸들이라 target:Get()으로 읽습니다 — :Compute와 같습니다.

숫자 키 자리에 안 넣으면 어떻게 되나요?

:Observer(fn)은 등록하는 그 자리에서 한 번 발화하고, 그 뒤로는 조용합니다. 이후 변경까지 받으려면 살아나야1 하는데, 경로가 둘입니다.

  1. 인스턴스에 묶는 것 — props의 숫자 키 자리에 넣으면 quad가 묶어 줍니다. 그 인스턴스가 사는 동안만 살고, 인스턴스가 파괴되면 관측이 멈춥니다. UI에 딸린 관측은 대개 이쪽입니다.
  2. 전역 구독 — :Subscribe() 또는 :WeakSubscribe(). 인스턴스와 무관하게 사는 구독입니다. :Subscribe()는 참조를 어디에도 남기지 않아도 :Unsubscribe() 전까지 살아 있고(강한 유지), :WeakSubscribe()는 여러분이 그 핸들을 변수 등에 쥐고 있는 동안만 살아 있습니다(약한 유지 — 놓으면 GC가 거둡니다).

어느 쪽도 하지 않으면 등록 시 한 번이 전부입니다.

-- 어디에도 묶지 않으면 등록 시 한 번뿐이다
const lonely = count:Observer(function(target)
print("이건 한 번만 찍힙니다", target:Get())
end)
count:Set(99) -- lonely는 발화하지 않는다(보류)

묶이기 전에 일어난 변경은 버려지는 게 아니라 보류돼 있다가, 살아나는 순간 최신값으로 정확히 한 번 재생됩니다. 이 보류와 재생이 왜 그렇게 설계됐는지는 19장에서 다룹니다.

관측이 그 인스턴스와 함께 산다는 것은, 인스턴스를 파괴하면 그 자리에서 멈춘다는 뜻이기도 합니다. 라벨을 Destroy()한 뒤에 count:Set(2)을 해도 그 줄은 더 이상 찍히지 않습니다.

관측을 도중에 끄려면요?

인스턴스를 파괴하지 않고 관측만 떼고 싶을 때가 있습니다. 숫자 키 자리에는 자식 인스턴스만 오는 게 아니라 그 자리를 State로 잡아 둘 수도 있어서, 관측 핸들 자체를 Source에 담아 놓으면 그 자리를 갈아 끼우는 것으로 끄고 켤 수 있습니다.

-- 관측 핸들을 State에 담아 자리에 놓는다
const logger = q.Source<<q.Observer?>>(count:Observer(function(t)
print("카운트", t:Get())
end))
const card = q.Elem "Frame" {
logger,
-- …자식들 생략…
}
logger:Set(nil) -- 이 시점부터 위 print는 더 이상 돌지 않는다

실행하면 logger:Set(nil) 전까지는 count가 바뀔 때마다 찍히고, 그 뒤로는 조용합니다.

q.Observer 타입은 01장 설정 모듈의 export type Observer = QuadTypes.Observer 줄에서 옵니다.

자리 하나를 State로 잡아 두고 갈아 끼우는 같은 원리가 11장에서는 자식 인스턴스에도 쓰입니다. 다만 이건 “이렇게도 된다”에 가깝지 강하게 권하는 패턴은 아닙니다 — 관측을 껐다 켤 일이 정말 있을 때만 쓰세요.


Observer는 “한 노드가 바뀌었다”만 알려 줍니다. 의존성이 여럿이거나, 매번 뒤처리가 필요하면 q.Effect(fn, ...deps)를 씁니다.

card의 숫자 키 자리에 한 덩이를 더합니다.

-- … card의 숫자 키 자리에 이어집니다
q.Effect(function()
const n = count:Get()
print("이펙트: 지금", n)
return function() -- ← 이 함수가 cleanup
print("정리:", n, "회차의 뒤처리")
end
end, count),

실행하면 만들어지는 즉시 이펙트: 지금 0이 찍힙니다. 버튼을 한 번 누르면 먼저 정리: 0회차가 찍힌 뒤 이펙트: 지금 1이 이어집니다 — 다음 실행 직전에 직전 회차의 뒤처리가 도는 것입니다. 그리고 카드를 Destroy()하면 정리: 1회차가 한 번 더 찍히고 끝납니다. 그 뒤로는 count를 아무리 바꿔도 이 이펙트는 조용합니다.

Observer와 갈리는 지점은 셋입니다.

  • 의존성을 여럿 겁니다 — q.Effect(fn, a, b, c). 어느 하나가 움직여도 다시 돕니다. State/Source뿐 아니라 Ref도 의존성 자리에 놓을 수 있습니다(4절이 그 예입니다).

  • 값이 인자로 오지 않습니다 — 클로저로 count:Get()을 직접 읽습니다(fn이 받는 인자는 핸들 자신 하나뿐입니다).

  • cleanup을 돌려줄 수 있습니다 — 도는 자리는 넷이고, 넷 각각에서 정확히 한 번씩 돕니다.

    • 다음 실행 직전
    • :Unsubscribe()로 강한 구독을 끊을 때
    • 매달린 인스턴스가 파괴될 때
    • 그 숫자 키 자리를 다른 값으로 갈아 끼울 때(1절 접힘처럼 자리를 State로 잡아 뒀다가 바꾸는 경우)

    약하게 풀어 주는 :WeakUnsubscribe()는 cleanup을 건드리지 않습니다.

Observer와 마찬가지로 숫자 키 자리에 넣어야 계속 삽니다. 넣지 않으면 만들 때 한 번 돌고 조용해집니다.


3. 화면 안쪽을 바꾸고 싶다면 — Effect가 아니라 값으로

섹션 제목: “3. 화면 안쪽을 바꾸고 싶다면 — Effect가 아니라 값으로”

앞의 두 절이 세운 규칙은 “Observer와 Effect는 화면 밖으로 나가는 길“이었습니다. 그래서 “카운트가 10 이상이면 버튼을 노란색으로”처럼 화면 안쪽을 바꾸는 일은 이 둘의 일이 아닙니다. 그건 03장에서 이미 배운 파이프의 일입니다.

버튼의 BackgroundColor3에 색 리터럴 대신 파이프를 꽂습니다.

-- … card 안의 버튼에 한 줄을 더합니다
q.Elem "TextButton" {
-- …buttonRef·Text·BackgroundTransparency = 0·Activated(07장 2절 그대로)…
BackgroundColor3 = count:Compute(function(c: q.StateData<number>) -- 인라인 자리라 파라미터 타입을 적습니다(위 접힘)
return if c:Get() >= 10
then Color3.fromRGB(255, 190, 0)
else Color3.fromRGB(0, 162, 255)
end),
},

실행하면 버튼이 파란색으로 시작해서, 카운트가 10이 되는 순간 노란색으로 바뀌고, 다시 10 아래로 내려가면 파란색으로 돌아옵니다.

화면에 보이는 것은 값이 흘러 닿는 프로퍼티로 적는 것이 quad의 기본입니다. 같은 일을 Effect 안에서 inst.BackgroundColor3 = …로 적으면 세 가지가 어긋납니다.

  • 한 프로퍼티에 주인이 둘이 됩니다. 같은 프로퍼티에 이미 State가 꽂혀 있으면 파이프가 값을 쓰고 Effect가 그 위에 또 쓰고, 다음 변경에 다시 뒤집힙니다. quad는 그 손쓰기를 모릅니다. 07장 2절이 Activated 안에서 BackgroundTransparency를 손으로 쓴 것은, 그 프로퍼티에 State가 꽂혀 있지 않아 다시 쓸 주인이 없기 때문입니다(정적 리터럴 BackgroundTransparency = 0은 한 번 쓰이고 끝입니다).
  • 되돌릴 방법이 없습니다. 조건이 풀렸을 때 원래 색으로 돌려놓는 일까지 직접 적어야 합니다. 파이프는 조건이 풀리면 그냥 다른 값을 흘려보냅니다.
  • 이 장의 규칙을 스스로 깹니다. Effect가 화면 밖으로 나가는 길이라는 약속이 흐려집니다.

정리하면 계속 바뀌는 값은 파이프로 꽂고, 한 번 일으키는 동작은 그 자리에서 손으로 써도 됩니다. 계속 바뀌는 값을 Effect로 미는 것이 이 절이 말리는 모양입니다.

그럼 Effect는 언제 쓰나요?

quad 바깥에 있는 것에 손댈 때입니다 — 엔진 서비스에 연결하고, 타이머를 걸고, 다른 시스템에 자기를 등록하는 일. 이런 일은 끝날 때 되돌려야 하고, Effect에 cleanup이 있는 이유가 바로 그것입니다. 바로 아래 4절이 그 모양이고, 09장이 그 위에 이름 붙은 훅을 얹습니다. 엔진 연결을 걸고 끊는 실전 배치는 04. RemoteEvent와 엔진 입력을 상태로 브릿징하기에 있습니다.


Effect의 의존성 자리에 07장의 Ref를 같이 걸면 “이 상자가 채워졌을 때”와 “이 값이 바뀌었을 때”를 한 함수에서 다룰 수 있습니다.

바깥으로 나가는 일 하나를 붙여 보겠습니다 — 카운트가 10 이상인 동안 게임패드 선택(GuiService.SelectedObject)을 이 버튼에 두는 것입니다. 선택을 걸었으면 조건이 풀릴 때 풀어 줘야 하므로 cleanup이 필요한 일이고, 대상이 인스턴스라 Ref가 필요합니다. 상자는 07장 3절에서 만든 buttonRef를 그대로 씁니다 — 평범한 Ref입니다.

-- … 위쪽 코드에 이어집니다(buttonRef는 07장 3절에서 만든 그 상자 그대로)
const GuiService = game:GetService("GuiService")
const isBig: q.State<boolean> = count:Compute(function(c) return c:Get() >= 10 end)
const card = q.Elem "Frame" {
-- …생략…
q.Elem "TextButton" {
buttonRef, -- ← 07장과 같은 평범한 Ref(인스턴스마다 새로 만든다)
Text = "+ 1",
-- …3절의 BackgroundColor3 파이프와 07장 2절의 BackgroundTransparency·Activated는 그대로…
},
-- 숫자 키: 카운트가 10 이상인 동안 게임패드 선택을 이 버튼에 둔다
q.Effect(function()
const inst = buttonRef.Value
if inst and isBig:Get() then
GuiService.SelectedObject = inst
return function() -- ← 되돌리기
if GuiService.SelectedObject == inst then
GuiService.SelectedObject = nil
end
end
end
return nil
end, isBig, buttonRef),
}

실행하면 카운트가 10이 되는 순간 게임패드 선택이 이 버튼으로 옮겨 오고, 10 아래로 내려가면 cleanup이 돌아 선택이 풀립니다. 선택이 걸려 있는 채로 카드를 Destroy()해도 cleanup이 한 번 돌아, 사라진 버튼이 선택된 채로 남지 않습니다. 게임패드 없이 마우스로만 확인한다면 선택 표시가 눈에 잘 띄지 않을 수 있으니, 선택을 거는 줄과 cleanup 안에 print를 넣어 보세요. 두 가지만 알아 두세요 — 이 버튼은 PlayerGui 아래에 있어야 합니다(밖에 있으면 엔진이 SelectedObject 대입을 거부해 fn이 던지고 그 Effect는 죽습니다 — 테스트용으로 화면 밖에 두고 돌리면 그렇게 됩니다), 그리고 파괴로 도는 cleanup은 Destroy() 직후가 아니라 한 틱 뒤에 돕니다(엔진의 Destroying이 지연 배달되기 때문 — 파괴된 선택을 엔진이 스스로 비워 주지는 않으므로 이 cleanup이 실제로 필요한 일입니다).

의존성 자리에 count를 그대로 걸 수도 있었습니다. 그런데 이 Effect가 정말 신경 쓰는 것은 카운트 숫자가 아니라 10을 넘었는가입니다. 그래서 그 판정을 isBig 한 줄로 먼저 만들어 두고 그것을 겁니다 — 의존성은 값 자체가 아니라 “바뀌었을 때 다시 돌아야 하는 것”으로 고릅니다. 같은 자리에 count를 걸어도 화면 결과는 같지만, 의도가 코드에 남지 않습니다. 다만 도는 횟수까지 줄지는 않습니다 — 파이프는 계산 결과가 같아도 통지를 내려보내므로, 10에서 11로 갈 때도 cleanup이 돌고 곧바로 다시 선택합니다(눈에는 안 보이는 한 사이클입니다).

이 Effect는 buttonRef가 차기 전에 도는 것 아닌가요?

여기서는 아닙니다. Lua는 테이블 리터럴의 원소를 위에서 아래로 평가하므로, q.Elem "TextButton" { buttonRef, … }가 먼저 실행되어 버튼이 만들어지고 buttonRef가 채워진 다음에야 q.Effect(...)가 만들어집니다. Effect의 첫 실행은 만들어지는 그 자리에서 돌기 때문에, 그때 이미 상자에 값이 있습니다.

바꿔 말하면 순서를 뒤집으면 첫 실행이 빈 상자를 봅니다(그래서 위 코드에 if inst and … 가드가 있습니다). 그 뒤 상자가 채워지면 Ref가 의존성이므로 Effect는 어차피 한 번 더 돕니다 — 다만 그때까지의 한 사이클은 아무 일도 안 한 셈이 됩니다.

순서에 기대고 싶지 않다면 q.PreRef를 쓰세요. 숫자 키 위치와 무관하게 가장 먼저 채워지므로 첫 실행부터 값이 있습니다.

이 Effect처럼 “어떤 Ref와 어떤 State를 묶어 이런 일을 한다”를 함수 하나로 이름 붙여 재사용할 수 있고, 컴포넌트를 쓰기 시작하면 그 함수를 컴포넌트에 넘겨 안쪽 상태에 붙이게도 됩니다. 그 모양은 13장에서 한꺼번에 다룹니다.


어디에도 놓지 않은 `Observer`

count:Observer(fn)을 변수에만 담고 숫자 키 자리에도, 구독에도 걸지 않으면 어떻게 되나요?

  • 아무 일도 일어나지 않습니다 — 어딘가에 놓여야 첫 발화가 시작됩니다
  • 등록하는 그 자리에서 한 번 발화하고, 그 뒤로는 조용합니다
  • 인스턴스와 무관하게 살아 있어 값이 바뀔 때마다 계속 발화합니다

:Observer(fn)은 등록 자리에서 한 번 발화합니다. 이후 변경까지 받으려면 숫자 키 자리에 넣어 인스턴스에 묶이거나 :Subscribe()/:WeakSubscribe()로 구독되어야 하고, 어느 쪽도 하지 않으면 등록 시 한 번이 전부입니다.

cleanup이 도는 횟수

isBig(카운트가 10 이상인가)을 의존성으로 건 q.Effect가 카운트 10에서 버튼에 선택을 걸고 cleanup을 돌려준 상태입니다. 카운트를 11로 올리고, 이어서 카드를 Destroy()하면 cleanup은 모두 몇 번 도나요?

  • 한 번 — isBig이 true 그대로라 11에서는 다시 돌지 않고, 파괴될 때만 돕니다
  • 두 번 — 11에서 다시 실행되기 직전에 한 번, 파괴될 때 한 번
  • 0번 — 카드를 파괴하면 Effect도 같이 사라져 cleanup을 부를 기회가 없습니다

파이프는 계산 결과가 같아도 아래로 통지를 내려보내므로, isBig이 true에서 true로 그대로여도 Effect는 다시 실행되고 그 직전에 cleanup이 돕니다. 매달린 인스턴스가 파괴될 때도 cleanup이 한 번 돌아, 사라진 버튼이 선택된 채로 남지 않습니다.

화면 안쪽을 바꾸는 길

“카운트가 10 이상이면 버튼을 노란색으로”는 어떻게 적는 것이 quad의 기본인가요?

  • Effect 안에서 그 인스턴스의 BackgroundColor3에 직접 대입합니다
  • BackgroundColor3 자리에 파이프를 꽂아 값이 흘러 닿게 합니다
  • Observer 콜백에서 조건을 보고 색을 쓰고, 조건이 풀리면 원래 색으로 되돌립니다

Observer와 Effect는 화면 밖으로 나가는 길이고, 화면에 보이는 것은 값이 흘러 닿는 프로퍼티로 적습니다. 같은 일을 Effect 안에서 손으로 쓰면 한 프로퍼티에 주인이 둘이 되고, 조건이 풀렸을 때 원래 값으로 돌려놓는 일까지 직접 적어야 합니다.


Quiz Progress

0 / 0 questions answered (0%)

0 correct

  1. 이 문서에서 살아난다는 Observer/Effect에 구독이 붙어 그 뒤의 변경을 계속 받게 된다는 뜻입니다. 레퍼런스의 Subscribed 필드는 그중 전역 구독(:Subscribe/:WeakSubscribe) 쪽만 true로 표시하고, 숫자 키 자리에 묶여 살아난 경우는 false입니다 — 두 경로 다 “살아났다”이지만 필드는 하나만 가리킵니다. ↩