rune /ruːn/ 명사
신비롭거나 마법적인 상징으로 사용되는 문자나 표시
Runes는 .svelte와 .svelte.js / .svelte.ts 파일에서 Svelte 컴파일러를 제어하는 데 사용하는 기호입니다. Svelte를 언어로 생각한다면, runes는 구문의 일부이며 키워드입니다.
$접두사: 모든 runes는$로 시작하며 함수처럼 보입니다- import 불필요: 언어의 일부이므로 별도로 import할 필요 없음
- 값이 아님: 변수에 할당하거나 함수의 인수로 전달할 수 없음
- 위치 제한: JavaScript 키워드처럼 특정 위치에서만 유효
let message = $state('hello'); // ✅ 올바른 사용
const myState = $state; // ❌ 불가능 - 값이 아님Svelte 5에는 총 7개의 주요 Runes가 있습니다:
| Rune | 목적 | 카테고리 |
|---|---|---|
$state |
반응형 상태 생성 | 상태 관리 |
$derived |
파생된 상태 생성 | 상태 관리 |
$effect |
부작용 처리 | 상태 관리 |
$props |
컴포넌트 속성 받기 | 컴포넌트 통신 |
$bindable |
양방향 바인딩 | 컴포넌트 통신 |
$inspect |
디버깅 도구 | 디버깅 |
$host |
커스텀 엘리먼트 호스트 접근 | 특수 목적 |
$state는 Svelte 5의 반응형 시스템의 핵심입니다. UI가 상태 변화에 반응하도록 하는 reactive state를 생성합니다.
let count = $state(0);
let user = $state({ name: 'John', age: 30 });
let items = $state(['apple', 'banana']);- 직접 접근:
count는 숫자 자체이며,.value나getCount()같은 래퍼 없이 직접 사용 - Deep Reactivity: 객체나 배열의 경우 깊은 반응성을 제공하는 Proxy 생성
- 세밀한 업데이트:
array.push()같은 메서드도 반응성 트리거
<script>
let todos = $state([
{ done: false, text: 'add more todos' }
]);
</script>
<button onclick={() => todos.push({ done: false, text: 'new todo' })}>
Add Todo
</button>
{#each todos as todo}
<div>
<input bind:checked={todo.done} type="checkbox" />
{todo.text}
</div>
{/each}객체와 배열을 deeply reactive하게 만들고 싶지 않을 때 사용합니다.
let person = $state.raw({
name: 'Heraclitus',
age: 49
});
// ❌ 이것은 효과 없음
person.age += 1;
// ✅ 이것은 작동함 (전체 객체 교체)
person = {
name: 'Heraclitus',
age: 50
};언제 사용하나요?
- 큰 배열이나 객체에서 성능 향상이 필요할 때
- 변경하지 않을 데이터를 다룰 때
- 반응성 비용을 피하고 싶을 때
deeply reactive $state proxy의 정적 스냅샷을 생성합니다.
let counter = $state({ count: 0 });
function onClick() {
// 'Proxy { ... }' 대신 '{ count: ... }'를 로그
console.log($state.snapshot(counter));
}언제 사용하나요?
- 외부 라이브러리나 API에 상태를 전달할 때
structuredClone같은 함수에 전달할 때- 디버깅 목적으로 현재 상태를 확인할 때
다른 상태로부터 파생된 상태를 선언합니다. 의존성이 변경될 때 자동으로 재계산됩니다.
let count = $state(0);
let doubled = $derived(count * 2);
let isEven = $derived(count % 2 === 0);복잡한 파생 로직을 위한 함수형 버전입니다.
let numbers = $state([1, 2, 3, 4, 5]);
let total = $derived.by(() => {
let sum = 0;
for (const n of numbers) {
sum += n;
}
return sum;
});
let statistics = $derived.by(() => {
const sum = numbers.reduce((a, b) => a + b, 0);
const avg = sum / numbers.length;
const max = Math.max(...numbers);
const min = Math.min(...numbers);
return { sum, avg, max, min };
});- 부작용 금지:
$derived내부에서는 상태를 변경하면 안됨 - 순수 함수: 같은 입력에 대해 항상 같은 출력을 반환해야 함
// ❌ 잘못된 사용
let count = $state(0);
let doubled = $derived(count++ * 2); // 상태 변경 금지!
// ✅ 올바른 사용
let count = $state(0);
let doubled = $derived(count * 2);상태가 업데이트될 때 실행되는 함수입니다. DOM 조작, API 호출, 서드파티 라이브러리 연동 등에 사용합니다.
let size = $state(50);
let color = $state('#ff3e00');
let canvas;
$effect(() => {
const context = canvas.getContext('2d');
context.clearRect(0, 0, canvas.width, canvas.height);
// size나 color가 변경될 때마다 재실행
context.fillStyle = color;
context.fillRect(0, 0, size, size);
});effect가 재실행되기 전에 실행될 정리 함수를 반환할 수 있습니다.
let milliseconds = $state(1000);
$effect(() => {
const interval = setInterval(() => {
count++;
}, milliseconds);
// cleanup 함수 - milliseconds가 변경되면 이전 interval 정리
return () => {
clearInterval(interval);
};
});DOM 업데이트가 적용되기 전에 실행되는 effect입니다.
let element;
$effect.pre(() => {
// DOM 업데이트 전에 요소의 크기나 위치 읽기
const rect = element.getBoundingClientRect();
console.log('Before update:', rect);
});- 상태 동기화:
$derived사용
// ❌ 잘못된 방법
let firstName = $state('John');
let lastName = $state('Doe');
let fullName = $state('');
$effect(() => {
fullName = `${firstName} ${lastName}`;
});
// ✅ 올바른 방법
let firstName = $state('John');
let lastName = $state('Doe');
let fullName = $derived(`${firstName} ${lastName}`);- 이벤트 핸들러에서 상태 업데이트: 직접 업데이트
// ❌ 불필요한 effect
let count = $state(0);
$effect(() => {
if (someCondition) {
count++;
}
});
// ✅ 이벤트 핸들러에서 직접
function handleClick() {
if (someCondition) {
count++;
}
}컴포넌트의 입력값(properties)을 받는 Rune입니다. Svelte 4의 export let을 대체합니다.
부모 컴포넌트:
<script>
import Button from './Button.svelte';
</script>
<Button variant="primary" disabled={false}>
Click me
</Button>자식 컴포넌트 (Button.svelte):
<script>
let { variant = 'default', disabled = false, children } = $props();
</script>
<button class="btn btn-{variant}" {disabled}>
{@render children()}
</button>1. 속성 이름 변경:
<script>
// 'class'는 JavaScript 키워드이므로 이름 변경
let { class: className, ...rest } = $props();
</script>
<div class={className} {...rest}>
<!-- content -->
</div>2. Rest Props (나머지 속성):
<script>
let { variant, disabled, ...rest } = $props();
</script>
<button class="btn btn-{variant}" {disabled} {...rest}>
<!-- 나머지 모든 속성을 button에 전달 -->
</button>3. TypeScript 타입 안전성:
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'danger';
disabled?: boolean;
children: Snippet;
onclick?: () => void;
}
let {
variant = 'primary',
disabled = false,
children,
onclick
}: ButtonProps = $props();일반적으로 props는 부모에서 자식으로의 단방향 흐름이지만, $bindable을 사용하면 자식에서 부모로도 데이터가 흐를 수 있습니다.
자식 컴포넌트 (FancyInput.svelte):
<script>
let { value = $bindable(), placeholder = '', ...rest } = $props();
</script>
<input bind:value {placeholder} {...rest} />
<style>
input {
border: 2px solid #ff3e00;
border-radius: 4px;
padding: 8px;
}
</style>부모 컴포넌트:
<script>
let message = $state('Hello World');
</script>
<FancyInput bind:value={message} placeholder="Enter message" />
<p>Current message: {message}</p><!-- CustomCheckbox.svelte -->
<script>
let {
checked = $bindable(false),
label,
disabled = false
} = $props();
</script>
<label class:disabled>
<input
type="checkbox"
bind:checked
{disabled}
/>
<span class="checkmark"></span>
{label}
</label>
<style>
label {
display: flex;
align-items: center;
cursor: pointer;
}
.checkmark {
width: 20px;
height: 20px;
border: 2px solid #ccc;
margin-right: 8px;
}
input:checked + .checkmark {
background-color: #ff3e00;
}
</style>사용:
<script>
let isAccepted = $state(false);
</script>
<CustomCheckbox
bind:checked={isAccepted}
label="I accept the terms and conditions"
/>
{#if isAccepted}
<p>Thank you for accepting!</p>
{/if}- 신중하게 사용: 양방향 바인딩은 데이터 흐름을 복잡하게 만들 수 있음
- 명확한 소유권: 어떤 컴포넌트가 상태를 '소유'하는지 명확히 해야 함
- 대안 고려: 대부분의 경우 이벤트 콜백이 더 명확할 수 있음
개발 중에만 작동하는 디버깅 도구입니다. 프로덕션 빌드에서는 자동으로 제거됩니다.
let count = $state(0);
let message = $state('hello');
// count나 message가 변경될 때마다 console.log
$inspect(count, message);1. 커스텀 로깅:
$inspect(count, message).with((type, ...values) => {
if (type === 'init') {
console.log('🚀 초기값:', values);
} else if (type === 'update') {
console.log('🔄 업데이트:', values);
}
});2. 스택 트레이스:
// 값이 어디서 변경되었는지 추적
$inspect.trace(count);<script>
let user = $state({
name: 'John',
preferences: {
theme: 'dark',
notifications: true
}
});
// 사용자 객체의 모든 변경사항 추적
$inspect(user).with((type, value) => {
console.group(`User ${type}`);
console.log('Current state:', JSON.stringify(value, null, 2));
console.groupEnd();
});
function updateTheme(newTheme) {
user.preferences.theme = newTheme;
}
</script>
<button onclick={() => updateTheme('light')}>
Switch to Light Theme
</button>컴포넌트를 custom element로 컴파일할 때 host element에 접근할 수 있게 해주는 Rune입니다.
Stepper.svelte (Custom Element):
<svelte:options customElement="my-stepper" />
<script>
let count = $state(0);
function dispatch(type, detail = {}) {
$host().dispatchEvent(new CustomEvent(type, { detail }));
}
function increment() {
count++;
dispatch('increment', { count });
}
function decrement() {
count--;
dispatch('decrement', { count });
}
</script>
<div class="stepper">
<button onclick={decrement}>-</button>
<span>{count}</span>
<button onclick={increment}>+</button>
</div>
<style>
.stepper {
display: flex;
align-items: center;
gap: 8px;
}
button {
width: 32px;
height: 32px;
border: 1px solid #ccc;
background: white;
cursor: pointer;
}
</style>HTML에서 사용:
<script>
const stepper = document.querySelector('my-stepper');
stepper.addEventListener('increment', (e) => {
console.log('Incremented to:', e.detail.count);
});
stepper.addEventListener('decrement', (e) => {
console.log('Decremented to:', e.detail.count);
});
</script>
<my-stepper></my-stepper>// stores/theme.svelte.js
export const theme = $state({
mode: 'light',
primaryColor: '#ff3e00'
});
export function toggleTheme() {
theme.mode = theme.mode === 'light' ? 'dark' : 'light';
}// stores/userStore.svelte.js
class UserStore {
user = $state(null);
isLoading = $state(false);
error = $state(null);
async login(credentials) {
this.isLoading = true;
this.error = null;
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
});
if (!response.ok) {
throw new Error('Login failed');
}
this.user = await response.json();
} catch (err) {
this.error = err.message;
} finally {
this.isLoading = false;
}
}
logout() {
this.user = null;
this.error = null;
}
get isLoggedIn() {
return this.user !== null;
}
}
export const userStore = new UserStore();<!-- ContactForm.svelte -->
<script>
let formData = $state({
name: '',
email: '',
message: ''
});
let errors = $state({});
let isSubmitting = $state(false);
// 실시간 유효성 검사
let isValid = $derived(
formData.name.length > 0 &&
formData.email.includes('@') &&
formData.message.length > 10
);
// 에러 메시지 계산
let nameError = $derived(
formData.name.length === 0 ? 'Name is required' : null
);
let emailError = $derived(
!formData.email.includes('@') ? 'Valid email is required' : null
);
async function handleSubmit() {
if (!isValid) return;
isSubmitting = true;
try {
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
if (response.ok) {
// 성공 시 폼 리셋
formData = { name: '', email: '', message: '' };
alert('Message sent successfully!');
}
} catch (error) {
alert('Failed to send message');
} finally {
isSubmitting = false;
}
}
</script>
<form onsubmit={handleSubmit}>
<div class="field">
<label for="name">Name</label>
<input
id="name"
bind:value={formData.name}
class:error={nameError}
/>
{#if nameError}
<span class="error-message">{nameError}</span>
{/if}
</div>
<div class="field">
<label for="email">Email</label>
<input
id="email"
type="email"
bind:value={formData.email}
class:error={emailError}
/>
{#if emailError}
<span class="error-message">{emailError}</span>
{/if}
</div>
<div class="field">
<label for="message">Message</label>
<textarea
id="message"
bind:value={formData.message}
rows="4"
></textarea>
</div>
<button
type="submit"
disabled={!isValid || isSubmitting}
>
{isSubmitting ? 'Sending...' : 'Send Message'}
</button>
</form>
<style>
.field {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
}
input, textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
}
.error {
border-color: #ff0000;
}
.error-message {
color: #ff0000;
font-size: 0.875rem;
margin-top: 0.25rem;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>// stores/dataStore.svelte.js
class DataStore {
cache = $state(new Map());
loading = $state(new Set());
async fetchData(key, fetcher) {
// 캐시에 있으면 반환
if (this.cache.has(key)) {
return this.cache.get(key);
}
// 이미 로딩 중이면 대기
if (this.loading.has(key)) {
return new Promise((resolve) => {
const checkCache = () => {
if (this.cache.has(key)) {
resolve(this.cache.get(key));
} else {
setTimeout(checkCache, 100);
}
};
checkCache();
});
}
// 새로운 데이터 페칭
this.loading.add(key);
try {
const data = await fetcher();
this.cache.set(key, data);
return data;
} finally {
this.loading.delete(key);
}
}
invalidate(key) {
this.cache.delete(key);
}
isLoading(key) {
return this.loading.has(key);
}
}
export const dataStore = new DataStore();Svelte 4:
<script>
let count = 0;
let user = { name: 'John' };
</script>Svelte 5:
<script>
let count = $state(0);
let user = $state({ name: 'John' });
</script>Svelte 4:
<script>
let count = 0;
$: doubled = count * 2;
$: {
if (count > 5) {
alert('Count is too high!');
}
}
</script>Svelte 5:
<script>
let count = $state(0);
let doubled = $derived(count * 2);
$effect(() => {
if (count > 5) {
alert('Count is too high!');
}
});
</script>Svelte 4:
<script>
export let title = 'Default Title';
export let items = [];
export let onItemClick = () => {};
</script>Svelte 5:
<script>
let {
title = 'Default Title',
items = [],
onItemClick = () => {}
} = $props();
</script>Svelte 4:
// store.js
import { writable, derived } from 'svelte/store';
export const count = writable(0);
export const doubled = derived(count, $count => $count * 2);Svelte 5:
// store.svelte.js
export const state = $state({ count: 0 });
export const doubled = $derived(state.count * 2);Svelte 5는 기존 Svelte 4 구문과 호환됩니다. 다음과 같이 점진적으로 마이그레이션할 수 있습니다:
- 새 컴포넌트부터 Runes 사용
- 기존 컴포넌트는 필요에 따라 점진적 업데이트
- Svelte 4와 Svelte 5 컴포넌트 혼용 가능
Svelte 5의 Runes는 더 명시적이고 예측 가능한 반응형 시스템을 제공합니다. 주요 장점은:
- Universal Reactivity: 컴포넌트 내외부에서 동일한 반응형 시스템 사용
- 명시적 API: 무엇이 반응형인지 명확하게 표현
- 성능 향상: 더 효율적인 신호 기반 시스템
- TypeScript 친화적: 타입 안전성 향상
- 디버깅 개선: 더 나은 개발자 경험
각 Rune을 적절한 상황에서 사용하면 더 깔끔하고 유지보수하기 쉬운 Svelte 애플리케이션을 만들 수 있습니다.
앞서 설명한 기본 개념을 바탕으로, 실제 애플리케이션에서 상태를 어떻게 구조화할지 결정하는 것이 중요합니다.
- UI 상태: 모달 열림/닫힘, 폼 입력값, 로딩 상태 등
- 일시적 상태: 애니메이션 상태, 임시 계산값 등
- 컴포넌트별 고유 상태: 각 인스턴스마다 다른 값을 가져야 하는 경우
<!-- TodoItem.svelte -->
<script>
let { todo, onToggle, onDelete } = $props();
// 컴포넌트별 UI 상태
let isEditing = $state(false);
let editText = $state('');
function startEdit() {
isEditing = true;
editText = todo.text;
}
function saveEdit() {
if (editText.trim()) {
todo.text = editText.trim();
isEditing = false;
}
}
</script>
<div class="todo-item">
{#if isEditing}
<input
bind:value={editText}
onkeydown={(e) => e.key === 'Enter' && saveEdit()}
/>
<button onclick={saveEdit}>Save</button>
{:else}
<span
class:completed={todo.completed}
ondblclick={startEdit}
>
{todo.text}
</span>
{/if}
<button onclick={() => onToggle(todo.id)}>
{todo.completed ? '↶' : '✓'}
</button>
<button onclick={() => onDelete(todo.id)}>🗑</button>
</div>- 애플리케이션 전체 설정: 테마, 언어, 사용자 설정 등
- 사용자 데이터: 로그인 정보, 프로필 등
- 공유 데이터: 여러 컴포넌트에서 접근해야 하는 데이터
- 캐시된 데이터: API 응답, 계산 결과 등
재사용 가능한 상태 로직을 만들 때 유용합니다.
// composables/useCounter.svelte.js
export function useCounter(initialValue = 0, step = 1) {
let count = $state(initialValue);
const increment = () => count += step;
const decrement = () => count -= step;
const reset = () => count = initialValue;
const set = (value) => count = value;
return {
get count() { return count; },
increment,
decrement,
reset,
set
};
}<!-- 사용 예제 -->
<script>
import { useCounter } from './composables/useCounter.svelte.js';
const counter1 = useCounter(0, 1);
const counter2 = useCounter(100, 10);
</script>
<div>
<h3>Counter 1: {counter1.count}</h3>
<button onclick={counter1.increment}>+1</button>
<button onclick={counter1.decrement}>-1</button>
</div>
<div>
<h3>Counter 2: {counter2.count}</h3>
<button onclick={counter2.increment}>+10</button>
<button onclick={counter2.decrement}>-10</button>
</div>복잡한 상태 전환을 관리할 때 유용합니다.
// stores/authStore.svelte.js
class AuthStore {
state = $state('idle'); // 'idle' | 'loading' | 'authenticated' | 'error'
user = $state(null);
error = $state(null);
get isIdle() { return this.state === 'idle'; }
get isLoading() { return this.state === 'loading'; }
get isAuthenticated() { return this.state === 'authenticated'; }
get hasError() { return this.state === 'error'; }
async login(credentials) {
if (this.state === 'loading') return;
this.state = 'loading';
this.error = null;
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
});
if (!response.ok) {
throw new Error('Invalid credentials');
}
this.user = await response.json();
this.state = 'authenticated';
} catch (err) {
this.error = err.message;
this.state = 'error';
}
}
logout() {
this.user = null;
this.error = null;
this.state = 'idle';
}
clearError() {
if (this.state === 'error') {
this.state = 'idle';
this.error = null;
}
}
}
export const authStore = new AuthStore();상태 변화를 구독하고 반응하는 패턴입니다.
// stores/eventStore.svelte.js
class EventStore {
listeners = new Map();
subscribe(event, callback) {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event).add(callback);
// 구독 해제 함수 반환
return () => {
this.listeners.get(event)?.delete(callback);
};
}
emit(event, data) {
this.listeners.get(event)?.forEach(callback => {
callback(data);
});
}
}
export const eventStore = new EventStore();
// 사용 예제
class NotificationStore {
notifications = $state([]);
constructor() {
// 이벤트 구독
eventStore.subscribe('user:login', (user) => {
this.add(`Welcome back, ${user.name}!`, 'success');
});
eventStore.subscribe('api:error', (error) => {
this.add(error.message, 'error');
});
}
add(message, type = 'info') {
const id = Date.now();
this.notifications.push({ id, message, type });
// 3초 후 자동 제거
setTimeout(() => this.remove(id), 3000);
}
remove(id) {
this.notifications = this.notifications.filter(n => n.id !== id);
}
}
export const notificationStore = new NotificationStore();큰 데이터셋이나 자주 변경되지 않는 데이터에 사용합니다.
// stores/dataStore.svelte.js
class DataStore {
// 큰 읽기 전용 데이터
staticData = $state.raw({
countries: [...], // 큰 배열
categories: [...],
constants: {...}
});
// 자주 변경되는 작은 상태
currentPage = $state(1);
filters = $state({});
// 필터링된 데이터 (derived로 계산)
filteredData = $derived.by(() => {
return this.staticData.countries.filter(country => {
// 복잡한 필터링 로직
return Object.entries(this.filters).every(([key, value]) => {
return !value || country[key] === value;
});
});
});
}// stores/lazyDataStore.svelte.js
class LazyDataStore {
cache = $state(new Map());
loading = $state(new Set());
async getData(key) {
// 캐시에서 확인
if (this.cache.has(key)) {
return this.cache.get(key);
}
// 이미 로딩 중인지 확인
if (this.loading.has(key)) {
// 로딩 완료까지 대기
while (this.loading.has(key)) {
await new Promise(resolve => setTimeout(resolve, 50));
}
return this.cache.get(key);
}
// 새로운 데이터 로딩
this.loading.add(key);
try {
const data = await fetch(`/api/data/${key}`).then(r => r.json());
this.cache.set(key, data);
return data;
} finally {
this.loading.delete(key);
}
}
invalidate(key) {
this.cache.delete(key);
}
isLoading(key) {
return this.loading.has(key);
}
}
export const lazyDataStore = new LazyDataStore();Runes 기반 상태를 테스트하는 방법입니다.
// stores/counterStore.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import { CounterStore } from './counterStore.svelte.js';
describe('CounterStore', () => {
let store;
beforeEach(() => {
store = new CounterStore();
});
it('should initialize with zero', () => {
expect(store.count).toBe(0);
});
it('should increment count', () => {
store.increment();
expect(store.count).toBe(1);
});
it('should decrement count', () => {
store.decrement();
expect(store.count).toBe(-1);
});
it('should reset count', () => {
store.increment();
store.increment();
store.reset();
expect(store.count).toBe(0);
});
});// stores/debuggableStore.svelte.js
class DebuggableStore {
state = $state({
user: null,
posts: [],
loading: false
});
constructor() {
// 개발 환경에서만 디버깅 활성화
if (import.meta.env.DEV) {
$inspect(this.state).with((type, value) => {
console.group(`🔍 Store ${type}`);
console.log('State:', JSON.stringify(value, null, 2));
console.trace('Stack trace');
console.groupEnd();
});
}
}
updateUser(user) {
this.state.user = user;
}
addPost(post) {
this.state.posts.push(post);
}
}- 명확한 소유권: 각 상태가 어느 컴포넌트나 스토어에 속하는지 명확히 정의
- 최소한의 전역 상태: 정말 필요한 경우에만 전역 상태 사용
- 단방향 데이터 흐름: 가능한 한 props와 이벤트로 데이터 전달
- 성능 고려: 큰 데이터나 자주 변경되지 않는 데이터는
$state.raw사용 - 타입 안전성: TypeScript와 함께 사용하여 타입 안전성 확보
- 테스트 가능성: 상태 로직을 별도 클래스나 함수로 분리하여 테스트 용이성 확보
이러한 패턴들을 조합하여 복잡한 애플리케이션의 상태를 효과적으로 관리할 수 있습니다.