쓰기 시 복사
고친 사람 github-actions[bot]
쓰기 시 복사는 복사를 고쳐 쓰는 순간까지 미룹니다. 복사본을 달라고 하면 우선 원본을 함께 보게만 해 줍니다. 누군가 고쳐 쓰려 할 때 그 조각만 따로 떠서 고칩니다. 프로세스를 복제할 때도, 스냅샷을 뜰 때도, 파일 시스템이 디스크에 쓸 때도 이 방법을 씁니다.
쉽고 빠른 이해
쓰기 시 복사는 「고칠 때 가서 복사하기」입니다. 프로세스 하나를 복제해도 처음에는 메모리를 조금도 복사하지 않습니다. 메모리는 일정한 크기의 조각으로 나뉘어 있습니다. 둘 중 하나가 어느 조각을 고치는 순간 그 조각만 복사합니다.
이게 없으면 복사본을 만들 때마다 전부를 미리 복사합니다. 복사본이 거의 안 고쳐지고 끝나도 시간과 메모리는 전부에 대해 냅니다.
어떻게 도나.
- 복사본을 달라고 하면 어느 조각이 어디 있는지 적은 목록만 하나 더 만듭니다
- 함께 보는 조각에는 「읽기만 허용」 표시를 붙여 둡니다
- 누가 고치려 하면 그 조각만 복사해 그쪽에 붙이고 고치게 합니다
대가가 있습니다. 조각마다 첫 쓰기가 복사 때문에 느려집니다. 쓰기가 몰리면 미뤄 둔 복사가 한꺼번에 일어나 메모리가 갑자기 늘어납니다. 그래서 복사본을 대부분 고치거나 첫 쓰기가 느려지면 안 되는 곳에는 안 맞습니다.
상세
이 절은 먼저 복사를 미루면 무엇을 버는지 봅니다. 그다음 함께 쓰던 조각이 쓰기 한 번에 갈라지는 순서를 그림으로 따라갑니다.
그 뒤로는 쓰임새 넷을 봅니다. 운영체제가 프로세스를 복제하는 방식, 그 위에서 스냅샷을 뜨는 저장소, 디스크에 덮어쓰지 않는 파일 시스템, 자바의 리스트 하나입니다. 끝으로 미뤄서 생기는 대가와 쓸 때·안 쓸 때를 짚습니다.
복사를 미루면 버는 것
팀원 다섯이 같은 기획서를 한 부씩 달라고 합니다. 복사기로 다섯 부를 뽑는 대신 원본 한 부를 가운데 두고 같이 봅니다. 누가 자기 몫에 줄을 긋고 싶어지면 그때 가서 그 장만 복사해 줍니다. 끝까지 읽기만 한 사람 몫은 한 장도 복사하지 않습니다.
복사는 원본 크기에 비례해 시간과 메모리를 씁니다. 그런데 복사본을 받은 쪽이 전부를 고치는 경우는 드뭅니다. 대부분은 읽기만 하거나 일부만 고칩니다.
쓰기 시 복사는 이 틈을 노립니다. 복사해 달라는 요청에는 바로 답합니다. 실제 복사는 고치는 조각에 대해서만 합니다. 버는 양은 끝내 안 고쳐진 조각의 양과 같습니다. 영어로는 copy-on-write, 줄여서 COW 라고 부릅니다.
함께 쓰던 조각이 갈라지는 순서
조각의 단위는 쓰는 곳마다 다릅니다. 메모리에서는 페이지이고 디스크에서는 블록입니다. 둘 다 데이터를 일정한 크기로 자른 한 칸입니다.
복사본을 달라고 하면 어느 조각이 어디 있는지 적은 새 목록 하나만 만듭니다. 그 목록은 원본과 같은 조각을 가리킵니다. 이제 두 목록이 조각을 함께 씁니다. 조각마다 「읽기만 허용」 표시가 붙습니다.
한쪽이 조각 2를 고치려 하면 이 표시에 걸립니다. 시스템은 쓰기를 멈추고 조각 2를 새 칸에 복사합니다. 고치려던 쪽의 목록이 새 칸을 가리키게 바꾼 뒤 쓰기를 마저 합니다.
flowchart TD
subgraph S1["복사 직후"]
A1["원본 목록"] --> P1["조각 1"]
A1 --> P2["조각 2"]
B1["복사본 목록"] --> P1
B1 --> P2
end
subgraph S2["복사본이 조각 2를 고친 뒤"]
A2["원본 목록"] --> Q1["조각 1"]
A2 --> Q2["조각 2"]
B2["복사본 목록"] --> Q1
B2 --> Q3["조각 2 의 복사본 · 고친 값"]
end
S1 --> S2
그림 아래쪽에서 조각 1은 여전히 한 벌입니다. 복사는 고친 조각 2에 대해서만 한 번 일어났습니다. 복사본이 조각을 백 개 들고 있어도 하나만 고쳤다면 복사도 하나입니다.
함께 보는 쪽을 세는 참조 카운팅
시스템은 조각 하나를 몇 쪽이 함께 보는지 알아야 합니다. 혼자 보는 조각이면 복사할 필요 없이 바로 고치면 되기 때문입니다. 그래서 조각마다 자기를 가리키는 목록의 수를 셉니다. 이것을 참조 카운팅이라고 부릅니다.
쓰기에 걸렸을 때 이 수가 둘 이상이면 복사합니다. 하나뿐이면 다른 쪽은 이미 자기 복사본으로 옮겨 갔거나 끝나서 이 조각을 놓은 것입니다. 그러니 표시만 풀고 제자리에서 고칩니다. 복사를 하고 나면 원래 조각의 수는 하나 줄어듭니다.
fork 가 프로세스를 복제하는 방식
유닉스 계열 운영체제에서 새 프로세스는 흔히 fork 로 만듭니다. fork 는 부른 프로세스를 복제해 자식 프로세스를 하나 더 만드는 시스템 콜입니다. 자식은 부모의 메모리를 전부 물려받은 상태로 시작합니다.
메모리를 미리 다 복사하면 부모가 큰 메모리를 쓸수록 fork 도 그만큼 오래 걸립니다. 그런데 자식은 대개 곧바로 exec 를 부릅니다. exec 는 프로세스의 메모리를 버리고 다른 프로그램을 새로 올리는 시스템 콜입니다. 물려받은 메모리를 거의 안 쓰고 버리는 셈입니다. 미리 한 복사는 헛일이 됩니다.
fork 가 이 헛일을 어떻게 피하는지 보려면 주소 이야기를 먼저 해야 합니다. 프로세스는 실제 메모리 칸의 주소 대신 프로세스마다 자기만 보는 주소를 따로 씁니다. 이 주소가 가상 메모리 주소입니다. 덕분에 한 프로세스가 다른 프로세스의 메모리를 건드리지 못합니다.
가상 주소를 실제 메모리의 칸과 짝지어 둔 표가 페이지 테이블입니다. 프로세스마다 이 표가 하나씩 있습니다. 앞 절에서 본 목록이 메모리에서는 바로 이 표입니다.
fork 는 메모리 대신 페이지 테이블만 복사합니다. 부모와 자식의 표가 같은 칸을 가리키게 됩니다. 운영체제는 두 표에서 그 칸들을 읽기 전용으로 바꿔 둡니다.
쓰기를 붙잡는 것은 MMU(Memory Management Unit, 메모리 관리 장치)입니다. MMU 는 프로그램이 메모리에 닿을 때마다 가상 주소를 실제 칸의 주소로 바꾸고 권한을 검사하는 하드웨어입니다. 읽기 전용 페이지에 쓰기가 오면 MMU 는 그 접근을 멈춥니다.
멈춘 접근은 운영체제의 중심부인 커널로 넘어갑니다. 이 넘김을 페이지 폴트라고 합니다. 커널은 왜 멈췄는지 보고 뒷일을 처리합니다. 쓰기 시 복사로 나눠 쓰던 페이지라면 여기서 복사가 일어납니다.
자식이 물려받은 페이지에 처음 쓸 때의 흐름입니다.
sequenceDiagram
participant 자식 as 자식 프로세스
participant MMU
participant 커널
자식->>MMU: 페이지에 쓰기
MMU->>커널: 페이지 폴트 · 읽기 전용이다
Note over 커널: 페이지를 새 칸에 복사한다
Note over 커널: 자식의 표가 새 칸을 가리키게 하고 쓰기를 허용한다
커널-->>자식: 멈춘 쓰기를 다시 실행
자식->>MMU: 페이지에 쓰기
MMU-->>자식: 새 칸에 들어간다
자식 쪽에서 보면 쓰기 한 번이 조금 늦었을 뿐 아무 일도 없던 것처럼 보입니다. 부모가 먼저 쓰면 같은 일이 부모 쪽에서 일어납니다. 둘 다 안 고친 페이지는 끝까지 한 칸을 함께 씁니다.
Redis 가 fork 로 스냅샷을 뜨는 방식
백엔드에서 이 방식을 가장 자주 마주치는 곳은 Redis 입니다. Redis 는 데이터를 메모리에 두는 키-값 저장소입니다. 메모리의 내용은 전원이 꺼지면 사라지므로, 한 시점의 데이터 전부를 디스크 파일로 떠 둡니다. 이 파일이 스냅샷입니다.
Redis 는 스냅샷을 뜰 때 fork 로 자식을 만듭니다. 자식은 fork 한 시점의 메모리를 보면서 파일을 씁니다. 부모는 그동안 멈추지 않고 요청을 받아 데이터를 고칩니다.
둘이 서로를 방해하지 않는 것은 쓰기 시 복사 덕분입니다. 부모가 고친 페이지는 복사되어 부모 쪽에만 새 값이 들어갑니다. 자식은 옛 페이지를 계속 보므로, 파일에는 fork 한 시점의 데이터가 흐트러짐 없이 담깁니다.
비용은 부모가 고친 페이지 수만큼 늘어나는 메모리입니다. 스냅샷을 쓰는 동안 쓰기가 많아 거의 모든 페이지가 고쳐지면 메모리를 두 벌 가까이 씁니다. Redis 를 운영할 때 메모리를 넉넉히 남겨 두라고 하는 까닭이 이것입니다.
덮어쓰지 않는 파일 시스템
파일 시스템도 같은 생각을 디스크에 씁니다. 파일을 고칠 때 원래 블록을 덮어쓰지 않습니다. 바뀐 내용을 빈 블록에 새로 쓰고, 그 블록을 가리키던 쪽을 새 블록으로 바꿉니다.
블록을 가리키는 쪽도 디스크 위의 블록입니다. 파일이 어느 블록에 있는지 적은 이런 정보를 메타데이터라고 부릅니다. 앞 절에서 본 목록이 파일 시스템에서는 이 메타데이터입니다.
메타데이터 블록도 다른 메타데이터 블록이 가리킵니다. 그렇게 따라 올라가면 맨 위에 모든 것을 가리키는 블록 하나가 있습니다. 이 맨 위 블록이 뿌리입니다.
메타데이터 블록을 고치는 것도 덮어쓰기입니다. 그래서 그 블록 역시 빈 블록에 새로 씁니다. 이렇게 고친 데이터 블록에서 뿌리까지 한 줄이 새로 써집니다.
flowchart TD
R1["옛 뿌리"] --> M1["메타데이터 A"]
R1 --> M2["메타데이터 B"]
M1 --> D1["데이터 블록"]
M2 --> D2["데이터 블록 · 옛 값"]
R2["새 뿌리"] --> M1
R2 --> M3["메타데이터 B 의 새 판"]
M3 --> D3["데이터 블록 · 새 값"]
그림에서 메타데이터 A 는 옛 뿌리와 새 뿌리가 함께 가리킵니다. 안 바뀐 부분은 두 판이 나눠 씁니다. 새로 쓴 것은 바뀐 데이터 블록에서 뿌리까지 이르는 한 줄뿐입니다.
뿌리가 어디 있는지는 디스크의 정해진 곳에 값 하나로 적혀 있습니다. 파일 시스템은 이 값에서부터 읽기 시작합니다.
마지막 단계는 이 값을 새 뿌리로 바꾸는 것입니다. 이 한 번의 쓰기는 원자적입니다. 끝까지 되거나 아예 안 되거나 둘 중 하나라는 뜻입니다. 그래서 중간에 전원이 나가도 파일 시스템은 옛 판이나 새 판 중 하나로 남고, 반쯤 고친 상태가 되지 않습니다.
덮어쓰는 파일 시스템은 같은 안전을 저널링으로 얻습니다. 고치기 전에 무엇을 고칠지 따로 적어 두는 방식입니다. 쓰기 시 복사는 따로 적는 일 없이 쓰는 방식 자체로 이 안전을 얻습니다.
옛 뿌리를 지우지 않고 남겨 두면 그것이 곧 스냅샷입니다. 옛 뿌리에서 내려가면 고치기 전의 파일이 전부 보입니다. 스냅샷을 뜨는 데 드는 일은 뿌리 하나를 챙겨 두는 것뿐입니다. 데이터가 커도 스냅샷은 바로 끝납니다. Btrfs 와 ZFS(Zettabyte File System)가 이렇게 동작하는 파일 시스템입니다.
옛 값을 옮기는 방식과 새 값을 빈 칸에 쓰는 방식
파일 시스템 아래에는 디스크를 나눠 쓰는 볼륨이 있습니다. LVM(Logical Volume Manager)처럼 볼륨을 다루는 도구는 볼륨째로 스냅샷을 뜹니다. 이런 볼륨 스냅샷에서는 쓰기 시 복사라는 이름이 두 방식을 함께 부릅니다. 두 방식은 새 값이 어디에 들어가느냐가 다릅니다.
첫째 방식은 쓰기가 오면 원래 칸의 옛 값부터 스냅샷 공간으로 복사합니다. 스냅샷 공간은 스냅샷이 옛 값을 담아 두려고 따로 떼어 둔 디스크 영역입니다. 쓰기가 올 때 가서 복사하므로 이 방식도 이름과 맞습니다.
| 방식 | 쓰기가 오면 | 원래 칸에 남는 값 |
|---|---|---|
| 옛 값을 옮긴다 | 옛 값을 스냅샷 공간에 복사하고 원래 칸에 새 값을 덮어쓴다 | 새 값 |
| 새 값을 빈 칸에 쓴다 | 새 값을 빈 칸에 쓰고 가리키는 쪽을 바꾼다 | 옛 값 |
첫째 방식은 쓰기 한 번에 일이 세 번 듭니다. 옛 값을 읽고, 옮겨 적고, 새 값을 씁니다. LVM 의 스냅샷이 이 방식입니다.
둘째 방식은 쓰기 한 번이면 끝납니다. 앞 소절의 파일 시스템이 이 방식입니다. 따로 redirect-on-write 라고 부르기도 합니다. 대신 파일의 블록이 디스크 여기저기로 흩어집니다.
앞에서 본 fork 와 그 위에서 도는 Redis 스냅샷도 둘째 방식 쪽입니다. 고친 쪽이 새 칸을 받고 옛 칸은 그대로 남기 때문입니다.
자바의 CopyOnWriteArrayList
쓰기 시 복사는 프로그램 안의 자료구조에도 씁니다. Java 표준 라이브러리의 CopyOnWriteArrayList 가 이름부터 그렇습니다. 여러 스레드가 함께 읽는 리스트를 잠금 없이 읽게 해 주는 클래스입니다.
이 리스트는 원소를 배열 하나에 담습니다. 원소를 더하거나 빼면 배열 전체를 복사한 새 배열을 만들어 고치고, 리스트가 새 배열을 가리키게 바꿉니다. 이 리스트에서는 조각이 배열 하나 전체인 셈입니다. 목록 자리에는 그 배열을 가리키는 참조 하나가 있습니다.
읽는 쪽은 자기가 잡은 배열을 끝까지 읽습니다. 원소를 하나씩 꺼내 주는 객체인 반복자로 보면 이렇습니다. 오른쪽 주석이 그 줄이 돌려주는 값입니다.
var list = new CopyOnWriteArrayList<String>();
list.add("a");
var it = list.iterator();
list.add("b");
it.next(); // "a"
it.hasNext(); // false
list.size(); // 2
list.iterator() 는 그때의 배열을 잡은 반복자를 돌려줍니다. 그 뒤의 add("b") 는 새 배열을 만들 뿐, 반복자가 잡은 배열은 건드리지 않습니다. 그래서 반복자는 "a" 하나만 보고 끝납니다. 리스트 자체에는 원소가 둘 들어 있습니다.
보통의 ArrayList 였다면 반복자를 만든 뒤 원소를 더하고 next() 를 부를 때 ConcurrentModificationException 이 납니다. 순회하는 동안 리스트가 바뀌었다는 예외입니다. 이 리스트에서는 그 예외가 나지 않습니다. 읽는 쪽과 쓰는 쪽이 서로 다른 배열을 보기 때문입니다.
미뤄서 생기는 대가
첫 쓰기가 느려집니다. 복사를 없앤 것이 아니라 미룬 것이라서, 조각마다 처음 고칠 때 복사 비용을 냅니다. fork 뒤의 프로세스는 페이지마다 첫 쓰기에서 페이지 폴트와 복사를 치릅니다. 응답 시간이 고르지 않게 튀는 원인이 됩니다.
메모리 사용을 미리 알기 어렵습니다. 복사가 언제 얼마나 일어날지는 앞으로 들어올 쓰기에 달려 있습니다. Redis 스냅샷처럼 쓰기가 몰리면 미뤄 둔 복사가 한꺼번에 일어나 메모리가 갑자기 늘어납니다.
디스크에서는 파일이 흩어집니다. 고친 블록마다 빈 블록으로 옮겨 가므로, 처음에 나란히 있던 블록이 점점 떨어집니다. 이것을 단편화라고 부릅니다. 파일을 처음부터 끝까지 읽는 순차 읽기가 그만큼 느려집니다.
작은 쓰기 하나가 뿌리까지 한 줄을 새로 쓰게 만드는 것도 대가입니다. 디스크에 쓰는 양이 고친 양보다 커지는 이 현상을 쓰기 증폭이라고 부릅니다.
스냅샷이 남아 있는 동안은 옛 블록을 비우지 못합니다. 옛 뿌리가 여전히 그 블록을 가리키기 때문입니다. 스냅샷을 오래 둘수록 디스크를 더 차지합니다.
쓰기마다 전부를 복사하는 구현은 쓰기가 비쌉니다. CopyOnWriteArrayList 는 원소 하나를 더할 때도 배열 전체를 복사합니다. 원소가 많고 쓰기가 잦으면 복사에 드는 시간과 버려지는 옛 배열이 빠르게 쌓입니다.
쓸 때와 안 쓸 때
고르는 기준은 둘입니다. 복사본이 얼마나 고쳐지나, 그리고 첫 쓰기가 느려져도 되나입니다.
| 상황 | 고를 방식 | 까닭 |
|---|---|---|
| fork 한 뒤 바로 exec 하는 자식 | 쓰기 시 복사 | 물려받은 메모리를 거의 안 고치고 버린다 |
| 한 시점을 떠 두고 계속 고치는 데이터 | 쓰기 시 복사 스냅샷 | 스냅샷이 바로 끝나고 고친 만큼만 복사한다 |
| 읽기는 잦고 쓰기는 드문 목록 | CopyOnWriteArrayList |
읽을 때 잠금이 없다 |
| 원소가 많고 쓰기가 잦은 리스트 | 잠금을 쓰는 리스트 | 쓰기마다 배열 전체를 복사한다 |
| 한 파일 안을 자주 고쳐 쓰는 데이터베이스 파일 | 덮어쓰기 | 고칠 때마다 블록이 흩어져 단편화가 쌓인다 |
이름이 비슷한 다른 기법
지연 로딩은 읽기를, 지연 평가는 계산을 쓸 때까지 미룹니다. 쓰기 시 복사가 미루는 것은 복사입니다. 셋 다 「필요해질 때까지 안 한다」는 생각을 대상만 바꿔 씁니다.
MVCC(Multi-Version Concurrency Control, 다중 버전 동시성 제어)는 데이터베이스가 한 행을 고칠 때 옛 판을 곧바로 지우지 않고 새 판과 함께 두는 방식입니다. 읽는 쪽은 자기가 시작한 때의 판을 봅니다. 고칠 때 새 판을 만들고 옛 판을 읽는 쪽에 남긴다는 점에서 쓰기 시 복사와 닮았습니다.
관련 항목
쓰기 시 복사를 받쳐 주는 메모리 관리 구성 요소
MMU · 페이지 테이블 · 페이지 폴트 · 페이지 · 가상 메모리 · 커널 · 참조 카운팅
쓰기 시 복사와 얽힌 프로세스 생성 시스템 콜
fork · exec · vfork · posix_spawn · clone
쓰기 시 복사로 스냅샷을 뜨는 저장 방식
스냅샷 · Btrfs · ZFS · LVM · redirect-on-write · 섀도 페이징 · Redis · RDB
쓰기 시 복사로 이미지 레이어를 나누는 컨테이너 기술
Docker · 컨테이너 이미지 · OverlayFS · 유니온 파일 시스템
쓰기 시 복사로 구현한 자료구조
CopyOnWriteArrayList · CopyOnWriteArraySet · 영속 자료구조 · 구조적 공유 · 불변 객체
쓰기 시 복사와 맞세워지는 쓰기 방식
깊은 복사 · 덮어쓰기 · 저널링 · WAL
쓰기 시 복사에서 자주 나는 문제
단편화 · 쓰기 증폭 · OOM 킬러 · 메모리 오버커밋 · ConcurrentModificationException
필요할 때까지 일을 미루는 다른 기법
쓰기 시 복사가 속하는 상위 분류
다른 이름: copy-on-write · Copy-on-Write · COW · CoW · 카피 온 라이트