thisyujeong.dev

토이 프로젝트로 시작하는 Svelte 5 사용기
May 5, 2025
17 min read
Flippix 프로젝트 미리보기 Flippix 프로젝트 미리보기

시작하며

State of JS 2024 Front-end Frameworks Interest 에 따르면 Svelte는 프론트엔드 프레임워크 분야에서 긍정적인 평가를 받으며, 사용자 수가 꾸준히 증가하고 있다고 발표했습니다.

사용률 변화 그래프를 살펴보면 React가 1위, Vue가 2위, Angular가 3위, 그리고 4위를 Svelte가 차지했습니다. 특히 주목할 점은 Svelte가 개발자들의 만족도·관심도 부문에서는 1위를 기록했다는 점입니다.

이처럼 많은 개발자들이 높은 관심도를 보이는 이유는 다음과 같은 특징 덕분인이지 않을까 싶습니다.

Svelte: attractively thin, graceful and stylish

  1. 컴파일러 기반의 프레임워크(성능 최적화)
    Svelte는 React나 Vue와 달리 런타임에서 가상 DOM을 사용하지 않습니다. 작성된 코드를 빌드 타임에 컴파일하여 Vanilla JavaScript로 변환하기 때문에 런타임 오버헤드가 거의 없고 번들 크기가 줄어듭니다.
  2. 직관적인 문법
    간결하고 직관적인 상태 관리와 반응성 코드를 통해 복잡한 상태 관리 라이브러리 없이도 깔끔한 코드를 작성할 수 있습니다. Svelte에서는 이러한 반응성 문법을 ‘rune(룬, $)’이라고 부릅니다.
  3. 낮은 학습 곡선
    HTML, CSS, JS에 가깝게 작성할 수 있어 진입 장벽이 낮습니다. 하나의 .svelte 파일에 HTML, CSS, JS가 캡슐화되어 있어 모듈화와 스타일 스코프 관리가 쉽습니다.

이러한 특징들 덕분에 리액트 중심의 개발 환경에 익숙한 저 또한 Svelte를 학습해보고 싶다는 생각이 들었습니다. 미루고 미루던 학습을 이제서야 실행에 옮기게 되었고, 이를 위해 Svelte 기반의 토이 프로젝트를 제작했습니다.

이 글은 토이 프로젝트의 자세한 코드구현에 대한 내용보다는 토이 프로젝트를 제작하면서 사용해본 Svelte 기술들을 중심으로 작성하였습니다.


프로젝트 구상

결과물 미리보기: flippix-clock.vercel.app/
소스코드(깃허브): https://github.com/thisyujeong/flippix
프로젝트의 목적: Svelte 5 학습

본격적으로 제작에 들어가기 전, 주제를 선정하고 어떤 기능을 추가할지 구상합니다.

프로젝트명은 Flippix로, 플립 시계를 웹상에서 디지털로 구현한 프로젝트입니다. 이름은 Flip + Pixel의 합성어에서 따왔습니다. 일반 시계 모드와 타이머 모드로 나뉘며, 여러 부가 기능을 포함합니다.

플립 시계를 주제로 선정한 이유는 예전에 봤던 플립 애니메이션 구현 글이 떠올랐고, 이 주제라면 스벨트 학습뿐만 아니라 store 같은 기능도 충분히 익힐 수 있겠다고 판단했기 때문입니다.

개발 환경

  • Framework: Svelte 5 + SvelteKit
  • Language: TypeScript
  • Style: SCSS
  • State Management: Svelte Store

주요 기능

  • 시계 모드: 현재 시간을 디지털 플립 시계의 형태로 실시간 표시합니다.
  • 타이머 기능:
    • URL Query string으로 타이머 시간 설정 (?timer=1:30:20)
    • 예: ?timer=1:30:20 → 1시간 30분 20초
    • 일시정지(Pause), 재개(Resume), 재시작(Restart) 기능
    • 타임오버 시 재시작 또는 시계 모드로 전환

