시스템 콜과 유저 모드, 커널 모드를 설명해 주세요
- 면접 출제
- ★
- 예상 시간
- 7분
유저 모드는 일반 애플리케이션이 제한된 권한으로 실행되는 영역이고, 커널 모드는 OS 커널이 하드웨어와 핵심 자원에 접근할 수 있는 권한 영역입니다. 프로그램이 파일 읽기나 socket 전송처럼 보호된 작업을 해야 하면 시스템 콜을 호출하고, CPU는 커널 모드로 전환해 OS가 요청을 처리한 뒤 다시 유저 모드로 돌아옵니다.
이 구분은 OS의 약속이 아니라 CPU 하드웨어의 기능입니다. x86은 ring 0부터 ring 3까지 권한 레벨을 두고 커널은 ring 0, 애플리케이션은 ring 3에서 실행합니다. 유저 모드에서 특권 명령(입출력 포트 접근, page table 변경, 인터럽트 제어 등)을 실행하면 CPU가 즉시 예외를 발생시켜 커널에 제어를 넘깁니다. 소프트웨어가 아무리 시도해도 하드웨어 수준에서 차단되는 것입니다.
경계를 넘는 유일한 방법이 CPU가 제공하는 전용 명령(x86-64의 syscall)입니다. 이 명령은 임의의 커널 주소로 점프하는 것이 아니라 커널이 미리 등록해 둔 단일 진입점으로만 이동하므로, 유저 프로그램이 커널 코드의 아무 곳이나 실행할 수 없습니다. "정해진 문으로만, 정해진 번호의 요청만" 받는 구조가 보호의 핵심입니다.
꼬리질문
조금 더 깊게 물어본다면
답변 뒤에 이어질 수 있는 질문들을 하나씩 열어볼 수 있어요.
라이브러리 함수와 시스템 콜은 같은 건가요?
항상 같지는 않습니다. 예를 들어 printf는 라이브러리 함수이고 내부적으로 buffering 후 write 시스템 콜을 호출할 수 있습니다.
printf가 좋은 예인 이유는 라이브러리가 시스템 콜을 "덜 부르기 위해" 존재하는 면을 보여주기 때문입니다. printf를 열 번 호출해도 stdio가 유저 공간 버퍼에 모아 두었다가 버퍼가 차거나 개행을 만날 때 write 한 번으로 내보낼 수 있습니다. 커널 경계를 넘는 비용을 라이브러리 계층에서 흡수하는 것입니다.
둘의 경계는 strace 같은 도구로 직접 관찰할 수 있습니다. strace는 프로세스가 실제로 호출한 시스템 콜만 보여주므로, printf 열 번이 write 한 번으로 나가는 것을 눈으로 확인할 수 있습니다. read나 write처럼 이름이 같은 경우도 프로그램이 부르는 것은 glibc가 제공하는 얇은 wrapper 함수이고, 그 안에서 syscall 명령으로 진짜 시스템 콜을 호출합니다.
시스템 콜이 어떤 게 있나요?
read, write, open, close, fork, exec, socket, mmap 같은 호출이 대표적입니다. 언어 런타임이나 표준 라이브러리가 이를 감싸서 제공합니다.
범주로 묶으면 기억하기 쉽습니다. 파일 관련(open, read, write, close), 프로세스 관련(fork, exec, wait, exit), 메모리 관련(mmap, brk), 네트워크 관련(socket, connect, send, recv), 그리고 신호와 권한 관련(kill, setuid)입니다. 커널이 관리하는 자원의 종류마다 대응하는 시스템 콜 묶음이 있는 셈입니다.
흔한 함정이 malloc입니다. malloc은 시스템 콜이 아니라 라이브러리 함수로, 커널에서 큰 덩어리를 미리 받아 두고(brk나 mmap 사용) 그 안에서 잘라 나눠 줍니다. malloc을 호출할 때마다 커널에 다녀오는 것이 아니라, 확보해 둔 것이 모자랄 때만 시스템 콜이 발생합니다. "자주 쓰는 것은 유저 공간에서 해결하고 커널은 가끔만 간다"는 패턴이 여기에도 있습니다.
SIGKILL은 시스템 콜인가요?
아닙니다. SIGKILL은 시그널(signal)이고, kill()이 시스템 콜입니다.
kill(pid, sig)는 프로그램이 커널에 "이 프로세스에 이 신호를 보내줘"라고 요청하는 시스템 콜입니다. SIGKILL은 그 두 번째 인자로 전달하는 신호 번호(9)의 이름입니다.
터미널에서 치는 kill -9 <pid>도 내부적으로 kill() 시스템 콜을 호출합니다.
정리: kill()은 시스템 콜, SIGKILL은 그 시스템 콜로 전달하는 신호의 이름입니다.
인터럽트와 시스템 콜의 차이는?
인터럽트는 하드웨어(타이머, 네트워크 카드 등)나 소프트웨어가 CPU에 비동기적으로 알리는 신호입니다. 시스템 콜은 프로그램이 의도적으로 커널 서비스를 요청하는 동기적 호출입니다. 시스템 콜은 내부적으로 소프트웨어 인터럽트(트랩)를 통해 커널로 진입하는 구현이 많습니다.
커널로 들어가는 사건을 셋으로 나누면 구분이 선명해집니다. 인터럽트는 외부 장치가 비동기로 일으키고(언제 올지 모름), 예외(exception)는 실행 중인 명령이 동기로 일으키며(0으로 나누기, page fault), 트랩은 프로그램이 의도적으로 일으킵니다(시스템 콜). 셋 다 "하던 일을 멈추고 커널의 처리 루틴으로 점프한다"는 하드웨어 메커니즘은 공유하고, 커널은 벡터 테이블에서 원인별 처리 루틴을 찾습니다.
역사적으로 Linux x86에서 시스템 콜은 int 0x80이라는 소프트웨어 인터럽트 명령을 썼지만, 범용 인터럽트 처리 경로가 느려서 현대 x86-64는 시스템 콜 전용으로 설계된 syscall/sysret 명령을 씁니다. 벡터 테이블 조회 없이 등록된 진입점으로 바로 이동해 전환 비용을 줄인 것입니다.
모드 전환은 컨텍스트 스위칭과 같은가요?
다릅니다. 모드 전환은 같은 실행 흐름 안에서 권한 수준이 바뀌는 것이고, 컨텍스트 스위칭은 실행할 작업 자체가 바뀌는 것입니다. 다만 시스템 콜 중 blocking되면 스케줄링으로 이어질 수 있습니다.
비용 차이로 구분하면 명확합니다. 모드 전환은 같은 프로세스 안에서 권한과 스택만 바꾸므로 주소 공간이 유지되고 캐시와 TLB(주소 변환 캐시)가 대부분 살아 있습니다. 컨텍스트 스위칭은 레지스터 전체를 저장·복원하고 다른 프로세스의 주소 공간으로 바꾸므로, page table 교체와 TLB 무효화가 따라와 훨씬 비쌉니다. 여기에 새 프로세스의 데이터로 캐시가 다시 채워질 때까지의 간접 비용도 있습니다.
둘이 이어지는 시나리오가 blocking 시스템 콜입니다. read를 호출하면 모드 전환으로 커널에 들어가고, 데이터가 아직 없으면 커널이 이 thread를 대기 상태로 바꾸고 스케줄러가 다른 thread를 고릅니다. 이때 컨텍스트 스위칭이 일어납니다. 즉 모드 전환은 항상 일어나지만, 컨텍스트 스위칭은 기다릴 일이 생겼을 때만 뒤따라옵니다.
커널 모드에서 버그가 더 위험한 이유는?
커널 모드는 하드웨어와 전체 메모리 관리에 접근할 수 있습니다. 잘못된 접근이 프로세스 하나가 아니라 시스템 전체 장애로 이어질 수 있습니다.
유저 프로세스의 잘못된 메모리 접근은 커널이 감지해 그 프로세스만 종료시키면 끝납니다(segmentation fault). 하지만 커널 자신이 잘못된 접근을 하면 이를 수습할 상위 존재가 없습니다. Linux의 kernel panic, Windows의 블루스크린이 이 상황으로, 커널이 자신의 상태를 신뢰할 수 없으니 데이터 손상을 막기 위해 시스템 전체를 멈추는 것입니다.
이 위험은 OS 설계 방향에도 영향을 줍니다. 장치 드라이버는 커널 모드에서 도는 코드 중 가장 흔한 crash 원인이라, 커널에 넣는 코드를 최소화하려는 시도가 계속되어 왔습니다. 파일 시스템을 유저 공간에서 구현하는 FUSE, 커널 안에서 실행되지만 사전에 안전성이 검증된 코드만 허용하는 eBPF가 그 예입니다. "커널 모드 코드는 최소한으로"가 공통 원칙입니다.
시스템 콜 비용을 줄이는 방법은?
buffering, batch 처리, zero-copy 기법, async I/O 같은 방식이 있습니다. 핵심은 커널 경계를 불필요하게 자주 넘지 않는 것입니다.
각 방식이 줄이는 것을 구체적으로 보면, buffering은 stdio처럼 작은 read/write 여러 번을 큰 호출 한 번으로 합치고, epoll 같은 multiplexing은 소켓 수천 개를 각각 확인하는 대신 "준비된 것 목록"을 한 번에 받습니다. io_uring은 한발 더 나가 유저 공간과 커널이 공유하는 큐에 요청을 써 두면 커널이 가져가는 구조로, 요청 제출 자체에 시스템 콜이 거의 필요 없게 만듭니다.
커널 경계를 아예 없애는 방식도 있습니다. gettimeofday처럼 자주 불리지만 읽기만 하는 호출은, 커널이 관련 데이터를 유저 공간에서 읽을 수 있는 페이지에 노출해(vDSO) 시스템 콜 없이 처리됩니다. 공통 원리는 하나입니다. 경계 넘기 한 번의 비용은 못 줄이니, 넘는 횟수를 줄이거나 넘지 않고 해결하는 것입니다.
zero-copy와 시스템 콜의 관계는?
일반적으로 파일 데이터를 네트워크로 보내려면 커널 버퍼에서 유저 버퍼로, 다시 소켓 버퍼로 데이터를 복사하면서 여러 번 시스템 콜이 발생합니다. sendfile 같은 zero-copy 기법은 유저 공간을 거치지 않고 커널 안에서 데이터를 전달하므로 복사 횟수와 시스템 콜 오버헤드를 줄입니다.
read 후 write로 파일을 소켓에 보내는 경로를 따라가 보면 낭비가 보입니다. 디스크에서 커널의 page cache로, page cache에서 유저 버퍼로, 유저 버퍼에서 소켓 버퍼로 복사가 이어지는데, 유저 공간을 거치는 두 번의 복사 동안 프로그램은 데이터를 들여다보지도 않습니다. 그대로 전달만 할 데이터를 CPU가 두 번 옮겨 적는 셈입니다.
sendfile은 "이 파일의 이 구간을 저 소켓으로"라는 요청 한 번으로 커널이 내부에서 직접 전달하게 합니다. 시스템 콜이 두 번(read, write)에서 한 번으로 줄고 유저 공간 복사가 사라집니다. 정적 파일을 대량으로 서빙하는 웹 서버(Nginx)나 디스크의 로그를 그대로 네트워크로 내보내는 Kafka가 이 기법으로 처리량을 얻는 대표 사례입니다.
부가 설명
프로그램이 마음대로 디스크나 메모리 관리 구조를 건드릴 수 있다면 한 애플리케이션의 버그가 시스템 전체를 망가뜨릴 수 있습니다. 그래서 CPU와 OS는 권한 수준을 나누고, 일반 코드는 유저 모드에서 제한된 명령만 실행하게 둡니다.
시스템 콜은 이 경계를 안전하게 넘는 공식 입구입니다. 다만 경계를 넘는 데에는 비용이 있습니다. 커널로 들어가 인자를 검증하고, 필요한 작업을 수행하고, 다시 사용자 공간으로 돌아와야 합니다. 그래서 작은 read/write를 지나치게 많이 호출하는 코드는 buffering으로 시스템 콜 횟수를 줄이기도 합니다.
한 줄 정리
시스템 콜은 사용자 프로그램이 보호된 OS 기능을 쓰기 위해 커널에 들어가는 안전한 관문입니다.