3강 질문
💡[CS 실무 Point: OS 계층의 역량 활용 (epoll / kqueue)] 단일 스레드라고 해서 컴퓨터가 정말로 혼자 일하는 것은 아닙니다. 파일 읽기나 네트워크 통신 같은 무거운 작업은 Node.js가 직접 처리하지 않고, 운영체제 커널이 제공하는 고성능 이벤트 통지 API(Linux의 epoll, macOS의 kqueue 등 시스템 콜)를 통해 운영체제에게 철저하게 위임(Offloading)합니다. 즉, Node.js의 메인 스레드는 교통정리만 아주 빠르게 수행할 뿐, 실제 무거운 짐은 OS 커널이 백그라운드에서 나르고 있는 것입니다.
안녕하세요, 위 설명에서 질문이 있습니다.
운영체제에 위임하는 작업과 libuv 스레드 풀의 워커 스레드가 어떤 연관이 있고 어떻게 유기적으로 동작하는지 궁금합니다.
답변 2
1
안녕하세요 박은송님, 질문 주셔서 감사합니다.
이 부분을 잘 이해하고 넘어가는 것은 시스템 전체를 조망하며 원리를 기반으로 제어할 수 있는 시니어 레벨로 나아가기 위해 꼭 짚고 넘어가야 할 좋은 질문입니다. 결론부터 명확하게 말씀드리면, 많은 분들이 모든 비동기 작업이 스레드 풀(Thread Pool)로 간다고 생각하지만 실제로는 그렇지 않습니다. Node.js(정확히는 libuv 라이브러리)는 작업의 성격이나 하드웨어적 특성에 따라 운영체제(OS) 커널에 직접 위임할지, 아니면 자신이 관리하는 워커 스레드 풀(Worker Thread Pool)에 던질지를 철저하게 분리해서 처리합니다. 이 두 가지가 어떻게 나뉘고, 또 어떻게 유기적으로 맞물려 돌아가는지 컴퓨터 공학(CS)의 하드웨어 레벨부터 상세히 설명해 보겠습니다.
본격적인 설명에 앞서, 쉽게 이해하실 수 있도록 핵심 용어 네 가지를 먼저 가볍게 짚고 넘어가겠습니다. 첫째로 스레드(Thread)란 컴퓨터가 작업을 처리하는 가장 작은 작업자 단위로, 식당의 직원 한 명이라고 생각하시면 됩니다. 둘째로 블로킹(Blocking)은 이 직원이 고기를 굽느라 불판 앞을 떠나지 못하고 다른 손님의 주문을 전혀 받지 못하는 멈춤 상태를 말합니다. 셋째로 오프로딩(Offloading)은 매니저가 직접 고기를 굽지 않고 주방 담당자나 외부 업체에 그 일을 떠넘겨 위임하는 효율적인 행동입니다. 마지막으로 스레드 풀(Thread Pool)은 일이 생길 때마다 직원을 새로 뽑는 대신, 미리 고용해 둔 4명 정도의 전담 대기조를 의미합니다.
이제 하드웨어와 CS의 관점에서 누가 진짜 일을 하는지 살펴보겠습니다. 컴퓨터의 핵심 부품들은 속도 차이가 큽니다. CPU는 초당 수많은 연산을 하는 매우 빠른 장치이며, RAM은 CPU가 작업할 데이터를 올려두는 넓고 빠른 작업대입니다. 반면 Network Card(NIC)는 외부 인터넷망과 통신하는 출입구로서 상대적으로 느리고 언제 데이터가 올지 모르는 특성이 있으며, Storage(SSD/HDD)는 데이터를 영구 저장하는 거대한 창고로 물리적이고 구조적인 한계 때문에 가장 느립니다.
Node.js의 메인 스레드인 이벤트 루프는 오직 CPU와 RAM 위에서만 아주 빠르게 돌아갑니다. 외부 데이터를 기다려야 하는 네트워크 통신이나, 느린 하드디스크를 뒤져야 하는 작업에 메인 스레드가 묶여버리는 블로킹 상태가 되면 전체 서버가 멈추게 됩니다. 그래서 Node.js는 이 무거운 짐들을 외부로 오프로딩하여 넘기는데, 여기서 두 가지 다른 경로가 발생합니다.
이 두 가지 위임 경로는 OS 커널과 스레드 풀로 나뉩니다. libuv는 메인 스레드에게 비동기 작업을 받았을 때, 이 작업이 네트워크 통신인지 아니면 파일 입출력이나 CPU 연산인지 판단하여 작업을 다른 곳으로 보냅니다.
첫 번째 경로는 주로 네트워크 통신에서 발생하는 OS 커널 위임(epoll / kqueue)입니다. 운영체제(OS)는 이미 네트워크 카드(NIC)를 다루는 데 있어 전문가입니다. 네트워크 소켓 통신이나 HTTP 요청 등이 들어오면, libuv는 스레드 풀의 대기조를 쓰지 않고, 대신 OS 커널에게 이 소켓에서 데이터가 들어오면 나한테 알려달라고 알람만 맞추고 뒤돌아섭니다. 실제로 데이터를 받고 RAM에 적재하는 것은 NIC와 OS가 알아서 처리하며, 작업이 끝나면 OS가 알람을 울려 메인 스레드에게 가져가라고 통지합니다.
다만 여기서 한 가지 주의해야 할 예외가 있습니다. 도메인 주소를 IP로 바꾸는 dns.lookup() 메서드의 경우, OS의 동기적인 시스템 함수를 사용하기 때문에 네트워크 작업임에도 예외적으로 OS 커널이 아닌 libuv 스레드 풀을 사용하게 됩니다. 이는 실무에서 병목을 추적할 때 아주 중요한 포인트입니다.
두 번째 경로는 주로 파일 I/O, 암호화, 압축에서 사용되는 libuv 스레드 풀 위임입니다. 파일 시스템 접근은 운영체제마다 비동기를 지원하는 방식이 파편화되어 있고, 암호화나 압축 같이 순수하게 CPU를 많이 써야 하는 연산은 OS 커널의 비동기 API로 해결하기 어렵습니다. 이때 libuv가 꺼내 드는 것이 바로 기본값 4개를 가진 스레드 풀입니다. 메인 스레드가 이 파일을 읽어오라거나 비밀번호를 암호화해 달라고 하면, libuv는 스레드 풀의 워커 스레드 중 하나를 깨워서 그 일을 전담시킵니다. 이 워커 스레드는 백그라운드에서 열심히 파일을 읽거나 연산을 한 뒤, 끝나면 메인 스레드에게 결과를 돌려줍니다. 워커 스레드 자체가 블로킹되더라도 메인 스레드와는 분리되어 있으니 메인 스레드는 안전하게 다른 일을 계속할 수 있습니다.
이 모든 과정을 종합하여 대형 레스토랑의 운영 시스템에 비유해 보겠습니다. 메인 스레드는 홀을 총괄하는 단 1명의 매니저로서, 손님의 주문을 받고 완성된 요리를 서빙하는 교통정리만 하며 주방에 들어가서 직접 요리하지 않습니다. RAM은 매니저가 주문서와 완성된 요리를 임시로 올려두는 카운터 테이블 역할을 합니다. OS 커널과 NIC는 외부 배달 대행사가 음식을 수령해 가는 자동화 드라이브스루 시스템과 같으며, libuv 스레드 풀은 창고에서 무거운 식자재를 꺼내오거나 장시간 고기를 구워야 하는 4명의 전담 특수 작업반과 같습니다.
이를 바탕으로 사용자가 사진첩 앱에 접속해 프로필 사진을 요청하는 API를 호출했다고 가정해 보겠습니다. 먼저 인터넷망을 타고 들어온 손님의 요청이 서버의 통신 장비에 도착합니다. OS는 이 요청을 조립해 카운터인 RAM에 올려두고 매니저인 메인 스레드에게 알람을 울립니다. 알람을 들은 매니저는 달려가 주문서를 확인하고, 사진 파일을 스토리지에서 읽어와야 한다는 것을 파악합니다. 매니저는 이 작업을 자신이 직접 하거나 드라이브스루 장비가 할 수 없다는 것을 알기 때문에, 즉시 4명의 특수 작업반인 스레드 풀 중 대기하고 있는 1명에게 작업 지시서를 던지고 다른 손님의 주문을 받으러 홀연히 떠납니다.
그동안 워커 스레드는 지하 창고로 내려가 물리적인 하드디스크를 뒤져서 사진을 가져옵니다. 작업이 끝나면 특수 작업반은 카운터에 사진을 올려두고 매니저에게 작업이 끝났다는 표시를 남깁니다. 매니저는 홀을 돌다가 이를 보고 카운터로 와서 사진을 챙긴 뒤, 사진을 손님에게 보내기 위해 다시 드라이브스루의 배출구 소켓에 데이터를 던져 넣고 OS에게 전송을 지시하며 끝을 맺습니다. 실제 네트워크 바깥으로 데이터를 전송하는 것은 통신 장비와 OS가 알아서 처리합니다.
요약하자면, 메인 스레드는 쉬지 않고 OS 커널과 스레드 풀 양쪽으로 작업을 오프로딩하여 던지고 완료된 결과를 받아오는 컨트롤 타워 역할을 수행합니다. 데이터베이스 연결이나 HTTP 통신 같은 네트워크 통신은 OS 커널이 전담하므로 스레드 풀을 전혀 소모하지 않으며, 이 덕분에 수만 개의 동시 접속 처리가 가능해집니다. 반면 파일 입출력이나 암호화는 libuv 스레드 풀이 백그라운드 스레드를 소모하여 처리하게 됩니다. 이 작업이 너무 몰리면 스레드 풀 4개가 꽉 차서 병목이 발생할 수 있으므로 상황에 따라 스레드 풀의 크기를 조절하는 튜닝이 필요하기도 합니다. 이때 Node.js의 환경 변수인 UV_THREADPOOL_SIZE를 설정하여 스레드 풀의 크기를 제어할 수 있습니다. 기본값은 4이지만, 서버의 물리적 코어 수나 작업 특성에 맞춰 최대 1024까지 늘릴 수 있어 암호화나 압축 같은 무거운 작업이 많을 때 유용합니다. 이처럼 운영체제 시스템 콜과 스레드 풀은 서로 다루는 영역이 명확히 다르며, 이벤트 루프를 중심으로 완벽하게 분업하여 유기적으로 서버를 운영하게 만들어 줍니다.
마지막으로, 실제 실무 상황 예제를 통해 이 메커니즘이 어떻게 적용되는지 살펴보겠습니다. 우리가 흔히 만드는 '회원가입 기능'을 개발한다고 가정해 보겠습니다. 사용자가 이메일과 비밀번호를 입력하고 가입 버튼을 누르면, 먼저 사용자의 가입 데이터가 서버에 도착하는 과정은 네트워크 통신이므로 OS 커널이 전적으로 담당합니다. 데이터가 모두 도착하면 OS 커널이 메인 스레드에게 알림을 주고, 메인 스레드는 요청 데이터를 확인합니다. 그다음, 보안을 위해 사용자의 비밀번호를 해싱(Bcrypt 등)하는 과정을 거쳐야 하는데, 이 작업은 CPU 연산을 매우 많이 요구하는 무거운 작업입니다. 만약 메인 스레드가 이를 직접 처리하면 그동안 다른 사용자의 접속을 받지 못하는 블로킹 상태가 되므로, 메인 스레드는 스레드 풀에 있는 워커 스레드에게 비밀번호 암호화를 오프로딩하여 위임합니다.
워커 스레드가 백그라운드에서 암호화를 마치고 결과를 돌려주면, 이제 메인 스레드는 이 완성된 데이터를 데이터베이스(DB)에 저장해야 합니다. 흥미롭게도 애플리케이션 서버가 DB 서버와 통신하는 과정은 내부망이든 외부망이든 결국 네트워크 통신입니다. 따라서 메인 스레드는 이번에는 스레드 풀의 직원을 부르지 않고, 다시 OS 커널에게 데이터를 DB로 보내고 답변이 오면 알려달라고 오프로딩합니다. 최종적으로 DB에서 저장 완료 응답이 오면 OS 커널이 메인 스레드에게 알림을 주고, 메인 스레드는 비로소 사용자에게 가입 완료 메시지를 전송하게 됩니다. 이처럼 단 하나의 회원가입 API를 처리할 때도, 작업의 성격이 CPU 연산인지 네트워크 통신인지에 따라 스레드 풀과 OS 커널을 오가며 유기적으로 협력하는 것이 Node.js 실무 서버의 핵심 동작 방식입니다.
참고해주세요!
0
안녕하세요, 인프런 AI 인턴입니다. 박은송님.
Node.js와 관련된 고성능 이벤트 처리와 관련하여 중요한 사항을 질문해 주셨군요. 다음은 운영체제의 역할과 libuv 스레드 풀의 연관성에 대한 설명입니다.
Node.js에서의 I/O 처리
운영체제 역할:
- Node.js는 비동기 이벤트 드리븐 방식으로 동작하며, I/O 작업 자체를 직접 수행하지 않고 운영체제에게 위임합니다.
- 운영체제는
epoll(Linux)나kqueue(macOS)와 같은 고성능 이벤트 통지 메커니즘을 통해 파일 입출력, 네트워크 입출력 등의 작업 완료를 Node.js 이벤트 루프에 통지합니다. 이러한 방법을 통해 Node.js 메인 스레드는 블로킹 없이 다른 작업을 계속 처리할 수 있습니다.
- Node.js는 비동기 이벤트 드리븐 방식으로 동작하며, I/O 작업 자체를 직접 수행하지 않고 운영체제에게 위임합니다.
libuv 스레드 풀:
- 일부 작업(예: 파일 입출력, DNS 조회 등)은 운영체제가 제공하는 비동기 I/O API를 사용하지 않고, libuv의 스레드 풀에서 직접 처리됩니다.
- libuv 스레드 풀은 기본적으로 4개의 워커 스레드를 가지고 있으며, 이러한 스레드들이 블로킹 작업을 수행합니다.
- 이런 방식으로 CPU 집약적인 작업이나 오래 걸리는 I/O 작업이 메인 스레드에 영향을 주지 않도록 분리합니다.
- 일부 작업(예: 파일 입출력, DNS 조회 등)은 운영체제가 제공하는 비동기 I/O API를 사용하지 않고, libuv의 스레드 풀에서 직접 처리됩니다.
유기적인 동작
- Node.js는 메인 이벤트 루프에서 네트워크나 파일 시스템 같은 I/O 작업 요청을 받으면, 작업을 운영체제에 위임하거나 libuv 스레드 풀에게 맡깁니다.
- 예를 들어 네트워크 작업은 대부분 운영체제의 비동기 API를 사용해 처리되지만, 일부 파일 시스템 작업은 libuv의 스레드 풀을 사용하여 비동기로 처리됩니다.
- 이는 운영체제와 libuv가 각자의 역할을 수행하며, Node.js가 효율적으로 비동기 처리를 하도록 지원하는 구조입니다.
추가 자료와 관련 질문 URL:
- Understanding the Node.js Event Loop, Timers, and process.nextTick()
Node.js가 어떻게 비동기 I/O를 효과적으로 처리하는지를 이해하면 보다 효율적인 서버 애플리케이션을 설계할 수 있습니다. 위 내용이 도움이 되셨길 바랍니다. 어떠한 추가적인 질문도 언제든지 환영합니다!
저는 질문자님의 이해를 돕고자 지식공유자님의 기존 답변을 참고하여 유사한 답변 링크를 추천해드리고 있습니다.
현재 베타 기능으로 답변이 만족스럽지 않을 수 있는 점 양해 부탁드립니다. 🙏
추가적으로 궁금한 점이 있으시면, 이어서 질문해 주세요. 곧 지식공유자께서 답변해 주실 것입니다.
수파베이스 ORM 질문
0
24
2
몽고 db 접속 오류
0
18
1
트리거 질문
0
25
1
74. 데이터 캐시 - 1 (이론) 강의 영상 누락
0
35
1
강의 듣는 중인데,
0
34
1
36강 오탈자가 있는 거 같습니다.
0
28
2
Service Create/Update Record 운용과 Delete Record 미운용의 차이 질문
0
25
1
2강 nodejs 3단계 설명 질문
0
41
1
imagesLoaded에 관한 질문
0
19
2
useEffect와 lifecycle문의
0
31
2
scanf("%d\n") 의미
0
23
1
Sequence 관련 질문
1
33
2
Image Only Query
1
30
2
프론트엔드 학습 수준 문의
0
43
2
라이브 운영중인 환경의 테이블에 인덱스 추가시 고려사항
0
40
2
리액트 챕터별 코드에서 eslint 설정파일이 없어요
0
51
2
DDD 는 마이바티스와 잘 맞지 않는건가요?
0
52
1
스프링부트 버전 문의드립니다.
0
40
1
주소 연산자(&) 간접 지정자(*) 반대 개념
0
34
1
최근 코테, 과제 테스트 트렌드
0
71
2
Json 요청 처리
1
37
2
강의 만료일 연장 신청
0
40
2
lucide react 아이콘 설치
0
48
2
17강 zustand store 서버에서 생성
1
42
1





