19

React, Svelte 무엇이 다를까?

시작하기 전에

이 글은 Svelte를 배워보면서 느낀 React와 Svelte의 차이점, Svelte의 헷갈릴만한 부분을 소개하는 글입니다.

React를 하다가 Svelte를 배워보고 싶은 경우 또는 그 둘의 차이가 궁금하셨던 분들에게는 조금이나마 도움이 될 수 있을 것입니다.

지향하는 가치

React

Minimal API를 지향하며 개발자가 가능한 많은 명시적(explicit) 코드를 작성하도록 만듭니다.

장점과 그에 따른 비용들

장점비용
명시적으로 작성해야 하는 코드가 많기 때문에 숨겨지는 코드가 적으며 겉으로만 보았을 때도 이해할 수 있는 부분이 많아집니다.비교적 많은 코드를 작성해야 합니다.(비교적 많은 시간이 소모됩니다) ⇒ 버그가 있는 코드를 작성할 확률이 높아집니다.
최소한의 API를 제공하기 때문에 기억해야 할 것들이 비교적 적습니다.API가 최소화된다는 것은 그만큼 라이브러리가 해주는 것이 적다는 것을 의미합니다.

React는 다음과 같은 생각을 가지고 있습니다.

  • 반복적인 코드가 잘못된 추상화을 제공하는 것보다 좋다.
  • 명시적(explicit) 코드를 묵시적(implicit) 코드로 변경하는 것은 쉽지만 그 반대는 어렵다.

⇒ 그래서 추상화를 최소화하고 나머지는 React를 사용하는 개발자에게 맡깁니다.

Svelte

"Write less code"

많은 것들을 내부적으로 처리해 주며 개발자가 더 적은 코드를 작성하도록 만듭니다.

장점과 그에 따른 비용들

장점비용
비교적 적은 코드를 작성합니다.(비교적 적은 시간이 소모됩니다) ⇒ 버그가 있는 코드를 작성할 확률이 낮아집니다.명시적으로 작성해야 하는 코드가 줄어들기 때문에 겉으로만 보았을 때는 비교적 어떻게 동작하는지 이해하기가 어렵습니다.
많은 것들을 svelte가 알아서 처리해 줍니다.제대로 이해하고 사용하기 위해서 기억해야 할 것들이 많아집니다.

svelte는 개발자가 더 많은 코드를 작성할수록 시간도 오래 걸리고 더 많은 버그를 생성할 것이라고 생각합니다. 따라서 React와는 상반되게 "Write less code"를 지향합니다.

결론

지향하는 방향은 180도 다른 느낌이지만 둘 다 충분히 납득할 만한 이유가 있다는 생각이 들었습니다.

차이점

상태값이 변경되었을 때 동작 방식

React는 처음 그리고 상태 값이 변경될 때마다 컴포넌트 전체가 실행됩니다.

function NameForm() {
const [name, setName] = useState('');
const handleChange = (event) => {
setName(event.target.value);
};
return (
<div>
<h1>이름 입력</h1>
<input type="text" value={name} onChange={handleChange} />
<p>입력된 이름: {name}</p>
</div>
);
}

위 예시에서 name이 변경될 때마다 handleChange 함수가 계속 생성됩니다. ⇒ 계속 불필요한 일을 수행합니다. (하지만 다른 관점에서 보면 상태 값이 변경되면 컴포넌트 코드가 전부 실행되기 때문에 결과를 예상하기 쉽습니다.)

반면에 Svelte의 경우 일반적으로 script 내에 작성된 코드는 컴포넌트 인스턴스가 생성될 때 실행되며 상태 값이 변경될 때는 다시 실행되지 않습니다. (반응성 구문 같은 몇 가지 예외 제외)

<script> let name = ''; function handleChange(event) { name = event.target.value; } </script>
<h1>이름 입력</h1>
<input type="text" value={name} on:input={handleChange} />
<p>입력된 이름: {name}</p>

React와 달리 handleChange는 한 번만 생성됩니다. ⇒ 효율적입니다.

그렇기 때문에 다음과 같이 console.log를 추가하면 처음에만 name의 값이 출력되며 그 이후 name이 변경될 때는 어떠한 값도 출력되지 않습니다.

