신규 강좌 알림 기능 - Event 기반 아키텍처 리팩토링

SayBridge 프로젝트 구현 중 신규 강좌 알림 구독 서비스 신청 시 신규 강좌가 등록되면 해당 강좌 정보를 구독한 사용자들에게 이메일로 전송해는 기능을 구현하였다.

1. 기능 개요

 처음 구현 시 수강생에게 메일을 보내려면 강좌를 생성하는 CourseService 안에서 메일 전송 로직을 넣으려고 하다가  “메일 전송”이라는 기능은 다른 서비스나 기능에서 응용할 수 있을 것 같다는 생각을 해서 MailService 코드를 따로 생성 하였다.

 

MailService 코드

@Service
public class EmailService {
@Autowired
private JavaMailSender mailSender;
@Autowired
private SubscriberRepository subscriberRepository;
@Value("${spring.mail.username}")
private String fromEmail;
public void sendNewCourseNotification(Course course) {
List<Subscriber> subscribers = subscriberRepository.findAll();
for (Subscriber subscriber : subscribers) {
sendEmail(subscriber.getEmail(), course);
}
}
private void sendEmail(String toEmail, Course course) {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(fromEmail);
message.setTo(toEmail);
message.setSubject("SayBridge에서 새로운 강좌가 등록되었습니다! ");
message.setText("안녕하세요!\n\n SayBridge에 신규 강좌가 등록되었습니다: "+
"\n강의명: " + course.getTitle() +
"\n설명: " + course.getDescription() +
"\n언어: " + course.getLanguage() +
"\n레벨: " + course.getLevel() +
"\n\n수강을 원하시면 사이트를 방문해 주세요!");
mailSender.send(message);
}
}

 

 

CourseService 코드

@Service
@RequiredArgsConstructor
public class CourseService {
@Autowired
private CourseRepository courseRepository;
@Autowired
private EmailService emailService;
@Autowired
private TeacherProfileRepository teacherProfileRepository;
public void createCourse(CourseDto courseDto, User user) {
TeacherProfile teacherProfile = teacherProfileRepository.findByUser(user)
.orElseThrow(() -> new UsernameNotFoundException("선생님 정보를 찾을 수 없습니다."));
Course course = new Course();
course.setTitle(courseDto.getTitle());
course.setDescription(courseDto.getDescription());
course.setLevel(courseDto.getLevel());
course.setLanguage(courseDto.getLanguage());
course.setMaxStudents(courseDto.getMaxStudents());
course.setCurrentStudents(0);
course.setTeacher(teacherProfile);
courseRepository.save(course);
emailService.sendNewCourseNotification(course);
}
}

 

 

 이렇게 서비스를 분리해서 재사용성 및 확장성, 유지보수성이 높아졌다고 생각했다. 하지만 CourseService에서 MailService를 직접 호출하게 되는 상황이 만들어졌다.

 

2. 직접 호출 방식의 문제점

(1) 결합도(Coupling) 증가

CourseService는 “강좌 생성”이라는 도메인 로직에만 집중하면 되지만, 직접 메일 전송 로직(MailService)까지 호출함으로써 “인프라(메일)” 로직과 섞여 버린다. “신규 강좌 등록 후 알림”처럼 단순한 기능에서는 별 문제가 없어 보이지만, 나중에 “SMS 발송”, “푸시 알림” 같은 채널이 추가되거나, 이메일 전송 정책이 변경될 때마다, CourseService를 계속 수정해야 하므로 유지보수성이 떨어진다.

 

(2) 확장성/유연성 저하

 강좌가 생성되었을 때 보내야 하는 알림이 여러 종류가 될 수도 있는데, 매번 CourseService가 새 알림 로직을 알게 되면 Service 코드가 비대해지고, 새로운 요구사항이 들어올 때마다 로직을 끼워 넣어야 하므로, 결합이 점점 강해지는 구조가된다. 

 

