Svelte 4에서 5로 옮길 때: Runes 마이그레이션 전략, 성능 튜닝, SvelteKit load 연동

이 글의 핵심

Runes는 Svelte 5에서 반응성을 “컴파일러가 이해하는 특별한 구문”으로 명시하는 방식입니다. $state·$derived·$effect의 역할, 레거시 스토어·라벨 기반 반응성과의 차이, 마이그레이션 절차, SvelteKit과의 조합, 성능 튜닝까지 한 번에 정리합니다.

이 글의 핵심

Svelte 5의 Runes(룬)는 반응성을 컴파일러의 암묵적인 규칙이 아니라 코드에 드러나는 구문으로 바꾼 설계 전환입니다. 이전 버전에서는 컴포넌트 최상위 let 변수에 대한 할당이 자동으로 반응성에 연결되었지만, 이제는 $state·$derived·$effect 등으로 의도를 명시합니다. 그 결과 컴포넌트, .svelte.js 모듈, 클래스 필드 어디서든 같은 반응성 규칙이 적용되고 타입 추론과 도구 지원도 나아졌습니다. 이 글은 Svelte 3·4 경험이 있고 기존 코드를 Runes로 옮기려는 독자를 기준으로 씁니다.


Runes란 무엇인가

Runes는 Svelte 5에서 반응형 상태·파생 값·부수 효과를 선언하기 위한, 컴파일러가 특별하게 처리하는 $ 접두사 구문입니다. 함수 호출처럼 보이지만 import할 필요가 없고 일반 JavaScript 함수가 아닙니다. Svelte 컴파일러가 이를 해석해 의존성 추적과 업데이트 스케줄링 코드로 변환하므로, .svelte 파일의 <script>나 .svelte.js/.svelte.ts 모듈 안에서만 쓸 수 있습니다.

$state: 반응형 상태

$state는 반응형 상태를 만듭니다. 원시값을 넣으면 그 변수 자체가 반응형이 되고, 일반 객체와 배열을 넣으면 깊은 프록시로 감싸져 속성 단위로 변경이 추적됩니다. 클래스 인스턴스나 Map처럼 일반 객체가 아닌 값은 프록시로 감싸지지 않으므로, 내부 변경을 추적하려면 클래스 필드에 $state를 쓰거나 svelte/reactivity의 SvelteMap 등을 사용해야 합니다.

<script>
	let count = $state(0);
	let user = $state({ name: 'Ana', score: 0 });

	function increment() {
		count += 1;
		user.score += 1;
	}
</script>

<button onclick={increment}>
	{user.name}: {count} / score {user.score}
</button>

템플릿이나 $derived·$effect 안에서 count, user.score를 읽으면 그 읽기가 의존성으로 등록되고, 값이 바뀌면 해당 부분만 다시 갱신됩니다. Svelte 4의 최상위 let 자동 반응성과 목표는 같지만, 반응성의 범위가 $state 호출로 명시된다는 점이 다릅니다.

$derived: 파생 상태

$derived는 다른 반응형 값으로부터 계산되는 값을 표현합니다. 읽은 의존성이 바뀌었을 때만 다시 계산되고, 그렇지 않으면 이전 결과를 재사용합니다.

<script>
	let first = $state('');
	let last = $state('');
	let fullName = $derived(`${first} ${last}`.trim());

	let items = $state(/** @type {number[]} */ ([]));
	let sum = $derived.by(() => {
		let total = 0;
		for (const n of items) total += n;
		return total;
	});
</script>

$derived(expr)과 $derived.by(fn)은 의존성 추적 방식이 같고, 차이는 표현식 하나로 쓸 수 있느냐뿐입니다. 여러 문장이 필요한 계산이면 $derived.by에 함수를 넘깁니다. 두 경우 모두 계산 중에 동기적으로 읽은 반응형 값이 의존성이 됩니다.

$effect: 부수 효과

