🐪
Svelte로 만드는 Todo List - 2

Svelte 프레임워크로 Todo List 어플리케이션을 제작하고 배포하는 과정을 공유합니다.

June 24, 2020


Svelte FrontEnd


이전 글 Svelte로 만드는 Todo List - 1에서 내용이 이어집니다.

레이아웃

완성본

우선 레이아웃을 위처럼 만드려고 생각하고 있습니다. 간단하긴 하지만, 구현해야 할 스펙을 정리해보고 가도록 하죠.

  • 간단한 네비게이션 바와 반응형 레이아웃
  • 할 일을 입력받는 인풋 박스
  • 추가하기 버튼
  • 남은 일이 몇 개인지 알려주기
  • 각 아이템은 체크와 삭제, 수정이 가능해야 함

그래서 대충 Bulma 문서를 보면서 마크업을 해주었습니다. 네비게이션 부분은 예제 코드를 복붙한 수준이라 사실 큰 의미는 없는데… 마크업이 좀 복잡해서 별도의 컴포넌트로 분리해주었습니다.

<!-- component/Navbar.svelte -->
<header class="navbar">
  <div class="container">
    <div class="navbar-brand">
      <div class="navbar-item">
        <img src="favicon.png" alt="Logo" />
      </div>
    </div>
    <div class="navbar-menu">
      <div class="navbar-end">
        <span class="navbar-item">
          <a
            class="button is-success is-inverted"
            href="https://github.com/wormwlrm/svelte-todo-list"
            target="_blank"
          >
            <span class="icon">
              <i class="fab fa-github"></i>
            </span>
            <span>Github</span>
          </a>
        </span>
      </div>
    </div>
  </div>
</header>

NavBar라는 이름으로 싱글 파일 컴포넌트(SFC)를 만들고, 앞으로 만들어질 모든 컴포넌트는 /src/components 디렉토리 아래에서 관리하기로 했습니다.

이런 컴포넌트들은 <script> 태그 안에서 직접 import 하여 템플릿에서 사용할 수 있습니다. 이 때 공식 문서에 따르면, 커스텀하게 만든 컴포넌트의 이름은 일반 HTML 엘리먼트와 구분하기 위해 대문자로 시작해야 합니다.

<!-- App.svelte -->
<script>
  import Navbar from "./components/Navbar.svelte";
</script>

<section class="hero is-primary is-fullheight">
  <div class="hero-head">
    <Navbar />
  </div>

  <div class="hero-body">
    <div class="container has-text-centered">
      <div class="columns is-desktop">
        <div class="column"></div>
        <div class="column">
          <h1 class="title">Todo List</h1>
          <h2 class="subtitle">Built with Svelte, Bulma</h2>
          <!-- 이 곳에 TodoInput 넣기 -->
          <!-- 이 곳에 TodoList 넣기 -->
        </div>
        <div class="column"></div>
      </div>
    </div>
  </div>
</section>

차후에 만들 TodoInputTodoList 컴포넌트가 들어갈 영역을 표시해주었습니다. 여기까지 제작한 결과물은 아래와 같습니다.

네비게이션 추가

참, Bulma는 기본적으로 fontawesome 아이콘 폰트를 사용하고 있기 때문에, Bulma 프레임워크에서 사용하는 아이콘이 제대로 나오기를 원한다면 아래 스크립트를 index.html 의 헤더에 넣으면 됩니다.

<!-- index.html -->
<script defer src="https://use.fontawesome.com/releases/v5.3.1/js/all.js"></script>

목록 출력하기

우선 할 일을 출력해보도록 하겠습니다. 우선 뼈대가 될 데이터를 todos 배열에 저장해보도록 하겠습니다. 각 할 일 목록은 고유한 키값으로 사용될 id, 체크 상태를 나타낼 checked, 그리고 텍스트를 저장할 text 프로퍼티를 갖고 있습니다.

<!-- App.svelte -->
<script>
  import Navbar from "./components/Navbar.svelte";
  import TodoList from "./components/TodoList.svelte";

  let todos = [
    { id: 0, checked: false, text: "finish Svelte tutorial" },
    { id: 1, checked: false, text: "build an app" },
    { id: 2, checked: false, text: "world domination" },
  ];
</script>

뼈대가 될 데이터 todos는 앱의 최상단 컴포넌트인 App에서 관리하려고 합니다.

