본문 바로가기
TYLER SONGBlog
블로그 목록
CS학습

System Call: 사용자 모드와 커널 모드를 잇는 유일한 통로

시스템 콜(System Call)은 사용자 공간(user space)의 프로그램이 커널(kernel)의 기능을 요청하는 표준화된 인터페이스다. CPU 권한 수준을 전환하는 트랩(trap) 메커니즘을 통해 안전하게 커널 코드를 실행시킨다. 파일 입출력, 네트워크, 프로세스 생성 등 하드웨어 자원에 접근하는 거의 모든 작업이 시스템 콜을 거친다. 이 글에서는

송민성7분 읽기

1. 개념

시스템 콜은 사용자 모드(user mode)에서 실행 중인 프로세스가 커널 모드(kernel mode)의 기능—파일 읽기/쓰기, 메모리 할당, 프로세스 생성, 네트워크 통신 등—을 요청할 때 사용하는 인터페이스다.

일반 함수 호출과 다른 점은 호출자와 실행 주체가 서로 다른 권한 수준(privilege level)에 있다는 것이다. 사용자 프로그램은 CPU의 특권 명령어(privileged instruction)를 직접 실행할 수 없고, 물리 메모리 주소나 디스크 컨트롤러에 직접 접근할 수 없다. 이런 자원 접근은 오직 커널만 할 수 있으며, 사용자 프로그램은 시스템 콜이라는 정해진 문을 통해서만 커널에 요청을 전달할 수 있다.

2. 왜 사용하는가

운영체제가 사용자 프로그램에게 하드웨어를 직접 제어하도록 허용하면 다음과 같은 문제가 생긴다.

  • 보호(protection) 붕괴: 프로세스 A가 프로세스 B의 메모리나 디스크 영역을 임의로 건드릴 수 있다.
  • 일관성 붕괴: 여러 프로세스가 동시에 같은 디스크 블록에 쓰면 파일 시스템 구조가 깨진다.
  • 이식성 저하: 프로그램이 특정 하드웨어의 레지스터 배치에 직접 의존하게 된다.

시스템 콜은 이 문제를 "커널이 유일한 중재자가 되는" 구조로 해결한다. 사용자 프로그램은 "파일을 열어달라"는 추상화된 요청만 보내고, 실제 디스크 컨트롤러 제어나 권한 검사, 동시성 제어는 커널이 책임진다.

3. 동작 원리

x86-64 Linux 기준으로 시스템 콜이 처리되는 흐름은 다음과 같다.

  1. 인자 준비: 사용자 프로그램이 레지스터에 시스템 콜 번호와 인자를 채운다 (rax에 콜 번호, rdi, rsi, rdx 등에 인자).
  2. 트랩 명령 실행: syscall 명령어(과거에는 int 0x80 소프트웨어 인터럽트)를 실행한다. 이 명령은 CPU의 권한 수준을 사용자 모드(Ring 3)에서 커널 모드(Ring 0)로 전환한다.
  3. 컨텍스트 저장: CPU는 현재 스택 포인터, 프로그램 카운터 등을 저장하고, 미리 등록된 시스템 콜 핸들러 주소로 점프한다.
  4. 시스템 콜 테이블 조회: 커널은 rax의 번호를 이용해 시스템 콜 테이블(syscall table)에서 해당 핸들러 함수를 찾아 실행한다.
  5. 커널 작업 수행: 핸들러가 실제 작업(디스크 I/O, 메모리 매핑 등)을 수행하고 결과를 rax에 담는다.
  6. 복귀: 저장했던 컨텍스트를 복원하고 권한 수준을 사용자 모드로 되돌린 뒤 다음 명령어부터 실행을 재개한다.

이 과정에서 핵심은 사용자 프로그램이 커널 코드의 임의 지점으로 점프하는 게 아니라, 오직 커널이 미리 정의한 진입점(entry point)으로만 진입할 수 있다는 점이다. 이것이 보안의 근간이다.

응용 프로그램은 보통 이 저수준 과정을 직접 다루지 않고, C 표준 라이브러리(glibc)가 제공하는 래퍼 함수(예: read(), write(), open())를 통해 간접적으로 시스템 콜을 호출한다.

4. 코드 예제

Python에서 os 모듈을 통해 파일 시스템 콜을 호출하고, strace로 실제 발생하는 시스템 콜을 관찰하는 예제다.

python
import os # open, write, close 시스템 콜이 각각 발생한다 fd = os.open("example.txt", os.O_WRONLY | os.O_CREAT, 0o644) os.write(fd, b"hello syscall\n") os.close(fd) # read 시스템 콜 fd = os.open("example.txt", os.O_RDONLY) data = os.read(fd, 1024) os.close(fd) print(data.decode())
shell
# 실제로 어떤 시스템 콜이 호출되는지 추적한다 strace -e trace=open,openat,write,read,close python3 example.py # 출력 예시 (환경에 따라 다를 수 있음) # openat(AT_FDCWD, "example.txt", O_WRONLY|O_CREAT, 0644) = 3 # write(3, "hello syscall\n", 14) = 14 # close(3) = 0 # openat(AT_FDCWD, "example.txt", O_RDONLY) = 3 # read(3, "hello syscall\n", 1024) = 14 # close(3) = 0

