2024년 10월 20일 Svelte 5가 출시되었다. 기존 회사에서 Svelte 4를 사용하여 프로젝트를 진행 중이었는데 이번에 Svelte 5로 마이그레이션을 하게 되었다. 이에 따라 공부를 하면서 한글로 정리된 정보는 많이 없는 것 같아 벨로그에 Svelte5가 되며 달라진 점
을 정리해 보려고 한다.
"Svelte 5의 핵심 변화"
Svelte 5에서는 보다 직관적인 API와 성능 최적화를 목표로 큰 변화를 가져왔다.
- 반응성 모델 변경: $state(), $derived(), $effect() 도입
- Props & 이벤트 개선: $props(), $bindable() 지원
- 컴포넌트 구조 최적화: slot 대신 children props 사용
- 렌더링 최적화: Fine-Grained Reactivity 도입
이제 하나씩 상세히 알아보도록 하자.
반응형 변수일 경우
$state()
를 사용하여 반응형 상태임을 명시한다.
<script>
let count = 0;
</script>
<script>
let count = $state(0);
</script>
비교 항목 | Svelte 4 (기존 방식) | Svelte 5 (새로운 방식) |
---|---|---|
반응성 선언 방식 | let count = 0; (자동 감지) | let count = $state(0); |
변수의 명확성 | 반응형인지 불분명함 | $state() 를 사용하여 명확히 반응형 변수임을 표시 |
렌더링 최적화 | 전체 블록이 다시 실행될 가능성 있음 | 변경된 부분만 업데이트 |
즉, Svelte 5에서는 $state()
를 통해 더 직관적인 상태 관리를 제공하면서, 렌더링 성능도 최적화되었다.
반응형 계산일 경우
$derived()
를 사용하여 명확히 선언한다.
복잡한 연산(여러 개의 반응형 상태를 조합하여 새로운 값을 만들 때)는$derived.by()
를 사용한다.
<script>
let count = 10;
$: doubleCount = count * 2;
</script>
<div>{doubleCount}</div>
<script>
let count = $state(10);
const doubleCount = $derived(count * 2);
let total = $derived.by(() => {
// code...
});
</script>
<div>{doubleCount}</div>
비교 항목 | Svelte 4 (기존 방식) | Svelte 5 (새로운 방식) |
---|---|---|
반응형 계산 방식 | $: 를 사용한 반응형 선언 | $derived() 를 사용하여 명확히 선언 |
실행 방식 | 모든 반응형 값 변경 시 실행됨 | 필요한 값이 변경될 때만 실행됨 |
렌더링 성능 | 전체 블록이 다시 실행될 가능성 있음 | 변경된 부분만 업데이트 |
Svelte 5에서는 $derived()
를 통해 불필요한 재계산을 줄이고, 더 최적화된 반응형 상태 관리를 제공하게 되었다.
$effect()
를 활용해 라이프사이클과 반응성을 더욱 직관적으로 관리할 수 있다.
<script lang="ts">
import { onMount } from "svelte"
onMount(() => {
// code...
});
</script>
<script lang="ts">
$effect(() => {
// code...
});
</script>
Effect Rune
은 Svelte5에서 반응성을 처리하는 새로운 방식 중 하나다.
$effect는 참조된 반응형 변수(State)가 변경될 때마다 실행된다. (Untrack
을 사용하지 않는 이상 😥) 이러한 특성으로 인해 API 호출, DOM 조작 등 사이드 이펙트에만 사용해야 하고, 반응형 변수를 기반으로 새로운 값을 계산하는 용도로 쓰면 안 된다.
<script lang='ts'>
let total = 100;
let spent = $state(0);
let left = $state(total);
$effect(() => {
left = total - spent;
});
$effect(() => {
spent = total - left;
});
</script>
이처럼 작성할 경우 두 $effect가 서로 값을 갱신하면서 무한 루프가 발생할 위험이 있다. Svelte5에서는 이를 방지하기 위해 $derived를 사용하거나 직접 함수로 변경하는 것이 더 적절하다고 말한다.
위 코드를 함수로 변경해 보자.
<script lang='ts'>
let total = 100;
let spent = $state(0);
let left = $state(total);
function updateSpent(e) {
spent = +e.target.value;
left = total - spent;
}
function updateLeft(e) {
left = +e.target.value;
spent = total - left;
}
</script>
변수 spent과 left를 updateSpent()와 updateLeft() 함수에서 직접 갱신하도록 수정해 보았다. 불필요한 반응형 트리거를 없애고 더 직관적인 코드가 되었다.
$effect.pre()
를 사용하면 상태 변경 전에 특정 작업을 수행할 수 있다.
<script lang="ts">
$effect.pre(() => {
// code...
});
</script>
$effect.pre()
는 Pre-Effect로, 일반적인 $effect보다 앞서 실행되는 반응형 효과를 정의하는 기능이다. 기존 값을 보존해야 하거나 선제적인 처리가 필요한 경우 유용하게 쓸 수 있다.
기능 | $effect | $effect.pre() |
---|---|---|
실행 시점 | 상태가 변경된 후 실행됨 | 상태가 변경되기 직전에 실행됨 |
용도 | 상태 변경 후 후속 작업 (예: API 호출, 로깅) | 상태 변경 전에 선제적으로 실행해야 하는 작업 (예: 기존 값 백업) |
<script lang="ts">
let count = $state(0);
$effect(() => {
console.log(`Count changed to: ${count}`);
});
function increment() {
count++;
}
</script>
이 경우 count가 변경된 이후에 $effect가 실행된다.
<script lang="ts">
let count = $state(0);
let previousCount = $state(0);
$effect.pre(() => {
previousCount = count; // count 변경 전에 기존 값 저장
});
$effect(() => {
console.log(`Count changed from ${previousCount} to ${count}`);
});
function increment() {
count++;
}
</script>
이번에는 count가 변경되기 전에 previousCount를 저장하도록 $effect.pre()를 사용해 보았다. 변경 후 실행되는 $effect에서 이전 값과 새로운 값을 비교 가능하다.
$props()
를 사용해 기존 export let으로 가져오던 props를 한 번에 쉽게 가져올 수 있다.
<script>
export let name = "";
export let age = null;
export let favouriteColors = [];
export let isAvailable = false;
</script>
<p>My name is {name}!</p>
<p>My age is {age}!</p>
<p>My favourite colors are {favouriteColors.join(", ")}!</p>
<p>I am {isAvailable ? "available" : "not available"}</p>
<script lang="ts">
const {
name = '',
age = null,
favouriteColors = [],
isAvailable = false,
} = $props();
</script>
<p>My name is {name}!</p>
<p>My age is {age}!</p>
<p>My favourite colors are {favouriteColors.join(", ")}!</p>
<p>I am {isAvailable ? "available" : "not available"}</p>
기존 export let으로 하나하나 가져오던 props를 $props()를 사용하여 한 번에 가져올 수 있다.
$bindable()
을 사용하여 데이터 양방향 바인딩을 할 수 있다.
<!-- Parent.svelte -->
<UserProfile bind:optionalProp={someValue} />
<!-- UserProfile.svelte -->
<script>
export let optionalProp;
</script>
<input bind:value={optionalProp} />
기존에는 양방향 바인딩을 하기 위해서는 부모 컴포넌트에서 bind:를 붙이고 자식 컴포넌트에서 export let을 붙여야만 했다.
<script>
const { optionalProp = $bindable() } = $props();
</script>
$bindable()
을 사용하면 bind: 없이도 양방향 바인딩이 가능하다. optionalProp은 부모로부터 값을 받을 수 있고, 자식에서도 변경할 수 있다.
$inspect
는 지정한 변수나 상태가 변경될 때마다 해당 값을 콘솔에 출력한다.
<script>
let count = $state(0);
let message = $state('hello');
$: {
console.log(count);
console.log(message);
}
</script>
<button onclick={() => count++}>Increment</button>
<input bind:value={message} />
<script>
let count = $state(0);
let message = $state('hello');
$inspect(count, message); // count 또는 message가 변경될 때마다 콘솔에 출력
</script>
<button onclick={() => count++}>Increment</button>
<input bind:value={message} />
위 코드에서 count나 message가 변경될 때마다 $inspect가 자동으로 해당 값을 콘솔에 출력한다. 이는 개발 중에 상태 변화를 추적하는 데 유용하며, 프로덕션 빌드에서는 자동으로 제거되어 성능에 영향을 주지 않는다.
$inspect는 기본적으로 console.log를 사용하여 값을 출력하지만,
.with()
메서드를 통해 커스텀 콜백 함수를 지정할 수 있다.
<script>
let count = $state(0);
$inspect(count).with((type, count) => {
if (type === 'update') {
console.trace('Count updated:', count); // 상태 변경 시 스택 트레이스 출력
}
});
</script>
<button onclick={() => count++}>Increment</button>
위 예시에서 count가 변경될 때마다 스택 트레이스를 포함한 커스텀 메시지가 콘솔에 출력된다. 이를 통해 상태 변경의 출처를 쉽게 추적할 수 있다.
$inspect.trace()
는 함수 내부에서 사용되어, 해당 함수가 실행될 때마다 어떤 반응형 상태의 변경으로 인해 실행되었는지를 콘솔에 출력한다.
<script>
let count = $state(0);
function increment() {
$inspect.trace();
count += 1;
}
</script>
<button onclick={increment}>Increment</button>
increment 함수 내에 $inspect.trace()를 추가하면, count의 변경으로 인해 함수가 실행될 때마다 콘솔에 어떤 상태 변경이 함수 실행을 트리거했는지에 대한 정보가 출력된다.
부모 컴포넌트의 상태나 메서드에 접근할 때
$host()
를 사용하여 더 직관적으로 데이터를 공유할 수 있다.
<!-- Parent.svelte -->
<script>
import Child from './Child.svelte';
let message = "Hello from Parent!";
</script>
<Child message={message} />
<!-- Child.svelte -->
<script>
export let message;
</script>
<p>부모 메시지: {message}</p>
<!-- Parent.svelte -->
<script>
import Child from './Child.svelte';
let message = "Hello from Parent!";
</script>
<Child />
<!-- Child.svelte -->
<script>
let { message } = $host; // 부모 컴포넌트의 message 가져오기
</script>
<p>부모 메시지: {message}</p>
비교 항목 | Svelte 4 (기존 방식) | Svelte 5 (새로운 방식) |
---|---|---|
부모 상태 접근 | Props(export let ) 사용 | $host() 사용 |
데이터 전달 방식 | 부모 → 자식으로만 가능 | 부모의 데이터를 직접 참조 가능 |
렌더링 최적화 | Props 변경 시 전체 업데이트 가능 | 변경된 부분만 반영 |
사용 편의성 | export let 을 매번 선언해야 함 | $host() 로 간편하게 접근 가능 |
즉, Svelte 5에서는 $host()
를 통해 부모 상태 및 메서드 접근이 더 쉬워지고, Props 선언 없이도 데이터를 공유할 수 있다.
슬롯이 children이라는 prop으로 전달된다.
@render children()
을 사용해 이를 렌더링한다.
<button>
<slot></slot> <!-- 슬롯을 여기에 삽입 -->
</button>
Svelte 4에서는 태그를 사용해서 슬롯 콘텐츠를 렌더링했다.
<script>
let { children } = $props(); // 슬롯을 props로 가져옴
</script>
<button>
{@render children()} <!-- 슬롯 내용을 렌더링 -->
</button>
Svelte 5에서는 슬롯이 children이라는 prop으로 전달된다. 그리고 @render children()
을 사용해 렌더링한다. 기존 방식보다 더 명확하고 함수형 컴포넌트 스타일에 가까운 구조다.
#snippet
은 Svelte 5에서 기존 slot을 대체하는 기능으로, 반복적으로 사용될 수 있는 UI 조각을 정의하는 역할을 한다.
@render snippetName()
을 사용하면 스니펫을 원하는 위치에서 쉽게 렌더링 가능하다.
{#each images as image}
{#if image.href}
<a href={image.href}>
<img src={image.src} />
</a>
{:else}
<img src={image.src} />
{/if}
{/each}
이 코드에서는 images 배열을 순회하면서 각각의 image를 처리하고 있다. image.href가 존재하면 이미지를 a 태그 안에 넣고, 존재하지 않으면 그냥 img 태그만 렌더링된다. 이렇게 하면 중복되는 img 태그가 여러 번 반복된다.
{#snippet figure(image)}
<img src={image.src} />
{/snippet}
{#each images as image}
{#if image.href}
<a href={image.href}>
{@render figure(image)}
</a>
{:else}
{@render figure(image)}
{/if}
{/each}
figure(image)라는 스니펫을 정의하고, image.src를 사용해 img 태그를 렌더링하도록 했다. 이 스니펫을 사용하면, 반복적인 img 태그를 직접 작성할 필요 없이 @render figure(image)
를 호출하여 재사용할 수 있다. 따라서 중복되는 img 태그를 스니펫으로 묶어 가독성이 좋아지고 유지보수가 쉬워진다!
on:click이 아니라 HTML의 기본
onclick
속성을 그대로 사용한다.
<script>
let count = 0;
function handleClick() {
count++;
}
</script>
<button on:click={handleClick}>
clicks: {count}
</button>
기존에는 on ":" click을 사용했다.
<script>
let count = $state(0);
function onclick() {
count++;
}
</script>
<button {onclick}>
clicks: {count}
</button>
Svelte 5에서는 on:click이 아니라 HTML의 기본 onclick
속성을 그대로 사용한다. 또한 이벤트 핸들러를 함수로 분리한 뒤 {}를 사용해 속성으로 바로 전달이 가능하다.
"세밀한 반응성을 제공한다."
불필요한 렌더링을 최소화하면서 필요한 부분만 업데이트하는 방식으로 성능을 최적화한다.
비교 항목 | Svelte 4 (기존 방식) | Svelte 5 (새로운 방식) |
---|---|---|
반응형 상태 선언 | $: 를 사용한 반응형 선언 | $state() 를 사용한 세밀한 상태 관리 |
렌더링 범위 | 상태 변경 시 전체 블록 실행 | 변경된 부분만 업데이트 |
최적화 수준 | 비교적 전체적으로 반응성 적용 | 최소한의 변경만 감지하여 업데이트 |
렌더링 성능 | 컴포넌트 전체 리렌더링 가능 | 필요한 부분만 재렌더링 |
<script>
let todos = [];
function remaining(todos) {
console.log('recalculating');
return todos.filter((todo) => !todo.done).length;
}
function addTodo(event) {
if (event.key !== 'Enter') return;
todos = [
...todos,
{
done: false,
text: event.target.value
}
];
event.target.value = '';
}
</script>
Svelte 4의 코드를 살펴보자. remaining() 함수는 todos가 변경될 때마다 실행된다. todos 배열이 새로운 값으로 할당되면서 컴포넌트 전체가 다시 렌더링될 가능성이 높다. 할 일 목록에서 특정 항목만 변경되더라도 전체 todos 배열을 다시 검사해야 되기 때문에 성능 저하가 발생한다.
<script>
let todos = $state([]);
let remaining = $derived(() => {
console.log('recalculating');
return todos.filter((todo) => !todo.done).length;
});
function addTodo(event) {
if (event.key !== 'Enter') return;
todos = [
...todos,
{
done: false,
text: event.target.value
}
];
event.target.value = '';
}
</script>
todos를 $state([])
로 변경하여 배열 전체가 아닌 개별 항목이 변경될 때만 반응형 업데이트가 발생한다. 또한 remaining을 $derived()
로 변경하여, todos가 변경될 때마다 remaining이 전체를 다시 계산하지 않고 필요한 부분만 업데이트 한다. 전체 렌더링이 아닌 최소한의 업데이트만 수행하는 방식으로 성능이 개선된 것이다.
지금까지 Svelte 5가 되면서 Svelte 4와 달라진 점들에 대해 살펴보았다. 여러 변화들이 있지만 이전보다 세밀하게 상태 관리를 할 수 있다는 것이 장점이지 않을까 싶다. 애증의 스벨트.. 기는 하지만 파면 팔수록 재미있는 건 사실이다. 앞으로도 화이팅해보자고. 😎
정리가 잘되어 있네요 잘보고 갑니다