[Reactive Programming] 3. Blocking I/O vs Non-Blocking I/O

y001·2025년 4월 18일

Reactive Programming

목록 보기
3/30
post-thumbnail

Blocking I/O vs Non-Blocking I/O

Blocking I/O

Blocking I/O는 전통적인 방식의 입출력 처리 모델로, 하나의 스레드가 특정 I/O 작업(예: 파일 읽기, 외부 HTTP 요청 등)을 수행하는 동안 해당 작업이 완료될 때까지 아무런 다른 작업도 수행하지 못하고 대기하는 방식이다. 예를 들어, RestTemplate을 이용해 외부 API에 요청을 보내고 그 응답을 기다리는 동안, 해당 스레드는 아무 일도 하지 않은 채 블로킹 상태로 머무르게 된다.

이러한 방식은 구조적으로 단순하며 이해하기도 쉽지만, 동시성이 중요한 환경에서는 큰 제약을 초래한다. 예를 들어 동시에 100명의 사용자가 요청을 보낼 경우, 100개의 스레드를 유지해야 하며 이는 서버 자원을 크게 소모한다.

이를 어느 정도 완화하기 위해 스레드 풀(Thread Pool)이나 멀티 스레딩을 사용할 수 있으나, 이 경우에도 컨텍스트 스위칭 비용이 발생하고 메모리 사용량 증가, GC 부담 증가, 응답 지연 등의 부작용이 뒤따른다.

Non-Blocking I/O

Non-Blocking I/O는 요청을 보낸 스레드가 응답을 기다리지 않고 다음 작업으로 넘어가는 방식이다. 비동기적으로 작동하며, 콜백이나 이벤트 루프, 리액티브 스트림(Publisher-Subscriber 모델)을 활용해 I/O 완료 시점에 별도의 스레드가 그 결과를 처리한다.

즉, 스레드는 I/O 작업을 요청한 이후 더 이상 해당 작업에 묶여 있지 않으며, 그 사이에 다른 요청들을 자유롭게 처리할 수 있다. 이는 상대적으로 적은 수의 스레드로도 수천~수만 개의 동시 요청을 처리할 수 있는 구조를 가능하게 한다.

단, Non-Blocking 모델에서도 주의할 점은 존재한다. 예를 들어, CPU 연산이 많은 작업이 Reactor 흐름 내부에 포함된다면, 비동기적 흐름이 오히려 병목 지점을 만들어낼 수 있으며, 전체 성능을 저해할 수 있다. 따라서 진정한 의미의 Non-Blocking 시스템을 구현하려면 전체 호출 체인(Controller, Service, Repository 등)이 모두 Non-Blocking이어야 한다.


실험을 통한 Blocking vs Non-Blocking 비교

실험 환경 및 코드 개요

  • 요청 대상: /delay/2 → 2초 대기 후 응답하는 API
  • 테스트 방식: k6 도구를 사용하여 동시에 10개의 요청을 보냄(스레드 변화 확인을 극대화하기 위해 server.tomcat.threads.max=5로 제한)
  • 비교 대상: RestTemplate을 사용한 /blocking 엔드포인트와 WebClient를 사용한 /non-blocking 엔드포인트
  • 각 요청의 시작/종료 시점과 처리한 스레드 이름을 로그로 출력해 흐름 추적

Blocking I/O 코드

@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)
}

Non-Blocking I/O 코드

@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()}") }
}

로그 분석 결과

Blocking 방식
  • 요청을 받은 스레드가 그대로 응답까지 책임진다.
  • 예)
[http-nio-8080-exec-5][ba77d1d8...] 요청 시작
[http-nio-8080-exec-5][ba77d1d8...] 응답 수신 완료
  • 스레드가 요청 시작부터 응답까지 계속 점유되어 있어, 동시에 많은 요청을 처리하려면 그만큼의 스레드가 필요하다.

Non-Blocking 방식

  • 요청은 한 스레드가 받고, 응답은 다른 스레드가 처리하기도 한다.
  • 예)
[http-nio-8080-exec-5][a967b85f...] 요청 시작
[http-nio-8080-exec-1][a967b85f...] 응답 수신 완료
  • 실제 응답은 Reactor Netty의 별도 워커 스레드가 처리하며, 요청을 받은 스레드는 빠르게 반환되어 다른 요청을 처리할 수 있다.

k6 벤치마크 결과 요약

항목BlockingNon-Blocking
평균 응답 시간3.06s2.14s
처리 요청 수10건20건
처리 속도1.96 req/s3.17 req/s
  • 동일 시간 동안 Non-Blocking 방식은 두 배의 요청을 처리함
  • 스레드 수가 제한된 환경에서는 특히 더 큰 효과 기대 가능

결론: 언제 Non-Blocking I/O를 고려해야 하는가?

고려 사항설명
대량의 트래픽적은 수의 스레드로 많은 요청을 처리해야 할 때 유리함
실시간/스트리밍 처리빠른 반응성과 느슨한 연결 유지에 적합
마이크로서비스다양한 외부 호출을 동시에 비동기 처리해야 하는 구조에 적합

단, Non-Blocking은 구조가 복잡해질 수 있으며, 모든 레이어에서 비동기 처리를 일관되게 구성해야 효과를 볼 수 있다. 또한 CPU 바운드 작업이 많을 경우에는 적절히 워커 스레드를 분리하거나 별도의 처리 큐를 활용하는 방식이 병행되어야 한다.

결국, 시스템의 성격에 맞춰 Blocking과 Non-Blocking 방식을 선택하고, 필요한 경우 두 방식을 조화롭게 혼합하는 것이 중요하다.

0개의 댓글