TypeScript(Node.js) 환경에서는 시스템 콜이 libuv를 거쳐 비동기로 처리되지만, 결국 내부적으로는 동일하게 read/write 계열 시스템 콜을 호출한다.

typescript
import { readFile } from "node:fs/promises"; async function main() { // 내부적으로 open, read, close 시스템 콜이 libuv 스레드 풀을 통해 실행된다 const data = await readFile("example.txt", "utf-8"); console.log(data); } main();

5. 성능 특성

시스템 콜은 일반 함수 호출보다 비용이 크다. 권한 수준 전환, 컨텍스트 저장/복원, 캐시 오염(cache pollution) 때문이다.

  • 일반 함수 호출: 수 나노초(ns) 수준.
  • 시스템 콜 1회: 대략 수백 나노초에서 마이크로초(μs) 단위. 정확한 수치는 CPU 세대, Meltdown/Spectre 완화 패치 적용 여부, 커널 버전에 따라 크게 달라지므로 특정 숫자를 일반화하기는 어렵다.
  • vDSO(virtual dynamic shared object): gettimeofday, clock_gettime처럼 커널 모드 전환 없이 사용자 공간에서 바로 처리 가능한 일부 시스템 콜은 vDSO를 통해 오버헤드를 줄인다.

이 때문에 시스템 콜을 반복 호출하는 코드(예: 1바이트씩 read() 호출)는 성능 저하가 뚜렷하다. 버퍼링(buffering)을 통해 호출 횟수를 줄이는 것이 표준적인 최적화 방법이다.

python
# 나쁜 예: 시스템 콜이 파일 크기만큼 반복 발생 with open("large.txt", "rb") as f: while byte := f.read(1): pass # 좋은 예: 내부 버퍼링으로 시스템 콜 횟수를 줄인다 with open("large.txt", "rb") as f: while chunk := f.read(65536): pass

6. 실무 사용 사례

  • 웹 서버의 I/O 모델: Nginx, Node.js 같은 이벤트 기반 서버는 epoll(Linux) 시스템 콜을 사용해 수천 개의 소켓 상태를 하나의 시스템 콜로 효율적으로 감시한다. select/poll 대비 감시 대상이 늘어나도 성능 저하가 적다.
  • 컨테이너 기술: Docker 같은 컨테이너는 namespaces, cgroups 관련 시스템 콜(clone, unshare 등)로 격리된 실행 환경을 만든다. seccomp은 컨테이너가 호출 가능한 시스템 콜 목록을 화이트리스트로 제한해 공격 표면을 줄인다.
  • 프로파일링/디버깅: strace, dtrace 같은 도구로 애플리케이션이 어떤 시스템 콜을 얼마나 호출하는지 분석해 병목을 찾는다.
  • 비동기 I/O: io_uring(최신 Linux 커널)은 시스템 콜 발생 횟수 자체를 줄이는 방향으로 설계되어, 기존 epoll 기반 모델보다 오버헤드가 낮은 I/O 처리를 지향한다.

7. 주의할 점

  • 시스템 콜 횟수 최소화: 애플리케이션 레벨에서 잦은 파일/소켓 I/O를 호출하면 컨텍스트 전환 비용이 누적된다. 버퍼링, 배치 처리(batching)로 줄여야 한다.
  • 블로킹 여부 확인: 기본적으로 많은 시스템 콜(read, write)은 블로킹(blocking)이다. Node.js 같은 이벤트 루프 기반 런타임에서 블로킹 시스템 콜을 잘못 사용하면 이벤트 루프 전체가 멈출 수 있다.
  • 에러 처리: 시스템 콜은 실패 시 음수 반환값과 errno를 통해 오류를 알린다. EINTR(시그널에 의한 중단)처럼 재시도가 필요한 경우를 놓치면 버그로 이어진다.
  • 플랫폼 차이: 시스템 콜 번호와 인터페이스는 OS마다 다르다. Linux의 epoll과 macOS/BSD의 kqueue는 유사한 목적이지만 다른 시스템 콜이다. 크로스 플랫폼 코드는 이런 차이를 추상화 레이어로 감싸야 한다.

8. 핵심 정리

시스템 콜은 사용자 모드 프로그램이 커널 모드의 자원 접근 기능을 안전하게 요청하는 유일한 공식 통로다. CPU 권한 전환과 시스템 콜 테이블 조회라는 정해진 절차를 거치기 때문에 보호와 격리가 보장되지만, 그 대가로 일반 함수 호출보다 큰 오버헤드가 발생한다. 실무에서는 이 오버헤드를 이해하고 버퍼링, epoll/io_uring 같은 효율적인 인터페이스, strace 기반 프로파일링을 통해 시스템 콜 비용을 관리하는 것이 성능 최적화의 기본 축이다.

© 2026 Tyler Song