개요
사용자가 인증 번호 요청 버튼을 여러 번 클릭하거나 악의적인 요청을 보낼 때, 동일한 번호로 짧은 시간 내에 여러 건의 SMS가 발송되는 것을 방지해야 합니다. 이를 위해 Redis 기반의 Redisson 분산 락을 활용하여 1분간 발송을 제한하는 로직을 구성합니다.
의존성 설정
프로젝트의 pom.xml에 분산 락을 위한 Redisson과 알리운(Aliyun) SMS API 라이브러리를 추가합니다.
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.21.0</version>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dysmsapi20170525</artifactId>
<version>2.0.24</version>
</dependency>
Bean 설정
SMS 클라이언트와 Redisson 클라이언트를 생성하는 설정 클래스를 작성합니다.
@Configuration
public class SmsServiceConfig {
@Bean
@ConditionalOnProperty(prefix = "sms", name = "enabled", havingValue = "true")
public Client aliyunSmsClient(SmsProperties props) throws Exception {
Config config = new Config()
.setAccessKeyId(props.getAccessKey())
.setAccessKeySecret(props.getSecretKey());
return new Client(config);
}
@Bean
public RedissonClient redissonClient(RedissonProperties props) {
Config config = new Config();
config.useSingleServer()
.setAddress(props.getHost())
.setPassword(props.getPassword())
.setDatabase(props.getDbIndex());
return Redisson.create(config);
}
}
SMS 전송 및 분산 락 적용
동일한 비즈니스 타입과 전화번호에 대해 동시성 제어를 수행하여 중복 발송을 차단합니다.
@Service
public class SmsSenderImpl implements SmsSender {
private final RedissonClient redisson;
private final Client smsClient;
public void processSmsDelivery(SmsRequest request) {
String lockKey = String.format("LOCK:SMS:%s:%s", request.getCategory(), request.getPhone());
RLock smsLock = redisson.getLock(lockKey);
try {
if (smsLock.tryLock(5, 10, TimeUnit.SECONDS)) {
SendSmsRequest apiRequest = new SendSmsRequest()
.setPhoneNumbers(request.getPhone())
.setSignName(request.getSign())
.setTemplateCode(request.getTemplate())
.setTemplateParam(request.getParams());
SendSmsResponse response = smsClient.sendSms(apiRequest);
request.setResultCode(response.getBody().getCode());
}
} catch (Exception e) {
log.error("SMS 발송 처리 중 오류 발생: {}", e.getMessage());
request.setResultCode("SYSTEM_ERROR");
} finally {
if (smsLock.isHeldByCurrentThread()) {
smsLock.unlock();
}
}
}
}
인증 번호 발송 및 주기 제한
사용자에게 인증 번호를 보낼 때, Redis에 저장된 제한 키를 확인하여 60초 내 재발송을 금지하고 비동기 방식으로 전송을 처리합니다.
@Service
public class VerificationService {
private final RedisTemplate<String, Object> redisTemplate;
private final RedissonClient redisson;
private final ExecutorService smsExecutor;
public void requestVerificationCode(String bizType, String phoneNumber) {
String limitKey = String.format("SMS:LIMIT:%s:%s", bizType, phoneNumber);
// 1차 체크: 이미 제한 키가 존재하는지 확인
if (Boolean.TRUE.equals(redisTemplate.hasKey(limitKey))) {
throw new CustomException("60초 이내에는 다시 요청할 수 없습니다.");
}
RLock requestLock = redisson.getLock("LOCK:REQ:" + limitKey);
try {
requestLock.lock();
// 2차 체크: Double-Checked Locking 패턴 적용
if (Boolean.TRUE.equals(redisTemplate.hasKey(limitKey))) {
throw new CustomException("이미 요청이 처리 중입니다.");
}
// 발송 제한 키 설정 (60초)
redisTemplate.opsForValue().set(limitKey, "LOCKED", 60, TimeUnit.SECONDS);
// 비동기 전송 로직 실행
smsExecutor.submit(() -> {
String secureCode = String.valueOf((int)((Math.random() * 899999) + 100000));
String codeKey = String.format("SMS:CODE:%s:%s", bizType, phoneNumber);
redisTemplate.opsForValue().set(codeKey, secureCode, 5, TimeUnit.MINUTES);
SmsRequest smsRequest = new SmsRequest(phoneNumber, bizType, secureCode);
processSmsDelivery(smsRequest);
});
} finally {
requestLock.unlock();
}
}
}
인증 번호 검증
저장된 인증 번호와 사용자가 입력한 번호를 비교한 후, 검증에 성공하면 즉시 Redis에서 해당 데이터를 삭제합니다.
public boolean verifySmsCode(String bizType, String phoneNumber, String inputCode) {
String storageKey = String.format("SMS:CODE:%s:%s", bizType, phoneNumber);
String savedCode = (String) redisTemplate.opsForValue().get(storageKey);
if (savedCode == null) {
throw new RuntimeException("만료되었거나 유효하지 않은 인증 번호입니다.");
}
if (savedCode.equals(inputCode)) {
redisTemplate.delete(storageKey);
return true;
}
return false;
}