<script> let name = ''; console.log(name); function handleChange(event) { name = event.target.value; } </script>

단순화시켜서 정리를 해보자면 React는 조금 비효율적이지만 자연스러우며 Svelte는 부자연스럽지만(React에 익숙해져서인지) 효율적으로 동작한다는 생각이 들었습니다.

상태값 변경을 감지하는 방법

React는 Object.is를 사용해서 변경을 감지합니다. 원시 값이 아닌 상태 값을 변경할 때 기존 상태 값을 복사해야 합니다.

const [todos, setTodos] = useState([])
// 참조 값이 같으므로 동작 X
todos.push(newTodo)
setTodos(todos) // Object.is(이전 값, 현재 값) => true이므로 변경 감지 불가
// 참조 값이 다르므로 동작 O
setTodos([...todos, newTodo]) // Object.is(이전 값, 현재 값) => false이므로 변경 감지 가능

변경할 때마다 새로운 값을 생성하고 복사하는 불필요하고 비효율적인 일을 수행해야 합니다.

반면에 Svelte는 컴파일러가 = 을 통해 변경을 감지합니다. 따라서 복사할 필요가 없습니다.

let todos = [];
// 1. 참조값이 변경되지 않아도 동작 O
todos.push(newTodo);
todos = todos;
// 2. React 처럼 복사해도 동작 O
todos = [...todos, newTodo]

불필요한 복사를 할 필요가 없으니 조금 더 효율적입니다. 위와 같이 2가지 방법 다 사용 가능하며 데이터의 크기가 크고 변경이 빈번할 경우 1번 방식이 더 효과적일 것 같다는 생각이 들었습니다.

Virtual DOM 사용 유무

React 컴포넌트가 반환하는 것을 실제 DOM이 아니라 화면에 무엇을 그릴지 선언적으로 표현한 가상의 엘리먼트들에 불과합니다.

다음과 같이 JSX라는 JS 확장 문법을 사용해서 JS 파일 내에서 HTML과 같은 문법을 작성할 수 있고

function HelloMessage(props) {
return <div className="greeting">Hello {props.name}</div>;
}

이는 결과적으로 babel에 의해 컴파일 되어 순수 자바스크립트 객체로 변환되며 이를 흔히 Virtual DOM이라고 부릅다. 이는 상태 값이 변경될 때마다 항상 새롭게 생성됩니다.

function HelloMessage(props) {
return _jsx('div', { className: 'greeting' }, 'Hello ', props.name);
}

React는 이렇게 생성한 Virtual DOM을 실제 DOM과 동기화시켜 줍니다.

name이 “React” ⇒ “Svelte” 될 경우 다음과 같은 일이 일어납니다.

  1. name이 변경되었으므로 변경된 name(”Svelte”)을 기반으로 새로운 자바스크립트 객체를 생성합니다. (_jsx('div', { className: 'greeting' }, 'Hello ', 'Svelte') 가 생성됩니다.)
  2. 기존에 생성된 객체와 이번에 생성된 객체를 비교합니다.
  • type - 이전: div ⇒ 현재: div(동일 ⇒ 유지)
  • props - 이전: { className: 'greeting' } ⇒ 현재: { className: 'greeting' }(동일 ⇒ 유지)
  • 첫 번째 children - 이전: 'Hello ' ⇒ 현재: 'Hello '(동일 ⇒ 유지)
  • 두 번째 children - 이전: ‘React’ ⇒ 현재: ‘Svelte’(변경됨 ⇒ 변경)
  1. div 엘리먼트는 유지하고, children만 변경합니다.
divElement.textContent = 'Hello ' + ‘Svelte’;

결과적으로 변경된 부분만 실제 DOM에 반영하기는 하지만 Virtual DOM을 매번 생성하고 이전 결과물과 비교하는 것 자체가 비효율적입니다.

비록 대부분의 경우 사소하지만 이러한 불필요한 동작이 디폴트 동작인 것이라면 나중에 앱이 커지고 성능 문제가 생겼을 때 어떤 부분이 병목 현상의 원인이 되는지 파악하기 어렵습니다.

