자바 백엔드 입문 39편. @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(...)패턴 - 자바 백엔드 = 매일 비동기 다루는 일
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 34편 — Bean Validation @Valid @NotNull
- 35편 — 커스텀 Validator 만들기
- 36편 — Logback SLF4J 로깅
- 37편 — Spring Security 기초
- 38편 — Spring ApplicationEvent @EventListener
다음 글: