2026 SMS 펌핑 사기 완벽 방어 가이드 - 개발자가 반드시 알아야 할 보안 전략
2026-03-16T06:43:56.639Z
2026 SMS 펌핑 사기 완벽 방어 가이드 - 개발자가 반드시 알아야 할 보안 전략
당신의 서비스에서 매달 수천 달러의 SMS 비용이 갑자기 급증한다면? OTP 인증 요청은 폭증하는데 실제 가입 전환은 거의 없다면? 이미 SMS 펌핑(SMS Pumping) 공격의 표적이 된 것일 수 있습니다.
2023년 전 세계 기업들이 사기성 SMS 메시지에 약 11.6억 달러를 지출했으며, 일론 머스크는 트위터(현 X)가 SMS 펌핑 공격으로 연간 6천만 달러의 피해를 입었다고 밝혔습니다. 2025~2026년에는 이 피해 규모가 수십억 달러에 이를 것으로 전문가들은 전망하고 있습니다. MITRE ATT&CK 프레임워크에서도 SMS 펌핑을 **T1496.003(Resource Hijacking: SMS Pumping)**으로 공식 분류하여 그 위험성을 인정하고 있습니다.
이 가이드에서는 SMS 펌핑 공격의 원리부터 실전 코드 기반의 방어 전략까지, 개발자가 반드시 알아야 할 모든 것을 다룹니다.
SMS 펌핑이란 무엇인가?
SMS 펌핑(SMS Pumping 또는 SMS Toll Fraud)은 공격자가 통신사와 공모하여 대량의 SMS 트래픽을 인위적으로 발생시키고, 그 수익을 나누는 사기 행위입니다.
공격 메커니즘
- 공격자가 특정 통신사로부터 프리미엄 요금 번호 세트를 확보합니다
- 피해 서비스의 회원가입, OTP 인증, 비밀번호 재설정 등 SMS를 자동 발송하는 엔드포인트를 찾습니다
- 봇을 이용해 해당 엔드포인트에 대량의 인증 요청을 보냅니다
- 발송된 SMS마다 통신사가 과금하고, 공격자는 통신사와 수익을 분배합니다
- 공격자는 실제로 OTP 코드를 사용할 의도가 전혀 없습니다 — 목적은 오직 SMS 발송 자체입니다
핵심 특징
- OTP 미사용: 발송된 인증 코드의 실제 인증 완료율이 비정상적으로 낮습니다
- 특정 국가 집중: 특정 국가 코드나 번호 대역으로의 요청이 급증합니다
- 연속 번호 패턴: +1-555-0001, +1-555-0002 같은 순차적 번호로 요청이 옵니다
- 비인간적 속도: 수초 내에 수백 건의 요청이 단일 IP/세션에서 발생합니다
방어 전략 1: 다층 레이트 리미팅
레이트 리미팅은 SMS 펌핑 방어의 가장 기본적이면서도 효과적인 첫 번째 방어선입니다.
Node.js/Express 구현 예시
const express = require('express');
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);
const app = express();
// 1단계: IP 기반 글로벌 레이트 리미팅
const globalSmsLimiter = rateLimit({
store: new RedisStore({ sendCommand: (...args) => redis.call(...args) }),
windowMs: 60 * 60 * 1000, // 1시간
max: 10, // IP당 시간당 최대 10건
keyGenerator: (req) => req.ip,
message: { error: 'Too many SMS requests. Please try again later.' },
standardHeaders: true,
});
// 2단계: 전화번호 기반 레이트 리미팅
const phoneNumberLimiter = rateLimit({
store: new RedisStore({ sendCommand: (...args) => redis.call(...args) }),
windowMs: 10 * 60 * 1000, // 10분
max: 3, // 같은 번호로 10분에 3건까지
keyGenerator: (req) => req.body.phoneNumber,
message: { error: 'Verification limit reached for this number.' },
});
// 3단계: 국가 코드 기반 레이트 리미팅
const countryCodeLimiter = async (req, res, next) => {
const phoneNumber = req.body.phoneNumber;
const countryCode = extractCountryCode(phoneNumber);
const key = `sms:country:${countryCode}`;
const count = await redis.incr(key);
if (count === 1) await redis.expire(key, 3600);
// 서비스 대상 국가별 차등 제한
const limits = {
'+82': 1000, // 한국 (주요 서비스 국가)
'+1': 500, // 미국
'+81': 300, // 일본
'default': 50 // 기타 국가는 매우 보수적으로
};
const maxAllowed = limits[countryCode] || limits['default'];
if (count > maxAllowed) {
return res.status(429).json({
error: 'Regional SMS limit reached.'
});
}
next();
};
// 미들웨어 적용
app.post('/api/send-otp',
globalSmsLimiter,
phoneNumberLimiter,
countryCodeLimiter,
sendOtpHandler
);
지수 백오프(Exponential Backoff) 구현
같은 번호로의 반복 요청에 대해 대기 시간을 기하급수적으로 늘립니다:
async function enforceExponentialBackoff(phoneNumber) {
const key = `sms:backoff:${phoneNumber}`;
const attempts = await redis.get(key);
if (attempts) {
const waitSeconds = Math.min(Math.pow(2, parseInt(attempts)) * 30, 3600);
const ttl = await redis.ttl(key);
if (ttl > 0) {
throw new Error(
`Please wait ${Math.ceil(ttl / 60)} minutes before requesting another code.`
);
}
}
await redis.multi()
.incr(key)
.expire(key, Math.min(Math.pow(2, parseInt(attempts || 0)) * 30, 3600))
.exec();
}
방어 전략 2: 전화번호 유효성 검증 및 리스크 스코어링
레이트 리미팅만으로는 충분하지 않습니다. SMS 발송 이전에 번호 자체를 검증해야 합니다.
번호 유형 및 캐리어 검증
const { parsePhoneNumber, isValidPhoneNumber } = require('libphonenumber-js');
async function validatePhoneNumber(phoneNumber) {
const result = {
isValid: false,
riskScore: 0,
riskFactors: [],
};
// 1. 기본 형식 검증
if (!isValidPhoneNumber(phoneNumber)) {
result.riskFactors.push('INVALID_FORMAT');
return result;
}
const parsed = parsePhoneNumber(phoneNumber);
const countryCode = parsed.country;
// 2. 허용 국가 화이트리스트 확인
const allowedCountries = ['KR', 'US', 'JP'];
if (!allowedCountries.includes(countryCode)) {
result.riskFactors.push('BLOCKED_COUNTRY');
result.riskScore += 100;
return result;
}
// 3. 한국 번호 추가 검증 (010, 011 등)
if (countryCode === 'KR') {
const localNumber = parsed.nationalNumber;
const validPrefixes = ['010', '011', '016', '017', '018', '019'];
const prefix = localNumber.substring(0, 3);
if (!validPrefixes.includes(prefix)) {
result.riskFactors.push('INVALID_KR_PREFIX');
result.riskScore += 80;
}
}
// 4. 캐리어 조회 (VoIP 번호 차단)
const carrierInfo = await lookupCarrier(phoneNumber);
if (carrierInfo.type === 'voip') {
result.riskFactors.push('VOIP_NUMBER');
result.riskScore += 60;
}
// 5. 연속 번호 패턴 감지
const isSequential = await checkSequentialPattern(phoneNumber);
if (isSequential) {
result.riskFactors.push('SEQUENTIAL_PATTERN');
result.riskScore += 70;
}
result.isValid = result.riskScore < 50;
return result;
}
// 연속 번호 패턴 감지 함수
async function checkSequentialPattern(phoneNumber) {
const baseNumber = phoneNumber.slice(0, -2);
const recentRequests = await redis.smembers(`sms:recent:${baseNumber}`);
// 같은 번호 대역에서 5개 이상 요청이면 의심
return recentRequests.length >= 5;
}
방어 전략 3: 봇 탐지 및 행동 분석
CAPTCHA는 전통적인 방어 수단이지만, 2025~2026년 현재 자동화된 CAPTCHA 우회 서비스가 보편화되어 단독으로는 충분하지 않습니다. 행동 기반 분석을 병행해야 합니다.
클라이언트 사이드 행동 데이터 수집
// 프론트엔드: 사용자 행동 데이터 수집
class BehaviorCollector {
constructor() {
this.events = [];
this.startTime = Date.now();
this.mouseMovements = 0;
this.keystrokes = 0;
this.touchEvents = 0;
}
init() {
document.addEventListener('mousemove', () => this.mouseMovements++);
document.addEventListener('keydown', () => this.keystrokes++);
document.addEventListener('touchstart', () => this.touchEvents++);
document.addEventListener('scroll', () => {
this.events.push({ type: 'scroll', time: Date.now() - this.startTime });
});
}
getSignals() {
const timeOnPage = Date.now() - this.startTime;
return {
timeOnPageMs: timeOnPage,
mouseMovements: this.mouseMovements,
keystrokes: this.keystrokes,
touchEvents: this.touchEvents,
eventCount: this.events.length,
// 봇은 보통 페이지 로드 직후 바로 요청합니다
isSuspiciouslyFast: timeOnPage < 2000,
// 봇은 마우스 이동이 없거나 비정상적으로 직선적입니다
hasNaturalInteraction: this.mouseMovements > 5 || this.touchEvents > 0,
};
}
}
서버 사이드 행동 분석
function analyzeBehavior(signals, req) {
let riskScore = 0;
const flags = [];
// 페이지 체류 시간이 2초 미만이면 봇 가능성 높음
if (signals.timeOnPageMs < 2000) {
riskScore += 40;
flags.push('SUSPICIOUSLY_FAST');
}
// 사용자 상호작용이 전혀 없으면 봇 가능성
if (!signals.hasNaturalInteraction && signals.keystrokes === 0) {
riskScore += 35;
flags.push('NO_INTERACTION');
}
// User-Agent 분석
const ua = req.headers['user-agent'];
if (!ua || ua.includes('bot') || ua.includes('curl') || ua.includes('python-requests')) {
riskScore += 50;
flags.push('SUSPICIOUS_UA');
}
// Headless 브라우저 감지 시그널 확인
if (signals.webdriverDetected) {
riskScore += 60;
flags.push('WEBDRIVER_DETECTED');
}
return {
riskScore,
flags,
action: riskScore >= 60 ? 'BLOCK' : riskScore >= 30 ? 'CHALLENGE' : 'ALLOW',
};
}
방어 전략 4: OTP 전환율 모니터링
SMS 펌핑의 가장 핵심적인 특징 중 하나는 발송된 OTP가 실제로 인증에 사용되지 않는다는 점입니다. 이 지표를 모니터링하면 공격을 조기에 감지할 수 있습니다.
class OtpConversionMonitor {
constructor(redis) {
this.redis = redis;
}
async recordSmsSent(countryCode) {
const key = `otp:sent:${countryCode}:${this.getHourKey()}`;
await this.redis.incr(key);
await this.redis.expire(key, 86400);
}
async recordOtpVerified(countryCode) {
const key = `otp:verified:${countryCode}:${this.getHourKey()}`;
await this.redis.incr(key);
await this.redis.expire(key, 86400);
}
async getConversionRate(countryCode) {
const hourKey = this.getHourKey();
const sent = parseInt(await this.redis.get(`otp:sent:${countryCode}:${hourKey}`)) || 0;
const verified = parseInt(await this.redis.get(`otp:verified:${countryCode}:${hourKey}`)) || 0;
if (sent === 0) return { rate: 1, sent, verified, alert: false };
const rate = verified / sent;
// 전환율이 20% 미만이면 경고
// 전환율이 5% 미만이면 즉시 해당 국가 차단
return {
rate,
sent,
verified,
alert: rate < 0.20,
critical: rate < 0.05,
action: rate < 0.05 ? 'BLOCK_COUNTRY' : rate < 0.20 ? 'ALERT' : 'NORMAL',
};
}
getHourKey() {
return new Date().toISOString().slice(0, 13);
}
}
// 사용 예시: 자동 방어 시스템
async function autoDefenseCheck(countryCode, monitor) {
const stats = await monitor.getConversionRate(countryCode);
if (stats.critical) {
console.error(`[CRITICAL] SMS pumping detected for ${countryCode}! ` +
`Conversion rate: ${(stats.rate * 100).toFixed(1)}% ` +
`(${stats.verified}/${stats.sent})`);
// 즉시 해당 국가 코드 차단
await blockCountryCode(countryCode);
await sendAlertToSlack(countryCode, stats);
return false;
}
if (stats.alert) {
console.warn(`[WARNING] Low OTP conversion for ${countryCode}: ` +
`${(stats.rate * 100).toFixed(1)}%`);
await sendAlertToSlack(countryCode, stats);
}
return true;
}
방어 전략 5: 지리적 제어 및 국가 차단
서비스 대상 국가만 허용하고 나머지는 차단하는 것은 가장 효과적인 방어 중 하나입니다.
const ALLOWED_COUNTRIES = new Map([
['KR', { dailyLimit: 10000, description: '대한민국' }],
['US', { dailyLimit: 5000, description: '미국' }],
['JP', { dailyLimit: 3000, description: '일본' }],
]);
const HIGH_RISK_PREFIXES = [
'+248', // 세이셸
'+592', // 가이아나
'+675', // 파푸아뉴기니
'+960', // 몰디브
];
async function geoFilterMiddleware(req, res, next) {
const phoneNumber = req.body.phoneNumber;
const parsed = parsePhoneNumber(phoneNumber);
// 허용 국가 확인
if (!ALLOWED_COUNTRIES.has(parsed.country)) {
console.warn(`Blocked SMS to non-allowed country: ${parsed.country}`);
return res.status(403).json({
error: 'SMS verification is not available in your region.'
});
}
// 고위험 번호 대역 차단
const callingCode = '+' + parsed.countryCallingCode;
if (HIGH_RISK_PREFIXES.some(prefix => phoneNumber.startsWith(prefix))) {
return res.status(403).json({
error: 'This number cannot be verified.'
});
}
// 국가별 일일 한도 확인
const countryConfig = ALLOWED_COUNTRIES.get(parsed.country);
const dailyCount = await getDailyCountForCountry(parsed.country);
if (dailyCount >= countryConfig.dailyLimit) {
await sendAlertToOps(`Daily SMS limit reached for ${parsed.country}`);
return res.status(429).json({
error: 'Service temporarily unavailable. Please try again later.'
});
}
next();
}
방어 전략 6: IP 및 디바이스 핑거프린팅
async function ipAndDeviceCheck(req) {
const ip = req.ip;
const riskFactors = [];
let riskScore = 0;
// 1. IP 위치와 전화번호 국가 불일치 확인
const ipGeo = await getGeoFromIP(ip);
const phoneCountry = parsePhoneNumber(req.body.phoneNumber).country;
if (ipGeo.country !== phoneCountry) {
riskScore += 25;
riskFactors.push('GEO_MISMATCH');
}
// 2. VPN/프록시/TOR 감지
const ipType = await checkIPType(ip);
if (['vpn', 'proxy', 'tor', 'datacenter'].includes(ipType)) {
riskScore += 40;
riskFactors.push(`IP_TYPE_${ipType.toUpperCase()}`);
}
// 3. 단일 IP에서 다수 번호로 요청 감지
const uniqueNumbers = await redis.scard(`ip:numbers:${ip}`);
if (uniqueNumbers > 3) {
riskScore += 30;
riskFactors.push('MULTIPLE_NUMBERS_SAME_IP');
}
// 4. 디바이스 핑거프린트 중복 확인
const deviceFp = req.headers['x-device-fingerprint'];
if (deviceFp) {
const fpCount = await redis.incr(`device:sms:${deviceFp}`);
await redis.expire(`device:sms:${deviceFp}`, 86400);
if (fpCount > 5) {
riskScore += 35;
riskFactors.push('DEVICE_ABUSE');
}
}
return { riskScore, riskFactors };
}
통합 방어 아키텍처
위의 모든 전략을 하나의 미들웨어 파이프라인으로 통합합니다:
async function smsSecurityPipeline(req, res, next) {
const phoneNumber = req.body.phoneNumber;
let totalRiskScore = 0;
const allFlags = [];
try {
// Layer 1: 지리적 필터링
const geoResult = await geoFilter(phoneNumber);
if (geoResult.blocked) {
return res.status(403).json({ error: geoResult.message });
}
// Layer 2: 전화번호 검증
const phoneResult = await validatePhoneNumber(phoneNumber);
totalRiskScore += phoneResult.riskScore;
allFlags.push(...phoneResult.riskFactors);
// Layer 3: IP/디바이스 분석
const ipResult = await ipAndDeviceCheck(req);
totalRiskScore += ipResult.riskScore;
allFlags.push(...ipResult.riskFactors);
// Layer 4: 행동 분석
const behaviorResult = analyzeBehavior(req.body.signals, req);
totalRiskScore += behaviorResult.riskScore;
allFlags.push(...behaviorResult.flags);
// Layer 5: OTP 전환율 모니터링
const conversionOk = await autoDefenseCheck(
extractCountryCode(phoneNumber), otpMonitor
);
if (!conversionOk) {
return res.status(429).json({
error: 'Service temporarily restricted.'
});
}
// 최종 판정
if (totalRiskScore >= 80) {
logSecurityEvent('SMS_BLOCKED', { phoneNumber, totalRiskScore, allFlags });
return res.status(403).json({ error: 'Request could not be processed.' });
}
if (totalRiskScore >= 40) {
// 추가 인증 요구 (CAPTCHA 등)
req.requireChallenge = true;
}
// 지수 백오프 적용
await enforceExponentialBackoff(phoneNumber);
next();
} catch (error) {
console.error('SMS security pipeline error:', error);
// Fail closed: 에러 시 SMS 발송 차단
return res.status(500).json({ error: 'Service temporarily unavailable.' });
}
}
2026년 차세대 대안: 무음 네트워크 인증(SNA)
SMS OTP의 근본적인 한계를 극복하는 새로운 대안도 주목할 필요가 있습니다. **무음 네트워크 인증(Silent Network Authentication, SNA)**은 통신사 네트워크를 통해 SIM과 디바이스를 직접 검증하여, OTP SMS 자체를 발송하지 않는 방식입니다.
- 사용자 경험: OTP 입력 불필요, 마찰 제로
- 보안: 네트워크 레벨 인증으로 피싱/펌핑 면역
- 비용: SMS보다 낮은 단가로 운영 가능한 경우가 있음
다만 모든 통신사와 국가에서 지원되는 것은 아니므로, SMS OTP를 폴백으로 유지하되 가능한 경우 SNA를 우선 적용하는 하이브리드 접근을 권장합니다.
모니터링 대시보드 핵심 지표
효과적인 방어를 위해 반드시 추적해야 할 지표들:
| 지표 | 정상 범위 | 경고 임계값 | 긴급 임계값 | |------|----------|------------|------------| | OTP 전환율 | > 50% | < 20% | < 5% | | 시간당 SMS 발송량 | 서비스별 상이 | 평균 대비 3배 | 평균 대비 10배 | | 단일 IP 다중 번호 | 1~2개 | 5개 이상 | 10개 이상 | | 신규 국가 트래픽 | 기준 내 | 갑작스러운 급증 | 이전 없던 국가 | | 연속 번호 요청 | 0% | 5% 이상 | 15% 이상 |
체크리스트: SMS 펌핑 방어 구현 순서
즉시 적용 가능 (1일 이내):
- [ ] 서비스 국가 외 SMS 발송 차단
- [ ] IP 기반 레이트 리미팅 (시간당 10건)
- [ ] 전화번호 기반 레이트 리미팅 (10분당 3건)
단기 적용 (1주 이내):
- [ ] 전화번호 형식 검증 및 VoIP 번호 차단
- [ ] 지수 백오프 구현
- [ ] OTP 전환율 모니터링 및 알림 설정
중기 적용 (1개월 이내):
- [ ] 행동 분석 기반 봇 탐지
- [ ] IP/디바이스 핑거프린팅
- [ ] 자동 국가 차단 시스템
- [ ] 통합 리스크 스코어링 파이프라인
장기 검토:
- [ ] SNA(무음 네트워크 인증) 도입 검토
- [ ] 캐리어 레벨 사기 탐지 API 통합
SMS 인증 시스템을 구축할 때 Easy Auth와 같이 사기 방어 기능이 내장된 인증 API를 활용하면 이러한 보안 레이어를 직접 구현하는 부담을 크게 줄일 수 있습니다.
결론
SMS 펌핑은 단순한 스팸이 아니라, 기업에 수십만~수백만 달러의 직접적인 재정 피해를 주는 심각한 사기 행위입니다. 단일 방어 수단으로는 정교해진 공격을 막을 수 없으며, 다층 방어(Defense in Depth) 접근이 필수적입니다.
핵심 원칙을 정리하면:
- Fail Closed: 에러 상황에서는 SMS 발송을 차단합니다
- 다층 검증: 레이트 리미팅 + 번호 검증 + 행동 분석 + 전환율 모니터링을 조합합니다
- 지리적 제한: 서비스 대상 국가만 허용합니다
- 실시간 모니터링: 이상 징후를 즉시 감지하고 자동 대응합니다
- 비용 한도 설정: SMS 제공업체에서 일일/월간 발송 한도를 설정합니다
오늘 소개한 코드와 전략을 단계적으로 적용하여, 여러분의 서비스를 SMS 펌핑으로부터 안전하게 보호하시기 바랍니다.
비트베이크에서 광고를 시작해보세요
광고 문의하기