# 빌드마다 베타 테스터 피드백 모으기

> 테스터와 빌드별 개인 링크, 두 가지 평점 척도, 빌드마다 나아졌는지 보여 주는 주간 요약을 갖춘 베타 피드백 폼입니다.

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

각 테스터에게 불투명한 테스터 코드와 빌드 번호가 담긴 개인 링크를 주면, 폼이 묻지 않고도 누가 어느 빌드에 대해 답하는지 압니다. 빌드를 무엇에 썼는지, 안정성과 유용성을 각각 1~5점으로, 그리고 무엇이 막았는지를 물으세요. 그런 다음 개별 답변이 아니라 빌드끼리 비교해 릴리스마다 수치가 움직였는지 보세요.

## 베타 피드백이 다른 점

공개 피드백은 모르는 사람이 한 번 보냅니다. 베타 피드백은 같은 소수의 사람이 매주 바뀌는 소프트웨어에 대해 반복해서 보냅니다. 답변 하나보다 추세가 중요합니다. 같은 사람들에게 빌드 1.4.0이 1.3.0보다 안정적으로 느껴졌을까요? 그러려면 일반 피드백 폼에 없는 두 가지가 필요합니다. 누가 답했는지, 그리고 어느 빌드를 쓰고 있었는지입니다.

테스터에게 이메일과 버전 번호를 입력하라고 하면 실제로는 실패합니다. 버전을 잊거나, 잘못 입력하거나, "최신"이라고 씁니다. 그래서 둘 다 보내는 링크에 넣고 페이지가 채우게 합니다.

다이어그램: 새 빌드에서 다음 빌드까지 이어지는 주간 베타 피드백 루프

1. 새 빌드가 베타 그룹에 배포됩니다.
2. 스크립트가 테스터마다 코드와 빌드 번호가 담긴 링크를 하나씩 만들고, 이를 이메일로 보냅니다.
3. 테스터가 링크를 엽니다. 페이지가 hidden 테스터와 빌드 필드를 채우고 어느 빌드에 대해 묻는지 보여 줍니다.
4. 요약 스크립트가 API로 모든 답변을 읽고 빌드별로 점수의 평균을 냅니다.
5. 가장 낮은 점수의 차단 요인이 다음 빌드의 수정 목록이 됩니다.

행이 아니라 열을 비교하세요. 테스터 한 명의 나쁜 한 주는 노이즈이고, 그룹 전체의 하락은 회귀입니다.

## 필드

폼: Beta feedback

- **테스터 코드** (`tester`, text, 필수)
- **빌드** (`build`, text, 필수)
- **이 빌드를 무엇에 썼나요?** (`tried`, textarea, 필수)
- **얼마나 안정적이었나요?** (`stability`, rating, 필수)
- **얼마나 유용했나요?** (`usefulness`, rating, 필수)
- **더 이상 쓸 수 없게 된다면 어떤 기분일까요?** (`would_miss`, radio, 필수)
- **막힌 부분이 있었나요?** (`blockers`, textarea)

`tester`와 `build`는 링크에서 채워지는 hidden 입력입니다. 패턴이 실제 초대에서 오지 않은 값은 모두 거부합니다.

| 필드 | 이유 |
| --- | --- |
| `tester` | 무작위 10자 코드. URL에 이메일을 넣지 않고도 빌드를 넘나들며 한 사람을 추적할 수 있습니다 |
| `build` | 모든 점수를 릴리스와 연결합니다. 패턴은 `1.4.0` 같은 버전만 받습니다 |
| `tried` | 실제로 무엇을 했는지. 이게 없으면 점수를 해석하기 어렵습니다 |
| `stability`, `usefulness` | 1~5점 `rating` 필드 두 개. 잘못된 것을 안정적으로 만든 빌드도 여전히 잘못된 것이므로 따로 둡니다 |
| `would_miss` | 없으면 얼마나 아쉬울지. "매우"의 비율이 오르는 것이 베타가 잘되고 있다는 가장 강한 신호입니다 |
| `blockers` | 선택 입력이며, 수정 목록이 여기서 나옵니다 |

## 테스터마다 링크 만들기

`testers.txt`에 테스터 이메일 목록을 일반 텍스트로 두세요. 이 스크립트는 처음 보는 테스터에게 고정 코드를 부여하고, 매핑을 `testers.json`에 저장하고, 지정한 빌드에 대한 초대 링크를 테스터마다 하나씩 씁니다. `testers.json`은 `{}`로 시작하세요.

`links.mjs`:

```js
import { randomBytes } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";

const BUILD = process.argv[2];
if (!/^\d+\.\d+\.\d+$/.test(BUILD ?? "")) throw new Error("Usage: node links.mjs 1.4.0");

const known = JSON.parse(readFileSync("testers.json", "utf8"));
const emails = readFileSync("testers.txt", "utf8").split(/\r?\n/).map((line) => line.trim().toLowerCase()).filter(Boolean);

for (const email of emails) {
  known[email] ??= randomBytes(5).toString("hex");
}
writeFileSync("testers.json", JSON.stringify(known, null, 2));

const rows = emails.map((email) => {
  const link = new URL("https://example.com/beta/feedback");
  link.searchParams.set("t", known[email]);
  link.searchParams.set("build", BUILD);
  return `${email},${link}`;
});
writeFileSync(`invites-${BUILD}.csv`, ["email,link", ...rows].join("\n"));
console.log(`${rows.length} links for build ${BUILD}`);
```

CSV를 이메일 도구의 메일 머지로 보내거나, 소규모 그룹이라면 링크를 직접 붙여 넣으세요. `testers.json`은 비공개로 두세요. 코드를 사람과 연결하는 유일한 곳입니다.

## 피드백 페이지

사이트의 `/beta/feedback` 페이지에 넣으세요. 링크에서 코드와 빌드를 읽고, 둘이 없으면 폼을 보여 주지 않으며, 일반적인 방식으로 POST합니다. **Redirect after submit**을 감사 페이지로 설정하세요. 스크립트를 허용하는 정적 호스트나 빌더라면 어디서든 동작합니다.

`beta/feedback.html`:

```html
<p id="beta-missing" hidden>This page needs the link from your invite email.</p>

<form id="beta" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post" hidden>
  <h2>Feedback for build <span id="beta-build"></span></h2>
  <input type="hidden" name="tester" />
  <input type="hidden" name="build" />
  <label for="tried">What did you use this build for?</label>
  <textarea id="tried" name="tried" rows="4" required minlength="15"></textarea>
  <fieldset>
    <legend>How stable did it feel?</legend>
    <label><input type="radio" name="stability" value="1" required /> 1, it crashed a lot</label>
    <label><input type="radio" name="stability" value="2" /> 2</label>
    <label><input type="radio" name="stability" value="3" /> 3</label>
    <label><input type="radio" name="stability" value="4" /> 4</label>
    <label><input type="radio" name="stability" value="5" /> 5, no problems</label>
  </fieldset>
  <fieldset>
    <legend>How useful was it?</legend>
    <label><input type="radio" name="usefulness" value="1" required /> 1, not at all</label>
    <label><input type="radio" name="usefulness" value="2" /> 2</label>
    <label><input type="radio" name="usefulness" value="3" /> 3</label>
    <label><input type="radio" name="usefulness" value="4" /> 4</label>
    <label><input type="radio" name="usefulness" value="5" /> 5, I rely on it</label>
  </fieldset>
  <fieldset>
    <legend>If you could no longer use it, how would you feel?</legend>
    <label><input type="radio" name="would_miss" value="very" required /> Very disappointed</label>
    <label><input type="radio" name="would_miss" value="somewhat" /> Somewhat disappointed</label>
    <label><input type="radio" name="would_miss" value="not" /> Not disappointed</label>
  </fieldset>
  <label for="blockers">Anything that stopped you?</label>
  <textarea id="blockers" name="blockers" rows="3"></textarea>
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Send this week's feedback</button>
</form>

<script>
  const params = new URLSearchParams(location.search);
  const tester = params.get("t") ?? "";
  const build = params.get("build") ?? "";
  const form = document.getElementById("beta");
  if (/^[a-z0-9]{10}$/.test(tester) && /^\d+\.\d+\.\d+$/.test(build)) {
    form.elements.tester.value = tester;
    form.elements.build.value = build;
    document.getElementById("beta-build").textContent = build;
    form.hidden = false;
  } else {
    document.getElementById("beta-missing").hidden = false;
  }
</script>
```

> 테스터의 링크를 가진 사람은 누구나 그 테스터로 답할 수 있습니다. 베타에서는 받아들일 만합니다. 코드는 무작위이고, 그룹은 작고, 이상한 답변은 눈에 띕니다. 코드를 무언가에 대한 접근 권한 부여에 쓰지 마세요.

## 빌드 비교하기

대시보드는 개별 답변을 보여 줍니다. 빌드를 비교하려면 SDK로 전부 읽고 `build`로 묶으세요. `IKIFORM_SECRET_KEY`에 시크릿 키를 넣고 실행하세요.

`summary.mjs`:

```js
import { createClient } from "@ikiform/sdk";

const ikiform = createClient({ apiKey: process.env.IKIFORM_SECRET_KEY });
const builds = new Map();

for await (const submission of ikiform.submissions.iterate({ formId: "frm_xxxxxxxxxxxxxxxx", status: "received" })) {
  const { build, tester, stability, usefulness, would_miss } = submission.fields;
  const row = builds.get(build) ?? { testers: new Set(), stability: 0, usefulness: 0, very: 0, answers: 0 };
  row.testers.add(tester);
  row.stability += stability;
  row.usefulness += usefulness;
  row.very += would_miss === "very" ? 1 : 0;
  row.answers += 1;
  builds.set(build, row);
}

console.table(
  [...builds].map(([build, row]) => ({
    build,
    testers: row.testers.size,
    stability: (row.stability / row.answers).toFixed(1),
    usefulness: (row.usefulness / row.answers).toFixed(1),
    "very disappointed": `${Math.round((row.very / row.answers) * 100)}%`,
  })),
);
```

