# 페이지, 브라우저, 재현 단계를 담는 버그 리포트 폼 추가하기

> 재현 단계와 심각도는 사람에게 묻고, URL, 브라우저, 뷰포트, 최근 오류는 페이지가 알아서 추가하는 버그 리포트 폼입니다.

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

리포터에게는 그 사람이 아는 것만 물으세요. 무엇이 잘못됐는지, 직전에 무엇을 했는지, 얼마나 막히는지입니다. 나머지는 제출 시점에 페이지가 채우게 하세요. 페이지 URL, 브라우저의 user agent, 뷰포트, 최근 JavaScript 오류 몇 개입니다. 폼 백엔드로 POST하고 각 리포트를 웹훅으로 이슈 트래커에 전달하세요.

## 버그 리포트가 다른 점

버그 리포트는 재현할 수 있어야만 쓸모가 있는데, 리포터는 필요한 세부 정보를 거의 모릅니다. 어떤 브라우저를 쓰는지 물으면 "Chrome"이나 "그냥 기본 거"라고 답합니다. URL을 물으면 페이지 이름이 돌아옵니다. 그러니 폼을 둘로 나누세요. 리포터만 답할 수 있는 질문과, 페이지가 이미 알고 있는 맥락입니다.

다이어그램: 버그 리포트에서 사람이 입력하는 부분과 페이지가 추가하는 부분

1. 리포터가 한 줄 요약을 씁니다.
2. 문제가 생기기 직전에 무엇을 했는지 설명합니다.
3. 얼마나 심하게 막히는지 고릅니다.
4. 스크린샷이나 녹화 링크를 붙여 넣을 수 있습니다.
5. 제출 시 페이지가 쿼리 문자열을 뺀 자기 주소를 추가합니다.
6. 브라우저의 user agent를 추가합니다.
7. 창 크기와 픽셀 비율을 추가합니다.
8. 페이지가 본 최근 JavaScript 오류 몇 개를 호스트 이름을 지우고 추가합니다.

리포터에게 보이는 것은 윗줄뿐입니다. 아랫줄은 hidden 입력으로, 보내기를 누르는 순간 채워집니다.

코드에 영향을 주는 제약은 세 가지입니다.

- **referrer는 페이지가 아닙니다.** 브라우저는 기본적으로 크로스 사이트 POST에 origin만 보내므로 `meta.referrer`로는 경로를 알 수 없습니다. `page_url`을 직접 보내세요.
- **서버 POST는 브라우저를 가립니다.** Ikiform은 요청을 보낸 쪽의 user agent를 기록합니다. 서버 액션에서 보내면 그건 내 서버입니다. 방문자의 `user-agent` 헤더를 필드로 전달하세요.
- **스택 트레이스는 링크투성이입니다.** 프레임마다 스크립트 URL이 있고, 링크가 3개 이상이면 스팸 점수에 2가 더해집니다. 오류를 보내기 전에 호스트를 지우세요. 그러지 않으면 스크린샷 링크를 붙여 넣은 리포트가 스팸으로 넘어갈 수 있습니다.

## 필드

폼: Bug report

- **무엇이 잘못됐나요?** (`summary`, text, 필수)
- **직전에 무엇을 했나요?** (`steps`, textarea, 필수)
- **어떻게 될 거라고 예상했나요?** (`expected`, textarea)
- **얼마나 막히나요?** (`severity`, radio, 필수)
- **스크린샷이나 녹화 링크** (`screenshot_url`, url)
- **추가 질문을 위한 이메일** (`email`, email)
- **페이지** (`page_url`, url)
- **브라우저** (`user_agent`, text)
- **뷰포트** (`viewport`, text)
- **앱 버전** (`app_version`, text)
- **최근 오류** (`recent_errors`, textarea)

마지막 다섯 필드는 실제 폼에서 hidden 입력입니다. 필드로 정의하면 Ikiform이 다른 필드처럼 길이와 타입을 검사합니다.

심각도는 우선순위 레이블이 아니라 리포터에게 미치는 영향으로 표현합니다. 사람들은 "더 진행할 수 없음"은 정확하게 고르지만 "Critical"은 무엇에든 고릅니다. 재현 단계는 최소 20자라서 "고장 났어요"가 대응할 수 있는 문장으로 바뀝니다.

