-
Notifications
You must be signed in to change notification settings - Fork 3
Wiki Backend Design
상태: 조사/논의 완료, 구현 미착수 (스토리지 백엔드 및 동시성 방식 미결정)
- SimpleRCS는 "파일 하나의 버전 이력"을 잘 관리하는 라이브러리다. 위키 시스템에 그대로 쓰려면 동시편집 충돌 감지와 쓰기 잠금을 애플리케이션 레벨에서 추가로 구현해야 한다 (SimpleRCS 자체엔 없음).
- 저장소는 파일시스템, SQLite, MySQL, PostgreSQL 중 어디든 쓸 수 있지만, SimpleRCS의 실제 쓰기 방식(마지막 블록만 다시 쓰는 방식)과 가장 잘 맞는 건 파일시스템과 PostgreSQL Large Object다. SQLite/MySQL은 조금 덜 자연스럽지만, 일반적인 개인 위키 규모라면 문제없이 쓸 수 있다는 걸 실측으로 확인했다.
- "리비전마다 전체 텍스트를 따로 저장"하는 방식보다 SimpleRCS 방식이 저장 공간을 약 3배 아낀다 — 편집 속도는 거의 차이가 없다.
SimpleRCS는 "파일 하나의 버전 이력 저장소"이지 위키 시스템 자체가 아니다.
충실히 커버하는 것
- 버전 저장/조회 (
commit,checkout,log) - 두 버전 간 비교 (
diff) - 라인 단위 저자 추적 (
blame) - 무결성 검증(해시체인) + GPG 서명
위키 시스템이면 따로 얹어야 하는 것
- 페이지 관리/네임스페이스 (여러 페이지를 하나의 위키로 묶는 레지스트리)
- 페이지 이름 변경 이력 (파일명이 곧 식별자라 이름을 바꾸면 이력이 끊김)
- 사이트 전체 "최근 변경" 피드
- 검색/인덱싱, 위키링크 파싱
- 접근 제어(ACL) — GPG 서명은 "누가 승인했는가"의 증명이지 "누가 쓸 수 있는가"의 통제가 아니다
이 문서는 이 중 버전 관리 엔진 자체의 동시성/저장소 문제만 다룬다.
commit()을 직접 확인한 결과:
| 항목 | 현황 |
|---|---|
| 동시편집 충돌 감지 | 없음. 두 사용자가 같은 페이지를 동시에 편집하면, 먼저 저장한 사람의 수정이 나중에 저장한 사람에게 조용히 덮어써질 수 있다 (lost update) |
| 파일 잠금 | 없음. 두 프로세스가 동시에 같은 파일에 커밋해도 막아주는 장치가 없다 |
| 과거 버전 조회 속도 | HEAD에서 멀어질수록 느려진다 (버전 차이만큼 델타를 순서대로 적용해야 함) |
| 페이지 단위 | 인스턴스 하나 = 파일 하나. 여러 페이지를 한 유닛으로 묶는 기능은 없다 |
참고 — 전통적 RCS는 어떻게 처리했나: GNU RCS는 co -l(체크아웃과
동시에 잠금)로 "이 리비전은 아무개가 편집 중"임을 명시적으로 기록하고,
잠금을 잡지 않은 사람의 체크인은 거부한다(비관적 락). 파일 자체는 임시
파일에 쓴 뒤 rename()으로 교체하고, 별도 락 파일로 동시 쓰기를 막는다.
SimpleRCS는 이 두 가지 다 없다.
권장: 편집 시작 시점의 버전 번호를 클라이언트가 들고 있다가, 저장 직전에 현재 HEAD 버전과 비교해서 다르면 거부하는 로직을 애플리케이션에 직접 구현한다. 물리적 잠금과는 별개로 반드시 필요한 방어선이다.
commit()은 흔히 생각하는 "파일 끝에 계속 이어붙이기(append)"가 아니다.
실제로는:
- 직전 HEAD(전체 텍스트였던 것)를 "델타"로 다시 인코딩한다
- 그 블록이 있던 위치로 되감아서, "델타로 바뀐 이전 HEAD" + "새 전체 텍스트 HEAD"를 그 자리에 덮어쓴다
- 남는 부분은 잘라낸다
즉 매 커밋마다 마지막 블록 하나만 다시 쓰고, 그 앞의 모든 이력은 전혀 건드리지 않는다.
또 하나 중요한 점: 매 리비전마다 전체 페이지를 통째로 저장하는 게 아니라, "직전 버전으로 되돌리기 위한 변경분(델타)"만 저장하고 최신 버전(HEAD) 하나만 전체 텍스트로 들고 있다. 그래서 한 줄만 고친 커밋은 전체 페이지 크기가 아니라 몇백 바이트짜리 델타 하나만 늘어난다 — 이 특성이 아래 저장소 비교에서 계속 등장한다.
seek()/truncate()를 그대로 지원해서 §3의 쓰기 방식과 가장 잘
맞는다 (현재 SimpleRCS 구현이 전제하는 모델). 동시편집 잠금은 페이지별
락파일이나 flock()으로 직접 구현해야 한다.
"페이지 하나 = 행 하나, 이력 전체를 BLOB 컬럼 하나에 저장"하는 방식은
직관적이지만, SQLite/MySQL 모두 컬럼 값의 일부만 바꿔쓰는 기능이 없다
— UPDATE 한 번에 컬럼 값 전체를 다시 쓴다. 그래서 커밋마다 "새로
추가된 몇백 바이트"가 아니라 "그 페이지 이력 전체"를 다시 쓰는 셈이
된다.
다만 §3에서 봤듯 이력 전체 크기 자체가 생각보다 작다(리비전마다 전체 스냅샷을 안 찍으니까). 그래서 이 비효율이 실제로 체감되는 정도는 규모에 달려 있다 — 아래 §5 실측 참고.
더 정석적인 방법은 리비전마다 별도 행을 쓰는 것이다:
CREATE TABLE revisions (
page_id INTEGER NOT NULL,
version TEXT NOT NULL,
block_bytes BLOB NOT NULL,
author TEXT, log TEXT, date TEXT,
PRIMARY KEY (page_id, version)
);새 커밋 = "직전 HEAD 행 하나만 UPDATE" + "새 HEAD 행 INSERT" — 둘 다 이력 길이와 무관하게 저렴하다. 다만 SimpleRCS는 "파일 하나짜리 스트림"을 가정하므로, 이 행들을 이어붙여 SimpleRCS에 보여주는 어댑터를 별도로 만들어야 한다.
참고로 SQLite/MySQL은 행 단위가 아니라 DB 파일 단위로 쓰기를 직렬화하기 때문에, 한 DB에 페이지를 몰아넣으면 서로 다른 페이지 편집도 순서대로 밀린다. 쓰기가 많은 서비스라면 여러 DB 파일로 나누는 것도 고려해야 한다.
두 가지를 구분해야 한다.
-
bytea컬럼: SQLite/MySQL BLOB과 같은 한계 (전체 재기록). -
Large Object (
lo):lo_write/lo_truncate로 파일처럼 임의 위치를 읽고 쓸 수 있어서, §3의 쓰기 방식을 그대로 재현할 수 있는 유일한 DB 네이티브 방법이다.simple_rcs/adapters.py에 이미 이 어댑터가 있어서 구현 비용이 가장 낮다. (단, 삭제된 페이지의 LO가 자동으로 정리되지 않는 점, Postgres 전용이라 이식성이 없는 점은 감안해야 한다.)
| 백엔드 | §3 쓰기 방식과의 궁합 | 구현 비용 |
|---|---|---|
| 파일시스템 | 네이티브 | 낮음 |
| SQLite/MySQL (컬럼 1개) | 별로임 (매번 전체 재기록) | 낮음, 규모 커지면 부담 |
| SQLite/MySQL (리비전별 행) | 좋음 | 중간 (어댑터 필요) |
PostgreSQL bytea
|
별로임 | 낮음, 규모 커지면 부담 |
| PostgreSQL Large Object | 네이티브 | 낮음 (어댑터 이미 있음) |
이론만으로 판단하지 않고 실제로 SQLite에 붙여서 측정했다. 스크립트는
scripts/에 있고, 재현 방법은 §7 참고.
50줄짜리 페이지에 매번 한 줄씩 바꾸며 2000번 커밋. 컬럼 하나 방식(A)과 리비전별 행 방식(B)의 커밋당 소요 시간을 비교했다.
| 초반 10% 평균 | 후반 10% 평균 | 증가율 | |
|---|---|---|---|
| A (컬럼 전체 재기록) | 5.0ms | 12.1ms | 2.4배 |
| B (행 단위 갱신) | 5.5ms | 16.2ms | 3.0배 |
이력이 10배 커지는(53KB→536KB) 동안 A의 소요 시간은 2.4배만 늘었다. 이론상 O(1)이어야 할 B가 오히려 A보다 느려지는 경향까지 보였다 — 이 규모(수백 KB, 수천 리비전)에서는 SQLite가 커밋마다 수행하는 디스크 동기화(fsync) 비용이 지배적이고, 컬럼을 통째로 다시 쓰느냐 마느냐는 체감 차이를 만들지 않았다. 즉 일반적인 위키 규모에서는 컬럼 하나 방식으로 충분하고 구현도 훨씬 단순하다.
개인 위키치고 큰 편인 "페이지 1000개 × 평균 리비전 200개" 규모를 가정해, 50페이지 × 200리비전(=1만 커밋)을 실측하고 20배로 외삽했다.
| A (우리 방식) | C (리비전마다 전체 텍스트) | |
|---|---|---|
| 커밋 1건 평균 시간 | 5.7ms | 5.8ms |
| 저장 용량 (1000페이지 외삽) | ~52 MB | ~155 MB |
커밋 속도는 사실상 동일하고(§5-1과 같은 이유), 저장 용량은 A가 C보다 3배 작다 — §3에서 설명한 "리비전마다 전체 스냅샷을 안 찍는 구조" 덕분에 나오는 정직한 이득이다.
A(컬럼 하나)와 C(리비전별 전체 텍스트)를 각각 독립된 SQLite 파일에 단독으로 넣고 똑같이 50페이지×200리비전을 돌려서, "논리적으로 저장된 바이트 수"와 "실제 디스크 파일 크기"를 비교했다.
| 논리 저장량 | 실제 파일 크기 | 배율 | |
|---|---|---|---|
| A만 단독 | 2.54 MB | 2.75 MB | 1.08배 |
| C만 단독 | 7.76 MB | 9.25 MB | 1.19배 |
둘 다 정상 범위(1.1~1.2배)다. 컬럼 하나에 계속 커지는 BLOB을 저장하는 방식이라고 해서 디스크 낭비가 심해지는 일은 없었다.
- 동시편집 충돌 감지는 반드시 애플리케이션에서 직접 구현한다. 편집 시작 버전을 저장 직전 HEAD와 비교해서 다르면 거부. 물리적 잠금과는 별개로 필요하다.
-
쓰기 잠금은 백엔드가 기본 제공하는 방식을 그대로 쓴다.
- 파일: 페이지별 락파일 또는
flock()으로 "버전 확인 → 커밋" 구간을 감싼다. - SQLite/MySQL: 트랜잭션으로 같은 구간을 감싸면 DB가 알아서 직렬화해준다.
- PostgreSQL Large Object를 쓸 수 있다면 별도 정규화 없이 SimpleRCS를 거의 그대로 붙일 수 있는 유일한 DB 백엔드다.
- 파일: 페이지별 락파일 또는
- 처음엔 단순하게 시작한다. SQLite/MySQL에서 "컬럼 하나" 방식으로 시작하고, 실측(§5)에서 확인했듯 일반적인 위키 규모라면 그걸로 충분하다. 특정 페이지의 편집 저장이 눈에 띄게 느려질 때만 그 페이지를 리비전별 행 방식으로 옮기는 걸 고려한다.
- 동시편집이 실제로 빈번한 서비스라면 PostgreSQL Large Object를 1순위로 검토한다. 이미 어댑터가 존재하고 추가 스키마 설계가 필요 없다.
- 배포 전에는 실제로 쓸 스키마 전체(여러 테이블 포함)로 목표 규모에 가깝게 재현 측정해본다. 이 문서의 실측은 단일 테이블 기준이며, 여러 테이블을 한 DB에서 같이 운영할 때의 특성까지 보장하지는 않는다.
스크립트는 이 위키가 아니라 SimpleRCS 본 저장소의
scripts/에 있다. 본 저장소를 clone한 뒤 그 루트에서 실행한다:
# §5-1: 컬럼 하나(A) vs 리비전별 행(B), 단일 페이지 2000커밋
uv run python scripts/wiki_sqlite_bench.py
# §5-2: 우리 방식(A) vs 전체 스냅샷(C), 50페이지 x 200리비전
uv run python scripts/wiki_multipage_bench.py
# §5-3: A/C를 각각 독립된 SQLite 파일에 넣고 저장 효율 검증
uv run python scripts/wiki_isolation_bench.py셋 다 실제 sqlite3 연결에 디스크 fsync를 포함해 커밋하며 시간을
측정한다 (인메모리 타이밍이 아님).