[이메일 비동기 전송 관련] CompletableFuture 와 Async 의 장점을 누리려면..

박상준·2024년 3월 11일
2

실무

목록 보기
8/8

package com.psj.itembrowser.mail;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

import javax.persistence.EntityManager;

import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.psj.itembrowser.cart.domain.entity.CartEntity;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Service
@RequiredArgsConstructor
@Slf4j
@Transactional(readOnly = true)
public class MailMockService {
	
	private final EntityManager em;
	
	@Transactional(readOnly = true)
	public void dbSave() {
		long successCount = 0L;
		long failCount = 0L;
		
		for (int i = 0; i < 100; i++) {
			try {
				CompletableFuture<Boolean> result = sendMockMail();
				result.get();
				successCount++;
			} catch (InterruptedException | ExecutionException e) {
				failCount++;
			}
		}
		
		log.info("성공 횟수 : {}", successCount);
		log.info("실패 횟수 : {}", failCount);
		
		log.info("메일 보내기 작업이 성공한 친구들만 별도 db에 기록작업을 수행한다.");
	}
	
	@Async("mailSenderExecutor")
	public CompletableFuture<Boolean> sendMockMail() throws InterruptedException {
		log.info(Thread.currentThread().getName() + " - 메일을 보내는 중입니다.");
		
		Thread.sleep(1000);
		
		// CompletableFuture.completedFuture를 사용하여 즉시 완료된 Future를 반환합니다.
		return CompletableFuture.completedFuture(true);
	}
}
package com.psj.itembrowser.mail;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@RestController
@RequiredArgsConstructor
@Slf4j
public class MailMockController {
	
	private final MailMockService mailMockService;
	
	@GetMapping("/mail")
	public void mail() {
		mailMockService.dbSave();
	}
}
package com.psj.itembrowser.security.common.config.mail;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;

@Configuration
@EnableAsync
public class MailConfig implements AsyncConfigurer {
    
    @Override
    @Bean(name = "mailSenderExecutor")
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(20);
        executor.setQueueCapacity(10);
        executor.setThreadNamePrefix("mailSender-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        //executor.initialize(); 
        
        return executor;
    }
}
  • 일단 위 설정을 단순하게 나열해보았다.

💡 ❗❗❗❗❗주의❗❗❗❗❗
initialize() 메서드의 경우
- getAsyncExecutor 가 빈으로 등록되지 않은 경우에 별도로 추가해줘야하는 메서드입니다.
- 스프링에서 빈으로 등록된 경우 별도의 초기화과정은 필요하지 않습니다
- 스프링 자체에서 별도 초기화를 자동으로 관리해줍니다.

  • 기본적인 비동기설정이 메서드에 되어 있고, 호출하는 메서드에서는 해당 메서드의 결과값을 기다리고 성공과 실패에 대하여 별도 처리를 위하여 저런식으로 설정하였다.

실제 컨트롤러 테스트 호출시

  • 요러고 앉아있다.
  • 동기적으로 수행되는 것이다.

왜 문제인지는 사실 호출방식이 한몫한다.

  • 이유는?
    1. 같은 Bean (즉, 같은 서비스) 에서는 비동기 메서드를 호출하는 경우, 비동기적으로 동작하지 않는다.
      • 왜?
        • 스프링의 프록시 기반 AOP 동작 방식때문이다.
        • 클라이언트는 프록시 객체를 통해 실제 객체로 접근하는데, 프록시 객체의 경우 실제 객체의 메서드 호출을 가로채서 비동기 작업을 수행한다.
      • @Async 내부 동작 관련
        • @EnableAsync 이 활성화된 경우 AsyncAnnotationBeanPostProcessor 를 등록하게 되는데
          • 해당 BeanPostProcessor 의 경우
            • 스프링의 비동기 실행 기능을 지원하는 클래스다.
            • Bean 이 스프링에서 등록된 이후에 후처리하는 역할 ( 단어 그대로임 ) 을 하는데,
            • 비동기 수행을 위해 AOP 어드바이저를 자동으로 빈에 추가하는 역할을 수행한다.
        • 즉, Bean ( 서비스 자체에 @Async 동작을 프록시로 감싸버리는 것이다 )
          • 자세한 내용을 스프링 내부 소스를 참고하길 바란다.
        • 그래서 비동기로 사용할 메서드는 별도의 서비스로 꼭 생성해서 호출해주어야 한다.!!!

