Troubleshooting: 무엇이 문제였는가?/취하여 프로젝트

3주 차: 알림 기능 찾아 삼만리 Day 10 - 로직 나누기는 마치 원심분리기 같아요. 영혼까지 분리될 뻔했거든요 (2)

writingforever162 2025. 2. 24. 20:36

[알림 기능 찾아 삼만리 Day 1 링크]

[알림 기능 찾아 삼만리 Day 2 링크]

[알림 기능 찾아 삼만리 Day 3 링크]

[알림 기능 찾아 삼만리 Day 4 링크]

[알림 기능 찾아 삼만리 Day 5 링크]

[알림 기능 찾아 삼만리 Day 6 링크]

[알림 기능 찾아 삼만리 Day 7 링크]

[알림 기능 찾아 삼만리 Day 8 링크]

[알림 기능 찾아 삼만리 Day 9 링크]

 

천만다행으로 준실시간 알림 기능을 구현할 틀을 마련했다. 로직 나누기에 가까스로 성공했다. 알림이란 객체를 만들어서 이메일 전송이나 푸시(Push) 알림 등등 다양한 알림 기능을 구현할 때 활용하고 싶었고, 알림을 읽었는지 또는 언제 읽었는지를 확인할 수 있다면, 나중에 또 의미 있는 정보를 얻을 수 있을 듯했다. 물론 이 알림 객체는 전송 이후 할 일이 끝나니 데이터베이스에 저장할 필요가 없는 만큼, '읽음 여부'나 '전송 여부'를 어디에 저장할지는 또 다른 과제일 테지만.

 

로직을 분리하면서 '이게 맞나?' 의문이 수십 번도 더 들어서 수정할 때마다 코드를 남겨두었다. 아래와 같은 삽질을 거쳐 푸시 알림을 구현할 틀을 완성했다.

 

(1) 1차 삽질 ▼

키워드끼리 연결된 값은 데이터베이스에 상상한 대로 저장되었다. 이 객체를 가져와서 URL로 묶어서 보내든 따로따로 보내든 하고 싶었다. 얼추 저장된 모습을 다시 확인한 뒤에는 다시 수정을 이어갔다.

 

(2) 2차 삽질 ▼ [깃허브(GitHub) 링크]

더보기
package com.project.cheerha.domain.notice.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 java.time.ZonedDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@NoArgsConstructor
@Table(
    name = "mapping",
    uniqueConstraints = {@UniqueConstraint(columnNames = {"email", "job_opening_url"})})
public class Mapping {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String email;

    @Column(nullable = false)
    private String jobOpeningUrl;

    @Column
    private boolean isRead;

    @Column
    private ZonedDateTime readTime;

    public static Mapping toEntity(
        String email, 
        String jobOpeningUrl
    ) {
        Mapping mapping = new Mapping();
        mapping.email = email;
        mapping.jobOpeningUrl = jobOpeningUrl;
        mapping.isRead = false;
        return mapping;
    }
}
package com.project.cheerha.domain.notice.repository;

import com.project.cheerha.domain.notice.entity.Mapping;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MappingRepository extends JpaRepository<Mapping, Long> {
}
더보기
package com.project.cheerha.domain.notice.scheduler;

import com.project.cheerha.domain.notice.UserDto;
import com.project.cheerha.domain.notice.service.DataFetchService;
import com.project.cheerha.domain.notice.service.MappingService;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Component
@RequiredArgsConstructor
public class Scheduler {

    private final DataFetchService dataFetchService;
    private final MappingService mappingService;

    @Scheduled(cron = "*/30 * * * * *")
    @Transactional
    public void fetchData() {
        ZonedDateTime referenceTime = ZonedDateTime.now()
            .minusDays(7L)
            .withZoneSameInstant(ZoneId.of("UTC"));

        Map<Long, List<String>> keywordIdToUrlList = 
            dataFetchService.findKeywordIdToUrlList(referenceTime);

        List<UserDto> userDtoList = dataFetchService
            .findUserDtoList();

        mappingService.saveEmailJobOpeningMappings(
            userDtoList,
            keywordIdToUrlList
        );
    }
}
더보기
package com.project.cheerha.domain.notice.service;

