Next.js(App Router)에서 카카오톡 인증번호 로그인 구현하기

K-OTP 팀
2026. 10. 25.

이 글에서는 Next.js App Router 앱에 휴대폰 인증번호 로그인을 붙입니다. 사용자가 휴대폰 번호를 입력하면 카카오톡으로 6자리 인증번호가 오고, 그 번호를 입력하면 로그인됩니다. 시크릿 키는 서버에만 두고 Server Actions에서 K-OTP를 호출하므로, 브라우저에는 키가 노출되지 않고 서버가 검증 결과를 그대로 믿을 수 있습니다.

전 과정을 테스트 키로 만들고 확인할 수 있습니다. 테스트 키는 메시지를 보내지 않고 크레딧도 쓰지 않습니다.

준비물

  • App Router를 쓰는 Next.js 15 이상 앱 (예제 코드는 Next.js 16 기준으로 타입 검사했습니다)
  • K-OTP 계정. 콘솔에서 앱을 열고 API 키의 테스트 탭에서 테스트 키를 만듭니다. 이 글에서는 sk_test_ 시크릿 키를 씁니다.
  • @k-otp/sdk 2.1.0과 server-only:
npm install @k-otp/sdk server-only

키는 .env.local에 둡니다. NEXT_PUBLIC_ 접두사가 없으므로 서버에만 남습니다.

K_OTP_SECRET_KEY=sk_test_...

흐름

  1. sendCode(phoneNumber)가 용도("login")와 멱등키를 담아 issue를 호출합니다. K-OTP가 인증번호를 만들어 카카오 알림톡으로 전달합니다. 알림톡으로 전달하지 못하면 같은 인증번호를 문자로 자동 대체 발송하며, 추가 비용은 없습니다.
  2. 서버는 진행 중인 로그인 정보(휴대폰 번호, 멱등키, issue가 돌려준 issueId)를 저장합니다.
  3. verifyCode(code)가 그 issueId로 verify를 호출합니다. 틀린 인증번호는 예외가 아니라 정상 응답(verified: false와 reasonCode)입니다. 기본값은 유효시간 3분, 시도 5회입니다.

1. 서버 클라이언트

// lib/otp.ts
import "server-only";

import { createOtpServerClient } from "@k-otp/sdk/server";

// 키는 요청할 때 읽습니다. 환경 변수가 없으면 빌드가 아니라 그 요청이 실패합니다.
export const otp = createOtpServerClient({
	apiKey: () => process.env.K_OTP_SECRET_KEY ?? "",
});

createOtpServerClient는 sk_ 키만 받습니다. 서버 코드에서만 가져오세요. 클라이언트 컴포넌트가 이 파일을 가져오면 server-only 때문에 빌드가 실패합니다.

2. 진행 중인 로그인을 어디에 둘까

검증 결과는 인증번호를 보낸 휴대폰 번호와 묶여 있어야 의미가 있습니다. 이 연결은 브라우저가 고칠 수 있는 쿠키가 아니라 서버에 두세요. 예제는 세션 ID를 키로 하는 메모리 맵을 쓰지만, 실제 서비스에서는 세션 저장소나 데이터베이스로 바꾸세요. Map은 재시작하면 사라지고 여러 인스턴스가 공유하지 못합니다.

// lib/pending-otp.ts
import "server-only";

export type PendingOtp = {
	phoneNumber: string;
	/** issue를 호출하기 전에 저장하고, 같은 발송을 재시도할 때 다시 씁니다. */
	idempotencyKey: string;
	/** issue가 성공하면 채웁니다. */
	issueId?: string;
	/** 마지막으로 보낸 시각(epoch ms). 재발송 대기 시간에 씁니다. */
	sentAt?: number;
};

// 예제용: 한 프로세스 안에서만 유지되고 재시작하면 사라집니다. 세션 저장소나 DB를 쓰세요.
const store = new Map<string, PendingOtp>();

export const pendingOtp = {
	get: async (sessionId: string) => store.get(sessionId),
	set: async (sessionId: string, value: PendingOtp) => {
		store.set(sessionId, value);
	},
	delete: async (sessionId: string) => {
		store.delete(sessionId);
	},
};

