Spring Boot 서버의 비동기 처리

코-드 텐카이·2025년 1월 12일

Spring Boot

목록 보기
6/10

1. 비동기 처리 개요

비동기 처리는 요청한 작업의 완료를 기다리지 않고 다음 작업을 수행하는 방식입니다. Spring Boot에서는 다음과 같은 상황에서 주로 사용됩니다:

  • 외부 API 호출
  • 대용량 데이터 처리
  • 이메일 발송
  • 파일 업로드/다운로드

2. Spring Boot 비동기 처리 방식별 사용 시나리오

Spring Boot에서는 크게 세 가지 방식으로 비동기 처리를 구현할 수 있습니다.

@Async 사용이 적합한 경우

@Async는 다음과 같은 상황에서 가장 적합합니다:

  1. 단순한 비동기 처리가 필요한 경우

    • 이메일 발송
    • 알림 전송
    • 로그 기록
    • 간단한 백그라운드 작업
  2. 기존 동기 메서드를 비동기로 전환할 때

    • 기존 코드의 최소 변경으로 비동기 처리를 구현하고 싶은 경우
    • 메서드 레벨에서 비동기 처리가 필요한 경우
@Service
public class NotificationService {
    @Async
    public void sendNotification(String userId) {
        // 간단한 알림 발송
    }
    
    @Async
    public CompletableFuture<Boolean> generateReport(Long reportId) {
        // 시간이 걸리는 보고서 생성
        return CompletableFuture.completedFuture(true);
    }
}

* @EnableAsync 설정: @Async 사용시 필요한 설정

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
    
    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(30);
        executor.setQueueCapacity(50);
        executor.setThreadNamePrefix("Async-");
        executor.initialize();
        return executor;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return new SimpleAsyncUncaughtExceptionHandler();
    }
}

@EnableAsync만 설정하고 Executor를 따로 설정하지 않으면, Spring은 기본적으로 SimpleAsyncTaskExecutor를 사용합니다.

SimpleAsyncTaskExecutor의 특징은:

  • 스레드 풀을 사용하지 않음
  • 매 요청마다 새로운 스레드를 생성
  • 스레드 재사용 없음
  • 동시 실행 스레드 수 제한 없음

따라서 프로덕션 환경에서는 위험할 수 있습니다:
1. 리소스 낭비 (계속 새로운 스레드 생성)
2. 스레드 수 제한이 없어서 시스템 부하 위험
3. 성능 저하 (스레드 생성/제거 오버헤드)

그래서 실제 서비스에서는 ThreadPoolTaskExecutor 설정을 통해 스레드 풀을 명시적으로 설정하는 것이 권장됩니다.

AsyncTaskExecutor 사용이 적합한 경우

AsyncTaskExecutor는 다음과 같은 상황에서 유용합니다:

  1. 세밀한 스레드 풀 제어가 필요한 경우

    • 다양한 종류의 작업을 각각 다른 스레드 풀로 처리
    • 작업의 우선순위 관리가 필요한 경우
    • 스레드 풀의 동적 조정이 필요한 경우
  2. 대량의 비동기 작업을 처리할 때

    • 배치 작업
    • 대량 데이터 처리
    • 파일 처리
@Configuration
public class ExecutorConfig {
    
    @Bean("highPriorityExecutor") //  구현체 등록
    public AsyncTaskExecutor highPriorityExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setThreadNamePrefix("high-priority-");
        executor.initialize();
        return executor;
    }
}

@Service
@RequiredArgsConstructor
public class BatchProcessService {
    // AsyncTaskExecutor 인터페이스를 사용
    private final AsyncTaskExecutor highPriorityExecutor;
    private final AsyncTaskExecutor lowPriorityExecutor;

    public void processTasks(List<Task> tasks) {
        tasks.forEach(task -> {
            if (task.isHighPriority()) {
                highPriorityExecutor.execute(() -> processTask(task));
            } else {
                lowPriorityExecutor.execute(() -> processTask(task));
            }
        });
    }
}

