Series · Building My Own Newsletter · 06

가입 확정 페이지 만들기

토큰만 검증하고 사용자 의도를 따로 받는 가입 확정 페이지 구현

지난 글에서 구독 폼과 /api/subscribe 라우트까지 만들었어요. 이제 누군가 이메일을 입력하면 메일함으로 가입 확정 링크가 도착하는 것까지는 동작합니다. 다만 메일에서 그 링크를 누르면 지금은 404 페이지가 떠요. /confirm/[token] 경로의 페이지를 아직 만들지 않았기 때문이죠.

이번 글에서 이 페이지를 만들어보겠습니다.

Step 1. 메일 링크 한 번에 status를 바꾸지 않기

가장 직관적인 구현은 이런 모양이에요. 메일에 담긴 /confirm/<token>을 사용자가 클릭하면, 그 GET 요청 안에서 토큰을 검증하고 곧바로 statusconfirmed로 바꿔버리는 거죠. 한 번의 클릭으로 끝나서 깔끔해 보입니다.

그런데 여기서 고려해야할 포인트가 있어요. 그 클릭이 "사용자의 클릭"이 아닐 때가 있다는 거예요.

  • Gmail · Outlook 같은 메일 앱은 받은 메일 안의 링크를 자동으로 미리 가져와서 안전 여부를 검사합니다. 사용자가 누르기 전에 서버에 GET이 한 번 들어와요.
  • 일부 메일 보안 솔루션은 모든 외부 링크를 자동으로 한 번씩 따라가 봅니다.
  • 사용자가 메일을 열어 두면 일부 메일 앱은 이미지뿐 아니라 링크 미리 가져오기(prefetch)도 같이 합니다.

이 자동 GET 요청들이 도착했을 때 서버가 곧바로 status='confirmed'로 바꿔버리면, 사용자는 메일을 열어 본 적도 없는데 가입이 끝난 것처럼 처리되는 문제가 있습니다. "이중 옵트인"이라는 표현 자체가 깨지는 거죠. 사용자 의도를 한 번 더 확인하려고 만든 절차인데, 자동으로 가져오는 요청 한 번이 그 절차를 무력화해버리는 꼴이죠.

그래서 GET 요청은 토큰의 유효성만 확인해서 페이지로 보여주고, 실제 status 변경은 사용자가 페이지의 버튼을 직접 눌렀을 때 일어나는 별도 요청으로 처리하려고 합니다. 이렇게 하면, 자동으로 가져오는 요청은 GET까지는 도달할 수 있지만, 그 시점에는 DB에 아무 변화도 생기지 않죠. 사용자가 직접 페이지에서 버튼을 눌러야 진짜로 확정 요청이 발생합니다.

이 글에서는 그 GET 페이지까지를 만들어 보겠습니다. 사용자가 버튼을 눌렀을 때 호출될 요청을 처리하는 부분은 다음 글에서 다룰 거예요.

Step 2. /confirm/[token] 페이지 만들기

Next.js의 App Router(폴더 구조로 라우팅을 표현하는 시스템)에서 동적 경로는 폴더 이름을 대괄호로 감싸 만듭니다. app/confirm/[token]/page.tsx를 두면 /confirm/임의문자열 형태로 들어오는 모든 요청이 이 파일로 처리돼요.

이 페이지가 하는 일은 다음과 같습니다.

  1. URL의 토큰이 발급한 형식(64글자 16진수 문자열)이 맞는지 검사
  2. 같은 방식(SHA-256)으로 해시한 값으로 DB에서 가입자 정보 조회
  3. 정보가 있으면 만료 시각·status를 확인해서 어떤 화면을 보여줄지 결정
  4. 결과적으로 사용자에게 4가지 화면 중 하나를 보여줌: 유효 / 잘못된 링크 / 만료 / 이미 확정됨

이 페이지는 Server Component로 만들었어요. 토큰 검증과 DB 조회를 브라우저 쪽으로 내보낼 이유가 없기 때문이죠.

// app/confirm/[token]/page.tsx
import { createHash } from "node:crypto";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { subscribers } from "@/lib/db/schema";
 
type Outcome =
  | { kind: "valid"; maskedEmail: string }
  | { kind: "invalid" }
  | { kind: "expired" }
  | { kind: "already" };
 
