본문으로 건너뛰기

© 2026 Molayo

GeekNews헤드라인2026. 06. 07. 09:40

fork() + exec()를 넘어

요약

Unix의 fork()+exec() 모델이 현대 운영체제 설계에서 갖는 한계와 역사적 배경을 분석합니다. 메모리 관리 구조 복사 비용과 같은 성능 이슈 및 현대적인 프로세스 생성 방식에 대한 논의를 다룹니다.

핵심 포인트

  • fork()+exec()는 과거 메모리 제약 극복을 위한 설계였으나 현대에는 나쁜 추상화일 수 있음
  • fork() 호출 시 페이지 테이블 및 VMA 복사로 인한 비용 발생
  • Redis와 같이 대용량 메모리를 사용하는 경우 fork() 성능 저하 문제 발생
  • 완전히 새로운 프로세스 생성을 위한 posix_spawn 등의 대안 존재

관련 논의로 A fork() in the road 논문이 있음: https://www.microsoft.com/en-us/research/wp-content/uploads/...
초록에서는 Unix의 fork()+exec() 조합이 영감 어린 설계라는 통념과 달리, 1970년대 기계와 프로그램에는 영리한 해킹이었지만 이제는 현대 프로그래머에게 나쁜 추상화이고 운영체제 구현도 제약한다고 주장함
운영체제의 1급 원시 기능으로 남겨두기보다 역사적 유물로 가르치고, 학생들이 처음 배우는 프로세스 생성 방식이 되지 않게 해야 한다는 입장임

fork()+exec()가 그렇게 된 이유는 부모 프로그램과 함께 메모리에 들어가지 못할 만큼 큰 프로그램을 실행할 수 있게 하려는 것이었음
원래 구현은 fork() 호출 시 포크하는 프로그램을 디스크로 스왑아웃하고, 제어가 돌아오기 전 프로세스 테이블 항목을 복제·조정해서 메모리에 있는 프로세스와 스왑아웃된 프로세스가 생기게 했고, 메모리에 있는 쪽이 제어를 받아 exec()를 호출할 수 있었음
이 방식 덕분에 작은 PDP-11 기계에서도 큰 프로그램을 실행할 수 있었고, 메모리가 매우 비싸던 시대에는 필요했음
QNX는 흥미롭게도 프로그램 로딩이 운영체제 안에 없고 라이브러리에 있음. 실행 파일 헤더를 읽고 메모리를 할당하고 프로그램을 로드해 실행 준비를 한 뒤 시작하는 .so에 링크하며, 프로그램 로더는 권한 없는 사용자 공간에서 돌아감. 아마 이쪽이 올바른 방식에 가까움

fork()를 쓰지 않는 가장 널리 쓰이는 “큰” 운영체제인 Windows의 프로세스 생성이 매우 느리다는 점은 흥미로움 fork()가 아닌 원시 기능이 있어야 한다는 데는 동의하지만, 성능이 최고의 논거인지는 잘 모르겠음

fork()는 zygote 패턴에는 훌륭함
그만큼 효율적이고 우아한 최적화를 떠올리기 어려움

최근에 포크된 프로세스에서 더 많은 파일 디스크립터를 닫아야 해서 생긴 모호한 버그를 겪었음
내 경험상 “현재 프로세스의 복제본을 원한다”보다 “완전히 새 프로세스를 원한다”가 훨씬 흔한데, 후자를 직접 표현할 방법이 없고 복제한 다음 사후에 고치는 식으로만 근사해야 한다는 게 이상하게 느껴짐

보통 그 프로세스와 통신하고 싶으니, 예를 들어 파일 디스크립터 같은 것을 설정해야 하고 부모 프로세스의 정보를 넘겨야 함

그건 O_CLOEXEC로 해결되는 것 아닌가?

“후자를 직접 표현할 방법”이라면 그게 posix_spawn 의 용도 아닌가?

“완전히 새 프로세스”가 정확히 무슨 뜻인가?

“fork()는 상대적으로 비싼 시스템 호출이고, 자식 프로세스를 위해 메모리를 포함한 전체 프로세스 상태를 복사해야 한다. 수년간 많은 최적화가 있었지만 근본적으로 비용이 큰 작업이다. 더 나쁜 것은 fork() 호출 뒤에 곧바로 exec()가 따라와서, 자식을 위해 정성껏 복사한 메모리를 전부 버리는 경우가 많다는 점이다”라고 하면서 쓰기 시 복사(copy-on-write) 를 언급하지 않는 건 이상함
실제 메모리 전체를 복사하지 않게 해주는 최적화인데 빠져 있음

글에서는 암묵적으로 처리했지만, 여기서 프로세스 상태 복사는 메모리 관리 구조를 뜻함. 주로 페이지 테이블과 VMA임
실제 페이지가 가리키는 메모리는 공유되더라도, 이 구조들의 복사본을 담기 위해 새 페이지를 할당해야 함. 그리고 그 구조들을 전부 순회해 복사하는 것 자체가 여전히 비쌈

