본문 바로가기

탐구일지/Java

Blocking Vs. Non-Blocking I/O 선택은 어떻게 해야 하는가?

1. 배경

 
서버 개발을 하다 보면 Blocking, Non-Blocking이라는 용어를 심심치 않게 듣게 된다
 
흔히 접하는 설명에 따르면,
Non-Blocking은 적은 스레드 사용, 스레드 차단 없음, 많은 연결 유지 등 효율적으로 처리할 수 있는 구조로 소개된다
 
그렇다면 Non-Blocking을 메인으로,
Blocking은 필요할 때만 보조적으로 사용하면 되는 것일까?
 
하지만 현실의 서버 아키텍처를 보면 여전히 Blocking 기반 모델이 주류를 이루고 있고,
Non-Blocking 방식은 특정 상황에서 선택적으로 사용하는 경우가 많다
 
왜 이런 선택이 이루어지는 것인가?
이 글에서는 Blocking과 Non-Blocking I/O를
스레드 모델, 확장성, 실제 운영 시나리오 관점에서 비교하며, 어떤 상황에서 어떤 선택이 합리적인지 살펴보고자 한다
 
 

2. Blocking I/O

 
Blocking I/O의 동작을 간단히 시뮬레이션 해보자
 

 
 /test/thread  URI를 가지는 API를 생성하고, 3초 간 I/O 작업을 하는 상황을 시뮬레이션하였다
 

 
그리고 이를 테스트하기 위해 총 스레드 갯수 5개, 요청 10개가 들어오는 경우를 시뮬레이션해보면,
 

 
스레드 풀의 크기에 따라 5개를 각각 약 3초간 점유하고 있고,
이후의 요청은 Blocking I/O가 끝난 이후 처리되게 된다
 
즉 I/O가 끝날 때까지 요청 처리를 위한 스레드를 점유하게 되고,
하나의 요청 당 하나의 스레드(Thread-per-Request) 전략을 취함을 알 수 있다
 
이러한 구조는 처리량이 스레드 수에 강하게 의존하지만, 명확한 장점을 가진다
 
대부분의 서버는 DB와의 통신 중심으로 이루어져 있으며, 이를 연결하는 JDBC는 Blocking 방식을 취한다
또한 트랜잭션의 경계 역시 스레드 기반으로 만들어져 있어, 하나의 트랜잭션 흐름이 스레드 안에서 닫혀 있다
이를 통해 트랜잭션 일관성, 디버깅, 운영 안정성 측면에서 예측 가능하며 안전하게 작동되도록 한다
이러한 이유 때문에 아직까지 MVC가 메인으로 사용된다
 
다만 이 방식은 명확한 단점이 존재하는데,
점유 시간이 긴 요청의 경우 스레드 풀이 쉽게 고갈될 수 있으며,
절대적인 스레드 수가 적은 경우 병목이 크게 발생할 수 있다는 점이다
 
 

3. Non-Blocking I/O

 
이러한 Blocking I/O의 한계를 극복하기 위해 등장한 모델이 Non-Blocking I/O이다
 
Non-Blocking I/O는 I/O 작업이 완료될 때까지 실행 흐름을 멈추는 대신,
요청을 등록한 후 즉시 반환하고,
작업이 완료되면 이벤트를 통해 이어서 처리하는 방식을 말한다
이때 흐름은 특정 요청에 고정되지 않으며,
Event Loop와 Queue 구조를 기반으로 순차적으로 처리된다
 
이러한 모델은 하나의 요청을 빠르게 처리하기보다는,
대기 시간이 긴 요청을 다수 동시에 유지하는 데 초점이 맞춰져 있다
소켓, 스트리밍, 실시간 알림과 같이 연결은 유지되지만 실제 연산은 간헐적으로 발생하는 경우,
Non-Blocking I/O는 스레드 점유 없이 효율적인 자원 활용이 가능하다
 
실제 예시를 하나 들어 보자
WS을 통해 10,000명의 사용자가 하나의 채널에 연결된 상태이다
 
이때 Tomcat은 연결에 대해 Blocking과 Non-Blocking을 정할 수 있는데,
Spring에 내장된 Tomcat은 기본적으로 연결에 있어 Non-Blocking 방식을 사용하기에 연결 자체 문제는 크게 발생하지 않는다

