스프링 비동기 시리즈 (1/6)

이 시리즈는 Spring의 비동기 처리를 처음부터 단계별로 다룬다.

1. @Async와 비동기 처리의 기본 ← 현재 문서

2. ThreadPoolTaskExecutor 설정과 스레드 풀 관리

3. Spring Event 기반 비동기 처리

4. 비동기 예외 처리와 재시도 전략

5. TaskDecorator와 컨텍스트 전파

6. WebClient를 활용한 비동기 HTTP 통신

Spring MVC는 요청 하나당 스레드 하나를 할당하는 동기 모델이다. API 하나가 5초 걸리는 작업을 처리한다면, 그 5초 동안 해당 스레드는 아무것도 못 하고 묶여 있다. 동시에 100명이 요청하면 100개의 스레드가 전부 점유되는 셈이다.

해결책은 오래 걸리는 작업을 별도 스레드에 위임하고, 요청을 받은 스레드는 즉시 다음 요청을 처리하게 만드는 것이다.

동기와 비동기의 차이

먼저 용어를 정리하자.

  • 동기(Synchronous) — 작업을 요청하면 그 작업이 끝날 때까지 기다린다. 호출자가 직접 결과를 받아간다.
  • 비동기(Asynchronous) — 작업을 요청하고 기다리지 않는다. 작업이 끝나면 별도의 방법(콜백, Future 등)으로 결과를 전달받는다.

카페에 비유하면 이렇다. 동기 방식은 주문하고 카운터 앞에서 커피가 나올 때까지 서 있는 것이다. 비동기 방식은 진동벨을 받고 자리에 앉아 다른 일을 하다가, 벨이 울리면 커피를 가지러 가는 것이다.

Spring MVC의 톰캣 스레드가 바로 그 카운터 직원이다. 동기 방식이면 직원 한 명이 커피를 직접 만드느라 다음 손님을 못 받는다. 비동기 방식이면 직원은 주문만 받고 커피 제조는 다른 사람에게 넘긴 뒤, 바로 다음 손님을 응대한다.

sequenceDiagram participant Client as 클라이언트 participant Tomcat as 톰캣 스레드 participant Worker as 비동기 스레드 Note over Client, Tomcat: 동기 방식 Client->>Tomcat: 요청 Tomcat->>Tomcat: 5초 걸리는 작업 직접 처리 Tomcat-->>Client: 응답 (5초 후) Note over Client, Worker: 비동기 방식 Client->>Tomcat: 요청 Tomcat->>Worker: 작업 위임 Tomcat-->>Client: 응답 (즉시) Worker->>Worker: 5초 걸리는 작업 처리

@EnableAsync로 비동기 기능 켜기

Spring에서 비동기 처리를 사용하려면 먼저 스위치를 켜야 한다.

@SpringBootApplication
@EnableAsync
public class SpringAsyncPracticeApplication {
    public static void main(String[] args) {
        SpringApplication.run(SpringAsyncPracticeApplication.class, args);
    }
}
  • @EnableAsync — 이 어노테이션이 Spring의 비동기 처리를 활성화한다. 이것이 없으면 @Async를 아무리 붙여도 그냥 동기로 실행된다.
@EnableAsync를 빠뜨리는 실수

프로젝트 초기 설정에서 @EnableAsync를 빠뜨리고 "@Async가 동작하지 않는다"며 한참 헤매는 경우가 정말 많다. @Async를 쓸 계획이라면 가장 먼저 확인해야 할 항목이다.

나중에 AsyncConfig 같은 설정 클래스를 만들면, @EnableAsync를 그쪽으로 옮기는 것이 깔끔하다. 메인 클래스에 어노테이션이 쌓이는 것을 방지할 수 있다. 이 부분은 2편에서 다룬다.

@Async 메서드 만들기

비동기 기능을 켰으면 이제 서비스 메서드에 @Async를 달아보자.

동기 메서드

@Service
@Slf4j
public class CoffeeService {

    public Coffee makeCoffee(String type) {
        log.info("[{}] 커피 제조 시작: {}", Thread.currentThread().getName(), type);
        sleep(5000);
        Coffee coffee = new Coffee(type);
        log.info("[{}] 커피 제조 완료: {}", Thread.currentThread().getName(), type);
        return coffee;
    }
}
  • Thread.currentThread().getName() — 현재 코드를 실행하는 스레드의 이름을 확인한다. 동기 메서드에서는 nio-8080-exec-1 같은 톰캣 스레드 이름이 찍힌다.

비동기 메서드