평점은 숫자로 도착하므로 평균을 낼 때 파싱이 필요 없습니다. 각 평균 옆에 테스터 수를 세어 두세요. 답변이 세 개인 빌드는 서른 개인 빌드와 공정하게 비교할 수 없습니다.

## 작동 확인하기

1. `node links.mjs 1.4.0`을 실행하고 링크 하나를 여세요. 폼에 "Feedback for build 1.4.0"이 표시됩니다.
2. 쿼리 문자열 없이 페이지를 여세요. 초대 메시지만 표시됩니다.
3. 답변을 제출한 뒤 브라우저 개발자 도구에서 `build`를 `latest`로 바꾸고 다시 제출하세요. Ikiform이 "Use a version like 1.4.0"과 함께 `422`로 응답합니다.
4. `summary.mjs`를 실행하세요. 표에 테스터 한 명과 두 점수가 있는 빌드 1.4.0이 표시됩니다.

## 주의할 점

- 베타 그룹은 같은 IP를 쓰는 한 사무실에서 테스트하는 경우가 많습니다. Standard 리밋은 이들을 함께 세므로, 이 폼에는 방문자당 분당 20회인 Relaxed를 고르세요.
- TestFlight의 모바일 베타는 이미 테스터가 App Store Connect로 스크린샷 피드백을 보낼 수 있게 해 줍니다. 화면 단위 버그에는 그것을, 주간 질문에는 이 폼을 쓰세요.
- 매주 같은 질문을 하세요. 문구를 바꾸면 빌드 간 비교가 깨집니다.
- 베타가 진행될수록 테스터의 응답이 줄어듭니다. 폼은 2~3분 안에 끝나게 하고, 릴리스 노트에서 이름을 불러 감사를 전하세요.
- Ikiform은 반복 답변을 합치지 않습니다. 테스터가 한 빌드에 두 번 답하면 요약에서는 나중 것을 남기세요.

## 문제 해결

| 문제 | 해결 방법 |
| --- | --- |
| "Open the link from your invite email"과 함께 `422` | `tester` 값이 패턴에 맞지 않습니다. 대개 이메일 클라이언트에서 링크가 잘렸기 때문입니다. 링크를 다시 보내세요. |
| `429 rate_limited` | 한 방문자가 폼의 레이트 리밋보다 많이 보냈습니다. Standard는 방문자당 분당 5회를 허용합니다. `Retry-After`만큼 기다리거나, 공유 IP 하나에서 POST하는 폼이라면 Relaxed나 Custom을 고르세요. |
| `403 origin_not_allowed`, 또는 모든 POST가 스팸으로 분류됨 | 페이지의 도메인이 폼의 **Allowed domains**에 없습니다. 도메인을 추가하고, 로컬 테스트용으로 `localhost`도 추가하세요. 등록된 도메인의 하위 도메인은 허용됩니다. |

## 질문

### 어느 베타 테스터가 피드백을 보냈는지 어떻게 알 수 있나요?

각 테스터에게 무작위 코드를 주고 개인 링크에 넣으세요. 페이지가 이를 hidden 필드에 복사하고, 코드를 이메일과 연결하는 것은 비공개 매핑 파일뿐입니다.

### 빌드별 피드백은 어떻게 추적하나요?

빌드 번호도 링크에 넣고, 패턴으로 검증하고, 답변을 빌드별로 묶으세요. SDK의 submissions.iterate로 요약에 필요한 모든 답변을 읽을 수 있습니다.

### 베타 피드백 폼에서는 무엇을 물어야 하나요?

빌드를 무엇에 썼는지, 안정성과 유용성을 1~5점으로 어떻게 느꼈는지, 없으면 얼마나 아쉬울지, 그리고 무엇이 막았는지입니다. 매주 같은 질문을 유지하세요.

### 베타 테스터가 같은 폼으로 버그를 보고해야 하나요?

재현이 필요한 것은 별도의 버그 리포트 폼으로 받으세요. 주간 폼은 빌드의 전반적인 느낌을 위한 것이고, 둘을 섞으면 둘 다 읽기 어려워집니다.

## 가이드 더 보기

- [웹사이트나 앱용 피드백 폼](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/feature-request.md): 해결책 대신 문제를 묻고, 요청을 테마별로 저장하고, 행이 아닌 사람 수를 세고, 출시되면 이메일을 보내는 기능 요청 폼입니다.
- [React 피드백 위젯](https://ikiform.com/ko/guides/feedback-form/react-feedback-widget.md): 작은 패널을 열고, 답변마다 화면, 앱 버전, 계정을 함께 보내고, 작성 중인 내용을 잃지 않는 React 앱용 플로팅 피드백 버튼입니다.
- [해지 설문](https://ikiform.com/ko/guides/feedback-form/cancellation-survey.md): SaaS 해지 흐름 안에서 고객이 해지하는 이유를 묻고, 건너뛰기는 클릭 한 번으로 두고, 이유를 Stripe와 Ikiform에 저장하며, 해지를 절대 지연시키지 않습니다.
