시스템 콜, 인터럽트, IPC의 실제 경계
유저 프로그램이 커널 기능을 요청하는 시스템 콜, 하드웨어 이벤트를 알리는 인터럽트, 프로세스 간 통신인 IPC를 Node.js와 운영 관점에서 연결해 설명한다. 성능 비용, epoll과 io_uring의 선택 기준, 파일 디스크립터와 조용한 실패 같은 실무 함정까지 함께 다룬다.
Script Companion
오디오와 함께 스크립트 보기
- 01
Node.js가 파일을 읽거나 네트워크 통신을 할 때, 겉으로는 fs.readFile이나 http.get 같은 API를 호출하는 것처럼 보인다. 하지만 그 아래에서는 open, read, socket 같은 시스템 콜이 커널에 요청을 보낸다. 이 구조를 이해하면 파일 읽기, HTTP 요청 수신, Docker 컨테이너, AWS Lambda, Nginx 같은 시스템 소프트웨어가 모두 어떤 공통 기반 위에서 움직이는지 볼 수 있다. 특히 Too many open files 같은 운영 장애는 파일 디스크립터와 시스템 콜의 관계를 모르면 증상만 보이고 원인은 잘 보이지 않는다.
- 02
시스템 콜은 유저 프로그램이 커널 기능을 요청하는 공식 인터페이스다. 레스토랑 비유로 보면 손님인 유저 프로그램은 주방인 커널과 하드웨어에 직접 들어갈 수 없고, 반드시 웨이터인 시스템 콜을 통해 주문해야 한다. 메뉴판에 해당하는 시스템 콜 테이블에는 커널이 허용하는 요청 목록이 있다. 이 분리는 안전을 위한 경계다. 유저 프로그램이 하드웨어에 직접 접근할 수 있다면 악성 프로그램이 다른 프로세스의 메모리를 읽거나, 버그 있는 프로그램 하나가 전체 시스템을 다운시키거나, 여러 프로그램의 디스크 쓰기가 충돌해 데이터가 손상될 수 있다.
- 03
이 경계는 CPU 실행 모드와 연결된다. 일반 애플리케이션은 제한된 권한의 User Mode, 즉 Ring 3에서 실행되고, OS 커널과 디바이스 드라이버는 Kernel Mode, 즉 Ring 0에서 실행된다. x86 아키텍처에는 Ring 0부터 Ring 3까지 네 단계가 있지만, Linux는 단순성을 위해 Ring 0과 Ring 3만 사용한다. Ring 1과 Ring 2는 과거 OS/2 같은 시스템에서 디바이스 드라이버용으로 쓰였으나 현대 OS에서는 사용하지 않는다. 유저 모드 코드가 시스템 콜을 호출하면 CPU는 권한이 높은 커널 모드로 들어갔다가, 작업이 끝난 뒤 다시 유저 모드로 돌아온다.
- 04
시스템 콜은 일반 함수 호출보다 비싸다. 한 번 호출에 수백에서 수천 나노초의 오버헤드가 발생하며, CPU 레지스터 저장, 컨텍스트 전환, 커널 스택 전환 같은 작업이 필요하다. 더 깊게 보면 CPU 파이프라인 플러시로 분기 예측과 명령어 재배치 같은 최적화가 초기화되고, KPTI 패치가 적용된 커널에서는 유저 모드와 커널 모드의 페이지 테이블이 분리되어 모드 전환 시 TLB 일부가 무효화된다. 이 비용 때문에 libuv는 I/O 작업을 배치로 처리하려고 한다. fs.readFileSync 한 번에도 Node.js 초기화와 모듈 로드까지 포함해 89개의 시스템 콜이 발생할 수 있다는 점은, 호출 횟수 자체가 성능 관찰 대상임을 보여준다.
- 05
Linux는 이 비용을 줄이기 위한 우회로도 제공한다. vDSO, 즉 virtual Dynamic Shared Object는 gettimeofday나 clock_gettime처럼 자주 호출되고 보안상 위험이 낮은 시스템 콜을 유저 공간에 매핑한다. 이렇게 하면 실제 커널 모드 전환 없이 실행할 수 있고, 일반 시스템 콜보다 10배 이상 빠르다. 더 근본적인 방향으로는 Linux 5.1부터 도입된 io_uring이 있다. io_uring은 유저 공간과 커널이 링 버퍼를 공유해, 시스템 콜 없이 I/O 요청을 제출하고 결과를 받을 수 있게 한다. epoll이 fd가 준비되었다고 알려준 뒤 사용자가 다시 read나 write 시스템 콜을 호출해야 하는 구조라면, io_uring은 커널이 I/O 자체를 완료한 뒤 결과를 전달한다.
- 06
io_uring은 빠르다는 말만으로 선택할 기술은 아니다. Node.js는 20.3.0부터 내부적으로 io_uring을 활용하기 시작했지만, 운영 환경에서는 커널 버전과 보안 이력, 컨테이너 제한, Completion Queue 오버플로우를 확인해야 한다. 기본 기능은 Linux 5.1이 필요하고, multishot accept 같은 고급 기능은 5.19 이상, IORING_OP_SEND_ZC는 6.0 이상이 필요하다. CVE-2024-0582처럼 use-after-free로 커널 6.4부터 6.6.5까지 영향을 준 이력도 있다. Google은 2022년 버그바운티에서 io_uring 관련 익스플로잇이 전체 커널 제출의 60퍼센트를 차지한다고 발표했고, Google Production Servers, ChromeOS, Android에서는 io_uring을 기본 비활성화한다.
- 07
워크로드 선택 기준도 중요하다. API 게이트웨이나 gRPC 마이크로서비스처럼 짧은 메시지와 다수 동시 connection이 있는 요청-응답 1:1, 즉 ping-pong 워크로드에서는 io_uring의 batching과 registered buffers 효과가 크다. 반대로 지속적인 read와 write, 큰 chunk가 이어지는 streaming 워크로드에서는 epoll 유지가 권장된다. liburing 트래커에는 io_uring이 epoll보다 느린 사례가 issue #189로 보고되어 있고, 이 구간에서는 컨텍스트 전환 절감보다 Submission Queue와 Completion Queue 관리 비용이 더 클 수 있다. registered buffers와 batching 없이 epoll 자리에 그대로 io_uring을 넣으면 개선이 10퍼센트 미만에 그치는 경우도 흔하다. 다양한 커널 버전, 특히 5.1 미만까지 지원해야 한다면 epoll만 가능하다.
- 08
인터럽트는 CPU가 하던 일을 잠시 멈추고 먼저 처리해야 하는 신호다. 응급실 의사가 일반 진료 중 응급 전화를 받으면 응급 처치를 먼저 하고 돌아오는 것처럼, CPU는 인터럽트가 오면 인터럽트 서비스 루틴, 즉 ISR을 실행한 뒤 원래 작업으로 복귀한다. 하드웨어 인터럽트는 키보드, NIC, 디스크 같은 외부 장치가 발생시키며 언제든 올 수 있는 비동기 이벤트다. 네트워크 패킷 도착이나 타이머가 여기에 속한다. 소프트웨어 인터럽트는 프로그램 내부에서 특정 명령이 실행될 때 발생하며, 시스템 콜, 예외, 0으로 나누기 같은 상황이 예시다. 인터럽트 번호와 ISR의 매핑은 IDT, Interrupt Descriptor Table이 담당한다.
- 09
Node.js의 비동기 I/O를 이해하려면 인터럽트와 epoll을 함께 봐야 한다. 네트워크 패킷이 도착하면 NIC 인터럽트가 발생하고, 커널 네트워크 스택이 데이터를 처리한 뒤 fd의 준비 상태를 갱신한다. epoll은 단순 API가 아니라 커널 내부 자료구조를 사용한다. epoll_ctl로 등록한 fd는 Red-Black Tree에서 관리되어 추가와 삭제가 O log n으로 처리되고, 실제 I/O가 준비된 fd는 Ready List에 연결 리스트로 유지된다. epoll_wait은 이 Ready List만 반환하므로 O 1로 준비된 이벤트를 확인할 수 있다. 반면 select와 poll은 매번 모든 fd를 순회해야 하므로 fd 수가 많아질수록 급격히 느려진다.
- 10
epoll에서 특히 조심해야 할 실패 모드는 Edge-Triggered 모드다. EPOLLET 플래그를 사용하면 fd 상태가 변할 때만 이벤트가 한 번 발생한다. 그 기회를 놓치면 커널이 다시 알려주지 않기 때문에, 데이터가 버퍼에 남아 있어도 epoll_wait이 영원히 블로킹되는 silent hang이 생길 수 있다. 이 모드에서는 fd를 반드시 O_NONBLOCK, 즉 논블로킹으로 설정해야 하고, read나 write가 EAGAIN을 반환할 때까지 루프로 처리해야 한다. 여기서 핵심은 이벤트를 받았다는 사실이 데이터 전체를 처리했다는 뜻이 아니라는 점이다. 준비 신호를 받은 뒤 커널 버퍼를 끝까지 비우는 패턴이 빠지면, 장애는 에러 로그 없이 멈춤으로 나타난다.
- 11
IPC, 즉 Inter-Process Communication은 독립된 메모리 공간을 가진 프로세스들이 데이터를 주고받는 방식이다. Pipe는 단방향이며 부모와 자식 프로세스 통신에 맞고, Named Pipe는 이름이 있어서 임의 프로세스 간 통신이 가능하다. Unix Domain Socket은 동일 호스트 안에서 양방향 소켓 통신을 제공하며 Docker API와 Nginx 같은 사례와 연결된다. TCP Socket은 네트워크 너머 통신에 사용되고 HTTP와 gRPC가 여기에 속한다. Shared Memory는 가장 빠르지만 직접 메모리를 공유하므로 동기화가 필요하다. Message Queue는 OS 레벨 큐를 사용해 비동기 처리가 가능하고, 시스템 로그나 RabbitMQ 아이디어 기반으로 설명된다.
- 12
IPC 선택은 메시지 크기, 지연 요구, 프로세스 수, 네트워크 경계로 나뉜다. 메시지 크기가 4KB보다 작으면 Named Pipe가 100바이트 기준 318 Mbps로 유리하고, 64KB 이상 대형 메시지나 스트림이면 Unix Domain Socket이 1MB 기준 41,334 Mbps로 강하거나 Shared Memory를 고려한다. 지연 요구가 10마이크로초보다 작으면 복사가 없고 커널 개입이 없는 Shared Memory와 spinlock 조합이 제시된다. 다대다 팬아웃 구조에서는 Pipe가 1:1 전용이라 Message Queue나 Unix Socket이 맞고, 다른 호스트와 통신해야 하면 TCP Socket이 유일한 선택이다. 같은 호스트의 서로 다른 프로세스라면 Unix Domain Socket이 TCP 대비 20에서 40퍼센트 레이턴시를 줄일 수 있다.
- 13
Unix Domain Socket이 TCP보다 빠른 이유는 네트워크 스택을 덜 지나가기 때문이다. TCP Socket은 같은 호스트 안에서도 소켓 API, TCP 계층의 시퀀스 번호와 ACK와 체크섬 계산, IP 계층의 라우팅 테이블 조회, 루프백 인터페이스를 거친다. Unix Domain Socket은 이 과정을 건너뛰고 커널 내부 버퍼 간 직접 데이터 복사를 수행한다. 그래서 같은 서버에서 Redis나 PostgreSQL에 연결할 때 Unix Socket을 사용하면 레이턴시가 줄어든다. Node.js 관점에서는 child_process.spawn이 fork, exec, pipe와 연결되고, cluster 모듈의 Worker 프로세스 간 통신도 Pipe 기반이라는 점을 기억해야 한다. 대용량 데이터를 Worker 사이에 자주 보내면 Pipe 버퍼 한계가 문제가 될 수 있다.
- 14
운영 장애로 돌아오면 첫 번째로 볼 것은 파일 디스크립터다. EMFILE, 즉 too many open files는 리눅스가 프로세스당 열 수 있는 fd 수를 제한하기 때문에 발생한다. 기본값은 1024개이며, HTTP 연결, DB 연결, 파일 핸들이 누적되거나 close 호출이 빠져 fd가 누수되면 한계에 도달한다. 두 번째는 느린 시스템 콜 추적이다. NestJS 서비스가 특정 요청에서 간헐적으로 수 초 이상 느린데 코드 레벨에서 원인이 보이지 않는다면, 파일 I/O나 네트워크 연결 또는 시스템 콜 자체가 블로킹될 수 있다. strace로 보면 PostgreSQL 연결 타임아웃 5초처럼 커널 경계에서 오래 걸린 지점을 확인할 수 있다.
- 15
마지막으로 조용한 실패를 구분해야 한다. cluster 모듈에서 process.send로 보낸 메시지가 간헐적으로 수신되지 않는 경우, 내부 Pipe 버퍼가 Linux 기본값 64KB로 제한되어 있고 Worker가 죽은 상태에서 send를 호출했을 가능성이 있다. SIGPIPE는 Unix Socket이나 TCP Socket에 쓰려 했지만 상대가 이미 연결을 끊었을 때 발생하며, 기본 동작은 프로세스 종료다. EINTR도 중요하다. 시그널을 자주 받는 환경에서 블로킹된 read나 write가 깨어나 -1과 errno EINTR을 반환할 수 있고, 이를 정상 완료나 일반 에러로 잘못 처리하면 데이터가 남아 있는데도 다 읽었다고 판단하는 silent partial read가 생긴다. Node.js 핵심 모듈은 libuv가 자동 재시도하지만, N-API 네이티브 애드온과 외부 C 라이브러리 바인딩에서는 직접 처리해야 한다. 정리하면 유저 프로그램은 시스템 콜로 커널에 접근하고, 하드웨어 이벤트는 인터럽트로 전달되며, 프로세스 간 통신은 Pipe, Socket, Shared Memory 중 조건에 맞게 골라야 한다.
같은 레이어