부가 기능

  • 전체화면 모드: 버튼 클릭으로 전체화면 진입, 불필요한 UI 요소 숨김
  • 진행률 상태 표시(progress bar): 타이머 모드 사용 시 현재 진행률 시각화
  • 버튼 인터랙션 사운드: 일시정지/재개/재시작 등의 액션에 사운드 효과 제공
    • 주요 액션(일시정지/재개/재시작) 시 사운드 효과를 제공합니다.
  • 테마 전환: 라이트/다크 모드 지원, prefers-color-scheme 감지
  • Toast 메세지 알림: 상태 변화나 완료 이벤트 발생 시 알림 메세지를 제공

플립 카드 구현

플립시계의 가장 작은 단위가 뒬 숫자 카드 컴포넌트를 구현했습니다.

플립 카드 설계 구조

플립 애니메이션은 다음 구조로 설계됩니다.

  • upper → 고정된 카드의 상단 패널 (현재 숫자 표시)
  • lower → 고정된 카드의 하단 패널 (이전 숫자 표시)
  • middle → 중간에서 플립되는 패널
    • middle front → 앞면, 플립되기 전에 이전 숫자 표시
    • middle back → 뒷면, 플립된 후의 현재 숫자 표시

HTML로 나타내면 다음과 같이 나타낼 수 있습니다.

<div class="panel">
  <div class="panel-inner">
    <!-- 고정 패널(upper, lower) -->
    <div class="panel-container">
      <div class="panel-upper">현재 숫자 upper 부분</div>
      <div class="panel-lower">이전 숫자 lower 부분</div>
    </div>
    <!-- 플립 애니메이션이 적용될 middle 패널 -->
    <div class="panel-middle">
      <div class="panel-middle__front">middle 이전 숫자(front)</div>
      <div class="panel-middle__back">middle 현재 숫자(back)</div>
    </div>
  </div>
</div>

플립 카드의 핵심 동작은 숫자가 바뀔 때 middle 패널이 회전 애니메이션을 통해 이전 숫자에서 현재 숫자로 전환되는 동작입니다.

  1. upper와 lower 패널은 고정된 패널로 upper는 현재 숫자의 상단, lower는 이전 숫자의 하단만 담당합니다. 실제로 middle 패널만 움직입니다.
  2. 외부에서 전달된 현재 숫자 정보를 props를 통해 curr 값으로 저장합니다.
  3. 컴포넌트가 마운트될 때 prev 상태를 curr로 맞추어 초기 동기화를 진행합니다.
  4. 이후 curr 값이 갱신되는 것을 감지해 middle 패널의 플립 애니메이션을 실행하고
  5. 애니메이션이 종료되면 prevcurr 값을 새로 기록합니다.

1. 변수 정의 및 초기화 ($props, $state)

$props()
Svelte 4 버전에서는 export let 으로 props 값을 가져왔지만, Svelte 5에서는 룬 $props 를 통해 한번에 받아올 수 있게 되었습니다.

$state()
기존 Svelte 4 버전에서는 let value = 0; 으로 작성했습니다. Svelte 5에서 반응형 상태임을 좀 더 직관적으로 알 수 있게 되었습니다.

<script lang="ts">
  interface PanelProps {
    value: number;
  }
 
  let { value: curr }: PanelProps = $props();
  let prev: number = $state(0);
 
  let flipped = $state(false);
  let flipper: HTMLDivElement;
 
  onMount(() => (prev = curr));
</script>
 
<!-- 플립 애니메이션이 적용될 middle 패널 -->
<div class="panel-middle" class:flipped bind:this="{flipper}"></div>
  • curr: 외부에서 props를 통해 현재 숫자의 값을 전달받음
  • flipped: 플립 여부를 체크해 추후 클래스를 부여해 CSS를 처리하기 위한 상태 변수
  • flipper: 애니메이션을 적용할 엘리먼트를 참조
  • onMount: 컴포넌트가 마운트될 때 prev를 현재값(curr)으로 초기화

