백엔드/Spring

Spring backend 비동기 처리 정리 (Async)

Sophie_ 2025. 6. 15. 13:41

백엔드 개발을 하다 보면 “이건 굳이 기다릴 필요 없는데?” 싶은 작업들이 있다.
예를 들어 외부 API 호출, 이메일 발송, 이미지 처리, 대용량 작업 등이 대표적이다.
이런 작업들을 비동기로 돌리면 응답 속도도 빨라지고 서버 자원도 효율적으로 쓸 수 있다.

이번 글에서는 Spring Boot에서 쓸 수 있는 비동기 처리 방법들을 정리해본다.
각 방식의 개념, 실전 예제, 언제 쓰는 게 좋은지, 성능 비교 방법,
그리고 Java에서 async/await처럼 처리하는 방법까지 다뤘다.
마지막엔 @Async + CompletableFuture를 조합한 실전 예제도 참고.


1. @Async — 가장 간단한 Spring 비동기 처리

@Async
public CompletableFuture<String> process() {
    // 이 코드는 별도 쓰레드에서 실행됨
    return CompletableFuture.completedFuture("작업 완료");
}

// AsyncConfig.class Example
@Configuration
@EnableAsync
public class AsyncConfig {
    // Optional: TaskExecutor 커스터마이징
    @Bean
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("Async-");
        executor.initialize();
        return executor;
    }
}
  • @EnableAsync 설정이 필요하다. (설정 클래스에 붙이거나, SpringBootApplication class에 바로 붙이거나)
  • @Async는 @Component, @Service 같은 Bean 클래스에서만 동작
  • 프록시 기반이라서 같은 클래스 내에서 자기 자신을 호출하면 적용 안 됨
  • CompletableFuture로 결과를 리턴하면 .get()이나 .join()으로 기다릴 수 있다.
  • 내부 메서드에서 자기 자신을 호출하면 프록시 적용이 안 돼서 비동기 동작하지 않음.

2. CompletableFuture — Java 표준의 유연한 방식

Java 8부터 제공되는 비동기 API다. 단순한 .get()뿐 아니라 병렬 처리, 체이닝, 예외 처리, 스레드 제어 등 실무에서 많이 쓰인다.

CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> "a");
CompletableFuture<String> f2 = CompletableFuture.supplyAsync(() -> "b");

CompletableFuture.allOf(f1, f2).join();
String result = f1.get() + f2.get();

👉 CompletableFuture 자세히 보기 (개념, 생성방법, 결과받기, 체이닝 등)

✅ 기본 개념

  • CompletableFuture<String>는 미래에 계산이 완료될 String을 비동기적으로 다루는 객체
  • Future의 한계를 극복하고, 체이닝, 예외처리, 조합 등 다양한 기능 제공

✅ 생성 방법

메서드 설명
supplyAsync(Supplier) 리턴값 있는 비동기 작업 시작
runAsync(Runnable) 리턴값 없는 비동기 작업
CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> "hello");
CompletableFuture<Void> f2 = CompletableFuture.runAsync(() -> doSomething());

✅ 결과 받기 (await 개념)

String result = future.get();   // checked 예외 발생
String result = future.join();  // unchecked 예외 (실무에서 많이 사용)
  • .get()은 InterruptedException, ExecutionException을 던짐
  • .join()은 런타임 예외 (CompletionException)로 감싸서 던짐

✅ 체이닝 (thenApply / thenApplyAsync)

CompletableFuture<String> future = CompletableFuture
    .supplyAsync(() -> "A")
    .thenApply(s -> s + "B")
    .thenApply(s -> s + "C");  // 결과: ABC
  • thenApply: 동기 실행
  • thenApplyAsync: 별도 쓰레드에서 비동기 실행

✅ 또 다른 비동기 연결 (thenCompose vs thenApply)

목적 메서드 설명
값 변환 thenApply 값만 변환
비동기 연결 thenCompose 비동기 메서드 체이닝
CompletableFuture<String> f = getUserId()
    .thenCompose(userId -> fetchUserProfile(userId));

✅ 여러 작업 병렬 처리

allOf: 모든 작업 완료 대기

CompletableFuture.allOf(f1, f2).join();

thenCombine: 두 개 결과 조합

