Spring에서 이메일 인증 기능 개발하기(feat. Gmail, Thymeleaf)

Minjae An·2024년 1월 26일
1

Spring ETC

목록 보기
6/8

개요

웹서비스를 이용하다 보면 가입시 이메일을 인증하기 위해 해당 이메일로 메일을 발송해 인증 절차를 진행하는 경우가 있다. 이런 기능은 어떤 기술을 사용하여 구현되는 것인지 알아보자.

이하 예제 코드는 Java 17, SpringBoot 3.x에서 작성되었다.

SMTP 서버

SMTP란 Simple Mail Transfer Protocol의 준말로, 인터넷을 통해 이메일을 주고 받는데 사용되는 통신 프로토콜이다.

발신 메일 서버라고도 하는 SMTP 서버는 발신 이메일 메시치를 처리하는 컴퓨터 혹은 소프트웨어를 의미한다. 일반적으로 메일 서버는 이메일을 수집, 처리 및 전달하는 시스템을 칭한다. SMTP 서버는 SMTP를 이용해 메일을 보내는 메일 서버의 구성 요소이다. 메일 서버가 수신 및 발신 이메일을 모두 처리하지만 SMTP 서버는 발신 이메일을 적절한 목적지로 보내는 작업만을 담당한다.

출처 : https://www.socketlabs.com/blog/smtp-or-imap/

스프링에서 이메일을 전송하는 기능을 제공할 경우 이런 SMTP 메일 서버와의 연결 설정이 필요하다.

SMTP용 계정 설정 - Google

사용하고자 하는 계정의 Gmail → 설정 → 전달 및 POP/IMAP로 들어가 IMAP 사용해 체크를 해준다.


구글 계정 → 2단계 인증에서 앱 비밀번호를 설정해준다.

위와 같이 앱 비밀번호를 설정하면 Gmail로 보안 알림 메일이 날라올 수 있는데, 본인 활동 여부를 확인하는 것이니 확인 처리해주면 된다.

build.gradle

스프링에서 제공하는 이메일 유틸 기능을 사용하기 위해 다음 의존성을 추가해준다.

implementation 'org.springframework.boot:spring-boot-starter-mail:3.2.2'

maven central repository에서 가장 많이 사용되는 버전은 2.5.6이나, Java 17 기반으로 코드를 작성하여 호환성 문제로 인해 가장 최신 버전을 사용했다.

application.yml

spring:
	mail:
	    host: smtp.gmail.com
	    port: 587
	    username: ${GOOGLE_SMTP_ACCOUNT} # 앱 비밀번호를 발급받은 구글 계정
	    password: ${GOOGLE_SMTP_PW} # 발급받은 앱 비밀번호
	    properties:
	      mail:
	        smtp:
	          starttls:
	            enable: true
	          auth: true

사용하는 SMTP 서버에 따라 포트가 다르다. 공식 문서를 잘 찾아보며 설정하자.

요구사항 가정

  • 회원은 인증 받을 이메일을 요청에 포함하여 서버에 보낸다.
  • 서버는 일정 시간 동안 인증 코드를 발행한다.
  • 서버는 인증 코드를 회원에게 받은 이메일로 전송한다.
  • 회원은 입력 받은 인증 코드를 요청에 포함하여 서버에 보낸다.
  • 인증 코드가 만료되지 않았으며 일치하면 정상 처리, 이외의 경우 예외 처리

VerificationCode

package com.example.springallinoneproject.auth;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class VerificationCode {
    private String code;
    private LocalDateTime createAt;
    private Integer expirationTimeInMinutes;

    public boolean isExpired(LocalDateTime verifiedAt) {
        LocalDateTime expiredAt = createAt.plusMinutes(expirationTimeInMinutes);
        return verifiedAt.isAfter(expiredAt);
    }

    public String generateCodeMessage() {
        String formattedExpiredAt = createAt
                .plusMinutes(expirationTimeInMinutes)
                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));

        return String.format(
                """
                        [Verification Code] 
                        %s
                        Expired At : %s
                                """,
                code, formattedExpiredAt
        );
    }
}

인증 코드를 나타낼 간단한 클래스를 구현한다. 식별을 위한 고유한 ID와 인증 코드, 만료 시간(분)을 필드로 가진다.

MailConfig - JavaMailSender 빈 등록

package com.example.springallinoneproject.config;

import java.util.Properties;
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;

@Configuration
public class MailConfig {
    @Value("${spring.mail.host}")
    private String mailServerHost;
    @Value("${spring.mail.port}")
    private String mailServerPort;
    @Value("${spring.mail.username}")
    private String mailServerUsername;
    @Value("${spring.mail.password}")
    private String mailServerPassword;

