Skip to content

Vol. 12 — 파동을 받는 두 방식: Observer와 Effect

This content is not available in your language yet.

작성 목적: 프레임워크 아키텍트 및 고급 엔지니어를 위한 기술 해설서 관련 소스: quad-base/src/Observer.luau, quad-base/src/Effect.luau, quad-base/src/State.luau, quad-base/src/Source.luau


Observer와 Effect는 겉보기에 거의 같습니다. 둘 다 반응형 그래프의 말단에 붙어 값이 바뀌면 콜백을 부르고, 이름이 같은 구독 진입점 넷을 갖습니다. 그런데 레퍼런스를 읽다 보면 두 핸들이 정반대로 다뤄지는 장면이 둘 있고, 결과는 네 칸이 됩니다.

장면 Observer Effect
콜백이 자기 의존(이나 그 상류)을 :Set한다 정의되지 않음 정의됨 — 끝난 뒤 한 번 더 돈다
콜백이 예외를 던진다 다음 변경에 다시 돈다(약속하는 회복) 죽는다 — 다시 돌지 않는다(약속하는 비회복)

얼핏 보면 네 칸의 규칙이 따로 정해진 것 같습니다. 되쓰기는 Effect에 너그럽고, 예외는 Observer에 너그럽습니다. 이 권의 주장은 하나입니다 — 네 결과는 두 핸들이 변경 파동을 받는 방식 하나에서 나옵니다. Observer는 파동이 도착할 때마다 그 자리에서 콜백을 부르고, Effect는 자기 콜백이 도는 동안 도착한 파동을 미뤄 두었다가 자기 루프로 접어서 받습니다. 이 차이 하나를 따라가면 네 칸이 저절로 채워집니다.


Source:Set(v)는 값을 바꾸고 리비전을 올린 뒤 구독자에게 통지를 내려보냅니다. 이 한 번의 내려보내기를 이 권에서는 파동이라고 부릅니다. 파동이 노드를 지날 때 하는 일은 State.luau의 emitDown 하나입니다:

