Spring @Async란?
동기 처리로는 이메일 발송, 알림 전송, 로그 적재 같은 부수 작업이 API 응답 시간을 늘린다. Spring의 @Async는 메서드 호출을 별도 스레드에서 비동기로 실행하여, 메인 스레드가 즉시 응답을 반환할 수 있게 한다. 내부적으로 AOP 프록시가 메서드 호출을 가로채 TaskExecutor에 위임하는 구조다.
기본 설정과 활성화
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5); // 기본 스레드 수
executor.setMaxPoolSize(20); // 최대 스레드 수
executor.setQueueCapacity(100); // 대기 큐 크기
executor.setThreadNamePrefix("async-"); // 스레드 이름 접두사
executor.setRejectedExecutionHandler(
new ThreadPoolExecutor.CallerRunsPolicy() // 큐 초과 시 호출 스레드에서 실행
);
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) ->
log.error("비동기 메서드 예외: {}#{} - {}",
method.getDeclaringClass().getSimpleName(),
method.getName(),
ex.getMessage(), ex);
}
}
@Async 기본 사용법
void 반환이면 fire-and-forget, CompletableFuture 반환이면 결과를 나중에 받을 수 있다.
@Service
@Slf4j
public class NotificationService {
private final EmailClient emailClient;
private final SlackClient slackClient;
private final PushClient pushClient;
// Fire-and-forget: 결과 불필요
@Async
public void sendWelcomeEmail(String email, String name) {
log.info("[{}] 환영 이메일 발송 시작: {}",
Thread.currentThread().getName(), email);
emailClient.send(email, "환영합니다!", buildWelcomeHtml(name));
log.info("환영 이메일 발송 완료: {}", email);
}
// CompletableFuture: 결과 대기 가능
@Async
public CompletableFuture<Boolean> sendSlackNotification(String channel, String message) {
try {
slackClient.postMessage(channel, message);
return CompletableFuture.completedFuture(true);
} catch (Exception e) {
log.error("Slack 알림 실패: {}", e.getMessage());
return CompletableFuture.completedFuture(false);
}
}
// 여러 비동기 작업 동시 실행
@Async
public CompletableFuture<NotificationResult> sendAll(NotificationRequest req) {
CompletableFuture<Boolean> emailFuture = sendEmailAsync(req);
CompletableFuture<Boolean> slackFuture = sendSlackAsync(req);
CompletableFuture<Boolean> pushFuture = sendPushAsync(req);
return CompletableFuture.allOf(emailFuture, slackFuture, pushFuture)
.thenApply(v -> new NotificationResult(
emailFuture.join(),
slackFuture.join(),
pushFuture.join()
));
}
}
멀티 Executor 전략
작업 유형별로 다른 스레드 풀을 분리하면, 특정 작업이 다른 작업의 스레드를 고갈시키는 문제를 방지할 수 있다.
@Configuration
@EnableAsync
public class MultiExecutorConfig {
// 이메일 전용 (I/O 바운드 → 스레드 많이)
@Bean("emailExecutor")
public TaskExecutor emailExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(30);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("email-");
return executor;
}
// 연산 작업 전용 (CPU 바운드 → 코어 수 기준)
@Bean("computeExecutor")
public TaskExecutor computeExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
int cores = Runtime.getRuntime().availableProcessors();
executor.setCorePoolSize(cores);
executor.setMaxPoolSize(cores * 2);
executor.setQueueCapacity(50);
executor.setThreadNamePrefix("compute-");
return executor;
}
// 이벤트 처리 전용
@Bean("eventExecutor")
public TaskExecutor eventExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(15);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("event-");
return executor;
}
}
// 사용: executor 이름 지정
@Async("emailExecutor")
public void sendEmail(String to, String subject, String body) { ... }
@Async("computeExecutor")
public CompletableFuture<Report> generateReport(ReportRequest req) { ... }
@Async("eventExecutor")
public void processEvent(DomainEvent event) { ... }
@Async 주의사항과 함정
| 함정 | 원인 | 해결 |
|---|---|---|
| 같은 클래스 내부 호출 | 프록시 우회 → 동기 실행 | 별도 Bean으로 분리 |
| private 메서드 | AOP 프록시 불가 | public으로 변경 |
| 트랜잭션 컨텍스트 유실 | 다른 스레드 → 별도 트랜잭션 | @Transactional을 async 메서드에 선언 |
| SecurityContext 유실 | ThreadLocal 기반 | DelegatingSecurityContextExecutor 사용 |
| MDC 로그 컨텍스트 유실 | ThreadLocal 기반 | TaskDecorator로 전파 |
// ❌ 같은 클래스 내부 호출 → @Async 무시됨
@Service
public class OrderService {
public void createOrder(OrderRequest req) {
// 내부 호출 → 프록시 우회 → 동기 실행!
this.sendConfirmation(req.getEmail());
}
@Async
public void sendConfirmation(String email) { ... }
}
// ✅ 별도 Bean으로 분리
@Service
public class OrderService {
private final OrderNotificationService notificationService;
public void createOrder(OrderRequest req) {
// 다른 Bean 호출 → 프록시 경유 → 비동기 실행!
notificationService.sendConfirmation(req.getEmail());
}
}
@Service
public class OrderNotificationService {
@Async
public void sendConfirmation(String email) { ... }
}
MDC 컨텍스트 전파: TaskDecorator
비동기 스레드에서 요청 ID, 사용자 정보 등 로그 컨텍스트가 유실되면 추적이 불가능하다. TaskDecorator로 MDC를 전파하면 비동기 스레드에서도 동일한 로그 컨텍스트를 유지할 수 있다.
public class MdcTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
// 호출 스레드의 MDC 복사
Map<String, String> contextMap = MDC.getCopyOfContextMap();
// SecurityContext 복사
SecurityContext securityContext = SecurityContextHolder.getContext();
return () -> {
try {
if (contextMap != null) {
MDC.setContextMap(contextMap);
}
SecurityContextHolder.setContext(securityContext);
runnable.run();
} finally {
MDC.clear();
SecurityContextHolder.clearContext();
}
};
}
}
// Executor에 적용
@Bean("emailExecutor")
public TaskExecutor emailExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setTaskDecorator(new MdcTaskDecorator()); // 여기!
executor.setCorePoolSize(10);
executor.setMaxPoolSize(30);
executor.setThreadNamePrefix("email-");
return executor;
}
Spring Logback MDC 구조화 로깅에서 다룬 MDC 패턴을 @Async와 결합할 때 TaskDecorator가 핵심이다.
Virtual Thread와 @Async
Java 21의 Virtual Thread를 사용하면 스레드 풀 튜닝 없이도 대량의 비동기 작업을 효율적으로 처리할 수 있다.
@Configuration
@EnableAsync
public class VirtualThreadAsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
// Virtual Thread 기반 Executor
return new TaskExecutorAdapter(
Executors.newVirtualThreadPerTaskExecutor()
);
}
}
// application.yml (Spring Boot 3.2+)
spring:
threads:
virtual:
enabled: true # 전체 앱에 Virtual Thread 적용
Spring Virtual Thread 실전 글에서 다룬 Virtual Thread의 특성과 주의사항을 @Async에도 동일하게 적용해야 한다.
@Scheduled와 조합
@Configuration
@EnableScheduling
public class SchedulerConfig implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
taskRegistrar.setScheduler(schedulerExecutor());
}
@Bean
public Executor schedulerExecutor() {
return Executors.newScheduledThreadPool(3,
new CustomizableThreadFactory("scheduler-"));
}
}
@Service
@Slf4j
public class ScheduledTasks {
// 고정 간격 실행 (이전 완료 후 5분 뒤)
@Scheduled(fixedDelay = 300_000)
public void cleanupExpiredSessions() {
log.info("만료 세션 정리 시작");
sessionRepository.deleteExpired();
}
// Cron 표현식 (매일 새벽 2시)
@Scheduled(cron = "0 0 2 * * *")
public void generateDailyReport() {
log.info("일일 리포트 생성");
reportService.generateAndSend();
}
// 조건부 스케줄링
@Scheduled(fixedRate = 60_000)
@ConditionalOnProperty(name = "feature.health-check.enabled", havingValue = "true")
public void healthCheck() {
externalServices.forEach(svc -> {
boolean healthy = svc.ping();
if (!healthy) {
alertService.sendAlert(svc.getName() + " 헬스체크 실패");
}
});
}
}
Actuator 모니터링
// 스레드 풀 메트릭 노출
@Bean
public MeterBinder asyncExecutorMetrics(
@Qualifier("emailExecutor") ThreadPoolTaskExecutor executor) {
return registry -> {
Gauge.builder("async.executor.active", executor, ThreadPoolTaskExecutor::getActiveCount)
.tag("name", "email")
.register(registry);
Gauge.builder("async.executor.pool.size", executor, ThreadPoolTaskExecutor::getPoolSize)
.tag("name", "email")
.register(registry);
Gauge.builder("async.executor.queue.size", executor,
e -> e.getThreadPoolExecutor().getQueue().size())
.tag("name", "email")
.register(registry);
};
}
// Prometheus 메트릭:
// async_executor_active{name="email"} 3
// async_executor_pool_size{name="email"} 10
// async_executor_queue_size{name="email"} 0
마무리
Spring @Async는 간단한 어노테이션 하나로 비동기 처리를 구현하지만, 프록시 함정, 컨텍스트 유실, 스레드 풀 튜닝을 이해하지 못하면 운영 장애로 이어진다. 작업 유형별 멀티 Executor 분리, TaskDecorator를 통한 MDC/SecurityContext 전파, Virtual Thread 활용이 프로덕션 레벨의 핵심 패턴이다.