import com.project.cheerha.domain.notice.UserDto;
import com.project.cheerha.domain.notice.entity.Mapping;
import com.project.cheerha.domain.notice.repository.MappingRepository;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class MappingService {

    private final MappingRepository mappingRepository;

    @Transactional
    public void saveEmailJobOpeningMappings(
        List<UserDto> userDtoList,
        Map<Long, List<String>> jobOpeningKeywordMap
    ) {
        Map<String, Set<String>> emailToUrl = new HashMap<>();

        for (UserDto dto : userDtoList) {
            List<String> matchingUrlList = jobOpeningKeywordMap
                .getOrDefault(
                    dto.keywordId(),
                    List.of()
                );

            if (!matchingUrlList.isEmpty()) {
                emailToUrl.computeIfAbsent(
                    dto.email(),
                    email -> new HashSet<>()
                ).addAll(matchingUrlList);
            }
        }

        emailToUrl.forEach((email, urlSet) -> {
            urlSet.forEach(jobOpeningUrl -> {
                Mapping mapping = Mapping.toEntity(
                    email,
                    jobOpeningUrl
                );
                
                mappingRepository.save(mapping);
            });
        });
    }
}
더보기
package com.project.cheerha.domain.notice.service;

import com.project.cheerha.domain.notice.repository.MappingRepository;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
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.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class EmailSender {

    private final MappingRepository mappingRepository;
    private final JavaMailSender javaMailSender;
    private static final String SENDER_EMAIL = "cheerha35@gmail.com";

    @Async
    public void sendEmailNotifications() {
        Map<String, Set<String>> emailUrlMap = new HashMap<>();

        mappingRepository.findAll().
            forEach(mapping -> {
            emailUrlMap.computeIfAbsent(
                mapping.getEmail(),
                    k -> new HashSet<>()
                ).add(mapping.getJobOpeningUrl());
        });

        emailUrlMap.forEach(this::sendMail);
    }

    private void sendMail(
        String recipientEmail, 
        Set<String> jobOpeningUrlSet
    ) {
        try {
            MimeMessage message = javaMailSender.createMimeMessage();
            MimeMessageHelper helper = new MimeMessageHelper(
                message, 
                true, 
                "UTF-8"
            );

            helper.setFrom(SENDER_EMAIL);
            helper.setTo(recipientEmail);
            helper.setSubject("📢 새로운 맞춤 채용 공고가 도착했어요!");

            StringBuilder content = new StringBuilder();
            content.append("<h1>🚀 새로운 채용 공고가 준비됐어요! 🎉</h1>");
            content.append("<p>맞춤형 채용 공고가 도착했답니다! 💼</p>");
            content.append("<p>아래 링크에서 확인해보세요! ⬇️</p>");
            content.append("<ul>");

            for (String url : jobOpeningUrlSet) {
                content.append("<li>👉 <a href=\"")
                    .append(url)
                    .append("\" target=\"_blank\">")
                    .append("채용 공고 자세히 보기</a></li>");
            }

            content.append("</ul>");
            content.append("<p>행운을 빕니다! 🙌</p>");

            helper.setText(content.toString(), true);

            javaMailSender.send(message);

            log.info("이메일 전송 완료: {}", recipientEmail);
        } catch (MessagingException e) {
            log.error("이메일 전송 실패: {}", recipientEmail, e);
        }
    }
}
더보기
package com.project.cheerha.domain.notice.service;

import com.project.cheerha.domain.notice.entity.Mapping;
import com.project.cheerha.domain.notice.repository.MappingRepository;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class NotificationSender {

    private final MappingRepository mappingRepository;

    public void sendNotifications() {
        List<Mapping> mappings = mappingRepository.findAll();

        mappings.forEach(mapping -> {
            log.info("이메일: {}, URL: {}",
                mapping.getEmail(),
                mapping.getJobOpeningUrl()
            );
        });
    }
}
더보기
package com.project.cheerha.domain.notice.scheduler;