## 맥락 수집하기

브라우저는 사람이 입력을 마친 뒤 `submit` 핸들러에서 hidden 필드를 채우므로, 리포트를 쓰는 동안 발생한 오류도 목록에 포함됩니다.

**HTML**

폼은 일반적인 방식으로 POST하고, 스크립트는 `submit` 중 브라우저가 요청을 만들기 전에 hidden 입력만 채웁니다. 빌드 시 `<html>` 요소에 `data-app-version`을 설정하세요. 브라우저가 직접 POST하므로 Ikiform의 `meta.user_agent`가 `user_agent` 필드와 일치합니다.

`report-bug.html`:

```html
<form id="bug" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label for="summary">What went wrong?</label>
  <input id="summary" name="summary" required minlength="8" maxlength="140" />
  <label for="steps">What did you do right before?</label>
  <textarea id="steps" name="steps" rows="5" required minlength="20"></textarea>
  <label for="expected">What did you expect to happen?</label>
  <textarea id="expected" name="expected" rows="3"></textarea>
  <fieldset>
    <legend>How much does it block you?</legend>
    <label><input type="radio" name="severity" value="blocker" required /> I can't continue</label>
    <label><input type="radio" name="severity" value="major" /> I found a workaround</label>
    <label><input type="radio" name="severity" value="minor" /> It's cosmetic</label>
  </fieldset>
  <label for="screenshot_url">Link to a screenshot or recording</label>
  <input id="screenshot_url" name="screenshot_url" type="url" />
  <label for="email">Email for follow-up questions</label>
  <input id="email" name="email" type="email" />
  <input type="hidden" name="page_url" />
  <input type="hidden" name="user_agent" />
  <input type="hidden" name="viewport" />
  <input type="hidden" name="app_version" />
  <input type="hidden" name="recent_errors" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Send report</button>
</form>

<script>
  const recent = [];
  function remember(text) {
    recent.push(String(text).replace(/https?:\/\/[^/\s]+/g, "").slice(0, 400));
    if (recent.length > 5) recent.shift();
  }
  window.addEventListener("error", (event) => remember(`${event.message} at ${event.filename}:${event.lineno}`));
  window.addEventListener("unhandledrejection", (event) => remember(`Unhandled: ${event.reason?.stack ?? event.reason}`));

  document.getElementById("bug").addEventListener("submit", (event) => {
    const fields = event.currentTarget.elements;
    fields.page_url.value = location.origin + location.pathname;
    fields.user_agent.value = navigator.userAgent;
    fields.viewport.value = `${innerWidth}x${innerHeight} @${devicePixelRatio}x`;
    fields.app_version.value = document.documentElement.dataset.appVersion ?? "";
    fields.recent_errors.value = recent.join("\n---\n");
  });
</script>
```

**Next.js**

작은 모듈이 최근 오류 다섯 개를 메모리에 두고 브라우저 맥락을 반환합니다. 폼은 액션을 감싸 제출 시 `FormData`에 맥락을 추가하고, 서버 액션은 `headers()`에서 방문자의 user agent를 추가합니다.

`lib/error-buffer.ts`:

```ts
const recent: string[] = [];

function remember(text: string) {
  recent.push(text.replace(/https?:\/\/[^/\s]+/g, "").slice(0, 400));
  if (recent.length > 5) recent.shift();
}

export function installErrorBuffer() {
  const onError = (event: ErrorEvent) => remember(`${event.message} at ${event.filename}:${event.lineno}`);
  const onRejection = (event: PromiseRejectionEvent) => remember(`Unhandled: ${event.reason?.stack ?? event.reason}`);
  window.addEventListener("error", onError);
  window.addEventListener("unhandledrejection", onRejection);
  return () => {
    window.removeEventListener("error", onError);
    window.removeEventListener("unhandledrejection", onRejection);
  };
}

export function browserContext() {
  return {
    page_url: location.origin + location.pathname,
    viewport: `${innerWidth}x${innerHeight} @${devicePixelRatio}x`,
    recent_errors: recent.join("\n---\n"),
  };
}
```

