Next.js 15 App Router에서 SMS 인증 구현하기 — 2026 보안 가이드
2026-03-30T01:04:06.751Z
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' && (
<>
setPhoneNumber(e.target.value)}
className="w-full border rounded px-3 py-2"
/>
{isPending ? '발송 중...' : '인증번호 받기'}
</>
)}
{step === 'verify' && (
<>
<p>인증번호 6자리를 입력해주세요.</p>
setCode(e.target.value)}
className="w-full border rounded px-3 py-2 text-center text-2xl tracking-widest"
/>
{isPending ? '확인 중...' : '인증하기'}
</>
)}
{error && <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 /send와 POST /verify 두 개의 엔드포인트만으로 SMS 인증이 완성됩니다. 가입 시 10건 무료 제공되니, 이 글의 코드를 복사해서 지금 바로 테스트해보세요.
건당 15~25원으로 기존 대비 절반 가격이라, 사이드 프로젝트나 MVP 단계에서 부담 없이 시작할 수 있습니다.
본 글은 2026년 3월 기준으로 작성되었습니다. Next.js 15.x 및 최신 보안 권고사항을 반영합니다.
Start advertising on Bitbake
Contact Us