본문 바로가기
Framework/Springboot

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

by IFLA 2024. 2. 4.

 

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

 

이메일 인증 구현 로직

 

개발 환경

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

댓글


\