$effect는 반응형 의존성이 바뀔 때 부수 효과를 실행합니다. DOM 직접 조작, 외부 라이브러리 동기화, 구독 설정처럼 Svelte 바깥 세계와 맞물리는 작업에 사용합니다. effect는 컴포넌트가 마운트된 뒤 브라우저에서만 실행되며, 정리 함수가 필요하면 콜백에서 함수를 반환합니다. 정리 함수는 effect가 다시 실행되기 직전과 컴포넌트가 제거될 때 호출됩니다.

<script>
	let query = $state('');
	let results = $state([]);

	$effect(() => {
		const controller = new AbortController();

		// query를 동기적으로 읽었으므로 의존성으로 등록됨
		fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: controller.signal })
			.then((r) => r.json())
			.then((data) => (results = data))
			.catch((e) => {
				if (e.name !== 'AbortError') console.error(e);
			});

		// 다음 실행 직전에 이전 요청을 취소
		return () => controller.abort();
	});
</script>

<input bind:value={query} />

파생 가능한 값을 $effect로 맞추는 것은 안티패턴입니다. 상태 A로부터 상태 B를 계산할 수 있다면 $derived를 써야 합니다. effect 안에서 상태를 쓰면 한 번 더 갱신이 일어나고, 그 상태를 effect가 다시 읽으면 무한 루프로 이어질 수 있습니다.

그 외 자주 쓰는 Runes

  • $props(): 부모가 넘긴 속성을 받습니다. SvelteKit 페이지의 data도 이것으로 받습니다.
  • $bindable(): 부모가 bind:로 양방향 바인딩할 수 있는 prop을 지정합니다.
  • $inspect: 개발 중 값이 바뀔 때마다 로그를 남깁니다. .with(fn)으로 출력 방식을 바꿀 수 있고, 프로덕션 빌드에서는 아무 동작도 하지 않습니다.
  • $state.raw: 깊은 프록시 없이 값을 보관합니다. 속성을 바꿔도 갱신되지 않으며, 값 전체를 재할당해야 반영됩니다.

Svelte 4 반응성 시스템과 달라진 점

암묵에서 명시로

Svelte 3·4에서는 컴포넌트 최상위 let 선언이 반응형으로 취급되고, $: 라벨 문이 파생 값과 부수 효과를 모두 담당했습니다. 간결하지만 이 규칙은 컴포넌트 최상위에서만 통했고, .js 모듈이나 클래스에서는 스토어를 따로 써야 했습니다. $:는 의존성을 컴파일 시점에 문장에 직접 나타난 변수로만 판단했기 때문에, 함수 안에서 간접적으로 읽은 값은 추적되지 않는 함정도 있었습니다.

Runes에서는 “이 값이 반응형인가”가 $state 등으로 선언되었는지로 결정되고, 의존성은 실행 시점에 실제로 읽은 값으로 추적됩니다. 코드 리뷰 기준이 분명해지고, 재사용 로직을 .svelte.js로 분리할 때도 같은 규칙을 씁니다.

스토어(writable 등)와의 관계

writable·readable·derived 스토어는 여전히 지원되며, 컴포넌트에서 $store 문법으로 구독하는 것도 그대로 됩니다. Svelte 바깥 라이브러리와 연동하거나 기존 코드와 공존할 때 유용합니다. 다만 새 코드에서는 $state와 $derived로 지역·공유 상태를 모델링하고, 스토어 계약(subscribe)이 꼭 필요한 곳에만 스토어를 남기는 식으로 단순화할 수 있습니다.

마이그레이션 전략은 스토어를 Runes로 완전히 옮기는 방법과, 스토어는 유지하고 컴포넌트 쪽만 Runes로 바꾸는 방법이 있습니다. 후자는 위험이 낮고, 전자는 장기적으로 일관성이 높습니다.

export let에서 $props()로

이전에는 export let propName으로 속성을 받았습니다. Svelte 5에서는 다음과 같이 씁니다.

<script>
	let { title, count = 0, ...rest } = $props();
</script>

