10. Spring/BOOT

06. 스프링 부트 (Spring Boot) - 자바 메일 센더 (Java Mail Sender)

THE HEYDAZE 2020. 8. 29. 18:00
OS Windows 10 Home 64bit 버전 1903 (OS 빌드 18362.836)
Framework Spring Boot 2.3.3 RERELEASE
EditTool Inellij IDEA 2019.1.3
BuildTool Gradle

# 참고

[ Thymleaf 템플린 엔진 Guide ]

 

Tutorial: Using Thymeleaf

1 Introducing Thymeleaf 1.1 What is Thymeleaf? Thymeleaf is a Java library. It is an XML/XHTML/HTML5 template engine able to apply a set of transformations to template files in order to display data and/or text produced by your applications. It is better s

www.thymeleaf.org

 

[ 메일 ]

 

[SpringBoot] 이메일 전송 ( JavaMailSender, MimeMessageHelper )

이번 글에서는 MailSender 인터페이스를 상속받은 JavaMailSender를 사용하여 이메일 전송 시스템을 구현해보도록 하겠습니다. 전체 코드는 깃헙을 참고하시길 바랍니다. 개발환경 IntelliJ 2019.02 Java 11

victorydntmd.tistory.com

 

 

스프링(Spring) MailSender, JavaMailSender (메일 발송)

1. MailSender와 JavaMailSender 메일 발송 - 메일 발송 기능을 위한 MailSender 인터페이스 제공 - SimpleMailMessage(메일 제목, 단순 텍스트 내용)를 전달받아 메일을 발송하는 기능을 정의 package org.sprin..

gangzzang.tistory.com

 

[비동기 처리]

 

[Spring 레퍼런스] 26장 태스크(Task) 실행과 스케줄링 :: Outsider's Dev Story

이 문서는 개인적인 목적이나 배포하기 위해서 복사할 수 있다. 출력물이든 디지털 문서든 각 복사본에 어떤 비용도 청구할 수 없고 모든 복사본에는 이 카피라이트 문구가 있어야 한다. ## 26. ��

blog.outsider.ne.kr

 

# 생성 및 코드

1-1

 

1-2

 

1-3

 

1-5 (이전 단계에서 원하는 위치에 프로젝트를 생성하시면 됩니다)

 

plugins {
    id 'org.springframework.boot' version '2.3.3.RELEASE'
    id 'mail.spring.dependency-management' version '1.0.10.RELEASE'
    id 'java'
}

group = 'mail.spring'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-mail'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compile group: 'org.springframework.boot', name: 'spring-boot-starter-freemarker', version: '2.3.1.RELEASE'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation('org.springframework.boot:spring-boot-starter-test') {
        exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
    }
    testImplementation 'org.springframework.security:spring-security-test'
}

test {
    useJUnitPlatform()
}

 

# active
spring:
  profiles:
    active: prod

---
# prod

spring:
  profiles: prod

  mail:
    host: smtp.gmail.com
    port: 587
    username: '아이디@gmail.com'
    password: '비밀번호'
    properties.mail.smtp:
      nickname: 'admin'
      auth: true
      starttls.enable: true
      ssl.trust: smtp.gmail.com

#  쓰레드 풀 설정
#  task.execution.pool:
#    core-size: 8
#    max-size: 8


---
# local ( junit5 테스트 시 )

spring:
  profiles: local

  mail:
    host: localhost
    port: 1025
    properties:
      mail:
        stmp:
          auth: false

 

 

<!-- main.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <link rel="shortcut icon" href="#">
    <meta charset="UTF-8">
    <title>메일 센더</title>
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <!-- b4 CSS -->
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
    <!-- jQuery.js / popper.js / Bootstrap4.js -->
    <script src="https://code.jquery.com/jquery-3.4.1.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"></script>
    <script>
        $(function () {
            $('#add').click(function () {
                $('.emailList').append(
                    '        <div class="form-group">\n' +
                    '            <div class="input-group">\n' +
                    '                <input class="form-control"  name="to" placeholder="이메일 주소">\n' +
                    '            </div>\n' +
                    '        </div>'
                );
            });

            $('#async-label').click(function () {
                let async = $('#async');
                let bol = async.val();
                async.val(bol === 'false');
            });
        });

    </script>

</head>
<body>

