[SpringBoot] JavaMailSender을 이용한 비동기 이메일 발송 기능 구현

다은·2025년 4월 20일
1

SpringBoot

목록 보기
4/12
post-thumbnail

노약자를 위한 AI 말동무 서비스, POPPET 서비스의 개발 일대기입니다.
사용자에게 노약자가 특정 기간동안 AI와 대화한 내용의 요약본을 이메일로 전송하는 기능을 구현했습니다.


1. Google 계정 설정

1-1. 2단계 인증 설정

  • 설정 - 보안 - 2단계 인증
  • 2단계 인증을 사용하도록 설정 변경합니다

1-2. 앱 비밀번호 설정

  • 보안 - 앱 비밀번호

  • app name에 임의의 이름을 입력한 후 만들기 클릭
    - 소문자로 된 비밀번호가 생성되며, 이 비밀번호는 secret 파일에 사용될 예정입니다
    - 보안에 주의해야합니다!

1-3. Gmail 프로토콜 설정

  • gmail - 사이드바 - 설정(라벨 관리) - 전달 및 POP/IMAP
  • POP / IMAP을 사용 가능으로 설정으로 변경합니다




2. 의존성 주입 및 YML 설정


2-1. gradle 의존성 수정

java sender 기능을 사용하기 위해 아래와 같이 의존성을 추가해줍니다

dependencies {
	...
	implementation 'org.springframework.boot:spring-boot-starter-mail'
	...
}

2-2. application.yml 수정

  • 이메일 전송에 필요한 환경 변수를 설정해줍니다
    • SpringBoot는 JavaEmailSender 인터페이스를 이용해 이메일을 보낼 수 있으므로, 이를 사용하기 위한 변수들을 설정해줍니다
spring:
  mail:
    host: smtp.gmail.com
    port: 587
    username: ${MAIL_USERNAME}
    password: ${MAIL_PASSWORD}
    properties:
      mail:
        smtp:
          auth: true
          timeout: 5000
          ssl: false
          starttls:
            enable: true
            required: true
  • host : smtp.gmail을 이용해 메일을 보냄을 명시함 (naver mail을 이용할 경우 naver으로 변경)
  • port : gmail의 경우 587 포트를 이용함
  • username : 발신이메일의 주소 (@gmail.com 까지 기재 필요)
  • password : 발신이메일의 앱비밀번호
  • smtp.auth : 사용자에 대한 인증 여부 설정
  • smtp.timeout : 이메일 발송에 대한 타임아웃 설정
  • smtp.ssl : ssl 소켓을 통한 암호화 연결 사용 여부 설정
  • smtp.starttls.enable : tls 기반 이메일 전송 사용 여부
  • smtp.starttls.required : tls 사용을 필수로 설정(비암호화 서버 사용 거부)

💡gmail은 465포트(SSL)와 587포트(TLS)를 분리하여 제공
465포트 → ssl : true
587포트 → starttls.enable : true (+ ssl : false)




3. SpringBoot 이메일 전송 기능 구현

3-1. EmailServiceImpl

기능 흐름은 다음과 같습니다

  1. 유저 정보 획득
  2. 가장 최근 생성하고 이메일을 보내지 않은 채팅방 조회
    • ChatRoom Entity의 is_mail_sent 필드 활용 (boolean)
    • 여담으로, ChatRoom을 생성하는 과정에서 User이메일 전송 설정 주기를 활용해 생성하므로, 채팅방 선정 과정에서는 이메일 전송 설정 주기를 고려하지 않았음
  3. 전송할 채팅방 요약 내용 추출
    • ChatRoom Entity의 summary 필드 활용 (String)
    • 채팅방이 없거나, 요약 내용이 없을 경우 종료
  4. 해당 ChatRoomis_mail_sent필드 true로 변경
  5. 메일 전송 이벤트 발행

