lechuck.dev

Introducing solid.js

마지막 업데이트:

소개

SolidJS 는 Ryan Carniato이 만들어 2018년 오픈 소스화 한 리액티브 자바스크립트 프레임워크입니다. 많은 자바스크립트 프레임워크들 중에서는 나온지 얼마되지 않은 프레임워크로, 최근에는 “fine-grained reactivity”라는 특징으로 인기를 얻기 시작하고 있습니다.

가장 많이 사용중인 React와 여러가지 철학들을 공유하고 있어서 JSX를 사용하는 등 비슷한 점도 많지만, 몇 가지 다른 점이 있습니다. 예를 들면, Virtual DOM 을 사용하지 않는다거나, 컴포넌트 렌더링을 단 한 번만 한다는 점 등이 다릅니다. 이러한 특징으로 인해 현존하는 자바스크립트 프레임워크 중 가장 빠른 성능을 보여줍니다.

리액티브 프로그래밍이란?

SolidJS를 설명할 때 빠지지 않는 점이 바로 진정한 리액티브 라이브러리라는 것입니다.

React는 그 이름과는 달리 진정한 리액티브 라이브러리라고 할 수 없습니다. 그렇다면 리액티브 프로그래밍이란 무엇일까요?

리액티브 프로그래밍은 다음과 같이 정의할 수 있습니다:

시간에 따라 변하는 값들의 관계에 대한 선언적 표현

다음과 같은 코드를 예를 들어보겠습니다:

var a = 10;
var b = a + 1;
a = 11;
b = a + 1;

일반적인 프로그래밍 패러다임에서는 ab의 관계를 설정하려면 값이 변경될 때마다 위와 같이 싱크를 맞춰주는 작업이 필요합니다.

$=라는 연산자가 있다고 가정해보겠습니다. 이 연산자는 오른쪽 표현식의 값을 왼쪽에 할당할 뿐만 아니라, 참조하는 변수의 값이 변경되는 경우 자동으로 변경됩니다.

var a = 10;
var b $= a + 1;
a = 20;
Assert.AreEqual(21, b);

이 경우 a의 값이 20으로 변경되면, b의 값은 자동으로 20 + 1이 되어 21로 업데이트됩니다.

이런 방식의 프로그래밍 패러다임을 리액티브 프로그래밍이라고 합니다.

특징

Vanishing Component

React 함수형 컴포넌트는 매번 함수를 다시 실행해 렌더링을 수행합니다. React에서는 render 메서드를 반복적으로 호출하면서 변경된 부분을 확인하는 Top-Down 방식으로 컴포넌트를 분할합니다.

반면에 SolidJS 함수형 컴포넌트는 한 번 호출된 후 사라집니다. 컴포넌트가 실행되면서 리액티브 그래프를 구성한 다음, 변경이 일어난 경우 이와 관련된 명령만 실행하도록 세밀하게 컨트롤합니다.

Reactivity

SolidJS 에서는 뷰 코드에서 리액티브한 값에 접근하는 경우, 모든 디펜던시가 자동으로 추적됩니다. React에서 useEffect() 사용시에 디펜던시를 지정해야하는 작업이 필요 없어집니다.

SolidJS 에서는 시그널이라는 구독 리스트를 가지는 이벤트 이미터Event Emitter를 제공합니다. 시그널은 값이 변경될 때마다 이를 구독하고 있는 구독자에게 변경을 알립니다. SolidJS에서는 자동으로 디펜던시를 추적해 구독을 하고, 데이터가 변경되는 경우 이를 자동으로 업데이트합니다.

다음은 count 시그널 변경에 따라 자동 증가되는 카운터를 구현한 코드입니다:

import { createSignal, onCleanup } from "solid-js";
import { render } from "solid-js/web";

const App = () => {
  const [count, setCount] = createSignal(0);
  const timer = setInterval(() => setCount(count() + 1), 1000);
  onCleanup(() => clearInterval(timer));

  return <div>{count()}</div>;
};

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

시그널 작동 방식은 간단하게 다음과 같이 생각할 수 있습니다. 물론 실제 구현은 이보다는 복잡하지만, 핵심은 아래 코드와 같습니다:

function createSignal(value) {
  const subscribers = new Set();

  const read = () => {
    const listener = getCurrentListener();
    if (listener) subscribers.add(listener);
    return value;
  };

  const write = (nextValue) => {
    value = nextValue;
    for (const sub of subscribers) sub.run();
  };

  return [read, write];
}

웹 컴포넌트

solid-element를 사용하면 SolidJS 함수형 컴포넌트를 사용해 작고 성능 좋은 웹 컴포넌트를 만들 수 있습니다.

웹 컴포넌트로 작성하면 어디서나 사용할 수 있게 됩니다. React, VueJS 등과 같은 프레임워크 뿐만 프레임워크를 사용하지 않는 곳에서도 사용할 수 있게 되면서 프레임워크 컴포넌트 재사용성이 증가합니다.

React 에서 웹 컴포넌트를 사용하는 방법은 React 공식 문서에서 확인할 수 있습니다.

No Virtual DOM

SolidJS 는 Virtual DOM 을 사용하지 않고, 컴파일 시점에 세분화된 반응성에 최적화된 네이티브 DOM 코드를 생성합니다.

Virtual DOM 을 사용하지 않으면 다음과 같은 이점이 있습니다.

보통 Virtual DOM 을 사용하면 DOM을 직접 조작하는 것에 비해 불필요한 렌더링을 줄여서 속도가 빠르다고 합니다. 하지만, 변경이 발생했을 때 DOM 업데이트에 최적화된 코드를 생성할 수 있다면, Virtual DOM의 이런 장점은 오히려 오버헤드가 되면서 단점이 되어버립니다.

js-framework-benchmark를 사용한 자바스크립트 프레임워크 벤치마크 결과는 이 곳에서 확인할 수 있습니다.

SolidJS 홈페이지에 게시된 벤치마크 결과 그래프를 보면, SolidJS 실행 시간은 바닐라 스크립트를 사용한 것과 비교해 1.05배 증가했으며, React 는 1.93배 증가한 것을 볼 수 있습니다.

Reactive Primitives

createSignal

React 의 useState Hook 에 해당합니다. React와 다른 점은 getter와 setter를 반환합니다.

const [getValue, setValue] = createSignal(initialValue);

// read value
getValue();

// set value
setValue(nextValue);

// set value with a function setter
setValue((prev) => prev + next);

주의할 점은 React와 달리 props의 값을 디스트럭쳐링하는 경우, 리액티브 기능을 사용할 수 없습니다.

// `props.name`은 예상대로 업데이트됩니다.
const MyComponent = (props) => <div>{props.name}</div>;

// `props.name`은 업데이트되지 않습니다.
const MyComponent = ({ name }) => <div>{name}</div>;

createEffect

React 의 useEffect Hook 에 해당합니다.

const [a, setA] = createSignal(initialValue);

// effect that depends on signal `a`
createEffect(() => doSideEffect(a()));

createMemo

React 의 useMemo Hook 에 해당합니다.

const makeDouble = (val) => val * 2
const doubleCount = createMemo(() => makeDouble(appCount()))
console.log("doubleCount ", doubleCount());

createResource

이 함수는 비동기 요청을 담당하는 시그널을 생성합니다.

const [data, { mutate, refetch }] = createResource(getQuery, fetchData);

// read value
data();

// 로딩중인지 확인
data.loading;

// 에러가 발생했는지 확인
data.error;

// Promise를 생성하지 않고 바로 값을 설정
mutate(optimisticValue);

// 마지막 요청을 다시 실행
refetch();

웹 컴포넌트를 사용한 React 연동

먼저 solid-element를 사용해 웹 컴포넌트 생성을 위한 프로젝트를 생성합니다.

템플릿 프로젝트는 web-component를 사용합니다.

다음은 SolidJS를 사용해 구현한 카운트 다운 웹 컴포넌트입니다.

import { customElement } from "solid-element";
import { createSignal, onCleanup, onMount } from "solid-js";

function getRemainSeconds(dueDate: number): number {
  const now = Date.now();
  const diff = dueDate - now;
  return diff < 0 ? 0 : Math.round(diff / 1000);
}

function formatRemains(remains: bigint): string {
  let n = remains;
  const days = n / 86400n;
  n %= 86400n;
  const hours = n / 3600n;
  n %= 3600n;
  const minutes = n / 60n;
  n %= 60n;
  const seconds = n % 60n;
  return `${days}D ${hours}H ${minutes}M ${seconds}S`;
}

interface Props {
  date: number;
  interval: number;
}

customElement<Props>(
  "my-custom-component",
  {
    date: Date.now(),
    interval: 1000,
  },
  (props) => {
    const [remains, setRemains] = createSignal(0);
    const [intervalId, setIntervalId] = createSignal(-1);

    onMount(() => {
      const updateRemains = () => {
        const remainSeconds = getRemainSeconds(props.date);
        setRemains(remainSeconds);
        if (remainSeconds <= 0) {
          clearInterval(intervalId());
        }
      };
      updateRemains();
      const handle = setInterval(updateRemains, props.interval);
      setIntervalId(handle);
    });

    onCleanup(() => clearInterval(intervalId()));

    return <div>{formatRemains(BigInt(remains()))}</div>;
  }
);

