props로 컴포넌트에 데이터 전달하기
컴포넌트가 진짜 재사용되기 시작하는 지점
앞 장까지는 컴포넌트 안에서 상태를 만들고, 그 상태가 바뀌면 화면이 함께 바뀌는 흐름을 살펴보았습니다. 이제 한 단계 더 나아가 보겠습니다.
상품 카드가 하나 있다고 생각해 봅시다.
<article class="card">
<h2>무선 키보드</h2>
<p>가볍고 조용한 블루투스 키보드</p>
<strong>39,000원</strong>
</article>이 코드는 한 상품만 보여 줄 때는 충분합니다. 하지만 상품이 10개라면 어떻게 해야 할까요? 같은 HTML을 10번 복사하고 제목, 설명, 가격만 바꾸는 방식은 오래 유지하기 어렵습니다.
이때 필요한 것이 props입니다. props는 부모 컴포넌트가 자식 컴포넌트에게 전달하는 값입니다. 카드의 디자인은 자식 컴포넌트 안에 두고, 제목·설명·가격 같은 데이터만 부모가 넘깁니다. 그러면 같은 컴포넌트를 여러 데이터에 반복해서 사용할 수 있습니다.
Svelte 5에서는 자식 컴포넌트가 props를 받을 때 $props()를 사용합니다.1
AI 생성 이미지
가장 기본적인 props 전달
먼저 ProductCard.svelte라는 자식 컴포넌트를 만들어 보겠습니다.
<!-- src/lib/ProductCard.svelte -->
<script>
let { title, description, price } = $props();
</script>
<article class="card">
<h2>{title}</h2>
<p>{description}</p>
<strong>{price.toLocaleString()}원</strong>
</article>
<style>
.card {
padding: 16px;
border: 1px solid #ddd;
border-radius: 12px;
}
h2 {
margin: 0 0 8px;
font-size: 18px;
}
p {
margin: 0 0 12px;
color: #666;
}
</style>핵심은 이 줄입니다.
let { title, description, price } = $props();$props()는 부모가 넘긴 값들을 담고 있습니다. 여기에 구조 분해 문법을 사용해 title, description, price를 바로 꺼냅니다. 구조 분해는 객체에서 필요한 속성만 변수로 꺼내는 JavaScript 문법입니다.
이제 부모 컴포넌트에서 이 카드를 사용할 수 있습니다.
<!-- src/routes/+page.svelte -->
<script>
import ProductCard from '$lib/ProductCard.svelte';
</script>
<ProductCard
title="무선 키보드"
description="가볍고 조용한 블루투스 키보드"
price={39000}
/>
<ProductCard
title="USB-C 허브"
description="노트북에 포트를 더해 주는 확장 허브"
price={29000}
/>문자열은 title="무선 키보드"처럼 넘길 수 있습니다. 숫자, 변수, 계산식처럼 JavaScript 값은 {} 안에 넣습니다. 그래서 가격은 price={39000}처럼 작성했습니다.
여기서 부모는 +page.svelte이고, 자식은 ProductCard.svelte입니다. 데이터는 부모에서 자식으로 내려갑니다. 자식은 받은 데이터를 화면에 보여 주는 역할을 합니다.
props는 함수의 매개변수처럼 생각하면 쉽다
처음 props를 배울 때는 컴포넌트를 함수처럼 생각하면 이해하기 쉽습니다.
function ProductCard(title, description, price) {
// title, description, price를 사용해 카드 UI를 만든다
}실제 Svelte 컴포넌트가 이렇게 동작한다는 뜻은 아닙니다. 다만 역할은 비슷합니다. 함수가 매개변수로 값을 받아 다른 결과를 만들듯이, 컴포넌트는 props로 값을 받아 다른 화면을 만듭니다.
같은 ProductCard 컴포넌트라도 props가 달라지면 화면도 달라집니다.
<ProductCard title="노트북 스탠드" description="자세를 편하게 잡아 주는 접이식 스탠드" price={24000} />
<ProductCard title="마우스 패드" description="넓고 부드러운 데스크 매트" price={15000} />이제 우리는 카드 디자인을 한 곳에서 관리할 수 있습니다. 테두리 색이나 여백을 바꾸고 싶다면 ProductCard.svelte만 수정하면 됩니다. 여러 곳에 복사해 둔 HTML을 하나씩 찾아다닐 필요가 없습니다.
기본값으로 안전하게 받기
부모가 항상 모든 props를 넘긴다고 믿으면 위험합니다. 아직 준비되지 않은 데이터가 들어올 수도 있고, 어떤 화면에서는 설명이 없을 수도 있습니다. 이럴 때 기본값을 지정하면 컴포넌트가 더 안전해집니다.
<script>
let {
title = '제목 없음',
description = '설명이 없습니다.',
price = 0
} = $props();
</script>
<article class="card">
<h2>{title}</h2>
<p>{description}</p>
<strong>{price.toLocaleString()}원</strong>
</article>부모가 description을 넘기지 않으면 자식은 '설명이 없습니다.'를 사용합니다. 부모가 해당 prop을 아예 넘기지 않았거나 값이 undefined일 때 기본값이 적용된다고 이해하면 됩니다.
<ProductCard title="스마트폰 거치대" price={12000} />이 코드는 오류 없이 다음과 비슷한 카드로 표시됩니다.
제목: 스마트폰 거치대
설명: 설명이 없습니다.
가격: 12,000원
기본값은 컴포넌트의 최소 동작을 보장하는 장치입니다. 특히 재사용할 컴포넌트일수록 "이 값이 없으면 어떻게 보일까?"를 생각해 두는 것이 좋습니다.
다만 한 가지는 기억해야 합니다. $props()에서 지정한 기본값은 부모가 값을 주지 않았을 때 사용하는 대체값입니다. 객체나 배열을 기본값으로 넣을 수는 있지만, 그 기본값 자체가 자동으로 $state처럼 반응형 상태가 되는 것은 아닙니다. 기본값으로 만든 객체나 배열의 내부를 자식에서 직접 바꾸며 상태를 관리하려고 하면 기대한 대로 화면이 바뀌지 않거나 흐름이 복잡해질 수 있습니다. 변경 가능한 상태가 필요하다면 $state를 어디에 둘지 먼저 정하는 편이 좋습니다.
이름을 바꿔 받아야 할 때
props 이름이 자식 컴포넌트 안에서 쓰기 불편한 경우도 있습니다. 예를 들어 부모가 class라는 이름으로 값을 넘긴다고 해 봅시다. class는 HTML과 JavaScript 문맥에서 특별하게 자주 등장하는 이름이라 컴포넌트 내부 변수명으로 그대로 쓰면 혼란스러울 수 있습니다.
이럴 때는 구조 분해의 이름 변경 문법을 사용합니다.
<script>
let {
title,
class: className = ''
} = $props();
</script>
<article class={`card ${className}`}>
<h2>{title}</h2>
</article>부모는 여전히 class라는 이름으로 넘깁니다.
<ProductCard title="기획 노트" class="highlight" />하지만 자식 내부에서는 className이라는 변수로 사용합니다.
let { class: className = '' } = $props();왼쪽의 class는 부모가 넘긴 prop 이름입니다. 오른쪽의 className은 자식 컴포넌트 안에서 사용할 변수 이름입니다. 이렇게 하면 외부에서 사용하는 이름은 유지하면서 내부 코드는 읽기 좋게 만들 수 있습니다.
책 카드 컴포넌트로 한 번 더 보기
이번에는 상품 대신 책 카드를 만들어 보겠습니다. 구조는 거의 같습니다. props가 필요한 이유도 같습니다.
<!-- src/lib/BookCard.svelte -->
<script>
let {
title = '제목 없음',
author = '작가 미상',
description = '소개가 없습니다.',
price = 0
} = $props();
</script>
<article class="book-card">
<h2>{title}</h2>
<p class="author">{author}</p>
<p>{description}</p>
<strong>{price.toLocaleString()}원</strong>
</article>
<style>
.book-card {
padding: 20px;
border: 1px solid #e5e5e5;
border-radius: 14px;
background: white;
}
.author {
color: #777;
font-size: 14px;
}
</style>부모에서는 여러 책을 같은 디자인으로 보여 줄 수 있습니다.
<script>
import BookCard from '$lib/BookCard.svelte';
let books = [
{
title: '처음 배우는 Svelte',
author: '김새싹',
description: '컴포넌트와 반응성을 기초부터 익히는 책',
price: 22000
},
{
title: '프론트엔드 UI 패턴',
author: '이하늘',
description: '자주 쓰는 화면 구조를 컴포넌트로 정리하는 법',
price: 28000
}
];
</script>
{#each books as book}
<BookCard
title={book.title}
author={book.author}
description={book.description}
price={book.price}
/>
{/each}아직 {#each} 문법을 자세히 배우지는 않았지만 의미는 어렵지 않습니다. books 배열의 각 항목을 book이라는 이름으로 꺼내고, 그 값을 BookCard에 넘기는 코드입니다. 다음 장에서 조건, 반복, 이벤트를 더 자세히 다룹니다.
spread props로 반복 줄이기
위 코드는 명확하지만 조금 길어 보입니다.
<BookCard
title={book.title}
author={book.author}
description={book.description}
price={book.price}
/>객체의 속성 이름과 컴포넌트가 받는 prop 이름이 같다면 더 짧게 쓸 수 있습니다. 이때 사용하는 문법이 spread props입니다.2
<BookCard {...book} />book 객체가 다음과 같다면,
{
title: '처음 배우는 Svelte',
author: '김새싹',
description: '컴포넌트와 반응성을 기초부터 익히는 책',
price: 22000
}<BookCard {...book} />는 아래처럼 넘기는 것과 비슷하게 이해할 수 있습니다.
<BookCard
title={book.title}
author={book.author}
description={book.description}
price={book.price}
/>전체 코드는 이렇게 줄어듭니다.
{#each books as book}
<BookCard {...book} />
{/each}spread props는 목록 화면에서 특히 유용합니다. 서버에서 받은 객체를 카드 컴포넌트에 그대로 넘길 때 코드 중복을 크게 줄여 줍니다.
하지만 무조건 좋은 것은 아닙니다. 어떤 값이 자식 컴포넌트로 전달되는지 한눈에 잘 보이지 않을 수 있습니다. 처음 읽는 사람이 book 객체 안을 찾아봐야 하기 때문입니다.
그래서 기준을 이렇게 잡으면 좋습니다.
넘길 속성이 1~2개라면 명시적으로 적는다.
객체 구조와 prop 이름이 안정적이고 속성이 여러 개라면 spread를 고려한다.
컴포넌트의 사용법을 분명히 보여 주고 싶다면 명시적인 props가 더 낫다.
짧은 코드보다 중요한 것은 읽기 쉬운 코드입니다.
나머지 props를 한꺼번에 받기
자식 컴포넌트에서 일부 props만 직접 사용하고, 나머지는 내부 HTML 요소에 넘기고 싶을 때가 있습니다. 예를 들어 버튼 컴포넌트를 만든다고 해 보겠습니다.
<!-- src/lib/AppButton.svelte -->
<script>
let {
variant = 'primary',
children,
...rest
} = $props();
</script>
<button class={`button ${variant}`} {...rest}>
{@render children?.()}
</button>여기서 variant는 컴포넌트가 직접 사용하는 prop입니다. ...rest에는 type, disabled, aria-label처럼 따로 꺼내지 않은 나머지 props가 들어갑니다.
부모는 이렇게 사용할 수 있습니다.
<AppButton type="submit" disabled={false} aria-label="저장하기">
저장
</AppButton>그러면 type, disabled, aria-label은 내부의 <button> 요소로 전달됩니다. 이런 패턴은 공통 버튼, 입력창, 링크처럼 HTML 요소를 감싸는 컴포넌트를 만들 때 자주 사용합니다.
Svelte 5에서는 컴포넌트 태그 사이에 넣은 내용이 children이라는 snippet prop으로 들어옵니다. 그래서 위 예제에서는 {@render children?.()}로 저장이라는 내용을 버튼 안에 표시했습니다. 다만 이 장의 핵심은 props 자체이므로 children과 {@render ...} 문법은 깊게 다루지 않겠습니다. 지금은 "명시적으로 꺼낸 props 외의 나머지를 ...rest로 모을 수 있다"는 정도만 기억하면 충분합니다.
props는 아래로 흐른다
props를 사용할 때 가장 중요한 원칙은 데이터가 부모에서 자식으로 내려간다는 점입니다.
부모가 값을 가지고 있습니다.
<script>
import ProductCard from '$lib/ProductCard.svelte';
let product = $state({
title: '무선 마우스',
description: '손목이 편한 인체공학 마우스',
price: 33000
});
</script>
<ProductCard {...product} />자식은 값을 받습니다.
<script>
let { title, description, price } = $props();
</script>
<h2>{title}</h2>
<p>{description}</p>
<strong>{price.toLocaleString()}원</strong>부모의 product.title이 바뀌면 자식의 화면도 바뀝니다. 이 흐름은 자연스럽습니다. 부모가 데이터를 소유하고 있고, 자식은 그 데이터를 표시하기 때문입니다.
문제는 반대 방향입니다. 자식이 받은 props를 직접 바꾸려고 하면 역할이 흐려집니다.
<script>
let { price } = $props();
function discount() {
price = price * 0.9;
}
</script>이 코드는 자식 안에서 받은 가격을 임시로 바꾸는 코드입니다. 작은 예제에서는 동작하는 것처럼 보일 수 있지만, 앱이 커질수록 혼란이 생깁니다. 실제 가격의 주인은 부모인가요, 자식인가요? 다른 컴포넌트도 같은 상품 가격을 보고 있다면 어디까지 같이 바뀌어야 할까요?
특히 객체나 배열처럼 내부 값을 바꿀 수 있는 props는 더 조심해야 합니다. 부모가 소유한 상태 객체를 자식이 직접 수정하면 Svelte가 경고를 보여 줄 수 있고, 반대로 일반 객체나 기본값 객체는 수정해도 화면이 기대처럼 갱신되지 않을 수 있습니다. 그래서 처음에는 props를 직접 바꾸는 방식보다, 상태의 주인을 먼저 정하는 습관이 더 중요합니다.
기본 원칙은 간단합니다.
props는 보통 읽고 보여 주는 값으로 다룬다. 변경이 필요하면 그 상태를 누가 소유할지 먼저 정한다.
상태 변경은 어디서 책임질까?
상태를 어디에 둘지는 다음 기준으로 판단하면 됩니다.
첫째, 한 컴포넌트 안에서만 쓰는 값이라면 그 컴포넌트 안에 둡니다.
<script>
let open = $state(false);
</script>
<button onclick={() => open = !open}>상세 보기</button>
{#if open}
<p>추가 설명입니다.</p>
{/if}open은 이 컴포넌트의 상세 설명을 열고 닫는 데만 필요합니다. 부모가 알 필요가 없다면 자식 내부 상태로 충분합니다.
둘째, 여러 자식이 함께 봐야 하는 값이라면 부모에 둡니다.
예를 들어 장바구니 수량은 상품 카드 하나만의 문제가 아닙니다. 헤더의 장바구니 개수, 총액, 주문 버튼에도 영향을 줍니다. 이런 값은 부모나 더 상위 컴포넌트가 소유하는 편이 좋습니다.
<script>
import ProductCard from '$lib/ProductCard.svelte';
let count = $state(0);
function addToCart() {
count += 1;
}
</script>
<p>장바구니: {count}개</p>
<ProductCard
title="무선 마우스"
description="손목이 편한 인체공학 마우스"
price={33000}
onAdd={addToCart}
/>자식은 함수를 props로 받아 호출합니다.
<script>
let { title, description, price, onAdd } = $props();
</script>
<article class="card">
<h2>{title}</h2>
<p>{description}</p>
<strong>{price.toLocaleString()}원</strong>
<button onclick={onAdd}>장바구니 담기</button>
</article>여기서 자식은 장바구니 개수를 직접 바꾸지 않습니다. 대신 "사용자가 담기 버튼을 눌렀다"는 사실을 부모에게 알려 줍니다. 실제 상태 변경은 부모의 addToCart 함수에서 일어납니다.
이 패턴은 데이터 흐름을 예측 가능하게 만듭니다. 자식은 UI와 사용자 동작을 담당하고, 부모는 중요한 상태 변경을 책임집니다.
Svelte에는 자식과 부모가 값을 양방향으로 연결하는 $bindable도 있습니다. 하지만 처음부터 모든 값을 양방향으로 만들 필요는 없습니다. 양방향 바인딩은 편리하지만, 너무 자주 쓰면 값이 어디서 바뀌는지 따라가기 어려워질 수 있습니다. 대부분의 경우에는 props로 값을 내려보내고, 변경이 필요할 때 콜백 함수를 올려 보내는 방식이 더 단순합니다.
예전 문법 export let 읽기
Svelte 4까지의 자료를 보면 props를 받을 때 이런 코드를 자주 만납니다.
<script>
export let title;
export let price = 0;
</script>Svelte 5의 runes 문법에서는 같은 코드를 다음처럼 씁니다.3
<script>
let { title, price = 0 } = $props();
</script>오래된 글이나 예제에서 export let을 보더라도 당황하지 않아도 됩니다. "아, 부모에게서 받는 props구나"라고 읽으면 됩니다. 이 책에서는 Svelte 5의 runes 문법에 맞춰 $props()를 기준으로 설명합니다.
props 설계 체크리스트
재사용 가능한 컴포넌트를 만들 때는 다음 질문을 해 보세요.
이 컴포넌트가 부모에게서 받아야 할 값은 무엇인가?
값이 없을 때 사용할 기본값이 필요한가?
prop 이름이 내부 변수명으로 쓰기 적절한가?
객체를 spread로 넘기면 더 읽기 쉬운가, 아니면 명시적으로 적는 편이 나은가?
이 값은 자식이 직접 바꿔도 되는가, 아니면 부모가 소유해야 하는가?
props를 이해하면 컴포넌트는 단순한 파일 분리가 아니라 재사용 가능한 UI 단위가 됩니다. 같은 디자인을 복사하지 않고, 데이터만 바꿔 여러 화면에 사용할 수 있습니다.
다음 장에서는 조건문, 반복문, 이벤트를 함께 다룹니다. 이번 장에서 만든 카드 컴포넌트에 목록 렌더링과 버튼 동작을 더하면 실제 앱에 가까운 화면을 만들 수 있습니다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.