[Spring] 이메일 인증 기능 비동기 처리와 주의사항: @Async, @EnableAsync

기존의 코드에서 비동기 처리를 고려한 이유

 클라이언트가 ‘이메일 전송 요청’에 대한 기존의 시나리오는 다음과 같습니다. 먼저, 우리의 웹 서버와 DB에서 실행되어야 할 작업들이 실행됩니다. 그리고 외부 서버인 구글 SMTP 서버로 이메일 발송 요청을 한 후, 이메일 발송에 대한 응답이 온 후에야 클라이언트에게 완료했다는 응답을 보내주고 있습니다.

 

 또 다른 시나리오는 푸시 알림 발송을 위한 firebase 서버로 요청하는 경우가 있습니다. 마찬가지로 사용자가 (대) 댓글을 작성했을 때 푸시 알림이 발송 완료될 때까지 응답을 기다리게 해야 하는 상황입니다.

 

 현재는 비동기가 아니기 때문에, 하나의 스레드에서 일련의 작업을 진행하고 있습니다. 그러나 비동기 처리를 도입하게 되면, 작업이 완료되기를 기다리지 않습니다. 따라서 외부 서비스와 통신할 때 인증 메일 전송 요청 혹은 댓글 작성 요청에 대한 응답 시간을 줄일 수 있습니다.

 

비동기 처리:
작업을 별도의 스레드에서 실행하고 결과를 나중에 처리하는 방식입니다. 이를 통해 특정 로직의 실행이 끝날 때까지 기다리지 않고 다음 코드를 실행할 수 있으며, 결과가 준비되면 이벤트를 받거나 콜백을 통해 처리합니다.

 


 

@Async를 이용한 비동기 처리

스프링에서 제공하는 @Async는 해당 메서드를 비동기적으로 실행 할 수 있는 어노테이션입니다. 메서드가 호출되면 스프링은 내부적으로 별도의 스레드를 생성하여 작업을 비동기적으로 실행합니다.

 

@Async
void doSomething() {
	// this will be run asynchronously
}

@Async
void doSomething(String s) {
	// this will be run asynchronously
}

@Async
Future<String> returnSomething(int i) {
	// this will be run asynchronously
}

 

@Async 어노테이션은 메서드의 반환 타입이 void, Future,ListenableFuture,CompletableFuture 일 수 있습니다.

  • java.util.concurrent.Future(Java 5 추가)
  • org.springframework.util.concurrent.ListenableFuture
  • java.util.concurrent.CompletableFuture(JDK 8 추가)

 

반환 타입이 Future 인 경우, 비동기 작업이 완료되면 Future 객체를 통해 결과를 얻을 수 있습니다. CompletableFuture는 Java 8에서 추가된 클래스로, 비동기 작업의 결과를 처리하기 위한 기능을 제공합니다. Future 인터페이스를 확장하고, 비동기 작업의 결과를 처리하거나 다른 비동기 작업을 조합하는 데 사용됩니다.

 

Future는 비동기 처리에 대한 결과값을 처리하는 것에 대한 여러 한계점이 있습니다. 이런 문제들을 해결하기 위해 CompletableFuture가 등장하게 되었습니다. 해당 내용은 관련된 글로 대체합니다.

 

출처: https://dzone.com/articles/effective-advice-on-spring-async-part-1

 

@Async는 Spring AOP에 의해서 프록시 방식으로 작동됩니다.

 

Spring Context에 등록되어 있는 Async Bean이 호출되면 Spring이 개입하여 해당 Async Bean을 프록시 객체로 Wrapping 합니다.

다시 말해서, 컨테이너에 의해 Bean으로 등록되는 시점에 프록시 객체화를 하는 것입니다. 호출한 객체는 실질적으로 AOP를 통해 만들어진 프록시 객체화된 Async Bean을 참조하게 되는 것입니다. 즉, 위의 그림에서 Caller Method B는 Proxy 객체의 Method A를 호출하게 되는 것입니다.

 

@Async 사용 시 제약조건

메서드 호출

  1. 위의 그림에서 Method A가 private으로 지정되어 있다면 AOP가 가로채서 프록시 객체로 만들 때 Method A에 접근할 수 없으므로 private method는 사용할 수 없습니다.
  2. self-invocation(자가 호출)의 경우에는 프록시 객체를 거치지 않고 직접 Method A를 호출하기 때문에 Async가 동작하지 않습니다.

-> 이에 대해서는 해당 글에서 더 자세히 확인하실 수 있습니다.

 

트랜잭션 관리

비동기 메서드에는 트랜잭션을 사용할 때 주의해야 합니다. @Async 어노테이션이 붙은 메서드는 호출한 메서드와 독립적인 스레드에서 동작하기 때문에, 비동기 메서드 내에서 생성된 트랜잭션은 호출한 메서드의 트랜잭션과는 별개의 생명 주기를 가집니다.

 

@Service
@RequiredArgsConstructor
public class TestService {

	private final AsyncService asyncService;
	
	@Transactional
	public void testMethod() {
		asyncService.asyncMethodWithTransactional();
	}
}

 

위의 testMethod() 는 트랜잭션 범위 내에서 실행되지만, asyncMethodWithTransactional()는 비동기 처리로 새로운 스레드에서 실행되므로 별도의 트랜잭션을 생성합니다.

 

따라서 asyncMethodWithTransactional()에서 예외 발생으로 롤백이 필요하더라도, 이는 testMethod()와는 별도의 스레드에서 처리되므로 testMethod()의 트랜잭션에는 영향을 미치지 않습니다.

 


구현 방식

