[Next.js] 서류 없이 5분 만에 회원가입 SMS 본인인증 구현하기 (feat. App Router)

2026년 5월 7일6분 소요

Professional, modern tech imagery with focus on code, data, or secure interactions, suitable for text overlay on developer/authentication content. Example image (not fetched by AI) of hands typing on a glowing keyboard representing secure coding or digital authentication. Search query: developer authentication secure

사이드 프로젝트에 SMS 인증을 붙이려다 좌절한 적 있으신가요?

새로운 서비스의 MVP(Minimum Viable Product)를 개발하거나 사이드 프로젝트를 진행할 때, 사용자 본인인증은 필수적인 기능 중 하나입니다. 가짜 계정 생성을 막고, 안전한 서비스 환경을 구축하기 위해 가장 널리 쓰이는 방법이 바로 '휴대폰 SMS 인증'이죠.

하지만 국내에서 SMS API를 연동하려고 하면 시작부터 큰 장벽에 부딪히게 됩니다. API 문서를 읽기도 전에 다음과 같은 서류들을 요구하기 때문입니다:

  • 사업자등록증 사본
  • 통신서비스 이용증명원
  • 발신번호 사전등록

주말 해커톤이나 아직 법인을 설립하지 않은 개인 개발자, 프리랜서에게 이러한 절차는 사실상 '구현 불가'를 의미합니다.

이 글에서는 복잡한 서류 제출 없이, 단 5분 만에 Next.js(App Router) 환경에서 SMS OTP 인증을 구현하는 방법을 알아봅니다. Server Actions를 활용한 최신 Next.js 패턴과 함께, 즉시 실무에 적용할 수 있는 코드를 제공합니다.


이 글에서 배울 내용

  1. Next.js App Router와 Server Actions를 활용한 안전한 API 통신 구조
  2. 서류 없이 즉시 사용할 수 있는 SMS API 연동 방법
  3. 타이머, 에러 처리 등을 포함한 완전한 클라이언트 UI 구현
  4. 실무에 필요한 보안 및 어뷰징 방지 팁

1단계: API 인증 정보 설정하기

복잡한 심사 과정 없이 바로 사용할 수 있는 EasyAuth(이지어스) API를 기준으로 진행합니다. (가입 시 즉시 발급되는 API Key를 사용합니다)

프로젝트 루트의 .env.local 파일에 다음 환경 변수를 추가합니다.

# .env.local
EASYAUTH_API_KEY=your_api_key_here

보안 팁: NEXT_PUBLIC_ 접두사를 붙이지 않아야 API 키가 브라우저에 노출되지 않습니다.


2단계: Server Actions 작성 (SMS 발송 및 검증)

Next.js 14 이후부터는 Server Actions를 사용하여 클라이언트에서 서버 측 코드를 직접 호출할 수 있습니다. 이를 통해 API Routes를 따로 만들 필요 없이, 백엔드 로직을 안전하게 감출 수 있습니다.

app/actions/auth.ts 파일을 생성하고 다음 코드를 작성합니다.

'use server';

// app/actions/auth.ts

interface ActionResponse {
  success: boolean;
  message?: string;
}

// 1. 인증번호 발송 액션
export async function sendVerificationSMS(phoneNumber: string): Promise {
  try {
    // 전화번호 형식 검증 (숫자만 추출)
    const cleanPhone = phoneNumber.replace(/[^0-9]/g, '');
    
    if (cleanPhone.length < 10 || cleanPhone.length > 11) {
      return { success: false, message: '유효한 휴대폰 번호를 입력해주세요.' };
    }

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

    if (!response.ok) {
      throw new Error('SMS 발송에 실패했습니다.');
    }

    return { success: true };
  } catch (error) {
    console.error('SMS Send Error:', error);
    return { success: false, message: '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' };
  }
}

