기술 스택프론트엔드SvelteKit › › › 폼 액션: <form>로 서버에 데이터 보내기 목차 폼 액션: 로 서버에 데이터 보내기기본 actions이름있는 actionsaction의 구조검증 오류리다이렉트데이터 로딩점진적 향상use:enhanceuse:enhance 커스터마이즈커스텀 이벤트 리스너대안GET vs POST더 알아보기 폼 액션: 로 서버에 데이터 보내기 +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에서 추가됐어요. 더 이전 버전에서는 data와 form 속성을 각각 타입으로 달아야 했어요: /// file: +page.svelte /** @type {{ data: import('./$types').PageData, form: import('./$types').ActionData }} */ let { data, form } = $props(); Svelte 4에서는 속성을 선언하기 위해 export let data와 export 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:enhance는 method="POST"를 가진 폼이면서 +page.server.js 파일에 정의된 actions를 가리킬 때만 쓸 수 있어요. method가 지정되지 않은 폼의 기본값인 method="GET"에서는 동작하지 않아요. use:enhance를 method="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를 호출하면 되는데, update는 invalidateAll과 reset 파라미터를 받아요. 또는 결과에 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, failure — page.status를 result.status로 설정하고 form과 page.form을 result.data로 갱신해요.(어디에서 제출하든, enhance의 update와 달리) redirect — goto(result.location, { invalidateAll: true })를 호출해요. error — result.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 함수처럼 Date나 BigInt 객체 반환도 지원하기 때문이에요. +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 속성을 설정해 라우터 동작을 제어할 수 있어요. 더 알아보기 튜토리얼: 폼