# SaaS 해지 흐름에 해지 설문 추가하기

> SaaS 해지 흐름 안에서 고객이 해지하는 이유를 묻고, 건너뛰기는 클릭 한 번으로 두고, 이유를 Stripe와 Ikiform에 저장하며, 해지를 절대 지연시키지 않습니다.

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

해지 페이지에 선택 입력인 질문 하나를 보여 주고 제출 버튼 옆에 건너뛰기 버튼을 두세요. 사용자가 무엇을 고르든 구독부터 해지하세요. 그런 다음 세션에서 가져온 계정, 플랜, 사용 기간과 함께 서버에서 답변을 POST하되, 응답을 보낸 뒤에 하거나 짧은 타임아웃을 걸어 설문이 해지를 절대 붙잡지 않게 하세요. 같은 이유를 Stripe 구독에도 저장해 두 시스템이 일치하게 하세요.

## 해지 흐름에서 다른 점

다른 곳에서는 피드백 폼이 부탁입니다. 해지 흐름에서는 고객과 고객이 이미 결정한 일 사이를 가로막고 있습니다. 그래서 규칙이 정해집니다.

- **해지는 절대 설문에 의존하면 안 됩니다.** 답변에도, Ikiform 연결 여부에도, 느린 요청에도 의존하지 않습니다. 구독을 먼저 해지하고, 설문 POST는 아무도 모르게 실패해도 됩니다.
- **건너뛰기는 클릭 한 번입니다.** 제출 버튼 옆에 **Skip and cancel**(건너뛰고 해지) 버튼이 있습니다. 이를 숨기거나 혜택 제안 화면을 두세 개 더 넣으면 설문이 장애물이 되고, 사람들은 다시 돌아올지 정할 때 그걸 기억합니다.
- **맥락은 서버에서 가져옵니다.** 계정, 플랜, 결제 기간은 이미 알고 있습니다. 데이터를 신뢰할 수 있도록 hidden 입력이 아니라 세션에서 가져오세요.

다이어그램: 고객이 기다리는 동안 일어나는 일과 확인 화면을 본 뒤에 일어나는 일

1. 고객이 결제 페이지에서 플랜 해지를 누릅니다.
2. 질문 하나가 보이고, 답하거나 건너뛸 수 있습니다.
3. 어느 쪽이든 서버가 Stripe 구독을 기간 종료 시 해지로 설정하고, 이유가 있으면 함께 저장합니다.
4. 고객은 바로 확인 화면을 봅니다.
5. 응답을 보낸 뒤에야 서버가 답변을 Ikiform으로 POST합니다.
6. Ikiform이 플랜, 사용 기간과 함께 저장하고 웹훅을 보내면, 릴레이가 이탈 채널에 올릴 수 있습니다.

아랫줄의 어떤 것도 윗줄을 늦추거나 막을 수 없습니다. Ikiform에 연결할 수 없어도 고객은 모릅니다.

## 필드

폼: Cancellation survey

- **해지하시는 주된 이유는 무엇인가요?** (`reason`, radio, 필수)
- **더 알려 주고 싶은 것이 있나요?** (`details`, textarea)
- **어떤 도구로 옮기시나요?** (`switched_to`, text)
- **계정** (`account_id`, text, 필수)
- **플랜** (`plan`, text, 필수)
- **구독 개월 수** (`months_subscribed`, number)

고객에게는 처음 세 개만 보입니다. `account_id`, `plan`, `months_subscribed`는 서버가 세션에서 추가합니다.

이유 값은 Stripe의 `cancellation_details.feedback` 옵션과 같으므로 하나의 값이 양쪽으로 가고 리포트가 일치합니다. 레이블은 쉬운 말로 바꿨습니다. `switched_to`는 받을 수 있는 가장 유용한 자유 입력 답변입니다. 사람들이 옮겨 가는 도구 목록이 곧 경쟁 분석입니다.

| 필드 | 유입 경로 | 이유 |
| --- | --- | --- |
| `reason` | 고객 | 선택지 하나, Stripe와 같은 ID |
| `details` | 고객 | 선택. 진짜 사정을 알 수 있는 유일한 곳인 경우가 많습니다 |
| `switched_to` | 고객 | 이유가 `switched_service`일 때만 저장됩니다 |
| `account_id`, `plan` | 내 세션 | 답장하기 전에 사용량을 조회합니다 |
| `months_subscribed` | 내 세션 | 초기 이탈과 후기 이탈은 원인이 다릅니다 |

## 해지 액션

두 버전 모두 짧은 이유 목록을 공유하므로 폼과 서버가 유효한 값에 대해 일치합니다.

`reasons.ts`:

