가입 확정 페이지 만들기
토큰만 검증하고 사용자 의도를 따로 받는 가입 확정 페이지 구현
지난 글에서 구독 폼과 /api/subscribe 라우트까지 만들었어요. 이제 누군가 이메일을 입력하면 메일함으로 가입 확정 링크가 도착하는 것까지는 동작합니다. 다만 메일에서 그 링크를 누르면 지금은 404 페이지가 떠요. /confirm/[token] 경로의 페이지를 아직 만들지 않았기 때문이죠.
이번 글에서 이 페이지를 만들어보겠습니다.
Step 1. 메일 링크 한 번에 status를 바꾸지 않기
가장 직관적인 구현은 이런 모양이에요. 메일에 담긴 /confirm/<token>을 사용자가 클릭하면, 그 GET 요청 안에서 토큰을 검증하고 곧바로 status를 confirmed로 바꿔버리는 거죠. 한 번의 클릭으로 끝나서 깔끔해 보입니다.
그런데 여기서 고려해야할 포인트가 있어요. 그 클릭이 "사용자의 클릭"이 아닐 때가 있다는 거예요.
- 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/임의문자열 형태로 들어오는 모든 요청이 이 파일로 처리돼요.
이 페이지가 하는 일은 다음과 같습니다.
- URL의 토큰이 발급한 형식(64글자 16진수 문자열)이 맞는지 검사
- 같은 방식(SHA-256)으로 해시한 값으로 DB에서 가입자 정보 조회
- 정보가 있으면 만료 시각·status를 확인해서 어떤 화면을 보여줄지 결정
- 결과적으로 사용자에게 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>
);
}invalid와 already는 사용자에게 보여주는 메시지가 같습니다. 이건 의도한건데요. "이 이메일은 이미 가입된 상태입니다" 같은 구체적인 안내를 주면, 임의 토큰을 넣어보는 식으로 누가 가입했는지 추측할 수 있는 단서가 됩니다. 지난 글의 /api/subscribe 응답을 모든 경우에 같게 둔 것과 같은 이유인거죠.
네 분기가 실제로 어떻게 보이는지 살펴보겠습니다. 먼저 토큰이 유효한 경우입니다.

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

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

정규식에서 막혀서 DB 조회조차 일어나지 않습니다.
마지막은 형식은 맞지만 발급한 적 없는 토큰을 시도해 봤어요. 64글자 16진수 문자열이긴 한데 마지막 한 글자만 다른 값으로 바꿔본 결과입니다.

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 자체를 보내지 않아서 외부 사이트로 토큰이 노출되지 않습니다.
페이지 소스를 열어서 두 메타가 잘 들어갔는지 확인해 보겠습니다.

이 두 줄 추가하는게 어렵지 않고, 또 효과는 분명해서, 토큰이 담긴 URL을 다루는 페이지라면 기본적으로 같이 가져가는 게 좋습니다.
마치며
여기까지 해서, 토큰이 유효한지 검증하고 사용자에게 화면을 보여주는 부분까지 만들어졌습니다. "가입 확정하기" 버튼은 아직 동작하지 않는데요. 그 버튼을 누르면 호출할 요청과, 거기서 status를 confirmed로 바꾸고 토큰을 일회용으로 비우는 작업은 다음 글에서 다루겠습니다.