`app/report/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";

const FIELDS = ["summary", "steps", "expected", "severity", "screenshot_url", "email", "page_url", "viewport", "recent_errors", "_gotcha"];

export interface ReportState {
  reference: string;
  error: string;
}

export async function reportBug(_previous: ReportState, formData: FormData): Promise<ReportState> {
  const fields = Object.fromEntries(FIELDS.map((name) => [name, String(formData.get(name) ?? "")]));
  const incoming = await headers();
  const origin = incoming.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({
      ...fields,
      user_agent: incoming.get("user-agent") ?? "",
      app_version: process.env.NEXT_PUBLIC_APP_VERSION ?? "",
    }),
  });
  const result = await response.json().catch(() => null);
  if (result?.ok) return { reference: result.id, error: "" };
  const errors: Record<string, string[]> = result?.error?.fields ?? {};
  const first = Object.entries(errors)[0];
  return { reference: "", error: first ? `${first[0]}: ${first[1][0]}` : "Couldn't send the report. Try again." };
}
```

`app/report/bug-report-form.tsx`:

```tsx
"use client";

import { useActionState, useEffect } from "react";
import { browserContext, installErrorBuffer } from "@/lib/error-buffer";
import { type ReportState, reportBug } from "./actions";

const initial: ReportState = { reference: "", error: "" };

export function BugReportForm() {
  const [state, dispatch, pending] = useActionState(reportBug, initial);
  useEffect(() => installErrorBuffer(), []);

  function withContext(formData: FormData) {
    for (const [name, value] of Object.entries(browserContext())) formData.set(name, value);
    dispatch(formData);
  }

  if (state.reference) return <p role="status">Thanks. Quote {state.reference} if you write to us about it.</p>;

  return (
    <form action={withContext}>
      <label htmlFor="summary">What went wrong?</label>
      <input id="summary" name="summary" required minLength={8} maxLength={140} />
      <label htmlFor="steps">What did you do right before?</label>
      <textarea id="steps" name="steps" rows={5} required minLength={20} />
      <label htmlFor="expected">What did you expect to happen?</label>
      <textarea id="expected" name="expected" rows={3} />
      <fieldset>
        <legend>How much does it block you?</legend>
        <label><input type="radio" name="severity" value="blocker" required /> I can't continue</label>
        <label><input type="radio" name="severity" value="major" /> I found a workaround</label>
        <label><input type="radio" name="severity" value="minor" /> It's cosmetic</label>
      </fieldset>
      <label htmlFor="screenshot_url">Link to a screenshot or recording</label>
      <input id="screenshot_url" name="screenshot_url" type="url" />
      <label htmlFor="email">Email for follow-up questions</label>
      <input id="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 report"}</button>
    </form>
  );
}
```

**SvelteKit**

같은 `error-buffer.ts` 모듈을 `src/lib`에 둡니다. `use:enhance`가 전송 전에 `FormData`를 넘겨주므로 맥락을 거기에 넣습니다. 액션은 요청에서 user agent를, 공개 환경 변수에서 버전을 읽습니다. `$app/env/private`와 `$app/env/public`이 읽을 수 있도록 둘 다 `src/env.ts`에서 `defineEnvVars`로 선언하고, `PUBLIC_APP_VERSION`에는 `public: true`를 지정하세요.

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

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

export const actions = {
  default: async ({ request, fetch, url }) => {
    const data = await request.formData();
    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({
        ...Object.fromEntries(data),
        user_agent: request.headers.get("user-agent") ?? "",
        app_version: PUBLIC_APP_VERSION,
      }),
    });
    const result = await response.json();
    if (!result.ok) return fail(response.status, { errors: (result.error.fields ?? {}) as Record<string, string[]>, message: String(result.error.message) });
    return { reference: String(result.id) };
  },
} satisfies Actions;
```

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

```svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import { browserContext, installErrorBuffer } from "#lib/error-buffer.js";
  import type { PageProps } from "./$types";

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

