Spring @Async 비동기 처리 심화

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 활용이 프로덕션 레벨의 핵심 패턴이다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux