폼 액션: <form>로 서버에 데이터 보내기

폼 액션:
로 서버에 데이터 보내기

+page.server.js 파일은 _actions_를 export할 수 있어요. 이 actions로 <form> 요소를 사용해 서버에 데이터를 POST할 수 있어요. <form>을 쓸 때 클라이언트 사이드 JavaScript는 선택 사항이지만, JavaScript로 폼 상호작용을 쉽게 점진적으로 향상(progressively enhance)시켜 최상의 사용자 경험을 만들 수 있어요.

출처: SvelteKit 공식 문서 — Form actions

기본 actions

가장 단순한 경우, 페이지가 default action을 선언해요.

/// file: src/routes/login/+page.server.js
/** @satisfies {import('./$types').Actions} */
export const actions = {
	default: async (event) => {
		// TODO log the user in
	}
};

/login 페이지에서 이 action을 호출하려면 <form>을 추가하기만 하면 돼요. JavaScript는 필요 없어요.

<!--- file: src/routes/login/+page.svelte --->
<form method="POST">
	<label>
		Email
		<input name="email" type="email">
	</label>
	<label>
		Password
		<input name="password" type="password">
	</label>
	<button>Log in</button>
</form>

누군가 버튼을 클릭하면 브라우저가 POST 요청으로 폼 데이터를 서버에 보내고 default action을 실행해요.

actions는 항상 POST 요청을 사용해요. GET 요청은 부작용이 없어야 하기 때문이에요.

다른 페이지에서도 이 action을 호출할 수 있어요(예: 루트 레이아웃의 내비에 로그인 위젯이 있을 때). action 속성에 페이지를 가리키도록 추가하면 돼요.

/// file: src/routes/+layout.svelte
<form method="POST" action="/login">
	<!-- content -->
</form>

이름있는 actions

페이지는 하나의 default action 대신 필요한 만큼 많은 이름있는 action을 가질 수 있어요.

