[잔디 일기] Spring에서 이메일로 댓글 알림 전송하기

잔디 일기 서비스의 2차 배포 이후 가장 먼저 구현해야 할 기능은 댓글 알림 기능이라고 판단했습니다.

 

이 기능은 기존 회원들이 다시 방문하도록 유도할 수 있어, 서비스 활성화에 중요한 역할을 합니다. 알림 기능은 크게 세 가지 방법을 고려했습니다.

 

  1. 웹 브라우저 알림: 사용자 브라우저에서 권한을 요청하고, 실시간으로 알림을 전송할 수 있습니다. 다만, 사용자가 권한을 허용하지 않으면 효과가 제한적입니다.
  2. UI 내 알림 기능 추가: 웹 서비스 내부에 알림 확인 기능을 추가할 수 있습니다. 그러나, 서비스를 방문해야만 알림을 확인할 수 있다는 점에서 재방문 유도에는 한계가 있습니다. 또한 프론트엔드 개발자분들의 업무 일정으로 추가 작업이 어려워 새로운 기능 개발이 어려웠습니다.
  3. 이메일 알림: 현재 구글 로그인 방식만 사용하고 있어 이를 활용해 이메일 알림을 구현할 수 있다고 판단했습니다.

개선점
초기에는 댓글 작성 시 이벤트가 발생하고, 댓글 작성 → 이메일 전송 → 값 반환의 순서로 진행되었습니다. 하지만 이 방식은 이메일 전송 과정 때문에 댓글 작성에 시간이 오래 걸리는 문제가 있었습니다. 이를 해결하기 위해 이메일 전송을 비동기(@Async)로 처리하여 댓글 작성 → 값 반환의 순서를 유지하도록 개선했습니다. 이로써 댓글 작성 메서드는 이메일 전송이 끝나기를 기다리지 않고 즉시 응답을 반환할 수 있게 되었습니다.

 

Spring에서 댓글 알림 기능을 이메일로 구현하는 방법

1. 프로젝트 의존성 추가

먼저, build.gradle에 필요한 의존성을 추가합니다.

// 이메일 전송용
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect'

 

2. 이메일 이벤트 클래스 생성

댓글이 작성될 때 이벤트를 발생시키기 위해 CommentCreatedEvent 클래스를 정의합니다.

해당 클래스는 댓글과 관련된 정보를 담아 이벤트 리스너로 전달합니다.

@Getter
@Slf4j
public class CommentCreatedEvent extends ApplicationEvent {
    private final Long diaryId;
    private final String authorName;
    private final String diaryAuthorEmail;
    private final String commentContent;
    private final LocalDateTime diaryCreatedAt;
    private final String commentCreatedBy;
    private final LocalDateTime commentCreatedAt;

    public CommentCreatedEvent(Object source, Long diaryId, String authorName, String diaryAuthorEmail,
                               String commentContent, LocalDateTime diaryCreatedAt,
                               String commentCreatedBy, LocalDateTime commentCreatedAt) {
        super(source);
        this.diaryId = diaryId;
        this.authorName = authorName;
        this.diaryAuthorEmail = diaryAuthorEmail;
        this.commentContent = commentContent;
        this.diaryCreatedAt = diaryCreatedAt;
        this.commentCreatedBy = commentCreatedBy;
        this.commentCreatedAt = commentCreatedAt;
    }
}

 

3. 이메일 알림 리스너 클래스 정의

이벤트가 발생하면 EmailNotificationListener 클래스에서 EmailService를 호출하여 이메일을 전송합니다.

@Component
@RequiredArgsConstructor
public class EmailNotificationListener {

    private final EmailService emailService;

    /**
     * 댓글 알림 이벤트 리스너
     */
    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleCommentCreatedEvent(CommentCreatedEvent event) {
        emailService.sendCommentNotification(event);
    }
}

 

@TransactionalEventListener 어노테이션을 사용해 트랜잭션이 성공적으로 커밋된 후에만 이벤트를 처리하도록 설정되어 있어, 데이터베이스에 댓글 작성이 정상적으로 완료된 경우에만 이메일이 전송됩니다.

 

또한 @Async 어노테이션을 통해 비동기로 처리되게 하여 메인 트랜잭션 흐름을 지연시키지 않으면서 사용자에게 빠른 응답을 제공하였습니다.

 

4. 이메일 서비스 클래스 구현

EmailService 클래스에서 실제 이메일 전송 로직을 구현합니다.

Thymeleaf 템플릿 엔진을 이용해 HTML 이메일 내용을 생성해주었습니다.

@Slf4j
@Service
@RequiredArgsConstructor
public class EmailService {

    private final JavaMailSender mailSender;
    private final SpringTemplateEngine templateEngine;

    public void sendCommentNotification(CommentCreatedEvent event) {
        try {
            log.info("Sending comment notification for comment {}", event);
            MimeMessage message = mailSender.createMimeMessage();
            MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");

            // 이메일 템플릿 변수 설정
            Context context = new Context();
            context.setVariable("recipientName", event.getAuthorName());
            context.setVariable("diaryDate", event.getDiaryCreatedAt());
            context.setVariable("commenterName", event.getCommentCreatedBy());
            context.setVariable("commentDate", event.getCommentCreatedAt());
            context.setVariable("commentContent", event.getCommentContent());
            context.setVariable("diaryUrl", "<https://grassdiary.site/diary/>" + event.getDiaryId());

            String htmlContent = templateEngine.process("comment-notification", context);

            helper.setTo(event.getDiaryAuthorEmail());
            helper.setSubject("[잔디 일기] 새로운 댓글 알림");
            helper.setText(htmlContent, true);

            // 이메일에 이미지 첨부
            ClassPathResource imageResource = new ClassPathResource("static/images/grass-diary-logo.png");
            helper.addInline("headerImage", imageResource);

            mailSender.send(message);
            log.info("{}님께 [댓글 알림] 이메일을 보냈습니다.", event.getAuthorName());

        } catch (MessagingException e) {
            throw new RuntimeException("댓글 이메일 보내기가 실패했습니다.", e);
        }
    }
}

 

5. 이벤트 발생 로직 추가

댓글 작성하는 로직에 CommentCreatedEvent를 발생시키는 로직을 추가해 알림 기능 완성입니다. 

/**
 * 댓글 작성 및 이메일 알림
 */
@Transactional
public CommentResponseDTO save(Long diaryId, CommentSaveRequestDTO requestDTO, final Long logInMemberId) {
    
    // 댓글 작성 로직 ...
    
    if (isNotificationRequired(logInMemberId, diary)) {
        publishCommentCreatedEvent(diary, member, comment);
    }

    return CommentResponseDTO.from(comment);
}

private boolean isNotificationRequired(Long logInMemberId, Diary diary) {
    return !logInMemberId.equals(diary.getMember().getId());
}

private void publishCommentCreatedEvent(Diary diary, Member member, Comment comment) {
    Member diaryAuthor = diary.getMember();
    log.info("댓글 알람 시작: {}", diaryAuthor);

    CommentCreatedEvent event = new CommentCreatedEvent(this,
            diary.getId(),
            diaryAuthor.getNickname(),
            diaryAuthor.getEmail(),
            truncateContent(comment.getContent()),
            diary.getCreatedAt(),
            member.getNickname(),
            comment.getCreatedAt()
    );
    eventPublisher.publishEvent(event);
}

 

6. 결과