import com.project.cheerha.domain.notice.service.EmailSender;
import com.project.cheerha.domain.notice.service.NotificationSender;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class NotificationScheduler {

    private final NotificationSender notificationSender;
    private final EmailSender emailSender;

    @Scheduled(cron = "0 0/1 * * * ?")
    public void sendScheduledNotifications() {
        log.info("실시간 알림 전송 시작");
        notificationSender.sendNotifications();
    }

    @Scheduled(cron = "0 0 9,18 * * ?")
    public void sendEmailNotifications() {
        log.info("이메일 알림 전송 시작");

        emailSender.sendEmailNotifications();
    }
}

2차로 수정할 때는 'isRead'라는 이름으로 '읽음 여부'와 'readTime'이라는 이름으로 '언제 읽었는지' 확인하고 싶었다. 문제는 지금 상태로는 이 속성이 어떤 알림에 해당하는지 알 수 없었다. 두 번째 문제로는 이런 식으로 값을 늘린다면 새로운 알림 기능 구현 시 또 그 기능에 맞게 읽음 여부와 언제 읽었는지 값을 추가해야 했다.

 

이런 이유로 3차 수정 때는 우선 한 번 전송한 알림은 다시 보내지 않게 'isSent'라는 값을 추가하고, 알림 객체(Mapping)가 동일한 사용자와 채용 공고 URL 값으로 저장되지 않도록 수정하고, 가능하면 for문을 남발하지 않으려고 애썼다. 물론 'isSent' 또한 현재 알림이 두 개이므로 속성도 두 개 들어가지만, 푸시 알림 기능 구현 후 리팩토링(refactoring)하기로 했다.

 

'사용자가 알림을 읽었는지 안 읽었는지는 'readAt' 속성을 추가해서 읽은 시간까지 한 번에 확인하자.'

 

'지금은 MySQL을 사용하는데 이렇게 계속 저장할 필요가 없는 데이터를 어디에 저장할지도 정해야지.'

 

기능 구현 후 해야 할 일이 휘발되기 전 기록으로 남긴 동시에.

 

(3) 3차 수정 ▼ [깃허브(GitHub) 링크]

더보기
package com.project.cheerha.domain.notice.repository;

import com.project.cheerha.domain.notice.UserDto;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Map;

public interface EmailRepositoryQuery {

    /**
     * 조회 기준으로 모든 채용 공고 정보를 조회
     *
     * @param referenceTime 조회 시간
     * @return 채용 공고 키워드와 해당 키워드가 연관된 URL 목록을 매핑한 맵
     * Key: KeywordId (키워드 식별자)
     * Value: 해당 키워드로 매칭되는 URL 목록
     */
    Map<Long, List<String>> findAllJobOpeningKeywords(ZonedDateTime referenceTime);

    /**
     * 모든 사용자의 이메일 및 등록한 키워드를 조회
     * @return 사용자 이메일과 선택한 키워드를 포함한 UserDto 리스트
     */
    List<UserDto> findAllUserKeywords();
}
package com.project.cheerha.domain.notice.repository;

import static com.querydsl.core.group.GroupBy.groupBy;
import static com.querydsl.core.group.GroupBy.list;
import static com.project.cheerha.domain.keyword.entity.QUserKeyword.*;
import static com.project.cheerha.domain.user.entity.QUser.*;
import static com.project.cheerha.domain.jobopening.entity.QJobOpening.*;
import static com.project.cheerha.domain.keyword.entity.QJobOpeningKeyword.*;

import com.project.cheerha.domain.notice.QUserDto;
import com.project.cheerha.domain.notice.UserDto;
import com.querydsl.jpa.impl.JPAQueryFactory;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

@Repository
@RequiredArgsConstructor
public class EmailRepositoryQueryImpl implements EmailRepositoryQuery {

    private final JPAQueryFactory queryFactory;

    @Override
    public Map<Long, List<String>> findAllJobOpeningKeywords(
        ZonedDateTime referenceTime
    ) {
        return queryFactory
            .from(jobOpeningKeyword)
            .join(jobOpeningKeyword.jobOpening, jobOpening)
            .where(jobOpening.createdAt.after(referenceTime)) 
            // 기준 시간 이후 생성된 채용 공고
            .transform(
                groupBy(jobOpeningKeyword.keyword.id) 
                // 키워드 ID별로 그룹화
                    .as(list(jobOpening.jobOpeningUrl)) 
                    // URL 목록을 그룹에 매핑
            );
    }

