티스토리 뷰

Overview

안녕하세요. '롤문철 닷컴' 서비스 개발자 쿠키입니다.

회원가입 고객을 대상으로 인증 메일을 보내는 기능에서 외부 API를 사용하였는데요. 외부 API의 오류가 서버 전체에 영향을 끼쳤습니다. 오늘은 이를 개선한 이야기를 얘기해보겠습니다.

 

Problem

 

그림처럼 회원가입 서비스는 고객에게 인증 메일을 보내기 위해 내부적으로 메일 전송 서비스를 호출하는데요. 이 때, 메일 전송 서비스의 오류와 지연이 회원가입 서비스에도 영향을 줍니다.

 

간단하게 코드로 살펴볼까요?

 

**회원가입 서비스**

@Slf4j
@RequiredArgsConstructor
@Service
public class MemberSignUpService {
	private final MemberJpaRepository memberJpaRepository;
	private final PasswordEncoder passwordEncoder;
    private final AuthenticationCodeEmailService authCodeEmailService;

	@Transactional
	public Member signUp(MemberSignUpDto dto) {
		Member member = Member.builder()
			.email(dto.getEmail())
			.password(encryptPassword(dto.getPassword()))
			.build();

		authCodeEmailService.sendAuthenticationCodeEmail(dto.getEmail());
        
		return memberJpaRepository.save(member);
	}

	private String encryptPassword(String password) {
		return passwordEncoder.encode(password);
	}
}

 

**메일 전송 서비스**

@Slf4j
@Service
@RequiredArgsConstructor
public class AuthenticationCodeMailService {
	private final MailAuthCodeGenerator mailAuthCodeGenerator;
	private final JavaMailSender javaMailSender;
    
	public void sendAuthenticationCodeEmail(String to) {
		try {
            MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(message, true, "UTF-8");
			mimeMessageHelper.setFrom(...);
			mimeMessageHelper.setTo(to);
			mimeMessageHelper.setSubject("회원가입 인증 메일");
			body += "<h1>회원가입 인증 메일<h1>";
			...
			mimeMessageHelper.setText(body, true);
			javaMailSender.send(message);
		} catch (MessagingException e) {
			log.error("이메일 전송에 실패하였습니다. receiverMail={}", event.member().getEmail());
			throw new RuntimeException(e);
		}
	}
}

 

MemberSignUpService의 회원가입 기능을 담당하고 있는 signUp() 메서드를 보겠습니다. AuthenticationCodeEmailService의 sendAuthenticationCodeEmail()을 호출하여 인증메일 전송을 시도하고 있습니다. 만약 AuthenticationCodeEmail 클래스에서 MessagingException 예외가 발생한다면, 회원가입 기능이 실패할 것입니다. 또는 시간이 지연된다면 회원가입도 느려지게 됩니다.

이를 해결하기 위해 비동기 처리를 적용해보았습니다.

 

비동기(asynchronous) 알아보기

mdn에서는 비동기를 다음과 같이 정의하고 있습니다.

Asynchronous communication is a method of exchanging messages in which the sending, receiving, and processing of each message is not dependent on the sending, receipt, or processing of other messages. In asynchronous communication, each party receives and processes messages when convenient or possible to do so, rather than doing so immediately upon receipt. Additionally, messages may be sent without waiting for acknowledgement, with the understanding that if a problem occurs, the recipient will request corrections or otherwise handle the situation.

(한국어 번역)
비동기 통신은 각 메시지의 송신, 수신 및 처리가 다른 메시지의 송신, 수신 또는 처리에 의존하지 않는 메시지를 교환하는 방법입니다. 비동기 통신에서는 각 당사자가 메시지를 수신하는 즉시 처리하는 것이 아니라 편리하거나 가능할 때 메시지를 수신하고 처리합니다. 또한 문제가 발생하면 수신자가 수정을 요청하거나 다른 방법으로 상황을 처리한다는 전제하에 승인을 기다리지 않고 메시지를 보낼 수 있습니다.

 

유튜브 쉬운코드 채널에서는 관점에 따라 동기/비동기를 정의하고 있습니다.

