SolidJS와 함께 되짚어보는 반응형 프로그래밍


들어가며

최근 몇 년 사이 반응형 프로그래밍(Reactive Programming)이라는 개념이 웹 프런트엔드 개발 분야에 많이 스며들었다. Vue.js가 대표적으로 반응형 프레임워크로 알려져있으며, 최근에는 Svelte가 많이 언급되고 있다. Angular는 대표적인 반응형 프로그래밍 라이브러리 RxJS를 피어 의존성(Peer Dependency)으로 두고 있으며, 이를 활용하여 반응형 프로그래밍이 가능하도록 설계되어있다.

그리고 React를 빼놓으면 섭섭한데, React는 엄밀히 말해 반응형 라이브러리가 아니다. 다만 React와 함께 많이 활용하고 있는 상태 관리 라이브러리 중 MobX가 반응형 프로그래밍이 적용된 라이브러리로 잘 알려져 있다. (React의 반응성(Reactivity)에 대한 주제는 이번 글에서 이야기하고자 하는 내용을 벗어나기 때문에 깊게 언급하지 않는다.)

"팀 안에서는 React가 완전히 '반응형'이 되려 하지 않기 때문에 'Schedule'로 불려야 한다는 농담을 합니다."(There is an internal joke in the team that React should have been called “Schedule” because React does not want to be fully “reactive”.) -- https://reactjs.org/docs/design-principles.html#scheduling

반응형 라이브러리 중 아직 언급하지 않은 것이 있으니, 바로 오늘 이야기할 SolidJS이다. 이 라이브러리는 개발이 시작된 지 5년이 넘었지만, 2021년 State of JS 설문에서야 처음 순위 목록에 모습을 드러냈다. 그리고 설문에 참여한 사람들의 만족도 순위 중 1위를 달성했다. 필자는 SolidJS 특징과 이 라이브러리가 추구하는 반응형 모델을 살펴보면서, 반응형 프로그래밍의 개념을 다시 돌아보게 되었다.

이번 글을 통해 반응형 프로그래밍의 개념을 가볍게 살펴본 후 구현 방식의 한 형태인 '투명한 반응형 프로그래밍(Transparent Reactive Programming, TRP)'을 알아본다. 그리고 투명한 반응형 프로그래밍의 개념이 적용된 SolidJS의 반응형 모델과 SolidJS의 렌더링 특징을 간단히 소개한다.

반응형 프로그래밍?

반응형 프로그래밍을 설명하고자 하는 자료는 무척 많지만 쉽게 와닿는 자료는 그렇게 많지 않았다. 그나마 짧게 요약하자면 아래와 같이 표현할 수 있을 것이다.

"반응형 프로그래밍은 데이터 중심의 이벤트 이미터 위에 만들어진 선언적 프로그래밍 패러다임이다.(Reactive Programming is a declarative programming paradigm built on data-centric event emitters.)" -- Ryan Carniato(SolidJS의 제작자)

여기서 "선언적 프로그래밍 패러다임"과 "데이터 중심의 이벤트 이미터" 라는 두 가지 포인트가 있다. 간단하게 하나씩 짚어보면

  • 선언적 프로그래밍 패러다임: 코드가 과정을 표현하는 것이 아니라 행위 자체를 표현하는 것. 대표적인 예로 HTML이나 SQL같이 어떻게 내부 구조가 이루어져 DOM을 렌더링하거나 데이터를 가져올지 표현하는 것이 아니라 어떤 DOM을 표현할지, 어떤 데이터를 가져올 지만 표현하고 있다.
  • 데이터 중심의 이벤트 이미터: 우리는 이벤트가 존재하는 시스템 위에서 개발을 하고 있다. DOM 뿐 아니라 OS도 이벤트 큐를 가지고 있다. 덕분에 변화를 다루는 부분과 변화를 실행하는 액터(actors)를 분리하여 다룰 수 있다. 반응형 시스템의 핵심은 액터가 데이터라는 것이다. 각각의 데이터가 값이 바뀌었을 때 값이 변경되었다는 이벤트를 실행하고 구독자에게 알리는 책임을 가지고 있다.

위의 요약에 따라 반응형 프로그래밍이 적용된 대표적인 예를 살펴보자. 바로 스프레드시트이다.


출처: What is Functional Reactive Programming (FRP)?

