비동기 처리(Asynchronous)
비동기 처리 작업이란 멀티스레드를 사용하여 작업을 분리하고, 작업이 끝날 때까지 대기하지 않고 다른 작업을 처리하는 것을 말한다. Spring Boot에서 비동기 처리는 멀티스레딩 환경에서 비동기적으로 실행되는 작업을 처리하는 것으로, 동기적인 방식과 비교해 처리 속도와 성능을 개선할 수 있다.
사용하는 이유
- 높은 응답성 : 동기적인 방식으로 작업을 처리할 때, 작업이 끝날 때까지 다른 요청을 처리할 수 없다. 예를 들어 외부 서비스와 통신할 때 I/O 작업이 많은 경우 작업이 끝날 때까지 기다리는 대신 다른 요청을 처리하며 시간을 절약할 수 있다.
- 자원의 효율성 : 동기적인 방식으로 작업을 처리할 때는 스레드를 많이 생성하므로 시스템 자원을 많이 사용한다. 비동기처리는 작업이 끝날 때까지 스레드를 차지하지 않기 때문에 자원을 효율적으로 사용할 수 있다. 또한 스레드가 많아짐에 따라 처리량을 늘리는 것이 가능해 시스템이 확장될 때도 확장성을 유지할 수 있다.
즉, 비동기 처리를 이용하여 자원을 효율적으로 분배하여 응답시간을 단축하는 것이 주목적이다.
사용 시 주의사항
- 별도의 설정이 없으면 스프링 AOP를 가져간다. 따라서, AOP와 관련된 제약 사항을 가지게 된다.
- public 메서드만 사용 가능 : proxy에서는 private 접근 불가
- 자가 호출 불가 : self-invocation 시 proxy를 거치지 않는다.
- @Async는 새로운 스레드를 생성하여 기존 스레드의 ThreadLocal을 사용하지 못하므로, 데이터를 복사해서 전달해주어야 한다. (taskDecorator 활용하면 된다.)
- 기본적으로 void만 반환한다. 따라서, 비동기 스레드에서 발생한 Error는 Main까지 반환할 수 없으므로, 별도의 처리 또는 xxFuture로 반환해야 한다.
사용 사례
1. 외부 API 호출
외부 API를 호출할 때 해당 API의 응답을 기다리는 동안 서버의 자원이 블로킹될 수 있다. 이를 방지하기 위해 비동기 처리를 사용하여 API 호출 결과를 기다리지 않고, 다른 작업을 수행할 수 있다.
2. 데이터베이스 작업
데이터 베이스 작업은 I/O 작업으로 대기 시간이 길어질 수 있습니다. 비동기 처리르 사용하여 데이터베이스 작업을 백그라운드 스레드에서 실행하고, 결괏값을 반환받을 때까지 다른 작업을 수행할 수 있다.
3. WebSocket통신
실시간 양방향 통신을 지원하기 때문에 클라이언트와 서버 간에 계속해서 데이터를 주고받아야 한다. 비동기 처리를 사용하면 서버 처리량을 높이고, 응답시간을 단축할 수 있다.
우리 프로젝트에서는 GitHub API를 통해 사용자의 Pull Request 정보, Public Repository 등 다양한 정보를 받아와 처리해야 했다. 그러나 데이터를 로드한 후 처리하는 데 평균 1초 이상의 시간이 소요된다는 것을 확인했다. 실제 운영 환경에서도 외부 API를 사용하는 기능이 느리게 작동하는 것이 체감될 정도였기에(빠름을 중시하는 한국인..!), 이 문제를 해결하기 위해 비동기 처리를 적용했다.
AsyncConfig
- ThreadPoolTaskExecutor 빈 설정
- ThreadPool
- CorePoolSize : 최초 동작 시에 corePoolSize만큼 스레드가 생성하여 사용된다.(Default 1)
- MaxPoolSize : Queue 사이즈 이상의 요청이 들어오게 될 경우, 스레드의 개수를 MaxPoolSize만큼 늘린다.(Default : Integer.MAX_VAULE)
- QueueCapacity : CorePoolSize 이상의 요청이 들어올 경우, LinkedBlockingQueue에서 대기하게 되는데 그 Queue의 사이즈를 지정해 주는 것이다.(Default : Integer.MAX_VAULE)
- SetThreadNamePrefix : 스레드명 설정
- CustomDecorator
- TaskDecorator를 통해 TaskExecutor 생성 시에 커스터마이징을 해줄 수가 있다. 기존 스레드로컬 데이터를 새로운 스레드 생성 시에 복사해 주자.
- Rejection Policy(거부된 작업 관리)
- AbortPolicy : 작업이 거부되면 RejectedExecutionException을 던짐.
- CallerRunsPolicy : Async 메서드를 불렀던 메인 스레드에서 거부된 작업을 실행함.
- DiscardOldestPolicy : 큐에서 가장 오래된 task를 제거하고 실행시킨다.
- DiscardPolicy : Reject 된 Task에 대해 어떠한 작업도 진행 안 함.
- 비동기 작업 예외 핸들러
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
private int CORE_POOL_SIZE = 10; // 스레드 풀의 코어 스레드 수
private int MAX_POOL_SIZE = 30; // 스레드 풀의 최대 스레드 수
private int QUEUE_CAPACITY = 10000; // 작업 큐의 용량
// 1. ThreadPoolTaskExecutor
@Bean(name = "sampleExecutor")
public Executor threadPoolTaskExecutor() {
ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
// 2. ThreadPool - taskExecutor 설정
taskExecutor.setCorePoolSize(CORE_POOL_SIZE); // 코어 스레드 수 설정
taskExecutor.setMaxPoolSize(MAX_POOL_SIZE); // 최대 스레드 수 설정
taskExecutor.setQueueCapacity(QUEUE_CAPACITY); // 큐 용량 설정
taskExecutor.setThreadNamePrefix("Executor-"); // 스레드 이름 접두사 설정
// 3. 데코레이터 적용
taskExecutor.setTaskDecorator(new CustomDecorator());
// 4. 거부 작업 처리
taskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
return taskExecutor;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
// 5. 핸들러 생성해 예외처리
return new AsyncExceptionHandler();
}
}
customDecorator
public class CustomDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
// 현재 요청의 RequestAttribute를 가져옴
RequestAttributes attributes = RequestContextHolder.currentRequestAttributes();
return () -> {
try {
// 작업 실행 전에 RequestAttributes를 설정
RequestContextHolder.setRequestAttributes(attributes);
// 작업 실행
runnable.run();
} finally {
// 작업 실행 후에 RequestAttributes를 제거
RequestContextHolder.resetRequestAttributes();
}
};
}
}
AsyncExceptionHandler
@Slf4j
public class AsyncExceptionHandler implements AsyncUncaughtExceptionHandler {
@Override
public void handleUncaughtException(Throwable ex, Method method, Object... params) {
log.error(ex.getMessage(), ex); // 예외 메시지와 스택 트레이스를 로깅
}
}
만약 비동기 처리 중 서버가 다운된다면 스레드는 어떻게 될까? 별 다른 처리가 없다면 요청은 멈추게 되고, 서버가 재가동되더라도 해당 요청은 실행되지 않는다. 이러한 문제를 해결하기 위해 ContextClosedEvent를 사용하자.
ContextClosedEvent
비동기 작업을 기다리는 코드를 추가하거나 아래처럼 기다리지 않고 종료 후, 비정상으로 종료된 작업에 대한 처리를 진행할 수 있다.
@Component
@Slf4j
public class CloseHandler implements ApplicationListener<ContextClosedEvent> {
private final ThreadPoolTaskExecutor executor;
public CloseHandler(ThreadPoolTaskExecutor executor) {
this.executor = executor;
}
@Override
public void onApplicationEvent(ContextClosedEvent event) {
executor.shutdown();
log.info("Gracefully Shutdown.");
}
}
그 외 yml 파일에 설정하는 방법도 존재한다.
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 20s
registerGithubToken()
비동기 처리 전 실행 속도 : 802ms
비동기 처리 후 실행 속도 : 69ms
verifyRepositoy()
비동기 처리 전 실행 속도 : 1043ms
비동기 처리 후 실행 속도 : 57ms
verifyToken()
비동기 처리 전 실행 속도 : 736ms
비동기 처리 후 실행 속도 : 58ms
Github API 비동기 처리 성능 개선 결과
- 802ms → 69ms
- 1043ms → 57ms
- 736ms → 58ms
참고 자료
- https://chamggae.tistory.com/204
- https://blog.gangnamunni.com/post/mdc-context-task-decorator/
- https://blog.gangnamunni.com/post/mdc-context-task-decorator/
- https://dkswnkk.tistory.com/733
- https://cano721.tistory.com/208