Library/Backend

Reactive stream을 이용하여 Non blocking 이메일 서비스 구현하기 (Implement Non blocking email service using Reactive stream)

Knunu 2019. 8. 25. 22:50

안녕하세요.

Backend library 첫 게시물로, Reactive stream을 이용하여 Blocking email service를 Non blocking email service로 개선한 사례를 공유해보고자 합니다.


문제의 시작

개인적으로 진행중인 한 프로젝트에서, 이메일 인증 기능이 있는 회원 가입을 구현하고 있었습니다. 폼을 모두 작성하고, 가입 요청하는 단계에서 평균 5초간 지연이 발생했습니다. 5초는 사용자가 느끼기에 굉장히 긴 시간입니다. (버튼을 누르고 5초동안 아무것도 못하고 기다린다고 상상해보세요.) 그래서 반드시 해결해야 하는 문제라 판단하고, 어디가 문제인지 로그를 남기면서 파악해보기 시작했습니다. 그 결과, 회원 가입 과정에서 인증용 이메일을 발송하는 곳에서 병목이 발생한다는 사실을 알아냈습니다.

 

이를 Sequence diagram으로 나타내면 다음과 같이 정리가 됩니다.

 

Signup process with Blocking email verification

 

그리고 코드는 다음과 같이 회원가입 진행 과정에서 requestToVerifyEmail()을 호출하고, 내부적으로 이메일 인증을 위한 updateVerificationCode() 및 실제로 이메일을 보내는 작업을 담당하는 sendVerificationEmail()로 구성되어 있었습니다.

 

public class EmailService {
	private final JavaMailSender emailSender;
	private final MemberRepository memberRepository;
    
    public Mono<ResponseEntity<?>> requestToVerifyEmail(@NonNull String email) {
            return memberRepository.findByEmail(email)
                // validation for member
                .flatMap(this::updateVerificationCode)
                .flatMap(this::sendVerificationEmail)
                .map(member -> ResponseEntity.ok(HttpStatus.OK.getReasonPhrase()));
        }

    private Mono<Member> updateVerificationCode(Member member) {
			return Mono.fromCallable(() -> {
				member.setVerificationCode(Verifier.generateCode());
				return member;
            })
			.flatMap(memberRepository::save);
        }

    private Mono<String> sendVerificationEmail(Member member) {
            log.info("[Email] Start to send verification mail. [{}]", member.getEmail());

            return Mono.fromCallable(() -> {
                MimeMessagePreparator messagePreparator = mimeMessage -> {
                    // Setup Email template
                };
                try {
                    emailSender.send(messagePreparator);
                    return HttpStatus.OK.getReasonPhrase();
                } catch (MailException me) {
                    log.error("[Email] MailException occurred.", me);
                    throw me;
                }
            }).doOnSuccess(stringResponseEntity -> log.info("[Email] Succeeded in sending verification email. [{}]", member.getEmail()));
        }
        
	...
    
}

 

이렇게 method chain을 구성하게 되면, sendVerificationEmail()까지 온전히 끝나야 사용자에게 회원 가입 결과를 반환할 수 밖에 없게 됩니다. 즉, 인증 이메일을 보내는 행위가 회원가입 과정에서 blocking이 되는 것이지요.

 

...
2019-08-25 22:11:14.986  INFO 74844 --- [ntLoopGroup-2-4] c.m.backend.service.member.EmailService  : [Email] Start to send verification mail. [knunu2@naver.com]
2019-08-25 22:11:20.796  INFO 74844 --- [     parallel-4] c.m.backend.service.member.EmailService  : [Email] Succeeded in sending verification email
2019-08-25 22:11:20.798 DEBUG 74844 --- [     parallel-4] .s.w.r.r.m.a.ResponseEntityResultHandler : Using 'text/plain;charset=UTF-8' given [*/*] and supported [text/plain;charset=UTF-8, text/event-stream, text/plain;charset=UTF-8, */*]
2019-08-25 22:11:20.798 DEBUG 74844 --- [     parallel-4] .s.w.r.r.m.a.ResponseEntityResultHandler : [f24e5fa1] 0..1 [java.lang.String]
...

 

