[Spring] Google SMTP, JavaMailSender, Redis를 이용하여 이메일 인증 구현하기

박도연·2024년 10월 6일

Spring

목록 보기
1/7
post-thumbnail

*리크루팅 사이트 제작 프로젝트

지원서를 작성할 때, 본인인증을 위한 이메일 인증을 구현하려고 한다. (로그인을 따로 구현하지 않음)

0. Google SMTP란?

SMTP는 SMTP (Simple Mail Transfer Protocol)의 약자로 전자 메일 전송을위한 표준 프로토콜이다.

이메일을 전송하기 위해 google smtp(네이버를 사용하는 방법도 있다.)를 사용해주었는데, 사용 전에 몇 가지 설정을 해주어야한다.

사용할 계정에 구글 로그인 -> Google 계정 관리하기 -> 검색창에 '앱 비밀번호' 검색
계정 관리하기검색창 앱 비밀번호가 뜨지 않는다면 '2단계 인증'을 하지 않은 계정일 수 있으니, 2단계 인증까지 마쳐준다.

앱 비밀번호 생성
생성된 비밀번호는 노출시키지 말고, 따로 저장해둔다.

Gmail 접속하여 IMAP 설정
Gmail에 접속하여 톱니바퀴 모양을 눌러주고 '모든 설정 보기'를 눌러준다.
위와 같이 "IMAP 사용", "자동 삭제 사용", "폴더 크기 제한 - 메일 수 제한 안함"으로 설정해준다.

1. JavaMailSender를 위한 의존성 추가

smtp 설정이 완료되었으면 구현을 시작해보자

build.gradle

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

2. application.yml 작성

spring:
  mail:
    host: smtp.gmail.com
    port: 587                            # Google STMP 포트 번호
    username: your-email@gmail.com       # 발신 계정 이메일 주소
    password: ${EMAIL_PASSWORD}          # 발신 계정 앱 비밀번호
    properties:
      mail:
        smtp:
          auth: true
          starttls:
            enable: true
          connectiontimeout: 5000
          timeout: 5000
          writetimeout: 5000
    auth-code-expiration-millis: 1800000 #인증 코드 만료시간

앱 비밀번호는 ${EMAIL_PASSWORD}로 환경변수 설정을 해주었다.

3. EmailConfig

EmailConfig.java
JavaMailSender 인터페이스를 구현하는 클래스. yml파일에 있는 환경변수들을 사용해준다.

@Configuration
public class EmailConfig {

    @Value("${spring.mail.host}")
    private String host;

    @Value("${spring.mail.port}")
    private int port;

    @Value("${spring.mail.username}")
    private String username;

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

    @Value("${spring.mail.properties.mail.smtp.auth}")
    private boolean auth;

    @Value("${spring.mail.properties.mail.smtp.starttls.enable}")
    private boolean starttlsEnable;

    @Value("${spring.mail.properties.mail.smtp.connectiontimeout}")
    private int connectionTimeout;

    @Value("${spring.mail.properties.mail.smtp.timeout}")
    private int timeout;

    @Value("${spring.mail.properties.mail.smtp.writetimeout}")
    private int writeTimeout;

    @Bean
    public JavaMailSender javaMailSender() {
        JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
        mailSender.setHost(host);
        mailSender.setPort(port);
        mailSender.setUsername(username);
        mailSender.setPassword(password);
        mailSender.setDefaultEncoding("UTF-8");
        mailSender.setJavaMailProperties(getMailProperties());

        return mailSender;
    }

    private Properties getMailProperties() {
        Properties properties = new Properties();
        properties.put("mail.smtp.auth", auth);
        properties.put("mail.smtp.starttls.enable", starttlsEnable);
        properties.put("mail.smtp.connectiontimeout", connectionTimeout);
        properties.put("mail.smtp.timeout", timeout);
        properties.put("mail.smtp.writetimeout", writeTimeout);

        return properties;
    }
}

4. EmailService

EmailService.java
이메일을 발송하는 클래스이다.

@Slf4j
@Transactional(readOnly = true)
@Service
@RequiredArgsConstructor
public class EmailService {

    private final JavaMailSender javaMailSender;

    public void sendEmail(String toEmail, String title, String content) {
        SimpleMailMessage emailForm = createEmailForm(toEmail, title, content);
        try {
            javaMailSender.send(emailForm);
            log.info("이메일 발송 성공");
        } catch (Exception e) {
            log.error("이메일 발송 오류");
        }
    }

    public SimpleMailMessage createEmailForm(String toEmail, String title, String text) {
        SimpleMailMessage message = new SimpleMailMessage();
        message.setTo(toEmail);
        message.setSubject(title);
        message.setText(text);

        return message;
    }
}

5. ApplicantController

ApplicantController.java

@RestController
@RequestMapping("/applicant")
@RequiredArgsConstructor
public class ApplicantController {

    private final ApplicantService applicantService;

    @Operation(summary = "이메일 인증 요청 API")
    @PostMapping("/email/send")
    public ApiResponse<EmailDTO> emailSend(
            @RequestParam("email") String email
    ) throws NoSuchAlgorithmException {
        String vCode = applicantService.sendCodeToEmail(email);
        EmailDTO result = new EmailDTO(email, vCode);
        return ApiResponse.onSuccess(result);
    }

