[알림 기능 찾아 삼만리 링크 1주 차: (1) (2)]
[알림 기능 찾아 삼만리 링크 2주 차: (3) (4) (5) (6) (7) (8) (9) (10)]
[알림 기능 찾아 삼만리 링크 3주 차: (11) (12) (13) (14) (15) (16) (17) (18) (19) (20)]
[깃허브 링크]
package com.project.cheerha.domain.notification.service;
import com.project.cheerha.domain.notification.dto.NotificationRecipientDto;
import com.project.cheerha.domain.notification.entity.Notification;
import com.project.cheerha.domain.notification.repository.NotificationRepository;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class NotificationServiceInitialVersion {
private final NotificationRepository notificationRepository;
@Transactional
public void createNotification(
List<NotificationRecipientDto> notificationRecipientDtoList,
Map<Long, List<String>> keywordIdToUrlList
) {
Map<String, Set<String>> emailToUrlSet = new HashMap<>();
matchEmailToUrlSetByKeywordId(
notificationRecipientDtoList,
keywordIdToUrlList,
emailToUrlSet
);
emailToUrlSet.forEach((email, urlSet) -> {
List<Notification> foundNotificationList = notificationRepository.findAllByEmailAndJobOpeningUrlIn(
email,
urlSet.stream().toList()
);
Set<String> existingUrlSet = findExistingUrlSet(foundNotificationList);
List<Notification> notificationList = createNotificationList(
email,
urlSet,
existingUrlSet
);
notificationRepository.saveAll(notificationList);
});
}
private void matchEmailToUrlSetByKeywordId(
List<NotificationRecipientDto> notificationRecipientDtoList,
Map<Long, List<String>> keywordIdToUrlList,
Map<String, Set<String>> emailToUrlSet
) {
for (NotificationRecipientDto dto : notificationRecipientDtoList) {
List<String> matchingUrlList = keywordIdToUrlList.getOrDefault(
dto.keywordId(),
List.of()
);
if (!matchingUrlList.isEmpty()) {
emailToUrlSet.computeIfAbsent(
dto.email(),
email -> new HashSet<>()
).addAll(matchingUrlList);
}
}
}
private Set<String> findExistingUrlSet(List<Notification> notificationList) {
return notificationList.stream()
.map(Notification::getJobOpeningUrl)
.collect(Collectors.toSet());
}
private List<Notification> createNotificationList(
String email,
Set<String> urlSet,
Set<String> existingUrlSet
) {
return urlSet.stream()
.filter(jobOpeningUrl -> !existingUrlSet.contains(jobOpeningUrl))
.map(jobOpeningUrl -> Notification.toEntity(email, jobOpeningUrl))
.toList();
}
}
기존 로직은 사용자가 고른 기술 키워드가 하나라도 겹치면 전부 이메일 내용에 담겼기 때문에 스무 개 정도로 제한해야 했다. 채용 공고를 전부 이메일에 담아서 보내면 사용자 눈에는 스팸(spam)으로밖에 안 보일 테니까. 알림 객체(Notification Entity)를 생성할 때 사용자 정보와 채용 공고 정보를 가져오는 만큼, 데이터베이스와 다시 소통하지 않고 맨 처음 가져오는 데이터를 입맛에 맞게 가공하기로 했다.
처음에는 '기술 키워드가 많이 겹치는 순'으로 채용 공고 스무 개를 선정하려고 했다.
(1) Notification에 '키워드가 겹치는 개수' 속성 추가하기 ▼
@Column
private int overlapCount;
package com.project.cheerha.domain.notification.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@Getter
@NoArgsConstructor
@Table(
name = "notification",
uniqueConstraints = {@UniqueConstraint(
columnNames = {"email", "job_opening_url"}
)}
)
public class Notification {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String email;
@Column(nullable = false)
private String jobOpeningUrl;
@Column
private boolean isEmailSent;
@Column
private boolean isPushSent;
@Column
private int overlapCount;
public static Notification toEntity(
String email,
String jobOpeningUrl,
int overlapCount
) {
Notification notification = new Notification();
notification.email = email;
notification.jobOpeningUrl = jobOpeningUrl;
notification.isEmailSent = false;
notification.isPushSent = false;
notification.overlapCount = overlapCount;
return notification;
}
public void markEmailAsSent() {
this.isEmailSent = true;
}
public void markPushAsSent() {
this.isPushSent = true;
}
}
(2) 채용 공고 정보를 Map으로 변환하는 메서드 생성하기 ▼
// Key: 채용 공고의 URL
// Value: 기술 키워드 ID 집합
private Map<String, Set<Long>> invertKeywordIdToUrlList(
Map<Long, List<String>> keywordIdToUrlList) {
Map<String, Set<Long>> urlToKeywordIdSet = new HashMap<>();
keywordIdToUrlList.forEach((keywordId, urlList) -> {
for (String url : urlList) {
urlToKeywordIdSet
.computeIfAbsent(url, urlAsKey -> new HashSet<>())
.add(keywordId);
}
});
return urlToKeywordIdSet;
}
(3) 사용자 정보를 Map으로 변환하는 메서드 생성하기 ▼
// Key: 이메일 주소
// Value: 기술 키워드 ID 집합
private Map<String, Set<Long>> invertEmailToKeywordIdList(
List<NotificationRecipientDto> notificationRecipientDtoList) {
Map<String, Set<Long>> emailToKeywordIdSet = new HashMap<>();
notificationRecipientDtoList.forEach(dto -> {
emailToKeywordIdSet
.computeIfAbsent(dto.email(), emailAsKey -> new HashSet<>())
.add(dto.keywordId());
});
return emailToKeywordIdSet;
}
(4) 사용자와 채용 공고의 기술 키워드끼리 겹치는 개수 구하는 메서드 생성하기 ▼
private Map<String, Map<String, Long>> compareKeywordOverlap(
Map<String, Set<Long>> emailToKeywordIdSet,
Map<String, Set<Long>> urlToKeywordIdSet
) {
Map<String, Map<String, Long>> emailToUrlToOverlapCount = new HashMap<>();
Set<String> emailSet = emailToKeywordIdSet.keySet();
Set<String> urlSet = urlToKeywordIdSet.keySet();
for (String email : emailSet) {
Set<Long> userKeywords = emailToKeywordIdSet.get(email);
for (String url : urlSet) {
Set<Long> jobOpeningKeywords = urlToKeywordIdSet.get(url);
long overlapCount = userKeywords.stream()
.filter(jobOpeningKeywords::contains)
.count();
if (overlapCount > 0) {
emailToUrlToOverlapCount
.computeIfAbsent(
email,
emailAsKey -> new HashMap<>()
).put(url, overlapCount);
}
}
}
return emailToUrlToOverlapCount;
}
(5) (2) ~ (4) 로직을 알림 객체 생성 로직에 반영하기 ▼
@Transactional
public void createNotification(
List<NotificationRecipientDto> notificationRecipientDtoList,
Map<Long, List<String>> keywordIdToUrlList
) {
Map<String, Set<String>> emailToUrlSet = new HashMap<>();
matchEmailToUrlSetByKeywordId(
notificationRecipientDtoList,
keywordIdToUrlList,
emailToUrlSet
);
Map<String, Map<String, Long>> emailToUrlToOverlapCount = compareKeywordOverlap(
invertEmailToKeywordIdList(notificationRecipientDtoList),
invertKeywordIdToUrlList(keywordIdToUrlList)
);
emailToUrlSet.forEach((email, urlSet) -> {
List<Notification> foundNotificationList = notificationRepository.findAllByEmailAndJobOpeningUrlIn(
email,
urlSet.stream().toList()
);
Set<String> existingUrlSet = findExistingUrlSet(foundNotificationList);
List<Notification> notificationList = createNotificationList(
email,
urlSet,
existingUrlSet,
emailToUrlToOverlapCount
);
notificationRepository.saveAll(notificationList);
});
}
package com.project.cheerha.domain.notification.service;
import com.project.cheerha.domain.notification.dto.NotificationRecipientDto;
import com.project.cheerha.domain.notification.entity.Notification;
import com.project.cheerha.domain.notification.repository.NotificationRepository;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class NotificationService {
private final NotificationRepository notificationRepository;
@Transactional
public void createNotification(
List<NotificationRecipientDto> notificationRecipientDtoList,
Map<Long, List<String>> keywordIdToUrlList
) {
Map<String, Set<String>> emailToUrlSet = new HashMap<>();
matchEmailToUrlSetByKeywordId(
notificationRecipientDtoList,
keywordIdToUrlList,
emailToUrlSet
);
Map<String, Map<String, Long>> emailToUrlToOverlapCount = compareKeywordOverlap(
invertEmailToKeywordIdList(notificationRecipientDtoList),
invertKeywordIdToUrlList(keywordIdToUrlList)
);
emailToUrlSet.forEach((email, urlSet) -> {
List<Notification> foundNotificationList = notificationRepository.findAllByEmailAndJobOpeningUrlIn(
email,
urlSet.stream().toList()
);
Set<String> existingUrlSet = findExistingUrlSet(foundNotificationList);
List<Notification> notificationList = createNotificationList(
email,
urlSet,
existingUrlSet,
emailToUrlToOverlapCount
);
notificationRepository.saveAll(notificationList);
});
}
private void matchEmailToUrlSetByKeywordId(
List<NotificationRecipientDto> notificationRecipientDtoList,
Map<Long, List<String>> keywordIdToUrlList,
Map<String, Set<String>> emailToUrlSet
) {
for (NotificationRecipientDto dto : notificationRecipientDtoList) {
List<String> matchingUrlList = keywordIdToUrlList.getOrDefault(
dto.keywordId(),
List.of());
if (!matchingUrlList.isEmpty()) {
emailToUrlSet.computeIfAbsent(
dto.email(),
email -> new HashSet<>()
).addAll(matchingUrlList);
}
}
}
private Set<String> findExistingUrlSet(List<Notification> notificationList) {
return notificationList.stream()
.map(Notification::getJobOpeningUrl)
.collect(Collectors.toSet());
}
private List<Notification> createNotificationList(String email, Set<String> urlSet,
Set<String> existingUrlSet, Map<String, Map<String, Long>> emailToUrlToOverlapCount) {
return urlSet.stream()
.filter(jobOpeningUrl -> !existingUrlSet.contains(jobOpeningUrl))
.map(jobOpeningUrl -> {
long overlapCount = emailToUrlToOverlapCount.getOrDefault(email, new HashMap<>())
.getOrDefault(jobOpeningUrl, 0L);
return Notification.toEntity(email, jobOpeningUrl, (int) overlapCount);
}).collect(Collectors.toList());
}
private Map<String, Set<Long>> invertKeywordIdToUrlList(
Map<Long, List<String>> keywordIdToUrlList) {
Map<String, Set<Long>> urlToKeywordIdSet = new HashMap<>();
keywordIdToUrlList.forEach((keywordId, urlList) -> {
for (String url : urlList) {
urlToKeywordIdSet
.computeIfAbsent(url, urlAsKey -> new HashSet<>())
.add(keywordId);
}
});
return urlToKeywordIdSet;
}
private Map<String, Set<Long>> invertEmailToKeywordIdList(
List<NotificationRecipientDto> notificationRecipientDtoList) {
Map<String, Set<Long>> emailToKeywordIdSet = new HashMap<>();
notificationRecipientDtoList.forEach(dto -> {
emailToKeywordIdSet
.computeIfAbsent(dto.email(), emailAsKey -> new HashSet<>())
.add(dto.keywordId());
});
return emailToKeywordIdSet;
}
private Map<String, Map<String, Long>> compareKeywordOverlap(
Map<String, Set<Long>> emailToKeywordIdSet,
Map<String, Set<Long>> urlToKeywordIdSet
) {
Map<String, Map<String, Long>> emailToUrlToOverlapCount = new HashMap<>();
Set<String> emailSet = emailToKeywordIdSet.keySet();
Set<String> urlSet = urlToKeywordIdSet.keySet();
for (String email : emailSet) {
Set<Long> userKeywords = emailToKeywordIdSet.get(email);
for (String url : urlSet) {
Set<Long> jobOpeningKeywords = urlToKeywordIdSet.get(url);
long overlapCount = userKeywords.stream()
.filter(jobOpeningKeywords::contains)
.count();
if (overlapCount > 0) {
emailToUrlToOverlapCount
.computeIfAbsent(
email,
emailAsKey -> new HashMap<>()
).put(url, overlapCount);
}
}
}
return emailToUrlToOverlapCount;
}
}
이후에는 가독성을 높이고자 for문 대신 stream()을 사용했다. ▼
private Map<String, Map<String, Long>> compareKeywordOverlap(
Map<String, Set<Long>> emailToKeywordIdSet,
Map<String, Set<Long>> urlToKeywordIdSet
) {
return emailToKeywordIdSet.entrySet().stream()
// entrySet을 스트림으로 변환
// 목적: 이메일과 해당 이메일에 대응하는 키워드 목록 처리
.collect(Collectors.toMap(
// 스트림의 결과를 Map으로 수집
Map.Entry::getKey,
// Key: 이메일 주소 == entry.getKey()
entry -> urlToKeywordIdSet.keySet().stream()
// URL을 스트림으로 변환
// 목적: URL 순회
.collect(Collectors.toMap(
// 각 URL에 계산된 겹치는 키워드 개수를 Map으로 수집
url -> url,
// Key: URL
url -> entry.getValue().stream()
// 이메일에 대응하는 키워드 목록을 스트림으로 변환
.filter(urlToKeywordIdSet.get(url)::contains)
// URL에 대응하는 키워드 목록과 비교
.count()
// 겹치는 키워드 개수를 셈
))
));
}
(6) 알림 객체를 내림차순으로 정렬하는 로직 반영하기 ▼
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.List;
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 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 {
// 내림차순 정렬
// 겹치는 키워드 개수가 많은 순으로
List<Notification> sortedNotificationList = notificationSet.stream()
.sorted((n1, n2)
-> Integer.compare(
n2.getOverlapCount(),
n1.getOverlapCount()
)).toList();
// 이메일 내용 생성
String[] emailData = NotificationFormat.createEmailNotification(sortedNotificationList);
String subject = emailData[0];
String content = emailData[1];
// 이메일 전송
emailSender.send(recipientEmail, subject, content);
// 전송된 알림 상태 변경
sortedNotificationList.forEach(notification -> {
notification.markEmailAsSent();
notificationRepository.save(notification);
});
} catch (IOException e) {
log.error("이메일 전송 실패: {}", recipientEmail, e);
}
}
}
package com.project.cheerha.common.email.format;
import com.project.cheerha.domain.notification.entity.Notification;
import java.util.List;
public class NotificationFormat {
public static String[] createEmailNotification(List<Notification> notificationList) {
// 이메일 제목 생성
String subject = "📢 새로운 맞춤 채용 공고가 도착했어요!";
// 이메일 본문 생성
StringBuilder content = new StringBuilder();
content.append("<h1>🚀 새로운 채용 공고가 준비됐어요! 🎉</h1>");
content.append("<p>맞춤형 채용 공고가 도착했답니다! 💼</p>");
content.append("<p>아래 링크에서 확인해보세요! ⬇️</p>");
content.append("<ul>");
int count = 0;
// 알림(Notification) 목록을 이메일 내용에 추가
// 상위 20개까지만
for (Notification notification : notificationList) {
if (count < 20) {
content.append("<li>👉 <a href=\"")
.append(notification.getJobOpeningUrl())
.append("\" target=\"_blank\">")
.append("채용 공고 자세히 보기")
.append("</a></li>");
}
count++;
}
content.append("</ul>");
content.append("<p>행운을 빕니다! 🙌</p>");
return new String[]{subject, content.toString()};
}
}
예상한 대로 키워드가 겹치는 개수는 잘 구했는데, 사용자 2,000명에 채용 공고 1,000건으로 다시 애플리케이션(application)을 실행했을 때 컴퓨터가 힘들어했다. 로컬 환경이니 망정이지 서버였으면 또 터뜨릴 뻔했다.
즉, 진짜 문제는 따로 있었다.
Big O Calc 사이트에서 compareKeywordOverlap() 메서드를 입력하니, 다음과 같은 결과를 도출할 수 있었다.
(1) The outer stream processes each entry in 'emailToKeywordIdSet', which has a size of 'm'.
- emailToKeywordIdSet의 각 entry를 순회하는 외부 스트림이 있음
- 이때, emailToKeywordIdSet 크기를 m이라고 가정
(2) For each entry, it processes each key in 'urlToKeywordIdSet', which has a size of 'n'.
- 각 emailToKeywordIdSet의 entry에 urlToKeywordIdSet의 모든 키를 순회하는 내부 스트림이 있음
- urlToKeywordIdSet의 크기를 n이라고 가정
(3) Inside the inner stream, for each URL, it filters the keyword IDs from the corresponding set in 'emailToKeywordIdSet'.
- 내부 스트림에서는 각 URL에 해당 URL의 키워드 ID 집합과 현재 이메일의 키워드 ID 집합 간 교집합, 즉 공통 키워드 수를 계산함
(4) 이 과정을 수식으로 정리하면 'm*n*k' 만큼 연산이 필요함
즉, compareKeywordOverlap() 메서드는 이메일 개수(m), URL 개수(n), 키워드 개수(k)에 따라 성능이 결정되며, 시간 복잡도는 'O(m*n*k)'였다.
다시 말해, 입력 데이터 크기가 클수록 성능 저하가 발생할 가능성이 커졌다.
어떻게든 해결책을 찾아서 시간 복잡도를 줄이고 싶었지만, 변명하자면 몸 상태가 너무 안 좋아서 더 나은 로직을 생각하기 어려웠다. 이런 이유로 정말 아쉽지만, 채용 공고 스무 개를 무작위로 선정하기로 했다.
'사용자는 연봉 높은 회사를 더 선호할 테니, 연봉이 높은 순으로 정렬해도 되지 않나요?'
사실 '연봉'도 고려하긴 했는데, 대부분 채용 공고는 연봉을 '면접 후 결정'으로 명시했다. 이런 이유로 선정 기준을 수치화하기 어려워서 지금으로서는 '무작위 선정'이 최선의 선택지로 보였다.
package com.project.cheerha.domain.notification.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@Getter
@NoArgsConstructor
@Table(
name = "notification",
uniqueConstraints = {@UniqueConstraint(
columnNames = {"email", "job_opening_url"}
)}
)
public class Notification {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String email;
@Column(nullable = false)
private String jobOpeningUrl;
@Column
private boolean isEmailSent;
@Column
private boolean isPushSent;
public static Notification toEntity(
String email,
String jobOpeningUrl
) {
Notification notification = new Notification();
notification.email = email;
notification.jobOpeningUrl = jobOpeningUrl;
notification.isEmailSent = false;
notification.isPushSent = false;
return notification;
}
public void markEmailAsSent() {
this.isEmailSent = true;
}
public void markPushAsSent() {
this.isPushSent = true;
}
}
package com.project.cheerha.domain.notification.service;
import com.project.cheerha.domain.notification.dto.NotificationRecipientDto;
import com.project.cheerha.domain.notification.entity.Notification;
import com.project.cheerha.domain.notification.repository.NotificationRepository;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class NotificationService {
private final NotificationRepository notificationRepository;
@Transactional
public void createNotification(
List<NotificationRecipientDto> notificationRecipientDtoList,
Map<Long, List<String>> keywordIdToUrlList
) {
Map<String, Set<String>> emailToUrlSet = new HashMap<>();
matchEmailToUrlSetByKeywordId(
notificationRecipientDtoList,
keywordIdToUrlList,
emailToUrlSet
);
emailToUrlSet.forEach((email, urlSet) -> {
List<Notification> foundNotificationList = notificationRepository.findAllByEmailAndJobOpeningUrlIn(
email,
urlSet.stream().toList()
);
Set<String> existingUrlSet = findExistingUrlSet(foundNotificationList);
List<Notification> notificationList = createNotificationList(
email,
urlSet,
existingUrlSet
);
notificationRepository.saveAll(notificationList);
});
}
private void matchEmailToUrlSetByKeywordId(
List<NotificationRecipientDto> notificationRecipientDtoList,
Map<Long, List<String>> keywordIdToUrlList,
Map<String, Set<String>> emailToUrlSet
) {
for (NotificationRecipientDto dto : notificationRecipientDtoList) {
List<String> matchingUrlList = keywordIdToUrlList.getOrDefault(
dto.keywordId(),
List.of()
);
if (!matchingUrlList.isEmpty()) {
emailToUrlSet.computeIfAbsent(
dto.email(),
email -> new HashSet<>()
).addAll(matchingUrlList);
}
}
}
private Set<String> findExistingUrlSet(List<Notification> notificationList) {
return notificationList.stream()
.map(Notification::getJobOpeningUrl)
.collect(Collectors.toSet());
}
private List<Notification> createNotificationList(
String email,
Set<String> urlSet,
Set<String> existingUrlSet
) {
return urlSet.stream()
.filter(jobOpeningUrl -> !existingUrlSet.contains(jobOpeningUrl))
.map(jobOpeningUrl -> Notification.toEntity(email, jobOpeningUrl))
.toList();
}
}
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.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
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 EmailSender emailSender;
@Async
public void sendNotificationEmails() {
Map<String, Set<Notification>> emailToNotificationSet = new HashMap<>();
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 {
List<Notification> notificationList = new ArrayList<>(notificationSet);
Collections.shuffle(notificationList);
notificationList = notificationList.stream()
.limit(20)
.toList();
String[] emailData = NotificationFormat.createEmailNotification(notificationList);
String subject = emailData[0];
String content = emailData[1];
emailSender.send(recipientEmail, subject, content);
notificationList.forEach(notification -> {
notification.markEmailAsSent();
notificationRepository.save(notification);
});
} catch (IOException e) {
log.error("이메일 전송 실패: {}", recipientEmail, e);
}
}
}
package com.project.cheerha.common.email.format;
import com.project.cheerha.domain.notification.entity.Notification;
import java.util.List;
public class NotificationFormat {
public static String[] createEmailNotification(List<Notification> notificationList) {
String subject = "📢 새로운 맞춤 채용 공고가 도착했어요!";
StringBuilder content = new StringBuilder();
content.append("<h1>🚀 새로운 채용 공고가 준비됐어요! 🎉</h1>");
content.append("<p>맞춤형 채용 공고가 도착했답니다! 💼</p>");
content.append("<p>아래 링크에서 확인해보세요! ⬇️</p>");
content.append("<ul>");
for (Notification notification : notificationList) {
content.append("<li>👉 <a href=\"")
.append(notification.getJobOpeningUrl())
.append("\" target=\"_blank\">")
.append("채용 공고 자세히 보기")
.append("</a></li>");
}
content.append("</ul>");
content.append("<p>행운을 빕니다! 🙌</p>");
return new String[]{subject, content.toString()};
}
}
임의로 30개 알림 객체를 저장하고 이메일 안에 20개만 담기는지 확인한 다음, 두 번째 기술적 의사결정은 이 내용으로 정했다. 지금은 무작위로 골라서 사용자 맞춤화 면에서는 아쉬움이 남지만, 대기업이든 중견 기업이든 사용자에게 이메일 알림으로 전달된다는 점을 장점으로 내세울 수 있지 않을까 싶었다.
이렇게 또 막막하게만 느껴진 산 하나를 넘었다.
Day 22에서 계속…….
'개발 일지 > 취하여 프로젝트' 카테고리의 다른 글
4주 차: 알림 기능 찾아 삼만리 Day 20 - 아니, 이메일 안에 토글을 못 넣는 건 계획에 없었습니다만? (0) | 2025.03.06 |
---|---|
4주 차: 알림 기능 찾아 삼만리 Day 19 - 아마도 마지막 리팩토링(Refactoring), 끝을 향하여 (2) (0) | 2025.03.05 |
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 |