System Call

mongBrown·2026년 4월 14일

System Call — 프로세스가 OS에 부탁을 보내는 방식

System.out.println("hello")를 실행하면 어떤 일이 일어날까. 문자열을 화면에 출력하는 단순한 작업처럼 보이지만, 내부적으로는 OS를 거쳐야만 실행할 수 있는 요청이다. 왜 우리 코드가 직접 화면에 출력하지 못하고 OS한테 부탁해야 하는 걸까.


왜 프로세스는 직접 하드웨어에 접근하지 못할까

CPU 코어 하나는 프로세스가 실행될 때 두 가지 모드 중 하나로 동작한다.

User Mode는 일반 프로그램이 실행되는 모드다. 자신이 할당받은 메모리 안에서 계산하고 로직을 처리하는 건 자유롭게 할 수 있다. 하지만 파일 시스템, 네트워크 카드, 다른 프로세스의 메모리 같은 외부 자원에 직접 접근하는 CPU 명령어는 실행 자체가 막혀 있다.

Kernel Mode는 OS가 실행되는 모드다. 모든 하드웨어 자원에 직접 접근할 수 있다.

이렇게 나눠놓은 이유는 하나다. A 프로그램이 실행 중인 B 프로세스의 메모리를 마음대로 읽거나 바꿀 수 있다면, 또는 임의의 파일을 실행해버릴 수 있다면 시스템 전체가 위험해진다. CPU 레벨에서 애초에 그런 명령어를 실행하지 못하도록 막는 것이다.

User Mode에서 하드웨어 접근이 필요한 순간, 프로세스는 OS에 요청을 보낸다. 이게 System Call이다.


어떤 작업들이 System Call을 필요로 할까

자신의 메모리 안에서 벌어지는 일은 System Call 없이 처리된다. 변수 계산, 조건문, 반복문 같은 것들은 User Mode에서 그대로 실행된다.

반면 외부 자원을 건드리는 순간부터는 반드시 OS를 거쳐야 한다.

파일 입출력이 대표적이다. 파일은 디스크에 있고, 디스크는 하드웨어다. User Mode에서 직접 디스크에 명령을 보낼 수 없으니 OS가 대신한다. open(), read(), write() 같은 시스템 콜이 여기에 해당한다.

네트워크 통신도 마찬가지다. 네트워크 카드에 데이터를 보내고 받는 것도 하드웨어 접근이다. socket(), connect(), send() 등이 내부적으로 System Call로 처리된다.

프로세스 생성도 OS가 해야 한다. 새 프로세스를 만들려면 메모리를 할당하고 스케줄러에 등록하는 작업이 필요한데, 이건 커널만 할 수 있다. fork()가 여기에 해당한다.

화면 출력도 빠지지 않는다. System.out.println("hello")는 결국 표준 출력 장치(터미널)에 쓰는 작업이고, 이것도 write() 시스템 콜로 처리된다.


System Call이 실행될 때 어떤 일이 일어나는가

User Mode에서 System Call이 호출되면, CPU는 이 요청을 trap으로 처리한다. 프로그램이 System Call 명령어(syscall 같은)를 실행하는 순간, CPU가 자동으로 trap을 발생시키고 Kernel Mode로 전환한다.

하드웨어 인터럽트와 비교하면 차이가 명확하다. 하드웨어 인터럽트는 키보드나 네트워크 카드 같은 외부 장치가 CPU에 신호를 보내는 것으로, 프로그램이 예상하지 못한 시점에 발생한다. 반면 trap은 프로그램이 System Call 명령어를 실행했을 때 CPU가 발생시키는 것으로, 프로그램이 의도한 시점에 발생한다. 외부에서 신호가 오는 게 아니라, 특정 명령어 실행 자체가 trap을 유발하는 구조다.