2. 애니메이션 및 상태 업데이트 ($effect)

$effect()
Svelte 5에서 새로 도입된 Effect Rune은 반응성을 처리하기 위한 메소드로 React로 치면 useEffect와 같은 훅이라고 볼 수 있습니다. $effect 스코프 내에 참조된 반응형 변수가 업데이트될 때마다 실행됩니다.

<script lang="ts">
  $effect(() => {
    if (curr === prev) return;
    flipper.style.transition = 'transform 0.4s ease-in-out';
    flipped = true;
 
    const flipping = setTimeout(() => {
      flipped = false;
      flipper.style.transition = 'transform 0s';
      prev = curr;
    }, 450);
 
    return () => clearTimeout(flipping);
  });
</script>
 
<!-- 플립 애니메이션이 적용될 middle 패널 -->
<div class="panel-middle" class:flipped bind:this="{flipper}"></div>
 
<style lang="scss">
  .panel-middle {
    transform-origin: center bottom;
    transform-style: preserve-3d;
 
    // flipped 클래스가 추가되면 transition 실행
    &.flipped {
      transition: rotateX(-180deg);
    }
  }
</style>
  1. 외부에서 전달받는 curr 값이 갱신되면 curr를 참조하는 $effect가 실행됩니다

  2. if (curr === prev) return; 갱신 값이 같다면 아무것도 실행하지 않습니다.

  3. 값이 다르다면

    1. 애니메이션이 실행되기 전, flipper 의 트랜지션 속도를 0.4초로 조절하고
    2. flipped = true; middle 패널에 flipped 클래스 부여하면서 애니메이션이 실행됩니다.
    3. 0.4초 후 애니메이션이 종료되므로 setTimeout을 여유 시간을 두고 0.45초 후 상태를 초기화합니다.
    4. 동시에 prev 값으로 curr 값으로 갱신합니다.
    5. 컴포넌트가 언마운트될 때 setTimeout도 함께 정리해줍니다.
      return () => clearTimeout(flipping);
  4. 마지막으로 HTML에 curr 값과 prev 값을 반영합니다.

<div class="panel">
  <div class="panel-inner">
    <div class="panel-container">
      <!-- 고정 패널(upper, lower) -->
      <div class="panel-upper">
        <span class="panel-digit">{curr}</span>
      </div>
      <div class="panel-lower">
        <span class="panel-digit">{prev}</span>
      </div>
      <!-- / 고정 패널(upper, lower) -->
    </div>
 
    <!-- 플립 애니메이션이 적용된 middle 패널 -->
    <div class="panel-middle" class:flipped bind:this="{flipper}">
      <div class="panel-upper panel-middle__front">
        <span class="panel-digit">{prev}</span>
      </div>
      <div class="panel-lower panel-middle__back">
        <span class="panel-digit">{curr}</span>
      </div>
    </div>
    <!-- 플립 애니메이션이 적용된 middle 패널 -->
  </div>
</div>

이렇게 제작된 Panel 컴포넌트를 조합하여 시, 분, 초를 표현했습니다.

추가로 시간에 대한 정보는 store로 관리하고, 시간이 갱신될 때마다 갱신된 값은 Panel 컴포넌트로 전달되고 자동으로 플립 애니메이션이 실행되는 플립 시계가 됩니다.


Svelte Store - writable

Reference: svelte/store

set, update

타이머 모드와 일반 시계모드를 구현하면서 타이머에 대한 상태를 전역으로 관리해야할 필요가 있었습니다.

예를 들어 다음 코드와 같이 현재 모드, 현재 시간, 설정된 타이머 시간, 타이머로 설정된 전체 시간(초), 진행률, 타이머 종료 여부 .. 등의 상태를 효과적으로 관리하기 위해 store를 적극 활용했습니다.

import { writable } from 'svelte/store';
 
export const isTimer = writable(false);