반면에 Svelte의 경우 VirtualDom을 사용하지 않습니다. Svelte는 컴파일러이기 때문에 빌드 타임 때 이미 어떤 부분이 변경될 수 있는지 알 수 있습니다. 다음과 같은 코드를 빌드 타임에 생성해 주기 때문에 매번 Virtual DOM을 생성, 비교하는 과정이 없이 효율적이고 빠르게 화면을 업데이트합니다.

// name이 변경될 경우 svelte 컴파일러가 다음과 같은 코드를 작성해줍니다.
if (changed.name) {
text.data = name;
}

💡 React는 Virtual DOM을 사용하기 때문에 빠르다”라는 잘못된 정보가 널리 퍼져있는데 이는 사실이 아닙니다. 정확히는 “충분히 빠르다”에 가깝습니다. 매번 Virtual DOM을 생성, 비교하지 않는 Svelte가 훨씬 빠릅니다. React core team에서 useMemo, useCallback, Memo 등의 여러 최적화 API를 제공한다는 것 자체가 React가 기본적으로 빠르지 않다는 증거입니다.

최상위 엘리먼트

React는 컴포넌트의 최상위 앨리먼트가 반드시 하나여야 합니다. ⇒ 불필요한 제약 조건이 필요합니다.

function HelloWorld(){
return (<>
<h1>Hello</h1>
<p>World</p>
</>)
}

반면에 Svelte는 그러한 제약 조건이 없습니다.

<h1>Hello</h1>
<p>World</p>

React는 왜 반드시 하나여야 할까요?

React 컴포넌트가 반환하는 것은 객체이고 함수는 한 번에 2가지 이상을 반환할 수 없습니다.

function HelloWorld(){
return (
<h1>Hello</h1> // _jsx('h1', null, 'Hello')
<p>World</p> // _jsx('p', null, 'World')
)
}
function HelloWorld(){
// 감싸고 싶은 element가 없을 경우 fragment 또는 축약형 사용합니다.
// _jsx(React.Fragment, null, _jsx('h1', null, 'Hello'), _jsx('p', null, 'World'))
return (<>
<h1>Hello</h1>
<p>World</p>
</>)
}

Svelte는 왜 제약이 없을까요?

Svelte는 컴포넌트에 실제(가상) 엘리먼트로 이루어진 HTML을 작성하기 때문에 React와 같은 제약이 필요 없게 됩니다.

<h1>Hello</h1>
<p>World</p>

State, Binding

