SpringBoot에서 비동기 처리를 구현하는 네 가지 방법

이 글에서는 Spring Boot에서 제공하는 비동기 처리 기능을 요청 수준과 메서드 수준 두 가지 관점에서 살펴보고, 실제 적용 가능한 네 가지 주요 구현 방식을 설명합니다.

비동기 요청과 동기 요청의 차이

클라이언트, 웹 컨테이너, 비즈니스 처리 스레드 세 가지 요소가 존재하는 시나리오를 고려해보면, 동기 요청은 모든 단계에서 블로킹 상태로 대기하며, 리소스를 지속적으로 점유합니다. 반면 비동기 요청은 초기 요청 처리 후 컨테이너 스레드를 즉시 반환하고, 별도의 스레드에서 작업을 완료한 후 결과를 클라이언트에 전달합니다. 이를 통해 서버의 동시 처리 능력이 향상됩니다.

Servlet 3.0 이상에서의 비동기 처리

Servlet 3.0 이전에는 각 요청마다 독립된 스레드가 할당되는 방식(스레드-퍼-리퀘스트)이 사용되었지만, 긴 처리 시간이 필요한 작업에서는 성능 저하가 발생했습니다. 이후 도입된 비동기 지원은 AsyncContext 객체를 통해 요청의 스레드 및 자원을 조기에 해제하고, 별도의 스레드에서 처리를 완료한 후 결과를 전송할 수 있게 했습니다.


@RequestMapping("/async")
public void async(HttpServletRequest request) {
    AsyncContext context = request.startAsync();
    context.setTimeout(6000);
    context.addListener(new AsyncListener() {
        @Override
        public void onComplete(AsyncEvent event) {
            System.out.println("처리 완료");
        }

        @Override
        public void onTimeout(AsyncEvent event) {
            System.out.println("타임아웃 발생");
        }

        @Override
        public void onError(AsyncEvent event) {
            System.out.println("에러 발생: " + event.getThrowable().getMessage());
        }

        @Override
        public void onStartAsync(AsyncEvent event) {
            System.out.println("비동기 시작");
        }
    });

    context.start(() -> {
        try {
            Thread.sleep(5000);
            context.getResponse().getWriter().write("비동기 응답 완료");
        } catch (Exception e) {
            e.printStackTrace();
        }
        context.complete();
    });
}
    

Spring 기반 비동기 요청 처리

Spring MVC는 다양한 방식으로 비동기 처리를 지원합니다. 대표적인 방법 중 하나인 Callable은 비동기 작업을 실행하고 결과를 반환하는 방식입니다.


@GetMapping("/email")
public Callable<String> sendEmail() {
    System.out.println("메인 스레드: " + Thread.currentThread().getName());
    return () -> {
        Thread.sleep(1000);
        System.out.println("서브 스레드: " + Thread.currentThread().getName());
        return "이메일 전송 완료";
    };
}
    

기본적으로 SimpleAsyncTaskExecutor가 사용되지만, 실제 환경에서는 ThreadPoolTaskExecutor와 같은 커스터마이징된 스레드 풀을 사용하는 것이 권장됩니다.


@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Bean("asyncExecutor")
    public ThreadPoolTaskExecutor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(20);
        executor.setThreadNamePrefix("async-thread-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }

    @Override
    public void configureAsyncSupport(AsyncSupportConfigurer configurer) {
        configurer.setTaskExecutor(taskExecutor());
        configurer.setDefaultTimeout(30000L);
    }
}
    

WebAsyncTask 활용

WebAsyncTaskCallable을 래핑한 클래스로, 타임아웃, 완료, 오류 콜백을 설정할 수 있어 더 유연한 제어가 가능합니다.


@GetMapping("/task")
public WebAsyncTask<String> executeTask() {
    WebAsyncTask<String> task = new WebAsyncTask<>(5000L, () -> {
        Thread.sleep(3000);
        return "작업 완료";
    });

    task.onTimeout(() -> "처리 시간 초과");
    task.onCompletion(() -> System.out.println("완료 처리"));

    return task;
}
    

DeferredResult를 통한 장기 대기 처리

DeferredResult는 결과가 즉시 생성되지 않을 때 사용되며, 다른 스레드에서 setResult()로 값을 설정할 수 있습니다. 이는 서버 푸시, 장기 폴링, 알림 시스템 등에 적합합니다.


@Controller
public class NotificationController {

    private final List pendingRequests = new ArrayList<>();

    @GetMapping("/notify")
    public DeferredResult<String> notify() {
        DeferredResult<String> result = new DeferredResult<>(10000L);
        pendingRequests.add(result);
        return result;
    }

    @PostMapping("/trigger")
    public void trigger() {
        pendingRequests.forEach(r -> r.setResult("알림 발송 완료"));
        pendingRequests.clear();
    }
}
    

메서드 수준의 비동기 처리 (@Async)

스프링의 @Async 어노테이션은 메서드 자체를 별도 스레드에서 실행하도록 합니다. 이는 서비스 계층이나 기타 컴포넌트에서도 활용 가능하며, 스레드 풀을 미리 구성해야 합니다.


@SpringBootApplication
@EnableAsync
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

@Service
public class MessageService {

    @Async
    public void sendSms() {
        System.out.println("SMS 전송 시작 - " + Thread.currentThread().getName());
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        System.out.println("SMS 전송 완료");
    }
}
    

각 방식의 비교 및 선택 기준

  • @Async: 일반적인 메서드 호출 비동기화, 모든 계층에서 사용 가능.
  • Callable: Controller에서 비동기 요청 처리, 결과 반환 형식.
  • WebAsyncTask: 타임아웃 및 콜백 처리가 필요한 경우, 확장성 우수.
  • DeferredResult: 결과가 외부 이벤트에 의해 결정되는 경우, 장기 대기 시나리오에 적합.

결론적으로, 요청 수준의 비동기 처리는 Callable, WebAsyncTask, DeferredResult를 사용하고, 메서드 수준의 비동기는 @Async를 사용하는 것이 자연스럽습니다. 상황에 따라 적절한 도구를 선택하는 것이 중요합니다.

태그: SpringBoot 비동기 처리 Callable WebAsyncTask DeferredResult

6월 30일 03:06에 게시됨