# React 앱에 떠 있는 피드백 위젯 만들기

> 작은 패널을 열고, 답변마다 화면, 앱 버전, 계정을 함께 보내고, 작성 중인 내용을 잃지 않는 React 앱용 플로팅 피드백 버튼입니다.

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

고정 버튼을 렌더링하고, 브라우저의 Popover API로 만든 패널을 열게 하세요. 그러면 z-index 싸움 없이 앱의 모든 레이어 위에 표시되고 Escape로 닫힙니다. 제출 시 현재 라우트, 앱 버전, 계정 ID, 뷰포트를 폼 데이터에 추가하고 Ikiform SDK로 브라우저에서 POST하세요. 사용자의 이메일은 답장을 원한다고 체크했을 때만 보내세요.

## 인앱 위젯이 다른 점

페이지 폼은 사람들이 스크롤해서 내려오기를 기다립니다. 위젯은 모든 화면에 있으므로 잘못될 수 있는 부분이 달라집니다. 앱의 모달, 메뉴, 토스트 위에 있어야 합니다. 누군가 바깥을 클릭했을 때 쓰다 만 메시지를 잃으면 안 됩니다. 그리고 사용자가 로그인해 있으므로 누구인지, 어디에 있는지 이미 알고 있으니 묻는 대신 위젯이 그 정보를 보내야 합니다.

다이어그램: 피드백 위젯 세션 하나의 흐름, 클릭부터 저장까지

1. 버튼은 항상 화면 오른쪽 아래에 고정되어 있습니다.
2. 클릭하면 브라우저의 top layer에 패널이 열리고 새 시도 키가 만들어집니다.
3. 사용자가 표정을 고르고 메모를 씁니다. 패널이 열려 있든 숨겨져 있든 폼의 상태는 마운트된 채 유지됩니다.
4. 제출 시 위젯이 라우트, 앱 버전, 계정 ID, 뷰포트를 추가하고 SDK로 POST합니다.
5. 버튼을 두 번 눌러도 두 POST가 같은 멱등성 키를 가지므로 Ikiform은 한 번만 저장합니다.
6. 사용자가 먼저 바깥을 클릭하거나 Escape를 누르면 패널이 숨겨지고, 다음에 열 때 작성 중인 내용이 그대로 있습니다.

라이트 디스미스는 Popover API의 기본 동작입니다. 패널은 숨겨지기만 하므로 텍스트 필드에 입력한 내용이 남아 있습니다.

## 앱용 필드

폼: In-app feedback

- **사용해 보니 어떠세요?** (`rating`, rating, 필수)
- **더 자세히 알려 주세요** (`message`, textarea)
- **팀이 이메일로 답장해도 됩니다** (`may_reply`, checkbox)
- **이메일** (`email`, email)
- **화면** (`route`, text)
- **앱 버전** (`app_version`, text)
- **계정** (`account_id`, text)
- **뷰포트** (`viewport`, text)

사용자에게는 처음 세 필드만 보입니다. 나머지는 제출 시 코드에서 추가합니다. `may_reply`는 체크박스이며 `true` 또는 `false`로 저장됩니다.

| 필드 | 출처 | 이유 |
| --- | --- | --- |
| `rating` | 사용자, 표정 다섯 개 | `max: 5`인 `rating` 필드. 숫자보다 표정이 더 빨리 고를 수 있습니다 |
| `message` | 사용자 | 선택. 나쁜 표정일 때 가장 유용합니다 |
| `may_reply`, `email` | 체크박스, 그다음 세션 | 답장을 원할 때만 이메일을 보내며, 알림의 답장은 그 사람에게 갑니다 |
| `route` | `window.location.pathname` | 어느 화면인지. 클라이언트 측 내비게이션 후 제출 시점에 읽습니다 |
| `app_version` | 내 빌드 | 이미 변경한 코드에 대한 불만인지 알려 줍니다 |
| `account_id` | 내 세션 | 플랜과 데이터를 조회할 수 있습니다. 신원 증명은 아닙니다. 아래를 참고하세요 |
| `viewport` | `window.innerWidth`와 `innerHeight` | 레이아웃 불만은 크기에 따라 다른 경우가 많습니다 |