기본값과 나머지 전개는 일반적인 구조 분해 규칙을 따릅니다. $$props와 $$restProps는 이 나머지 전개로 대체됩니다. TypeScript에서는 let { title }: { title: string } = $props();처럼 타입을 붙입니다.

슬롯은 Snippet으로, 이벤트는 콜백 prop으로

Runes와 함께 컴포넌트 사이의 계약도 바뀌었습니다. <slot>은 Snippet으로 대체되는데, Snippet은 템플릿 조각을 담은 값이라서 {@render children()}처럼 호출하고, 인자를 받거나 다른 컴포넌트에 prop으로 넘길 수 있습니다. 테이블 행 렌더링처럼 “부모가 모양을 정하고 자식이 데이터를 채우는” 경우가 이전의 슬롯 props보다 읽기 쉬워집니다.

이벤트는 on:click 디렉티브가 onclick 속성으로 바뀌고, |preventDefault 같은 수식어는 사라져 핸들러 안에서 e.preventDefault()를 직접 호출합니다. 자식이 부모에게 알리던 createEventDispatcher는 deprecated되었고, onsave 같은 콜백 함수를 $props()로 받는 방식이 표준입니다. 콜백 prop은 일반 함수 타입이라 TypeScript 타입 추론이 자연스럽게 따라옵니다.

<!-- Card.svelte -->
<script lang="ts">
  import type { Snippet } from 'svelte';
  let { header, children, onclose }: { header?: Snippet; children: Snippet; onclose?: () => void } = $props();