```ts
export const REASONS = [
  ["too_expensive", "It's too expensive"],
  ["missing_features", "It's missing something I need"],
  ["switched_service", "I found an alternative"],
  ["unused", "I don't use it enough"],
  ["too_complex", "It was too hard to use"],
  ["low_quality", "It didn't work well"],
  ["customer_service", "Support didn't help"],
  ["other", "Something else"],
] as const;

export type Reason = (typeof REASONS)[number][0];

export function readReason(value: FormDataEntryValue | null): Reason | null {
  return REASONS.find(([id]) => id === value)?.[0] ?? null;
}
```

**Next.js**

서버 액션은 먼저 해지한 뒤, 응답이 끝나면 실행되는 `after()`로 Ikiform POST를 예약합니다. 그 때문에 리디렉션이 늦어지지 않고, 실패한 POST는 무시됩니다. **Skip and cancel**은 `formNoValidate`를 쓰므로 필수 라디오가 막지 않습니다.

`app/billing/cancel/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { after } from "next/server";
import Stripe from "stripe";
import { requireSession } from "@/lib/session";
import { readReason } from "./reasons";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function cancelPlan(formData: FormData) {
  const session = await requireSession();
  const skipped = formData.get("intent") === "skip";
  const reason = skipped ? null : readReason(formData.get("reason"));
  const details = skipped ? "" : String(formData.get("details") ?? "").slice(0, 2000);

  await stripe.subscriptions.update(session.subscriptionId, {
    cancel_at_period_end: true,
    ...(reason ? { cancellation_details: { feedback: reason, comment: details || undefined } } : {}),
  });

  if (reason) {
    const origin = (await headers()).get("origin");
    after(async () => {
      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({
          reason,
          details,
          switched_to: reason === "switched_service" ? String(formData.get("switched_to") ?? "") : "",
          account_id: session.accountId,
          plan: session.plan,
          months_subscribed: Math.floor((Date.now() - session.subscribedAt.getTime()) / 2_629_800_000),
        }),
      }).catch(() => undefined);
    });
  }

  redirect("/billing/cancelled");
}
```

`app/billing/cancel/page.tsx`:

```tsx
import { cancelPlan } from "./actions";
import { REASONS } from "./reasons";

export default function CancelPage() {
  return (
    <form action={cancelPlan}>
      <h1>Cancel your plan</h1>
      <p>Your plan stays active until the end of the billing period. One question first, if you have a moment.</p>
      <fieldset>
        <legend>What's the main reason you're cancelling?</legend>
        {REASONS.map(([value, label]) => (
          <label key={value}>
            <input type="radio" name="reason" value={value} required /> {label}
          </label>
        ))}
      </fieldset>
      <label htmlFor="switched_to">If you're moving to another tool, which one?</label>
      <input id="switched_to" name="switched_to" maxLength={120} />
      <label htmlFor="details">Anything you'd like us to know?</label>
      <textarea id="details" name="details" rows={4} maxLength={2000} />
      <button name="intent" value="submit">Cancel my plan</button>
      <button name="intent" value="skip" formNoValidate>Skip and cancel</button>
    </form>
  );
}
```

**SvelteKit**

SvelteKit의 폼 액션에는 `after()`가 없으므로 POST에 3초짜리 `AbortSignal.timeout`을 걸고 오류는 무시합니다. 그때는 이미 해지가 끝났습니다. 폼 마크업은 Next.js 페이지와 같고 `method="POST"`를 씁니다. `reasons.ts`는 `src/lib`에 두고, `$app/env/private`가 읽을 수 있도록 두 키를 `src/env.ts`에서 `defineEnvVars`로 선언하세요.

`src/routes/billing/cancel/+page.server.ts`:

```ts
import { redirect } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY, STRIPE_SECRET_KEY } from "$app/env/private";
import Stripe from "stripe";
import { readReason } from "#lib/reasons.js";
import type { Actions } from "./$types";

const stripe = new Stripe(STRIPE_SECRET_KEY);

export const actions = {
  default: async ({ request, locals, fetch, url }) => {
    const session = locals.session;
    if (!session) redirect(303, "/sign-in");
    const form = await request.formData();
    const reason = form.get("intent") === "skip" ? null : readReason(form.get("reason"));
    const details = reason ? String(form.get("details") ?? "").slice(0, 2000) : "";

    await stripe.subscriptions.update(session.subscriptionId, {
      cancel_at_period_end: true,
      ...(reason ? { cancellation_details: { feedback: reason, comment: details || undefined } } : {}),
    });

    if (reason) {
      await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
        method: "POST",
        signal: AbortSignal.timeout(3000),
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
          Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
          Origin: request.headers.get("origin") ?? url.origin,
        },
        body: JSON.stringify({
          reason,
          details,
          switched_to: reason === "switched_service" ? String(form.get("switched_to") ?? "") : "",
          account_id: session.accountId,
          plan: session.plan,
          months_subscribed: session.monthsSubscribed,
        }),
      }).catch(() => undefined);
    }

    redirect(303, "/billing/cancelled");
  },
} satisfies Actions;
```

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

