본문으로 건너뛰기

13. 데이터 로딩

SvelteKit의 데이터 로딩 시스템은 웹 애플리케이션의 성능과 사용자 경험을 크게 향상시키는 핵심 기능입니다. load 함수를 통해 서버 사이드와 클라이언트 사이드에서 유연하게 데이터를 가져올 수 있으며, 자동으로 타입 안전성과 성능 최적화를 제공합니다. 이 장에서는 SvelteKit의 강력한 데이터 로딩 메커니즘을 통해 효율적이고 사용자 친화적인 웹 애플리케이션을 구축하는 방법을 완전히 마스터해보겠습니다.


13.1 Load 함수 기초

+page.js+page.server.js의 차이점

SvelteKit은 데이터 로딩을 위해 두 가지 유형의 load 함수를 제공합니다. +page.js는 클라이언트와 서버 모두에서 실행되는 유니버설 load 함수이고, +page.server.js는 서버에서만 실행되는 서버 전용 load 함수입니다. 각각의 특성을 이해하고 적절한 상황에 맞게 사용하는 것이 성능과 보안에 중요합니다.

기본 차이점 비교

특징+page.js+page.server.js
실행 환경서버 + 클라이언트서버 전용
API 접근공개 API만 가능모든 백엔드 리소스 접근
보안클라이언트에 코드 노출서버에서만 실행
번들 크기클라이언트 번들에 포함클라이언트 번들에 포함 안됨
데이터베이스접근 불가직접 접근 가능
환경 변수공개 변수만모든 환경 변수 접근

Universal Load 함수 (+page.js)

+page.svelte
<!-- src/routes/products/+page.svelte -->
<script>
let { data } = $props();
</script>

<div>
<h1>상품 목록</h1>

