Spring Boot 자체 이메일 전송을 선택한 이유
AWS의 관리형 서비스인 SES를 사용할지 아니면 Spring Boot에서 자체적으로 이메일 전송을 할지 고민이 많았었는데, 결과적으로는 자체적으로 이메일 전송을 하는 것으로 결정했다. 그 이유는 SES를 사용하려면 사용자가 최초 한 번은 “보안 인증”이라는 것을 해야 하는데, 이런 추가적인 과정이 사용자들에게 약간 불편할 수 있겠다는 생각이 들었다. 그리고 내 서비스에는 OAuth로 소셜 로그인을 하기 때문에 사용자의 이메일 정보가 잘못될 가능성이 없어 자체 이메일 전송을 선택했다.
build.gradle에 종속성 설정
메일을 보내기 위해서 spring-boot-starter-mail
종속성을 추가했고, 서버 단에서 간단하게 이메일에 들어갈 html을 파싱하고 바로 메일로 보내기 위해서 thymeleaf
종속성도 추가한다.
dependencies {
//Mail
implementation 'org.springframework.boot:spring-boot-starter-mail'
//thymeleaf(for email service)
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect'
}
todo.html
이메일로 보낼 html을 thymeleaf로 만들어준다. 반드시 src/main/resources/templates
경로 아래에 해당 html이 있어야 한다. 나는 본문 텍스트에 date
값만 넣어주는 간단한 html을 만들어보았다. 추가적인 디자인은 프론트엔드 친구가 해줄 거라고 믿는다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<div style="margin:100px;">
<h1>Today's Overview on NESS</h1>
<br>
<p th:text="|${date}의 할일 목록입니다.|">오늘의 할일 목록입니다.</p>
</div>
</body>
</html>
구글 앱 비밀번호 설정
메일을 보내려면 우선 메일 보낼 계정이 필요하다. “Google 계정 관리”에 들어가서 앱 비밀번호를 설정한다.
우선 앱 비밀번호를 설정하기 전에 보안을 위해서 2단계 인증을 설정한다.
그 후 앱 비밀번호를 검색해서 새롭게 비밀번호를 설정한다.
자동으로 16자리 비밀번호가 생성되는데, 이 비밀번호는 일반적인 구글 계정 비밀번호와 마찬가지로 구글 계정의 모든 권한을 가지고 있는 비밀번호이다. 따라서 절대 외부로 유출되서는 안된다. 참고로 다시 비밀번호를 보는 것이 가능하기 때문에 어디에 적어놓거나 다른 사람들과 공유할 필요가 없다.
구글 지메일 POP/IMAP 설정
이제 지메일의 설정으로 들어가, “전달 및 POP/IMAP” 설정 탭으로 이동한다. “POP 다운로드” 설정은 “모든 메일에 POP 사용하기”로, “IMAP 엑세스”는 “IMAP 사용”으로, “IMAP 자동 삭제”는 “자동 삭제 사용”으로 변경한다. 변경된 설정을 2단계 인증을 거쳐서 저장한다.
참고로 MAP 및 POP는 전자 메일에 액세스하는 두 가지 방법이다. IMAP은 다른 클라이언트에서 Gmail에게 엑세스 가능한지를 설정하는 것으로, IMAP을 사용하면 모든 장치에서 어디서나 전자 메일에 액세스할 수 있다. 우리는 Spring Boot 서버를 통해서 지메일에 접근 가능해야 하므로 이 설정을 ON해준다.
POP는 전자 메일 서비스에 문의하고 모든 새 메시지를 다운로드하는 기능으로, 보낸 메일은 전자 메일 서버가 아닌 PC 또는 Mac에 로컬로 저장된다. 나는 이 기능을 쓸 일이 없긴 하지만, 수신받는 메일을 Spring Boot 서버에서 다운로드하고 로컬 또는 DB에 저장하는 로직이 있다면 이를 ON해줘야 한다.
참고 문헌
application.yml 구성
이제 메일 관련 설정을 application.yml
에 구성해준다. 여기서 username은 지메일 계정, password는 앱 비밀번호가 되어야 한다. 위에서 경고 문구가 설명했던 대로, 이 앱 비밀번호는 계정에 대한 모든 권한을 가지고 있으므로 절대 퍼블릭 깃허브에 노출되서는 안된다. 깃허브에 올라가지 않기 위해서 환경변수 또는 .gitignore
로 설정하고, 깃허브 시크릿으로 해당 값을 제공하는 것을 추천한다.
spring:
mail:
host: smtp.gmail.com
port: 587
username: [지메일 계정]
password: [앱 비밀번호]
properties:
mail.smtp.debug: true
mail.smtp.connectiontimeout: 1000 #1초
mail.starttls.enable: true
mail.smtp.auth: true
MailConfig.java
이제 위에서 설정한 값을 JavaMailSender
라는 인터페이스에 설정해준다. 하나의 JavaMailSender 설정으로 다양한 이메일을 보낼 것이므로, JavaMailSender 설정은 스프링 빈에 등록해주고, 여기에 메세지를 할당하는 로직은 서비스 계층에서 만들어 줄 예정이다.
@Configuration
@RequiredArgsConstructor
public class MailConfig {
private static final String MAIL_SMTP_AUTH = "mail.smtp.auth";
private static final String MAIL_DEBUG = "mail.smtp.debug";
private static final String MAIL_CONNECTION_TIMEOUT = "mail.smtp.connectiontimeout";
private static final String MAIL_SMTP_STARTTLS_ENABLE = "mail.smtp.starttls.enable";
// SMTP 서버
@Value("${spring.mail.host}")
private String host;
// 계정
@Value("${spring.mail.username}")
private String username;
// 비밀번호
@Value("${spring.mail.password}")
private String password;
// 포트번호
@Value("${spring.mail.port}")
private int port;
@Value("${spring.mail.properties.mail.smtp.auth}")
private boolean auth;
@Value("${spring.mail.properties.mail.smtp.debug}")
private boolean debug;
@Value("${spring.mail.properties.mail.smtp.connectiontimeout}")
private int connectionTimeout;
@Value("${spring.mail.properties.mail.starttls.enable}")
private boolean startTlsEnable;
@Bean
public JavaMailSender javaMailService() {
JavaMailSenderImpl javaMailSender = new JavaMailSenderImpl();
javaMailSender.setHost(host);
javaMailSender.setUsername(username);
javaMailSender.setPassword(password);
javaMailSender.setPort(port);
Properties properties = javaMailSender.getJavaMailProperties();
properties.put(MAIL_SMTP_AUTH, auth);
properties.put(MAIL_DEBUG, debug);
properties.put(MAIL_CONNECTION_TIMEOUT, connectionTimeout);
properties.put(MAIL_SMTP_STARTTLS_ENABLE, startTlsEnable);
javaMailSender.setJavaMailProperties(properties);
javaMailSender.setDefaultEncoding("UTF-8");
return javaMailSender;
}
}
참고로 JavaMailSender
는 복잡한 메세지 형식인 MIME messages를 지원해주는 인터페이스이다. JavaMailSender 인터페이스는 MailSender
인터페이스를 상속한 것인데, MailSender 인터페이스는 단순한 메세지 형식인 SimpleMailMessage를 전송해준다. 나는 html 등의 복잡한 메세지를 전송해줄 것이므로 JavaMailSender를 선택했다.
참고 문헌
EmailService.java
이제 서비스 계층에서 실제 JavaMailSender를 통해서 전송해줄 내용을 정한다. 전송해줄 message는 SpringTemplateEngine
에 의해서 todo.html
이 된다. MimeMessageHelper
를 통해서 수신자 이메일, 메일 제목, 메일 본문 message을 설정해주고, JavaMailSender를 통해서 전송해주면 간단하게 전송 가능하다.
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional(readOnly = true)
public class EmailService {
private final JavaMailSender javaMailSender;
private final SpringTemplateEngine templateEngine;
@Async
public void sendEmailNotice(String email){
MimeMessage mimeMessage = javaMailSender.createMimeMessage();
try {
MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMessage, false, "UTF-8");
mimeMessageHelper.setTo(email); // 메일 수신자
mimeMessageHelper.setSubject("Today's Overview on NESS"); // 메일 제목
mimeMessageHelper.setText(setContext(todayDate()), true); // 메일 본문 내용, HTML 여부
javaMailSender.send(mimeMessage);
log.info("Succeeded to send Email");
} catch (Exception e) {
log.info("Failed to send Email");
throw new RuntimeException(e);
}
}
public String todayDate(){
ZonedDateTime todayDate = LocalDateTime.now(ZoneId.of("Asia/Seoul")).atZone(ZoneId.of("Asia/Seoul"));
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("M월 d일");
return todayDate.format(formatter);
}
//thymeleaf를 통한 html 적용
public String setContext(String date) {
Context context = new Context();
context.setVariable("date", date);
return templateEngine.process("todo", context);
}
}
결과
이제 만든 서비스 로직을 테스트해보면 잘 전송된 것을 확인할 수 있다!
table 사용해 복잡한 이메일 전송
위에서 구현한 이메일 템플릿은 아주 간단한 형식이고, 나는 좀 더 복잡한 방식의 HTML을 전송하고 싶다. 그래서 프론트엔드 개발자에게 부탁해서 복잡한 템플릿의 HTML을 받아와서 전송을 테스트하는데, 다음과 같이 CSS가 깨지는 문제가 생겼다.
보니까 우리가 기본적으로 사용하는 flexbox 등의 CSS는 이메일을 전송할 때 렌더링이 안된다고 한다. 그래서 내가 받은 이메일 중 링크드인에서 온 이메일이 내가 원하는 템플릿과 비슷하길래 HTML를 살펴보았더니 table을 사용하고 있었다. 보니까 flexbox 등의 CSS를 사용하는 게 아니라 전통적인 table 방식을 사용해야 하는 것 같길래, ChatGPT의 도움을 받아 기존 코드를 다시 table로 변경했다.
변경한 이메일 템플릿은 다음과 같다. 참고로 템플릿 엔진으로는 thymeleaf를 썼다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" />
<style>
body {
font-family: "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, "Helvetica Neue", "Segoe UI", "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", sans-serif;
}
</style>
</head>
<body>
<div style="margin:0px; width:100%; background-color:#F2F0FF; padding:0px; padding-top:8px;">
<table
style="width: 512px; align-content: center; position: relative; overflow: hidden; background: #fff;
margin: 0px auto; max-width:512px; background-color:#ffffff; padding:0px">
<tr>
<td style="height: 49px;">
<img
src="https://...(생략).../ness_logo.png"
style="width: 40px; height: 40px; vertical-align: middle;"
/>
<p
style="font-size: 20px; font-weight: 700; text-align: left; color: #140f33; display: inline-block; vertical-align: middle; margin-left: 10px;"
th:text="|${date}, 오늘 하루도 수고한 당신에게|">오늘 하루도 수고한 당신에게
</p>
</td>
</tr>
<!--구분선-->
<tr>
<td colspan="2" style="height: 1px; background-color: #3E426A; margin-bottom: 10px;"></td>
</tr>
<tr>
<td colspan="2" style="text-align: center; padding-top: 15px; padding-left: 5px; padding-right: 5px;">
<img
th:if="${image != null}" th:src="${image}"
src="https://...(생략).../email_sample.png"
style="object-fit: cover; width: 490px;"
alt="email-image"
/>
</td>
</tr>
<tr>
<td colspan="2" style="text-align: center;">
<p
th:text="|${text}|"
style="font-size: 16px; font-weight: 300; padding-left: 5px; padding-right: 5px; text-align: left; color: #140f33;">
오늘 정말 수고 많았어요! 당신의 노력과 열정에 정말 감탄해요.
하루 종일 바쁘게 움직이면서도 절대 포기하지 않고 최선을 다하고 있는 모습이 정말 멋져요.
오늘 저희가 함께한 시간은 뜻깊게 기억될 거에요.
내일을 위해서 오늘은 충분한 휴식을 취하고, 재충전 후 다시 만나요.
</p>
</td>
</tr>
<tr>
<td colspan="2" style="height: 1px; background-color: #3E426A; margin-bottom: 10px;"></td>
</tr>
<tr>
<td colspan="2" style="font-size: 14px; font-weight: 300; text-align: center; color: #686868; padding-top: 5px;">
© 2024 Re:coding Service, All rights reserved.
</td>
</tr>
</table>
</div>
</body>
</html>
@Scheduled로 이메일 전송 작업 스케쥴링
위의 Service 코드는 API 등으로 트리거 되어야 이메일을 전송해주는 코드인데, 내가 하고 싶은 건 매일 자정 12시마다 사용자에게 오늘의 리포트를 보내주는 기능이다. 이런 기능은 스프링 부트의 @Scheduled 어노테이션을 사용해서 간단하게 cron 작업을 만들 수 있다.
먼저, 1. @SpringBootApplication
의 하위 패키지이자, 2. @Component
가 있는 클래스(즉 @Service 있는 클래스도 가능)에 @EnableScheduling
을 붙여준다. 그리고 @EnableScheduling를 붙여준 클래스에서 원하는 메소드에 @Scheduled
를 붙여주면 스케쥴링이 활성화된다.
@Scheduled의 옵션으로는 메소드 호출~다음 메소드 호출 사이의 간격을 정해주는 fixedRate
, 메소드 종료~다음 메소드 호출 사이의 간격을 정해주는 fixedDelay
등이 있지만, 원하는 시간마다 호출하려면 cron
을 사용하면 된다.
@Service
@EnableScheduling
public class EmailService {
private final ProfileRepository profileRepository;
private final MemberRepository memberRepository;
private final AsyncEmailService asyncEmailService;
// 매일 12:00에 스케쥴링
@Scheduled(cron = "0 0 0 * * *")
public void scheduleEmailCron(){
log.info("스케쥴링 활성화");
List<Member> activeMembers = memberRepository.findMembersByProfileIsEmailActive(true);
for (Member member : activeMembers) {
String email = member.getEmail();
asyncEmailService.sendEmailNotice(email);
}
}
}
@Async로 이메일 전송 비동기 처리
실제로 이메일을 전송하는 부분은 따로 AsyncEmailService
클래스를 만들어서 그 안에 구현해놨다. 그 이유는 Async는 프록시 기반 동작이므로, self-invocation(자가 호출) 불가하기 때문이다. 따라서 @Scheduled가 있는 EmailService
와는 다른 클래스에 속해야 EmailService가 호출 가능하다.
비동기 처리를 하는 방법은 매우 간단한데, 클래스 위에 @EnableAsync
를 붙여주고, 비동기로 처리하고 싶은 메소드에 @Async
를 붙여주면 된다. 여기서는 이메일을 보내는 로직에 @Async를 붙여주었다.
(Tip: @Async를 붙인 메소드가 다른 메소드를 호출해도 되기 때문에, 함수화를 통해 클린 코드를 만들어보자.)
@Service
@RequiredArgsConstructor
@Slf4j
@EnableAsync
public class AsyncEmailService {
/* Async는 프록시 기반 동작이므로, self-invocation(자가 호출) 불가->다른 클래스로 분리*/
// TODO: setQueueCapacity로 비동기로 처리 가능한 이메일 제한 필요
private final JavaMailSender javaMailSender;
private final SpringTemplateEngine templateEngine;
private final FastApiEmailApi fastApiEmailApi;
@Async
public void sendEmailNotice(Long memberId, String email){
log.info("Trying to send Email to " + email);
try {
PostFastApiAiEmailDto aiDto = postTodayAiAnalysis(memberId, getToday());
String image = aiDto.getImage();
String text = aiDto.getText().replace("<br>", "\n");
MimeMessage mimeMessage = javaMailSender.createMimeMessage();
MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMessage, true, "UTF-8");
mimeMessageHelper.setTo(email); // 메일 수신자
mimeMessageHelper.setSubject("End of Today with NESS"); // 메일 제목
mimeMessageHelper.setText(setContext(getTodayDate(), image, text), true);
javaMailSender.send(mimeMessage);
log.info("Succeeded to send Email to " + email);
} catch (Exception e) {
log.info("Failed to send Email to " + email + ", Error log: ", e);
throw new RuntimeException(e);
}
}
public PostFastApiAiEmailDto postTodayAiAnalysis(Long id, ZonedDateTime today){
PostFastApiUserEmailDto userDto = PostFastApiUserEmailDto.builder()
.member_id(id.intValue())
.user_persona("")
.schedule_datetime_start(today)
.schedule_datetime_end(today)
.build();
//Fast API에 전송하고 값 받아오기
return fastApiEmailApi.creatFastApiEmail(userDto);
}
public ZonedDateTime getToday(){
return ZonedDateTime.now(ZoneId.of("Asia/Seoul"));
}
public String getTodayDate(){
ZonedDateTime todayDate = LocalDateTime.now(ZoneId.of("Asia/Seoul")).atZone(ZoneId.of("Asia/Seoul"));
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("M월 d일");
return todayDate.format(formatter);
}
//thymeleaf를 통한 html 적용
public String setContext(String date, String image, String text) {
Context context = new Context();
context.setVariable("date", date);
context.setVariable("image", image);
context.setVariable("text", text);
return templateEngine.process("end-of-today", context);
}
}
비동기 처리하기 전에는 아래와 같이 순차적으로 이메일이 보내졌다.
비동기 처리한 후에는 이렇게 동시에 이메일이 보내진다.
이메일 전송 자체가 시간이 꽤 소요되기도 하고, 특히 내가 구현한 이메일의 경우에는 DALL-E와 ChatGPT가 생성한 이미지 및 텍스트를 받아와서 이메일을 전송하는 방식이기 때문에 순차적으로 이메일을 보내주면 정말 시간이 오래 걸린다. 비동기 처리를 통해서 이 부분의 시간을 크게 간소화했다.