그 후 src/component 디렉토리 내에 TodoList 컴포넌트를 만들었습니다. 본문 영역에는 이것을 Prop으로 넘겨주어서 사용할 수 있게 TodoList 컴포넌트를 만들어줍니다. 템플릿에 사용하기 전에 import 하는 것도 잊지 말아주시구요.

<!-- App.svelte -->
<section class="hero is-primary is-fullheight">
  <div class="hero-head">
    <Navbar />
  </div>

  <div class="hero-body">
    <div class="container has-text-centered">
      <div class="columns is-desktop">
        <div class="column"></div>
        <div class="column">
          <h1 class="title">Todo List</h1>
          <h2 class="subtitle">Built with Svelte, Bulma</h2>
          <!-- 이 곳에 TodoInput 넣기 -->
          <TodoList {todos} />
        </div>
        <div class="column"></div>
      </div>
    </div>
  </div>
</section>

Prop을 넘겨주는 방법은 React스럽습니다. 중괄호({})를 이용해 JavaScript 표현식을 나타낼 수 있고, Prop 이름과 값이 같은 경우에는 축약형으로 사용할 수 있습니다.

<!-- 같은 표현입니다 -->
<TodoList todos={todos} />
<TodoList {todos} />

TodoList 컴포넌트에서는 이 Prop을 어떻게 받을까요? 바로 export 키워드를 이용해 받습니다.

<script>
  // 일반 Prop입니다
  export let foo;

  // default value를 가지는 Prop입니다
  export let bar = 'optional default initial value';

  // Read-only 한 Prop입니다
  export const readonly = 'readonly';
</script>

띠용? 너무 이상하다고 생각되지만… 공식 튜토리얼에서는 아래처럼 설명하고 있습니다.

일반적인 JavaScript 모듈의 문법처럼 동작하지 않기 때문에 처음에 보기에 좀 이상하게 느껴질 겁니다. 그래도 일단 그런 셈 치고 넘어가면 곧 자연스러워질 것입니다.

음… 그렇다고 하는 군요. 아무튼 export 키워드를 이용해 todos Prop을 받아줍시다.

<!-- TodoList.svelte -->
<script>
  import TodoItem from "./TodoItem.svelte";

  export let todos;

  $: remaining = todos.filter((t) => !t.checked).length;
</script>

