비트베이크

Next.js 15 App Router에서 SMS 인증 구현하기 — 2026 보안 가이드

2026-03-30T01:04:06.751Z

NEXTJS15-SMS-2026

Next.js 15 App Router에서 SMS 인증 구현하기 — 2026 보안 가이드

> "사이드 프로젝트에 SMS 인증 붙이려고 했더니, 사업자등록증 내라고?" — 한 번쯤 겪어본 좌절감이죠. 이 글에서는 Next.js 15 App Router 환경에서 SMS OTP 인증을 서류 없이, 10분 안에 구현하는 방법을 단계별로 안내합니다.

이 글에서 배울 내용

  • Next.js 15 App Router의 Route Handler와 Server Action 활용법
  • SMS 인증번호(OTP) 발송 및 검증 API 구현
  • Rate Limiting으로 SMS 남용(SMS Pumping) 방지
  • 2026년 기준 SMS 인증 보안 모범 사례

1단계: 프로젝트 셋업

프로젝트 생성

npx create-next-app@latest my-sms-auth --app --typescript
cd my-sms-auth
npm install @upstash/ratelimit @upstash/redis

Next.js 15에서는 App Router가 기본이며, app/ 디렉토리 아래에 Route Handler(route.ts)를 생성해 API 엔드포인트를 만듭니다.

환경변수 설정

# .env.local
EASYAUTH_API_KEY=your_api_key_here
UPSTASH_REDIS_REST_URL=your_upstash_url
UPSTASH_REDIS_REST_TOKEN=your_upstash_token

2단계: SMS 인증번호 발송 API 구현

app/api/auth/send/route.ts 파일을 생성합니다.

// app/api/auth/send/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

// Rate Limiter: IP당 60초에 1회로 제한
const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(1, '60 s'),
  prefix: 'sms-send',
});

export async function POST(request: NextRequest) {
  try {
    // 1. Rate Limiting 체크
    const ip = request.headers.get('x-forwarded-for') ?? 'anonymous';
    const { success } = await ratelimit.limit(ip);

    if (!success) {
      return NextResponse.json(
        { error: '요청이 너무 잦습니다. 60초 후 다시 시도해주세요.' },
        { status: 429 }
      );
    }

    // 2. 전화번호 유효성 검증
    const { phoneNumber } = await request.json();
    const phoneRegex = /^01[016789]\d{7,8}$/;

    if (!phoneRegex.test(phoneNumber)) {
      return NextResponse.json(
        { error: '유효하지 않은 전화번호입니다.' },
        { status: 400 }
      );
    }

    // 3. SMS 인증번호 발송 요청
    const response = await fetch('https://api.easyauth.io/send', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${process.env.EASYAUTH_API_KEY}`,
      },
      body: JSON.stringify({ phoneNumber }),
    });

    const data = await response.json();

    if (!response.ok) {
      return NextResponse.json(
        { error: '인증번호 발송에 실패했습니다.' },
        { status: 500 }
      );
    }

    return NextResponse.json({
      success: true,
      message: '인증번호가 발송되었습니다.',
      requestId: data.requestId,
    });
  } catch (error) {
    return NextResponse.json(
      { error: '서버 오류가 발생했습니다.' },
      { status: 500 }
    );
  }
}

3단계: 인증번호 검증 API 구현

// app/api/auth/verify/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

// 검증은 IP당 60초에 5회로 제한 (브루트포스 방지)
const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, '60 s'),
  prefix: 'sms-verify',
});

export async function POST(request: NextRequest) {
  try {
    const ip = request.headers.get('x-forwarded-for') ?? 'anonymous';
    const { success } = await ratelimit.limit(ip);

    if (!success) {
      return NextResponse.json(
        { error: '인증 시도 횟수를 초과했습니다.' },
        { status: 429 }
      );
    }

    const { requestId, code } = await request.json();

    // 인증번호 검증 요청
    const response = await fetch('https://api.easyauth.io/verify', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${process.env.EASYAUTH_API_KEY}`,
      },
      body: JSON.stringify({ requestId, code }),
    });

    const data = await response.json();

    if (!response.ok || !data.verified) {
      return NextResponse.json(
        { error: '인증번호가 일치하지 않습니다.' },
        { status: 400 }
      );
    }

    // 인증 성공 — 세션/토큰 발급 로직 추가
    return NextResponse.json({
      success: true,
      message: '인증이 완료되었습니다.',
    });
  } catch (error) {
    return NextResponse.json(
      { error: '서버 오류가 발생했습니다.' },
      { status: 500 }
    );
  }
}

4단계: 클라이언트 컴포넌트 구현

// app/components/SmsVerification.tsx
'use client';

import { useState, useTransition } from 'react';

