Skip to content

Storage Format

Won-Kyu Park edited this page Aug 4, 2026 · 3 revisions

저장 포맷

.srcs 파일이 실제로 어떻게 생겼는지, 그리고 커밋할 때 바이트 단위로 무슨 일이 일어나는지를 다룬다. 이 문서는 System Overview에서 설명한 "역방향 델타" 개념을 코드 레벨로 한 단계 더 들어간 버전이다.

헤더

v2 파일은 첫 줄에 헤더가 있다.

# SimpleRCS v2.0; hash_algo=sha256; encoding=utf-8;

v1은 헤더가 없는 옛날 포맷이다 (아래 "v1과의 차이" 참고).

블록 하나의 모양

블록은 key @value@; 형태의 줄을 여러 개 이어붙인 것이고, 빈 줄 하나로 다음 블록과 구분된다. @ 문자는 값 안에서 @@로 이스케이프된다.

ver @1.2@;
date @2026-08-04T12:00:00@;
author @wkpark@;
log @fix typo@;
delta @0a1,1
> new line
@;
prev_hash @9f86d0...@;
hash @a3f5c2...@;

필드는 이런 식으로 채워진다.

  • ver, date, author, log — 항상 있음
  • text — 이 블록이 전체 텍스트를 담고 있을 때 (텍스트 HEAD)
  • delta — 이 블록이 델타(다음 버전에서 이 버전으로 되돌리는 명령)를 담고 있을 때
  • binary — 콘텐츠가 바이너리일 때 (text/delta 대신 씀). <길이>;base64,<인코딩된 데이터> 형태
  • prev_hash, hash — v2부터. 각각 이전 블록 해시, 이 블록의 해시
  • signature — GPG 서명이 붙어 있으면 여러 줄 반복

해시는 뭘 대상으로 계산하나

여기가 좀 미묘한 지점이다. 해시는 저장된 델타가 아니라 그 버전의 논리적 전체 텍스트를 대상으로 계산한다 (codec.calculate_block_hash). 페이로드는 ver|date|author|log|<전체텍스트>|<이전해시> 순서로 이어 붙여서 해시한다.

왜 이렇게 하냐면 — HEAD였던 블록이 다음 커밋에서 델타로 바뀌어도, 그 블록의 해시값 자체는 바뀌면 안 되기 때문이다. 저장 표현이 바뀌어도 "이 버전이 실제로 담고 있던 내용"은 그대로니까, 해시도 그대로 유지돼야 체인이 끊기지 않는다.

커밋할 때 디스크에서 일어나는 일

commit()을 호출하면 (simple_rcs.py 커밋 메서드 안쪽):

  1. 지금 HEAD를 델타 블록 형태로 다시 인코딩한다 (is_delta=True_format_block 호출)
  2. 새 콘텐츠를 전체 텍스트 블록으로 인코딩한다 (is_delta=False)
  3. 스트림을 이전 HEAD 블록이 시작하던 위치로 되감는다 (stream.seek(head_info["start"]))
  4. "재인코딩된 이전 HEAD" + "새 HEAD"를 한 번의 write() 호출로 그 자리에 쓴다
  5. truncate()로 남는 꼬리를 잘라낸다
self.stream.seek(self.head_info["start"])
self.stream.write(old_block_bytes + new_block_bytes)
self.stream.truncate()

한 번의 write()로 묶어서 쓰는 이유가 코드 주석에 명시돼 있다 — 두 번에 나눠 쓰면 프로세스가 그 사이에 죽었을 때 "이전 HEAD는 덮어썼는데 새 HEAD는 없는" 반쪽짜리 상태가 남을 수 있어서다.

정리하면: 매 커밋마다 마지막 블록 하나만 재작성하고, 그 앞의 모든 이력은 파일 안에서 원래 있던 위치 그대로 남는다. "파일 끝에 계속 이어붙이기"처럼 보이지만 정확히는 "마지막 블록 교체 + 새 블록 추가"다.

스냅샷

commit(..., snapshot=True)를 쓰면 3번 단계에서 이전 HEAD를 델타 대신 전체 텍스트로 저장한다. 그러면 그 지점에서 델타 체인이 끊긴다 — 그 버전을 읽을 때 델타를 하나도 안 거치고 바로 전체 텍스트를 읽을 수 있다는 뜻이다. 히스토리가 아주 길어질 게 예상되는 지점에서 미리 찍어두면 과거 조회 성능을 그 구간만큼 확보할 수 있다. 다만 자동으로 찍히는 게 아니라 호출하는 쪽에서 선택해야 한다.

과거 버전을 읽을 때

checkout(ver)는 HEAD에서 시작해서 목표 버전을 찾을 때까지 뒤로 한 블록씩 걸어가며 델타를 적용한다 (_get_prev_block). 스냅샷 블록을 만나면 그 지점에서 바로 점프한다. 파일 전체를 메모리에 올리지 않고 필요한 블록만 순서대로 읽는다.

v1과의 차이 (참고용, 읽기 전용 지원만 남아있음)

v1은 실제로 배포된 적 없는 초기 포맷이다 — 헤더가 없고, 델타 블록도 그냥 text 키를 썼다(v2의 delta 키 구분이 없음). 해시체인도, GPG 서명도 없다. simple_rcs.pyself._version < 2 분기로 읽기 지원만 남아 있다. v1→v2 마이그레이션 도구는 저장소 히스토리에는 남아있지만 (add-then-revert 패턴으로 커밋 0c7783e/90f2706) 현재 트리에는 없다.

텍스트/바이너리 델타 포맷

  • 텍스트: RCS 스타일 diff -n 스크립트. 사람이 봐도 읽을 수 있는 add/delete 명령 목록이다.
  • 바이너리: BSDIFF40 호환 패치. base64/base85로 인코딩해서 텍스트 블록 안에 넣는다. 포맷이 표준이라 시스템에 설치된 bsdiff/bspatch 도구와도 상호운용된다.

실측 특성

리비전마다 전체 스냅샷을 찍는 게 아니기 때문에, 파일 전체 크기는 "리비전 수 × 페이지 크기"가 아니라 "델타 크기 합 + 전체 텍스트 하나"에 가깝다. 한 줄만 고친 커밋은 보통 수백 바이트짜리 델타 하나만 늘어난다. 이 특성을 DB에 저장할 때 실제로 어떻게 활용할 수 있는지는 Wiki Backend Design에 실측과 함께 정리해뒀다.

Clone this wiki locally