Back to feed
ASH avatar
ASH

2026. 5. 11.·base·

I/O를 처리하는 두 가지 방식 Blocking VS Non-Blocking

스프링과 node는 어떻게 다를까?

서버 성능 저하의 주 원인은 CPU가 아니다. CPU는 나노초 단위로 일하지만, DB나 하드디스크는 밀리초 단위로 응답한다. CPU 입장에서 I/O 작업은 아주 오래걸리는 작업인 것이다.

이 작업을 어떻게 처리하느냐에 따라 Blocking(Spring)과 Non-Blocking(Node.js) 아키텍처가 구분된다.

Blocking : 데이터가 올 때까지 멈춰라

전통적인 Spring 모델이 채택한 방식이다.

Spring의 스레드가 DB에 데이터를 요청하는 순간, 내부적으로 다음과 같은 일이 벌어진다.

  1. 스레드는 커널에게 시스템콜 read()를 호출하여 데이터를 요청한다.
  2. 데이터가 아직 도착하지 않았으므로 커널은 데이터가 준비될 때까지 스레드를 대기시킨다.
  3. 해당 스레드의 상태는 RUNNABLE에서 WAITING으로 강제 변경된다.
  4. 해당 스레드는 CPU에서 내려와 Wait Queue에서 응답이 도착할 때까지 대기하게 된다.

자바의 표준 DB 접속 기술인 JDBC는 애초에 만들어질때부터 이 Blocking 방식을 사용하도록 설계됐다.
DB에 쿼리를 날리는 함수는 DB가 응답을 보내줄 때까지 절대 return 되지 않도록 되어있는데 이에 따라 함수가 리턴되지 않으니 다음 줄의 코드를 실행할 수 없고 스레드는 그 자리에서 멈추게 되는 것이다.

Non-Blocking : 데이터가 오면 알려다오

Node.js가 채택한 방식으로 데이터가 오지 않아도 기다리지 않는다.

Node.js의 메인스레드가 데이터를 요청할 때는 Spring과는 전혀 다른 일이 일어난다.

  1. 메인 스레드는 커널에게 read()를 요청하되, 소켓을 Non-Blocking 모드로 설정한다.
  2. 커널은 데이터가 없더라도 스레드를 재우지 않고 아직 데이터가 없다는 상태를 즉시 반환한다.
  3. 함수가 바로 리턴되었으므로 메인 스레드는 멈추지 않고 바로 다음 코드를 실행한다.
  4. 대신 Node.js는 운영체제의 이벤트 알림 시스템(리눅스의 epoll)에 이 소켓 데이터가 도착하면 알려달라고 구독 신청을 한다.
  5. 다른 일을 처리하다가 데이터가 도착하면 커널이 이벤트를 발생시키고 그때 메인 스레드가 데이터를 가져와서 처리한다.

이 방식을 통해 데이터가 도착할 때까지 기다리지 않고 그 사이에 다른 작업을 할 수 있게 만들었다.

Blocking 방식에서 가장 큰 문제는 단순히 스레드가 쉬는 것이 아니다. 스레드를 재우고 깨우는 과정, 즉 컨텍스트 스위칭이 시스템에 부담을 주는 것이 문제였다.

Spring에서는 요청 하나당 스레드 하나가 필요하다. 1000개의 요청이 들어오면 스레드 1000개가 생성되는 것이다. 하지만 이를 처리할 CPU는 100개도 되지 않는다. OS는 공평함을 위해 이 1000개의 스레드를 아주 짧은 시간마다 번갈아가며 실행을 시켜야한다.

스레드 A를 멈추고 스레드 B를 실행하려면 스레드 A가 어디까지 계산했는지를 메모리에 저장하고 스레드 B의 저장된 기록을 메모리에서 가져와 CPU에 복구해야한다. 거기에 스레드 A가 실행되는 동안 CPU의 캐시 메모리는 스레드 A가 자주 쓰는 데이터로 채워져 있는데 B로 교체되는 순간 해당 캐시는 날아가고 스레드 B의 데이터로 채워진다. 이때 또 A의 차례가 다시오면 이전 과정에서 캐시는 이미 날아갔기 때문에 상대적으로 느린 메모리에서 데이터를 다시 가져와야한다.

그래서 Spring에서는 I/O를 기다리는 동안 수시로 컨텍스트 스위칭이 발생하고 캐시 효율이 떨어지는 문제가 발생하지만 Node.js는 네트워크 I/O 시 메인 스레드는 잠들지 않고 계속 실행이 되기 때문에 컨텍스트 스위칭이 발생하지 않아 CPU 캐시 효율을 극대화할 수 있다.

네트워크 I/O는 운영체제가 epoll, kqueue, IOCP 같은 완전한 비동기 시스템을 지원하지만 많은 운영체제에서 파일 I/O는 네트워크처럼 효율적인 비동기 방식을 완벽하기 지원하지 않거나 구현이 매우 복잡하다.
그래서 Node.js는 파일 I/O 요청이 들어오면 메인 스레드를 재우지 않기 위해 다른 방법을 사용한다.

바로 Worker Thread를 이용하는 것인데 메인 스레드가 fs.readFile()같은 비동기코드를 만나면 워커 스레드에게 작업을 위임하고 즉시 다른 일을 처리한다. 워커 스레드는 시스템 콜 직후 디스크 응답을 기다리며 블로킹(대기) 상태가 되어 CPU를 반납한다. 이 빈 시간 동안 메인 스레드가 다시 CPU를 차지하여 효율을 높이며, 그 사이 디스크 데이터는 DMA가 CPU 개입 없이 메모리로 옮겨놓는다. 전송이 끝나면 하드웨어 인터럽트가 발생해 워커 스레드를 깨우고, 워커는 메인 스레드에게 완료 보고를 하여 콜백이 실행되는 흐름으로 동작한다.

0
Comments

Join the thread

Leave feedback, ask for clarification, or keep a focused discussion attached to this article.

0 comments
No comments yet. Start the first thread for this article.
Current user avatar
Styling with Markdown is supported