# 이메일 링크부터 점수까지, 내 사이트에서 NPS 설문 운영하기

> 이메일에 0~10 점수를 넣고, 점수에 따라 바뀌는 후속 질문을 내 페이지에서 묻고, 스크립트로 NPS를 계산하는 NPS 설문을 운영합니다.

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

설문 이메일에 0부터 10까지 링크 열한 개를 넣고, 각각 URL에 점수와 불투명한 응답자 토큰이 담긴 내 사이트의 페이지를 가리키게 하세요. 페이지는 점수를 미리 선택하고, 점수에 따른 후속 질문을 하며, 사람이 보내기를 누르기 전까지 아무것도 기록하지 않습니다. 점수는 0~10 number 필드에 저장하고, NPS는 9점과 10점의 비율에서 0~6점의 비율을 빼서 계산하세요.

## NPS가 다른 점

NPS는 고정된 척도로 질문 하나를 합니다. 우리를 추천할 가능성이 0부터 10까지 얼마나 되는지입니다. 세 가지 세부 사항이 필러의 1~5 평점과 다릅니다.

- **척도가 0부터 시작합니다.** Ikiform의 `rating` 필드는 1부터 최대 10까지라서 0을 담을 수 없습니다. NPS는 `min: 0`, `max: 10`, `integer: true`인 `number` 필드를 쓰며, 숫자로 저장됩니다.
- **대개 이메일로 보냅니다.** 이메일 클라이언트는 폼 제출이나 스크립트 실행을 안정적으로 하지 못하므로, 이메일에는 링크를 담고 폼은 내 사이트에 둡니다. Ikiform에는 호스팅 폼 페이지가 없습니다.
- **이메일 속 링크는 기계가 클릭합니다.** 많은 메일 보안 스캐너가 확인을 위해 메시지의 모든 링크를 엽니다. 링크를 여는 것만으로 점수가 기록된다면 스캐너의 점수를 모으게 됩니다. 페이지는 사람이 보내기를 눌렀을 때만 기록합니다.

다이어그램: NPS 점수 구간마다 후속 질문이 다르고 결과에 반영되는 방식

1. 0~6점은 무엇이 실망스러웠는지 묻고 비추천자로 셉니다.
2. 7점이나 8점은 무엇이 있으면 10점이 될지 묻고 전체 수에만 포함합니다.
3. 9점이나 10점은 무엇을 가장 높이 평가하는지 묻고 추천자로 셉니다.
4. NPS는 추천자 비율에서 비추천자 비율을 뺀 값으로, -100부터 100까지입니다.

유용한 답이 점수에 따라 다르기 때문에 질문도 점수에 따라 바뀝니다. 비추천자의 이유는 고쳐야 할 문제이고, 추천자의 이유는 홈페이지 문구가 됩니다.

## 필드

폼: NPS survey

- **친구나 동료에게 우리를 추천할 가능성은 얼마나 되나요?** (`score`, number, 필수)
- **이 점수를 준 주된 이유는 무엇인가요?** (`reason`, textarea)
- **응답자** (`respondent`, text)
- **설문 회차** (`wave`, text, 필수)

11이나 7.5를 넣어 보세요. Ikiform이 둘 다 거부합니다. `wave`가 없어도 거부되므로 모든 답변은 설문 회차에 속합니다.

| 필드 | 이유 |
| --- | --- |
| `score` | `number`, 0~10, 정수만. 숫자로 저장됩니다 |
| `reason` | 후속 질문. 점수만으로도 집계되므로 선택 입력입니다 |
| `respondent` | 사람마다 생성해 보내는 불투명한 토큰. 내 데이터베이스에서 고객과 연결됩니다 |
| `wave` | `2026-q4` 같은 회차. 날짜 필터 없이 분기끼리 비교할 수 있습니다 |

링크에 고객의 이메일이나 계정 ID를 넣지 마세요. URL은 브라우저 기록, 프록시 로그, 전달된 이메일에 남습니다. 16자 이상의 무작위 토큰이면 누가 답했는지 알 수 있고, 다른 사람에게는 아무것도 알려 주지 않습니다.

## 설문 이메일

링크 줄은 이메일 도구의 머지 태그로 만들거나, 이메일을 보내는 코드에서 생성하세요. 이 함수는 응답자 한 명의 HTML을 반환합니다.

`nps-email.js`:

```js
function npsRow(respondent, wave) {
  const cells = Array.from({ length: 11 }, (_, score) => {
    const link = new URL("https://example.com/nps");
    link.searchParams.set("score", String(score));
    link.searchParams.set("r", respondent);
    link.searchParams.set("wave", wave);
    return `<td><a href="${link}" style="display:block;padding:8px 10px">${score}</a></td>`;
  });
  return `<table role="presentation"><tr>${cells.join("")}</tr></table>