A2셀은 그저 "B2셀의 값과 C2셀의 합을 표현한다"라는 선언적인 표현만 작성되어있을 뿐이다. 우리가 어떻게 B2 셀과 C2 셀에서 값을 꺼내와 계산할지 등을 명시적으로 표현하지 않았다. B2나 C2셀의 변경사항은 자동으로 전파되어 A2의 값에 반영될 것이다.

더 나아가 '이벤트로부터 시작하여 시간에 따라 변하는 값의 관계를 선언적으로 표현한다' 는 개념을 구현한 대표적인 구현체가 ReactiveX(Rx)이다. Rx는 이벤트를 비동기 스트림으로 바꾸고, 다양한 오퍼레이터를 적용하며 이 스트림으로 무엇을 하고자 하는지 선언적으로 표현한다.


출처: The introduction to Reactive Programming you've been missing

위의 이미지와 같이 더블 클릭 이상의 이벤트를 수신하는 흐름을 몇 줄의 코드로, 그리고 선언적으로 만들어낼 수 있다. 그런데 왜 우리가 이런 방식의 반응형 프로그래밍을 알 필요가 있을까? 바로 UI를 구성하는데 유용하기 때문이다.

우리가 사용자에게 제공하는 UI는 언제 일어날 지 알 수 없는 거대한 이벤트 트리거 뭉치나 다름없다. 다양한 상호작용이 실시간으로 일어나면서 그 상호작용의 결과를 표시하는 비즈니스 로직을 작성할 때, 효과적으로 이벤트를 처리하는 코드를 가독성 있게 적재적소에 작성하기는 쉽지 않은 일이다. 하지만 반응형 프로그래밍을 통해 코드의 추상화 단계를 끌어올려 구현 방법 그 자체를 고민하는 것보다 비즈니스 로직을 선언적으로 표현하는 것에 더 집중할 수 있게 된다.

하지만 스트림 기반의 반응형 프로그래밍은 러닝 커브가 높고, 문제가 발생했을 때 디버깅이 어렵다는 단점이 자주 언급되어왔다. 스트림 기반의 반응형 프로그램 외에 많이 알려진 형태의 반응형 프로그래밍 방식이 바로 이번에 이야기할 Transparent Reactive Programming(TRP)이다.

'Transparent' 는 투명한, 혹은 명쾌한이란 뜻으로 별도의 코드 변경 없이 반응형 프로그래밍이 이루어지는 것이라는 의미다. 스트림과 오퍼레이터 대신 자동으로 추적 계산이 가능한 데이터를 기반으로 이루어져 있으며, 데이터의 변경 사항을 전파하여 파생된 값을 형성하고 궁극적으로 부수 효과도 일으킨다.

이런 방식을 차용한 라이브러리 혹은 프레임워크로 MobX, Vue.js, SolidJS, Svelte 등이 있다. TRP는 스트림 기반의 반응형 프로그래밍보다 비교적 단순하게 동작하면서, 잠재적으로 존재하는 불안 요소도 제어할 수 있다.

  • 중복 구독을 하거나, 필요한 곳에 구독이 제대로 되지 않은 경우
  • 자동으로 구독이 해제되었으면 하는 경우

어떤 면에서 TRP는 비교적 단순하게, 그리고 투명하게 동작한다고 볼 수 있을까? TRP의 기본 구성 요소를 살펴보며 실마리를 찾아보자.

TRP의 기본 구성 요소

TRP의 Primitive(일반적인 프로그래밍 언어의 원시 타입과 유사하지만, 동일한 개념은 아니라고 판단하여 이 글에서는 기본 구성 요소라는 용어를 사용한다.)는 크게 두 가지가 있다. Observable(옵저버블), 그리고 Reaction(반응)이다.

Observable(옵저버블)

먼저 옵저버블은 관측 가능한 값으로서 그 값을 조회하는 Subscriber(구독자)를 관리하고, 값이 변경될 때 변경 사항을 구독자에게 전파하는 역할을 한다. 다양한 라이브러리에서 Atom, Signal, Ref 등의 각기 다른 용어를 사용하지만 개념은 동일하다. 자바스크립트로 옵저버블을 구현할 때 ES6 Proxy를 사용하거나 직접 Object.defineProperty 사용하여 객체의 속성을 오버라이드하는 방식으로 구현한다. 자세한 구현 원리는 아래의 글을 참고하라.