writable로 정의하여 외부에서 사용하거나 값을 업데이트 할 수 있습니다.

writable은 set, update 메서드를 가진 객체로 생성됩니다.

  • set: 설정할 값을 하나의 인수로 받는 메서드
  • update: 기존 스토어값을 콜백 함수의 인수로 받아 스토어에 설정할 새 값을 반환합니다.
const isTimer = wriable(false);
 
isTimer.set(true);
isTimer.update((prevState) => !prevState);

정의한 store 값은 $ 문법으로 쉽게 사용할 수 있습니다.

<div>{$isTimer ? 'Timer mode' : 'Clock mode'}</div>

subscribe

추가로 Toast 컴포넌트를 구현하면서 wirable storeupdatesubscribe 메서드를 사용했습니다.

subscribe: 스토어의 변화를 구독하고 값이 변경될 때마다 콜백이 실행됩니다.

import type { Toast, ToastOption } from '@/types';
import { writable } from 'svelte/store';
 
let id = 0;
 
function createToastStore() {
  const { subscribe, update } = writable<Toast[]>([]);
 
  const show = ({ text, status = 'info', timeout = 2000 }: ToastOption) => {
    const toastId = ++id;
    const toast: Toast = { id: toastId, status, text, timeout };
 
    update((toasts) => [...toasts, toast]);
 
    setTimeout(() => {
      update((toasts) => toasts.filter((t) => t.id !== toastId));
    }, timeout);
  };
 
  return {
    subscribe,
    show,
  };
}
 
export const toastStore = createToastStore();
  1. 외부에서 toastStore 의 변경을 감지하여 추가 로직을 구성할 수 있도록 subscribe 메서드를 리턴
  2. 새 Toast를 추가하는 함수로 show()를 리턴
    1. 고유 ID를 만들어 새 Toast 객체를 생성하고 update를 통해 Toast 배열에 추가
    2. timeout이 되면 해당 토스트를 배열에서 제거

Svelte 내장 애니메이션 in: and out:

Svelte에서는 in:, out: 템플릿을 사용해 간단히 트랜지션을 적용할 수 있었습니다.

복잡한 애니메이션이 아니라면 별도 CSS나 라이브러리 없이도 충분히 자연스러운 UI를 만들 수 있어 정말 편한 기능이었습니다. 😮

Svelte 내장 애니메이션이 적용된 Toast 컴포넌트
<div class={['toast']} in:fly={{ y: 45, duration: 200 }} out:fade={{ duration: 300 }}>
  <div class="toast-status">..</div>
  <p class="toast-text">{text}</p>
</div>

마무리

Svelte 5가 출시되면서 기존의 특정 문법들(slot, export let, $$ 등)은 레거시로 밀어내고 새로운 문법(대표적으로 $)을 도입했습니다.

5 버전만 경험해봤지만 확실히 직관적이고 적은 코드로 개발할 수 있다는 점은 꽤나 만족스러웠습니다. 특히 문법이 간단하고 html, css, js 기반의 프레임워크라서 그런지 학습 곡선이 낮았습니다.

반면, 학습하면서 느낀 몇가지 단점도 있었습니다.

  1. 불안정한 TypeScript 지원. 요새 프로젝트를 진행하다보면 기본 스택으로 자리 잡은 듯한 타입스크립트가 스벨트의 잦은 업데이트로 속도를 따가가지 못하는 모양
  2. 작은 생태계와 커뮤니티. 문법이나 이슈에 대해서 구글링해보면 자료가 생각보다 적었습니다.

비록 현재는 불안정하고 작은 생태계라는 단점이 있지만, 서서히 안정화된다면 프레임워크 3대장(리액트, 뷰, 앵귤러)에 대항할 정도의 프론트엔드 프레임워크로 자리잡을 수 있을지 궁금해지네요😮

자세한 코드는 깃허브에서 확인할 수 있습니다.

React Props로 컴포넌트 반응형 사이즈 지정하기
다음 포스트가 없습니다.