2019. 5. 5.

Svelte 3에 대해 알아보자

Svelte, 정확히는 Svelte 3에 대해 알아보고 간단한 Todo도 만들어보았다.


svelte logo

Svelte?

Svelte는 Rich Harris(NYT, Graphics Editor)를 주축으로 만들어진 UI 라이브러리/프레임워크다. 나온지는 꽤 되었지만, 얼마전 Ver. 3이 릴리즈 되었고, 관련된 발표와 함께 화제가 되고 있는 중.

어쩌다 보게 된 것인진 모르겠는데(트위터 아니면 유튜브 돌다가 인데 기억이 나지 않는다..) 그 발표를 보게 됐다. 한글 자막은 없지만 여러모로 재미있는 발표이고, Svelte와 관련 이슈에 대해 현재 가장 잘 설명된 자료이므로, 체크해보면 좋을 것 같다.

Svelte의 문제의식은 이렇다. 리액트로 대표되는 현재의 선언형 웹 프론트엔드 UI 라이브러리/프레임워크는 Virtual-DOM을 사용한다. JS 영역에서 가상 DOM tree를 만들어 런타임에 이벤트가 발생할 때 마다 tree 를 매번 순회하면서 이전과 달라진 부분을 DOM에 반영하는 식인데, 일단 순회/비교(diffing) 자체가 성능에 영향을 미치거니와, 컴포넌트 내 함수 선언등도 매번 재실행 되므로, 불합리하다는 것이다.

그래서 Svelte가 취한 방식은, 컴파일러가 되는 것이다. 컴포넌트 파일은 선언형으로 작성할 수 있도록 하고, 빌드타임에 (V-DOM을 사용하지 않는) 성능 좋은 명령형 JS 코드로 컴파일해 선언형 프레임워크의 개발자 경험과 명령형 코드의 성능을 동시에 잡겠다는 전략이다.

발표에 따르면 유명한 JSConf Iceland 2018에서의 Dan Abramov의 발표Asynchronous React 데모 보다 Synchronous Svelte가 더 가볍게 처리한다고 한다. 아무튼 빠르긴 빠른가봄.

간단한 Todo 앱 만들기

Scaffolding

그럼 간단한 Todo를 만들어보자. Svelte 공식 사이트의 퀵스타트 가이드를 따라 아래와 같이 degit(이것도 Rich Harris가 만듬)을 사용해서 프로젝트 scaffolding을 한다.

npx degit sveltejs/template sveltodo
cd sveltodo
npm i
npm run dev & open http://localhost:5000
npx degit sveltejs/template sveltodo
cd sveltodo
npm i
npm run dev & open http://localhost:5000

http://localhost:5000 로 가보면 헬로 월드가 떠있는 것을 볼 수 있다.

svelte-hello

프로젝트 폴더의 구조는 아래와 같다.

├── public
│   ├── bundle.css
│   ├── bundle.css.map
│   ├── bundle.js
│   ├── bundle.js.map
│   ├── global.css
│   └── index.html
├── src
│   ├── App.svelte
│   └── main.js
├── package-lock.json
├── package.json
├── README.md
└── rollup.config.js
├── public
│   ├── bundle.css
│   ├── bundle.css.map
│   ├── bundle.js
│   ├── bundle.js.map
│   ├── global.css
│   └── index.html
├── src
│   ├── App.svelte
│   └── main.js
├── package-lock.json
├── package.json
├── README.md
└── rollup.config.js

번들러로 rollup(이것도 Rich Harris가..)을 쓰고 있고(webpack도 사용 가능하다고).. 일단 눈에 띄는 것은 package.json인데, dependencies가 없다.