/// file: src/routes/login/+page.server.js
/** @satisfies {import('./$types').Actions} */
export const actions = {
---	default: async (event) => {---
+++	login: async (event) => {+++
		// TODO log the user in
	},
+++	register: async (event) => {
		// TODO register the user
	}+++
};

이름있는 action을 호출하려면 이름 앞에 / 문자를 붙인 쿼리 파라미터를 추가하면 돼요.

<!--- file: src/routes/login/+page.svelte --->
<form method="POST" action="?/register">
<!--- file: src/routes/+layout.svelte --->
<form method="POST" action="/login?/register">

action 속성뿐 아니라 버튼의 formaction 속성으로도 같은 폼 데이터를 부모 <form>과 다른 action으로 POST할 수 있어요.

/// file: src/routes/login/+page.svelte
<form method="POST" +++action="?/login"+++>
	<label>
		Email
		<input name="email" type="email">
	</label>
	<label>
		Password
		<input name="password" type="password">
	</label>
	<button>Log in</button>
	+++<button formaction="?/register">Register</button>+++
</form>

default action은 이름있는 action 옆에 둘 수 없어요. 리다이렉트 없이 이름있는 action으로 POST하면 쿼리 파라미터가 URL에 남아서, 다음 default POST가 이전의 이름있는 action을 통과하게 되기 때문이에요.

action의 구조

각 action은 RequestEvent 객체를 받아 request.formData()로 데이터를 읽을 수 있어요. 요청을 처리한 후(예: 쿠키를 설정해 사용자를 로그인시키기) action은 데이터로 응답할 수 있는데, 이 데이터는 해당 페이지의 form 속성과 다음 업데이트 전까지 앱 전역의 page.form에서 사용할 수 있어요.

/// file: src/routes/login/+page.server.js
// @filename: ambient.d.ts
declare module '$lib/server/db';

// @filename: index.js
// ---cut---
import * as db from '$lib/server/db';

/** @type {import('./$types').PageServerLoad} */
export async function load({ cookies }) {
	const user = await db.getUserFromSession(cookies.get('sessionid'));
	return { user };
}

/** @satisfies {import('./$types').Actions} */
export const actions = {
	login: async ({ cookies, request }) => {
		const data = await request.formData();
		const email = data.get('email');
		const password = data.get('password');

		const user = await db.getUser(email);
		cookies.set('sessionid', await db.createSession(user), { path: '/' });

		return { success: true };
	},
	register: async (event) => {
		// TODO register the user
	}
};
<!--- file: src/routes/login/+page.svelte --->
<script>
	/** @type {import('./$types').PageProps} */
	let { data, form } = $props();
</script>

{#if form?.success}
	<!-- this message is ephemeral; it exists because the page was rendered in
	       response to a form submission. it will vanish if the user reloads -->
	<p>Successfully logged in! Welcome back, {data.user.name}</p>
{/if}

PageProps는 2.16.0에서 추가됐어요. 더 이전 버전에서는 dataform 속성을 각각 타입으로 달아야 했어요:

/// file: +page.svelte
/** @type {{ data: import('./$types').PageData, form: import('./$types').ActionData }} */
let { data, form } = $props();

Svelte 4에서는 속성을 선언하기 위해 export let dataexport let form을 썼죠.

검증 오류

데이터가 잘못돼 요청을 처리할 수 없을 때는 검증 오류를 이전에 제출한 폼 값과 함께 사용자에게 돌려줘서 다시 시도하게 할 수 있어요. fail 함수로 HTTP 상태 코드(검증 오류의 경우 보통 400 또는 422)와 함께 데이터를 반환할 수 있어요. 상태 코드는 page.status로, 데이터는 form으로 사용할 수 있어요.

/// file: src/routes/login/+page.server.js
// @filename: ambient.d.ts
declare module '$lib/server/db';

// @filename: index.js
// ---cut---
+++import { fail } from '@sveltejs/kit';+++
import * as db from '$lib/server/db';

/** @satisfies {import('./$types').Actions} */
export const actions = {
	login: async ({ cookies, request }) => {
		const data = await request.formData();
		const email = data.get('email');
		const password = data.get('password');

+++		if (!email) {
			return fail(400, { email, missing: true });
		}+++

		const user = await db.getUser(email);

+++		if (!user || user.password !== db.hash(password)) {
			return fail(400, { email, incorrect: true });
		}+++

		cookies.set('sessionid', await db.createSession(user), { path: '/' });

		return { success: true };
	},
	register: async (event) => {
		// TODO register the user
	}
};

주의해서, 페이지에는 이메일 값만 돌려주고 비밀번호는 돌려주지 않아요.

/// file: src/routes/login/+page.svelte
<form method="POST" action="?/login">
+++	{#if form?.missing}<p class="error">The email field is required</p>{/if}
	{#if form?.incorrect}<p class="error">Invalid credentials!</p>{/if}+++
	<label>
		Email
		<input name="email" type="email" +++value={form?.email ?? ''}+++>
	</label>
	<label>
		Password
		<input name="password" type="password">
	</label>
	<button>Log in</button>
	<button formaction="?/register">Register</button>
</form>

반환된 데이터는 JSON으로 직렬화 가능해야 해요. 그 외에는 그 구조가 전적으로 여러분의 선택이에요. 예를 들어 페이지에 여러 폼이 있다면 반환된 form 데이터가 어느 <form>을 가리키는지 id 속성 같은 것으로 구분할 수 있어요.

리다이렉트

리다이렉트(와 오류)는 load에서와 완전히 똑같이 동작해요.

// @errors: 2345
/// file: src/routes/login/+page.server.js
// @filename: ambient.d.ts
declare module '$lib/server/db';

// @filename: index.js
// ---cut---
import { fail, +++redirect+++ } from '@sveltejs/kit';
import * as db from '$lib/server/db';

/** @satisfies {import('./$types').Actions} */
export const actions = {
	login: async ({ cookies, request, +++url+++ }) => {
		const data = await request.formData();
		const email = data.get('email');
		const password = data.get('password');

		const user = await db.getUser(email);
		if (!user) {
			return fail(400, { email, missing: true });
		}

		if (user.password !== db.hash(password)) {
			return fail(400, { email, incorrect: true });
		}

		cookies.set('sessionid', await db.createSession(user), { path: '/' });

+++		if (url.searchParams.has('redirectTo')) {
			redirect(303, url.searchParams.get('redirectTo'));
		}+++

		return { success: true };
	},
	register: async (event) => {
		// TODO register the user
	}
};

데이터 로딩

action이 실행된 후 페이지는(리다이렉트나 예상치 못한 오류가 없다면) 다시 렌더링되고, action의 반환값은 form prop으로 페이지에서 사용할 수 있어요. 즉 페이지의 load 함수가 action 완료 후 실행된다는 뜻이에요.

handle은 action이 호출되기 전에 실행되고, load 함수 전에 다시 실행되지는 않아요. 예를 들어 handle로 쿠키를 기반으로 event.locals를 채운다면, action에서 쿠키를 설정하거나 삭제할 때 event.locals도 갱신해야 해요.

/// file: src/hooks.server.js
// @filename: ambient.d.ts
declare namespace App {
	interface Locals {
		user: {
			name: string;
		} | null
	}
}

// @filename: global.d.ts
declare global {
	function getUser(sessionid: string | undefined): {
		name: string;
	};
}

export {};

// @filename: index.js
// ---cut---
/** @type {import('@sveltejs/kit').Handle} */
export async function handle({ event, resolve }) {
	event.locals.user = await getUser(event.cookies.get('sessionid'));
	return resolve(event);
}
/// file: src/routes/account/+page.server.js
// @filename: ambient.d.ts
declare namespace App {
	interface Locals {
		user: {
			name: string;
		} | null
	}
}

// @filename: index.js
// ---cut---
/** @type {import('./$types').PageServerLoad} */
export function load(event) {
	return {
		user: event.locals.user
	};
}

/** @satisfies {import('./$types').Actions} */
export const actions = {
	logout: async (event) => {
		event.cookies.delete('sessionid', { path: '/' });
		event.locals.user = null;
	}
};

점진적 향상

앞선 섹션에서 클라이언트 사이드 JavaScript 없이 동작하는 /login action을 만들었어요. fetch가 하나도 없죠. 좋아요. 하지만 JavaScript가 가능할 때는 폼 상호작용을 점진적으로 향상시켜 더 나은 사용자 경험을 제공할 수 있어요.

use:enhance

폼을 점진적으로 향상시키는 가장 쉬운 방법은 use:enhance action을 추가하는 거예요.

/// file: src/routes/login/+page.svelte
<script>
	+++import { enhance } from '$app/forms';+++

	/** @type {import('./$types').PageProps} */
	let { form } = $props();
</script>

<form method="POST" +++use:enhance+++>

use:enhancemethod="POST"를 가진 폼이면서 +page.server.js 파일에 정의된 actions를 가리킬 때만 쓸 수 있어요. method가 지정되지 않은 폼의 기본값인 method="GET"에서는 동작하지 않아요. use:enhancemethod="POST"가 아닌 폼에 쓰거나 +server.js 엔드포인트로 POST하면 오류가 나요.

네, enhance action과 <form action>이 둘 다 'action'이라 부르는 게 조금 헷갈릴 수 있어요. 이 문서는 action으로 가득하네요. 죄송해요.

인자 없이 use:enhance는 브라우저 네이티브 동작을 흉내 내되 전체 페이지 리로드만 없애요. 다음을 수행해요.

  • 성공 또는 무효 응답에서 form 속성, page.form, page.status를 갱신해요. 단 action이 제출하는 페이지와 같은 페이지에 있을 때만요. 예를 들어 폼이 <form action="/somewhere/else" ..>처럼 생겼다면 form prop과 page.form 상태는 갱신되지 않아요. 네이티브 폼 제출에서는 action이 있는 페이지로 리다이렉트되기 때문이에요. 어느 쪽이든 갱신되길 원한다면 applyAction을 사용하세요.
  • <form> 요소를 리셋해요.
  • 성공 응답에서 invalidateAll로 모든 데이터를 무효화해요.
  • 리다이렉트 응답에서 goto를 호출해요.
  • 오류가 발생하면 가장 가까운 +error 경계를 렌더링해요.
  • 적절한 요소로 포커스를 리셋해요.

use:enhance 커스터마이즈

동작을 커스터마이즈하려면 폼이 제출되기 직전에 실행되는 SubmitFunction을 제공하고, (선택적으로) ActionResult와 함께 실행되는 콜백을 반환할 수 있어요.

<form
	method="POST"
	use:enhance={({ formElement, formData, action, cancel, submitter }) => {
		// `formElement` is this `<form>` element
		// `formData` is its `FormData` object that's about to be submitted
		// `action` is the URL to which the form is posted
		// calling `cancel()` will prevent the submission
		// `submitter` is the `HTMLElement` that caused the form to be submitted

		return async ({ result, update }) => {
			// `result` is an `ActionResult` object
			// `update` is a function which triggers the default logic that would be triggered if this callback wasn't set
		};
	}}
>

이 함수들을 사용해 로딩 UI를 보여주고 숨기는 등의 작업을 할 수 있어요.

콜백을 반환하면 기본적인 제출 후 동작을 덮어써요. 기본 동작을 되찾으려면 update를 호출하면 되는데, updateinvalidateAllreset 파라미터를 받아요. 또는 결과에 applyAction을 쓸 수도 있어요.

/// file: src/routes/login/+page.svelte
<script>
	import { enhance, +++applyAction+++ } from '$app/forms';

	/** @type {import('./$types').PageProps} */
	let { form } = $props();
</script>

<form
	method="POST"
	use:enhance={({ formElement, formData, action, cancel }) => {
		return async ({ result }) => {
			// `result` is an `ActionResult` object
+++			if (result.type === 'redirect') {
				goto(result.location);
			} else {
				await applyAction(result);
			}+++
		};
	}}
>

applyAction(result)의 동작은 result.type에 따라 달라져요.

  • success, failurepage.statusresult.status로 설정하고 formpage.formresult.data로 갱신해요.(어디에서 제출하든, enhanceupdate와 달리)
  • redirectgoto(result.location, { invalidateAll: true })를 호출해요.
  • errorresult.error로 가장 가까운 +error 경계를 렌더링해요.

모든 경우에 포커스가 리셋돼요.

커스텀 이벤트 리스너

use:enhance 없이 <form>에 일반 이벤트 리스너를 붙여 점진적 향상을 직접 구현할 수도 있어요.

<!--- file: src/routes/login/+page.svelte --->
<script>
	import { invalidateAll, goto } from '$app/navigation';
	import { applyAction, deserialize } from '$app/forms';

	/** @type {import('./$types').PageProps} */
	let { form } = $props();

	/** @param {SubmitEvent & { currentTarget: EventTarget & HTMLFormElement}} event */
	async function handleSubmit(event) {
		event.preventDefault();
		const data = new FormData(event.currentTarget, event.submitter);

		const response = await fetch(event.currentTarget.action, {
			method: 'POST',
			body: data
		});

		/** @type {import('@sveltejs/kit').ActionResult} */
		const result = deserialize(await response.text());

		if (result.type === 'success') {
			// rerun all `load` functions, following the successful update
			await invalidateAll();
		}

		applyAction(result);
	}
</script>

<form method="POST" onsubmit={handleSubmit}>
	<!-- content -->
</form>

더 처리하기 전에 $app/forms의 해당 메서드로 응답을 deserialize해야 한다는 점에 주의하세요. JSON.parse()만으로는 부족해요. 폼 actions는 load 함수처럼 DateBigInt 객체 반환도 지원하기 때문이에요.

+page.server.js 옆에 +server.js가 있으면 fetch 요청은 기본으로 거기로 라우팅돼요. 대신 +page.server.js의 action으로 POST하려면 커스텀 x-sveltekit-action 헤더를 사용하세요.

// @errors: 2532 2304
const response = await fetch(this.action, {
	method: 'POST',
	body: data,
+++	headers: {
		'x-sveltekit-action': 'true'
	}+++
});

대안

폼 actions는 점진적으로 향상할 수 있으므로 서버로 데이터를 보내는 권장 방법이지만, +server.js 파일을 사용해 (예를 들어) JSON API를 노출할 수도 있어요. 이런 상호작용은 이렇게 생겼어요.

<!--- file: src/routes/send-message/+page.svelte --->
<script>
	function rerun() {
		fetch('/api/ci', {
			method: 'POST'
		});
	}
</script>

<button onclick={rerun}>Rerun CI</button>
// @errors: 2355 1360 2322
/// file: src/routes/api/ci/+server.js
/** @type {import('./$types').RequestHandler} */
export function POST() {
	// do something
}

GET vs POST

앞서 봤듯이 폼 action을 호출하려면 method="POST"를 써야 해요.

어떤 폼은 서버로 데이터를 POST할 필요가 없어요. 검색 입력이 그 예죠. 이런 폼은 method="GET"을 쓸 수 있고(또는 동등하게 method를 아예 생략), SvelteKit은 <a> 요소처럼 취급해서 전체 페이지 네비게이션 대신 클라이언트 사이드 라우터를 사용해요.

<form action="/search">
	<label>
		Search
		<input name="q">
	</label>
</form>

이 폼을 제출하면 /search?q=...로 네비게이션되고 load 함수가 호출되지만 action은 호출되지 않아요. <a> 요소와 마찬가지로 <form>data-sveltekit-reload, data-sveltekit-replacestate, data-sveltekit-keepfocus, data-sveltekit-noscroll 속성을 설정해 라우터 동작을 제어할 수 있어요.

더 알아보기