Skip to main content
Summary

상태 관리는 "어디에 둘 것인가"의 문제다

지금까지는 대부분 한 컴포넌트 안에서 상태를 만들었다. 예를 들어 검색어를 입력받는 화면이라면 다음처럼 작성할 수 있다.

<script>
	let keyword = $state('');
</script>

<input bind:value={keyword} placeholder="검색어" />
<p>검색어: {keyword}</p>

이 상태는 이 컴포넌트 안에서만 필요하다. 이런 값은 밖으로 꺼낼 이유가 없다. 컴포넌트 내부에 두는 편이 가장 단순하다.

문제는 컴포넌트가 늘어날 때 시작된다. 상품 목록 컴포넌트에서 장바구니에 상품을 담고, 헤더 컴포넌트에서는 장바구니 개수를 보여 주고, 결제 컴포넌트에서는 총액을 계산해야 한다면 어떨까? 같은 데이터를 여러 곳에서 읽고 바꿔야 한다.

이때 상태 관리가 필요하다. 상태 관리는 어려운 기술 이름이 아니라, 결국 다음 질문에 답하는 일이다.

이 데이터는 어느 컴포넌트가 소유해야 하는가?

처음부터 모든 상태를 전역으로 만들면 편해 보인다. 하지만 전역 상태가 많아질수록 "누가 언제 이 값을 바꿨는지" 추적하기 어려워진다. 좋은 상태 관리는 store를 많이 쓰는 것이 아니라, 필요한 곳에만 쓰는 것이다.

상태 관리와 store 제대로 사용하기AI 생성 이미지

지역 상태, 부모 상태, 전역 상태 구분하기

상태를 둘 위치는 보통 세 단계로 나눠 생각하면 된다.

첫째, 지역 상태다. 한 컴포넌트 안에서만 쓰는 값이다. 입력창의 열림 여부, 탭 선택 상태, 임시 폼 값처럼 다른 컴포넌트가 몰라도 되는 값은 $state로 컴포넌트 안에 둔다.

<script>
	let isOpen = $state(false);
</script>

<button onclick={() => isOpen = !isOpen}>
	필터 열기
</button>