실제로 코드를 실행해보면 위와 같이 로그가 찍힙니다. 회원가입 결과가 나오기까지 약 5초정도 걸리는걸 알 수 있습니다.

자 그럼 어떻게 해결할 수 있을까요?


해결 방법

해결 방법은 간단합니다. sendVerificationEmail()를 별도 subscriber(thread)로 수행하고, 이메일 전송 결과를 기다리지 않은 채 바로 Http response를 반환하면 됩니다.

 

Signup process with Non blocking email service

 

코드 상으로는 다음과 같이 변경 됩니다.

 

public class EmailService {
	private final JavaMailSender emailSender;
	private final MemberRepository memberRepository;
    
    public Mono<ResponseEntity<?>> requestToVerifyEmail(@NonNull String email) {
        return memberRepository.findByEmail(email)
        // validation for member
        .flatMap(this::updateVerificationCode)
        .doOnSuccess(member -> sendVerificationEmail(member).subscribe())
        .map(member -> ResponseEntity.ok(HttpStatus.OK.getReasonPhrase()));
    }

    private Mono<Member> updateVerificationCode(Member member) {			
        // same as blocking one
	}

    private Mono<String> sendVerificationEmail(Member member) {
    	// same as blocking one
    }
    
	...
    
}

 

requestToVerifyEmail()에서 flatMap으로 이어지는 chain에서 updateVerificationCode()에 성공하면 sendVerificationEmail()을 subscribe만 하고, 위에서 언급한대로 바로 Http response를 반환합니다. 이렇게 구현하게 되면, 이메일 발송 결과를 굳이 기다리지 않고 바로 회원가입 결과를 반환할 수 있게 됩니다.

 

...
2019-08-25 22:24:35.014  INFO 74959 --- [     parallel-2] c.m.backend.service.member.EmailService  : [Email] Start to send verification mail. [knunu2@naver.com]
2019-08-25 22:24:35.017 DEBUG 74959 --- [     parallel-2] .s.w.r.r.m.a.ResponseEntityResultHandler : Using 'text/plain;charset=UTF-8' given [*/*] and supported [text/plain;charset=UTF-8, text/event-stream, text/plain;charset=UTF-8, */*]
2019-08-25 22:24:35.017 DEBUG 74959 --- [     parallel-2] .s.w.r.r.m.a.ResponseEntityResultHandler : [6c002520] 0..1 [java.lang.String]
2019-08-25 22:24:35.102  INFO 74959 --- [ctor-http-nio-3] reactor.netty.http.server.AccessLog      : 0:0:0:0:0:0:0:1 - - [25/Aug/2019:22:24:33 +0900] "POST /api/members/signup HTTP/1.1" 200 2 80 1771 ms
2019-08-25 22:24:39.901  INFO 74959 --- [      elastic-2] c.m.backend.service.member.EmailService  : [Email] Succeeded in sending verification email. [knunu2@naver.com]
...

 

위와 같이 5초 걸리던 회원 가입 결과 반환은 약 0.1초만에 완료되고, 이후 별도의 subscriber(thread)에서 수행한 이메일 발송도 완료되어 로그가 남은 것을 확인 할 수 있습니다.


사실, Blocking component를 Non blocking component로 개선하게 되면서 추가적으로 생기는 문제들도 있습니다. 예를 들어 위와 같은 케이스에서는 이메일 발송이 실패한 경우에 대해서도 회원가입 프로세스가 완료되는 문제가 생길 수 있습니다. 이를 해결하기 위해서는 Callback handler를 이용해서 다시 이메일 발송을 요청하는 형태로 Fallback을 마련해놓을 수 있을 것입니다.

 

이상입니다. 긴 글 읽어주셔서 감사합니다.