푸시 기반 시그널에 대한 새로운 접근법과 그 이점, 한계, 그리고 현실에서의 적용 가능성에 대해 살펴봅니다. Solid 프레임워크 2.0과의 관련성도 함께 논의합니다.
몇 주 전, Milo가 정말 참신한 아이디어를 들고 저를 찾아왔습니다. 그는 푸시(Push)-기반 신호를 구현할 수 있는 새로운 접근법을 생각해냈습니다. 여러분은 "그게 왜 필요하지?"라는 의문이 들 수도 있습니다. 결국 우리는 푸시 기반 방식이 사이클(순환 참조)을 방지할 정보를 가지고 있지 않다는 점을 잘 알고 있습니다.
그런데 만약 그 문제가 없다면 어떨까요? 푸시 기반의 가장 큰 장점은 무한히 단순하다는 점입니다. 오늘날 우리가 사용하는 푸시-풀-푸시(Push-Pull-Push) 또는 푸시-풀(Push-Pull) 구조 대신, 그냥 푸시만 하면 됩니다. 업데이트를 위해 그래프를 세 번 순회할 필요 없이 한 번만 걸으면 되죠.
실제로 이 방식은 놀랍지 않게도 Alien Signals보다도 더 빨라 보입니다. 또한, Projection과 같은 부분에서 정확성을 네이티브로 지원하므로, 해당 데이터 전파 방식 역시 대폭 단순화됩니다.
하지만 이런 세상에서 우리가 살아갈 수 있을까요?
푸시 기반은 대부분의 경우 eager(즉시 실행)입니다. Solid의 역사를 돌아보면 오늘날 우리가 사용하는 방식과 꽤 닮아 있습니다. Solid 2.0에서 lazy(지연 평가) 시스템을 도입하려는 노력은 있었지만, 여러 측면에서 푸시는 Solid 1.0의 eager 시스템에 더 가깝습니다.
작동 방식은 모든 것을 의존성의 깊이(depth) 기준으로 정렬하는 것입니다. 즉, 자신의 깊이는 자신이 가지고 있는 의존성 중 가장 큰 깊이에 +1을 한 값이 됩니다. 이 같은 트릭은 부모/ownership 모델에도 적용되어, 부모의 깊이+1(혹은 최대값)로 처리할 수 있습니다.
그런 다음 업데이트가 발생하면, 단 한 번만 아래로 내려가며 실행하고 변화가 확인된 경우에만 즉시 관찰자들만 큐잉합니다. 변하지 않은 것은 건너뛰기 때문에 작업이 최적화됩니다.
그래프에 여러 개의 '2'가 있지만 상호 의존하지 않으니 실행 순서는 신경 쓸 필요가 없습니다. 그리고 모든 의존성이 실행되기 전에 미리 무언가가 실행될 가능성도 없습니다.
여기까지는 좋아 보입니다. 그런데 왜 아무도 이걸 먼저 하지 않았을까요? 이 방식은 고정된(fixed) 그래프에는 매우 잘 동작합니다. 하지만 동적인 의존성(dynamic dependency)은 깊이의 변화를 유발할 수 있습니다. 예를 들어 1레벨에서 실행 중이던 노드가 갑자기 4레벨에 있는 무언가에 의존하게 된다면, 그 값이 최신값이 아닐 수 있습니다. 게다가, 푸시 기반 시스템에서는 변경 가능성 자체를 알릴 방법도 없습니다.
이를 해결하는 한 가지 방법은 시스템 전체를 continuation(계속성, 중간중간 멈췄다 이어서 실행하는 것)로 모델링하는 것입니다. 사실 이 모델은 Jane Street의 Incremental 라이브러리에 기반해 있습니다. JavaScript에서 Promises를 사용해 실행을 멈췄다가 준비되면 재개하는 방식으로 구현할 수도 있겠죠. 하지만 자바스크립트의 프라미스는 항상 비동기(async)라 모든 읽기마다 이를 쓰는 것은 어색하며, 재개 시 컨텍스트를 다시 주입할 방법이 없습니다.
그래서 JavaScript의 제너레이터(generator)를 고려할 수 있습니다. 하지만 이 역시 너무 번거롭죠. 우리가 비동기에서 throw/re-execute(예외를 던져 재실행)하는 방식도 있지만, 이건 downstream(하위)쪽에도 깊이 변화가 ripple(파도처럼 전파)되어 자신이 원래보다 더 깊다는 사실을 깨닫고 예외를 던지는 문제가 남아 있습니다.
그래서 Milo가 내놓은 간단한 해결이 있습니다. 이 순간에는 Push-Pull-Push로 fallback(되돌아가) 합니다. Queue는 정확히 어떤 것이 dirty인지 알기 때문에, 해당 지점부터 아래로 변경을 알릴 수 있습니다. 만약 아직 준비되지 않은 값을 읽으려고 할 때만 평가하면 됩니다. 이 방식은 하위까지 모든 effect를 다시 큐잉하지 않고, 값이 실제로 사용되는 위치에서만 불러오니 기존 Signal 솔루션의 최고 성능과 비슷하거나 오히려 조금 더 나을 수 있습니다.
만약 depth(깊이)가 늘어날 뿐 줄어들지는 않는다면, 큐에 여러 레벨이 쌓일 수는 있어도 시간이 지날수록 안정화되고, 다음에 동적 의존성이 들어와도 최적 성능을 얻게 됩니다.
즉, 결국 이것은 단순성과 성능 측면에서 엄청난 최적화이긴 하지만 몇 가지 tradeoff(대가, 절충)도 있습니다.
노드가 eager하게 실행됩니다. ...음, Solid 1.0 경험을 보면 큰 문제는 아닐 것 같습니다. Ownership 덕분에 필요한 만큼만 노드가 살아있으며, 정말로 lazy가 필요한 경우 fallback을 강제해 선택적으로 적용할 수 있습니다.
반응성 전파 과정에서 상위 노드에 쓰기는 불가능합니다. 즉, Memo 등에서 값을 쓸 수 없습니다. 신호에 ownership 기반 depth를 주면 레벨이 1 이상 차이가 날 때 오류가 발생하게 해 탐지가 쉽습니다. Store는 Signal과 depth를 맞추는데 소소한 도전과제가 남긴 합니다.
노드가 기본적으로 알림을 보내지 않으므로, 값을 임의로 읽는 것은 최신값임이 보장되지 않습니다. 이 부분은 더 고민이 필요하므로, 다음 섹션에서 자세히 다룹니다.
반응성 시스템 내부에서는 모든 것이 잘 동작합니다. 순서대로 실행되어 항상 최신이 유지되기 때문이죠. 문제가 되는 상황은 반응성 시스템 외부, 예를 들어 이벤트나 부수 효과(side effect)에서 작업할 때입니다.
대표적인 예시는 다음과 같습니다:
jsconst [count, setCount] = createSignal(0); const doubleCount = createMemo(() => count() * 2); let ref; return <button ref={el} onClick={() =>{ setCount(1); console.log(count(), doubleCount(), ref.textContent) }}>{doubleCount()}</button>
Solid에서는 결과가 1, 2, 2
가 되고, React에선 0, 0, 0
, Vue/Svelte에서는 1, 2, 0
이 됩니다.
저는 Solid 2.0의 lazy 평가에서는 1, 2, 0
을 지지했지만, push 기반 시스템에서는 상황이 좀 더 까다로워집니다. 파생값이 한 단계면 Signal이 더티 마킹될 때 doubleCount도 자동으로 마킹되는 것은 어렵지 않지만, 중간에 단계가 더 많아지면 doubleCount가 더티인지 모르고 이전 값을 반환할 수 있습니다.
즉, 1, 0, 0
처럼 나오게 되는데, 이는 Svelte의 옛 모델이며 가장 별로입니다. 몇 가지 대안이 있으나 모두 tradeoff가 있습니다.
첫째, 그냥 Solid 1.0처럼 처리해서 매번 signal을 set할 때 마다 모든 계산을 실행할 수 있습니다. 즉, 1, 2, 2
가 나옵니다. 보통 그렇게 나쁘지 않습니다. tricky한 부분은 Effect의 처리인데, 1.0에서는 effect들을 batch로 묶어 처리했습니다. 이번에는 코드가 같더라도 doubleCount가 실제로 업데이트되는지 알지 못합니다.
사실 effect 분리(splitting) 후에는 reactivity(반응성) 값에 바로 기대지 않는 것이 좋을 수 있습니다.
예를 들어,
jscreateEffect(inputSignal, () => { setCount(1); console.log(count(), doubleCount(), ref.textContent) })
는
jscreateEffect(inputSignal, () => { setCount(1); }) createEffect( () => [count(), doubleCount()], ([count, double]) => console.log(count, doubleCount, ref.textContent) );
더 나아가 이렇게 쓸 수 있습니다:
jsconst count = createMemo(() => fn(inputSignal())); const doubleCount = createMemo(() => count() * 2); createEffect( () => [count(), doubleCount()], ([count, double]) => console.log(count, doubleCount, ref.textContent) );
결국 문제의 핵심은 batch의 동작 방식입니다. effect loop 내에서는 batch 동작이 빠질 수 없습니다. 이벤트라면 최소한으로 effect들을 묶어야 하죠. 하지만 업데이트까지 batch하면 Svelte 3처럼 의도하지 않은 동작이 나옵니다.
React 방식처럼 그냥 값을 안 바꿀 수도 있습니다. Solid 1.5 이전은 batch일 때 항상 이전 값을 유지했고, 꽤 일관적이었습니다. 이후로는 Vue처럼 필요할 때마다 값을 물어보는(On-demand) 방식이었습니다.
만약 이 모델을 push 기반에 그대로 적용한다면, 모든 update마다 순수(pure) reactivity를 모두 실행해야 합니다. 부수효과(effect)는 스케줄링하더라도, 그래프를 즉시 즉시 바로바로 안정화(stabilize)해야 하죠. 즉, 다음 라운드의 effect만 batch하게 됩니다.
적당한 절충점은 on-demand로만 안정화하는 것일 수 있습니다. batch 상태에서 어떤 계산을 읽었을 때만 모든 계산을 안정화(stabilize)합니다. 즉, 합리적으로 코딩하면 성능 하락이 없고, 이상한 패턴만 썼을 때만(읽기-쓰기-읽기 반복 등) 성능이 떨어집니다.
이외에 다른 모델이 있을까요?
만약 batch일 때만이 아니라 항상 이 안정화(stabilize) 방식을 택하면 어떨까요? 즉, 순수 큐(queue) 실행이 아닌 모든 외부 읽기마다 그래프를 최종적으로 안정화하려고 하는 겁니다. 대신 effect는 항상 batch하니 따로 batch helper는 필요 없고, 빠른 batch 효과가 필요하면 flushSync를 남겨둡니다.
외부에서는 Solid 2.0의 현재 동작과 거의 비슷해집니다. write 이후 read에서 해야 할 일이 조금 더 많아지지만 큰 문제는 아닙니다.
인기가 없더라도 언급하고 넘어가야죠. effect 내에서 read-after-write 자체가 안티패턴이라면 이게 제일 단순한 방식일 수 있습니다. flushSync로 보내버리니 defer와 큰 차이는 없지만, 수정된 그래프가 너무 비싸다고 걱정할 필요가 있다면 더 좋을 수도 있습니다.
개인적으로는 1.0 Solid API처럼 배치로 업데이트와 효과를 묶어, batch 안에서 읽기를 빨리 하면 업데이트가 트리거되는 쪽이 자연스럽다고 생각합니다. 최적화 여지도 있고요. flushSync 패턴도 나쁘지 않지만, 저는 미묘하게 배치 쪽에 더 마음이 갑니다.
잘 모르겠습니다. 아직 빠진 부분이 있는 것 같은데, 노드가 laziness를 opt-in하면 안정화 그래프와 어떻게 상호 작용할지 의문이 듭니다. 대표적인 예는 children helper를 lazy로 처리하면 좋겠다는 것입니다. 소유권이 바뀌기 어려워도, 사람들이 아직 DOM에 삽입되지 않은 노드에서 children을 읽기 원할 수 있기 때문이죠.
다행히도 대부분의 경우 이는 DOM/렌더링 관련이라 대개 eager로 읽히지 않습니다. 중요한 점은 eager 시스템에선 한 번만 연결되어도 더 이상 lazy하지 않게 됩니다.
유사하게, 제어흐름(control flow)이나 fragment 주위의 memo 역시 lazy로 가야 할지 고민이 됩니다. 사이드 이펙트는 크지 않지만, JSX 분기 등에서는 lazy가 더 맞을 수도 있습니다. 만약 다 lazy하면 아래 그래프를 계속 알려주어야 해서 최적화가 잘 안 될 수도 있습니다.
차라리 아예 eager라고 가정하고 children의 API를 더 명확히 하는 것도 방법일 듯합니다. 예를 들어 children 대신 <Children>
처럼 컴포넌트를 만든다든지:
jsfunction MyComp(props) { return <Children children={props.children}> {children => <MyContext value={something}>{ safeChildren(children) }</MyContext> } </Children> }
좀 번거로울 순 있지만 ownership 구조를 망치긴 어려울 겁니다. ContextProvider보다 위에서 처리됨이 명확하니까요.
이런 방법도 가능합니다:
jsfunction MyComp(props) { return <MyContext value={something}> <Children children={props.children}>{ children => <>{safeChildren(children)}</> }</Children> </MyContext> }
최종적으로 lazy를 선택적으로 적용한다면, 얕은(shallow) 수준에 그칠 가능성이 큽니다. 즉, 다음과 같이 쓸 수 있습니다:
jsconst expensiveThing = createMemo( () => expensiveCalcution(props.something), undefined, { lazy: true } ); return <>{showExpensive() ? expensiveThing() : undefined}</>
단, 일단 lazy를 만나면 아래 업데이트는 변경됐을지도 모르니 모두 알림을 보내게 됩니다. 하지만 많은 경우 dirty로 만드는 것이 위쪽에 있으므로 별 문제는 아닐 겁니다.
흥미로운 질문입니다. 만약 순수 큐가 eager하다면, createAsync와 createMemo의 차이는 결국 Promise/Async Iterable을 처리하는 코드의 유무뿐입니다. 실제로 코드사이즈만 감수한다면 일반 메모에는 영향이 없습니다.
이와 관련해 몇 가지 고려사항이 있습니다. createMemo가 프라미스를 그대로 보관하지 않게 되고(기다림을 전제로 하게 됨), 이는 무난할 것 같습니다.
서버에서는 createAsync만의 특별한 직렬화(serialize) 동작이 있지만, 각 노드는 id가 있어 해당 경로는 여전히 발견될 수 있고, 해당 경로가 async이므로 문제 없습니다.
deferStream 옵션 역시 고려 대상입니다. 이 옵션은 모든 memo에 붙일 수 있지만, 사실상 비동기인 메모에만 의미가 있습니다. 체인 어느 곳이든 promise가 발생하면 deferStream을 심을 수 있죠. 명확성을 위해 분리하는 것이 혼란을 줄일 수 있습니다.
총론적으로 기술적으로는 충분히 가능합니다. 더욱 심플하게 4가지 기본 프리미티브만 남길 수 있습니다: createSignal, createMemo, createStore, createProjection.
여기에 Signals(그리고 Store)가 에러 상태를 설정하는 세 번째 setter를 반환하도록(예: setError) 확장할 수도 있겠죠:
jsconst [value, setValue, setError] = createSignal();
이렇게 하면 NotReadyError를 설정/해제하는 것으로 Suspense도 트리거할 수 있습니다. 조금 극단적일 순 있지만, 항상 원리부터 다시 고민해보면 유익한 점이 많습니다.
지금까지가 간단한 정리입니다. 여전히 신선한 아이디어라 앞으로 더 깊이 있게 고민해야 할 지점이 남았지만 확실히 재미있습니다. 2.0의 큰 방향에는 영향이 없고, 오히려 업데이트 처리 구조가 더 명확해질 수 있습니다.
Milo는 아직 완전 구현까지 할 일이 많이 남았지만, 한 번쯤 더 생각하고 논의할 만한 주제임은 분명합니다.