비트베이크

Next.js 15 + Better Auth로 SMS 인증 구현하기: 2026 완벽 가이드

2026-03-24T01:03:35.061Z

BETTER-AUTH-SMS

Next.js 15 + Better Auth로 SMS 인증 구현하기: 2026 완벽 가이드

SMS 인증을 붙이려고 했는데, 사업자등록증 제출하라고? 발신번호 사전등록은 또 뭐야?

사이드 프로젝트에 전화번호 인증 하나 붙이려고 일주일을 날릴 수는 없습니다. 이 글에서는 Better Auth의 Phone Number 플러그인과 Next.js 15 App Router를 사용해서, 실제로 동작하는 SMS OTP 인증을 처음부터 끝까지 구현합니다.

이 글에서 배우는 것

  • Better Auth 설치 및 Next.js 15 연동
  • Phone Number 플러그인 설정
  • SMS 발송 API 연결 (커스텀 프로바이더)
  • OTP 전송/검증 UI 구현
  • 보안 베스트 프랙티스

1단계: 프로젝트 셋업

Better Auth 설치

npx create-next-app@latest my-auth-app --app --typescript
cd my-auth-app
npm install better-auth

환경 변수 설정

.env.local 파일을 생성합니다:

BETTER_AUTH_SECRET=your-secret-key-at-least-32-characters-long
BETTER_AUTH_URL=http://localhost:3000
DATABASE_URL=file:./dev.db
SMS_API_KEY=your-sms-api-key

BETTER_AUTH_SECRET은 최소 32자 이상이어야 합니다. openssl rand -base64 32로 생성할 수 있습니다.


2단계: Better Auth 서버 설정

lib/auth.ts에 인증 인스턴스를 생성합니다:

// lib/auth.ts
import { betterAuth } from "better-auth";
import { phoneNumber } from "better-auth/plugins";
import Database from "better-sqlite3";

export const auth = betterAuth({
  database: new Database("./sqlite.db"),
  plugins: [
    phoneNumber({
      otpLength: 6,
      expiresIn: 300, // 5분
      allowedAttempts: 3,
      sendOTP: async ({ phoneNumber, code }, ctx) => {
        // SMS API를 통해 인증번호 발송
        await fetch("https://api.easyauth.kr/v1/send", {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "Authorization": `Bearer ${process.env.SMS_API_KEY}`,
          },
          body: JSON.stringify({
            phoneNumber,
            code,
          }),
        });
        // ⚠️ Better Auth 공식 문서에서는 sendOTP를 await하지 않는 것을 권장합니다
        // 타이밍 공격 방지 및 응답 속도 향상을 위해서입니다
      },
      signUpOnVerification: {
        getTempEmail: (phoneNumber) => `${phoneNumber}@phone.local`,
        getTempName: (phoneNumber) => phoneNumber,
      },
    }),
  ],
});

Next.js Route Handler 연결

// app/api/auth/[...all]/route.ts
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth";

export const { POST, GET } = toNextJsHandler(auth);

DB 마이그레이션

npx auth migrate

이 명령이 user 테이블에 phoneNumberphoneNumberVerified 필드를 자동 추가합니다.


3단계: 클라이언트 설정

// lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
import { phoneNumberClient } from "better-auth/client/plugins";

export const authClient = createAuthClient({
  plugins: [phoneNumberClient()],
});

4단계: SMS 인증 UI 구현

OTP 발송 + 검증 컴포넌트

// app/components/PhoneAuth.tsx
"use client";

import { useState } from "react";
import { authClient } from "@/lib/auth-client";

export default function PhoneAuth() {
  const [phone, setPhone] = useState("");
  const [code, setCode] = useState("");
  const [step, setStep] = useState<"phone" | "verify">("phone");
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");

  const handleSendOTP = async () => {
    setLoading(true);
    setError("");
    try {
      await authClient.phoneNumber.sendOtp({
        phoneNumber: phone,
      });
      setStep("verify");
    } catch (e) {
      setError("인증번호 발송에 실패했습니다. 번호를 확인해주세요.");
    } finally {
      setLoading(false);
    }
  };

  const handleVerify = async () => {
    setLoading(true);
    setError("");
    try {
      const result = await authClient.phoneNumber.verify({
        phoneNumber: phone,
        code,
      });
      // 인증 성공 → 세션 생성됨
      window.location.href = "/dashboard";
    } catch (e) {
      setError("인증번호가 일치하지 않습니다.");
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      <h1>전화번호 인증</h1>

      {step === "phone" ? (
        <div>
           setPhone(e.target.value)}
            className="w-full p-3 border rounded-lg"
          /&gt;
          
            {loading ? "발송 중..." : "인증번호 받기"}
          
        </div>
      ) : (
        <div>
          <p>
            {phone}으로 발송된 6자리 코드를 입력하세요
          </p>
           setCode(e.target.value)}
            className="w-full p-3 border rounded-lg text-center text-2xl tracking-widest"
          /&gt;
          
            {loading ? "확인 중..." : "인증하기"}
          
           { setStep("phone"); setCode(""); }}
            className="w-full p-3 text-gray-500 text-sm"
          &gt;
            번호 다시 입력
          
        </div>
      )}

      {error &amp;&amp; <p>{error}</p>}
    </div>
  );
}

5단계: 세션 확인 (서버 컴포넌트)

// app/dashboard/page.tsx
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";