아래와 같이 수정하여 비동기의 이점을 누릴수있도록 수정

package com.psj.itembrowser.mail;

import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Service
@RequiredArgsConstructor
@Slf4j
@Transactional(readOnly = true)
public class MailMockService2 {
	
	private final MailMockService mailMockService;
	
	@Transactional(readOnly = true)
	public CompletableFuture<Void> dbSave() {
		// 비동기 작업을 위한 CompletableFuture 리스트를 생성합니다.
		List<CompletableFuture<Boolean>> futures = IntStream.range(0, 100)
			.mapToObj(i -> mailMockService.sendMockMail())
			.collect(Collectors.toList());
		
		// 모든 CompletableFuture가 완료될 때까지 기다립니다.
		return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
			.thenApply(v -> {
				// 모든 작업이 완료된 후, 성공 및 실패 카운트를 계산합니다.
				long successCount = futures.stream()
					.filter(CompletableFuture::join) // join()을 사용하여 결과를 가져옵니다.
					.count();
				long failCount = futures.size() - successCount;
				
				// 결과를 로깅합니다.
				log.info("성공 횟수 : {}", successCount);
				log.info("실패 횟수 : {}", failCount);
				
				log.info("메일 보내기 작업이 성공한 친구들만 별도 db에 기록작업을 수행한다.");
				return null; // Void 타입의 CompletableFuture를 반환하기 위해 null을 사용합니다.
			});
	}
}
  • 별도 비동기 호출부
package com.psj.itembrowser.mail;

import java.util.concurrent.CompletableFuture;

import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Service
@RequiredArgsConstructor
@Slf4j
@Transactional(readOnly = true)
public class MailMockService {
	
	@Async("mailSenderExecutor")
	public CompletableFuture<Boolean> sendMockMail() {
		log.info(Thread.currentThread().getName() + " - 메일을 보내는 중입니다.");
		
		try {
			Thread.sleep(1000);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		
		// CompletableFuture.completedFuture를 사용하여 즉시 완료된 Future를 반환합니다.
		return CompletableFuture.completedFuture(true);
	}
}
  • 비동기 수행부, 이메일 전송 로직을 추가할 수도 있겠다.

그리고!, 컨트롤러에서 수행시간을 체크하는 부분의 로직을 그대로 사용한다면..

  • 실제로 로직이 종료되지 않더라도 수행 시간을 체크해버린다.

그렇다면 수행 시간 체크는 어떤식으로 해야할까?

  • CompletableFuture를 사용하는 경우, thenAccept, thenApply 등의 메서드로 콜백을 받아올 수 있다.
  • 해당 콜백으로 비동기 작업이 완료된 이후에 시간 측정 로직을 실행할 수 있다.
  • 그래서
    @RestController
    @RequiredArgsConstructor
    @Slf4j
    public class MailMockController {
    	
    	private final MailMockService2 mailMockService;
    	
    	@GetMapping("/mail")
    	public void mail() {
    		long start = System.currentTimeMillis();
    		
    		mailMockService.dbSave().thenRun(() -> {
    			long end = System.currentTimeMillis();
    			
    			log.info("메일 보내기 작업이 완료되었습니다. 소요시간 : {}", end - start);
    		});
    	}
    }
    • 이런식으로 비동기 작업이 완료된 후에 시간 측정 로직을 수행하여야한다
💡 다만, 주의해야하는 점이 있다.    
  • HTTP 별도 반환이 필요한 경우에는 CompletableFuture 를 HTTP 응답으로 직접 반환하거나, 다른 방법을 고려해야한다.

이제 호출해본다면

  • 이런식으로 비동기 수행되며, 메일 보내기 작업에 대한 대략적인 수행시간을 체크할 수 있다.
  • 만약 성능 로깅 툴을 개발한다면 대략적으로 비동기 메서드는 이런식으로 감싸서 체크하지 않을까 싶다.

참고

AsyncAnnotationBeanPostProcessor (Spring Framework 5.3.3 API)

profile
이전 블로그 : https://oth3410.tistory.com/

0개의 댓글