프론트엔드 프레임워크의 심장: Push, Pull, 그리고 Push-Pull 반응성 알고리즘 파헤치기


Reactivity Cover

최근 프론트엔드 생태계를 보면 온통 Signals 이야기뿐이다. Vue, SolidJS, Angular, 심지어 Svelte까지 각자의 방식으로 이 패러다임을 수용하고 있다. 하지만 정작 이 반응성(Reactivity)이라는 마법이 내부적으로 어떻게 동작하는지 깊게 고민해 본 엔지니어는 생각보다 많지 않다.

최근 Jonathan Frere가 작성한 블로그 글을 읽으며, 우리가 매일 사용하는 반응성 시스템의 근본적인 알고리즘 3가지를 다시 한번 정리해 볼 좋은 기회라고 생각했다. 15년 넘게 다양한 프레임워크의 흥망성쇠를 지켜본 엔지니어로서, 이 글을 통해 Push, Pull, 그리고 이 둘의 하이브리드인 Push-Pull 알고리즘의 실체와 한계를 파헤쳐 보려 한다.

훌륭한 반응성 시스템의 4가지 조건

스프레드시트를 상상해 보자. 셀 하나를 변경했을 때 연관된 수식들이 연쇄적으로 업데이트되는 과정이 바로 반응성의 핵심이다. 프로덕션 레벨의 엔진이 되려면 다음 4가지 조건을 만족해야 한다.

  • Efficient: 계산은 최소한으로 수행해야 한다. 즉시 버려질 중간 연산은 하지 않아야 한다.
  • Fine-grained: 변경된 입력에 영향을 받는 노드만 정확히 업데이트해야 한다.
  • Glitchless: 중간 상태(Invalid state)가 사용자나 다른 로직에 노출되면 안 된다.
  • Dynamic: 조건문에 따라 런타임에 의존성이 동적으로 추가되거나 제거될 수 있어야 한다.

Push-Based Reactivity (밀어내기)

데이터가 변경되면 해당 노드가 자신의 의존성 트리 아래로 업데이트 신호를 밀어낸다. RxJS의 스트림이나 일반적인 Event Emitter 패턴이 이 방식을 따른다.

장점은 명확하다. 상태가 변한 노드부터 시작하므로 매우 세밀하게(Fine-grained) 동작한다. 하지만 치명적인 단점이 있다. 바로 다이아몬드 문제로 인한 비효율성과 Glitch 발생 가능성이다. 노드 A가 B와 C를 업데이트하고, B와 C가 모두 D를 업데이트한다고 가정해 보자. 단순한 Push 시스템에서는 D가 불필요하게 여러 번 재계산되며, 그 과정에서 일시적으로 동기화되지 않은 엉뚱한 값을 노출할 수 있다.

솔직히 말해서, 나는 순수한 Push 기반 시스템을 UI 상태 관리에 사용하는 것을 선호하지 않는다. 이를 완벽하게 제어하려면 전체 그래프를 정적으로 분석해 위상 정렬(Topological sort)을 수행해야 하는데, 이는 동적 의존성(Dynamic)을 포기하는 것과 다름없기 때문이다.

Pull-Based Reactivity (당겨오기)

React의 렌더링 방식을 떠올려 보자. 부모 컴포넌트가 렌더링될 때 자식들에게 값을 당겨온다(Pull). 본질적으로 함수 호출 스택과 동일한 구조다.

이 방식은 항상 최신 상태의 일관된 데이터를 보장하므로 Glitchless 조건을 완벽하게 만족한다. 또한 런타임에 조건부로 함수를 호출하면 되므로 동적 의존성 관리도 공짜로 얻는다.

하지만 효율성 측면에서는 최악에 가깝다. 입력값이 변했을 때 어떤 출력 노드가 영향을 받는지 미리 알 수 없으므로, 원칙적으로는 전체 트리를 다시 계산해야 한다. 이를 막기 위해 우리는 무거운 캐싱 메커니즘을 도입해야만 했다. React 프로젝트에서 불필요한 렌더링을 막기 위해 useMemouseCallback으로 도배된 코드를 리뷰해 본 시니어라면 이 고통을 뼈저리게 알 것이다.

Push-Pull Reactivity (하이브리드의 승리)

최근 Signals 라이브러리들이 채택하고 있는 방식이자, 이 글의 핵심이다. Push와 Pull의 장점만 교묘하게 섞었다.

  1. Push 단계: 데이터가 변경되면 하위 노드들을 순회하며 dirty 플래그만 마킹한다. 실제 계산은 하지 않는다.
  2. Pull 단계: 결과를 관찰하는 출력 노드(예: UI 렌더링 함수)가 값을 요청할 때, 그래프를 거슬러 올라가며 dirty 마킹이 된 노드만 선택적으로 재계산한다.

이 우아한 O(n) 알고리즘은 단일 스레드 환경에서 거의 완벽하게 동작한다. 불필요한 연산을 피하면서도(Efficient), 필요한 부분만 건드리고(Fine-grained), 일관된 상태를 보장하며(Glitchless), 런타임 의존성 추적(Dynamic)까지 완벽하게 지원한다.

Hacker News의 시선과 DX (Developer Experience)

원문이 올라온 Hacker News 스레드에서 가장 흥미로웠던 토론은 알고리즘 자체가 아니라, 이를 개발자에게 어떻게 노출할 것인가(DX)에 대한 부분이었다. 한 유저가 언급한 Vue의 예시를 보자.

const input = ref(1);
const output = computed(() => input.value + 1);

개발자는 의존성을 명시하지 않지만, 시스템은 output 클로저를 평가(Pull)하면서 내부적으로 접근되는 ref를 추적해 의존성 그래프를 런타임에 구축한다.

Svelte 5의 런타임 프록시(Proxy) 도입에 대한 논쟁도 뜨거웠다. 암시적 반응성(Implicit reactivity)이 타입 시스템에 명확히 드러나지 않아 유지보수 시 “이게 반응형 값인지 일반 값인지” 헷갈리게 만든다는 비판이다. 나 역시 이 의견에 강력히 동의한다. 프레임워크의 마법이 과해지면 디버깅은 재앙이 된다. 순수한 값과 반응형 래퍼(Wrapper)의 경계가 모호해지는 것은 장기적으로 프로젝트 아키텍처를 망치는 지름길이다.

또한, 프론트엔드를 넘어 백엔드의 데이터플로우 관점에서 이 문제를 짚은 컴파일러 엔지니어의 댓글도 인상적이었다. 그래프 탐색 시 포인터 체이싱(Pointer chasing) 대신 배열과 비트마스크를 활용한 최적화 기법은, Latency가 극단적으로 중요한 백엔드 엔진 개발 시 반드시 고려해야 할 포인트다.

Verdict

현재 웹 생태계에서 Push-Pull(Signals) 알고리즘은 명백한 승자다. 하지만 은탄환은 아니다. 이 알고리즘은 “모든 Pull 로직이 입력값 변경 사이의 단일 틱(Tick) 내에 동기적으로 처리된다”는 가정을 전제로 한다. 비동기 I/O나 Rust의 멀티스레드 환경으로 넘어가면 트랜잭셔널 메모리나 CRDT 같은 훨씬 복잡한 개념이 필요해진다.

당신이 시니어 엔지니어라면 프레임워크가 제공하는 반응성을 그저 블랙박스처럼 가져다 쓰는 데 그쳐서는 안 된다. 그 이면에 숨겨진 위상 정렬과 그래프 탐색의 트레이드오프를 명확히 이해해야만, 예기치 않은 성능 병목과 Glitch의 늪에서 팀을 구출할 수 있을 것이다.


References: