React VS SolidJS(Signal base), 그리고 Leptos

zdpk·2024년 6월 23일

Leptos

목록 보기
1/2
post-thumbnail

Leptos는 Rust 진영의 Frontend Framework로 CSR, SSR을 모두 지원한다.

현재 Frontend에서 가장 유명한 라이브러리인 React는 Virtual DOM 기반으로 작성되었다.

이를 Course-grained 방식이라고 부른다.

Course-grained(React)

간단하게 React의 작동 원리를 살펴보면 Browser에 존재하는 DOM Tree를 조작하는 것은 비용이 크기 때문에, Pure JS Object로 이루어진 Virtual DOM Tree를 만든다.

setState hook을 호출하면 변경된 부분에 대해서 새로운 VNode를 생성한다.

React의 Component는 Immutable하며, 그저 함수에 불과한데,

이 때문에 변경된 부분에 대한 Component 함수는 재호출되고, 재렌더링된다.

이 때 새로운 VNode가 생성된다.

그리고 변경된 부분들에 대한 VNode와 기존 VNode를 모아서 새로운 VDOM Tree가 만들어질 것이다.

그러면 Prev VDOM, Next VDOM 2개의 VDOM Tree가 공존하는 상태가 될 것인데,

이 때, VDOM Tree는 모든 VDOM을 Copy하는 것은 아니다.

VNode가 총 1,000개이고, 변경된 VNode는 1개에 불과하다면 999개의 VNode는 쓸 데 없는 비용만 발생시키기 때문이다.

그래서 실제 변경된 VNode만 COW(Copy on Write)과 유사한 형태로 복사될 것이며, 변경되지 않은 부분은 기존 VNode를 참조하는 형태로 갈 것이라고 추측된다.
(말이 어렵지만 CoW의 예시를 들면, DB에 저장된 10GB의 데이터 중 변경 내역을 1초 간격으로 다른 DB에 동기화한다고 가정하면, 10GB를 전부 복사하는 것이 아니라, 변경된 부분만 복사한다고 생각하면 된다. 즉, 변경(Write)이 일어난 부분만 복사(Copy)하는 것이다.)

이렇게 Prev, Next VDOM Tree를 비교하는 과정을 Diffing이라고 하며, 이는 매우 효율적인 알고리즘을 통해 빠르게 진행된다.

이후 실제 변경이 발생된 부분에 한하여 실제 DOM 조작이 일어나는 것이다.

이 과정을 Reconciliation이라고 하며, 변경된 Node가 많더라도 Batch Update를 통해 상당히 좋은 성능을 보여준다.

DB에서도 여러 SQL을 한 번에 보내는 것이 성능이 좋듯이, 변경이 일어난 부분을 한 번에 브라우저에 페인팅 해주기 때문에 속도가 잘 나오는 것이다.

React, Flutter 등 많은 상용 라이브러리에서 이미 검증된, 상당히 괜찮은 방법이라고 볼 수 있다.


Fine-grained(SolidJS, Leptos)

이와 달리, 비교적 최근 출시된 Frontend Library들은 Fine-grained 방식을 채택하거나 전환하는 경우가 늘어나고 있다.

Course-grained 방식이 시장에서 다년간 검증되었고 적당한 속도가 잘 나온다지만, 이조차도 성에 안차는 사람들은 언제나 존재한다.

UI가 Mission Critical한 작업을 하는 것도 아니고, 하나의 클라이언트 뷰만 신경쓰면 되기 때문에 상대적으로 성능이 크게 중요하지 않다라는 것이 일반적인 통념이지만, 언제나 성능은 빠를수록 좋으며, 메모리는 클수록 좋다.

"메모리는 640KB면 충분하다."

빌 게이츠가 1981년에 실제로 했던 발언이다.

표준 데스크탑 메모리 크기가 16GB(2,500배 정도)가 되버린 현대에는 너무나도 부족한 크기다.

그러나 저 때는 다수가 동의했을 것이다.

그저 문서를 열람하는 것이 전부였던 과거의 웹과 SPA가 출시되고, 사용자 인터랙션이 점차 늘어나며 앱과 비슷한 경험을 주고 있는 현대의 웹은 요구사항 자체가 완전히 다른데,

앞으로도 계속해서 웹이 발전해 나가면 지금보다도 훨씬 더 빠른 성능, 최적화를 요구하게 될 것이 분명하다.

아직 크게 가시화 되지는 않았지만, 이러한 한계를 깨기 위해 WebGPU, WASM 등 다양한 시도가 일어나고 있으며, Fine-grained 방식으로의 전환도 이러한 일환 중에 하나가 아닐까 생각한다.

서론이 길었는데, Leptos는 2022년 10월 7일에 0.0.1 버전이 출시되어 현재 0.6.12 버전까지 출시된 신생 웹 프레임워크다.

고로 Fine-grained을 적용하였으며, VDOM 대신 singal이라는 개념을 도입하였다.
(Fine-grained를 적용한 JS 진영의 SolidJS와 또 다른 Rust Framework인 Dixous에서도 동일하게 signal이라는 용어를 사용한다.)


React VS Solid Cheat Sheet

소스 - https://blog.theodo.com/2023/07/solidjs-beginner-virtual-dom-signals/

React, SolidJS의 차이를 한 장으로 깔끔하게 정리한 Cheat Sheet이다.

부연 설명 없이 그림만 봐도 이해가 잘 되는 사람도 많을 것이지만 나처럼 프론트 개발에 익숙하지 않은 사람이나 초심자들을 위해 조금 더 설명을 작성하겠다.

가장 눈에 띄는 것은 React에 존재하던 Virtual DOM이 사라지고 Real DOM만 남은 것이다.

Virtual DOM이 아무리 가벼워도 결국 Component가 늘어날수록 메모리를 먹기 때문에 그런 것 없이도 충분히 변경된 부분만 업데이트 할 수 있다는 것을 보여준다.

왼쪽 하단으로 넘어오면 Signals라고 적힌 부분이 보인다.

[data, setData] = createSignal("xx");라고 적힌 것이 보이는데,(SolidJS 문법)

React로 치면 [data, setData] = useState("xx");와 유사하다.

아마 의도적으로 전반적인 문법을 비슷하게 만든 것 같다.

Rust Frontend Framework인 Dioxus, Leptos 창시자들도 이미 React에 익숙해진 사람들이 많기 때문에 구조가 너무 달라지면 새로 배워야 할 것들이 늘어나고, 괜한 진입 장벽이 추가되어 좋지 않다는 생각을 가지고 있었던 것을 보아 같은 맥락일 것으로 추측된다.

이런 식으로 state 대신 signal을 만들 수 있으며, 둘 다 '상태'를 저장하는 유사한 역할을 한다고 보면 된다.

단지 coursed-grained 방식이냐 fine-grained 방식이냐의 차이가 존재하기 때문에 이를 구분하기 위해 다른 용어를 선택한 것으로 보인다.

잠시 후에 자세히 보겠지만, signalread, write로 나뉜다는 것과 우측 하단에 Under the hood에서 subscribers를 볼 수 있는데, 이 4가지 용어를 잘 기억해두자.


Signal

SignalState와 달리, '함수'만 반환한다.

[data, setData] = createSignal("xx");

무슨 말인가 하면 Signal을 생성하는 함수를 다시 살펴보면 data, setData가 존재하는데,

React는 state가 실제 데이터에 대한 객체를 반환한 것이었다면, Signaldata가 실제 데이터를 읽기 위한 read 함수에 불과하다는 것이다.

JS의 Closure에 대해 기억하는가?

실제 데이터는 바로 createSignal 내부의 Closure에 저장되는 것이다.

Closure는 그저 외부 Scope의 데이터를 캡쳐하여 객체에 쑤셔 넣는 것이라고 생각하면 편하다.

function returnsFn() {
  	let a = 1;
  
  	// closure
	return () => a;
}

returnsFn은 익명 함수를 반환하는데, 이것이 반환하는 변수 a는 익명 함수의 상위 Scope에 존재한다.

const fn = returnsFn();

const result = fn(); // a를 실제로 반환

그렇다면 returnsFn이 Stack Frame에서 해제된 후에서야 a를 반환하게 될텐데, a는 어디에 저장되어 있을까

바로 Closure가 임의로 메모리를 할당하여 물고 있는 것이다.

그래서 result는 정상적으로 1을 반환 받을 수 있다.

const closure = {
	data: [a],
	call: () => closure.data.a,
};

그래서 Closure를 굳이 객체 형태로 표현하자면 위와 같은 모양새가 된다.(Capture 한 변수가 여러 개일 수도 있으므로 리스트에 a를 넣어 표현했다.)

Capture가 말 그대로 변수를 물어와서 객체에 쑤셔 넣는 것이 전부다.(어디다 넣고 어떻게 최적화 되는지는 V8이 알아서 신경쓸 것이다.)

특별할 것이 없다.

Signal도 동일하게 Closure에 실제 데이터를 넣고 이를 읽고 쓰는 함수를 반환하는데, 정말 대충 표현하면 이런식으로 그릴 수 있겠다.

주제는 Leptos 였는데 React와 SolidJS 이야기만 주구장창 나오는 것 같지만, 유사한 방식으로 작동하기 때문에 알아둬서 나쁠 것은 없을 것이다.

아마 React는 대부분 알고 있을 것이고, SolidJS의 코드 구조가 거의 비슷하여 이해하기 수월할 것이라는 생각에 이렇게 작성하게 되는 것 같다.

이후 장부터는 Leptos에 집중하겠다.


SoliJS Counter Example

다음은 실제 SolidJS로 작성한 매우 간단한 Counter 코드다.

SolidJS를 몰라도 읽는데 전혀 문제가 되지 않는다.

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

const Counter = () => {
	const [count, setCount] = createSignal(0);
	return (
		<>
      		// count는 React와 달리 변수가 아닌 '함수'이므로 호출 필요
      		// 즉, Closure에 저장된 실제 데이터를 읽는 read 함수
			<div>Count value is {count()}</div>
			// setCount는 Write 함수. React와 사용 방법 매우 유사
			<button onClick={() => setCount(n => n + 1)}>Increment</button>
		</>
	);
};

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

https://www.solidjs.com/examples/counter 에서 직접 코드를 붙여넣어서 실행 해볼 수 있다.

버튼을 누르면 count가 잘 증가하는 것을 볼 수 있다.


Read, Write, Closure를 직접 사용 해보면서 React와 유사하지만 약간의 사용법의 차이가 있음을 확인했다.


Fine-Grained 방식에서 Component는 setup 시, 단 한 번만 호출된다.

Component는 결국 함수에 불과하다.

React의 경우, 변경된 상태에 의존하는 Component는 재호출된다.

즉, 이전 Component는 파기하고 완전히 새로운 Component를 다시 생성하는 것이다.

그래서 React가 Immutable 하다고 말하는 것이다.

방금 본 것과 동일한 코드를 React로 작성하여 이를 증명해보자.

import React, { useState } from 'react'

const Counter = () => {
  const [count, setCount] = useState(0)

  // rendering 시 마다 출력
  console.log('rendered');
  
  return (
        <>
            <h1>{count}</h1>
            <button onClick={() => setCount(count + 1)}>
                Increment
            </button>
        </>
  );
}

export default Counter;

https://reactplayground.vercel.app

vercel이 친절하게 제공하는 React playground에 코드를 복붙하고 실행해보자.

버튼을 클릭할 때마다 수가 증가하고, rendered 메세지가 찍히는 것을 확인했다.

Counter를 Component라 생각하지 말고, 단순히 함수라고 생각하면 함수가 렌더링 횟수만큼 실행된 것이다.

즉, 매 번 상태가 업데이트 될 때마다 함수가 재실행 된 것이고,

React의 관점에서는 매 번 Component가 파기되고 재생성 된 것이다.

SolidJS는 버튼을 10번 눌렀음에도 rendered가 단 한 번 찍혔다.

상태는 10으로 잘 변경되었다.

즉, Fine-grained 방식은 최초 한 번만 Component가 호출되고 이후로는 절대 호출되지 않는다.
(이는 SolidJS, Leptos 등 Signal base 라이브러리에 동일하게 적용된다.)