아래는 Proxy 기반의 옵저버블을 구현하는 코드 일부이다. 대표적으로 MobX는 버전 5부터, Vue는 버전 3부터 Proxy를 사용하여 옵저버블을 구현하고 있다.

function observable(data) {
  const handler = {
    get: function(target, key, receiver) {
      // 구독자를 등록한다.
      // ...
      return Reflect.get(target, key, receiver);
    },
    set: function(target, key, value, receiver) {
      // 값의 변경을 구독자에게 알린다.
      // ...
      return Reflect.set(target, key, value, receiver);
    }
  };
  
  return new Proxy(data, handler);
}

Reaction(반응)

반응은 옵저버블 값이 변화될 때 특정 동작을 수행하는 것이며 두 가지 형태로 나뉜다. 옵저버블 값을 기반으로 파생된 값을 리턴하는 Derivation(파생, 혹은 Computed)과, 관찰하고 있는 값이 변경될 때마다 사이드 이펙트를 실행하는 Effect(이펙트)이다.

이펙트는 함수를 인자로 받아 그 함수 안에서 사용되는 옵저버블 값의 변화가 발생할 때마다 인자로 받은 함수를 실행하는 것이다. 옵저버블 값이 있다 하더라도 그 값이 변경될 때마다 무엇을 해야 할지 매번 코드를 작성해주는 것이 아니라, 변화가 발생할 때마다 자동으로 실행되는 부수 효과를 통해 UI의 동작을 효과적으로 관리할 수 있게 된다. MobX의 autorun, Vue의 watch 등이 대표적인 예이다.

// observable 값을 통해 document.title을 변경하는 예.
const user = observable({ username: 'iamironman', fullname: 'Tony Stark' });

effect(() => {
  // 이펙트는 최초에 한 번 실행되고, 이후 user.fullname 값이 변화될 때마다 자동으로 다시 호출된다.
  document.title = user.fullname;
});

user.fullname = 'Riri Williams';
// 타이틀 변경됨

React에 익숙하다면 useEffect을 활용한 코드와 비슷한 형태로 보일 수 있다. useEffect 훅과 달리 TRP의 반응은 어떤 옵저버블 값을 추적하고 있는지 자동으로 관리한다. 이를 위해 이펙트는 매번 관찰 중인 옵저버블 값이 바뀔 때마다 자신이 어떤 값에 의존하고 있는지 재평가하는 과정을 거친다. 이 동작을 기반으로 최근의 반응형 라이브러리는 '자동으로 의존성을 파악하는(Automatic Dependency Detection)' 단계에 이를 수 있게 되었다.

마지막으로 파생은 기본적으로 이펙트와 동일한 동작을 하지만 값을 리턴한다는 특징이 있다. 거기에 결과값을 메모이제이션하여 불필요한 추가 계산을 방지하고, 실질적으로 조회될 때까지 옵저버블 값의 파생이 일어나지 않도록 지연 평가를 할 수도 있다. 대표적인 예로 MobX의 computed가 있다.

// 스프레드시트를 상상해보자. C1에는 A1 * B1이라는 함수를 지정해두었다.
const A1 = observable({ value: 1 });
const B1 = observable({ value: 15 });

// 최초에 한 번 평가된 뒤 관찰하고 있는 값이 변화될 때까지는 캐싱된 값을 리턴한다.
const C1 = computed(() => A1.value * B1.value);
console.log(C1.get()); // 15
console.log(C1.get()); // 15
A1.value = 2;
console.log(C1.get()); // 30

SolidJS를 비롯한 다양한 라이브러리는 TRP 기본 구성 요소를 적절히 조합하여 Fine-Grained Reactivity(잘게 나누어진 반응성)를 구축하고 효과적으로 값이 변경된 부분만 업데이트하는 것을 추구한다. 옵저버블 값을 하나의 노드로 보고, 노드 사이의 연결을 촘촘하게 구성된 그래프를 만들어 특정 부분이 변경되면 연관된 다른 노드도 반응하여 다시 값이 평가되도록 구성하는 것이다.


출처: Becoming fully reactive: an in-depth explanation of MobX

SolidJS의 반응형 모델

SolidJS는 위에 언급한 TRP의 기본 구성요소로 반응형 모델을 구축하였다. 옵저버블의 역할을 하는 Signal, 부수 효과를 처리하는 Effect, 파생 값을 다루는 Memo가 있다. 각각 createSignal, createEffect, createMemo 를 통해 만들어낸다. SolidJS에서 옵저버블을 제공하는 기본 API인 createSignal 을 간단한 형태로 직접 구현해보자.