    @Bean
    public JavaMailSender javaMailSender() {
        JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
        mailSender.setHost(mailServerHost);
        mailSender.setPassword(mailServerPort);

        mailSender.setUsername(mailServerUsername);
        mailSender.setPassword(mailServerPassword);

        Properties properties = mailSender.getJavaMailProperties();
        properties.put("mail.transport.protocol", "smtp");
        properties.put("mail.smtp.auth", "true");
        properties.put("mail.smtp.starttls.enable", "true");

        return mailSender;
    }
}

이메일 발송에 사용되는 JavaMailSender 를 빈으로 등록해주기 위해 별도의 Config 클래스를 정의하였다. 앞선 application.yml 에서 기입한 데이터들을 끌어다 빈을 등록하도록 구성하였다.

VerificationCodeRepository

package com.example.springallinoneproject.auth;

import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Repository;

@Repository
public class VerificationCodeRepository {
    private final Map<String, VerificationCode> repository = new ConcurrentHashMap<>();

    public VerificationCode save(VerificationCode verificationCode) {
        return repository.put(verificationCode.getCode(), verificationCode);
    }

    public Optional<VerificationCode> findByCode(String code) {
        return Optional.ofNullable(repository.get(code));
    }

    public void remove(VerificationCode verificationCode) {
        repository.remove(verificationCode.getCode());
    }
}

인증 코드 객체를 인메모리에 저장하는 간단한 Repository 클래스를 구현했다.

EmailService

package com.example.springallinoneproject.email;

import com.example.springallinoneproject.api_payload.status_code.ErrorStatus;
import com.example.springallinoneproject.auth.VerificationCode;
import com.example.springallinoneproject.auth.VerificationCodeRepository;
import com.example.springallinoneproject.exception.GeneralException;
import java.time.LocalDateTime;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class EmailService {
    @Value("${spring.mail.username")
    private String serviceEmail;
    private final Integer EXPIRATION_TIME_IN_MINUTES = 5;

    private final JavaMailSender mailSender;
    private final VerificationCodeRepository verificationCodeRepository;

    public void sendSimpleVerificationMail(String to, LocalDateTime sentAt) {
        SimpleMailMessage mailMessage = new SimpleMailMessage();
        mailMessage.setFrom(serviceEmail);
        mailMessage.setTo(to);
        mailMessage.setSubject(String.format("Email Verification For %s", to));

        VerificationCode verificationCode = generateVerificationCode(sentAt);
        verificationCodeRepository.save(verificationCode);

        String text = verificationCode.generateCodeMessage();
        mailMessage.setText(text);

        mailSender.send(mailMessage);
    }

    public void verifyCode(String code, LocalDateTime verifiedAt) {
        VerificationCode verificationCode = verificationCodeRepository.findByCode(code)
                .orElseThrow(() -> new GeneralException(ErrorStatus._VERIFICATION_CODE_NOT_FOUND));

        if (verificationCode.isExpired(verifiedAt)) {
            throw new GeneralException(ErrorStatus._VERIFICATION_CODE_EXPIRED);
        }

        verificationCodeRepository.remove(verificationCode);
    }

    private VerificationCode generateVerificationCode(LocalDateTime sentAt) {
        String code = UUID.randomUUID().toString();
        return VerificationCode.builder()
                .code(code)
                .createAt(sentAt)
                .expirationTimeInMinutes(EXPIRATION_TIME_IN_MINUTES)
                .build();
    }
}

이메일 인증 관련 로직을 전담하는 서비스 클래스이다.

private VerificationCode generateVerificationCode(LocalDateTime sentAt) {
    String code = UUID.randomUUID().toString();
    return VerificationCode.builder()
            .code(code)
            .createAt(sentAt)
            .expirationTimeInMinutes(EXPIRATION_TIME_IN_MINUTES)
            .build();
}

인증 코드는 일정 시간동안 고유해야 하므로 java.util.UUID 를 활용하여 생성한다. 만료 기간은 인증 요청을 보낸 시점으로부터 5분으로 설정하였다.

public void sendSimpleVerificationMail(String to, LocalDateTime sentAt) {
    SimpleMailMessage mailMessage = new SimpleMailMessage();
    mailMessage.setFrom(serviceEmail);
    mailMessage.setTo(to);
    mailMessage.setSubject(String.format("Email Verification For %s", to));

    VerificationCode verificationCode = generateVerificationCode(sentAt);
    verificationCodeRepository.save(verificationCode);

    String text = verificationCode.generateCodeMessage();
    mailMessage.setText(text);

    mailSender.send(mailMessage);
}

인증 코드 메일을 보내는 로직은 다음 과정을 거쳐 메일을 전송한다.

  • generateVerificationCode 로직을 통해 인증 코드를 생성한다.
  • 생성한 인증 코드를 VerificationCodeRepository 에 저장한다.
  • 인증 코드 메일 내용을 설정하여 JavaMailSender 를 통해 전송한다.
