Tech/Spring

Spring 이메일 인증 구현하기

봄의 개발자 2023. 9. 8. 00:00
728x90
반응형

프로젝트 진행하면서 회원가입과 비밀번호 찾기 기능에서 이메일 인증을 적용하기로 했다. 처음에는 MySQL을 사용해해서 엔티티를 만들고 해당 엔티티의 상태를 나타내는 필드를 Enum으로 만들어 사용했다. 하지만 팀 내 회의를 통해 만료 기간을 지정할 수 있는 Redis를 사용하기로 결정했다.

 

 

이 글에서는 두 가지 방법을 비교할 예정이다. 먼저 엔티티 클래스를 비교할 것이다.

1. Entity 비교

1) MySQL 사용 

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class EmailCode {

    @Id
    @Column(name = "email_code_id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 25)
    private String email;

    @Column(nullable = false, length = 7)
    private String verifyCode;

    @Column(nullable = false)
    private EmailCodeStatusEnum emailCodeStatus;

    public void changeEmailCodeStatus(EmailCodeStatusEnum emailCodeStatus) {
        this.emailCodeStatus = emailCodeStatus;
    }
    
    public void changeVerifyCode(String verifyCode) {
        this.verifyCode = verifyCode;
    }
}

 

아래 코드는 이메일 코드 상태를 나타내는 Enum 클래스이다. 만약 완료됨 상태라면 이메일로 새로운 코드를 발급받는다. 보류 중이라면 이메일을 확인하라는 메시지가 뜨도록 설계했다. 이에 대한 자세한 코드는 아래에서 확인할 예정이다.

@AllArgsConstructor
@Getter
public enum EmailCodeStatusEnum {
    COMPLETED("완료됨"), PENDING("보류중");
    private String value;
}

 

2) Redis 사용

@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@RedisHash(value = "emailCode", timeToLive = 210)
public class EmailCode {

    @Id
    @Indexed
    private String email;

    private String verifyCode;
}

EmailCodeStatusEnum와 같은 필드가 없어지고 Redis에 적용되는 새로운 EmailCode를 생성했다. 

이때 이메일 인증 제한 시간을 3분으로 결정했기 때문에 통신 과정을 고려해 3분 30초로 결정했다.

 

 

2. Repository

두 개의 차이는 상속받은 Repository 클래스이다. MySQL을 사용하는 경우 JpaRepository를, Redis는 CrudRepository를 상속받는다.

1) MySQL

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

2) Redis

public interface EmailCodeRepository extends CrudRepository<EmailCode, Long> {
    Optional<EmailCode> findByEmail(String email);
}

 

3.  Service 

서비스 코드도 비슷하다. 해당 엔티티를 저장하는 형태가 변경되고 MySQL을 사용할 때는 이메일 코드의 상태를 검사하는 로직이 추가되어 보기에 더 복잡해 보인다.

1) MySQL

@Service
@RequiredArgsConstructor
public class EmailCodeServiceImpl implements EmailCodeService {
    private final JavaMailSender javaMailSender;
    private final EmailCodeRepository emailCodeRepository;

    @Override
    @Transactional
    public SendEmailCodeRespDto sendEmail(String email) {

        EmailCode findEmailCode = emailCodeRepository.findByEmail(email).orElse(null);
        String verifyCode = null;
        if (findEmailCode != null) {
            if (findEmailCode.getEmailCodeStatus() == EmailCodeStatusEnum.PENDING)
                throw new CustomApiException("이메일 수신합을 확인하세요");
            else if (findEmailCode.getEmailCodeStatus() == EmailCodeStatusEnum.COMPLETED) {
                verifyCode = sendVerifyCodeByEmail(email);
                findEmailCode.changeEmailCodeStatus(EmailCodeStatusEnum.PENDING);
                findEmailCode.changeVerifyCode(verifyCode);
            }
        }
        else {
            verifyCode = sendVerifyCodeByEmail(email);
            emailCodeRepository.save(EmailCode.builder()
                    .email(email)
                    .verifyCode(verifyCode)
                    .emailCodeStatus(EmailCodeStatusEnum.PENDING) // 보류 중
                    .build());
        }

        return new SendEmailCodeRespDto(verifyCode);
    }

    private String sendVerifyCodeByEmail(String email) {
        MimeMessage mimeMessage = javaMailSender.createMimeMessage();
        String verifyCode = generateRandomString();
        MimeMessageHelper mimeMessageHelper = null;
        try {
            mimeMessageHelper = new MimeMessageHelper(mimeMessage, false, "UTF-8");

            mimeMessageHelper.setTo(email); // 메일 수신자
            mimeMessageHelper.setSubject("이메일 인증 코드"); // 메일 제목
            mimeMessageHelper.setText("인증 코드를 입력하세요: " + verifyCode); // 메일 본문 내용

            javaMailSender.send(mimeMessage);
        } catch (MessagingException e) {
            throw new CustomApiException("이메일 전송을 실패했습니다");
        }
        return verifyCode;
    }