customElement() 함수를 사용해 웹 컴포넌트를 정의합니다.

React 에서 이를 사용하기 위해서는 다음과 같이 코드를 작성합니다.

import * as React from 'react'
import './my-custom-component.es.js'

type CustomElement<T> = Partial<T & React.DOMAttributes<T> & { children: any }>;
declare global {
  namespace JSX {
    interface IntrinsicElements {
      "my-custom-component": CustomElement<{ date: number, interval: number }>;
    }
  }
}

function App() {
  const [date] = React.useState(Date.now() + 100 * 1000)
  return (
    <div>
      <my-custom-component date={date} interval={1000}></my-custom-component>
    </div>
  )
}
export default App

타입스크립트 사용시에는 커스텀 엘리먼트 태그 타입을 알 수 없기 때문에, 위의 코드와 같이 IntrinsicElements에 태그 타입을 추가해야 합니다.

첨부된 소스 코드를 실행하기 위해서는 다음과 같이 실행합니다:

# build web-component
$ cd solidjs/web-component
$ yarn install && yarn build

# run react-app dev server
$ cd ../react-app
$ yarn install && yarn dev

SolidJS 와 React 비교

비슷한 점

JSX 지원

JSX 문법을 사용함으로써, 템플릿 문법을 사용하는 Vue, Svelte등에 비해서 좀 더 직관적이고 모듈화된 개발을 할 수 있게 됩니다.

Declarative

Solid와 React는 모두 데이터가 변경될 때마다 효율적으로 업데이트하고, 관련 컴포넌트를 렌더링합니다.

선언적 프로그래밍에서는 원하는 UI의 최종 상태만 설명하면 렌더링 엔진이 가장 좋은 실행 방법을 결정합니다. 선언적 컴포넌트는 명령형 코드 방법과 달리 디버깅이 쉬워지고 가독성이 향상됩니다.

단방향 데이터 흐름

단방향 데이터 흐름 패턴은 데이터가 애플리케이션의 다른 부분으로 전송되는 방법이 한 가지만 있음을 의미합니다. Solid와 React에서는 상위 컴포넌트 내에 하위 컴포넌트를 중첩해야함을 의미하게 됩니다.

단방향 데이터 흐름 패턴을 사용하면 다음과 같은 장점이 있습니다:

다른 점

Virtual DOM 없음

React와의 가장 큰 차이점은 VirtualDOM을 사용하지 않는다는 것입니다. 실제 DOM을 직접 사용하면 애플리케이션 속도가 느려진다는 생각과 달리, Solid는 빠른 성능을 보여줍니다.

과거에는 자바스크립트 실행속도가 DOM 업데이트보다 상대적으로 빠르기 때문에 VirtualDOM을 사용했습니다. DOM은 대규모의 잦은 업데이트를 처리할 수 있도록 구축되지 않았기 때문에, VirtualDOM을 사용해 diff를 수행하고 변경된 부분만 실제 DOM에 적용하는 방식을 사용했습니다.

React, Vue.js 등과 같은 많은 라이브러리에서 VirtualDOM을 사용하지만, Svelte와 Solid 개발자는 VirtualDOM을 라이브러리의 성능을 저하시키는 오버헤드로 설명하고 있습니다. Svelte와 Solid는 실제 DOM을 사용하면서도 VirtualDOM을 사용하는 것 보다 더 빠른 대안을 찾아냈습니다.

Solid는 VirtualDOM을 사용하는 대신, 템플릿을 실제 DOM 노드로 컴파일하고 세분화된 리액션으로 업데이트를 래핑합니다. 이렇게 하면 상태가 업데이트될 때 해당 상태에 종속된 코드만 실행됩니다.

컴포넌트는 한 번만 렌더링

React와 달리 Solid는 컴포넌트를 단 한 번만 렌더링합니다.

컴포넌트를 사용할 때마다 매번 렌더링할 필요가 없으므로 전체적인 성능이 향상됩니다. 이는 Solid가 컴포넌트 내부의 변경 사항을 추적할 수 있을만큼 리액티브하기 때문입니다.

세밀한 반응성

React 팀에서도 언급하다시피 React는 완전히 “리액티브”하지 않습니다. 그렇다고 해도 여전히 많은 개발자들과 회사의 사랑을 받고 있기 때문에, 이런 점이 사용하지 못하겠다라는 단점이 되지는 않습니다.

SolidJS는 반응성을 염두에 두고 제작되었고, 이를 셀링 포인트로 사용하고 있습니다. 빠른 성능과 데이터를 빨리 업데이트하는 기능들을 통틀어 “세밀한 반응성”이라는 용어를 사용합니다.

참고 문서