Copy-on-Write(COW)
유닉스 계열 운영체제에서 새 프로세스를 만드는 기본 방법은 fork입니다. fork는 호출한 프로세스(부모)의 복제본(자식)을 만들고, 자식은 부모와 같은 코드와 데이터를 가진 채 fork 다음 줄부터 실행합니다. 부모의 메모리가 수백 메가바이트라면 이를 전부 복사하는 일은 시간과 메모리를 많이 씁니다. Copy-on-Write(COW, 쓰기 시 복사)는 이 복사를 미루는 기법입니다. fork 때는 메모리를 복사하지 않고 부모와 자식이 같은 물리 페이지를 읽기 전용으로 공유하다가, 어느 한쪽이 그 페이지에 쓰려고 하는 순간 그 페이지만 복사합니다.
COW 덕분에 fork가 빨라지고, 자식이 실제로 건드리지 않는 페이지는 끝까지 공유되어 메모리도 아낍니다. 특히 fork 직후 exec으로 다른 프로그램을 실행하는 흔한 패턴에서는, 곧 버려질 메모리를 복사하는 낭비가 사라집니다. 이 토픽에서는 fork 직후 부모와 자식이 메모리를 공유하는 모습, 쓰기 순간에 페이지가 복사되는 과정, fork 뒤 exec 패턴에서의 이점을 봅니다.
핵심 개념
fork
현재 프로세스를 복제해 자식 프로세스를 만드는 시스템 콜입니다. 자식은 부모와 같은 내용의 주소 공간을 갖지만 별개의 프로세스이므로, 한쪽이 변수를 바꿔도 다른 쪽에는 보이지 않습니다.
핵심 개념
페이지 공유
COW에서는 fork 때 자식의 페이지 테이블이 부모와 같은 물리 프레임을 가리키게 하고, 양쪽 페이지를 읽기 전용으로 표시합니다. 메모리 내용은 복사하지 않습니다.
핵심 개념
쓰기 시 페이지 폴트
읽기 전용으로 공유한 페이지에 쓰면 CPU가 페이지 폴트를 일으킵니다. 커널은 이 폴트가 COW 때문임을 알아보고 그 페이지를 새 프레임에 복사한 뒤 쓰는 쪽에 전용 복사본을 줍니다.
핵심 개념
필요한 만큼만 복사
복사는 쓰기가 일어난 페이지 단위로만 이뤄집니다. 읽기만 하는 페이지는 끝까지 공유되므로 자식이 일부만 건드리면 복사량도 그만큼 작습니다.
핵심 개념
fork 뒤 exec
자식이 fork 직후 exec으로 다른 프로그램을 불러오면 복제한 메모리는 곧 교체됩니다. COW는 이 메모리를 복사하지 않으므로 낭비 없이 새 프로그램을 시작할 수 있습니다.
핵심 개념
남는 비용
COW에서도 부모의 페이지 테이블을 복제하고 자식의 프로세스 관리 구조를 만드는 비용은 듭니다. 메모리가 클수록 이 비용도 늘고, 쓰기가 많으면 페이지 복사와 폴트 처리 비용이 생깁니다.
COW의 배경
초기 유닉스의 fork는 부모의 메모리를 통째로 복사했고, 자식이 곧바로 exec으로 메모리를 버리는 경우가 많아 낭비가 컸습니다. 이를 줄이려고 vfork 같은 변형이 쓰였고, 이후 가상 메모리와 페이지 폴트를 이용해 복사를 미루는 COW가 리눅스를 비롯한 많은 fork 구현에 쓰입니다.
숫자로 확인하기
자주 하는 오해
면접에서 이렇게 물어요
면접 질문
Copy-on-Write가 무엇인가요?
fork 때 메모리를 복사하지 않고 부모와 자식이 같은 물리 페이지를 읽기 전용으로 공유하다가, 쓰기가 일어나는 순간 그 페이지만 복사하는 기법입니다.
면접 질문
쓰기 시 어떤 일이 일어나나요?
읽기 전용 페이지에 쓰기를 시도하면 페이지 폴트가 발생하고, 커널이 그 페이지를 새 프레임에 복사해 쓰는 프로세스에 전용 복사본을 준 뒤 쓰기를 허용합니다.
면접 질문
fork 뒤 exec에서 COW가 왜 유리한가요?
exec이 자식의 메모리를 새 프로그램으로 교체하므로 fork 때 전체를 복사하면 낭비입니다. COW는 복사를 미뤄 이 낭비를 없앱니다.
면접 질문
COW의 단점은?
쓰기가 많으면 페이지 복사와 폴트 처리 비용이 생깁니다. 페이지 테이블 복제 비용도 남습니다.
실제 시스템에서는
함께 보면 좋은 토픽
참고 자료
C — fork 뒤 쓰기는 서로 보이지 않는다
#include <stdio.h> #include <unistd.h> #include <sys/wait.h> int main(void) { int x = 10; pid_t pid = fork(); if (pid == 0) { /* 자식 */ x = 99; /* 쓰기 → 이 페이지가 복사됨 */ printf("child x=%d\n", x); return 0; } wait(NULL); printf("parent x=%d\n", x); /* 10 그대로 */ }
fork 직후 부모와 자식의 x는 같은 물리 페이지를 읽기 전용으로 공유합니다. 자식이 x에 99를 쓰는 순간 페이지 폴트가 일어나 커널이 그 페이지를 복사하고, 자식은 자기 복사본에 값을 씁니다. 그래서 부모가 출력하는 x는 10 그대로입니다. COW는 구현 방식일 뿐이고 프로그램이 보는 의미는 fork 때 메모리를 전부 복사한 것과 같습니다. x가 있는 페이지가 복사되고, 건드리지 않는 페이지는 부모와 자식이 계속 공유합니다.
C — fork 직후 exec (복사가 필요 없는 패턴)
pid_t pid = fork(); if (pid == 0) { execlp("ls", "ls", "-l", (char *)NULL); _exit(127); /* exec이 실패했을 때만 도달 */ } waitpid(pid, NULL, 0); /* 비슷한 일을 한 번의 호출로: */ #include <spawn.h> extern char **environ; char *argv[] = {"ls", "-l", NULL}; posix_spawnp(&pid, "ls", NULL, NULL, argv, environ);
자식이 fork 직후 execlp를 호출하면 자식의 메모리는 ls 프로그램으로 교체됩니다. COW 덕분에 fork 때 복제한 메모리를 실제로 복사하지 않았으므로 버려지는 복사가 없습니다. exec이 성공하면 execlp는 돌아오지 않고, 실패했을 때만 다음 줄의 _exit가 실행됩니다. 자식에서는 exit 대신 _exit를 써서, fork로 복제된 stdio 버퍼가 부모와 겹쳐 flush되지 않게 합니다. posix_spawn 계열은 새 프로그램을 시작하는 이 흔한 패턴을 하나의 호출로 제공하는 인터페이스입니다.
Python · Redis — fork와 COW를 눈으로 확인
# Python: os.fork 뒤 자식의 변경은 부모에 보이지 않음 import os data = [1, 2, 3] pid = os.fork() if pid == 0: data.append(4) os._exit(0) os.wait() print(data) # [1, 2, 3] # Redis: 스냅샷 중 COW로 복사된 메모리 크기 redis-cli INFO persistence | grep cow_size
파이썬의 os.fork도 같은 의미를 줍니다. 자식이 리스트에 4를 추가해도 부모의 리스트는 [1, 2, 3] 그대로이며, 내부적으로는 쓰기가 일어난 페이지가 복사됩니다. CPython은 참조 카운트를 갱신하므로 읽기만 해도 페이지가 복사될 수 있습니다. Redis는 RDB 스냅샷과 AOF 재작성 때 자식을 fork해 운영체제의 COW를 이용하고, INFO persistence의 cow_size 항목은 그동안 COW로 복사된 메모리 크기를 알려 줍니다. 스냅샷 중 쓰기가 많을수록 복사되는 페이지가 늘어 메모리 사용량이 올라가므로, 이 값은 스냅샷 시점에 여유 메모리가 필요한 정도를 가늠하는 데 쓰입니다.
Q1
Copy-on-Write 방식에서 fork() 직후 부모와 자식 프로세스의 메모리는 어떻게 되어 있을까?
Q2
COW에서 자식이 공유 중인 페이지에 쓰기를 시도하면 일어나는 일은?
Q3
fork() 뒤에 곧바로 exec()를 호출하는 흔한 패턴에서 COW의 이점은?