# 서버 액션으로 Next.js 대기자 명단 만드는 방법

> Next.js App Router 대기자 명단: 시크릿 키로 가입을 전달하는 서버 액션, useActionState, 낙관적 감사 문구, 방문자별 한도를 다룹니다.

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

이메일을 검사하고, 방문자별 한도를 적용하고, 시크릿 키로 가입을 폼 백엔드에 전달하는 서버 액션을 작성하세요. 오류가 상태로 돌아오도록 `useActionState`로 호출하고, `useOptimistic`으로 감싸서 버튼을 누르는 순간 감사 문구가 보이고 POST가 실패하면 되돌아가게 하세요.

[메인 가이드](https://ikiform.com/ko/guides/waitlist.md#form)에는 짧은 Next.js 탭이 있습니다. 서버 액션과 `useActionState`입니다. 조용한 랜딩 페이지라면 그걸로 충분합니다. 이 페이지는 널리 공유할 대기자 명단을 위해 더 나아갑니다. 즉각적인 감사 문구, 솔직한 오류 메시지, 그리고 서버 측 POST에서는 사라지는 방문자별 한도를 다룹니다.

## 서버 액션이 바꾸는 것

서버 액션은 시크릿 키를 서버에 두고, 무엇이든 앱 밖으로 나가기 전에 이메일을 검사할 수 있게 해 줍니다. 또 모든 POST를 서버의 IP 주소로 옮기는데, 이로 인해 세 가지 영향이 있습니다.

|   | 브라우저에서 Ikiform으로 POST | 시크릿 키를 쓰는 서버 액션 |
| --- | --- | --- |
| Ikiform이 보는 대상 | 각 방문자의 IP | 모든 방문자에 대해 서버의 IP |
| 방문자별 레이트 리밋 | Ikiform의 IP별 한도 | 직접 추가, 아래 참고 |
| 허용 도메인과 사람 확인 | 실행됨 | 키가 내 POST임을 증명하므로 건너뜀 |
| 호출할 수 있는 사람 | 허용 도메인에서 누구나 | 내 페이지로 POST할 수 있는 누구나 |

마지막 행이 중요합니다. Next.js는 서버 액션을 공개 엔드포인트로 취급합니다. 폼뿐 아니라 누구든 조작한 POST로 호출할 수 있습니다. 시크릿 키가 Ikiform의 출처 검사를 건너뛰므로 액션이 유일한 관문이고, 그래서 자체 한도가 필요합니다.

다이어그램: 가입이 Next.js 서버 액션을 거치는 과정

1. 방문자가 버튼을 누릅니다. useOptimistic이 폼을 즉시 감사 문구로 바꿉니다.
2. 서버 액션이 이메일을 검사하고 방문자의 최근 시도 횟수를 IP별로 셉니다.
3. 한도 안이면 시크릿 키로 가입을 Ikiform에 POST합니다.
4. 결과가 액션 상태가 됩니다. 성공하면 감사 문구가 유지되고, 오류가 나면 메시지와 입력한 이메일이 담긴 폼이 돌아옵니다.
5. 한도를 넘으면 아무것도 POST하지 않고 오류 상태가 바로 돌아옵니다.

낙관적 문구는 액션이 실행되는 동안만 유지됩니다. 화면에 남는 것은 액션 상태가 결정합니다.

## 만들기

### 시크릿 키

대시보드에서 **API keys**를 열고 `submissions:write` 스코프가 있는 시크릿 키를 만든 뒤, 서버 전용 변수로 저장하세요. 절대 `NEXT_PUBLIC_` 접두사를 붙이지 마세요.

`.env.local`:

```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`는 서버 전용 환경 변수에 두세요.

### 방문자별 한도

IP당 분당 가입 5회는 사람에게는 넉넉하고 스크립트에게는 쓸모없는 수준입니다. 이 카운터는 서버 메모리에 있습니다. `server-only`는 클라이언트 컴포넌트가 이를 가져오면 빌드가 실패하게 합니다.

`lib/rate-limit.ts`:

```ts
import "server-only";

const WINDOW_MS = 60_000;
const LIMIT = 5;
const hits = new Map<string, number[]>();

export function allow(key: string, now = Date.now()) {
  const recent = (hits.get(key) ?? []).filter((time) => now - time < WINDOW_MS);
  const allowed = recent.length < LIMIT;
  if (allowed) recent.push(now);
  if (hits.size > 10_000) hits.clear();
  hits.set(key, recent);
  return allowed;
}
```

- 메모리는 서버 인스턴스끼리 공유되지 않습니다. 단일 Node 서버나 컨테이너에서는 정확합니다. 서버리스 호스팅에서는 인스턴스마다 자체 카운트를 가지므로 과속 방지턱 정도로 여기고, 트래픽이 늘면 카운터를 Redis 같은 공유 저장소로 옮기세요.
- 호스트가 설정하는 IP 헤더만 신뢰하세요. Vercel은 `x-forwarded-for`를 설정하고, Cloudflare 뒤에서는 `cf-connecting-ip`를 읽으세요. 방문자가 직접 설정할 수 있는 헤더는 한도 역할을 전혀 못 합니다.

### 서버 액션

`app/waitlist/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";
import { allow } from "@/lib/rate-limit";

export interface JoinState {
  status: "idle" | "joined" | "error";
  message: string;
  email?: string;
}

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export async function joinWaitlist(_previous: JoinState, formData: FormData): Promise<JoinState> {
  const email = String(formData.get("email") ?? "").trim();
  if (!EMAIL.test(email)) return { status: "error", message: "Enter the email we should send your invite to.", email };

  const incoming = await headers();
  const visitor = incoming.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
  if (!allow(visitor)) return { status: "error", message: "Too many tries from this connection. Wait a minute.", email };

  let response: Response;
  try {
    response = 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: incoming.get("origin") ?? "",
      },
      body: JSON.stringify({
        email,
        ref: String(formData.get("ref") ?? "homepage").slice(0, 40),
        _gotcha: String(formData.get("_gotcha") ?? ""),
      }),
      signal: AbortSignal.timeout(8000),
    });
  } catch {
    return { status: "error", message: "We couldn't reach the list. Try again.", email };
  }

  if (response.status === 429) {
    const wait = response.headers.get("Retry-After") ?? "60";
    return { status: "error", message: `The list is busy. Try again in ${wait} seconds.`, email };
  }
  const result = await response.json().catch(() => null);
  if (result?.ok) return { status: "joined", message: "You're on the list. We'll email you on launch day." };
  return {
    status: "error",
    message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
    email,
  };
}
```

- 비용이 적은 검사를 먼저 실행합니다. 잘못된 이메일은 요청을 한 번도 쓰지 않습니다.
- `AbortSignal.timeout`은 느린 네트워크 때문에 버튼이 대기 상태에 영원히 머무는 것을 막습니다.
- Ikiform의 `429`는 `Retry-After`와 함께 따로 읽습니다. 모든 방문자가 IP 하나 뒤에 있으므로 바쁜 것은 이 사람이 아니라 폼 전체이기 때문입니다.
- 모든 오류는 이메일을 함께 반환하므로 입력란에 다시 표시할 수 있습니다. React는 액션 후 폼을 초기화하고, `defaultValue`가 주소를 되돌려 놓습니다.
- Ikiform은 브라우저의 `Origin` 헤더로 무료 테스트 제출을 세는데, 서버 자체의 `fetch`는 이 헤더를 보내지 않으므로 전달합니다.
- SDK의 `submit()`은 `Authorization` 헤더를 보내지 않아 허용 도메인 검사를 건너뛸 수 없습니다. 여기서는 키와 함께 `fetch`를 쓰세요.

### 낙관적 감사 문구가 있는 폼

`app/waitlist/waitlist-form.tsx`:

```tsx
"use client";

import { useActionState, useOptimistic } from "react";
import { type JoinState, joinWaitlist } from "./actions";

const initial: JoinState = { status: "idle", message: "" };

export function WaitlistForm({ source }: { source: string }) {
  const [state, dispatch, pending] = useActionState(joinWaitlist, initial);
  const [shown, showJoined] = useOptimistic(
    state,
    (_current, email: string): JoinState => ({ status: "joined", message: `Adding ${email} to the list` }),
  );

  function join(formData: FormData) {
    showJoined(String(formData.get("email") ?? ""));
    dispatch(formData);
  }

  if (shown.status === "joined") {
    return (
      <p role="status" aria-busy={pending}>
        {shown.message}
      </p>
    );
  }

  return (
    <form action={join}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.email} />
      <input type="hidden" name="ref" value={source} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {shown.status === "error" ? <p role="alert">{shown.message}</p> : null}
      <button disabled={pending}>Join the waitlist</button>
    </form>
  );
}
```

- `useOptimistic`은 액션 안에서 설정해야 합니다. `join`이 폼의 액션이므로 React가 이를 트랜지션 안에서 실행하고, 낙관적 문구는 `dispatch`가 끝날 때까지 유지됩니다.
- 성공하면 액션 상태가 `joined`가 되고 문구가 최종 메시지로 바뀌어 유지됩니다. 오류가 나면 낙관적 값이 사라지고, `state.email`과 메시지가 담긴 폼이 돌아옵니다.
- `aria-busy`는 스크린 리더에 문구가 아직 확인 중임을 알립니다.

> 액션을 `join`으로 감싸면 클라이언트 함수가 되므로 이 폼에는 JavaScript가 필요합니다. 즉각적인 감사 문구보다 하이드레이션 전 POST가 더 중요하다면 `dispatch`를 `action`에 바로 넘기고 `useOptimistic`을 빼세요. 나머지 코드는 그대로입니다.

### 페이지

`app/waitlist/page.tsx`:

```tsx
import { WaitlistForm } from "./waitlist-form";

export default async function Page({ searchParams }: PageProps<"/waitlist">) {
  const { ref } = await searchParams;
  const source = typeof ref === "string" ? ref.slice(0, 40) : "homepage";

  return (
    <main>
      <h1>Acme is almost here</h1>
      <p>Leave your email and we'll send you an invite the day it opens.</p>
      <WaitlistForm source={source} />
    </main>
  );
}
```

페이지는 URL에서 `?ref=`를 읽어 폼에 넘기므로, `/waitlist?ref=newsletter` 같은 링크는 가입마다 유입 경로를 태그합니다. `PageProps`는 Next.js가 생성하는 라우트 인식 타입이며, `searchParams`를 읽으면 페이지가 요청마다 렌더링됩니다.

## 작동 확인하기

1. `next dev`를 실행하고 `/waitlist?ref=test`를 열어 가입하세요. 요청이 끝나기 전에 감사 문구가 보입니다.
2. 대시보드에서 **Submissions**를 여세요. `ref`가 `test`로 설정된 가입이 있습니다.
3. 1분 안에 여섯 번 제출하세요. 여섯 번째에는 한도 메시지가 나오고, 제출은 다섯 건만 저장됩니다.
4. `IKIFORM_SECRET_KEY`를 잘못된 값으로 설정하고 제출하세요. 낙관적 문구가 되돌아가고, 폼이 이메일을 그대로 둔 채 오류를 보여 줍니다.
5. 알림 수신자마다 받은편지함을 확인하세요. 각자 필드가 담긴 이메일을 받으며, 폼에 `email` 필드가 있으면 **Reply**(답장)는 제출자에게 갑니다.
6. 허니팟을 채워서 `_gotcha=bot`으로 POST해 보세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다.

## 주의할 점

| 문제 | 해결 방법 |
| --- | --- |
| Ikiform에서 `403 forbidden` | 키가 다른 워크스페이스의 것이거나, 폐기되었거나, `submissions:write`가 없습니다. 그 스코프가 있는 시크릿 키를 만드세요. |
| `429 rate_limited` | 한 방문자가 폼의 레이트 리밋보다 많이 보냈습니다. Standard는 방문자당 분당 5회를 허용합니다. `Retry-After`만큼 기다리거나, 공유 IP 하나에서 POST하는 폼이라면 Relaxed나 Custom을 고르세요. |
| 모든 방문자가 한꺼번에 한도에 걸림 | IP 헤더가 없어서 모두가 `unknown`으로 집계됩니다. Cloudflare 뒤라면 `cf-connecting-ip`처럼 호스트가 설정하는 헤더를 읽으세요. |
| 감사 문구가 잠깐 보였다가 메시지 없이 폼이 돌아옴 | 액션이 상태를 반환하지 않고 예외를 던졌습니다. 예제처럼 모든 실패 경로에서 `JoinState`를 반환하세요. |
| 낙관적 업데이트가 트랜지션 밖에서 일어났다는 경고 | `showJoined`를 이벤트 핸들러에서 호출했습니다. `join`처럼 폼의 액션 안에서 호출하세요. |

## 질문

### Next.js 대기자 명단은 브라우저에서 POST해야 하나요, 서버 액션에서 POST해야 하나요?

둘 다 됩니다. 브라우저 POST는 Ikiform의 방문자별 한도, 허용 도메인, 사람 확인을 유지합니다. 서버 액션은 엔드포인트를 숨기고, 입력을 먼저 검사하고, 하이드레이션 전에도 작동하지만, 모든 POST가 서버에서 오므로 방문자별 한도를 직접 추가해야 합니다.

### useOptimistic은 useActionState와 함께 작동하나요?

네. 폼의 action prop처럼 같은 액션 안에서 낙관적 값을 설정하고 useActionState의 dispatch 함수를 호출하세요. 낙관적 값은 액션이 실행되는 동안 보이고, 끝나면 액션 상태로 바뀝니다.

### Next.js 대기자 명단에 데이터베이스가 필요한가요?

아니요. 서버 액션이 각 가입을 Ikiform으로 전달하면 Ikiform이 저장하고, 스팸을 거르고, 이메일을 보내고, 웹훅을 보냅니다. 앱은 레이트 리밋 카운터 말고는 상태를 갖지 않습니다.

### Ikiform은 무료인가요?

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

## 가이드 더 보기

- [새 SaaS용 대기자 명단](https://ikiform.com/ko/guides/waitlist.md): 내 랜딩 페이지에 출시 전 대기자 명단을 만듭니다. 폼, 스팸 방지, 중복 처리, 확인 이메일, 출시일의 깔끔한 내보내기까지 다룹니다.
- [일반 HTML 대기자 명단](https://ikiform.com/ko/guides/waitlist/html.md): 빌드 없이 일반 HTML로 만든 완전한 대기자 명단 페이지로, JavaScript가 꺼져도 작동하고, 스크립트가 로드되면 인라인 감사 문구를 보여 주며, 필요할 때는 기본 방식으로 돌아갑니다.
- [선별 기능이 있는 베타 신청](https://ikiform.com/ko/guides/waitlist/beta-signup.md): 서버가 강제하는 선별 질문이 있는 베타 신청 폼과, 지원자에게 점수를 매겨 목록에서 기수별 참가자를 고르는 스크립트입니다.
- [Product Hunt 출시 리스트](https://ikiform.com/ko/guides/waitlist/product-hunt.md): Product Hunt 몇 주 전부터 출시 리스트를 모으고, 가입마다 채널 태그를 달고, 태평양 시간 리셋에 맞춰 출시일 이메일을 두 번에 나눠 보냅니다.
- [모바일 앱 대기자 명단](https://ikiform.com/ko/guides/waitlist/mobile-app.md): iOS나 Android 앱의 출시 전 대기자 명단으로, 사용하는 휴대폰을 묻고 iPhone 테스터는 TestFlight로 보내고 Play 비공개 테스트를 채웁니다.
- [Framer 대기자 명단](https://ikiform.com/ko/guides/waitlist/framer.md): Framer에서 대기자 명단 가입을 받는 두 가지 방법: 웹훅으로 Ikiform에 보내는 Framer 폼, 또는 방문자별 한도와 스팸 검사를 유지하는 코드 컴포넌트입니다.