3. Server Actions: 발송과 검증

// app/login/actions.ts
"use server";

import { randomUUID } from "node:crypto";

import { isAmbiguousIssueError } from "@k-otp/sdk";
import { createIdempotencyKey, getIssueRequestErrorCode, isOtpApiError } from "@k-otp/sdk/server";
import { parseOtpPhoneNumber } from "@k-otp/sdk/ui";
import { cookies } from "next/headers";

import { otp } from "@/lib/otp";
import { type PendingOtp, pendingOtp } from "@/lib/pending-otp";
import { signIn } from "@/lib/session"; // 직접 구현하는 세션 코드

const RESEND_COOLDOWN_MS = 30_000;

export type SendCodeResult =
	| { ok: true; expiresAt: string; resendInMs: number }
	| {
			ok: false;
			error: "INVALID_PHONE" | "COOLDOWN" | "RATE_LIMITED" | "RETRY" | "UNAVAILABLE";
			retryInMs?: number;
	  };

export type VerifyCodeResult =
	| { ok: true }
	| {
			ok: false;
			error: "NO_CODE" | "MISMATCH" | "NEW_CODE_NEEDED" | "RATE_LIMITED" | "RETRY";
			attemptsRemaining?: number;
			retryInMs?: number;
	  };

async function getSessionId() {
	const jar = await cookies();
	const existing = jar.get("otp_sid")?.value;
	if (existing) return existing;
	const sessionId = randomUUID();
	jar.set("otp_sid", sessionId, {
		httpOnly: true,
		secure: process.env.NODE_ENV === "production",
		sameSite: "lax",
		path: "/",
	});
	return sessionId;
}

export async function sendCode(input: string): Promise<SendCodeResult> {
	// 국내 휴대폰 번호만: "010-1234-5678" -> "01012345678", 그 밖에는 undefined
	const phoneNumber = parseOtpPhoneNumber(input).value;
	if (!phoneNumber) return { ok: false, error: "INVALID_PHONE" };
	// 어뷰징 방지(CAPTCHA, 사용자·IP별 제한)는 여기에 둡니다.

	const sessionId = await getSessionId();
	const previous = await pendingOtp.get(sessionId);

	if (previous?.sentAt && previous.phoneNumber === phoneNumber) {
		const waitMs = previous.sentAt + RESEND_COOLDOWN_MS - Date.now();
		if (waitMs > 0) return { ok: false, error: "COOLDOWN", retryInMs: waitMs };
	}

	// 응답을 받지 못한 발송(같은 번호, 아직 issueId 없음)을 다시 시도할 때만 키를 재사용합니다.
	// 그 밖에는 새 시도이므로 새 키를 만듭니다.
	const pending: PendingOtp =
		previous && !previous.issueId && previous.phoneNumber === phoneNumber
			? previous
			: { phoneNumber, idempotencyKey: createIdempotencyKey("login") };
	await pendingOtp.set(sessionId, pending); // issue를 호출하기 "전에" 키를 저장

	try {
		const result = await otp.issue({
			phoneNumber: pending.phoneNumber,
			purpose: "login",
			idempotencyKey: pending.idempotencyKey,
		});
		await pendingOtp.set(sessionId, { ...pending, issueId: result.issueId, sentAt: Date.now() });
		return { ok: true, expiresAt: result.expiresAt, resendInMs: RESEND_COOLDOWN_MS };
	} catch (error) {
		if (!isOtpApiError(error)) throw error;

		if (isAmbiguousIssueError(error)) {
			// 이미 발송됐을 수 있습니다. 키를 유지하면 재시도는 다시 보내지 않고 첫 결과를 돌려받습니다.
			if (error.code === "TOO_MANY_REQUESTS") {
				return { ok: false, error: "RATE_LIMITED", retryInMs: error.retryAfterMs ?? 30_000 };
			}
			return { ok: false, error: "RETRY" };
		}

		// 확정된 거절: 아무것도 보내지 않았고, 다음 발송은 새 키로 합니다.
		await pendingOtp.delete(sessionId);
		const requestError = getIssueRequestErrorCode(error); // 예: TEMPLATE_NOT_AVAILABLE
		if (!requestError && error.code === "BAD_REQUEST") return { ok: false, error: "INVALID_PHONE" };
		// PAYMENT_REQUIRED(크레딧 부족), UNAUTHORIZED, FORBIDDEN 등은 사용자가 아니라 서비스 쪽 문제입니다.
		console.error("K-OTP issue failed", requestError ?? error.code, error.requestId);
		return { ok: false, error: "UNAVAILABLE" };
	}
}

