내가 코드로 파일을 못 여는 이유
첫 스타트업을 시작하고 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)은 모든 방의 마스터 키를 가지고 있다.
그 "전화"가 바로 시스템 콜이다.
시스템 콜의 정체: Trap Instruction
시스템 콜은 특별한 CPU 명령어로 구현된다.
- x86 아키텍처:
int 0x80(구형) 또는syscall(현대) - ARM 아키텍처:
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 함수- Wrapper가 레지스터 설정:
rax = 1(sys_write의 번호)rdi = 1(파일 디스크립터, stdout)rsi = "Hello"의 주소rdx = 5(글자 수)
syscall명령어 실행 → User Mode에서 Kernel Mode로 전환- 커널이
sys_call_table[1]을 찾음 →sys_write()실행 - 화면에 "Hello" 출력
sysret명령어로 Kernel Mode에서 User Mode로 복귀
이 과정을 Context Switch라고 부른다. CPU의 상태(레지스터, 스택 포인터 등)를 저장하고 복원하는 비용이 발생한다.
대표적인 시스템 콜들
리눅스에는 300개가 넘는 시스템 콜이 있지만, 자주 쓰이는 것들은 정해져 있다.
파일 I/O
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(): 장치별 특수 명령 전송 (범용 제어 인터페이스)
POSIX 표준 - 통일된 시스템 콜 인터페이스
문제는 운영체제마다 시스템 콜이 다르다는 것이다. 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별로 다른 시스템 콜을 호출한다.
libc: 시스템 콜의 Wrapper
우리가 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를 쓰는 이유:
- 이식성: OS가 바뀌어도 같은 코드 사용 가능
- 편의성: 복잡한 레지스터 조작을 숨김
- 에러 처리: 시스템 콜 실패 시
errno설정
strace로 시스템 콜 들여다보기
내 프로그램이 어떤 시스템 콜을 호출하는지 궁금하다면? 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에서 시스템 콜까지의 여정
Node.js의 fs.readFile()을 호출하면 무슨 일이 벌어질까?
const fs = require('fs');
fs.readFile('/tmp/data.txt', 'utf8', (err, data) => {
console.log(data);
});
내부 흐름:
- JavaScript 레이어:
fs.readFile()호출 - Node.js Binding: C++ 레이어로 전달 (
binding.cc) - libuv: 비동기 I/O 라이브러리가 파일 작업을 스레드 풀에 넘김
- POSIX API: libuv가
open(),fstat(),read(),close()호출 - libc Wrapper: glibc의 wrapper 함수가 시스템 콜 번호 설정
- System Call:
syscall명령어로 커널 진입 - Kernel:
sys_openat(),sys_read()등 실제 파일 I/O 수행 - Return: 데이터를 유저 공간 버퍼에 복사 후 반환
- Callback: libuv가 이벤트 루프에 콜백 등록, JavaScript로 결과 전달
즉, 간단한 fs.readFile() 하나가 여러 계층을 거쳐 최종적으로 커널의 시스템 콜로 귀결된다. 이 과정에서 User Mode ↔ Kernel Mode 전환이 최소 4번(open, fstat, read, close) 발생한다.
시스템 콜의 비용: Context Switch Overhead
시스템 콜은 공짜가 아니다. User Mode에서 Kernel Mode로 전환할 때마다:
- 현재 레지스터 상태 저장
- 커널 스택으로 전환
- 권한 레벨 변경
- TLB(Translation Lookaside Buffer) 플러시 가능성
- 캐시 미스 증가
일반적으로 시스템 콜 하나당 수백 나노초가 걸린다. 함수 호출(몇 나노초)보다 100배 이상 느리다.
그래서 고성능 애플리케이션은 시스템 콜을 최소화한다.
나쁜 예:
for (int i = 0; i < 1000000; i++) {
write(fd, &data[i], 1); // 1바이트씩 쓰기 → 100만 번 시스템 콜
}
좋은 예:
write(fd, data, 1000000); // 한 번에 쓰기 → 1번 시스템 콜
버퍼링이 중요한 이유가 바로 이거다.
vDSO: 시스템 콜 없이 커널 기능 쓰기
리눅스는 자주 쓰이는 시스템 콜의 오버헤드를 줄이기 위해 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의 접근: Win32 API
Windows는 POSIX를 따르지 않고 자체 시스템 콜 구조를 갖는다.
- User Mode: Win32 API (kernel32.dll, user32.dll 등)
- Kernel Mode: Native API (ntdll.dll → 실제 시스템 콜)
예를 들어, 파일 읽기:
// Win32 API
HANDLE hFile = CreateFile("file.txt", GENERIC_READ, ...);
DWORD bytesRead;
ReadFile(hFile, buffer, 100, &bytesRead, NULL);
CloseHandle(hFile);
내부적으로는:
ReadFile()→NtReadFile()(Native API) 호출syscall명령어로 커널 진입- Windows 커널의
NtReadFile()실행
Windows는 시스템 콜 번호를 공개하지 않고, 버전마다 바뀔 수 있어서 직접 호출은 권장되지 않는다. 항상 Win32 API를 거쳐야 한다.
마치며 - 추상화의 레이어를 이해하는 힘
처음 fs.readFile()을 쓸 때는 그냥 "파일 읽는 함수"라고만 생각했다. 하지만 그 아래에는:
- JavaScript 엔진
- Node.js 바인딩
- libuv 이벤트 루프
- POSIX wrapper
- 시스템 콜 테이블
- 커널의 파일 시스템 드라이버
- 하드웨어 컨트롤러
이렇게 여러 겹의 추상화가 쌓여 있다.
이걸 알고 나니 병목이 왜 생기는지, 어디서 최적화할 수 있는지 보이기 시작했다. 파일을 100번 따로 읽는 대신 한 번에 읽는다거나, 시스템 콜 횟수를 줄이기 위해 버퍼링을 쓴다거나.
시스템 콜은 단순히 "커널과 대화하는 방법"이 아니다. 내 코드가 실제로 어떻게 하드웨어를 움직이는지 이해하는 첫 번째 관문이다. 그리고 그 관문을 통과하면, 성능 문제의 90%가 설명된다.