[Event] 이벤트를 이용한 성능 개선 및 의존성 분리 경험
프로젝트를 한창 할 당시에는 몰랐었지만 추후에 학습하면서 Event처리를 통해 의존성을 끊는 방법을 알았다. 기존의 공식 프로젝트도 외부 api사용 로직을 분리하고 인터페이스를 통해서 의존성을 많이 줄였다고 생각했는데 여전히 불필요한 의존성이 엮여있었다.
프로젝트가 종료된 후, 해당 문제점을 개선하면서 경험을 포스팅을 해봅니다.
문제 정의
임시 저장 게시글을 등록할 시, 임시 저장 게시글 삭제 로직
아래는 게시글 임시 저장 -> 임시 저장된 게시글을 등록 -> 게시글은 저장되고 임시 저장 글은 제거하는 로직입니다.
해당 로직에서 구체적으로 2가지 문제를 가집니다.
- Article -> TempArticle의 의존성 생성
- 게시글을 저장하는 로직에서 임시 저장글이 어떻게 처리 되어야 하는지를 알 필요가 없다고 생각했습니다.
- 하나의 트랜잭션으로 묶여서 동기적 처리 -> 즉, 성능 저하
- 사용자는 게시글이 삭제되기만 기다리면 됩니다. 임시 저장글이 삭제되는 것은 1~2초 늦게 작동하더라도 큰 문제가 없다는 것입니다.
해결 방안
이벤트를 통한 의존성 분리
@Async
@EnableAsync 는 스프링의 @Async어노테이션을 감지한다. @Async는 스프링 AOP로 동작하는 self-invocation을 피해야하고 public으로 선언한 메서드에만 적용이 가능하다.
비동기적으로 메서드를 실행하기 위해서 SimpleAsyncTaskExecutor를 사용한다. (SimpleAsyncTaskExecutor는 요청이 오는대로 계속해서 쓰레드를 생성한다.) 따라서 ThreadPoolTaskExecutor를 재정의함으로써 스레드 풀을 사용하도록 해야한다.
이것을 어플리케이션 레벨 또는 각 메서드 레벨에서 override 함으로써 default를 변경할 수 있다. 혹은 spring boot 2.0이상부터는 yaml파일로 설정이 가능하다.
- Async는 결국 BeanProcessor에 의해 Proxy객체로 반환되고 이를 사용시 Interceptor에 의해 Executor가 실행이 된다 ( 포인트컷은 @Async가 달린 클래스나 메서드 )
- 위의 과정에 의해 Bean이 등록이 되어있어야 Async가 작동한다
tempArticleService의존성을 제거해주고 ApplicationEventPublisher 의존성을 추가해준다.
EventHandler
@RequiredArgsConstructor
@Component
public class TempArticleEventHandler {
private final TempArticleEventService tempArticleEventService;
@Async
@TransactionalEventListener
public void deleteTempArticle(TempArticleEvent event) {
tempArticleEventService.delete(event.getTempArticleId());
}
AsyncConfigurer
AsyncConfigurer 인터페이스를 구현하여 ThreadPoolTaskExecutor를 커스텀하여 사용할 수 있다. 이 Configurer는 뒤에 예외처리할 때도 사용된다.
@Configuration
@EnableAsync
public class SpringAsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor();
threadPoolTaskExecutor.setThreadNamePrefix("async-delete-tempArticle");
threadPoolTaskExecutor.setCorePoolSize(2); // 스레드 풀에 속한 기본 스레드 갯수
threadPoolTaskExecutor.setQueueCapacity(10); // 이벤트 대기 큐 (deafult가 integer.MAX)
threadPoolTaskExecutor.setMaxPoolSize(10); // 최대 pool size
threadPoolTaskExecutor.initialize();
return threadPoolTaskExecutor;
}
}
CorePoolSize는 실행 상태의 스레드 최수 개수이다.
현재 점유하고 있는 스레드가 corePoolSize만큼을 넘어서면 QueueCapacity크기만큼 큐에 담을 수 있다.
maxPoolSize는 스레드 풀에서 사용할 수 있는 최대 thread 개수로 큐에도 꽉 차게 된다면 설정 값만큼 스레드 풀 사이즈를 늘린다.
주의 사항
- 비동기 이벤트는 예외가 전파되지 않는다.
- Spring also provides an AsyncResult class that implements Future. We can use this to track the result of asynchronous method execution.
- 순차적인 결과를 반환하는 이벤트를 퍼블리싱 할 수 없다.
@Async
public Future<String> asyncMethodWithReturnType() {
System.out.println("Execute method asynchronously - "
+ Thread.currentThread().getName());
try {
Thread.sleep(5000);
return new AsyncResult<String>("hello world !!!!");
} catch (InterruptedException e) {
//
}
return null;
}
예외 처리
메소드의 리턴타입이 Future일 경우 예외처리는 쉽다 - Future.get() 메소드가 예외를 발생한다.
하지만 리턴타입이 void일 때, 예외는 호출 스레드에 전달되지 않을 것이다. 따라서 우리는 예외 처리를 위한 추가 설정이 필요하다.
우리는 AsyncUncaughtExceptionHandler 인터페이스를 구현함으로서 커스텀 비동기 예외처리자를 만들것이다. handleUncaughtException() 메소드는 잡히지않은uncaught 비동기 예외가 발생할때 호출된다.
public class CustomAsyncExceptionHandler implements AsyncUncaughtExceptionHandler {
@Override
public void handleUncaughtException(Throwable throwable, Method method, Object... obj) {
System.out.println("Exception message - " + throwable.getMessage());
System.out.println("Method name - " + method.getName());
for (Object param : obj) {
System.out.println("Parameter value - " + param);
}
}
전 섹션에서 우리는 설정 클래스에 의해 구현된 AsyncConfigurer 인터페이스를 보았다. 그 일부로서 우리의 커스텀 비동기 예외처리자를 리턴하는 getAsyncUncaughtExceptionHandler() 메소드 또한 오버라이드해주어야한다.
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return new CustomAsyncExceptionHandler();
}
}
정리
비동기에서 예외처리는 스레드가 다르게 동작하므로 디비에서만으론 처리가 안되는 것 같다. 따라서 어플리케이션에서 따로 처리를 해주어야 한다고 생각했다. 그래서 A→B 트랜잭션으로 비동기 일어날때, 3가지 경우를 생각해봤다.
- A가 성공 ⇒ 이벤트 발행 ⇒ B 실행 (성공)
- A가 실패 ⇒ 이벤트 발행 X ⇒ B 작동 X ( A는 롤백)
- A가 성공 ⇒ 이벤트 발행 ⇒ B 실패할 때, (B롤백 ⇒ 비동기라서 쓰레드가 다르므로 예외가 전파가 안된다.)
이때, 3번의 예외 처리를 애플리케이션에서 처리해주어 만약 임시 게시글이 삭제가 안되었다면 -> 예외를 터뜨려 사용자에게 임시 게시글이 삭제가 안되었다고 알리는 방법이 있겠다. 혹은 해당 임시 게시글의 id를 모아 나중에 스케줄러를 통해서 삭제하는 방법이 있을 것 같다.
https://www.baeldung.com/spring-async