동기와 비동기, 블로킹과 논블로킹을 호출 흐름으로 구분하기
빠른 답
- 동기와 비동기는 결과를 호출 흐름 안에서 기다릴지, 나중에 별도 흐름으로 받을지의 차이다.
- 블로킹과 논블로킹은 호출한 스레드가 멈춰서 기다리는지, 제어권을 돌려받아 다음 일을 할 수 있는지의 차이다.
- 비동기 API를 써도 결과를 바로
get()이나join()으로 기다리면 다시 블로킹 구간이 생긴다. - Spring
@Async는 프록시와 Executor 위에서 동작하므로 내부 호출, 예외, 트랜잭션, 스레드풀 포화 상태를 함께 봐야 한다.
목차
한눈에 비교
시간 흐름으로 이해하기
왜 헷갈릴까
동기와 블로킹은 자주 함께 나타납니다. 예를 들어 서버 코드에서 JDBC로 데이터베이스를 조회하면 호출자는 조회 결과가 필요하고, 요청 스레드는 DB 응답이 올 때까지 기다립니다. 이 상황은 동기이면서 블로킹입니다. 그래서 두 단어가 같은 뜻처럼 쓰이기 쉽습니다.
하지만 두 개념은 보는 축이 다릅니다. 동기와 비동기는 “결과를 어느 흐름에서 처리하느냐”에 가깝고, 블로킹과 논블로킹은 “현재 실행 스레드가 멈추느냐”에 가깝습니다. @Async를 붙였다고 해서 I/O가 논블로킹이 되는 것은 아니고, 논블로킹 API를 쓴다고 해서 결과 처리와 오류 처리가 자동으로 단순해지는 것도 아닙니다.
언어 런타임 관점에서는 값과 상태의 의미도 달라집니다. 동기 함수는 이미 계산된 값을 반환하거나 예외를 던집니다. 비동기 함수는 아직 값이 없을 수 있으므로 “나중에 완료될 상태”를 반환합니다. Java의 CompletableFuture는 정상 완료, 예외 완료, 취소, 미완료 상태를 모두 표현합니다.
동기와 블로킹이 항상 같은 말은 아니다
동기 블로킹은 가장 익숙한 조합입니다. 일반 메서드 호출, 파일 읽기, JDBC 조회, 외부 HTTP 호출을 요청 스레드에서 바로 기다리는 코드가 여기에 가깝습니다. 호출 스택이 이어져 있어서 반환값과 예외 흐름을 읽기 쉽지만, 대기 시간이 길어지면 현재 스레드를 오래 붙잡습니다.
동기 논블로킹도 가능합니다. 예를 들어 논블로킹 채널에서 읽기를 시도했는데 아직 데이터가 없어서 즉시 0이나 “읽을 수 없음” 상태를 반환할 수 있습니다. 호출자는 지금 시점의 상태를 직접 확인하므로 동기적으로 판단하지만, 스레드는 멈추지 않습니다.
비동기 블로킹도 흔합니다. Spring @Async로 메일 발송을 별도 스레드에 넘기면 HTTP 요청 스레드는 먼저 반환될 수 있습니다. 그러나 메일 발송 스레드가 SMTP 서버 응답을 기다린다면 그 작업 스레드는 블로킹됩니다. 호출자 입장에서는 비동기지만, 작업 내부는 블로킹인 셈입니다.
비동기 논블로킹은 높은 동시성 I/O에 유리하지만, 후속 처리 위치와 오류 전파, 취소, 타임아웃, 컨텍스트 전달이 더 복잡해질 수 있습니다. 그래서 비동기는 “작업이 빨라진다”보다 “기다리는 위치와 결과를 받는 방식을 바꾼다”에 가깝습니다.
코드와 출력으로 보는 실행 시점
아래 예시는 동기 호출과 비동기 호출의 로그 순서를 비교합니다. 동기 호출은 blockingCall()이 끝나야 다음 줄로 넘어가고, 비동기 호출은 CompletableFuture를 먼저 받은 뒤 후속 작업을 이어갑니다.
import java.time.LocalTime;
import java.util.concurrent.CompletableFuture;
public class FlowDemo {
public static void main(String[] args) {
log("sync start");
String syncResult = blockingCall();
log("sync result=" + syncResult);
log("async start");
CompletableFuture<String> future = asyncCall();
log("after async call, done=" + future.isDone());
future.thenAccept(result -> log("callback result=" + result));
log("main can do other work");
sleep(700);
}
static String blockingCall() {
sleep(500);
return "sync-result";
}
static CompletableFuture<String> asyncCall() {
return CompletableFuture.supplyAsync(() -> {
sleep(500);
log("async work finished");
return "async-result";
});
}
static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(e);
}
}
static void log(String message) {
System.out.printf("%s [%s] %s%n",
LocalTime.now(), Thread.currentThread().getName(), message);
}
}
실행 환경에 따라 시간 값은 달라지지만, after async call이 비동기 작업 완료보다 먼저 찍히는 순서는 유지됩니다.
10:15:00.001 [main] sync start
10:15:00.506 [main] sync result=sync-result
10:15:00.507 [main] async start
10:15:00.510 [main] after async call, done=false
10:15:00.511 [main] main can do other work
10:15:01.012 [ForkJoinPool.commonPool-worker-1] async work finished
10:15:01.013 [ForkJoinPool.commonPool-worker-1] callback result=async-result
여기서 비동기 호출 직후 future.join()을 호출하면 main 스레드는 결과가 올 때까지 기다립니다. 비동기 작업을 시작했다는 사실과 결과를 소비하는 지점에서 블로킹이 생기는지는 분리해서 봐야 합니다.
값, 상태, 오류의 의미
동기 코드는 반환값과 예외가 호출 스택에 직접 묶입니다. String result = call()은 성공하면 값이 들어오고, 실패하면 그 줄에서 예외가 던져집니다. 호출자는 같은 흐름 안에서 try-catch로 오류를 다룰 수 있습니다.
비동기 코드는 반환값이 실제 결과가 아니라 결과를 나타내는 상태 객체일 때가 많습니다. CompletableFuture<T>는 “이미 값이 있다”가 아니라 “언젠가 T로 완료되거나 실패할 수 있다”는 의미를 가집니다. Java API 문서도 CompletableFuture를 Future이자 CompletionStage로 설명하며, 완료 후 후속 단계를 연결할 수 있는 타입으로 다룹니다. 참고: Java CompletableFuture API
- 값의 의미: 동기 반환값은 계산이 끝난 값이고, 비동기 반환값은 완료를 추적하는 핸들이다.
- 상태의 의미: 비동기는 미완료, 정상 완료, 예외 완료, 취소 상태를 함께 다룬다.
- 오류의 의미: 동기 오류는 호출 지점에서 보이고, 비동기 오류는
get(),join(),whenComplete(),exceptionally()같은 지점에서 드러난다. - 예외 타입의 의미:
get()은 실패를ExecutionException으로 감싸고,join()은CompletionException계열의 unchecked 예외로 드러낸다. - 후속 처리의 의미:
thenApply(),thenAccept()는 완료 스레드에서 실행될 수 있고,thenApplyAsync()처럼 Executor를 지정하면 다른 실행 위치를 선택할 수 있다.
이 차이는 HTTP 응답과 비동기 작업을 분리할 때 중요합니다. 컨트롤러가 비동기 작업을 시작하고 이미 응답을 보냈다면, 이후 작업의 실패를 같은 HTTP 응답으로 되돌리기 어렵습니다. 실패 로그, 재시도, 보상 처리, 상태 저장, 알림 같은 흐름이 별도로 필요해집니다.
현재 기준 버전과 마이그레이션 포인트
2026년 4월 기준 Spring의 현재 안정 문서 흐름은 Spring Framework 7.0.x와 Spring Boot 4.0.x를 기준으로 볼 수 있습니다. Spring 프로젝트 페이지에는 Spring Framework 7.0.6, Spring Boot 4.0.5가 현재 릴리스로 안내되어 있습니다. 참고: Spring Framework, Spring Boot
오래된 Spring 4.x, 5.x 글에서는 @Async 반환 타입 예시로 ListenableFuture와 AsyncResult가 자주 등장합니다. 하지만 Spring 6.0부터 ListenableFuture는 CompletableFuture 사용을 권장하는 방향으로 deprecated 처리되었고, AsyncResult도 같은 흐름에 있습니다. 새 코드라면 ListenableFuture.addCallback() 대신 CompletableFuture.whenComplete(), handle(), exceptionally()를 우선 검토하는 편이 현재 API 흐름에 맞습니다. 참고: Spring ListenableFuture API, Spring Async API
Java 21 이후에는 가상 스레드도 함께 언급됩니다. Spring Boot는 spring.threads.virtual.enabled=true 설정으로 가상 스레드를 사용할 수 있습니다. 다만 가상 스레드는 논블로킹 I/O와 같은 개념이 아닙니다. 블로킹 코드를 더 많은 경량 스레드로 감당하기 쉽게 만드는 모델에 가깝고, 특정 동기화 구간이나 네이티브 호출에서는 pinned virtual thread 문제가 성능에 영향을 줄 수 있습니다. 참고: OpenJDK JEP 444 Virtual Threads, Spring Boot Task Execution and Scheduling
Spring Async 설정과 반환 타입
Spring에서 @Async를 사용하려면 @EnableAsync가 필요합니다. Spring Boot는 별도 Executor Bean이 없을 때 AsyncTaskExecutor를 자동 구성합니다. 운영 코드에서는 스레드 이름, 풀 크기, 큐 크기, 거부 정책이 드러나야 로그와 지표를 해석하기 쉽습니다.
아래 YAML은 Spring Boot가 자동 구성하는 작업 Executor 기준 예시입니다. 별도의 mailExecutor 같은 Executor Bean을 직접 만들면 그 Bean 설정이 우선합니다.
spring:
task:
execution:
thread-name-prefix: "async-"
pool:
core-size: 4
max-size: 16
queue-capacity: 100
keep-alive: "10s"
---
spring:
config:
activate:
on-profile: virtual-threads
threads:
virtual:
enabled: true
@Async 메서드는 void, Future, CompletableFuture 계열 반환을 사용할 수 있습니다. 실패를 호출자가 알아야 한다면 void보다 CompletableFuture가 다루기 쉽습니다. void 반환 메서드에서 발생한 예외는 호출자에게 직접 전파되지 않고, AsyncUncaughtExceptionHandler 또는 기본 로그로 관찰됩니다.
내부 호출, 예외, 트랜잭션 함정
@Async는 기본적으로 프록시 기반 AOP로 동작합니다. 외부 Bean이 Spring 프록시를 통해 호출할 때 Executor 제출이 일어나지만, 같은 클래스 안에서 this.someAsyncMethod()로 호출하면 프록시를 지나지 않습니다. 이 경우 @Async가 붙은 메서드라도 현재 스레드에서 그대로 실행됩니다.
아래 코드는 내부 호출과 다른 Bean 호출의 차이를 일부러 드러낸 예시입니다. sendReceipt()는 같은 클래스 내부 호출이라 요청 스레드에서 실행되고, writeAudit()는 다른 Bean 프록시를 통해 호출되므로 mailExecutor에서 실행될 수 있습니다.
@Configuration(proxyBeanMethods = false)
@EnableAsync
class AsyncConfig {
@Bean("mailExecutor")
ThreadPoolTaskExecutor mailExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setThreadNamePrefix("mail-");
executor.setCorePoolSize(4);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(100);
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
return executor;
}
@Bean
AsyncConfigurer asyncConfigurer(@Qualifier("mailExecutor") Executor executor) {
return new AsyncConfigurer() {
@Override
public Executor getAsyncExecutor() {
return executor;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) -> System.err.printf(
"async failed method=%s params=%s error=%s%n",
method.getName(), Arrays.toString(params), ex);
}
};
}
}
@Service
class OrderService {
private final AuditService auditService;
OrderService(AuditService auditService) {
this.auditService = auditService;
}
@Transactional
public void pay(long orderId) {
log("pay start orderId=" + orderId);
try {
this.sendReceipt(orderId);
} catch (RuntimeException e) {
log("self-invocation caught: " + e.getMessage());
}
auditService.writeAudit(orderId)
.exceptionally(ex -> {
log("audit failed: " + ex.getMessage());
return null;
});
log("pay returned to controller");
}
@Async("mailExecutor")
public void sendReceipt(long orderId) {
log("sendReceipt start orderId=" + orderId);
throw new IllegalStateException("SMTP timeout");
}
static void log(String message) {
System.out.printf("[%s] %s%n", Thread.currentThread().getName(), message);
}
}
@Service
class AuditService {
@Async("mailExecutor")
@Transactional(propagation = Propagation.REQUIRES_NEW)
public CompletableFuture<Void> writeAudit(long orderId) {
System.out.printf("[%s] writeAudit start orderId=%d%n",
Thread.currentThread().getName(), orderId);
return CompletableFuture.completedFuture(null);
}
}
ThreadPoolExecutor.CallerRunsPolicy는 큐가 가득 찼을 때 호출자 스레드가 직접 작업을 실행하게 합니다. 작업을 조용히 버리지 않는 장점이 있지만, 요청 스레드가 갑자기 오래 잡힐 수 있습니다. 따라서 이 정책을 쓰는 경우 “비동기 호출은 항상 즉시 반환된다”는 기대와 실제 동작이 달라질 수 있습니다.
트랜잭션도 스레드 경계를 함께 봐야 합니다. Spring의 일반적인 @Transactional은 현재 실행 스레드에 묶인 트랜잭션을 사용하며, 새로 시작한 스레드로 자동 전파되지 않습니다. 공식 Javadoc도 thread-bound 트랜잭션이 새 스레드로 전파되지 않는다고 설명합니다. 참고: Spring Transactional API
디버깅과 운영 로그
비동기 문제를 볼 때는 로그에 스레드 이름을 남기는 편이 좋습니다. http-nio-8080-exec-1, mail-1, ForkJoinPool.commonPool-worker-1처럼 실행 위치가 보이면 내부 호출인지, Executor를 탔는지, 콜백이 어느 스레드에서 실행되는지 빠르게 구분할 수 있습니다.
위 예시를 실행하면 다음과 비슷한 로그를 볼 수 있습니다. sendReceipt가 mail- 스레드가 아니라 요청 스레드에서 실행된 점이 내부 호출 문제를 보여줍니다.
[http-nio-8080-exec-1] pay start orderId=42
[http-nio-8080-exec-1] sendReceipt start orderId=42
[http-nio-8080-exec-1] self-invocation caught: SMTP timeout
[http-nio-8080-exec-1] pay returned to controller
[mail-1] writeAudit start orderId=42
디버깅할 때는 먼저 세 가지를 확인하면 흐름이 빨리 정리됩니다. 첫째, 호출이 Spring 프록시를 지나갔는지 봅니다. 같은 클래스 내부 호출이면 @Async가 적용되지 않을 수 있습니다. 둘째, 반환 타입이 void인지 CompletableFuture인지 봅니다. 호출자가 실패를 알아야 하는 작업이라면 완료 상태를 관찰할 수 있는 반환 타입이 필요합니다. 셋째, Executor의 큐와 거부 정책을 확인합니다. 큐가 무한이면 장애가 늦게 드러나고, 큐가 제한되어 있으면 거부 정책에 따라 호출자 흐름이 달라질 수 있습니다.
비동기 작업이 외부 시스템에 부수 효과를 남긴다면 요청 트랜잭션과 분리된 실패도 고려해야 합니다. 주문 저장 트랜잭션이 아직 커밋되기 전에 메일 발송이나 감사 로그가 실행될 수 있습니다. 커밋 이후 실행이 중요하다면 after-commit 이벤트, outbox 패턴, 메시지 큐처럼 전달 시점을 명확히 하는 구조를 함께 검토해야 합니다.
원문 참고
https://www.maeil-mail.kr/question/77
댓글
댓글 쓰기