## 컴포넌트

`@ikiform/sdk`와 `popover`, `popoverTarget`, `toggle` 이벤트의 타입을 제공하는 React 19가 필요합니다. 버튼의 `popoverTarget`이 패널과 연결되므로 열기, Escape, 버튼으로 포커스 되돌리기를 브라우저가 처리합니다.

`components/feedback-widget.tsx`:

```tsx
import { submit } from "@ikiform/sdk";
import { type FormEvent, type ToggleEvent, useId, useRef, useState } from "react";
import "./feedback-widget.css";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";
const FACES = ["Awful", "Bad", "Okay", "Good", "Great"];

interface Props {
  accountId: string;
  email: string;
  appVersion: string;
}

export function FeedbackWidget({ accountId, email, appVersion }: Props) {
  const panelId = useId();
  const attempt = useRef("");
  const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
  const [error, setError] = useState("");

  function onToggle(event: ToggleEvent<HTMLDivElement>) {
    if (event.newState !== "open") return;
    attempt.current = crypto.randomUUID();
    if (status === "sent") setStatus("idle");
  }

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    if (data.get("may_reply") === "true") data.set("email", email);
    data.set("route", window.location.pathname);
    data.set("app_version", appVersion);
    data.set("account_id", accountId);
    data.set("viewport", `${window.innerWidth}x${window.innerHeight}`);
    setStatus("sending");
    setError("");
    const result = await submit(FORM_ID, data, { idempotencyKey: attempt.current });
    if (result.ok) {
      setStatus("sent");
      return;
    }
    setError(result.error.fields?.rating?.[0] ?? result.error.message);
    setStatus("idle");
  }

  return (
    <>
      <button type="button" className="feedback-trigger" popoverTarget={panelId}>
        Feedback
      </button>
      <div id={panelId} popover="auto" className="feedback-panel" onToggle={onToggle} aria-label="Send feedback">
        {status === "sent" ? (
          <p role="status">Thanks, that helps.</p>
        ) : (
          <form onSubmit={onSubmit}>
            <fieldset>
              <legend>How is it going?</legend>
              {FACES.map((face, index) => (
                <label key={face}>
                  <input type="radio" name="rating" value={index + 1} required /> {face}
                </label>
              ))}
            </fieldset>
            <label htmlFor={`${panelId}-message`}>Tell us more</label>
            <textarea id={`${panelId}-message`} name="message" rows={4} maxLength={4000} />
            <label>
              <input type="checkbox" name="may_reply" value="true" /> The team can reply to me by email
            </label>
            <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"}</button>
          </form>
        )}
      </div>
    </>
  );
}
```