{#if data.loading}
<p>로딩 중...</p>
{:else}
<div class="products">
{#each data.products as product}
<div class="product-card">
<h3>{product.name}</h3>
<p>가격: {product.price.toLocaleString()}원</p>
</div>
{/each}
</div>
{/if}
</div>

<style>
.products {
display: grid;
grid-template-columns: repeat(
auto-fill,
minmax(250px, 1fr)
);
gap: 1rem;
}
</style>
+page.js
// src/routes/products/+page.js
export async function load({ fetch, url }) {
try {
const category =
url.searchParams.get('category') || 'all';

const response = await fetch(
`/api/products?category=${category}`
);

if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}

const products = await response.json();

return {
products,
category,
loading: false,
};
} catch (error) {
return {
products: [],
category: 'all',
loading: false,
error: error.message,
};
}
}

Server Load 함수 (+page.server.js)

+page.svelte
<!-- src/routes/dashboard/+page.svelte -->
<script>
let { data } = $props();
</script>

<div>
<h1>대시보드</h1>

<div class="stats">
<div class="stat-card">
<h3>총 사용자</h3>
<p>{data.userCount}</p>
</div>

<div class="stat-card">
<h3>이번 달 수익</h3>
<p>{data.monthlyRevenue.toLocaleString()}원</p>
</div>
</div>

<div class="recent-orders">
<h2>최근 주문</h2>
{#each data.recentOrders as order}
<div class="order">
<span>주문 #{order.id}</span>
<span>{order.customerName}</span>
<span>{order.total.toLocaleString()}원</span>
</div>
{/each}
</div>
</div>

<style>
.stats {
display: grid;
grid-template-columns: repeat(
auto-fit,
minmax(200px, 1fr)
);
gap: 1rem;
margin-bottom: 2rem;
}
</style>
+page.server.js
// src/routes/dashboard/+page.server.js
import { error } from '@sveltejs/kit';

export async function load({ locals }) {
if (!locals.user?.isAdmin) {
throw error(403, '관리자 권한이 필요합니다');
}

try {
// 모의 데이터 (실제로는 데이터베이스에서 가져옴)
const mockData = {
userCount: 1250,
monthlyRevenue: 15420000,
recentOrders: [
{ id: 1001, customerName: '김철수', total: 45000 },
{ id: 1002, customerName: '이영희', total: 32000 },
{ id: 1003, customerName: '박민수', total: 67000 },
],
};

return mockData;
} catch (err) {
throw error(
500,
'데이터를 불러오는 중 오류가 발생했습니다'
);
}
}

실습해보기: Svelte REPL에서 이 코드를 직접 실행해보세요!

서버 사이드 vs 클라이언트 사이드

데이터 로딩 방식을 선택할 때는 보안, 성능, SEO 요구사항을 고려해야 합니다. 서버 사이드 로딩은 초기 페이지 로드가 빠르고 SEO에 유리하며, 클라이언트 사이드 로딩은 상호작용이 빠르고 서버 부하가 적습니다. 대부분의 경우 하이브리드 접근법을 사용하여 최적의 사용자 경험을 제공할 수 있습니다.

클라이언트 사이드 로딩 패턴

+page.svelte
<!-- src/routes/search/+page.svelte -->
<script>
let searchTerm = $state('');
let results = $state([]);
let loading = $state(false);

async function search() {
if (!searchTerm.trim()) return;

loading = true;

try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(searchTerm)}`
);
const data = await response.json();
results = data.results;
} catch (error) {
console.error('검색 실패:', error);
results = [];
} finally {
loading = false;
}
}

let debounceTimer;
function handleInput() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(search, 300);
}
</script>

<div>
<h1>실시간 검색</h1>

<input
bind:value="{searchTerm}"
oninput="{handleInput}"
placeholder="검색어를 입력하세요"
/>

{#if loading}
<p>검색 중...</p>
{:else if results.length > 0}
<div class="results">
{#each results as result}
<div class="result-item">
<h3>{result.title}</h3>
<p>{result.excerpt}</p>
</div>
{/each}
</div>
{:else if searchTerm}
<p>검색 결과가 없습니다.</p>
{/if}
</div>

실습해보기: Svelte REPL에서 이 코드를 직접 실행해보세요!

데이터 반환과 타입

SvelteKit의 load 함수는 직렬화 가능한 데이터만 반환할 수 있으며, TypeScript를 사용하면 완전한 타입 안전성을 제공받을 수 있습니다. 함수, 클래스 인스턴스, Symbol 등은 직렬화되지 않으므로 반환할 수 없고, 대신 JSON 호환 데이터만 사용해야 합니다. 타입 정의를 통해 컴파일 타임에 오류를 방지하고 개발자 경험을 향상시킬 수 있습니다.

TypeScript와 함께 사용하기

+page.server.ts
// src/routes/profile/+page.server.ts
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) {
throw error(401, '로그인이 필요합니다');
}

// 모의 사용자 데이터
const mockUser = {
id: locals.user.id,
name: '홍길동',
email: 'hong@example.com',
bio: 'SvelteKit을 좋아하는 개발자입니다',
avatar: '/default-avatar.png',
};

return {
user: mockUser,
};
};

13.2 레이아웃 데이터

+layout.js+layout.server.js

레이아웃 load 함수는 해당 레이아웃을 사용하는 모든 페이지에서 공통으로 필요한 데이터를 로드합니다. 사용자 정보, 네비게이션 메뉴, 전역 설정 등 앱 전반에서 사용되는 데이터를 효율적으로 관리할 수 있습니다. 레이아웃 데이터는 자동으로 모든 하위 페이지에 전달되어 중복 요청을 방지합니다.

루트 레이아웃 데이터

+layout.server.ts
// src/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types';

export const load: LayoutServerLoad = async ({
cookies,
}) => {
const sessionId = cookies.get('session');
let user = null;

if (sessionId) {
// 모의 세션 검증
user = {
id: 1,
name: '김철수',
email: 'kim@example.com',
avatar: '/avatar.jpg',
};
}

const config = {
siteName: 'My SvelteKit App',
version: '1.0.0',
};

return {
user,
config,
};
};
+layout.svelte
<!-- src/routes/+layout.svelte -->
<script>
let { data, children } = $props();
</script>

<div class="app">
<header>
<nav>
<a href="/"></a>

{#if data.user}
<div class="user-menu">
<img
src="{data.user.avatar}"
alt="{data.user.name}"
/>
<span>{data.user.name}</span>
<a href="/profile">프로필</a>
<a href="/logout">로그아웃</a>
</div>
{:else}
<a href="/login">로그인</a>
{/if}
</nav>
</header>

<main>{@render children()}</main>

<footer>
<p>&copy; 2024 {data.config.siteName}</p>
</footer>
</div>

<style>
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}

main {
flex: 1;
padding: 2rem;
}
</style>

부모 데이터 접근

자식 레이아웃이나 페이지에서는 parent() 함수를 통해 부모의 load 데이터에 접근할 수 있습니다. 이를 통해 부모에서 로드한 사용자 정보나 설정을 기반으로 추가 데이터를 로드할 수 있습니다. 부모 데이터는 Promise로 반환되므로 적절히 await 처리해야 합니다.

중첩 레이아웃 데이터 활용

+layout.server.ts
// src/routes/admin/+layout.server.ts
import type { LayoutServerLoad } from './$types';

export const load: LayoutServerLoad = async ({
parent,
}) => {
const { user } = await parent();

if (!user || user.id !== 1) {
throw error(403, '관리자 권한이 필요합니다');
}

const adminStats = {
totalUsers: 1250,
totalOrders: 3400,
totalRevenue: 25600000,
};

return {
adminStats,
};
};
+layout.svelte
<!-- src/routes/admin/+layout.svelte -->
<script>
let { data, children } = $props();
</script>

<div class="admin-layout">
<aside class="sidebar">
<h2>관리자 패널</h2>

<div class="stats">
<div class="stat">
<span>총 사용자</span>
<strong>{data.adminStats.totalUsers}</strong>
</div>
<div class="stat">
<span>총 주문</span>
<strong>{data.adminStats.totalOrders}</strong>
</div>
</div>

<nav>
<a href="/admin/users">사용자 관리</a>
<a href="/admin/orders">주문 관리</a>
<a href="/admin/products">상품 관리</a>
</nav>
</aside>

<main class="admin-content">{@render children()}</main>
</div>

실습해보기: Svelte REPL에서 이 코드를 직접 실행해보세요!

데이터 상속

레이아웃 계층 구조에서 데이터는 자동으로 하위 컴포넌트로 전달됩니다. 각 레벨에서 추가된 데이터는 기존 데이터와 병합되어 최종적으로 페이지 컴포넌트에서 사용할 수 있습니다. 이러한 상속 구조를 통해 효율적인 데이터 흐름과 코드 재사용을 달성할 수 있습니다.

+page.server.ts
// src/routes/admin/users/+page.server.ts
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async ({
parent,
url,
}) => {
const parentData = await parent();

const page = parseInt(
url.searchParams.get('page') || '1'
);

// 모의 사용자 데이터
const users = [
{
id: 1,
name: '김철수',
email: 'kim@example.com',
isActive: true,
},
{
id: 2,
name: '이영희',
email: 'lee@example.com',
isActive: true,
},
{
id: 3,
name: '박민수',
email: 'park@example.com',
isActive: false,
},
];

const itemsPerPage = 10;
const start = (page - 1) * itemsPerPage;
const paginatedUsers = users.slice(
start,
start + itemsPerPage
);

return {
users: paginatedUsers,
pagination: {
current: page,
total: Math.ceil(users.length / itemsPerPage),
hasNext: start + paginatedUsers.length < users.length,
hasPrev: page > 1,
},
};
};

13.3 고급 데이터 로딩

병렬 로딩

여러 독립적인 데이터를 동시에 로드하면 전체 로딩 시간을 크게 단축할 수 있습니다. Promise.all()을 사용하여 병렬 처리하거나, 각각 독립적인 Promise로 처리하여 부분적 로딩도 가능합니다. 에러 처리와 로딩 상태 관리를 적절히 구현하여 사용자 경험을 최적화해야 합니다.

Promise.all을 활용한 병렬 로딩

+page.server.ts
// src/routes/dashboard/analytics/+page.server.ts
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user?.isAdmin) {
throw error(403, '권한이 없습니다');
}

try {
// 모든 데이터를 병렬로 로드 (모의 데이터)
const [userStats, salesData, topProducts] =
await Promise.all([
// 사용자 통계
Promise.resolve([
{ date: '2024-01-01', count: 45 },
{ date: '2024-01-02', count: 52 },
{ date: '2024-01-03', count: 38 },
]),

// 매출 데이터
Promise.resolve([
{ date: '2024-01-01', total: 1200000 },
{ date: '2024-01-02', total: 1350000 },
{ date: '2024-01-03', total: 980000 },
]),

// 인기 상품
Promise.resolve([
{
name: '노트북',
salesCount: 23,
price: 1500000,
},
{ name: '마우스', salesCount: 45, price: 50000 },
{ name: '키보드', salesCount: 34, price: 120000 },
]),
]);

return {
analytics: {
userStats,
salesData,
topProducts,
},
};
} catch (error) {
throw error(500, '분석 데이터를 불러올 수 없습니다');
}
};

스트리밍

SvelteKit은 데이터 스트리밍을 통해 페이지의 일부를 먼저 렌더링하고 나머지 데이터가 준비되면 점진적으로 업데이트할 수 있습니다. 이는 사용자가 빠르게 페이지를 볼 수 있게 하면서도 모든 데이터가 로드되기를 기다리지 않아도 되는 장점을 제공합니다. 중요한 데이터는 먼저 로드하고 부가적인 데이터는 나중에 스트리밍할 수 있습니다.

스트리밍 데이터 패턴

+page.server.ts
// src/routes/product/[id]/+page.server.ts
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async ({ params }) => {
const productId = parseInt(params.id);

// 핵심 상품 정보는 즉시 로드 (모의 데이터)
const product = {
id: productId,
name: 'MacBook Pro',
price: 2500000,
description: '최신 M3 칩셋이 탑재된 고성능 노트북',
images: ['/macbook1.jpg', '/macbook2.jpg'],
category: '노트북',
};

return {
product,
// 부가적인 데이터는 Promise로 반환하여 스트리밍
reviews: loadReviews(productId),
recommendations: loadRecommendations(productId),
stockInfo: loadStockInfo(productId),
};
};

async function loadReviews(productId: number) {
await new Promise(resolve => setTimeout(resolve, 800));

return [
{
id: 1,
rating: 5,
comment: '정말 만족합니다!',
userName: '김철수',
},
{
id: 2,
rating: 4,
comment: '성능이 우수해요',
userName: '이영희',
},
];
}

async function loadRecommendations(productId: number) {
await new Promise(resolve => setTimeout(resolve, 1000));

return [
{ id: 2, name: 'MacBook Air', price: 1800000 },
{ id: 3, name: 'iPad Pro', price: 1200000 },
];
}

async function loadStockInfo(productId: number) {
await new Promise(resolve => setTimeout(resolve, 500));

return {
available: 15,
reserved: 3,
incoming: 20,
};
}
+page.svelte
<!-- src/routes/product/[id]/+page.svelte -->
<script>
let { data } = $props();
</script>

<div class="product-detail">
<div class="product-main">
<h1>{data.product.name}</h1>
<p class="price">
{data.product.price.toLocaleString()}원
</p>
<p>{data.product.description}</p>
</div>

<div class="stock-section">
<h3>재고 현황</h3>
{#await data.stockInfo}
<p>재고 정보를 확인하는 중...</p>
{:then stock}
<p>재고: {stock.available}개</p>
<p>예약: {stock.reserved}개</p>
<p>입고 예정: {stock.incoming}개</p>
{:catch}
<p>재고 정보를 불러올 수 없습니다.</p>
{/await}
</div>

<div class="reviews-section">
<h3>고객 리뷰</h3>
{#await data.reviews}
<p>리뷰를 로딩하는 중...</p>
{:then reviews} {#each reviews as review}
<div class="review">
<strong>{review.userName}</strong>
<span>★★★★★</span>
<p>{review.comment}</p>
</div>
{/each} {:catch}
<p>리뷰를 불러올 수 없습니다.</p>
{/await}
</div>
</div>

실습해보기: Svelte REPL에서 이 코드를 직접 실행해보세요!

에러 처리

효과적인 에러 처리는 안정적인 웹 애플리케이션의 필수 요소입니다. SvelteKit은 error() 함수를 통해 HTTP 상태 코드와 함께 의미있는 에러 메시지를 제공할 수 있습니다. 다양한 에러 시나리오를 고려하여 사용자에게 적절한 피드백을 제공해야 합니다.

포괄적 에러 처리

+page.server.ts
// src/routes/api/users/+page.server.ts
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async ({
url,
locals,
}) => {
try {
if (!locals.user) {
throw error(401, {
message: '로그인이 필요한 서비스입니다',
code: 'UNAUTHORIZED',
});
}

if (!locals.user.isAdmin) {
throw error(403, {
message: '관리자 권한이 필요합니다',
code: 'FORBIDDEN',
});
}

const page = parseInt(
url.searchParams.get('page') || '1'
);
const search = url.searchParams.get('search') || '';

if (page < 1) {
throw error(400, {
message: '유효하지 않은 페이지 번호입니다',
code: 'INVALID_PAGE',
});
}

// 모의 데이터
const mockUsers = [
{
id: 1,
name: '김철수',
email: 'kim@example.com',
isActive: true,
},
{
id: 2,
name: '이영희',
email: 'lee@example.com',
isActive: true,
},
];

const filteredUsers = search
? mockUsers.filter(
user =>
user.name.includes(search) ||
user.email.includes(search)
)
: mockUsers;

return {
users: filteredUsers,
search,
page,
};
} catch (err) {
if (err instanceof Error && 'status' in err) {
throw err;
}

console.error('사용자 데이터 로딩 실패:', err);
throw error(500, {
message: '서버 오류가 발생했습니다',
code: 'INTERNAL_SERVER_ERROR',
});
}
};
+error.svelte
<!-- src/routes/+error.svelte -->
<script>
import { page } from '$app/stores';

let status = $state(500);
let message = $state('알 수 없는 오류가 발생했습니다');
</script>

<div class="error-container">
<h1>{status}</h1>

{#if status === 404}
<h2>페이지를 찾을 수 없습니다</h2>
<p>요청하신 페이지가 존재하지 않거나 이동되었습니다.</p>
<a href="/">홈으로 돌아가기</a>
{:else if status === 403}
<h2>접근 권한이 없습니다</h2>
<p>{message}</p>
<a href="/login">로그인하기</a>
{:else}
<h2>서버 오류</h2>
<p>{message}</p>
<button onclick="{()" ="">
window.location.reload()}> 다시 시도
</button>
{/if}
</div>

실습해보기: Svelte REPL에서 이 코드를 직접 실행해보세요!


정리

SvelteKit의 데이터 로딩 시스템을 완전히 마스터했습니다! 이제 다음과 같은 핵심 개념들을 이해했습니다:

핵심 요약

  • Load 함수: +page.js+page.server.js의 차이점을 이해하고 적절한 상황에서 올바른 방식 선택
  • 레이아웃 데이터: +layout.js와 부모 데이터 접근을 통한 효율적인 데이터 상속 구조 구현
  • 고급 기능: 병렬 로딩, 스트리밍, 포괄적 에러 처리를 통한 최적의 사용자 경험 제공

실무 활용 팁

  • 보안이 중요한 데이터는 서버 사이드 로딩으로 처리하여 클라이언트 노출 방지
  • 병렬 로딩과 스트리밍을 활용하여 페이지 로딩 성능 최적화
  • 타입스크립트와 함께 사용하여 컴파일 타임 타입 안전성 확보

다음 단계: 14장 "폼과 액션"에서는 SvelteKit의 폼 처리와 서버 액션을 통한 데이터 변경 방법을 알아보겠습니다. Progressive Enhancement와 함께하는 현대적인 폼 처리를 마스터해보세요!