# SvelteKit으로 대기자 명단 만들기

> SvelteKit 3 대기자 명단: 훅이 첫 ?ref=를 쿠키에 보관하고, 이름 있는 폼 액션이 가입을 전달하며, 재방문자는 이미 가입했음을 확인합니다.

2026-10-25 게시 · https://ikiform.com/ko/guides/waitlist/sveltekit

방문자의 첫 요청에서 `hooks.server.ts`로 `?ref=`를 읽어 쿠키에 보관하면, 방문자가 여기저기 둘러본 뒤에도 가입이 처음 데려온 링크의 공으로 기록됩니다. 이름 있는 폼 액션이 쿠키를 읽어 시크릿 키로 가입을 폼 백엔드에 전달하고, 두 번째 쿠키를 설정해 재방문자에게 다시 묻는 대신 인사를 건넵니다.

[메인 가이드](https://ikiform.com/ko/guides/waitlist.md#form)에는 기본 액션과 `use:enhance`를 쓰고 `ref`를 `homepage`로 고정한 SvelteKit 탭이 있습니다. 이 페이지는 실제 출시에 필요한 것을 더합니다. 방문자는 뉴스레터를 보고 블로그 글로 들어와 한동안 읽다가 두 페이지 뒤에 가입합니다. 그 가입은 이메일을 입력한 페이지가 아니라 뉴스레터의 공으로 기록되어야 합니다.

## SvelteKit에서 다른 점

SvelteKit의 두 기능이 여기에 잘 맞고, 규칙 하나가 구성을 결정합니다.

| 기능 | 여기서 하는 일 |
| --- | --- |
| `hooks.server.ts`의 `handle` | 서버 렌더링 요청마다 먼저 실행되므로, 방문자가 어느 페이지로 들어오든 `?ref=`를 볼 수 있음 |
| `event.cookies` | 첫 유입 경로를 30일 동안 저장. 액션이 다시 읽으며, 조작할 숨겨진 입력이 없음 |
| `use:enhance`를 쓰는 폼 액션 | 서버 코드 하나로 JavaScript 없이 POST하고, 로드되면 기능을 개선 |
| 규칙 | 액션이 있는 페이지는 서버가 `POST`에 응답해야 하므로 프리렌더링할 수 없음 |

숨겨진 입력으로는 할 수 없습니다. 폼이 있는 페이지만 알고, URL의 태그는 첫 클릭 후 사라집니다. 서버에서 설정한 쿠키는 모든 페이지에서 유지되고, 방문자가 보거나 수정할 수 없습니다.

이것은 첫 접점 기여 방식입니다. 처음 태그된 링크가 이깁니다. 나중에 다른 링크로 방문해도 덮어쓰지 않습니다. 마지막 링크의 공으로 기록하고 싶다면 `!event.cookies.get(...)` 검사를 빼세요.

다이어그램: SvelteKit 방문자의 첫 ?ref=가 가입까지 따라가는 과정

1. 방문자가 ?ref=news가 붙은 링크로 블로그 글에 들어옵니다.
2. handle 훅이 태그를 보고, 아직 waitlist_ref 쿠키가 없으므로 30일짜리 쿠키를 설정합니다.
3. 방문자가 요금제를 읽고 홈 페이지로 돌아옵니다. 두 URL 모두 태그가 없지만 쿠키에는 남아 있습니다.
4. 가입합니다. join 액션이 waitlist_ref를 읽고 ref를 news로 설정해 가입을 Ikiform에 전달합니다.
5. 성공하면 액션이 waitlist_joined를 설정하므로, 다음 방문 때 홈 페이지가 폼 대신 감사 문구를 보여 줍니다.

유입 경로는 폼이 아니라 httpOnly 쿠키에 있으므로 페이지를 이동해도 유지되고 페이지에서 수정할 수 없습니다.

## 프로젝트 설정하기

[SvelteKit 문서 가이드](https://ikiform.com/ko/docs/guides/sveltekit.md)처럼 폼과 시크릿 키를 만드세요. 이 페이지는 SvelteKit 3을 사용합니다. 파일은 다음과 같습니다.

`프로젝트`:

```text
src/
  env.ts
  hooks.server.ts
  routes/
    +page.server.ts
    +page.svelte
    blog/[slug]/+page.svelte
vite.config.ts
.env
```

### 시크릿 선언하기

SvelteKit 3은 선언된 변수를 쓰도록 `$env/...` 모듈을 지원 중단했습니다. 키를 `src/env.ts`에 선언하세요. 변수는 공개로 표시하지 않는 한 비공개이며, `$app/env/private`는 브라우저 코드에서 가져올 수 없습니다.

`src/env.ts`:

```ts
import { defineEnvVars } from "@sveltejs/kit/env";

export const variables = defineEnvVars({
  IKIFORM_SECRET_KEY: {},
});
```

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

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

액션은 `email`과 `ref`를 보냅니다. 폼의 **Fields** 탭에 필드 정의가 있다면 둘 다 넣으세요.

`필드`:

```json
{
  "version": 1,
  "title": "Waitlist",
  "fields": [
    { "id": "email", "type": "email", "label": "Email", "required": true, "placeholder": "you@company.com" },
    { "id": "ref", "type": "text", "label": "Source", "validation": { "max_length": 40 } }
  ]
}
```

## 만들기

### 유입 경로 기록하기

`src/hooks.server.ts`:

```ts
import type { Handle } from "@sveltejs/kit/hooks";

const SOURCE = /^[a-z0-9_-]{1,40}$/;
const THIRTY_DAYS = 60 * 60 * 24 * 30;

export const handle: Handle = async ({ event, resolve }) => {
  const query = event.url.searchParams;
  const tag = (query.get("ref") ?? query.get("utm_source") ?? "").toLowerCase();

  if (SOURCE.test(tag) && !event.cookies.get("waitlist_ref")) {
    event.cookies.set("waitlist_ref", tag, { maxAge: THIRTY_DAYS, httpOnly: true, sameSite: "lax" });
  }
  return resolve(event);
};
```

- 태그는 소문자로 바뀌고 Ikiform의 `max_length`가 허용하는 같은 패턴과 일치해야 하므로, 조작된 링크가 쓰레기 값을 저장할 수 없습니다.
- `httpOnly`는 페이지 스크립트가 쿠키에 접근하지 못하게 합니다. `sameSite: "lax"`여도 폼 자신의 `POST`에는 쿠키가 전송됩니다.
- SvelteKit 3에서 쿠키의 `path` 기본값은 `/`이므로, 따로 설정하지 않아도 모든 라우트에 전달됩니다.

### 이름 있는 액션

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

```ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions, PageServerLoad } from "./$types";

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const YEAR = 60 * 60 * 24 * 365;

export const load: PageServerLoad = ({ cookies }) => ({
  joined: cookies.get("waitlist_joined") === "yes",
});

export const actions = {
  join: async ({ request, cookies, fetch, url }) => {
    const form = await request.formData();
    const email = String(form.get("email") ?? "").trim();
    if (!EMAIL.test(email)) return fail(400, { email, message: "Enter the email we should send your invite to." });

    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
        Origin: request.headers.get("origin") ?? url.origin,
      },
      body: JSON.stringify({
        email,
        ref: cookies.get("waitlist_ref") ?? "direct",
        _gotcha: String(form.get("_gotcha") ?? ""),
      }),
      signal: AbortSignal.timeout(8000),
    }).catch(() => null);

    if (!response) return fail(504, { email, message: "We couldn't reach the list. Try again." });
    const result = await response.json().catch(() => null);
    if (!result?.ok) {
      return fail(response.status === 429 ? 429 : 400, {
        email,
        message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
      });
    }

    cookies.set("waitlist_joined", "yes", { maxAge: YEAR, httpOnly: true, sameSite: "lax" });
    return { joined: true };
  },
} satisfies Actions;
```

- 이름 있는 액션 `join`을 쓰면 나중에 이 페이지에 다른 액션을 추가할 여지가 생깁니다. SvelteKit은 이름 있는 액션과 기본 액션을 함께 쓰는 것을 허용하지 않습니다.
- Ikiform은 브라우저의 `Origin` 헤더로 무료 `localhost` 테스트 제출을 세는데 액션 자체의 `fetch`는 이 헤더를 보내지 않으므로, 이를 전달합니다.
- `fail()`은 입력한 이메일과 메시지를 반환합니다. SvelteKit 3에서 개선된 제출은 `fail()`에 넘긴 상태 코드를 사용하므로 `page.status`는 `400` 또는 `429`가 됩니다.
- `load`가 `waitlist_joined`를 읽으므로 재방문자는 이미 가입했음을 알 수 있습니다. 브라우저 하나에 대한 배려일 뿐 중복 제거는 아닙니다. 다른 기기에서 다시 가입하면 Ikiform은 여전히 두 번째 가입을 저장합니다.

### 페이지

`src/routes/+page.svelte`:

```svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import type { PageProps } from "./$types";

  let { data, form }: PageProps = $props();
  let pending = $state(false);
</script>

<h1>Acme is almost here</h1>

{#if data.joined || form?.joined}
  <p role="status">You're on the list. We'll email you on launch day.</p>
{:else}
  <form
    method="POST"
    action="?/join"
    use:enhance={() => {
      pending = true;
      return async ({ update }) => {
        await update({ reset: false });
        pending = false;
      };
    }}
  >
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required value={form?.email ?? ""} />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {#if form?.message}
      <p role="alert">{form.message}</p>
    {/if}
    <button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
  </form>
{/if}
```

- `action="?/join"`이 액션 이름을 지정합니다. JavaScript가 없으면 브라우저가 그곳으로 POST하고, SvelteKit이 `form`이 설정된 페이지를 렌더링합니다.
- `use:enhance`는 페이지가 하이드레이션되면 `fetch`로 POST합니다. `update({ reset: false })`는 오류 후에도 이메일을 입력란에 남겨 두므로, 방문자가 다시 입력하지 않고 오타만 고칠 수 있습니다.
- `pending`은 액션이 응답할 때까지 버튼을 비활성화해 느린 휴대폰에서 두 번 POST되는 것을 막습니다.
- `data.joined`는 `load`에서, `form.joined`는 액션에서 옵니다. 둘 다 확인하면 조건 하나로 재방문자와 방금 가입한 사람을 모두 처리합니다.

허니팟은 손대지 않은 채 Ikiform에 도달하므로, 이를 채운 봇은 스팸으로 저장됩니다. 시크릿 키를 쓴 POST는 Ikiform의 사람 확인을 건너뛰므로 **Spam check**를 켜도 여기서는 아무 효과가 없습니다.

## 프리렌더링과 배포

홈 페이지에는 액션이 있으므로 서버에서 렌더링해야 합니다. 블로그 글 같은 콘텐츠는 `export const prerender = true`로 프리렌더링할 수 있지만, 방문자가 프리렌더링된 파일을 열 때는 `handle`이 실행되지 않습니다. 빌드 시점에 한 번 실행되었을 뿐입니다. 프리렌더링된 페이지로 바로 가는 태그 링크는 쿠키를 설정하지 않습니다. 캠페인 링크는 홈 페이지처럼 서버에서 렌더링되는 페이지를 가리키게 하세요.

| 호스트 | 어댑터 | 참고 |
| --- | --- | --- |
| Vercel | `@sveltejs/adapter-vercel` | SvelteKit 3에서는 여기서 `edge` 런타임이 빠졌으므로 액션은 Node 함수에서 실행됨 |
| Netlify | `@sveltejs/adapter-netlify` | **Environment variables**에서 키 설정 |
| Cloudflare | `@sveltejs/adapter-cloudflare` | 어댑터의 피어 의존성인 `wrangler` 4.118 이상이 필요. `wrangler secret put IKIFORM_SECRET_KEY`로 키 설정 |

## 작동 확인하기

1. `vite dev`를 실행하고 `http://localhost:5173/?ref=test`를 연 뒤, 쿼리를 지우고 새로 고치세요. 브라우저 개발자 도구에서 `waitlist_ref` 쿠키가 여전히 `test`입니다.
2. 가입하세요. 새로 고침 없이 감사 문구가 폼을 대신하고, **Submissions**의 가입에는 `ref`가 `test`로 설정되어 있습니다.
3. 페이지를 새로 고치세요. 폼이 아니라 감사 문구가 표시됩니다.
4. 쿠키를 지우고 JavaScript를 끈 뒤 `a@b`를 제출하세요. 오류와 함께 입력란에 주소가 남은 페이지가 돌아옵니다.
5. 허니팟을 채워서 `_gotcha=bot`으로 POST해 보세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다.
6. `vite dev`에서 보낸 POST는 `localhost` 출처를 가지므로 테스트 제출로 집계됩니다. Ikiform에는 무료 플랜이 없습니다. 테스트는 무료입니다. 플랜이 없는 워크스페이스도 `localhost`에서 보낸 테스트 제출을 매달 50건 받습니다. 실제 운영하려면 제출 500건에 월 $6부터인 Pro를 고르거나, 한 번 결제로 제출 10,000건에 $49 또는 50,000건에 $149를 내세요. 크레딧은 만료되지 않습니다. [요금](https://ikiform.com/ko/pricing)을 참고하세요.

## 주의할 점

| 문제 | 해결 방법 |
| --- | --- |
| 액션이 있는 페이지는 프리렌더링할 수 없다며 빌드가 실패함 | 레이아웃이 모든 라우트에 `prerender = true`를 설정합니다. 대기자 명단 페이지의 `+page.server.ts`에 `export const prerender = false`를 설정하세요. |
| `ref`가 항상 `direct`임 | 태그 링크가 프리렌더링된 페이지를 가리켜서 `handle`이 보지 못했거나, 이전 방문에서 이미 쿠키가 설정되었습니다. 개발자 도구에서 쿠키를 확인하세요. |
| 업그레이드 후 `$env/static/private` 경고 | SvelteKit 3은 `$env` 모듈을 지원 중단했습니다. 키를 `src/env.ts`에 선언하고 `$app/env/private`에서 가져오세요. |
| `unknown_field`와 함께 `422` | 필드 정의에 `ref`가 없습니다. 위의 정의처럼 추가하세요. |
| `402 plan_required` | 워크스페이스에 플랜이 없는데 POST가 실제 사이트에서 왔습니다. 잃어버린 것이 아니라 보류된 것입니다. **Settings › Plan and usage**에서 플랜을 고르거나 팩을 구매하면 보류된 제출이 들어옵니다. |

## 질문

### SvelteKit 폼에서 UTM 유입 경로는 어떻게 추적하나요?

첫 요청 때 handle 훅에서 쿼리를 읽어 태그를 httpOnly 쿠키에 저장하고, 폼 액션에서 쿠키를 읽으세요. 유입 경로는 페이지를 이동해도 유지되며 폼에 나타나지 않습니다.

### 폼 액션이 있는 SvelteKit 페이지를 프리렌더링할 수 있나요?

아니요. 액션이 있는 페이지는 POST에 응답할 서버가 필요하므로 SvelteKit이 프리렌더링을 거부합니다. 다른 페이지를 프리렌더링하고 폼 페이지는 서버 렌더링으로 두세요.

### waitlist_joined 쿠키가 중복 가입을 막나요?

그 브라우저에서만 막습니다. Ikiform은 이메일 기준으로 중복을 제거하지 않으므로, 목록이 있는 곳이나 내보낼 때 반복을 지우세요.

### Ikiform은 무료인가요?

아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제 전에 localhost에서 매달 테스트 제출 50건을 보낼 수 있습니다. 실제 운영하려면 제출 500건에 월 $6부터인 Pro를 쓰거나, 한 번 결제로 제출 10,000건에 $49 또는 50,000건에 $149를 낼 수 있으며, 크레딧은 만료되지 않습니다.

## 가이드 더 보기

- [새 SaaS용 대기자 명단](https://ikiform.com/ko/guides/waitlist.md): 내 랜딩 페이지에 출시 전 대기자 명단을 만듭니다. 폼, 스팸 방지, 중복 처리, 확인 이메일, 출시일의 깔끔한 내보내기까지 다룹니다.
- [Next.js 대기자 명단](https://ikiform.com/ko/guides/waitlist/nextjs.md): Next.js App Router 대기자 명단: 시크릿 키로 가입을 전달하는 서버 액션, useActionState, 낙관적 감사 문구, 방문자별 한도를 다룹니다.
- [Astro 대기자 명단](https://ikiform.com/ko/guides/waitlist/astro.md): 정적 랜딩 페이지의 Astro 대기자 명단: Action이 각 가입을 전달하고, 스크립트가 이를 호출하며, 요청 시 렌더링되는 페이지 하나가 JavaScript 없는 POST를 받습니다.
- [Product Hunt 출시 리스트](https://ikiform.com/ko/guides/waitlist/product-hunt.md): Product Hunt 몇 주 전부터 출시 리스트를 모으고, 가입마다 채널 태그를 달고, 태평양 시간 리셋에 맞춰 출시일 이메일을 두 번에 나눠 보냅니다.
- [선별 기능이 있는 베타 신청](https://ikiform.com/ko/guides/waitlist/beta-signup.md): 서버가 강제하는 선별 질문이 있는 베타 신청 폼과, 지원자에게 점수를 매겨 목록에서 기수별 참가자를 고르는 스크립트입니다.
- [모바일 앱 대기자 명단](https://ikiform.com/ko/guides/waitlist/mobile-app.md): iOS나 Android 앱의 출시 전 대기자 명단으로, 사용하는 휴대폰을 묻고 iPhone 테스터는 TestFlight로 보내고 Play 비공개 테스트를 채웁니다.
