본문 바로가기
프로젝트/낙낙(KnockKnock)

이메일인증을 구현해보자(JavaMailSender)

by 티코딩 2023. 9. 15.

우리 프로젝트에선 회원가입할때, 이메일인증이 되어야 할 수 있도록 만들기로 했다.

그래서 이메일인증과정을 어떻게 만들었는지에대해 코드리뷰겸 정리해보려고 한다.

가장 첫번째 단계는 구글에 들어가 [Google 계정 관리] 에 들어가서, 왼쪽탭에서 [보안]을 누르고, [2단계인증]을 하고, [앱비밀번호]를 만든다. 앱선택에선 메일로, 나머진 자유.

그렇게 생성하면 [기기용 앱 비밀번호]를 알려주는데 따로 보관하자.

그리고 지메일 설정에 들어가서 [모든 설정]을 눌러주고, 내가 체크한곳을 체크하고 넘어가자.

이 다음단계로 application.yml에다가 설정을 해주자.

ㅇ application.yml

 spring:
  mail:
    default-encoding: UTF-8
    host: smtp.gmail.com
    port: 587
    username: 4othersofficial@gmail.com
    password: ${SMTP_CREDENTIALS}
    properties:
      mail:
        smtp:
          auth: true
          starttls:
            enable: true
    protocol: smtp

나는 우리팀공식gmail을 쓸거기때문에, host는 smtp.gmail.com으로 해줬고, username과 pw는 맞춰 써줬다. password에는 위에서 발급받은 기기용 앱 비밀번호를 넣어준다. 

 

그다음은 build.gradle에 의존성을 주입해준다.

ㅇ build.gradle

 그다음은 구조

이런식으로 되어있다.

 

코드를 살펴보자. 먼저 엔티티.

ㅇ EmailConfirmRandomKey

@Entity
@Table(name = "EMAIL_CONFIRM_RANDOM")
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class EmailConfirmRandomKey {
    @Id
    private String email;
    @Column
    private String randomKey;
    @Column
    private Long expiration;
}

이 엔티티는 랜덤키를 정의한다.

 

ㅇ VerifyDto

@Getter
@Builder
@AllArgsConstructor
public class VerifyDto {
    private String tokenOrKey;
    private String email;
    private String password;
}

이 클래스는 이메일 인증키를 확인할 때 사용될 파라미터들이다.

 

ㅇ EmailConfirmRandomKeyRepository

public interface EmailConfirmRandomKeyRepository extends JpaRepository<EmailConfirmRandomKey, String> {
    Optional<EmailConfirmRandomKey> findByEmail(String email);
}

 

ㅇ EmailService

@Service
public class EmailService {
    private final JavaMailSender javaMailSender;
    private final UserRepository userRepository;
    private final EmailConfirmRandomKeyRepository emailConfirmRandomKeyRepository;
    private final TemplateEngine templateEngine;
    private final PasswordEncoder passwordEncoder;

    public EmailService(JavaMailSender javaMailSender, UserRepository userRepository, EmailConfirmRandomKeyRepository emailConfirmRandomKeyRepository, PasswordEncoder passwordEncoder) {
        this.javaMailSender = javaMailSender;
        this.userRepository = userRepository;
        this.emailConfirmRandomKeyRepository = emailConfirmRandomKeyRepository;
        this.passwordEncoder = passwordEncoder;


        // Create and configure the template engine
        templateEngine = new SpringTemplateEngine();
        ClassLoaderTemplateResolver templateResolver = new ClassLoaderTemplateResolver();
        templateResolver.setPrefix("templates/"); // Set the prefix where email templates are located
        templateResolver.setSuffix(".html"); // Set the suffix for email templates
        templateResolver.setTemplateMode("HTML");
        templateResolver.setCharacterEncoding("UTF-8");
        templateEngine.setTemplateResolver(templateResolver);
    }
    public void sendEmail(String recipientEmail, String subject) throws MessagingException {
        try {
            MimeMessage message = javaMailSender.createMimeMessage();
            MimeMessageHelper helper = new MimeMessageHelper(message, true);
            helper.setTo(recipientEmail);
            helper.setSubject(subject);
            String randomKey = generateRandomKey(7);
            // EmailConfirmRandomKey 저장
            EmailConfirmRandomKey confirmRandomKey = EmailConfirmRandomKey.builder()
                    .email(recipientEmail)
                    .randomKey(randomKey)
                    .build();
            emailConfirmRandomKeyRepository.save(confirmRandomKey);
            // Get email template
            String emailTemplate = getEmailTemplate(recipientEmail, randomKey);
            helper.setText(emailTemplate, true);

            javaMailSender.send(message);
            System.out.println("Email Template: " + emailTemplate);
        } catch (MessagingException e) {
            // 예외 처리 로직 작성
            e.printStackTrace(); // 예외 내용을 콘솔에 출력하거나 로깅할 수 있습니다.
            // 예외 처리 후 필요한 작업 수행
        }
    }