Redis는 이 비용이 크게 중요한 프로세스 유형임. fork()가 메모리 자체를 복사하지는 않지만 페이지 테이블은 여전히 복사해야 함
수십 GB RAM을 들고 있는 프로세스라면 fork()가 오래 걸릴 수 있고, Redis가 .rdb 파일을 덤프하거나 바이너리 로그인 AOF를 다시 쓸 때마다 한 번씩 발생함
2012년에도 이 작업의 높은 비용을 보여준 글이 있었음: https://redis.io/blog/testing-fork-time-on-awsxen-infrastruc...
약 25GB RAM을 쓰는 m2.xlarge에서 fork()에 5.67초가 걸렸음. Redis 클라이언트가 보통 대부분의 작업에서 한 자릿수 밀리초 지연을 겪는다는 점을 생각하면 긴 정지 시간임. 이건 페이지 테이블 복사 시간뿐임
huge page를 언급하지 않는 건 놀랍고, 여기서는 핵심 고려사항처럼 보임. 14년 뒤 하드웨어가 빨라졌겠지만 Redis 인스턴스도 더 많은 RAM을 쓸 가능성이 높으니, 이 벤치마크를 다시 해보면 흥미로울 듯함

이런 논문의 의도된 독자층에게 쓰기 시 복사는 기본 지식이라 생략된 듯함

쓰기 시 복사가 있어도 fork()는 그 설정 비용을 치러야 함. 부모 프로세스에 바쁜 스레드가 많으면, 예를 들어 Java에서는 exec()가 실행되기 전에 불필요한 쓰기 시 복사가 많이 발생할 수 있음

본문은 “상태”라고 했음. 쓰기 시 복사여도 내용을 복사하지 않을 뿐 페이지 테이블 항목 수에 비례하는 비용은 남음
큰 가상 메모리 크기를 가진 프로그램을 포크하는 것이 느리다는 건 잘 알려진 문제임

fork()+exec() 모델의 우아함은 fork() 이후에 일반 API를 그대로 써서 모든 종류의 설정을 할 수 있다는 데 있음
지금까지 본 결합 호출 방식의 대체안들은 근본적으로 빈약해 보였는데, 모든 설정 옵션을 호출 매개변수로 추가해야 하고, 나중에 확장 가능하면서도 난장판이 되지 않게 만들어야 하기 때문임

약간 동의하지 않지만 유용성은 보임. fork()/exec()가 어떤 경우에는 유용할 수 있어도, API들이 pidfd 인자를 받으면 꽤 괜찮을 것 같음. 0은 현재 프로세스를 뜻하게 할 수 있음
문제는 setuid/setgid 바이너리 정도일 텐데, 이 경우는 exec에서 특별 처리하는 편이 나을 수도 있음
예를 들어 pidfd_t ps = spawn();으로 정지된 프로세스를 만들고, setuid(ps, 33);, capset(ps, ...);, socket(ps, ...);, mmap(ps, ...);, process_vm_writev(ps, ...);, exec(ps, ...);, signal(ps, SIGCONT);처럼 구성할 수 있음
“내가 접근 권한을 가진 다른 프로세스에 이 작업을 하고 싶다면?”을 보통의 시스템 호출 API가 충분히 고려하지 않는다는 비판이기도 함. 이렇게 하면 fork()에서 스레드 안전성도 어느 정도 가능해짐
다만 수많은 매개변수를 받는 CreateProcess 같은 방식이 사용자 공간 API로 훌륭하진 않다는 데는 동의함

완전히 반대 생각임. UNIX식 모델의 큰 실수는 프로세스 생성 시 너무 많은 상태가 보존된다는 점임
예를 들어 어떤 객체가 파일 디스크립터 번호 4가 되도록 하는 API들이 있고, 프로그램을 실행해서 그 프로그램이 4번 디스크립터에서 그 객체를 찾게 만들 수 있음. 이건 이상함
Windows는 수많은 결점에도 fork()+exec()를 쓰지 않고, 대신 프로세스 생성 방법에 대한 옵션을 주로 제공함. 우아하진 않았지만 방향은 맞았음

그걸 우아하다고 부르는 건 fork()+exec() 역사의 경로 의존성임 fork()+exec()가 없던 다른 세계라면, 그런 “일반 API” 중 다수는 다른 프로세스의 설정을 바꿀 수 있게 명시적 pid 인자를 가졌을 것임. Fuchsia가 대략 그런 방식임
이 세계에는 장점이 많음. 가장 분명한 건 설정 오류를 보고하려고 별도의 IPC 체계를 마법처럼 만들어낼 필요가 없다는 점이고, 자식의 속성을 조정하는 관리자 프로세스를 둘 수 있다는 점도 꽤 유용함. 디버거들이 특히 좋아할 만함

