라이프사이클, 액션, 특수 요소 활용하기
컴포넌트에도 시작과 끝이 있다
지금까지는 주로 상태가 바뀌면 화면이 어떻게 바뀌는지 살펴봤다. 그런데 실제 앱을 만들다 보면 상태만으로는 해결되지 않는 일이 생긴다.
예를 들어 이런 작업이다.
페이지가 처음 열릴 때 API를 호출한다.
컴포넌트가 사라질 때 타이머를 멈춘다.
목록을 추가한 뒤 스크롤을 맨 아래로 내린다.
입력창이 나타나면 자동으로 포커스를 준다.
window의 크기나 키보드 이벤트를 다룬다.
이런 작업은 "컴포넌트가 화면에 나타난 뒤" 또는 "화면에서 사라지기 전"이라는 시점과 관련이 있다. 이 시점을 다루는 기능이 라이프사이클이다. Svelte에서는 onMount, onDestroy, tick을 주로 사용한다. 이 함수들은 svelte 패키지에서 가져오며, onMount는 컴포넌트의 DOM이 실제 문서에 붙은 뒤 실행된다1.
Svelte 5에서는 예전처럼 컴포넌트 전체의 업데이트 전후를 크게 나누기보다, 상태 변화에 반응하는 작은 단위를 $effect와 $derived로 표현한다. 그래도 DOM이 준비된 뒤 실행해야 하는 코드, 컴포넌트가 사라질 때 정리해야 하는 외부 자원, DOM 업데이트가 끝난 뒤의 처리는 여전히 라이프사이클을 알아야 자연스럽게 해결할 수 있다.
AI 생성 이미지
onMount: 화면에 나타난 뒤 실행하기
onMount는 컴포넌트가 브라우저 화면에 붙은 뒤 실행된다. 그래서 브라우저에서만 존재하는 기능을 안전하게 사용할 수 있다.
예를 들어 window, document, localStorage, canvas, DOM 요소 크기 측정은 서버에서는 사용할 수 없다. SvelteKit처럼 서버 렌더링을 함께 사용하는 환경에서는 이런 코드를 컴포넌트 최상위에서 바로 실행하면 문제가 될 수 있다. 이때 onMount 안으로 옮기면 브라우저에서 컴포넌트가 준비된 뒤 실행된다.
간단한 API 호출 예제를 보자.
<script>
import { onMount } from 'svelte';
let products = $state([]);
let loading = $state(true);
let error = $state('');
onMount(() => {
const controller = new AbortController();
let alive = true;
async function loadProducts() {
try {
const response = await fetch('/api/products', {
signal: controller.signal
});
if (!response.ok) {
throw new Error('상품을 불러오지 못했습니다.');
}
const data = await response.json();
if (alive) {
products = data;
}
} catch (err) {
if (alive && err.name !== 'AbortError') {
error = err.message;
}
} finally {
if (alive) {
loading = false;
}
}
}
loadProducts();
return () => {
alive = false;
controller.abort();
};
});
</script>
{#if loading}
<p>상품을 불러오는 중입니다.</p>
{:else if error}
<p>{error}</p>
{:else}
<ul>
{#each products as product}
<li>{product.name}</li>
{/each}
</ul>
{/if}여기서 중요한 점은 onMount 안에서 비동기 함수를 따로 만들었다는 것이다. onMount(async () => { ... })처럼 바로 async 콜백을 넘기면 반환값이 정리 함수가 아니라 Promise가 된다. 그러면 컴포넌트가 사라질 때 실행할 정리 함수를 onMount의 반환값으로 넘기기 어렵다.
위 예제는 컴포넌트가 사라질 때 controller.abort()로 요청을 취소한다. 또한 alive 값을 사용해 이미 사라진 컴포넌트의 상태를 뒤늦게 바꾸지 않도록 막는다. 작은 예제에서는 과해 보일 수 있지만, 실제 서비스에서는 이런 정리가 예상하지 못한 오류를 줄여 준다.
단, SvelteKit에서 페이지에 필요한 데이터를 불러오는 대표적인 방법은 이후에 배울 load 함수다. 이 장의 onMount API 호출은 "브라우저에서 컴포넌트가 뜬 뒤에만 실행해야 하는 호출"을 이해하기 위한 예제라고 보면 된다.
onDestroy: 만든 것은 직접 정리하기
컴포넌트가 사라졌는데도 타이머나 이벤트 리스너가 계속 살아 있으면 문제가 생긴다. 화면에는 보이지 않는 컴포넌트가 계속 일을 하고, 같은 이벤트가 여러 번 실행되거나 메모리를 낭비할 수 있다.
이럴 때 onDestroy를 사용한다.
<script>
import { onMount, onDestroy } from 'svelte';
let now = $state(new Date());
let timer;
onMount(() => {
timer = setInterval(() => {
now = new Date();
}, 1000);
});
onDestroy(() => {
if (timer) {
clearInterval(timer);
}
});
</script>
<p>현재 시각: {now.toLocaleTimeString()}</p>setInterval은 한 번 시작하면 직접 멈추기 전까지 계속 실행된다. 컴포넌트가 사라져도 자동으로 멈추지 않는다. 그래서 clearInterval로 정리해야 한다.
다만 SvelteKit처럼 서버 렌더링을 사용하는 환경에서는 한 가지를 기억해 두는 것이 좋다. onMount는 서버에서 실행되지 않지만, onDestroy는 서버에서 렌더링되는 컴포넌트 안에서도 실행될 수 있다1. 따라서 onDestroy 안에서 window나 document 같은 브라우저 전용 객체를 바로 만지는 코드는 조심해야 한다.
브라우저 전용 자원을 만들고 정리할 때는 onMount 안에서 만들고, 그 자리에서 정리 함수를 반환하는 방식이 더 안전하고 읽기도 쉽다.
<script>
import { onMount } from 'svelte';
let width = $state(0);
function updateWidth() {
width = window.innerWidth;
}
onMount(() => {
updateWidth();
window.addEventListener('resize', updateWidth);
return () => {
window.removeEventListener('resize', updateWidth);
};
});
</script>
<p>브라우저 너비: {width}px</p>직접 추가한 이벤트 리스너는 직접 제거해야 한다. 외부 차트 라이브러리, 지도 라이브러리, 에디터 라이브러리도 비슷하다. 생성할 때 받은 인스턴스가 있다면 컴포넌트가 사라질 때 destroy, dispose, remove 같은 정리 메서드를 호출해야 한다.
onMount에서 만들고 바로 그 자리에서 정리 함수를 반환하면 관련 코드가 가까이 모인다. 반대로 브라우저 전용 객체와 무관한 정리 작업을 한곳에 모아 보고 싶다면 onDestroy를 써도 된다. 중요한 기준은 하나다. 컴포넌트 밖에 남는 자원은 반드시 정리한다.
tick: 상태 변경 뒤 DOM 업데이트 기다리기
Svelte에서 상태를 바꾸면 화면은 자동으로 갱신된다. 하지만 상태를 바꾼 바로 다음 줄에서 DOM을 읽으면 아직 화면 업데이트가 끝나지 않았을 수 있다.
예를 들어 채팅 메시지를 추가한 뒤 목록을 맨 아래로 스크롤하고 싶다고 하자.
<script>
import { tick } from 'svelte';
let messages = $state([
{ id: 1, text: '안녕하세요!' }
]);
let listElement;
let text = $state('');
async function addMessage() {
if (!text.trim()) return;
messages.push({
id: crypto.randomUUID(),
text
});
text = '';
await tick();
listElement.scrollTop = listElement.scrollHeight;
}
</script>
<div class="chat-list" bind:this={listElement}>
{#each messages as message (message.id)}
<p>{message.text}</p>
{/each}
</div>
<input bind:value={text} onkeydown={(event) => {
if (event.key === 'Enter') addMessage();
}} />
<button onclick={addMessage}>보내기</button>messages.push(...)를 호출하면 Svelte는 화면을 갱신할 준비를 한다. 하지만 곧바로 listElement.scrollHeight를 읽으면 새 메시지가 DOM에 반영되기 전 높이를 읽을 수 있다. await tick()은 Svelte가 대기 중인 DOM 업데이트를 끝낼 때까지 기다린다. 대기 중인 업데이트가 없다면 다음 마이크로태스크에서 이어서 실행된다1.
tick은 이런 상황에 유용하다.
항목을 추가한 뒤 스크롤 위치를 조정할 때
조건부로 나타난 입력창에 포커스를 줄 때
상태 변경 후 요소의 실제 높이나 너비를 측정할 때
다만 tick을 남용할 필요는 없다. 대부분의 화면 갱신은 Svelte가 알아서 처리한다. tick은 "상태 변경 이후의 DOM 결과"가 꼭 필요할 때만 사용한다.
use 액션: DOM 동작을 재사용하기
어떤 동작은 컴포넌트 상태보다 DOM 요소 자체와 더 가깝다. 예를 들어 입력창이 나타나자마자 포커스를 주는 기능을 생각해 보자.
<script>
function autofocus(node) {
node.focus();
}
</script>
<input use:autofocus placeholder="검색어" />use:autofocus는 해당 DOM 요소가 만들어질 때 autofocus 함수를 실행한다. 함수의 첫 번째 인자로 실제 DOM 요소가 들어온다.
이처럼 use:로 연결하는 함수를 Svelte 액션이라고 부른다. 액션은 특정 요소에 DOM 동작을 붙이는 재사용 함수다. 액션은 요소가 만들어질 때 실행되고, 필요하면 매개변수 변경을 처리하는 update와 요소 제거 시 실행되는 destroy를 반환할 수 있다3.
조금 더 실전적인 예로 "바깥 클릭 시 닫기" 액션을 만들어 보자.
<script>
let open = $state(false);
function clickOutside(node, callback) {
let currentCallback = callback;
function handlePointerDown(event) {
if (!node.contains(event.target)) {
currentCallback?.();
}
}
const timeout = setTimeout(() => {
document.addEventListener('pointerdown', handlePointerDown);
});
return {
update(nextCallback) {
currentCallback = nextCallback;
},
destroy() {
clearTimeout(timeout);
document.removeEventListener('pointerdown', handlePointerDown);
}
};
}
</script>
<button onclick={() => open = true}>메뉴 열기</button>
{#if open}
<div class="menu" use:clickOutside={() => open = false}>
<button onclick={() => alert('프로필')}>프로필</button>
<button onclick={() => alert('로그아웃')}>로그아웃</button>
</div>
{/if}여기서 액션은 document에 이벤트 리스너를 등록한다. 따라서 메뉴가 사라질 때 반드시 이벤트 리스너를 제거해야 한다. destroy가 그 역할을 한다.
setTimeout을 사용한 이유는 메뉴를 여는 클릭과 바깥 클릭 감지가 같은 순간에 충돌하지 않게 하기 위해서다. 이런 세부 처리를 컴포넌트마다 반복하면 코드가 지저분해진다. 액션으로 빼면 한 번 만든 동작을 여러 곳에서 재사용할 수 있다.
툴팁처럼 간단한 동작도 액션으로 만들 수 있다.
<script>
function tooltip(node, message) {
node.setAttribute('title', message);
return {
update(nextMessage) {
node.setAttribute('title', nextMessage);
},
destroy() {
node.removeAttribute('title');
}
};
}
</script>
<button use:tooltip={'장바구니에 상품을 담습니다'}>
담기
</button>실제 프로젝트에서는 title 속성보다 더 정교한 툴팁 라이브러리를 붙일 수도 있다. 그때도 핵심은 같다. 액션에서 라이브러리를 초기화하고, 옵션이 바뀌면 업데이트하고, 요소가 사라지면 정리한다.
Svelte 5의 최신 문서에서는 액션과 함께 더 새로운 DOM 확장 방식도 다루지만, use: 액션은 여전히 많은 예제와 기존 코드에서 만나는 중요한 패턴이다. "한 DOM 요소에 붙는 동작을 함수로 분리한다"는 감각을 익혀 두면 다른 방식도 훨씬 쉽게 이해할 수 있다.
Svelte 5 이벤트 문법 짚고 가기
오래된 Svelte 예제에서는 이벤트를 이렇게 쓰는 경우가 많다.
<button on:click={save}>저장</button>Svelte 5에서는 이벤트를 일반 속성처럼 쓰는 방식이 기본이다2.
<button onclick={save}>저장</button>preventDefault 같은 이벤트 modifier도 직접 JavaScript 코드로 작성하면 된다.
<form onsubmit={(event) => {
event.preventDefault();
save();
}}>
<button>저장</button>
</form>이 책의 예제는 Svelte 5 스타일인 onclick, onsubmit, onkeydown을 기준으로 작성한다. 다만 기존 자료를 읽다가 on:click을 만나면 "예전 Svelte 문법에서 이벤트를 연결하는 방식"이라고 이해하면 된다.
svelte:window: 전역 이벤트를 선언적으로 다루기
window.addEventListener를 직접 써도 되지만, Svelte에는 전역 객체를 다루기 위한 특수 요소가 있다. svelte:window를 사용하면 window 이벤트와 값을 컴포넌트 마크업 안에서 선언적으로 연결할 수 있다4.
<script>
let width = $state(0);
let scrollY = $state(0);
let open = $state(false);
function handleKeydown(event) {
if (event.key === 'Escape') {
open = false;
}
}
</script>
<svelte:window
bind:innerWidth={width}
bind:scrollY={scrollY}
onkeydown={handleKeydown}
/>
<button onclick={() => open = true}>패널 열기</button>
{#if open}
<aside>Esc 키를 누르면 닫힙니다.</aside>
{/if}
<p>화면 너비: {width}px</p>
<p>스크롤 위치: {scrollY}px</p>이 방식의 장점은 정리 코드를 직접 작성하지 않아도 된다는 점이다. 컴포넌트가 사라지면 Svelte가 연결한 이벤트를 함께 정리한다. 서버 렌더링 환경에서도 window 존재 여부를 직접 확인하지 않아도 된다.
<svelte:window>는 컴포넌트의 최상위에 놓아야 한다. 일반 요소나 {#if} 블록 안에 넣어 조건부로 붙였다 떼는 용도로 쓰는 요소가 아니다. 조건에 따라 동작을 바꾸고 싶다면 이벤트 핸들러 안에서 상태를 확인하거나, 필요한 경우 onMount와 onDestroy를 사용하는 편이 더 명확하다.
또한 모든 전역 이벤트를 svelte:window로 처리해야 하는 것은 아니다. 외부 라이브러리와 복잡하게 연결해야 하거나 조건에 따라 세밀하게 등록·해제해야 한다면 onMount의 반환 함수로 정리 코드를 가까이 두는 편이 더 읽기 쉽다.
svelte:head: 페이지 제목과 메타 정보 넣기
브라우저 탭에 표시되는 제목은 HTML의 <head> 안에 있는 <title>이 결정한다. 일반 컴포넌트 마크업은 보통 <body> 안에 렌더링되므로, <head>를 직접 다루려면 svelte:head를 사용한다.
<script>
let product = $state({
name: 'Svelte 입문서',
description: 'Svelte를 처음 배우는 사람을 위한 책'
});
</script>
<svelte:head>
<title>{product.name} | Book Shop</title>
<meta name="description" content={product.description} />
</svelte:head>
<h2>{product.name}</h2>
<p>{product.description}</p>페이지별 제목, 검색 엔진에 제공할 설명, 공유 이미지 메타 태그 등을 설정할 때 사용한다. SvelteKit에서는 라우트별 페이지 컴포넌트에서 svelte:head를 자주 사용한다.
svelte:element: 태그를 동적으로 고르기
가끔 태그 이름 자체를 상태에 따라 바꿔야 할 때가 있다. 예를 들어 같은 제목 컴포넌트를 만들지만 상황에 따라 h2, h3, p 중 하나로 렌더링하고 싶을 수 있다.
<script>
let level = $state(2);
let title = $state('오늘의 추천 상품');
let tag = $derived(
level === 1 ? 'h1' :
level === 2 ? 'h2' :
level === 3 ? 'h3' : 'p'
);
</script>
<svelte:element this={tag} class="section-title">
{title}
</svelte:element>this={tag}에 들어간 문자열이 실제 태그 이름이 된다. 이 기능은 카드, 문서 뷰어, CMS 콘텐츠처럼 구조가 데이터에 따라 달라지는 UI에서 유용하다.
다만 남용하면 HTML 구조를 읽기 어려워진다. 단순히 스타일만 바꾸면 되는 상황이라면 동적 태그보다 CSS 클래스를 바꾸는 편이 낫다. 동적 태그는 "의미 있는 HTML 태그 자체가 달라져야 할 때" 사용하자.
언제 무엇을 써야 할까
이번 장의 기능들은 모두 DOM과 가까운 문제를 해결한다. 기준을 정리하면 다음과 같다.
브라우저에서 컴포넌트가 뜬 뒤 실행해야 한다면
onMount타이머, 이벤트 리스너, 외부 라이브러리를 정리해야 한다면
onDestroy또는onMount의 반환 함수브라우저 전용 객체를 만들고 정리한다면 가능하면
onMount의 반환 함수상태 변경 뒤 실제 DOM 결과가 필요하다면
tick특정 DOM 요소에 붙는 동작을 재사용하고 싶다면
use:액션window이벤트나 크기, 스크롤 값을 간단히 연결하려면svelte:window문서의
<head>를 수정해야 한다면svelte:head태그 이름 자체를 동적으로 정해야 한다면
svelte:element
핵심은 Svelte가 모든 DOM 코드를 없애 주는 것이 아니라는 점이다. 대부분의 화면 갱신은 상태와 템플릿으로 해결한다. 하지만 포커스, 스크롤, 타이머, 외부 라이브러리처럼 DOM이나 브라우저 API를 직접 다뤄야 하는 영역은 여전히 있다.
Svelte의 라이프사이클과 액션은 그 경계를 깔끔하게 나누도록 도와준다. 상태로 표현할 수 있는 것은 상태로 표현하고, DOM에 직접 붙는 동작은 필요한 시점에 만들고 사라질 때 정리한다. 이 기준을 지키면 컴포넌트가 복잡해져도 예측 가능한 코드를 유지할 수 있다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.