<div class="container mt-5">
    <h1>메일 발송</h1>
    <form class="form-block" th:action="@{/mail}" method="post">
        <div class="emailList">
            <div class="form-group">
                <div class="input-group">
                    <input class="form-control"  name="to" placeholder="이메일 주소">
                    <div class="input-group-append">
                        <button id="add" class="btn btn-primary form-control" type="button">추가</button>
                    </div>
                </div>
            </div>
        </div>
        <div class="form-group">
            <div class="input-group">
                <input class="form-control" name="subject" placeholder="제목">
                <div class="input-group-append p-2">
                    <div class="custom-control custom-switch">
                        <input type="checkbox" class="custom-control-input" id="async" name="async" value="false">
                        <label class="custom-control-label" style="user-select: none" id="async-label" for="async">비동기 처리</label>
                    </div>
                </div>
            </div>
        </div>
        <div class="form-group">
            <select class="form-control" name="fileName">
                <option value="message.html">message.html</option>
            </select>
        </div>
        <div class="form-group">
            <textarea class="form-control" name="text" placeholder="메일 내용을 입력해주세요." cols="60" rows="20"></textarea>
        </div>
        <div class="form-group">
            <button class="form-control">발송</button>
        </div>
    </form>
</div>
</body>
</html>

input value 는 String 이기 때문에 !true, !false 처럼 할 수 없다

 

bol === 'false' 로 하여 'false' 인 경우는 true 를

true 인 경우는 false 를 입력되게하였다

 

<!-- message.html -->
<html xmlns:th="http://www.thymeleaf.org">
<div style="border: 3px solid red; margin-left: 20%; margin-right: 20%; margin-top: 10%">
    <div style="margin: 10%; text-align: center">
        <p th:text="${subject}">${title}</p>
    </div>
    <div style="margin: 10%; text-align: center">
        <p th:text="${text}">${text}</p>
    </div>
    <div style="margin: 10%; text-align: center">
        <p th:text="${from}">${from}</p>
    </div>
</div>
</html>

 

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .anyRequest()
                .permitAll();
    }
}

 

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

@EnableAsync
@Configuration
public class AsyncConfig {

    @Bean(name = "mailSenderExecutor")
    public Executor threadPoolTaskExecutor() {
        ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
        taskExecutor.setCorePoolSize(3);
        taskExecutor.setMaxPoolSize(30);
        taskExecutor.setQueueCapacity(10);
        taskExecutor.setThreadNamePrefix("Executor-");
        taskExecutor.initialize();
        return taskExecutor;
    }
}

 

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class MailRequestDto {

    private String[] to;

    private String subject;

    private String text;
    
}

 

여러명에게 이메일을 보내기 위해서는 to (수신자)는 배열일 필요가 있다

 

import io.spring.mailsender.dto.MailRequestDto;
import io.spring.mailsender.service.MessageService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

@Controller
@RequiredArgsConstructor
@Slf4j
public class MessageController {

    private final MessageService messageService;

    @GetMapping("/")
    public String messageForm() {

        info("[GET] Success messageForm()");

        return "main";
    }

    @PostMapping("/mail")
    public String messageSend(
            final MailRequestDto mailRequestDto,
            @RequestParam(name = "fileName", required = false) String fileName,
            @RequestParam(name = "async", defaultValue = "false") boolean async ) {

        info("[POST] Success messageSend()");
        log.info("mailRequestDto -> {}", mailRequestDto);
        log.info("fileName -> {}", fileName);
        log.info("async -> {}", async);

        if (!async)
            messageService.mailSend(mailRequestDto, fileName);
        else
            messageService.mailSendWithAsync(mailRequestDto, fileName);

        return "redirect:/";
    }

    private void info(String logMessage) {
        log.info(logMessage);
    }
}

boolean 은 true 는 요청이 되나, false 요청 시, 오류를 발생시킨다

org.springframework.web.bind.MissingServletRequestParameterException 발생

때문에 true 가 아닌 값은 모두 기본값으로 false 로 설정하여 바인딩한다

 

import io.spring.mailsender.dto.MailRequestDto;
import io.spring.mailsender.mail.MailManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.thymeleaf.context.Context;

@Service
@Slf4j
@RequiredArgsConstructor
public class MessageService {

    private final MailManager mailManager;

    @Value("${spring.mail.username}")
    private String fromAddress;
    @Value("${spring.mail.properties.mail.smtp.nickname}")
    private String fromName;

    @Async(value = "mailSenderExecutor")
    public void mailSendWithAsync(MailRequestDto mailRequestDto, String fileName) {
        send(mailRequestDto, fileName);
    }

