Tech/Spring

Spring 이메일 인증 구현하기

봄의 개발자 2023. 9. 8.
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
반응형

댓글