이 문제를 해결하기 위해 이벤트를 발행하고 메일 서비스가 이벤트를 구독하게 만드는 방식으로 변경하였다.

 

CourseCreatedEvent 코드

public class CourseCreatedEvent {
private final Course course;
public CourseCreatedEvent(Course course) {
this.course = course;
}
public Course getCourse() {
return course;
}
}

 

 

CourseService 코드

@Service
@RequiredArgsConstructor
public class CourseService {
@Autowired
private CourseRepository courseRepository;
@Autowired
private EmailService emailService;
@Autowired
private TeacherProfileRepository teacherProfileRepository;
@Autowired
private ApplicationEventPublisher eventPublisher;
public void createCourse(CourseDto courseDto, User user) {
TeacherProfile teacherProfile = teacherProfileRepository.findByUser(user)
.orElseThrow(() -> new UsernameNotFoundException("선생님 정보를 찾을 수 없습니다."));
Course course = new Course();
course.setTitle(courseDto.getTitle());
course.setDescription(courseDto.getDescription());
course.setLevel(courseDto.getLevel());
course.setLanguage(courseDto.getLanguage());
course.setMaxStudents(courseDto.getMaxStudents());
course.setCurrentStudents(0);
course.setTeacher(teacherProfile);
courseRepository.save(course);
// == 이벤트 발행 (이메일 호출 X) ==
CourseCreatedEvent event = new CourseCreatedEvent(course);
eventPublisher.publishEvent(event);
}
}

 

 

EmailService 코드

@Service
public class EmailService {
@Autowired
private JavaMailSender mailSender;
@Autowired
private SubscriberRepository subscriberRepository;
@Value("${spring.mail.username}")
private String fromEmail;
@EventListener
public void handleCourseCreatedEvent(CourseCreatedEvent event) {
// 이벤트에서 새로 생성된 Course 꺼내기
Course course = event.getCourse();
// 구독자 목록 조회 후 메일 전송
List<Subscriber> subscribers = subscriberRepository.findAll();
for (Subscriber subscriber : subscribers) {
sendEmail(subscriber.getEmail(), course);
}
}
private void sendEmail(String toEmail, Course course) {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(fromEmail);
message.setTo(toEmail);
message.setSubject("SayBridge에서 새로운 강좌가 등록되었습니다! ");
message.setText("안녕하세요!\n\n SayBridge에 신규 강좌가 등록되었습니다: "+
"\n강의명: " + course.getTitle() +
"\n설명: " + course.getDescription() +
"\n언어: " + course.getLanguage() +
"\n레벨: " + course.getLevel() +
"\n\n수강을 원하시면 사이트를 방문해 주세요!");
mailSender.send(message);
}
}

 

 

3. 이벤트 기반 접근의 장점

 이벤트 기반 접근으로 인해 CourseService는 이제 “이벤트를 발행한다”는 역할만 담당하고, “어떻게 이메일을 보낼지”는 전혀 모르며, 이메일 로직 변경 시 CourseService를 건드릴 필요가 없으므로, 서로 간섭이 줄어들어 결합도가 감소한다.

 

 강좌 생성 시 문자 메시지도 보내는 것과 같은 요구가 추가돼도, SmsService가 @EventListener(CourseCreatedEvent)를 새로 하나 달면 되기 때문에 CourseService나 EmailService는 그대로 둘 수 있으므로, 기존 기능에 영향 없이 확장이 가능하다.

 

 각 서비스는 자기 책임(도메인 vs 알림/인프라)에만 집중하여 단일 책임 원칙 준수하며 복잡도가 커지더라도, 이벤트 리스너를 추가/수정하는 식으로 “후속 작업”을 손쉽게 조정할 수 있다.

 

 스프링 이벤트 기반은 기본적으로 동기/싱글스레드로 동작하지만, @Async와 결합하면 메일 전송을 별도 쓰레드에서 수행할 수도 있다. 또는, 메시지 브로커(예: RabbitMQ, Kafka)를 통해 완전히 분산/비동기 이벤트 처리도 가능하다.