# 웹사이트나 앱에 피드백 폼 추가하기

> 어떤 페이지나 앱 안에든 피드백 폼을 둡니다. 평점, 코멘트, 보낸 페이지, 스팸 필터링, 낮은 점수 알림까지 다룹니다.

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

1~5점 평점, 선택 입력인 코멘트와 이메일로 된 짧은 폼을 만들고 폼 백엔드로 POST하면 이를 위해 서버를 운영할 필요가 없습니다. 브라우저는 전체 URL을 알아서 알려 주지 않으므로 모든 답변에 페이지 주소를 함께 보내세요. 낮은 점수는 당일에 팀 채팅으로 보내고, 추세를 보기 위해 모든 답변을 한곳에 모아 두세요.

대부분의 사람은 사이트의 문제를 말해 주지 않습니다. 그냥 떠납니다. 작은 피드백 폼은 말해 줄 소수의 사람을, 그들이 문제를 알아차린 순간, 문제가 생긴 페이지에서 붙잡습니다. 이 가이드는 어떤 사이트나 앱에도 맞고, 보낸 페이지를 함께 전송하고, 봇을 막고, 불만족스러운 답변을 당일에 팀 앞에 가져다 놓는 폼을 만듭니다.

## 피드백 폼의 작동 방식

폼은 Ikiform으로 POST합니다. Ikiform은 점수를 검증하고, POST의 스팸 여부를 검사하고, 저장하고, 팀에 이메일을 보내고, 서명된 웹훅을 보냅니다. 웹훅에서 낮은 점수는 채팅 채널로 보내고, 모든 답변은 시트나 데이터베이스로 보낼 수 있습니다.

다이어그램: 피드백 하나가 페이지에서 팀까지 전달되는 과정

1. 방문자가 점수를 고르고 코멘트를 쓸 수도 있으며, 폼이 페이지 주소와 함께 POST합니다.
2. Ikiform이 점수가 1~5의 정수인지 확인하고, 스팸 검사를 실행하고, 답변을 저장합니다.
3. 답변이 대시보드에 표시되고, 거기서 필터링하거나 CSV로 내보낼 수 있습니다.
4. Ikiform이 팀에 이메일을 보냅니다. 방문자가 이메일을 남겼다면 답장이 바로 그 사람에게 갑니다.
5. Ikiform이 필드와 브라우저 메타데이터를 담은 서명된 submission.created 웹훅을 보냅니다.
6. 작은 릴레이가 낮은 점수만 팀 채팅에 올리므로 그날 안에 읽힙니다.

릴레이는 선택 사항입니다. 이메일과 대시보드로 시작하고, 답변이 쌓이기 시작하면 채팅 알림을 추가하세요.

## 피드백을 모으는 방법

도구마다 맞는 일이 다릅니다. 위젯의 모양이 아니라 답변이 도착했을 때 무엇을 봐야 하는지로 고르세요.