</script>
<article>
  {#if header}<header>{@render header()}</header>{/if}
  {@render children()}
  <button onclick={() => onclose?.()}>닫기</button>
</article>

<!-- 사용하는 쪽 -->
<Card onclose={() => (open = false)}>
  {#snippet header()}<h2>제목</h2>{/snippet}
  <p>본문</p>
</Card>

컴포넌트 태그 안에 직접 넣은 내용(<p>본문</p>)은 자동으로 children Snippet이 됩니다.

컴포넌트 밖에서 상태 공유: .svelte.ts

Runes는 .svelte 파일뿐 아니라 .svelte.ts(.svelte.js) 모듈에서도 쓸 수 있어, 스토어 없이 반응형 공유 상태를 만들 수 있습니다. 필드에 $state, $derived를 둔 클래스를 만들어 인스턴스를 내보내는 방식이 가장 다루기 쉽습니다.

주의할 점은 모듈 최상위에서 만든 인스턴스는 서버에서 모든 요청이 공유한다는 것입니다. SvelteKit에서 SSR을 켠 상태로 로그인 사용자 정보 같은 요청별 데이터를 이런 전역 인스턴스에 넣으면 다른 사용자의 요청에 값이 새어 나갈 수 있습니다. 요청마다 달라지는 상태는 레이아웃에서 인스턴스를 만들어 setContext로 내려 주는 편이 안전합니다.

// counter.svelte.ts
export class Counter {
  count = $state(0);
  double = $derived(this.count * 2);
  inc() { this.count++; }
}
export const counter = new Counter();   // 브라우저 전용 상태에만 사용 (SSR에서 요청 간 공유됨)

$state로 만든 원시값 변수를 모듈에서 직접 export하고 다른 파일에서 재할당하는 것은 허용되지 않습니다. 공유 상태는 위처럼 객체·클래스 인스턴스로 내보내고 속성을 바꾸는 방식으로 다룹니다.


Svelte 4 코드를 Runes로 옮기는 전략

공식 도구와 점진적 이전

Svelte 5는 한 프로젝트 안에서 레거시 문법 컴포넌트와 Runes 컴포넌트를 섞어 쓸 수 있습니다. 컴포넌트 단위로 모드가 결정되므로, 프로젝트 전체를 한 번에 바꾸기보다 기능·라우트 단위로 나누어 옮기는 것이 안전합니다.

먼저 별도 브랜치를 만들고, svelte·@sveltejs/kit·@sveltejs/vite-plugin-svelte를 Svelte 5와 호환되는 버전으로 올립니다. 그다음 공식 마이그레이션 명령 npx sv migrate svelte-5를 실행하면 export let, $:, on: 디렉티브, 슬롯 등을 새 문법으로 변환해 줍니다. 컴포넌트 하나만 옮기고 싶다면 VS Code의 Svelte 확장에 있는 “Migrate Component to Svelte 5 Syntax” 명령을 쓸 수 있습니다. 변환 직후에는 diff를 리뷰하고, UI 테스트나 E2E가 있다면 바로 실행해 회귀를 잡습니다.

자동 변환은 $: 문을 의미를 확신할 수 없을 때 run() 헬퍼(svelte/legacy)나 $effect로 옮기는 경우가 있습니다. 변환 결과에서 $effect나 run()이 과도하게 많지 않은지, 그중 $derived로 바꿀 수 있는 파생 값은 없는지를 특히 점검해야 합니다.

팀 규칙 예시

새 파일은 Runes로만 작성하고, 레거시 컴포넌트는 수정할 일이 생길 때 그 컴포넌트만 옮기는 규칙이 무난합니다. 전역 공유 상태는 인증, 테마, 장바구니처럼 도메인 경계가 분명한 곳에만 둡니다. 비동기 데이터는 가능하면 SvelteKit load(+page.ts / +page.server.ts)로 먼저 모델링하고, 클라이언트 상호작용으로 생기는 상태만 Runes로 둡니다. 이렇게 하면 상태의 출처가 “서버 로드 데이터”와 “클라이언트 상호작용”으로 나뉘어 추적하기 쉬워집니다.

마이그레이션 중 흔한 함정

가장 흔한 것은 $effect 남용입니다. 파생 값을 effect로 복제하면 한 번 더 렌더링이 일어나고, 실행 순서 문제나 무한 루프, 불필요한 네트워크 호출이 생깁니다.

두 번째는 async effect입니다. effect 콜백을 async로 만들면 Promise를 반환하므로 정리 함수를 돌려줄 수 없고, await 이후에 읽은 상태는 의존성으로 추적되지 않습니다.

세 번째는 프록시 모델에 대한 오해입니다. $state 객체는 속성을 직접 바꿔도 반응하므로 불변 업데이트를 고집할 필요가 없습니다. 반대로 $state 객체를 다른 변수에 넣어 두면 같은 프록시를 공유하게 되어, 한쪽 변경이 다른 쪽에도 보입니다. 복사본이 필요하면 $state.snapshot()으로 일반 객체를 얻습니다.


Runes 코드의 성능 최적화

$derived와 $effect의 구분

파생 값은 $derived, 외부와의 동기화는 $effect라는 원칙이 첫 번째입니다. effect로 상태를 맞추면 “상태 변경 → effect 실행 → 다른 상태 변경 → 다시 렌더링”으로 갱신이 한 단계 더 생기지만, $derived는 값을 읽을 때 필요하면 계산하므로 이런 추가 단계가 없습니다.

$state.raw와 대용량 데이터

리스트 아이템이 수만 개이고 각 필드가 바뀔 일이 없다면 $state.raw로 저장해 프록시 생성 비용을 줄일 수 있습니다. $state는 배열 안의 객체까지 모두 프록시로 감싸기 때문에, 서버에서 받은 큰 JSON을 그대로 넣으면 그 비용이 커집니다. $state.raw에서는 items[0].done = true 같은 변경이 반영되지 않으므로, 갱신할 때는 새 배열을 만들어 재할당해야 합니다.

비용이 큰 파생 계산

필터·정렬·집계처럼 순수하고 비용이 큰 연산은 $derived(여러 문장이면 $derived.by) 안에 두면 입력이 바뀔 때만 다시 계산됩니다. 계산 안에서 불필요하게 다른 상태를 읽으면 그 상태가 바뀔 때도 재계산되므로, 실제로 필요한 값만 읽도록 하는 것이 좋습니다.

untrack으로 의존성 제외하기

로깅용 스냅샷처럼 읽기는 하지만 의존성으로 등록하고 싶지 않은 값이 있습니다. 이때는 svelte에서 가져온 untrack(() => value)으로 읽으면 의존성 그래프에서 제외됩니다. 남용하면 갱신이 누락되므로 이유가 분명할 때만 씁니다.

import { untrack } from 'svelte';

$effect(() => {
	// query가 바뀔 때만 실행되고, settings 변경으로는 다시 실행되지 않음
	sendAnalytics(query, untrack(() => settings.userId));
});

예제: Todo 앱

목록·입력·필터를 가진 간단한 Todo입니다.

<script>
	let nextId = 1;
	let todos = $state(/** @type {{ id: number; text: string; done: boolean }[]} */ ([]));
	let filter = $state(/** @type {'all' | 'active' | 'done'} */ ('all'));

	let visible = $derived.by(() => {
		if (filter === 'active') return todos.filter((t) => !t.done);
		if (filter === 'done') return todos.filter((t) => t.done);
		return todos;
	});

	let remaining = $derived(todos.filter((t) => !t.done).length);

	function add(text) {
		const t = text.trim();
		if (!t) return;
		todos.push({ id: nextId++, text: t, done: false });
	}

	function toggle(todo) {
		todo.done = !todo.done;
	}

	function remove(id) {
		todos = todos.filter((t) => t.id !== id);
	}
</script>

<form
	onsubmit={(e) => {
		e.preventDefault();
		const fd = new FormData(e.currentTarget);
		add(String(fd.get('text') ?? ''));
		e.currentTarget.reset();
	}}
>
	<input name="text" placeholder="할 일" />
	<button type="submit">추가</button>
</form>

<p>남은 작업: {remaining}</p>

<div class="filters">
	{#each ['all', 'active', 'done'] as f}
		<button type="button" class:active={filter === f} onclick={() => (filter = f)}>
			{f}
		</button>
	{/each}
</div>

<ul>
	{#each visible as todo (todo.id)}
		<li>
			<label>
				<input type="checkbox" checked={todo.done} onchange={() => toggle(todo)} />
				<span class:done={todo.done}>{todo.text}</span>
			</label>
			<button type="button" onclick={() => remove(todo.id)}>삭제</button>
		</li>
	{/each}
</ul>

<style>
	.done {
		text-decoration: line-through;
		opacity: 0.7;
	}
	.filters button.active {
		font-weight: bold;
	}
</style>

todos는 깊은 프록시이므로 push나 todo.done = !todo.done처럼 직접 바꿔도 화면이 갱신됩니다. Svelte 4에서는 todos = todos처럼 재할당해야 갱신되던 부분입니다. visible과 remaining은 filter나 todos가 바뀔 때만 다시 계산됩니다.


예제: 폼 검증과 메시지

폼에는 사용자 입력 상태와, 검증 규칙에서 파생된 에러 메시지가 함께 등장합니다. 에러를 $derived로 두면 입력이 바뀔 때만 다시 평가되고, 상태를 따로 맞출 필요가 없습니다.

<script>
	let email = $state('');
	let password = $state('');

	let emailError = $derived(
		email.length === 0 ? '이메일을 입력하세요.' : /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? '' : '형식이 올바르지 않습니다.'
	);

	let passwordError = $derived(
		password.length < 8 ? '8자 이상 입력하세요.' : ''
	);

	let canSubmit = $derived(!emailError && !passwordError);
</script>

<label>
	이메일
	<input type="email" bind:value={email} autocomplete="email" />
</label>
{#if emailError}<p class="err">{emailError}</p>{/if}

<label>
	비밀번호
	<input type="password" bind:value={password} autocomplete="new-password" />
</label>
{#if passwordError}<p class="err">{passwordError}</p>{/if}

<button type="button" disabled={!canSubmit}>가입</button>

<style>
	.err {
		color: crimson;
		font-size: 0.9rem;
	}
</style>

실제 서비스에서는 사용자가 입력을 시작하기도 전에 에러가 보이지 않도록 “필드를 건드렸는지” 상태를 하나 더 두는 경우가 많습니다. 클라이언트 검증은 UX를 위한 것이고, 서버에서 Zod 같은 스키마로 최종 검증을 다시 해야 합니다.


예제: SvelteKit load 함수와 API 통합

클라이언트에서만 fetch하는 경우와, SvelteKit load로 초기 데이터를 넘기는 경우를 구분하는 것이 중요합니다.

+page.server.ts에서 데이터 로드

// src/routes/items/+page.server.ts
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async ({ fetch }) => {
	const res = await fetch('/api/items');
	if (!res.ok) error(502, '목록을 불러오지 못했습니다.');
	const items = await res.json();
	return { items };
};

load에서 일반 Error를 throw하면 SvelteKit은 예상치 못한 오류로 보고 500과 일반 메시지를 보여 줍니다. 사용자에게 보여 줄 상태 코드와 메시지가 있다면 @sveltejs/kit의 error()를 씁니다(SvelteKit 2부터는 throw 없이 호출합니다).

+page.svelte에서 Runes로 상호작용

<script>
	let { data } = $props();

	// data.items가 바뀌면 다시 계산되고, 로컬에서 재할당도 가능 (Svelte 5.25+)
	let items = $derived(data.items);
	let busy = $state(false);
	let error = $state(/** @type {string | null} */ (null));

	async function refresh() {
		busy = true;
		error = null;
		try {
			const res = await fetch('/api/items');
			if (!res.ok) throw new Error('갱신 실패');
			items = await res.json();
		} catch (e) {
			error = e instanceof Error ? e.message : '알 수 없는 오류';
		} finally {
			busy = false;
		}
	}
</script>

{#if error}<p class="err">{error}</p>{/if}

<button type="button" onclick={refresh} disabled={busy}>
	{busy ? '불러오는 중…' : '새로고침'}
</button>

<ul>
	{#each items as item (item.id)}
		<li>{item.title}</li>
	{/each}
</ul>

같은 컴포넌트에서 다른 항목 페이지로 이동하면 SvelteKit은 컴포넌트를 새로 만들지 않고 data만 바꿉니다. 그래서 let items = $state(data.items)로 초기값을 복사하면 이동 후에도 이전 목록이 남습니다(Svelte가 state_referenced_locally 경고를 내는 이유입니다). 예전에는 이를 $effect(() => { items = data.items; })로 맞췄지만, Svelte 5.25부터는 $derived 값을 재할당할 수 있어 위처럼 쓰는 것이 더 간단합니다. data가 바뀌면 로컬 재할당은 버려지고 새 값으로 다시 계산됩니다. 그보다 이전 버전이라면 effect 방식을 써야 합니다.

다른 방법으로, 클라이언트에서 직접 fetch하지 않고 invalidate()나 invalidateAll()로 load를 다시 실행해 데이터의 출처를 data 하나로 유지할 수도 있습니다. 갱신 로직이 서버에 모여 있다면 이쪽이 더 단순합니다.


SvelteKit 라우팅·폼·스토어와 Runes를 함께 쓸 때

라우팅과 데이터 준비는 +page.ts·+page.server.ts·+layout.ts 등에서 하고, 컴포넌트는 $props()로 data를 받습니다. 폼은 Runes로 입력 상태를 관리하되, JavaScript 없이도 동작해야 한다면 SvelteKit 폼 액션과 use:enhance를 조합합니다.

$app/stores의 page 스토어는 SvelteKit 2.12부터 $app/state의 page 객체로 대체되었습니다. $app/state의 page는 Runes 기반이라 $page.url 대신 page.url처럼 바로 읽으며, $derived 안에서 읽으면 의존성으로 추적됩니다. 기존 $app/stores도 동작하지만 deprecated 상태이므로 새 코드는 $app/state를 쓰는 것이 좋습니다. 서버 전용 모듈과 클라이언트 번들을 나누는 규칙($lib/server, +page.server.ts)은 Runes와 무관하게 그대로입니다.


같이 보면 좋은 글