자바 백엔드 입문 39편 — Spring @Async CompletableFuture 비동기

2026-05-17자바 백엔드 입문

자바 백엔드 입문 39편. @Async와 CompletableFuture로 자바 비동기 처리하는 표준 패턴을 카페 주문 비유로 풀어쓴 학습 노트.

📚 자바 백엔드 입문 · 39편 — Spring @Async CompletableFuture 비동기

이 글은 자바 백엔드 입문 시리즈 59편 중 39편이에요. 38편 ApplicationEvent 에서 잠시 등장한 @Async"별도 스레드에서 비동기 실행" 의 표준 패턴을 풀어 가요.

비동기가 필요한 순간

API 응답이 느린 이유 — 한 요청 안에서 외부 API 3곳·이메일 1개·SMS 1개 호출하면 합쳐서 2~5초.

@Service
public class OrderService {

    public OrderResponse complete(Long orderId) {
        Order order = repository.findById(orderId).orElseThrow();
        order.complete();

        emailService.send(order);              // 500ms
        smsService.send(order);                // 800ms
        analyticsService.track(order);         // 300ms

        return OrderResponse.from(order);       // 합쳐서 1.6초
    }
}

부가 작업이 "응답 안에 동기로 박혀" 클라이언트가 1.6초 대기. 해결 = 비동기. 부가 작업은 별도 스레드로 보내고 핵심 응답만 즉시.

카페 주문 비유

비동기 = "카페 주문대 직원이 음료 만들고 있는 동안 다음 손님 주문 받기". 직원이 음료 만들 때까지 멈추면 카페 망함.

자바 비동기 두 가지 도구: - @Async — Spring이 자동으로 별도 스레드에서 메서드 실행 - CompletableFuture — 자바 8+ 비동기 결과 객체

둘은 같이 자주 씁니다.

@Async — 가장 쉬운 비동기

1단계 — 활성화

@SpringBootApplication
@EnableAsync                          // ← 비동기 활성화
public class MyShopApplication { ... }

2단계 — 메서드에 @Async

@Service
public class EmailService {

    @Async
    public void send(Order order) {
        // 500ms 걸리는 작업
        emailClient.send(order.getEmail(), buildMail(order));
    }
}

3단계 — 호출

@Service
@RequiredArgsConstructor
public class OrderService {

    private final EmailService emailService;

    public OrderResponse complete(Long orderId) {
        Order order = repository.findById(orderId).orElseThrow();
        order.complete();

        emailService.send(order);              // ← 즉시 반환 (별도 스레드에서 진행)
        smsService.send(order);                // ← 즉시 반환
        analyticsService.track(order);          // ← 즉시 반환

        return OrderResponse.from(order);       // 1.6초 → 50ms
    }
}

이 한 줄(@Async)이 카페 직원을 "음료 제작 별도 알바" 로 자동 분리.

@Async 자가 호출 함정

@Async 는 Spring 프록시 기반. 같은 클래스 안에서 자기 자신을 호출하면 비동기 적용 X.

@Service
public class MyService {

    public void caller() {
        send(...);    // ❌ 비동기 X — 같은 인스턴스 메서드 직접 호출
    }

    @Async
    public void send(...) { ... }
}

이유 — @Async"외부 호출자 → Spring 프록시 → 실제 객체" 흐름에서만 동작. 자기 자신 호출은 프록시를 거치지 않아 그냥 동기.

해결 — 비동기 메서드는 별도 빈으로 분리.

CompletableFuture — 비동기 결과

@Async 메서드가 "결과를 반환해야 할 때"void 가 아닌 CompletableFuture<T> 반환.

@Async
public CompletableFuture<UserProfile> fetchUser(Long id) {
    UserProfile profile = externalApi.getUser(id);    // 200ms
    return CompletableFuture.completedFuture(profile);
}

호출자는 CompletableFuture 를 받아서 — 결과가 준비됐을 때 처리.

여러 비동기 결과 조합

진짜 비동기의 매력 — 여러 비동기를 병렬 실행 후 합치기.