이제 Course-grained와 Fine-grained 차이가 느껴질 것이다.

Course-grained는 굵은 입자, Fine-grained는 미세 입자를 나타내는데,

Course-grained가 Component 단위의 재조정을 한다면, Fine-grained는 Signal 단위로 재조정을 수행한다.

말 그대로 미세 조정인 것이다.

변경이 발생한 Component 전체를 재생성 하는 것이 아니라, 딱 변경된 부분에 한해서만 변경 사항을 반영한다.

이것이 Fine-grained의 핵심이다.

Component가 많아질수록 비용이 크게 줄어들 것이다.


Subscribers, Observer Pattern

이제 이 부분은 다 봤다.

여기서 우측 상단을 보면 Observer Pattern, subscribers라는 용어가 등장한다.

Observer Pattern이 어떤건지 간략한 코드를 적어보겠다.

이번 글은 읽을 대상을 특정할 수가 없으니 생각나는 대로 쓰고 있는데,

너무 세세하게 설명해서 장황해지는 면이 있으므로 잘 알고 있다면 적당히 지나가도록 하자.

국민 언어인 파이썬으로 작성해보겠다.

# 주로 예제에서 Subject라고 많이 부름
# 이해를 위해 '알림을 보내는 주체'라는 뜻으로 Notifier라고 임의로 변경
class Notifier:

	# 생성자. observers 리스트를 들고 있음
	def __init__(self):
    	self.observers = []

	# 들고 있는 리스트에 observer를 추가함
	def add(observer):
    	self.observers.append(observer)

	# 들고 있는 모든 observer에게 메세지를 보냄
	def notify(message):
    	for observer in self.observers:
        	observer.notify(message)
        
# 알림을 받는 Observer
# 이름은 마치 자기가 알림이 발생한 것을 감지하는 것 같지만
# 그냥 메세지 주는 것을 받아먹는 것에 가까움
class Observer:

	def __init__(self):
    
    # notifier에게 받은 메세지를 출력
    def notify(message):
    	print(message)

매우 간단하게 작성한 Observer 패턴이다.

Notifier에게 여러 Observer를 등록하고, Notifier.notify()를 호출하면 모든 Observer에게 메세지를 출력하게 만들 것이다.

Pub-Sub 패턴도 거의 비슷한데, NotifierPublisher로, ObserverSubscriber로 생각하면 된다.

그래서 위 도표에서 SubscribersObserver Pattern이라는 용어가 함께 등장한 것이다.

아래 코드는 이름만 바꾼 것이다.

로직은 전혀 바뀐 것이 없으니 Observer 패턴이라 볼 수 있다.

# 알림을 보내는 Publisher
class Publisher:

	# 생성자. subscribers 리스트를 들고 있음
	def __init__(self):
    	self.subscribers = []

	# 들고 있는 리스트에 observer를 추가함
	def add(subscriber):
    	self.subscribers.append(subscriber)

	# 들고 있는 모든 subscriber에게 메세지를 보냄
	def notify(message):
    	for subscriber in self.subscribers:
        	subscriber.notify(subscribers)
        
# 알림을 받는 Subscriber
class Subscriber:

	def __init__(self):
    
    # subscribe에게 받은 메세지를 출력
    def notify(message):
    	print(message)

SolidJS는 각 Signal에 대한 subscribers들을 등록해 놓고 특정 Signal의 상태가 변경되면, 해당 Signal의 모든 subscribers를 호출하여 상태를 변경하라고 신호를 보낸다.

즉, 하나의 Signal 변경이 해당 Signal에 등록된 모든 subscribers에게 전파된다.


Effect, Derived State

대표적으로 Effect, Derived State가 Signalsubscribers에 등록된다.

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

const Counter = () => {
	const [count, setCount] = createSignal(0);
	
  	// count Signal이 변경될 때마다 실행
	createEffect(() => {
		console.log(`effect count: ${count()}`);
	});

	return (
		<>
			<div>Count value is {count()}</div>
			<button onClick={() => setCount(n => n + 1)}>Increment</button>
		</>
	);
};

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

createEffect에서 count Signal을 read 하고 있다.
(Signal에서 count는 변수가 아닌, read 함수였다.)