(https://tomcat.apache.org/tomcat-8.0-doc/web-socket-howto.html)

 
문제는 메시지 Broadcasting에 있다
Spring MVC는 Blocking 방식의 Servlet API를 전제로 구성되어 있으며,
WebSocket도 이 위에서 구현되어 있기에 Blocking 방식을 따른다
또한 하나의 메시지 이벤트를 하나의 스레드가 처리한다는 점에서 Thread-per-Request와 유사한 실행 모델을 가진다
이때 Broadcast 대상이 많아질수록 하나의 스레드가 처리해야 하는 I/O 작업이 선형적으로 증가하게 되며,
이에 따라 스레드 점유 시간이 급격히 길어지게 된다
 
그리고 이 과정에서 각 세션에 대한 전송은 무결성을 유지하기 위해 Flush Lock이 걸리며,
동시에 여러 Broadcast가 발생할 경우 또 하나의 전송 지연의 요인이 된다
 

 
Spring Framework의  ConcurrentWebSocketSessionDecorator  에서는 이와 같이 설명한다
 
요약하자면,
세션에는 한 번에 하나의 스레드만 메시지를 보낼 수 있으며, 더 많은 메시지를 보내려는 시도는 버퍼링된다
이때 지정된 버퍼 크기 제한과 전송 시간 제한이 확인되며, 이를 초과하면 세션이 종료된다
 

 
그렇다면 Non-Blocking I/O 기반의 WebSocket은 어떤 방식으로 처리할까?
 
요청이 들어올 경우 세션 별로 존재하는 Outbound Queue에 이를 Push하는 것으로 로직이 끝난다
이후 실제 I/O는 Event Loop에서 순차적으로 처리한다
Event Loop는 여러 세션이 공유하지만, 각 세션은 정확히 하나의 Event Loop에 고정된다
이 방법을 사용하면 각 세션은 자동적으로 순차적 처리를 하게 되므로
Write 경쟁을 하지 않으며, 세션 단위의 순서 보장이 동시에 이루어진다
 

 
이로 인해 브로드캐스트 규모가 커지더라도 스레드 점유 시간이 증가하지 않으며,
느린 세션으로 인한 지연이 전체 처리 흐름으로 전파되는 것을 방지할 수 있다
 
다만 이 방법은 물리적인 Thread 양 자체에 대한 제약을 줄인 것이지, Broadcast 자체의 크기를 줄여준 것은 아니다
즉 Thread 기반의 Lock에서 생기는 Overhead를 제거함으로써 부하 상황에서 시스템이 스레드 고갈로 붕괴되는 것을 방지한다
 
이러한 Non-Blocking 방식에도 여러 단점들이 존재한다
 
첫 번째로, 앞서 설명했듯이 JDBC 프로토콜은 Blocking 방식으로 설계되어 있어 WebFlux와 자연스럽게 결합되기 어렵다
Non-Blocking DB 접근을 위해 R2DBC와 같은 대안이 존재하지만, 지원 범위 제약과 학습 곡선이 있다
 
두 번째로, 비동기 흐름 특성상 흐름 추적 및 디버깅 난이도가 높아진다는 문제도 존재한다
이로 인해 명시적인 로깅과 모니터링이 요구된다
 
세 번째로, Backpressure를 고려해야 한다
메시지 폭주 상태에서 어떤 세션을 유지하고, 어떤 세션을 지연 또는 종료시킬 것인지에 대해서 프레임워크가 정해주지 않는다
 
마지막으로 CPU-Bound 작업이 치명적이라는 문제가 있다
단순히 생각했을 때 Event Loop 방식이 치명적인 시나리오에는 무엇이 있을까?
하나의 작업이 Event Loop를 장시간 점유하는 경우이다
이로 인해 해당 Event Loop가 담당하는 모든 세션의 처리가 지연되게 된다
 
따라서 WebFlux 기반 시스템에서는 CPU-Bound 작업을 별도의 전용 스레드 풀로 분리하거나,
비동기 I/O 흐름 밖에서 처리하는 설계가 필수적이다

(이는 운영체제의 Multi-Level Queue Scheduling과 유사한 철학을 가진다)