@Service
@RequiredArgsConstructor
public class DashboardService {

    private final UserApi userApi;
    private final OrderApi orderApi;
    private final PaymentApi paymentApi;

    public Dashboard fetch(Long userId) {
        CompletableFuture<User> userFuture = userApi.getUserAsync(userId);            // 200ms
        CompletableFuture<List<Order>> orderFuture = orderApi.getOrdersAsync(userId); // 300ms
        CompletableFuture<List<Payment>> paymentFuture = paymentApi.getPaymentsAsync(userId); // 400ms

        // 세 개 다 끝날 때까지 대기
        CompletableFuture.allOf(userFuture, orderFuture, paymentFuture).join();

        return new Dashboard(
                userFuture.join(),
                orderFuture.join(),
                paymentFuture.join());     // 합쳐서 400ms (가장 느린 거 + α)
    }
}

직렬로 호출하면 900ms — 병렬로 호출하면 400ms. 한국 회사 백엔드의 자주 보는 패턴.

CompletableFuture 주요 메서드

메서드 의미
supplyAsync(supplier) 비동기 시작 (값 반환)
runAsync(runnable) 비동기 시작 (void)
thenApply(fn) 결과 변환 (T → R)
thenAccept(c) 결과 소비 (void)
thenCompose(fn) 비동기 체이닝 (Optional의 flatMap과 비슷)
thenCombine(other, fn) 두 비동기 결과 합치기
exceptionally(fn) 예외 처리
handle(fn) 결과·예외 둘 다 처리
join() 결과 대기 (블로킹)
get() 결과 대기 (체크드 예외)
allOf(...) 모두 끝날 때까지 대기
anyOf(...) 가장 빠른 거

비동기 체이닝

Optional.map(...).orElse(...) 처럼 — CompletableFuture 도 체이닝.

CompletableFuture.supplyAsync(() -> fetchUser(userId))           // 비동기 시작
    .thenApply(user -> enrich(user))                              // 변환
    .thenCompose(user -> fetchOrdersAsync(user.getId()))          // 비동기 체이닝
    .thenAccept(orders -> log.info("loaded {}", orders.size()))   // 소비
    .exceptionally(e -> {                                          // 예외 처리
        log.error("실패", e);
        return null;
    });

콜백 지옥 없이 — 함수형 체이닝으로 비동기 흐름 표현.

스레드 풀 설정 — 운영에 필수

@Async 기본 동작 = SimpleAsyncTaskExecutor"매번 새 스레드 생성". 요청 폭주 시 OOM 위험. 반드시 스레드 풀 명시.

@Configuration
public class AsyncConfig {

    @Bean(name = "taskExecutor")
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);                 // 평시 스레드 수
        executor.setMaxPoolSize(50);                  // 최대 스레드 수
        executor.setQueueCapacity(100);               // 큐 크기
        executor.setThreadNamePrefix("async-");
        executor.initialize();
        return executor;
    }
}

이렇게 박으면 @Async 가 자동으로 이 풀 사용. 운영 환경 필수.

여러 풀 분리도 가능:

@Bean("emailExecutor")
public Executor emailExecutor() { ... }

@Async("emailExecutor")
public void sendEmail(...) { ... }

이메일 풀과 SMS 풀 분리 — 한쪽이 막혀도 다른 쪽 영향 X.

비동기 예외 처리

@Async void 메서드의 예외는 — 호출자가 받을 수 없음. 별도 핸들러 박기.

@Configuration
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (throwable, method, params) -> {
            log.error("Async error in {}: {}", method.getName(), throwable.getMessage(), throwable);
        };
    }
}

CompletableFuture 반환이면 — .exceptionally(...) 또는 .handle(...) 로 처리.

함정 5가지

(1) 자가 호출 비동기 X

위에서 다뤘듯이 — 같은 클래스 메서드 직접 호출은 동기. 별도 빈으로 분리.

(2) @Transactional + @Async 조합 주의

@Async
@Transactional
public void process() { ... }