자체 페이지 대신 Stripe의 호스팅 고객 포털로 해지를 처리한다면 포털이 직접 이유를 물을 수 있고, 구독에서 그 값을 읽으면 됩니다. 이 페이지는 해지 버튼이 자체 앱에 있는 팀을 위한 것입니다.

## 답변에 따라 행동하기

각 답변을 이탈 채널에 올리는 웹훅을 추가하세요. 필러의 [알림 릴레이](https://ikiform.com/ko/guides/feedback-form.md#routing)에서 조건만 바꾸면 됩니다. 처음 몇 달은 모든 답변을 읽으세요. 그다음 CSV 내보내기에서 플랜과 사용 기간별로 이유를 보세요. 첫 달 고객의 "너무 비쌈"은 요금 페이지 문제이고, 2년 뒤의 같은 답은 대개 예산 삭감입니다.

자세히 써 준 사람에게는 첫 메시지에 할인을 넣지 말고 내 받은편지함에서 답장하세요. "감사합니다, 도움이 됩니다, 이렇게 개선하고 있습니다" 같은 짧은 답장은 설문으로는 절대 못 받을 답을 이끌어 냅니다. Ikiform이 고객에게 직접 이메일을 보내지는 않습니다.

## 작동 확인하기

1. 테스트 구독에서 이유를 고르고 해지하세요. 확인 페이지가 나타나고, Stripe 구독에 같은 feedback 값과 함께 `cancel_at_period_end`가 표시됩니다.
2. **Submissions**를 여세요. 답변에 서버가 채운 `account_id`, `plan`, `months_subscribed`가 있습니다.
3. 다른 테스트 구독을 **Skip and cancel**로 해지하세요. Stripe에는 해지가 표시되고 Ikiform에는 새 항목이 없습니다.
4. `IKIFORM_SECRET_KEY`를 틀린 값으로 바꾸고 다시 해지하세요. 해지는 여전히 성공하고 고객은 오류를 보지 않습니다.

## 주의할 점

- 한 화면으로 끝내세요. 단계가 늘어날 때마다 답변을 잃고 호감도 잃습니다.
- 이유를 해지의 필수 조건으로 만들지 마세요. 폼 정의에서 필수인 것은 답한 설문이 완전하도록 하기 위해서일 뿐이며, 건너뛰면 아무것도 보내지 않습니다.
- 해지가 얼마나 쉬워야 하는지 규제하는 지역도 있습니다. 흐름은 가입만큼 짧게 유지하고, 판매하는 지역의 규정을 확인하세요.
- `details`는 신뢰할 수 없는 텍스트로 다루세요. 화가 난 고객이 입력한 내용이며 채팅 채널로 들어갑니다.

## 문제 해결

| 문제 | 해결 방법 |
| --- | --- |
| 해지는 되는데 Ikiform에 답변이 없음 | POST는 설계상 조용히 실패합니다. 서버 로그에서 요청을 확인하고, 시크릿 키가 틀렸을 때의 `403 forbidden`이나 필드가 맞지 않을 때의 `422`를 찾아보세요. |
| `422 validation_failed` | POST가 폼의 필드 정의와 맞지 않습니다. `error.fields`에 각 필드와 이유가 나옵니다. 입력의 `name`을 필드 ID와 맞추세요. |
| `402 plan_required` | 워크스페이스에 플랜이 없는데 POST가 실제 사이트에서 왔습니다. 잃어버린 것이 아니라 보류된 것입니다. **Settings › Plan and usage**에서 플랜을 고르거나 팩을 구매하면 보류된 제출이 들어옵니다. |

## 질문

### 해지 설문에서는 무엇을 물어야 하나요?

이유 하나 고르기, 선택 입력인 코멘트, 그리고 대안을 찾았다면 어떤 도구로 옮기는지입니다. 설문 전체를 건너뛸 수 있게 하고, 플랜과 사용 기간은 묻지 말고 내 데이터에서 가져오세요.

### 해지 설문을 필수로 해야 하나요?

아니요. 제출 버튼 옆에 건너뛰고 해지 버튼을 두고, 어느 쪽이든 해지하세요. 떠나려면 반드시 답해야 하는 설문은 더 나쁜 답변과 반감을 모읍니다.

### 해지 이유를 Stripe에도 저장할 수 있나요?

네. Stripe 구독은 feedback 값과 comment가 담긴 cancellation_details를 받습니다. Stripe의 feedback ID를 옵션 값으로 쓰면 두 시스템이 맞춰집니다.

### 고객이 해지할 때 Ikiform이 다운되어 있으면 어떻게 되나요?

고객에게는 아무것도 달라지지 않습니다. 구독이 먼저 해지되고, 설문 POST는 응답 후에 또는 짧은 타임아웃과 함께 실행되며 오류를 무시합니다.

## 가이드 더 보기

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