웹사이트나 앱용 피드백 폼

웹사이트나 앱용 피드백 폼6분 분량

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

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

Preet SutharIkiform 창업자

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

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

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

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

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

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

SvelteKit 피드백 POST가 레이아웃 폼에서 하나의 폼 액션을 거치는 과정레이아웃폼모든화면,use:enhancehandle훅세션을locals.user로?/send액션사용자와점수확인Ikiform시크릿키,저장page.form같은화면에감사문구fail(401또는400)폼아래메시지POST /feedbacklocals+ 사용자, 페이지navigate: false사용자 없음 또는 잘못된 점수SvelteKit 피드백 POST가 레이아웃 폼에서 하나의 폼 액션을 거치는 과정레이아웃 폼모든 화면, use:enhancehandle 훅세션을 locals.user로?/send 액션사용자와 점수 확인Ikiform시크릿 키, 저장page.form같은 화면에 감사 문구fail(401또는 400)폼 아래 메시지POST /feedbacklocals+ 사용자, 페이지navigate: false사용자 없음 또는 잘못된 점수
  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으로 이동하고, 거기서 같은 결과와 돌아가는 링크를 보여 줍니다.

프로젝트 설정

프로젝트 트리
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
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
src/env.ts
import { defineEnvVars } from "@sveltejs/kit/env";

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

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

미리보기

Ikiform의 검증기를 브라우저에서 실행합니다. 아무것도 전송되지 않습니다.

이 화면은 어떠신가요?

서버가 요청에서 설정합니다.

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

만들기

locals에 사용자 넣기

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

src/app.d.ts
declare global {
  namespace App {
    interface Locals {
      user: { id: string; email: string } | null;
    }
  }
}

export {};
src/hooks.server.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
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
<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
<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
<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 레이트 리밋을 고르고, 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. 사이트의 환경 변수에 키를 설정하세요.
Cloudflarepeer 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_failedPOST가 폼의 필드 정의와 맞지 않습니다. error.fields에 각 필드와 이유가 나옵니다. 입력의 name을 필드 ID와 맞추세요.

질문

막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.