# SvelteKit으로 인앱 피드백 폼 만들기

> SvelteKit 인앱 피드백 폼입니다. 레이아웃의 폼이 하나의 폼 액션으로 POST하고, 액션이 서버에서 locals의 사용자와 요청의 페이지를 추가합니다.

2026-10-25 게시 · https://ikiform.com/ko/guides/feedback-form/sveltekit

로그인한 화면을 감싸는 레이아웃에 폼을 두고 `use:enhance`로 이름 있는 액션 하나, `/feedback?/send`에 POST하세요. `handle` 훅이 사용자를 `event.locals`에 넣으므로 액션이 직접 ID와 이메일을 추가하고 요청에서 페이지를 읽습니다. 브라우저는 점수, 코멘트, 라우트 ID만 보냅니다.

[메인 가이드의 SvelteKit 탭](https://ikiform.com/ko/guides/feedback-form.md#form)은 로그인 여부와 관계없이 어느 페이지에서든 `+server.ts` 엔드포인트로 JSON을 POST합니다. 이 페이지는 앱 내부용 버전을 만듭니다. JavaScript가 로드되기 전에도 동작하는 실제 폼 액션이며, 사용자의 신원은 서버에서 세션으로부터 가져옵니다. SvelteKit 3와 Svelte 5 기준입니다. 키와 허용 도메인은 [SvelteKit 문서 가이드](https://ikiform.com/ko/docs/guides/sveltekit.md)에서 다룹니다.

## 모든 화면에 액션 하나를 쓰는 이유

폼 액션은 페이지에 속하지만, 피드백 폼은 레이아웃에 있으면서 수십 개 페이지에 표시됩니다. 액션을 `+page.server.ts`마다 복사하면 엉망이 됩니다. SvelteKit에서는 폼이 쿼리 문자열로 다른 라우트의 액션을 호출할 수 있으므로, 모든 화면이 `/feedback?/send`로 POST하고 로직은 파일 하나에 둡니다. 그러면 각 값이 알맞은 곳에서 옵니다.

| 값 | 유입 경로 | 신뢰 |
| --- | --- | --- |
| `rating`, `comment` | 폼 | 사용자가 말하려는 것 |
| `user_id`, `email` | `handle` 훅이 설정하는 `event.locals.user` | 서버에서 세션 쿠키로부터 읽음 |
| `page` | 액션 요청의 `Referer` | 같은 origin만, 나머지는 버림 |
| `screen` | hidden 입력의 `page.route.id` | 묶기 위한 레이블. 저장 전에 패턴으로 확인 |

`page.route.id`는 `/(app)/projects/[id]/settings` 같은 라우트의 파일 경로입니다. 모든 프로젝트 설정 페이지가 이를 공유하며, 화면별로 점수 평균을 낼 때 바로 원하는 것입니다. `(app)` 같은 그룹 세그먼트는 폴더 구조의 세부 사항이므로 액션이 떼어 냅니다.

다이어그램: SvelteKit 피드백 POST가 레이아웃 폼에서 하나의 폼 액션을 거치는 과정

1. 사용자가 레이아웃의 폼에서 화면을 평가합니다. use:enhance가 페이지 로드 없이 /feedback?/send로 보냅니다.
2. handle 훅이 먼저 실행되어 세션 쿠키를 읽고 사용자를 event.locals에 넣습니다.
3. send 액션이 사용자가 있는지, 점수가 1~5의 정수인지 확인합니다. 아니면 메시지와 함께 fail을 반환합니다.
4. 사용자의 ID와 이메일, Referer의 페이지, 정리한 라우트 ID를 추가하고 시크릿 키로 POST합니다.
5. navigate를 false로 준 update가 결과를 현재 화면에 적용하므로, 사용자가 있는 곳에 감사 문구가 표시됩니다.

JavaScript가 없으면 브라우저가 대신 /feedback으로 이동하고, 거기서 같은 결과와 돌아가는 링크를 보여 줍니다.

## 프로젝트 설정

`프로젝트 트리`:

```text
src/
  app.d.ts
  env.ts
  hooks.server.ts
  lib/
    FeedbackForm.svelte
    server/session.ts
  routes/
    (app)/+layout.svelte
    feedback/
      +page.server.ts
      +page.svelte
.env
```

**API keys**에서 `submissions:write` 스코프로 시크릿 키를 만드세요. SvelteKit 3는 `src/env.ts`에서 환경 변수를 선언합니다. 따로 지정하지 않으면 변수는 비공개이고 런타임에 읽히므로, 키는 브라우저 번들에 들어가지 않으며 다시 빌드하지 않고도 호스트에서 바꿀 수 있습니다. 개발 중에는 `.env`가 값을 채웁니다.

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

`src/env.ts`:

```ts
import { defineEnvVars } from "@sveltejs/kit/env";

export const variables = defineEnvVars({
  IKIFORM_SECRET_KEY: {},
});
```

폼의 **Fields** 탭에 이 정의를 붙여 넣으세요. 폼에는 점수와 코멘트가 보이고 나머지는 액션이 채우며, `email` 필드 덕분에 각 알림의 **Reply**가 사용자에게 갑니다.

폼: In-app feedback

- **이 화면은 어떠신가요?** (`rating`, rating, 필수)
- **무엇이 더 좋아지면 될까요?** (`comment`, textarea)
- **페이지** (`page`, url)
- **화면** (`screen`, text)
- **사용자** (`user_id`, text)
- **이메일** (`email`, email)

점수 `6`이나 정의에 없는 필드를 보내면, 미리보기에 액션이 메시지로 바꾸는 `422`가 표시됩니다.

## 만들기

### locals에 사용자 넣기

`handle`은 폼 액션 POST를 포함해 서버가 받는 모든 요청에서 실행됩니다. `app.d.ts`에서 사용자 타입을 한 번 정의하고, 훅에서 세션으로부터 읽으세요. `readSession`은 인증 코드가 이미 쿠키로 하고 있는 처리를 대신합니다.

`src/app.d.ts`:

```ts
declare global {
  namespace App {
    interface Locals {
      user: { id: string; email: string } | null;
    }
  }
}

export {};
```

`src/hooks.server.ts`:

```ts
import type { Handle } from "@sveltejs/kit/hooks";
import { readSession } from "#lib/server/session.js";

export const handle: Handle = async ({ event, resolve }) => {
  event.locals.user = await readSession(event.cookies.get("session"));
  return resolve(event);
};
```

### 폼 액션

`src/routes/feedback/+page.server.ts`:

```ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions } from "./$types";

export const prerender = false;

const SCREEN = /^[a-z0-9/[\]._-]{1,120}$/;

function pageFrom(referer: string | null, origin: string) {
  try {
    const url = new URL(referer ?? "");
    return url.origin === origin ? url.origin + url.pathname : "";
  } catch {
    return "";
  }
}

export const actions = {
  send: async ({ request, locals, url }) => {
    const data = await request.formData();
    if (!locals.user) return fail(401, { message: "Your session ended. Sign in again, then send it." });

    const rating = Number(data.get("rating"));
    if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
      return fail(400, { message: "Pick a score from 1 to 5." });
    }

    const screen = String(data.get("screen") ?? "").replace(/\/\([^)]+\)/g, "") || "/";
    const back = pageFrom(request.headers.get("referer"), url.origin);

    let response: Response;
    try {
      response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
        method: "POST",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
          Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
          Origin: request.headers.get("origin") ?? url.origin,
        },
        body: JSON.stringify({
          rating,
          comment: String(data.get("comment") ?? "").trim().slice(0, 4000),
          page: back,
          screen: SCREEN.test(screen) ? screen : "unknown",
          user_id: locals.user.id,
          email: locals.user.email,
          _gotcha: String(data.get("_gotcha") ?? ""),
        }),
        signal: AbortSignal.timeout(8000),
      });
    } catch {
      return fail(502, { message: "We couldn't send it. Try again in a moment." });
    }

    if (response.status === 429) return fail(429, { message: "Too many answers at once. Try again in a minute." });
    const result = await response.json().catch(() => null);
    if (!result?.ok) {
      return fail(400, { message: result?.error?.fields?.comment?.[0] ?? result?.error?.message ?? "We couldn't send it." });
    }
    return { sent: true, back };
  },
} satisfies Actions;
```

- `fail`은 예외 없이 상태 코드와 데이터를 반환합니다. SvelteKit 3에서는 enhance된 폼도 그 상태 코드를 받으므로 `401`과 `400`이 네트워크 탭에 그대로 표시됩니다.
- `pageFrom`은 `Referer`가 내 origin일 때만 남기고 쿼리 문자열은 버립니다.
- `export const prerender = false`는 일부러 넣은 것입니다. 액션이 있는 페이지는 사전 렌더링할 수 없으므로, 루트 레이아웃이 기본으로 사전 렌더링한다면 이게 없을 때 빌드가 실패합니다.
- `Origin` 줄은 브라우저의 origin을 전달하고, 없으면 앱 자체의 origin을 써서 요청이 어디서 왔는지 Ikiform에 알립니다. 아래에서 설명하듯 로컬 테스트가 `localhost`로 집계되는 이유입니다.
- Ikiform의 `429`에는 별도 메시지를 둡니다. 서버 뒤에서는 이 사용자가 아니라 폼 전체가 바쁘다는 뜻이기 때문입니다.

### 레이아웃 폼

`src/lib/FeedbackForm.svelte`:

```svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import { page } from "$app/state";

  const scores = ["Broken", "Frustrating", "Fine", "Good", "Great"];
  let sending = $state(false);
</script>

{#if page.form?.sent}
  <p role="status">Thanks. We read every one.</p>
{:else}
  <form
    method="POST"
    action="/feedback?/send"
    aria-busy={sending}
    use:enhance={() => {
      sending = true;
      return async ({ update }) => {
        await update({ navigate: false, refreshAll: false });
        sending = false;
      };
    }}
  >
    <fieldset>
      <legend>How is this screen working for you?</legend>
      {#each scores as label, index}
        <label><input type="radio" name="rating" value={index + 1} required /> {label}</label>
      {/each}
    </fieldset>
    <label for="feedback-comment">What would make it better?</label>
    <textarea id="feedback-comment" name="comment" rows="4" maxlength="4000"></textarea>
    <input type="hidden" name="screen" value={page.route.id ?? ""} />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {#if page.form?.message}
      <p role="alert">{page.form.message}</p>
    {/if}
    <button disabled={sending}>{sending ? "Sending" : "Send feedback"}</button>
  </form>
{/if}
```

- 기본적으로 액션이 다른 페이지에 있는 enhance된 폼은 POST 후 그 페이지로 이동합니다. `navigate: false`는 사용자를 현재 화면에 두고 결과를 여기의 `page.form`에 넣습니다.
- `refreshAll: false`는 성공 후 모든 `load` 함수를 다시 실행하지 않습니다. 피드백은 페이지에 표시되는 내용을 바꾸지 않습니다.
- 실패하면 폼이 초기화되지 않으므로 코멘트가 메시지 옆 입력란에 남아 있습니다.
- `page.form`은 현재 페이지에 속합니다. 사용자가 이동하면 지워지고 다음 화면에는 새 폼이 표시됩니다.

`src/routes/(app)/+layout.svelte`:

```svelte
<script lang="ts">
  import FeedbackForm from "#lib/FeedbackForm.svelte";

  let { children } = $props();
</script>

<main>{@render children()}</main>
<aside>
  <FeedbackForm />
</aside>
```

JavaScript 없이도 같은 폼이 동작합니다. 브라우저가 `/feedback?/send`로 POST하고 `/feedback`으로 이동하며, 그 페이지가 결과와 원래 페이지로 돌아가는 링크를 렌더링합니다.

`src/routes/feedback/+page.svelte`:

```svelte
<script lang="ts">
  import type { PageProps } from "./$types";

  let { form }: PageProps = $props();
</script>

{#if form?.sent}
  <p role="status">Thanks. We read every one.</p>
  <a href={form.back || "/"}>Back to where you were</a>
{:else if form?.message}
  <p role="alert">{form.message}</p>
  <a href="/">Back to the app</a>
{:else}
  <p>Send feedback from the panel on any screen of the app.</p>
{/if}
```

## 허니팟, 사람 확인, 한도

hidden `_gotcha` 입력은 그대로 전달되고, 값이 채워지면 Ikiform이 스팸으로 저장합니다. 시크릿 키 POST에서는 Ikiform의 사람 확인을 건너뜁니다. 여기서는 액션이 이미 세션 없는 사람을 거부하므로 사람 확인이 막을 봇을 막습니다. 남은 것은 로그인한 계정이 반복해서 POST하는 경우이므로, 한 서버에서 POST하는 폼에 맞는 레이트 리밋 프리셋을 설정하세요.

서버 측 버전은 시크릿 키로 POST하므로 Ikiform이 **Allowed domains**와 사람 확인을 건너뜁니다. 그러면 모든 POST가 서버의 IP에서 오고, 방문자당 분당 5회인 Standard 한도가 모든 방문자를 합쳐서 계산합니다. 서버를 거쳐 POST하는 폼에는 Relaxed 또는 Custom [레이트 리밋](https://ikiform.com/ko/docs/rate-limits.md)을 고르고, `IKIFORM_SECRET_KEY`는 서버 전용 환경 변수에 두세요.

## 렌더링과 배포

폼 주변 화면은 원하는 대로 서버 렌더링하거나 사전 렌더링해도 됩니다. 폼 자체는 렌더링 시점에 아무것도 필요하지 않습니다. `/feedback`만 서버에서 실행되어야 합니다. 어댑터는 `vite.config.ts`의 `sveltekit()` 플러그인 안에서 설정합니다.

| 호스트 | 어댑터와 키 |
| --- | --- |
| Vercel | `@sveltejs/adapter-vercel`. **Environment Variables**에 `IKIFORM_SECRET_KEY`를 추가하세요. SvelteKit 3에서 edge 런타임이 사라졌으므로 액션은 Node 함수에서 실행됩니다. |
| Netlify | `@sveltejs/adapter-netlify`. 사이트의 환경 변수에 키를 설정하세요. |
| Cloudflare | peer dependency인 Wrangler 4.118 이상과 함께 `@sveltejs/adapter-cloudflare`를 쓰세요. 키는 `wrangler secret put IKIFORM_SECRET_KEY` 등으로 Worker 시크릿에 저장하고, 위처럼 `$app/env/private`로 읽으세요. |

## localhost에서 무료로 테스트하기

플랜이 없으면 워크스페이스는 `localhost`에서 월 50건의 테스트 제출을 받습니다. Ikiform은 `Origin` 헤더로 이를 판단하는데, 서버의 `fetch`는 직접 설정하지 않으면 헤더를 보내지 않습니다. 그래서 액션이 요청의 `Origin`을 전달합니다. `vite dev`에서는 `http://localhost:5173`이므로 POST가 무료 테스트로 집계됩니다. 빼면 로컬 테스트가 `402 plan_required`로 돌아와 플랜을 고를 때까지 보류됩니다.

## 작동 확인하기

1. `vite dev`를 실행하고 로그인한 뒤 프로젝트 설정 화면을 열어 코멘트와 함께 점수 2를 보내세요.
2. **Submissions**를 여세요. `screen`은 `/projects/[id]/settings`, `page`에는 실제 프로젝트 ID가 있고, `user_id`와 `email`이 내 계정과 일치합니다.
3. 세션 쿠키를 지우고 다시 보내세요. 폼에 로그인 메시지가 표시되고 코멘트는 유지됩니다.
4. JavaScript를 끄고 보내세요. 감사 문구와 화면으로 돌아가는 링크가 있는 `/feedback`으로 이동합니다.
5. 알림 이메일에 답장하세요. 로그인한 사용자에게 갑니다.

## 주의할 점

| 문제 | 해결 방법 |
| --- | --- |
| 감사 문구가 현재 화면이 아니라 `/feedback`에 표시됨 | enhance 콜백이 `navigate: false` 없이 `update()`를 호출하거나, 폼이 enhance되지 않았습니다. `use:enhance` 블록을 확인하세요. |
| 액션에서 `locals.user`가 항상 `null` | 훅이 다른 쿠키 이름을 읽거나, 쿠키의 `path`가 `/feedback`을 포함하지 않습니다. SvelteKit 3는 새 쿠키의 기본값이 `/`이지만, 예전 코드가 설정한 쿠키는 하위 경로로 제한되어 있을 수 있습니다. |
| 내가 소유한 다른 도메인에서 보낸 POST가 거부됨 | SvelteKit은 폼 POST의 origin을 항상 확인합니다. 모두에게 열지 말고 그 origin을 `csrf.trustedOrigins`에 추가하세요. |
| `/feedback` 사전 렌더링에 관한 오류로 빌드가 실패함 | 액션이 있는 페이지는 사전 렌더링할 수 없습니다. 그 `+page.server.ts`에 `export const prerender = false`를 유지하세요. |
| `422 validation_failed` | POST가 폼의 필드 정의와 맞지 않습니다. `error.fields`에 각 필드와 이유가 나옵니다. 입력의 `name`을 필드 ID와 맞추세요. |

## 질문

### SvelteKit 폼이 다른 라우트의 액션을 호출할 수 있나요?

네. 폼의 action을 /feedback?/send처럼 다른 라우트와 액션 이름으로 설정하세요. use:enhance에서는 update에 navigate: false를 넘겨 그 페이지로 이동하지 않고 현재 페이지에 결과를 표시하세요.

### SvelteKit 폼 액션 안에서 로그인한 사용자를 어떻게 가져오나요?

handle 훅에서 세션을 읽고 event.locals.user를 설정하세요. 모든 액션이 locals를 받으므로 브라우저가 보내는 것을 믿지 않고도 사용자의 ID와 이메일을 붙일 수 있습니다.

### 피드백 라우트 때문에 사전 렌더링 빌드가 깨지는 이유는 무엇인가요?

폼 액션이 있는 페이지는 서버가 POST에 응답해야 하므로 사전 렌더링할 수 없습니다. 액션이 있는 라우트에서 prerender를 false로 설정하세요.

### Ikiform은 무료인가요?

아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제 전에 localhost에서 테스트 제출을 매월 50건 보낼 수 있습니다. 실제로 운영하려면 Pro가 제출 500건에 월 $6부터이며, 한 번만 결제해 제출 10,000건을 $49에, 50,000건을 $149에 살 수도 있습니다. 크레딧은 만료되지 않습니다.

## 가이드 더 보기

- [웹사이트나 앱용 피드백 폼](https://ikiform.com/ko/guides/feedback-form.md): 어떤 페이지나 앱 안에든 피드백 폼을 둡니다. 평점, 코멘트, 보낸 페이지, 스팸 필터링, 낮은 점수 알림까지 다룹니다.
- [Nuxt 피드백 폼](https://ikiform.com/ko/guides/feedback-form/nuxt.md): Nuxt 인앱 피드백 폼입니다. 서버 라우트가 사용자 세션을 읽고 Referer에서 페이지를 가져오며, JavaScript가 로드되지 않았을 때도 동작합니다.
- [Astro 피드백 폼](https://ikiform.com/ko/guides/feedback-form/astro.md): Astro 인앱 피드백 폼입니다. 액션이 미들웨어 locals에서 사용자를, 요청에서 페이지를, 라우트 패턴에서 화면을 가져옵니다.
- [Next.js 피드백 폼](https://ikiform.com/ko/guides/feedback-form/nextjs.md): Next.js 인앱 피드백 폼입니다. 서버 액션이 세션을 읽고, 요청에서 페이지를 가져오고, 사용자 정보를 붙여 평점을 전달합니다.
- [버그 리포트 폼](https://ikiform.com/ko/guides/feedback-form/bug-report.md): 재현 단계와 심각도는 사람에게 묻고, URL, 브라우저, 뷰포트, 최근 오류는 페이지가 알아서 추가하는 버그 리포트 폼입니다.
- [기능 요청 폼](https://ikiform.com/ko/guides/feedback-form/feature-request.md): 해결책 대신 문제를 묻고, 요청을 테마별로 저장하고, 행이 아닌 사람 수를 세고, 출시되면 이메일을 보내는 기능 요청 폼입니다.