export default async function Dashboard() {
  const session = await auth.api.getSession({
    headers: await headers(),
  });

  if (!session) redirect("/login");

  return (
    <div>
      <h1>환영합니다!</h1>
      <p>전화번호: {session.user.phoneNumber}</p>
      <p>인증 상태: {session.user.phoneNumberVerified ? "✅" : "❌"}</p>
    </div>
  );
}

보안 베스트 프랙티스

Rate Limiting

OTP 발송 남용을 막기 위해 반드시 Rate Limiting을 적용하세요:

phoneNumber({
  allowedAttempts: 3, // 3회 실패 시 OTP 무효화
  expiresIn: 300,     // 5분 후 만료
  sendOTP: async ({ phoneNumber, code }) =&gt; {
    // 발송 전 rate limit 체크
  },
})

Better Auth는 allowedAttempts 초과 시 자동으로 OTP를 삭제하고 403 에러를 반환합니다. 사용자는 새 코드를 요청해야 합니다.

전화번호 유효성 검사

phoneNumber({
  phoneNumberValidator: (phoneNumber) =&gt; {
    // 한국 전화번호 형식 검증
    return /^01[016789]\d{7,8}$/.test(phoneNumber.replace(/-/g, ""));
  },
})

타이밍 공격 방지

Better Auth 문서에서 권장하듯, sendOTP 함수 내에서 SMS 발송을 await하지 않는 패턴을 사용하면 발송 성공/실패 여부와 관계없이 일정한 응답 시간을 유지할 수 있습니다.


SMS 프로바이더 선택 가이드

SMS 인증 구현에서 가장 귀찮은 부분이 SMS 발송 프로바이더 연동입니다. 대부분의 국내 서비스는 사업자등록증, 발신번호 사전등록, 이용증명원 등을 요구합니다.

사이드 프로젝트나 MVP 단계에서 이런 서류를 준비하는 건 현실적으로 어렵습니다. EasyAuth(이지어스)처럼 서류 없이 가입 후 바로 API 키를 발급받아 사용할 수 있는 서비스를 활용하면, sendOTP 함수 안에 API 호출 코드만 넣으면 전체 인증 플로우가 완성됩니다. 건당 15~25원으로 가격도 합리적이고, 가입 시 10건 무료 체험도 제공합니다.


전체 프로젝트 구조

my-auth-app/
├── app/
│   ├── api/auth/[...all]/route.ts
│   ├── components/PhoneAuth.tsx
│   ├── dashboard/page.tsx
│   ├── login/page.tsx
│   └── layout.tsx
├── lib/
│   ├── auth.ts
│   └── auth-client.ts
├── .env.local
└── package.json

마무리

Better Auth의 Phone Number 플러그인은 sendOTP 콜백 하나만 구현하면 전체 OTP 인증 플로우(발송, 검증, 세션 생성, 재시도 제한)를 알아서 처리해줍니다. Next.js 15 App Router와의 통합도 Route Handler 파일 하나면 끝입니다.

가장 큰 허들인 SMS 발송 부분은, 서류 제출 없이 5분 만에 시작할 수 있는 EasyAuth 같은 서비스를 활용하면 프로젝트 셋업부터 SMS 인증 완성까지 30분이면 충분합니다.

빠르게 만들고, 빠르게 검증하세요. 🚀

비트베이크에서 광고를 시작해보세요

광고 문의하기

다른 글 보기

2026-06-18T06:01:39.386Z

2026년 부동산: 청약 대출 금리 전망과 성공적인 내집마련 전략

2026년 부동산 시장은 금리, 정책, 공급 등 다양한 변수로 인해 복잡합니다. 이 글에서는 2026년 상반기 부동산 시장 전망과 함께 정부 정책 변화, 주택담보대출 금리 최적화 전략, 그리고 성공적인 청약 당첨을 위한 지역 및 단지 선택 팁을 상세히 다룹니다. 현명한 내집마련 의사결정을 위한 실질적인 가이드를 제공합니다.

2026-06-18T05:01:46.246Z

AI 웨어러블 건강 최적화 2026: 나만의 맞춤 로드맵

2026년, AI 웨어러블 기기가 선사할 개인 맞춤 건강 관리의 혁신을 소개합니다. AI 코칭으로 최적화된 영양, 운동, 수면 관리와 예측 예방 전략으로 나만의 건강 로드맵을 설계하세요.

2026-06-18T05:01:38.929Z

2026 여름 출산준비물 리스트: 신생아부터 첫 휴가까지 필수템!

2026년 여름 출산을 앞둔 예비 부모를 위한 완벽 가이드! 신생아 여름용품부터 첫 휴가를 위한 필수템까지, 더위로부터 아기를 보호할 쿨링 아이템과 외출/휴가용품, 여름 의류를 상세히 소개합니다. 육아 선배들의 꿀팁과 체크리스트로 현명한 여름 출산준비를 시작하세요.

2026-06-18T05:01:32.846Z

2026년 AI PC 구매 가이드: 나에게 맞는 인공지능 노트북은?

2026년 AI PC 시대, NPU 기반 인공지능 노트북 구매를 위한 완벽 가이드! 코파일럿+ 핵심 기능부터 인텔, AMD, 퀄컴 제조사별 라인업 비교, 예산 및 용도별 추천 모델까지, 나에게 맞는 최신 AI PC를 현명하게 선택하는 방법을 알아보세요.

서비스

피드자주 묻는 질문고객센터

문의

비트베이크

레임스튜디오 | 사업자 등록번호 : 542-40-01042

경기도 남양주시 와부읍 수례로 116번길 16, 4층 402-제이270호

트위터인스타그램네이버 블로그