@Async
public CompletableFuture<Coffee> makeCoffeeAsync(String type) {
    log.info("[{}] 커피 제조 시작: {}", Thread.currentThread().getName(), type);
    sleep(5000);
    Coffee coffee = new Coffee(type);
    log.info("[{}] 커피 제조 완료: {}", Thread.currentThread().getName(), type);
    return CompletableFuture.completedFuture(coffee);
}
  • @Async — 이 메서드가 호출되면, 호출한 스레드가 직접 실행하지 않는다. Spring이 내부적으로 프록시 객체를 만들어 호출을 가로채고, 스레드 풀에서 꺼낸 별도 스레드가 대신 실행한다.
  • CompletableFuture.completedFuture() — 비동기 작업의 결과를 감싸는 컨테이너다. 호출자는 이 Future를 통해 나중에 결과를 꺼내볼 수 있다.

로그를 찍어보면, 동기 메서드에서는 nio-8080-exec-1이 찍히지만 비동기 메서드에서는 task-1처럼 완전히 다른 스레드 이름이 찍힌다. 이것이 비동기로 동작하고 있다는 증거다.

@Async의 동작 원리 — 프록시

@Async가 어떻게 스레드를 바꿔서 실행하는 걸까? 핵심은 Spring AOP 프록시다.

Spring은 @Async가 붙은 메서드를 가진 빈을 등록할 때, 원본 객체를 직접 등록하지 않는다. 대신 원본 객체를 감싼 프록시 객체를 만들어 등록한다. 다른 빈에서 이 메서드를 호출하면, 실제로는 프록시 객체의 메서드가 호출된다. 프록시는 원본 메서드를 직접 실행하는 대신, 스레드 풀에서 스레드를 하나 꺼내 그 스레드에서 원본 메서드를 실행시킨다.

sequenceDiagram participant Controller as Controller participant Proxy as 프록시 객체 participant Executor as TaskExecutor participant Real as 실제 CoffeeService Controller->>Proxy: makeCoffeeAsync("아메리카노") Note over Proxy: @Async 감지
직접 실행하지 않음 Proxy->>Executor: 작업 제출 Proxy-->>Controller: CompletableFuture 즉시 반환 Executor->>Real: 별도 스레드에서 실행 Real-->>Executor: Coffee 객체 반환 Note over Executor: Future에 결과 저장

Controller는 프록시를 통해 메서드를 호출했고, 프록시가 즉시 CompletableFuture를 반환했기 때문에 Controller는 블로킹 없이 바로 다음 코드로 넘어갈 수 있다.

CompletableFuture 기초

@Async 메서드의 반환 타입은 크게 두 가지다.

void 반환

@Async
public void notifyCustomer(String type) {
    sleep(2000);
    log.info("[{}] 고객 알림 발송", Thread.currentThread().getName());
}

결과가 필요 없는 "실행만 하면 되는" 작업에 사용한다. 알림 발송이 대표적이다. 단, void 타입은 예외가 발생해도 호출한 쪽에서 알 수 없다는 치명적인 단점이 있다. 이 문제는 4편에서 다룬다.

CompletableFuture 반환

@Async
public CompletableFuture<Coffee> makeCoffeeAsync(String type) {
    Coffee coffee = new Coffee(type);
    return CompletableFuture.completedFuture(coffee);
}

결과를 받아야 하는 작업에 사용한다. CompletableFuture는 비동기 작업의 결과를 담는 상자라고 생각하면 된다. 호출한 쪽에서는 이 상자를 열어 결과를 꺼내거나, 여러 상자를 조합할 수 있다.

CompletableFuture의 유용한 조합 메서드 몇 가지를 알아두면 좋다.

// 두 비동기 작업의 결과를 합치기
CompletableFuture<Coffee> coffeeFuture = coffeeService.makeCoffeeAsync(type);
CompletableFuture<Boolean> paymentFuture = paymentService.processPayment("user123", 4500);

coffeeFuture.thenCombine(paymentFuture, (coffee, paymentResult) -> {
    return String.format("주문 완료: %s (결제: %s)", coffee.getType(), paymentResult ? "성공" : "실패");
});
  • thenCombine() — 두 개의 CompletableFuture가 모두 완료되면, 두 결과를 합쳐 새로운 결과를 만든다. 커피 제조와 결제 처리를 동시에 진행하고, 둘 다 끝났을 때 최종 응답을 구성하는 패턴이다.
