처음부터 운영체제를 만드는 것은 C 언어를 작성하는 커널 해커들의 일처럼 들립니다. 하지만 파이썬을 사용하면 오후 한나절 만에 단순화된 시뮬레이션을 돌려볼 수 있으며, 프로세스 관리 로직이 고수준 언어에서도 똑같이 가차 없다는 사실을 금방 깨닫게 될 것입니다. 저는 이 사실을 뼈아프게 배웠습니다. 저는 아주 작은 OS 시뮬레이터를 작성하기 시작했습니다. 목표는 소박했습니다. 몇 개의 프로세스를 생성하고, 스케줄링하고, 작업이 끝나면 완료로 표시하는 것이었습니다. 코드는 짧았습니다. 로직은 완벽해 보였습니다. 그런데 실행해 보니, 아무것도 종료되지 않았습니다.

왜 파이썬으로 미니 OS를 만드는가?

실제 운영체제는 메모리 페이징, 파일 시스템, 하드웨어 인터럽트, 장치 드라이버 등을 동시에 처리합니다. 시뮬레이션은 이 모든 것을 걷어내고 핵심 개념인 '상태(state)'에 집중하게 해줍니다. 프로세스를 정의합니다. 프로세스는 PID, 버스트 타임(burst time), 그리고 생명주기 상태를 가집니다. Ready(준비), Running(실행), Finished(완료). 스케줄러 루프가 다음 후보를 선택하고, 상태를 진행시키며, 타임 슬라이스를 시뮬레이션하고, 완료 상태로 전환합니다.

파이썬은 포인터 연산이나 메모리 정렬을 무시할 수 있기 때문에 이런 종류의 실험을 하기에 훌륭한 도구입니다. 딕셔너리 리스트가 프로세스 테이블이 되고, while 루프가 커널 스케줄러가 됩니다. 표준 라이브러리 도구만으로도 라운드 로빈(round-robin) 스케줄링이나 우선순위 큐를 구현할 수 있습니다. 접근하기 쉬워 보이지만, 바로 그 점 때문에 뒤이어 나타난 버그가 더욱 화가 났습니다.

설정

제 시뮬레이션은 process_table이라는 리스트를 사용했습니다. 각 항목은 다음과 같은 형태의 딕셔너리였습니다:

{
    "pid": 1,
    "burst_time": 3,
    "status": "ready"
}

스케줄러는 단순한 while 루프를 실행했습니다. 테이블을 스캔하여 상태가 "finished"가 아닌 첫 번째 프로세스를 찾았습니다. 프로세스를 찾으면 execute_tick(p)라는 헬퍼 함수를 호출하여 해당 프로세스를 한 번의 시뮬레이션 사이클 동안 실행했습니다. execute_tick 내부에서 프로세스 상태를 "running"으로 설정하고, 버스트 타임을 감소시킨 뒤, 남은 작업량이 0이 되었는지 확인했습니다. 0이 되면 상태를 "finished"로 업데이트했습니다. 외부 루프는 모든 프로세스가 완료 상태에 도달하면 종료되어야 했습니다.

서류상으로는 흐름이 깔끔했습니다. 준비된 프로세스를 찾고, 실행하고, 완료 여부를 확인하고, 완료될 때까지 반복합니다. 스케줄러가 작동하는 것을 관찰하기 위해 print 문도 추가했습니다. 프로세스가 선택되는 것을 볼 수 있었고, 루프는 계속 돌아갔습니다. 하지만 프로세스들은 마치 영원한 현재 시점에 갇힌 것처럼, 영원히 실행만 될 뿐 결코 다음 단계로 넘어가지 못했습니다.

증상

이것은 최악의 실패 유형인 '침묵하는 실패'입니다. 터미널에 스택 트레이스가 출력되지도 않았고, IndexErrorKeyError 같은 단서도 주어지지 않았습니다. 인터프리터는 아무런 문제 없이 작동했습니다. 프로그램이 그냥 의도대로 움직이지 않을 뿐이었습니다. 프로세스는 시작되었지만 결코 끝나지 않았습니다. 저는 흐름을 추적하는 데 몇 시간을 보냈습니다.

루프 조건이 잘못되었나? 버스트 타임 계산에서 오프 바이 원(off-by-one) 에러가 났나? 프로세스 테이블이 제자리에서 업데이트되는 대신 섀도잉되거나 복사되고 있는 건 아닐까? 종료 조건이 잘못된 키를 확인하고 있나? 저는 print 문을 더 추가하고, 모든 불리언(boolean) 표현식을 검토했습니다. 정작 중요한 단 한 줄을 제외한 모든 것을 의심했습니다.