const runningContext = [];

function subscribe(running, subscriptions) {
  subscriptions.add(running);
  running.dependencies.add(subscriptions);
}

function createSignal(value) {
  const subscriptions = new Set();
  
  const read = () => {
    const currentRunning = runningContext[runningContext.length - 1];
    if (currentRunning) {
      subscribe(currentRunning, subscriptions);
    }
    return value;
  };
  const write = (nextValue) => {
    value = nextValue;
    
    for (const sub of [...subscriptions]) {
      sub.run();
    }
  };
  
  return [read, write];
}

runningContext 는 현재 실행중인 반응을 담아두는 스택 역할을 한다. 그리고 각각의 Signal은 자신이 관리하는 구독자 리스트(subscriptions)가 있다. 이 두 자료구조가 자동으로 의존성을 추적하는 기능의 기본이 된다. 옵저버블 값의 변화에 따라 반응이 일어날 때 시그널 안에 있는 read 함수가 실행되는 것이고 이 때 구독 처리가 되는 것이다.

여기까지만 보면 subscribe 함수 안에서 활용되는 running 이라는 인자가 무엇을 의미하는지 파악하기 어렵다. 실제로 옵저버블 값을 활용하기 위해 createEffect 함수도 만들어보자.

function cleanup(running) {
  for (const dep of running.dependencies) {
    dep.delete(running);
  }
  running.dependencies.clear();
}

function createEffect(fn) {
  const run = () => {
    cleanup(running);
    runningContext.push(running);
    try {
      fn();
    } finally {
      context.pop();
    }
  };
  
  const running = {
    run,
    depdendencies: new Set()
  };
  
  run();
}

반응이 실행될 때 자신이 어떤 값에 의존하여 반응하고 있는지 관리하기 위한 dependencies 속성이 따로 있다. 그리고 새로이 반응할때마다 기존에 등록해놨던 구독을 청소하고 다시 구독하는 과정을 거친다. 덕분에 동적으로 옵저버블 값의 의존성을 관리하는 것이 가능해졌다.

파생 값을 리턴하는 createMemo 는 각종 최적화를 위해 더 복잡한 방식으로 구현되어야 하지만, 간략한 예시로 createSignal, createEffect 를 활용하여 만들 수 있다.

function createMemo(fn) {
  const [computed, set] = createSignal();
  createEffect(() => set(fn()));
  return computed;
}

이제 위의 재료들을 이용하여 스프레드시트와 유사한 예를 만들어보자. 특정 조건에 따라 관찰하는 셀을 바꾸어야 할 때 어떤 방식으로 처리될까?

const [A1, setA1] = createSignal(1);
const [B1, setB1] = createSignal(2);
const [showA1Only, setShowA1Only] = createSignal(false);

const C1 = createMemo(() => showA1Only() ? A1() : A1() + B1());

createEffect(() => console.log('C1: ' + C1()));
// C1: 3
setShowA1Only(true);
// C1: 1
setB1(10);
// 아무 일도 일어나지 않음
  • 콘솔을 출력하는 이펙트는 C1 을 구독하고 있으며, C1 은 기본적으로 showA1Only 를 구독하면서 상황에 따라 A1 혹은 A1B1 모두를 구독한다.
  • 먼저 이펙트가 최초 실행되면서 C1 을 호출하고 그 과정에서 createSignal 함수 안에 있던 read 함수가 실행되는 식으로 구독이 이루어진다. 이 과정에서 showA1Only 도 구독되고, showA1Onlyfalse 이므로 A1B1 을 모두 구독하였다.
  • setShowA1Only 의 값을 변경하면서 showA1Only 를 구독하던 C1 의 값이 재구성된다. 먼저 기존의 구독을 모두 해제하고 다시 showA1Only 와 현재 조건에 부합하는 A1 값만 새로 구독한다. 이런 방식으로 동적 의존성 관리가 이루어진다.
  • 이후 setShowA1Onlyfalse 로 변경하지 않는 한 아무리 setB1 을 호출해도 이펙트가 실행되지 않을 것이다. 어디에서도 B1 을 구독하고있지 않기 때문이다.

