[알림 기능 찾아 삼만리 링크 1주 차: (1) (2)]
[알림 기능 찾아 삼만리 링크 2주 차: (3) (4) (5) (6) (7) (8) (9) (10)]
[알림 기능 찾아 삼만리 링크 3주 차: (11) (12) (13) (14) (15) (16) (17) (18)]
'이메일 발송 로직을 분리해야 할까?'
이번에 '구현 사항 설명하기' 2회차 영상을 녹화할 때 '이메일 발송 로직 분리'를 첫 번째 기술적 의사결정으로 얘기하기로 했다. 왜 로직을 나누었으며, 나눌 때와 나누지 않을 때 장단점을 비교하고 어떤 이유로 로직을 나누었는지 설명하기로 했다. 새로운 기술 적용은 아니라서 '과연 이걸 기술적 의사결정이라고 할 수 있을까?' 의문이 들었지만, 오히려 가장 기초가 되는 기술적 의사결정이라는 피드백에 마음이 놓였다.
(1) 로직 분리 전 Email Sender ▼
package com.project.cheerha.domain.notification.sender;
import com.project.cheerha.domain.notification.entity.Notification;
import com.project.cheerha.domain.notification.repository.NotificationRepository;
import com.sendgrid.Method;
import com.sendgrid.Request;
import com.sendgrid.SendGrid;
import com.sendgrid.helpers.mail.Mail;
import com.sendgrid.helpers.mail.objects.Content;
import com.sendgrid.helpers.mail.objects.Email;
import java.io.IOException;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
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;
@Slf4j
@Service
@RequiredArgsConstructor
public class EmailSender {
private final NotificationRepository notificationRepository;
@Value("${SENDGRID_API_KEY}")
private String sendGridApiKey;
@Value("${SENDGRID_FROM_EMAIL}")
private String senderEmail;
// 알림을 이메일로 비동기 전송
@Async
public void sendEmails() {
// key: 알림 받을 이메일, value: Notification Set
Map<String, Set<Notification>> emailToNotificationSet = new HashMap<>();
// (1) 이메일로 전송되지 않은 Notification 조회
// (2) 이메일 주소별로 해당 Notification Set 그룹화하여 저장
notificationRepository.findByIsEmailSentFalse().forEach(
notification -> {
emailToNotificationSet.computeIfAbsent(
notification.getEmail(),
emailAsKey -> new HashSet<>()
).add(notification);
});
// 묶은 알림을 각 이메일로 전송
emailToNotificationSet.forEach(this::sendMail);
}
/**
* 이메일 인증 코드 전송
*
* @param recipientEmail 이메일 수신자
* @param code 인증 코드
*/
public void sendVerificationEmail(
String recipientEmail,
String code
) {
try {
Email from = new Email(senderEmail);
Email to = new Email(recipientEmail);
String subject = "이메일 인증 코드";
String content = "<p>인증 코드: <strong>" + code + "</strong></p>";
Content emailContent = new Content(
"text/html",
content
);
sendSendGridEmail(
from,
subject,
to,
emailContent
);
log.info("인증 코드 이메일 전송 완료: {}", recipientEmail);
} catch (IOException e) {
log.error("인증 코드 이메일 전송 실패: {}", recipientEmail, e);
}
}
/**
* SendGrid로 이메일 전송
*
* @param from 발신자 이메일
* @param subject 이메일 제목
* @param to 수신자 이메일
* @param emailContent 이메일 내용
* @throws IOException 이메일 전송 시 발생할 수 있는 예외
*/
private void sendSendGridEmail(
Email from,
String subject,
Email to,
Content emailContent
)
throws IOException {
Mail mail = new Mail(
from,
subject,
to,
emailContent
);
SendGrid sendGrid = new SendGrid(sendGridApiKey);
Request request = new Request();
request.setMethod(Method.POST);
request.setEndpoint("mail/send");
request.setBody(mail.build());
sendGrid.api(request);
}
/**
* 수신자에게 맞춤형 채용 공고 알림을 이메일로 전송
*
* @param recipientEmail 수신자 이메일
* @param notificationSet 해당 수신자에게 보낼 알림 목록
*/
private void sendMail(
String recipientEmail,
Set<Notification> notificationSet
) {
try {
// 이메일 설정
Email from = new Email(senderEmail);
Email to = new Email(recipientEmail);
String subject = "📢 새로운 맞춤 채용 공고가 도착했어요!";
StringBuilder content = new StringBuilder();
content.append("<h1>🚀 새로운 채용 공고가 준비됐어요! 🎉</h1>");
content.append("<p>맞춤형 채용 공고가 도착했답니다! 💼</p>");
content.append("<p>아래 링크에서 확인해보세요! ⬇️</p>");
content.append("<ul>");
// 알림 목록을 이메일 내용에 추가
for (Notification notification : notificationSet) {
content.append("<li>👉 <a href=\"")
.append(notification.getJobOpeningUrl())
.append("\" target=\"_blank\">")
.append("채용 공고 자세히 보기</a></li>");
}
content.append("</ul>");
content.append("<p>행운을 빕니다! 🙌</p>");
// 이메일 전송
Content emailContent = new Content(
"text/html",
content.toString()
);
sendSendGridEmail(
from,
subject,
to,
emailContent
);
log.info("이메일 전송 완료: {}", recipientEmail);
notificationSet.forEach(notification -> {
notification.markEmailAsSent();
notificationRepository.save(notification);
});
} catch (IOException e) {
log.error("이메일 전송 실패: {}", recipientEmail, e);
}
}
}
이미 이메일 알림을 보내는 로직만 쓸 때도 전송 로직 사이에 끼어든 HTML이 불편했다. 어느 한 부분을 수정하려고 하면 사실상 전체 로직을 다 읽어야 하는 점 또한 불편했다. 물론, 로직을 여러 클래스로 분리하면 어디서 문제가 생겼을 때 여러 클래스를 살펴야 하는 단점이 있었기에, 각각 장단점을 정리한 다음 기술적 의사결정을 내렸다.
이번에 이메일 인증 코드를 전송하는 로직만 추가되었을 뿐인데도 클래스를 읽는 데 시간이 오래 걸린 만큼, 로직을 분리하기로 했다. 혹시 나중에 메서드를 다른 데서 쓸 수 있다는 점 또한 고려해서 여러 클래스로 이메일 전송 로직을 나누었다.
(1) 이메일 형식을 클래스로 분리하기 ▼
package com.project.cheerha.common.email.format;
import com.project.cheerha.domain.notification.entity.Notification;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class NotificationFormat {
public String[] createEmailNotification(Set<Notification> notificationSet) {
// 이메일 제목
String subject = "📢 새로운 맞춤 채용 공고가 도착했어요!";
// 이메일 본문 생성
StringBuilder content = new StringBuilder();
content.append("<h1>🚀 새로운 채용 공고가 준비됐어요! 🎉</h1>");
content.append("<p>맞춤형 채용 공고가 도착했답니다! 💼</p>");
content.append("<p>아래 링크에서 확인해보세요! ⬇️</p>");
content.append("<ul>");
// 알림(Notification) 목록을 이메일 내용에 추가
for (Notification notification : notificationSet) {
content.append("<li>👉 <a href=\"")
.append(notification.getJobOpeningUrl())
.append("\" target=\"_blank\">")
.append("채용 공고 자세히 보기</a></li>");
}
content.append("</ul>");
content.append("<p>행운을 빕니다! 🙌</p>");
return new String[]{subject, content.toString()};
}
}
package com.project.cheerha.common.email.format;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class VerificationFormat {
public String[] createVerification (String code) {
String subject = "이메일 인증 코드";
String content = "<p>인증 코드: <strong>" + code + "</strong></p>";
return new String[] {subject, content};
}
}
(2) 이메일 전송 인터페이스 및 구현체 생성하기 ▼
package com.project.cheerha.common.email.sender;
import java.io.IOException;
public interface EmailSender {
void send(String recipientEmail, String subject, String content) throws IOException;
}
package com.project.cheerha.common.email.sender;
import com.sendgrid.Method;
import com.sendgrid.Request;
import com.sendgrid.SendGrid;
import com.sendgrid.helpers.mail.Mail;
import com.sendgrid.helpers.mail.objects.Content;
import com.sendgrid.helpers.mail.objects.Email;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
public class SendGridEmailSender implements EmailSender {
@Value("${SENDGRID_API_KEY}")
private String sendGridApiKey;
@Value("${SENDGRID_FROM_EMAIL}")
private String senderEmail;
/**
* 이메일을 SendGrid로 전송
*
* @param recipientEmail 수신자 이메일
* @param subject 이메일 제목
* @param content 이메일 본문 (HTML)
* @throws IOException 이메일 전송 시 발생할 수 있는 예외
*/
@Override
public void send(String recipientEmail, String subject, String content) throws IOException {
Email from = new Email(senderEmail);
Email to = new Email(recipientEmail);
Content emailContent = new Content("text/html", content);
Mail mail = new Mail(from, subject, to, emailContent);
SendGrid sendGrid = new SendGrid(sendGridApiKey);
Request request = new Request();
request.setMethod(Method.POST);
request.setEndpoint("mail/send");
request.setBody(mail.build());
sendGrid.api(request);
log.info("이메일 전송 완료: {}", recipientEmail);
}
}
(3) 인터페이스로 구현 세부 사항을 숨기고 로직 추상화하기 ▼
package com.project.cheerha.common.email.sender;
import com.project.cheerha.common.email.format.NotificationFormat;
import com.project.cheerha.domain.notification.entity.Notification;
import com.project.cheerha.domain.notification.repository.NotificationRepository;
import java.io.IOException;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class NotificationEmailSender {
private final NotificationRepository notificationRepository;
private final NotificationFormat notificationFormat;
private final EmailSender emailSender;
// Notification을 이메일로 비동기 전송
@Async
public void sendNotificationEmails() {
// key: 알림 받을 이메일, value: Notification Set
Map<String, Set<Notification>> emailToNotificationSet = new HashMap<>();
// (1) 이메일로 전송되지 않은 Notification 조회
// (2) 이메일 주소별로 해당 Notification Set 그룹화하여 저장
notificationRepository.findByIsEmailSentFalse().forEach(
notification -> emailToNotificationSet
.computeIfAbsent(notification.getEmail(), emailAsKey -> new HashSet<>())
.add(notification)
);
// 묶은 알림을 각 이메일로 전송
emailToNotificationSet.forEach(this::sendNotificationEmail);
}
private void sendNotificationEmail(String recipientEmail, Set<Notification> notificationSet) {
try {
// 이메일 내용 생성
String[] emailData = notificationFormat.createEmailNotification(notificationSet);
String subject = emailData[0];
String content = emailData[1];
// 이메일 전송
emailSender.send(recipientEmail, subject, content);
// 전송된 알림 상태 변경
notificationSet.forEach(notification -> {
notification.markEmailAsSent();
notificationRepository.save(notification);
});
} catch (IOException e) {
log.error("이메일 전송 실패: {}", recipientEmail, e);
}
}
}
package com.project.cheerha.common.email.sender;
import com.project.cheerha.common.email.format.VerificationFormat;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class VerificationEmailSender {
private final VerificationFormat verificationFormat;
private final EmailSender emailSender;
public void sendVerificationEmail(String recipientEmail, String code) {
try {
String[] emailData = verificationFormat.createVerification(code);
String subject = emailData[0];
String content = emailData[1];
emailSender.send(recipientEmail, subject, content);
} catch (IOException e) {
log.error("인증 코드 이메일 전송 실패: {}", recipientEmail, e);
}
}
}
(4) Email Format 클래스를 빈(Bean)으로 등록하는 대신 static 메서드로 선언하기 ▼ [링크]
이번에 로직을 분리하면서 뼈저리게 느꼈다. 되도록 처음부터 로직을 분리해서 기능을 구현하자고. 힘들었지만 로직을 분리하고 나니 이메일 전송 관련 메서드를 고치거나 확인할 때마다 이메일 형식을 함께 볼 필요가 없어서 확실히 코드가 한눈에 잘 들어왔다.
이렇게 리팩토링해서 보람을 느낀 동시에 또 다른 고민이 뒤따랐다.
'지금은 기술 요건이 하나라도 겹치면 채용 공고 목록에 포함된단 말이지.'
'현재 팀에서 크롤링(crawling)할 때 채용 공고는 대략 천 개씩 올라오고.'
'잠시만, 사용자한테 이렇게 채용 공고 목록을 전부 다 보내주는 게 맞나?'
Day 20에서 계속…….
'개발 일지 > 취하여 프로젝트' 카테고리의 다른 글
4주 차: 알림 기능 찾아 삼만리 Day 21 - 이메일에 넣을 채용 공고 스무 개는 어떤 기준으로 뽑지? (0) | 2025.03.07 |
---|---|
4주 차: 알림 기능 찾아 삼만리 Day 20 - 아니, 이메일 안에 토글을 못 넣는 건 계획에 없었습니다만? (0) | 2025.03.06 |
4주 차: 알림 기능 찾아 삼만리 Day 18 - 아마도 마지막 리팩토링(Refactoring), 끝을 향하여 (1) (0) | 2025.03.04 |
4주 차: 알림 기능 찾아 삼만리 Day 17 - 내 사전에 줄 글 범벅 PPT는 없으니까! (0) | 2025.03.03 |
3주 차: 알림 기능 찾아 삼만리 Day 16 - 다양한 일을 겪은 덕분에 '구현 사항 설명하기' 대본이 20분가량 나왔습니다 (0) | 2025.03.02 |