폼을 만들었다면 이제 실제 서버와 연결해 빠른 반응을 유지해야 합니다.
시작 전에 정리하기
- 무엇을 만든다:
/projects페이지에서 서버의 프로젝트 목록을 불러오고, 새 항목 생성과 별표 토글을 낙관적으로 처리하는 CRUD 흐름. - 왜 중요한가:
load/invalidate짝을 익히면 새로고침 없이 서버 상태를 맞출 수 있고, 낙관적 UI는 느린 네트워크에서도 사용감을 지켜 줍니다. - 주의할 점:
depends키와invalidate키를 정확히 맞추고, 낙관적 업데이트 전에 스냅샷을 저장해 실패 시 즉시 롤백해야 합니다.
이번 글에서 새로 나오는 용어
load함수: 페이지가 렌더링되기 전에 실행되어 초기 데이터를 불러오는 SvelteKit 서버 훅입니다.- invalidate: 특정 키에 연결된
load결과를 다시 실행하도록 요청해 화면을 새로고침 없이 갱신합니다. - 낙관적 UI: 서버 응답을 기다리지 않고 예상 결과를 먼저 화면에 그려 체감 속도를 높이는 전략입니다.
- 스냅샷 롤백: 낙관적 업데이트 전에 리스트를 복사해 두었다가 실패 시 원본으로 되돌리는 안전 장치입니다.
개념
API(Application Programming Interface, 응용 프로그램 인터페이스)는 서버와 데이터를 주고받는 약속입니다. load 함수는 페이지가 렌더링될 때 API를 호출해 초기 데이터를 가져옵니다. invalidate 함수는 이미 실행한 load를 다시 실행하도록 요청하는 키 기반 트리거입니다. 낙관적 UI(optimistic UI, 먼저 보여 주는 인터페이스)는 서버 응답을 기다리지 않고 예상 결과를 화면에 그립니다. 이런 전략을 쓸 때는 스냅샷을 만들어 실패하면 곧바로 롤백할 준비를 해야 합니다.
서버·클라이언트 경계 다시 보기
+page.server.ts안load와actions는 서버에서만 실행되어 DB나 비밀 키를 만집니다.+page.svelte는 브라우저에서 렌더링되며, 서버가 돌려준data를 받습니다. 여기서invalidate를 호출하면 서버load가 다시 실행됩니다.- 낙관적 토글은 브라우저 상태(
projectsStore)를 먼저 바꾸고, 실패하면 서버 응답에 따라 복구합니다. 즉, “먼저 보여주고 나중에 확인” 순서를 의도적으로 만든다는 점을 기억하세요.
D2로 보는 데이터 흐름
👉 미니 요약: 브라우저 폼이 액션을 호출하고, 액션이 API를 거친 뒤 invalidate로 load를 다시 실행해 목록을 새로고침 없이 맞춥니다. 별표 토글은 스냅샷을 저장해 두고 실패하면 즉시 롤백합니다.
코드
프로젝트 목록을 불러오고, 새 항목 등록 시 리스트를 갱신하며, 별표 토글을 낙관적으로 처리하는 흐름입니다.
// src/routes/projects/+page.server.ts
export const load = async ({ fetch, depends }) => {
depends('data:projects');
const res = await fetch('/api/projects');
if (!res.ok) {
return { projects: [], error: '프로젝트를 불러오지 못했습니다.' };
}
return { projects: await res.json() };
};
export const actions = {
create: async ({ request, fetch }) => {
const form = Object.fromEntries(await request.formData());
if (!form.title) {
return fail(400, { errors: { title: '제목은 필수입니다.' } });
}
const res = await fetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form)
});
if (!res.ok) {
return fail(500, { errors: { title: '생성에 실패했습니다.' } });
}
return { created: true };
}
};
<!-- src/routes/projects/+page.svelte -->
<script>
import { enhance } from '$app/forms';
import { invalidate } from '$app/navigation';
import { page } from '$app/stores';
import { derived, writable, get } from 'svelte/store';
export let data;
const projectsStore = writable(data.projects);
const pending = derived(page, ($page) => $page.form?.errors);
async function toggleStar(id) {
const snapshot = get(projectsStore);
projectsStore.update((list) => list.map((p) => (p.id === id ? { ...p, starred: !p.starred } : p)));
const res = await fetch(`/api/projects/${id}/star`, { method: 'POST' });
if (!res.ok) projectsStore.set(snapshot);
}
</script>
<form method="POST" action="?/create"
use:enhance(({ result }) => {
if (result.type === 'success') invalidate('data:projects');
})>
<label>
<span class="sr-only">프로젝트 이름</span>
<input name="title" placeholder="프로젝트 이름" aria-invalid={pending?.title ? 'true' : 'false'} required />
</label>
{#if pending?.title}
<p class="form-error">{pending.title}</p>
{/if}
<button type="submit">추가</button>
</form>
<ul>
{#each $projectsStore as project (project.id)}
<li>
<button type="button" on:click={() => toggleStar(project.id)} aria-pressed={project.starred}>
{project.starred ? '★' : '☆'}
</button>
{project.title}
</li>
{/each}
</ul>
💬 댓글
이 글에 대한 의견을 남겨주세요