    @Operation(summary = "이메일 인증코드 검증 API")
    @PostMapping("/email/verify")
    public ApiResponse<Void> emailVerify(
            @RequestBody EmailDTO emailDTO
    ){
        boolean isVerified = applicantService.verifiedCode(emailDTO.getEmail(), emailDTO.getVCode());
        if (isVerified) {
            return ApiResponse.onSuccess(null); // 성공 시 null 반환
        } else {
            throw new ExceptionHandler(INVALID_CODE); // 실패 시 실패 응답 반환 (예시)
        }
    }
}

EmailDTO.java

@Getter
public class EmailDTO {
    private String email;
    private String vCode;

    public EmailDTO(String email, String vCode) {
        this.email = email;
        this.vCode = vCode;
    }
}

6. ApplicantService

ApplicantService.java

@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ApplicantService {

    private final ApplicantRepository applicantRepository;

    private final EmailService emailService;
    private final RedisService redisService;

    @Value("${spring.mail.auth-code-expiration-millis}")
    private long authCodeExpirationMillis;

    private static final String AUTH_CODE_PREFIX = "AuthCode ";

	//이메일에 인증코드 전송
    public String sendCodeToEmail(String toEmail) throws NoSuchAlgorithmException {
        this.checkDuplicatedEmail(toEmail);

        String title = "이메일 인증 번호";
        String authCode = this.createCode();
        emailService.sendEmail(toEmail, title, authCode);

       // 인증 번호 Redis에 저장
        redisService.setValues(AUTH_CODE_PREFIX + toEmail,
                authCode, Duration.ofMillis(this.authCodeExpirationMillis));

        return authCode;
    }

	//이메일 중복 검사
    private void checkDuplicatedEmail(String email) {
        Optional<Applicant> applicant = applicantRepository.findByEmail(email);
        if (applicant.isPresent()) {
            log.debug("checkDuplicatedEmail exception occur email: {}", email);
            throw new ExceptionHandler(EMAIL_EXIST);
        }
    }

    //랜덤 인증번호 생성
    private String createCode() throws NoSuchAlgorithmException {
        int lenth = 6;
        try {
            Random random = SecureRandom.getInstanceStrong();
            StringBuilder builder = new StringBuilder();
            for (int i = 0; i < lenth; i++) {
                builder.append(random.nextInt(10));
            }
            return builder.toString();
        } catch (NoSuchAlgorithmException e) {
            log.debug("ApplicantService.createCode() exception occur");
            throw new NoSuchAlgorithmException();
        }
    }

	//인증번호 검증
    public boolean verifiedCode(String email, String authCode) {
        String redisAuthCode = redisService.getValues(AUTH_CODE_PREFIX + email);
        return redisService.checkExistsValue(redisAuthCode) && redisAuthCode.equals(authCode);
    }
}

ApplicationRepository.java

public interface ApplicantRepository extends JpaRepository<Applicant, Long> {
    Optional<Applicant> findByEmail(String email);
}

7. Redis란?

Redis는 Remote Dictionary Server의 약자로, "키-값" 구조의 비정형 데이터를 저장하고 관리하기 위한 오픈 소스 기반의 비관계형 데이터베이스 관리 시스템이다.

이메일 인증 코드와 같은 일시적이고 빠른 액세스가 필요한 데이터의 경우, 디비보다는 Redis에 저장하는 것이 적합하다.

여기서 우리는 key에 AUTH_CODE_PREFIX + Email를, value에 authCode(인증코드)를 저장해준다.

redis mac에 설치

brew install redis

build.gradle 의존성 추가

implementation 'org.springframework.boot:spring-boot-starter-data-redis:3.2.0'

application.yml 작성

spring:
	data:
    	redis:
      		port: 6379
      		host: localhost

RedisConfig.java

@RequiredArgsConstructor
@Configuration
@EnableRedisRepositories
public class RedisConfig {
    private final RedisProperties redisProperties;

    // RedisProperties로 yml에 저장한 host, post를 연결. @Value("${spring.data.redis.host}")이렇게 따로 변수를 설정하지 않아도 됨.
    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(redisProperties.getHost(), redisProperties.getPort());
    }

    
    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        redisTemplate.setConnectionFactory(redisConnectionFactory());

        return redisTemplate;
    }
}

RedisService.java

@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class RedisService {
    private final RedisTemplate<String, Object> redisTemplate;

    public void setValues(String key, String data) {
        ValueOperations<String, Object> values = redisTemplate.opsForValue();
        values.set(key, data);
    }

    public void setValues(String key, String data, Duration duration) {
        ValueOperations<String, Object> values = redisTemplate.opsForValue();
        values.set(key, data, duration);
    }

    @Transactional(readOnly = true)
    public String getValues(String key) {
        ValueOperations<String, Object> values = redisTemplate.opsForValue();
        if (values.get(key) == null) {
            return "false";
        }
        return (String) values.get(key);
    }

    public boolean checkExistsValue(String value) {
        return !value.equals("false");
    }
}

8. 포스트맨 테스트

이렇게 메일도 왔다능

인증코드 검증도 성공 !!

*이메일에 HTML 형식을 보내는 건 나중에 다뤄볼 예정이다.


<참고사이트>
https://green-bin.tistory.com/83
https://velog.io/@kyungmin/Spring-이메일-인증Google
https://jasonoh22.tistory.com/174

profile
도여줄게 완전히 도라진 나

0개의 댓글