// 2. 인증번호 검증 액션
export async function verifySMSCode(phoneNumber: string, code: string): Promise {
  try {
    const cleanPhone = phoneNumber.replace(/[^0-9]/g, '');

    const response = await fetch('https://api.easyauth.kr/verify', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${process.env.EASYAUTH_API_KEY}`
      },
      body: JSON.stringify({ to: cleanPhone, code })
    });

    if (!response.ok) {
      return { success: false, message: '인증번호가 일치하지 않거나 만료되었습니다.' };
    }

    return { success: true };
  } catch (error) {
    console.error('SMS Verify Error:', error);
    return { success: false, message: '인증 처리 중 오류가 발생했습니다.' };
  }
}

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

이제 사용자가 전화번호와 인증번호를 입력할 수 있는 UI를 만듭니다. 3분(180초) 타이머 기능도 함께 구현합니다.

app/components/SmsAuth.tsx를 생성합니다.

'use client';

import { useState, useEffect } from 'react';
import { sendVerificationSMS, verifySMSCode } from '../actions/auth';

export default function SmsAuth() {
  const [phone, setPhone] = useState('');
  const [code, setCode] = useState('');
  const [step, setStep] = useState<'IDLE' | 'SENT' | 'VERIFIED'>('IDLE');
  const [timeLeft, setTimeLeft] = useState(180); // 3분
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');

  // 타이머 로직
  useEffect(() => {
    if (step === 'SENT' && timeLeft > 0) {
      const timer = setInterval(() => setTimeLeft((prev) => prev - 1), 1000);
      return () => clearInterval(timer);
    }
  }, [step, timeLeft]);

  const formatTime = (seconds: number) => {
    const m = Math.floor(seconds / 60);
    const s = seconds % 60;
    return `${m}:${s.toString().padStart(2, '0')}`;
  };

  const handleSend = async () => {
    setLoading(true);
    setError('');
    
    const res = await sendVerificationSMS(phone);
    
    if (res.success) {
      setStep('SENT');
      setTimeLeft(180);
    } else {
      setError(res.message || '발송 실패');
    }
    setLoading(false);
  };

  const handleVerify = async () => {
    if (timeLeft === 0) {
      setError('인증 시간이 만료되었습니다. 다시 요청해주세요.');
      return;
    }

    setLoading(true);
    setError('');

    const res = await verifySMSCode(phone, code);
    
    if (res.success) {
      setStep('VERIFIED');
    } else {
      setError(res.message || '인증 실패');
    }
    setLoading(false);
  };

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

      {/* 에러 메시지 표시 */}
      {error &amp;&amp; <p>{error}</p>}

      {step === 'VERIFIED' ? (
        <div>
          ✅ 인증이 완료되었습니다!
        </div>
      ) : (
        <div>
          {/* 전화번호 입력부 */}
          <div>
            휴대폰 번호
            <div>
               setPhone(e.target.value)}
                placeholder="01012345678"
                disabled={step === 'SENT'}
                className="flex-1 p-2 border rounded focus:ring-2 focus:ring-blue-500"
              /&gt;
              
                {step === 'SENT' ? '재전송' : '인증번호 받기'}
              
            </div>
          </div>

          {/* 인증번호 입력부 */}
          {step === 'SENT' &amp;&amp; (
            <div>
              
                인증번호 <span>({formatTime(timeLeft)})</span>
              
              <div>
                 setCode(e.target.value)}
                  placeholder="6자리 숫자"
                  maxLength={6}
                  className="flex-1 p-2 border rounded focus:ring-2 focus:ring-blue-500"
                /&gt;
                
                  확인
                
              </div>
            </div>
          )}
        </div>
      )}
    </div>
  );
}

Tips & Best Practices (실무 팁)

1. 보안과 비용을 위한 Rate Limiting (호출 제한)

악의적인 사용자가 무단으로 SMS 발송 API를 계속 호출하면 과도한 비용이 발생할 수 있습니다.
Next.js 환경에서는 @upstash/ratelimit과 Redis를 활용해 IP당 하루 최대 발송 횟수(예: 5회)를 제한하는 것을 강력히 권장합니다.

2. 연락처 입력 정규식 처리

사용자들은 010-1234-5678처럼 하이픈을 넣기도 하고, 01012345678로 붙여 쓰기도 합니다. 클라이언트에서 1차적으로 하이픈을 제거하고, 서버 Action에서도 replace(/[^0-9]/g, '')를 통해 숫자만 남기도록 이중 처리하는 것이 안전합니다.

3. 인증 만료 시간 설정 (3분 규칙)

OTP 보안의 핵심은 제한된 시간입니다. 클라이언트 UI에서 3분 타이머를 보여주는 것은 물론이고, 백엔드(API)에서도 발송된 지 3분이 지난 코드는 무효화 처리해야 합니다.


마치며: 개발자를 위한 가장 쉬운 선택, EasyAuth

지금까지 Next.js를 이용해 회원가입용 SMS 본인인증을 구현하는 방법을 살펴보았습니다. 코드는 간단하지만, 막상 실제로 구현하려고 하면 **'국내 통신망 심사'**라는 거대한 장벽 때문에 코딩보다 서류 준비에 더 많은 시간을 쏟아야 하는 경우가 많습니다.

토이 프로젝트, 스타트업 MVP, 1인 개발을 진행 중이라면 **EasyAuth(이지어스)**를 추천합니다.

  • 서류 0장: 사업자등록증, 통신서비스 이용증명원 제출이 필요 없습니다.
  • 즉시 연동: 회원가입 후 발급받은 API 키로 즉시 발송 가능합니다. (자동 발신번호 사용)
  • 압도적 가성비: 기존 API들이 건당 30~50원인 반면, EasyAuth는 건당 15~25원으로 매우 합리적입니다.
  • 무료 테스트: 가입 시 10건의 무료 크레딧이 제공되어 개발 및 테스트 비용이 들지 않습니다.

단 두 개의 엔드포인트(POST /send, POST /verify)만으로 끝나는 초간단 SMS 인증 API, 지금 바로 EasyAuth로 5분 만에 본인인증 로직을 완성해 보세요!

SMS 인증을 쉽게 시작하세요

서류 없이 가입 즉시 API Key를 발급받고 바로 시작할 수 있습니다.
건당 25원, 가입 시 10건 무료!