- `popover="auto"`는 열릴 때 패널을 top layer에 둡니다. `z-index` 없이 대화상자와 메뉴 위에 표시되고, 사용자가 다른 곳을 클릭하면 닫힙니다.
- 패널이 숨겨져도 자식 요소는 마운트된 채로 남으므로, 실수로 바깥을 클릭해도 초안이 남습니다. 전송에 성공하면 감사 문구가 폼을 대체해 내용이 지워지고, 다음에 열면 빈 폼이 표시됩니다.
- `onToggle`은 패널이 열릴 때마다 새 키를 만듭니다. `submit()`이 이를 `Idempotency-Key` 헤더로 보내므로 더블 클릭해도 답변이 하나만 저장됩니다. [멱등성](https://ikiform.com/ko/docs/frameworks.md#idempotency)을 참고하세요.
- 이메일은 체크박스를 선택했을 때만 설정됩니다. 선택하지 않으면 답변에 계정 ID는 담기지만 주소는 없습니다.

popover의 기본 스타일은 화면 가운데에 배치합니다. 아래 규칙은 대신 버튼 위에 고정합니다.

`components/feedback-widget.css`:

```text
.feedback-trigger {
  position: fixed;
  right: 16px;
  bottom: 16px;
}

.feedback-panel {
  inset: auto 16px 64px auto;
  margin: 0;
  width: min(360px, calc(100vw - 32px));
  max-height: calc(100dvh - 96px);
  overflow: auto;
}
```

## 한 번만 마운트하기

로그인한 화면을 감싸는 레이아웃에서 위젯을 렌더링하면 모든 곳에 나타나고 내비게이션에도 유지됩니다. Next.js App Router에서는 레이아웃이 세션을 읽고 클라이언트 위젯에 일반 props를 넘기는 서버 컴포넌트입니다.

`app/(app)/layout.tsx`:

```tsx
import type { ReactNode } from "react";
import { FeedbackWidget } from "@/components/feedback-widget";
import { getSession } from "@/lib/session";

export default async function AppLayout({ children }: { children: ReactNode }) {
  const session = await getSession();
  return (
    <>
      {children}
      {session ? (
        <FeedbackWidget
          accountId={session.accountId}
          email={session.email}
          appVersion={process.env.NEXT_PUBLIC_APP_VERSION ?? "dev"}
        />
      ) : null}
    </>
  );
}
```

App Router에서는 위젯 파일에 `"use client"`를 표시하세요. Vite나 React Router 앱에서는 라우터 outlet 옆에 렌더링하면 똑같이 동작합니다.

## 브라우저가 증명할 수 있는 것과 없는 것

이 위젯은 폼의 공개 ID로 브라우저에서 바로 POST합니다. 피드백에는 적절한 절충입니다. 유지할 서버 라우트가 없고, Ikiform의 `meta.user_agent`가 사용자의 실제 브라우저가 됩니다. 반면 누구나 원하는 `account_id`를 POST할 수 있다는 뜻이기도 합니다. 조회용으로만 쓰고, 무언가를 허용하는 데는 절대 쓰지 마세요.

> 계정을 신뢰해야 한다면, 예를 들어 관리자 도구 안에서 피드백을 보여 주려면, 서버에서 시크릿 키로 POST하고 거기서 세션의 계정을 추가하세요. 필러의 [서버 버전](https://ikiform.com/ko/guides/feedback-form.md#form)에 패턴이 있습니다. 모든 POST가 서버의 IP에서 오게 되므로 Relaxed나 Custom 레이트 리밋을 고르세요.

## 작동 확인하기

1. 폼의 **Allowed domains**에 사이트 도메인을 추가하고, 개발하는 동안에는 `localhost`도 추가하세요. 비워 두면 어디서든 POST를 받습니다.
2. 패널을 열고 메모를 입력한 뒤 바깥을 클릭하고 다시 여세요. 메모가 그대로 있습니다.
3. 앱에서 모달을 연 다음 위젯을 여세요. 패널이 모달 위에 표시됩니다.
4. Escape를 누르세요. 패널이 닫히고 포커스가 피드백 버튼으로 돌아옵니다.
5. 표정을 고르고 보내기를 더블 클릭하세요. **Submissions**에 `route`, `app_version`, `account_id`가 채워진 답변이 하나 표시됩니다.
6. 답장 체크박스를 선택하고 한 번 보내세요. 알림 이메일의 답장이 사용자에게 갑니다.

## 주의할 점

- 채팅 버블이나 쿠키 배너 같은 다른 플로팅 요소와 버튼이 겹치지 않게 하세요. 둘 중 하나를 옮기거나 메뉴 항목에서 위젯을 열게 하세요.
- 결제나 에디터처럼 사람들이 집중하는 화면에는 버튼을 보여 주지 마세요. prop으로 숨기세요.
- Popover API는 현재 Chrome, Edge, Firefox, Safari에서 지원됩니다. 오래된 브라우저를 지원해야 한다면 의존하기 전에 확인하세요.
- 코드에서 추가하는 모든 필드는 폼 정의에 있어야 합니다. 없으면 Ikiform이 Unknown field 오류로 POST를 거부합니다.
- 앱 상태 전체를 보내지 마세요. 라우트, 버전, 계정, 크기로 대부분의 질문에 답할 수 있고, 그 이상은 개인정보 문제가 됩니다.

## 문제 해결

| 문제 | 해결 방법 |
| --- | --- |
| `403 origin_not_allowed`, 또는 모든 POST가 스팸으로 분류됨 | 페이지의 도메인이 폼의 **Allowed domains**에 없습니다. 도메인을 추가하고, 로컬 테스트용으로 `localhost`도 추가하세요. 등록된 도메인의 하위 도메인은 허용됩니다. |
| `403 challenge_required` | 폼의 스팸 검사가 **Ikiform**으로 설정되어 있는데 `fetch` POST에 토큰이 없었습니다. 제출할 때마다 `@ikiform/sdk`의 `challenge(formId)`로 토큰을 받거나 검사를 끄세요. |
| 패널이 화면 가운데에 나타남 | 스타일시트가 로드되지 않아 브라우저의 기본 popover 스타일이 적용된 것입니다. CSS import를 확인하거나 패널에 `inset`과 `margin`을 설정하세요. |
| `422 validation_failed` | POST가 폼의 필드 정의와 맞지 않습니다. `error.fields`에 각 필드와 이유가 나옵니다. 입력의 `name`을 필드 ID와 맞추세요. |

## 질문

### React 앱에 피드백 버튼을 어떻게 추가하나요?

popover=auto인 패널을 popoverTarget으로 가리키는 고정 버튼을 렌더링하세요. 열기, 닫기, 포커스는 브라우저가 처리합니다. 패널의 폼은 @ikiform/sdk의 submit()으로 POST하고, 제출 시 라우트와 앱 버전을 추가하세요.

### 모달 라이브러리 대신 Popover API를 쓰는 이유는 무엇인가요?

popover는 top layer에 렌더링되므로 z-index 규칙 없이 앱의 대화상자 위에 표시되고, 라이트 디스미스와 Escape가 기본으로 제공됩니다. 피드백 패널은 모달처럼 페이지를 막을 필요가 없습니다.

### 위젯의 계정 ID는 신뢰할 수 있나요?

아니요. 브라우저에서 보내는 것은 무엇이든 사용자가 바꿀 수 있습니다. 맥락을 조회하는 데 쓰고, 신원을 신뢰해야 할 때는 서버에서 시크릿 키로 POST하세요.

### 위젯에 서버가 필요한가요?

아니요. 브라우저에서 Ikiform으로 바로 POST하고, Allowed domains가 폼을 쓸 수 있는 사이트를 제한합니다. 신뢰할 수 있는 신원이 필요할 때만 서버 라우트를 추가하세요.

## 가이드 더 보기

- [웹사이트나 앱용 피드백 폼](https://ikiform.com/ko/guides/feedback-form.md): 어떤 페이지나 앱 안에든 피드백 폼을 둡니다. 평점, 코멘트, 보낸 페이지, 스팸 필터링, 낮은 점수 알림까지 다룹니다.
- [버그 리포트 폼](https://ikiform.com/ko/guides/feedback-form/bug-report.md): 재현 단계와 심각도는 사람에게 묻고, URL, 브라우저, 뷰포트, 최근 오류는 페이지가 알아서 추가하는 버그 리포트 폼입니다.
- [NPS 설문](https://ikiform.com/ko/guides/feedback-form/nps-survey.md): 이메일에 0~10 점수를 넣고, 점수에 따라 바뀌는 후속 질문을 내 페이지에서 묻고, 스크립트로 NPS를 계산하는 NPS 설문을 운영합니다.
- [베타 테스터 피드백](https://ikiform.com/ko/guides/feedback-form/beta-tester-feedback.md): 테스터와 빌드별 개인 링크, 두 가지 평점 척도, 빌드마다 나아졌는지 보여 주는 주간 요약을 갖춘 베타 피드백 폼입니다.
- [기능 요청 폼](https://ikiform.com/ko/guides/feedback-form/feature-request.md): 해결책 대신 문제를 묻고, 요청을 테마별로 저장하고, 행이 아닌 사람 수를 세고, 출시되면 이메일을 보내는 기능 요청 폼입니다.
- [해지 설문](https://ikiform.com/ko/guides/feedback-form/cancellation-survey.md): SaaS 해지 흐름 안에서 고객이 해지하는 이유를 묻고, 건너뛰기는 클릭 한 번으로 두고, 이유를 Stripe와 Ikiform에 저장하며, 해지를 절대 지연시키지 않습니다.
