본문 바로가기
스프링

Spring Boot로 이메일 인증 API 설계 및 구현: 레디스와 비동기화를 활용한 최적화

by 순원이 2024. 1. 24.

제가 개발중인 대학교 네트워킹 플랫폼신분 인증을 위해 이메일 인증이 필수적인 요소입니다. 본 포스팅에서는 Spring Boot를 활용하여 이메일 인증 시스템을 구현, 레디스를 활용하여 인증시간 단축 API 호출회수 제한비동기화를 도입하여 사용자 경험 개선한 사례를 설명드리겠습니다.다.

주요 목표

  1. 이메일 인증 번호 발송 시스템 설계 및 구현
  2. Redis를 활용한 인증 번호 관리
  3. API 호출 횟수 제한을 통해 시스템 안정성 강화

인증 이메일을 보낼 이메일(Google, Naver ...)마다 다르겠지만 추가적인 이메일 설정이 필요합니다. 저는 Gmail을 사용했습니다. Gmail 설정은 이 링크를 참고하세요! 


Gradle

이메일 전송을 위해 Spring Boot Starter Mail 의존성을 추가합니다.

    implementation'org.springframework.boot:spring-boot-starter-mail'

1. 기본 구현

Mailconfig

JavaMailSender를 설정합니다. Gmail SMTP 서버를 사용하며, 이메일 전송 시 SSL/TLS를 활성화합니다.

@Configuration
public class MailConfig {

    @Value("${email.id}")
    private String fromId;

    @Value("${email.password}")
    private String password;


    @Bean
    public JavaMailSender getJavaMailSender() {
        JavaMailSenderImpl mailSender = new JavaMailSenderImpl();

        mailSender.setHost("smtp.gmail.com");
        mailSender.setPort(465);
        mailSender.setUsername(fromId);
        mailSender.setPassword(password);

        Properties props = mailSender.getJavaMailProperties();
        props.put("mail.transport.protocol", "smtp");
        props.put("mail.smtp.auth", "true");
        props.put("mail.smtp.ssl.enable", "true");
        props.put("mail.debug", "true");
        return mailSender;
    }
}
  •  @Value로 설정되어 있는 값은 yml에 설정하시면 됩니다
  • smtp 프로트콜 이용 (465 포트번호) 
  •  JavaMailSender 객체를 생성하여. 이 객체를 사용해서 이메일을 보내는 것입니다.

RandomGenerator

@Component
public class RandomGenerator {

    private static final Random random = new Random();
    public int makeRandomNumber() {
        // 난수의 범위 111111 ~ 999999 (6자리 난수)
        return  random.nextInt(888888) + 111111;
    }
}

 

SendMailService

@Service
@RequiredArgsConstructor
public class SendMailService {

    private final JavaMailSender mailSender;

    @Value("${email.id}")
    private String fromId;

    @Async("mailExecutor")
    public void sendEmail(String to, String subject, String content) {
        MimeMessagePreparator messagePreparator =
                mimeMessage -> {
                    //true는 멀티파트 메세지를 사용하겠다는 의미
                    final MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8");
                    helper.setFrom(fromId);
                    helper.setTo(to);
                    helper.setSubject(subject);
                    helper.setText(content, true);
                };
        mailSender.send(messagePreparator);
    }


}

2. 레디스를 이용한 인증번호 저장

이메일 인증에서는 인증번호를 저장하고 검증하는 과정이 필수적입니다. 이를 위해 관계형 데이터베이스 대신 **레디스(Redis)**를 사용했습니다. 레디스는 인메모리 데이터베이스로, 인증번호와 같은 단기 데이터를 저장하고 빠르게 조회하는 데 적합합니다.

왜 레디스를 사용했는가?

  1. 속도: 인증번호 검증은 빈번히 발생하며 빠른 처리가 요구됩니다. 레디스는 관계형 데이터베이스보다 빠르게 처리할 수 있습니다.
  2. TTL(Time-To-Live): 레디스를 사용하면 인증번호의 유효기간을 설정할 수 있어, 만료된 데이터를 자동으로 제거할 수 있습니다.
  3. 기존 사용 환경: 이미 프로젝트에서 레디스를 사용하고 있었기 때문에 자연스럽게 레디스를 활용했습니다.

인증번호 저장 코드

@Service
@RequiredArgsConstructor
public class AuthMailService {

    private static final Long EXPIRATION = 1800000L; // 30분
    private final RedisUtil redisUtil;
    private final RandomGenerator randomGenerator;
    private final SendMailService sendMailService;

    @Transactional
    public AuthNumberResponse sendCodeEmail(String email, Long key) {
        int authNumber = randomGenerator.makeRandomNumber();
        String title = "회원 가입 인증 이메일입니다.";
        String content = "인증 번호는 " + authNumber + "입니다.";

        // 이메일 전송
        sendMailService.sendEmail(email, title, content);

        // 레디스에 인증번호 저장
        redisUtil.saveAuthNumber(key, String.valueOf(authNumber), EXPIRATION);
        return new AuthNumberResponse(authNumber);
    }
}