| 방식 | 잘하는 것 | 단점 |
| --- | --- | --- |
| Hotjar나 Usersnap 같은 웹사이트 피드백 도구 | script 태그 하나 외에는 코드 없이 스크린샷, 주석, 페이지 맥락을 받을 수 있습니다. | 모든 페이지에 서드파티 스크립트가 들어가고, 답변은 그 제품에 저장되며, 가격은 그쪽 플랜이 정합니다. |
| Sentry User Feedback 같은 에러 트래커의 위젯 | 리포트를 에러와 연결하고, 세션 리플레이를 켜면 무슨 일이 있었는지 녹화본과도 연결합니다. | 의견이 아니라 버그용입니다. 먼저 앱에 트래커의 SDK가 있어야 합니다. |
| [Typeform](https://ikiform.com/alternatives/typeform), [Tally](https://ikiform.com/alternatives/tally), [Google Forms](https://ikiform.com/alternatives/google-forms) 같은 폼 빌더 | 코드 없이 다듬어진 폼을 만들 수 있습니다. 링크로 보내는 설문에 좋습니다. | iframe이나 별도 페이지이므로, URL로 넘기지 않으면 답변이 어느 페이지에 대한 것인지 잃어버립니다. |
| Canny 같은 피드백 보드 | 기능 요청에 대한 공개 투표와 상태 업데이트. | 빠른 반응이 아니라 아이디어용입니다. 답변은 설계상 공개됩니다. |
| 직접 만든 API 라우트와 데이터베이스 | 데이터와 모든 로직을 완전히 제어할 수 있습니다. | 스팸 필터링, 레이트 리밋, 알림 이메일, 내보내기를 직접 만들어야 하고 서버가 필요합니다. |
| Ikiform 같은 폼 백엔드 | 어떤 사이트나 앱에서든 직접 작성한 마크업을 씁니다. 서버 없이 검증, 스팸 필터링, 저장, 이메일, 서명된 웹훅을 제공합니다. | 파일 업로드를 지원하지 않으므로 스크린샷은 없고, 투표나 세션 리플레이도 없습니다. |

모든 리포트에 스크린샷이 필요하다면 피드백 도구나 에러 트래커의 스크립트를 쓸 만합니다. 폼이 내 사이트처럼 보이고, 답변을 옮기기 쉽게 두고, 어디로 보낼지 직접 정하고 싶다면 폼 백엔드를 쓰세요. 이 가이드의 나머지는 그 방법을 다룹니다.

## 폼

질문은 세 개면 충분합니다. 점수, 코멘트, 선택 입력인 이메일입니다. 점수는 사람들이 답하는 부분이고, 코멘트에 가치가 있고, 이메일은 후속 대응을 하는 수단입니다. 아래에서 스택을 고르세요. 선택한 스택은 이 가이드들의 모든 코드 예제에 이어집니다.

**HTML**

마크업 한 블록과 짧은 스크립트입니다. JavaScript가 없어도 폼은 POST하고 리디렉션되며, JavaScript가 있으면 방문자는 페이지에 머물고 버튼 옆에서 서버의 오류를 봅니다. 스크립트는 `page`에 쿼리 문자열을 뺀 주소를 채우므로 URL 속 토큰이 받은편지함에 닿지 않습니다.

`feedback.html`:

```html
<form id="feedback" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <fieldset>
    <legend>How useful was this page?</legend>
    <label><input type="radio" name="rating" value="1" required /> 1</label>
    <label><input type="radio" name="rating" value="2" /> 2</label>
    <label><input type="radio" name="rating" value="3" /> 3</label>
    <label><input type="radio" name="rating" value="4" /> 4</label>
    <label><input type="radio" name="rating" value="5" /> 5</label>
  </fieldset>
  <label for="comment">What would make it better?</label>
  <textarea id="comment" name="comment" rows="4" maxlength="4000"></textarea>
  <label for="feedback-email">Email, if you'd like a reply</label>
  <input id="feedback-email" name="email" type="email" />
  <input type="hidden" name="page" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Send feedback</button>
  <p id="feedback-status" role="status"></p>
</form>

<script>
  const form = document.getElementById("feedback");
  const status = document.getElementById("feedback-status");
  form.elements.page.value = location.origin + location.pathname;

  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    const button = form.querySelector("button");
    button.disabled = true;
    status.textContent = "Sending";
    try {
      const response = await fetch(form.action, {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(form),
      });
      const result = await response.json();
      if (result.ok) {
        form.replaceChildren(Object.assign(document.createElement("p"), { textContent: "Thanks. The team reads every note." }));
        return;
      }
      const fields = result.error.fields ?? {};
      status.textContent = fields.rating?.[0] ?? fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message;
    } catch {
      status.textContent = "Couldn't send it. Try again in a moment.";
    }
    button.disabled = false;
  });
</script>
```

**Next.js**

서버 액션은 자신에게 온 POST의 `Referer` 헤더를 읽습니다. 폼과 액션이 같은 origin을 공유하므로 브라우저는 전체 페이지 URL을 보내고, 액션은 origin과 경로만 남깁니다. hidden 입력은 필요 없습니다.

`app/feedback/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";

export interface FeedbackState {
  sent: boolean;
  error: string;
}

export async function sendFeedback(_previous: FeedbackState, formData: FormData): Promise<FeedbackState> {
  const rating = Number(formData.get("rating"));
  if (!Number.isInteger(rating) || rating < 1 || rating > 5) return { sent: false, error: "Pick a score from 1 to 5." };

  const incoming = await headers();
  const referer = incoming.get("referer");
  const origin = incoming.get("origin");
  const page = referer ? new URL(referer) : null;

  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify({
      rating,
      comment: String(formData.get("comment") ?? ""),
      email: String(formData.get("email") ?? ""),
      page: page ? page.origin + page.pathname : "",
      _gotcha: String(formData.get("_gotcha") ?? ""),
    }),
  });
  const result = await response.json().catch(() => null);
  if (result?.ok) return { sent: true, error: "" };
  const fields = result?.error?.fields ?? {};
  return { sent: false, error: fields.comment?.[0] ?? fields.email?.[0] ?? "Couldn't send it. Try again." };
}
```

`app/feedback/feedback-form.tsx`:

```tsx
"use client";

import { useActionState } from "react";
import { type FeedbackState, sendFeedback } from "./actions";

const initial: FeedbackState = { sent: false, error: "" };

export function FeedbackForm() {
  const [state, action, pending] = useActionState(sendFeedback, initial);

  if (state.sent) return <p role="status">Thanks. The team reads every note.</p>;

  return (
    <form action={action}>
      <fieldset>
        <legend>How useful was this page?</legend>
        {[1, 2, 3, 4, 5].map((score) => (
          <label key={score}>
            <input type="radio" name="rating" value={score} required /> {score}
          </label>
        ))}
      </fieldset>
      <label htmlFor="comment">What would make it better?</label>
      <textarea id="comment" name="comment" rows={4} maxLength={4000} />
      <label htmlFor="feedback-email">Email, if you'd like a reply</label>
      <input id="feedback-email" name="email" type="email" />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {state.error ? <p role="alert">{state.error}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send feedback"}</button>
    </form>
  );
}
```

Next.js에서 더 자세히 알아보려면 [Next.js로 인앱 피드백 폼 만들기](https://ikiform.com/ko/guides/feedback-form/nextjs.md)을(를) 읽어 보세요.

**Astro**

페이지는 렌더링되는 동안 자기 주소를 알고 있으므로 `Astro.url`이 서버에서 hidden `page` 입력을 채웁니다. 빈 입력은 액션에 `null`로 전달되고, Ikiform은 이를 누락으로 처리합니다. 액션 결과가 표시되려면 페이지가 요청 시 렌더링되어야 합니다.

`src/actions/index.ts`:

```ts
import { ActionError, defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";

export const server = {
  sendFeedback: defineAction({
    accept: "form",
    input: z.object({
      rating: z.coerce.number().int().min(1).max(5),
      comment: z.string().max(4000).nullish(),
      email: z.email().nullish(),
      page: z.string().nullish(),
      _gotcha: z.string().nullish(),
    }),
    handler: async (input, context) => {
      const origin = context.request.headers.get("origin");
      const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
        method: "POST",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
          Authorization: `Bearer ${getSecret("IKIFORM_SECRET_KEY")}`,
          ...(origin ? { Origin: origin } : {}),
        },
        body: JSON.stringify(input),
      });
      const result = await response.json();
      if (!result.ok) throw new ActionError({ code: "BAD_REQUEST", message: result.error.message });
      return { sent: true };
    },
  }),
};
```

`src/components/FeedbackForm.astro`:

```astro
---
import { actions, isInputError } from "astro:actions";

const result = Astro.getActionResult(actions.sendFeedback);
const page = Astro.url.origin + Astro.url.pathname;
---

{result?.data?.sent ? (
  <p role="status">Thanks. The team reads every note.</p>
) : (
  <form method="POST" action={actions.sendFeedback}>
    <fieldset>
      <legend>How useful was this page?</legend>
      {[1, 2, 3, 4, 5].map((score) => (
        <label><input type="radio" name="rating" value={score} required /> {score}</label>
      ))}
    </fieldset>
    <label for="comment">What would make it better?</label>
    <textarea id="comment" name="comment" rows="4" maxlength="4000"></textarea>
    <label for="feedback-email">Email, if you'd like a reply</label>
    <input id="feedback-email" name="email" type="email" />
    <input type="hidden" name="page" value={page} />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {result?.error && (
      <p role="alert">{isInputError(result.error) ? "Pick a score and check your email address." : result.error.message}</p>
    )}
    <button>Send feedback</button>
  </form>
)}
```

Astro에서 더 자세히 알아보려면 [Astro로 인앱 피드백 폼 만들기](https://ikiform.com/ko/guides/feedback-form/astro.md)을(를) 읽어 보세요.

**TanStack Start**

POST 서버 함수가 서버 밖으로 무엇이든 나가기 전에 점수를 확인합니다. 컴포넌트는 제출 시점에 `window.location`에서 `page`를 설정하므로 클라이언트 측 내비게이션 후에도 정확합니다.

`src/components/FeedbackForm.tsx`:

```tsx
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";

const sendFeedback = createServerFn({ method: "POST" })
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    const rating = Number(data.get("rating"));
    if (!Number.isInteger(rating) || rating < 1 || rating > 5) throw new Error("Pick a score from 1 to 5.");
    return {
      rating,
      comment: String(data.get("comment") ?? ""),
      email: String(data.get("email") ?? ""),
      page: String(data.get("page") ?? ""),
      _gotcha: String(data.get("_gotcha") ?? ""),
    };
  })
  .handler(async ({ data }) => {
    const origin = getRequestHeader("origin");
    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
        ...(origin ? { Origin: origin } : {}),
      },
      body: JSON.stringify(data),
    });
    const result = await response.json();
    if (result.ok) return { sent: true, error: "" };
    const fields = result.error.fields ?? {};
    return { sent: false, error: String(fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message) };
  });

export function FeedbackForm() {
  const send = useServerFn(sendFeedback);
  const [state, setState] = useState({ sent: false, error: "" });
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    data.set("page", window.location.origin + window.location.pathname);
    setPending(true);
    try {
      setState(await send({ data }));
    } catch (error) {
      setState({ sent: false, error: error instanceof Error ? error.message : "Couldn't send it. Try again." });
    } finally {
      setPending(false);
    }
  }

  if (state.sent) return <p role="status">Thanks. The team reads every note.</p>;

  return (
    <form method="post" onSubmit={onSubmit}>
      <fieldset>
        <legend>How useful was this page?</legend>
        {[1, 2, 3, 4, 5].map((score) => (
          <label key={score}>
            <input type="radio" name="rating" value={score} required /> {score}
          </label>
        ))}
      </fieldset>
      <label htmlFor="comment">What would make it better?</label>
      <textarea id="comment" name="comment" rows={4} maxLength={4000} />
      <label htmlFor="feedback-email">Email, if you'd like a reply</label>
      <input id="feedback-email" name="email" type="email" />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {state.error ? <p role="alert">{state.error}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send feedback"}</button>
    </form>
  );
}
```

TanStack Start에서 더 자세히 알아보려면 [TanStack Start로 인앱 피드백 폼 만들기](https://ikiform.com/ko/guides/feedback-form/tanstack-start.md)을(를) 읽어 보세요.

**React Router**

리소스 라우트가 액션을 담고, `useFetcher`가 내비게이션 없이 어느 페이지에서든 그 라우트로 POST합니다. `app/routes.ts`에서 라우트를 `api/feedback`으로 등록하세요. `useLocation`이 경로를 제공하고, 액션은 사이트의 origin을 가진 자신의 URL을 기준으로 경로를 해석합니다.

`app/routes/api.feedback.ts`:

```ts
import { data } from "react-router";
import type { Route } from "./+types/api.feedback";

export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData();
  const origin = request.headers.get("origin");
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify({
      rating: String(formData.get("rating") ?? ""),
      comment: String(formData.get("comment") ?? ""),
      email: String(formData.get("email") ?? ""),
      page: new URL(String(formData.get("path") ?? "/"), request.url).href,
      _gotcha: String(formData.get("_gotcha") ?? ""),
    }),
  });
  const result = await response.json();
  if (result.ok) return { sent: true, error: "" };
  const fields = result.error.fields ?? {};
  return data(
    { sent: false, error: String(fields.rating?.[0] ?? fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message) },
    { status: response.status },
  );
}
```

`app/components/feedback-form.tsx`:

```tsx
import { useFetcher, useLocation } from "react-router";
import type { action } from "../routes/api.feedback";

export function FeedbackForm() {
  const fetcher = useFetcher<typeof action>();
  const { pathname } = useLocation();
  const pending = fetcher.state !== "idle";

  if (fetcher.data?.sent) return <p role="status">Thanks. The team reads every note.</p>;

  return (
    <fetcher.Form method="post" action="/api/feedback">
      <fieldset>
        <legend>How useful was this page?</legend>
        {[1, 2, 3, 4, 5].map((score) => (
          <label key={score}>
            <input type="radio" name="rating" value={score} required /> {score}
          </label>
        ))}
      </fieldset>
      <label htmlFor="comment">What would make it better?</label>
      <textarea id="comment" name="comment" rows={4} maxLength={4000} />
      <label htmlFor="feedback-email">Email, if you'd like a reply</label>
      <input id="feedback-email" name="email" type="email" />
      <input type="hidden" name="path" value={pathname} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {fetcher.data?.error ? <p role="alert">{fetcher.data.error}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send feedback"}</button>
    </fetcher.Form>
  );
}
```

React Router에서 더 자세히 알아보려면 [React Router로 인앱 피드백 폼 만들기](https://ikiform.com/ko/guides/feedback-form/react-router.md)을(를) 읽어 보세요.

**SvelteKit**

`+server.ts` 엔드포인트가 POST를 전달하므로 컴포넌트를 레이아웃에 두고 모든 페이지에서 쓸 수 있습니다. `$app/state`의 `page`가 현재 URL을 제공합니다. `$app/env/private`가 읽을 수 있도록 `src/env.ts`에서 `defineEnvVars`로 `IKIFORM_SECRET_KEY`를 선언하세요. SvelteKit 3에서 `json()` 헬퍼가 deprecated되었으므로 엔드포인트는 `Response.json()`을 반환합니다.

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

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

export const POST: RequestHandler = async ({ request, fetch, url }) => {
  const body = await request.json();
  const rating = Number(body.rating);
  if (!Number.isInteger(rating) || rating < 1 || rating > 5) error(400, "Pick a score from 1 to 5.");

  const 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(body.comment ?? ""),
      email: String(body.email ?? ""),
      page: String(body.page ?? ""),
      _gotcha: String(body._gotcha ?? ""),
    }),
  });
  const result = await response.json();
  if (!result.ok) {
    const fields = result.error.fields ?? {};
    error(response.status, String(fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message));
  }
  return Response.json({ sent: true });
};
```

`src/lib/FeedbackForm.svelte`:

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

  let rating = $state(0);
  let comment = $state("");
  let email = $state("");
  let trap = $state("");
  let status = $state<"idle" | "sending" | "sent">("idle");
  let message = $state("");

  async function send(event: SubmitEvent) {
    event.preventDefault();
    status = "sending";
    message = "";
    const response = await fetch("/api/feedback", {
      method: "POST",
      headers: { Accept: "application/json", "Content-Type": "application/json" },
      body: JSON.stringify({ rating, comment, email, page: page.url.origin + page.url.pathname, _gotcha: trap }),
    });
    if (response.ok) {
      status = "sent";
      return;
    }
    message = (await response.json().catch(() => null))?.message ?? "Couldn't send it. Try again.";
    status = "idle";
  }
</script>

{#if status === "sent"}
  <p role="status">Thanks. The team reads every note.</p>
{:else}
  <form onsubmit={send}>
    <fieldset>
      <legend>How useful was this page?</legend>
      {#each [1, 2, 3, 4, 5] as score}
        <label><input type="radio" name="rating" value={score} bind:group={rating} required /> {score}</label>
      {/each}
    </fieldset>
    <label for="comment">What would make it better?</label>
    <textarea id="comment" name="comment" rows="4" maxlength="4000" bind:value={comment}></textarea>
    <label for="feedback-email">Email, if you'd like a reply</label>
    <input id="feedback-email" name="email" type="email" bind:value={email} />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden bind:value={trap} />
    {#if message}
      <p role="alert">{message}</p>
    {/if}
    <button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Send feedback"}</button>
  </form>
{/if}
```

SvelteKit에서 더 자세히 알아보려면 [SvelteKit으로 인앱 피드백 폼 만들기](https://ikiform.com/ko/guides/feedback-form/sveltekit.md)을(를) 읽어 보세요.

**Nuxt**

서버 라우트는 `NUXT_IKIFORM_SECRET_KEY`에서 채워지는 `runtimeConfig.ikiformSecretKey`에 키를 보관합니다. `useRoute().path`와 `useRequestURL()`이 서버와 브라우저 양쪽에서 똑같이 페이지 주소를 만듭니다.

`server/api/feedback.post.ts`:

```ts
export default defineEventHandler(async (event) => {
  const body = await readBody<{ rating?: number; comment?: string; email?: string; page?: string; _gotcha?: string }>(event);
  const config = useRuntimeConfig(event);
  const origin = getRequestHeader(event, "origin");

  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${config.ikiformSecretKey}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify({
      rating: body.rating,
      comment: body.comment ?? "",
      email: body.email ?? "",
      page: body.page ?? "",
      _gotcha: body._gotcha ?? "",
    }),
  });
  const result = await response.json();
  if (!result.ok) {
    const fields = result.error.fields ?? {};
    throw createError({
      status: response.status,
      message: String(fields.rating?.[0] ?? fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message),
    });
  }
  return { sent: true };
});
```

`app/components/FeedbackForm.vue`:

```vue
<script setup lang="ts">
const route = useRoute();
const requestUrl = useRequestURL();
const rating = ref<number | null>(null);
const comment = ref("");
const email = ref("");
const trap = ref("");
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");

async function send() {
  status.value = "sending";
  error.value = "";
  try {
    await $fetch("/api/feedback", {
      method: "POST",
      body: {
        rating: rating.value,
        comment: comment.value,
        email: email.value,
        page: requestUrl.origin + route.path,
        _gotcha: trap.value,
      },
    });
    status.value = "sent";
  } catch (failure) {
    error.value = (failure as { data?: { message?: string } }).data?.message ?? "Couldn't send it. Try again.";
    status.value = "idle";
  }
}
</script>

<template>
  <p v-if="status === 'sent'" role="status">Thanks. The team reads every note.</p>
  <form v-else @submit.prevent="send">
    <fieldset>
      <legend>How useful was this page?</legend>
      <label v-for="score in 5" :key="score">
        <input v-model="rating" type="radio" name="rating" :value="score" required /> {{ score }}
      </label>
    </fieldset>
    <label for="comment">What would make it better?</label>
    <textarea id="comment" v-model="comment" name="comment" rows="4" maxlength="4000" />
    <label for="feedback-email">Email, if you'd like a reply</label>
    <input id="feedback-email" v-model="email" name="email" type="email" />
    <input v-model="trap" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p v-if="error" role="alert">{{ error }}</p>
    <button :disabled="status === 'sending'">{{ status === "sending" ? "Sending" : "Send feedback" }}</button>
  </form>
</template>
```

Nuxt에서 더 자세히 알아보려면 [Nuxt로 인앱 피드백 폼 만들기](https://ikiform.com/ko/guides/feedback-form/nuxt.md)을(를) 읽어 보세요.

**React**

자체 서버가 없으면 브라우저가 Ikiform으로 바로 POST합니다. `@ikiform/sdk`의 `submit()`이 `FormData`를 보내고 `{ ok }`나 필드 오류를 반환합니다. 여기에는 절대 시크릿 키를 넣지 마세요. 대신 **Allowed domains**(허용 도메인)가 폼을 보호합니다.

`src/FeedbackForm.tsx`:

```tsx
import { submit } from "@ikiform/sdk";
import { type FormEvent, useState } from "react";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

export function FeedbackForm() {
  const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
  const [error, setError] = useState("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    data.set("page", window.location.origin + window.location.pathname);
    setStatus("sending");
    setError("");
    const result = await submit(FORM_ID, data);
    if (result.ok) {
      setStatus("sent");
      return;
    }
    const fields = result.error.fields ?? {};
    setError(fields.rating?.[0] ?? fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message);
    setStatus("idle");
  }

  if (status === "sent") return <p role="status">Thanks. The team reads every note.</p>;

  return (
    <form onSubmit={onSubmit}>
      <fieldset>
        <legend>How useful was this page?</legend>
        {[1, 2, 3, 4, 5].map((score) => (
          <label key={score}>
            <input type="radio" name="rating" value={score} required /> {score}
          </label>
        ))}
      </fieldset>
      <label htmlFor="comment">What would make it better?</label>
      <textarea id="comment" name="comment" rows={4} maxLength={4000} />
      <label htmlFor="feedback-email">Email, if you'd like a reply</label>
      <input id="feedback-email" name="email" type="email" />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {error ? <p role="alert">{error}</p> : null}
      <button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Send feedback"}</button>
    </form>
  );
}
```

**Vue**

같은 브라우저 측 POST를 싱글 파일 컴포넌트로 작성한 것입니다. 점수가 1이나 2이면 코멘트 레이블이 바뀌어, 불만족한 방문자에게서 더 구체적인 답변을 얻습니다.

`src/components/FeedbackForm.vue`:

```vue
<script setup lang="ts">
import { ref } from "vue";

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

const rating = ref(0);
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");

async function send(event: SubmitEvent) {
  const body = new FormData(event.target as HTMLFormElement);
  body.set("page", window.location.origin + window.location.pathname);
  status.value = "sending";
  error.value = "";
  try {
    const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
    const result = await response.json();
    if (result.ok) {
      status.value = "sent";
      return;
    }
    const fields = result.error.fields ?? {};
    error.value = fields.rating?.[0] ?? fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message;
  } catch {
    error.value = "Couldn't send it. Try again in a moment.";
  }
  status.value = "idle";
}
</script>

<template>
  <p v-if="status === 'sent'" role="status">Thanks. The team reads every note.</p>
  <form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
    <fieldset>
      <legend>How useful was this page?</legend>
      <label v-for="score in 5" :key="score">
        <input v-model="rating" type="radio" name="rating" :value="score" required /> {{ score }}
      </label>
    </fieldset>
    <label for="comment">{{ rating && rating <= 2 ? "What went wrong?" : "What would make it better?" }}</label>
    <textarea id="comment" name="comment" rows="4" maxlength="4000" />
    <label for="feedback-email">Email, if you'd like a reply</label>
    <input id="feedback-email" name="email" type="email" />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p v-if="error" role="alert">{{ error }}</p>
    <button :disabled="status === 'sending'">{{ status === "sending" ? "Sending" : "Send feedback" }}</button>
  </form>
</template>
```

- `frm_xxxxxxxxxxxxxxxx`를 대시보드에 있는 폼 ID로 바꾸세요.
- `rating`은 다섯 값의 라디오 그룹입니다. Ikiform이 숫자로 저장하므로 평균을 낼 때 파싱이 필요 없습니다.
- `email`은 선택 입력입니다. 사람들은 신원을 밝히지 않아도 될 때 더 솔직하게 쓰고, 답변을 원하는 사람은 이메일을 남깁니다.
- hidden `_gotcha` 입력이 허니팟입니다. 사람에게는 보이지 않고, 모든 입력을 채우는 봇은 스스로 스팸임을 드러냅니다.
- `page`는 답변이 어느 페이지에 대한 것인지 기록합니다. 왜 직접 보내야 하는지는 다음 섹션에서 설명합니다.

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

문서의 프레임워크 가이드에서 설정을 더 자세히 다룹니다: [Next.js](https://ikiform.com/ko/docs/guides/nextjs.md), [Astro](https://ikiform.com/ko/docs/guides/astro.md), [SvelteKit](https://ikiform.com/ko/docs/guides/sveltekit.md), [Nuxt](https://ikiform.com/ko/docs/guides/nuxt.md), [React](https://ikiform.com/ko/docs/guides/react.md), [Vue](https://ikiform.com/ko/docs/guides/vue.md), [일반 HTML](https://ikiform.com/ko/docs/html.md). [피드백 폼 템플릿](https://ikiform.com/forms/feedback)은 같은 필드를 HTML, React, 서버 액션으로 생성하고, [버그 리포트](https://ikiform.com/forms/bug-report)와 [설문](https://ikiform.com/forms/survey) 템플릿은 더 긴 폼을 다룹니다.

## 필드 정의하기

폼의 **Fields** 탭에 정의를 붙여 넣으면 Ikiform이 어느 페이지에서 오든 모든 POST에 이를 강제합니다. 아래에서 시험해 보세요. 미리보기는 브라우저에서 Ikiform의 검증기를 실행하므로 점수 `0`이나 잘못된 이메일을 넣으면 실제로 돌려받을 `422`가 표시됩니다.

폼: Page feedback

- **이 페이지가 얼마나 유용했나요?** (`rating`, rating, 필수)
- **무엇이 더 좋아지면 될까요?** (`comment`, textarea)
- **답장을 원하시면 이메일** (`email`, email)
- **페이지** (`page`, url)

`rating`은 1부터 `max`(여기서는 5)까지의 정수를 받습니다. 10까지 올릴 수 있지만 선택지가 다섯 개면 더 빨리 답할 수 있습니다.

| 필드 | 유형 | 이유 |
| --- | --- | --- |
| `rating` | `rating`, 최대 5 | 클릭 한 번. 숫자로 저장되므로 페이지별 평균을 낼 수 있습니다 |
| `comment` | `textarea`, 최대 4,000자 | 점수의 이유 |
| `email` | `email`, 선택 | 질문한 사람에게 답장합니다. 알림의 reply-to가 됩니다 |
| `page` | `url`, 선택 | 답변이 어느 페이지에 대한 것인지 |

필드를 정의하면 POST에 담긴 다른 이름은 `Unknown field`로 거부됩니다. 나중에 앱 버전이나 플랜 이름 같은 맥락을 더 보내려면 먼저 그 필드를 추가하세요. 필드 ID는 소문자 snake_case입니다.

## 어느 페이지에 대한 것인지 알기

페이지 없는 점수는 대응하기 어렵습니다. Ikiform은 모든 제출에 브라우저 메타데이터를 기록하지만, 그중 하나는 보기보다 쓸모가 적습니다.

| 값 | 출처 | 얻는 것 |
| --- | --- | --- |
| `meta.user_agent` | Ikiform이 기록 | 브라우저 POST에서 얻는 브라우저와 운영체제 |
| `meta.origin` | Ikiform이 기록 | `https://example.com` 같은 사이트의 origin |
| `meta.referrer` | Ikiform이 기록 | 대개 origin뿐입니다. 브라우저의 기본 정책인 `strict-origin-when-cross-origin`은 다른 사이트로 POST할 때 경로를 떼어 냅니다 |
| `meta.country` | Ikiform이 기록 | 두 글자 국가 코드 |
| `page` | 폼이 전송 | 전체 경로. 답변이 어느 페이지에 대한 것인지 알 수 있습니다 |

그래서 위의 모든 예제는 `page`를 직접 보냅니다. HTML과 SPA 버전은 `location`을 읽고, 서버 버전은 각 프레임워크가 이미 알고 있는 주소를 읽습니다. 모두 쿼리 문자열을 떼어 냅니다. 쿼리 문자열에는 저장하고 싶지 않은 검색어, 추적 ID, 일회용 토큰이 들어 있을 수 있습니다.

> 서버 버전은 서버에서 POST하므로 `meta.user_agent`는 방문자의 브라우저가 아니라 서버의 런타임입니다. 페이지 피드백에서는 거의 문제가 되지 않지만 버그 리포트에서는 중요하므로, [버그 리포트 가이드](https://ikiform.com/ko/guides/feedback-form/bug-report.md)에서는 브라우저의 user agent를 필드로 전달합니다.

## 어디서 물어볼지

폼을 어디에 두느냐에 따라 듣는 내용이 달라집니다. 모든 페이지 푸터에 있는 폼에는 강한 의견이 드문드문 들어옵니다. 작업을 끝낸 직후의 질문에는 꾸준하고 구체적인 답변이 들어옵니다.

| 위치 | 적합한 용도 | 주의할 점 |
| --- | --- | --- |
| 문서 페이지나 글의 끝 | 질문에 답하지 못하는 페이지 찾기 | 페이지당 양이 적습니다. 페이지가 아니라 섹션 단위로 보세요 |
| 앱에서 내보내기나 첫 프로젝트 같은 작업을 마친 뒤 | 특정 흐름이 어떻게 느껴지는지, 기억이 생생할 때 | 작업을 마칠 때마다가 아니라 사람당 한 번만 물어보세요 |
| 모든 화면에 떠 있는 버튼 | 무엇이든, 언제든. [React 위젯](https://ikiform.com/ko/guides/feedback-form/react-feedback-widget.md)을 참고하세요 | 내 버튼, 쿠키 배너, 채팅 버블을 가리면 안 됩니다 |
| 이메일 속 링크 | [NPS](https://ikiform.com/ko/guides/feedback-form/nps-survey.md)처럼 시간에 따라 추적할 점수 | 이메일 클라이언트는 내 페이지가 아니므로 폼에 사이트의 랜딩 페이지가 필요합니다 |

어디에 두든 폼은 작게 유지하세요. 점수를 먼저 묻고 코멘트 상자는 두 번째 단계가 아니라 점수와 함께 보여 주세요. 두 번째 단계에서 대부분이 이탈하며, 이유 없는 점수라도 받아 둘 가치가 있습니다.

## 낮은 점수를 사람 앞에 가져다 놓기

다이어그램: 점수에 따라 답변이 어떻게 처리되는지

1. 점수와 보낸 페이지에 상관없이 모든 답변이 저장됩니다.
2. Ikiform이 서명된 submission.created 웹훅을 릴레이로 보냅니다.
3. 릴레이가 평점을 읽습니다. 3~5점은 당장 더 할 일이 없습니다.
4. 1점이나 2점은 페이지와 코멘트와 함께 팀 채팅으로 갑니다.
5. 방문자가 이메일을 남겼다면 누군가 당일에 답장합니다.
6. 일주일에 한 번 내보내기로 페이지별 평균을 봅니다. 추세는 거기서 드러납니다.

나쁜 점수에만 알림을 보내세요. 5점 만점에 5점마다 알림이 울리는 채널은 일주일 안에 음소거됩니다.

알림 이메일은 폼당 최대 10명의 확인된 수신자에게 가며 모든 답변마다 도착합니다. 하루 몇 건이라면 괜찮습니다. 그 이상이면 이 Next.js 라우트 같은 릴레이로 낮은 점수만 채팅에 보내세요. 같은 코드가 Cloudflare Worker에서도 동작합니다.

`app/api/feedback-alerts/route.ts`:

```ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

export async function POST(request: Request) {
  const payload = await request.text();
  try {
    const event = await verifyWebhook({
      payload,
      headers: request.headers,
      secret: process.env.IKIFORM_WEBHOOK_SECRET!,
    });
    if (event.type !== "submission.created") return new Response(null, { status: 204 });
    const { rating, comment, page, email } = event.data.fields;
    if (Number(rating) > 2) return new Response(null, { status: 204 });

    const lines = [
      `*${rating}/5* on ${page ?? event.data.meta.origin ?? "an unknown page"}`,
      comment ? `> ${String(comment).slice(0, 500)}` : "_No comment._",
      email ? `Reply to ${email}` : "Anonymous",
    ];
    const response = await fetch(process.env.SLACK_WEBHOOK_URL!, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ text: lines.join("\n") }),
    });
    if (!response.ok) return new Response(`Slack answered ${response.status}`, { status: 502 });
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
}
```

- `verifyWebhook`이 서명과 타임스탬프를 확인하므로 다른 사람은 이 URL로 채널에 글을 올릴 수 없습니다.
- 2점보다 높은 점수는 `204`로 응답하고 끝납니다. Ikiform은 모든 2xx를 전달 완료로 처리합니다.
- Slack 호출이 실패하면 `502`로 응답하고, Ikiform이 같은 `webhook-id`로 재시도하므로 잠깐의 장애로 알림을 잃지 않습니다.
- 필드 값은 방문자가 입력한 그대로입니다. 릴레이는 코멘트를 채팅에 보내기 전에 500자로 자릅니다.

**Webhooks**에서 엔드포인트를 추가하고 `whsec_` 시크릿을 `IKIFORM_WEBHOOK_SECRET`에 복사하세요. 워크스페이스에는 엔드포인트를 3개까지 둘 수 있습니다. 노코드 방식은 [Slack](https://ikiform.com/blog/form-submissions-to-slack.md), [Discord](https://ikiform.com/blog/form-submissions-to-discord.md), [Google Sheets](https://ikiform.com/blog/form-submissions-to-google-sheets.md)를 참고하세요. 티켓이 되어야 하는 피드백이라면 [Linear와 GitHub Issues](https://ikiform.com/blog/form-submissions-to-linear-and-github-issues.md)에 제출마다 티켓을 하나씩 만드는 릴레이가 있습니다.

> 원클릭 연동은 없습니다. 모든 도구는 서명된 [웹훅](https://ikiform.com/ko/docs/webhooks.md)으로 직접, 또는 [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md)나 [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md) 같은 자동화 도구를 거쳐 연결합니다.

## 봇 막기

모든 페이지의 피드백 폼은 모든 페이지에 있는 공개 폼입니다. 봇이 찾아내고, 봇이 보내는 내용은 대부분 링크입니다. Ikiform의 기본 설정이 대부분을 처리합니다.

| 방어 | 막는 것 | 설정 |
| --- | --- | --- |
| 허니팟 | 모든 입력을 채우는 봇 | 예제의 hidden `_gotcha` 입력 |
| 스팸 점수 | 링크 3개 이상(+2), 알려진 스팸 문구(+2), 일회용 이메일(+2), 동일한 반복(+3). 5점이 되면 스팸으로 저장됩니다 | 없음 |
| 레이트 리밋 | 반복해서 POST하는 스크립트 | 기본으로 켜짐: Standard, 방문자당 분당 5회, 폼 전체 분당 300회 |
| 허용 도메인 | 다른 사이트에서 온 POST | 폼 설정에 도메인을 추가하세요 |
| 사람 확인 | 실제 브라우저를 구동하는 봇 | **Spam check**를 **Ikiform**으로 설정하세요. 그러면 브라우저 `fetch` POST에 `challenge()`의 토큰이 필요합니다 |

스팸은 실제 POST와 같은 성공 응답을 받지만 이메일이나 웹훅을 보내지 않고 사용량에도 포함되지 않습니다. 코멘트에 링크 세 개를 붙여 넣은 실제 방문자가 반복 규칙과 합쳐져 링크 규칙에 걸릴 수 있으니 가끔 **Spam** 필터를 확인하세요. 답변을 스팸 아님으로 표시하면 그때 웹훅이 전송됩니다. 각 검사는 [스팸 문서](https://ikiform.com/ko/docs/spam.md)에서 설명합니다.

## 답변 읽기

처음 몇 주 동안은 모든 코멘트를 읽으세요. 그 후에는 페이지별 평균 점수를 낮은 순으로, 코멘트와 나란히 보는 것이 유용합니다.

### 내보내기

**Submissions**를 열고 피드백 폼을 고른 뒤 날짜 범위를 정하고 **Export CSV**를 누르세요. 열은 `id`, `created_at`, `form`, `status` 다음에 필드마다 하나씩이므로, 위 필드라면 `rating`은 E열, `page`는 H열입니다. 한 번에 최대 100,000행까지 내보낼 수 있으며, 그 이상은 [내보내기 가이드](https://ikiform.com/ko/docs/guides/export.md)에서 API를 다룹니다.

파일을 Google Sheets로 가져오고 빈 시트에 아래 수식을 넣으세요. 각 페이지의 평균 점수와 답변 수를 나쁜 순서대로 나열합니다.

`Google Sheets`:

```text
=QUERY(A2:H, "select H, avg(E), count(E) where D = 'received' and E is not null group by H order by avg(E) asc label avg(E) 'average', count(E) 'answers'", 0)
```

1. 답변이 한두 개뿐인 페이지는 무시하세요. 화난 방문자 한 명은 추세가 아닙니다.
2. 점수가 가장 낮은 세 페이지의 코멘트를 읽으세요. 해결책은 대개 처음 몇 개에 있습니다.
3. 페이지를 바꾼 뒤에는 전체 기간 수치가 아니라 변경 전후 몇 주의 평균을 비교하세요.

> 이메일이 없어도 피드백은 Ikiform에 익명이 아닙니다. 각 답변에는 user agent, origin, referrer, 국가, 그리고 절대 반환되지 않는 IP 해시가 기록됩니다. 수집하는 항목을 개인정보 처리방침에 밝히세요.

## 상황별 가이드

위 폼은 어느 페이지에서든 빠른 반응을 받습니다. 아래 페이지들은 다른 목적의 피드백을 다룹니다. 재현할 버그, 따져 볼 요청, 추적할 점수, 떠나려는 고객입니다.

- [버그 리포트 폼](https://ikiform.com/ko/guides/feedback-form/bug-report.md): 재현 단계와 심각도는 사람에게 묻고, URL, 브라우저, 뷰포트, 최근 오류는 페이지가 알아서 추가하는 버그 리포트 폼입니다.
- [기능 요청 폼](https://ikiform.com/ko/guides/feedback-form/feature-request.md): 해결책 대신 문제를 묻고, 요청을 테마별로 저장하고, 행이 아닌 사람 수를 세고, 출시되면 이메일을 보내는 기능 요청 폼입니다.
- [React 피드백 위젯](https://ikiform.com/ko/guides/feedback-form/react-feedback-widget.md): 작은 패널을 열고, 답변마다 화면, 앱 버전, 계정을 함께 보내고, 작성 중인 내용을 잃지 않는 React 앱용 플로팅 피드백 버튼입니다.
- [베타 테스터 피드백](https://ikiform.com/ko/guides/feedback-form/beta-tester-feedback.md): 테스터와 빌드별 개인 링크, 두 가지 평점 척도, 빌드마다 나아졌는지 보여 주는 주간 요약을 갖춘 베타 피드백 폼입니다.
- [NPS 설문](https://ikiform.com/ko/guides/feedback-form/nps-survey.md): 이메일에 0~10 점수를 넣고, 점수에 따라 바뀌는 후속 질문을 내 페이지에서 묻고, 스크립트로 NPS를 계산하는 NPS 설문을 운영합니다.
- [해지 설문](https://ikiform.com/ko/guides/feedback-form/cancellation-survey.md): SaaS 해지 흐름 안에서 고객이 해지하는 이유를 묻고, 건너뛰기는 클릭 한 번으로 두고, 이유를 Stripe와 Ikiform에 저장하며, 해지를 절대 지연시키지 않습니다.
- [Next.js 피드백 폼](https://ikiform.com/ko/guides/feedback-form/nextjs.md): Next.js 인앱 피드백 폼입니다. 서버 액션이 세션을 읽고, 요청에서 페이지를 가져오고, 사용자 정보를 붙여 평점을 전달합니다.
- [Astro 피드백 폼](https://ikiform.com/ko/guides/feedback-form/astro.md): Astro 인앱 피드백 폼입니다. 액션이 미들웨어 locals에서 사용자를, 요청에서 페이지를, 라우트 패턴에서 화면을 가져옵니다.
- [TanStack Start 피드백 폼](https://ikiform.com/ko/guides/feedback-form/tanstack-start.md): TanStack Start 인앱 피드백 폼입니다. 세션 미들웨어가 붙은 POST 서버 함수, 컨텍스트로 쓰는 라우트 ID, 요청 헤더에서 읽는 페이지로 구성합니다.
- [React Router 피드백 폼](https://ikiform.com/ko/guides/feedback-form/react-router.md): React Router 8 피드백 폼입니다. 리소스 라우트 액션이 쿠키 세션을 읽고, fetcher가 레이아웃에서 POST하며, JS 없는 POST는 원래 페이지로 리디렉션됩니다.
- [SvelteKit 피드백 폼](https://ikiform.com/ko/guides/feedback-form/sveltekit.md): SvelteKit 인앱 피드백 폼입니다. 레이아웃의 폼이 하나의 폼 액션으로 POST하고, 액션이 서버에서 locals의 사용자와 요청의 페이지를 추가합니다.
- [Nuxt 피드백 폼](https://ikiform.com/ko/guides/feedback-form/nuxt.md): Nuxt 인앱 피드백 폼입니다. 서버 라우트가 사용자 세션을 읽고 Referer에서 페이지를 가져오며, JavaScript가 로드되지 않았을 때도 동작합니다.

## 작동 확인하기

폼을 모든 페이지에 두기 전에 아래를 확인하세요. Ikiform에는 무료 플랜이 없습니다. 테스트는 무료입니다. 플랜이 없는 워크스페이스는 `localhost`에서 월 50건의 테스트 제출을 받습니다. 실제 운영하려면 월 $6부터 500건을 제공하는 Pro를 고르거나, 한 번 결제로 10,000건 $49 또는 50,000건 $149를 구매하세요. 크레딧은 만료되지 않습니다. [요금](https://ikiform.com/ko/pricing)을 참고하세요.

1. 폼의 **Allowed domains**에 사이트 도메인을 추가하고, 개발하는 동안에는 `localhost`도 추가하세요. 비워 두면 어디서든 POST를 받습니다.
2. 점수를 고르고 코멘트를 써서 제출하세요. 감사 문구가 폼을 대체하거나, 일반 HTML 버전이라면 브라우저가 리디렉션 페이지로 이동합니다.
3. 대시보드에서 **Submissions**를 여세요. 답변에 `rating`은 숫자로, `page`는 전체 경로로 표시됩니다.
4. `curl`이나 브라우저 콘솔로 점수 `6`을 POST하세요. Ikiform이 `rating`을 지목하는 `422`로 응답합니다.
5. 알림 수신자 각각의 받은편지함을 확인하세요. 각자 필드가 담긴 이메일을 받고, 폼에 `email` 필드가 있으면 **Reply**는 제출자에게 갑니다.
6. 점수 1을 제출하세요. 릴레이가 팀 채팅에 올리고, **Webhooks**의 전달 기록에 `succeeded`가 표시됩니다.
7. 허니팟을 채워서 `_gotcha=bot`으로 POST하세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다.
8. 아무것도 도착하지 않았나요? 대시보드의 상태 필터를 **Spam**으로 바꾸세요. 스팸도 실제 제출과 같은 성공 응답을 받습니다.

## 문제 해결

| 문제 | 해결 방법 |
| --- | --- |
| `403 origin_not_allowed`, 또는 모든 POST가 스팸으로 분류됨 | 페이지의 도메인이 폼의 **Allowed domains**에 없습니다. 도메인을 추가하고, 로컬 테스트용으로 `localhost`도 추가하세요. 등록된 도메인의 하위 도메인은 허용됩니다. |
| `Rating must be at most 5`와 함께 `422` | 폼이 필드의 `max`보다 높은 점수를 보냅니다. 라디오 값을 바꾸거나 정의에서 `max`를 최대 10까지 올리세요. |
| Unknown field와 함께 `422` | 폼이 정의에 없는 이름을 보냅니다. 새로 추가한 hidden 입력인 경우가 많습니다. 그 ID로 필드를 추가하거나 입력을 제거하세요. |
| `page`가 비어 있음 | 값을 채우는 스크립트가 실행되지 않았거나 폼이 생기기 전에 실행되었습니다. 스크립트를 폼 뒤에 두거나, SPA 예제처럼 제출 시점에 `page`를 설정하세요. |
| `meta.referrer`에 도메인만 표시됨 | 정상입니다. 브라우저는 기본적으로 크로스 사이트 POST에 origin만 보냅니다. 대신 `page` 필드를 쓰세요. |
| 제출은 대시보드에 있는데 이메일이나 웹훅이 오지 않았습니다 | 스팸으로 저장되었습니다. 스팸은 아무것도 보내지 않습니다. **Spam** 필터에서 열어 이유를 확인하고, 스팸이 아님으로 표시하면 이메일과 웹훅이 발송됩니다. |
| `429 rate_limited` | 한 방문자가 폼의 레이트 리밋보다 많이 보냈습니다. Standard는 방문자당 분당 5회를 허용합니다. `Retry-After`만큼 기다리거나, 공유 IP 하나에서 POST하는 폼이라면 Relaxed나 Custom을 고르세요. |
| `402 test_limit_reached` | 워크스페이스에 플랜이 없고 이번 달 무료 `localhost` 테스트 50건을 다 썼습니다. 플랜을 고르거나 대시보드의 **Send test**를 사용하세요. |

## 질문

### 백엔드 없이 웹사이트에 피드백 폼을 추가하려면 어떻게 하나요?

폼의 action을 폼 백엔드 엔드포인트로 지정하세요. 폼은 정적 사이트의 일반 HTML이어도 됩니다. 백엔드가 각 답변을 검증, 저장하고 이메일로 보내며, 다른 곳으로 보내고 싶다면 웹훅도 보냅니다.

### 피드백이 어느 페이지에서 왔는지 어떻게 알 수 있나요?

페이지 주소를 필드로 보내세요. Ikiform이 referrer도 기록하지만 브라우저는 크로스 사이트 POST에 대개 도메인만 보내므로, location에서 채운 page 필드가 확실한 방법입니다.

### 피드백은 익명이어야 하나요?

이메일을 선택 입력으로 만드세요. 이메일이 없으면 사람들이 더 솔직해지고, 답장을 원하는 사람은 남깁니다. 그래도 Ikiform은 각 답변에 브라우저, origin, referrer, 국가를 기록합니다.

### 방문자가 스크린샷을 첨부할 수 있나요?

파일로는 안 됩니다. Ikiform은 파일이 담긴 POST를 422 files_not_supported로 거부합니다. 대신 스크린샷 링크를 요청하거나, 모든 리포트에 스크린샷이 필요하다면 스크린샷 중심의 피드백 도구를 쓰세요.

### 별점 대신 0~10 척도를 써도 되나요?

rating 필드로는 안 됩니다. rating은 1부터 최대 10까지입니다. 0~10 점수는 min 0, max 10, integer true인 number 필드를 씁니다. 전체 설정은 NPS 가이드에 있습니다.

### 나쁜 피드백 알림은 어떻게 받나요?

평점을 확인해 낮은 점수만 Slack이나 Discord에 올리는 작은 릴레이에 웹훅을 연결하세요. 알림 이메일은 모든 답변을 다루고, 릴레이는 오늘 대응해야 할 답변을 다룹니다.

### Ikiform은 무료인가요?

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

## 가이드 더 보기

- [버그 리포트 폼](https://ikiform.com/ko/guides/feedback-form/bug-report.md): 재현 단계와 심각도는 사람에게 묻고, URL, 브라우저, 뷰포트, 최근 오류는 페이지가 알아서 추가하는 버그 리포트 폼입니다.
- [기능 요청 폼](https://ikiform.com/ko/guides/feedback-form/feature-request.md): 해결책 대신 문제를 묻고, 요청을 테마별로 저장하고, 행이 아닌 사람 수를 세고, 출시되면 이메일을 보내는 기능 요청 폼입니다.
- [React 피드백 위젯](https://ikiform.com/ko/guides/feedback-form/react-feedback-widget.md): 작은 패널을 열고, 답변마다 화면, 앱 버전, 계정을 함께 보내고, 작성 중인 내용을 잃지 않는 React 앱용 플로팅 피드백 버튼입니다.
- [베타 테스터 피드백](https://ikiform.com/ko/guides/feedback-form/beta-tester-feedback.md): 테스터와 빌드별 개인 링크, 두 가지 평점 척도, 빌드마다 나아졌는지 보여 주는 주간 요약을 갖춘 베타 피드백 폼입니다.
- [NPS 설문](https://ikiform.com/ko/guides/feedback-form/nps-survey.md): 이메일에 0~10 점수를 넣고, 점수에 따라 바뀌는 후속 질문을 내 페이지에서 묻고, 스크립트로 NPS를 계산하는 NPS 설문을 운영합니다.
- [해지 설문](https://ikiform.com/ko/guides/feedback-form/cancellation-survey.md): SaaS 해지 흐름 안에서 고객이 해지하는 이유를 묻고, 건너뛰기는 클릭 한 번으로 두고, 이유를 Stripe와 Ikiform에 저장하며, 해지를 절대 지연시키지 않습니다.
- [Next.js 피드백 폼](https://ikiform.com/ko/guides/feedback-form/nextjs.md): Next.js 인앱 피드백 폼입니다. 서버 액션이 세션을 읽고, 요청에서 페이지를 가져오고, 사용자 정보를 붙여 평점을 전달합니다.
- [Astro 피드백 폼](https://ikiform.com/ko/guides/feedback-form/astro.md): Astro 인앱 피드백 폼입니다. 액션이 미들웨어 locals에서 사용자를, 요청에서 페이지를, 라우트 패턴에서 화면을 가져옵니다.
- [TanStack Start 피드백 폼](https://ikiform.com/ko/guides/feedback-form/tanstack-start.md): TanStack Start 인앱 피드백 폼입니다. 세션 미들웨어가 붙은 POST 서버 함수, 컨텍스트로 쓰는 라우트 ID, 요청 헤더에서 읽는 페이지로 구성합니다.
- [React Router 피드백 폼](https://ikiform.com/ko/guides/feedback-form/react-router.md): React Router 8 피드백 폼입니다. 리소스 라우트 액션이 쿠키 세션을 읽고, fetcher가 레이아웃에서 POST하며, JS 없는 POST는 원래 페이지로 리디렉션됩니다.
- [SvelteKit 피드백 폼](https://ikiform.com/ko/guides/feedback-form/sveltekit.md): SvelteKit 인앱 피드백 폼입니다. 레이아웃의 폼이 하나의 폼 액션으로 POST하고, 액션이 서버에서 locals의 사용자와 요청의 페이지를 추가합니다.
- [Nuxt 피드백 폼](https://ikiform.com/ko/guides/feedback-form/nuxt.md): Nuxt 인앱 피드백 폼입니다. 서버 라우트가 사용자 세션을 읽고 Referer에서 페이지를 가져오며, JavaScript가 로드되지 않았을 때도 동작합니다.
- [리드 수집 폼](https://ikiform.com/ko/guides/lead-capture.md): reply-to가 설정된 이메일을 보내고, 팀 채팅에 알리고, CRM에 넣는 내 사이트의 리드 수집 폼입니다. 리드가 아직 있을 때 답할 수 있습니다.
- [뉴스레터 가입 폼](https://ikiform.com/ko/guides/newsletter-signup.md): 직접 작성한 마크업으로 뉴스레터 가입 폼을 만들고, 동의를 기록하고, 봇 가입을 막고, 각 구독자를 Kit, MailerLite, beehiiv 등 어떤 도구로든 넘깁니다.
- [HTML 폼 제출](https://ikiform.com/ko/guides/html-form-submission.md): 폼이 제출될 때 브라우저가 하는 일: 어떤 필드를 보내는지, 어떻게 인코딩하는지, 서버가 어떻게 응답해야 하는지, 그리고 Ikiform이 어떻게 응답하는지 설명합니다.
