2.4 Timeline과 Branch 내부
Neon 콘솔에서 branch를 만들면 1초 안에 끝나고 데이터 크기와 무관합니다. 이유는 branch가 데이터를 복사하는 작업이 아니라 storage 안에 "어느 timeline의 어느 LSN에서 갈라진다"는 메타데이터 한 줄을 추가하는 작업이기 때문입니다. 이 장은 그 메타데이터가 읽기 경로, 저장량, 보관 기간과 어떻게 얽히는지 설명합니다.
요약
- Branch는 Pageserver의 timeline입니다. 자식 timeline은
ancestor_timeline_id와ancestor_lsn두 값으로 부모와 연결됩니다. - 자식에서 page를 읽을 때 자식 layer에 없으면 부모 layer를 봅니다. 단, 부모의
ancestor_lsn이후 변경은 무시합니다. - 과거 LSN에서 branch를 만드는 것이 곧 PITR입니다. 현재 시점에서 만드는 것과 구현이 같습니다.
- Logical size는 branch가 보는 데이터 크기이고, physical size는 그 timeline이 실제로 차지하는 layer 파일 크기입니다. 새 branch는 logical은 부모와 같고 physical은 0에 가깝습니다.
- History window(보관 기간)가 "얼마나 과거로 branch를 만들 수 있는가"를 정합니다.
Timeline과 branch의 관계
개발 문서 용어로 정리하면 다음과 같습니다.
| 용어 | 뜻 |
|---|---|
| Tenant | 하나의 repository. 같은 initdb에서 갈라진 timeline들의 집합. 서비스에서는 project에 해당 |
| Timeline | WAL 이력 하나. 서비스의 branch에 대응 |
| Ancestor | 자식 timeline이 갈라져 나온 부모 timeline |
ancestor_lsn | 분기 지점의 LSN. 자식이 부모 이력에서 물려받는 마지막 위치 |
콘솔의 branch 이름(main, feature-x)은 control plane이 붙이는 라벨이고, Pageserver는 16진수 timeline ID만 알고 있습니다. 실습 환경처럼 control plane 없이 Pageserver API를 직접 쓰면 이름 없이 ID로만 다루게 됩니다.
Branch를 만드는 API
Pageserver HTTP API의 timeline 생성 요청입니다.
curl -s -X POST "http://localhost:9898/v1/tenant/${TENANT_ID}/timeline/" \
-H 'Content-Type: application/json' \
-d '{
"new_timeline_id": "b3b863fa45fa9e57e615f9f2d944e601",
"ancestor_timeline_id": "de200bd42b49cc1814412c7e592dd6e9",
"ancestor_start_lsn": "0/16F9A00",
"pg_version": 17
}'
| 필드 | 의미 |
|---|---|
new_timeline_id | 만들 timeline의 ID. 호출자가 16진수 32자리로 생성 |
ancestor_timeline_id | 부모 timeline. 생략하면 initdb부터 시작하는 root timeline |
ancestor_start_lsn | 분기 LSN. 생략하면 부모의 현재 끝(last_record_lsn) |
pg_version | PostgreSQL major 버전. root timeline 생성 시 사용. 자식 timeline은 부모의 버전을 상속하고 요청값은 호환성을 위해 받기만 함(API 모델 주석 기준) |
응답(TimelineInfo)에는 ancestor_timeline_id, ancestor_lsn, last_record_lsn, current_logical_size, current_physical_size 등이 담깁니다. 이 노트의 실습 이미지(2026-09 latest)에서는 끝에 슬래시가 있는 /timeline/ 경로로 요청이 동작했고 응답에 두 크기 필드가 모두 있었지만, 경로 형식과 필드 유무는 Pageserver 버전에 따라 다를 수 있습니다. API 문서는 "다른 endpoint에서 timeline이 보이더라도 durable하다는 뜻은 아니므로 성공할 때까지 재시도하라"고 적어 두었습니다. 같은 파라미터로 다시 만들면 성공, 다른 파라미터로 같은 ID를 쓰면 409입니다. 직접 실행해 보는 절차는 3.2 첫 Branch 만들기에 있습니다.
읽기 경로에서 branch가 보이는 방법
Pageserver storage 문서의 예시를 따라갑니다. main에 orders, customers 두 table이 있고 LSN 250에서 child를 만들었습니다. 그 뒤 두 branch에서 orders를 각각 다르게 갱신했습니다.
@250
----main--+------------------------------>
\
+---child---------------->
디스크에는 다음 layer가 있습니다(단순화 표기: branch/relation_startLSN_endLSN은 delta, branch/relation_LSN은 image).
main/orders_100
main/orders_100_200
main/orders_200
main/orders_200_300
main/orders_300
main/orders_300_400
main/orders_400
main/customers_100
main/customers_100_200
main/customers_200
child/orders_250_300
child/orders_300
child/orders_300_400
child/orders_400
customers는 child에서 바뀐 적이 없으므로 child 디렉터리에 파일이 없습니다. child에서 customers page를 요청하면 child의 layer map에서 찾지 못하고 부모 main으로 넘어가 main/customers_200을 읽습니다.
orders는 두 branch 모두에 이력이 있습니다. child 입장에서 orders의 이력은 다음 파일들로 이루어진 직선입니다.
main/orders_100
main/orders_100_200
main/orders_200
main/orders_200_300 <- 250 까지만 유효
child/orders_250_300
child/orders_300
child/orders_300_400
child/orders_400
child에서 LSN 275의 page를 요청하면 child/orders_250_300에서 250~275 구간 레코드를 읽고, base image는 main/orders_200에 main/orders_200_300의 200~250 레코드를 적용해 만듭니다. main/orders_200_300 안에 있는 250~300 구간의 레코드는 부모의 이력이므로 child를 위해서는 무시합니다. 이 판단 기준이 ancestor_lsn입니다.
부모의 부모가 있으면 같은 규칙으로 계속 올라갑니다. Branch 깊이가 깊고 자식에 변경이 적을수록 한 page를 읽기 위해 거치는 timeline 수가 늘어납니다.
PITR은 과거 LSN의 branch다
문서는 이렇게 적었습니다. 부모의 끝이 LSN 250일 때 branch를 만들든, 부모가 이미 LSN 400까지 나아간 뒤에 "250에서" branch를 만들든 storage 입장에서는 차이가 없습니다. 후자가 Neon이 PITR을 구현하는 방식입니다.
그래서 "어제 14:00 시점으로 복구"는 다음 절차와 같습니다.
- 14:00에 해당하는 LSN을 찾습니다(서비스는 timestamp에서 LSN을 찾아 줍니다).
- 그 LSN을
ancestor_start_lsn으로 하는 새 timeline을 만듭니다. - 그 timeline에 compute를 붙여 확인하고, 필요하면 그것을 새 main으로 삼습니다.
복사가 없으므로 복구에 걸리는 시간이 데이터 크기와 무관합니다. 다만 그 LSN의 layer가 아직 GC되지 않고 남아 있어야 합니다. 이것이 history window의 의미입니다.
콘솔의 reset(부모 상태로 되돌리기)과 restore(과거 시점으로 되돌리기)도 공개 문서 기준으로는 "원하는 지점의 새 timeline을 만들고 기존 branch 이름을 거기에 옮기는" 동작으로 이해하는 것이 자연스럽습니다. 옮기기 전 상태를 --preserve-under-name으로 남길 수 있다는 CLI 옵션이 이를 뒷받침합니다. 다만 control plane 내부 구현은 공개되어 있지 않으므로 이 설명은 문서에서 추론한 것입니다. 사용 방법은 4.4 Instant Restore, Reset, Snapshot에서 다룹니다.
Logical size와 physical size
Pageserver는 timeline마다 두 크기를 추적합니다.
| 크기 | 정의 | Branch 직후 값 |
|---|---|---|
| Logical size | 그 timeline이 보는 모든 database의 모든 relation 크기 합. FSM, VM fork 포함, SLRU 제외 | 부모와 동일 |
| Physical size | 그 timeline 디렉터리의 layer 파일 크기 합 | 0에 가까움 |
Glossary의 표현을 빌리면, 1 GB 데이터베이스의 branch를 만들면 두 branch 모두 logical size가 1 GB이지만, 자식은 변경이 생기기 전까지 물리 디스크를 추가로 쓰지 않습니다. Logical size는 Safekeeper를 거쳐 compute로 전달되어 Free 플랜의 용량 제한에 쓰이고 콘솔에도 표시됩니다.
자식에 쓰기가 발생하면 그 변경분만 자식의 layer로 쌓여 physical size가 늘어납니다. 반대로 부모에 쓰기가 발생해도 자식의 크기는 변하지 않습니다. 부모의 새 layer는 ancestor_lsn 이후이므로 자식과 무관합니다.
History window와 GC의 관계
2.3에서 본 GC horizon은 "branch 끝에서 얼마나 뒤까지 page 버전을 유지하는가"를 바이트로 정한 경계였습니다. 서비스가 노출하는 history window는 시간 기준 cutoff인 pitr_interval에 해당하며, Pageserver는 두 기준과 자식 branch의 분기점을 함께 고려해 GC합니다. 공개 문서 기준 기본값과 상한은 다음과 같습니다.
| 플랜 | 기본 보관 | 최대 |
|---|---|---|
| Free | 6시간 | 6시간 |
| Launch | 1일 | 7일 |
| Scale | 1일 | 30일 |
History window 안의 어느 시점으로든 branch를 만들 수 있고, 그 밖의 시점은 layer가 지워져 불가능합니다. 창을 늘리면 복구 범위가 넓어지는 대신 그만큼의 page 버전이 저장량에 더해집니다. 이 저장량은 branch 수와 무관하게 부모 timeline 쪽에 붙습니다.
여기에 branch가 겹치면 GC 규칙이 하나 추가됩니다. 자식 branch의 ancestor_lsn 시점 상태를 재구성하는 데 필요한 부모 layer는 history window와 무관하게 자식이 존재하는 동안 유지됩니다. 분기점 이후의 부모 변경은 자식이 참조하지 않으므로 이 규칙에 걸리지 않습니다. 오래된 시점에서 만든 branch를 몇 달 동안 지우지 않으면 그 분기점을 재구성하는 오래된 layer가 계속 남습니다. Branch 자체의 physical size는 작아 보여도 부모의 저장량이 줄지 않는 구조입니다.
Root branch와 schema-only branch
Timeline 생성 API에서 ancestor_timeline_id를 비우면 initdb부터 시작하는 root timeline이 됩니다. 한 tenant 안에 root가 여러 개 존재합니다. 서비스의 schema-only branch는 부모의 schema만 옮긴 독립 root branch로 만들어지며, 그래서 부모와 CoW 관계가 없고 reset-from-parent를 지원하지 않습니다. 이 구분은 4.5에서 다시 다룹니다.
실패 사례
깊은 branch 체인(main, a, b, c 순으로 갈라진 구조)에서 c의 읽기가 느려지는 경우가 있습니다. c에 없는 page는 b, a, main을 차례로 확인하므로 timeline 수만큼 layer map 탐색이 늘어납니다. 자식에 쓰기가 많아 자체 layer가 충분히 쌓이면 부모 참조가 줄어드는데, 이는 read-mostly branch에서는 일어나지 않습니다.
또 하나는 "branch를 지웠는데 저장량이 줄지 않는" 상황입니다. 자식을 지우면 그 자식의 layer는 사라지지만, 부모의 오래된 layer는 다음 GC 주기까지 남아 있거나 다른 자식 branch가 아직 참조 중일 수 있습니다. 저장량 변화는 즉시 반영되지 않습니다.
연습 문제
- 위 파일 목록에서 child가 LSN 350의
orderspage를 읽으려면 어떤 파일들이 필요한지 순서대로 적습니다. - child가 LSN 300에서 다시
grandchild를 만들었다고 가정하고, grandchild가customerspage를 읽을 때 거치는 timeline 순서를 적습니다. - 실습 환경에서 branch를 만든 직후와 branch에 1만 행을 insert한 뒤의
current_physical_size를 비교합니다. 절차는 3.4에 있습니다.
심화 체크
ancestor_lsn이 없다면 자식에서 부모 layer를 읽을 때 어떤 문제가 생기는가?- History window를 30일로 늘렸을 때 늘어나는 저장량은 어느 timeline에 붙는가?
- Branch 삭제가 부모의 GC에 즉시 영향을 주지 않는 이유를 설명할 수 있는가?