f1.thenCombine(f2, (a, b) -> a + "-" + b);

anyOf: 가장 먼저 끝난 작업만 받기

CompletableFuture.anyOf(f1, f2).thenAccept(System.out::println);

✅ 예외 처리

future.exceptionally(ex -> "기본값"); // 예외 발생 시 대체값

future.handle((result, ex) -> ex != null ? "실패" : result); // 정상/에러 모두 처리

future.whenComplete((result, ex) -> log.info("끝남"));

✅ 타임아웃 처리

future.orTimeout(1, TimeUnit.SECONDS)               // 시간 초과되면 예외 발생
      .completeOnTimeout("타임아웃 기본값", 1, SECONDS); // 시간 초과되면 기본값 사용

✅ 커스텀 스레드풀 사용

ExecutorService executor = Executors.newFixedThreadPool(10);

CompletableFuture.supplyAsync(() -> "hello", executor);

✅ 실무에서 자주 쓰는 패턴

외부 API 병렬 호출

CompletableFuture<String> user = fetchUser();
CompletableFuture<String> post = fetchPost();

CompletableFuture.allOf(user, post).join();
String result = user.get() + post.get();

비동기 체이닝 + 예외 처리

CompletableFuture.supplyAsync(() -> callApi())
    .thenApply(this::parse)
    .exceptionally(ex -> "에러 시 기본값")
    .join();

✅ 테스트 팁

  • .join()은 예외가 unchecked라 테스트에서 유용
  • awaitility, StepVerifier, mock WebClient 등과도 잘 연동됨

CompletableFuture<String>는 단순히 미래의 문자열을 담는 게 아니라,
실제 실무에서 API 병렬 호출, 응답 조합, 에러 복구, 결과 파이프라인을 구성할 때 매우 유용하게 쓰이는 도구다.

적절하게 쓰면 성능과 응답 속도를 한층 개선할 수 있다.


3. ExecutorService — 직접 스레드풀 제어

ExecutorService executor = Executors.newFixedThreadPool(10);
Future<String> result = executor.submit(() -> "결과");
String value = result.get();  // await 느낌
  • ExecutorService를 직접 만들고 submit()으로 비동기 작업 등록
  • .get()으로 결과를 기다릴 수 있음
  • shutdown을 안 하면 memory leak 위험 있음 → @PreDestroy에서 shutdown() 호출 권장

✅ 실무에서 async/await 스타일이 필요한 경우

예시 1. 외부 API 병렬 호출 후 결과 병합

CompletableFuture<String> f1 = service.fetchFromServiceA();
CompletableFuture<String> f2 = service.fetchFromServiceB();

CompletableFuture.allOf(f1, f2).join();
String result = f1.get() + f2.get();
  • 순차 호출 시 2초 걸리던 작업이 병렬 호출로 1초 내외로 단축 가능
  • 사용자에게 빠른 응답 제공

예시 2. 파일 업로드 후 OCR 결과 기다리기

@PostMapping("/upload")
public String handleUpload(@RequestParam MultipartFile file) throws Exception {
    byte[] bytes = file.getBytes();
    CompletableFuture<String> ocr = CompletableFuture.supplyAsync(() -> callOcrApi(bytes));
    return "결과: " + ocr.get();
}

✅ Java에서 await을 구현하는 방법

Java에는 await 키워드가 없지만, 비슷한 동작은 다음으로 가능:

Java 동작 의미
future.get() await 동기 대기 (checked 예외)
future.join() await 동기 대기 (unchecked 예외)
.allOf(...).join() 여러 작업 병렬 실행 후 기다리기
Kotlin에서는 suspend fun과 await() 사용 가능

👉 비동기 처리 흐름 설명 접기/펼치기

예시 3. Spring Boot에서 비동기 로직 처리 흐름 예시 (AsyncConfig + @Async + CompletableFuture)

Spring Boot에서 @Async와 CompletableFuture를 조합하면, 여러 개의 비동기 작업을 병렬로 처리하고 결과를 한 번에 모을 수 있다. 여기에 AsyncConfig를 통해 명시적인 스레드풀을 구성하면 운영 환경에 맞는 조정이 가능하다.


1. 비동기 설정 클래스: AsyncConfig