{
  "name": "svelte-app",
  "version": "1.0.0",
  "devDependencies": {
    "npm-run-all": "^4.1.5",
    "rollup": "^1.10.1",
    "rollup-plugin-commonjs": "^9.3.4",
    "rollup-plugin-livereload": "^1.0.0",
    "rollup-plugin-node-resolve": "^4.2.3",
    "rollup-plugin-svelte": "^5.0.3",
    "rollup-plugin-terser": "^4.0.4",
    "sirv-cli": "^0.4.0",
    "svelte": "^3.0.0"
  },
  "scripts": {
    "build": "rollup -c",
    "autobuild": "rollup -c -w",
    "dev": "run-p start:dev autobuild",
    "start": "sirv public",
    "start:dev": "sirv public --dev"
  }
}
{
  "name": "svelte-app",
  "version": "1.0.0",
  "devDependencies": {
    "npm-run-all": "^4.1.5",
    "rollup": "^1.10.1",
    "rollup-plugin-commonjs": "^9.3.4",
    "rollup-plugin-livereload": "^1.0.0",
    "rollup-plugin-node-resolve": "^4.2.3",
    "rollup-plugin-svelte": "^5.0.3",
    "rollup-plugin-terser": "^4.0.4",
    "sirv-cli": "^0.4.0",
    "svelte": "^3.0.0"
  },
  "scripts": {
    "build": "rollup -c",
    "autobuild": "rollup -c -w",
    "dev": "run-p start:dev autobuild",
    "start": "sirv public",
    "start:dev": "sirv public --dev"
  }
}

위에서 설명했다시피 svelte는 기본적으로 컴파일러이기 때문에, 런타임에 svelte 관련 모듈을 불러와야 한다던가 하는 일이 없기 때문이라고. 신선..

src/App.svelte를 열어보면, 아래와 같이 생겼다.

<script>
  export let name;
</script>

<style>
  h1 {
    color: purple;
  }
</style>

<h1>Hello {name}!</h1>
<script>
  export let name;
</script>

<style>
  h1 {
    color: purple;
  }
</style>

<h1>Hello {name}!</h1>

Vue와 많은 부분 비슷한데, .sveltehtml 기반이라는 점이 다르다(이전 버전에선 걍 .html 파일을 썼다고). 그래서 일단 React나 Vue 에서 볼 수 있는 컴포넌트의 탑레벨엔 엘리먼트가 하나만 있어야 된다 뭐 그런 제한이 없다. 그래서 아래 코드를 그냥 추가해도,

<h1>Hello {name}!</h1>
<p>{name}</p>
<h1>Hello {name}!</h1>
<p>{name}</p>

Hello world! is purple and world is black

그냥 잘 된다. 그 외 SSR도 쉽게 되고 그런 장점이 있다는데.. 일단 여기선 넘어가기로 한다.

(참고로 각 태그 영역의 순서는 상관없다. <script></script>가 html 영역 아래로 내려와도 되고 <style></style>이 맨 아래에 위치해도 똑같이 동작한다. 여기선 공식 웹사이트에 나와 있는 순서를 따르는 것 뿐이다.)

Reactive

위 발표나 슬라이드에서 볼 수 있듯, Svelte는 반응형 프로그래밍을 지향한다. 역시 Vue와 비슷한 점인데, 더 극단적인데가 있다. Vue에선 Vue 인스턴스 내의 data 프로퍼티 안에 넣어주어야 하지만 Svelte에선 위와 같이 그냥 변수 선언만 해주면 된다.

<script>
  let appTitle = "Sveltodo";
</script>

<h1>{appTitle}</h1>
<script>
  let appTitle = "Sveltodo";
</script>

<h1>{appTitle}</h1>

그런데 어째서 let 으로 선언한 것일까? 이것이 Svelte의 신박 혹은 흑마법적인 점 중 하나인데, Svelte에선 뭔가가 바뀌었다고 컴파일러에게 알려주기 위해 재대입(reassignment)를 사용한다(!?). 그래서 아래와 같이,

<script>
  let appTitle = "Sveltodo";

  function appendEmoji() {
    appTitle += "😎";
  }
</script>

<h1 on:click="{appendEmoji}">{appTitle}</h1>
<script>
  let appTitle = "Sveltodo";

  function appendEmoji() {
    appTitle += "😎";
  }
</script>

<h1 on:click="{appendEmoji}">{appTitle}</h1>

let 으로 선언 후 재대입하는 함수를 만들어서 on:click 으로 연결해주는 것만으로,

