콘텐츠로 이동

System Call & Interrupt

System Call & Interrupt (시스템 콜 & 인터럽트)

섹션 제목: “System Call & Interrupt (시스템 콜 & 인터럽트)”

시스템 콜(System Call)은 유저 프로그램이 운영체제 커널의 기능(파일, 네트워크, 메모리)을 안전하게 요청하는 공식 인터페이스이며, 인터럽트(Interrupt)는 CPU가 현재 작업을 멈추고 긴급한 이벤트를 처리하도록 하는 신호 메커니즘이다.


  • 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)와 시스템 콜의 관계를 알아야 한다.

레스토랑에서 손님(유저 프로그램)은 직접 주방(커널/하드웨어)에 들어갈 수 없다. 위생 규정과 안전을 위해 반드시 웨이터(시스템 콜)를 통해 주문을 넣어야 한다. 웨이터는 손님의 요청을 받아 주방에 전달하고, 결과(음식)를 손님에게 가져다 준다.

  • 손님(유저 프로그램): 직접 하드웨어에 접근 불가
  • 웨이터(시스템 콜): 커널에 공식적으로 요청하는 인터페이스
  • 주방(커널): 하드웨어를 직접 제어하는 특권 영역
  • 메뉴판(시스템 콜 테이블): 커널이 허용하는 요청 목록

CPU는 두 가지 실행 모드를 가진다.

구분User Mode (Ring 3)Kernel Mode (Ring 0)
권한제한적 (하드웨어 직접 접근 불가)무제한 (모든 명령 실행 가능)
동작일반 애플리케이션 코드 실행OS 커널, 드라이버 실행
충돌 시해당 프로세스만 종료시스템 전체 크래시 (커널 패닉)
예시Node.js, Python, ChromeLinux 커널, 디바이스 드라이버

시스템 콜 호출 흐름:

유저 프로그램 (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 작업을 배치로 처리한다.

왜 시스템 콜이 이렇게 비싼가? 단순히 함수 호출과 다르게 시스템 콜은 다음 단계를 거친다:

  1. CPU 파이프라인 플러시 — 현재 실행 중인 명령어 최적화(분기 예측, 명령어 재배치)가 초기화된다
  2. 보안 검증 — Meltdown/Spectre 취약점 대응을 위한 KPTI(Kernel Page Table Isolation) 패치가 적용된 커널에서는 유저 모드와 커널 모드의 페이지 테이블이 완전히 분리되어 모드 전환 시 TLB 일부가 무효화된다
  3. 스택 전환 — 유저 스택에서 커널 스택으로 전환한다

이 비용을 줄이기 위해 Linux는 vDSO(virtual Dynamic Shared Object) 를 제공한다. gettimeofday(), clock_gettime() 같은 자주 호출되고 보안상 위험이 없는 시스템 콜을 유저 공간에 매핑하여, 실제 커널 모드 전환 없이 실행할 수 있다. vDSO를 통한 호출은 일반 시스템 콜 대비 10배 이상 빠르다.

Terminal window
# 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() 로 주기적 확인 필요
Terminal window
# 커널 버전 확인
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)기능
read0파일/소켓에서 데이터 읽기
write1파일/소켓에 데이터 쓰기
open2파일 열기 → fd 반환
close3파일 디스크립터 닫기
socket41소켓 생성
accept43TCP 연결 수락
fork57프로세스 복제
exec59새 프로그램 실행
mmap9메모리 매핑
epoll_create213epoll 인스턴스 생성

코드 예시: 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 큐에 콜백 등록
메인 스레드가 콜백 실행 → 결과 전달
// 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;
}

컴파일 및 실행:

Terminal window
gcc -o read_file read_file.c && ./read_file

예상 출력:

호스트명: my-server

공항에서 일반 승객(유저 모드)은 보안 검색대(시스템 콜)를 통과해야 관제탑 영역(커널 모드)에 접근할 수 있다. 직접 관제탑에 들어가면 항공 안전(시스템 안정성)이 위험해진다.

┌─────────────────────────────────────┐
│ Ring 3: 유저 모드 │ ← Node.js, Python, Chrome 실행
│ (User Applications) │
│ ┌─────────────────────────────┐ │
│ │ Ring 0: 커널 모드 │ │ ← Linux 커널, 디바이스 드라이버
│ │ (Kernel) │ │
│ │ - 하드웨어 직접 접근 │ │
│ │ - 모든 CPU 명령 실행 가능 │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────┘
↑↓ 시스템 콜로만 이동 가능

