Framework/Springboot

스프링부트로 이메일 인증 서비스 구현하기

IFLA 2024. 2. 4. 09:00

목차

     

    회원가입 기능을 개발하면서 이메일로 회원가입할 수 있는 기능을 개발중이다. 이메일이 정상적으로 사용할 수 있는 지를 확인할 필요가 있다. 그래서 이메일로 인증코드가 발송되고, 인증코드를 인증하는 기능을 구현하려고 한다.

     

    이메일 인증 구현 로직

     

    개발 환경

    IDE(개발툴) : IntelliJ

    JDK(자바 버전) : 17

    STS(스프링부트 버전) : 3.1.0

    플러그인 추가 : build.gradle

     

    구글 이메일 서비스(SMTP)

    Redis 사용

     


    구글 SMTP 서비스 설정하기

    먼저 이메일을 보내기 위해서는 SMTP 서비스를 이용해야한다. SMTP란 인터넷을 통해 이메일을 주고 받을 때 사용하는 기능이다. 개인이 개발을 진행하면 SMTP 서버를 구현해서 서비스를 이용하기엔 복잡하다. 그래서 구글이나 네이버에서 SMTP 기능을 이용할 수 있게 제공하고 있다. 필자는 구글의 SMTP 서비스를 이용하려고 한다.

     

     

     

    1. 먼저 구글 서비스로 로그인 후 Gmail 로 들어간다.

    2. Gmail 화면에서 오른쪽 상단의 톱니바퀴 모양의 버튼을 클릭한다.

    3. 제일 위에 빠른설정 - [ 모든 설정 보기 ] 메뉴를 클릭한다.

     

     

    4. 이메일 설정 화면으로 이동된다.

    5. 설정 제목 밑에 [ 전달 및 POP/IMAP ] 메뉴를 클릭한다.

    6. 위 이미지에서 보여진느 것 처럼 설정 후 제일 아래에 있는 [ 변경사항 저장 ] 버튼을 클릭하여 설정한 정보를 저장한다.

     


    구글 계정 비밀번호 설정하기

     

    1. 구글 계정 옆의 바둑판 모양의 버튼을 클릭한다.

    2. 위의 화면처럼 나타나면 [ 계정 ] 메뉴를 클릭한다.

     

     

    3. 왼쪽 메뉴에서 [ 보안 ] 을 클릭 후 오른족에 나타난 보안 화면의 [ 2단계 인증 ] 을 클릭한다.

     

    4. 제일 밑에 [ 앱 비밀번호 ] 클릭한다. 메뉴를 클릭한다.

     

     

     

    5. 이동한 화면에서 앱 이름에 아무 문구나 입력 후 [ 만들기 ] 버튼을 클릭하면 앱 비밀번호가 생성되면서 팝업창에 표시된다. 이 앱 비밀번호는 스프링부트에서 smtp 설정을 위해 필요한 비밀번호 이기 때문에 따로 적어둬야 한다.

     


     

    개발 코드

    파일 구조

     

    mail.html

    <!DOCTYPE html>
    <html xmlns:th="http://www.thymeleaf.org">
    <body>
    <div style="margin:120px">
        <div style="margin-bottom: 10px">
            <h1>인증 코드 메일입니다.</h1>
            <br/>
            <h3 style="text-align: center;"> 아래 코드를 사이트에 입력해주십시오</h3>
        </div>
        <div style="text-align: center;">
            <h2 style="color: crimson;" th:text="${code}"></h2>
        </div>
        <br/>
    </div>
    </body>
    </html>

     

    emailSend.html

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>인증코드 구현하기</title>
    </head>
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js"></script>
    <script type="text/javascript">
        function sendNumber() {
            $("#mail_number").css("display", "block");
            $.ajax({
                url: "/api/v1/email/send",
                type: "post",
                dataType: "json",
                data: {"mail": $("#mail").val()},
                success: function (data) {
                    alert("인증번호 발송");
                    $("#Confirm").attr("value", data);
                }
            });
        }
    
        function confirmNumber() {
            let mail = $("#mail").val();
            let number = $("#number").val();
    
            $.ajax({
               url: "/api/v1/email/verify",
               type: "post",
                dataType: "json",
                data: {"mail": mail, "verifyCode": number},
                success: function (data, status, xhr) {
                   console.log(status);
                }
            });
        }
    </script>
    <body>
        <div id="mail_input" name="mail_input">
            <input type="text" name="mail" id="mail" placeholder="이메일 입력">
            <button type="button" id="sendBtn" name="sendBtn" onclick="sendNumber()">인증번호</button>
        </div>
        <br>
        <div id="mail_number" name="mail_number" style="display: none">
            <input type="text" name="number" id="number" placeholder="인증번호 입력">
            <button type="button" name="confirmBtn" id="confirmBtn" onclick="confirmNumber()">이메일 인증</button>
        </div>
        <br>
        <input type="text" id="Confirm" name="Confirm" style="display: none" value="">
    </body>
    </html>

     

     

    PageController

    package com.study.email_verification.api.controller;
    
    import com.study.email_verification.api.dto.EmailDto;
    import com.study.email_verification.api.service.EmailService;
    import jakarta.mail.MessagingException;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.web.bind.annotation.*;
    
    @Slf4j
    @RestController
    @RequiredArgsConstructor
    @RequestMapping("/api/v1/email")
    public class EmailController {
        private final EmailService emailService;
    
        // 인증코드 메일 발송
        @PostMapping("/send")
        public String mailSend(EmailDto emailDto) throws MessagingException {
            log.info("EmailController.mailSend()");
            emailService.sendEmail(emailDto.getMail());
            return "인증코드가 발송되었습니다.";
        }
    
        // 인증코드 인증
        @PostMapping("/verify")
        public String verify(EmailDto emailDto) {
            log.info("EmailController.verify()");
            boolean isVerify = emailService.verifyEmailCode(emailDto.getMail(), emailDto.getVerifyCode());
            return isVerify ? "인증이 완료되었습니다." : "인증 실패하셨습니다.";
        }
    }

     

     

    EmailController

    package com.study.email_verification.api.controller;
    
    import com.study.email_verification.api.dto.EmailDto;
    import com.study.email_verification.api.service.EmailService;
    import jakarta.mail.MessagingException;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.web.bind.annotation.*;
    
    @Slf4j
    @RestController
    @RequiredArgsConstructor
    @RequestMapping("/api/v1/email")
    public class EmailController {
        private final EmailService emailService;
    
        // 인증코드 메일 발송
        @PostMapping("/send")
        public String mailSend(EmailDto emailDto) throws MessagingException {
            log.info("EmailController.mailSend()");
            emailService.sendEmail(emailDto.getMail());
            return "인증코드가 발송되었습니다.";
        }
    
        // 인증코드 인증
        @PostMapping("/verify")
        public String verify(EmailDto emailDto) {
            log.info("EmailController.verify()");
            boolean isVerify = emailService.verifyEmailCode(emailDto.getMail(), emailDto.getVerifyCode());
            return isVerify ? "인증이 완료되었습니다." : "인증 실패하셨습니다.";
        }
    }

     

     

     

    EmailDto

    package com.study.email_verification.api.dto;
    
    import lombok.Data;
    
    @Data
    public class EmailDto {
    
        // 이메일 주소
        private String mail;
        // 인증 코드
        private String verifyCode;
    }

     

    EmailRepository

    package com.study.email_verification.api.repository;
    
    import com.study.email_verification.entity.Email;
    import org.springframework.data.jpa.repository.JpaRepository;
    import org.springframework.stereotype.Repository;
    
    import java.util.Optional;
    
    @Repository
    public interface EmailRepository extends JpaRepository<Email, Long> {
        // 인증코드 발송한 이메일 주소 조회
        public Optional<Email> findByEmail(String email);
    }

     

    EmailService

    package com.study.email_verification.api.service;
    
    import com.study.email_verification.common.util.RedisUtil;
    import jakarta.mail.MessagingException;
    import jakarta.mail.internet.MimeMessage;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.mail.javamail.JavaMailSender;
    import org.springframework.stereotype.Service;
    import org.thymeleaf.TemplateEngine;
    import org.thymeleaf.context.Context;
    import org.thymeleaf.templatemode.TemplateMode;
    import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
    
    import java.util.Random;
    
    @Slf4j
    @Service
    @RequiredArgsConstructor
    public class EmailService {
    
        private final JavaMailSender javaMailSender;
        private final RedisUtil redisUtil;
        private static final String senderEmail = "sanbyul1@naver.com";
    
        private String createCode() {
            int leftLimit = 48; // number '0'
            int rightLimit = 122; // alphabet 'z'
            int targetStringLength = 6;
            Random random = new Random();
    
            return random.ints(leftLimit, rightLimit + 1)
                    .filter(i -> (i <= 57 || i >= 65) && (i <= 90 | i >= 97))
                    .limit(targetStringLength)
                    .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append)
                    .toString();
        }
    
        // 이메일 내용 초기화
        private String setContext(String code) {
            Context context = new Context();
            TemplateEngine templateEngine = new TemplateEngine();
            ClassLoaderTemplateResolver templateResolver = new ClassLoaderTemplateResolver();
    
            context.setVariable("code", code);
    
            templateResolver.setPrefix("templates/");
            templateResolver.setSuffix(".html");
            templateResolver.setTemplateMode(TemplateMode.HTML);
            templateResolver.setCacheable(false);
    
            templateEngine.setTemplateResolver(templateResolver);
    
            return templateEngine.process("mail", context);
        }
    
        // 이메일 폼 생성
        private MimeMessage createEmailForm(String email) throws MessagingException {
            String authCode = createCode();
    
            MimeMessage message = javaMailSender.createMimeMessage();
            message.addRecipients(MimeMessage.RecipientType.TO, email);
            message.setSubject("안녕하세요. 인증번호입니다.");
            message.setFrom(senderEmail);
            message.setText(setContext(authCode), "utf-8", "html");
    
            // Redis 에 해당 인증코드 인증 시간 설정
            redisUtil.setDataExpire(email, authCode, 60 * 30L);
    
            return message;
        }
    
        // 인증코드 이메일 발송
        public void sendEmail(String toEmail) throws MessagingException {
            if (redisUtil.existData(toEmail)) {
                redisUtil.deleteData(toEmail);
            }
            // 이메일 폼 생성
            MimeMessage emailForm = createEmailForm(toEmail);
            // 이메일 발송
            javaMailSender.send(emailForm);
        }
    
        // 코드 검증
        public Boolean verifyEmailCode(String email, String code) {
            String codeFoundByEmail = redisUtil.getData(email);
            log.info("code found by email: " + codeFoundByEmail);
            if (codeFoundByEmail == null) {
                return false;
            }
            return codeFoundByEmail.equals(code);
        }
    }

     

     

    EmailConfig

    package com.study.email_verification.common.config;
    
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.mail.javamail.JavaMailSender;
    import org.springframework.mail.javamail.JavaMailSenderImpl;
    
    import java.util.Properties;
    
    @Configuration
    public class EmailConfig {
        @Value("${spring.mail.host}")
        private String host;
    
        @Value("${spring.mail.port}")
        private int port;
    
        @Value("${spring.mail.username}")
        private String username;
    
        @Value("${spring.mail.password}")
        private String password;
    
        @Value("${spring.mail.properties.mail.smtp.auth}")
        private boolean auth;
    
        @Value("${spring.mail.properties.mail.smtp.starttls.enable}")
        private boolean starttlsEnable;
    
        @Value("${spring.mail.properties.mail.smtp.starttls.required}")
        private boolean starttlsRequired;
    
        @Value("${spring.mail.properties.mail.smtp.connectiontimeout}")
        private int connectionTimeout;
    
        @Value("${spring.mail.properties.mail.smtp.timeout}")
        private int timeout;
    
        @Value("${spring.mail.properties.mail.smtp.writetimeout}")
        private int writeTimeout;
    
        @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(getMailProperties());
    
            return mailSender;
        }
    
        private Properties getMailProperties() {
            Properties properties = new Properties();
            properties.put("mail.smtp.auth", auth);
            properties.put("mail.smtp.starttls.enable", starttlsEnable);
            properties.put("mail.smtp.starttls.required", starttlsRequired);
            properties.put("mail.smtp.connectiontimeout", connectionTimeout);
            properties.put("mail.smtp.timeout", timeout);
            properties.put("mail.smtp.writetimeout", writeTimeout);
    
            return properties;
        }
    }

     

     

    RedisConfig

    package com.study.email_verification.common.config;
    
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.data.redis.connection.RedisConnectionFactory;
    import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
    import org.springframework.data.redis.core.RedisTemplate;
    
    @Configuration
    public class RedisConfig {
        @Value("${spring.data.redis.host}")
        private String host;
    
        @Value("${spring.data.redis.port}")
        private int port;
    
        @Bean
        public RedisConnectionFactory redisConnectionFactory() {
            return new LettuceConnectionFactory(host, port);
        }
    
        @Bean
        public RedisTemplate<?, ?> redisTemplate() {
            RedisTemplate<byte[], byte[]> redisTemplate = new RedisTemplate<>();
            redisTemplate.setConnectionFactory(redisConnectionFactory());
            return redisTemplate;
        }
    }

     

     

    RedisUtil

    package com.study.email_verification.common.util;
    
    import lombok.RequiredArgsConstructor;
    import org.springframework.data.redis.core.StringRedisTemplate;
    import org.springframework.data.redis.core.ValueOperations;
    import org.springframework.stereotype.Service;
    
    import java.time.Duration;
    
    @RequiredArgsConstructor
    @Service
    public class RedisUtil {
        private final StringRedisTemplate template;
    
        public String getData(String key) {
            ValueOperations<String, String> valueOperations = template.opsForValue();
            return valueOperations.get(key);
        }
    
        public boolean existData(String key) {
            return Boolean.TRUE.equals(template.hasKey(key));
        }
    
        public void setDataExpire(String key, String value, long duration) {
            ValueOperations<String, String> valueOperations = template.opsForValue();
            Duration expireDuration = Duration.ofSeconds(duration);
            valueOperations.set(key, value, expireDuration);
        }
    
        public void deleteData(String key) {
            template.delete(key);
        }
    }

     

     

    Email

    package com.study.email_verification.entity;
    
    import jakarta.persistence.*;
    import lombok.Builder;
    import lombok.Getter;
    import lombok.NoArgsConstructor;
    
    @Getter
    @NoArgsConstructor
    @Entity
    @Table(name = "email")
    public class Email {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        @Column(name = "email_id", unique = true, nullable = false)
        private Long id;
    
        // 이메일 주소
        @Column(name = "email", nullable = false)
        private String email;
    
        // 이메일 인증 여부
        @Column(name = "email_status", nullable = false)
        private boolean emailStatus;
    
        @Builder
        public Email(String email) {
            this.email = email;
            this.emailStatus = false;
        }
    }

     

     

    application.yaml

    spring:
      datasource:
        hikari:
          maximum-pool-size: 4
          username: testuser
          password: testpass
        url: jdbc:postgresql://localhost:5432/study
    
      # JPA
      jpa:
        hibernate:
          ddl-auto: update
        properties:
          hibernate:
            format_sql: true
        show-sql: true
    
      # Email
      mail:
        host: smtp.gmail.com
        port: 587
        username: {google 이메일}
        password: {발급받은 앱 비밀번호}
        properties:
          mail:
            smtp:
              auth: true
              starttls:
                enable: true
                required: true
              connectiontimeout: 5000
              timeout: 5000
              writetimeout: 5000
        auth-code-expiration-millis: 1800000  # 30 * 60 * 1000 == 30?
    
      # Redis
      data:
        redis:
          host: localhost
          port: 6379