서버 훅: 요청의 처음과 끝을 제어하기
서버 훅: 요청의 처음과 끝을 제어하기
'훅'(hooks)은 특정 이벤트에 응답해 SvelteKit이 호출하도록 선언하는 앱 전역 함수예요. 프레임워크의 동작을 미세하게 제어할 수 있게 해줘요. 훅 파일은 세 가지가 있고, 모두 선택 사항이에요.
src/hooks.server.js— 앱의 서버 훅src/hooks.client.js— 앱의 클라이언트 훅src/hooks.js— 클라이언트와 서버 양쪽에서 실행되는 앱의 훅
이 모듈들의 코드는 앱이 시작될 때 실행돼서, 데이터베이스 클라이언트 초기화 같은 작업에 유용해요.
handle
src/hooks.server.js에 추가할 수 있어요.
이 함수는 SvelteKit 서버가 요청을 받을 때마다 실행돼요. 앱이 실행 중일 때든 prerendering 중일 때든 상관없이요. 그리고 응답을 결정해요. 요청을 나타내는 event 객체와, 라우트를 렌더링하고 Response를 생성하는 resolve라는 함수를 받아요. 이렇게 하면 응답 헤더나 본문을 수정하거나, SvelteKit을 완전히 우회(예: 라우트를 프로그래매틱하게 구현)할 수 있어요.
/// file: src/hooks.server.js
/** @type {import('@sveltejs/kit').Handle} */
export async function handle({ event, resolve }) {
if (event.url.pathname.startsWith('/custom')) {
return new Response('custom response');
}
const response = await resolve(event);
return response;
}
이미 prerender된 페이지를 포함한 정적 에셋에 대한 요청은 SvelteKit이 처리하지 않아요.
handle 훅이 클라이언트가 시작한 remote function 요청의 일부로 실행되면, route, params, url은 SvelteKit이 remote function을 위해 만든 엔드포인트의 URL이 아니라 remote function이 호출된 페이지를 가리켜요. 사용자가 특정 데이터에 접근할 권한이 있는지 판단하는 데 이 값들을 절대 사용하지 마세요. 이 값들은 조작될 수 있는 요청의 일부니까요. 쿼리도 사용자가 네비게이션할 때 다시 실행되지 않으므로(네비게이션 결과로 쿼리 인자가 바뀌지 않는 한), 이 값들을 어떻게 쓰는지 주의해야 해요.
구현하지 않으면 기본값은 ({ event, resolve }) => resolve(event)예요.
prerendering 중에는 SvelteKit이 페이지를 크롤링해 링크를 찾고 찾은 각 라우트를 렌더링해요. 라우트를 렌더링하면 handle 함수(와 load 같은 다른 모든 라우트 의존성)가 호출돼요. 이 단계에서 어떤 코드가 실행되지 않게 하려면 앱이 building 상태가 아니라는 것을 먼저 확인하세요.
handle 함수는 여러 개를 정의해 sequence 헬퍼 함수로 실행할 수 있어요.
resolve는 응답이 렌더링되는 방식을 더 세밀하게 제어할 수 있는 선택적 두 번째 파라미터도 지원해요. 그 파라미터는 다음 필드를 가질 수 있는 객체예요.
transformPageChunk(opts: { html: string, done: boolean }): MaybePromise<string | undefined>— HTML에 커스텀 변환을 적용해요.done이 true면 마지막 청크예요. 청크가 완전한 형태의 HTML이라는 보장은 없지만(예: 요소의 여는 태그는 있고 닫는 태그는 없을 수 있어요) 항상%sveltekit.head%나 레이아웃/페이지 컴포넌트 같은 합리적인 경계에서 쪼개져요.filterSerializedResponseHeaders(name: string, value: string): boolean—load함수가fetch로 리소스를 로드할 때 직렬화된 응답에 어떤 헤더를 포함할지 결정해요. 기본값은 아무것도 포함하지 않아요.preload(input: { type: 'js' | 'css' | 'font' | 'asset', path: string }): boolean— 어떤 파일을 프리로드할지 결정해요. 파일은<head>태그에 추가되는<link>태그로 프리로드돼요.output.linkHeaderPreload가 활성화되면 동적으로 렌더링되는 페이지는 대신Link응답 헤더를 사용해요. 이 메서드는 코드 청크를 구성하는 동안 빌드 시점에 발견된 각 파일마다 호출돼요. 예를 들어+page.svelte에import './styles.css가 있으면 그 페이지를 방문할 때preload가 그 CSS 파일의 해석된 경로로 호출돼요. dev 모드에서는 빌드 시점에 일어나는 분석에 의존하므로preload가 호출되지 않아요. 프리로딩은 에셋을 더 일찍 다운로드해 성능을 향상시킬 수 있지만, 불필요하게 많이 다운로드하면 해가 될 수도 있어요. 기본적으로js와css파일이 프리로드돼요.asset파일은 현재 전혀 프리로드되지 않지만, 피드백을 평가한 후 나중에 추가할 수 있어요.
/// file: src/hooks.server.js
/** @type {import('@sveltejs/kit').Handle} */
export async function handle({ event, resolve }) {
const response = await resolve(event, {
transformPageChunk: ({ html }) => html.replace('old', 'new'),
filterSerializedResponseHeaders: (name) => name.startsWith('x-'),
preload: ({ type, path }) => type === 'js' || path.includes('/important/')
});
return response;
}
resolve(...)는 절대 오류를 던지지 않는다는 점에 주의하세요. 항상 적절한 상태 코드를 가진 Promise<Response>를 반환해요. handle의 다른 곳에서 오류가 발생하면 치명적으로 취급되고, SvelteKit은 Accept 헤더에 따라 오류의 JSON 표현이나 폴백 오류 페이지(— src/error.html로 커스터마이즈 가능)로 응답해요. 오류 처리에 대한 자세한 내용은 errors에서 볼 수 있어요.
locals
+server.js의 핸들러와 서버 load 함수에 전달되는 요청에 커스텀 데이터를 추가하려면 아래처럼 event.locals 객체를 채우면 돼요.
/// file: src/hooks.server.js
// @filename: ambient.d.ts
type User = {
name: string;
}
declare namespace App {
interface Locals {
user: User;
}
}
const getUserInformation: (cookie: string | void) => Promise<User>;
// @filename: index.js
// ---cut---
/** @type {import('@sveltejs/kit').Handle} */
export async function handle({ event, resolve }) {
event.locals.user = await getUserInformation(event.cookies.get('sessionid'));
const response = await resolve(event);
// Note that modifying response headers isn't always safe.
// Response objects can have immutable headers
// (e.g. Response.redirect() returned from an endpoint).
// Modifying immutable headers throws a TypeError.
// In that case, clone the response or avoid creating a
// response object with immutable headers.
response.headers.set('x-custom-header', 'potato');
return response;
}
handleFetch
src/hooks.server.js에 추가할 수 있어요.
이 함수는 서버(또는 prerendering 중)에서 엔드포인트, load, action, handle, handleError 또는 reroute 안에서 실행되는 event.fetch 호출의 결과를 수정(또는 대체)할 수 있게 해줘요.
예를 들어 load 함수가 사용자가 해당 페이지로 클라이언트 사이드 네비게이션할 때 https://api.yourapp.com 같은 공개 URL에 요청할 수 있지만, SSR 중에는 (공개 인터넷과 그 사이의 프록시·로드 밸런서를 우회해서) API를 직접 때리는 게 합리적일 수 있어요.
/// file: src/hooks.server.js
/** @type {import('@sveltejs/kit').HandleFetch} */
export async function handleFetch({ request, fetch }) {
if (request.url.startsWith('https://api.yourapp.com/')) {
// clone the original request, but change the URL
request = new Request(
request.url.replace('https://api.yourapp.com/', 'http://localhost:9999/'),
request
);
}
return fetch(request);
}
event.fetch로 만든 요청은 브라우저의 자격 증명 모델을 따르는데, 동일 출처 요청에서는 credentials 옵션이 "omit"으로 설정되지 않은 한 cookie와 authorization 헤더가 전달돼요. 교차 출처 요청에서 요청 URL이 앱의 하위 도메인에 속하면 cookie가 포함돼요. 예를 들어 앱이 my-domain.com에 있고 API가 api.my-domain.com에 있다면 쿠키가 요청에 포함돼요.
한 가지 주의사항이 있어요. 앱과 API가 형제 하위 도메인에 있으면 — 예를 들어 www.my-domain.com과 api.my-domain.com — my-domain.com 같은 공통 부모 도메인에 속한 쿠키는 포함되지 않아요. SvelteKit이 쿠키가 어느 도메인에 속하는지 알 방법이 없기 때문이에요. 이런 경우 handleFetch로 쿠키를 수동으로 포함해야 해요.
/// file: src/hooks.server.js
// @errors: 2345
/** @type {import('@sveltejs/kit').HandleFetch} */
export async function handleFetch({ event, request, fetch }) {
if (request.url.startsWith('https://api.my-domain.com/')) {
request.headers.set('cookie', event.request.headers.get('cookie'));
}
return fetch(request);
}
handleValidationError
src/hooks.server.js에 추가할 수 있어요.
이 훅은 remote function이 제공된 Standard Schema와 일치하지 않는 인자로 호출될 때 호출돼요. App.Error의 모양과 일치하는 객체를 반환해야 해요.
문자열을 인자로 기대하는 remote function이 있다고 해볼게요...
/// file: todos.remote.js
import * as v from 'valibot';
import { query } from '$app/server';
export const getTodo = query(v.string(), (id) => {
// implementation...
});
...그런데 스키마와 일치하지 않는 것 — 숫자 같은 것(예: await getTodos(1)) — 으로 호출되면 검증이 실패하고, 서버가 400 상태 코드로 응답하며 함수는 'Bad Request' 메시지로 throw돼요.
이 메시지를 커스터마이즈하고 오류 객체에 추가 속성을 넣으려면 handleValidationError를 구현하세요.
/// file: src/hooks.server.js
/** @type {import('@sveltejs/kit').HandleValidationError} */
export function handleValidationError({ issues }) {
return {
message: 'No thank you'
};
}
여기서 노출하는 정보에 대해 신중하세요. 검증이 실패하는 가장 흔한 이유는 누군가 서버에 악의적인 요청을 보내기 때문이거든요.
handleError
src/hooks.server.js와 src/hooks.client.js에 추가할 수 있어요.
로딩·렌더링 중이거나 엔드포인트에서 예상치 못한 오류가 발생하면 이 함수가 error, event, status 코드, message와 함께 호출돼요. 이를 통해 두 가지를 할 수 있어요.
- 오류를 로깅할 수 있어요.
- 사용자에게 보여줘도 안전한, 메시지나 스택 트레이스 같은 민감한 세부 정보를 생략한 오류의 커스텀 표현을 생성할 수 있어요. 기본값이
{ message }인 반환값은page.error의 값이 돼요.
여러분의 코드(또는 여러분의 코드가 호출하는 라이브러리 코드)에서 발생한 오류의 상태는 500이고 메시지는 "Internal Error"예요. error.message는 사용자에게 노출해서는 안 되는 민감한 정보를 담을 수 있지만, message는 안전해요(일반 사용자에게는 의미가 없지만요).
타입 안전한 방식으로 page.error 객체에 더 많은 정보를 추가하려면 App.Error 인터페이스를 선언해 기대되는 모양을 커스터마이즈할 수 있어요(합리적인 폴백 동작을 보장하려면 message: string을 포함해야 해요). 이를 통해 예를 들어 사용자가 기술 지원과의 통신에서 인용할 추적 ID를 덧붙일 수 있어요.
/// file: src/app.d.ts
declare global {
namespace App {
interface Error {
message: string;
errorId: string;
}
}
}
export {};
/// file: src/hooks.server.js
// @errors: 2322 2353
// @filename: ambient.d.ts
declare module '@sentry/sveltekit' {
export const init: (opts: any) => void;
export const captureException: (error: any, opts: any) => void;
}
// @filename: index.js
// ---cut---
import * as Sentry from '@sentry/sveltekit';
Sentry.init({/*...*/})
/** @type {import('@sveltejs/kit').HandleServerError} */
export async function handleError({ error, event, status, message }) {
const errorId = crypto.randomUUID();
// example integration with https://sentry.io/
Sentry.captureException(error, {
extra: { event, errorId, status }
});
return {
message: 'Whoops!',
errorId
};
}
/// file: src/hooks.client.js
// @errors: 2322 2353
// @filename: ambient.d.ts
declare module '@sentry/sveltekit' {
export const init: (opts: any) => void;
export const captureException: (error: any, opts: any) => void;
}
// @filename: index.js
// ---cut---
import * as Sentry from '@sentry/sveltekit';
Sentry.init({/*...*/})
/** @type {import('@sveltejs/kit').HandleClientError} */
export async function handleError({ error, event, status, message }) {
const errorId = crypto.randomUUID();
// example integration with https://sentry.io/
Sentry.captureException(error, {
extra: { event, errorId, status }
});
return {
message: 'Whoops!',
errorId
};
}
src/hooks.client.js에서는 handleError의 타입이 HandleServerError가 아니라 HandleClientError이고, event는 RequestEvent가 아니라 NavigationEvent예요.
이 함수는 @sveltejs/kit에서 import한 error 함수로 던진 예상된 오류에는 호출되지 않아요.
개발 중에 Svelte 코드의 문법 오류 때문에 오류가 발생하면, 전달된 오류에 오류 위치를 강조하는 frame 속성이 붙어요.
handleError가 절대 오류를 던지지 않도록 하세요.
init
src/hooks.server.js와 src/hooks.client.js에 추가할 수 있어요.
이 함수는 서버가 생성될 때 또는 앱이 브라우저에서 시작될 때 한 번 실행돼요. 데이터베이스 연결 초기화 같은 비동기 작업을 하기 좋은 자리예요.
환경이 최상위 await를 지원하면 init 함수는 모듈 최상위에서 초기화 로직을 쓰는 것과 다를 바가 없지만, 일부 환경 — 특히 Safari — 은 지원하지 않아요.
// @errors: 2307
/// file: src/hooks.server.js
import * as db from '$lib/server/database';
/** @type {import('@sveltejs/kit').ServerInit} */
export async function init() {
await db.connect();
}
브라우저에서는 init의 비동기 작업이 hydration을 지연시키므로, 무엇을 넣을지 주의하세요.
reroute
src/hooks.js에 추가할 수 있어요. 서버와 클라이언트 양쪽에서 실행돼요.
이 함수는 handle보다 먼저 실행되며 URL이 라우트로 변환되는 방식을 바꿀 수 있게 해줘요. 반환된 pathname(기본값은 url.pathname)이 라우트와 그 파라미터를 선택하는 데 사용돼요.
예를 들어 /en/about, /de/ueber-uns, /fr/a-propos로 접근 가능해야 하는 src/routes/[[lang]]/about/+page.svelte 페이지가 있다고 해볼게요. reroute로 이것을 구현할 수 있어요.
// @errors: 2345 2304
/// file: src/hooks.js
/** @type {Record<string, string>} */
const translated = {
'/en/about': '/en/about',
'/de/ueber-uns': '/de/about',
'/fr/a-propos': '/fr/about',
};
/** @type {import('@sveltejs/kit').Reroute} */
export function reroute({ url }) {
if (url.pathname in translated) {
return translated[url.pathname];
}
}
lang 파라미터는 반환된 pathname에서 올바르게 파생돼요.
reroute를 사용해도 브라우저 주소창의 내용이나 event.url 값은 바뀌지 않아요.
2.18 버전부터 reroute 훅은 비동기가 될 수 있어요. 예를 들어 백엔드에서 데이터를 가져와 어디로 reroute할지 결정할 수 있죠. 신중하게, 그리고 빠르게 해야 해요. 그렇지 않으면 네비게이션을 지연시키거든요. 데이터를 가져와야 한다면 인자로 제공된 fetch를 사용하세요. 이 fetch는 load 함수에 제공되는 fetch와 같은 이점이 있지만, 라우트가 아직 알려지지 않아 handleFetch에는 params와 id를 사용할 수 없다는 제약이 있어요.
// @errors: 2345 2304
/// file: src/hooks.js
/** @type {import('@sveltejs/kit').Reroute} */
export async function reroute({ url, fetch }) {
// Ask a special endpoint within your app about the destination
if (url.pathname === '/api/reroute') return;
const api = new URL('/api/reroute', url);
api.searchParams.set('pathname', url.pathname);
const result = await fetch(api).then(r => r.json());
return result.pathname;
}
reroute는 순수하고 멱등인 함수로 간주돼요. 같은 입력에는 항상 같은 출력을 반환하고 부작용이 없어야 해요. 이런 가정 아래 SvelteKit은 reroute의 결과를 클라이언트에서 캐시해서 고유한 URL마다 한 번만 호출해요.
transport
src/hooks.js에 추가할 수 있어요. 서버와 클라이언트 양쪽에서 실행돼요.
이것은 load와 폼 actions에서 반환된 커스텀 타입을 서버/클라이언트 경계를 넘어 전달할 수 있게 해주는 트랜스포터(transporter) 모음이에요. 각 트랜스포터는 서버에서 값을 인코딩하는 encode 함수(타입의 인스턴스가 아닌 것에는 falsy 값을 반환)와 대응하는 decode 함수를 담아요.
// @errors: 2307
/// file: src/hooks.js
import { Vector } from '$lib/math';
/** @type {import('@sveltejs/kit').Transport} */
export const transport = {
Vector: {
encode: (value) => value instanceof Vector && [value.x, value.y],
decode: ([x, y]) => new Vector(x, y)
}
};