범인

그러다 발견했습니다. execute_tick 내부에서 저는 이렇게 작성했었습니다:

p["status"] == "running"

등호가 두 개였습니다. 할당이 아니라 비교를 하고 있었던 것입니다. 해결책은 단 한 글자 차이였습니다:

p["status"] = "running"

파이썬에서 p["status"] == "running"은 완벽하게 유효한 표현식입니다. 이는 True 또는 False로 평가되지만, 제가 그 결과를 어디에도 할당하지 않았기 때문에 인터프리터는 그 결과를 그냥 버립니다. 이 줄은 아무런 유용한 일도 하지 않습니다. 딕셔너리 항목은 건드려지지 않은 채 이전 상태를 그대로 유지했고, 프로세스는 생명주기를 따라 전진하지 못했습니다.

저는 이를 단일 등호로 바꿨습니다. 스크립트를 다시 실행했습니다. 시뮬레이션이 살아 움직였습니다. 프로세스들이 계획대로 준비, 실행, 완료 단계를 거치며 순환했습니다. 키 하나를 잘못 누른 대가로 몇 시간을 허비했습니다.

이러한 버그가 숨어있는 이유

이 버그가 이토록 뼈아픈 이유는 파이썬이 문법적으로 완전히 틀리지 않는 한 표현식 문장(expression statement)을 에러로 표시하지 않기 때문입니다. 이 버그는 의미론적 오타(semantic typo)였습니다. 프로그램은 상태를 비교하여 불리언 값을 생성한 뒤 그냥 버렸습니다. 비교 결과 자체가 False를 반환할 수 있었기 때문에 프로세스는 이전 상태에 갇혀 있었고, 외부 루프는 종료될 이유를 찾지 못했던 것입니다.

여기에 확증 편향까지 더해집니다. 할당을 의도했기 때문에 자신이 할당문을 작성했다는 사실을 이미 알고 있는 것입니다. 코드를 다섯 번째 읽을 때쯤이면, 뇌는 기호를 자동으로 수정해서 인식합니다. 이것이 바로 러버 덕 디버깅(rubber ducking)이 효과적인 이유입니다. 한 줄 한 줄을 천천히 말로 설명하게 함으로써, 실제로 작성된 코드와 의도했던 코드 사이의 간극을 눈에 보이게 만들기 때문입니다.

이런 작은 버그는 극적인 크래시보다 찾기가 더 어렵습니다. 세그폴트(segfault)나 구문 오류(syntax error)는 즉각적으로 나타납니다. 하지만 조용한 no-op은 단순히 상태를 오염시키고 프로그램이 간신히 계속 실행되도록 내버려 둡니다. 실패는 나중에(downstream) 나타나며, 본능적으로 원인이 아닌 증상을 디버깅하게 됩니다.

더 나은 방어책

눈만 믿어서는 안 됩니다. 이번 일을 겪은 후, 실수를 더 일찍 잡아낼 수 있도록 몇 가지 습관을 바꿨습니다.

첫째, 딕셔너리로 상태를 관리하고 있다면, 프로세스 상태를 위해 dataclassenum.Enum을 사용하는 것을 고려해 보세요. 상태를 상수나 enum 멤버로 정의하십시오:

from enum import Enum

class ProcessState(Enum):
    READY = "ready"
    RUNNING = "running"
    FINISHED = "finished"

명시적인 타입을 사용하면 mypy와 같은 도구가 정적 분석 중에 의심스러운 비교를 찾아낼 수 있습니다. 할당이 있어야 할 자리에 실수로 비교 연산자가 들어간 경우, 타입이 예상과 일치하지 않으면 훨씬 쉽게 발견할 수 있습니다.

둘째, 스케줄러 로직을 작성하기 전에 상태 전이에 대한 유닛 테스트를 작성하세요. 한 번의 틱(tick)만큼 작업이 있는 프로세스를 생성하고, 스케줄러를 실행한 뒤 최종 상태가 FINISHED인지 확인하는 간단한 테스트만 있었어도 즉시 실패했을 것입니다. 그 실패 덕분에 전체 루프를 헤매는 대신 상태 업데이트 로직으로 검색 범위를 좁힐 수 있었을 것입니다.