CompletableFuture API 사용이 적합한 경우

CompletableFuture는 다음과 같은 상황에서 강점을 발휘합니다:

  1. 복잡한 비동기 작업 흐름이 필요한 경우

    • 여러 비동기 작업의 결과를 조합해야 할 때
    • 작업 간의 의존성이 있는 경우
    • 조건부 실행이 필요한 경우
  2. 세밀한 예외 처리와 fallback이 필요한 경우

    • 각 단계별 예외 처리
    • 대체 값 제공
    • 복구 로직 실행
@Service  // 스프링의 서비스 계층 컴포넌트임을 나타내는 어노테이션
public class OrderProcessingService {
    
    // 비동기 주문 처리를 위한 메서드, CompletableFuture를 사용하여 비동기 실행 체인 구성
    public CompletableFuture<OrderResult> processOrder(Long orderId) {
        return validateOrder(orderId)    // Step 1: 주문 유효성 검증 (비동기)
            .thenCompose(valid -> {      // 검증 결과를 받아 다음 단계 결정
                if (!valid) return CompletableFuture.failedFuture(new OrderValidationException());
                return checkInventory(orderId);  // Step 2: 재고 상태 확인 (비동기)
            })
            .thenCompose(inStock -> {    // 재고 확인 결과를 받아 다음 단계 결정
                if (!inStock) return CompletableFuture.failedFuture(new OutOfStockException());
                return processPayment(orderId);  // Step 3: 결제 진행 (비동기)
            })
            .thenCompose(paid -> {       // 결제 결과를 받아 다음 단계 결정
                if (!paid) return CompletableFuture.failedFuture(new PaymentFailedException());
                return updateInventory(orderId); // Step 4: 재고 수량 갱신 (비동기)
            })
            .exceptionally(ex -> {       // 에러 처리 로직
                // 각 단계별 예외 상황에 따른 적절한 주문 상태 반환
                if (ex instanceof OrderValidationException) {
                    return new OrderResult(OrderStatus.INVALID);      // 주문 유효성 검증 실패
                }
                if (ex instanceof OutOfStockException) {
                    return new OrderResult(OrderStatus.OUT_OF_STOCK); // 재고 부족
                }
                return new OrderResult(OrderStatus.FAILED);           // 기타 실패
            });
    }
}
  1. CompletableFuture를 사용한 비동기 처리 체인으로 구성되어 있어 효율적인 주문 처리가 가능합니다.
  2. 주문 처리는 검증 → 재고확인 → 결제 → 재고갱신의 4단계로 진행됩니다.
  3. 각 단계마다 실패 시 적절한 예외를 발생시키고, 최종적으로 exceptionally 블록에서 이를 처리합니다.
  4. thenCompose를 사용하여 각 비동기 작업의 결과를 다음 작업에 전달하는 구조입니다.

방식 선택 시 고려사항

  1. @Async

    • 장점: 구현이 간단, 기존 코드 수정 최소화
    • 단점: 세밀한 제어 어려움, Spring AOP 의존성
  2. AsyncTaskExecutor

    • 장점: 세밀한 스레드 풀 제어, 명시적인 비동기 처리
    • 단점: 보일러플레이트 코드 증가, 복잡한 설정 필요
  3. CompletableFuture

    • 장점: 유연한 작업 조합, 강력한 예외 처리
    • 단점: 학습 곡선이 높음, 코드가 복잡해질 수 있음

3. 비동기 처리의 반환 타입

3.1 void

  • 결과 반환이 필요 없는 단순 작업
  • 작업 완료 여부를 확인할 수 없음
  • 예: 로깅, 이메일 발송

void 반환 메서드의 예외 처리

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return new AsyncUncaughtExceptionHandler() {
            @Override
            public void handleUncaughtException(Throwable ex, Method method, Object... params) {
                log.error("Async method {} threw exception:", method.getName(), ex);
                // 추가 에러 처리 로직
            }
        };
    }
}