public void verifyCode(String code, LocalDateTime verifiedAt) {
    VerificationCode verificationCode = verificationCodeRepository.findByCode(code)
            .orElseThrow(() -> new GeneralException(ErrorStatus._VERIFICATION_CODE_NOT_FOUND));

    if (verificationCode.isExpired(verifiedAt)) {
        throw new GeneralException(ErrorStatus._VERIFICATION_CODE_EXPIRED);
    }

    verificationCodeRepository.remove(verificationCode);
}

인증 코드를 검증하는 로직은 먼저 해당 인증 코드가 존재하는 지 확인하고, 만료 여부를 판별한다. 이 검증 과정에서 예외가 발생하지 않으면 정상 처리된 것이므로 저장되어 있는 인증 코드를 제거한다.

EmailController

package com.example.springallinoneproject.email;

import com.example.springallinoneproject.api_payload.CommonResponse;
import com.example.springallinoneproject.api_payload.status_code.SuccessStatus;
import com.example.springallinoneproject.email.dto.EmailRequest.EmailForVerificationRequest;
import com.example.springallinoneproject.email.dto.EmailRequest.VerificationCodeRequest;
import java.time.LocalDateTime;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class EmailController {
    private final EmailService emailService;

    @PostMapping("/verify-email")
    public CommonResponse<Void>
    getEmailForVerification(@RequestBody EmailForVerificationRequest request) {
        LocalDateTime requestedAt = LocalDateTime.now();
        emailService.sendSimpleVerificationMail(request.getEmail(), requestedAt);
        return CommonResponse.of(SuccessStatus._ACCEPTED, null);
    }

    @PostMapping("/verification-code")
    public CommonResponse<String>
    verificationByCode(@RequestBody VerificationCodeRequest request) {
        LocalDateTime requestedAt = LocalDateTime.now();
        emailService.verifyCode(request.getCode(), requestedAt);
        return CommonResponse.ok("정상 인증 완료");
    }
}

이메일 인증과 관련된 API를 전담하는 컨트롤러 클래스이다. 쓰인 요청 DTO는 다음과 같다.

EmailRequest - EmailForVerificationRequest, VerificationCodeRequest

package com.example.springallinoneproject.email.dto;

import lombok.Getter;

public class EmailRequest {
    @Getter
    public static class EmailForVerificationRequest {
        private String email;
    }

    @Getter
    public static class VerificationCodeRequest {
        private String code;
    }
}

동작 확인

Postman을 이용하여 API가 정상적으로 동작하는 지 확인해보자. 먼저 검증 받을 이메일을 서버에 전송한다.


아래와 같이 요청한 이메일로 인증 메일이 잘 보내지는 확인할 수 있다.

이 인증 코드를 통해 이메일을 마저 인증할 수 있다.

Spring Mail & ThymeLeaf

서비스에서 유저에게 각종 필요에 따라 메일을 전송할 때 메일 내에 이미지나 청구서 등 각종 파일을 첨부할 필요가 있을 수 있다. 이런 경우 ThymeLeaf와 같은 템플릿 엔진을 활용하여 HTML 메일을 전송하는 방법으로 구현이 가능하다.

build.gradle

ThymeLeaf를 사용하기 위해 다음 의존성을 추가해준다.

implementation 'org.springframework.boot:spring-boot-starter-thymeleaf:3.1.2'

application.yml

spring:
  mail:
    host: smtp.gmail.com
    port: 587
    username: ${GOOGLE_SMTP_ACCOUNT}
    password: ${GOOGLE_SMTP_PW}
    properties:
      mail:
        smtp:
          starttls:
            enable: true
          auth: true
    templates:
      path: mail-templates/
      logo-path: classpath:/static/logo.png

보낼 HTML 메일에 임의의 이미지가 첨부되게 할 것이다. 사용할 HTML 파일 경로와 이미지 경로를 설정해준다.

logo-path: classpath:/static/logo.png

classpath:/ prefix를 붙여주어야 정상적으로 resources이하의 경로에서 이미지 파일을 찾는다.

MailConfig - 템플릿 엔진 관련 빈 등록

package com.example.springallinoneproject.config;

import java.util.Properties;
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 org.thymeleaf.spring6.SpringTemplateEngine;
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
import org.thymeleaf.templateresolver.ITemplateResolver;

@Configuration
public class MailConfig {
		// 생략

    @Value("${spring.mail.templates.path}")
    private String mailTemplatesPath;