export async function verifyCode(code: string): Promise<VerifyCodeResult> {
	const sessionId = await getSessionId();
	const pending = await pendingOtp.get(sessionId);
	if (!pending?.issueId) return { ok: false, error: "NO_CODE" };
	if (!/^\d{6}$/.test(code)) return { ok: false, error: "MISMATCH" };

	try {
		const result = await otp.verify({ issueId: pending.issueId, code });
		// ALREADY_VERIFIED: 이 세션의 이전 검증이 성공했지만 응답을 받지 못한 경우
		if (result.verified || result.reasonCode === "ALREADY_VERIFIED") {
			await pendingOtp.delete(sessionId);
			await signIn(pending.phoneNumber);
			return { ok: true };
		}
		if (result.reasonCode === "MISMATCH") {
			return { ok: false, error: "MISMATCH", attemptsRemaining: result.attemptsRemaining };
		}
		return { ok: false, error: "NEW_CODE_NEEDED" }; // EXPIRED, MAX_ATTEMPTS, REPLACED, NOT_FOUND
	} catch (error) {
		if (!isOtpApiError(error)) throw error;
		if (error.code === "TOO_MANY_REQUESTS") {
			return { ok: false, error: "RATE_LIMITED", retryInMs: error.retryAfterMs ?? 30_000 };
		}
		console.error("K-OTP verify failed", error.code, error.requestId);
		return { ok: false, error: "RETRY" }; // verify는 재시도해도 안전합니다
	}
}

signIn은 직접 구현하는 세션 코드입니다. 검증된 휴대폰 번호로 로그인 세션을 만드세요.

멱등키 다루기

issue는 실제 메시지를 보내고 크레딧을 쓰므로, SDK는 모든 호출에 멱등키를 요구합니다. sendCode의 규칙은 다음과 같습니다.

  • 발송 시도 하나에 키 하나. createIdempotencyKey("login")으로 만들고, issue를 호출하기 전에 저장합니다.
  • 결과를 모르면 같은 키. 타임아웃, 네트워크 오류, 503, 429 뒤에는 인증번호가 이미 발송 중일 수 있습니다. isAmbiguousIssueError(error)가 바로 이 경우에 true를 돌려줍니다. 저장된 키를 그대로 두므로 사용자가 버튼을 다시 누르면 K-OTP는 메시지를 다시 보내거나 두 번 차감하지 않고 첫 결과를 돌려줍니다.
  • 결과가 확정되면 새 키. 성공했거나 거절(400, 401, 402, 403, 409)됐다면 다음 발송은 새 시도입니다. 휴대폰 번호를 바꿔도 새 시도입니다. 같은 키로 다른 요청을 보내면 409 CONFLICT로 거절됩니다.

isAmbiguousIssueError는 @k-otp/sdk에서, 서버 클라이언트와 나머지 도우미는 @k-otp/sdk/server에서 가져옵니다.

사용자에게 알려 줄 오류

  • TOO_MANY_REQUESTS(429): 요청 한도에 걸렸습니다. error.retryAfterMs만큼 기다려야 하며, 폼은 이 값으로 카운트다운을 보여 줍니다.
  • BAD_REQUEST(400): 대개 휴대폰 번호 문제입니다. getIssueRequestErrorCode(error)는 요청 자체의 문제(CHANNEL_NOT_SUPPORTED, TEMPLATE_NOT_AVAILABLE, WEB_OTP_NOT_SUPPORTED)를 구분해 줍니다. 이는 사용자 입력이 아니라 코드의 버그입니다.
  • PAYMENT_REQUIRED(402): 조직의 크레딧이 부족합니다. 사용자 잘못이 아니므로 일반적인 안내만 보여 주고 운영자에게 알리세요.