local function emitDown(self: any, from: any)
-- "이번 파동 중에 붙은 구독자는 다음 파동부터"
local snap = {}
for sub in self._subs do
snap[#snap + 1] = sub
end
for _, sub in snap do
(sub :: any):_receive(from) -- 구독자마다 동기로
end
end

두 가지를 기억해 두십시오.

  1. 파동은 동기입니다. 구독자의 _receive를 차례로 부르고, 그 안에서 콜백이 돕니다. 파동이 끝났다는 것은 이 루프가 돌아왔다는 뜻입니다.
  2. 파동 안에서 :Set하면 새 파동이 바깥 파동 안에서 시작됩니다. 콜백이 :Set을 부르는 순간 emitDown이 다시 불리고, 그 안쪽 파동은 바깥 파동의 스냅샷 순회가 아직 남아 있는 채로 끝까지 돕니다. quad에는 파동을 줄 세우는 큐가 없습니다(배치는 사용자가 Blocker 같은 게이트로 명시적으로 겁니다).

파생 State 노드는 받은 통지를 에폭 기록으로 걸러(Vol. 1) 다시 emitDown합니다. 그 끝에 말단 핸들 — Observer와 Effect — 이 있습니다. 차이는 이 말단의 _receive에서 납니다.


3.1 Observer — 도착하면 그 자리에서

섹션 제목: “3.1 Observer — 도착하면 그 자리에서”
-- Observer.luau (요지)
local function run(self, from)
local outer = self._running
self._running = true
self._seenRevision = rawget(self._state, "Revision")
self.fn(self._state, self, from)
self._running = outer
end
function Impl._receive(self, from)
if from == self._state and self._seenRevision == from.Revision then
return -- 직접 보는 Source의, 이미 돈 리비전이면 건너뛴다
end
if module.Backend.canExecute(self) then
run(self, from) -- 지금 바로 콜백
else
self._rerunRequired = true -- 아직 살아나지 않았으면 붙들었다가 묶일 때 한 번
end
end

Observer가 가진 것은 이게 전부입니다. 재실행 루프가 없고, 자기가 무엇을 받았는지의 기록도 직접 관측하는 Source의 마지막 리비전 하나(_seenRevision)뿐입니다 — 파생 State를 관측하면 그것조차 비어 있습니다. 실행 자격이 있으면 통지가 도착한 그 프레임에서 콜백을 부르고, 콜백이 도는 중에 통지가 또 오면 또 그 자리에서 부릅니다. _running은 그 겹침을 막지 않습니다 — 재진입한 실행이 바깥 실행의 값을 덮어쓰지 않게 저장했다가 되돌릴 뿐입니다.

3.2 Effect — 도는 중이면 미뤘다가 한 번 더

섹션 제목: “3.2 Effect — 도는 중이면 미뤘다가 한 번 더”
-- Effect.luau (요지)
function Impl._receive(self, from)
if self._epochs:Update(from) then -- 의존마다 "어느 리비전까지 봤나" — 새 것이면 참
self:Rerun()
end
end
local function rawRerun(self, force)
if self._running then
self._pending = true -- 도는 중에 왔다 → 끝난 뒤 한 번 더
return
end
-- (실행 자격이 없으면 붙들었다가 살아날 때 한 번 — 생략)
self._running = true
local count = 0
repeat
self:_consumeCleanup()
self._pending = false
count += 1
if count > RERUN_LIMIT then -- 1만
error("Quad0263 Effect: fn re-ran 10000 times in one loop - …")
end
local cleanup = self.fn(self)
self._cleanup = cleanup
until not self._pending
self._running = false
end

Effect에는 Observer에 없는 것이 둘 있습니다. 의존마다 본 리비전을 적는 에폭 기록(_epochs)과, fn이 도는 동안 도착한 요청을 _pending 깃발 하나로 접어 두는 재실행 루프입니다. 도는 중에 몇 번이 오든 깃발은 하나라서, fn이 돌아온 뒤 정확히 한 번 더 돕니다. fn은 절대 자기 자신 위에 겹쳐 돌지 않습니다.


자기 상류를 되쓰는 콜백 하나를 두 핸들에 걸어 봅니다. 값이 1이면 2로 고쳐 쓰는 콜백입니다. 두 장면은 서로 섞이지 않게 원천을 따로 둡니다.

-- (가) Observer
local a = q.Source(0)
a:Observer(function(t)
local v = t:Get()
if v == 1 then
a:Set(2) -- 자기 상류를 되쓴다
end
print("observer saw", v)
end):Subscribe()
a:Set(1)
-- (나) Effect
local b = q.Source(0)
q.Effect(function()
local v = b:Get()
if v == 1 then
b:Set(2)
end
print("effect saw", v)
end, b):Subscribe()
b:Set(1)

(만드는 순간의 첫 실행은 값이 0이라 saw 0만 찍고 아무것도 쓰지 않으니 건너뜁니다.) :Set(1)이 만드는 첫 파동을 W1, 콜백 안의 :Set(2)가 만드는 둘째 파동을 W2라고 하면, 두 핸들이 겪는 일은 이렇습니다.

단계 (가) Observer (나) Effect
1 W1이 도착 → 콜백 실행, v = 1 W1이 도착 → 에폭 갱신 → fn 실행, v = 1
2 콜백 안 a:Set(2) → W2가 W1 안에서 시작 fn 안 b:Set(2) → W2가 W1 안에서 시작
3 W2가 도착 → 같은 콜백이 바깥 실행 위에 겹쳐 돈다, v = 2 → "observer saw 2" W2가 도착 → 에폭 갱신 → Rerun → 도는 중이므로 _pending = true만 하고 돌아간다
4 바깥 실행이 이어진다 — 지역 v는 여전히 1 → "observer saw 1" fn이 돌아온다 → "effect saw 1"
5 끝 루프가 _pending을 보고 cleanup → fn 한 번 더, v = 2 → "effect saw 2" → 끝
6 W1 스냅샷에 다른 구독자가 있었다면 그들이 W1 통지를 마저 받는다(값은 이미 2) 같음

출력 순서가 모든 것을 말해 줍니다. Observer는 "saw 2"가 "saw 1"보다 먼저 찍힙니다 — 나중 값의 실행이 먼저 끝나고, 마지막으로 끝난 실행은 낡은 값을 들고 있습니다. Effect는 "saw 1" → "saw 2" 순서로, 실행이 겹치지 않고 마지막 실행이 최신 값을 봅니다.

6단계도 눈여겨보십시오. W1 순회는 스냅샷을 이어 돌므로, 안쪽 W2가 이미 지나간 형제에게 W1의 옛 통지를 나중에 마저 배달합니다. 그 형제가 그 통지로 다시 도는지는 형제의 종류에 달립니다 — 같은 Source를 직접 보는 핸들은 이미 돈 리비전이라 건너뛰지만, 파생 State를 보는 핸들은 같은 값으로 한 번 더 돌 수 있습니다. 이 순서들은 스냅샷 순서(= 약한 키 테이블의 순회 순서)에 달려 있어 quad가 정하지 않습니다.


5.1 Effect가 자기 상류를 :Set한다 — 정의됨

섹션 제목: “5.1 Effect가 자기 상류를 :Set한다 — 정의됨”

위 (나)가 그 경우입니다. 안쪽 파동은 _pending 깃발 하나로 접히고, 바깥 fn이 돌아온 뒤 다음 실행 하나가 그 변경을 처리합니다. 실행이 겹치지 않으니 “어느 실행이 마지막인가”가 늘 분명하고, 그 마지막 실행이 최신 값을 읽습니다. 이것이 레퍼런스가 Effect의 자기 의존 :Set을 정의된 경로라고 부르는 이유입니다.

이 성질은 되쓰기가 어디를 거쳐 오든 같습니다.

  • 그 의존의 상류를 써도 — fn이 a를 보고 a의 원천 src를 쓰면, src의 파동이 a를 거쳐 이 Effect에 닿을 때 이미 도는 중이라 똑같이 접힙니다.
  • 다른 Effect를 거쳐도 — 서로의 의존을 맞추는 두 Effect A(flag → other), B(other → flag)에서, A의 fn이 other를 쓰면 B가 그 자리에서 돌고(B는 도는 중이 아니므로), B가 flag를 쓰면 그 파동은 아직 돌고 있는 A에 닿아 _pending으로 접힙니다. 순환이 한 바퀴 돌아도 두 fn 모두 자기 위에 겹치지 않습니다.
  • 의존이 게이트 뒤에 있어도 — Blocker·Debounce 같은 게이트는 유보한 통지 묶음을 내보내기 직전에 빈 묶음과 맞바꿉니다(GateImpl._flush의 “swap, never clear”). 그래서 내보내는 도중 하류 Effect가 게이트의 상류를 써도 그 새 통지는 새 묶음에 쌓이고, 지금 나가는 묶음과 섞이지 않습니다. 이 Effect가 받는 쪽에서는 다시 위의 경우가 됩니다.

정의되는 것은 수렴하는 고리입니다 — 값이 자리 잡아 더는 :Set이 일어나지 않으면 루프가 끝납니다. 자기 의존을 다듬어 되쓰는 fn은 :SetIfChanged로 써야 하는 이유도 여기 있습니다. Source:Set은 같은 값도 전파하므로, 조건 없이 되쓰는 fn은 매번 _pending을 다시 세워 영영 끝나지 않습니다.

수렴하지 않는 고리는 정의되지 않습니다. 다만 이 루프는 repeat … until이라 스택이 자라지 않습니다 — 재귀라면 스택 오버플로가 언젠가 멈춰 주지만, 이 루프는 아무것도 멈춰 주지 않아 무한히 돕니다. 그래서 루프 지역 카운터 하나로 1만 번째 뒤 Quad0263을 던집니다. 이 한도는 고리를 “1만 번까지는 정의”하려는 것이 아니라, 정의되지 않은 고리가 무한 정지 대신 끝나게 동작을 맞추는 장치입니다. 재귀로 도는 정의되지 않은 고리(아래 5.2의 조건 없는 되쓰기, Slot Length 고리)가 스택 오버플로로 끝나는 것과 같은 자리에 있습니다.

5.2 Observer가 자기 상류를 :Set한다 — 정의되지 않음

섹션 제목: “5.2 Observer가 자기 상류를 :Set한다 — 정의되지 않음”

위 (가)가 그 경우입니다. 루프가 없으니 안쪽 파동은 그 자리에서 콜백을 다시 부르고, 두 실행이 겹칩니다. 그 결과로 정해지지 않는 것이 셋입니다.

  1. 겹친 실행 중 어느 것이 마지막인가 — 바깥 실행은 되쓰기 전에 읽은 값을 들고 이어지므로, 마지막으로 끝나는 실행이 낡은 값으로 일합니다.
  2. 형제가 무엇을 몇 번 받는가 — 4절 6단계대로 스냅샷 순서와 형제의 종류에 달립니다.
  3. 언제 멈추는가 — 조건 없이 되쓰는 콜백은 겹친 실행마다 새 파동을 만들어 스택이 자라다 넘칩니다.

quad는 이 셋 중 어느 것도 정하지 않고, 그래서 이 장면 전체를 정의하지 않습니다. 이것은 Observer의 결함이 아니라 자리입니다 — Observer는 최종 소비자로, 관측한 값을 화면·로그·바깥 세계로 내보내는 끝자리이지 그래프에 값을 다시 공급하는 자리가 아닙니다. 다른 대상을 :Set하는 것은 본업이고, 자기 상류를 고쳐 써야 한다면 그 일은 루프를 가진 Effect로 옮기면 됩니다.

q.debug를 켜면 이 겹침이 보입니다. Observer의 _receive가 통지를 받을 때, 그 핸들의 콜백 프레임이 지금 호출 스택에 있으면 — 곧 자기 콜백이 도는 도중 같은 핸들에 통지가 다시 왔으면 — 한 줄을 찍습니다. 같은 모양이지만 정의된 경로 셋은 건너뜁니다: Slot의 Offset/Length를 보는 콜백이 Slot을 고쳐 그 값이 다시 바뀌는 고리(자리 계산이 다시 돌아 맞춥니다), 엔진이 q.Out·OnChange로 값을 되올리는 왕복(엔진은 같은 값의 재대입에 신호를 내지 않아 한 바퀴에서 멈춥니다), 그리고 Tween의 Completed에서 다음 트윈을 잇는 연쇄입니다. 셋 다 그 고리를 정의된 것으로 만드는 장치가 Observer 바깥에 있습니다 — 자리 계산의 되감기, 엔진의 같은 값 무발화, 기록을 마친 뒤에야 Completed를 부르는 프로퍼티 핸들러입니다.

rawRerun은 fn을 pcall로 감싸지 않습니다(quad는 사용자 콜백을 감싸지 않습니다 — 던진 뒤의 상태를 복구하지 않는 대가로 정상 경로에 아무 비용도 얹지 않습니다). 그래서 fn이 던지면 루프는 self._running = false에 닿지 못하고, 깃발은 선 채로 남습니다.

그 뒤 어떤 변경이 와도 _receive → Rerun → rawRerun의 첫 줄이 _running을 보고 _pending = true만 올린 채 돌아갑니다. 그 _pending을 소비할 루프는 이미 예외로 빠져나갔으므로, fn은 다시 돌지 않습니다. 이것이 Effect의 죽음입니다. cleanup이 던져도 같습니다(_cleanupRunning이 선 채로 남습니다).

이 동작은 3절의 미루기 장치에서 그대로 나옵니다 — 겹친 요청을 “지금 도는 실행이 끝난 뒤”로 미루는 장치는, 지금 도는 실행이 끝나지 않으면 그 요청을 영원히 미룹니다. quad는 이것을 약속으로 둡니다: fn이나 cleanup이 던진 Effect는 다시 돌지 않습니다. 같은 깃발 때문에 그 핸들의 (재)구독과 다시 묶기는 거부되고(Quad0088/Quad0087), 해제 둘(:Unsubscribe()/:WeakUnsubscribe())만 통과합니다 — 죽은 핸들을 놓아주는 길입니다. 다시 돌려야 한다면 원인을 고치고 새 Effect를 만드십시오.

5.4 Observer가 던진다 — 다음 파동에 다시 돈다

섹션 제목: “5.4 Observer가 던진다 — 다음 파동에 다시 돈다”

Observer의 run도 pcall이 없으므로, 콜백이 던지면 self._running = outer에 닿지 못해 깃발이 선 채로 남습니다. 여기까지는 Effect와 같습니다. 다른 것은 Observer의 _receive가 _running을 읽지 않는다는 점입니다(3.1의 코드). 다음 변경이 오면 _receive는 그 깃발과 무관하게 run을 부르고, 콜백은 다시 돕니다.

죽는 것은 그 파동의 그 전달 하나입니다. 예외는 그 전달을 일으킨 :Set 줄까지 올라가고, 그 파동의 나머지 순회도 함께 끊깁니다. 하지만 Observer는 다음 파동을 받는 데 자기 상태를 쓰지 않으므로, 다음 파동이 오면 아무 일도 없었던 것처럼 받습니다. quad는 이것을 약속으로 둡니다: 던진 Observer 콜백은 다음 변경에 다시 돕니다. 루프가 없는 구조가 공짜로 주는 성질이고, 관측만 하는 끝자리에는 꼭 맞는 성질입니다 — 일시적으로 던진 로그·표시 콜백이 한 번의 실수로 영원히 꺼지지 않습니다.

다만 선 채로 남은 깃발이 막는 것은 있습니다. 관측(계약 아님): 지금은 그 핸들을 다시 구독하거나 다른 인스턴스에 묶으려 하면 “콜백 도중”으로 판정해 거부합니다(Quad0088/Quad0087). 계약은 “다음 변경에 다시 돈다” 하나이고, 던진 핸들을 새 자리로 옮기는 일까지 약속하지는 않습니다.


한쪽으로 합치면 어느 쪽이 잃는지가 분명합니다.

  • 모든 말단에 Effect의 루프를 두면 — 자기 상류 되쓰기는 어디서나 정의되겠지만, 가장 흔한 말단인 “값이 바뀌면 화면에 반영”마다 에폭 기록과 루프 상태를 얹게 되고, 한 번 던진 표시 콜백이 영원히 꺼지는 성질을 모든 말단이 물려받습니다.
  • 모든 말단을 Observer처럼 두면 — 비용은 가장 낮지만, 자기 입력을 고쳐 써서 수렴시켜야 하는 정당한 일(값을 범위 안으로 되돌리기, 두 원천을 서로 맞추기)을 할 자리가 그래프 안에 없어집니다.

그래서 quad는 역할을 나눕니다. Observer는 값을 내보내는 끝자리이고, 받은 전달마다 그 자리에서 일하고 그 이상의 상태를 갖지 않습니다. Effect는 자기 입력까지 다루는 부수효과의 자리이고, 그 일을 정의하기 위해 루프와 에폭 기록을 갖습니다. 되쓰기와 예외에서 갈리는 네 결과는 이 분업의 앞면과 뒷면입니다 — 루프가 있어서 되쓰기가 정의되고, 같은 루프가 있어서 던지면 죽습니다. 루프가 없어서 되쓰기가 정의되지 않고, 같은 이유로 던져도 살아납니다.


Observer Effect
통지를 받으면 그 자리에서 콜백 에폭 기록을 갱신하고 새 것이면 Rerun
콜백이 도는 중에 또 오면 그 자리에서 겹쳐 다시 _pending으로 접어 끝난 뒤 한 번 더
무엇을 받았는지의 기록 직접 보는 Source의 마지막 리비전 하나 의존마다 본 리비전(_epochs)
자기 상류 :Set 정의되지 않음 수렴하면 정의 — 수렴하지 않으면 정의되지 않음(Quad0263으로 끝남)
콜백이 던지면 그 전달만 죽고 다음 파동에 다시 돈다(약속) 깃발이 남아 다시 돌지 않는다(약속)
던진 뒤 재구독·묶기 거부됨(관측 — 계약 아님) 거부됨(해제만 통과)