이벤트, 비동기, 스케줄링
Spring Application Events
도메인 이벤트를 통해 컴포넌트 간 결합도를 낮출 수 있다. 주문이 생성되면 “알림 발송”, “포인트 적립”, “재고 감소”를 각각 독립적인 리스너가 처리하게 하는 패턴이다.
이벤트 정의
// Spring 4.2+: ApplicationEvent 상속 없이 POJO 이벤트 가능
public record OrderCreatedEvent(Long orderId, Long userId, BigDecimal totalAmount) {}이벤트 발행
@Service
@RequiredArgsConstructor
public class OrderService {
private final ApplicationEventPublisher eventPublisher;
private final OrderRepository orderRepository;
@Transactional
public Order createOrder(CreateOrderRequest request) {
Order order = Order.of(request);
orderRepository.save(order);
// 트랜잭션 내에서 이벤트 발행
eventPublisher.publishEvent(new OrderCreatedEvent(
order.getId(), request.userId(), order.getTotalAmount()
));
return order;
}
}이벤트 리스너
@Component
@RequiredArgsConstructor
public class OrderEventHandler {
private final NotificationService notificationService;
private final PointService pointService;
@EventListener
public void handleOrderCreated(OrderCreatedEvent event) {
notificationService.sendOrderConfirmation(event.userId(), event.orderId());
}
// 여러 이벤트 처리
@EventListener({OrderCreatedEvent.class, OrderCancelledEvent.class})
public void updateInventory(Object event) { ... }
// 조건부 처리 (SpEL)
@EventListener(condition = "#event.totalAmount > 50000")
public void awardBonusPoints(OrderCreatedEvent event) {
pointService.awardBonus(event.userId(), event.totalAmount());
}
}@TransactionalEventListener
트랜잭션 결과에 따라 이벤트 처리 시점을 제어한다.
@Component
@RequiredArgsConstructor
public class OrderNotificationHandler {
private final SlackClient slackClient;
private final EmailService emailService;
// 트랜잭션 커밋 후 실행 (기본값)
// DB 저장이 확실히 완료된 후 알림을 보낼 때 사용
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void sendOrderConfirmEmail(OrderCreatedEvent event) {
emailService.sendOrderConfirmation(event.userId(), event.orderId());
}
// 트랜잭션 롤백 후 실행
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void handleOrderFailure(OrderCreatedEvent event) {
slackClient.sendAlert("주문 생성 실패: " + event.orderId());
}
}주의:
@TransactionalEventListener는 트랜잭션 내에서 발행된 이벤트에만 작동한다. 트랜잭션 없이 발행하면 기본적으로 무시된다.fallbackExecution = true를 설정하면 트랜잭션 없을 때도 실행된다.
@Async 비동기 처리
설정
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
@Bean(name = "taskExecutor")
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.initialize();
return executor;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) -> {
log.error("Async error in {}: {}", method.getName(), ex.getMessage(), ex);
};
}
}@Async 메서드
@Service
public class EmailService {
// void 반환 — fire-and-forget
@Async
public void sendWelcomeEmail(String email) {
// 실제 이메일 발송 (느린 작업)
smtpClient.send(email, "환영합니다!");
}
// CompletableFuture 반환 — 결과 추적 가능
@Async
public CompletableFuture<Boolean> sendVerificationEmail(String email, String code) {
try {
smtpClient.send(email, "인증 코드: " + code);
return CompletableFuture.completedFuture(true);
} catch (Exception e) {
return CompletableFuture.completedFuture(false);
}
}
}병렬 처리 패턴
@Service
@RequiredArgsConstructor
public class DashboardService {
private final OrderService orderService;
private final UserService userService;
private final AnalyticsService analyticsService;
public DashboardData getDashboard(Long userId) throws ExecutionException, InterruptedException {
// 세 가지 조회를 병렬로 실행
CompletableFuture<List<Order>> ordersFuture = orderService.findRecentOrdersAsync(userId);
CompletableFuture<UserStats> statsFuture = userService.getUserStatsAsync(userId);
CompletableFuture<List<Recommendation>> recsFuture = analyticsService.getRecommendationsAsync(userId);
// 모두 완료될 때까지 대기
CompletableFuture.allOf(ordersFuture, statsFuture, recsFuture).join();
return DashboardData.of(
ordersFuture.get(),
statsFuture.get(),
recsFuture.get()
);
}
}자기 호출 문제
@Service
public class ReportService {
// ❌ 자기 호출 — @Async 무시됨 (AOP 프록시 우회)
public void generateAllReports() {
this.generateReport("monthly"); // 프록시를 통하지 않아 @Async 미적용
}
@Async
public void generateReport(String type) { ... }
}해결: 별도 빈으로 분리하거나, ApplicationContext에서 자기 자신을 꺼내 호출한다.
스케줄링
기본 설정
@SpringBootApplication
@EnableScheduling
public class MyApplication { }@Scheduled 옵션
@Component
public class ScheduledTasks {
// 고정 주기 — 이전 실행 시작 기준으로 5초마다
@Scheduled(fixedRate = 5000)
public void fixedRateTask() { }
// 고정 지연 — 이전 실행 완료 후 5초 기다렸다가 실행
@Scheduled(fixedDelay = 5000)
public void fixedDelayTask() { }
// 시작 후 10초 대기, 이후 5초마다 실행
@Scheduled(fixedRate = 5000, initialDelay = 10000)
public void delayedTask() { }
// Cron 표현식 — 매일 새벽 2시
@Scheduled(cron = "0 0 2 * * *")
public void dailyBatchJob() { }
// 프로퍼티에서 cron 읽기
@Scheduled(cron = "${batch.cron:0 0 2 * * *}")
public void configurableBatchJob() { }
}Cron 표현식
초 분 시 일 월 요일
0 0 2 * * * → 매일 02:00:00
0 */5 * * * * → 5분마다
0 0 9-18 * * MON-FRI → 평일 9시~18시 매 정각
0 0 0 1 * * → 매월 1일 자정
동적 스케줄 등록/해제
@Configuration
@EnableScheduling
public class DynamicScheduleConfig implements SchedulingConfigurer {
private final Map<String, ScheduledFuture<?>> scheduledTasks = new ConcurrentHashMap<>();
private final TaskScheduler scheduler;
@Override
public void configureTasks(ScheduledTaskRegistrar registrar) {
registrar.setTaskScheduler(scheduler);
}
// 런타임에 새 스케줄 등록
public void addTask(String taskId, Runnable task, String cronExpression) {
ScheduledFuture<?> future = scheduler.schedule(task, new CronTrigger(cronExpression));
scheduledTasks.put(taskId, future);
}
// 런타임에 스케줄 취소
public void removeTask(String taskId) {
ScheduledFuture<?> future = scheduledTasks.remove(taskId);
if (future != null) {
future.cancel(false);
}
}
}