스프링에서는 @EnableAsync 어노테이션만 적용해도, 비동기 처리를 지원하도록 활성화할 수 있습니다.

 

AsyncConfig

이를 커스터마이징 하기 위해서는, AsyncConfigurer를 구현한 설정 파일을 만들어 줍니다.

 

 

Executors, Executor, ExecutorService: 스레드 생성과 관리 & 스레드 풀을 위한 기능

Java 5에는 스레드 생성과 관리를 위한, 그리고 스레드 풀을 위한 기능들이 추가되었는데요. Executors, ExecutorSerice 그리고 스레드 풀 생성을 도와주는 팩토리 클래스인 Executor에 대해 간단히 알아보겠습니다.

 

출처: https://mangkyu.tistory.com/259

 

Executor 인터페이스

 

 동시에 여러 요청을 처리해야 하는 경우에 매번 다른 thread를 만드는 것을 비효율적입니다. 그래서 thread를 만들어두고 재사용하기 위한 thread pool이 등장하게 되었는데, Executor 인터페이스는 thread pool의 구현을 위한 인터페이스입니다.

  • 등록된 작업(Runnable)을 실행하기 위한 인터페이스
  • 작업 등록과 작업 실행 중에서 작업 실행만을 책임진다.

 

 

ExecutorService 인터페이스

 작업(Runnable, Callable) 등록을 위한 인터페이스입니다. Executor를 상속받아서 작업 등록뿐만 아니라 실행을 위한 책임도 갖습니다. 따라서 thread pool은 기본적으로 ExecutorService 인터페이스를 구현합니다.

 대표적으로, ThreadPoolExecutor가 ExecutorService의 구현체인데, ThreadPoolExecutor 내부에 있는 BlockingQueue에 작업들을 등록합니다.

 

 

Executors

 Executor과 ExecutorService가 쓰레드 풀을 위한 인터페이스라면, Executors는 직접 스레드를 다루는 번거로운 작업을 도와주는 팩토리 클래스입니다. 고수준(High-Level)의 동시성 프로그래밍 모델로써 Executor, ExecutorService를 구현한 스레드 풀을 손쉽게 생성해 줍니다.

 


이번에는 비동기 작업을 처리할 스레드 풀을 구성하기 위해 TaskExecutor 빈을 설정해 준다.

 

스프링에서 비동기 작업을 수행하려면 보통 @EnableAsync와 @Async를 사용하는데, 이렇게 되면 기본적으로 SimpleAsyncTaskExecutor가 사용되는데요. 이는 비동기 작업마다 새로운 스레드를 생성하는 스레드 풀입니다. 이는 리소스 낭비, 성능 저하, 스케일링 문제 등이 발생할 수 있습니다.

 

이러한 이유로 제한된 리소스를 사용하기 위해서 스프링에서 제공하는 TaskExecutor 인터페이스의 구현체인 ThreadPoolTaskExecutor를 사용하였습니다. (비동기 작업의 종류나 복잡도에 따라 다른 Executor를 사용할 수 있습니다.)

 

Executor를 정의하는 방법은 Bean 등록과 위처럼 getAsyncExecutor() 재정의하는 방식이 있습니다.

  • CorePoolSize: 기본적으로 실행 대기 중인 thread 개수 (default: 1)
  • MaxPoolSize: 동시에 동작하는 최대 thread 개수(default: Integer.MAX_VALUE)
  • QueueCapacity: CorePool의 크기를 넘어설 때 저장되는 큐의 최대 용량 (default: Integer.MAX_VALUE)
  • ThreadNamePrefix: 생성되는 스레드의 이름 접두사를 정의

→ 별도로 설정하지 않으면 default 값을 가집니다.

 

Async에 대한 예외 핸들링

Async에 대한 예외 핸들링은 AsyncUncaughtExceptionHandler 인터페이스를 구현한 핸들러를 추가하거나, 아니면 위처럼 람다로 표현하는 방법이 있습니다.

@Configuration
@EnableAsync
public class AsyncConfig extends AsyncConfigurerSupport {

	//...
	
	@Override
	public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
		return new CustomAsyncExceptionHandler();
	}
}
@Slf4j
public class CustomAsyncExceptionHandler implements AsyncUncaughtExceptionHandler {
	@Override
	public void handleUncaughtException(Throwable ex, Method method, Object... params) {
		log.error("Method name : {}, param Count : {}\n\nException Cause -{}",method.getName(), params.length, ex.getMessage());
	}
}

 

 

후에 메서드에 @Async 어노테이션을 명시만 하면 task 처리를 비동기 방식으로 할 수 있습니다.

 


결론

실제로 서비스의 이메일 인증 기능에 대한 비동기 처리 적용 전 후 실행 속도가 개선되었습니다.

 

비동기 처리 전
비동기 처리 후

 

이제 외부 서비스에 요청하는 작업을 비동기 처리로 적용 후, 목표한 대로 클라이언트에게 더 빠른 응답을 줄 수 있게 되었습니다.

 

@EnableAsync와 @Async를 사용한 비동기 처리는 Spring의 지원을 받아서 간단하게 처리할 수 있지만, 주의해야 할 점이 많습니다. 예외 처리, self-invocation 이슈, 리턴 타입, 트랜잭션 관리, Executor 등 여러 사항들을 적절히 고려해야 합니다.

 

 


참고 자료 🙇‍♀️ :

https://docs.spring.io/spring-framework/reference/integration/scheduling.html#scheduling-annotation-support-async

https://www.baeldung.com/spring-async

https://dkswnkk.tistory.com/706

https://mangkyu.tistory.com/259

https://dkswnkk.tistory.com/706