이렇게 SolidJS의 반응형 모델은 비교적 이해하기 쉬우면서도 동적으로 의존성 변화를 관리할 수 있어서 강력하기까지 하다. 하지만 그 대신 옵저버블 값을 조회하고 관리하는 코드를 작성할 때 createEffect 혹은 createMemo 을 활용해야 원하는 결과를 얻을 수 있을 것이다.

또한 SolidJS의 반응성 모델은 변화를 동기적으로(Synchronously) 추적한다. 이펙트 안에 setTimeout 등의 비동기 작업을 수행하는 코드가 있다면 옵저버블 값의 변화를 제대로 추적할 수 없다. 물론 이런 문제를 보완하기 위한 API가 따로 제공되고 있다.

SolidJS의 렌더링 특징

SolidJS는 JSX 문법을 제공하긴 하지만 React, Vue와 다르게 Virtual DOM(가상 돔)을 사용하지 않는다. 대신 Svelte와 유사하게 개발자가 작성한 JSX와 반응형 요소들을 기반으로 컴파일을 하고, 런타임에서는 사용자의 인터랙션에 따라 직접 변화가 일어나야 하는 부분의 DOM만 변경하는 전략을 사용하고 있다.

React의 컴포넌트가 상태 변화 등에 따라 render(함수 컴포넌트는 JSX 리턴 구문)를 계속 호출하는 것과 달리 SolidJS의 컴포넌트는 한 번 실행되면 끝인 생성자에 가깝다.

// 인터벌에 따라 카운터가 변경되는 예
import { createSignal, onCleanup } from "solid-js";
import { render } from "solid-js/web";

const CountingComponent = () => {
  const [count, setCount] = createSignal(0);
  const interval = setInterval(
    () => setCount(c => c + 1),
    1000
  );
  onCleanup(() => clearInterval(interval));
  return <div>Count value is {count()}</div>;
};

render(() => <CountingComponent />, document.getElementById("app"));

React라면 count 가 변화될 때마다 컴포넌트가 다시 호출되고 리턴되는 가상 돔의 변화를 비교하여 브라우저에서 그리는 작업을 수행할 것이다. 그러면 컴포넌트가 호출되는 동안 인터벌을 관리하기 위해 useEffect 등의 훅을 사용하여 관리해 주어야 한다.

DOM을 직접 변경하는 만큼 최적화를 위해 다른 컴포넌트에 전달되는 props도 프록시 객체로 감싸진 뒤 최대한 값이 필요한 순간까지 미루었다가 지연 평가된다. 따라서 React 컴포넌트에서 props를 가져다 쓸 때 하던 것처럼 구조 분해 할당하는 경우 컴포넌트가 제대로 반응하지 않는다고 한다.

const Parent = () => {
  const [greeting, setGreeting] = createSignal("Hello");

  return (
    <section>
      <Label greeting={greeting()}>
        <div>John</div>
      </Label>
    </section>
  );
};

// greeting, children의 변화에 반응한다.
// ({greeting, children}) => {...} 같은 식으로 선언하면 반응하지 않는다.
const Label = (props) => (
  <>
    <div>{props.greeting}</div>
    {props.children}
  </>
);

마치며

스트림 기반의 반응형 프로그래밍보다 조금 더 친숙하게 다가오는 TRP를 통해 어떻게 요즘 반응형 라이브러리들이 반응형 시스템을 구현하는지 조금 더 자세히 알아볼 수 있었고, 그 예시 중 하나로 SolidJS를 살펴보았다.

새로운 라이브러리(혹은 프레임워크)의 홍수 속에서 개발자는 '내가 만드는(혹은 만들고자 하는) 프로그램의 용도에 맞는지', '얼마나 효율적으로 프로그램을 작성할 수 있는지' 등의 명확한 기준을 가지고 라이브러리를 살펴보아야 할 것이다. 덤으로 라이브러리의 근본 원리나 디자인 철학을 파악할 수 있다면 더욱 좋은 선택을 할 수 있을 것이다.

SolidJS는 그 원리와 구성을 상세하게 문서화해두었다. 이번 위클리를 작성하기 위해 자료를 조사하고 정리하는 과정에서 제작자의 식견에 크게 감탄하기도 했다. 한 번 SolidJS의 공식 문서를 훑어보면서 요즘 프런트엔드 영역의 반응형 패러다임은 어떤 방향으로 흘러가고 있는지, 연관된 개발 트렌드가 무엇이 있을지 살펴보면 꽤 유익한 학습이 될 것이다.

참고 자료