sveltodo-title-click

아무튼 Todo를 만들거니까 일단 input을 만들어보자. 이것 역시 충격적으로 간단한데,

<script>
  let appTitle = "svelTodo";
  let todoText = "";
</script>

<h1>{appTitle}</h1>
<input bind:value="{todoText}" type="text" placeholder="[여기에 할 일 입력]" />
<p>{todoText}</p>
<script>
  let appTitle = "svelTodo";
  let todoText = "";
</script>

<h1>{appTitle}</h1>
<input bind:value="{todoText}" type="text" placeholder="[여기에 할 일 입력]" />
<p>{todoText}</p>

역시 <script>에서 변수 선언 후 input 엘리먼트에 bind:value로 연결해주는 것만으로, 아래와 같이 잘 동작한다.

svletodo-no-todo

역시 너무 간단하고.. 하여간 보일러플레이트가 거의 0에 수렴하도록 만들어놨다는 것이 인상적이다(공식 블로그의 포스트 소제목 중엔 "Death to Boilerplate"가 있기도).

이제 todo list를 만들어 리스트에 할 일이 입력되도록 해보자. todos 배열을 만들어 거기에 todoText를 추가하면 될텐데, Vue 같으면 methods 객체 안에 아래와 같이 Array.prototype.push를 사용하는 식이겠지만,

addTodo() {
  this.todos.push(this.todoText)
}
addTodo() {
  this.todos.push(this.todoText)
}

Svelte에선 상태 변경이 재대입을 통해 이뤄지므로, 새 배열을 만들어서 todos 변수에 다시 넣어줘야 하고, 그래서 아래와 같이 spread operator를 사용하는 것이 권장된다.

function addTodo() {
  todos = [...todos, todoText];
}
function addTodo() {
  todos = [...todos, todoText];
}

인풋이 없을 시 입력 방지, 입력후 input 필드를 비우는 로직을 추가하면 아래와 같이 될 것이다.

<script>
  let appTitle = "svelTodo";
  let todoText = "";
  let todos = [];

  function addTodo() {
    if (!todoText) return;
    todos = [...todos, todoText];
    todoText = "";
  }
</script>

<h1>{appTitle}</h1>

<input bind:value="{todoText}" type="text" placeholder="[여기에 할 일 입력]" />
<button on:click="{addTodo}">입력</button>

<p>{todos}</p>
<script>
  let appTitle = "svelTodo";
  let todoText = "";
  let todos = [];

  function addTodo() {
    if (!todoText) return;
    todos = [...todos, todoText];
    todoText = "";
  }
</script>

<h1>{appTitle}</h1>

<input bind:value="{todoText}" type="text" placeholder="[여기에 할 일 입력]" />
<button on:click="{addTodo}">입력</button>

<p>{todos}</p>

sveltodo-add-todo

계속해서 todo item을 object로 만들어 체크가 가능하도록 done 항목을 추가하고 리스트로 나타나게 해보자.

리스트 다루기(each 블록)

addTodo 함수 부분은 물론 아래와 같이 하면 될 것이다.

function addTodo() {
  if (!todoText) return;
  todos = [...todos, { text: todoText, done: false }];
  todoText = "";
}
function addTodo() {
  if (!todoText) return;
  todos = [...todos, { text: todoText, done: false }];
  todoText = "";
}

todo item의 text만 리스트로 보여줘야 하는데, Svelte에서 리스트를 처리하는 방법은 아래와 같이 each 블록을 사용하는 것이다.

<ul>
  {#each todos as todo}
  <li>{todo.text}</li>
  {/each}
</ul>
<ul>
  {#each todos as todo}
  <li>{todo.text}</li>
  {/each}
</ul>

Vue의 v-for 디렉티브 보다 Django 등의 웹 프레임워크에서 사용하는 템플릿 언어에 더 가까운 모습인데 개인적으론 이 쪽이 더 좋다고 생각한다. Vue를 사용하면서 반복이나 조건 등의 기능을 엘리먼트에 직접 추가해서 생기는 불편함이 종종 있었던 것 같고.. 이런 기능은 엘리먼트와 분리되어 있는 편이 좋은 것 같다.

그리고 as 뒤의 리스트 아이템이 object일 땐 아래와 같이 destructuring을 사용할 수도 있다.

<ul>
  {#each todos as { text }}
  <li>{text}</li>
  {/each}
</ul>
<ul>
  {#each todos as { text }}
  <li>{text}</li>
  {/each}
</ul>

오호..

이어서 done 토글 기능과 삭제 기능을 추가해보자.

each 블록에서 리스트 아이템의 인덱스를 아래와 같이 받아올 수 있으므로

{#each todos as { text, done }, todoIndex}
{#each todos as { text, done }, todoIndex}

html의 리스트 부분은 아래와 같이 클릭 이벤트 연결을 해주면 될 것이다.

<ul>
  {#each todos as { text, done }, todoIndex}
    <li on:click={() => toggleTodo(todoIndex)}>
      {text}
      <button on:click={() => deleteTodo(todoIndex)}>
        X
      </button>
    </li>
  {/each}
</ul>
<ul>
  {#each todos as { text, done }, todoIndex}
    <li on:click={() => toggleTodo(todoIndex)}>
      {text}
      <button on:click={() => deleteTodo(todoIndex)}>
        X
      </button>
    </li>
  {/each}
</ul>

그런데 X 버튼이 <li> 내부에 있으므로, 클릭시 이벤트 전파를 막기 위해 stopPropagation 처리를 해주어야 할텐데, 찾아보니 이벤트 이름 오른쪽에 | 로 modifier를 넣어줄 수 있었다. 오호..

<button on:click|stopPropagation={() => deleteTodo(todoIndex)}>
<button on:click|stopPropagation={() => deleteTodo(todoIndex)}>

토글, 삭제 함수는 아래와 같이 index를 사용해서 만들면 될 것이다. 재대입을 위해 새 배열을 반환하는 .map.filter를 사용했다.

function toggleTodo(selected) {
  todos = todos.map((todo, i) =>
    i === selected ? { text: todo.text, done: !todo.done } : todo
  );
}

function deleteTodo(selected) {
  todos = todos.filter((todo, i) => i !== selected);
}
function toggleTodo(selected) {
  todos = todos.map((todo, i) =>
    i === selected ? { text: todo.text, done: !todo.done } : todo
  );
}

function deleteTodo(selected) {
  todos = todos.filter((todo, i) => i !== selected);
}

그리고 done 토글시 리스트 아이템의 스타일링 변경도 해줘야 할텐데,

class 명 다루기

Svelte는 css class 사용도 엄청 편하게 되어있다. 일단 토글시 li 엘리먼트에 done 이라는 클래스명을 추가해 컬러는 옅어지게, 텍스트는 line-through 되도록 해두자.

<style>
  li.done {
    color: #999;
    text-decoration: line-through;
  }
</style>
<style>
  li.done {
    color: #999;
    text-decoration: line-through;
  }
</style>

li에 클래스명을 추가하려면 일반적으로 아래와 같이 하면 될텐데,

<li class={done ? 'done' : ''}>
<li class={done ? 'done' : ''}>

Svelte에서는 클래스명 관련 편의 문법을 제공한다.

<li class:done="{done}"></li>
<li class:done="{done}"></li>

위와 같이 class:클래스명={조건}의 형식으로 간편하게 사용할 수 있고, 클래스명과 조건에 사용되는 변수명이 같으면,

<li class:done></li>
<li class:done></li>

아예 위와 같이 줄여쓸 수도 있다. 오홍..

그래서 최종적으론 아래와 같은 코드가 되고,

<script>
  let appTitle = "svelTodo";
  let todoText = "";
  let todos = [];

  function addTodo() {
    if (!todoText) return;
    todos = [...todos, { text: todoText, done: false }];
    todoText = "";
  }

  function toggleTodo(selected) {
    todos = todos.map((todo, i) =>
      i === selected ? { text: todo.text, done: !todo.done } : todo
    );
  }

  function deleteTodo(selected) {
    todos = todos.filter((todo, i) => i !== selected);
  }
</script>

<style>
  li.done {
    color: #999;
    text-decoration: line-through;
  }
</style>

<h1>{appTitle}</h1>

<input bind:value={todoText} type="text" placeholder="[여기에 할 일 입력]">
<button on:click={addTodo}>입력</button>

<ul>
{#each todos as { text, done }, todoIndex}
  <li
    on:click={() => toggleTodo(todoIndex)}
    class:done
  >
    {text}
    <button
      on:click={() => deleteTodo(todoIndex)}
    >
      X
    </button>
  </li>
{/each}
</ul>
<script>
  let appTitle = "svelTodo";
  let todoText = "";
  let todos = [];

  function addTodo() {
    if (!todoText) return;
    todos = [...todos, { text: todoText, done: false }];
    todoText = "";
  }

  function toggleTodo(selected) {
    todos = todos.map((todo, i) =>
      i === selected ? { text: todo.text, done: !todo.done } : todo
    );
  }

  function deleteTodo(selected) {
    todos = todos.filter((todo, i) => i !== selected);
  }
</script>

<style>
  li.done {
    color: #999;
    text-decoration: line-through;
  }
</style>

<h1>{appTitle}</h1>

<input bind:value={todoText} type="text" placeholder="[여기에 할 일 입력]">
<button on:click={addTodo}>입력</button>

<ul>
{#each todos as { text, done }, todoIndex}
  <li
    on:click={() => toggleTodo(todoIndex)}
    class:done
  >
    {text}
    <button
      on:click={() => deleteTodo(todoIndex)}
    >
      X
    </button>
  </li>
{/each}
</ul>

잘 돌아간다.

sveltodo-toggle-delete

그러나 Svelte의 가장 띠용한 점 중 하나가 아직 하나 남이 있는데..

애니메이션

애니메이션 기능을 디폴트로 제공한다! 애니메이션을 적용하는 방법도 매우 간단하다. todo 아이템 입력시 li 엘리먼트가 fade in 되고, 삭제시엔 화면 왼쪽으로 이동하면서 사라지도록 해보자. 다른 프레임워크에선 css를 좀 신경써서 만져야 하겠지만, Svelte에선 너무 간단하게 할 수 있다.

일단 관련된 함수를 가져와서,

<script>
  import { fade, fly } from "svelte/transition";
</script>
<script>
  import { fade, fly } from "svelte/transition";
</script>

필요한 엘리먼트에 아래와 같이 적용한다.

<li in:fade out:fly={{ x: -300, duration: 400 }} >
<li in:fade out:fly={{ x: -300, duration: 400 }} >

눈에 보이는대로다. in 은 추가시, out 은 삭제시 적용되는 애니메이션이고, : 옆에 사용할 애니메이션 함수를, ={} 안에 parameter로 객체를 넘겨주면 세부 조정을 할 수 있고, 아무것도 넘겨주지 않으면 디폴트 설정으로 적용된다. 적용한 애니메이션이 어떻게 동작하는지 보자.

sveltodo-fade-fly

별 거 안했는데 이런 게 그냥 된다!

맺으며

빅3에 비하면 미래가 아직은 불투명하다는 점이 있지만, 굉장히 매력적이고, 특히 생산성이 매우 좋을 것 같다. 뭐든 간단하게 되어있으니 손이 쉽게 가는 면도 있고(벌써부터 Vue가 약간 오징어로 보이는..).

더해서, 위에서 언급하진 않았지만 인상적이었던 것은, 발표 후반부에 나왔던 두 가지 케이스인데, 하나는 svelte로 만든 웹앱의 크기가 작고 성능이 좋아서 다른 프레임워크로는 불가능했던 임베디드 앱을 만들 수 있었다는 것과, 협업시 비개발자들도 쉽게 건드릴 수 있어서 좋았다는 것이었다.

svelte의 아주 기본적인 부분들이지만 위에서 다루지 않은 것도 많다($: 라던가). 그런 부분들은 튜토리얼이 워낙에 잘 되어있으므로 마저 체크해보면 좋을 것이다.