    @Override
    public List<UserDto> findAllUserKeywords() {

        return queryFactory
            .select(
                new QUserDto(
                    userKeyword.keyword.id, 
                    // 사용자가 선택한 키워드 ID
                    user.email 
                    // 사용자 이메일
                )
            ).from(userKeyword)
            .join(userKeyword.user, user) 
            // 사용자와 키워드 조인
            .fetch();
    }
}

▲ QueryDSL로 채용 공고 정보와 사용자 정보를 가져오는 데이터베이스는 일단 그대로 두었다. 푸시 알림 기능 구현에 성공하면 이름부터 수정해야 한다. 지금은 분리한 로직과 헷갈릴까 봐 건드리지 않았다.

더보기
package com.project.cheerha.domain.notice.service;

import com.project.cheerha.domain.notice.UserDto;
import com.project.cheerha.domain.notice.repository.EmailRepositoryQuery;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class DataFetchService {

    private final EmailRepositoryQuery repositoryQuery;

    public Map<Long, List<String>> findKeywordIdToUrlList(ZonedDateTime referenceTime) {
        return repositoryQuery.findAllJobOpeningKeywords(referenceTime);
    }

    public List<UserDto> findUserDtoList() {
        return repositoryQuery.findAllUserKeywords();
    }
}
package com.project.cheerha.domain.notice.scheduler;

import com.project.cheerha.domain.notice.UserDto;
import com.project.cheerha.domain.notice.service.DataFetchService;
import com.project.cheerha.domain.notice.service.MappingService;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Component
@RequiredArgsConstructor
public class DataFetchScheduler {

    private final DataFetchService dataFetchService;
    private final MappingService mappingService;

    @Scheduled(cron = "*/30 * * * * *")
    @Transactional
    public void fetchData() {
        ZonedDateTime referenceTime = ZonedDateTime.now()
            .minusDays(7L)
            .withZoneSameInstant(ZoneId.of("UTC"));

        Map<Long, List<String>> keywordIdToUrlList =
            dataFetchService.findKeywordIdToUrlList(referenceTime);

        List<UserDto> userDtoList = dataFetchService
            .findUserDtoList();

        mappingService.saveMappings(
            userDtoList,
            keywordIdToUrlList
        );
    }
}

DataFetchService는 레포지토리 메서드를 호출하고, DataFetchScheduler는 정해진 시간마다 데이터를 가져온다. 일단 테스트를 계속 해야 하므로 30초마다 조회하고 조회 시간 또한 널널하게 일주일로 잡았다. 기존 로직과 달리 사용자와 채용 공고 연결은 MappingService에서 하도록 했다.

더보기
package com.project.cheerha.domain.notice.repository;

import com.project.cheerha.domain.notice.entity.Mapping;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MappingRepository extends JpaRepository<Mapping, Long> {

    /**
     * 특정 이메일과 채용 공고 URL 목록에 해당하는 Mapping 목록 조회 
     *
     * @param email 조회할 사용자 이메일
     * @param jobOpeningUrlList 조회할 채용 공고 URL 목록
     * @return 해당 이메일과 채용 공고 URL이 연결된 Mapping 목록
     */
    List<Mapping> findAllByEmailAndJobOpeningUrlIn(
        String email,
        List<String> jobOpeningUrlList
    );

    /**
     * 아직 이메일이 발송되지 않은 Mapping 목록 조회
     *
     * @return 이메일이 발송되지 않은 Mapping 목록
     */
    List<Mapping> findByIsEmailSentFalse();
}
더보기
package com.project.cheerha.domain.notice.service;

import com.project.cheerha.domain.notice.UserDto;
import com.project.cheerha.domain.notice.entity.Mapping;
import com.project.cheerha.domain.notice.repository.MappingRepository;
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;

// 사용자와 채용 공고 URL을 연결하는 Service
@Service
@RequiredArgsConstructor
public class MappingService {

    private final MappingRepository mappingRepository;

