인증 메일 관리

이전 글에서 인증 번호를 담은 메일 발송까지는 구현했다.

이제 인증번호를 DB에 저장하고, 사용자가 입력한 인증번호와 대조하여 인증하는 로직을 작성해야 한다.

인증메일 저장 및 인증 로직 구현에는 아래의 두 가지 방법이 있을것 같다.

  • Redis에 저장하고 만료시간 설정을 해둬서 자동으로 오래된 인증 메일이 삭제되게 관리
  • 관계형 DB에 저장하고 주기적으로 오래된 인증 메일을 삭제하는 스케줄러 작성

주기적으로 오래된 인증 메일을 삭제하는건 조건 설정하기 귀찮을것 같아서 레디스를 써서 구현해보려 한다.

스케줄러를 사용하는건 다음에 해봐야지.


사용 기술

  • Spring Boot 3.2.4 / gradle-kotlin
  • Java 17
  • SpringDataRedis
  • Gmail

1. 사전작업 - Redis 설치

레디스를 쓰려면 우선 EC2든 로컬이든 레디스가 깔려있어야 한다.

인텔리제이 얼티밋 버전은 따로 DB 연결을 관리하는지는 모르겠지만 본인은 흙수저 버전이므로 직접 설치해야 한다.

레디스 설치 및 기본 사용법은 이 글을 참고한다.


2. 스프링부트와 Redis 연동을 위한 설정

2-1. 의존성 추가

스프링 부트에서 레디스를 쓰기 위해 의존성을 추가해줘야한다.

// build.gradle.kts
// 레디스
implementation("org.springframework.boot:spring-boot-starter-data-redis")

2-2. yml 파일 설정

위 의존성을 추가했다면 기본 설정이 돼있어서 yml과 config 설정을 딱히 안해줘도 된다.

그래서 2-2, 2-3은 사실 안해도 되는 설정이긴 하다.

만약 호스트와 포트 번호를 커스텀해서 쓰려면 2-2, 2-3 설정을 하면 된다.

@ConfigurationProperties로 환경 변수 읽어서 설정할거라 당장은 yml의 depth는 딱히 상관없을듯.

// application.yml
spring:
  main:
    allow-bean-definition-overriding: true // Redis Bean 오버라이딩 허용
  data:
    redis:
      host: localhost  // 호스트, localhost = 127.0.0.1 이므로  입력하든 상관없음
      port: 6379       // 포트번호(레디스 기본값이 6379)

2-3. RedisConfig 추가

일단은 RedisTemplate만 쓸거라 생각하기 때문에 아래처럼 설정했다.

다른데서 보면 그냥 RedisConnectionFactory 부분에서

return new LettuceConnectionFactiory(host, port) 만 해도 되던데 난 안됐다.

RedisConnectionFactory를 빈으로 등록해서 써봤는데도 안돼서 connectionFactory.start()를 추가하니까 되더라.

@RequiredArgsConstructor
@ConfigurationProperties(prefix = "spring.data.redis")
public class RedisConfig {

    private final String host;  // localhost
    private final int port;     // 6379

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

    public RedisConnectionFactory redisConnectionFactory() {
        LettuceConnectionFactory connectionFactory = new LettuceConnectionFactory(host, port);
        connectionFactory.start();
        return connectionFactory;
    }
}

3. MailService 구현

3-1. MailService에서 Redis로 메일 저장

RedisConfig에서 빈 등록한 RedisTemplate를 사용한다.

redisTemplate.opsForValues() 메서드로 ValueOperations를 통해 레디스 DB에 저장한다.

데이터의 만료시간은 3분으로 두고 3분이 지나면 자동 삭제된다.

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

    private final MailConfig mailConfig;
    private final JavaMailSender mailSender;
    private final RedisTemplate<String, String> redisTemplate;

    @Transactional
    public void sendVerificationCode(String toEmail) throws MessagingException, UnsupportedEncodingException {
        String verificationCode = generateVerificationCode();
        ValueOperations<String, String> values = redisTemplate.opsForValue();
        values.set(toEmail, verificationCode, Duration.ofMinutes(3));
        MimeMessage message = createMessage(toEmail, verificationCode);
        try {
            mailSender.send(message);
        } catch (RuntimeException e) {
            log.error("[메일 전송 실패]");
            throw new MailSendException("메일 전송에 실패했습니다.");
        }
    }
}

