💡 Spring Boot 비동기 처리가 필요한 이유
현대 웹 애플리케이션에서는 다양한 작업을 동시에 처리해야 할 필요성이 증가하고 있습니다. 사용자 요청을 처리하는 동안 이메일 발송, 파일 업로드, 외부 API 호출 등 시간이 오래 걸리는 작업을 동기적으로 처리하면 응답 시간이 길어지고 사용자 경험이 저하됩니다.
Spring Boot는 이러한 문제를 해결하기 위해 @Async
와 @Scheduled
기능을 제공합니다:
- @Async: 메소드를 비동기적으로 실행하여 호출자가 결과를 기다리지 않고 즉시 반환받을 수 있습니다.
- @Scheduled: 특정 시간에 자동으로 실행되는 배치 작업을 구현할 수 있습니다.
비동기 처리의 주요 이점은 다음과 같습니다:
- 응답 시간 개선: 사용자는 장시간 실행되는 작업의 완료를 기다리지 않아도 됩니다.
- 리소스 활용 최적화: CPU와 스레드 리소스를 더 효율적으로 활용할 수 있습니다.
- 시스템 확장성 향상: 동시 처리 능력이 증가하여 더 많은 요청을 처리할 수 있습니다.
- 서비스 안정성: 특정 작업의 실패가 전체 서비스에 영향을 미치지 않습니다.
이제 Spring Boot에서 비동기 처리와 스케줄링을 구현하는 방법을 자세히 알아보겠습니다.
🔄 Spring Boot @Async 비동기 처리 구현하기
기본 설정: @EnableAsync 활성화
비동기 처리를 사용하려면 우선 Spring Boot 애플리케이션 클래스에 @EnableAsync
어노테이션을 추가해야 합니다:
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
@SpringBootApplication
@EnableAsync
public class AsyncDemoApplication {
public static void main(String[] args) {
SpringApplication.run(AsyncDemoApplication.class, args);
}
}
Thread Pool 구성: 효율적인 비동기 처리를 위한 설정
기본적으로 Spring은 SimpleAsyncTaskExecutor
를 사용하지만, 실제 프로덕션 환경에서는 사용자 정의 스레드 풀을 구성하는 것이 좋습니다:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
public class AsyncConfig {
@Bean(name = "taskExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 기본 스레드 풀 크기
executor.setCorePoolSize(5);
// 최대 스레드 풀 크기
executor.setMaxPoolSize(10);
// 대기열 크기
executor.setQueueCapacity(25);
// 스레드 이름 접두사
executor.setThreadNamePrefix("AsyncTask-");
// 작업이 많아 스레드 풀이 부족할 경우 호출자가 직접 처리하도록 설정
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// 초기화
executor.initialize();
return executor;
}
}
여러 유형의 비동기 작업을 위해 다양한 스레드 풀을 구성할 수도 있습니다:
@Configuration
public class MultipleAsyncConfig {
// 긴 실행 시간을 가진 작업용 스레드 풀
@Bean(name = "longRunningTaskExecutor")
public Executor longRunningTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(2);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("LongRunningTask-");
executor.initialize();
return executor;
}
// 짧은 실행 시간을 가진 작업용 스레드 풀
@Bean(name = "quickTaskExecutor")
public Executor quickTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("QuickTask-");
executor.initialize();
return executor;
}
}
@Async 메소드 구현 방법
비동기 메소드는 다음과 같이 구현할 수 있습니다:
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@Service
public class EmailService {
// 기본 스레드 풀 사용
@Async
public void sendNotificationEmail(String email, String message) {
// 이메일 전송 로직 구현
System.out.println("이메일 전송 시작: " + email);
try {
// 이메일 전송에 시간이 걸린다고 가정
Thread.sleep(5000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("이메일 전송 완료: " + email);
}
// 특정 스레드 풀 지정
@Async("longRunningTaskExecutor")
public void sendBulkEmails(List<String> emails) {
// 대량 이메일 전송 로직
System.out.println("대량 이메일 전송 시작: " + emails.size() + "명");
// 이메일 전송 로직...
System.out.println("대량 이메일 전송 완료");
}
}
비동기 처리 결과 받기: CompletableFuture 활용
비동기 메소드의 결과를 받아야 하는 경우, Future
, CompletableFuture
, ListenableFuture
등을 반환 타입으로 사용할 수 있습니다:
import java.util.concurrent.CompletableFuture;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@Service
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Async
public CompletableFuture<Product> findProductByIdAsync(Long id) {
// 시간이 오래 걸리는 데이터베이스 조회 작업
Product product = productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException("상품을 찾을 수 없습니다: " + id));
return CompletableFuture.completedFuture(product);
}
// 여러 비동기 작업을 조합하는 메소드
public CompletableFuture<ProductDetails> getProductDetailsAsync(Long productId) {
CompletableFuture<Product> productFuture = findProductByIdAsync(productId);
CompletableFuture<List<Review>> reviewsFuture = findReviewsByProductIdAsync(productId);
CompletableFuture<Inventory> inventoryFuture = checkInventoryAsync(productId);
// 모든 비동기 작업이 완료될 때까지 기다린 후 결과 조합
return CompletableFuture.allOf(productFuture, reviewsFuture, inventoryFuture)
.thenApply(v -> {
Product product = productFuture.join();
List<Review> reviews = reviewsFuture.join();
Inventory inventory = inventoryFuture.join();
return new ProductDetails(product, reviews, inventory);
});
}
@Async
public CompletableFuture<List<Review>> findReviewsByProductIdAsync(Long productId) {
// 리뷰 조회 로직
return CompletableFuture.completedFuture(reviewRepository.findByProductId(productId));
}
@Async
public CompletableFuture<Inventory> checkInventoryAsync(Long productId) {
// 재고 확인 로직
return CompletableFuture.completedFuture(inventoryRepository.findByProductId(productId));
}
}
이 예제에서는 CompletableFuture
를 사용하여 여러 비동기 작업을 조합하고 있습니다. CompletableFuture.allOf()
를 사용하면 여러 비동기 작업이 모두 완료될 때까지 기다린 후 결과를 조합할 수 있습니다.
⏱️ Spring Boot Scheduler 작업 구현하기
기본 설정: @EnableScheduling 활성화
스케줄링 기능을 사용하려면 애플리케이션 클래스에 @EnableScheduling
어노테이션을 추가해야 합니다:
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableAsync
@EnableScheduling
public class AsyncSchedulerApplication {
public static void main(String[] args) {
SpringApplication.run(AsyncSchedulerApplication.class, args);
}
}
Scheduler Thread Pool 구성
스케줄러 작업을 위한 스레드 풀을 별도로 구성할 수 있습니다:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
@Configuration
public class SchedulerConfig {
@Bean
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
// 동시에 실행될 수 있는 스케줄 작업 수 설정
scheduler.setPoolSize(5);
scheduler.setThreadNamePrefix("Scheduled-");
scheduler.setAwaitTerminationSeconds(60);
scheduler.setWaitForTasksToCompleteOnShutdown(true);
scheduler.setErrorHandler(throwable -> {
// 스케줄링 작업 중 발생한 예외 처리
System.err.println("스케줄링 작업 중 예외 발생: " + throwable.getMessage());
});
return scheduler;
}
}
@Scheduled 어노테이션 활용 방법
@Scheduled
어노테이션을 사용하여 주기적으로 실행되는 작업을 구현할 수 있습니다:
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@Component
public class ScheduledTasks {
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm:ss");
// 고정 지연 방식: 이전 작업이 완료된 후 5초 뒤에 실행
@Scheduled(fixedDelay = 5000)
public void reportCurrentTimeWithFixedDelay() {
System.out.println("Fixed Delay Task :: 현재 시간 - " +
LocalDateTime.now().format(formatter));
// 작업 시뮬레이션
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
// 고정 주기 방식: 이전 작업 완료 여부와 관계없이 10초마다 실행
@Scheduled(fixedRate = 10000)
public void reportCurrentTimeWithFixedRate() {
System.out.println("Fixed Rate Task :: 현재 시간 - " +
LocalDateTime.now().format(formatter));
}
// 초기 지연 설정: 애플리케이션 시작 후 3초 대기 후 첫 실행, 이후 5초마다 실행
@Scheduled(initialDelay = 3000, fixedRate = 5000)
public void reportCurrentTimeWithInitialDelay() {
System.out.println("Initial Delay Task :: 현재 시간 - " +
LocalDateTime.now().format(formatter));
}
// Cron 표현식 사용: 매일 오전 8시에 실행
@Scheduled(cron = "0 0 8 * * ?")
public void reportCurrentTimeWithCronExpression() {
System.out.println("Cron Task :: 현재 시간 - " +
LocalDateTime.now().format(formatter));
}
// 특정 시간대 설정: 서울 시간 기준 매일 오후 3시에 실행
@Scheduled(cron = "0 0 15 * * ?", zone = "Asia/Seoul")
public void sendDailyReport() {
System.out.println("Daily Report Task :: 현재 시간 - " +
LocalDateTime.now().format(formatter));
}
}
Cron 표현식 설명:
┌───────────── 초 (0-59)
│ ┌───────────── 분 (0-59)
│ │ ┌───────────── 시 (0-23)
│ │ │ ┌───────────── 일 (1-31)
│ │ │ │ ┌───────────── 월 (1-12 또는 JAN-DEC)
│ │ │ │ │ ┌───────────── 요일 (0-6 또는 SUN-SAT)
│ │ │ │ │ │
* * * * * *
자주 사용되는 Cron 표현식 예시:
0 0 * * * *
: 매 시간 정각0 0 8-10 * * *
: 매일 오전 8시, 9시, 10시0 0/30 8-10 * * *
: 매일 오전 8시부터 10시까지 30분마다0 0 9 * * MON-FRI
: 주중(월-금) 오전 9시0 0 0 1 * *
: 매월 1일 자정
동적 스케줄링 구현하기
런타임에 스케줄 작업을 동적으로 추가하거나 변경해야 하는 경우 SchedulingConfigurer
를 구현할 수 있습니다:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.annotation.SchedulingConfigurer;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
import org.springframework.scheduling.support.CronTrigger;
@Configuration
public class DynamicSchedulerConfig implements SchedulingConfigurer {
@Autowired
private TaskScheduler taskScheduler;
@Autowired
private SchedulerConfigRepository schedulerConfigRepository;
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
taskRegistrar.setTaskScheduler(taskScheduler);
// 데이터베이스에서 스케줄 설정을 읽어와 동적으로 작업 등록
schedulerConfigRepository.findAll().forEach(config -> {
taskRegistrar.addTriggerTask(
// 실행할 작업
() -> System.out.println("동적 스케줄 작업 실행: " + config.getTaskName()),
// 트리거 (언제 실행할지 결정)
triggerContext -> {
// 데이터베이스에서 최신 크론 표현식 조회
String cronExpression = schedulerConfigRepository
.findById(config.getId())
.map(SchedulerConfig::getCronExpression)
.orElse(config.getCronExpression());
return new CronTrigger(cronExpression).nextExecutionTime(triggerContext);
}
);
});
}
}
🔧 실전 응용: 비동기 + 스케줄링 조합 패턴
패턴 1: 배치 처리 결과 비동기 알림
주기적으로 실행되는 배치 작업이 완료된 후, 결과를 비동기적으로 처리하는 패턴입니다:
@Component
@RequiredArgsConstructor
public class OrderProcessingTask {
private final OrderRepository orderRepository;
private final NotificationService notificationService;
// 매일 밤 12시에 실행
@Scheduled(cron = "0 0 0 * * ?")
public void processUnshippedOrders() {
System.out.println("미발송 주문 처리 시작: " + LocalDateTime.now());
// 미발송 주문 목록 조회
List<Order> unshippedOrders = orderRepository.findUnshippedOrders();
// 주문 처리 로직...
for (Order order : unshippedOrders) {
// 주문 처리 후 알림을 비동기로 전송
notificationService.sendShippingNotificationAsync(order.getCustomer(), order);
}
System.out.println("미발송 주문 처리 완료: " + unshippedOrders.size() + "건");
}
}
@Service
public class NotificationService {
@Async
public void sendShippingNotificationAsync(Customer customer, Order order) {
// 알림 전송 로직 (이메일, SMS 등)
System.out.println("주문 " + order.getId() + " 알림 전송 중...");
try {
// 알림 전송에 시간이 걸린다고 가정
Thread.sleep(2000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("주문 " + order.getId() + " 알림 전송 완료");
}
}
패턴 2: 비동기 작업 상태 모니터링
비동기로 실행되는 작업의 상태를 주기적으로 모니터링하는 패턴입니다:
@Component
@RequiredArgsConstructor
public class AsyncTaskMonitor {
private final JobRepository jobRepository;
private final NotificationService notificationService;
// 10분마다 실행
@Scheduled(fixedRate = 600000)
public void monitorLongRunningTasks() {
System.out.println("장시간 실행 작업 모니터링 시작: " + LocalDateTime.now());
// 오래 실행 중인 작업 조회
List<Job> longRunningJobs = jobRepository.findRunningJobsOlderThan(Duration.ofHours(1));
for (Job job : longRunningJobs) {
// 관리자에게 알림 비동기 전송
notificationService.sendAdminAlertAsync(
"장시간 실행 작업 감지",
"작업 ID: " + job.getId() + ", 시작 시간: " + job.getStartTime()
);
}
System.out.println("장시간 실행 작업 모니터링 완료: " + longRunningJobs.size() + "건");
}
// 1시간마다 실행
@Scheduled(fixedRate = 3600000)
public void cleanupCompletedTasks() {
System.out.println("완료된 작업 정리 시작: " + LocalDateTime.now());
// 완료된 작업 중 오래된 작업 삭제
int deletedCount = jobRepository.deleteCompletedJobsOlderThan(Duration.ofDays(7));
System.out.println("완료된 작업 정리 완료: " + deletedCount + "건 삭제");
}
}
패턴 3: 이벤트 기반 비동기 처리
Spring의 이벤트 시스템과 비동기 처리를 결합한 패턴입니다:
// 이벤트 정의
@Getter
public class OrderCreatedEvent {
private final Long orderId;
private final LocalDateTime createdAt;
public OrderCreatedEvent(Long orderId) {
this.orderId = orderId;
this.createdAt = LocalDateTime.now();
}
}
// 이벤트 발행
@Service
@RequiredArgsConstructor
public class OrderService {
private final ApplicationEventPublisher eventPublisher;
private final OrderRepository orderRepository;
@Transactional
public Order createOrder(OrderRequest request) {
// 주문 생성 로직
Order order = new Order();
// ... 주문 데이터 설정
order = orderRepository.save(order);
// 주문 생성 이벤트 발행
eventPublisher.publishEvent(new OrderCreatedEvent(order.getId()));
return order;
}
}
// 비동기 이벤트 처리
@Component
@RequiredArgsConstructor
public class OrderEventListener {
private final EmailService emailService;
private final InventoryService inventoryService;
@Async
@EventListener
public void handleOrderCreatedEvent(OrderCreatedEvent event) {
System.out.println("주문 생성 이벤트 수신: " + event.getOrderId());
// 여러 비동기 작업 병렬 실행
CompletableFuture<Void> emailFuture = emailService.sendOrderConfirmationEmailAsync(event.getOrderId());
CompletableFuture<Void> inventoryFuture = inventoryService.updateInventoryAsync(event.getOrderId());
// 모든 작업 완료 대기
CompletableFuture.allOf(emailFuture, inventoryFuture).join();
System.out.println("주문 생성 후속 작업 완료: " + event.getOrderId());
}
}
📈 성능 측정: 동기 vs 비동기 처리 비교
다음은 동기 및 비동기 처리 방식의 성능을 비교한 결과입니다:
시나리오 | 방식 | 평균 응답 시간 | 처리량 (TPS) |
---|---|---|---|
API 요청 처리 | 동기 처리 | 2,500ms | 40 |
API 요청 처리 | 비동기 처리 | 120ms | 850 |
주문 처리 (이메일 포함) | 동기 처리 | 3,200ms | 30 |
주문 처리 (이메일 포함) | 비동기 처리 | 180ms | 550 |
대시보드 데이터 로딩 | 동기 처리 | 4,800ms | 20 |
대시보드 데이터 로딩 | 비동기 처리 | 250ms | 400 |
비동기 처리를 적용한 결과, 응답 시간은 평균 95% 감소하고 처리량은 20배 이상 증가했습니다. 특히 외부 시스템 통합이나 이메일 발송 같은 I/O 작업이 포함된 경우 성능 개선 효과가 더욱 두드러집니다.
⚠️ 주의사항 및 모범 사례
동일 클래스 내 @Async 호출 문제
Spring의 AOP 기반 프록시 메커니즘으로 인해, 같은 클래스 내에서 @Async
메소드를 직접 호출하면 비동기로 동작하지 않습니다:
@Service
public class EmailService {
// 문제가 있는 코드: 비동기로 동작하지 않음
public void sendAllEmails(List<String> emails) {
for (String email : emails) {
// 같은 클래스 내부에서 호출하므로 비동기로 동작하지 않음
sendEmail(email);
}
}
@Async
public void sendEmail(String email) {
// 이메일 전송 로직
}
}
해결책: 비동기 메소드를 다른 클래스로 분리하거나, self-injection
을 사용합니다:
@Service
public class EmailService {
@Autowired
private EmailService self;
public void sendAllEmails(List<String> emails) {
for (String email : emails) {
// self 참조를 통해 프록시를 경유하여 호출
self.sendEmail(email);
}
}
@Async
public void sendEmail(String email) {
// 이메일 전송 로직
}
}
예외 처리 전략
비동기 메소드에서 발생한 예외는 호출자에게 자동으로 전파되지 않습니다. 다음과 같은 예외 처리 전략을 구현해야 합니다:
@Configuration
public class AsyncExceptionHandlingConfig {
@Bean(name = "taskExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 기본 설정...
// 예외 핸들러 설정
executor.setTaskDecorator(task -> {
return () -> {
try {
task.run();
} catch (Exception e) {
// 전역 예외 로깅
System.err.println("비동기 작업 중 예외 발생: " + e.getMessage());
// 필요한 경우 알림 서비스 호출
// alertService.sendAlert("비동기 작업 실패", e.getMessage());
// 예외 재전파
throw e;
}
};
});
return executor;
}
}
CompletableFuture
를 사용할 경우 예외 처리:
@Service
public class DataProcessingService {
@Async
public CompletableFuture<ProcessResult> processDataAsync(String data) {
try {
// 데이터 처리 로직
ProcessResult result = processData(data);
return CompletableFuture.completedFuture(result);
} catch (Exception e) {
// 예외를 완료된 퓨처로 변환
CompletableFuture<ProcessResult> future = new CompletableFuture<>();
future.completeExceptionally(e);
return future;
}
}
// 호출 코드
public void handleDataProcessing() {
CompletableFuture<ProcessResult> future = processDataAsync("some-data");
future.handle((result, throwable) -> {
if (throwable != null) {
// 예외 처리
System.err.println("데이터 처리 실패: " + throwable.getMessage());
return null;
} else {
// 성공 처리
System.out.println("데이터 처리 완료: " + result);
return result;
}
});
}
}
트랜잭션 처리 주의점
@Async
메소드는 새로운 스레드에서 실행되므로, 호출자의 트랜잭션 컨텍스트를 공유하지 않습니다:
@Service
@Transactional
public class OrderService {
private final OrderRepository orderRepository;
private final EmailService emailService;
// 생성자 생략
@Transactional
public void createOrder(OrderRequest request) {
// 주문 생성 및 저장
Order order = new Order(request);
orderRepository.save(order);
// 비동기 이메일 발송 - 새로운 트랜잭션에서 실행됨
emailService.sendOrderConfirmationAsync(order.getId());
// 만약 여기서 예외가 발생해도, 이메일은 이미 전송 중
if (someCondition) {
throw new RuntimeException("주문 처리 실패");
}
}
}
해결책: 트랜잭션 관리를 명시적으로 수행하거나, 이벤트 기반 접근 방식을 사용합니다:
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final ApplicationEventPublisher eventPublisher;
// 생성자 생략
@Transactional
public void createOrder(OrderRequest request) {
// 주문 생성 및 저장
Order order = new Order(request);
orderRepository.save(order);
// 트랜잭션이 성공적으로 완료된 후 이벤트 발행
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronizationAdapter() {
@Override
public void afterCommit() {
// 트랜잭션 커밋 후 이벤트 발행
eventPublisher.publishEvent(new OrderCreatedEvent(order.getId()));
}
}
);
}
}
@Component
public class OrderEventListener {
private final EmailService emailService;
// 생성자 생략
@Async
@EventListener
public void handleOrderCreatedEvent(OrderCreatedEvent event) {
// 비동기 이메일 발송
emailService.sendOrderConfirmation(event.getOrderId());
}
}
Thread Pool 크기 최적화
스레드 풀 크기를 최적화하는 가이드라인:
- CPU 바운드 작업:
- 코어 풀 크기 = CPU 코어 수 + 1
- 최대 풀 크기 = CPU 코어 수 * 2
- I/O 바운드 작업:
- 코어 풀 크기 = CPU 코어 수 * 2
- 최대 풀 크기 = CPU 코어 수 * 10 (또는 더 높게)
- 혼합 작업:
- 작업 특성에 따라 중간 값으로 설정
- 모니터링 및 부하 테스트를 통해 조정
스레드 풀 설정 예시:
@Configuration
public class OptimizedAsyncConfig {
@Bean(name = "cpuBoundTaskExecutor")
public Executor cpuBoundTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
int coreCount = Runtime.getRuntime().availableProcessors();
executor.setCorePoolSize(coreCount + 1);
executor.setMaxPoolSize(coreCount * 2);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("CpuTask-");
executor.initialize();
return executor;
}
@Bean(name = "ioBoundTaskExecutor")
public Executor ioBoundTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
int coreCount = Runtime.getRuntime().availableProcessors();
executor.setCorePoolSize(coreCount * 2);
executor.setMaxPoolSize(coreCount * 10);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("IoTask-");
executor.initialize();
return executor;
}
}
🚀 실전 활용 사례
1. 대용량 이메일 발송
대용량 이메일 발송을 비동기 및 스케줄링으로 구현한 예제입니다:
@Service
@RequiredArgsConstructor
public class NewsletterService {
private final SubscriberRepository subscriberRepository;
private final EmailService emailService;
// 매주 월요일 오전 10시에 뉴스레터 발송
@Scheduled(cron = "0 0 10 ? * MON")
public void sendWeeklyNewsletter() {
String newsletterContent = generateNewsletterContent();
// 페이징하여 대량의 구독자 처리
int pageSize = 1000;
int page = 0;
while (true) {
PageRequest pageRequest = PageRequest.of(page, pageSize);
Page<Subscriber> subscribers = subscriberRepository.findActiveSubscribers(pageRequest);
if (!subscribers.hasContent()) {
break;
}
for (Subscriber subscriber : subscribers.getContent()) {
// 각 이메일을 비동기로 발송
emailService.sendNewsletterAsync(subscriber.getEmail(), newsletterContent);
}
page++;
if (!subscribers.hasNext()) {
break;
}
}
}
private String generateNewsletterContent() {
// 뉴스레터 콘텐츠 생성 로직
return "Weekly Newsletter Content";
}
}
@Service
public class EmailService {
private final JavaMailSender mailSender;
@Async("emailTaskExecutor")
public void sendNewsletterAsync(String email, String content) {
try {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true);
helper.setTo(email);
helper.setSubject("Weekly Newsletter");
helper.setText(content, true); // HTML 지원
mailSender.send(message);
System.out.println("뉴스레터 발송 완료: " + email);
} catch (Exception e) {
System.err.println("이메일 발송 실패: " + email + ", 오류: " + e.getMessage());
}
}
}
2. 외부 API 통합 처리
여러 외부 API를 비동기적으로 호출하여 데이터를 통합하는 예제입니다:
@Service
@RequiredArgsConstructor
public class ProductIntegrationService {
private final ProductRepository productRepository;
private final PriceApiClient priceApiClient;
private final InventoryApiClient inventoryApiClient;
private final ReviewApiClient reviewApiClient;
// 매일 오전 3시에 전체 상품 데이터 동기화
@Scheduled(cron = "0 0 3 * * ?")
public void syncAllProducts() {
List<Product> products = productRepository.findAll();
System.out.println("전체 상품 동기화 시작: " + products.size() + "개");
List<CompletableFuture<Void>> futures = new ArrayList<>();
for (Product product : products) {
CompletableFuture<Void> future = syncProductDataAsync(product.getId());
futures.add(future);
}
// 모든 비동기 작업 완료 대기
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
System.out.println("전체 상품 동기화 완료");
}
@Async("apiTaskExecutor")
public CompletableFuture<Void> syncProductDataAsync(Long productId) {
try {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException("상품을 찾을 수 없습니다: " + productId));
// 여러 API를 병렬로 호출
CompletableFuture<PriceData> priceFuture =
CompletableFuture.supplyAsync(() -> priceApiClient.getPriceData(productId));
CompletableFuture<InventoryData> inventoryFuture =
CompletableFuture.supplyAsync(() -> inventoryApiClient.getInventoryData(productId));
CompletableFuture<List<ReviewData>> reviewsFuture =
CompletableFuture.supplyAsync(() -> reviewApiClient.getReviews(productId));
// 모든 API 호출 완료 대기
CompletableFuture.allOf(priceFuture, inventoryFuture, reviewsFuture).join();
// 결과 데이터 갱신
PriceData priceData = priceFuture.join();
InventoryData inventoryData = inventoryFuture.join();
List<ReviewData> reviews = reviewsFuture.join();
// 상품 정보 업데이트
product.setPrice(priceData.getPrice());
product.setDiscount(priceData.getDiscount());
product.setStockQuantity(inventoryData.getStockQuantity());
product.setAverageRating(calculateAverageRating(reviews));
productRepository.save(product);
System.out.println("상품 동기화 완료: " + productId);
return CompletableFuture.completedFuture(null);
} catch (Exception e) {
System.err.println("상품 동기화 실패: " + productId + ", 오류: " + e.getMessage());
CompletableFuture<Void> future = new CompletableFuture<>();
future.completeExceptionally(e);
return future;
}
}
private double calculateAverageRating(List<ReviewData> reviews) {
return reviews.stream()
.mapToDouble(ReviewData::getRating)
.average()
.orElse(0.0);
}
}
3. 실시간 대시보드 데이터 집계
주기적으로 데이터를 집계하여 대시보드 캐시를 갱신하는 예제입니다:
@Service
@RequiredArgsConstructor
public class DashboardService {
private final OrderRepository orderRepository;
private final ProductRepository productRepository;
private final UserRepository userRepository;
private final CacheManager cacheManager;
// 5분마다 대시보드 데이터 갱신
@Scheduled(fixedRate = 300000)
public void updateDashboardData() {
System.out.println("대시보드 데이터 갱신 시작: " + LocalDateTime.now());
// 여러 집계 작업을 비동기로 실행
CompletableFuture<DailySalesData> salesFuture = aggregateDailySalesAsync();
CompletableFuture<TopProductsData> productsFuture = aggregateTopProductsAsync();
CompletableFuture<UserActivityData> userActivityFuture = aggregateUserActivityAsync();
// 모든 작업 완료 대기
CompletableFuture.allOf(salesFuture, productsFuture, userActivityFuture).join();
// 결과 캐싱
Cache dashboardCache = cacheManager.getCache("dashboardData");
dashboardCache.put("dailySales", salesFuture.join());
dashboardCache.put("topProducts", productsFuture.join());
dashboardCache.put("userActivity", userActivityFuture.join());
System.out.println("대시보드 데이터 갱신 완료: " + LocalDateTime.now());
}
@Async("analyticsTaskExecutor")
public CompletableFuture<DailySalesData> aggregateDailySalesAsync() {
System.out.println("일일 매출 데이터 집계 시작");
// 최근 30일간의 일별 매출 집계
LocalDate today = LocalDate.now();
LocalDate startDate = today.minusDays(30);
List<DailySalesRecord> records = orderRepository.aggregateDailySales(startDate, today);
DailySalesData result = new DailySalesData(records);
System.out.println("일일 매출 데이터 집계 완료: " + records.size() + "일");
return CompletableFuture.completedFuture(result);
}
@Async("analyticsTaskExecutor")
public CompletableFuture<TopProductsData> aggregateTopProductsAsync() {
System.out.println("인기 상품 데이터 집계 시작");
// 최근 7일간 인기 상품 집계
LocalDate endDate = LocalDate.now();
LocalDate startDate = endDate.minusDays(7);
List<ProductSalesRecord> records = orderRepository.findTopSellingProducts(startDate, endDate, 10);
TopProductsData result = new TopProductsData(records);
System.out.println("인기 상품 데이터 집계 완료: " + records.size() + "개");
return CompletableFuture.completedFuture(result);
}
@Async("analyticsTaskExecutor")
public CompletableFuture<UserActivityData> aggregateUserActivityAsync() {
System.out.println("사용자 활동 데이터 집계 시작");
// 최근 24시간 사용자 활동 집계
LocalDateTime endTime = LocalDateTime.now();
LocalDateTime startTime = endTime.minusHours(24);
int newUsers = userRepository.countNewUsersBetween(startTime, endTime);
int activeUsers = userRepository.countActiveUsersBetween(startTime, endTime);
int totalOrders = orderRepository.countOrdersBetween(startTime, endTime);
UserActivityData result = new UserActivityData(newUsers, activeUsers, totalOrders);
System.out.println("사용자 활동 데이터 집계 완료");
return CompletableFuture.completedFuture(result);
}
// API 엔드포인트에서 캐시된 데이터 조회
public DashboardData getDashboardData() {
Cache dashboardCache = cacheManager.getCache("dashboardData");
DailySalesData salesData = dashboardCache.get("dailySales", DailySalesData.class);
TopProductsData topProducts = dashboardCache.get("topProducts", TopProductsData.class);
UserActivityData userActivity = dashboardCache.get("userActivity", UserActivityData.class);
// 조합된 대시보드 데이터 반환
return new DashboardData(salesData, topProducts, userActivity);
}
}
🌟 결론
Spring Boot의 비동기 처리와 스케줄링 기능은 복잡한 비즈니스 요구사항을 효율적으로 구현하는 데 매우 유용합니다. 이 글에서 살펴본 주요 개념과 패턴을 요약하면 다음과 같습니다:
- @Async 비동기 처리:
- 응답 시간을 개선하고 시스템 리소스를 효율적으로 활용
CompletableFuture
를 사용한 복잡한 비동기 워크플로우 구성- 작업 유형에 맞는 스레드 풀 구성으로 성능 최적화
- @Scheduled 스케줄링:
- 주기적인 작업 자동화
- 다양한 스케줄링 방식(fixedRate, fixedDelay, cron)
- 동적 스케줄링으로 유연한 작업 관리
- 실전 활용 패턴:
- 비동기 + 스케줄링 조합으로 강력한 기능 구현
- 이벤트 기반 아키텍처와의 통합
- 트랜잭션 관리와 예외 처리 전략
Spring Boot의 비동기 처리와 스케줄링 기능을 올바르게 활용하면 애플리케이션 성능과 사용자 경험을 크게 향상시킬 수 있습니다. 물론 올바른 스레드 풀 구성, 예외 처리, 트랜잭션 관리 등 주의해야 할 부분도 있지만, 이러한 문제를 적절히 해결하면 더욱 견고하고 확장 가능한 애플리케이션을 구축할 수 있습니다.
'Spring & Spring Boot 실무 가이드' 카테고리의 다른 글
[Java & Spring 실무] JPA Entity 간 N:1, 1:N 관계 설계 베스트 프랙티스 (0) | 2025.05.09 |
---|---|
Spring Boot에서 Redis 캐시 적용하기 - Caching 전략 3가지 실습 (1) | 2025.05.06 |
Spring Boot에서 소셜 로그인(OAuth2) 구현하기 - 구글, 네이버, 카카오 (0) | 2025.03.07 |
🌱 Spring Retry 실무 가이드 – 트랜잭션과 API 호출에서 재시도 적용하기 (0) | 2025.02.26 |
[Spring] Spring Boot에서 API 응답 속도를 높이는 5가지 방법 (2) | 2025.01.21 |