컴퓨터 구조 운영체제 네트워크 자료구조 알고리즘 데이터베이스 내 학습
os · 13

Copy-on-Write(COW)

메모리forkCOW

유닉스 계열 운영체제에서 새 프로세스를 만드는 기본 방법은 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에서도 부모의 페이지 테이블을 복제하고 자식의 프로세스 관리 구조를 만드는 비용은 듭니다. 메모리가 클수록 이 비용도 늘고, 쓰기가 많으면 페이지 복사와 폴트 처리 비용이 생깁니다.

복사를 쓰는 시점까지 미룹니다 — fork 때 메모리를 복사하지 않고 공유하다가 쓰는 순간 그 페이지만 복사합니다.
프로세스의 독립성은 그대로입니다 — 복사는 쓰기 직전에 이뤄지므로 한쪽의 쓰기가 다른 쪽에 보이지 않습니다. 겉으로는 fork 때 전부 복사한 것과 같습니다.
fork가 빨라지고 메모리를 아낍니다 — 복사할 데이터 양이 아니라 페이지 테이블 크기에 비용이 비례합니다.
쓰기가 많으면 이득이 줄어듭니다 — 자식이 대부분의 페이지를 쓰면 결국 대부분을 복사하고 폴트 처리 비용까지 더해집니다.
이 시각화에서 확인할 것 — "fork 직후 공유" 탭에서 모두 복사하는 방식과 공유 방식의 프레임 수를, "쓰기 시 복사" 탭에서 페이지 폴트 뒤 한 페이지만 복사되는 과정을, "fork 뒤 exec" 탭에서 버려지는 복사의 양을 STEP으로 확인하세요.

COW의 배경

초기 유닉스의 fork는 부모의 메모리를 통째로 복사했고, 자식이 곧바로 exec으로 메모리를 버리는 경우가 많아 낭비가 컸습니다. 이를 줄이려고 vfork 같은 변형이 쓰였고, 이후 가상 메모리와 페이지 폴트를 이용해 복사를 미루는 COW가 리눅스를 비롯한 많은 fork 구현에 쓰입니다.

숫자로 확인하기

fork 직후(페이지 4개 가정) — 모두 복사하면 물리 프레임 8개를 쓰고 4개를 복사합니다. COW는 프레임 4개를 공유하고 복사는 0개입니다.
자식이 한 페이지에 쓰면 — 그 페이지만 새 프레임에 복사되어 프레임 5개, 복사 1개가 됩니다. 나머지 3개 페이지는 계속 공유됩니다.
fork 뒤 exec(페이지 1,000개 가정) — 모두 복사하면 1,000개를 복사하고 exec 때 1,000개를 버립니다. COW는 복사 0개이고 버려지는 것도 없습니다.

자주 하는 오해

COW는 fork 때 아무 비용이 없다 — 메모리 내용은 복사하지 않지만 페이지 테이블 복제와 프로세스 구조 생성 비용은 남습니다.
부모와 자식이 같은 메모리를 영원히 공유한다 — 읽기만 하는 동안만 공유합니다. 쓰는 순간 그 페이지는 각자의 복사본이 됩니다.
자식이 쓰면 부모의 값도 바뀐다 — 쓰기는 복사본에 이뤄지므로 부모의 데이터는 그대로입니다. 스레드와 달리 프로세스는 메모리가 격리됩니다.
COW는 프로세스에서만 쓰인다 — 파일 시스템의 스냅샷(예: Btrfs), 가상 머신 디스크 이미지(예: QEMU qcow2), 일부 언어 라이브러리의 컬렉션(예: Java CopyOnWriteArrayList)에서도 같은 발상을 씁니다.

면접에서 이렇게 물어요

면접 질문

Copy-on-Write가 무엇인가요?

fork 때 메모리를 복사하지 않고 부모와 자식이 같은 물리 페이지를 읽기 전용으로 공유하다가, 쓰기가 일어나는 순간 그 페이지만 복사하는 기법입니다.

면접 질문

쓰기 시 어떤 일이 일어나나요?

읽기 전용 페이지에 쓰기를 시도하면 페이지 폴트가 발생하고, 커널이 그 페이지를 새 프레임에 복사해 쓰는 프로세스에 전용 복사본을 준 뒤 쓰기를 허용합니다.

면접 질문

fork 뒤 exec에서 COW가 왜 유리한가요?

exec이 자식의 메모리를 새 프로그램으로 교체하므로 fork 때 전체를 복사하면 낭비입니다. COW는 복사를 미뤄 이 낭비를 없앱니다.

면접 질문

COW의 단점은?

쓰기가 많으면 페이지 복사와 폴트 처리 비용이 생깁니다. 페이지 테이블 복제 비용도 남습니다.

실제 시스템에서는

리눅스 fork — fork(2) 문서는 리눅스가 COW 페이지로 fork를 구현하며, 부모의 페이지 테이블을 복제하고 자식의 태스크 구조를 만드는 시간과 메모리만 든다고 설명합니다.
Redis의 스냅샷 — Redis는 RDB 저장과 AOF 재작성 때 자식 프로세스를 fork하고 운영체제의 COW를 이용합니다. 그동안 부모가 데이터를 바꾸면 해당 페이지가 복사되어 메모리 사용량이 늘 수 있습니다.
대안 — posix_spawn은 새 프로그램을 시작하는 용도로 fork와 exec을 합친 인터페이스를 제공합니다.

함께 보면 좋은 토픽

프로세스 — fork로 만들어지는 프로세스와 그 주소 공간을 봅니다.
가상 메모리 — 페이지 테이블과 페이지 폴트가 COW의 바탕임을 봅니다.
메모리 관리 — 페이징과 프레임 할당을 봅니다.
시스템 콜 — fork와 exec이 시스템 콜로 호출되는 과정을 봅니다.

참고 자료

리눅스 — fork(2), posix_spawn(3)
Redis — Redis persistence
교재 — Silberschatz 외, "Operating System Concepts"의 가상 메모리 중 Copy-on-Write 절

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() 직후 부모와 자식 프로세스의 메모리는 어떻게 되어 있을까?

"쓰기 전에는" 복사하지 않는다는 의미입니다.
COW에서는 fork 직후 부모와 자식이 같은 물리 페이지를 읽기 전용으로 공유합니다. 실제로 쓰기가 일어나기 전까지는 복사하지 않아 fork가 빠르고 메모리도 절약됩니다.

Q2

COW에서 자식이 공유 중인 페이지에 쓰기를 시도하면 일어나는 일은?

읽기 전용 페이지에 쓰면 페이지 폴트가 발생합니다.
읽기 전용으로 공유 중인 페이지에 쓰기를 시도하면 페이지 폴트가 발생하고, 커널이 그 페이지만 복사해 쓰려는 프로세스에게 전용 복사본을 준 뒤 쓰기를 허용합니다. 부모의 데이터는 그대로 유지됩니다.

Q3

fork() 뒤에 곧바로 exec()를 호출하는 흔한 패턴에서 COW의 이점은?

exec()는 자식의 메모리를 새 프로그램으로 바꿔버립니다.
fork 직후 exec으로 메모리 공간이 새 프로그램으로 교체된다면, fork 때 전체를 복사하는 것은 낭비입니다. COW는 실제로 쓸 때까지 복사를 미뤄 이 낭비를 없앱니다.