서로 다른 관심사
동기/비동기와 블로킹/논블로킹은 흔히 혼용되지만, 실제로는 서로 다른 관심사를 다루는 별개의 축이다.
이 구분을 명확히 하는것이 모든 논의의 바탕이 되므로 명확히 짚어보자.
동기(Synchronous) vs 비동기(Asynchronous)
작업 완료를 누가 확인하는가?
- 동기(Synchronous)
- 호출자(Caller)가 직접 작업의 완료 여부를 확인하거나 기다린다. 결과를 호출자가 내놓으라고 끌어오는 Pull 방식이다.
- 비동기(Asynchronous)
- 피호출자(Callee)가 작업이 완료되면 호출자에게 알려준다. 콜백(callback), 이벤트(event) 등을 통해
결과를 내보내는 Push 방식이다.
- 피호출자(Callee)가 작업이 완료되면 호출자에게 알려준다. 콜백(callback), 이벤트(event) 등을 통해
즉, 동기/비동기의 핵심은 완료 통지의 주체가 누구인가이다.
블로킹 (Blocking) vs 논블로킹 (Non-Blocking)
제어권이 즉시 반환되는가?
- 블로킹 (Blocking)
- 호출된 함수가 작업을 완료할 때까지 호출자에게 제어권을 돌려주지 않는다. 호출자의 스레드는 그자리에서 멈추고 대기한다.
- 논블로킹 (Non-Blocking)
- 호출된 함수가 즉시 제어권을 반환한다. 작업이 완료되지 않았더라도 호출자는 다음 코드를 실행할 수 있다.
즉, 블로킹/논블로킹의 핵심은 호출자의 스레드가 멈추느냐 아니냐이다.
4가지 조합 - Java 코드 예시
| 블로킹(Blocking) | 논블로킹(Non-Blocking) | |
| 동기(Sync) | ① Sync + Blocking | ② Sync + Non-Blocking |
| 비동기(Async) | ③ Async + Blocking | ④ Async + Non-Blocking |
1. 동기 + 블로킹
- 호출자가 결과를 직접 확인하고 (동기), 결과가 올 때까지 스레드가 멈춤. (블로킹)
- 일반적인 Java 개발에서 볼 수 있는 형태이다. 대부분의 I/O 호출이 여기에 해당한다.
예시
1)
// JDBC 호출 — 전형적인 동기 + 블로킹
String name = jdbcTemplate.queryForObject(
"SELECT name FROM users WHERE id = ?",
String.class, userId
);
// DB가 결과를 반환할 때까지 이 스레드는 여기서 멈춘다.
System.out.println(name);
2)
// RestTemplate — 동기 + 블로킹 HTTP 호출
RestTemplate restTemplate = new RestTemplate();
String result = restTemplate.getForObject(
"https://api.example.com/data", String.class
);
// 응답이 올 때까지 스레드가 블로킹된다.
3)
// CompletableFuture.get() — 비동기로 시작했지만, get()에서 동기 + 블로킹이 된다
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> heavyWork());
String result = future.get(); // 호출자가 직접 결과를 가져오고(동기), 완료까지 멈춘다(블로킹)
특징
- 코드가 순차적이므로 가독성이 높고 디버깅이 쉽다.
- 요청 하나당 스레드 하나가 점유되므로, 동시 요청이 많아지면 스레드 풀이 고갈될 수 있다.
- Spring MVC + JDBC 기반의 전통적인 서버 아키텍처가 이 모델이다.
적합한 상황
- 요청량이 예측 가능하고, 개발 생산성과 코드 유지보수성을 우선시하는 경우에 적합하다.
2. 동기 + 논블로킹 (Synchronous + Non-Blocking)
- 호출자가 직접 결과를 확인하고 (동기), 제어권은 즉시 반환됨. (논블로킹)
- 호출자는 반환된 제어권으로 다른일도 하고 주기적으로 응답 완료 여부를 확인 (Polling) 한다.
예시
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> heavyWork());
// isDone()은 즉시 반환된다 (논블로킹)
// 호출자가 직접 완료 여부를 반복 확인한다 (동기 — polling)
while (!future.isDone()) {
doSomethingElse(); // 제어권이 있으므로 다른 작업 가능
}
String result = future.get(); // 이미 완료되었으므로 즉시 반환
특징
- 호출자가 주기적으로 확인해야 하므로 CPU 타임(싸이클)을 소모한다. (Busy Wating 방식)
- 호출자가 완료를 직접 확인하는 만큼 호출자의 흐름안에서 결과를 처리할 수 있다.
- 실무에서 실제 사용하는 경우보다는 비동기-논블로킹을 가기 위한 단계로 볼 수 있다.
의문점 - 제어권이 호출자에게 반환되었는데, 피호출자의 작업은 어떻게 진행될 수 있을까?
바로 스레드가 분리되기 때문이다.
supplyAsync()는 기본적으로 ForkJoinPool.commonPool()의 워커 스레드에서 작업을 실행한다.
호출자의 스레드와 작업 스레드는 서로 다르기 때문에, 호출자가 제어권을 돌려받아도 작업은 다른 스레드에서 동시에 진행된다.
System.out.println("Caller: " + Thread.currentThread().getName());
// 출력: Caller: main
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
System.out.println("Worker: " + Thread.currentThread().getName());
// 출력: Worker: ForkJoinPool.commonPool-worker-1
return heavyWork();
});
3. 비동기 + 블로킹 (Asynchronous + Blocking)
- 피호출자가 작업완료를 알려주고 (비동기), 제어권은 반환되지 않아 호출자의 스레드가 멈춰있다. (블로킹)
- 의도적인 설계보다는 비동기로 전환하던 중 블로킹 지점이 일부 남아있을 때 자연스럽게 발생하는 경우이다. (webflux + JDBC)
예시 1 (Spring WebFlux + JDBC Blocking)
Mono.fromCallable(() -> {
// 리액티브 파이프라인 안에서 블로킹 JDBC 호출이 발생
return jdbcTemplate.queryForObject(
"SELECT name FROM users WHERE id = ?",
String.class, userId
);
})
.subscribeOn(Schedulers.boundedElastic())
.subscribe(name -> sendResponse(name)); // 콜백으로 결과 수신 (비동기)
- 위 코드를 분석해보면
- 비동기인가? -> subscribe에 콜백을 등록하고, 파이프라인이 완료되면 알려주는 push 방식이다.
호출자가 결과를 직접 끌어오지 않으므로 비동기다. - 블로킹인가? -> 파이프라인 내부에서 jdbcTemplate 호출이 실행되는 순간, 그 작업을 실행하는 스레드는 DB 응답이 올 때까지 멈춘다. JDBC 자체가 블로킹 API이기 때문이다.
호출자가 멈추므로 블로킹이다.
- 비동기인가? -> subscribe에 콜백을 등록하고, 파이프라인이 완료되면 알려주는 push 방식이다.
예시 2 (WebClient + JDBC)
// 비동기 + 논블로킹으로 의도한 코드
WebClient webClient = WebClient.create();
Mono<String> result = webClient.get()
.uri("https://api.example.com/data")
.retrieve()
.bodyToMono(String.class)
.flatMap(data -> {
// ⚠️ 여기서 블로킹 JDBC 호출이 발생!
String saved = jdbcTemplate.queryForObject(
"INSERT INTO logs(data) VALUES(?) RETURNING id",
String.class, data
);
return Mono.just(saved);
});
result.subscribe(id -> log.info("저장 완료: {}", id));
- 위 코드를 분석해보면
- 1. 요청 수신 → Netty 이벤트 루프 스레드(reactor-http-nio-1)가 처리 시작
- 2. webClient.get()...bodyToMono()
- 외부 API에 HTTP 요청 전송 (A)
- 응답을 기다리는 동안 이벤트 루프 스레드는 해방됨
- 다른 요청(B, C, D...)를 처리할 수 있다.
- 3. 외부 API 응답 도착 (A)
- 이벤트 루프 스레드가 깨어나서 flatMap 진입
- 4. flatMap 내에서 jdbc.queryForObject() 호출 (블로킹!)
- JDBC 블로킹 API - DB가 응답할 때까지 현재 스레드가 멈춘다!
- 이 스레드는 Netty 이벤트 루프 스레드 (중요, Netty는 CPU 코어수만큼의 스레드로만 처리함)
- DB 응답을 대기하는 수십~수백 ms 동안 이 루프에 할당된 모든 요청이 멈춘다.
특징
- 비동기 방식을 도입했으나 병목 지점에서 블로킹이 발생하여 비동기의 장점이 상쇄된다.
- 시스템 전체를 논블로킹으로 전환하지 않으면 제대로된 리액티브가 되지 못한다.
R2DBC의 등장 배경
즉, WebFlux를 도입하면 HTTP 요청 처리는 비동기 + 논블로킹이 된다.
하지만 DB 접근 계층에서 JDBC를 사용하면, 결국 그 지점에서 스레드가 블로킹된다.
비동기의 핵심 장점인 "적은 스레드로 높은 처리량"을 온전히 누릴 수 없는 것이다.
이 문제를 해결하기 위해 등장한 것이 R2DBC(Reactive Relational Database Connectivity)다.
R2DBC는 관계형 데이터베이스에 대한 완전한 논블로킹 API를 제공하여, WebFlux와 결합했을 때 요청 처리 전 구간에서
비동기 + 논블로킹을 달성할 수 있게 해준다.
Netty의 이벤트 루프 스레드 방식
Netty는 기본적으로 CPU 코어 수만큼의 이벤트 루프 스레드로 전체 요청을 처리한다.
예를 들어 4코어 서버에서는 reactor-http-nio-1 ~ reactor-http-nio-4, 총 4개의 스레드가 모든 동시 요청을 나눠 처리하는 구조이다.
이 중 하나의 스레드가 JDBC 호출로 100ms만 블로킹되어도, 그 스레드에 할당된 모든 커넥션의 요청처리가 100ms동안 완전히 멈추게 되는것이다. 4개중 한개의 스레드가 막히면 전체 처리량의 25%가 줄어드는 셈이된다.
4. 비동기 + 논블로킹 (Asynchronous + Non-Blocking)
- 작업 완료는 피호출자가 알려주고 (비동기), 호출자의 스레드도 즉시 해방된다. (논블로킹)
- 가장 효율적인 방식이다. 호출자는 요청만 보내고 즉시 다른일을 할 수 있고, 피호출자의 작업이 완료되면 콜백이나 이벤트를 통해
확인하게 된다.
예시
// 1. CompletableFuture — thenAccept()는 콜백을 등록하고 즉시 반환된다
CompletableFuture
.supplyAsync(() -> callExternalApi()) // 별도 스레드에서 실행 (논블로킹)
.thenApply(response -> parse(response)) // 완료 시 자동으로 체이닝
.thenAccept(result -> save(result)); // 피호출자가 완료를 알려줌 (비동기)
System.out.println("요청 전송 완료 — 결과를 기다리지 않고 다음 로직 실행");
// 2. Spring WebFlux — 완전한 비동기 + 논블로킹 (WebFlux + R2DBC)
@GetMapping("/users/{id}")
public Mono<User> getUser(@PathVariable Long id) {
return userRepository.findById(id); // R2DBC — 논블로킹 DB 호출
// 반환 즉시 스레드 해방, 결과가 준비되면 Reactor가 응답을 push
}
// 3. Java 11+ HttpClient — 비동기 + 논블로킹 HTTP 호출
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/data"))
.build();
client
.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenAccept(response -> {
System.out.println("Status: " + response.statusCode());
});
// sendAsync()는 즉시 반환되고, 응답이 오면 thenAccept()로 콜백이 실행된다
특징
- 적은 수의 스레드로 많은 요청을 처리할 수 있다. 스레드가 I/O 대기로 낭비되지 않는다.
- 코드 복잡도가 올라간다. 콜백 체이닝, 에러 전파, 디버깅이 동기식 코드보다 어려워진다.
마무리 정리
4가지 조합 간략 요약
| 완료 확인 | 제어권 반환 | 예시 | 스레드 효율 | |
| 동기 + 블로킹 | 호출자가 직접 확인 | 완료까지 미반환 | JDBC, RestTemplate, future.get() | 낮음 (스레드 점유) |
| 동기 + 논블로킹 | 호출자가 직접 확인 (polling) | 즉시 반환 | future.isDone() While 루프 | 보통 (busy-wait) |
| 비동기 + 블로킹 | 피호출자가 알려줌 | 중간에 블로킹 발생 | WebFlux 요청 내 JDBC (블로킹) | 낮음 (블로킹 병목) |
| 비동기 + 논블로킹 | 피호출자가 알려줌 | 즉시 반환 | thenAccept(), WebFlux + R2DBC | 높음 |
헷갈리는 내용 정리
한줄 정리
- 동기/비동기는 - 완료를 누가 알려주는가!
- 블로킹/논블로킹은 - 호출자 스레드를 멈추는가!
CompletableFuture를 사용하면 항상 비동기 + 논블로킹인가?
-> No! CompletableFuture를 어떻게 사용하냐에 따라 동기/비동기 블로킹/논블로킹이 다르다.
- future.get(): 결과 내놔! Polling 이므로 동기이다. get()으로 응답 내놔 하는동안 호출자 스레드가 블로킹된다.
- while(future.isDone().not()): 결과 확인! Polling 이므로 동기이다. 단, while 내에서 다른 작업을 할 수 있는 논블로킹이다.
- future.thenAccept(callback): 결과를 확인하지 않고 thenAccept로 결과를 콜백받으므로 비동기이다. 그동안 다른 작업을 할 수 있으므로 논블로킹이다.
'기술 학습' 카테고리의 다른 글
| MSA에서 CORS 문제를 해결하는 4가지 전략 (0) | 2026.03.27 |
|---|---|
| TCP/IP 체크섬(Checksum) 내부 동작 원리 (0) | 2026.03.23 |
| ANSI Isolation Level vs MySQL Isolation Level: 같은 이름, 다른 보장 (0) | 2026.03.15 |
| MSA에서 ACID의 의미 (0) | 2026.03.10 |
| 객체지향 설계 원칙 - SOLID (0) | 2026.03.08 |