프로세스 (User Mode)
    → System Call 호출
    → CPU trap 발생
    → User Mode → Kernel Mode 전환
    → OS가 요청 검증
        - 이 파일 존재하는가
        - 이 프로세스가 접근 권한이 있는가
        - 다른 프로세스가 이 자원을 잠갔는가
    → 작업 수행
    → 결과 반환
    → Kernel Mode → User Mode 복귀

OS가 단순히 "허가/거부"만 하는 게 아니다. 요청이 유효한지, 자원의 현재 상태는 어떤지까지 확인한 뒤 작업을 수행하고 결과를 돌려준다. 여러 프로세스가 같은 파일에 쓰기 요청을 동시에 보낼 때 순서를 조율하는 것도 이 과정에서 일어난다.


우리는 System Call을 직접 부르지 않는다

Java로 파일을 읽을 때 read() 시스템 콜을 직접 작성하지 않는다. FileInputStream 같은 API를 쓰고, 그 내부에서 시스템 콜이 호출된다.

내 코드 → 언어 API (Java stdlib 등) → System Call → Kernel

이 흐름이 어떻게 동작하는지 조금 더 들여다보면, 컴파일 시점과 런타임 시점을 구분해서 이해해야 한다.

Java 소스 코드를 컴파일하면 바이트코드(.class)가 만들어진다. 이 바이트코드 안에는 "이 OS에서는 이 System Call을 써라"라는 내용이 없다. 그냥 FileInputStream.read()를 호출한다고만 적혀 있다. System Call로의 변환은 컴파일 시점에 일어나지 않는다.

실제 변환은 런타임에 JVM이 담당한다. JVM이 바이트코드를 실행하다가 FileInputStream.read() 같은 API를 만나면, JVM 내부에 C/C++로 구현된 네이티브 코드가 해당 OS의 System Call을 호출한다. Windows 위에서 실행 중인 JVM이라면 Windows의 System Call을, Linux 위라면 Linux의 System Call을 사용한다.

같은 바이트코드
    → Windows JVM → Windows System Call
    → Linux JVM   → Linux System Call

결국 개발자는 운영체제마다 다른 System Call 이름이나 방식을 알 필요 없이 Java API만 사용하면 된다. OS 차이를 흡수하는 건 컴파일러가 아니라 JVM이기 때문이다.


System Call 비용이 크다는 게 실제로 무슨 의미인가

System Call은 단순히 "OS한테 요청 보내고 받는" 게 아니다. 모드 전환 자체에 두 가지 큰 비용이 따른다.

첫 번째는 레지스터 저장/복원이다. User Mode에서 Kernel Mode로 전환할 때 CPU는 현재 프로세스가 사용하던 레지스터 상태 전부를 저장한다. 어디까지 실행했는지, 어떤 값을 들고 있었는지를 기록해두지 않으면 Kernel Mode 작업이 끝나고 돌아왔을 때 이어서 실행할 수 없기 때문이다. 작업이 끝나면 저장해둔 상태를 다시 복원하는 과정도 필요하다.

두 번째는 CPU 캐시 오염이다. CPU는 자주 접근하는 데이터를 캐시에 올려두고 메모리보다 훨씬 빠르게 읽는다. 그런데 Kernel Mode로 전환되면 커널 코드가 실행되면서 캐시에 있던 프로세스 데이터가 밀려난다. 다시 User Mode로 돌아왔을 때 필요한 데이터가 캐시에 없어서 메모리에서 다시 가져와야 한다. 이 캐시 미스가 누적되면 생각보다 큰 성능 차이가 난다.

그래서 I/O가 빈번한 애플리케이션에서 성능을 고민할 때 System Call 호출 횟수 자체가 지표 중 하나가 된다. Java NIO가 기존 IO보다 대용량 처리에 유리한 이유 중 하나도 여기 있다. 시스템 콜 한 번에 더 많은 데이터를 처리하도록 설계해서 모드 전환 횟수를 줄이기 때문이다.

profile
화이팅!

0개의 댓글