SvelteKit 5 Runes 완전 정복 — 실무 마이그레이션 가이드
안녕하세요, 코드픽(codepick.kr) 기술 블로그 독자 여러분!
프론트엔드 개발 생태계는 눈부시게 발전하고 있으며, 그 중심에는 항상 혁신적인 아이디어와 기술이 있습니다. 오늘 저희가 주목할 기술은 바로 SvelteKit 5 Runes입니다. Svelte는 "컴파일러를 이용한 반응성"이라는 독특한 접근 방식으로 많은 개발자들의 사랑을 받아왔지만, Svelte 5는 여기서 한 단계 더 나아가 더욱 직관적이고 강력한 반응성 시스템인 Runes를 도입했습니다.
이번 포스트에서는 SvelteKit 5 Runes가 무엇인지, 왜 주목해야 하는지, 그리고 기존 SvelteKit 프로젝트를 Runes 기반으로 성공적으로 마이그레이션하기 위한 실전 가이드를 상세하게 다룰 예정입니다. 복잡한 이론보다는 실제 코드 예제와 함께 개발 현장에서 바로 적용할 수 있는 팁들을 중심으로 설명하니, SvelteKit 개발자라면 반드시 읽어봐야 할 내용이 될 것입니다.
자, 그럼 SvelteKit 5 Runes의 세계로 함께 떠나볼까요?
Svelte 5 Runes, 왜 주목해야 하는가?
Svelte는 그동안 $: 레이블과 같은 컴파일러 매직을 통해 놀라운 성능과 간결한 코드를 제공해왔습니다. 하지만 이러한 방식은 때때로 Svelte의 내부 동작을 이해하기 어렵게 만들거나, JavaScript의 표준적인 문법과는 다소 이질적인 느낌을 주기도 했습니다. 특히 객체나 배열의 깊은 반응성을 다룰 때는 명시적인 재할당이 필요하여 혼란을 야기하는 경우도 있었습니다.
Svelte 5에서 도입된 Runes는 이러한 문제점들을 해결하고, 더욱 명시적이고 예측 가능한 반응성 시스템을 제공합니다. 이는 React의 Hooks나 Vue의 Composition API와 유사하게, 런타임에 작동하는 함수 호출 기반의 반응성 프리미티브들을 제공함으로써 Svelte의 반응성 모델을 근본적으로 변화시킵니다.
Runes의 주요 장점은 다음과 같습니다.
- 명시적 반응성 (Explicit Reactivity):
$state(),$derived(),$effect()와 같은 함수 호출을 통해 어떤 변수가 반응성을 가지는지, 언제 다시 계산되는지, 어떤 부수 효과를 일으키는지를 명확하게 알 수 있습니다. 이는 코드의 가독성과 유지보수성을 크게 향상시킵니다. - 직관적인 객체/배열 처리: 더 이상 객체나 배열을 재할당하지 않아도 내부 값의 변경에 반응합니다. 이는 JavaScript의 일반적인 객체 조작 방식과 일치하여 개발 경험을 개선합니다.
- 성능 최적화 가능성: 컴파일러가 모든 것을 추론해야 했던 이전 방식과 달리, Runes는 개발자가 반응성 흐름을 직접 제어할 수 있게 하여 잠재적으로 더 세밀한 성능 최적화를 가능하게 합니다.
- 표준 JavaScript 친화적: 일반적인 JavaScript 함수 호출 방식으로 반응성을 표현함으로써, Svelte를 처음 접하는 개발자들도 더 쉽게 개념을 이해하고 코드를 작성할 수 있게 됩니다.
이러한 변화는 Svelte 개발의 패러다임을 바꾸는 중요한 전환점이 될 것입니다. SvelteKit 5 Runes를 이해하고 활용하는 것은 미래의 Svelte 개발 역량을 확보하는 핵심이 될 것입니다.
Runes의 핵심 개념 파헤치기
Svelte 5 Runes는 몇 가지 핵심적인 반응성 프리미티브(Rune)로 구성됩니다. 이들을 이해하는 것이 Runes 기반 개발의 첫걸음입니다.
$state(): 반응형 상태 선언
$state()는 가장 기본적인 Rune으로, 컴포넌트 내부의 반응형 상태를 선언할 때 사용합니다. let 변수를 $state()로 감싸면 해당 변수의 값이 변경될 때마다 컴포넌트가 자동으로 업데이트됩니다.
// Svelte 4 (기존 방식)
let count = 0;
function increment() {
count++; // 컴파일러가 변경을 감지하고 업데이트
}
// Svelte 5 Runes
let count = $state(0); // count는 반응형 상태
let user = $state({ name: CodePick, age: 5 }); // 객체도 반응형으로 선언
function increment() {
count++; // count 값을 직접 변경해도 반응
}
function updateUserName(newName) {
user.name = newName; // 객체 내부 값 변경에도 반응 (재할당 필요 없음!)
}
$state()는 객체나 배열의 깊은 반응성도 자동으로 처리합니다. 더 이상 data = { ...data, key: value } 와 같이 객체를 재할당할 필요가 없습니다.
$derived(): 파생된 상태 계산
$derived()는 하나 이상의 $state() 변수에 의존하여 새로운 값을 계산할 때 사용합니다. 의존하는 $state() 변수가 변경되면 $derived()로 선언된 값도 자동으로 다시 계산됩니다. 이는 기존 Svelte의 $: 레이블을 이용한 파생 상태와 유사합니다.
// Svelte 4 (기존 방식)
let count = 0;
$: doubled = count * 2; // count가 변경되면 doubled도 다시 계산
// Svelte 5 Runes
let count = $state(0);
let doubled = $derived(count * 2); // count가 변경되면 doubled도 다시 계산
let firstName = $state(Code);
let lastName = $state(Pick);
let fullName = $derived(`${firstName} ${lastName}`); // 여러 상태에 의존 가능
$derived()는 항상 읽기 전용(read-only)입니다. 즉, $derived()로 선언된 변수에 직접 값을 할당할 수 없습니다.
$effect(): 부수 효과 관리
$effect()는 컴포넌트의 렌더링 외부에서 발생하는 부수 효과(side effects)를 관리할 때 사용합니다. 예를 들어, 콘솔 로깅, DOM 조작, 서버로 데이터 전송, 구독 설정 및 해제 등이 여기에 해당합니다. $effect()는 의존하는 $state()나 $derived() 값이 변경될 때마다 실행됩니다.
// Svelte 4 (기존 방식)
let count = 0;
$: console.log(Count changed:, count); // 부수 효과로 사용
onMount(() => {
const handler = () => console.log(Mounted!);
window.addEventListener(resize, handler);
return () => window.removeEventListener(resize, handler); // 클린업
});
// Svelte 5 Runes
let count = $state(0);
$effect(() => {
console.log(Count changed:, count); // count가 변경될 때마다 실행
});
$effect(() => {
// DOM 조작이나 외부 API 호출 등
const handler = () => console.log(Window resized!);
window.addEventListener(resize, handler);
return () => { // 클린업 함수 반환
window.removeEventListener(resize, handler);
console.log(Effect cleaned up);
};
});
$effect()는 클린업(cleanup) 함수를 반환할 수 있어, 컴포넌트가 언마운트되거나 의존성이 변경되어 이펙트가 다시 실행될 때 이전 이펙트를 정리할 수 있습니다. 이는 React의 useEffect 훅과 매우 유사합니다.
$props(): 컴포넌트 속성 선언
$props()는 컴포넌트가 외부로부터 받는 속성(props)을 선언할 때 사용합니다. 기존의 export let 문법을 대체합니다.
// Svelte 4 (기존 방식)
// MyComponent.svelte
export let name;
export let age = 0; // 기본값
// Svelte 5 Runes
// MyComponent.svelte
let { name, age = 0 } = $props(); // name은 필수, age는 기본값 0
// App.svelte
// <MyComponent name="Alice" age={30} />
$props()는 구조 분해 할당과 함께 사용하여 여러 속성을 한 번에 선언할 수 있으며, 기본값도 지정할 수 있습니다.
기타 Runes: $host(), $bindable()
$host(): 컴포넌트의 호스트 요소(예:<div/>또는<button/>)에 직접 접근하거나 속성을 설정할 때 사용합니다. SSR 환경에서는 동작하지 않습니다.$bindable(): 상위 컴포넌트에서 바인딩 가능한 prop을 선언할 때 사용합니다.bind:value와 같은 양방향 바인딩을 지원합니다.
이러한 핵심 Runes들을 이해했다면, 이제 실제 SvelteKit 프로젝트에 Runes를 적용하고 기존 코드를 마이그레이션하는 방법을 살펴보겠습니다.
SvelteKit 프로젝트에 Runes 적용하기: 실무 마이그레이션 전략
SvelteKit 프로젝트에 Runes를 도입하는 과정은 비교적 간단하지만, 기존 코드 베이스의 규모에 따라 전략적인 접근이 필요합니다.
1단계: SvelteKit 프로젝트 준비 및 Runes 활성화
가장 먼저 SvelteKit 및 Svelte를 최신 버전으로 업데이트해야 합니다. Runes는 Svelte 5의 기능이므로, Svelte 5 프리뷰 버전을 사용해야 합니다.
# 프로젝트 디렉토리에서
npm install svelte@next svelte-kit@next
# 또는 yarn
yarn add svelte@next svelte-kit@next
다음으로, svelte.config.js 파일에 runes: true 옵션을 추가하여 Runes 모드를 활성화합니다.
// svelte.config.js
import adapter from @sveltejs/adapter-auto;
/** @type {import(@sveltejs/kit).Config} */
const config = {
compilerOptions: {
runes: true // 이 부분을 추가하여 Runes 모드 활성화
},
kit: {
adapter: adapter()
}
};
export default config;
이제 프로젝트는 Runes를 사용할 준비가 되었습니다.
2단계: 기존 반응성 문법에서 Runes로 전환
이 단계는 실제 코드 마이그레이션의 핵심입니다. 기존 컴포넌트의 let 변수, $: 반응성 구문, onMount 등을 Runes API로 전환해야 합니다.
2.1. let 변수를 $state로
가장 기본적인 마이그레이션입니다. 컴포넌트 내에서 변경될 수 있는 모든 상태 변수는 $state()로 감싸줍니다.
기존 Svelte 4 코드:
<!-- src/lib/components/Counter.svelte (Svelte 4) -->
<script>
let count = 0;
function increment() {
count++;
}
</script>
<button on:click={increment}>
Count: {count}
</button>
Svelte 5 Runes 코드:
<!-- src/lib/components/Counter.svelte (Svelte 5 Runes) -->
<script>
let count = $state(0); // $state()로 감싸 반응형으로 선언
function increment() {
count++; // 직접 변경해도 반응
}
</script>
<button on:click={increment}>
Count: {count}
</button>
2.2. $ 반응성 구문을 $derived로
$: 레이블을 사용하여 계산했던 파생 상태는 $derived()로 전환합니다.
기존 Svelte 4 코드:
<!-- src/lib/components/ProductDisplay.svelte (Svelte 4) -->
<script>
export let price = 100;
export let quantity = 1;
$: totalPrice = price * quantity; // 파생 상태
$: if (totalPrice > 200) {
console.log("Discount eligible!");
}
</script>
<p>Price: ${price}</p>
<p>Quantity: {quantity}</p>
<p>Total: ${totalPrice}</p>
Svelte 5 Runes 코드:
<!-- src/lib/components/ProductDisplay.svelte (Svelte 5 Runes) -->
<script>
let { price = 100, quantity = 1 } = $props(); // $props()로 props 선언
let totalPrice = $derived(price * quantity); // $derived()로 파생 상태 선언
// $: 를 사용한 부수 효과는 $effect()로 전환
$effect(() => {
if (totalPrice > 200) {
console.log("Discount eligible!");
}
});
</script>
<p>Price: ${price}</p>
<p>Quantity: {quantity}</p>
<p>Total: ${totalPrice}</p>
2.3. onMount 및 onDestroy를 $effect로
컴포넌트의 라이프사이클 훅 중 onMount와 onDestroy에서 처리하던 부수 효과 중 일부는 $effect()로 전환할 수 있습니다. 특히 특정 반응형 값에 의존하는 효과는 $effect()가 더 적합합니다.
기존 Svelte 4 코드:
<!-- src/lib/components/Timer.svelte (Svelte 4) -->
<script>
import { onMount, onDestroy } from svelte;
let seconds = 0;
let intervalId;
onMount(() => {
intervalId = setInterval(() => {
seconds++;
}, 1000);
});
onDestroy(() => {
clearInterval(intervalId);
});
</script>
<p>Elapsed: {seconds}s</p>
Svelte 5 Runes 코드:
<!-- src/lib/components/Timer.svelte (Svelte 5 Runes) -->
<script>
let seconds = $state(0);
$effect(() => {
// 이펙트는 컴포넌트 마운트 시점에 실행
const intervalId = setInterval(() => {
seconds++;
}, 1000);
// 클린업 함수를 반환하여 컴포넌트 언마운트 시 또는 이펙트 재실행 시 정리
return () => {
clearInterval(intervalId);
};
});
</script>
<p>Elapsed: {seconds}s</p>
주의: $effect()가 onMount를 완전히 대체하는 것은 아닙니다. onMount는 오직 클라이언트 측에서 한 번만 실행되는 것을 보장하는 반면, $effect()는 의존성 변경 시 여러 번 실행될 수 있습니다. DOM 요소에 접근해야 하는 경우 등 특정 시점에만 실행되어야 하는 로직은 여전히 onMount를 사용하는 것이 안전할 수 있습니다.
2.4. 스토어(Stores) 사용
Svelte의 스토어는 전역 상태 관리를 위한 강력한 도구입니다. Runes가 도입되었다고 해서 스토어가 사라지는 것은 아닙니다. 여전히 writable, readable, derived 스토어를 사용하여 애플리케이션 전반에 걸친 상태를 관리할 수 있습니다.
Runes 환경에서는 스토어 값을 구독하는 방식이 변경됩니다. 기존의 $storeName 문법 대신, use:storeName 액션이나 $effect 내에서 직접 구독하고 구독 해제를 처리하는 방식이 권장됩니다. 하지만 Svelte 5는 $state()가 로컬 컴포넌트 상태 관리를 훨씬 더 강력하게 만들어주므로, 전역적으로 공유되지 않는 단순한 로컬 상태에는 스토어 대신 $state()를 사용하는 것을 고려해볼 수 있습니다.
스토어 사용 예시 (Svelte 5 Runes):
<!-- src/lib/stores/countStore.js -->
import { writable } from svelte/store;
export const countStore = writable(0);
<!-- src/lib/components/GlobalCounter.svelte (Svelte 5 Runes) -->
<script>
import { countStore } from $lib/stores/countStore;
// $state()로 스토어 값을 래핑하여 반응형으로 사용
// Svelte 5에서는 $storeName 문법이 기본적으로 지원되지 않으므로,
// 직접 구독하거나, $state로 래핑하는 방식을 사용합니다.
let currentCount = $state(0);
$effect(() => {
const unsubscribe = countStore.subscribe(value => {
currentCount = value;
});
return unsubscribe; // 클린업 함수 반환
});
function incrementGlobal() {
countStore.update(n => n + 1);
}
</script>
<p>Global Count: {currentCount}</p>
<button on:click={incrementGlobal}>Increment Global</button>
3단계: 복잡한 시나리오 및 엣지 케이스 처리
3.1. 객체 및 배열 반응성
Runes는 객체와 배열의 반응성을 훨씬 직관적으로 만듭니다. 기존 Svelte에서는 객체나 배열의 특정 속성만 변경하더라도, 변경 사항을 감지시키기 위해 객체/배열 전체를 재할당해야 하는 경우가 많았습니다. Runes에서는 그럴 필요가 없습니다.
기존 Svelte 4 (객체 재할당 필요):
let user = { name: Alice, age: 30 };
function updateAge() {
user.age++;
user = user; // Svelte에 변경을 알리기 위해 재할당
}
Svelte 5 Runes (직접 변경 가능):
let user = $state({ name: Alice, age: 30 });
function updateAge() {
user.age++; // 직접 변경해도 반응!
}
let items = $state([apple, banana]);
function addItem(item) {
items.push(item); // push, pop, splice 등 배열 메서드도 직접 사용 가능
}
3.2. 비동기 처리
$effect() 내에서 비동기 로직을 처리할 때는 클린업 함수를 적절히 활용하여 메모리 누수나 예상치 못한 동작을 방지해야 합니다.
let userId = $state(1);
let userData = $state(null);
let isLoading = $state(false);
$effect(() => {
if (!userId) return;
isLoading = true;
userData = null; // 이전 데이터 초기화
const abortController = new AbortController();
async function fetchUser() {
try {
const response = await fetch(`https://api.example.com/users/${userId}`, {
signal: abortController.signal
});
userData = await response.json();
} catch (error) {
if (error.name === AbortError) {
console.log(Fetch aborted);
} else {
console.error(Failed to fetch user:, error);
}
} finally {
isLoading = false;
}
}
fetchUser();
return () => {
// userId가 변경되거나 컴포넌트가 언마운트될 때 이전 요청 취소
abortController.abort();
};
});
3.3. 컨텍스트 API
SvelteKit의 컨텍스트 API(getContext, setContext)는 컴포넌트 트리 깊숙이 데이터를 전달하는 데 여전히 유용합니다. Runes와 함께 사용할 때 특별히 달라지는 점은 없지만, 컨텍스트로 전달되는 값이 반응형이라면 $state()나 $derived()로 선언된 값이어야 합니다.
// parent.svelte
<script>
import { setContext } from svelte;
import Child from ./Child.svelte;
let theme = $state(dark);
setContext(theme, theme); // 반응형 값을 컨텍스트로 제공
</script>
<Child />
// child.svelte
<script>
import { getContext } from svelte;
let theme = getContext(theme); // 컨텍스트에서 반응형 값 가져오기
</script>
<p>Current theme: {theme}</p>
3.4. 컴포넌트 합성 및 $props
$props()를 사용하면 컴포넌트의 인터페이스가 더욱 명확해집니다. 또한, $$restProps와 같은 특수 변수 없이 스프레드 문법을 사용하여 나머지 props를 전달할 수 있습니다.
<!-- src/lib/components/Button.svelte -->
<script>
let { label = Click Me, ...rest } = $props(); // label prop과 나머지 props
</script>
<button {...rest}>
{label}
</button>
<!-- App.svelte -->
<script>
import Button from $lib/components/Button.svelte;
function handleClick() {
alert(Button clicked!);
}
</script>
<Button label="Submit" class="primary" on:click={handleClick} />
<Button title="Info button" />
4단계: 점진적 마이그레이션 전략
대규모 프로젝트의 경우 모든 코드를 한 번에 Runes로 마이그레이션하는 것은 비현실적일 수 있습니다. 다음과 같은 점진적 마이그레이션 전략을 고려할 수 있습니다.
- 새로운 컴포넌트는 Runes로 작성: Runes 모드를 활성화한 후, 새로 개발하는 모든 컴포넌트는 Runes를 사용하여 작성합니다.
- 핵심 컴포넌트부터 전환: 애플리케이션의 핵심 로직을 담당하거나 자주 변경되는 컴포넌트부터 Runes로 전환합니다.
- Leaf 컴포넌트(말단 컴포넌트)부터 전환: 의존성이 적은 가장 작은 컴포넌트부터 전환하여 점진적으로 위로 올라가는 방식도 좋습니다.
- 테스트 주도 마이그레이션: 각 컴포넌트를 마이그레이션할 때마다 기존 테스트 코드를 활용하여 기능이 제대로 작동하는지 확인합니다.
Runes 마이그레이션, 이것만은 꼭!
Runes로의 마이그레이션은 Svelte 개발 경험을 크게 향상시킬 수 있지만, 몇 가지 중요한 고려사항이 있습니다.
Svelte 4 반응성 vs Svelte 5 Runes 비교
| 특징 | Svelte 4 Reactivity (기존 방식) | Svelte 5 Runes (새로운 방식) |
|---|---|---|
| **기본 상태 선언** | `let count = 0;` (컴파일러가 변경 감지) | `let count = $state(0);` (명시적으로 반응형 상태 선언) |
| **파생 상태** | `$: doubled = count * 2;` (컴파일러가 의존성 추론) | `let doubled = $derived(count * 2);` (명시적으로 파생 상태 선언) |
| **부수 효과** | `$: console.log(count);` 또는 `onMount/onDestroy` | `$effect(() => { ... });` (명시적, 클린업 가능) |
| **컴포넌트 Props** | `export let name;` | `let { name } = $props();` |
| **객체/배열 반응성** | 재할당 필요 (`obj = obj;` 또는 `arr = [...arr];`) | 직접 변경 가능 (`obj.prop = value;`, `arr.push(item);`) |
| **반응성 모델** | 컴파일 타임에 코드 변환 (Magic) | 런타임 함수 호출 (Explicit) |
| **성능** | 일반적으로 우수 | 잠재적으로 더 세밀한 최적화 가능, 더 예측 가능 |
| **학습 곡선** | Svelte 고유의 문법 학습 필요 | 표준 JS 함수 호출에 가까워 더 직관적 |
성능 최적화 팁
- 필요한 곳에만
$state사용: 모든 변수를$state로 만들 필요는 없습니다. 컴포넌트 내부에서만 사용되고 반응성이 필요 없는 변수는 일반let변수로 두는 것이 좋습니다. $derived남용 주의: 너무 많은$derived체인은 불필요한 재계산을 유발할 수 있습니다. 정말 필요한 파생 상태에만 사용하고, 복잡한 계산은 유틸리티 함수로 분리하여$derived내부에서 호출하는 것을 고려하세요.$effect클린업:$effect에서 리소스(타이머, 이벤트 리스너, 네트워크 요청 등)를 사용하는 경우 반드시 클린업 함수를 반환하여 메모리 누수를 방지해야 합니다.- 컴포넌트 분리: 여전히 컴포넌트를 작고 재사용 가능하게 분리하는 것은 성능과 유지보수성 측면에서 중요합니다.
팀워크와 코드 컨벤션
Runes로의 전환은 팀 전체의 합의와 교육이 필요합니다.
- 가이드라인 수립: Runes를 어떻게 사용할지, 어떤 패턴을 따를지에 대한 명확한 코드 컨벤션과 가이드라인을 수립합니다.
- 코드 리뷰: Runes 스타일로 작성된 코드에 대한 적극적인 코드 리뷰를 통해 팀원들의 이해도를 높이고 일관성을 유지