// 여러 비동기 작업이 모두 끝날 때까지 대기
List<CompletableFuture<Coffee>> futures = new ArrayList<>();
for (int i = 0; i < 100; i++) {
    futures.add(coffeeService.makeCoffeeAsync("아메리카노"));
}
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
  • allOf() — 전달받은 모든 CompletableFuture가 완료될 때까지 기다린다.
  • join()allOf가 반환하는 CompletableFuture<Void>의 완료를 블로킹 방식으로 대기한다.

톰캣 스레드 1개로 차이 확인하기

글로만 읽으면 와닿지 않으니 실제로 확인해보자. application.yml에서 톰캣 스레드를 1개로 제한한다.

server:
  tomcat:
    threads:
      max: 1

톰캣이 딱 하나의 스레드로만 요청을 받는 극단적인 상황이다. 컨트롤러에 동기와 비동기 엔드포인트를 나란히 만들어 비교한다.

@GetMapping("/sync/{type}")
public Coffee orderCoffeeSync(@PathVariable String type) {
    return coffeeService.makeCoffee(type);
}

@GetMapping("/async/{type}")
public CompletableFuture<Coffee> orderCoffeeAsync(@PathVariable String type) {
    return coffeeService.makeCoffeeAsync(type);
}

동기 방식의 결과

/sync/아메리카노를 동시에 2번 호출하면, 첫 번째 요청이 5초간 스레드를 잡고 있는 동안 두 번째 요청은 아무것도 시작하지 못한 채 대기한다. 총 10초가 걸린다.

비동기 방식의 결과

/async/아메리카노를 동시에 2번 호출하면, 톰캣 스레드는 각 요청을 @Async 메서드에 위임하고 즉시 해방된다. 두 잔의 커피가 별도 스레드에서 동시에 만들어지므로 약 5초면 끝난다.

스레드가 1개뿐인데도 여러 요청을 동시에 처리할 수 있다는 것이 @Async의 핵심 가치다.

자주 하는 실수

같은 클래스 내부에서 @Async 호출 (자가 호출 문제)

@Service
public class OrderService {

    private final NotificationService notificationService;

    public void processOrder(String type) {
        log.info("[{}] 주문 처리 시작", Thread.currentThread().getName());
        notificationService.notifyCustomer(type);
        log.info("[{}] 주문 처리 완료", Thread.currentThread().getName());
    }
}

위 코드에서 notificationService.notifyCustomer()는 다른 빈의 메서드이므로 비동기가 정상 동작한다. 하지만 만약 notifyCustomer()가 같은 클래스 안에 있었다면?

@Async는 프록시 기반이다. 같은 클래스 내에서 this.메서드()로 호출하면 프록시를 거치지 않고 원본 객체의 메서드가 직접 실행된다. 비동기가 적용되지 않는다. 비동기가 필요한 메서드는 반드시 다른 빈에서 호출해야 한다.

sequenceDiagram participant Controller as Controller participant Proxy as OrderService 프록시 participant Real as OrderService 원본 Note over Controller, Real: 다른 빈에서 호출 (정상) Controller->>Proxy: notifyCustomer() Proxy->>Real: 비동기 스레드에서 실행 Note over Controller, Real: 같은 클래스 내부 호출 (문제!) Real->>Real: this.notifyCustomer() Note over Real: 프록시를 거치지 않음
동기로 실행됨

void 반환 메서드의 예외 소실

@Async 메서드가 void를 반환하면, 그 메서드 안에서 예외가 터져도 호출한 쪽에서 전혀 알 수 없다. 로그도 없이 조용히 삼켜진다. 이 문제의 해결책은 4편에서 자세히 다룬다.

정리

핵심 요약

- @EnableAsync로 비동기 기능을 활성화하고, @Async로 메서드를 비동기 처리한다.

- @Async 메서드는 프록시를 통해 별도 스레드에서 실행된다.

- 반환 타입은 void 또는 CompletableFuture를 사용한다. void는 예외를 감지할 수 없으니 주의해야 한다.

- 같은 클래스 내부에서 @Async 메서드를 호출하면 비동기가 동작하지 않는다 (자가 호출 문제).

지금까지 @Async의 기본 동작을 살펴봤다. 하지만 @Async만 달면 Spring이 기본으로 SimpleAsyncTaskExecutor라는 녀석을 사용하는데, 이 녀석은 스레드 풀이 아니다. 요청이 올 때마다 새 스레드를 만들고 재사용하지 않는다. 실무에서 이대로 쓰면 서버가 죽을 수 있다. 다음 편에서 제대로 된 스레드 풀을 설정하는 방법을 다룬다.