커널과 시스템 콜
운영체제의 심장은 커널(Kernel)입니다. 커널은 하드웨어를 직접 제어하는 OS의 핵심 부분으로, 메모리에 항상 상주하면서 프로세스 관리, 메모리 관리, 파일 시스템, 장치 제어를 담당합니다.
우리가 작성하는 프로그램은 직접 화면에 글자를 찍거나, 디스크에 파일을 쓰거나, 네트워크로 데이터를 보낼 수 없습니다. 모든 하드웨어 접근은 반드시 커널을 통해야 합니다. 왜 이런 구조가 필요한지, 커널은 어떤 형태로 설계될 수 있는지, 그리고 프로그램이 커널에 요청을 보내는 시스템 콜은 실제로 어떻게 동작하는지를 이 절에서 깊이 있게 다루겠습니다.
커널의 역할
커널은 하드웨어와 소프트웨어 사이의 유일한 통로입니다. 애플리케이션이 파일을 읽거나, 네트워크 패킷을 보내거나, 새 프로세스를 생성하려면, 반드시 커널을 거쳐야 합니다.
이를 위해 CPU는 두 가지 실행 모드를 제공합니다. 이 모드 분리는 현대 OS의 안정성을 지탱하는 가장 근본적인 메커니즘입니다.
사용자 모드(User Mode): 일반 프로그램이 실행되는 모드입니다. 하드웨어에 직접 접근할 수 없고, 접근하려 하면 CPU가 즉시 예외(Exception)를 발생시킵니다. 이 예외를 커널이 받아서 해당 프로그램을 강제 종료시킵니다. 우리가 보는 "Segmentation Fault"나 "Access Violation" 같은 에러가 바로 이 메커니즘의 결과입니다.
커널 모드(Kernel Mode): 운영체제 커널이 실행되는 모드입니다. 모든 하드웨어와 메모리에 접근할 수 있는 특권 모드입니다. CPU의 모든 명령어를 실행할 수 있고, 어떤 메모리 주소든 접근할 수 있습니다.
이 분리가 없다면 어떻게 될까요? 웹 브라우저의 버그가 그래픽 카드의 레지스터를 덮어써서 화면이 깨지고, 메일 프로그램이 디스크 컨트롤러를 직접 조작하다가 파일 시스템을 통째로 날릴 수 있습니다. 실제로 MS-DOS 시절에는 이런 일이 가능했고, 하나의 프로그램 버그가 전체 시스템을 마비시키는 일이 빈번했습니다.
x86 CPU는 이 개념을 좀 더 세분화하여 링(Ring)이라는 4단계 보호 수준을 제공합니다. Ring 0이 가장 높은 특권(커널 모드)이고, Ring 3이 가장 낮은 특권(사용자 모드)입니다. 대부분의 OS는 Ring 0과 Ring 3만 사용하고, Ring 1과 Ring 2는 사용하지 않습니다. 가상화 기술이 등장하면서 Ring -1(하이퍼바이저 모드)이라는 개념도 추가되었습니다. 13장에서 가상화를 다룰 때 다시 만나게 됩니다.
ARM 프로세서(스마트폰, Apple Silicon)에서는 EL0(사용자), EL1(커널), EL2(하이퍼바이저), EL3(보안 모니터)의 4단계 Exception Level을 사용합니다. 이름은 다르지만 핵심 원리 — 낮은 단계에서 높은 단계의 기능을 직접 호출할 수 없고, 정해진 인터페이스(시스템 콜)를 통해야 한다 — 는 동일합니다.
커널의 종류
커널을 어떤 구조로 설계하느냐에 따라 성능, 안정성, 확장성이 크게 달라집니다. 크게 세 가지 접근법이 있습니다.
모놀리식 커널 (Monolithic Kernel)
모놀리식 커널은 모든 OS 서비스 — 프로세스 관리, 메모리 관리, 파일 시스템, 네트워크 스택, 디바이스 드라이버 — 가 하나의 커다란 커널 모드 이미지 안에서 동작하는 구조입니다. Linux, 전통적 UNIX가 이 방식입니다.
장점은 성능입니다. 파일 시스템이 디스크 드라이버를 호출할 때, 같은 주소 공간에 있으므로 일반 함수 호출과 동일한 비용입니다. 컨텍스트 스위칭이나 메시지 복사가 필요 없습니다.
단점은 안정성입니다. 디바이스 드라이버에 버그가 있으면 — 예를 들어 잘못된 포인터로 메모리를 덮어쓰면 — 같은 주소 공간의 커널 전체가 오염됩니다. 실제로 Linux 커널 버그의 대부분은 서드파티 디바이스 드라이버에서 발생합니다. 커널 전체가 한 주소 공간에 있기 때문에, 드라이버 하나의 버그가 커널 패닉(시스템 전체 중단)으로 이어질 수 있습니다.
Linux는 이 단점을 로더블 커널 모듈(Loadable Kernel Module, LKM)로 완화합니다. 필요한 드라이버를 런타임에 커널에 삽입하거나 제거할 수 있습니다. 모듈 자체는 여전히 커널 모드에서 동작하므로 안정성 문제가 해결되는 것은 아니지만, 모든 드라이버를 커널에 정적으로 컴파일하지 않아도 되므로 유연성이 높아집니다.
#include <linux/module.h>
#include <linux/kernel.h>
static int __init hello_init(void) {
printk(KERN_INFO "Hello, Kernel!\n");
return 0;
}
static void __exit hello_exit(void) {
printk(KERN_INFO "Goodbye, Kernel!\n");
}
module_init(hello_init);
module_exit(hello_exit);insmod로 이 모듈을 커널에 삽입하면 hello_init이 호출되고, rmmod로 제거하면 hello_exit이 호출됩니다. GPU 드라이버, 파일 시스템, 네트워크 프로토콜 같은 것들이 이런 모듈 형태로 관리됩니다.
마이크로커널 (Microkernel)
마이크로커널은 정반대의 철학입니다. 커널에는 정말 최소한의 기능만 남깁니다 — 프로세스 간 통신(IPC), 기본 스케줄링, 메모리 주소 공간 관리 정도입니다. 파일 시스템, 네트워크 스택, 디바이스 드라이버 등은 모두 사용자 모드의 별도 프로세스(서버)로 분리합니다.
Minix, QNX, L4 계열이 마이크로커널 구조입니다. QNX는 자동차, 의료 기기, 원자력 발전소 등 미션 크리티컬 시스템에서 사용되는데, 그 이유가 안정성입니다. 파일 시스템 서버가 충돌(crash)하더라도 커널과 다른 서버에는 영향이 없습니다. 커널이 충돌한 서버를 재시작하면 됩니다. 자동차의 브레이크 제어 소프트웨어가 GPS 네비게이션의 버그 때문에 멈추면 안 되니까요.
단점은 성능입니다. 파일을 읽으려면 애플리케이션 → 커널(IPC) → 파일 시스템 서버 → 커널(IPC) → 디스크 드라이버 서버 → 커널 → 파일 시스템 서버 → 커널 → 애플리케이션, 이렇게 여러 번의 IPC와 모드 전환이 발생합니다. 모놀리식에서는 단 한 번의 시스템 콜이면 되는 작업이 여러 번의 컨텍스트 스위칭을 거치므로, 순수 성능에서는 불리합니다.
1990년대 리누스 토르발스(Linux)와 앤드류 타넨바움(Minix)이 이 주제로 유명한 논쟁을 벌였습니다. 타넨바움은 마이크로커널이 기술적으로 우월하며, 모놀리식은 퇴보라고 주장했고, 리누스는 현실적으로 모놀리식이 빠르고 실용적이라고 반박했습니다. 30년이 지난 지금, 범용 OS에서는 모놀리식(Linux)이 압도적으로 지배하고 있지만, 안전이 최우선인 임베디드 분야에서는 마이크로커널이 여전히 중요한 위치를 차지하고 있습니다.
하이브리드 커널 (Hybrid Kernel)
하이브리드 커널은 두 방식의 장점을 취합하려는 시도입니다. Windows NT 커널, macOS의 XNU 커널이 이 구조입니다. 기본적으로는 마이크로커널 철학(모듈 분리, 잘 정의된 인터페이스)을 따르되, 성능이 중요한 구성 요소(그래픽 서브시스템, 네트워크 스택 등)는 커널 모드에서 실행합니다.
macOS의 XNU는 이름부터 X is Not UNIX의 재귀적 약어입니다. CMU의 Mach 마이크로커널과 BSD 유닉스 코드를 결합한 구조입니다. Mach 부분이 프로세스 관리와 IPC를 담당하고, BSD 부분이 파일 시스템과 네트워크를 담당합니다. 그런데 BSD 코드가 커널 모드에서 직접 실행되므로, 순수한 마이크로커널이라고 보기는 어렵습니다.
| 구분 | 모놀리식 | 마이크로 | 하이브리드 |
|---|---|---|---|
| 대표 OS | Linux, 전통 UNIX | QNX, MINIX, L4 | Windows NT, macOS |
| 성능 | 높음 (함수 호출) | 낮음 (IPC 오버헤드) | 중간~높음 |
| 안정성 | 낮음 (전체 오염 위험) | 높음 (서버 격리) | 중간 |
| 복잡성 | 중간 | 높음 (IPC 설계) | 높음 |
| 확장성 | LKM으로 유연 | 서버 추가로 유연 | 구조에 따라 다름 |
시스템 콜의 동작 원리
시스템 콜(System Call)은 사용자 모드의 프로그램이 커널 모드의 OS 서비스를 요청하는 유일한 공식 인터페이스입니다. "사용자 모드에서 커널 모드로 넘어가는 관문"이라고 이해하면 됩니다.
일상적인 비유를 들면, 은행 창구와 비슷합니다. 고객(사용자 프로그램)이 금고(하드웨어)에 직접 손을 대는 것은 불가능합니다. 반드시 창구 직원(커널)에게 요청서(시스템 콜)를 제출해야 합니다. 직원이 요청을 확인하고, 적법하면 금고에서 돈을 꺼내줍니다.
프로그램이 파일을 읽으려 할 때의 과정을 단계별로 따라가 보겠습니다.
- 프로그램이
read()함수를 호출합니다. - C 라이브러리(glibc)의 래퍼 함수가 시스템 콜 번호(x86_64에서
read는 0번)와 인자(파일 디스크립터, 버퍼 주소, 크기)를 CPU 레지스터에 설정합니다.rax에 시스템 콜 번호,rdi,rsi,rdx에 각각 인자를 넣습니다. syscall명령어(x86_64)를 실행합니다. 이 특수 명령어가 CPU에게 모드 전환을 해달라고 알립니다.- CPU가 하드웨어적으로 사용자 모드에서 커널 모드로 전환합니다. 이때 스택 포인터가 커널 스택으로 바뀌고, 인스트럭션 포인터가 미리 등록된 시스템 콜 핸들러 주소로 이동합니다.
- 커널의 시스템 콜 핸들러가
rax의 번호를 시스템 콜 테이블에서 찾아, 해당하는 커널 함수(sys_read)를 호출합니다. sys_read가 파일 디스크립터를 확인하고, 디스크 드라이버를 통해 데이터를 읽어 사용자 프로그램이 제공한 버퍼에 복사합니다.- 작업이 끝나면 반환값(읽은 바이트 수 또는 에러 코드)을
rax에 넣고,sysret명령어로 사용자 모드로 돌아갑니다. - glibc 래퍼 함수가
rax의 값을 확인하여, 음수면 errno를 설정하고 -1을 반환합니다.
이 전체 과정은 수백 나노초 정도 걸립니다. 빠르긴 하지만, 일반 함수 호출(수 나노초)보다는 수십~수백 배 느립니다. 이 때문에 시스템 콜을 줄이는 것이 성능 최적화라는 말이 나옵니다.
실제로 시스템 콜 오버헤드를 줄이기 위한 다양한 기법이 존재합니다. Linux의 vDSO(virtual Dynamic Shared Object)는 일부 자주 사용되는 시스템 콜(gettimeofday 등)을 커널 모드 전환 없이 사용자 모드에서 바로 실행할 수 있게 합니다. 또한 io_uring(Linux 5.1+)은 커널과 사용자 공간이 공유하는 링 버퍼를 통해 I/O 요청을 배치 처리하여, 시스템 콜 횟수를 크게 줄입니다.
시스템 콜을 직접 확인하고 싶다면, strace(Linux) 명령어를 사용할 수 있습니다.
// hello.c
#include <stdio.h>
int main() {
printf("Hello, OS!\n");
return 0;
}gcc -o hello hello.c
strace ./hello출력에서 write(1, "Hello, OS!\n", 11) 같은 시스템 콜을 직접 확인할 수 있습니다. printf가 내부적으로 write 시스템 콜을 호출한다는 사실을 눈으로 보는 것입니다. 디버깅 현장에서 프로그램이 어떤 파일을 열고, 어떤 소켓에 연결하고, 어디서 블로킹되는지를 strace로 추적하는 일이 매우 잦으므로, 기억해두면 좋습니다.
주요 시스템 콜
운영체제의 기능별로 대표적인 시스템 콜을 살펴보겠습니다. 각 시스템 콜이 어떤 상황에서 사용되는지를 이해하면, 이후 장에서 프로세스, 파일, 메모리를 다룰 때 기초가 됩니다.
프로세스 관리
fork()— 현재 프로세스를 복제하여 새 프로세스(자식)를 생성합니다. 부모와 자식은 동일한 코드와 데이터를 갖지만, 서로 독립적인 프로세스입니다.exec()— 현재 프로세스의 코드와 데이터를 새 프로그램으로 교체합니다.fork()로 만든 자식에서exec()을 호출하여 다른프로그램을 실행하는 것이 일반적인 패턴입니다.wait()— 자식 프로세스의 종료를 기다립니다. 자식의 종료 상태를 수거하지 않으면 좀비 프로세스가 됩니다.exit()— 프로세스를 종료합니다. 열린 파일을 닫고, 메모리를 해제합니다.
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
/* 자식 프로세스: ls 명령 실행 */
execlp("ls", "ls", "-l", NULL);
} else {
/* 부모 프로세스: 자식 종료 대기 */
int status;
waitpid(pid, &status, 0);
printf("자식 종료, 상태: %d\n", WEXITSTATUS(status));
}
return 0;
}이 패턴은 셸이 명령어를 실행할 때 정확히 사용하는 방식입니다. 3장에서 프로세스를 다룰 때 더 깊이 살펴보겠습니다.
파일 관리
UNIX의 모든 것은 파일이다 철학에 따라, 파일 관련 시스템 콜은 일반 파일뿐 아니라 디바이스, 파이프, 소켓에도 동일하게 사용됩니다.
open()— 파일을 열고 파일 디스크립터(File Descriptor)를 반환합니다. 파일 디스크립터는 정수형 핸들로, 0이 표준 입력(stdin), 1이 표준 출력(stdout), 2가 표준 에러(stderr)입니다.read()— 파일 디스크립터에서 데이터를 읽어 버퍼에 저장합니다. 반환값은 실제로 읽은 바이트 수이며, 0이면 파일 끝(EOF)입니다.write()— 버퍼의 데이터를 파일 디스크립터에 씁니다.close()— 파일 디스크립터를 닫습니다. 닫지 않으면 프로세스당 열 수 있는 파일 개수 제한(보통 1024)에 걸릴 수 있습니다.lseek()— 파일 내의 읽기/쓰기 위치를 변경합니다.
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main() {
int fd = open("/etc/hostname", O_RDONLY);
if (fd < 0) {
perror("open 실패");
return 1;
}
char buf[256];
ssize_t n = read(fd, buf, sizeof(buf) - 1);
if (n > 0) {
buf[n] = '\0';
printf("호스트명: %s", buf);
}
close(fd);
return 0;
}메모리 관리
mmap()— 파일이나 장치를 프로세스의 가상 주소 공간에 매핑합니다. 파일의 내용을 메모리처럼 접근할 수 있어, 대용량 파일 처리에 유리합니다.munmap()—mmap으로 매핑한 영역을 해제합니다.brk()/sbrk()— 힙 영역의 크기를 확장하거나 축소합니다.malloc()이 내부적으로 이 시스템 콜을 사용합니다.
프로그래머가 직접 시스템 콜을 호출하는 경우는 드뭅니다. 대부분 C 라이브러리(glibc, musl)나 언어 런타임이 시스템 콜을 감싸서 더 편리하고 이식 가능한 API를 제공합니다. Python의 open()은 내부적으로 CPython 인터프리터의 fileio를 거치고, 이것이 C 라이브러리의 fopen()을 호출하고, 최종적으로 커널의 open 시스템 콜이 실행됩니다.
하지만 시스템 프로그래밍, 고성능 서버, 컨테이너 런타임 등을 개발할 때는 시스템 콜의 존재와 동작 원리를 반드시 알아야 합니다. epoll, io_uring, clone, unshare 같은 시스템 콜은 표준 라이브러리가 직접 제공하지 않는 저수준 기능이기 때문입니다.
시스템 콜과 라이브러리 함수의 차이
이 구분은 혼동하기 쉬운 부분입니다. printf()는 시스템 콜이 아닙니다. C 라이브러리 함수입니다. 내부적으로 시스템 콜(write)을 호출하지만, printf 자체는 사용자 공간에서 서식 문자열을 처리하고 버퍼에 저장한 뒤, 버퍼가 차거나 줄바꿈이 나오면 그때 write를 한 번 호출합니다.
이 버퍼링 전략이 성능에 큰 차이를 만듭니다. printf를 100번 호출하면, 실제 시스템 콜은 몇 번만 발생합니다. 반면 write를 100번 직접 호출하면, 시스템 콜도 100번 발생합니다. 시스템 콜 한 번당 수백 나노초의 오버헤드가 있으므로, 버퍼링은 매우 효과적인 최적화입니다.
Python에서도 마찬가지입니다. print()를 호출할 때마다 즉시 시스템 콜이 발생하는 것이 아니라, 내부 버퍼에 데이터가 쌓이고 버퍼가 가득 차거나 줄바꿈이 나올 때 실제 write가 호출됩니다. sys.stdout.flush()를 호출하면 버퍼를 강제로 비워 즉시 출력할 수 있습니다.
셸의 역할
셸(Shell)은 사용자가 OS와 상호작용하는 인터페이스입니다. 명령줄 셸(bash, zsh)에서 ls를 입력하면, 셸이 이를 해석하여 적절한 시스템 콜을 호출합니다. 셸은 사용자의 텍스트 명령을 시스템 콜 시퀀스로 번역하는 통역자입니다.
셸이 ls 명령을 처리하는 과정은 이렇습니다.
- 셸이
fork()로 자식 프로세스를 생성합니다. - 자식 프로세스가
exec()으로/usr/bin/ls프로그램을 실행합니다. ls가opendir(),readdir()등의 시스템 콜로 디렉토리 내용을 읽습니다.- 결과를
write()로 표준 출력(파일 디스크립터 1)에 출력합니다. ls가exit()으로 종료합니다.- 부모 셸은
wait()으로 자식의 종료 상태를 수거하고, 다시 프롬프트를 표시합니다.
이 과정에서 파이프(|)가 사용되면 더 흥미로워집니다. ls | grep .txt를 입력하면, 셸은 pipe() 시스템 콜로 파이프를 만들고, fork()를 두 번 호출하여 ls와 grep을 각각 실행합니다. ls의 표준 출력이 파이프로 연결되고, grep의 표준 입력이 같은 파이프에 연결됩니다. ls가 출력한 데이터가 파이프를 통해 grep에게 전달되는 것입니다.
셸 스크립트를 작성할 때 $?로 직전 명령의 종료 코드를 확인하는 것, 파이프로 명령을 연결하는 것, 리다이렉션(>, <)으로 입출력을 변경하는 것 — 이 모든 것이 시스템 콜의 조합입니다. 셸은 이 조합을 사람이 읽기 편한 텍스트 인터페이스로 포장한 것뿐입니다.
정리: 커널과 시스템 콜이 중요한 이유
커널과 시스템 콜은 OS의 거의 모든 개념이 수렴하는 지점입니다. 프로세스를 만드는 것, 메모리를 할당하는 것, 파일을 읽는 것, 네트워크 통신을 하는 것 — 모든 것이 시스템 콜을 통해 커널에 요청됩니다. 3장에서 다룰 프로세스의 fork()/exec(), 8장에서 다룰 메모리의 mmap()/brk(), 10장에서 다룰 파일 시스템의 open()/read()/write() 모두 여기서 소개한 시스템 콜의 확장입니다.
다음 장에서는 OS가 관리하는 컴퓨터 하드웨어의 구조 — CPU, 메모리, I/O 장치가 어떻게 연결되어 있고, OS가 각각을 어떻게 제어하는지 — 를 살펴보겠습니다.