{#if form?.reference}
  <p role="status">Thanks. Quote {form.reference} if you write to us about it.</p>
{:else}
  <form
    method="POST"
    use:enhance={({ formData }) => {
      for (const [name, value] of Object.entries(browserContext())) formData.set(name, value);
    }}
  >
    <label for="summary">What went wrong?</label>
    <input id="summary" name="summary" required minlength="8" maxlength="140" />
    <label for="steps">What did you do right before?</label>
    <textarea id="steps" name="steps" rows="5" required minlength="20"></textarea>
    <label for="expected">What did you expect to happen?</label>
    <textarea id="expected" name="expected" rows="3"></textarea>
    <fieldset>
      <legend>How much does it block you?</legend>
      <label><input type="radio" name="severity" value="blocker" required /> I can't continue</label>
      <label><input type="radio" name="severity" value="major" /> I found a workaround</label>
      <label><input type="radio" name="severity" value="minor" /> It's cosmetic</label>
    </fieldset>
    <label for="screenshot_url">Link to a screenshot or recording</label>
    <input id="screenshot_url" name="screenshot_url" type="url" />
    <label for="email">Email for follow-up questions</label>
    <input id="email" name="email" type="email" />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {#if form?.message}
      <p role="alert">{Object.values(form.errors)[0]?.[0] ?? form.message}</p>
    {/if}
    <button>Send report</button>
  </form>
{/if}
```

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

성공 메시지에 제출 ID가 표시됩니다. 리포터가 나중에 지원팀에 연락하면 그 ID로 한 번에 리포트를 찾을 수 있습니다. [버그 리포트 템플릿](https://ikiform.com/forms/bug-report)에는 맥락 수집 없이 더 짧은 버전이 있습니다.

## 이슈 트래커로 보내기

리포트는 수정이 추적될 곳에 있어야 합니다. 이슈를 만드는 릴레이를 가리키는 웹훅을 추가하세요. `summary`는 제목으로, 재현 단계와 맥락은 본문으로, `severity`는 레이블로 씁니다. [Linear와 GitHub Issues 가이드](https://ikiform.com/blog/form-submissions-to-linear-and-github-issues.md)에 서명 확인과 재시도를 갖춘 릴레이가 있습니다. [웹훅 문서](https://ikiform.com/ko/docs/webhooks.md)에 페이로드가 담는 모든 `meta` 값이 나와 있습니다. 재현 단계가 잘 읽히도록 수집한 필드는 이슈 본문 끝의 접힌 블록에 넣으세요.

| 필드 | 이슈에서 |
| --- | --- |
| `summary` | 제목 |
| `steps`, `expected` | 본문, 맨 앞 |
| `severity` | 레이블. blocker를 필터링할 수 있습니다 |
| `page_url`, `user_agent`, `viewport`, `app_version` | 재현 단계 아래의 details 블록 |
| `recent_errors` | 코드 블록, 맨 끝 |

## 작동 확인하기

1. 데스크톱 브라우저에서 페이지를 열고 콘솔에서 `undefinedFunction()`을 실행한 뒤 리포트를 보내세요.
2. 대시보드에서 제출을 여세요. `recent_errors`에 `https://` 호스트 없이 `ReferenceError`가 들어 있습니다.
3. 서버 버전에서도 `user_agent`에 `node`가 아니라 내 브라우저가 표시됩니다.
4. `page_url`에는 경로는 있지만 쿼리 문자열은 없습니다.
5. 10자짜리 `steps`로 리포트를 보내세요. Ikiform이 `steps`를 지목하는 `422`로 응답하고, 폼이 메시지를 표시합니다.

## 주의할 점

- 파일 업로드는 아직 지원하지 않습니다. 파일이 담긴 POST는 `422 files_not_supported`를 받습니다. 스크린샷 링크를 요청하거나, 이미지를 먼저 자체 스토리지에 올리고 그 URL을 제출하세요.
- 각 값은 최대 16 KB입니다. 400자로 자른 오류 다섯 개는 그보다 훨씬 작지만, 콘솔 전체를 덤프하면 넘을 수 있습니다.
- Chromium 브라우저는 macOS 버전처럼 user agent 문자열의 일부를 고정합니다. macOS 버전은 항상 `10_15_7`로 나옵니다. 분류할 때는 OS 버전이 아니라 브라우저 이름과 메이저 버전을 쓰세요.
- 다른 origin의 스크립트에서 난 오류는 그 스크립트가 CORS 헤더와 함께 제공되고 `crossorigin`으로 로드되지 않는 한 `Script error.`만 표시됩니다.
- 폼 내용, 로컬 스토리지, 쿠키를 리포트에 담지 마세요. 비밀번호와 토큰이 들어 있을 수 있고, 그것을 평문으로 저장하게 됩니다.

## 문제 해결

| 문제 | 해결 방법 |
| --- | --- |
| `422 validation_failed` | POST가 폼의 필드 정의와 맞지 않습니다. `error.fields`에 각 필드와 이유가 나옵니다. 입력의 `name`을 필드 ID와 맞추세요. |
| `422 files_not_supported` | 폼에 파일이 선택된 `<input type="file">`이 있습니다. 파일 업로드는 아직 지원하지 않습니다. 입력을 지우거나 대신 링크를 요청하세요. |
| 스크린샷 링크가 있는 리포트가 스팸으로 분류됨 | 모든 필드의 링크 수를 세어 보세요. 3개 이상이면 점수가 더해지고, 같은 리포트가 반복되면 임계값에 닿습니다. 오류에서 호스트를 지우고 리포트를 스팸 아님으로 표시하세요. |
| `403 origin_not_allowed`, 또는 모든 POST가 스팸으로 분류됨 | 페이지의 도메인이 폼의 **Allowed domains**에 없습니다. 도메인을 추가하고, 로컬 테스트용으로 `localhost`도 추가하세요. 등록된 도메인의 하위 도메인은 허용됩니다. |

## 질문

### 버그 리포트 폼에서 브라우저와 OS를 어떻게 수집하나요?

submit 핸들러에서 navigator.userAgent를 읽어 필드로 보내세요. 폼이 브라우저에서 바로 POST하면 Ikiform이 같은 값을 meta.user_agent로 기록하고, 서버에서 POST하면 방문자의 user-agent 헤더를 전달해야 합니다.

### 버그 리포트에 콘솔 오류를 포함할 수 있나요?

네. error와 unhandledrejection 이벤트를 듣고, 최근 메시지 몇 개를 메모리에 두었다가 제출 시 hidden 필드에 추가하세요. 링크가 스팸 점수에 포함되지 않도록 먼저 호스트 이름을 지우세요.

### 리포터가 스크린샷을 첨부할 수 있나요?

파일로는 안 됩니다. Ikiform은 파일이 담긴 POST를 422 files_not_supported로 거부합니다. 대신 스크린샷이나 녹화 링크를 요청하세요.

### 버그 리포트로 GitHub나 Linear 이슈를 자동으로 만들 수 있나요?

네. GitHub나 Linear API를 호출하는 작은 릴레이에 웹훅을 연결하면 됩니다. Ikiform은 각 전달에 서명하고 실패하면 같은 webhook-id로 재시도합니다.

## 가이드 더 보기

- [웹사이트나 앱용 피드백 폼](https://ikiform.com/ko/guides/feedback-form.md): 어떤 페이지나 앱 안에든 피드백 폼을 둡니다. 평점, 코멘트, 보낸 페이지, 스팸 필터링, 낮은 점수 알림까지 다룹니다.
- [React 피드백 위젯](https://ikiform.com/ko/guides/feedback-form/react-feedback-widget.md): 작은 패널을 열고, 답변마다 화면, 앱 버전, 계정을 함께 보내고, 작성 중인 내용을 잃지 않는 React 앱용 플로팅 피드백 버튼입니다.
- [기능 요청 폼](https://ikiform.com/ko/guides/feedback-form/feature-request.md): 해결책 대신 문제를 묻고, 요청을 테마별로 저장하고, 행이 아닌 사람 수를 세고, 출시되면 이메일을 보내는 기능 요청 폼입니다.
- [베타 테스터 피드백](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에 저장하며, 해지를 절대 지연시키지 않습니다.
