User Mode / Ring 3
Node.js, Python, Chrome 같은 일반 애플리케이션 코드가 실행되는 제한 권한 영역이다.
버그가 나도 해당 프로세스만 종료되어야 할 때시스템 콜(System Call, syscall)은 유저 프로그램이 파일, 네트워크, 프로세스 같은 커널 자원을 안전하게 요청하는 공식 통로다. 인터럽트(Interrupt)는 CPU가 실행 중인 일을 잠시 멈추고 하드웨어나 예외 이벤트를 먼저 처리하게 만드는 신호다.
이 문서의 핵심은 “앱이 커널을 직접 만지지 않는다”는 감각이다. Node.js에서 fs.readFile()을 호출하거나 HTTP 요청을 받을 때도 실제 파일, 소켓, 네트워크 카드는 커널이 관리한다. 애플리케이션은 syscall로 요청하고, 커널은 파일 디스크립터(fd)와 epoll 같은 메커니즘으로 결과를 돌려준다.
Intel 8086 기반 MS-DOS 같은 초기 PC 환경은 단일 권한 모드(real mode)에 가까웠다. 일반 프로그램과 OS가 같은 권한으로 실행되면 편하지만, 서버 운영에 필요한 격리와 안정성이 성립하지 않는다.
보호 모드(protected mode)와 현대 OS는 이 문제를 세 가지 경계로 풀었다.
선행 토픽과의 경계는 다음처럼 나뉜다.
content/topics/L4/linux-basics.mdx는 CLI, 파일, 프로세스, 시그널의 표면을 잡는다. 이 문서는 그 표면 명령이 내부적으로 어떤 커널 경계를 넘는지 설명한다.content/topics/L4/process-thread.mdx는 프로세스, 스레드, fork()/clone()과 실행 단위 의미를 맡는다. 이 문서는 그 실행 단위가 커널 자원을 요청하는 통로를 맡는다.content/topics/L0/nodejs-event-loop.mdx는 이벤트 루프의 런타임 흐름을 맡는다. 이 문서는 “패킷 도착 → 커널 → epoll → libuv 콜백”이라는 OS 쪽 다리를 연결한다.출처: Protected mode — Wikipedia, CPU Rings, Privilege, and Protection — manybutfinite.com
아래 용어는 뒤에서 반복된다. 처음에는 내부 구현보다 “무엇을 구분하기 위한 말인가”를 잡으면 된다.
| 용어 | 첫 이해 | 왜 필요한가 |
|---|---|---|
| 유저 모드(User Mode) | 일반 애플리케이션이 제한된 권한으로 실행되는 CPU 상태 | 앱 버그가 시스템 전체를 망가뜨리지 않게 한다 |
| 커널 모드(Kernel Mode) | Linux 커널과 드라이버가 하드웨어와 모든 메모리 권한을 다루는 CPU 상태 | 파일, 소켓, 메모리 같은 공유 자원을 중앙에서 검증한다 |
| CPU Ring | CPU가 명령 실행 권한을 나누는 보호 레벨 | Ring 3 앱이 Ring 0 커널 명령을 임의로 실행하지 못하게 한다 |
| syscall | 유저 모드 코드가 커널 기능을 요청하는 동기 호출 | read, write, socket, epoll_wait 같은 요청의 입구다 |
| interrupt | CPU에게 지금 처리해야 할 이벤트가 왔음을 알리는 신호 | 네트워크, 타이머, 키보드 같은 비동기 이벤트를 놓치지 않는다 |
| fd(File Descriptor, 파일 디스크립터) | 프로세스가 열린 파일, 소켓, 파이프를 가리키는 작은 정수 핸들 | 커널 객체를 직접 노출하지 않고 번호로 안전하게 참조한다 |
| epoll | Linux가 많은 fd 중 I/O 준비가 된 fd만 깨워주는 이벤트 감시 메커니즘 | 수천~수만 연결을 매번 전체 순회하지 않고 처리한다 |
| IPC(Inter-Process Communication) | 서로 다른 프로세스가 데이터를 주고받는 방법 | 프로세스 메모리는 격리되어 있으므로 별도 통신 경로가 필요하다 |
| EINTR | syscall이 시그널 때문에 중간에 깨졌다는 errno | C/C++ 네이티브 코드에서 재시도하지 않으면 부분 처리로 끝날 수 있다 |
| SIGPIPE / EPIPE | 끊긴 파이프나 소켓에 쓰려고 했다는 신호/오류 | 상대가 이미 닫은 연결에 계속 쓰는 버그를 알려준다 |
일반 함수 호출은 같은 유저 모드 안에서 스택과 레지스터만 바꾸면 된다. syscall은 다르다. CPU가 Ring 3에서 Ring 0으로 전환하고, 커널 스택으로 넘어가고, 커널이 인자를 검증한 뒤 결과를 돌려준다.
Node.js, Python, Chrome 같은 일반 애플리케이션 코드가 실행되는 제한 권한 영역이다.
버그가 나도 해당 프로세스만 종료되어야 할 때Linux 커널과 디바이스 드라이버가 하드웨어, 메모리, 파일, 네트워크를 직접 다루는 영역이다.
공유 자원을 검증하고 조작해야 할 때유저 프로그램이 커널에게 작업을 요청하는 공식 입구다.
`open`, `read`, `write`, `socket`, `accept`, `epoll_wait` 같은 작업을 할 때flowchart TD User["유저 프로그램: Ring 3"] --> Call["syscall 명령어 또는 libc wrapper 호출"] Call --> Switch["CPU 모드 전환: Ring 3 -> Ring 0"] Switch --> Kernel["커널: 권한·인자·fd 검증 후 작업 수행"] Kernel --> Result["결과값 또는 errno 설정"] Result --> Return["Ring 3으로 복귀"] Return --> App["유저 프로그램: 결과 처리"]
수치 감각: syscall 하나는 보통 수백 ns수 µs 수준의 비용을 가진다. 느린 것처럼 보이지 않을 수 있지만, 요청당 syscall이 수십수백 번 반복되고 p99 지연을 보는 서버에서는 누적 비용이 된다. 함수 호출처럼 “공짜에 가까운 호출”이 아니라 권한 경계를 건너는 작업이라고 봐야 한다.
첫 회독에서는 KPTI, TLB, vDSO 이름까지 외울 필요는 없다. syscall이 유저 모드와 커널 모드 사이의 권한 경계를 건너며, hot path에서 반복되면 비용이 누적된다는 점만 잡고 넘어가면 된다.
비용은 단지 함수 호출보다 명령어가 많아서가 아니다. CPU가 유저 스택에서 커널 스택으로 전환하고, 레지스터와 권한 상태를 저장/복원한다. 보안 패치 환경에서는 KPTI(Kernel Page Table Isolation)처럼 유저/커널 page table을 분리하는 기법 때문에 TLB locality가 깨질 수 있다. TLB는 가상 주소 -> 물리 주소 변환 결과를 저장하는 CPU 캐시이고, 이 캐시가 잘 맞지 않으면 메모리 접근 비용이 늘어난다. 그래서 clock_gettime처럼 안전하고 자주 호출되는 일부 기능은 vDSO(virtual Dynamic Shared Object)를 통해 커널 모드 진입 없이 유저 공간에 매핑된 코드에서 처리되기도 한다. 핵심은 syscall을 무조건 피하라는 뜻이 아니라, hot path에서 반복 호출되는 경계 통과를 버퍼링, 배치, 캐시, 비동기화로 줄이는 것이다.
Node.js 코드는 한 줄이지만 커널 관점에서는 여러 syscall로 쪼개진다.
const fs = require("fs");
fs.readFile("/etc/hostname", "utf8", (err, data) => { if (err) throw err; console.log("호스트명:", data.trim());});
console.log("readFile 호출 직후");실행 흐름은 다음과 같다.
fs.readFile() 호출 -> Node.js C++ binding -> libuv thread pool에 파일 I/O 위임 -> open("/etc/hostname") = fd 5 -> read(5, buffer, 4096) = 읽은 바이트 수 -> close(5) -> 완료 콜백이 Event Loop 큐로 이동여기서 중요한 점은 fs.readFile() 자체가 커널 함수가 아니라는 것이다. Node.js와 libuv가 syscall을 대신 호출하고, 커널은 fd 5라는 핸들을 통해 열린 파일을 식별한다.
같은 모델은 C 코드에서도 그대로 보인다. 아래는 include, 버퍼 선언, 에러 처리를 생략한 축약 예시다. C 문법을 읽지 못해도 괜찮다. open -> read -> write -> close 순서로 커널에 요청이 들어간다는 흐름만 보면 된다.
언어와 런타임이 달라도 커널 경계에서는 결국 fd를 얻고, 읽고, 닫는다.
int fd = open("/etc/hostname", O_RDONLY);ssize_t n = read(fd, buf, sizeof(buf));write(1, buf, n);close(fd);readFileSync()도 마찬가지다. 아래처럼 strace -c로 보면 Node.js 초기화와 파일 읽기 과정에서 openat, read, close, mmap 같은 syscall이 여러 번 발생한다.
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초심자가 자주 놓치는 지점은 fs.readFile()도, read()도 모두 “호출”이라는 점이다. 하지만 둘은 경계가 다르다.
| 이름 | 실행되는 곳 | 예시 | 커널 모드 전환 여부 |
|---|---|---|---|
| API call | 애플리케이션/런타임 추상화 | fs.readFile(), axios.get() | 직접은 아님. 내부에서 syscall 가능 |
| library call | 유저 공간 라이브러리 | fread(), printf() | 직접은 아님. 필요하면 syscall 호출 |
| system call | 유저 공간에서 커널로 넘어가는 입구 | read(), write(), socket() | 예. Ring 3에서 Ring 0으로 전환 |
반례로 console.log()는 화면에 글자를 찍는 API처럼 보이지만, 최종적으로 stdout(fd 1)에 write()를 호출한다. 반대로 순수 계산 함수나 문자열 조작 함수는 아무리 많이 호출해도 커널 자원이 필요 없으면 syscall이 아니다.
syscall 비용을 줄이는 방향은 세 가지다.
| 방식 | 핵심 아이디어 | 이 문서에서의 위치 |
|---|---|---|
| 버퍼링 | 작은 read/write를 모아서 syscall 횟수를 줄인다 | 파일 I/O, 네트워크 송수신의 기본 최적화 |
| 이벤트 감시(epoll) | 준비된 fd만 깨워서 불필요한 반복 확인을 줄인다 | Node.js 네트워크 I/O의 핵심 다리 |
| 공유 링(io_uring) | 유저 공간과 커널이 큐를 공유해 요청/완료를 배치한다 | 고급 주제이며 커널·보안 조건을 확인해야 한다 |
io_uring은 syscall 자체를 줄이는 강력한 방법이지만, 이 문서의 중심은 아니다. Linux 커널 버전, seccomp 정책, 보안 패치 이력, 워크로드 패턴을 함께 봐야 하므로 “epoll을 무조건 대체하는 최신 기술”로 외우면 안 된다. 여기서는 syscall 비용을 줄이려는 고급 경계 기술 정도로만 잡고 넘어간다.
파일 디스크립터(fd)는 프로세스가 커널 객체를 가리키는 정수 번호다. 이름은 파일 디스크립터지만 실제로는 일반 파일뿐 아니라 소켓, 파이프, epoll 인스턴스, 터미널도 fd로 표현된다.
기본 fd 세 개는 거의 모든 프로세스가 갖고 시작한다.
| fd | 이름 | 의미 |
|---|---|---|
| 0 | stdin | 표준 입력 |
| 1 | stdout | 표준 출력 |
| 2 | stderr | 표준 에러 출력 |
HTTP 서버가 포트 3000을 listen하면 커널은 서버 소켓 fd를 만든다. 클라이언트가 연결될 때마다 accept()가 새 연결 소켓 fd를 돌려준다. DB 연결, Redis 연결, 업로드 파일, 로그 파일도 모두 fd를 소비한다.
프로세스 fd table0 -> /dev/pts/01 -> /dev/pts/02 -> /dev/pts/05 -> anon_inode:[eventpoll]7 -> socket:[LISTEN 0.0.0.0:3000]8 -> socket:[client connection]9 -> /var/log/app.logfd 번호는 시스템 전체에서 전역으로 유일하지 않다. 프로세스 A의 fd 5와 프로세스 B의 fd 5는 서로 다른 커널 객체를 가리킬 수 있다. 그래서 로그에서 “fd 7”만 보면 부족하고, 항상 “어느 PID의 fd 7인가”까지 함께 봐야 한다.
EMFILE: too many open files는 “파일 시스템이 고장났다”가 아니다. 한 프로세스가 가질 수 있는 fd 한도를 넘었다는 뜻이다.
Error: EMFILE: too many open files, open '/var/log/app.log'작은 서버에서 soft limit이 1024라면 대략 이런 계산이 가능하다.
| 사용처 | fd 개수 감각 |
|---|---|
| 표준 입출력 | 3개 |
| HTTP listen socket | 1개 |
| 동시 HTTP 연결 800개 | 연결마다 대략 1개 |
| DB pool 20개 | 연결마다 대략 1개 |
| Redis pool 10개 | 연결마다 대략 1개 |
| 로그/임시 파일 | 열린 파일마다 1개 |
이 상태에서 업로드 파일이나 외부 API 연결이 늘면 1024 한도에 쉽게 닿는다. 임시로 ulimit -n을 올리는 것은 증상을 늦출 뿐이고, 진짜 원인은 다음 둘 중 하나다.
코드 레벨에서는 close() 보장이 핵심이다.
const fd = fs.openSync("/var/log/app.log", "r");
try { fs.readSync(fd, buffer, 0, 1024, 0);} finally { fs.closeSync(fd);}운영에서 먼저 볼 최소 관찰값은 fd 수의 추세다.
ulimit -nls /proc/$(pgrep -n node)/fd | wc -l트래픽이 줄었는데도 fd 수가 계속 증가하면 limit 문제가 아니라 누수 가능성이 높다.
인터럽트는 CPU가 지금 하던 일을 잠시 멈추고 이벤트 처리 루틴으로 이동하게 만드는 신호다.
커널에서 이 신호를 받아 실제로 실행되는 처리 함수를 ISR(Interrupt Service Routine)이라고 부른다. ISR은 가능한 한 짧게 하드웨어 상태를 확인하고, 오래 걸리는 처리는 커널의 다른 단계로 넘겨 전체 시스템 지연을 줄인다.
| 구분 | 발생 주체 | 예시 | 성격 |
|---|---|---|---|
| 하드웨어 인터럽트 | NIC, 디스크, 키보드, 타이머 | 네트워크 패킷 도착, 타이머 tick | 비동기 |
| 소프트웨어 인터럽트/트랩 | 실행 중인 프로그램 또는 CPU 예외 | syscall, 0으로 나누기, page fault | 대체로 동기 |
네트워크 패킷이 들어오는 경로를 서버 관점에서 보면 다음과 같다.
sequenceDiagram participant NIC as Network Card participant CPU participant Kernel as Kernel / NIC Driver participant Socket as Socket Buffer participant Epoll as epoll Ready List participant Libuv as libuv Event Loop participant Node as Node.js Callback NIC->>CPU: hardware interrupt CPU->>Kernel: interrupt handler 실행 Kernel->>Socket: 패킷을 소켓 버퍼에 적재 Kernel->>Epoll: 해당 fd를 ready 상태로 표시 Epoll-->>Libuv: epoll_wait() 반환 Libuv-->>Node: JS 콜백 실행
Node.js 콜백은 커널 내부에서 직접 실행되지 않는다. 커널은 “이 fd에서 읽을 수 있다”는 상태를 만들고, libuv가 epoll_wait()에서 깨어난 뒤 JS 콜백을 실행한다.
select()나 poll()은 호출할 때마다 감시 대상 fd 전체를 훑는 구조에 가깝다. fd가 10개면 괜찮지만, 연결이 10,000개이고 실제로 데이터가 온 fd가 20개뿐이라면 매번 10,000개를 확인하는 비용이 커진다.
epoll은 커널 안에 감시 목록과 ready list를 유지한다.
| 내부 요소 | 역할 |
|---|---|
| interest list | epoll_ctl()로 등록한 fd 목록 |
| ready list | 지금 읽거나 쓸 준비가 된 fd 목록 |
| wait queue | epoll_wait()에서 잠든 프로세스/스레드를 깨우기 위한 대기열 |
핵심은 “등록은 한 번, 깨어날 때는 준비된 fd 중심”이라는 점이다. 그래서 Node.js HTTP 서버는 수많은 연결을 하나의 이벤트 루프에서 다룰 수 있다.
const http = require("http");
const server = http.createServer((req, res) => { res.writeHead(200); res.end("Hello World\n");});
server.listen(3000);이 서버가 요청을 받을 때 내부적으로는 accept(), read(), write(), epoll_wait() 같은 syscall이 오간다.
strace -p $(pgrep -n node) -e trace=epoll_wait,epoll_ctl,accept,read,writeepoll의 Edge-Triggered(ET) 모드는 상태 변화가 생겼을 때만 이벤트를 한 번 준다. 이 모드를 쓰면 “조금 읽고 나중에 또 깨워주겠지”라는 직관이 틀린다.
1. 파이프에 2KB 데이터 도착2. epoll_wait()이 한 번 깨어남3. read()로 1KB만 읽고 루프 종료4. 버퍼에 1KB가 남았지만 새 상태 변화가 없음5. 다시 epoll_wait()으로 들어가면 영원히 깨어나지 않을 수 있음ET 모드에서는 두 규칙이 필수다.
O_NONBLOCK으로 설정한다.read()나 write()가 EAGAIN을 반환할 때까지 반복한다.정상 패턴:read(5, ...) = 1024read(5, ...) = 1024read(5, ...) = -1 EAGAINepoll_wait(...)
위험 패턴:read(5, ...) = 1024epoll_wait(...) # 버퍼에 데이터가 남았는데 다시 잠듦이 반례는 epoll을 API 이름으로만 외우면 놓치기 쉽다. epoll은 데이터를 대신 읽어주는 기능이 아니라 “읽을 준비가 됐다”는 신호를 주는 기능이다.
출처: epoll(7) Linux manual page — man7.org, The edge-triggered misunderstanding — LWN.net
IPC(Inter-Process Communication)는 서로 다른 프로세스가 데이터를 주고받는 방법이다. process-thread.mdx에서 본 것처럼 프로세스는 메모리 주소 공간이 분리되어 있으므로, 한 프로세스가 다른 프로세스의 Heap을 그냥 읽을 수 없다. 그래서 커널이 중간에 서는 통신 경로가 필요하다.
| IPC 방식 | 적합한 상황 | 주의할 점 |
|---|---|---|
| Pipe | 부모-자식 사이의 단방향 바이트 스트림 | 1:1 흐름에 적합하고 버퍼가 유한하다 |
| Named Pipe(FIFO) | 이름 있는 단방향 로컬 통신 | 파일처럼 보이지만 실제 데이터 저장 파일은 아니다 |
| Unix Domain Socket | 같은 호스트 안의 양방향 소켓 통신 | TCP보다 빠르지만 호스트 밖으로 나갈 수 없다 |
| TCP Socket | 다른 호스트와 통신 | 네트워크 스택, 지연, 장애 처리를 감수한다 |
| Shared Memory | 극저지연 대용량 공유 | 동기화가 어렵고 데이터 경합을 직접 막아야 한다 |
| Message Queue | 생산자/소비자 비동기 큐 | 큐 크기, 재시도, 순서 보장을 별도로 설계한다 |
| 질문 | 선택 방향 |
|---|---|
| 다른 호스트와 통신해야 하는가? | TCP Socket |
| 같은 호스트에서 DB/Redis/Nginx와 통신하는가? | 가능하면 Unix Domain Socket 검토 |
| 부모-자식 프로세스 사이의 작은 제어 메시지인가? | Pipe 또는 Node.js process.send() |
| 64KB 이상 큰 payload를 자주 넘기는가? | Pipe에 직접 싣기보다 파일, Redis, shared memory 검토 |
| 10µs 이하 지연이 중요한가? | Shared Memory 가능성은 있지만 동기화 비용까지 계산 |
64KB는 절대 기준이 아니라 “작은 제어 메시지”와 “큰 payload”를 가르는 학습용 기준이다. Pipe나 cluster IPC는 커널 버퍼와 직렬화 비용의 영향을 받으므로 큰 JSON, 이미지 버퍼, 대량 row를 계속 넘기면 송신자와 수신자가 서로의 처리 속도에 묶인다. 이런 경우 메시지에는 위치나 ID만 담고 payload는 파일, object storage, shared memory 같은 별도 경로에 두는 편이 안정적이다.
Unix Domain Socket은 같은 호스트 안에서 TCP 대비 레이턴시를 줄일 수 있다. 벤치마크와 payload 크기에 따라 폭은 달라지지만, 일부 측정에서는 20~40% 개선도 나온다. 이유는 간단하다. TCP는 루프백이라도 TCP/IP 계층의 시퀀스 번호, ACK, 라우팅, 체크섬 경로를 거치지만 Unix Domain Socket은 커널 내부 버퍼 간 전달에 더 가깝다.
반대로 Shared Memory는 가장 빠를 수 있지만 가장 위험하다. 복사 비용을 줄이는 대신 동시 쓰기, 부분 업데이트, 락 설계를 애플리케이션이 책임져야 한다. 데이터 무결성이 중요하고 메시지 크기가 작다면 Unix Domain Socket이나 메시지 큐가 더 나은 선택일 수 있다.
출처: IPC Performance Comparison — Baeldung on Linux, ipc-bench GitHub
Node.js cluster의 primary와 worker는 서로 다른 프로세스다. Heap은 공유되지 않으므로 메시지는 IPC 채널을 통해 이동한다.
const cluster = require("cluster");
if (cluster.isPrimary) { const worker = cluster.fork();
worker.send({ type: "task", id: 42 }); worker.on("message", (msg) => { console.log("worker reply:", msg); });} else { process.on("message", (msg) => { process.send({ type: "done", id: msg.id }); });}작은 제어 메시지는 이 방식이 자연스럽다. 하지만 큰 JSON이나 이미지 버퍼를 계속 process.send()로 넘기면 serialization 비용과 pipe buffer 한계가 병목이 된다. worker가 죽었거나 IPC 채널이 끊긴 뒤에도 durable queue처럼 믿고 보내면 메시지 유실이나 callback error가 상위 로직의 부분 처리로 이어질 수 있다. 이때는 메시지에는 ID만 담고 실제 payload는 파일, Redis, object storage, shared memory 같은 별도 경로에 두는 편이 낫다.
운영 런북을 길게 외우기보다 “이 증상이 어떤 경계가 깨진 신호인가”를 먼저 잡는다.
| 실패 신호 | 의미 | 먼저 확인할 것 | 코드/설계 원칙 |
|---|---|---|---|
EMFILE: too many open files | 프로세스 fd 한도 초과 또는 fd 누수 | ulimit -n, /proc/<pid>/fd 수 추세 | 파일·소켓은 finally/close handler로 닫는다 |
EPIPE 또는 SIGPIPE | 상대가 닫은 pipe/socket에 쓰기 시도 | socket error 이벤트, peer disconnect 로그 | 쓰기 전 연결 상태를 가정하지 말고 에러를 처리한다 |
EINTR | syscall이 시그널 때문에 중간에 깨어남 | 네이티브 코드의 retry 처리, SIGCHLD 폭주 여부 | C/C++에서는 errno == EINTR이면 재시도 루프를 둔다 |
epoll_wait()에서 깨어나지 않는 hang | ET 모드에서 버퍼를 끝까지 비우지 않았을 수 있음 | read()가 EAGAIN까지 반복되는지 | nonblocking fd + EAGAIN까지 drain |
| 특정 syscall이 수 초 걸림 | 파일, DNS, 네트워크 연결이 커널 경계에서 블로킹됨 | strace -T의 느린 connect, read, openat | 애플리케이션 timeout과 backpressure를 둔다 |
EMFILE을 볼 때는 한도를 두 층으로 나눠 본다. ulimit -Sn은 현재 프로세스가 열 수 있는 fd soft limit이고, /proc/sys/fs/file-nr은 시스템 전체 file table 사용량을 보는 단서다. 한 프로세스만 새 소켓을 못 열면 전자, 서버 전체에서 파일 열기가 흔들리면 후자까지 같이 본다.
느린 syscall은 strace -T의 시간 표기가 직관적이다.
connect(42, ...) = -1 ETIMEDOUT (Connection timed out) <5.003456>이 한 줄은 JavaScript 함수가 느린 것이 아니라 커널 경계의 connect()가 약 5초 동안 블로킹됐음을 보여준다.
SIGPIPE는 끊긴 pipe나 socket에 쓰려고 할 때 커널이 보내는 신호다. C 프로그램에서는 기본 동작이 프로세스 종료일 수 있고, Node.js에서는 보통 EPIPE 에러로 관찰한다.
socket.on("error", (err) => { if (err.code === "EPIPE" || err.code === "ECONNRESET") { socket.destroy(); return; }
throw err;});핵심은 “write가 성공할 것”이라고 가정하지 않는 것이다. 네트워크나 IPC의 반대편은 언제든 먼저 닫을 수 있다.
EINTR은 syscall이 시그널 처리 때문에 중간에 중단되었음을 뜻한다. Node.js의 표준 fs, net, http API는 libuv가 대부분 처리하므로 자주 보이지 않는다. 그러나 N-API 애드온, C/C++ 네이티브 코드, 외부 C 라이브러리 바인딩에서는 직접 처리해야 한다.
ssize_t n;
do { n = read(fd, buf, sizeof(buf));} while (n < 0 && errno == EINTR);
if (n < 0) { return -1;}
process(buf, n);EINTR 자체가 읽은 바이트를 삼키는 것은 아니다. 문제는 호출 측이 재시도와 부분 처리 상태를 관리하지 않고 “작업이 끝났다”고 상위 로직을 진행시키는 경우다. 그러면 커널 버퍼에는 데이터가 남아 있는데 애플리케이션 관점에서는 데이터 일부가 사라진 것처럼 보이는 silent partial read가 생긴다.
출처: EINTR — Android Bionic docs, When to Check for EINTR — linuxvox
이 세 주제는 syscall과 interrupt의 확장 지점이지만, 이 문서 수준에서는 경계만 알면 충분하다.
| 주제 | 한 줄 이해 | 언제 다시 깊게 볼까 |
|---|---|---|
io_uring | 유저 공간과 커널이 submission/completion ring을 공유해 I/O syscall 횟수를 줄이는 방식 | 고성능 네트워크/스토리지 서버에서 epoll 한계가 측정됐을 때 |
| eBPF | 커널 안에서 안전하게 작은 프로그램을 실행해 관측·필터링하는 기술 | strace 오버헤드 없이 프로덕션 syscall/네트워크를 관찰해야 할 때 |
| seccomp | 프로세스가 호출할 수 있는 syscall을 제한하는 Linux 보안 필터 | 컨테이너가 특정 syscall을 못 써서 기능이 막히거나 보안 정책을 설계할 때 |
strace는 모든 syscall마다 프로세스를 멈추는 ptrace 기반 도구라 프로덕션 장시간 관찰에는 부담이 크다. eBPF 도구(opensnoop, tcpconnect, runqlat)는 같은 질문을 더 낮은 오버헤드로 볼 수 있다. 반대로 seccomp는 관찰 도구가 아니라 차단 도구다. Docker와 Kubernetes의 기본 seccomp 프로파일은 ptrace, mount, kexec_load 같은 위험 syscall을 제한할 수 있다.
최신 기술을 먼저 도입하는 것보다 중요한 기준은 측정이다. io_uring이 syscall 횟수와 p99 latency를 실제로 낮추는지, 커널 버전과 seccomp 정책이 허용하는지, 보안 패치 추적 비용을 감당할 수 있는지 확인해야 한다.
도입 판단은 최소한 아래 질문에 답할 수 있을 때만 한다.
| 질문 | 의미 |
|---|---|
| syscall/op가 실제로 줄었는가? | 요청 하나를 처리하는 경계 통과 횟수가 줄었는지 본다. |
| p99 latency가 낮아졌는가? | 평균이 아니라 꼬리 지연이 개선됐는지 본다. |
| 커널 버전과 seccomp가 허용하는가? | 기능이 있어도 컨테이너 보안 정책이 막을 수 있다. |
| 장애 대응 비용을 감당할 수 있는가? | 고급 경계 기술은 디버깅과 보안 패치 추적 비용도 늘린다. |
epoll은 준비된 fd를 알려주고 애플리케이션이 다시 read()/write() syscall을 호출하는 모델이다. 반면 io_uring은 submission queue에 I/O 요청을 넣고 completion queue에서 완료 결과를 읽는 모델이라, “준비 알림”과 “요청/완료 큐”의 차이를 구분해야 한다.
출처: Linux io_uring man page, Docker’s default seccomp profile — Docker 공식 문서, BPF Performance Tools — Brendan Gregg
아래 실습은 개념 관찰용 최소 예시다. 운영 절차로 외우기보다 출력이 어떤 커널 경계를 보여주는지 확인한다.
cat > /tmp/test-syscall.js << 'EOF'const fs = require('fs');fs.readFileSync('/etc/hostname');EOF
strace -e trace=openat,read,close node /tmp/test-syscall.js 2>&1 | grep -E "openat|read\\(|close\\("관찰 포인트: JS 코드는 파일 경로를 넘겼지만, 커널 경계에서는 openat으로 fd를 얻고 read로 바이트를 읽는다.
node -e "require('http').createServer().listen(3000)" &NODE_PID=$!
ls -la /proc/$NODE_PID/fdls /proc/$NODE_PID/fd | wc -l관찰 포인트: anon_inode:[eventpoll]은 epoll 인스턴스 fd이고, socket:[...]은 서버 소켓 또는 연결 소켓이다.
strace -p $NODE_PID -e trace=epoll_wait,epoll_ctl,accept 2>&1 | head -20kill $NODE_PID관찰 포인트: 실습 2에서 띄운 Node 서버를 종료하기 전에 붙어야 한다. Node.js 이벤트 루프가 바쁠 때도 네트워크 이벤트 대기는 epoll_wait()에서 보인다.
cat /proc/self/status | grep Seccomp관찰 포인트: Seccomp: 2는 filter 모드가 적용되어 있음을 뜻한다. 컨테이너에서 특정 syscall이 막히는 문제를 볼 때 첫 단서가 된다.
| 키워드 | 설명 |
|---|---|
| System Call / syscall | 유저 프로그램이 커널 기능을 요청하는 공식 인터페이스 |
| Ring 0 / Ring 3 | CPU 보호 레벨. Linux에서는 주로 커널 모드와 유저 모드 구분 |
| Mode Switch | syscall 등으로 유저 모드와 커널 모드가 전환되는 과정 |
| Interrupt | CPU가 비동기 이벤트나 예외를 처리하도록 깨우는 신호 |
| ISR | Interrupt Service Routine. 인터럽트 처리 함수 |
| fd | 열린 파일, 소켓, 파이프, epoll 인스턴스를 가리키는 정수 핸들 |
| epoll | Linux의 대규모 fd readiness 감시 메커니즘 |
| IPC | 격리된 프로세스 사이의 통신 방식 |
| EINTR | syscall이 시그널 때문에 중단되었음을 나타내는 errno |
| SIGPIPE / EPIPE | 닫힌 pipe/socket에 쓰려고 했음을 나타내는 신호/오류 |
| seccomp | 프로세스가 호출할 수 있는 syscall을 제한하는 보안 필터 |
| eBPF | 커널 내부 이벤트를 낮은 오버헤드로 관측·처리하는 기술 |
유저 프로그램은 syscall로만 커널 자원에 접근하고, 커널은 fd로 열린 자원을 추적하며, 하드웨어 이벤트는 interrupt로 커널을 깨우고 epoll을 통해 Node.js 이벤트 루프까지 전달된다. IPC는 격리된 프로세스 사이를 연결하는 선택지이고, EMFILE·EPIPE·EINTR 같은 에러는 이 경계 중 어디가 깨졌는지 알려주는 신호다.