개요
Spring의 @Async 어노테이션은 메서드를 비동기적으로 실행할 수 있게 해주는 강력한 기능이다.
스레드를 통해 비동기 방식으로 로직을 처리하여, 시간이 오래 걸리는 작업을 백그라운드에서 수행할 수 있다.
@Async 사용 이점
ThreadPoolExecutor를 직접 사용하지 않고 @Async를 사용할 때 얻을 수 있는 이점은 정말 많지만 가장 큰 이점을 뽑자면 코드의 간결성이다.
//1. ThreadPoolExecutor 사용
public void sendEmail(String email) {
executor.execute(() -> {
try {
// 비동기로 실행할 로직
} catch (Exception e) {
log.error("Error: {}", e.getMessage());
}
});
}
=========================
//2. @Async 사용
@Async
public void sendEmail(String email) {
// 비동기로 실행할 로직
}
AOP를 통해 ThreadPoolExecutor에 작업을 할당하는 부가 기능을 감춰주고, 핵심 기능에 집중할 수 있다.
또한 코드가 간결해지면서, 테스트 시에도 핵심 기능에만 집중하여 테스트할 수 있다. 이외에도 비동기 작업을 처리하기 위한 기능들을 간편하게 제공한다.
기본 설정 및 사용법
@EnableAsync 기능 활성화
@EnableAsync //비동기 기능 활성화
@Configuration
public class AsyncConfig {
}
해당 어노테이션을 추가하지 않으면, @Async가 동작하지 않는다.
@Async 추가
@Service
@RequiredArgsConstructor
public class FcmSender {
//비동기 동작
@Async
public void sendMessage(String token, Notification notification) {
//알림 발송 로직
}
}
@Async를 통해 메서드 단위로 비동기로 동작시킬 수 있다.
비동기 기능 호출
@Service
@RequiredArgsConstructor
public class UserService {
private final FcmSender fcmSender;
public void registerUser(User user) {
userRepository.save(user);
// 비동기 실행!
fcmSender.sendMessage(user.getFcmToken(), new Notification("회원가입을 축하합니다"));
// 알림 전송을 기다리지 않고 즉시 출력
System.out.println("회원가입 완료!");
}
}
알림이나 이메일을 발송하는 작업은 회원가입 절차에 중요하지 않은 부분이기에, 백그라운드로 처리하고 회원가입을 완료시킬 수 있다.
기본 설정일 때, 스레드 풀
기본 설정에서는 별도의 스레드 풀을 설정하지 않으면, 기본적으로 순수 Spring에서는 SimpleAsyncTaskExecutor를 통해 스레드를 관리하였다. SimpleAsyncTaskExecutor는 요청이 올 때마다 스레드를 생성하기에, 스레드 풀을 통해 관리하는 것보다 오버헤드가 크다는 단점이 있다.
Spring boot에서는 별도의 스레드 풀을 설정하지 않아도, pool size가 8개인 ThreadPoolTaskExecutor를 자동으로 빈으로 등록해준다.
커스텀 스레드 풀 설정
@Configuration
@EnableAsync //비동기 기능 활성화
public class AsyncConfig {
private final static int CORE_POOL_SIZE = 3;
private final static int MAX_POOL_SIZE = 10;
private final static int QUEUE_CAPACITY = 30;
//스레드 풀 설정
@Bean(name = "asyncTask") //스레드 풀 이름
public Executor threadPoolTaskExecutor() {
ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
taskExecutor.setCorePoolSize(CORE_POOL_SIZE); // 기본 스레드 수
taskExecutor.setMaxPoolSize(MAX_POOL_SIZE); // 최대 스레드 수
taskExecutor.setQueueCapacity(QUEUE_CAPACITY); // Queue 사이즈
taskExecutor.setThreadNamePrefix("async-thread-"); //스레드 이름 지정
return taskExecutor;
}
}
스레드 풀을 직접 설정하여 리소스를 더욱 더 효율적으로 관리하고 싶다면, 커스텀 설정한 ThreadPoolTaskExecutor를 Bean으로 등록하면 된다. 기본 스레드 수, 최대 스레드 수, Queue 사이즈, 스레드 풀 이름 등을 커스텀하게 설정할 수 있다.
풀은 기본 스레드 수 만큼 스레드를 생성해놓고, 스레드가 모두 사용중이라면 Queue에 작업을 저장해놓는다. 만약 작업의 수가 Queue 사이즈를 초과한다면, 최대 스레드 수 만큼 스레드를 추가한다. 작업이 최대 스레드 수를 초과한다면, RejectedExecutionException 예외가 발생한다.
ThreadPoolExecutor에 대해 자세히 알고 싶다면 해당 글을 참고 바란다.
비동기 작업 예외 처리
스레드를 통해 비동기로 작업이 진행 중일 때 발생하는 예외는 호출자에게 전달이 되지 않는다.
@Service
@RequiredArgsConstructor
public class UserService {
private final FcmSender fcmSender;
public void registerUser(User user) {
userRepository.save(user);
//예외를 잡을 수 없음.
try {
// 비동기 실행!
fcmSender.sendMessage(user.getFcmToken(), new Notification("회원가입을 축하합니다"));
} catch (Exception e) {
log.error(e.getMessage());
}
System.out.println("회원가입 완료!");
}
}
그렇기에 try-catch문으로 비동기 작업을 감싼다고 하더라도 예외가 전달되지 않기에 처리할 수 없다.
AsyncUncaughtExceptionHandler 구현
@Slf4j
//AsyncUncaughtExceptionHandler 구현
public class GlobalAsyncTaskAdvice implements AsyncUncaughtExceptionHandler {
@Override
public void handleUncaughtException(
Throwable exception,
Method method,
Object... params
) {
String message = exception.getMessage();
if (exception instanceof GudoknoteException) {
HttpStatusCode statusCode = ((GudoknoteException) exception).getStatusCode();
log.error("HttpStatus : {}, Exception Message : {}", statusCode, message);
}
log.error("Exception Message : {}", message);
// 추가적인 처리 가능
// - Slack 알림 발송
// - 에러 로그를 DB에 저장
// - 관리자에게 이메일 전송
}
}
AsyncUncaughtExceptionHandler를 통해 비동기 스레드에서 발생한 예외를 처리할 수 있다.
Throwable을 통해 발생한 예외에 대한 정보를 얻을 수 있다.
AsyncUncaughtExceptionHandler 등록
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
private final static int CORE_POOL_SIZE = 3;
private final static int MAX_POOL_SIZE = 10;
private final static int QUEUE_CAPACITY = 30;
@Bean(name = "asyncTask")
public Executor threadPoolTaskExecutor() {
ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
taskExecutor.setCorePoolSize(CORE_POOL_SIZE); // 기본 스레드 수
taskExecutor.setMaxPoolSize(MAX_POOL_SIZE); // 최대 스레드 수
taskExecutor.setQueueCapacity(QUEUE_CAPACITY); // Queue 사이즈
taskExecutor.setThreadNamePrefix("async-thread-"); // 스레드 이름 지정
return taskExecutor;
}
// 비동기 스레드 내 발생하는 예외에 대한 처리 클래스 설정
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return new GlobalAsyncTaskAdvice();
}
}
AsyncConfig 클래스를 AsyncConfigurer를 구현받도록 한 후, override 메서드를 통해 AsyncUncaughtExceptionHandler를 등록할 수 있다.
Spring에서는 다음과 같이 비동기 스레드에서 발생하는 예외에 대한 처리 로직을 공통화 할 수 있다.
반환 값이 있는 비동기 작업
비동기 메서드에서 반환값이 필요한 경우 CompletableFuture 타입을 사용하면, 비동기 스레드 작업의 결과를 반환받을 수 있다.
@Service
public class AsyncDataService {
@Async
public CompletableFuture<String> fetchDataFromExternalAPI() {
// 외부 API 호출 (시간이 오래 걸림)
String result = externalApiClient.call();
return CompletableFuture.completedFuture(result);
}
}
CompletableFuture에 대한 자세한 설명은 해당 글을 참고 바란다.
AsyncUncaughtExceptionHandler는 반환 타입이 void인 비동기 메서드 에서만 동작한다.
CompletableFuture를 반환하는 경우에는 CompletableFuture.exceptionally() 또는 handle() 등을 통해 예외를 처리해야 한다.
Virtual Thread Executor
아래와 같이 설정하면, Virtual Thread로 비동기 작업을 진행할 수 있다.
@Configuration
@EnableAsync //비동기 기능 활성화
public class AsyncConfig {
//Virtual thread 적용
@Bean
public SimpleAsyncTaskExecutor taskExecutor(
SimpleAsyncTaskExecutorBuilder builder) {
return builder.virtualThreads(true).build();
}
}
이 때, 스레드 풀을 만들어서 사용하는 것이 아닌, SimpleAsyncTaskExecutor를 통해 요청이 올 때마다 스레드를 생성하여 비동기 작업을 진행한다. 그 이유는 Virtual Thread는 정말 가볍기 때문에 매번 생성해도 문제가 없기 때문이다. 오히려 풀링하지 않는 것이 권장된다.
Virtual Thread에 대한 자세한 설명은 해당 글을 참고 바란다
사용 시 주의사항
정말 편리한 기능이지만, 주의해야 할 점이 몇 가지 있다.
1. @Transaction과 함께 사용될 경우
@Service
@RequiredArgsConstructor
public class UserService {
private final FcmSender fcmSender;
//트랜잭션
@Transactional
public void registerUser(User user) {
// 트랜잭션 내에서 저장
userRepository.save(user);
// 비동기 실행! -> 새로운 스레드
// 새로운 스레드에서 실행되므로 해당 트랜잭션 컨텍스트를 공유하지 않음
fcmSender.sendMessage(user.getFcmToken(), new Notification("회원가입을 축하합니다"));
System.out.println("회원가입 완료!");
}
}
Spring의 트랜잭션은 ThreadLocal을 통해 스레드 별로 관리된다. @Async를 통해 비동기 작업을 진행할 경우, 새로운 스레드가 생성되기 때문에 @Transactional 안에서 비동기 로직이 수행된다면 @Transactional이 유효하지 않다.
2. 프록시 기반 AOP 동작
@Service
public class UserService {
private final FcmSender fcmSender;
public void registerUser(User user) {
userRepository.save(user);
// 내부 호출 → @Async 동작하지 않음!
sendMessage(user.getFcmToken(), new Notification("회원가입을 축하합니다"))
System.out.println("회원가입 완료!");
}
@Async
public void sendMessage(String fcmToken, Notificaiton notification) {
fcmSender.sendMessage(fcmToken, notification);
}
}
다른 프록시 기반 AOP 어노테이션과 마찬가지로 내부 메서드 호출 시 작업이 비동기로 진행하지 않는다.
@Async를 사용하지 말아야 할 경우
1. 하나의 트랜잭션 내에서 진행되어야 하는 핵심 비즈니스 로직일 경우
2. 실행 순서가 보장되어야 할 경우
3. 즉시 결과가 필요한 경우
'Spring' 카테고리의 다른 글
| [Spring] @Scheduled를 통한 스케쥴러 구현 시 주의할 점 (0) | 2026.03.14 |
|---|---|
| [Spring] @EventListener를 통해 도메인 간 결합 끊기 (0) | 2026.02.18 |