3-2. MailService에서 Redis로 인증번호 확인

ValueOperations를 통해 레디스 DB에 저장된 데이터를 조회한다.

만약 인증메일과 인증번호가 일치하면 만료시간 5분짜리 인증 확인용 데이터를 추가한다.

public void verifyCode(String toEmail, String code) {
    ValueOperations<String, String> values = redisTemplate.opsForValue();
    String savedVerificationCode = values.get(toEmail);
    boolean isVerified = code.equals(savedVerificationCode);
    if (isVerified) {
        values.set(toEmail, "ok", Duration.ofMinutes(5));
    } else {
        log.error("[메일 인증 실패]");
        throw new InvalidCodeException();
    }
}

3-3. 테스트코드 작성

테스트 작성 시 레디스를 Mocking해서 테스트할지, 실제 레디스에 저장해서 테스트할지 고민했다.

2-2, 2-3처럼 따로 Bean 오버라이딩을 하니까 MockBean 주입할 방법을 못찾아서 실제 레디스를 쓰는 테스트를 작성했다.

어차피 각 테스트 후에 DB를 flush하기 때문에 당장은 큰 문제가 없을거라고 생각한다.

아마 2-2, 2-3을 생략하면 MockBean 주입해서 Mocking도 가능할듯.

MailServiceTest

@Autowired
private RedisTemplate<String, String> redisTemplate;

@Autowired
private MailVerificationService mailVerificationService;

@AfterEach
void tearDown() {
    redisTemplate.execute((RedisCallback<Object>) connection -> {
        connection.serverCommands().flushDb();
        return null;
    });
}

@DisplayName("이메일 전송 성공 시 인증 번호가 redis에 저장된다.")
@Test
void sendEmail3() throws MessagingException, UnsupportedEncodingException {
    // given
    String toEmail = "test@example.com";
    MimeMessage mimeMessage = new MimeMessage((Session) null);
    when(mailSender.createMimeMessage()).thenReturn(mimeMessage);

    // when
    mailVerificationService.sendVerificationCode(toEmail);
    String result = redisTemplate.opsForValue().get(toEmail);

    // then
    assertThat(result).isNotNull();
    assertThat(result.length()).isEqualTo(6);
}

@DisplayName("올바른 인증번호를 입력하면 redis에 성공 여부를 저장한다.")
@Test
void verifyCode1() {
    // given
    String email = "test@example.com";
    String verificationCode = "123456";
    ValueOperations<String, String> values = redisTemplate.opsForValue();
    values.set(email, verificationCode, Duration.ofMinutes(3));

    // when
    mailVerificationService.verifyCode(email, verificationCode);
    String result = values.get(email);

    // then
    assertThat(result).isNotNull();
    assertThat(result).isEqualTo("ok");
}

@DisplayName("틀린 인증번호를 입력하면 예외를 던진다.")
@Test
void verifyCode2() {
    // given
    String email = "test@example.com";
    String verificationCode = "123456";
    String WrongCode = "111111";
    ValueOperations<String, String> values = redisTemplate.opsForValue();
    values.set(email, verificationCode, Duration.ofMinutes(3));

    // expected
    assertThatThrownBy(() -> mailVerificationService.verifyCode(email, WrongCode))
        .isInstanceOf(InvalidCodeException.class)
        .hasMessage("인증번호를 확인해주세요.");
}

@DisplayName("메일 인증을 요청하지 않은 이메일로 인증 번호를 입력하면 예외를 던진다.")
@Test
void verifyCode3() {
    // given
    String email = "test@example.com";
    String notRequestedEmail = "midcon@nav.com";
    String verificationCode = "123456";
    ValueOperations<String, String> values = redisTemplate.opsForValue();
    values.set(email, verificationCode, Duration.ofMinutes(3));

    // expected
    assertThatThrownBy(() -> mailVerificationService.verifyCode(notRequestedEmail, verificationCode))
        .isInstanceOf(InvalidCodeException.class)
        .hasMessage("인증번호를 확인해주세요.");
}

4. 결과 확인

서비스 레이어를 작성했으니 컨트롤러는 원하는대로 작성해서 테스트하면 된다.

인증 메일 발송