고로 count signal이 변경될 때마다 다음과 같이 콜백 함수가 실행된다.

즉, 해당 콜백 함수가 subscribers로 저장 되었다는 이야기며,

만약 count를 콜백 함수 내에서 참조하지 않았다면, 호출되지 않을 것이다.

이러한 subscribers도 역시 Signal의 Closure 내에 저장될 것이라고 추정한다.

두 번째는 Derived State다.

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

const Counter = () => {
	const [count, setCount] = createSignal(0);
	
	// count에 의존. count가 변할 때마다 recompute
	const doubledCount = createMemo(() => count() * 2);

	return (
		<>
			<div>Count value is {count()}</div>
			<div>Doubled Count value is {doubledCount()}</div>
			<button onClick={() => setCount(n => n + 1)}>Increment</button>
		</>
	);
};

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

createMemo로 만들 수 있으며, doubledCountcount의 값에 2를 곱한 값이다.

이것도 역시 함수다.

subscribers에 저 함수 자체를 등록해두고, setCount, 즉 Write가 실행될 때마다 모든 subscribers가 다시 호출되므로 위와 같이 함께 변경되는 것을 볼 수 있다.

즉, subscribers는 전부 함수다.

아니 Signal 기반에서는 모든 것이 함수로 돌아간다.

당연하다.

실제 '값'조차 함수로 읽으니 말이다.


Python으로 Subscribers 흉내 내기

이제는 Under the hood를 겉핥기 정도로는 이해 했다고 말할 수 있다.

필자 역시 SolidJS 문서를 예전에 살짝 봤을 뿐, 오늘 처음 써보는 것이기 때문에 정말 기초적인 내용만 이해 했을 뿐, 실제 사용하면서 많은 난관에 부딪힐 것이라고 생각한다.

물론 SolidJS가 아니라 Leptos를 사용할 것이지만.

마지막으로 아까 작성했던 Python 코드를 메세지를 보내는 것이 아니라, SolidJS에서 사용하는 용례에 맞춰 변경해보고 마무리 하겠다.

직접 작성해 보면 도움이 될 것이다.

class Signal:

	# 생성자. subscribers 리스트를 들고 있음
	def __init__(self, data):    
    	self.subscribers = []
        # Signal의 데이터
        self.data = data

	# 들고 있는 리스트에 observer를 추가함
	def add(subscriber):
    	self.subscribers.append(subscriber)

	# write 시, data 변경 및 notify 호출 하여 모든 subscriber에게 알림
    # write() == setCount()
    def write(new_data):
    	self.data = new_data
        self.notify()
    
    # read 시, Signal이 들고 있는 data 반환
    # read() == count()
    def read():
    	return self.data
        
	def notify(message):
    	for subscriber in self.subscribers:
        	subscriber.subscribe(subscribers)
        
# Signal Write 시, Update 알림을 받는 Subscriber
class Subscriber:

	def __init__(self):
    	self.data = data
    
    # Signal에 의해 notify 메소드가 호출되면 상태 update
    def notify():
		self.update()
        
    def update():
		# update 로직 생략

Python 코드는 검증 없이 구색만 잡은 것이다.

대충 이런 느낌이겠거니 생각하자.


Signal에 대한 이론과 SolidJS 코드를 보면서 느낌 정도는 왔을 것이라고 생각한다.

Leptos 역시 SolidJS와 동일하게 Signal base이므로 유사한 부분이 많다.

그러나 Rust 기반이기 때문에 코드가 어색하고 난이도는 훨씬 어려울 것이다.

Rust의 Closure는 Static dispatch, Dynamic Dispatch가 모두 가능한 Trait 기반으로 돌아가고 하고 Lifetime, Ownership까지 엮이기 때문에 생각할 꺼리가 꽤나 많기 때문이다.

필자도 여전히 Rust가 매우 어렵다고 느끼지만, 언젠가 익숙해질 것이라는 낙천적인 마인드로 계속 Rust를 이곳 저곳에 사용 해보려고 한다.

국내에 한글 자료가 단 하나도 존재하지 않는 Leptos가 좋은 출발점이 될 것 같다.

0개의 댓글