		// 생략
    @Bean
    public ITemplateResolver thymeleafTemplateResolver() {
        ClassLoaderTemplateResolver templateResolver = new ClassLoaderTemplateResolver();
        templateResolver.setPrefix(mailTemplatesPath);
        templateResolver.setSuffix(".html");
        templateResolver.setTemplateMode("HTML");
        templateResolver.setCharacterEncoding("UTF-8");
        return templateResolver;
    }

    @Bean
    public SpringTemplateEngine thymeleafTemplateEngine() {
        SpringTemplateEngine templateEngine = new SpringTemplateEngine();
        templateEngine.setTemplateResolver(thymeleafTemplateResolver());
        return templateEngine;
    }
}

템플릿 엔진의 동작을 위한 빈들을 등록해준다.

verfication-code.html

메일에 첨부될 HTML은 resources/mail-templates의 경로에 아래와 같이 간단히 구성했다.

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
    <img src="cid:logo.png"/>
    <p th:text="${verificationCode}"></p>
</body>
</html>

Thymeleaf의 사용과 관련해서는 이 링크를 참고하자.

EmailService - HTML 메일 전송 로직 구성

package com.example.springallinoneproject.email;

import com.example.springallinoneproject.api_payload.status_code.ErrorStatus;
import com.example.springallinoneproject.auth.VerificationCode;
import com.example.springallinoneproject.auth.VerificationCodeRepository;
import com.example.springallinoneproject.exception.GeneralException;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import org.thymeleaf.context.Context;
import org.thymeleaf.spring6.SpringTemplateEngine;

@Service
@RequiredArgsConstructor
public class EmailService {
    @Value("${spring.mail.username}")
    private String serviceEmail;
    @Value("${spring.mail.templates.logo-path}")
    private Resource logoFile;

    private final Integer EXPIRATION_TIME_IN_MINUTES = 5;
    private final String VERIFICATION_CODE_MAIL_SUBJECT="Email Verification For %s";

    private final JavaMailSender mailSender;
    private final VerificationCodeRepository verificationCodeRepository;
    private final SpringTemplateEngine templateEngine;

		// 생략

    public void sendVerificationMailWithTemplate(String to, LocalDateTime sentAt) throws MessagingException {
        VerificationCode verificationCode = generateVerificationCode(sentAt);
        verificationCodeRepository.save(verificationCode);

        HashMap<String, Object> templateModel = new HashMap<>();
        templateModel.put("verificationCode", verificationCode.generateCodeMessage());

        String subject = String.format(VERIFICATION_CODE_MAIL_SUBJECT, to);
        Context thymeleafContext = new Context();
        thymeleafContext.setVariables(templateModel);
        String htmlBody = templateEngine.process("verification-code.html", thymeleafContext);

        sendHtmlMessage(to, subject, htmlBody);
    }
		
		// 생략

    private void sendHtmlMessage(String to, String subject, String htmlBody)
            throws MessagingException {
        MimeMessage message = mailSender.createMimeMessage();
        MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
        helper.setFrom(serviceEmail);
        helper.setTo(to);
        helper.setSubject(subject);
        helper.setText(htmlBody, true);
        helper.addInline("logo.png", logoFile);

        mailSender.send(message);
    }
}

HTML 메시지를 전송하는 로직과 메시지를 위한 데이터를 생성, 설정하는 로직을 구분하여 작성하였다. MIME 콘텐츠가 포함된 메일을 보내기 위해서 MimeMessage 를 사용하며 verification-code.html 에 설정해두었던 속성들을 이름 기반으로 매칭하여 구성할 수 있다.

EmailController - API 구성

package com.example.springallinoneproject.email;

import com.example.springallinoneproject.api_payload.CommonResponse;
import com.example.springallinoneproject.api_payload.status_code.SuccessStatus;
import com.example.springallinoneproject.email.dto.EmailRequest.EmailForVerificationRequest;
import com.example.springallinoneproject.email.dto.EmailRequest.VerificationCodeRequest;
import jakarta.mail.MessagingException;
import java.time.LocalDateTime;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class EmailController {
    private final EmailService emailService;
		
		// 생략

    @PostMapping("/v2/verify-email")
    public CommonResponse<Void>
    getEmailForVerificationV2(@RequestBody EmailForVerificationRequest request)
            throws MessagingException {
        LocalDateTime sentAt = LocalDateTime.now();
        emailService.sendVerificationMailWithTemplate(request.getEmail(), sentAt);
        return CommonResponse.of(SuccessStatus._ACCEPTED, null);
    }

		// 생략
}

동작 확인

위 API로 인증 요청을 먼저 보낸다.


인증 코드와 이미지를 포함한 메일이 잘 전송되는 것을 확인할 수 있다.

전체 코드

https://github.com/Minjae-An/spring-all-in-one/tree/feat/%2315-email-authentication

참고

profile
먹고 살려고 개발 시작했지만, 이왕 하는 거 잘하고 싶다.

0개의 댓글