왜 분리하는가?

만약 유저 프로그램이 하드웨어에 직접 접근할 수 있다면:

  • 악성 프로그램이 다른 프로세스의 메모리를 읽을 수 있다 (보안 취약점)
  • 버그가 있는 프로그램 하나가 전체 시스템을 다운시킬 수 있다
  • 여러 프로그램이 동시에 디스크에 쓰면 데이터가 손상된다
Terminal window
# 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 total

require('fs').readFileSync() 한 번 호출에 89개의 시스템 콜이 발생한다. 대부분은 Node.js 초기화(모듈 로드)에 사용된다.


응급실 의사(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 모드 필수 패턴: 반드시 아래 두 조건을 지켜야 한다.

  1. fd는 O_NONBLOCK(논블로킹)으로 설정
  2. read()/write()EAGAIN을 반환할 때까지 루프로 읽기
Terminal window
# 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
Terminal window
# 다른 터미널에서 확인
curl http://localhost:3000
# 출력: Hello World
Terminal window
# 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) = 1
epoll_ctl(5, EPOLL_CTL_ADD, 7, {events=EPOLLIN|EPOLLOUT|...}) = 0
epoll_wait(5, [], 1024, 0) = 0

3.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-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 (유일한 선택)
같은 호스트, 서로 다른 프로세스로컬 IPCUnix 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)

