이벤트 기반 시스템

이동영·2026년 2월 15일

웹개발

목록 보기
34/37

Java Spring MVC는 요청이 들어올 때마다 스레드 풀(Thread Pool)에서 스레드를 하나씩 꺼내 배정한다.
하지만 Node.js는 단 하나의 메인 스레드로 수천, 수만 개의 동시 접속을 처리한다. 이것이 가능한 이유는 바로 이벤트 루프(Event Loop) 기반의 비동기 I/O 덕분이다.

1. 구조적 이해 : 누가 일하는가?

이벤트 루프를 이해하려면 단순히 '루프'만 보는 게 아니라, 주변 요소들과의 협업 관계를 봐야 한다.

  • Call Stack (함수 실행창) : Java의 Stack과 같다. 현재 실행 중인 코드 라인이 쌓이는 곳이다. 단 하나뿐이므로, 여기서 시간이 오래 걸리면 전체 서버가 멈춘다.
  • Background (업무 위임처) : Node.js(Libuv)가 관리하는 영역이다. 파일 읽기(I/O), 네트워크 요청, 타이머 등 시간이 걸리는 작업은 메인 스레드가 직접 하지 않고 이곳으로 던진다. 여기서는 멀티 스레드 혹은 OS 커널의 비동기 API가 실제로 일을 처리한다.
  • Task Queue (대기소) : 백그라운드에서 일이 끝난 '콜백 함수'들이 메인 스레드로 돌아가기 위해 줄을 서는 곳이다.
  • Event Loop (감시자) : Call Stack이 비어있는지 24시간 감시한다. Stack이 텅 비는 순간, Task Queue에서 기다리는 콜백을 Stack으로 옮겨 실행시킨다.

2. 태스크 큐의 서열: Microtask vs Macrotask

Java의 PriorityQueue처럼, Node.js의 큐에도 우선순위가 있다. 이 개념을 모르면 비동기 코드의 실행 순서를 예측할 수 없다.

① Microtask Queue (VIP 대기실)

  • 대상 : Promise.then, process.nextTick
  • 특징 : 이벤트 루프는 Call Stack이 비면 Microtask Queue를 가장 먼저, 그리고 완전히 비울 때까 실행한다.

② Macrotask Queue (일반 대기실)

  • 대상 : setTimeout, setInterval, I/O 작업
  • 특징 : Microtask가 모두 처리된 후에야 하나씩 처리된다.
function first()  {
	second();
    console.log('첫 번째');
}
function second()  {
	third();
    console.log('두 번째');
}
function third()  {
    console.log('세 번째');
}
first();

위 코드를 실행하게 되면 결과는 다음과 같다.

세 번째
두 번째
첫 번째

first 함수가 제일 먼저 호출되고, 그 안의 second 함수가 호출된 뒤, 마지막으로 third 함수가 호출된다.

이번에는 특정 밀리초 이후에 코드를 실행하는 setTimeout을 사용해보면

function run() {
  console.log('3초 후 실행');
}
console.log('시작');
setTimeout(run, 3000);

console.log('끝');

결과는 다음과 같다.

시작
끝
3초 후 실행
  1. 호출 스택에 쌓인다.
  2. setTimeout 실행 시 콜백 run은 백그라운드로 보낸다.
  3. 백그라운드에서 3초 후 태스크 큐로 보낸다.
  4. 호출 스택 실행이 끝나 비워진다.
  5. 이벤트 루프가 태스크 큐의 콜백을 호출 스택으로 올린다.
  6. run이 후출 스택에서 실행되고 제거된다.
  7. 이벤트 루프는 태스크 큐ㅜ에 콜백이 들어올 때까지 대기한다.

만약 호출 스택에 함수가 너무 많이 들어 있으면 3초가 지난 후에도 run 함수가 실행되지 않을 수 있다. 이벤트 루프는 호출 스택이 비어 있을 때에만 태스크 큐에 있는 run 함수를 호출 스택으로 가져오기 때문이다. 이것이 setTimeout의 시간이 정확하지 않을 수도 있는 이유이다.


3. Java 개발자가 흔히 하는 실수: Event Loop Blocking

Java에서는 복잡한 이미지 연산이나 암호화 작업을 수행할 때 스레드 하나만 느려질 뿐 다른 요청은 안전하다. 하지만 Node.js는 다르다.

// 드라마켓 대본 파일 수만 개를 동기적으로 처리하는 나쁜 예
scripts.forEach(script => {
    const encrypted = encryptComplexSync(script.content); // Call Stack 점유!
});

위 코드가 Call Stack에서 실행되는 동안, 다른 사용자가 보내는 모든 로그인, 조회, 결제 요청은 Task Queue에서 마냥 대기하게 된다. 서비스가 마비되는 것이다.

  • 해결책 : 무거운 작업은 Worker Threads를 사용하거나, 비동기 API로 쪼개어 이벤트 루프가 숨 쉴 틈을 주어야 한다.

4. 백그라운드와 스레드 풀 (Libuv)

"Node.js는 싱글 스레드인데 어떻게 파일 I/O를 비동기로 하나요?"라는 질문에 대한 답이다.
Node.js 내부의 Libuv 라이브러리는 내부적으로 4개(기본값)의 스레드를 가진 스레드 풀을 운영한다. DB 조회나 파일 읽기 요청이 들어오면 메인 스레드는 Libuv에게 일을 시키고 본인은 다시 루프로 돌아간다. 일이 끝나면 Libuv가 결과물을 Task Queue에 넣어주는 방식이다.

0개의 댓글