인증 메일 발송 후 redis-cli 확인

포스트맨으로 인증번호 입력 후 redis-cli 확인

이메일 인증

팀 프로젝트에서 회원가입 기획에서 이메일 인증 기능을 넣기로 했다.

자체 메일 서버를 구축하기 보다는 메일 서버로 Gmail을 이용해서 보내려고 한다.

이번 글에서는 이메일을 발송하기까지만 다루도록 한다.

발송한 이메일을 DB에 저장을 해서 그걸 확인하고 인증하는 과정도 필요하다.

그걸 어떻게 구현할지 아래 두가지 방법 중 고민인데 아직 미정이라서 추후 추가할듯.

  • Redis를 써서 시간 설정을 해두고 자동으로 삭제되게 관리
  • 관계형 DB를 써서 주기적으로 오래된 인증 메일을 삭제

사용 기술

  • Spring Boot 3.2.4 / gradle-kotlin
  • Java 17
  • Gmail

1. 사전작업 - SMTP 계정 설정

1-1. 구글 계정 관리로 이동

Gmail을 메일 서버로 이용하기 위해 구글 계정의 앱 비밀번호가 필요하다.

구글 로그인 → 구글 계정 관리 → 검색창에 “앱 비밀번호” 검색해서 이동한다.

앱 비밀번호가 안뜨면 2단계 인증을 하지 않았을 수 있으므로 하도록 한다.

1-2. 앱 비밀번호 만들기

원하는 앱 이름을 입력하고 만든다.

1-3. 앱 비밀번호 발급

이 비밀번호를 이용해서 메일을 보낼 예정이니 잘 저장해둔다.


2. JavaMailSender 설정

2-1. 의존성 추가

이메일 전송을 위해 spring-boot-starter-mail의 의존성을 추가한다.

// build.gradle.kts
// 이메일 인증
implementation ("org.springframework.boot:spring-boot-starter-mail")

2-2. yml 파일 설정

이메일 전송을 위한 yml 설정을 추가한다.

본인이 쓰기 편한 dept로 설정한다.

본인은 @ConfigurationProperties를 쓰기 위해 smptProperties depth에 필요 설정을 몰아뒀음.

아래에서 바꿀건 username, password 뿐일듯

// application.yml
gmail:
  host: smtp.gmail.com            # 구글 이메일 사용 시
  port: 587                       # 포트번호
  username: hyukkind@gmail.com    # 구글 이메일, 다른 메일일 시 MailAuthenticationException 발생
  password: utnncuqkptaweaag      # 앱 비밀번호
  smtpProperties:
      auth: true
      starttls-enable: true
      starttls-required: true
      connection-timeout: 5000
      timeout: 5000
      writeTimeout: 5000

2-3. MailConfig 추가

JavaMailSender를 설정하는 방법은 여러가지가 있겠지만

본인은 MailConfig에서 JavaMailSender의 Bean 주입을 해주었다.

yml파일의 환경 변수를 가져오는건 @Value로 하든 @ConfigurationProperties로 하든 취향대로 하자.

기본적인 골자는 JavaMailSender의 property 설정만 아래처럼 Properties 객체로 넣어주면 된다.

@RequiredArgsConstructor
@ConfigurationProperties(prefix = "gmail")
public class MailConfig {

    private final String host;
    private final int port;
    private final String username;
    private final String password;
    private final SmtpProperties smtpProperties;

    public String getFromEmail(){
        return username;
    }

    @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(getProperties());
        return mailSender;
    }

    private Properties getProperties() {
        Properties properties = new Properties();
        properties.put("mail.smtp.auth", smtpProperties.auth);
        properties.put("mail.smtp.starttls.enable", smtpProperties.starttlsEnable);
        properties.put("mail.smtp.starttls.required", smtpProperties.starttlsRequired);
        properties.put("mail.smtp.connectiontimeout", smtpProperties.connectionTimeout);
        properties.put("mail.smtp.timeout", smtpProperties.timeout);
        properties.put("mail.smtp.writetimeout", smtpProperties.writeTimeout);
        return properties;
    }

    @Getter
    @ToString
    @RequiredArgsConstructor
    private static class SmtpProperties {
        private final boolean auth;
        private final boolean starttlsEnable;
        private final boolean starttlsRequired;
        private final int connectionTimeout;
        private final int timeout;
        private final int writeTimeout;
    }
}