export default function SmsVerification() {
  const [step, setStep] = useState<'phone' | 'verify' | 'done'>('phone');
  const [phoneNumber, setPhoneNumber] = useState('');
  const [code, setCode] = useState('');
  const [requestId, setRequestId] = useState('');
  const [error, setError] = useState('');
  const [isPending, startTransition] = useTransition();

  const handleSendOTP = () => {
    startTransition(async () => {
      setError('');
      const res = await fetch('/api/auth/send', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ phoneNumber: phoneNumber.replace(/-/g, '') }),
      });
      const data = await res.json();

      if (!res.ok) {
        setError(data.error);
        return;
      }
      setRequestId(data.requestId);
      setStep('verify');
    });
  };

  const handleVerify = () => {
    startTransition(async () => {
      setError('');
      const res = await fetch('/api/auth/verify', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ requestId, code }),
      });
      const data = await res.json();

      if (!res.ok) {
        setError(data.error);
        return;
      }
      setStep('done');
    });
  };

  if (step === 'done') {
    return <div>✓ 인증이 완료되었습니다!</div>;
  }

  return (
    <div>
      <h2>휴대폰 인증</h2>

      {step === 'phone' &amp;&amp; (
        &lt;&gt;
           setPhoneNumber(e.target.value)}
            className="w-full border rounded px-3 py-2"
          /&gt;
          
            {isPending ? '발송 중...' : '인증번호 받기'}
          
        &lt;/&gt;
      )}

      {step === 'verify' &amp;&amp; (
        &lt;&gt;
          <p>인증번호 6자리를 입력해주세요.</p>
           setCode(e.target.value)}
            className="w-full border rounded px-3 py-2 text-center text-2xl tracking-widest"
          /&gt;
          
            {isPending ? '확인 중...' : '인증하기'}
          
        &lt;/&gt;
      )}

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

5단계: Server Action 방식 (대안)

Route Handler 대신 Server Action으로 구현하면 더 간결합니다:

// app/actions/sms.ts
'use server';

import { headers } from 'next/headers';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(1, '60 s'),
  prefix: 'sms-action',
});

export async function sendOTP(phoneNumber: string) {
  const headersList = await headers();
  const ip = headersList.get('x-forwarded-for') ?? 'anonymous';
  const { success } = await ratelimit.limit(ip);

  if (!success) {
    return { error: '요청이 너무 잦습니다. 잠시 후 다시 시도해주세요.' };
  }

  const res = await fetch('https://api.easyauth.io/send', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${process.env.EASYAUTH_API_KEY}`,
    },
    body: JSON.stringify({ phoneNumber }),
  });

  const data = await res.json();
  if (!res.ok) return { error: '발송에 실패했습니다.' };

  return { success: true, requestId: data.requestId };
}

> : Next.js 15의 Server Action은 자동으로 Origin 헤더와 Host 헤더를 비교하여 CSRF 공격을 차단합니다. Route Handler보다 보안 면에서 기본 보호가 한층 강화됩니다.


2026년 SMS 인증 보안 체크리스트

| 항목 | 설명 | 중요도 | |------|------|--------| | Rate Limiting | IP당 발송/검증 횟수 제한 | 필수 | | OTP 만료시간 | 3~5분 이내로 설정 | 필수 | | 전화번호 검증 | 정규식으로 형식 검증 | 필수 | | HTTPS 강제 | 모든 API 통신 암호화 | 필수 | | 시도 횟수 제한 | 검증 실패 5회 시 차단 | 권장 | | 로깅/모니터링 | 비정상 패턴 감지 | 권장 | | SameSite 쿠키 | CSRF 추가 방어 | 권장 |

주의: SIM 스와핑 공격

2026년에도 SMS 인증의 가장 큰 위협은 SIM 스와핑입니다. 공격자가 통신사를 속여 피해자의 번호를 탈취하는 방식이죠. SMS 인증은 **중간 보안 수준(medium-assurance)**으로 분류되므로, 금융거래처럼 높은 보안이 필요한 경우에는 TOTP나 패스키와 함께 사용하는 것을 권장합니다.

하지만 회원가입, 본인확인 등 일반적인 인증 시나리오에서는 SMS OTP가 여전히 가장 실용적인 선택입니다.


마무리: 가장 빠르게 SMS 인증 붙이는 법

이 가이드에서 살펴본 것처럼, Next.js 15 App Router에서 SMS 인증을 구현하는 것 자체는 어렵지 않습니다. 진짜 허들은 SMS 발송 서비스를 선택하는 과정 — 사업자등록증 제출, 발신번호 사전등록, 복잡한 심사 절차 — 에 있습니다.

**EasyAuth(이지어스)**는 이 과정을 완전히 생략할 수 있게 해줍니다. 서류 제출 없이 가입 즉시 API 키를 발급받고, POST /sendPOST /verify 두 개의 엔드포인트만으로 SMS 인증이 완성됩니다. 가입 시 10건 무료 제공되니, 이 글의 코드를 복사해서 지금 바로 테스트해보세요.

건당 15~25원으로 기존 대비 절반 가격이라, 사이드 프로젝트나 MVP 단계에서 부담 없이 시작할 수 있습니다.


본 글은 2026년 3월 기준으로 작성되었습니다. Next.js 15.x 및 최신 보안 권고사항을 반영합니다.

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

광고 문의하기

다른 글 보기

2026-04-08T11:02:47.515Z

2026 Professionals Solo Party & Wine Mixer Complete Guide: Real Reviews and Success Tips for Korean Singles

2026-04-08T11:02:47.487Z

2026년 직장인 솔로파티 & 와인모임 소개팅 완벽 가이드 - 실제 후기와 성공 팁

2026-04-08T10:03:28.247Z

Complete Google NotebookLM Guide 2026: Master the New Studio Features, Video Overviews, and Gemini Canvas Integration

2026-04-08T10:03:28.231Z

2026년 구글 NotebookLM 완벽 가이드: 새로운 스튜디오 기능, 비디오 개요 및 제미나이 캔버스 통합 실전 활용법

서비스

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

문의

비트베이크

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

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

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