# Astro로 인앱 피드백 폼 만들기

> Astro 인앱 피드백 폼입니다. 액션이 미들웨어 locals에서 사용자를, 요청에서 페이지를, 라우트 패턴에서 화면을 가져옵니다.

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

`accept: "form"`인 Astro 액션을 작성해 Zod로 평점을 검증하고, `context.locals`에서 로그인한 사용자를 읽고, `astro:env`의 시크릿으로 답변을 폼 백엔드에 전달하세요. 폼의 `action`을 그 액션으로 설정해 렌더링하면 JavaScript 없이 POST되고, JavaScript가 있을 때 같은 액션을 제자리에서 호출하는 짧은 스크립트를 추가하세요. 답변이 어느 페이지에 대한 것인지는 요청의 `Referer`에서, 화면 이름은 `Astro.routePattern`에서 얻습니다.

[메인 가이드의 Astro 탭](https://ikiform.com/ko/guides/feedback-form.md#form)은 모든 방문자에게 페이지가 얼마나 유용했는지 묻습니다. 이 페이지는 로그인한 사용자가 있는 Astro 앱 안에 피드백을 넣습니다. 거기서는 누가 썼는지를 서버가 정해야 하고, 폼은 점수와 코멘트만 묻습니다. 키와 폼 설정은 [Astro 문서 가이드](https://ikiform.com/ko/docs/guides/astro.md)에 자세히 있습니다.

## 누가 무엇을 채우는지

Astro 액션은 액션 이름을 딴 URL로 접근할 수 있는 공개 엔드포인트입니다. 누구나 조작한 본문으로 `sendFeedback`을 호출할 수 있으므로 액션은 전달받은 사용자 ID를 믿을 수 없습니다. 값을 누가 믿을 만하게 제공할 수 있는지에 따라 나누세요.

| 값 | 설정 주체 | 방법 |
| --- | --- | --- |
| `rating`, `comment` | 사용자 | 액션의 Zod 스키마로 검증 |
| `user_id`, `email` | 미들웨어 | `context.locals.user`, 매 요청마다 세션 쿠키에서 |
| `page` | 요청 | `Referer` 헤더, 폼이 있는 페이지 |
| `screen` | 페이지 렌더링 | hidden 입력에 담긴 `/app/projects/[id]/settings` 같은 `Astro.routePattern` |

여기서 Astro가 편한 이유가 `routePattern`입니다. 페이지를 렌더링한 파일의 이름이므로, 모든 프로젝트의 설정 화면이 하나의 레이블로 모이고 `page`에는 실제 주소가 남습니다. 브라우저를 거치므로 스키마가 형식을 확인하고 이상한 값은 `unknown`으로 바꿉니다.

다이어그램: 하나의 Astro 피드백 폼이 같은 액션에 도달하는 두 가지 방법

1. JavaScript가 없으면 브라우저가 URL에 액션 이름을 담아 현재 페이지로 폼을 POST합니다.
2. 미들웨어가 모든 요청에서 먼저 실행되어 로그인한 사용자를 context.locals에 넣습니다.
3. 액션이 평점과 화면을 검증하고, 사용자와 페이지를 추가하고, 시크릿으로 Ikiform에 POST합니다.
4. Astro가 페이지를 다시 렌더링하고, Astro.getActionResult()가 감사 문구나 오류를 보여 줍니다.
5. JavaScript가 있으면 스크립트가 fetch로 같은 액션을 호출합니다. 미들웨어와 핸들러는 그대로 실행됩니다.
6. 스크립트가 새로고침 없이 폼을 감사 문구로 바꾸거나, 폼 아래에 오류를 씁니다.

핸들러 하나, 전송 방식 둘. 두 요청 모두 페이지에서 오므로 둘 다 세션 쿠키와 Referer의 페이지를 담습니다.

## 프로젝트 설정

`프로젝트 트리`:

```text
src/
  actions/index.ts
  components/FeedbackForm.astro
  middleware.ts
  env.d.ts
  pages/app/projects/[id]/settings.astro
astro.config.mjs
.env
```

시크릿은 `astro:env` 스키마에 선언하세요. 그러면 Astro는 시크릿 없이 시작하지 않고, import에 타입을 붙이고, 클라이언트 번들에서 제외합니다. 어떤 서버 어댑터든 되며, 예시는 Node입니다.

`astro.config.mjs`:

```js
import { defineConfig, envField } from "astro/config";
import node from "@astrojs/node";

export default defineConfig({
  adapter: node({ mode: "standalone" }),
  env: {
    schema: {
      IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
    },
  },
});
```

로컬 작업용으로 `.env`에 `IKIFORM_SECRET_KEY=ik_sk_...`를 넣으세요. 키는 **API keys**에서 `submissions:write` 스코프로 만드세요. 그런 다음 폼의 **Fields** 탭에 이 정의를 붙여 넣으세요.

폼: In-app feedback

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

액션의 스키마가 이 제한을 그대로 반영하므로, 잘못된 입력 대부분은 요청 비용이 들기 전에 거부됩니다.

## 만들기

### locals에 사용자 넣기

미들웨어는 모든 페이지와 액션보다 먼저 실행됩니다. 거기서 인증 코드가 제공하는 방법으로 세션을 한 번 읽으면 모든 핸들러가 결과를 받습니다. `readSession`이 그 코드를 대신합니다. Astro 자체 세션도 됩니다. 대신 액션에서 `context.session`을 읽으세요.

`src/middleware.ts`:

```ts
import { defineMiddleware } from "astro:middleware";
import { readSession } from "./lib/auth";

export const onRequest = defineMiddleware(async (context, next) => {
  context.locals.user = await readSession(context.cookies.get("session")?.value);
  return next();
});
```

`src/env.d.ts`:

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

### 액션

`src/actions/index.ts`:

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

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

export const server = {
  sendFeedback: defineAction({
    accept: "form",
    input: z.object({
      rating: z.coerce.number().int().min(1, "Pick a score from 1 to 5.").max(5, "Pick a score from 1 to 5."),
      comment: z.string().max(4000).nullish(),
      screen: z.string().regex(/^\/[a-z0-9/[\]._-]{0,119}$/).catch("unknown"),
      _gotcha: z.string().nullish(),
    }),
    handler: async (input, context) => {
      const user = context.locals.user;
      if (!user) throw new ActionError({ code: "UNAUTHORIZED", message: "Your session ended. Sign in again, then send it." });

      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 ${IKIFORM_SECRET_KEY}`,
          ...(origin ? { Origin: origin } : {}),
        },
        body: JSON.stringify({
          rating: input.rating,
          comment: input.comment ?? "",
          page: pageFrom(context.request.headers.get("referer")),
          screen: input.screen,
          user_id: user.id,
          email: user.email,
          _gotcha: input._gotcha ?? "",
        }),
        signal: AbortSignal.timeout(8000),
      }).catch(() => null);

      const result = await response?.json().catch(() => null);
      if (result?.ok) return { sent: true };
      throw new ActionError({
        code: "BAD_GATEWAY",
        message: result?.error?.message ?? "We couldn't send it. Try again in a moment.",
      });
    },
  }),
};
```

- 핸들러의 두 번째 인자는 액션 컨텍스트입니다. `locals`, `request`, `cookies`는 있지만 `redirect`는 없으므로, 세션이 없으면 폼이 표시할 수 있는 `UNAUTHORIZED` 오류가 됩니다.
- `screen`의 `.catch("unknown")`은 위조된 레이블 때문에 답변 전체를 실패시키지 않고 `unknown`으로 바꿉니다.
- 빈 폼 입력은 `form` 액션에 `null`로 전달되므로 `comment`와 허니팟이 `nullish`입니다.
- 폼 POST와 스크립트 호출 모두 페이지가 보내므로 어느 쪽이든 `Referer`에 페이지 주소가 담깁니다. `pageFrom`은 origin과 경로를 남깁니다.
- 로컬 테스트가 `localhost`로 집계되도록 `Origin`을 전달합니다. 아래에서 더 설명합니다.

### 폼 컴포넌트

`src/components/FeedbackForm.astro`:

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

const result = Astro.getActionResult(actions.sendFeedback);
const scores = ["Broken", "Frustrating", "Fine", "Good", "Great"];
---

{result?.data?.sent ? (
  <p role="status">Thanks. We read every one.</p>
) : (
  <form method="POST" action={actions.sendFeedback} data-feedback>
    <fieldset>
      <legend>How is this screen working for you?</legend>
      {scores.map((label, index) => (
        <label><input type="radio" name="rating" value={index + 1} required /> {label}</label>
      ))}
    </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={Astro.routePattern} />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p role="alert">
      {result?.error && (isInputError(result.error) ? result.error.fields.rating?.[0] : result.error.message)}
    </p>
    <button>Send feedback</button>
  </form>
)}

<script>
  import { actions } from "astro:actions";

  const form = document.querySelector<HTMLFormElement>("form[data-feedback]");
  form?.addEventListener("submit", async (event) => {
    event.preventDefault();
    const button = form.querySelector("button")!;
    const alert = form.querySelector("[role=alert]")!;
    button.disabled = true;
    button.textContent = "Sending";
    const { error } = await actions.sendFeedback(new FormData(form));
    if (!error) {
      form.replaceWith(Object.assign(document.createElement("p"), { role: "status", textContent: "Thanks. We read every one." }));
      return;
    }
    alert.textContent = error.message;
    button.disabled = false;
    button.textContent = "Send feedback";
  });
</script>
```

- `action={actions.sendFeedback}` 덕분에 JavaScript가 전혀 없어도 폼이 동작합니다. Astro가 POST를 처리하고 결과와 함께 페이지를 렌더링합니다.
- `isInputError`는 `fields`가 있는 스키마 실패와, 세션 메시지처럼 핸들러가 던진 오류를 구분합니다.
- 스크립트는 `astro:actions`에서 `actions`를 import해 폼 데이터로 같은 핸들러를 호출합니다. Astro가 번들링하므로 추가 파일이 없습니다.
- JavaScript 없이 제출하면 페이지가 POST 결과로 남아, 새로고침 시 다시 보낼지 묻습니다. 그게 거슬린다면 미들웨어의 `getActionContext`로 결과를 저장하고 리디렉션할 수 있습니다.

### 페이지

`src/pages/app/projects/[id]/settings.astro`:

```astro
---
import AppLayout from "../../../../layouts/AppLayout.astro";
import FeedbackForm from "../../../../components/FeedbackForm.astro";

export const prerender = false;

if (!Astro.locals.user) return Astro.redirect("/sign-in");
---

<AppLayout title="Project settings">
  <h1>Project settings</h1>
  <aside>
    <FeedbackForm />
  </aside>
</AppLayout>
```

## 렌더링 모드

폼 액션은 요청 시 렌더링되는 페이지에서만 동작합니다. 사전 렌더링된 페이지는 한 번만 빌드되므로 POST할 요청이 없습니다. 기본 정적 출력이라면 앱 페이지마다 `export const prerender = false`를 표시하고, 사이트 대부분이 로그인 뒤에 있다면 `output: "server"`를 설정하세요. 사전 렌더링 페이지에서도 빌드 시 미들웨어는 실행되지만 쿠키가 없으므로 거기서 `locals.user`는 `null`입니다.

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

hidden `_gotcha` 입력은 그대로 전달되고, 값이 채워지면 스팸으로 저장됩니다. 시크릿 키 POST에서는 사람 확인이 실행되지 않으며, 로그인 요구가 대신 그 역할을 합니다. Astro는 기본적으로 요청 시 렌더링 페이지로의 폼 POST에서 `Origin`을 확인하므로 다른 사이트가 사용자의 브라우저에서 폼을 제출할 수 없습니다.

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

## 배포

| 호스트 | 어댑터 | 시크릿 |
| --- | --- | --- |
| Vercel | `@astrojs/vercel` | 프로젝트의 환경 변수에 `IKIFORM_SECRET_KEY`를 추가하세요. Astro 세션을 쓴다면 스토리지 드라이버를 설정하세요. Vercel에는 기본값이 없습니다. |
| Netlify | `@astrojs/netlify` | 사이트의 환경 변수에 추가하세요. 세션의 기본값은 Netlify Blobs입니다. |
| Cloudflare | `@astrojs/cloudflare` | `npx wrangler secret put IKIFORM_SECRET_KEY`를 실행하고 로컬에서는 `.dev.vars`에 넣으세요. `astro:env/server`와 `cloudflare:workers`의 `env` 모두 이를 읽습니다. 세션은 `SESSION`이라는 KV 바인딩을 씁니다. |

Astro 6부터 Cloudflare 어댑터에서 `Astro.locals.runtime.env`가 사라졌습니다. 거기서 키를 읽던 코드는 위의 `astro:env` import가 필요합니다.

## 무료 로컬 테스트

플랜이 없는 워크스페이스는 `localhost`에서 월 50건의 무료 테스트 제출을 받으며, Ikiform은 이를 `Origin` 헤더로 알아봅니다. 브라우저는 `astro dev`에 `http://localhost:4321`을 보내지만, 액션이 Ikiform으로 보내는 `fetch`에는 origin이 없습니다. `Origin` 줄이 브라우저의 값을 전달하므로 로컬 테스트는 무료이고, 프로덕션에서는 헤더가 내 도메인이 됩니다.

## 작동 확인하기

1. `astro dev`를 실행하고 로그인한 뒤 프로젝트 설정 페이지를 열어 코멘트와 함께 2점을 보내세요.
2. **Submissions**에서 `screen`이 `/app/projects/[id]/settings`이고, `page`에 실제 ID가 있고, `user_id`와 `email`이 내 것인지 확인하세요.
3. JavaScript를 끄고 다시 제출하세요. 페이지가 새로고침되며 감사 문구가 표시됩니다.
4. 세션 쿠키를 삭제하고 제출하세요. 폼에 로그인 메시지가 표시됩니다.
5. 알림 이메일에 답장하세요. 로그인한 사용자에게 갑니다.

## 주의할 점

| 문제 | 해결 방법 |
| --- | --- |
| 폼은 POST되지만 페이지에 결과가 없음 | 페이지가 사전 렌더링되었습니다. `export const prerender = false`를 추가하거나 `output: "server"`로 바꾸세요. |
| 빌드 시작 시 `IKIFORM_SECRET_KEY is missing` | `astro:env/server`를 import할 때마다 시크릿을 검증합니다. 빌드에 변수를 제공하거나, 빌드에는 자리 표시 값을 주고 런타임에 실제 값을 주세요. |
| 로컬 테스트에서 `402 plan_required` | 전달된 `Origin`이 비어 있거나 `localhost`가 아닙니다. `localhost:4321`로 앱을 열고 `Origin` 줄을 유지하세요. |
| 프로덕션에서 `locals.user`가 항상 `null` | 환경마다 쿠키 이름이나 도메인이 다르거나, 페이지가 아직 사전 렌더링되고 있습니다. 미들웨어에서 `context.cookies.has("session")`을 로그로 남겨 보세요. |
| `422 validation_failed` | POST가 폼의 필드 정의와 맞지 않습니다. `error.fields`에 각 필드와 이유가 나옵니다. 입력의 `name`을 필드 ID와 맞추세요. |

## 질문

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

src/middleware.ts에서 context.locals에 설정하고, 핸들러의 두 번째 인자에서 context.locals.user를 읽으세요. 다른 방법은 context.session을 통한 Astro 세션입니다.

### Astro 액션은 JavaScript 없이도 동작하나요?

네, 요청 시 렌더링되는 페이지에서 동작합니다. 폼의 action을 액션으로, method를 POST로 설정하고, 페이지에서 Astro.getActionResult()로 결과를 읽으세요.

### Astro에서 피드백이 어느 화면에서 왔는지 어떻게 아나요?

Astro.routePattern을 hidden 입력에 렌더링하세요. /app/projects/[id]/settings 같은 라우트 파일의 패턴을 주므로 답변이 ID가 아니라 화면별로 묶입니다.

### Ikiform은 무료인가요?

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

## 가이드 더 보기

- [웹사이트나 앱용 피드백 폼](https://ikiform.com/ko/guides/feedback-form.md): 어떤 페이지나 앱 안에든 피드백 폼을 둡니다. 평점, 코멘트, 보낸 페이지, 스팸 필터링, 낮은 점수 알림까지 다룹니다.
- [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가 로드되지 않았을 때도 동작합니다.
- [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): 해결책 대신 문제를 묻고, 요청을 테마별로 저장하고, 행이 아닌 사람 수를 세고, 출시되면 이메일을 보내는 기능 요청 폼입니다.