async function checkToken(rawToken: string): Promise<Outcome> {
  if (!/^[a-f0-9]{64}$/.test(rawToken)) {
    return { kind: "invalid" };
  }
 
  const hash = createHash("sha256").update(rawToken).digest("hex");
  const rows = await db
    .select()
    .from(subscribers)
    .where(eq(subscribers.confirmationTokenHash, hash));
 
  if (rows.length === 0) {
    return { kind: "invalid" };
  }
 
  const sub = rows[0];
 
  if (sub.status === "confirmed") {
    return { kind: "already" };
  }
 
  if (
    !sub.confirmationTokenExpiresAt ||
    sub.confirmationTokenExpiresAt.getTime() < Date.now()
  ) {
    return { kind: "expired" };
  }
 
  return { kind: "valid", maskedEmail: maskEmail(sub.email) };
}

위 코드의 분기별 내용은 다음과 같습니다.

  • 형식 검사 (정규식): URL에는 아무 내용이나 입력될 수 있으니, 64글자 16진수 문자열이 아니면 DB 조회조차 안하도록 합니다. 의미 없는 쿼리를 막아주는 1차 방어선이에요.
  • 해시 비교: 사용자가 들고 온 원본 토큰을 그 즉시 SHA-256으로 해시해서 DB의 confirmation_token_hash와 직접 비교합니다.
  • 만료: confirmation_token_expires_at이 지금보다 과거면 만료된 거예요. 발급된 지 24시간 이상 지났다는 뜻입니다.
  • 이미 확정됨: status가 이미 confirmed라는 건, 한 번 가입을 완료한 사용자가 옛 메일의 링크를 다시 누른 경우입니다. 다음 단계에서 토큰을 일회용으로 비울 거라 사실 이 경로로 들어오는 일은 거의 없는데, 혹시 모를 경우를 대비해서 따로 처리했습니다.

이메일 마스킹은 head 첫 글자만 보여주고 나머지를 별표로 가렸습니다. 가입한 사람 본인이 자기 메일에서 넘어온 게 맞는지 확인할 수 있을 정도면 충분하니까요.

function maskEmail(email: string): string {
  const at = email.indexOf("@");
  if (at <= 0) return email;
  const head = email.slice(0, at);
  const domain = email.slice(at);
  const first = head[0] ?? "";
  return `${first}${"*".repeat(Math.max(1, head.length - 1))}${domain}`;
}

페이지 컴포넌트는 위 Outcome을 받아 조건 분기마다 다른 섹션을 화면에 보여줍니다. 네 분기 모두 같은 레이아웃 구조 안에서 헤더 문구와 안내 글만 다르게 했습니다. 유효한 경우엔 페이지 안에 클라이언트 컴포넌트(ConfirmForm)를 넣어둡니다. 이 컴포넌트가 사용자의 버튼 클릭을 받아 다음 글에서 만들 확정 요청을 보냅니다. 일단 이번 글에서는 토큰을 prop(컴포넌트에 전달하는 값)으로 넘겨주기만 할게요.

export default async function ConfirmPage({
  params,
}: {
  params: Promise<{ token: string }>;
}) {
  const { token } = await params;
  const outcome = await checkToken(token);
 
  return (
    <main>
      {outcome.kind === "valid" && (
        <>
          <h1>가입을 확정해 주세요.</h1>
          <p>
            <strong>{outcome.maskedEmail}</strong> 주소로 받으신 메일에서
            넘어오셨다면, 아래 버튼을 눌러 가입을 마무리해 주세요.
          </p>
          <ConfirmForm token={token} />
        </>
      )}
 
      {outcome.kind === "expired" && (
        <>
          <h1>링크가 만료됐어요.</h1>
          <p>확인 메일 링크는 24시간 동안만 유효해요. 다시 가입을 시도해 주세요.</p>
        </>
      )}
 
      {(outcome.kind === "invalid" || outcome.kind === "already") && (
        <>
          <h1>이 링크는 사용할 수 없어요.</h1>
          <p>메일에서 받은 링크가 맞는지 다시 한 번 확인해 주세요.</p>
        </>
      )}
    </main>
  );
}

