Series · Building My Own Newsletter · 07

가입을 확정하는 /api/confirm 라우트 만들기

버튼 클릭을 받아 status를 confirmed로 바꾸고, 토큰을 일회용으로 비우는 확정 처리 라우트

지난 글에서 가입 확정 페이지를 만들었어요. 토큰이 유효한지 검증해서 화면을 보여주는 데까지는 됐는데, 정작 "가입 확정하기" 버튼은 아직 아무 동작도 하지 않습니다. 이 버튼을 눌렀을 때 호출되는 요청과, 그 때 statusconfirmed로 바꾸고 토큰을 일회용으로 비우는 작업을 이번 글에서 만들어보겠습니다.

Step 1. 버튼 클릭을 처리하는 컴포넌트

지난 글의 페이지에서 유효한 토큰일 때 ConfirmForm이라는 클라이언트 컴포넌트를 넣어뒀고, 토큰을 prop으로 넘겨주기만 한 상태였어요. 이 컴포넌트가 실제로 하는 일은 단순합니다. 버튼을 누르면 /api/confirm으로 토큰을 담아 POST를 보내고, 응답에 따라 화면을 바꾸는 거예요.

// components/confirm-form.tsx
"use client";
 
import { useState } from "react";
 
type State =
  | { kind: "idle" }
  | { kind: "submitting" }
  | { kind: "success" }
  | { kind: "error"; message: string };
 
export function ConfirmForm({ token }: { token: string }) {
  const [state, setState] = useState<State>({ kind: "idle" });
 
  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    if (state.kind === "submitting") return;
 
    setState({ kind: "submitting" });
    try {
      const res = await fetch("/api/confirm", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ token }),
      });
      if (!res.ok) {
        setState({ kind: "error", message: "잠시 후 다시 시도해 주세요." });
        return;
      }
      setState({ kind: "success" });
    } catch {
      setState({ kind: "error", message: "잠시 후 다시 시도해 주세요." });
    }
  }
  // ... 렌더 부분 생략
}

상태 관리는 지난 구독 폼과 똑같은 방식이에요. 대기 / 전송 중 / 성공 / 오류 네 가지로 나누고, 전송 중에는 버튼 글자가 "확정하는 중..."으로 나오게 하면서 비활성화해줬습니다. 더블 클릭으로 두 번 보내는 걸 막기 위해서요. 성공하면 버튼이 있던 곳에 "가입이 확정됐어요" 라는 안내 박스가 나오게 했습니다.

여기서 주의할 점이 하나 있습니다. 이 컴포넌트가 토큰 형식이 맞는지, 만료됐는지 같은 검증을 전혀 하지 않는다는 거예요. 클라이언트 쪽 검증은 사용자 입장에서 보기 좋으라고 하는 거지, 보안 경계가 아니거든요. 누구든 브라우저 콘솔이나 curl/api/confirm에 직접 요청을 보낼 수 있으니까요. 그래서 진짜 검증은 전부 다음 단계의 라우트 안에서 합니다.

Step 2. /api/confirm 라우트

라우트가 받은 토큰으로 가입자를 찾아 확정 처리하는 부분입니다. 먼저 토큰 형식 검사와 레이트 리밋부터 보겠습니다.

// app/api/confirm/route.ts
import { createHash } from "node:crypto";
import { and, eq, gt } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { subscribers } from "@/lib/db/schema";
import { checkAndIncrementRateLimit } from "@/lib/rate-limit";
 
const CONFIRM_IP_RATE_LIMIT = 60;
 
function fail(status = 400) {
  return NextResponse.json({ ok: false }, { status });
}
 
export async function POST(req: NextRequest) {
  let body: { token?: string };
  try {
    body = await req.json();
  } catch (err) {
    console.warn("[/api/confirm] body parse failed:", err);
    return fail();
  }
 
  const token = body.token;
  if (!token || !/^[a-f0-9]{64}$/.test(token)) {
    console.warn("[/api/confirm] invalid token format");
    return fail();
  }
 
  const ip =
    req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
  const ipOk = await checkAndIncrementRateLimit(
    `confirm-ip:${ip}`,
    CONFIRM_IP_RATE_LIMIT,
  );
  if (!ipOk) {
    console.warn(`[/api/confirm] IP rate limit exceeded: ${ip}`);
    return fail(429);
  }

토큰 형식 검사는 지난 글의 확정 페이지와 같은 정규식이에요. 64글자 16진수가 아니면 DB 조회 없이 바로 막습니다. 레이트 리밋은 지난 구독 라우트에서 만들어 둔 헬퍼를 그대로 가져다 IP 기준 1시간 60회로 걸었어요. 토큰이 256bit 랜덤이라 임의로 찍어서 맞히는 건 현실적으로 불가능하지만, 외부에 공개된 라우트에는 기본 안전장치를 하나 두는 편이라 같이 넣었습니다. 구독 라우트의 키와 겹치지 않도록 confirm-ip: 접두어를 따로 붙였습니다.

이제 실제 확정 처리입니다.

  const hash = createHash("sha256").update(token).digest("hex");
  const now = new Date();
 
  const updated = await db
    .update(subscribers)
    .set({
      status: "confirmed",
      confirmedAt: now,
      confirmationTokenHash: null,
      confirmationTokenExpiresAt: null,
    })
    .where(
      and(
        eq(subscribers.confirmationTokenHash, hash),
        eq(subscribers.status, "pending"),
        gt(subscribers.confirmationTokenExpiresAt, now),
      ),
    )
    .returning({ id: subscribers.id });
 
  if (updated.length === 0) {
    console.warn("[/api/confirm] no matching pending row for token");
    return fail();
  }
 
  return NextResponse.json({ ok: true });
}