    public String generateRandomKey(int keyLength) {
        keyLength = 7;
        String characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
        StringBuilder randomKey = new StringBuilder();

        SecureRandom secureRandom = new SecureRandom();
        for (int i = 0; i < keyLength; i++) {
            int randomIndex = secureRandom.nextInt(characters.length());
            randomKey.append(characters.charAt(randomIndex));
        }

        return randomKey.toString();
    }
    public EmailConfirmRandomKey createEmailConfirmRandomKey(String email) {
        String randomKey = generateRandomKey(7);
        long expiration = 300L; // 5 minutes (in milliseconds)

        EmailConfirmRandomKey emailConfirmRandomKey = EmailConfirmRandomKey.builder()
                .email(email)
                .randomKey(randomKey)
                .expiration(expiration)
                .build();

        emailConfirmRandomKeyRepository.save(emailConfirmRandomKey);

        return emailConfirmRandomKey;
    }

    public void verifyEmail(String tokenOrKey, String email, String password) {
        // 이메일 검증 로직 수행
        EmailConfirmRandomKey emailConfirmRandomKey = emailConfirmRandomKeyRepository.findById(email)
                .orElseThrow(() -> new IllegalArgumentException("유효하지 않은 이메일입니다."));

        String randomKey = emailConfirmRandomKey.getRandomKey();

        if (!randomKey.equals(tokenOrKey)) {
            throw new IllegalArgumentException("인증 코드가 유효하지 않습니다.");
        }
        Optional<User> existingUser = userRepository.findByEmail(email);
        if (existingUser.isPresent()) {
            // 기존 사용자의 정보 업데이트
            User user = existingUser.get();
            user.setPassword(passwordEncoder.encode(password));
            user.setEmailVerifiedYn("Y"); // 이메일 인증 완료로 설정
            userRepository.save(user);
        } else {
            // 새로운 사용자 등록
            User user = User.builder()
                    .email(email)
                    .password(passwordEncoder.encode(password))
                    .emailVerifiedYn("Y") // 이메일 인증 완료로 설정
                    .build();
            userRepository.save(user);
        }
        // 토큰 삭제
        emailConfirmRandomKeyRepository.deleteById(email);
    }
    public String getEmailTemplate(String recipientEmail, String randomKey) {
        try {
            // Create the Thymeleaf context and set variables
            Context context = new Context();
            context.setVariable("recipientEmail", recipientEmail);
            context.setVariable("randomKey", randomKey);

            // Process the email template using the template engine
            String emailTemplate = templateEngine.process("email_template", context);

            return emailTemplate;
        } catch (Exception e) {
            throw new RuntimeException("Failed to process email template.", e);
        }
    }
}

메서드 하나씩 보자.

1. sendEmail()

public void sendEmail(String recipientEmail, String subject) throws MessagingException {
    try {
        MimeMessage message = javaMailSender.createMimeMessage();
        MimeMessageHelper helper = new MimeMessageHelper(message, true);
        helper.setTo(recipientEmail);
        helper.setSubject(subject);
        String randomKey = generateRandomKey(7);
        // EmailConfirmRandomKey 저장
        EmailConfirmRandomKey confirmRandomKey = EmailConfirmRandomKey.builder()
                .email(recipientEmail)
                .randomKey(randomKey)
                .build();
        emailConfirmRandomKeyRepository.save(confirmRandomKey);
        // Get email template
        String emailTemplate = getEmailTemplate(recipientEmail, randomKey);
        helper.setText(emailTemplate, true);

        javaMailSender.send(message);
        System.out.println("Email Template: " + emailTemplate);
    } catch (MessagingException e) {
        // 예외 처리 로직 작성
        e.printStackTrace(); // 예외 내용을 콘솔에 출력하거나 로깅할 수 있습니다.
        // 예외 처리 후 필요한 작업 수행
    }
}

파라미터로 email, 이메일제목을 받는다. helper객체로 보낼email을 set하고, 이메일제목을 set한다.

generateRandomKey(7)로 7자리랜덤키를 뽑아낸다.

그리고 레포지토리로 confirmRandomKey를 저장하고, getEmailTemplate() 메서드로 내가 설정해놓은 이메일템플릿에 randomkey를 넣고 보낸다. 그리고 로그에 어떻게 보냈는지 출력한다.

 

2. generateRandomKey()

public String generateRandomKey(int keyLength) {
    keyLength = 7;
    String characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
    StringBuilder randomKey = new StringBuilder();

    SecureRandom secureRandom = new SecureRandom();
    for (int i = 0; i < keyLength; i++) {
        int randomIndex = secureRandom.nextInt(characters.length());
        randomKey.append(characters.charAt(randomIndex));
    }

    return randomKey.toString();
}

파라미터로 랜덤키의 글자수를 정해주고, SecureRandom의 객체를하나만들고 그 객체로 characters 구성(나는 영어대문자와,숫자)으로랜덤키를 만들어준다.

 

3. verifyEmail()