3.2 Future

  • Java 5부터 제공되는 기본 비동기 반환 타입
  • 블로킹 방식의 결과 조회
Future<String> future = asyncMethod();
String result = future.get(); // 블로킹 호출

Future 반환 메서드의 예외 처리

try {
    Future<User> future = userService.findUser(id);
    User user = future.get(5, TimeUnit.SECONDS);
} catch (ExecutionException e) {
    // 비즈니스 예외 처리
} catch (TimeoutException e) {
    // 타임아웃 처리
}

3.3 ListenableFuture

  • Spring이 제공하는 향상된 Future
  • 콜백 등록 가능
ListenableFuture<String> future = asyncMethod();
future.addCallback(
    result -> log.info("Success: {}", result),
    ex -> log.error("Error", ex)
);

ListenableFuture의 예외처리

ListenableFuture<String> future = asyncMethod();
future.addCallback(
  new ListenableFutureCallback<String>() {
      @Override
      public void onSuccess(String result) {
          log.info("Success: {}", result);
      }

      @Override
      public void onFailure(Throwable ex) {
          if (ex instanceof CustomException) {
              log.error("Custom error occurred: {}", ex.getMessage());
          } else {
              log.error("Unexpected error", ex);
          }
      }
  }
);

3.4 CompletableFuture

  • Java 8부터 제공되는 가장 강력한 비동기 타입
  • 다양한 작업 조합 방법 제공
thenApply()     // 값 변환
thenCompose()   // Future 조합
thenCombine()   // 두 Future 결과 조합
allOf()         // 여러 Future 동시 실행
CompletableFuture<String> future = asyncMethod()
    .thenApply(result -> result + " processed")
    .thenCombine(anotherFuture, (r1, r2) -> r1 + r2)
    .exceptionally(ex -> "Error: " + ex.getMessage());

CompletableFuture의 예외 처리

CompletableFuture<User> future = userService.findUser(id)
    .exceptionally(ex -> {
        log.error("Error finding user", ex);
        return null;
    })
    .handle((user, ex) -> {
        if (ex != null) {
            return new User(); // 기본값 반환
        }
        return user;
    });

6. @Async와 @Transactional 통합

6.1 주의해야 할 문제점

  1. 트랜잭션 전파 문제
    • @Async는 새로운 스레드에서 실행되어 부모 트랜잭션이 전파되지 않음
  2. 트랜잭션 격리 문제
    • 비동기 메서드가 부모 메서드의 트랜잭션 변경사항을 볼 수 없음

6.2 권장되는 해결 방안

1. 이벤트 기반 처리

@Service
@RequiredArgsConstructor
public class OrderService {
    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public void processOrder(Order order) {
        orderRepository.save(order);
        eventPublisher.publishEvent(new OrderProcessedEvent(order.getId()));
    }

    @EventListener
    @Async
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void handleOrderProcessed(OrderProcessedEvent event) {
        Order order = orderRepository.findById(event.getOrderId()).orElseThrow();
        // 별도 트랜잭션에서 처리
    }
}

2. 명시적 트랜잭션 분리

@Service
@RequiredArgsConstructor
public class OrderService {
    private final TransactionTemplate transactionTemplate;

    @Async
    public CompletableFuture<Order> processOrderAsync(Long orderId) {
        return CompletableFuture.supplyAsync(() -> 
            transactionTemplate.execute(status -> {
                Order order = orderRepository.findById(orderId).orElseThrow();
                order.process();
                return orderRepository.save(order);
            })
        );
    }
}

마치며

Spring Boot의 비동기 처리는 애플리케이션의 성능을 크게 향상시킬 수 있는 강력한 기능입니다. 하지만 제대로 사용하기 위해서는 각 방식의 특징과 주의사항을 잘 이해하고 있어야 합니다.

특히 트랜잭션과 함께 사용할 때는 더욱 신중한 접근이 필요합니다. 이 글에서 다룬 내용들을 참고하여 여러분의 프로젝트에 맞는 최적의 방식을 선택하시기 바랍니다.

0개의 댓글