웹사이트나 앱용 피드백 폼

웹사이트나 앱용 피드백 폼4분 분량

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

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

Preet SutharIkiform 창업자

설문 이메일에 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 점수 구간마다 후속 질문이 다르고 결과에 반영되는 방식0~6점가장 실망스러웠던 점은 무엇인가요?비추천자빼기7 또는 8무엇이 있으면 10점이 될까요?중립전체 수에만 포함9 또는 10가장 높이 평가하는 점은무엇인가요?추천자더하기점수후속 질문집계 방식NPS 점수 구간마다 후속 질문이 다르고 결과에 반영되는 방식0~6점가장실망스러웠던점은무엇인가요?비추천자빼기7 또는 8무엇이 있으면10점이될까요?중립전체 수에만 포함9 또는 10가장 높이평가하는 점은무엇인가요?추천자더하기점수후속 질문집계 방식
  1. 0~6점은 무엇이 실망스러웠는지 묻고 비추천자로 셉니다.
  2. 7점이나 8점은 무엇이 있으면 10점이 될지 묻고 전체 수에만 포함합니다.
  3. 9점이나 10점은 무엇을 가장 높이 평가하는지 묻고 추천자로 셉니다.
  4. NPS는 추천자 비율에서 비추천자 비율을 뺀 값으로, -100부터 100까지입니다.
유용한 답이 점수에 따라 다르기 때문에 질문도 점수에 따라 바뀝니다. 비추천자의 이유는 고쳐야 할 문제이고, 추천자의 이유는 홈페이지 문구가 됩니다.

필드

미리보기

Ikiform의 검증기를 브라우저에서 실행합니다. 아무것도 전송되지 않습니다.

11이나 7.5를 넣어 보세요. Ikiform이 둘 다 거부합니다. wave가 없어도 거부되므로 모든 답변은 설문 회차에 속합니다.
필드이유
scorenumber, 0~10, 정수만. 숫자로 저장됩니다
reason후속 질문. 점수만으로도 집계되므로 선택 입력입니다
respondent사람마다 생성해 보내는 불투명한 토큰. 내 데이터베이스에서 고객과 연결됩니다
wave2026-q4 같은 회차. 날짜 필터 없이 분기끼리 비교할 수 있습니다

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

설문 이메일

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

nps-email.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
<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
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점 움직입니다. 점수 옆에 인원수를 함께 보고하세요.
  • 낮은 점수에는 답장할 가치가 있습니다. 필러의 낮은 점수 알림 같은 웹훅 릴레이에 score <= 6 조건을 써서 비추천자를 팀 채팅으로 보내세요.
  • 설문 폼 템플릿은 더 긴 설문 안에서 점수를 묻습니다. 일회성 조사에는 그게 맞고, 단독 NPS 질문이 더 많은 답을 받습니다.
  • Ikiform은 폼을 작성한 사람에게 이메일을 보내지 않습니다. 알림 이메일은 여러분과 팀에게만 가므로 자동 응답, 확인 이메일, 더블 옵트인은 없습니다. 이런 이메일은 웹훅으로 트리거해 이메일 도구에서 보내세요.

문제 해결

문제해결 방법
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을 고르세요.

질문

막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.