동기 커뮤니케이션 흐름 - 유튜브 쉬운코드 채널 (https://www.youtube.com/watch?v=EJNBLD3X2yg)

백엔드 관점에서는 설명해보자면, 동기처리는 피호출부가 호출부에 영향을 줍니다. 그림과 같이 A는 B를 호출하고 있으며, A는 B가 ok 사인을 응답할 때 까지 기다리게 됩니다.

 

비동기 커뮤니케이션 흐름 - 유튜브 쉬운코드 채널 (https://www.youtube.com/watch?v=EJNBLD3X2yg)

반면에 비동기처리 과정에서는 피호출부와 호출부가 서로 영향을 주지 않습니다. 위 그림을 보면, A는 B를 호출하는 것이 아닙니다. 단지, A는 메시지큐에 메시지를 삽입할 뿐이고, B는 메시지큐에 들어오는 메시지를 소비할 뿐입니다.

 

좌: 기존(동기) 방식 처리 흐름 / 우: 비동기 방식 처리 흐름

따라서, 기존의 회원가입→메일전송 순서로 수행되는 동기 방식의 시스템을 비동기로 변경하기로 결정하였습니다. 비동기 방식에는 시스템 간 안전한 메시지 전달을 위해 큐가 필요하다는 사실을 알 수 있습니다. 대표적으로 Kafka, RabbitMQ, Amazon SQS 등이 사용되며, 각 제품마다 특징과 사용처가 존재합니다.

 

RabbitMQ 사용하기

저는 몇 가지 비교를 통해 RabbitMQ를 도입하기로 결정하였습니다. 공식문서를 통해 RabbitMQ를 학습한후, 기존의 코드에 적용해봤습니다.

 

**회원가입 서비스**

@Slf4j
@RequiredArgsConstructor
@Service
public class MemberSignUpService {
	private final MemberJpaRepository memberJpaRepository;
	private final PasswordEncoder passwordEncoder;
    private final RabbitTemplate rabbitTemplate; // ...1

	@Transactional
	@Retryable(retryFor = AmqpException.class) // ...2
	public Member signUp(MemberSignUpDto dto) {
		Member member = Member.builder()
			.email(dto.getEmail())
			.password(encryptPassword(dto.getPassword()))
			.build();

		try {
        	rabbitTemplate.converAndSend("SIGN_UP_QUEUE", dto.getEmail()); // ...3
        } catch (AmqpException e) {
        	log.warn("RabbitMQ 메시지 전송에 실패하였습니다. 회원가입 이메일 = {}", dto.getEmail(), e);
            throw e;
        }
        
        return memberJpaRepository.save(member);
	}

	private String encryptPassword(String password) {
		return passwordEncoder.encode(password);
	}
}

1. RabbitMQ 메시지 전달을 위한 복잡한 과정(커넥션 연결, 채널 연결, 자원회수)을 대신 수행해줍니다. JDBCTemplate과 비슷한 클래스라고 이해했습니다.

2. AmqpException 예외에 의해 메시지 전달에 실패하는 경우가 있습니다. 이 때, 자동으로 재수행하기 위한 애노테이션입니다.

3. "SIGN_UP" 이라는 큐에 회원가입 고객의 이메일을 삽입합니다.

 

**메일 전송 서비스**

@Slf4j
@Service
@RequiredArgsConstructor
public class AuthenticationCodeMailService {
	private final MailAuthCodeGenerator mailAuthCodeGenerator;
	private final JavaMailSender javaMailSender;
    
	@RabbitListener(queues = "SIGN_UP", ackMode = "AUTO") // ...1
    public void sendAuthenticationCodeEmail(String to) {
		try {
            MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(message, true, "UTF-8");
			mimeMessageHelper.setFrom(...);
			mimeMessageHelper.setTo(to);
			mimeMessageHelper.setSubject("회원가입 인증 메일");
			body += "<h1>회원가입 인증 메일<h1>";
			...
			mimeMessageHelper.setText(body, true);
			javaMailSender.send(message);
		} catch (MessagingException e) {
			log.error("이메일 전송에 실패하였습니다. receiverMail={}", event.member().getEmail());
			throw new RuntimeException(e);
		}
	}
}

 

1. @RabbitListener 애노테이션을 활용하면 쉽게 메시지를 수신할 수 있습니다. 위 코드는 "SIGN_UP" 큐의 메시지를 수신합니다. ackMode를 "AUTO"로 설정하여, return문이 정상 수행되면 자동으로 ACK 메시지를 메시지 큐에 전달하도록 하였습니다. ACK 메시지를 전달받은 메시지 큐는 큐에서 메시지를 삭제합니다. 만약, return문이 수행되지 못하고, 예외가 발생할 경우, NACK 메시지를 메시지 큐에 전달합니다. NACK 메시지를 전달 받은 메시지 큐는 메시지를 재전송하거나 폐기하게 됩니다. 자세한 사항은 RabbitMQ Docs 를 참고해주세요.

 

이로써, AuthenticationCodeMailService는 MemberSignUpService 무관하게 "SIGN_UP" 큐의 메시지를 소비하여 인증 메일 전송을 수행하게 됐습니다.

 

참고

댓글