주노 님의 블로그

[트러블 슈팅] 이메일 발송을 비동기적으로 전환한 이유 본문

카테고리 없음

[트러블 슈팅] 이메일 발송을 비동기적으로 전환한 이유

juno0432 2024. 11. 21. 17:52

요약

이메일 인증을 동기 > 비동기로 전환하며

4.69s > 100ms로 97.87% 개선했다.

 

 

바꾸기전 속도는 4.69s였다

 

 

public HashMap<String, Object> sendMailAndStoreCode(String mail) {
    HashMap<String, Object> responseMap = new HashMap<>();

    //유저의 이메일 체크 로직.
    User user = userService.findUserEmail(mail);

    //소셜로그인일시, 비밀번호 변경을위한 이메일인증 불가.
    if (user.isSocialLogin()) {
        throw new ApplicationException(SOCIAL_LOGIN_UPDATE_NOT_ALLOWED);
    }

    try {
        int number = createRandomNumber();
        MimeMessage message = createMail(mail, number);
        javaMailSender.send(message);

        // Redis에 저장
        String key = "find:password:" + mail;
        redisTemplate.opsForValue().set(key, String.valueOf(number), TTL, TimeUnit.SECONDS);

        responseMap.put("success", Boolean.TRUE);
        responseMap.put("number", number);
    }
    catch (MessagingException exception) {
        log.error("메일 전송 실패 : {}", exception.getMessage());
        responseMap.put("success", Boolean.FALSE);
        responseMap.put("error", "메일 전송에 실패했습니다.");
    }

    return responseMap;
}

 

위 로직은 이메일을 전송하는 로직인데

외부 이메일 전송 서비스의 응답시간을 기다리는것이 큰 병목임을 확인했다.

 

이메일 전송은 이메일 전송을위해 smtp 서버로 요청을 보낸다

서버의 역할은 거기서 끝이지만

응답을 받을때까지 계속 물고 있는 것이 문제가 되었다.

 

서버가 이걸 물지 않고도, 다른작업을 할 수 있게 하면 어떨까?

 

그래서 비동기 작업으로 변경하였다

 

package com.spotlightspace.core.auth.email;

import static com.spotlightspace.common.exception.ErrorCode.INVALID_EMAIL_MATCH;
import static com.spotlightspace.common.exception.ErrorCode.SOCIAL_LOGIN_UPDATE_NOT_ALLOWED;

import com.spotlightspace.common.exception.ApplicationException;
import com.spotlightspace.core.auth.email.dto.MatchMailRequestDto;
import com.spotlightspace.core.user.domain.User;
import com.spotlightspace.core.user.service.UserService;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
import java.util.HashMap;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
@Slf4j
public class MailService {

    private final JavaMailSender javaMailSender;
    private final StringRedisTemplate redisTemplate;
    private final UserService userService;

    @Value("${spring.mail.username}")
    private String senderEmail;

    //redis의 ttl을 설정하는 변수입니다.
    private static final long TTL = 5 * 60;

    public void emailUserCehck(String mail) {
        // 유저의 이메일 체크 로직
        User user = userService.findUserEmail(mail);

        // 소셜 로그인일 시, 비밀번호 변경을 위한 이메일 인증 불가
        if (user.isSocialLogin()) {
            throw new ApplicationException(SOCIAL_LOGIN_UPDATE_NOT_ALLOWED);
        }
    }

    @Async
    public CompletableFuture<HashMap<String, Object>> sendMail(String mail) throws MessagingException {
        HashMap<String, Object> responseMap = new HashMap<>();

        int number = createRandomNumber();
        MimeMessage message = createMail(mail, number);
        javaMailSender.send(message);

        // Redis에 저장
        String key = "find:password:" + mail;
        redisTemplate.opsForValue().set(key, String.valueOf(number), TTL, TimeUnit.SECONDS);

        responseMap.put("success", Boolean.TRUE);
        responseMap.put("number", number);

        return CompletableFuture.completedFuture(responseMap);
    }

    private int createRandomNumber() {
        return (int) (Math.random() * (900000)) + 100000; // 6자리 랜덤 숫자
    }

    // 메일 메시지 생성
    private MimeMessage createMail(String mail, int number) throws MessagingException {
        mail = mail.trim();
        MimeMessage message = javaMailSender.createMimeMessage();

        // 이메일 형식 체크
        InternetAddress emailAddr = new InternetAddress(mail);
        emailAddr.validate();

        //발신자 이메일 주소를 설정함 (보내는사람 - 혜미.)
        message.setFrom(senderEmail);
        //수신자 이메일을 설정함 (받는사람)
        message.setRecipients(MimeMessage.RecipientType.TO, mail);
        //메일 본문
        message.setSubject("이메일 인증");
        String body = "<h3>요청하신 인증 번호입니다.</h3>"
                + "<h1>" + number + "</h1>"
                + "<h3>감사합니다.</h3>";
        message.setText(body, "UTF-8", "html");

        return message;
    }

    public boolean mailCheck(MatchMailRequestDto machMailRequestDto) {
        //저장된 값을 redis에서 가져옵니다.
        String key = "find:password:" + machMailRequestDto.getEmail();
        String storedNumber = redisTemplate.opsForValue().get(key);

        //값이 있다면 true로 없다면 false로 반환합니다.
        boolean isMatch = storedNumber != null && storedNumber.equals(machMailRequestDto.getUserNumber());
        if (!isMatch) {
            throw new ApplicationException(INVALID_EMAIL_MATCH);
        }

        return true;
    }
}

 

 

비동기로 변경을 하며

서버는 전송을 하게되면 쓰레드가 따로 생성이되어서 이메일 전송로직을 작동하게 된다.

 

 

비동기로 변경되며 100ms로 개선이 된것을 볼 수 있다.

 

물론 실패 성공 상태는 클라이언트 측에서 확인 할 수 없지만.

이메일 전송 성공 여부는 비즈니스 로직에 큰 영향을 미치지 않았다.

사용자 경험이 우선 순위가 될것이라 생각하여 고려하였다.