재발송 대기 시간(여기서는 30초)은 서버에서 지키므로, 누군가 액션을 직접 호출해도 유지됩니다. Server Actions는 공개된 엔드포인트입니다. 주석 위치에 어뷰징 방지 장치를 두세요.

4. 폼

폼은 두 액션을 호출하고, 인증번호 입력에는 @k-otp/sdk/ui/react의 OtpCodeInput을 씁니다. 붙여넣기, 키보드 이동, autocomplete="one-time-code"를 지원하는 헤드리스 입력 컴포넌트이며, 값만 다루므로 키가 필요 없습니다.

// app/login/login-form.tsx
"use client";

import { OtpCodeInput } from "@k-otp/sdk/ui/react";
import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";

import { type SendCodeResult, type VerifyCodeResult, sendCode, verifyCode } from "./actions";

type SendError = Extract<SendCodeResult, { ok: false }>["error"];
type VerifyError = Extract<VerifyCodeResult, { ok: false }>["error"];

const SEND_ERRORS: Record<SendError, string> = {
	INVALID_PHONE: "휴대폰 번호를 확인해 주세요.",
	COOLDOWN: "잠시 후 다시 요청해 주세요.",
	RATE_LIMITED: "요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.",
	RETRY: "인증번호 발송을 확인하지 못했습니다. 다시 시도해 주세요.",
	UNAVAILABLE: "지금은 인증번호를 보낼 수 없습니다. 잠시 후 이용해 주세요.",
};

const VERIFY_ERRORS: Record<VerifyError, string> = {
	NO_CODE: "먼저 인증번호를 요청해 주세요.",
	MISMATCH: "인증번호가 맞지 않습니다.",
	NEW_CODE_NEEDED: "더 이상 쓸 수 없는 인증번호입니다. 새로 요청해 주세요.",
	RATE_LIMITED: "시도가 너무 많습니다. 잠시 후 다시 시도해 주세요.",
	RETRY: "문제가 발생했습니다. 다시 시도해 주세요.",
};

export function LoginForm() {
	const router = useRouter();
	const [phoneNumber, setPhoneNumber] = useState("");
	const [code, setCode] = useState("");
	const [codeSent, setCodeSent] = useState(false);
	const [message, setMessage] = useState("");
	const [waitUntil, setWaitUntil] = useState(0);
	const [now, setNow] = useState(() => Date.now());
	const [isPending, startTransition] = useTransition();

	// 대기 시간이 남아 있는 동안 1초마다 갱신합니다.
	useEffect(() => {
		if (waitUntil <= now) return;
		const timer = setTimeout(() => setNow(Date.now()), 1000);
		return () => clearTimeout(timer);
	}, [waitUntil, now]);
	const waitSeconds = Math.max(0, Math.ceil((waitUntil - now) / 1000));

	const send = () =>
		startTransition(async () => {
			const result = await sendCode(phoneNumber);
			setNow(Date.now());
			if (result.ok) {
				setCodeSent(true);
				setCode("");
				setWaitUntil(Date.now() + result.resendInMs);
				setMessage("카카오톡으로 인증번호를 보냈습니다.");
				return;
			}
			if (result.retryInMs) setWaitUntil(Date.now() + result.retryInMs);
			setMessage(SEND_ERRORS[result.error]);
		});

	const verify = (value: string) =>
		startTransition(async () => {
			const result = await verifyCode(value);
			if (result.ok) {
				router.push("/");
				return;
			}
			setCode("");
			if (result.retryInMs) {
				setNow(Date.now());
				setWaitUntil(Date.now() + result.retryInMs);
			}
			const left = result.attemptsRemaining;
			setMessage(`${VERIFY_ERRORS[result.error]}${left ? ` (남은 시도 ${left}회)` : ""}`);
		});

	return (
		<form onSubmit={(event) => event.preventDefault()}>
			<label htmlFor="phone">휴대폰 번호</label>
			<input
				id="phone"
				type="tel"
				autoComplete="tel"
				value={phoneNumber}
				onChange={(event) => setPhoneNumber(event.target.value)}
			/>
			<button type="button" onClick={send} disabled={isPending || waitSeconds > 0}>
				{waitSeconds > 0
					? `${waitSeconds}초 후 재발송`
					: codeSent
						? "인증번호 재발송"
						: "인증번호 받기"}
			</button>

			{codeSent && (
				<OtpCodeInput
					aria-label="인증번호"
					locale="ko"
					value={code}
					onValueChange={setCode}
					onComplete={verify}
					readOnly={isPending}
					autoFocus
				/>
			)}

			<p role="status">{message}</p>
		</form>
	);
}
// app/login/page.tsx
import { LoginForm } from "./login-form";