RedisUtil 클래스 예시

@Component
@RequiredArgsConstructor
public class RedisUtil {

    private final RedisTemplate<String, String> redisTemplate;

      //이메일 emailAuthNumber 관련 redis 명령어
    public void saveAuthNumber(Long key, String emailAuthNumber, Long expiration) {
        redisTemplate.opsForValue().set("AuthNumber"+String.valueOf(key), emailAuthNumber, expiration, TimeUnit.MILLISECONDS);
    }

    public Object findEmailAuthNumberByKey(Long key) {
        return redisTemplate.opsForValue().get("AuthNumber"+String.valueOf(key));
    }
}

3. 비동기화로 사용자 경험 향상

비동기 처리의 필요성

  • 이슈: 이메일 전송에 평균적으로 5초가 소요되어 클라이언트의 요청 응답이 지연됨.
  • 해결: 비동기 처리로 메인 스레드의 작업을 방해하지 않고, 전송 작업을 별도 스레드에서 수행.

구현 방법

Spring의 @Async를 활용하여 비동기 처리했습니다. 별도의 스레드풀을 구성해 성능을 최적화했습니다.

비동기 메일 전송 코드

@Service
@RequiredArgsConstructor
public class SendMailService {

    private final JavaMailSender mailSender;

    @Async("mailExecutor")
    public void sendEmail(String to, String subject, String content) {
        MimeMessagePreparator messagePreparator = mimeMessage -> {
            MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8");
            helper.setTo(to);
            helper.setSubject(subject);
            helper.setText(content, true);
        };
        mailSender.send(messagePreparator);
    }
}

스레드풀 구성

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    @Bean(name = "mailExecutor")
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(2);
        executor.setMaxPoolSize(5);
        executor.setQueueCapacity(10);
        executor.setThreadNamePrefix("Async-MailExecutor-");
        executor.initialize();
        return executor;
    }
}

4. API 호출 횟수 제한으로 안정성 강화

이메일 인증 API는 사용자 요청에 따라 호출되며, 과도한 호출은 서버에 부하를 줄 수 있습니다. 이를 방지하기 위해 레디스를 활용해 호출 횟수를 제한했습니다.

호출 제한 설계

  • 제한 조건: 30분 동안 최대 10회 호출 가능.
  • 구현 방식: 사용자의 고유 키(예: Provider ID)를 기준으로 호출 횟수를 저장하고 관리.

호출 제한 코드

@Service
@RequiredArgsConstructor
public class RateLimitService {

    private static final int MAX_API_CALL = 10;
    private static final Long EXPIRATION = 1800000L; // 30분
    private final RedisUtil redisUtil;

    public boolean checkAPICall(Long key) {
        Object apiCall = redisUtil.findAPICallByKey(key);

        if (apiCall == null) {
            redisUtil.saveAPICall(key, "1", EXPIRATION);
            return true;
        } else if (Integer.parseInt((String) apiCall) < MAX_API_CALL) {
            redisUtil.updateAPICall(key, String.valueOf(Integer.parseInt((String) apiCall) + 1));
            return true;
        }
        return false;
    }
}

호출 제한 적용

@Transactional
public AuthNumberResponse sendCodeEmail(String email, Long key) {
    if (rateLimitService.checkAPICall(key)) {
        return sendAuthCode(email, key);
    }
    throw new CustomException(HttpStatus.TOO_MANY_REQUESTS, "Too many requests.");
}

5. 결론

비동기화 처리로 응답시간이 5s -> 20ms 로 250배로 성능향상을 하였고, 시스템의 안정성을 위해 redis를 통해 호출 횟수 제한하였습니다.

결과

 


6. 이메일 인증 API 향후 개선 방향

  1. 스케일링을 고려한 Redis 클러스터링 도입
    • 현재 단일 Redis를 활용해 인증번호와 호출 제한을 관리하고 있으나, 서비스 확장 시 Redis 클러스터링을 통해 데이터 처리 성능과 안정성을 높일 필요가 있습니다.
  2. 대용량 트래픽을 위한 기술 체인지
    • 간단한 구현을 위해 Gmail SMTP를 사용하였으나, 서비스 유저가 많아질시 대용량 트래픽을 감당하기 위해 Amazon SES와 같은 클라우드 서비스를 고려해볼 수도 있습니다.
  3. 사용자 요청 증가 대비 큐 시스템 적용
    • 이메일 발송 요청이 폭주할 경우를 대비해 RabbitMQ 또는 Kafka와 같은 큐 시스템을 도입.
  4. 모니터링 및 알림 시스템 구축
    • 이메일 발송 실패율, 인증번호 입력 실패 횟수 등을 모니터링하여 문제가 발생할 경우 즉각적인 알림을 받을 수 있도록 APM(Application Performance Monitoring) 시스템 도입.
      • 예: New Relic, Datadog, ELK Stack.