지난 글에서 확정 페이지가 이미 토큰을 검증했는데 여기서 왜 또 하나 싶을 수 있어요. 두 가지 이유가 있는데요.

하나는 방금 말한 것처럼 POST는 페이지를 거치지 않고 직접 호출될 수 있다는 점이에요. 페이지가 통과시킨 토큰만 여기 도착한다고 믿으면 안 됩니다.

다른 하나는 좀 더 미묘한데요. 보통 떠올리는 방식은 "DB에서 토큰으로 조회해서(SELECT) 유효하면 status를 바꾼다(UPDATE)"는 두 단계예요. 그런데 이 사이에 아주 짧은 틈이 있어요. 사용자가 버튼을 빠르게 두 번 누르거나, 메일 앱의 자동 요청과 사용자 클릭이 겹치면, 두 요청이 거의 동시에 SELECT를 통과해 둘 다 UPDATE를 시도하는 상황이 생길 수 있습니다. 지난 글에서 레이트 리밋을 얘기할 때 나온 race condition과 같은 종류예요.

그래서 검증 조건을 전부 UPDATE ... WHERE에 넣었습니다. "해시가 일치하고, 아직 pending이고, 만료되지 않은 row를 confirmed로 바꿔라"를 데이터베이스가 한 번에 처리하게 한 거예요. 조건에 맞는 row가 있으면 바꾸고, 없으면 아무것도 안 바뀝니다. 동시에 두 요청이 들어와도 실제로 row를 바꾼 건 하나뿐이고, returning으로 돌려받은 결과가 비어 있는 쪽은 "바꿀 게 없었다"는 걸 알게 되죠. updated.length === 0이면 실패로 처리하는 게 이 부분입니다.

그리고 확정과 동시에 confirmation_token_hashconfirmation_token_expires_atnull로 비웁니다. 이게 토큰을 일회용으로 만드는 부분이에요. 한 번 확정에 쓰인 토큰은 DB에서 사라지니까, 같은 링크를 다시 눌러도 해시가 일치하는 row가 없어서 지난 글의 "이 링크는 사용할 수 없어요" 화면으로 떨어집니다. 지난 글에서 already 분기를 만들면서 "이 경로로 들어오는 일은 거의 없다"고 했던 게 바로 이것 때문이에요. 정상적으로 확정한 토큰은 여기서 비워지니, 재클릭은 already가 아니라 invalid로 흡수됩니다.

Step 3. 로컬에서 한 사이클 돌려보기

이제 가입부터 확정까지 한 사이클을 직접 돌려보겠습니다. 폼에서 새 이메일로 가입하면 메일이 오고, 그 안의 링크로 확정 페이지에 들어가 버튼을 누르기 직전의 DB 상태는 이렇습니다.

 id │ email             │ status  │ token_hash │ confirmed_at │ token_expires
  5 │ …+conf1@gmail.com │ pending │ 9ebc75d0…  │ NULL         │ 2026-06-05 03:40:11

statuspending이고, 해시가 들어 있고, 만료 시각은 발급 24시간 뒤로 잡혀 있어요. confirmed_at은 아직 비어 있습니다. 여기서 "가입 확정하기" 버튼을 누르면 화면이 안내 박스로 바뀝니다.

가입을 확정해 주세요 페이지에서 버튼을 누른 뒤, 가입이 확정됐어요 안내 박스로 바뀐 모습

같은 row를 다시 보면 네 가지가 한 번에 바뀌어 있어요.

 id │ email             │ status    │ token_hash │ confirmed_at            │ token_expires
  5 │ …+conf1@gmail.com │ confirmed │ NULL       │ 2026-06-04 03:43:43.686 │ NULL

statusconfirmed로, confirmed_at에 방금 시각이 찍혔고, 해시와 만료 시각은 둘 다 NULL로 비워졌습니다. 위에서 짠 UPDATE 한 번이 의도한 그대로 동작한 거예요. 이 상태에서 같은 링크를 새 탭에서 다시 열면, 해시가 일치하는 row가 없어서 "이 링크는 사용할 수 없어요"로 떨어지는 것까지 확인했습니다.

production에 배포한 뒤에도 같은 사이클을 한 번 더 돌려봤어요. 로컬과 production이 같은 Neon DB를 보고 있어서, 확정까지 마친 뒤엔 테스트로 만든 row를 Drizzle Studio에서 정리했습니다.

마치며

여기까지 해서 이중 옵트인 전체 과정이 마무리됐어요. 이메일을 입력하면 확인 메일이 오고, 그 링크로 들어가 버튼을 누르면 진짜로 confirmed 상태가 되고, 쓰인 토큰은 그 자리에서 사라집니다. 이제 DB에는 "본인이 맞다고 한 번 더 확인해 준" 구독자만 confirmed로 쌓이게 됐어요.

다음은 이렇게 모인 구독자들에게 실제로 글을 보내는 발송 쪽이에요. 그 전에 만료된 채로 남은 pending row를 주기적으로 정리하는 과정 먼저 살펴보려고 합니다. 그 이야기는 다음 글에서 이어가볼게요.