HTTP 요청을 받으면 반드시 처리가 끝날 때까지 스레드를 붙잡고 있어야 할까요? 오래 걸리는 작업은 스레드를 돌려주고 나중에 응답하는 방법은 없을까요?

개념 정의

스프링 MVC의 비동기 처리 는 서블릿 스레드를 즉시 반환하고, 별도 스레드에서 작업을 처리한 후 결과가 준비되면 응답을 보내는 메커니즘입니다. 서블릿 3.0의 비동기 서블릿 지원을 기반으로 합니다.

왜 필요한가

동기 방식에서는 스레드 풀(기본 200개)이 한계입니다.

PLAINTEXT
[동기 방식]
스레드 1: 요청 → DB 조회(2초 대기) → 응답  → 해제
스레드 2: 요청 → 외부 API(3초 대기) → 응답 → 해제
...
스레드 200: 요청 → 대기...
스레드 201: 거부! (스레드 풀 고갈)

비동기 방식에서는 대기 시간 동안 스레드를 반환합니다.

PLAINTEXT
[비동기 방식]
스레드 1: 요청 → 비동기 작업 시작 → 스레드 반환 (즉시 다른 요청 처리 가능)
          ... 작업 완료 ...
스레드 N: 결과 받아서 응답 전송

내부 동작

비동기 요청 처리 흐름

PLAINTEXT
1. 클라이언트 요청 도착
2. 서블릿 스레드가 컨트롤러 메서드 실행
3. Callable/DeferredResult 반환
4. 서블릿 스레드 반환 (request.startAsync())
5. 별도 스레드에서 실제 작업 수행
6. 작업 완료 → DispatcherServlet에 재전달
7. 서블릿 스레드가 응답 전송

Callable — 간단한 비동기 처리

JAVA
@GetMapping("/slow")
public Callable<ResponseEntity<String>> slowEndpoint() {
    return () -> {
        // 이 코드는 별도 TaskExecutor 스레드에서 실행
        Thread.sleep(3000); // 오래 걸리는 작업
        return ResponseEntity.ok("완료");
    };
}

서블릿 스레드는 Callable을 반환하자마자 해제됩니다. Callable 내부의 로직은 스프링이 관리하는 TaskExecutor에서 실행됩니다.

DeferredResult — 유연한 비동기 처리

JAVA
@GetMapping("/events/{id}")
public DeferredResult<EventResponse> waitForEvent(@PathVariable Long id) {
    DeferredResult<EventResponse> deferredResult = new DeferredResult<>(30_000L); // 30초 타임아웃

    // 다른 곳(이벤트 리스너, 메시지 컨슈머 등)에서 결과를 설정할 수 있음
    eventService.registerCallback(id, event -> {
        deferredResult.setResult(new EventResponse(event));
    });

    deferredResult.onTimeout(() -> {
        deferredResult.setErrorResult(
            new ResponseStatusException(HttpStatus.REQUEST_TIMEOUT, "이벤트 대기 시간 초과")
        );
    });

    return deferredResult;
}

DeferredResultCallable과 달리 아무 스레드에서나 결과를 설정할 수 있습니다. 이벤트 드리븐 아키텍처에 적합합니다.

코드 예제

@Async — 메서드 레벨 비동기

JAVA
@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean("asyncExecutor")
    public TaskExecutor asyncTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(20);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("async-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        return executor;
    }
}
JAVA
@Service
public class NotificationService {

    @Async("asyncExecutor")
    public CompletableFuture<Boolean> sendEmail(String to, String subject, String body) {
        // 별도 스레드에서 실행
        boolean result = emailClient.send(to, subject, body);
        return CompletableFuture.completedFuture(result);
    }

    @Async("asyncExecutor")
    public void sendPushNotification(Long userId, String message) {
        // void 반환 — fire and forget
        pushClient.send(userId, message);
    }
}
JAVA
@RestController
@RequiredArgsConstructor
public class OrderController {
    private final OrderService orderService;
    private final NotificationService notificationService;

    @PostMapping("/orders")
    public ResponseEntity<Order> createOrder(@RequestBody OrderRequest request) {
        Order order = orderService.create(request);

        // 비동기로 알림 발송 (응답에 영향 없음)
        notificationService.sendEmail(order.getUserEmail(), "주문 확인", "...");
        notificationService.sendPushNotification(order.getUserId(), "주문 완료");

        return ResponseEntity.ok(order); // 알림 완료를 기다리지 않음
    }
}

SseEmitter — 서버 사이드 이벤트 스트리밍

JAVA
@RestController
public class NotificationController {

    private final CopyOnWriteArrayList<SseEmitter> emitters = new CopyOnWriteArrayList<>();