cluster-ipc.js
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: '처리 완료' }
unix-socket-server.js
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}`);
});
Terminal window
# 클라이언트로 테스트
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을 사용하면 레이턴시가 눈에 띄게 줄어든다.

Terminal window
# 터미널 1: FIFO 생성 및 읽기
mkfifo /tmp/myfifo
cat /tmp/myfifo
# 터미널 2: 데이터 쓰기
echo "Hello from process 2" > /tmp/myfifo

예상 출력 (터미널 1):

Hello from process 2

상황관련 시스템 콜/개념실무 영향
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멀티코어 활용
  • Lambda Cold Start: 새 컨테이너 프로세스 생성 = fork + exec 시스템 콜. Cold Start가 느린 이유 중 하나
  • ALB → EC2: 네트워크 패킷 수신 → NIC 인터럽트 → 커널 네트워크 스택 → Nginx → Node.js
  • ECS 컨테이너 간 통신: 같은 Task라면 Unix Socket, 다른 Task라면 TCP

// 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 엔지니어로서 실용적 포인트”
  1. 파일 디스크립터 모니터링: 각 연결(HTTP, DB, Redis)은 fd를 소비한다. lsof -p $(pgrep node) | wc -l로 모니터링
  2. IPC 성능 최적화: 같은 서버에서 Redis 연결 시 Unix Socket 사용으로 레이턴시 단축
  3. cluster 모듈 사용 시: Worker 프로세스 간 통신은 Pipe 기반임을 인지하고 대용량 데이터 전송 시 주의

구분System CallLibrary CallAPI 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으로 나누기, 페이지 폴트, 세그폴트동기
방식성능구현 복잡도네트워크 가능Node.js 지원
Pipe★★★★낮음불가child_process
Unix Socket★★★★★중간불가net 모듈
TCP Socket★★★중간가능net, http
Shared Memory★★★★★높음불가SharedArrayBuffer
Message Queue★★★★중간불가외부 라이브러리 필요

트러블슈팅 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가 누수되면 발생한다.

해결 방법:

Terminal window
# 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=65536
sudo 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, 네트워크 연결, 또는 시스템 콜 자체가 블로킹되고 있을 가능성.

해결 방법:

Terminal window
# 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 상태 확인 없이 send
cluster.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로 조회
Terminal window
# 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;
}
});

  • 시스템 콜이 무엇인지, 왜 필요한지 한 문장으로 설명할 수 있다
  • 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 프로젝트에서 시스템 콜이 어디서 발생하는지 연결할 수 있다

키워드설명
System Call유저 프로그램이 커널 기능을 요청하는 인터페이스
Ring 0 / Ring 3CPU 보호 레벨: 0=커널 모드, 3=유저 모드
Mode Switch유저↔커널 모드 전환, 시스템 콜 시 발생
InterruptCPU 현재 작업을 중단시키는 신호
ISRInterrupt Service Routine, 인터럽트 처리 함수
IDTInterrupt Descriptor Table, 인터럽트 번호→ISR 매핑 테이블
epollLinux의 비동기 I/O 이벤트 감시 메커니즘
File Descriptor (fd)열린 파일/소켓의 정수 식별자
IPCInter-Process Communication, 프로세스 간 통신
Pipe단방향 바이트 스트림, 부모↔자식 프로세스 통신
Unix Domain Socket동일 호스트 내 소켓 통신, TCP보다 빠름
Shared Memory가장 빠른 IPC, SharedArrayBuffer
libuvNode.js의 비동기 I/O 라이브러리, 시스콜 추상화
strace프로세스의 시스템 콜을 추적하는 디버깅 도구
ulimit프로세스 자원 제한 설정 명령어
EMFILEToo many open files 에러 코드

eBPF — 시스템 콜 모니터링의 현대적 방법

섹션 제목: “eBPF — 시스템 콜 모니터링의 현대적 방법”

eBPF(extended Berkeley Packet Filter) 는 커널을 수정하거나 모듈을 로드하지 않고 커널 내부에서 프로그램을 실행할 수 있는 기술이다. strace보다 훨씬 낮은 오버헤드로 시스템 콜을 추적할 수 있다.

strace 방식: eBPF 방식:
ptrace() → 모든 syscall마다 eBPF 프로그램 → 커널 내부에서
프로세스 중단 → 오버헤드 큼 직접 실행 → 오버헤드 매우 작음
(프로덕션 사용 위험) (프로덕션 사용 가능)
Terminal window
# 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 추적
tcpconnectTCP 연결 시도 추적
runqlatCPU Run Queue 대기 시간 분포
biolatency디스크 I/O 레이턴시 분포
profileCPU 프로파일링 (flame graph용)

📖 더 보기: BPF Performance Tools — Brendan Gregg — eBPF/BPF 기반 성능 분석 도구의 권위 있는 레퍼런스. 저자가 Netflix 수석 엔지니어 (고급)

Seccomp(Secure Computing Mode) 는 프로세스가 호출할 수 있는 시스템 콜을 제한하는 Linux 보안 기능이다. Docker와 Kubernetes는 기본적으로 Seccomp 프로파일을 적용하여 컨테이너가 위험한 시스템 콜을 사용하지 못하게 막는다.

Terminal window
# 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: v1
kind: Pod
spec:
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개 시스템 콜 목록과 커스텀 프로파일 작성법 (중급)



실습 1: strace로 Node.js 시스템 콜 추적

섹션 제목: “실습 1: strace로 Node.js 시스템 콜 추적”
Terminal window
# 간단한 파일 읽기 스크립트 작성
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) = 3
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libdl.so.2", O_RDONLY|O_CLOEXEC) = 3
...
openat(AT_FDCWD, "/etc/hostname", O_RDONLY|O_CLOEXEC) = 16
read(16, "my-server\n", 4096) = 10
close(16) = 0

실습 2: 현재 열린 파일 디스크립터 확인

섹션 제목: “실습 2: 현재 열린 파일 디스크립터 확인”
Terminal window
# 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 0
dr-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/0
lrwx------ 1 user user 64 Apr 2 10:00 1 -> /dev/pts/0
lrwx------ 1 user user 64 Apr 2 10:00 2 -> /dev/pts/0
lrwx------ 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 서버 소켓.

Terminal window
# 현재 프로세스 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
Terminal window
# 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 성능 비교”
Terminal window
# TCP vs Unix Socket 성능 비교 (nc 사용)
# TCP
time (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% 빠름

유저 프로그램은 시스템 콜이라는 공식 창구를 통해서만 커널(Ring 0)에 접근할 수 있고, 하드웨어 이벤트(네트워크 패킷, 키 입력)는 인터럽트로 CPU에 알려지며, 프로세스 간 통신(IPC)은 Pipe/Socket/Shared Memory 중 상황에 맞는 방식을 선택해야 한다. Node.js의 모든 비동기 I/O는 이 세 가지 원리 위에서 동작한다.