인증 메일 관리

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

이제 인증번호를 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 확인

+ Recent posts