먼저 @EnableAsync로 비동기 기능을 활성화하고, ThreadPoolTaskExecutor를 직접 빈으로 등록한다. 이 스레드풀이 실제 비동기 작업들을 처리하게 된다.

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean(name = "taskExecutor")
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("AsyncExecutor-");
        executor.initialize();
        return executor;
    }
}

2. 비동기 작업을 수행하는 서비스: AsyncUserService

@Async를 메서드에 붙이면 별도 쓰레드에서 작업이 실행된다. 반환 타입이 CompletableFuture인 경우, 작업 완료 후 결과를 받을 수 있다.

@Service
public class AsyncUserService {

    @Async("taskExecutor")
    public CompletableFuture<String> getUserInfo(String userId) {
        return CompletableFuture.completedFuture("UserInfo");
    }

    @Async("taskExecutor")
    public CompletableFuture<String> getOrderSummary(String userId) {
        return CompletableFuture.completedFuture("OrderSummary");
    }

    @Async("taskExecutor")
    public CompletableFuture<String> getNotificationSetting(String userId) {
        return CompletableFuture.completedFuture("NotificationSetting");
    }
}

3. 여러 비동기 작업을 병렬 처리하는 퍼사드 서비스: UserFacadeService

CompletableFuture.allOf(...).join()을 사용해 모든 비동기 작업이 완료될 때까지 기다린다. 이후 각 작업의 결과를 조합해 최종 응답을 구성한다.

@Service
public class UserFacadeService {

    private final AsyncUserService asyncUserService;

    public UserFacadeService(AsyncUserService asyncUserService) {
        this.asyncUserService = asyncUserService;
    }

    public String getUserProfileSummary(String userId) {
        CompletableFuture<String> infoFuture = asyncUserService.getUserInfo(userId);
        CompletableFuture<String> orderFuture = asyncUserService.getOrderSummary(userId);
        CompletableFuture<String> notiFuture = asyncUserService.getNotificationSetting(userId);

        CompletableFuture.allOf(infoFuture, orderFuture, notiFuture).join();

        return Stream.of(infoFuture, orderFuture, notiFuture)
                .map(CompletableFuture::join)
                .collect(Collectors.joining(" | "));
    }
}

4. 전체 실행 흐름 요약

  1. UserFacadeService.getUserProfileSummary() 호출
  2. 내부에서 AsyncUserService의 3가지 비동기 메서드 호출
  3. 각 메서드는 @Async에 의해 별도 스레드에서 병렬 실행됨
  4. CompletableFuture.allOf(...).join()으로 모든 작업 완료 대기
  5. 모든 결과를 join()으로 꺼내서 조합 후 반환

5. 테스트 예시

@Test
void getUserProfileSummary_성공() throws Exception {
    long start = System.currentTimeMillis();

    String result = userFacadeService.getUserProfileSummary("hong");

    long end = System.currentTimeMillis();

    System.out.println("실행 결과: " + result);
    System.out.println("실행 시간: " + (end - start) + "ms");

    assertThat(result).contains("UserInfo", "OrderSummary", "NotificationSetting");
    assertThat(end - start).isLessThan(400); // 병렬로 잘 동작했는지 검증
}

참고

  • @Async가 동작하려면 @EnableAsync와 별도 스레드풀 설정이 필요
  • CompletableFuture.allOf().join()은 병렬 처리의 동기화 지점
  • 테스트할 때는 실행 시간 측정을 통해 병렬 여부를 확인하는 것이 좋음
---

✅ 정리

방식 특징 용례
@Async 간편하게 비동기 실행 간단한 서비스 비동기 호출
CompletableFuture 조합/에러 처리 유연 외부 API 병렬 호출, 조합
ExecutorService 직접 제어 배치성 작업, 리소스 관리 직접
WebFlux 완전한 논블로킹 트래픽 큰 서비스, SSE 등
Reactor 세밀한 흐름 제어 복잡한 reactive 시나리오
@Async + WebClient Spring 기반 고급 병렬 처리 실무 API 연동에서 적합

비동기 처리는 무조건 써야 하는 게 아니라,
"굳이 기다리지 않아도 되는 작업"에만 선택적으로 써야 효과가 있다.
Spring Boot + @Async + WebClient 조합만 잘 써도 대부분의 실무 요구사항은 해결 가능하다고 함.