    public void mailSend(MailRequestDto mailRequestDto, String fileName) {
        send(mailRequestDto, fileName);
    }

    private void send(MailRequestDto mailRequestDto, String fileName) {
        String subject = mailRequestDto.getSubject();
        String text = mailRequestDto.getText();

        Context context = new Context();
        context.setVariable("subject", subject);
        context.setVariable("text", text);
        context.setVariable("from", fromAddress);

        try {
            mailManager.setSubject(subject);
            mailManager.setThymeleafText(context, fileName, true);
            mailManager.setFromName(fromAddress, fromName);

            for (String to : mailRequestDto.getTo()) {
                log.info("to -> {}", to);
                mailManager.setTo(to);
                mailManager.send();
            }

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

application.yml 에서 작성한 내용을 @Value 로 가져온다

AsyncConfig 에서 설장한 @EnableAsync 와 메소드에 @Async 를 사용하여 해당 메소드에 대해 비동기 처리를 하였고

value = "mailSenderExecutor" 는 AsyncConfig 에 있는 @Bean 을 가르킨다

 

@Async 는 private 메소드에 사용할 수 없다

 

import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.mail.MailAuthenticationException;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Component;
import org.thymeleaf.context.Context;

import javax.mail.MessagingException;
import javax.mail.internet.MimeMessage;
import java.io.File;
import java.io.IOException;
import java.io.UnsupportedEncodingException;

@Component
public class MailManager {

    private final JavaMailSender sender;
    private MimeMessage message;
    private MimeMessageHelper messageHelper;
    private final ThymeleafTemplateMapper thymeleafTemplateMapper;

    // 생성자
    public MailManager(JavaMailSender jSender, ThymeleafTemplateMapper thymeleafTemplateMapper) throws MessagingException {
        this.sender = jSender;
        message = jSender.createMimeMessage();
        messageHelper = new MimeMessageHelper(message, true, "UTF-8");
        this.thymeleafTemplateMapper = thymeleafTemplateMapper;
    }

    // 보내는 사람 이메일
    public void setFrom(String fromAddress) throws MessagingException {
        messageHelper.setFrom(fromAddress);
    }

    // 보내는 사람 이름
    public void setFromName(String fromAddress, String fromName) throws MessagingException, UnsupportedEncodingException {
        messageHelper.setFrom(fromAddress, fromName);
    }

    // 받는 사람 이메일
    public void setTo(String email) throws MessagingException {
        messageHelper.setTo(email);
    }

    // 제목
    public void setSubject(String subject) throws MessagingException {
        messageHelper.setSubject(subject);
    }

    // 메일 내용
    public void setText(String text, boolean useHtml) throws MessagingException {
        messageHelper.setText(text, useHtml);
    }

    // 메일 내용
    public void setThymeleafText(Context context, String fileName, boolean useHtml) throws MessagingException {

        String resultText = thymeleafTemplateMapper.parse(context, fileName);
        System.out.println(resultText);

        messageHelper.setText(resultText, useHtml);
    }

    // 첨부 파일
    public void setAttach(String displayFileName, String pathToAttachment) throws MessagingException, IOException {
        File file = new ClassPathResource(pathToAttachment).getFile();
        FileSystemResource fsr = new FileSystemResource(file);

        messageHelper.addAttachment(displayFileName, fsr);
    }

    // 이미지 삽입
    public void setInline(String contentId, String pathToInline) throws MessagingException, IOException {
        File file = new ClassPathResource(pathToInline).getFile();
        FileSystemResource fsr = new FileSystemResource(file);

        messageHelper.addInline(contentId, fsr);
    }

    // 발송
    public void send() {
        try {
            sender.send(message);

        } catch (MailAuthenticationException e) {
            e.printStackTrace();
            throw new IllegalArgumentException("계정 인증 실패");
        }catch(Exception e) {
            e.printStackTrace();
        }
    }
}

 

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;

@Component
@RequiredArgsConstructor
public class ThymeleafTemplateMapper {

    private final TemplateEngine templateEngine;

    public String parse(Context context, String fileName) {
        return templateEngine.process(fileName, context);
    }
}

thymeleaf 템플릿 엔진과 thymeleaf 콘텍스트를 이용하여 미리 작성한 thymeleaf 문서를 binding 하여 string 하여 반환 시킬 수 있다.

 

[ Too Much Infomation ]

TemplateEngine 은 Gradle 에서 의존성을 추가했기때문에 스프링에서 빈으로 생성하여 자동주입한다

TemplateEngine 에서 preffix 와 suffix 를 설정할 수 있다

 

import io.spring.mailsender.dto.MailRequestDto;
import io.spring.mailsender.mail.MailManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.web.servlet.ViewResolver;
import org.thymeleaf.context.Context;
import org.thymeleaf.spring5.SpringTemplateEngine;
import org.thymeleaf.spring5.templateresolver.SpringResourceTemplateResolver;
import org.thymeleaf.spring5.view.ThymeleafViewResolver;

import javax.mail.MessagingException;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;

@ExtendWith({SpringExtension.class, MockitoExtension.class})
class MessageServiceTests {

    @TestConfiguration
    static class ThymeleafTemplateMapper {

        @Bean
        public SpringResourceTemplateResolver templateResolver() {
            SpringResourceTemplateResolver  templateResolver = new SpringResourceTemplateResolver ();
            templateResolver.setPrefix("classpath:templates/");
            templateResolver.setCharacterEncoding("UTF-8");
            templateResolver.setSuffix(".html");
            templateResolver.setTemplateMode("LEGACYHTML5");
            templateResolver.setCacheable(true);
            return templateResolver;
        }

        @Bean
        public SpringTemplateEngine templateEngine(MessageSource messageSource) {
            SpringTemplateEngine templateEngine = new SpringTemplateEngine();
            templateEngine.setTemplateResolver(templateResolver());
            templateEngine.setTemplateEngineMessageSource(messageSource);

            return templateEngine;
        }

        @Bean
        @Autowired
        public ViewResolver viewResolver(MessageSource messageSource) {
            ThymeleafViewResolver viewResolver = new ThymeleafViewResolver();
            viewResolver.setTemplateEngine(templateEngine(messageSource));
            viewResolver.setCharacterEncoding("UTF-8");
            viewResolver.setOrder(0);
            return viewResolver;
        }

    }

    @Autowired
    private SpringTemplateEngine template;

    @Mock
    private MailManager mockMailManager;


    private MessageService messageService;

    @BeforeEach
    void setUp() {
        messageService = new MessageService(mockMailManager);
    }

    @Test
    void mailSend() throws MessagingException {

        // request
        String[] to = {"test@naver.com", "test@gmail.com"};
        String subject = "제목";
        String text = "내용";
        String fileName = "message.html";

        // @Value
        String fromAddress = "admin@gmail.com";

        // dto
        MailRequestDto mailRequestDto = MailRequestDto.builder()
                .to(to)
                .subject(subject)
                .text(text)
                .build();

        messageService.mailSend(mailRequestDto, fileName);

        // setThymeleafText() 메소드 확인
        // [선택 1]
        verify(mockMailManager).setThymeleafText(any(), eq("message.html"), eq(true));

        // [선택 2]
        // verify(mockMailManager).setThymeleafText(any(), anyString(), anyBoolean());

        // send() 메소드 확인 (2번 실행 됐는 지)
        verify(mockMailManager, times(2)).send();


        Context context = new Context();
        context.setVariable("subject", subject);
        context.setVariable("text", text);
        context.setVariable("from", fromAddress);
        String result = template.process(fileName, context);
        System.out.println(result);
    }
}

JUnit 5 Mockito 테스트 코드

 

Test 코드 결과

 

동기 처리 메일 전송

 

비동기 처리 메일 전송

 

메일 전송 처리속도는 비슷비슷 합니다

동기처리는 사용자가 서버가 처리하는 것을 기다려야 하는 반면,

비동기처리는 사용자가 서버가 처리하는 것을 기다리지 않습니다.

 

비동기 처리는 대신 쓰레드를 생성이 하기 때문에, 많은 경우 서버에 부담을 줍니다

 

[+참고]

JavaMailSender, MimeMessage, MimeMessageHelper  클래스들은 외부 라이브러리 입니다

 

[+참고]

메일에서는 <script></script> 구문은 실행되지 않습니다

<link href="CDN">  를 이용한 CSS 도 적용되지 않습니다

<div style="border: 3px solid blue"></div> 처럼 style 속성을 사용해야 CSS 가 적용된다

 

[+참고]

구글은 짧은시간이 기준인지 같은 문장이 기준인지 몰라도 2번째 보낼 때 조건에 걸리면 안보내집니다

아마 매크로 같은 거나 서버에 부담을 주는 요소에 대해서는 처리를 하지 않는 것 같아요

네이버는 그런거 없이 메일 전송이 잘 됩니다