💡 왜 이벤트 기반으로 기능을 구현했는가?
1. is_mail_sent 필드 업데이트를 위해 해당 함수는 Transaction 내부에서 수행되어야 함
2. 그러나, 이메일 전송과 같은 시간이 걸리고 외부 API를 호출해야 하는 작업을 Transaction 내부에서 수행하는 것은 안티패턴
3. 따라서, DB 작업이 완료되고, 즉. 커밋이 완료된 후 외부 작업을 수행하도록 로직을 구현
커밋 후 이벤트를 발행하면 리스너가 이벤트를 받아 수행하는 패턴

 /**
     * 유저의 이메일 전송 주기에 따라, 주기 내에 생성된 이메일의 요약 내용을 이메일로 전송한다.
     */
    @Transactional
    @Override
    public void sendEmail(String username) {
        // TODO: user data 얻는 과정 수정
        User user = getUser(username);

        // 가장 최근 생성되고 메일을 보내지 않은 채팅방 조회
        List<ChatRoom> chatRooms = chatRoomRepository.findByUsernameAndCreatedAtAndIsMailSent(username);
        if (chatRooms.isEmpty()) return;

        // 메일 보낼 채팅방 요약 내용 추출
        ChatRoom chatRoom = chatRooms.get(0);
        String chatSummary = chatRoom.getSummary();
        if (chatSummary == null) return;

        // 메일 보냄 여부 수정
        chatRoom.updateIsMailSent();

        // 메일 전송 이벤트 발행
        applicationEventPublisher.publishEvent(new EmailSendEvent(user, chatRoom));
    }

3-2. EmailSendEvent / Listener

  • EmailSendEvent : 이벤트 정보를 저장하기 위한 클래스
    • 이메일에 유저 정보와 채팅룸 관련 정보를 넣을 것이므로, 두 객체를 필드로 가지도록 설정
  • EmailSendEventListener : 이벤트를 받아 처리하는 리스너
    • @Async 를 이용해 비동기로 처리
      • 비동기를 사용하기 위해 @EnableAsync를 붙인 config 파일이 필요함
    • @TransactionalEventListener : 트랜잭션 상태에 따라 이벤트를 수행할 수 있게 해주는 리스너
      • DB 반영 이후 이메일이 전송되는 것을 보장하기 위해, AFTER_COMMIT 을 사용 → 이메일 재전송, 중복 방지

        phase 값실행 시점
        BEFORE_COMMIT트랜잭션 커밋 전에 실행됨
        AFTER_COMMIT트랜잭션 커밋 후 실행됨
        AFTER_ROLLBACK트랜잭션이 롤백된 경우에 실행
        AFTER_COMPLETION커밋이든 롤백이든 트랜잭션 종료 후 실행

진행 흐름은 다음과 같습니다.
  1. event로부터 User, ChatRoom 데이터 가져옴
  2. 유저가 설정한 이메일 리스트를 가져온 후, 해당 이메일 주소로 메일을 전송함
@Getter
public class EmailSendEvent {
    private final User user;
    private final ChatRoom chatRoom;

    public EmailSendEvent(User user, ChatRoom chatRoom) {
        this.user = user;
        this.chatRoom = chatRoom;
    }
}
@Slf4j
@Component
@RequiredArgsConstructor
public class EmailSendEventListener {

    private final EmailSendService emailSendService;

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleEmailSendEvent(EmailSendEvent event) {
        User user = event.getUser();
        String summary = event.getChatRoom().getSummary();

        for (Email email : user.getEmails()) {
            try {
                emailSendService.sendEmail(email.getEmailAddress(), summary);
            } catch (Exception e) {
                log.error("[*] {}으로 이메일 전송 중 오류 발생", email.getEmailAddress(), e);
            }
        }
    }
}

3-3. 이메일 전송하기

진행 흐름은 다음과 같습니다

  1. JavaMailSender interface DI
  2. HTML, 이미지 등의 파일을 이용하기 위해 MimeMessage 인터페이스 채택
  3. subject, body, to(보낼 이메일 주소) 등 정보 설정
  4. mailSender.send를 통해 이메일 전송

메일을 보내는 과정에서 생기는 Exception은 emailSendService를 호출하는 EmailSendEventListener에서 catch하고 있기 때문에 별도의 catch문은 사용하지 않았습니다

@Slf4j
@Service
@RequiredArgsConstructor
public class EmailSendService {

    private final JavaMailSender mailSender;

    public void sendEmail(String to, String body) throws MessagingException {
        MimeMessage mimeMessage = mailSender.createMimeMessage();

		// 이메일 제목
        String subject = "POPPET";

        MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMessage);
        mimeMessageHelper.setTo(to);
        mimeMessageHelper.setSubject(subject);
        mimeMessageHelper.setText(body, true);

        mailSender.send(mimeMessage);
    }
}

4. 이메일 발송

위 기능에 따라 이메일이 발송되면, 사용자는 다음과 같은 화면을 마주합니다.
여기에 템플릿을 적용해 예쁘게 이메일을 받아볼 수 있도록 추가적인 기능을 추가하겠습니다.

👇 다음 포스트 (템플릿 적용기) 👇
https://velog.io/@dooo_it_ly/SpringBoot-ApachePdfBox로-PDF에-글씨-그림-그리기

profile
CS 마스터를 향해 ..

0개의 댓글