fork()를 없애는 올바른 방법은 프로세스 상태를 바꾸는 일반 API들이 명시적인 프로세스 핸들을 받게 하는 것임
그러면 같은 API로 빈 프로세스를 설정할 수 있고, IPC나 디버깅 같은 다른 방식으로도 조합 가능함

순서는 spawn, configure, exec가 되어야 함
프로세스가 ptrace 연결 상태이고 스레드가 없게 시작하면 설정 단계에서 시스템 호출을 강제로 수행하게 할 수 있음. Linux에는 “스레드가 없는 프로세스” 개념조차 없으니 아마 더미 스레드가 필요할 것임

fork()가 싸다는 오해가 이상할 정도로 흔한데, 프로세스 크기에 대해 O(N) 이고 항상 그랬음
맞다, 쓰기 시 복사이긴 함. 하지만 프로세스 크기와 이를 표현하는 데 필요한 페이지 테이블 항목 수 사이에는 선형 관계가 있음

Chen의 패치가 거절된 건 놀랍지 않음. 너무 특수한 사용 사례라 지원할 가치가 낮음
셸 개발자 관점에서는 “개발자들이 현재 구현처럼 내부에서 fork()와 exec()를 숨기지 않는 네이티브 구현을 반길 가능성이 높다”는 결론에 동의함

특정 구현이 아니라 그 개념 자체에는 관심이 있어 보임

fork()는 처음 배웠을 때부터 개념적으로 끔찍해 보였음. 어떤 하나의 작업, 즉 프로세스 시작을 하고 싶다면, 그와 다른 무관한 작업인 현재 프로세스 포크를 하는 수수께끼 같은 주문을 거쳐야 해서는 안 됨
글의 예시처럼 한 프로세스가 많은 git 하위 프로세스를 띄우는 상황을 가장 잘 처리하는 방법이 궁금함. 오래 실행되는 부모 작업 중에 git을 반복해서 처음부터 시작하는 건 말이 안 되는 것 같은데, 같은 결과를 내는 저비용 추상화는 무엇일까?

fork()는 개념적으로 단순함. 다른 계층을 끌어오지 않으면, 존재한다고 확실히 아는 단 하나인 자기 자신으로 프로세스를 시작하는 것임
그렇지 않으면 프로세스를 만들고, 실행할 무언가로 채우고, 실행되게 배치하는 여러 단계가 필요함. 아니면 Win32처럼 파일시스템, 객체 로더, 링커 같은 다른 계층과 영구히 뭉개서 합쳐야 함

Windows에서 출발한 사람으로서 fork()+exec() 모델은 전혀 이해되지 않았음. 이제는 그저 역사적 특이점이라는 걸 알지만, 아직도 fork()+exec()가 실제로 좋은 것인 척하는 사람들이 있음

libgit2가 있음. 파이프나 소켓으로 어떤 gitd와 통신하는 방식을 상상할 수는 있지만, 그게 왜 좋은 아이디어인지는 모르겠음. 그게 아니면 프로세스를 띄워야 함

exec/fork를 대체하기 어려운 이유는 새 프로세스를 보통 설정해야 하기 때문임. 예를 들어 시그널 핸들러 설정, 파일 디스크립터 닫기나 열기, 네임스페이스 전환, seccomp 설정, 권한 조정이 필요함
그런데 이를 위한 시스템 호출들은 현재 프로세스에만 적용되므로 대체 수단이 필요함. 글의 제안은 이를 위한 새 API를 만드는 것이었음
내 생각에는 spawn 같은 새 시스템 호출이 빈 프로세스를 만들고, 그 안에 가벼운 로더를 올린 뒤 임의의 설정 데이터를 넘길 수 있음. 로더가 프로세스를 설정하고 주 프로그램을 exec()하는 방식임
이렇게 하면 메모리를 포크하지 않으면서 기존 API를 유지할 수 있지만, 파일 디스크립터와 다른 것들은 여전히 복제해야 함

다행히 누군가 타임머신을 타고 이 글을 보고 POSIX.1-2001에 추가해둔 듯함 :)
농담이 아니었다면 미안하지만, posix_spawn()은 이미 존재하고 glibc에서 fork는 그냥 clone()의 별칭임
원래 제안과 정확히 같지는 않아도 fork()/exec()는 정말 레거시에 가까움

fork와 exec가 쓰기 시 복사 성격을 넘어 지속적이고 대수적인 동작을 보일 수 있다면, 더 유용할 뿐 아니라 사용하기도 더 흥미로울 것임. 예를 들어 지연 평가에 쓸 수 있음

AI 자동 생성 콘텐츠

본 콘텐츠는 GeekNews의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0