public void verifyEmail(String tokenOrKey, String email, String password) {
    // 이메일 검증 로직 수행
    EmailConfirmRandomKey emailConfirmRandomKey = emailConfirmRandomKeyRepository.findById(email)
            .orElseThrow(() -> new IllegalArgumentException("유효하지 않은 이메일입니다."));

    String randomKey = emailConfirmRandomKey.getRandomKey();

    if (!randomKey.equals(tokenOrKey)) {
        throw new IllegalArgumentException("인증 코드가 유효하지 않습니다.");
    }
    Optional<User> existingUser = userRepository.findByEmail(email);
    if (existingUser.isPresent()) {
        // 기존 사용자의 정보 업데이트
        User user = existingUser.get();
        user.setPassword(passwordEncoder.encode(password));
        user.setEmailVerifiedYn("Y"); // 이메일 인증 완료로 설정
        userRepository.save(user);
    } else {
        // 새로운 사용자 등록
        User user = User.builder()
                .email(email)
                .password(passwordEncoder.encode(password))
                .emailVerifiedYn("Y") // 이메일 인증 완료로 설정
                .build();
        userRepository.save(user);
    }
    // 토큰 삭제
    emailConfirmRandomKeyRepository.deleteById(email);
}

랜덤키를 입력받고 인증하는 서비스 메서드다. 파라미터로 랜덤키, 이메일, 패스워드를 받는다. 랜덤키하나만 받게하고싶었는데 바꾸려다 실패해서 일단은 냅뒀다. 

이메일로 보낸 랜덤키를 DB에서 찾아서 사용자가 입력한 랜덤키와 일치하는지를 확인한다. 확인되면 유저의 emailVerifiedYn을 "Y"로 바꿔준다. 로그인 로직에 emailVerifiedYn가 "Y"가 아닌경우엔 로그인을 하지 못하게 했다. Y로 바꾸면 emailConfirmRandomKey를 DB에서 삭제한다.

 

4. getEmailTemplate()

public String getEmailTemplate(String recipientEmail, String randomKey) {
    try {
        // Create the Thymeleaf context and set variables
        Context context = new Context();
        context.setVariable("recipientEmail", recipientEmail);
        context.setVariable("randomKey", randomKey);

        // Process the email template using the template engine
        String emailTemplate = templateEngine.process("email_template", context);

        return emailTemplate;
    } catch (Exception e) {
        throw new RuntimeException("Failed to process email template.", e);
    }
}

따로 만들어놓은 이메일 템플릿을 불러오는 메서드다. 이메일 템플릿은

이렇게 html파일로 따로 만들어두었다.

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>[KnockKnock] 회원가입을 위한 이메일 인증번호 입니다.</title>
</head>
<body>
<h1>Welcome to KnockKnock</h1>
<p>회원가입을 완료하기 위해</p>
<p>밑의 키를 복사해서 인증을 완료해주세요.</p>
<p>Secret Key: <span th:text="${randomKey}"></span></p>
</body>
</html>

 

ㅇ EmailController

@RestController
@RequestMapping("/api/v1/emails")
public class EmailController {
    private final EmailService emailService;

    public EmailController(EmailService emailService) {
        this.emailService = emailService;
    }

    @PostMapping("/send")
    public ResponseEntity<String> sendEmail(@RequestParam String recipientEmail, @RequestParam String subject) {
        try {
            emailService.sendEmail(recipientEmail, subject);
            return ResponseEntity.ok("이메일이 성공적으로 발송되었습니다.");
        } catch (MessagingException e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("이메일 발송에 실패했습니다.");
        }
    }
    @PostMapping("/verify")
    public ResponseEntity<String> verifyEmail(@Validated @RequestBody VerifyDto verifyDto) {
        String tokenOrKey = verifyDto.getTokenOrKey();
        String email = verifyDto.getEmail();
        String password = verifyDto.getPassword();

        try {
            emailService.verifyEmail(tokenOrKey, email, password);
            return ResponseEntity.ok("이메일 인증이 성공적으로 완료되었습니다.");
        } catch (IllegalArgumentException e) {
            return ResponseEntity.badRequest().body(e.getMessage());
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("인증 처리 중 오류가 발생했습니다.");
        }
    }
}

필요한 api는 두개다. 이메일로 랜덤키를담은 템플릿을 보내는 api, 사용자가 입력한 랜덤키와 보낸 랜덤키를 비교검증 하는 api.

send api는 서비스의 sendEmail()를 사용해서 이메일을 보낸다.

verify api는 랜덤키 검증을 한다.

 

테스트 이미지를 보여주자면,

ㅁ 회원가입 api 완료

ㅁ send api 호출

ㅁ 인증메일

ㅁ 인증완료

 

 

/*

이렇게 이메일인증을 구현해봤다. html 파일에 랜덤키를 넣는과정이 제일 힘든 작업이었던거같다. 만약 나처럼 하고싶으면 그냥 내 코드를 가져다 쓰는걸 추천한다.

*/