    // 클라이언트가 SSE 연결
    @GetMapping(value = "/notifications/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter subscribe() {
        SseEmitter emitter = new SseEmitter(60_000L); // 60초 타임아웃

        emitters.add(emitter);

        emitter.onCompletion(() -> emitters.remove(emitter));
        emitter.onTimeout(() -> emitters.remove(emitter));
        emitter.onError(e -> emitters.remove(emitter));

        return emitter;
    }

이어서 이벤트 발행 및 처리 로직을 구현합니다.

JAVA
    // 이벤트 발생 시 모든 구독자에게 전송
    public void broadcast(NotificationEvent event) {
        List<SseEmitter> deadEmitters = new ArrayList<>();

        emitters.forEach(emitter -> {
            try {
                emitter.send(SseEmitter.event()
                    .name("notification")
                    .data(event, MediaType.APPLICATION_JSON));
            } catch (IOException e) {
                deadEmitters.add(emitter);
            }
        });

        emitters.removeAll(deadEmitters);
    }
}

StreamingResponseBody — 대용량 응답 스트리밍

JAVA
@GetMapping("/export/csv")
public ResponseEntity<StreamingResponseBody> exportCsv() {
    StreamingResponseBody stream = outputStream -> {
        // 서블릿 스레드를 점유하지 않고 별도 스레드에서 데이터를 쓴다
        Writer writer = new OutputStreamWriter(outputStream);
        writer.write("id,name,email\n");

        // 대용량 데이터를 청크 단위로 스트리밍
        try (Stream<User> users = userRepository.streamAll()) {
            users.forEach(user -> {
                try {
                    writer.write(user.getId() + "," + user.getName() + "," + user.getEmail() + "\n");
                    writer.flush();
                } catch (IOException e) {
                    throw new UncheckedIOException(e);

이어서 응답 객체를 구성하여 클라이언트에 반환하는 부분입니다.

JAVA
                }
            });
        }
    };

    return ResponseEntity.ok()
        .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=users.csv")
        .contentType(MediaType.parseMediaType("text/csv"))
        .body(stream);
}

비동기 예외 처리

JAVA
// @Async 메서드의 예외 처리
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (throwable, method, params) -> {
            log.error("비동기 메서드 예외 [{}]: {}",
                method.getName(), throwable.getMessage(), throwable);
        };
    }
}
JAVA
// DeferredResult의 예외 처리
@GetMapping("/async-with-error")
public DeferredResult<String> asyncWithError() {
    DeferredResult<String> result = new DeferredResult<>(5000L);

    CompletableFuture.supplyAsync(() -> {
        // 비동기 작업
        if (someCondition) {
            throw new BusinessException(ErrorCode.INTERNAL_ERROR);

이어서 각 예외 타입별 처리 메서드를 정의합니다.

JAVA
        }
        return "성공";
    }).whenComplete((value, ex) -> {
        if (ex != null) {
            result.setErrorResult(ex.getCause()); // @ExceptionHandler로 전달됨
        } else {
            result.setResult(value);
        }
    });

    return result;
}

비동기 타임아웃 설정

YAML
spring:
  mvc:
    async:
      request-timeout: 30000  # 비동기 요청 타임아웃 (ms)

주의할 점

1. 비동기 요청 타임아웃을 설정하지 않으면 DeferredResult가 영원히 대기한다

DeferredResult의 기본 타임아웃은 서버 설정에 의존합니다. 이벤트가 발생하지 않아 setResult()가 호출되지 않으면 클라이언트 연결이 무한 대기합니다. 대기 중인 요청이 쌓이면 Tomcat 스레드가 고갈됩니다. 생성자에서 new DeferredResult<>(30000L)로 타임아웃을 명시하고, onTimeout() 콜백으로 적절한 응답을 반환하세요.

2. SseEmitter를 완료하지 않으면 커넥션이 누수된다

SseEmitter를 생성한 후 complete()를 호출하지 않으면, 클라이언트와의 HTTP 연결이 계속 유지됩니다. 클라이언트가 연결을 끊어도 서버 측 리소스가 해제되지 않을 수 있습니다. onCompletion(), onTimeout(), onError() 콜백을 반드시 등록하여 리소스를 정리하세요.

3. @Async 메서드가 같은 클래스에서 호출되면 비동기가 동작하지 않는다

@Async도 AOP 프록시 기반이므로, 같은 클래스 내부에서 호출하면 동기로 실행됩니다. 오래 걸리는 작업을 비동기로 처리하려 했는데 동기로 실행되어 응답 시간이 그대로 긴 상태가 됩니다. @Async 메서드는 반드시 별도 빈에서 호출해야 합니다.

정리

  • Callable: 스프링이 스레드를 관리하는 간단한 비동기 처리에 적합합니다
  • DeferredResult: 이벤트 기반으로 외부 스레드에서 결과를 설정해야 할 때 사용합니다
  • @Async: 메서드 레벨에서 비동기 실행을 지원하며, @EnableAsync가 필수입니다
  • SseEmitter: 서버에서 클라이언트로 실시간 이벤트를 푸시할 때 사용합니다
  • StreamingResponseBody: 대용량 데이터를 청크 단위로 스트리밍할 때 사용합니다
  • 비동기 예외는 AsyncUncaughtExceptionHandlerDeferredResult.setErrorResult로 처리합니다
댓글 로딩 중...