    @Override
    @Transactional
    public void checkVerifyCode(String email, String verifyCode) {
        EmailCode findEmailCode = findEmailCodeByEmailOrElseThrowEx(emailCodeRepository, email);

        if (!isEqualsVerifyCode(verifyCode, findEmailCode)) {
            throw new CustomApiException("이메일 인증에 실패했습니다");
        }

        EmailCodeStatusEnum status = findEmailCode.getEmailCodeStatus();
        if (status == EmailCodeStatusEnum.COMPLETED) {
            throw new CustomApiException("이미 인증된 코드입니다");
        } else if (status == EmailCodeStatusEnum.PENDING) {
            findEmailCode.changeEmailCodeStatus(EmailCodeStatusEnum.COMPLETED);
        }
    }

    private boolean isEqualsVerifyCode(String verifyCode, EmailCode findEmailCode) {
        return findEmailCode.getVerifyCode().equals(verifyCode);
    }


    // 인증 코드 생성 (7글자)
    private static String generateRandomString() {
        String characters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890";
        StringBuilder randomString = new StringBuilder();
        int length = 7;

        Random random = new Random();
        for (int i = 0; i < length; i++) {
            int index = random.nextInt(characters.length());
            randomString.append(characters.charAt(index));
        }

        return randomString.toString();
    }
}

코드에서 볼 수 있듯이 예외 처리 과정이 복잡하다.

 

2) Redis

@Service
@RequiredArgsConstructor
public class EmailCodeServiceImpl implements EmailCodeService {
    private final JavaMailSender javaMailSender;
    private final EmailCodeRepository emailCodeRepository;

    @Override
    @Transactional
    public SendEmailCodeRespDto sendEmail(String email) {

        EmailCode findEmailCode = emailCodeRepository.findByEmail(email).orElse(null);
        if (findEmailCode != null)
            throw new CustomApiException("이메일 수신함을 확인하세요");

        String verifyCode = sendVerifyCodeByEmail(email);
        emailCodeRepository.save(EmailCode.builder()
                .email(email)
                .verifyCode(verifyCode)
                .build());

        return new SendEmailCodeRespDto(verifyCode);
    }

    private String sendVerifyCodeByEmail(String email) {
        MimeMessage mimeMessage = javaMailSender.createMimeMessage();
        String verifyCode = generateRandomString();
        MimeMessageHelper mimeMessageHelper = null;
        try {
            mimeMessageHelper = new MimeMessageHelper(mimeMessage, false, "UTF-8");

            mimeMessageHelper.setTo(email); // 메일 수신자
            mimeMessageHelper.setSubject("이메일 인증 코드"); // 메일 제목
            mimeMessageHelper.setText("인증 코드를 입력하세요: " + verifyCode); // 메일 본문 내용

            javaMailSender.send(mimeMessage);
        } catch (MessagingException e) {
            throw new CustomApiException("이메일 전송을 실패했습니다");
        }
        return verifyCode;
    }

    @Override
    @Transactional
    public void checkVerifyCode(String email, String verifyCode) {
        EmailCode findEmailCode = findEmailCodeByEmailOrElseThrowEx(emailCodeRepository, email);

        if (!isEqualsVerifyCode(verifyCode, findEmailCode))
            throw new CustomApiException("이메일 인증에 실패했습니다");
    }

    private boolean isEqualsVerifyCode(String verifyCode, EmailCode findEmailCode) {
        return findEmailCode.getVerifyCode().equals(verifyCode);
    }


    // 인증 코드 생성 (7글자)
    private static String generateRandomString() {
        String characters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890";
        StringBuilder randomString = new StringBuilder();
        int length = 7;

        Random random = new Random();
        for (int i = 0; i < length; i++) {
            int index = random.nextInt(characters.length());
            randomString.append(characters.charAt(index));
        }

        return randomString.toString();
    }
}

비교적 코드가 간단해졌다. 이메일 코드의 상태를 확인할 필요가 없으므로 코드가 같은지만 검사하면 된다.

 

☑️ 추가 문제

Redis사용할 때 Repository를 구현하는 중 에러가 발생했다.

Redis Repository가 Bean으로 등록되지 않는 이슈였다.

Error creating bean with name 'emailCodeRepository' defined in EmailCodeRepository defined in @EnableRedisRepositories declared on RedisRepositoriesRegistrar.EnableRedisRepositoriesConfiguration: Invocation of init method failed; nested exception is org.springframework.data.mapping.MappingException: Entity com.example.login.entity. Token requires to have an explicit id field. Did you forget to provide one using @Id?

Redis에 저장할 이메일 코드 객체의 @Id 어노테이션 import를 잘못해서 발생한 에러였다. 

javax.persistence.Id 대신 org.springframework.data.annotation.Id를 import하여 해결했다.

 

 

📧  인증 메일 전송

이메일 인증 구현을 성공했다.

728x90
반응형