export default function LoginPage() {
	return (
		<main>
			<h1>로그인</h1>
			<LoginForm />
		</main>
	);
}

컴포넌트에는 스타일이 없습니다. 기본 테마를 쓰려면 @k-otp/sdk/ui/theme.css를 가져오고, 직접 꾸미려면 data-k-otp 속성에 스타일을 거세요. UI 컴포넌트를 참고하세요.

테스트 모드로 확인하기

sk_test_ 키로는 아무것도 발송되지 않고 크레딧도 쓰이지 않습니다. 테스트 번호만 받으며, 번호의 끝 두 자리로 시나리오가 정해지고, 테스트 인증번호는 항상 000000입니다.

  • 010-0000-0000: 인증번호가 도달한 것으로 처리되고, 000000을 입력하면 로그인됩니다.
  • 010-0000-0005: 모든 인증번호가 틀린 것으로 처리되어 남은 시도 횟수가 줄어드는 것을 볼 수 있습니다.
  • 010-0000-0006: 429 응답이 오고, 버튼에 60초 카운트다운이 표시됩니다.
  • 010-0000-0007: 402 응답이 오고, 사용자에게는 일반 안내 문구가, 서버 로그에는 오류가 남습니다.
  • 010-0000-0009: 503 응답이 오고, "발송을 확인하지 못했습니다"가 표시됩니다. 다시 누르면 새로 보내지 않고 같은 키로 재시도합니다.

테스트 키에 실제 번호를 넣으면 400(TEST_NUMBER_REQUIRED)으로 거절되고, 폼에는 "휴대폰 번호를 확인해 주세요"가 표시됩니다. 콘솔의 테스트 수신함에서 테스트 발급 내역을 볼 수 있습니다. 전체 시나리오는 테스트 모드 가이드에 있습니다.

출시 전 확인할 것

  • 운영 환경에서는 sk_test_ 키를 라이브 sk_ 키로 바꾸세요. 라이브 키는 테스트 번호를 거절합니다.
  • 팀원 휴대폰으로 실제 인증번호를 몇 번 보내 보세요. 라이브 발송은 자동 문자 대체 발송을 포함해 1건에 1 크레딧이고, 검증 호출은 무료입니다.
  • 진행 중인 로그인 정보는 세션 저장소나 데이터베이스로 옮기세요.
  • sendCode 앞에 어뷰징 방지 장치를 두세요.

완성된 폼이 필요하다면

@k-otp/sdk/ui/react의 OtpForm은 휴대폰 번호 입력, 발송과 재발송, 인증번호 입력, 남은 시간, 안내 문구까지 전체 흐름을 그려 주고, pk_ 퍼블릭 키로 브라우저에서 K-OTP를 직접 호출합니다. 설정은 더 빠르지만, 브라우저에서 검증한 결과는 브라우저만 알고 있습니다. 서버가 그 결과를 근거로 로그인시킨다면 서버에서 sk_ 키로 getStatus({ issueId })를 호출해 다시 확인하세요. 위의 서버 중심 흐름에서는 이 단계가 필요 없습니다.

더 읽을거리