왜 논블로킹과 멀티플렉싱은 항상 세트로 등장할까?
서론
Redis, Netty, Spring Reactive... 백엔드 개발을 하며 한 번쯤은 "우리 기술 스택은 논블로킹(Non-blocking) 기반이라 빠르다"는 말을 들어봤을 것이다. 나 역시 지금까지는 특정 흐름을 다른 스레드에 던져두고, 메인 흐름은 자기 갈 길을 가는 방식으로 막연하게 이해해왔다.
이번 글에서는 이 이해가 맞는지 점검해보고, 왜 논블로킹을 이야기할 때 멀티플렉싱(Multiplexing)이라는 기술이 따라다니는지 정리해보려한다.
1. 수만 명의 접속자를 감당하는 서버
현대 백엔드 시스템에서 I/O 효율성이 중요한 이유
백엔드 시스템은 정말 간단히 말하면 '어떤 데이터를 달라는 요청에 대한 응답'을 주는 시스템인것이다. 이때 I/O 가 비효율적이라면 대규모 요청이 왔을때 요청은 적채될 것이고, 요청을 보낸 주체들은 응답이 올때까지 그 이후의 일을 못하게 될 확률이 크다.
항상 인터넷 서비스를 쓰는 현대 사회에서 이런 지연은 서비스의 심각한 흠이 될 것이다.
블로킹 모델의 한계점
블로킹(Blocking)은 말 그대로 '막는다'는 뜻이다.
하나의 스레드가 I/O 요청(파일 읽기, 네트워크 수신 등)을 보내면, 데이터가 준비될 때까지 그 스레드는 아무것도 못 하고 대기한다.
이 글에서 스레드가 하나의 요청을 처리하며 이 요청 처리를 '흐름'이라 지칭하겠다
시스템 자원인 스레드는 한정되어 있는데, 일을 하지 않고 대기만 하는 스레드가 늘어난다면 어떻게 될까? 수만 개의 동시 접속이 발생하는 상황에서 스레드를 무한정 늘릴 수는 없을 수 밖에 없기에 시스템은 자원이 딸려 결국 어느순간 문제가 생길게 자명하다.
2. 논블로킹(Non-blocking)
논블로킹의 정의와 동작 방식
논블로킹은 블로킹의 반대다. I/O 작업을 요청했을 때 데이터가 바로 준비되지 않았더라도, 스레드를 멈추지 않고 즉시 제어권을 돌려준다.
CPU가 대기하는 시간을 최소화 시키는걸 목적으로 논블로킹을 쓰는 것이다.
즉, CPU가 놀게 두지 않고 끊임 없이 일 시키기 위한 기법
개별 작업 단위에서의 효율성: 제어권을 즉시 돌려받는다
"지금 데이터 없으니까 나중에 다시 물어봐, 일단 흐름 계속 타" 라고 말하는 것과 같다. 덕분에 스레드는 데이터를 기다리는 동안 다른 비즈니스 로직을 처리하거나 다른 요청을 받을 수 있게 된다. 개별 작업 단위에서 '제어권을 즉시 돌려받는다'는 점이 핵심이다.
3. 멀티플렉싱(Multiplexing)
멀티플렉싱은 여러 개의 I/O 대상(소켓 등)을 하나의 스레드로 감시하는 기술이다.
전통적인 방식에서는 소켓 하나당 스레드 하나가 붙어서 "데이터 왔니?"라고 물어봐야 했다면, 멀티플렉싱(select, poll, epoll)을 사용하면 단 하나의 스레드가 수천 개의 소켓을 지켜보고 있다가 '데이터가 도착한 소켓'이 생길 때만 이를 알려준다. 가게의 수천 명의 손님을 매니저 한명이 관리하는 시스템으로 비유할 수 있다.
4. 핵심: 왜 둘은 함께 쓰여야만 하는가?
이제 내가 항상 품던 의문을 해소해보겠다. 왜 기술 서적이나 기술 영상에서들은 이 두 개념을 항상 묶어서 설명할까?
위에 기술한 내용을 논블로킹은 '흐름의 제어권을 즉시 되돌려 받기', 멀티플렉싱은 '스레드 하나로 여러 I/O 대상 감시' 이렇게 정리가 되는데 연관성이 없어보인다.
Case A: 논블로킹만 쓸 때의 단점 - 끊임없이 상태를 확인해야 하는 'Polling'
논블로킹만 사용하면, 데이터가 준비되었는지 확인하기 위해 무한 루프를 돌며 계속 "다 됐어? 지금은? 아직?"이라고 물어봐야 한다. 이를 폴링(Polling)이라 하는데, 이 과정에서 CPU 자원이 낭비될 수 밖에 없다.
Case B: 멀티플렉싱만 쓸 때의 단점 - 특정 작업이 지연될 때 전체 루프가 멈추는 'Stall'
멀티플렉싱으로 "어떤 소켓에 데이터가 왔어"는 알림을 받았다 가정해보자. 그런데 여기서 데이터를 읽어오는 과정이 '블로킹' 방식이라면? 데이터를 다 읽을 때까지 관리자 스레드가 멈춰버린다.
그러면 이 스레드가 관리하던 다른 수천 개의 소켓들도 모두 응답이 멈추는 Stall 현상이 발생한다. 하나의 작업에 나머지 전체 작업이 멈춰버리는 불상사인것이다.
Best Practice: 멀티플렉싱으로 알림을 받고, 논블로킹으로 처리하기
결국 가장 효율적인 모델은 멀티플렉싱으로 알림을 받고, 실제 처리는 논블로킹으로 진행하는 것이다.
멀티플렉싱이 "어떤 놈이 준비됐어"라고 알려주면, 논블로킹으로 "안 멈추고 빠르게 처리"함으로써 자원 낭비를 최소화한다.
위 삽화에서 소켓 7번이 좀 헷갈렸었는데 상황을 설명하자면 멀티플렉서(관리자)가 알람을 줘서 작업자(요리사)가 기껏 소켓을 들여다 보았더니 아직 데이터(재료)가 덜 도착한 것인 상황인것이다. 그래서 작업자는 "아직 준비 안 됨, 다음 작업으로!" 라는 멘트를 치고 바로 다른 작업을 하러 간다.
논블로킹 작업자는 데이터가 준비될때까지 멍하지 대기하지 않고 바로 다른 일을 하러 간다.
실체화된 제품들
- Redis - 싱글 스레드임에도 초당 수만 건을 처리하는 비결은 바로
epoll기반의 I/O 멀티플렉싱 덕분 - Nginx, Node.js - 이벤트 루프가 멀티플렉싱을 통해 이벤트를 감지하고 논블로킹으로 처리하는 Event-driven 아키텍처 소유
- Java NIO -
InputStream이 아닌Selector를 통해 이 메커니즘을 구현
5. Java로 보는 블로킹과 논블로킹 코드
Blocking I/O (InputStream)
InputStream을 통해 소켓과 연결하고 데이터가 올 때까지 기다린다.
// 소켓으로부터 데이터를 읽어오기 위한 통로(Stream)를 얻는 과정
// 읽기위한 스트림은 InputStream, 쓰기위한 스트림은 OutputStream
InputStream input = socket.getInputStream();
// 데이터를 담아 둘 버퍼를 선언
byte[] buffer = new byte[1024];
// 데이터가 들어올 때까지 여기서 스레드가 무한정 대기(Blocking)!
// 첫 줄에서 열어둔 스트림을 계속 붙잡고 있는 것
int bytesRead = input.read(buffer);Non-Blocking I/O (Selector)
InputStream 방식이 데이터가 올 때까지 한 스트림에 대해 스레드가 블록된다면, Selector는 한 스레드가 여러 스트림을 감시하며 데이터를 처리하는 방식이다.
// Selector는 여러 채널을 감시하는 관리자 역할
// 이걸로 스레드하나로 여러 연결을 관리할 수 있는 것!
Selector selector = Selector.open();
// ... 생략 ...
// 논블로킹 설정, 이제 이 채널은 데이터가 있든 없든 즉각 응답하게 됨
channel.configureBlocking(false);
// 채널을 Selector에 등록한다. OP_READ 가 발생하면 알려달라 채널에 등록함
channel.register(selector, SelectionKey.OP_READ);
//무한 루프인데 여기를 Event Loop 라 지칭한다.
while (true) {
// 등록된 채널 중 데이터가 도착해서 읽을 준비가 된 채널 하나라도 나올때까지
// '대기(Block)'한다
selector.select();
// 신호를 준 채널 목록 가져옴
Set<SelectionKey> selectedKeys = selector.selectedKeys();
for (SelectionKey key : selectedKeys) {
if (key.isReadable()) {
// 데이터가 준비된 것이 확실하여 논블로킹하게 읽기 처리가 가능
// 이때 처리되는것도 '가벼워'야 됨
// 만약 이미지처리, DB 통신 등 무거운 작업이라면 이벤트 루프 전체가 막혀버려 병목이 발생하니 주의하
// 무거운 작업 하려거든 다른 스레드에 작업을 위임(던지)도록 하자
handleRead(key);
}
}
}6. 후기
이번 글을 위해 '그로킹 동시성', '주니어 백엔드 개발자가 반드시 알아야 할 실무 지식' 등 서적에서 정리한 자료를 취합하고 새로운 정보와 함께 정리하면서 막연했던 개념들이 비로소 머릿속에서 자리를 잡은거 같다. 논블로킹과 멀티플렉싱이 어떻게 시너지를 내는지 제대로 이해하고 나니, 관련한 지식에 어느 정도 자신감이 생긴다.
이제는 Netty나 Node.js의 이벤트 루프 동작 원리에 대해서도 좀더 명확이 이해가 간다. 단순히 기술을 가져다 쓰는 것을 넘어 그 밑바닥의 동작을 파악하는게 이래서 중요한거 같다. 기초를 탄탄히 다진 만큼, 앞으로 마주할 고성능(처리량 특화) 아키텍처 설계도 조금 더 명확한 시각으로 바라볼 수 있을 것 같다.