현대 운영체제의 프로세스 모델이 어떻게 탄생했고 왜 이제는 한계가 되는지, 스레드·비동기 I/O·공유 라이브러리·컨테이너 등 수많은 우회책이 어떤 문제에서 비롯됐는지, 그리고 이를 넘어서는 새로운 OS와 프로세서 설계의 필요성을 논한다.
게시일 2025년 9월 17일
요즘 컴퓨팅과 프로그래밍, 그리고 그 주변 산업의 상태에는 엉망진창인 것들이 많다. 모두가 믿고 싶어 하는 것과는 달리, 이런 문제들 대부분에는 단일한 원인을 찍어 말할 수 없다. 하지만 특정 부류의 문제들을 곱씹다 보니, 프로세스 모델이 그 문제들에 크게 기여해 왔다는 걸 알게 되었고, 그래서 이 글에서 그 이야기를 해 보려 한다. 우리는 어떻게 프로세스 모델을 넘어섰는지, 그리고 다른 무언가를 찾는 데 가장 큰 장애물이 무엇인지에 대한 관점을 제시하겠다.
내가 말하는 프로세스 모델은, 오늘날의 대중적인 운영체제들이 사용자 코드를 실행하는 방식이다. 운영체제는 실행 파일을 받아 메모리의 일부를 떼어 넣고, 엔트리 포인트에서부터 코드를 실행하기 시작한다. 프로세스는 설계상 처음에는 "순수"하다. 실행 중인 코드가 실행 환경에 직접 영향을 줄 수 없고, 그렇게 하려면 운영체제에 요청해야 한다(보통 시스템 콜을 통해서). 운영체제는 여러 프로세스를 관리하고, 그들의 메모리와 자원이 서로 격리되도록 강제하며(공유를 요청하고 운영체제가 허용하는 경우는 예외), 프로세서(들)가 각 프로세스의 코드를 공정하게 실행하도록 보장한다(여기서 공정의 정의는 여러 가지가 있다).
집중하기 위해, 이 글은 운영체제에 대한 최소한의 기초 지식을 전제로 한다.
1940~50년대의 초기 디지털 컴퓨터는 단일 프로그램을 실행했으며, 그 프로그램은 패치 케이블, 스위치, 펀치 테이프와 카드 등 물리적 형태로 컴퓨터에 주어졌다. 그 프로그램들은 대개 수학 계산과 같은, 거의 순수한 계산 작업을 수행하는 데 쓰였다.
컴퓨터가 빨라지고, 메모리가 늘고, 주변장치가 도입되면서 사람들이 작성하는 프로그램의 크기와 복잡도도 증가했다(더 이상 단순 계산만이 아니었다). 하지만 프로그램을 물리적으로 입력하는 과정이 병목이 되었다.
이 시점에서 입력은 보통 펀치 테이프와 카드로 이뤄졌다. 컴퓨터는 사실상 한 번에 하나의 프로그램만 실행했기 때문에, 비록 연구자/프로그래머 팀이 공유했어도(비싼 기계였다) 어느 순간에는 “한 명의 사용자”만 활성 상태였다. 프로그램을 실행할 준비를 하는 데 귀중한 시간이 들었다. 일부 컴퓨터는 원시적인 운영체제1을 갖추어, 프로그램을 큐에 넣어 실행하고 현재 프로그램이 끝나자마자 다음 프로그램을 실행함으로써 프로세서를 효율적으로 사용하려 했다.
1960년대에 들어 사람들이 컴퓨터가 여러 프로그램을 동시에 실행하거나 여러 사용자가 동시에 같은 컴퓨터를 사용할 수 있게 하는 방법을 고안했다. 이 지점에서 프로세스 모델이 모습을 드러내기 시작했다.
그 전에는, 한 번에 하나의 프로그램만 실행된다는 보장이 있었기 때문에 프로그램이 주변장치와 시스템 자원을 직접 제어할 수 있었다. 여러 프로그램을 동시에 실행하려 하자 이것이 문제가 되었고, 운영체제는 프로그램이 서로의 상태를 망치지 못하게 막는 기능과 주목을 받기 시작했다.
이런 운영체제들이 여러 프로그램을 동시에 실행한 방식은, 각각의 프로그램이 마치 자신만이 컴퓨터에서 유일하게 실행되는 것처럼 “모의(simulate)”하는 것이었다. 이론적으로는, 이미 작성된 프로그램을 다시 쓸 필요가 없다는 뜻이었다. 그때쯤에는 확실히 컴퓨팅을 둘러싼 산업이 이미 있었고, 비용을 줄이고 빨리 출시하라는 압박도 지금과 비슷했으리라 추측한다.
프로세스는 배치 작업과 같았다. 사용자가 시작해 완료될 때까지 돌려 놓고, 결과를 받는 그런 것이다.
이후의 운영체제들도 같은 개념을 유지하면서 몇 가지를 더 얹었다(예: 가상 메모리, 스레드). 그리고 우리가 지금까지 다뤄 온 것이 대체로 이것이다. 우리는 여전히 배치 처리하듯이 프로그래밍하고, 그 위에 수많은 장식만 덧붙였을 뿐이다.
프로세스 모델에는 태생부터 동반된 결함이 하나 있다. 각 프로그램에 고립되고 무균에 가까운 환경을 보여 주는 것이다. 이는 당시 작성된 기존 프로그램을 수정 없이 새 운영체제에서 돌리기 위한 불가피한 우회책이었지만, 우리는 이미 이것을 넘어섰다.
오늘날 우리가 쓰는 많은 프로그램은 다른 프로그램과 통신하고 협력하기를 기대한다. 하지만 우리의 모델은 여전히 이 고립된 환경을 강제하고, 그래서 운영체제는 런타임에 통신과 공유를 요청할 수 있도록 우회책을 제공한다. 그 대가로, 각 프로그래머는 통신을 어떻게 허용할지와 다른 프로그램과 어떻게 연결할지를 매번 스스로 정해야 한다.
프로그램이 무균 환경에서 실행되다 보니, 실행에 필요한 모든 기능을 스스로 가져와야 한다. 운영체제는 우회책으로 어떤 형태든 코드 공유를 지원하려 했지만, 그것도 또 다른 골칫거리를 낳았다. 공유 라이브러리는 확실히 아쉬운 점이 많다. 그래서 사람들은 그것을 아예 포기(정적 컴파일)하거나, 컨테이너 같은 우회책을 쓴다(아이러니하게도 우회책 위에 또 다른 우회책). 하지만 이들 모두는 시스템에 이미 존재하는 추가 기능을 프로세스가 활용하도록 돕지 못한다. 이것은 공유 라이브러리도 끝내 해결하지 못한 문제다(아주 초기 Multics의 코드 공유 기능은 후대 시스템들보다 이 이상에 더 가까워 보인다). 결국 각 프로세스는 어떤 방식으로든 필요 물품을 다 짊어지고 와야 한다.
이 결함 때문에 우리는 계속 같은 결정을 반복하고, 같은 것들을 매번 다시 쓰고 있다.
처리 시간과 I/O는 디지털 컴퓨터 역사 내내 되풀이되어 등장하는 두 가지 주요 병목이다. 초기의 계산 중심 프로그램들은 몇 분에서 몇 시간까지 걸렸다. 이후 서비스처럼 동작하는 프로그램들이 등장하면서 주변장치와 I/O에서 병목이 나타나기 시작했다. 프로세서는 충분히 빨라져 계산은 금세 끝냈지만, 프로세스 모델(프로그램을 직렬로 돌리도록 강제함) 때문에 일부 프로그램은 I/O를 수행하느라 병목이 생겼다.
클럭을 더 빠르게 만드는 것은 처리 시간 병목을 해결하는 직관적인 방법이다. 하지만 그렇게 하려다 물리적 한계에 부딪혔고, 그래도 프로그램을 더 빨리 돌리고 싶었다. 그에 대한 최선의 해법은 다수의 처리 유닛을 두어, 프로그램이 몇 개의 부분으로 “쪼개져” 각 부분을 다른 유닛에서 실행하도록 하는 것이었다. 이는 1960년대에도 어느 정도 이미 있었다. 하지만 오늘날 우리가 아는 스레드의 개념이 공고해진 것은 수십 년이 지난 후였다. 그 뒤 더 빠른 실행을 향한 탐구 속에서 우리는 이벤트 루프, 코루틴, 비동기 코드 등을 거쳐 왔지만, 이들 모두는 운영체제가 프로세스에 제공하는 동시성 기반인 스레드 위에 서 있다.
스레드는 어떤 스레드는 I/O를 수행하는 동안 다른 스레드가 계속 계산하도록 해 I/O 병목을 완화했다. 하지만 이것도 이상적이라기보다 또 다른 우회책에 가까웠다. 운영체제는 마침내 I/O를 더 빠르게 하기 위한 다른 우회책을 도입했다. 실제 I/O 작업을 프로그램 바깥, OS 안으로 떠넘기도록 허용한 것이다(우리가 지금 비동기 I/O라고 부르는 것).
그 이후 우리는 느린 작업을 운영체제로, 프로세스 밖으로 떠넘기는 온갖 방법을 찾아냈다. 그것이 프로세스 모델을 따른 채로 더 많은 일을 하는 유일한 길이었기 때문이다. select 시스템 콜에서 epoll, io_uring에 이르기까지, 우리는 “이걸 해 주세요. 하지만 프로세스 안에서는 하지 말아 주세요. 저는 동시에 더 계산도 해야 하거든요.”라는 요청의 수많은 변주를 거쳐 왔다.
기록, 조작, 조회를 위한 프로그램(초기 데이터베이스)을 작성하기 시작하면서 우리는 프로세스 모델의 가정을 하나 깼다. 프로그램은 완료될 때까지 실행된다는 가정 말이다. 우리는 일회성 배치 처리에서 항상 요청을 받고 데이터를 보내는 서비스로 옮겨갔다.
하지만 코드의 어떤 실패라도 프로세스를 완전히 무너뜨린다. 그래서 프로그래머들은(운영체제의 약간의 도움을 받아) 프로세스가 멈췄을 때 재시작하는 방법을 찾거나, 코드가 운영체제 수준에서 크래시 나지 않도록 하는 방법을 찾았어야 했다(대개 둘 다). 그리고 프로세스는 항상 새 환경에서 시작되므로, 중요한 상태를 어떻게든 영속화해야 했다(즉 I/O를 해야 한다. 이는 앞서 말한 프로세스의 I/O 문제로 이어진다).
기계들을 네트워크로 연결하기 시작하자, 우리의 프로그램도 서로 다른 기계에서 실행되고 서로 통신하게 되었다. 그러자 여러 기계에서 실행 중인 프로그램(같은 프로그램일 수도, 서로 다른 프로그램일 수도 있다)을 조율할 필요가 생겼고, 프로세스가 언젠가 반드시 죽는다는 전제하에 상태를 어떻게 영속화할지도 다시 고민해야 했다.
프로세스 모델은 여기에 본질적으로 아무런 도움도 주지 못했고, 운영체제가 제공한 우회책도 거의 없었다. 그래서 우리는 우리만의 해결책을 만들어야 했고, 이는 프로세스 모델을(최소한) 두 번이나 우회한다는 뜻이 되었다. 가상화와 컨테이너화 역시 흔히 프로세스 모델의 제약을 그대로 안고 간다.
이쯤이면 프로세스 모델이 우리가 코드로 하고자 하는 많은 일에 역행하고 있다는 점에 어느 정도는 동의하리라 믿는다.
내가 본 대부분의 ‘다른 모델’ 시도는 프로그래밍 언어 차원에서 이뤄졌다. 그중 가장 대중적인 것(나도 좋아한다)은 Erlang과 BEAM일 것이다. 하지만 이런 노력도 결국 현재의 운영체제 위에서 벌어지므로, 최종적으로는 프로세스 모델의 제약에 갇힌다. 프로세스는 추상화이고, 정의상 누수되는 추상화다.
정말로 다른 무언가를 하려면 운영체제 레이어에서 재발명이 필요하다. 하지만 운영체제를 재발명하는 일은 간단치 않다.
역사적으로 프로세서와 운영체제 기능의 발전은 자주 맞물렸다. 새로운 하드웨어 기능이 등장하면, 운영체제는 사용자 코드에 그것을 어떤 식으로든 제공하기 위한 새로운 코드를 필요로 한다. 그리고 역사적으로 운영체제가 순전히 소프트웨어로만 해 보려 했던 일들 가운데 일부는, 하드웨어가 제대로 뒷받침해 주었을 때에야 비로소 실현되었다.
문제는, 그 결과 프로세서가 운영체제가 특정 방식으로 동작할 것을 기대하게 된다는 점이다. 누군가 운영체제에 대해 완전히 다른 모델을 고안하려 들면, 기존 프로세서에서 그것을 실행하지 못하거나 실행이 매우 어려워진다. 여기서도 추상화 누수가 다시 고개를 든다.
이것이 내가 프로세스 모델을 넘어서는 것이 어려운 이유라고 생각하는 바다.
인터넷에서 새로운 OS 프로젝트가 나타날 때마다 나는 완전히 다른 설계를 보게 될까 기대한다. 하지만 지금까지는 다들 대략 같은 모양새였다. 그들 모두에서 프로세스 모델의 한계를 알아볼 수 있다. 진정으로 새로운 설계에 도달하려면 프로세서 또한 우리가 직접 설계해야 한다.
이 결론에 이르렀을 때 한동안 의욕이 꺾였지만, 생각만큼 나쁜 소식은 아니다. 현재의 프로세서들은 매우 강력하지만, 지금은 대체로 스스로 만들어 낸 문제들을 스스로 해결하는 데에 그 힘을 쓰고 있다. 예컨대 명령 파이프라인과 투기 실행의 온갖 문제는, 모든 것을 할 수 있고 여러 종류의 코드를 동시에 많이 돌리도록 만든 거대한 처리 유닛이라는 현재 프로세서 설계 때문에 존재한다.
새로운 설계를 발명한다면, 같은 결정을 내리지 않는 한 이런 문제들에 얽매이지 않는다. 극단적으로 병렬성을 염두에 두고 설계되어, 하나의 처리 유닛을 여러 프로그램이 함께 재사용하는 것을 아예 허용하지 않는 새로운 프로세서를 상상해 보라. 기존 프로세서가 잘 동작하기 위해 수행해야 하는 많은 어려운 일들을 비켜갈 수 있다. 물론 그 역시 나름의 문제들을 반드시 마주하겠지만, 새로운 프로세서 아키텍처가 열어 줄 설계 공간이 그가 가져올 새로운 문제들을 상쇄하고도 남기를 바란다.
이것이 내가 다양한 아이디어를 탐색하도록 영감을 주는 것이며, (거의) 바닥부터 새로운 종류의 프로세서와 운영체제를 설계하는 실험을 해 온 이유다.