    @Transactional
    public void saveMappings(
        List<UserDto> userDtoList,
        Map<Long, List<String>> keywordIdToUrlList
    ) {
        // key: 사용자 이메일
        // value: 해당 이메일에 연결되는 채용 공고 URL 목록
        Map<String, Set<String>> emailToUrl = new HashMap<>();

        // 사용자 정보를 순회하며 이메일별로 URL 목록 구성
        for (UserDto dto : userDtoList) {
            List<String> matchingUrlList = keywordIdToUrlList
                .getOrDefault(
                    dto.keywordId(),
                    List.of()
                );

            // 기존에 존재하지 않으면 새로운 HashSet 생성 후 추가
            if (!matchingUrlList.isEmpty()) {
                emailToUrl.computeIfAbsent(
                    dto.email(),
                    email -> new HashSet<>()
                ).addAll(matchingUrlList);
            }
        }

        // 이메일별로 연결된 채용 공고 URL 목록 처리
        emailToUrl.forEach((email, urlSet) -> {

            // 기존에 저장된 Mapping 목록 조회
            List<Mapping> foundMappingList = mappingRepository.findAllByEmailAndJobOpeningUrlIn(
                email,
                urlSet.stream().toList()
            );

            // 이미 존재하는 Mapping의 URL 목록을 Set으로 변환
            // 중복을 확인할 때 사용
            Set<String> existingUrlSet = foundMappingList.stream()
                .map(Mapping::getJobOpeningUrl)
                .collect(Collectors.toSet());

            // 중복 확인 후 기존에 없는 Mapping 객체만 생성
            // .filter : 중복 제거
            // .map: Mapping 객체 생성
            List<Mapping> mappingList = urlSet.stream()
                .filter(jobOpeningUrl ->
                    !existingUrlSet.contains(jobOpeningUrl)
                ).map(jobOpeningUrl ->
                    Mapping.toEntity(email, jobOpeningUrl)
                ).toList();

            // 새로운 Mapping 객체를 한꺼번에 데이터베이스에 저장
            mappingRepository.saveAll(mappingList);
        });
    }
}
더보기
package com.project.cheerha.domain.notice.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 = "mapping",
    uniqueConstraints = {@UniqueConstraint(
        columnNames = {"email", "job_opening_url"}
    )}
)
public class Mapping {

    @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;

    /**
     * Mapping 객체를 생성하는 정적 팩토리 메서드
     *
     * @param email         : 사용자 이메일
     * @param jobOpeningUrl : 채용 공고 URL
     * @return 생성된 Mapping 객체
     * 
     * 기본값 1: 이메일 미발송 
     * 기본값 2: 푸시 미발송
     */
    public static Mapping toEntity(
        String email,
        String jobOpeningUrl
    ) {
        Mapping mapping = new Mapping();
        mapping.email = email;
        mapping.jobOpeningUrl = jobOpeningUrl;
        mapping.isEmailSent = false;
        mapping.isPushSent = false;
        return mapping;
    }

    // 이메일 발송 상태를 '발송됨'으로 변경
    public void markEmailAsSent() {
        this.isEmailSent = true;
    }

    // 푸시 발송 상태를 '발송됨'으로 변경 
    public void markPushAsSent() {
        this.isPushSent = true;
    }
}

▲ MappingService를 고치는 내내 속도 많이 상하고 너무 힘들었다. 거의 모든 클래스에서 for문과 forEach문을 마주하니 정말 이골이 났다. 어떻게든 중첩만 피하자는 심산으로 이미 알림 객체(Mapping Entity)가 존재하는지 확인하는 구간을 추가해서 해결했다. forEach문 안에 forEach문이 있을 때 심정이란. 

더보기
package com.project.cheerha.domain.notice.scheduler;

import com.project.cheerha.domain.notice.service.EmailSender;
import com.project.cheerha.domain.notice.service.PushSender;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class SenderScheduler {

    private final PushSender pushSender;
    private final EmailSender emailSender;

    @Scheduled(fixedDelay = 60_000)
    public void sendPushMessage() {
        log.info("푸시 알림 전송 시작");
        pushSender.sendPushMessage();
    }

    @Scheduled(fixedDelay = 60_000)
    public void sendEmails() {
        log.info("이메일 알림 전송 시작");
        emailSender.sendEmails();
    }
}
더보기
package com.project.cheerha.domain.notice.service;