<p>0 is not at all likely, 10 is extremely likely.</p>`;
}
```

이메일에는 질문과 숫자 줄만 넣으세요. 도입부가 길면 휴대폰에서 척도가 첫 화면 아래로 밀리고, 대부분의 답은 스크롤하지 않고 숫자를 누르는 사람에게서 나옵니다.

## 랜딩 페이지

사이트의 `/nps`에 두세요. 0~10 척도를 그리고, 링크의 점수를 미리 선택하고, 그 점수에 맞는 후속 질문을 설정하며, 사람이 보내기를 눌렀을 때만 `fetch`로 POST합니다. 페이지에서 점수를 바꾸면 질문도 함께 바뀝니다.

`nps.html`:

```html
<form id="nps" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <fieldset>
    <legend>How likely are you to recommend us to a friend or colleague?</legend>
    <div id="nps-scale"></div>
  </fieldset>
  <label for="reason" id="reason-label">What's the main reason for your score?</label>
  <textarea id="reason" name="reason" rows="4" maxlength="4000"></textarea>
  <input type="hidden" name="respondent" />
  <input type="hidden" name="wave" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Send</button>
  <p id="nps-status" role="status"></p>
</form>

<script>
  const QUESTIONS = [
    [6, "What disappointed you most?"],
    [8, "What would make it a 10 for you?"],
    [10, "What do you value most about it?"],
  ];
  const params = new URLSearchParams(location.search);
  const form = document.getElementById("nps");
  const label = document.getElementById("reason-label");
  const scale = document.getElementById("nps-scale");

  for (let score = 0; score <= 10; score += 1) {
    scale.insertAdjacentHTML(
      "beforeend",
      `<label><input type="radio" name="score" value="${score}" required /> ${score}</label>`,
    );
  }

  function askFor(score) {
    label.textContent = QUESTIONS.find(([top]) => score <= top)[1];
  }

  const preset = Number(params.get("score"));
  if (params.has("score") && Number.isInteger(preset) && preset >= 0 && preset <= 10) {
    form.elements.score.value = String(preset);
    askFor(preset);
  }
  form.elements.respondent.value = params.get("r") ?? "";
  form.elements.wave.value = params.get("wave") ?? "";
  scale.addEventListener("change", (event) => askFor(Number(event.target.value)));

  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    const status = document.getElementById("nps-status");
    status.textContent = "Sending";
    const response = await fetch(form.action, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(form),
    }).catch(() => null);
    const result = await response?.json().catch(() => null);
    if (result?.ok) {
      form.replaceChildren(Object.assign(document.createElement("p"), { textContent: "Thanks for answering." }));
      return;
    }
    status.textContent = result?.error?.fields?.score?.[0] ?? result?.error?.message ?? "Couldn't send it. Try again.";
  });
</script>
```

> 페이지는 링크 없이도 동작합니다. `/nps`를 직접 연 사람에게는 아무것도 선택되지 않은 척도가 보입니다. 기본값을 설정하지 않으면 그 답변은 `wave`가 없어서 거부되므로, 우연한 방문이 수치에 섞이지 않습니다.

## 점수 계산하기

SDK로 회차의 모든 답변을 읽고, 응답자별로 최신 답변만 남기고, 공식을 적용하세요. `IKIFORM_SECRET_KEY`에 시크릿 키를 넣고 실행하세요.

`nps.mjs`:

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

const WAVE = process.argv[2] ?? "2026-q4";
const ikiform = createClient({ apiKey: process.env.IKIFORM_SECRET_KEY });
const latest = new Map();

for await (const submission of ikiform.submissions.iterate({ formId: "frm_xxxxxxxxxxxxxxxx", status: "received" })) {
  const { score, respondent, wave } = submission.fields;
  if (wave !== WAVE) continue;
  const key = respondent || submission.id;
  const seen = latest.get(key);
  if (!seen || seen.at < submission.created_at) latest.set(key, { score, at: submission.created_at });
}

const scores = [...latest.values()].map((answer) => answer.score);
const promoters = scores.filter((value) => value >= 9).length;
const detractors = scores.filter((value) => value <= 6).length;
const nps = Math.round(((promoters - detractors) / scores.length) * 100);

