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

4주 차: 알림 기능 찾아 삼만리 Day 19 - 아마도 마지막 리팩토링(Refactoring), 끝을 향하여 (2)

writingforever162 2025. 3. 5. 17:37

[알림 기능 찾아 삼만리 링크 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) (2)]

(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에서 계속…….