// React
import { useState } from 'react'
export default () => {
const [a, setA] = useState(1);
const [b, setB] = useState(2);
function handleChangeA(event){
setA(+event.target.value);
}
function handleChangeB(event){
setB(+event.target.value);
}
return (
<div>
<input type="number" value={a} onChange={handleChangeA} />
<input type="number" value={b} onChange={handleChangeB} />
<p>
{a} + {b} = {a + b}
</p>
</div>
)
}
// Svelte
<script> let a = 1; let b = 2; </script>
<div>
<input type="number" bind:value={a} />
<input type="number" bind:value={b} />
<p>
{a} + {b} = {a + b}
</p>
</div>
ReactSvelte
state를 사용하려면 useState를 명시적으로 작성일반 자바스크립트 변수를 선언하면 컴파일러가 알아서 처리
input의 값이 변경되었을 때 state를 변경하는 코드를 명시적으로 작성bind 지시자를 통해 다음 코드와 기능적으로 동일 (value={state} on:input={(event) ⇒ state = +event.target.value)
+를 통해 명시적으로 숫자로 변경bind 지시자를 통해 이 문제도 해결

💡 비슷한 점은 React도 useState를 최상단에서만 사용할 수 있듯이 Svelte도 최상단에서 let으로 선언한 변수만이 상태값이 됩니다. 함수 안에서 let을 사용하면 그냥 순수 자바스크립트 코드와 동일합니다.

헷갈리기 쉬운 부분

반응성 구문 실행 시점

반응성 구문은 동기적으로 실행되지 않습니다.

<script> let count = 0; $: doubledCount = count * 2; function increment(){ count += 1; } </script>
<button on:click={increment}>
clicks: {count}
</button>
doubled: {doubledCount}

click 하면 숫자가 증가하는 간단한 어플리케이션

Svelte에서 상태 값으로부터 파생된 값이 필요할 경우 $: statement(반응성 구문)를 통해 이를 해결합니다.

$:는 무엇일까요? $: 은 Labled statement라는 JavaScript 문법입니다. 하지만 svelte는 컴파일러를 이용해서 본래의 JS가 제공하는 기능이 아닌 다른 목적으로 사용하고 있습니다.

하지만 주의해야 할 점이 있습니다. Svelte에서 동기적으로 변경되는 상태 값과는 달리 반응성 구문은 동기적으로 실행되지 않습니다. 모든 함수 실행이 종료되고 DOM이 변경되기 전에 실행됩니다.

반응성 구문 실행 순서

반응성 구문이 여러 개 있을 경우 코드의 순서와 관계없이 의존하는 값이 먼저 실행됩니다.

다음 코드의 경우 코드상으로는 시급 ⇒ 주급 ⇒ 일급 계산 코드가 실행되지만 svelte 컴파일러가 의존하는 값에 따라서 순서대로 실행시켜주기 때문에 (주급은 일급에 의존, 일급은 시급에 의존) 시급 ⇒ 일급 ⇒ 주급을 계산하는 순서로 실행이 됩니다.

이를 topological order에 따라 실행된다고 표현합니다.

<script>
let 시급 = 10000;
$: 주급 = 일급 * 5;
$: 일급 = 시급 * 8;
</script>
<label>
시급
<input type="number" bind:value={시급} />
</label>
<div>일급: {일급.toLocaleString()}</div>
<div>주급: {주급.toLocaleString()}</div>

DOM 변경 시점

DOM은 동기적으로 변경되지 않습니다.

<script>
let framework = 'React';
</script>
<h1>I am using {framework} now.</h1>
<button on:click={() => { framework = 'Svelte'; const h1 = document.querySelector('h1'); console.log(h1.textContent); // "I am using React now." }}>
use Svelte
</button>

상태 값을 변경하고 즉시 DOM에 접근하여 textContent 를 출력하면 변경되기 전 값이 출력됩니다. 그 이유는 Svelte가 성능을 높이기 위해 변경된 상태 값들을 모두 기억해두었다가 한 번에 DOM을 변경하기 때문입니다. 그래서 DOM은 동기적으로 변경되지 않고 비동기적으로 변경됩니다.

만약에 상태 값을 변경하고 즉시 최신의 DOM에 접근하고 싶다면 다음과 같이 tick을 활용할 수 있습니다.

import { tick } from 'svelte';
<button on:click={async () => {
framework = 'Svelte';
const h1 = document.querySelector('h1');
await tick();
console.log(h1.textContent); // "I am using Svelte now."
}}>
use Svelte
</button>

tick은 어떻게 동작할까요?

tick을 호출하면 프로미스가 반환됩니다. 근데 이 프로미스는 변경된 상태 값을 DOM에 반영하고 resolve 됩니다.

function tick(){
return new Promise((resolve) => {
// 변경된 state를 DOM에 반영하는 코드
resolve();
});
}
// 만약 변경된 상태값이 없다면 즉시 resolve
function tick(){
return Promise.resolve();
}

결과적으로 await tick()의 의미는 변경된 상태 값을 DOM에 반영할 때까지 기다린다는 의미입니다.

이벤트 핸들러에서 상태 값을 변경하고 그로 인해 DOM이 변경될 때 순서는 다음과 같습니다.

  1. 이벤트 핸들러 실행
  2. 상태 값 변경(동기)
  3. 이벤트 핸들러 종료
  4. 반응성 구문 실행
  5. 변경된 모든 state로 한 번에 DOM 변경

그래서 2번과 3번 사이에서 console.log를 출력하려고 했을 때 반응성 구문이 실행되기 이전 값, DOM이 변경되기 이전 값이 출력되었던 것입니다.

그래서 await tick()으로 4, 5번이 실행될 때까지 기다린 후 출력하면 최신 값을 얻을 수 있었습니다.

추가적으로 보면 좋은 글

Svelte 3: Rethinking reactivity