
시스템 콜(System Call): 커널에게 부탁하는 방법
개발자가 직접 하드디스크를 제어할 수 없습니다. 대신 API를 통해 커널에게 '부탁'해야 합니다. 그 부탁의 정체가 바로 시스템 콜입니다.

개발자가 직접 하드디스크를 제어할 수 없습니다. 대신 API를 통해 커널에게 '부탁'해야 합니다. 그 부탁의 정체가 바로 시스템 콜입니다.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

첫 스타트업을 시작하고 Node.js로 이미지 업로드 기능을 만들 때의 일이다. fs.readFile()을 쓰면 파일이 읽힌다는 건 알았지만, 내부에서 뭐가 돌아가는지는 몰랐다. 그냥 "Node가 알아서 하겠지" 하고 넘어갔다.
그러다 프로덕션에서 파일 읽기가 너무 느려서 병목이 생겼다. 왜 느릴까? fs.readFile()이 단순히 메모리에서 값을 읽는 게 아니라, 커널에게 부탁하는 비용이 있다는 걸 그때 처음 알았다.
바로 그 부탁의 메커니즘이 시스템 콜(System Call)이다.
상상해보자. 만약 모든 프로그램이 하드디스크의 헤드를 직접 움직일 수 있다면? A 프로그램이 "여기 읽을게!" 하는 순간, B 프로그램이 "아니 나 먼저!" 하면서 헤드를 다른 위치로 옮겨버린다. 데이터는 엉망이 되고, 보안은 제로다.
그래서 운영체제는 CPU의 권한 모드를 두 가지로 나눴다.
1. User Mode (유저 모드): 일반 애플리케이션이 실행되는 제한된 공간. 하드웨어 직접 접근 불가. 메모리도 자기 영역만 건드릴 수 있음.
2. Kernel Mode (커널 모드): 운영체제 커널만 진입 가능. 하드웨어 제어, 메모리 전체 접근, I/O 장치 조작 등 모든 권한을 가짐.
이건 마치 호텔 시스템과 같다. 투숙객(User Mode)은 자기 방 열쇠만 있고, 공용 시설을 쓰려면 프론트 데스크(Kernel)에 전화해야 한다. 직원(Kernel Mode)은 모든 방의 마스터 키를 가지고 있다.
그 "전화"가 바로 시스템 콜이다.
시스템 콜은 특별한 CPU 명령어로 구현된다.
int 0x80 (구형) 또는 syscall (현대)svc (supervisor call)이 명령어를 실행하면 Trap이 발생한다. Trap은 CPU가 "지금부터 커널 모드로 전환한다"고 선언하는 순간이다. 하드웨어 레벨에서 특권 레벨(privilege level)이 바뀌고, CPU는 미리 정의된 커널 주소로 점프한다.
Linux에서는 이 지점이 entry_SYSCALL_64라는 어셈블리 코드다. 여기서 커널은 "무슨 시스템 콜을 요청했지?"를 판단한다.
커널은 시스템 콜 테이블(System Call Table)이라는 배열을 가지고 있다. 마치 레스토랑 메뉴판처럼, 각 번호에 함수 포인터가 매핑되어 있다.
// Linux 커널의 시스템 콜 테이블 (간소화)
const sys_call_ptr_t sys_call_table[] = {
[0] = sys_read,
[1] = sys_write,
[2] = sys_open,
[3] = sys_close,
[57] = sys_fork,
[59] = sys_execve,
// ... 300개 이상
};
사용자 프로그램이 시스템 콜을 호출하면, 시스템 콜 번호를 레지스터(x86-64에서는 rax)에 넣는다. 커널은 이 번호를 인덱스 삼아 테이블에서 해당 함수를 찾아 실행한다.
write() 시스템 콜의 흐름
// C 프로그램
printf("Hello");
printf()는 내부적으로 write() 라이브러리 함수 호출write()는 glibc에서 제공하는 wrapper 함수rax = 1 (sys_write의 번호)rdi = 1 (파일 디스크립터, stdout)rsi = "Hello"의 주소rdx = 5 (글자 수)syscall 명령어 실행 → User Mode에서 Kernel Mode로 전환sys_call_table[1]을 찾음 → sys_write() 실행sysret 명령어로 Kernel Mode에서 User Mode로 복귀이 과정을 Context Switch라고 부른다. CPU의 상태(레지스터, 스택 포인터 등)를 저장하고 복원하는 비용이 발생한다.
리눅스에는 300개가 넘는 시스템 콜이 있지만, 자주 쓰이는 것들은 정해져 있다.
int fd = open("/tmp/data.txt", O_RDWR | O_CREAT, 0644);
write(fd, "Hello", 5);
read(fd, buffer, 100);
close(fd);
open(): 파일을 열고 파일 디스크립터(정수) 반환read(): 파일에서 데이터를 읽어 버퍼에 저장write(): 버퍼의 데이터를 파일에 씀close(): 파일 디스크립터 닫기pid_t pid = fork(); // 현재 프로세스를 복제
if (pid == 0) {
// 자식 프로세스
execve("/bin/ls", args, env); // 새 프로그램 실행
} else {
// 부모 프로세스
wait(NULL); // 자식이 끝날 때까지 대기
}
fork(): 현재 프로세스를 그대로 복사해서 자식 프로세스 생성exec(): 현재 프로세스를 다른 프로그램으로 교체wait(): 자식 프로세스의 종료를 기다림void* ptr = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
// 4KB의 메모리 페이지를 할당받음
munmap(ptr, 4096); // 메모리 해제
mmap(): 파일을 메모리에 매핑하거나, 익명 메모리 할당brk()/sbrk(): 힙 영역 크기 조정 (malloc이 내부적으로 사용)int fd = open("/dev/ttyUSB0", O_RDWR);
ioctl(fd, TIOCMGET, &status); // 시리얼 포트 상태 읽기
ioctl(): 장치별 특수 명령 전송 (범용 제어 인터페이스)문제는 운영체제마다 시스템 콜이 다르다는 것이다. Linux의 open()과 Windows의 CreateFile()은 완전히 다른 함수다.
그래서 POSIX(Portable Operating System Interface)라는 표준이 등장했다. Unix 계열 OS(Linux, macOS, BSD)들이 같은 시스템 콜 인터페이스를 제공하도록 약속한 것이다.
예를 들어, POSIX는 open(), read(), write(), fork() 같은 함수의 시그니처와 동작을 명시한다. 덕분에 Linux에서 짠 C 코드를 macOS에서 다시 컴파일하면 그냥 돌아간다.
하지만 Windows는 POSIX를 따르지 않는다. Windows는 Win32 API라는 자체 시스템을 쓴다.
| POSIX (Linux/Mac) | Win32 API (Windows) |
|---|---|
open() | CreateFile() |
read() | ReadFile() |
fork() | CreateProcess() |
execve() | CreateProcess() |
그래서 크로스 플랫폼 프로그램은 보통 libc 같은 라이브러리를 거쳐서 OS별로 다른 시스템 콜을 호출한다.
우리가 C에서 printf()를 쓸 때, 직접 시스템 콜 번호를 레지스터에 넣지 않는다. 대신 libc(C 표준 라이브러리)가 제공하는 함수를 호출한다.
libc는 내부적으로 시스템 콜을 감싸는 wrapper 함수를 제공한다.
// glibc의 write() wrapper (간소화)
ssize_t write(int fd, const void *buf, size_t count) {
ssize_t result;
asm volatile (
"mov $1, %%rax\n" // sys_write 번호
"mov %1, %%rdi\n" // fd
"mov %2, %%rsi\n" // buf
"mov %3, %%rdx\n" // count
"syscall\n"
"mov %%rax, %0\n"
: "=r" (result)
: "r" (fd), "r" (buf), "r" (count)
: "rax", "rdi", "rsi", "rdx"
);
return result;
}
이렇게 wrapper를 쓰는 이유:
errno 설정내 프로그램이 어떤 시스템 콜을 호출하는지 궁금하다면? strace를 쓰면 된다.
strace ls
출력 결과:
execve("/bin/ls", ["ls"], 0x7ffd...) = 0
brk(NULL) = 0x55a1b2000000
access("/etc/ld.so.preload", R_OK) = -1 ENOENT
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=123456, ...}) = 0
mmap(NULL, 123456, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f8a...
close(3) = 0
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3...", 832) = 832
...
write(1, "file1.txt\nfile2.txt\n", 20) = 20
exit_group(0) = ?
각 줄이 시스템 콜 호출이다. ls 명령어 하나 실행하는 데도 수십 개의 시스템 콜이 발생한다는 걸 알 수 있다.
특정 시스템 콜만 추적하고 싶다면:
strace -e trace=open,read,write cat file.txt
Node.js 애플리케이션도 추적 가능:
strace -e trace=read,write node app.js
이걸로 처음 알았다. fs.readFile()이 내부적으로 openat(), fstat(), read(), close()를 순차적으로 호출한다는 사실을.
Node.js의 fs.readFile()을 호출하면 무슨 일이 벌어질까?
const fs = require('fs');
fs.readFile('/tmp/data.txt', 'utf8', (err, data) => {
console.log(data);
});
내부 흐름:
fs.readFile() 호출binding.cc)open(), fstat(), read(), close() 호출syscall 명령어로 커널 진입sys_openat(), sys_read() 등 실제 파일 I/O 수행즉, 간단한 fs.readFile() 하나가 여러 계층을 거쳐 최종적으로 커널의 시스템 콜로 귀결된다. 이 과정에서 User Mode ↔ Kernel Mode 전환이 최소 4번(open, fstat, read, close) 발생한다.
시스템 콜은 공짜가 아니다. User Mode에서 Kernel Mode로 전환할 때마다:
일반적으로 시스템 콜 하나당 수백 나노초가 걸린다. 함수 호출(몇 나노초)보다 100배 이상 느리다.
그래서 고성능 애플리케이션은 시스템 콜을 최소화한다.
나쁜 예:for (int i = 0; i < 1000000; i++) {
write(fd, &data[i], 1); // 1바이트씩 쓰기 → 100만 번 시스템 콜
}
좋은 예:
write(fd, data, 1000000); // 한 번에 쓰기 → 1번 시스템 콜
버퍼링이 중요한 이유가 바로 이거다.
리눅스는 자주 쓰이는 시스템 콜의 오버헤드를 줄이기 위해 vDSO(virtual Dynamic Shared Object)를 도입했다.
vDSO는 커널이 유저 공간에 매핑한 작은 공유 라이브러리다. 여기에는 gettimeofday(), clock_gettime(), getcpu() 같은 가벼운 시스템 콜의 구현이 들어있다.
이 함수들은 커널 모드 전환 없이 유저 공간에서 직접 실행된다. 커널이 메모리 영역에 현재 시간 같은 데이터를 주기적으로 업데이트해두고, 유저 프로그램은 그냥 읽기만 하면 된다.
일반 시스템 콜:User Mode → syscall → Kernel Mode → sysret → User Mode
vDSO 시스템 콜:
User Mode → 메모리 읽기 → User Mode (전환 없음!)
속도 차이는 10배 이상이다.
Windows는 POSIX를 따르지 않고 자체 시스템 콜 구조를 갖는다.
예를 들어, 파일 읽기:
// Win32 API
HANDLE hFile = CreateFile("file.txt", GENERIC_READ, ...);
DWORD bytesRead;
ReadFile(hFile, buffer, 100, &bytesRead, NULL);
CloseHandle(hFile);
내부적으로는:
ReadFile() → NtReadFile() (Native API) 호출syscall 명령어로 커널 진입NtReadFile() 실행Windows는 시스템 콜 번호를 공개하지 않고, 버전마다 바뀔 수 있어서 직접 호출은 권장되지 않는다. 항상 Win32 API를 거쳐야 한다.
처음 fs.readFile()을 쓸 때는 그냥 "파일 읽는 함수"라고만 생각했다. 하지만 그 아래에는:
이렇게 여러 겹의 추상화가 쌓여 있다.
이걸 알고 나니 병목이 왜 생기는지, 어디서 최적화할 수 있는지 보이기 시작했다. 파일을 100번 따로 읽는 대신 한 번에 읽는다거나, 시스템 콜 횟수를 줄이기 위해 버퍼링을 쓴다거나.
시스템 콜은 단순히 "커널과 대화하는 방법"이 아니다. 내 코드가 실제로 어떻게 하드웨어를 움직이는지 이해하는 첫 번째 관문이다. 그리고 그 관문을 통과하면, 성능 문제의 90%가 설명된다.