System Call & Interrupt
System Call & Interrupt (시스템 콜 & 인터럽트)
섹션 제목: “System Call & Interrupt (시스템 콜 & 인터럽트)”1. 한 줄 정의
섹션 제목: “1. 한 줄 정의”시스템 콜(System Call)은 유저 프로그램이 운영체제 커널의 기능(파일, 네트워크, 메모리)을 안전하게 요청하는 공식 인터페이스이며, 인터럽트(Interrupt)는 CPU가 현재 작업을 멈추고 긴급한 이벤트를 처리하도록 하는 신호 메커니즘이다.
2. 왜 중요한가
섹션 제목: “2. 왜 중요한가”- Node.js가 파일을 읽거나 네트워크 통신을 할 때 반드시 시스템 콜을 사용한다.
fs.readFile(),http.get()같은 API들은 내부적으로open,read,socket시스템 콜을 호출한다. - 성능 문제의 근원을 이해할 수 있다. 시스템 콜은 유저 모드 ↔ 커널 모드 전환 비용이 발생한다. 너무 자주 호출하면 성능 병목이 된다.
- Node.js의 비동기 I/O 동작 원리를 이해하는 핵심이다.
epoll(Linux의 I/O 다중화 메커니즘)은 인터럽트를 기반으로 동작하며, 이것이 Node.js Event Loop의 근간이다. - 백엔드 엔지니어링의 공통 기반이다. Docker 컨테이너, AWS Lambda, Nginx 등 모든 시스템 소프트웨어가 시스템 콜 위에서 동작한다.
Too many open files같은 운영 장애를 이해하고 해결하려면 파일 디스크립터(fd)와 시스템 콜의 관계를 알아야 한다.
3. 핵심 개념
섹션 제목: “3. 핵심 개념”3.1 System Call (시스템 콜)
섹션 제목: “3.1 System Call (시스템 콜)”비유: 레스토랑 주문 시스템
섹션 제목: “비유: 레스토랑 주문 시스템”레스토랑에서 손님(유저 프로그램)은 직접 주방(커널/하드웨어)에 들어갈 수 없다. 위생 규정과 안전을 위해 반드시 웨이터(시스템 콜)를 통해 주문을 넣어야 한다. 웨이터는 손님의 요청을 받아 주방에 전달하고, 결과(음식)를 손님에게 가져다 준다.
- 손님(유저 프로그램): 직접 하드웨어에 접근 불가
- 웨이터(시스템 콜): 커널에 공식적으로 요청하는 인터페이스
- 주방(커널): 하드웨어를 직접 제어하는 특권 영역
- 메뉴판(시스템 콜 테이블): 커널이 허용하는 요청 목록
CPU는 두 가지 실행 모드를 가진다.
| 구분 | User Mode (Ring 3) | Kernel Mode (Ring 0) |
|---|---|---|
| 권한 | 제한적 (하드웨어 직접 접근 불가) | 무제한 (모든 명령 실행 가능) |
| 동작 | 일반 애플리케이션 코드 실행 | OS 커널, 드라이버 실행 |
| 충돌 시 | 해당 프로세스만 종료 | 시스템 전체 크래시 (커널 패닉) |
| 예시 | Node.js, Python, Chrome | Linux 커널, 디바이스 드라이버 |
시스템 콜 호출 흐름:
유저 프로그램 (Ring 3) ↓ syscall 명령어 실행 (소프트웨어 인터럽트)CPU 모드 전환: Ring 3 → Ring 0 ↓ 시스템 콜 번호로 커널 함수 실행커널 (Ring 0): 요청 처리 (파일 읽기, 네트워크 등) ↓ 결과 반환, 모드 복귀유저 프로그램 (Ring 3): 결과 수신왜 Ring 0/3만 쓰나? x86 아키텍처는 Ring 0~3까지 4단계를 지원하지만, Linux는 단순성을 위해 Ring 0(커널)과 Ring 3(유저)만 사용한다. Ring 1, 2는 과거 OS/2 같은 시스템에서 디바이스 드라이버용으로 쓰였으나 현대 OS에서는 사용하지 않는다.
모드 전환 비용: 시스템 콜 1번 호출은 수백~수천 나노초의 오버헤드가 발생한다. CPU 레지스터 저장, 컨텍스트 전환, 커널 스택 전환 등의 작업이 필요하기 때문이다. 이 때문에 libuv는 I/O 작업을 배치로 처리한다.
왜 시스템 콜이 이렇게 비싼가? 단순히 함수 호출과 다르게 시스템 콜은 다음 단계를 거친다:
- CPU 파이프라인 플러시 — 현재 실행 중인 명령어 최적화(분기 예측, 명령어 재배치)가 초기화된다
- 보안 검증 — Meltdown/Spectre 취약점 대응을 위한 KPTI(Kernel Page Table Isolation) 패치가 적용된 커널에서는 유저 모드와 커널 모드의 페이지 테이블이 완전히 분리되어 모드 전환 시 TLB 일부가 무효화된다
- 스택 전환 — 유저 스택에서 커널 스택으로 전환한다
이 비용을 줄이기 위해 Linux는 vDSO(virtual Dynamic Shared Object) 를 제공한다. gettimeofday(), clock_gettime() 같은 자주 호출되고 보안상 위험이 없는 시스템 콜을 유저 공간에 매핑하여, 실제 커널 모드 전환 없이 실행할 수 있다. vDSO를 통한 호출은 일반 시스템 콜 대비 10배 이상 빠르다.
# vDSO가 프로세스에 매핑되어 있는지 확인cat /proc/self/maps | grep vdso# 7fffa5ffe000-7fffa6000000 r-xp 00000000 00:00 0 [vdso]📖 더 보기: What Makes System Calls Expensive: A Linux Internals Deep Dive — codingconfessions.com — 시스템 콜의 숨겨진 비용을 파이프라인, TLB, KPTI 관점에서 분석
io_uring — 시스템 콜 오버헤드의 근본 해결: Linux 5.1부터 도입된 io_uring은 유저 공간과 커널이 링 버퍼(ring buffer) 를 공유하여, 시스템 콜 없이 I/O 요청을 제출하고 결과를 수신할 수 있다. epoll은 “fd가 준비되었음”을 알려주면 유저가 다시 read()/write() 시스템 콜을 호출해야 하지만, io_uring은 커널이 I/O 자체를 완료한 후 결과만 전달한다. Node.js는 20.3.0부터 내부적으로 io_uring을 활용하기 시작했다.
epoll 방식: io_uring 방식:1. epoll_wait() → 시스콜 1. SQ에 요청 쓰기 → 시스콜 없음2. read() → 시스콜 2. 커널이 I/O 수행3. 데이터 처리 3. CQ에서 결과 읽기 → 시스콜 없음
epoll: 2번의 모드 전환 io_uring: 0번의 모드 전환 (배치 시)📖 더 보기: Optimizing Linux System Calls: Cutting Costs with io_uring and eBPF — WebProNews — io_uring과 eBPF로 시스템 콜 비용을 최소화하는 최신 기법
io_uring 도입 시 주의 사항: 성능 이점이 크지만 운영 환경에서는 다음을 반드시 확인해야 한다.
| 항목 | 상세 |
|---|---|
| 최소 커널 버전 | Linux 5.1 (기본 기능). 고급 기능(multishot accept 등)은 5.19+, IORING_OP_SEND_ZC(Zero-Copy)는 6.0+ 필요 |
| 보안 취약점 이력 | CVE-2024-0582 (use-after-free, 커널 6.4~6.6.5 영향, LPE 가능). Google은 2022년 버그바운티에서 io_uring 관련 익스플로잇이 전체 커널 제출의 60%를 차지한다고 발표 |
| 비활성화 사례 | Google Production Servers, ChromeOS, Android에서 io_uring을 기본 비활성화. 컨테이너 환경에서는 Seccomp 프로파일로 제한 |
| CQ 오버플로우 | Completion Queue가 가득 차면 이벤트가 드롭된다. io_uring_cq_has_overflow() 로 주기적 확인 필요 |
# 커널 버전 확인uname -r # 5.1 미만이면 io_uring 사용 불가
# 컨테이너 환경에서 io_uring 허용 여부 확인cat /proc/self/status | grep Seccomp# Seccomp: 2 (filter 모드) → io_uring syscall이 차단될 수 있음📖 출처: Google Limiting IO_uring Use Due To Security Vulnerabilities — Phoronix, CVE-2024-0582 상세 — oss-security
주요 시스템 콜 목록
섹션 제목: “주요 시스템 콜 목록”| 시스템 콜 | 번호 (x86_64) | 기능 |
|---|---|---|
read | 0 | 파일/소켓에서 데이터 읽기 |
write | 1 | 파일/소켓에 데이터 쓰기 |
open | 2 | 파일 열기 → fd 반환 |
close | 3 | 파일 디스크립터 닫기 |
socket | 41 | 소켓 생성 |
accept | 43 | TCP 연결 수락 |
fork | 57 | 프로세스 복제 |
exec | 59 | 새 프로그램 실행 |
mmap | 9 | 메모리 매핑 |
epoll_create | 213 | epoll 인스턴스 생성 |
코드 예시: Node.js fs.readFile() 내부 시스템 콜
섹션 제목: “코드 예시: Node.js fs.readFile() 내부 시스템 콜”// Node.js 코드const fs = require("fs");
fs.readFile("/etc/hostname", "utf8", (err, data) => { if (err) throw err; console.log("호스트명:", data.trim());});
console.log("readFile 호출 직후 (비동기이므로 먼저 실행됨)");예상 출력:
readFile 호출 직후 (비동기이므로 먼저 실행됨)호스트명: my-server내부 실행 흐름:
fs.readFile() 호출 ↓Node.js C++ 바인딩 → libuv에 작업 위임 ↓libuv 스레드 풀(기본 4개 스레드) 중 하나가 작업 수행 ↓ [시스템 콜 발생] open('/etc/hostname', O_RDONLY) → fd=5 반환 read(5, buffer, 4096) → 읽은 바이트 수 반환 close(5) → fd 해제 ↓작업 완료 → Event Loop 큐에 콜백 등록 ↓메인 스레드가 콜백 실행 → 결과 전달C 언어로 직접 시스템 콜 확인
섹션 제목: “C 언어로 직접 시스템 콜 확인”// read_file.c - 시스템 콜 직접 사용 (교육용)#include <fcntl.h> // open()#include <unistd.h> // read(), close(), write()
int main() { char buf[256];
// 시스템 콜: open int fd = open("/etc/hostname", O_RDONLY); if (fd < 0) return 1;
// 시스템 콜: read int n = read(fd, buf, sizeof(buf) - 1); buf[n] = '\0';
// 시스템 콜: write (stdout = fd 1) write(1, "호스트명: ", 14); write(1, buf, n);
// 시스템 콜: close close(fd); return 0;}컴파일 및 실행:
gcc -o read_file read_file.c && ./read_file예상 출력:
호스트명: my-server3.2 Kernel Mode vs User Mode
섹션 제목: “3.2 Kernel Mode vs User Mode”비유: 공항 보안 구역
섹션 제목: “비유: 공항 보안 구역”공항에서 일반 승객(유저 모드)은 보안 검색대(시스템 콜)를 통과해야 관제탑 영역(커널 모드)에 접근할 수 있다. 직접 관제탑에 들어가면 항공 안전(시스템 안정성)이 위험해진다.
원리: CPU Protection Ring
섹션 제목: “원리: CPU Protection Ring”┌─────────────────────────────────────┐│ Ring 3: 유저 모드 │ ← Node.js, Python, Chrome 실행│ (User Applications) ││ ┌─────────────────────────────┐ ││ │ Ring 0: 커널 모드 │ │ ← Linux 커널, 디바이스 드라이버│ │ (Kernel) │ ││ │ - 하드웨어 직접 접근 │ ││ │ - 모든 CPU 명령 실행 가능 │ ││ └─────────────────────────────┘ │└─────────────────────────────────────┘ ↑↓ 시스템 콜로만 이동 가능왜 분리하는가?
만약 유저 프로그램이 하드웨어에 직접 접근할 수 있다면:
- 악성 프로그램이 다른 프로세스의 메모리를 읽을 수 있다 (보안 취약점)
- 버그가 있는 프로그램 하나가 전체 시스템을 다운시킬 수 있다
- 여러 프로그램이 동시에 디스크에 쓰면 데이터가 손상된다
코드 예시: 시스템 콜 수 측정
섹션 제목: “코드 예시: 시스템 콜 수 측정”# strace로 Node.js가 실제로 어떤 시스템 콜을 호출하는지 확인strace -c node -e "require('fs').readFileSync('/etc/hostname')"예상 출력 (요약):
% time seconds usecs/call calls errors syscall------ ----------- ----------- --------- --------- ---------------- 45.23 0.000234 12 19 mmap 23.11 0.000120 10 12 openat 15.67 0.000081 8 10 read 8.45 0.000044 5 9 close ...------ ----------- ----------- --------- --------- ----------------100.00 0.000518 89 12 totalrequire('fs').readFileSync() 한 번 호출에 89개의 시스템 콜이 발생한다. 대부분은 Node.js 초기화(모듈 로드)에 사용된다.
3.3 Interrupt (인터럽트)
섹션 제목: “3.3 Interrupt (인터럽트)”비유: 응급 전화
섹션 제목: “비유: 응급 전화”응급실 의사(CPU)가 일반 진료(현재 작업)를 하고 있다가 응급 전화(인터럽트)가 오면, 하던 일을 멈추고 응급 처치(ISR: 인터럽트 서비스 루틴)를 먼저 처리한 뒤, 원래 진료로 복귀한다. 응급 전화는 환자가 직접 걸 수도 있고(하드웨어 인터럽트), 병원 내부 알람(소프트웨어 인터럽트)일 수도 있다.
원리: 인터럽트 종류
섹션 제목: “원리: 인터럽트 종류”| 구분 | 하드웨어 인터럽트 | 소프트웨어 인터럽트 |
|---|---|---|
| 발생 주체 | 외부 장치 (키보드, NIC, 디스크) | 프로그램 내부 (시스템 콜, 예외) |
| 비동기성 | 언제든 발생 가능 (비동기) | 특정 명령 실행 시 발생 (동기) |
| 예시 | 키 입력, 네트워크 패킷 도착, 타이머 | int 0x80, syscall, 0으로 나누기 |
| CPU 예측 | 예측 불가 | 예측 가능 |
인터럽트 처리 과정
섹션 제목: “인터럽트 처리 과정”1. 인터럽트 신호 발생 (예: 네트워크 패킷 도착) ↓2. CPU: 현재 실행 중인 명령어 완료 후 중단 ↓3. 현재 레지스터 상태를 스택에 저장 (컨텍스트 저장) ↓4. IDT(Interrupt Descriptor Table)에서 ISR 주소 조회 ↓5. ISR(Interrupt Service Routine) 실행 - NIC 드라이버: 패킷을 커널 버퍼에 복사 - 키보드 드라이버: 입력값을 버퍼에 저장 ↓6. 저장했던 레지스터 상태 복원 ↓7. 중단된 지점부터 실행 재개실무 연결: 네트워크 패킷 → Node.js 콜백까지
섹션 제목: “실무 연결: 네트워크 패킷 → Node.js 콜백까지”[네트워크 카드(NIC)] 패킷 수신 ↓ 하드웨어 인터럽트 발생[CPU] 현재 작업 중단, ISR 실행 ↓ NIC 드라이버: 패킷을 커널 소켓 버퍼에 복사[Linux epoll] 해당 소켓 fd에 이벤트 발생 감지 ↓ epoll_wait() 반환[libuv Event Loop] 이벤트 큐에 등록 ↓ 메인 스레드에서 처리[Node.js 콜백] req.on('data', callback) 실행epoll 내부 구조: epoll은 단순한 API가 아니라 커널 내부에 복잡한 자료구조를 가진다.
- Red-Black Tree:
epoll_ctl()로 등록한 모든 fd를 관리. fd 추가/삭제가 O(log n) - Ready List: I/O가 준비된 fd를 연결 리스트로 유지.
epoll_wait()호출 시 이 리스트만 반환하므로 O(1) - Wait Queue:
epoll_wait()에서 블로킹된 프로세스를 관리
이 구조 덕분에 epoll은 수만 개의 fd를 모니터링하면서도 실제로 이벤트가 발생한 fd만 효율적으로 반환한다. 반면 select()/poll()은 매번 모든 fd를 순회(O(n))해야 하므로 fd가 많아지면 급격히 느려진다.
epoll ET(Edge-Triggered) 모드 silent failure: EPOLLET 플래그를 사용하면 fd 상태가 변할 때만 이벤트가 한 번 발생한다. 이 이벤트 기회를 놓치면 커널이 다시 알려주지 않아 데이터가 버퍼에 있어도 epoll_wait()이 영원히 블로킹되는 silent hang 이 발생한다.
# ET 모드 silent failure 시나리오1. 파이프에 2KB 데이터 수신 → epoll_wait() 이벤트 발생 (1회)2. read()로 1KB만 읽고 루프 탈출 (버퍼에 1KB 잔류)3. 다시 epoll_wait() 호출 → 새로운 "변화"가 없으므로 이벤트 미발생 → 무한 블로킹 → 남은 1KB 데이터는 읽히지 않음 (silent data isolation)ET 모드 필수 패턴: 반드시 아래 두 조건을 지켜야 한다.
- fd는
O_NONBLOCK(논블로킹)으로 설정 read()/write()가EAGAIN을 반환할 때까지 루프로 읽기
# strace로 ET silent failure 진단# EAGAIN 없이 epoll_wait으로 돌아가는 패턴 확인strace -p <PID> -e trace=epoll_wait,read 2>&1 | grep -A2 "epoll_wait"
# 정상 패턴 (EAGAIN까지 읽음):# read(5, ...) = 1024# read(5, ...) = 1024# read(5, ...) = -1 EAGAIN ← 여기서 루프 탈출# epoll_wait(...) ← 그 다음 대기
# 비정상 패턴 (EAGAIN 없이 epoll_wait 복귀):# read(5, ...) = 1024 ← 일부만 읽고# epoll_wait(...) ← 바로 대기 → silent hang 위험📖 출처: epoll(7) Linux manual page — man7.org, The edge-triggered misunderstanding — LWN.net
📖 더 보기: Mastering epoll: The Engine Behind High-Performance Linux Networking — Medium — epoll의 내부 자료구조와 동작 원리를 상세히 설명
코드 예시: epoll 기반 I/O 이벤트 (Node.js 관점)
섹션 제목: “코드 예시: epoll 기반 I/O 이벤트 (Node.js 관점)”// Node.js HTTP 서버 - 내부적으로 epoll 사용const http = require("http");
const server = http.createServer((req, res) => { // 이 콜백은 다음 경로로 실행됨: // 네트워크 패킷 도착 → 하드웨어 인터럽트 → NIC 드라이버 // → 커널 소켓 버퍼 → epoll 이벤트 → libuv → 이 콜백 res.writeHead(200); res.end("Hello World\n");});
server.listen(3000, () => { console.log("서버 시작: http://localhost:3000");});예상 출력:
서버 시작: http://localhost:3000# 다른 터미널에서 확인curl http://localhost:3000# 출력: Hello World# epoll 시스템 콜 확인 (서버 실행 중)strace -p $(pgrep node) -e epoll_wait,epoll_ctl 2>&1 | head -20예상 출력:
epoll_wait(5, [{events=EPOLLIN, data={u32=16, u64=16}}], 1024, 0) = 1epoll_ctl(5, EPOLL_CTL_ADD, 7, {events=EPOLLIN|EPOLLOUT|...}) = 0epoll_wait(5, [], 1024, 0) = 03.4 IPC (Inter-Process Communication, 프로세스 간 통신)
섹션 제목: “3.4 IPC (Inter-Process Communication, 프로세스 간 통신)”비유: 회사 내 부서 간 소통
섹션 제목: “비유: 회사 내 부서 간 소통”각 부서(프로세스)는 독립된 사무실(독립 메모리 공간)에서 일하며 서로 직접 접근할 수 없다. 소통 방법은 여러 가지다:
- Pipe: 사내 파이프(문서함) — 한 방향으로만 전달 가능
- Named Pipe: 공용 게시판 — 이름이 있어서 누구든 접근 가능
- Socket: 전화 — 네트워크 너머 다른 건물과도 통신
- Shared Memory: 공용 화이트보드 — 가장 빠르지만 동시 수정 주의 필요
- Message Queue: 메시지함 — 순서대로 처리, 비동기 가능
원리 및 비교
섹션 제목: “원리 및 비교”| IPC 방식 | 방향 | 속도 | 특징 | 사용 예시 |
|---|---|---|---|---|
| Pipe | 단방향 | 빠름 | 부모↔자식 프로세스 전용 | child_process.spawn() |
| Named Pipe (FIFO) | 단방향 | 빠름 | 임의 프로세스 간 통신 가능 | 쉘 파이프 (|) |
| Unix Domain Socket | 양방향 | 매우 빠름 | 동일 호스트 내 소켓 통신 | Docker API, Nginx |
| TCP Socket | 양방향 | 보통 | 네트워크 너머 통신 | HTTP, gRPC |
| Shared Memory | 양방향 | 가장 빠름 | 직접 메모리 공유, 동기화 필요 | Redis (같은 호스트) |
| Message Queue | 비동기 | 빠름 | OS 레벨 큐 (mq_send) | 시스템 로그, RabbitMQ 아이디어 기반 |
IPC 선택 매트릭스 (정량 기준)
섹션 제목: “IPC 선택 매트릭스 (정량 기준)”실측 벤치마크(ipc-bench, Baeldung Linux IPC 비교)를 기반으로 한 선택 임계값이다.
| 판단 기준 | 임계값 | 권장 메커니즘 |
|---|---|---|
| 메시지 크기 < 4KB | 소형 메시지 | Named Pipe (최고 318 Mbps @ 100B) |
| 메시지 크기 ≥ 64KB | 대형 메시지/스트림 | Unix Domain Socket (41,334 Mbps @ 1MB) 또는 Shared Memory |
| 지연 요구 < 10μs | 극저지연 | Shared Memory + spinlock (복사 없음, 커널 개입 0) |
| 프로세스 수 ≥ N:M (다대다) | 팬아웃 구조 | Message Queue 또는 Unix Socket (Pipe는 1:1 전용) |
| 네트워크 경계 필요 | 다른 호스트 간 | TCP Socket (유일한 선택) |
| 같은 호스트, 서로 다른 프로세스 | 로컬 IPC | Unix Domain Socket (TCP 대비 20~40% 레이턴시 절감) |
결정 흐름:
다른 호스트? → YES: TCP Socket → NO: 메시지 크기 ≥ 64KB이고 극저지연 필요? → YES: Shared Memory (동기화 구현 필수) → NO: 단방향 스트림(부모↔자식)? → YES: Pipe → NO: Unix Domain Socket (기본 선택)📖 출처: IPC Performance Comparison — Baeldung on Linux, ipc-bench GitHub (goldsborough)
코드 예시 1: Pipe (Node.js cluster)
섹션 제목: “코드 예시 1: Pipe (Node.js cluster)”const cluster = require("cluster");
if (cluster.isPrimary) { // 마스터 프로세스 const worker = cluster.fork();
// 워커에게 메시지 전송 (내부적으로 Pipe 사용) worker.send({ type: "task", data: "작업 데이터" });
// 워커로부터 메시지 수신 worker.on("message", (msg) => { console.log("[마스터] 워커 응답:", msg); });} else { // 워커 프로세스 process.on("message", (msg) => { console.log("[워커] 마스터 메시지 수신:", msg);
// 결과 반환 process.send({ type: "result", data: "처리 완료" }); });}예상 출력:
[워커] 마스터 메시지 수신: { type: 'task', data: '작업 데이터' }[마스터] 워커 응답: { type: 'result', data: '처리 완료' }코드 예시 2: Unix Domain Socket
섹션 제목: “코드 예시 2: Unix Domain Socket”const net = require("net");const fs = require("fs");
const SOCKET_PATH = "/tmp/my-app.sock";
// 기존 소켓 파일 제거if (fs.existsSync(SOCKET_PATH)) fs.unlinkSync(SOCKET_PATH);
const server = net.createServer((socket) => { socket.on("data", (data) => { console.log("수신:", data.toString()); socket.write("응답: " + data.toString()); });});
server.listen(SOCKET_PATH, () => { console.log(`Unix Socket 서버 시작: ${SOCKET_PATH}`);});# 클라이언트로 테스트echo "안녕하세요" | nc -U /tmp/my-app.sock예상 출력:
Unix Socket 서버 시작: /tmp/my-app.sock수신: 안녕하세요응답: 안녕하세요Unix Domain Socket은 TCP Socket보다 20~40% 빠르다. 네트워크 스택을 거치지 않고 커널 내에서 직접 데이터를 전달하기 때문이다.
왜 Unix Domain Socket이 TCP보다 빠른가? TCP Socket은 같은 호스트 내 통신이라도 전체 네트워크 스택을 거친다: 소켓 API → TCP 계층(시퀀스 번호, ACK, 체크섬 계산) → IP 계층(라우팅 테이블 조회) → 루프백 인터페이스. Unix Domain Socket은 이 모든 과정을 건너뛰고 커널 내부의 버퍼 간 직접 데이터 복사만 수행한다. 같은 서버에서 Redis, PostgreSQL에 연결할 때 Unix Socket을 사용하면 레이턴시가 눈에 띄게 줄어든다.
코드 예시 3: Named Pipe (FIFO)
섹션 제목: “코드 예시 3: Named Pipe (FIFO)”# 터미널 1: FIFO 생성 및 읽기mkfifo /tmp/myfifocat /tmp/myfifo
# 터미널 2: 데이터 쓰기echo "Hello from process 2" > /tmp/myfifo예상 출력 (터미널 1):
Hello from process 24. 실무에서 어떻게 쓰이나
섹션 제목: “4. 실무에서 어떻게 쓰이나”Node.js 백엔드 엔지니어 관점
섹션 제목: “Node.js 백엔드 엔지니어 관점”| 상황 | 관련 시스템 콜/개념 | 실무 영향 |
|---|---|---|
fs.readFile() 호출 | open, read, close | 파일 디스크립터 누수 주의 |
| HTTP 서버가 요청 수신 | accept, epoll_wait | 인터럽트 → epoll → 콜백 |
child_process.spawn() | fork, exec, pipe | 자식 프로세스 IPC |
| Docker 컨테이너 간 통신 | Unix Socket, TCP Socket | 같은 호스트면 Unix Socket이 빠름 |
| Redis 연결 | TCP Socket (또는 Unix Socket) | 같은 호스트면 Unix Socket 권장 |
cluster 모듈 사용 | fork, Pipe, Signal | 멀티코어 활용 |
AWS/인프라 관점
섹션 제목: “AWS/인프라 관점”- Lambda Cold Start: 새 컨테이너 프로세스 생성 =
fork+exec시스템 콜. Cold Start가 느린 이유 중 하나 - ALB → EC2: 네트워크 패킷 수신 → NIC 인터럽트 → 커널 네트워크 스택 → Nginx → Node.js
- ECS 컨테이너 간 통신: 같은 Task라면 Unix Socket, 다른 Task라면 TCP
5. 내 업무와의 연결고리
섹션 제목: “5. 내 업무와의 연결고리”NestJS 서비스에서의 연결
섹션 제목: “NestJS 서비스에서의 연결”// NestJS - 파일 업로드 처리@Post('upload')@UseInterceptors(FileInterceptor('file'))async uploadFile(@UploadedFile() file: Express.Multer.File) { // 내부적으로 발생하는 시스템 콜: // 1. multipart 파싱 중 read() - 요청 바디 읽기 // 2. open() - 임시 파일 생성 // 3. write() - 파일 내용 저장 // 4. rename() - 최종 위치로 이동 // 5. close() - 파일 디스크립터 해제
return { filename: file.originalname, size: file.size };}// NestJS - 데이터베이스 쿼리 (TCP Socket → 시스템 콜)@Injectable()export class UserService { constructor(private prisma: PrismaService) {}
async findUser(id: number) { // 내부 흐름: // 1. TCP Socket send() → PostgreSQL로 쿼리 전송 // 2. epoll_wait() → PostgreSQL 응답 대기 // 3. recv() → 응답 수신 (하드웨어 인터럽트 → epoll) return this.prisma.user.findUnique({ where: { id } }); }}BackOps 엔지니어로서 실용적 포인트
섹션 제목: “BackOps 엔지니어로서 실용적 포인트”- 파일 디스크립터 모니터링: 각 연결(HTTP, DB, Redis)은 fd를 소비한다.
lsof -p $(pgrep node) | wc -l로 모니터링 - IPC 성능 최적화: 같은 서버에서 Redis 연결 시 Unix Socket 사용으로 레이턴시 단축
cluster모듈 사용 시: Worker 프로세스 간 통신은 Pipe 기반임을 인지하고 대용량 데이터 전송 시 주의
6. 비슷한 개념과 비교
섹션 제목: “6. 비슷한 개념과 비교”System Call vs Library Call vs API Call
섹션 제목: “System Call vs Library Call vs API Call”| 구분 | System Call | Library Call | API Call |
|---|---|---|---|
| 실행 위치 | 커널 모드 | 유저 모드 | 유저 모드 |
| 예시 | read(), write() | fread(), printf() | axios.get(), fs.readFile() |
| 비용 | 높음 (모드 전환) | 낮음 | 낮음 (내부적으로 시스콜 포함) |
| 추상화 레벨 | 최하위 (OS 레벨) | 중간 (C 표준 라이브러리) | 최상위 (애플리케이션 레벨) |
fread()는 C 표준 라이브러리 함수로, 내부적으로 read() 시스템 콜을 호출한다. fs.readFile()은 libuv를 통해 read() 시스템 콜을 호출한다.
Hardware Interrupt vs Software Interrupt vs Exception
섹션 제목: “Hardware Interrupt vs Software Interrupt vs Exception”| 구분 | 발생 원인 | 예시 | 비동기 여부 |
|---|---|---|---|
| 하드웨어 인터럽트 | 외부 장치 신호 | 키보드 입력, 패킷 수신, 타이머 | 비동기 |
| 소프트웨어 인터럽트 | 프로그램 명령 | int 0x80, syscall | 동기 |
| 예외(Exception) | 오류 상황 | 0으로 나누기, 페이지 폴트, 세그폴트 | 동기 |
IPC 방식 상세 비교
섹션 제목: “IPC 방식 상세 비교”| 방식 | 성능 | 구현 복잡도 | 네트워크 가능 | Node.js 지원 |
|---|---|---|---|---|
| Pipe | ★★★★ | 낮음 | 불가 | child_process |
| Unix Socket | ★★★★★ | 중간 | 불가 | net 모듈 |
| TCP Socket | ★★★ | 중간 | 가능 | net, http |
| Shared Memory | ★★★★★ | 높음 | 불가 | SharedArrayBuffer |
| Message Queue | ★★★★ | 중간 | 불가 | 외부 라이브러리 필요 |
6.5. 트러블슈팅
섹션 제목: “6.5. 트러블슈팅”트러블슈팅 1: EMFILE: too many open files
섹션 제목: “트러블슈팅 1: EMFILE: too many open files”증상:
Error: EMFILE: too many open files, open '/var/log/app.log' at Object.openSync (node:fs:600:3) at Object.readFileSync (node:fs:468:35)원인:
리눅스는 프로세스당 열 수 있는 파일 디스크립터(fd) 수를 제한한다. 기본값은 1024개다. HTTP 연결, DB 연결, 파일 핸들이 누적되어 한계에 도달하거나, close()를 호출하지 않아 fd가 누수되면 발생한다.
해결 방법:
# 1. 현재 제한 확인ulimit -n# 출력: 1024
# 2. 현재 Node.js 프로세스가 사용 중인 fd 수 확인lsof -p $(pgrep node) | wc -l
# 3. 임시 해결: 현재 세션에서 제한 늘리기ulimit -n 65536
# 4. 영구 해결: /etc/security/limits.conf 수정sudo vi /etc/security/limits.conf# 아래 내용 추가:# * soft nofile 65536# * hard nofile 65536
# 5. systemd 서비스로 실행 중인 경우# /etc/systemd/system/my-app.service 에 추가:# [Service]# LimitNOFILE=65536sudo systemctl daemon-reload && sudo systemctl restart my-app
# 6. fd 누수 여부 확인 (fd 수가 계속 증가하는지 모니터링)watch -n 1 'lsof -p $(pgrep node) | wc -l'Node.js 코드 레벨 방어:
// 잘못된 코드 - fd 누수 가능const fd = fs.openSync("/var/log/app.log", "r");const data = fs.readSync(fd, buffer, 0, 1024, 0);// close 누락 시 fd 누수!
// 올바른 코드const fd = fs.openSync("/var/log/app.log", "r");try { const data = fs.readSync(fd, buffer, 0, 1024, 0);} finally { fs.closeSync(fd); // 반드시 close}트러블슈팅 2: strace로 느린 시스템 콜 추적
섹션 제목: “트러블슈팅 2: strace로 느린 시스템 콜 추적”증상: NestJS 서비스가 특정 요청에서 응답 시간이 간헐적으로 수 초 이상 걸림. 코드 레벨에서는 원인 불명.
원인: 파일 I/O, 네트워크 연결, 또는 시스템 콜 자체가 블로킹되고 있을 가능성.
해결 방법:
# 1. 실행 중인 Node.js에 strace 붙이기strace -p $(pgrep -f "node dist/main") -T -e trace=network,file 2>&1 | head -100# -T: 각 시스템 콜 소요 시간 표시# -e trace=network,file: 네트워크/파일 관련 시스콜만 표시
# 2. 특정 시간 이상 걸린 시스콜 필터링strace -p $(pgrep node) -T 2>&1 | awk -F'<' '{if($2+0 > 0.1) print}'# 0.1초 이상 걸린 시스콜만 출력
# 3. 시스콜 통계 수집 (30초간)timeout 30 strace -p $(pgrep node) -c 2>&1예상 출력 (느린 시스콜 발견):
connect(5, {sa_family=AF_INET, sin_port=htons(5432), ...}, 16) = -1 ETIMEDOUT <5.003456>→ PostgreSQL 연결 타임아웃이 5초 발생. 연결 풀 설정 문제 확인.
트러블슈팅 3: 프로세스 간 통신(IPC) 메시지 손실
섹션 제목: “트러블슈팅 3: 프로세스 간 통신(IPC) 메시지 손실”증상:
cluster 모듈로 실행 중인 서버에서 Worker 프로세스에게 process.send()로 전송한 메시지가 간헐적으로 수신되지 않음.
원인:
process.send()는 내부적으로 Pipe를 사용하며, Pipe 버퍼 크기는 64KB(Linux 기본값)로 제한된다. 대용량 메시지를 빠르게 전송하거나 Worker가 죽은 상태에서 send를 호출하면 메시지가 유실된다.
해결 방법:
// 잘못된 코드 - Worker 상태 확인 없이 sendcluster.workers[id].send(largeData);
// 올바른 코드 - 상태 확인 + 에러 처리const worker = cluster.workers[id];
if (worker && !worker.isDead()) { worker.send(data, (err) => { if (err) { console.error("IPC 전송 실패:", err.message); // 재시도 로직 또는 대체 처리 } });}
// 대용량 데이터는 Pipe 대신 Shared Memory 또는 Redis 활용// worker.send({ type: 'task', id: taskId }); // ID만 전송// 실제 데이터는 Redis에서 taskId로 조회# Pipe 버퍼 크기 확인 (Linux)cat /proc/sys/fs/pipe-max-size# 출력: 1048576 (1MB, 커널 4.x 이상)
# Pipe 버퍼 크기 증가 (일시적)echo 2097152 > /proc/sys/fs/pipe-max-size트러블슈팅 4: SIGPIPE: broken pipe 오류
섹션 제목: “트러블슈팅 4: SIGPIPE: broken pipe 오류”증상:
Error: write EPIPE at Socket.write (node:net:799:40) ...원인:
Unix Socket 또는 TCP Socket으로 데이터를 쓰려 했는데, 상대방이 이미 연결을 끊은 상태다. 커널이 SIGPIPE 신호를 보내고, 기본 동작은 프로세스 종료다.
해결 방법:
// SIGPIPE 무시 (Node.js는 기본적으로 이미 처리하지만 명시적으로 추가 가능)process.on("SIGPIPE", () => { // 조용히 무시});
// 소켓 에러 핸들링socket.on("error", (err) => { if (err.code === "EPIPE" || err.code === "ECONNRESET") { console.log("클라이언트가 연결을 끊었습니다."); socket.destroy(); } else { throw err; }});7. 체크리스트
섹션 제목: “7. 체크리스트”- 시스템 콜이 무엇인지, 왜 필요한지 한 문장으로 설명할 수 있다
- Kernel Mode(Ring 0)와 User Mode(Ring 3)의 차이를 설명할 수 있다
- 모드 전환(User → Kernel)이 언제 발생하고, 비용이 얼마나 드는지 이해한다
- 하드웨어 인터럽트와 소프트웨어 인터럽트의 차이를 설명할 수 있다
- 인터럽트 처리 과정(ISR 실행 → 복귀) 흐름을 순서대로 말할 수 있다
-
fs.readFile()이 내부적으로 어떤 시스템 콜을 호출하는지 설명할 수 있다 - 네트워크 패킷이 도착해서 Node.js 콜백이 실행되기까지의 흐름을 설명할 수 있다
- IPC 5가지 방식(Pipe, Named Pipe, Socket, Shared Memory, Message Queue)의 특징과 차이를 안다
-
strace명령어로 프로세스의 시스템 콜을 추적할 수 있다 -
EMFILE: too many open files에러 원인과 해결 방법을 안다 -
ulimit -n명령어로 fd 제한을 확인하고 변경할 수 있다 - NestJS 프로젝트에서 시스템 콜이 어디서 발생하는지 연결할 수 있다
8. 핵심 키워드
섹션 제목: “8. 핵심 키워드”| 키워드 | 설명 |
|---|---|
| System Call | 유저 프로그램이 커널 기능을 요청하는 인터페이스 |
| Ring 0 / Ring 3 | CPU 보호 레벨: 0=커널 모드, 3=유저 모드 |
| Mode Switch | 유저↔커널 모드 전환, 시스템 콜 시 발생 |
| Interrupt | CPU 현재 작업을 중단시키는 신호 |
| ISR | Interrupt Service Routine, 인터럽트 처리 함수 |
| IDT | Interrupt Descriptor Table, 인터럽트 번호→ISR 매핑 테이블 |
| epoll | Linux의 비동기 I/O 이벤트 감시 메커니즘 |
| File Descriptor (fd) | 열린 파일/소켓의 정수 식별자 |
| IPC | Inter-Process Communication, 프로세스 간 통신 |
| Pipe | 단방향 바이트 스트림, 부모↔자식 프로세스 통신 |
| Unix Domain Socket | 동일 호스트 내 소켓 통신, TCP보다 빠름 |
| Shared Memory | 가장 빠른 IPC, SharedArrayBuffer |
| libuv | Node.js의 비동기 I/O 라이브러리, 시스콜 추상화 |
| strace | 프로세스의 시스템 콜을 추적하는 디버깅 도구 |
| ulimit | 프로세스 자원 제한 설정 명령어 |
| EMFILE | Too many open files 에러 코드 |
8.3. 심화 개념
섹션 제목: “8.3. 심화 개념”eBPF — 시스템 콜 모니터링의 현대적 방법
섹션 제목: “eBPF — 시스템 콜 모니터링의 현대적 방법”eBPF(extended Berkeley Packet Filter) 는 커널을 수정하거나 모듈을 로드하지 않고 커널 내부에서 프로그램을 실행할 수 있는 기술이다. strace보다 훨씬 낮은 오버헤드로 시스템 콜을 추적할 수 있다.
strace 방식: eBPF 방식:ptrace() → 모든 syscall마다 eBPF 프로그램 → 커널 내부에서 프로세스 중단 → 오버헤드 큼 직접 실행 → 오버헤드 매우 작음 (프로덕션 사용 위험) (프로덕션 사용 가능)# bpftrace로 특정 프로세스의 시스템 콜 추적# (bpftrace 설치 필요: apt install bpftrace)bpftrace -e 'tracepoint:syscalls:sys_enter_* /pid == 12345/ { printf("%s\n", probe);}'
# opensnoop: 어떤 파일을 여는지 실시간 추적opensnoop -p $(pgrep node)# PID COMM FD ERR PATH# 12345 node 15 0 /app/config.json# 12345 node 16 0 /tmp/upload_abc123
# execsnoop: 새로운 프로세스 실행 추적execsnoop
# 특정 시스템 콜 레이턴시 분포 측정bpftrace -e 'tracepoint:syscalls:sys_enter_read { @start[tid] = nsecs; } tracepoint:syscalls:sys_exit_read /@start[tid]/ { @latency = hist((nsecs - @start[tid]) / 1000); delete(@start[tid]); }'BCC Tools 활용: bcc(BPF Compiler Collection) 패키지에는 수십 개의 실무용 eBPF 도구가 포함되어 있다.
| 도구 | 기능 |
|---|---|
opensnoop | 파일 open 추적 |
tcpconnect | TCP 연결 시도 추적 |
runqlat | CPU Run Queue 대기 시간 분포 |
biolatency | 디스크 I/O 레이턴시 분포 |
profile | CPU 프로파일링 (flame graph용) |
📖 더 보기: BPF Performance Tools — Brendan Gregg — eBPF/BPF 기반 성능 분석 도구의 권위 있는 레퍼런스. 저자가 Netflix 수석 엔지니어 (고급)
Seccomp — 시스템 콜 보안 필터
섹션 제목: “Seccomp — 시스템 콜 보안 필터”Seccomp(Secure Computing Mode) 는 프로세스가 호출할 수 있는 시스템 콜을 제한하는 Linux 보안 기능이다. Docker와 Kubernetes는 기본적으로 Seccomp 프로파일을 적용하여 컨테이너가 위험한 시스템 콜을 사용하지 못하게 막는다.
# Docker 기본 Seccomp 프로파일 확인docker inspect <container_id> | grep -A5 Seccomp# "SecurityOpt": ["seccomp=..."]
# Seccomp 프로파일 없이 실행 (보안 취약)docker run --security-opt seccomp=unconfined ...
# 현재 프로세스의 Seccomp 상태 확인cat /proc/self/status | grep Seccomp# Seccomp: 2 (0=없음, 1=strict, 2=filter 모드)Docker가 기본 차단하는 주요 시스템 콜:
| 시스템 콜 | 이유 |
|---|---|
ptrace | 다른 프로세스 디버깅/제어 — 컨테이너 탈출에 악용 가능 |
reboot | 호스트 재시작 방지 |
kexec_load | 커널 교체 방지 |
mount | 파일시스템 마운트 방지 |
create_module | 커널 모듈 로드 방지 |
Kubernetes securityContext 설정:
apiVersion: v1kind: Podspec: securityContext: seccompProfile: type: RuntimeDefault # 런타임 기본 Seccomp 프로파일 적용 containers: - name: api securityContext: allowPrivilegeEscalation: false # setuid 바이너리 실행 방지 readOnlyRootFilesystem: true # 루트 파일시스템 읽기 전용 runAsNonRoot: true # root로 실행 금지 capabilities: drop: - ALL # 모든 Linux capabilities 제거 add: - NET_BIND_SERVICE # 1024 이하 포트 바인딩만 허용📖 더 보기: Docker’s default seccomp profile — Docker 공식 문서 — Docker가 기본으로 차단하는 44개 시스템 콜 목록과 커스텀 프로파일 작성법 (중급)
8.5. 추천 리소스
섹션 제목: “8.5. 추천 리소스”📚 추천 리소스
섹션 제목: “📚 추천 리소스”- 📖 CPU Rings, Privilege, and Protection — manybutfinite.com — Ring 0/3 보호 메커니즘을 그림과 함께 알기 쉽게 설명 (입문)
- 📖 Node.js - Don’t Block the Event Loop — nodejs.org 공식 문서 — 이벤트 루프를 블로킹하지 않아야 하는 이유, Worker Pool 활용법 (입문)
- 📖 What Makes System Calls Expensive — codingconfessions.com — 파이프라인 플러시, KPTI, TLB 관점에서 시스콜 비용을 분석한 심층 글 (중급)
- 📖 Mastering epoll: The Engine Behind High-Performance Linux Networking — Medium — epoll 내부 자료구조(Red-Black Tree, Ready List)와 동작 원리 상세 설명 (중급)
- 📖 BPF Performance Tools — Brendan Gregg — eBPF 기반 프로덕션 성능 진단 도구의 표준 레퍼런스 (고급)
9. 직접 확인해보기
섹션 제목: “9. 직접 확인해보기”실습 1: strace로 Node.js 시스템 콜 추적
섹션 제목: “실습 1: strace로 Node.js 시스템 콜 추적”# 간단한 파일 읽기 스크립트 작성cat > /tmp/test-syscall.js << 'EOF'const fs = require('fs');fs.readFileSync('/etc/hostname');EOF
# strace로 시스템 콜 추적strace -e trace=openat,read,close,write node /tmp/test-syscall.js 2>&1 | grep -v "^strace:"예상 출력 (일부):
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libdl.so.2", O_RDONLY|O_CLOEXEC) = 3...openat(AT_FDCWD, "/etc/hostname", O_RDONLY|O_CLOEXEC) = 16read(16, "my-server\n", 4096) = 10close(16) = 0실습 2: 현재 열린 파일 디스크립터 확인
섹션 제목: “실습 2: 현재 열린 파일 디스크립터 확인”# Node.js 서버 실행 후 (백그라운드)node -e "require('http').createServer().listen(3000)" &NODE_PID=$!
# fd 목록 확인ls -la /proc/$NODE_PID/fd
# fd 수 카운트ls /proc/$NODE_PID/fd | wc -l
# 정리kill $NODE_PID예상 출력:
total 0dr-x------ 2 user user 0 Apr 2 10:00 .dr-xr-xr-x 9 user user 0 Apr 2 10:00 ..lrwx------ 1 user user 64 Apr 2 10:00 0 -> /dev/pts/0lrwx------ 1 user user 64 Apr 2 10:00 1 -> /dev/pts/0lrwx------ 1 user user 64 Apr 2 10:00 2 -> /dev/pts/0lrwx------ 1 user user 64 Apr 2 10:00 5 -> anon_inode:[eventpoll]lrwx------ 1 user user 64 Apr 2 10:00 7 -> socket:[12345]...→ fd 5가 eventpoll임을 확인 (epoll 인스턴스). fd 7이 TCP 서버 소켓.
실습 3: ulimit 및 fd 제한 확인
섹션 제목: “실습 3: ulimit 및 fd 제한 확인”# 현재 프로세스 fd 제한 확인ulimit -n# 출력: 1024
# 소프트/하드 제한 모두 확인ulimit -Sn # 소프트 제한ulimit -Hn # 하드 제한
# 시스템 전체 fd 제한cat /proc/sys/fs/file-max# 출력: 9223372036854775807 (Linux 5.x 이상 거의 무제한)
# 현재 열린 fd 수 (시스템 전체)cat /proc/sys/fs/file-nr# 출력: 현재열린수 0 최대값# 예: 3456 0 9223372036854775807실습 4: IPC Pipe 통신 테스트
섹션 제목: “실습 4: IPC Pipe 통신 테스트”# Node.js child process IPC 테스트node -e "const { spawn } = require('child_process');const child = spawn('node', ['-e', \" process.on('message', (m) => { console.error('워커 수신:', JSON.stringify(m)); process.send({ reply: 'ok' }); });\"], { stdio: ['pipe', 'pipe', 'pipe', 'ipc'] });
child.stderr.on('data', d => process.stdout.write(d));child.on('message', m => console.log('마스터 수신:', JSON.stringify(m)));child.send({ hello: 'world' });setTimeout(() => child.kill(), 500);"예상 출력:
워커 수신: {"hello":"world"}마스터 수신: {"reply":"ok"}실습 5: Unix Domain Socket 성능 비교
섹션 제목: “실습 5: Unix Domain Socket 성능 비교”# TCP vs Unix Socket 성능 비교 (nc 사용)# TCPtime (for i in $(seq 1 1000); do echo "test" | nc -q0 127.0.0.1 3000; done) 2>&1
# Unix Socket (서버가 /tmp/test.sock에서 대기 중이라면)time (for i in $(seq 1 1000); do echo "test" | nc -q0 -U /tmp/test.sock; done) 2>&1# Unix Socket이 보통 20~40% 빠름10. 한 줄 요약
섹션 제목: “10. 한 줄 요약”유저 프로그램은 시스템 콜이라는 공식 창구를 통해서만 커널(Ring 0)에 접근할 수 있고, 하드웨어 이벤트(네트워크 패킷, 키 입력)는 인터럽트로 CPU에 알려지며, 프로세스 간 통신(IPC)은 Pipe/Socket/Shared Memory 중 상황에 맞는 방식을 선택해야 한다. Node.js의 모든 비동기 I/O는 이 세 가지 원리 위에서 동작한다.