
Blocking I/O는 전통적인 방식의 입출력 처리 모델로, 하나의 스레드가 특정 I/O 작업(예: 파일 읽기, 외부 HTTP 요청 등)을 수행하는 동안 해당 작업이 완료될 때까지 아무런 다른 작업도 수행하지 못하고 대기하는 방식이다. 예를 들어, RestTemplate을 이용해 외부 API에 요청을 보내고 그 응답을 기다리는 동안, 해당 스레드는 아무 일도 하지 않은 채 블로킹 상태로 머무르게 된다.
이러한 방식은 구조적으로 단순하며 이해하기도 쉽지만, 동시성이 중요한 환경에서는 큰 제약을 초래한다. 예를 들어 동시에 100명의 사용자가 요청을 보낼 경우, 100개의 스레드를 유지해야 하며 이는 서버 자원을 크게 소모한다.
이를 어느 정도 완화하기 위해 스레드 풀(Thread Pool)이나 멀티 스레딩을 사용할 수 있으나, 이 경우에도 컨텍스트 스위칭 비용이 발생하고 메모리 사용량 증가, GC 부담 증가, 응답 지연 등의 부작용이 뒤따른다.
Non-Blocking I/O는 요청을 보낸 스레드가 응답을 기다리지 않고 다음 작업으로 넘어가는 방식이다. 비동기적으로 작동하며, 콜백이나 이벤트 루프, 리액티브 스트림(Publisher-Subscriber 모델)을 활용해 I/O 완료 시점에 별도의 스레드가 그 결과를 처리한다.
즉, 스레드는 I/O 작업을 요청한 이후 더 이상 해당 작업에 묶여 있지 않으며, 그 사이에 다른 요청들을 자유롭게 처리할 수 있다. 이는 상대적으로 적은 수의 스레드로도 수천~수만 개의 동시 요청을 처리할 수 있는 구조를 가능하게 한다.
단, Non-Blocking 모델에서도 주의할 점은 존재한다. 예를 들어, CPU 연산이 많은 작업이 Reactor 흐름 내부에 포함된다면, 비동기적 흐름이 오히려 병목 지점을 만들어낼 수 있으며, 전체 성능을 저해할 수 있다. 따라서 진정한 의미의 Non-Blocking 시스템을 구현하려면 전체 호출 체인(Controller, Service, Repository 등)이 모두 Non-Blocking이어야 한다.
/delay/2 → 2초 대기 후 응답하는 APIk6 도구를 사용하여 동시에 10개의 요청을 보냄(스레드 변화 확인을 극대화하기 위해 server.tomcat.threads.max=5로 제한)RestTemplate을 사용한 /blocking 엔드포인트와 WebClient를 사용한 /non-blocking 엔드포인트@GetMapping("/blocking")
@ResponseBody
fun blocking(): ResponseEntity<String> {
val id = UUID.randomUUID()
println("[${Thread.currentThread().name}][$id] 1. 요청 시작 ${System.currentTimeMillis()}")
val response = restTemplate.getForEntity("http://localhost:8081/delay/2", String::class.java)
println("[$id][${Thread.currentThread().name}] 2. 응답 수신 완료 ${System.currentTimeMillis()}")
return ResponseEntity.ok(response.body)
}
@GetMapping("/non-blocking")
@ResponseBody
fun nonBlocking(): Mono<String> {
val id = UUID.randomUUID()
println("[${Thread.currentThread().name}][$id] 1. 요청 시작 ${System.currentTimeMillis()}")
return webClient.build()
.get()
.uri("http://localhost:8081/delay/2")
.retrieve()
.bodyToMono(String::class.java)
.doOnSubscribe { println("[${Thread.currentThread().name}][$id] 2. 요청 전송 완료 ${System.currentTimeMillis()}") }
.doOnNext { println("[${Thread.currentThread().name}][$id] 3. 응답 수신 완료 ${System.currentTimeMillis()}") }
.doFinally { println("[${Thread.currentThread().name}][$id] 4. 요청 흐름 종료 ${System.currentTimeMillis()}") }
}
[http-nio-8080-exec-5][ba77d1d8...] 요청 시작
[http-nio-8080-exec-5][ba77d1d8...] 응답 수신 완료
[http-nio-8080-exec-5][a967b85f...] 요청 시작
[http-nio-8080-exec-1][a967b85f...] 응답 수신 완료
| 항목 | Blocking | Non-Blocking |
|---|---|---|
| 평균 응답 시간 | 3.06s | 2.14s |
| 처리 요청 수 | 10건 | 20건 |
| 처리 속도 | 1.96 req/s | 3.17 req/s |
| 고려 사항 | 설명 |
|---|---|
| 대량의 트래픽 | 적은 수의 스레드로 많은 요청을 처리해야 할 때 유리함 |
| 실시간/스트리밍 처리 | 빠른 반응성과 느슨한 연결 유지에 적합 |
| 마이크로서비스 | 다양한 외부 호출을 동시에 비동기 처리해야 하는 구조에 적합 |
단, Non-Blocking은 구조가 복잡해질 수 있으며, 모든 레이어에서 비동기 처리를 일관되게 구성해야 효과를 볼 수 있다. 또한 CPU 바운드 작업이 많을 경우에는 적절히 워커 스레드를 분리하거나 별도의 처리 큐를 활용하는 방식이 병행되어야 한다.
결국, 시스템의 성격에 맞춰 Blocking과 Non-Blocking 방식을 선택하고, 필요한 경우 두 방식을 조화롭게 혼합하는 것이 중요하다.