<div>
  {#if remaining}
    <p>남은 일이 {remaining}개 남았어요</p>
  {:else}
    <p>남은 일이 없어요</p>
  {/if}
  <br />
  {#each todos as todo (todo.id)}
    <TodoItem {todo} />
  {/each}
</div>

TodoList 컴포넌트에서는 템플릿을 잘 보여주기 위해 조건부 렌더링({#if}, {:else}, {/if})과 반복문({#each})이 등장합니다. 조건부 렌더링은 사실 특별히 설명할 거는 없어보이네요.

템플릿 반복문에서는 괄호가 등장하는데, 각 아이템들을 고유하게 식별할 수 있는 키값을 넣어줍니다. 키값을 명시해줌으로써 배열의 각 요소의 추가되거나 삭제 될 때 데이터의 변화를 잘 감지하여 DOM에 그려질 수 있게 도와줄 수 있습니다.

<div>
  <!-- 그냥 Prop만 넘겨줍니다 -->
  {#each todos as todo}
    <TodoItem {todo} />
  {/each}

  <!-- 순회 시 인덱스 정보가 필요할 때 사용합니다 -->
  {#each todos as todo, index}
    <TodoItem {todo} />
  {/each}

  <!-- 각 아이템들의 고유 키값을 넘겨줄 때 사용합니다 -->
  {#each todos as todo (key)}
    <TodoItem {todo} />
  {/each}

  <!-- 인덱스와 키값을 모두 사용한 경우입니다 -->
  {#each todos as todo, index (key)}
    <TodoItem {todo} />
  {/each}
</div>

그리고 눈에 띄는 한 가지가 또 있죠.

$: remaining = todos.filter((t) => !t.checked).length;

바로 라벨($) 문법입니다. 이것 덕분에 바닐라 자바스크립트의 개념만으로 반응형 프로퍼티를 만들 수 있었다고 하긴 하는데… 역시 처음 볼 때는 눈에 안 익어서 그런지 많이 어색합니다.

이것과 가장 유사한 개념이 바로 Vue의 계산된 속성(Computed Property)입니다. Vue의 개념을 빌려서 설명해보자면, 내부에서 참조된 값의 변화에 반응적인 데이터 라고 설명할 수 있겠네요. 메소드와는 다르게, 내부에서 참조된 값이 변화할 때만 로직이 실행되기 때문에 값이 변하지 않는 이상 한 번 계산된 값은 캐시가 되니, 성능 상의 이점이 있죠.

위의 remaining 이라는 프로퍼티(더 정확히는 라벨 이름?)는 todos 배열에서 체크가 되지 않은 아이템(!checked)들의 갯수를 나타내고, 반응형으로 값이 변합니다.

<!-- TodoItem.svelte -->
<script>
  export let todo;

  let placeholder = "할 일을 입력해주세요";
</script>

<style lang="scss">
  .control.has-icons-left,
  .control.has-icons-right {
    & .icon {
      pointer-events: inherit;
      transition: color 0.5s;
      cursor: pointer;

      &:hover {
        color: #363636;
      }
    }

    & .input:focus ~ .icon {
      color: #dbdbdb;
    }
  }

  .todo-item {
    &--checked {
      opacity: 0.4;
    }
  }
</style>

<div class="field">
  <div
    class="control has-icons-left has-icons-right todo-item"
    class:todo-item--checked={todo.checked}>
    <input
      class="input"
      type="text"
      {placeholder}
      bind:value={todo.text} />
    <span class="icon is-small is-left">
      <i class="fas fa-check" />
    </span>
    <span class="icon is-small is-right">
      <i class="fas fa-trash" />
    </span>
  </div>
</div>

TodoList 내부에서 각 아이템들을 렌더링하는 TodoItem 컴포넌트입니다. TodoList에서 동적으로 내려준 각각의 아이템들 todo 를 Prop으로 받고 있지요. 스타일은 Bulma의 기본 속성을 오버라이딩 한 것이기 때문에 적당히 읽고 지나가도 됩니다.

여기에서 짚고 넘어가면 좋을 게 있다면 동적 클래스값 바인딩 정도가 있겠네요. class:name={value} 라는 문법으로 클래스를 동적으로 설정할 수 있습니다. 위 코드는 todo가 체크된 상태라면, todo-item--checked라는 클래스를 활성화시키는 코드입니다. todo-item--checkedopacity: 0.4를 설정하는 스타일이므로, 약간 반투명해지겠군요.

값 바인딩은 input이나 select 같은 엘리먼트에 bind:value 키워드를 이용해 값을 바인딩해줍니다.

Todo

최종적으로 TodoList 컴포넌트를 출력하게 되면 요렇게 나타나게 됩니다.

아이템 수정하고 삭제하기

<script>
  import { createEventDispatcher } from 'svelte';
  const dispatch = createEventDispatcher();

  export let todo;
</script>

<button on:click="{() => dispatch('remove', todo.id)}">
  삭제하기
</button>

<SomeComponent on:remove={onHandleRemove}/>

사실 공식 튜토리얼에는 마치 Vue의 $emit 문법처럼, 하위 컴포넌트에서 상위 컴포넌트로 이벤트를 전달할 때 dispatch 문법을 쓰라고 되어 있어서 이런 방식으로 구현해야하나 생각을 했었습니다. 그런데 그렇게 구현하다보니 이벤트 처리를 위한 코드가 템플릿에 덕지덕지 붙게 되어서 장황해져고 좀 별로더라구요. 그러다가 좀 더 React스러운 방식으로 작성된 예제 코드를 봤는데, 훨씬 코드 길이도 짧고 가독성이 좋아서 Vue보다는 React스러운 방식으로 작성해봤습니다.

우선 체크하는 경우, 글자 수정하는 경우, 그리고 삭제하는 경우의 메소드를 아래처럼 최상위 컴포넌트 App 에서 작성해줍니다.

// App.svelte
let onHandleCheck = (id) => {
  const index = todos.findIndex((todo) => todo.id === id);
  todos[index]["checked"] = !todos[index]["checked"];
};

let onHandleRemove = (id) => {
  todos = todos.filter((todo) => todo.id !== id);
};

let onHandleModify = (id, text) => {
  const index = todos.findIndex((todo) => todo.id === id);
  todos[index]["text"] = text;
};

여기서 React스러운 것이 또 하나 있습니다. 배열을 수정할 때는 push()pop(), splice()처럼 원본 배열을 직접 수정하지 않고, 재할당하여야 합니다. Svelte에서는 재할당을 통해서만 반응적인 데이터가 유지됩니다. 이는 객체에서도 마찬가지입니다.

이제 작성한 각각의 메소드들을 Prop으로 하위 컴포넌트에 넘겨줍니다. 여기서는 TodoList 컴포넌트를 거쳐 TodoItem 까지 전달해야겠죠. 자칫 Prop의 단계가 깊어지면 곤란해질 수도 있는데(Prop drilling), 지금은 두 단계밖에 뎁스가 안 되니까 직접 전달해보겠습니다.

<div class="column">
  <h1 class="title">Todo List</h1>
  <h2 class="subtitle">Built with Svelte, Bulma</h2>
   <!-- 이 곳에 TodoInput 넣기 -->
  <TodoList {todos} {onHandleCheck} {onHandleRemove} {onHandleModify} />
</div>

아래는 TodoList의 모습입니다. 받은 Prop을 다시 TodoItem 컴포넌트에 넘겨줍니다.

<script>
  import TodoItem from "./TodoItem.svelte";

  export let todos;
  export let onHandleCheck;
  export let onHandleRemove;
  export let onHandleModify;

  $: remaining = todos.filter(t => !t.checked).length;
</script>

<div>
  {#if remaining}
    <p>남은 일이 {remaining}개 남았어요</p>
  {:else}
    <p>남은 일이 없어요</p>
  {/if}
  <br />
  {#each todos as todo (todo.id)}
    <TodoItem {todo} {onHandleCheck} {onHandleRemove} {onHandleModify} />
  {/each}
</div>

TodoItem 컴포넌트에서는 상위 컴포넌트에서 Prop으로 전달받은 메소드를 이용해, 버튼이 클릭되거나 인풋 이벤트가 발생했을 때에 발생하는 DOM 이벤트에 이벤트 리스너를 달아서 메소드를 실행시키게 해 줍니다.

<script>
  export let todo;
  export let onHandleCheck;
  export let onHandleRemove;
  export let onHandleModify;

  let placeholder = "할 일을 입력해주세요";
</script>

<style lang="scss">
/* 생략 */
</style>

<div class="field">
  <div
    class="control has-icons-left has-icons-right todo-item"
    class:todo-item--checked="{todo.checked}"
  >
    <input
      class="input"
      type="text"
      {placeholder}
      bind:value="{todo.text}"
      on:keyup="{e => onHandleModify(todo.id, e.target.value)}"
    />
    <span
      class="icon is-small is-left"
      on:click="{() => onHandleCheck(todo.id)}"
    >
      <i class="fas fa-check"></i>
    </span>
    <span
      class="icon is-small is-right"
      on:click="{() => onHandleRemove(todo.id)}"
    >
      <i class="fas fa-trash"></i>
    </span>
  </div>
</div>

메소드가 잘 부착이 되면 아래처럼 동작해야 합니다.

UD 체크 상태를 변경하고, 삭제하고, 텍스트를 수정할 수 있게 되었습니다

아이템 추가하기

// App.svelte
let todoInput = "";

let onHandleAdd = () => {
  if (!todoInput) {
    return;
  }

  const newTodo = {
    id: ++lastId,
    checked: false,
    text: todoInput,
  };

  todos = [...todos, newTodo];

  todoInput = "";
};

App 컴포넌트에서는 새로 추가할 아이템의 텍스트를 받는 todoInput 변수를 선언했고, 추가하기 버튼을 클릭했을 때 실행될 메소드도 정의해놓았습니다.

$: lastId = todos[todos.length - 1]?.id || 0;

todos 에 새로운 아이템이 들어갈 때는 항상 마지막에 들어가기 때문에, 마지막 인덱스 번째의 id를 기준으로 반응적으로 계산되게 했습니다. 아이템을 모두 삭제하는 경우에는 옵셔널 체이닝을 이용해 0부터 다시 인덱싱이 되게 했습니다.

let onHandleKeyup = e => {
  todoInput = e.target.value;

  if (e.keyCode === 13) {
    onHandleAdd();
  }
};

인풋 박스에서 엔터키를 눌렀을 때에도 아이템 추가 메소드가 실행되게 하는 로직입니다.

<!-- App.svelte -->
<div class="column">
  <h1 class="title">Todo List</h1>
  <h2 class="subtitle">Built with Svelte, Bulma</h2>
  <TodoInput {todoInput} {onHandleKeyup} {onHandleAdd} />
  <TodoList {todos} {onHandleCheck} {onHandleRemove} {onHandleModify} />
</div>

마찬가지로 TodoInput 컴포넌트를 만들어주고, import 해 준 후 Prop을 넘겨줍니다.

<script>
  export let todoInput = "";
  export let onHandleKeyup;
  export let onHandleAdd;

  let placeholder = "할 일을 입력해주세요";
</script>

<div class="field has-addons">
  <div class="control" style="width: 100%;">
    <input
      class="input"
      type="text"
      {placeholder}
      on:keyup="{e => onHandleKeyup(e)}"
      bind:value="{todoInput}"
    />
  </div>
  <div class="control">
    <button class="button is-info" on:click="{() => onHandleAdd()}">
      추가하기
    </button>
  </div>
</div>

그리고 적절하게 input 컴포넌트와 button 컴포넌트를 만들어준다면…?

완성본 완성!

배포하기

저는 /public 디렉토리에 빌드된 결과물을 Github Page에 배포하는 쉘 스크립트를 썼습니다. gh-pages 브랜치에 푸시를 하게 되면 Github에서 자동으로 배포가 됩니다.

# abort on errors
set -e

# build
npm run build

# navigate into the build output directory
cd public

git init
git add -A
git commit -m 'deploy'

git push -f git@github.com:wormwlrm/svelte-todo-list.git master:gh-pages

cd -

다만 리포지터리에서 별다른 설정 없이 배포하게 되면 자동적으로 경로가 wormwlrm.github.io/svelte-todo-list/인데, 빌드된 Svelte 파일의 CSS와 JS는 참조하고 있는 경로가 절대 경로, 즉 루트 디렉토리를 보고 있어서 결과가 제대로 안 나오더군요.

<!-- public/index.html -->
<head>
  <!-- `/` 로 참조하고 있는 것들을 `./`로 바꿔주어야 잘 나옵니다. -->
  <link rel="icon" type="image/png" href="./favicon.png" />
  <link rel="stylesheet" href="./global.css" />
  <link rel="stylesheet" href="./build/bundle.css" />
  <script defer src="./build/bundle.js"></script>
</head>

그래서 위처럼 상대 경로를 바라보게 해주었고, 결과가 잘 나오더군요.

느낀 점

오랜만에 새로운 기술을 공부해서 써보니까 재밌었습니다. React나 Vue에서 장점을 취해 적절히 짬뽕되어 있는 듯했는데(Angular 감성은 사실 못 찾겠…), 그 덕분인지 하나의 프레임워크에 대한 경험이 있다면 배우기가 꽤 쉬운 것 같다고 생각했습니다. 튜토리얼도 정말 잘 되어 있었구요.

그래도 뭐니뭐니해도 가상 DOM 없이 순수 JavaScript 파일로 컴파일되어서, 별도의 프레임워크를 로드할 필요가 없다는 것이 매우 큰 장점인 듯 합니다. 그 때문인지 문법이 조금 낯선 게 있긴 한데, 저는 한 몇 일 코드 보다 보니까 익숙해지더군요.

아쉬운 점을 꼽자면… 역시 레퍼런스인듯 합니다. 물론 자료가 없는 건 아니지만, React나 Vue에 비하면 아직 생태계가 많이 좁은 듯 합니다. 특히 국내 자료로 한정짓는다면… 인지도가 바닥을 기는 수준인 것 같더군요. 공식적인 라우트 도구가 없다는 것도 좀 의문이었습니다. 그래서 프로덕션 레벨에서 사용하기엔 좀 그렇고, 토이 프로젝트로는 충분히 해 볼만 한 것 같았습니다.

완성본 레트로 감성의 타자연습교실

마지막으로 Svelte로 만든 잘 만든 웹사이트 하나 소개해보고 글을 마무리지으려 합니다. 온라인타자교실이라는 사이트인데, 잘 만들어서 회사에서 점심시간 때 구경했던 기억이 나네요.

아무튼 Svelte 공부는 일단 여기까지 해 보는 걸로…!

참고자료