console.log(`${WAVE}: ${scores.length} people, ${promoters} promoters, ${detractors} detractors, NPS ${nps}`);
```

사람들은 마음을 바꿔 다른 숫자를 다시 누르기도 하므로 스크립트는 응답자당 답변 하나만 남깁니다. Ikiform은 같은 이메일의 반복 제출을 합치거나 거부하지 않습니다. 24시간 안의 동일한 반복은 스팸 점수만 올립니다. 중복 제거는 목록이 있는 곳이나 내보낼 때 하세요.

## 작동 확인하기

1. `/nps?score=3&r=test_respondent_0001&wave=2026-q4`를 여세요. 3이 선택되고 질문은 "What disappointed you most?"입니다.
2. 9를 누르세요. 질문이 "What do you value most about it?"으로 바뀝니다.
3. 같은 링크를 `curl`로 여세요. 아무것도 전송되지 않았으므로 **Submissions**에 새 항목이 없습니다.
4. 보내기를 누르세요. 답변에 `score`가 숫자 9로 표시됩니다.
5. `node nps.mjs 2026-q4`를 실행하세요. 한 명, 추천자 한 명으로 보고됩니다.

## 주의할 점

- 같은 고객에게 너무 자주 설문하지 마세요. 분기에 한 번처럼 간격을 정하고 응답자 목록은 내 쪽에서 관리하세요.
- 표본이 작으면 크게 흔들립니다. 답변이 20개라면 한 사람이 NPS를 5점 움직입니다. 점수 옆에 인원수를 함께 보고하세요.
- 낮은 점수에는 답장할 가치가 있습니다. 필러의 [낮은 점수 알림](https://ikiform.com/ko/guides/feedback-form.md#routing) 같은 웹훅 릴레이에 `score <= 6` 조건을 써서 비추천자를 팀 채팅으로 보내세요.
- 설문 [폼 템플릿](https://ikiform.com/forms/survey)은 더 긴 설문 안에서 점수를 묻습니다. 일회성 조사에는 그게 맞고, 단독 NPS 질문이 더 많은 답을 받습니다.
- Ikiform은 폼을 작성한 사람에게 이메일을 보내지 않습니다. 알림 이메일은 여러분과 팀에게만 가므로 자동 응답, 확인 이메일, 더블 옵트인은 없습니다. 이런 이메일은 [웹훅](https://ikiform.com/ko/docs/webhooks.md)으로 트리거해 이메일 도구에서 보내세요.

## 문제 해결

| 문제 | 해결 방법 |
| --- | --- |
| `Must be at most 10` 또는 정수 오류와 함께 `422` | 점수가 척도가 아닌 곳, 예를 들어 손으로 고친 URL에서 왔습니다. 페이지는 링크에서 0~10만 받으니 이메일 템플릿의 링크를 확인하세요. |
| `wave`를 지목하는 `422` | 링크에 `wave`가 없거나 `2026-q4` 형식의 값과 맞지 않습니다. 이메일 템플릿을 고치거나 페이지 스크립트에서 기본값을 설정하세요. |
| `403 origin_not_allowed`, 또는 모든 POST가 스팸으로 분류됨 | 페이지의 도메인이 폼의 **Allowed domains**에 없습니다. 도메인을 추가하고, 로컬 테스트용으로 `localhost`도 추가하세요. 등록된 도메인의 하위 도메인은 허용됩니다. |
| `429 rate_limited` | 한 방문자가 폼의 레이트 리밋보다 많이 보냈습니다. Standard는 방문자당 분당 5회를 허용합니다. `Retry-After`만큼 기다리거나, 공유 IP 하나에서 POST하는 폼이라면 Relaxed나 Custom을 고르세요. |

## 질문

### 이메일에 NPS 질문을 어떻게 넣나요?

이메일에 0부터 10까지 링크 열한 개를 넣고, 각각 URL에 점수가 담긴 내 사이트의 페이지를 열게 하세요. 페이지가 점수를 미리 선택하고 후속 질문을 하며, 사람이 보내기를 누르면 답변을 기록합니다.

### Ikiform의 rating 필드를 NPS에 쓸 수 있나요?

아니요. rating은 1부터 최대 10까지이고 NPS에는 0이 필요합니다. min 0, max 10, integer true인 number 필드를 쓰세요.

### NPS는 어떻게 계산하나요?

9점이나 10점인 답변의 비율에서 0~6점인 답변의 비율을 빼고, 7점과 8점은 전체 수에만 포함하세요. 결과는 -100부터 100까지입니다.

### 링크를 클릭하자마자 점수를 기록하지 않는 이유는 무엇인가요?

메일 보안 스캐너는 확인을 위해 들어오는 이메일의 링크를 엽니다. 클릭 시 기록하면 그런 방문도 답변으로 집계됩니다. 보내기를 눌렀을 때만 기록하면 수치가 정직하게 유지됩니다.

## 가이드 더 보기

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