invalidalready는 사용자에게 보여주는 메시지가 같습니다. 이건 의도한건데요. "이 이메일은 이미 가입된 상태입니다" 같은 구체적인 안내를 주면, 임의 토큰을 넣어보는 식으로 누가 가입했는지 추측할 수 있는 단서가 됩니다. 지난 글의 /api/subscribe 응답을 모든 경우에 같게 둔 것과 같은 이유인거죠.

네 분기가 실제로 어떻게 보이는지 살펴보겠습니다. 먼저 토큰이 유효한 경우입니다.

유효한 토큰으로 접속한 가입 확정 페이지. '가입을 확정해 주세요.' 헤더, 마스킹된 이메일 표시, '가입 확정하기' 버튼

마스킹된 이메일이 본인 주소가 맞으면 사용자가 버튼을 누릅니다. 다만 이 시점까지는 DB에 어떤 변화도 일어나지 않아요. status는 여전히 pending이고, 버튼 클릭 처리는 다음 글에서 다룹니다.

발급된 지 24시간이 지나 만료된 토큰으로 접속해 보겠습니다.

만료된 토큰으로 접속한 화면. '링크가 만료됐어요' 헤더, '확인 메일 링크는 발송 후 24시간 동안만 유효해요' 안내, '다시 가입하기' 링크

다음은 형식이 맞지 않는 토큰입니다. URL의 token 자리에 일부러 zzz처럼 짧은 문자열을 넣어봤습니다.

형식이 맞지 않는 토큰으로 접속한 화면. 주소창에 /confirm/zzz, '이 링크는 사용할 수 없어요' 헤더

정규식에서 막혀서 DB 조회조차 일어나지 않습니다.

마지막은 형식은 맞지만 발급한 적 없는 토큰을 시도해 봤어요. 64글자 16진수 문자열이긴 한데 마지막 한 글자만 다른 값으로 바꿔본 결과입니다.

형식은 맞지만 발급된 적 없는 토큰. 64글자 hex 토큰의 마지막 글자만 다른 값으로 변경된 URL, '이 링크는 사용할 수 없어요'

DB 조회는 일어나지만 해시가 일치하지 않아 유효하지 않은 토큰으로 처리되었습니다.

Step 3. noindex와 Referrer 보호

이 페이지에는 아래 두 가지를 추가로 넣어두는 게 좋습니다.

export const metadata: Metadata = {
  title: "가입 확정",
  description: "뉴스레터 가입을 확정합니다.",
  robots: { index: false, follow: false },
  referrer: "no-referrer",
};

robots: { index: false, follow: false }

이 페이지의 URL에는 사용자의 토큰이 들어있는데요. 검색엔진이 우연히 이 URL을 색인해버리면, 토큰이 검색 결과에 노출될 수 있습니다. 일어날 확률은 낮지만, 막아 두는 것이 어려운 작업은 아니니까요. 보안면에서 추가해 두는 걸 권합니다.

또 하나 넣어두면 좋을 것은..

referrer: "no-referrer"

이 페이지에서 다른 사이트로 이동하는 링크를 클릭할 때, 기본적으로는 referrer 헤더에 현재 URL이 그대로 담겨 갑니다. 그 URL 안에는 토큰이 들어 있고요. no-referrer로 두면 referrer 자체를 보내지 않아서 외부 사이트로 토큰이 노출되지 않습니다.

페이지 소스를 열어서 두 메타가 잘 들어갔는지 확인해 보겠습니다.

브라우저 페이지 소스에 noindex,nofollow와 no-referrer 메타 태그가 박힌 모습

이 두 줄 추가하는게 어렵지 않고, 또 효과는 분명해서, 토큰이 담긴 URL을 다루는 페이지라면 기본적으로 같이 가져가는 게 좋습니다.

마치며

여기까지 해서, 토큰이 유효한지 검증하고 사용자에게 화면을 보여주는 부분까지 만들어졌습니다. "가입 확정하기" 버튼은 아직 동작하지 않는데요. 그 버튼을 누르면 호출할 요청과, 거기서 statusconfirmed로 바꾸고 토큰을 일회용으로 비우는 작업은 다음 글에서 다루겠습니다.