안녕하세요, 백엔드 개발자입니다.
대부분의 서비스에서 사용자 인증의 첫 단계는 전화번호를 통한 본인 확인입니다. 사용자는 전화번호를 입력하고, SMS로 전송된 인증번호(OTP)를 입력하여 간편하게 인증을 마칩니다.
단순해 보이는 이 기능 뒤에는 몇 가지 중요한 보안적 고려사항이 숨어있습니다. 이번 글에서는 단순히 OTP를 전송하고 확인하는 기본 흐름을 넘어, 더 안정적이고 안전한 OTP 인증 시스템을 구현하기 위해 백엔드 개발자가 고민해야 할 지점들을 정리해 보려 합니다.
1. OTP 인증의 기본 흐름
먼저 가장 기본적인 처리 과정을 살펴보겠습니다.
- [요청] 사용자가 클라이언트(앱)에 전화번호를 입력하고 '인증번호 받기'를 요청합니다.
- [생성 및 전송] 서버는 6자리의 난수를 생성하고, 이 번호와 유효기간을 잠시 저장한 뒤, SMS 서비스를 통해 사용자에게 전송합니다.
- [검증] 사용자가 앱에 인증번호를 입력하면, 서버는 저장된 번호와 일치하는지, 유효기간이 지나지 않았는지 확인합니다.
- [결과] 검증 결과에 따라 성공 또는 실패를 응답합니다.
이 기본 흐름만 구현해도 기능은 동작합니다. 하지만 실제 서비스를 운영하기 위해서는 몇 가지 잠재적인 위험에 대비해야 합니다.
2. 안전한 구현을 위한 세 가지 고려사항
A. 인증번호 추측 공격 방어
6자리 숫자는 000000부터 999999까지, 총 100만 개의 경우의 수를 가집니다. 만약 공격자가 특정 사용자의 전화번호로 인증을 시도하며 모든 경우의 수를 빠르게 입력한다면 언젠가는 인증이 뚫릴 수 있습니다.
- 해결책: 시도 횟수 제한
- 인증번호 검증 요청이 실패할 때마다 시도 횟수를 기록합니다.
- 일정 횟수(예: 5회) 이상 실패하면, 해당 인증번호를 즉시 만료시키고 더 이상 검증이 불가능하도록 처리해야 합니다.
B. 무차별적 SMS 발송 요청 방지
SMS 발송은 건당 비용이 발생하는 유료 서비스입니다. 만약 공격자가 특정 전화번호 또는 여러 전화번호로 짧은 시간 안에 수만 건의 인증번호 발송을 요청한다면, 막대한 금전적 손실이 발생할 수 있습니다.
- 해결책: 요청 간격 제한 (Rate Limiting)
- 동일 전화번호에 대한 인증번호 발송 요청에 시간 간격(예: 1분)을 둡니다.
- 더 나아가, 동일 IP 주소에서 비정상적으로 많은 발송 요청이 발생할 경우, 일정 시간 동안 해당 IP의 요청을 차단하는 정책을 추가할 수 있습니다.
C. 만료된 인증번호 사용 방지
사용자가 5분 전에 받은 인증번호를 나중에 다시 사용하거나, 한 번 인증에 성공한 번호를 재사용할 수 있다면 보안상 문제가 됩니다.
- 해결책: 짧은 유효기간과 일회성(One-time) 사용
- 인증번호의 유효기간은 3분에서 5분 사이로 짧게 설정하는 것이 일반적입니다.
- 한 번 검증에 성공한 인증번호는 즉시 저장소에서 삭제하여 재사용이 불가능하도록 만들어야 합니다.
3. 백엔드 구현 예시 (NestJS, Redis)
이러한 고려사항들을 실제 코드로 구현한다면 어떤 모습일까요? NestJS와 Redis를 사용하는 간단한 예시입니다. 인증번호처럼 수명이 짧고 조회가 잦은 데이터는 RDB보다 In-memory DB인 Redis에 저장하는 것이 효율적입니다.
데이터 구조 (Redis)
Redis의 Hash 구조를 사용하여 전화번호별로 인증 정보를 관리할 수 있습니다.
- Key: otp:{phoneNumber} (예: otp:01012345678)
- Fields:
- code: "123456" (인증번호)
- attempts: "0" (실패 횟수)
- TTL (Time To Live): 키 자체에 3분(180초)의 만료 시간을 설정합니다.
서비스 로직 예시
// verification.service.ts
import { Injectable } from '@nestjs/common';
import { Redis } from 'ioredis';
@Injectable()
export class VerificationService {
private redisClient: Redis;
private readonly OTP_TTL_SECONDS = 180; // 3분
private readonly MAX_ATTEMPTS = 5;
constructor() {
// ... 레디스 클라이언트 주입 ...
}
async createOtp(phoneNumber: string): Promise<void> {
// 1. Rate Limiting 체크 (이전 요청이 1분 이내였는지 등)
// ...
const code = Math.floor(100000 + Math.random() * 900000).toString();
const key = `otp:${phoneNumber}`;
await this.redisClient.hmset(key, { code, attempts: 0 });
await this.redisClient.expire(key, this.OTP_TTL_SECONDS);
// 2. SMS 발송 서비스 호출
// await smsService.send(phoneNumber, `인증번호: ${code}`);
// 클라이언트에게는 아무것도 반환하지 않습니다.
// 인증번호는 SMS를 통해서만 전달되어야 합니다.
}
async verifyOtp(phoneNumber: string, code: string): Promise<boolean> {
const key = `otp:${phoneNumber}`;
const otpData = await this.redisClient.hgetall(key);
if (!otpData.code) {
// 인증번호가 존재하지 않거나 만료됨
return false;
}
if (Number(otpData.attempts) >= this.MAX_ATTEMPTS) {
// 시도 횟수 초과
await this.redisClient.del(key); // 인증번호 즉시 삭제
return false;
}
if (otpData.code !== code) {
// 인증번호 불일치
await this.redisClient.hincrby(key, 'attempts', 1); // 시도 횟수 증가
return false;
}
// 인증 성공
await this.redisClient.del(key); // 성공 시 즉시 삭제하여 재사용 방지
return true;
}
}
긴 글 읽어주셔서 감사합니다.
'Backend' 카테고리의 다른 글
| 나만의 데코레이터 만들기: 실전 기능 구현 (0) | 2025.08.29 |
|---|---|
| 나만의 데코레이터 만들기: NestJS에서 반복적인 API 설정 줄이기 (0) | 2025.08.27 |
| 서버 부하를 줄이는 이미지 업로드 전략 (3편): 유연한 썸네일 URL 관리 패턴 (0) | 2025.08.24 |
| 서버 부하를 줄이는 이미지 업로드 전략 (2편): Multer 스트리밍 파이프라인 구현 (0) | 2025.08.21 |
| 서버 부하를 줄이는 이미지 업로드 전략 (1편): 역할 분리 (0) | 2025.08.19 |