{#if isOpen}
	<section>필터 옵션</section>
{/if}

둘째, 부모 상태다. 부모와 여러 자식 컴포넌트가 함께 써야 하는 값이다. 예를 들어 할 일 목록 페이지에서 TodoInput, TodoList, TodoSummary가 같은 할 일 배열을 사용한다면, 그 배열은 공통 부모인 페이지 컴포넌트에 두는 것이 자연스럽다. 자식은 props로 값을 받고, 필요하면 콜백 함수를 호출해 부모에게 변경을 요청한다.

<script>
	import TodoInput from './TodoInput.svelte';
	import TodoList from './TodoList.svelte';

	let todos = $state([]);

	function addTodo(title) {
		todos.push({ id: crypto.randomUUID(), title, done: false });
	}
</script>

<TodoInput onAdd={addTodo} />
<TodoList {todos} />

셋째, 전역 상태다. 서로 멀리 떨어진 컴포넌트가 같은 데이터를 공유해야 할 때 사용한다. 로그인한 사용자 정보, 장바구니, 앱 테마, 알림 메시지처럼 여러 화면에서 동시에 필요한 값이 여기에 해당한다.

이때 store나 Svelte 5의 모듈 상태를 고려한다. 기준은 간단하다.

  • 한 컴포넌트에서만 필요하면 지역 상태

  • 가까운 자식들과만 공유하면 부모 상태

  • 여러 페이지나 멀리 떨어진 컴포넌트가 함께 쓰면 전역 상태

store는 강력하지만 기본 선택지는 아니다. 먼저 상태의 소유자를 정하고, props로 충분하지 않을 때 store를 사용한다.

Svelte store의 기본 형태

Svelte에는 오래전부터 제공되어 온 store API가 있다. 대표적으로 writable, readable, derived가 있다.

  • writable: 읽고 쓸 수 있는 상태

  • readable: 외부에서는 읽기만 가능한 상태

  • derived: 다른 store에서 계산되는 상태

store는 보통 별도 파일에 만든다. 예를 들어 src/lib/stores/cart.js 파일을 만들 수 있다.

// src/lib/stores/cart.js
import { writable } from 'svelte/store';

export const cartItems = writable([]);

컴포넌트에서는 이 store를 가져와 사용할 수 있다.

<script>
	import { cartItems } from '$lib/stores/cart.js';

	function addSampleItem() {
		cartItems.update((items) => [
			...items,
			{ id: crypto.randomUUID(), name: '무선 마우스', price: 25000, quantity: 1 }
		]);
	}
</script>

<button onclick={addSampleItem}>담기</button>

<p>장바구니에 담긴 줄 수: {$cartItems.length}</p>

여기서 $cartItems는 store 값을 편하게 읽기 위한 Svelte의 자동 구독 문법이다. cartItems 자체는 store 객체이고, $cartItems는 현재 store 안에 들어 있는 값이라고 생각하면 된다.

자동 구독 문법은 .svelte 컴포넌트 안에서 사용할 때 가장 편하다. 일반 .js 파일에서는 $cartItems처럼 쓰는 대신 subscribe, set, update 같은 store API를 사용한다고 생각하면 된다.

값을 직접 바꿀 때는 set을 쓴다.

cartItems.set([]);

기존 값을 바탕으로 새 값을 만들 때는 update를 쓴다.

cartItems.update((items) => [...items, newItem]);

장바구니처럼 배열을 다룰 때는 update가 자주 쓰인다. 기존 배열을 받아 새 배열을 반환하기 때문이다.

writable: 여러 곳에서 바뀌는 상태

writable은 가장 많이 쓰는 store다. 사용자가 직접 변경할 수 있는 값에 적합하다.

장바구니 store를 조금 더 실제처럼 만들어 보자.

// src/lib/stores/cart.js
import { writable } from 'svelte/store';

export const cartItems = writable([]);

export function addToCart(product) {
	cartItems.update((items) => {
		const found = items.find((item) => item.id === product.id);

		if (found) {
			return items.map((item) =>
				item.id === product.id
					? { ...item, quantity: item.quantity + 1 }
					: item
			);
		}

		return [...items, { ...product, quantity: 1 }];
	});
}

export function removeFromCart(id) {
	cartItems.update((items) => items.filter((item) => item.id !== id));
}

export function clearCart() {
	cartItems.set([]);
}

이 파일은 단순히 값만 내보내지 않는다. 장바구니를 바꾸는 함수도 함께 내보낸다. 이렇게 하면 여러 컴포넌트가 장바구니를 직접 제각각 수정하지 않고, 정해진 함수로만 수정하게 만들 수 있다.

상품 카드에서는 상품을 담는다.

<script>
	import { addToCart } from '$lib/stores/cart.js';

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

<article>
	<h3>{product.name}</h3>
	<p>{product.price.toLocaleString()}원</p>
	<button onclick={() => addToCart(product)}>장바구니 담기</button>
</article>

헤더에서는 장바구니에 담긴 상품 종류 수를 읽는다.

<script>
	import { cartItems } from '$lib/stores/cart.js';
</script>

<a href="/cart">장바구니 {$cartItems.length}</a>

같은 store를 가져왔기 때문에 상품 카드에서 담은 결과가 헤더에도 반영된다. 이것이 store를 사용하는 대표적인 이유다. 다만 여기서 $cartItems.length는 배열의 길이이므로 "상품 종류 수"에 가깝다. 같은 상품을 여러 개 담았을 때의 총 수량은 잠시 뒤에 derived로 따로 계산해 보겠다.

derived: store에서 계산되는 값

장바구니에는 계산값이 많다. 상품 개수, 총액, 세금 포함 가격 같은 값은 직접 저장하지 않는 편이 좋다. 원본 데이터는 장바구니 항목이고, 나머지는 그 항목에서 계산할 수 있기 때문이다.

이럴 때 derived를 사용한다.

// src/lib/stores/cart.js
import { writable, derived } from 'svelte/store';

export const cartItems = writable([]);

export const cartTotal = derived(cartItems, ($cartItems) => {
	return $cartItems.reduce((sum, item) => {
		return sum + item.price * item.quantity;
	}, 0);
});

export const cartCount = derived(cartItems, ($cartItems) => {
	return $cartItems.reduce((sum, item) => sum + item.quantity, 0);
});

export const taxIncludedTotal = derived(cartTotal, ($cartTotal) => {
	return Math.round($cartTotal * 1.1);
});

cartTotal은 cartItems가 바뀔 때마다 다시 계산된다. cartCount는 각 상품의 quantity를 모두 더하므로 장바구니의 총 수량을 나타낸다. taxIncludedTotal은 cartTotal에서 다시 계산된다. 이렇게 연결해 두면 총액을 매번 직접 갱신할 필요가 없다.

컴포넌트에서는 일반 store처럼 읽으면 된다.

<script>
	import { cartCount, cartTotal, taxIncludedTotal } from '$lib/stores/cart.js';
</script>

<p>총 수량: {$cartCount}개</p>
<p>상품 금액: {$cartTotal.toLocaleString()}원</p>
<p>세금 포함: {$taxIncludedTotal.toLocaleString()}원</p>

계산값을 상태로 따로 저장하면 버그가 생기기 쉽다. 예를 들어 상품을 삭제했는데 총액을 다시 계산하지 않으면 화면이 틀어진다. derived는 이런 문제를 줄여 준다. 원본은 하나만 두고, 나머지는 원본에서 계산한다.

필터링된 목록도 derived로 만들 수 있다

검색어와 상품 목록이 모두 store라면, 필터링된 목록도 derived로 만들 수 있다. 물론 검색어가 한 컴포넌트 안에서만 필요하다면 지역 상태로 두면 된다. 아래 예제는 검색 조건과 목록을 여러 컴포넌트가 함께 쓴다고 가정한 경우다.

// src/lib/stores/products.js
import { writable, derived } from 'svelte/store';

export const products = writable([
	{ id: 1, name: '무선 마우스', category: 'device', price: 25000 },
	{ id: 2, name: '기계식 키보드', category: 'device', price: 89000 },
	{ id: 3, name: 'Svelte 입문서', category: 'book', price: 18000 }
]);

export const keyword = writable('');
export const selectedCategory = writable('all');

export const filteredProducts = derived(
	[products, keyword, selectedCategory],
	([$products, $keyword, $selectedCategory]) => {
		const lowerKeyword = $keyword.trim().toLowerCase();

		return $products.filter((product) => {
			const matchesKeyword = product.name.toLowerCase().includes(lowerKeyword);
			const matchesCategory =
				$selectedCategory === 'all' || product.category === $selectedCategory;

			return matchesKeyword && matchesCategory;
		});
	}
);

여기서 filteredProducts는 세 store에 의존한다.

  • products가 바뀌면 다시 계산된다.

  • keyword가 바뀌면 다시 계산된다.

  • selectedCategory가 바뀌면 다시 계산된다.

검색 컴포넌트는 검색어와 카테고리만 바꾼다.

<script>
	import { keyword, selectedCategory } from '$lib/stores/products.js';
</script>

<input bind:value={$keyword} placeholder="상품 검색" />

<select bind:value={$selectedCategory}>
	<option value="all">전체</option>
	<option value="device">기기</option>
	<option value="book">도서</option>
</select>

목록 컴포넌트는 필터링 결과만 읽는다.

<script>
	import { filteredProducts } from '$lib/stores/products.js';
</script>

{#each $filteredProducts as product (product.id)}
	<p>{product.name} - {product.price.toLocaleString()}원</p>
{:else}
	<p>조건에 맞는 상품이 없습니다.</p>
{/each}

이 구조의 장점은 역할이 분리된다는 점이다. 입력 컴포넌트는 검색 조건을 바꾸고, 목록 컴포넌트는 결과를 보여 준다. 필터링 규칙은 store 파일에 모인다.

readable: 외부에서는 바꾸면 안 되는 값

readable은 읽기 전용 store다. 컴포넌트가 값을 읽을 수는 있지만 직접 set하거나 update할 수 없다.

예를 들어 현재 시간을 보여 주는 store를 만들 수 있다.

// src/lib/stores/time.js
import { readable } from 'svelte/store';

export const now = readable(new Date(), (set) => {
	const interval = setInterval(() => {
		set(new Date());
	}, 1000);

	return () => {
		clearInterval(interval);
	};
});

컴포넌트에서는 읽기만 한다.

<script>
	import { now } from '$lib/stores/time.js';
</script>

<p>현재 시각: {$now.toLocaleTimeString()}</p>

readable은 외부 데이터와 연결할 때 유용하다. 시간, 브라우저 온라인 상태, WebSocket으로 받은 값처럼 컴포넌트가 직접 바꾸면 안 되는 데이터에 어울린다.

읽기 전용이라는 점이 중요하다. 어떤 값이 앱 내부의 여러 곳에서 마음대로 바뀌면 흐름을 이해하기 어렵다. 반대로 "이 값은 이 store 내부 로직으로만 바뀐다"고 정하면 사용처가 단순해진다.

localStorage와 store 함께 사용하기

장바구니는 새로고침 후에도 유지되는 편이 좋다. 브라우저에는 localStorage라는 간단한 저장 공간이 있다. 문자열 데이터를 저장해 두면 새로고침 후에도 다시 읽을 수 있다.

다만 localStorage는 브라우저에만 있다. SvelteKit에서는 서버에서 코드가 실행될 수도 있으므로, 브라우저인지 확인한 뒤 사용해야 안전하다.

// src/lib/stores/persistentCart.js
import { browser } from '$app/environment';
import { writable, derived } from 'svelte/store';

const STORAGE_KEY = 'cart-items';

function loadInitialItems() {
	if (!browser) return [];

	try {
		return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]');
	} catch {
		return [];
	}
}

function createCartStore() {
	const { subscribe, set, update } = writable(loadInitialItems());

	if (browser) {
		subscribe((items) => {
			localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
		});
	}

	return {
		subscribe,
		add(product) {
			update((items) => {
				const found = items.find((item) => item.id === product.id);

				if (found) {
					return items.map((item) =>
						item.id === product.id
							? { ...item, quantity: item.quantity + 1 }
							: item
					);
				}

				return [...items, { ...product, quantity: 1 }];
			});
		},
		remove(id) {
			update((items) => items.filter((item) => item.id !== id));
		},
		clear() {
			set([]);
		}
	};
}

export const cart = createCartStore();

export const cartTotal = derived(cart, ($cart) =>
	$cart.reduce((sum, item) => sum + item.price * item.quantity, 0)
);

이 코드는 네 가지 일을 한다.

  1. 브라우저에서 실행될 때 localStorage에서 저장된 장바구니를 읽는다.

  2. 저장된 값이 깨져 있어도 앱이 바로 멈추지 않도록 빈 배열로 시작한다.

  3. store 값이 바뀔 때마다 localStorage에 다시 저장한다.

  4. 컴포넌트에는 add, remove, clear 같은 의도가 드러나는 함수만 제공한다.

컴포넌트에서는 이렇게 쓴다.

<script>
	import { cart, cartTotal } from '$lib/stores/persistentCart.js';
</script>

{#each $cart as item (item.id)}
	<p>{item.name} × {item.quantity}</p>
{/each}

<p>총액: {$cartTotal.toLocaleString()}원</p>
<button onclick={() => cart.clear()}>비우기</button>

여기서 cart는 일반 writable처럼 $cart로 읽을 수 있다. 이유는 반환 객체에 subscribe가 있기 때문이다. Svelte store는 subscribe 메서드를 가진 객체로 동작한다. 여기에 add, remove, clear 같은 메서드를 덧붙이면 "사용하기 쉬운 전용 store"를 만들 수 있다.

주의할 점도 있다. subscribe 안에서 매번 localStorage에 저장하면 값이 바뀔 때마다 쓰기 작업이 일어난다. 장바구니처럼 크기가 작고 변경이 많지 않은 데이터에는 괜찮다. 하지만 큰 데이터나 매우 자주 바뀌는 값이라면 저장 시점을 조절해야 한다.

또 하나 기억할 점은 서버 렌더링이다. 위 예제는 서버에서는 빈 장바구니로 시작하고, 브라우저에서 localStorage 값을 읽는다. 그래서 새로고침 직후 아주 잠깐 빈 값이 보인 뒤 브라우저에서 저장된 값으로 바뀌는 흐름이 생길 수 있다. 장바구니처럼 사용자 브라우저에만 저장해도 되는 값에는 실용적이지만, 서버 렌더링 결과부터 정확해야 하는 값이라면 쿠키, 데이터베이스, SvelteKit의 load 같은 방식을 함께 고려해야 한다.

Svelte 5에서는 .svelte.js 모듈 상태도 가능하다

Svelte 5에서는 store만이 전역 상태의 유일한 선택지가 아니다. 파일 이름을 .svelte.js 또는 .svelte.ts로 만들면 컴포넌트 밖의 모듈에서도 runes를 사용할 수 있다. 즉 $state, $derived를 별도 파일에서 사용할 수 있다.1

예를 들어 다음처럼 장바구니 모듈을 만들 수 있다.

// src/lib/state/cart.svelte.js
let items = $state([]);

let total = $derived(
	items.reduce((sum, item) => sum + item.price * item.quantity, 0)
);

export const cart = {
	get items() {
		return items;
	},
	get total() {
		return total;
	},
	add(product) {
		const found = items.find((item) => item.id === product.id);

		if (found) {
			found.quantity += 1;
			return;
		}

		items.push({ ...product, quantity: 1 });
	},
	remove(id) {
		items = items.filter((item) => item.id !== id);
	},
	clear() {
		items = [];
	}
};

컴포넌트에서는 store의 $cart 문법을 쓰지 않는다. 대신 가져온 객체의 속성과 메서드를 그대로 사용한다.

<script>
	import { cart } from '$lib/state/cart.svelte.js';
</script>

<button onclick={() => cart.add(product)}>담기</button>

<p>장바구니 개수: {cart.items.length}</p>
<p>총액: {cart.total.toLocaleString()}원</p>

이 방식은 Svelte 5의 runes 흐름과 잘 맞는다. 객체와 배열의 깊은 반응성을 활용할 수 있고, items.push()나 found.quantity += 1 같은 변경도 자연스럽게 화면에 반영된다.

다만 중요한 규칙이 있다. 모듈에서 반응형 원시값을 직접 export한 뒤 그 변수를 다시 할당하는 방식은 사용할 수 없다. 예를 들어 export let count = $state(0)로 내보내고 같은 파일에서 count += 1처럼 바꾸는 식은 문제가 된다. Svelte 컴파일러가 파일 단위로 코드를 변환하기 때문에, 다른 파일에서 그 값을 가져왔을 때 기대한 방식으로 읽기와 쓰기를 추적하기 어렵기 때문이다.2

그래서 모듈 상태를 공유할 때는 보통 두 가지 패턴을 쓴다.

// 패턴 1: 객체를 export하고, 객체의 속성을 바꾼다.
export const counter = $state({ count: 0 });

export function increment() {
	counter.count += 1;
}

또는 다음처럼 직접 export하지 않고 함수나 getter로 노출한다.

// 패턴 2: 상태 변수는 모듈 안에 숨기고, 함수로 접근한다.
let count = $state(0);

export function getCount() {
	return count;
}

export function increment() {
	count += 1;
}

앞의 장바구니 예제는 두 번째 패턴에 가깝다. items와 total은 모듈 안에 두고, 외부에는 cart.items, cart.total, cart.add()처럼 의미가 드러나는 모양으로 제공한다.

store와 .svelte.js 상태 중 무엇을 쓸까?

Svelte 5를 기준으로 보면 선택지는 두 가지다.

첫째, 기존 store를 사용한다.

import { writable, derived } from 'svelte/store';

이 방식은 여전히 유효하다. 특히 다음 상황에 잘 맞는다.

  • 기존 Svelte 자료나 라이브러리와 store 형태로 연동해야 할 때

  • $store 자동 구독 문법이 편할 때

  • readable, derived처럼 store API의 구조가 문제에 잘 맞을 때

  • 팀이 이미 store 패턴에 익숙할 때

둘째, .svelte.js 또는 .svelte.ts에서 runes 기반 모듈 상태를 만든다.

let user = $state({ name: '', email: '' });

이 방식은 다음 상황에 잘 맞는다.

  • Svelte 5의 $state, $derived 문법으로 상태를 통일하고 싶을 때

  • 객체나 배열을 깊은 반응성으로 다루고 싶을 때

  • store의 set, update보다 일반 객체 메서드 형태가 더 자연스러울 때

  • 앱 내부 전용 상태를 간단한 모듈로 공유하고 싶을 때

둘 중 하나만 정답은 아니다. 이 책에서는 Svelte 5를 중심으로 배우지만, store를 알아야 기존 예제와 라이브러리 문서를 읽을 수 있다. 또한 store는 여전히 명확하고 실용적인 도구다.

중요한 기준은 문법이 아니라 상태의 범위다. 상태가 어디에서 필요하고, 누가 바꿔야 하며, 어떤 값이 원본인지 먼저 정해야 한다. 그다음에 store나 .svelte.js 모듈 상태를 선택한다.

전역 상태를 사용할 때 주의할 점

전역 상태는 편하지만 신중해야 한다.

첫째, 전역 상태는 앱의 여러 부분을 서로 연결한다. 장바구니처럼 실제로 공유되어야 하는 값에는 적합하다. 하지만 단순한 입력창 상태까지 전역으로 만들면 컴포넌트의 독립성이 줄어든다.

둘째, 계산값을 원본 상태처럼 저장하지 않는다. 총액, 필터링 결과, 세금 포함 가격은 원본 데이터에서 계산한다. store에서는 derived, runes에서는 $derived를 사용한다.

셋째, 브라우저 API와 연결할 때는 실행 환경을 확인한다. localStorage, window, document는 브라우저에만 있다. SvelteKit에서는 browser 값을 확인하거나, 컴포넌트가 브라우저에서 실행된 뒤 처리해야 한다.

넷째, 서버 렌더링 환경에서 모듈 전역 상태를 조심한다. SvelteKit 앱은 서버에서도 코드를 실행할 수 있다. 서버의 모듈 전역 상태는 요청마다 새로 만들어지는 지역 상태가 아니라, 서버 프로세스 안에서 여러 사용자에게 공유될 수 있다. 로그인 사용자 정보처럼 요청마다 달라지는 값을 단순한 모듈 전역 변수나 전역 store에 저장하면, 한 사용자의 데이터가 다른 사용자에게 보이는 심각한 문제가 생길 수 있다.3

사용자별로 분리되어야 하는 데이터는 이후 SvelteKit의 데이터 로딩과 서버 기능을 배울 때 더 안전한 방식으로 다룬다. 지금은 "브라우저에서만 쓰는 UI 상태"와 "서버 요청마다 달라지는 사용자 데이터"를 구분해야 한다는 점만 기억하면 된다.

정리

상태 관리의 핵심은 store 사용법을 외우는 것이 아니다. 상태를 어디에 둘지 결정하는 능력이다.

한 컴포넌트 안에서만 쓰는 값은 $state로 지역 상태에 둔다. 가까운 자식들과 공유하면 부모가 상태를 소유하고 props와 콜백으로 연결한다. 여러 페이지나 멀리 떨어진 컴포넌트가 함께 써야 할 때 store나 Svelte 5의 .svelte.js 모듈 상태를 사용한다.

writable은 바뀔 수 있는 공유 상태에 적합하다. readable은 외부에서 읽기만 해야 하는 값에 적합하다. derived는 장바구니 총액, 세금 포함 가격, 필터링된 목록처럼 원본 상태에서 계산되는 값에 적합하다.

Svelte 5에서는 .svelte.js 또는 .svelte.ts 파일에 $state와 $derived를 두고 전역 반응형 상태를 만들 수도 있다. 이 방식은 runes 중심 코드와 잘 어울린다. 하지만 어떤 도구를 쓰든 원칙은 같다. 원본 상태는 하나로 두고, 계산값은 계산하게 만들고, 꼭 필요한 상태만 공유한다. 그리고 SvelteKit처럼 서버 렌더링이 함께 있는 환경에서는 사용자별 데이터가 전역으로 섞이지 않도록 주의한다.

참고

1Svelte Docs: $state

2Svelte Docs: Passing state across modules

3SvelteKit Docs: State management

책 목록

쉽게 배우는 Svelte 개발: Svelte 5와 SvelteKit으로 시작하는 프론트엔드

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기