3. MailService 구현

3-1. MailService 구현

본인은 테스트용 Mock 구현체를 만들 필요가 있지 않을까 싶어서 MailService 인터페이스를 만들었지만

굳이 MailService를 인터페이스로 만들어서 타입 계층을 만들 필요는 없다.

바로 MailService 구현체를 만들어도 무방함.

인증번호는 6자리 랜덤 숫자를 발급하기로 했다.

메일 발송까지만 구현했고, DB에 저장하고 검증하는 로직은 추후 추가 예정이다.

MailVerificationService

@Slf4j
@Service
@RequiredArgsConstructor
public class MailVerificationService implements MailService {

    private final MailConfig mailConfig;
    private final JavaMailSender mailSender;

    @Override
    public void sendEmail(String toEmail) throws MessagingException, UnsupportedEncodingException {
        MimeMessage message = createEmailVerificationMessage(toEmail);
        try {
            mailSender.send(message);
        } catch (RuntimeException e) {
            log.error("[메일 전송 실패]");
            throw new MailSendException("메일 전송에 실패했습니다.");
        }
    }

    private MimeMessage createEmailVerificationMessage(String toEmail) throws MessagingException, UnsupportedEncodingException {
        MimeMessage message = mailSender.createMimeMessage();
        MimeMessageHelper helper = new MimeMessageHelper(message);
        helper.setFrom(mailConfig.getFromEmail(), "나는짱");
        helper.setTo(toEmail);
        helper.setSubject("PAWLAND 이메일 인증");
        String template = createTemplate();
        helper.setText(template, true);
        return message;
    }

    private String createTemplate() {
        String template = "<html><body>";
        template += "<p>안녕하세요, PAWLAND입니다.</p>";
        template += "<p>인증 번호는 아래와 같습니다:</p>";
        template += "<h2>" + generateVerificationCode() + "</h2>";
        template += "<p>이 인증 번호를 입력하여 인증을 완료해주세요.</p>";
        template += "</body></html>";
        return template;
    }

    private String generateVerificationCode() {
        Random random = new Random();
        int randomNumber = random.nextInt(999999) + 1;
        return String.format("%06d", randomNumber);
    }
}

3-2. MailService 테스트

테스트마다 메일이 매번 날아온다면 내 메일함에 테스트용 메일이 가득할 것이라 바람직하지 않다.

그래서 JavaMailSender를 Mocking해서 테스트를 진행한다.

인증번호 생성 로직은 private 메서드라서 어떻게 테스트할까 생각해보았다.

간단한 로직이라 테스트할 필요가 있을까 싶기도 하고 인증번호가 이상하다고 큰 문제가 생기진 않을것 같아서

굳이 추가하지는 않았다.

MailVerificationServiceTest

@SpringBootTest
class MailVerificationServiceTest {

    @Autowired
    private MailConfig mailConfig;

    @MockBean
    private JavaMailSender mailSender;

    @Autowired
    private MailVerificationService mailVerificationService;

    @DisplayName("이메일 전송 성공 Mock 테스트")
    @Test
    void sendEmail1() throws MessagingException, UnsupportedEncodingException {
        // given
        String toEmail = "test@example.com";
        MimeMessage mimeMessage = new MimeMessage((Session) null);
        when(mailSender.createMimeMessage()).thenReturn(mimeMessage);

        // when
        mailVerificationService.sendEmail(toEmail);

        // then
        verify(mailSender).send(mimeMessage);
        verify(mailSender, times(1)).send(mimeMessage);
    }

    @DisplayName("이메일 전송 실패 Mock 테스트")
    @Test
    void sendEmail2() {
        // given
        String toEmail = "test@example.com";
        MimeMessage mimeMessage = new MimeMessage((Session) null);
        when(mailSender.createMimeMessage()).thenReturn(mimeMessage);
        doThrow(new MailAuthenticationException("Authentication failed"))
            .when(mailSender)
            .send(mimeMessage);

        // expected
        assertThatThrownBy(() -> mailVerificationService.sendEmail(toEmail))
            .isInstanceOf(MailSendException.class)
            .hasMessage("메일 전송에 실패했습니다.");
    }
}

4. 결과 확인

+ Recent posts