이건 작동은 해요. 다만 — "비동기 스레드의 새 트랜잭션" 이라 발행자 트랜잭션과 무관. 발행자가 롤백돼도 비동기 처리는 그대로. 이게 맞는지 비즈니스 의도 확인 필요.

(3) 영속성 컨텍스트 분리

47편 영속성 컨텍스트 에서 다루지만 — @Async 메서드는 "호출자의 영속성 컨텍스트" 와 별개. LazyLoading 시도하면 LazyInitializationException 폭발.

해결 — 비동기 메서드 매개변수에 필요한 데이터 미리 박기 (DTO·ID로).

(4) 무한 스레드 위험

기본 SimpleAsyncTaskExecutor = 매번 새 스레드. 1초에 1000요청 들어오면 1000 스레드 — OOM. 반드시 ThreadPoolTaskExecutor 박기.

(5) Thread.sleep() 으로 흉내내기

// ❌ 절대 X
Thread.sleep(5000);

// ✅ CompletableFuture 사용
CompletableFuture.delayedExecutor(5, TimeUnit.SECONDS).execute(() -> ...);

Spring·JPA에서 자주

// 외부 API 병렬 호출 (마이크로서비스 표준)
public Dashboard fetch(Long userId) {
    CompletableFuture<User> user = userApi.getUserAsync(userId);
    CompletableFuture<List<Order>> orders = orderApi.getOrdersAsync(userId);
    return new Dashboard(user.join(), orders.join());
}

// 부가 작업 비동기 (38편 ApplicationEvent 조합)
@Async
@TransactionalEventListener(AFTER_COMMIT)
public void onOrderCompleted(OrderCompletedEvent event) {
    emailService.send(event.orderId());
}
⚠️ 운영 필수 체크리스트

@EnableAsync 박았다면 — 반드시 ThreadPoolTaskExecutor 명시. 기본 풀이 매번 새 스레드 생성이라 OOM 위험. 풀 사이즈는 CPU 코어 × 2 ~ 외부 API 응답 시간 고려해 설정.

한 줄 정리 — @Async + CompletableFuture = 자바 비동기 표준. 부가 작업 분리·외부 API 병렬 호출에 필수. 스레드 풀 명시·자가 호출 회피·영속성 컨텍스트 분리 주의. 38편 ApplicationEvent 조합이 한국 회사 표준.

시험 직전 한 번 더 — @Async 입문자가 매번 헷갈리는 것

  • @Async = 메서드를 별도 스레드에서 실행
  • 활성화 = @EnableAsync (메인 클래스)
  • 자가 호출 비동기 X — 같은 클래스 직접 호출은 동기 (별도 빈으로 분리)
  • 반환 = void 또는 CompletableFuture<T>
  • 기본 풀 = SimpleAsyncTaskExecutor (운영 X — OOM 위험)
  • 운영 = ThreadPoolTaskExecutor 명시 (필수)
  • 풀 분리 = @Async("emailExecutor") 처럼 이름 지정
  • CompletableFuture = 비동기 결과 객체 (자바 8+)
  • 시작 = supplyAsync(supplier)·runAsync(runnable)
  • 변환 = thenApply(fn)
  • 비동기 체이닝 = thenCompose(fn)
  • 두 개 합치기 = thenCombine(other, fn)
  • 모두 대기 = allOf(...)
  • 결과 대기 = join() 또는 get()
  • 예외 처리 = exceptionally(fn)·handle(fn)
  • @Async void 예외 = AsyncUncaughtExceptionHandler
  • 영속성 컨텍스트 분리 — LazyLoading X (데이터 미리 박기)
  • @Transactional + @Async = 별도 트랜잭션 (의도 확인 필수)
  • 38편 ApplicationEvent 조합 = @Async + @TransactionalEventListener(AFTER_COMMIT) 한국 회사 표준
  • 외부 API 병렬 호출 = CompletableFuture.allOf(...) 패턴
  • 자바 백엔드 = 매일 비동기 다루는 일

시리즈 다른 편 (앞뒤 글 모음)

이전 글:

다음 글:

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!