import com.project.cheerha.domain.notice.entity.Mapping;
import com.project.cheerha.domain.notice.repository.MappingRepository;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
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.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class EmailSender {

    private final MappingRepository mappingRepository;
    private final JavaMailSender javaMailSender;
    private static final String SENDER_EMAIL = "발신자 이메일 주소";

    @Async
    public void sendEmails() {
        Map<String, Set<Mapping>> emailToMappings = new HashMap<>();

        mappingRepository.findByIsEmailSentFalse()
            .forEach(mapping -> {
                emailToMappings.computeIfAbsent(
                    mapping.getEmail(),
                    emailAsKey -> new HashSet<>()
                ).add(mapping);
            });

        emailToMappings.forEach(this::sendMail);
    }

    private void sendMail(
        String recipientEmail,
        Set<Mapping> mappings
    ) {
        try {
            MimeMessage message = javaMailSender.createMimeMessage();
            MimeMessageHelper helper = new MimeMessageHelper(
                message,
                true,
                "UTF-8"
            );

            helper.setFrom(SENDER_EMAIL);
            helper.setTo(recipientEmail);
            helper.setSubject("📢 새로운 맞춤 채용 공고가 도착했어요!");

            StringBuilder content = new StringBuilder();
            content.append("<h1>🚀 새로운 채용 공고가 준비됐어요! 🎉</h1>");
            content.append("<p>맞춤형 채용 공고가 도착했답니다! 💼</p>");
            content.append("<p>아래 링크에서 확인해보세요! ⬇️</p>");
            content.append("<ul>");

            for (Mapping mapping : mappings) {
                content.append("<li>👉 <a href=\"")
                    .append(mapping.getJobOpeningUrl())
                    .append("\" target=\"_blank\">")
                    .append("채용 공고 자세히 보기</a></li>");
            }

            content.append("</ul>");
            content.append("<p>행운을 빕니다! 🙌</p>");

            helper.setText(content.toString(), true);

            javaMailSender.send(message);

            log.info("이메일 전송 완료: {}", recipientEmail);

            mappings.forEach(mapping -> {
                mapping.markEmailAsSent();
                mappingRepository.save(mapping);
            });

        } catch (MessagingException e) {
            log.error("이메일 전송 실패: {}", recipientEmail, e);
        }
    }
}
더보기
package com.project.cheerha.domain.notice.service;

import com.project.cheerha.domain.notice.entity.Mapping;
import com.project.cheerha.domain.notice.repository.MappingRepository;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class PushSender {

    private final MappingRepository mappingRepository;

    public void sendPushMessage() {
        List<Mapping> mappingList = mappingRepository.findAll();

        mappingList.forEach(mapping -> {
            log.info("이메일: {}, URL: {}",
                mapping.getEmail(),
                mapping.getJobOpeningUrl()
            );
        });
    }
}

Mapping이라는 이름으로 알림 객체를 만들어두니, 어떤 알림을 보내든 입맛대로 Mapping을 가져와 활용할 수 있어서 좋았다. 지금은 어떤 알림이든 전송 시간을  '@Scheduled(fixedDelay = 60_000)'로 잡았으나, 테스트 및 리팩토링(refactoring) 후 각 알림에 맞게 시간을 바꿀 예정이다. 

emailToMappings.forEach(this::sendMail);
mappings.forEach(mapping -> {
      mapping.markEmailAsSent();
      mappingRepository.save(mapping);
});

두 번째로는 데이터를 늘려서 테스트하면 이메일 알림 전송 클래스(EmailSender)에서, 특히 저 코드에서 무조건 '재미있는' 문제가 생길 터라 그 점을 단단히 각오하고 푸시 알림을 구현해야 한다. 일단 이메일은 문제없이 전송되므로 푸시 알림 구현 후 하나씩 성능도 개선하고 문제점도 고치기로 했다.

"이제 틀이 나왔으니까, 직접 핸드폰으로 푸시(Push) 알림 한 번 받아봅시다."

 

Day 11에서 계속…….