개발 일지/취하여 프로젝트

4주 차: 알림 기능 찾아 삼만리 Day 21 - 이메일에 넣을 채용 공고 스무 개는 어떤 기준으로 뽑지?

writingforever162 2025. 3. 7. 20:44

[알림 기능 찾아 삼만리 링크 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에서 계속…….