Skip to content

Wiki Backend Design

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

위키 백엔드로 SimpleRCS를 쓸 때의 설계 메모

상태: 조사/논의 완료, 구현 미착수 (스토리지 백엔드 및 동시성 방식 미결정)

한눈에 보기

  • SimpleRCS는 "파일 하나의 버전 이력"을 잘 관리하는 라이브러리다. 위키 시스템에 그대로 쓰려면 동시편집 충돌 감지쓰기 잠금을 애플리케이션 레벨에서 추가로 구현해야 한다 (SimpleRCS 자체엔 없음).
  • 저장소는 파일시스템, SQLite, MySQL, PostgreSQL 중 어디든 쓸 수 있지만, SimpleRCS의 실제 쓰기 방식(마지막 블록만 다시 쓰는 방식)과 가장 잘 맞는 건 파일시스템PostgreSQL Large Object다. SQLite/MySQL은 조금 덜 자연스럽지만, 일반적인 개인 위키 규모라면 문제없이 쓸 수 있다는 걸 실측으로 확인했다.
  • "리비전마다 전체 텍스트를 따로 저장"하는 방식보다 SimpleRCS 방식이 저장 공간을 약 3배 아낀다 — 편집 속도는 거의 차이가 없다.

1. SimpleRCS가 위키 시스템에 커버하는 것 / 안 하는 것

SimpleRCS는 "파일 하나의 버전 이력 저장소"이지 위키 시스템 자체가 아니다.

충실히 커버하는 것

  • 버전 저장/조회 (commit, checkout, log)
  • 두 버전 간 비교 (diff)
  • 라인 단위 저자 추적 (blame)
  • 무결성 검증(해시체인) + GPG 서명

위키 시스템이면 따로 얹어야 하는 것

  • 페이지 관리/네임스페이스 (여러 페이지를 하나의 위키로 묶는 레지스트리)
  • 페이지 이름 변경 이력 (파일명이 곧 식별자라 이름을 바꾸면 이력이 끊김)
  • 사이트 전체 "최근 변경" 피드
  • 검색/인덱싱, 위키링크 파싱
  • 접근 제어(ACL) — GPG 서명은 "누가 승인했는가"의 증명이지 "누가 쓸 수 있는가"의 통제가 아니다

이 문서는 이 중 버전 관리 엔진 자체의 동시성/저장소 문제만 다룬다.

2. 동시편집 문제

commit()을 직접 확인한 결과:

항목 현황
동시편집 충돌 감지 없음. 두 사용자가 같은 페이지를 동시에 편집하면, 먼저 저장한 사람의 수정이 나중에 저장한 사람에게 조용히 덮어써질 수 있다 (lost update)
파일 잠금 없음. 두 프로세스가 동시에 같은 파일에 커밋해도 막아주는 장치가 없다
과거 버전 조회 속도 HEAD에서 멀어질수록 느려진다 (버전 차이만큼 델타를 순서대로 적용해야 함)
페이지 단위 인스턴스 하나 = 파일 하나. 여러 페이지를 한 유닛으로 묶는 기능은 없다

참고 — 전통적 RCS는 어떻게 처리했나: GNU RCS는 co -l(체크아웃과 동시에 잠금)로 "이 리비전은 아무개가 편집 중"임을 명시적으로 기록하고, 잠금을 잡지 않은 사람의 체크인은 거부한다(비관적 락). 파일 자체는 임시 파일에 쓴 뒤 rename()으로 교체하고, 별도 락 파일로 동시 쓰기를 막는다. SimpleRCS는 이 두 가지 다 없다.

권장: 편집 시작 시점의 버전 번호를 클라이언트가 들고 있다가, 저장 직전에 현재 HEAD 버전과 비교해서 다르면 거부하는 로직을 애플리케이션에 직접 구현한다. 물리적 잠금과는 별개로 반드시 필요한 방어선이다.

3. 실제 저장 방식 이해하기

commit()은 흔히 생각하는 "파일 끝에 계속 이어붙이기(append)"가 아니다. 실제로는:

  1. 직전 HEAD(전체 텍스트였던 것)를 "델타"로 다시 인코딩한다
  2. 그 블록이 있던 위치로 되감아서, "델타로 바뀐 이전 HEAD" + "새 전체 텍스트 HEAD"를 그 자리에 덮어쓴다
  3. 남는 부분은 잘라낸다

매 커밋마다 마지막 블록 하나만 다시 쓰고, 그 앞의 모든 이력은 전혀 건드리지 않는다.

또 하나 중요한 점: 매 리비전마다 전체 페이지를 통째로 저장하는 게 아니라, "직전 버전으로 되돌리기 위한 변경분(델타)"만 저장하고 최신 버전(HEAD) 하나만 전체 텍스트로 들고 있다. 그래서 한 줄만 고친 커밋은 전체 페이지 크기가 아니라 몇백 바이트짜리 델타 하나만 늘어난다 — 이 특성이 아래 저장소 비교에서 계속 등장한다.

4. 저장소 백엔드 비교

파일시스템

seek()/truncate()를 그대로 지원해서 §3의 쓰기 방식과 가장 잘 맞는다 (현재 SimpleRCS 구현이 전제하는 모델). 동시편집 잠금은 페이지별 락파일이나 flock()으로 직접 구현해야 한다.

SQLite / MySQL

"페이지 하나 = 행 하나, 이력 전체를 BLOB 컬럼 하나에 저장"하는 방식. 이론적으로는 걱정할 이유가 있어 보인다 — SQLite/MySQL 모두 컬럼 값의 일부만 바꿔쓰는 기능이 없어서, UPDATE 한 번에 컬럼 값 전체를 다시 쓴다. 그러니 커밋마다 "새로 추가된 몇백 바이트"가 아니라 "그 페이지 이력 전체"를 다시 쓰는 셈이 된다는 논리였다.

그런데 실제로 재보니 이게 체감되는 문제가 아니었다 — §5 실측이 정확히 이 질문을 겨냥한 것이었고, 결론은 "컬럼 하나 방식을 그대로 써도 된다"였다. 이유는 두 가지다.

  1. §3에서 본 것처럼 이력 전체 크기 자체가 생각보다 작다 (리비전마다 전체 스냅샷을 안 찍으니까). "전체를 다시 쓴다"고 해도 그 "전체"가 수백 KB 수준이면 별 의미가 없다.
  2. 이 규모에서는 컬럼 재기록 비용보다 SQLite가 커밋마다 수행하는 fsync 비용이 훨씬 크다. 컬럼을 통째로 다시 쓰든 안 쓰든 그 fsync 비용은 똑같이 낸다 — 그래서 "전체 재기록"이 이론만큼 아프지 않다.

실측(§5-1)에서는 오히려 "이론적으로 더 나은" 리비전별 행 방식이 컬럼 하나 방식보다 느려지는 경향까지 보였고, "낭비 없이 잘 저장되는지"를 직접 검증한 §5-3에서도 논리적 저장량 대비 실제 파일 크기가 1.08배로 거의 낭비가 없었다. 즉 일반적인 개인 위키 규모(수백 페이지, 페이지당 수백 리비전)에서는 SimpleRCS 스트림 전체를 BLOB 컬럼 하나에 그대로 밀어넣어도 아무 문제가 없다 — 이게 이 문서가 벤치마크를 돌린 진짜 이유이자 결론이다.

그럼 아래 "리비전마다 별도 행" 방식은 언제 필요한가 — 실측이 못 미친 극단적 규모(수 MB 이상으로 자란 페이지, 리비전 수만 개)에서 특정 페이지의 저장/조회가 실제로 느려지는 게 확인될 때만 그 페이지에 한해서 고려하면 된다. 처음부터 이 구조로 시작할 이유는 없다.

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" — 이론상 이력 길이와 무관하게 저렴하지만, §5-1 실측에서는 이 이론적 장점이 실제 이득으로 이어지지 않았다(오히려 더 느려지는 경향). 게다가 SimpleRCS는 "파일 하나짜리 스트림"을 가정하므로, 이 행들을 이어붙여 SimpleRCS에 보여주는 어댑터를 별도로 만들어야 하는 구현 비용도 있다.

참고로 SQLite/MySQL은 어느 방식을 쓰든 행 단위가 아니라 DB 파일 단위로 쓰기를 직렬화하기 때문에, 한 DB에 페이지를 몰아넣으면 서로 다른 페이지 편집도 순서대로 밀린다. 쓰기가 아주 많은 서비스라면 여러 DB 파일로 나누는 것도 고려해야 한다.

PostgreSQL

두 가지를 구분해야 한다.

  • bytea 컬럼: SQLite/MySQL BLOB과 같은 한계 (전체 재기록).
  • Large Object (lo): lo_write/lo_truncate로 파일처럼 임의 위치를 읽고 쓸 수 있어서, §3의 쓰기 방식을 그대로 재현할 수 있는 유일한 DB 네이티브 방법이다. simple_rcs/adapters.py에 이미 이 어댑터가 있어서 구현 비용이 가장 낮다. (단, 삭제된 페이지의 LO가 자동으로 정리되지 않는 점, Postgres 전용이라 이식성이 없는 점은 감안해야 한다.)

요약

백엔드 §3 쓰기 방식과의 궁합 (이론) 실측 결과 구현 비용
파일시스템 네이티브 낮음
SQLite/MySQL (컬럼 1개) 이론상 별로 (매번 전체 재기록) 실측상 문제없음 — 위키 규모에서 fsync가 지배적, 낭비율 1.08배 (§5) 낮음
SQLite/MySQL (리비전별 행) 이론상 좋음 실측에서 컬럼 1개보다 오히려 느린 경향 (§5-1) 중간 (어댑터 필요)
PostgreSQL bytea 이론상 별로 별도 실측 안 함, SQLite와 같은 한계로 추정 낮음, 규모 커지면 부담 예상
PostgreSQL Large Object 네이티브 별도 실측 안 함 (구조상 문제 없을 것으로 판단) 낮음 (어댑터 이미 있음)

결론: "이론상 궁합"만 보고 판단하면 컬럼 1개 방식이 열등해 보이지만, 실제로 재보면 일반적인 위키 규모에서는 가장 구현이 단순한 컬럼 1개 방식이 문제없이 동작한다. 리비전별 행 정규화는 이론적 우위가 실측으로 확인되지 않았으니, "정석이라서" 미리 채택할 이유가 없다.

5. 실측 결과

이론만으로 판단하지 않고 실제로 SQLite에 붙여서 측정했다. 스크립트는 scripts/에 있고, 재현 방법은 §7 참고.

5-1. 컬럼 하나(A) vs 리비전별 행(B) — 단일 페이지, 2000 커밋

50줄짜리 페이지에 매번 한 줄씩 바꾸며 2000번 커밋. 컬럼 하나 방식(A)과 리비전별 행 방식(B)의 커밋당 소요 시간을 비교했다.

초반 10% 평균 후반 10% 평균 증가율 총 소요
A (컬럼 전체 재기록) 6.7ms 11.6ms 1.74배 17.2s
B (행 단위 갱신) 6.2ms 5.3ms 0.85배 10.6s

이력이 10배 커지는(53KB→536KB) 동안 A의 커밋당 시간은 1.74배 늘었고, 이론대로 B는 이력 크기와 무관하게 평평했다. 절대값을 보면 둘 다 커밋 하나에 5~12ms 수준이다 — 이 규모(수백 KB, 수천 리비전)에서는 SQLite가 커밋마다 수행하는 디스크 동기화(fsync) 비용이 지배적이라, 컬럼을 통째로 다시 쓰는 대가가 사람이 체감할 만큼 커지지 않는다. B가 2000 리비전 누적 기준으로 약 40% 빠르지만, 그건 커밋당 6ms 차이이고 A는 테이블 하나로 끝난다. 즉 일반적인 위키 규모에서는 컬럼 하나 방식으로 충분하고 구현도 훨씬 단순하다.

이 표는 commit()이 인메모리 스트림에서 버전 문자열 대신 스트림 전체를 반환하던 버그를 고친 뒤 다시 측정한 값이다. 그 전 측정에서는 version 컬럼에 스트림 사본이 통째로 들어가 B 쪽이 부당하게 무거웠다(초반 5.5ms → 후반 16.2ms). 결론(컬럼 하나로 충분함)은 바뀌지 않았지만, "B가 A보다 느려진다"던 종전 서술은 측정 오류였다.

5-2. "우리 방식"(A) vs "리비전마다 전체 텍스트 저장"(C) — 다중 페이지

개인 위키치고 큰 편인 "페이지 1000개 × 평균 리비전 200개" 규모를 가정해, 50페이지 × 200리비전(=1만 커밋)을 실측하고 20배로 외삽했다.

A (우리 방식) C (리비전마다 전체 텍스트)
커밋 1건 평균 시간 5.6ms 5.2ms
저장 용량 (1000페이지 외삽) ~52 MB ~155 MB

커밋 속도는 사실상 동일하고(§5-1과 같은 이유), 저장 용량은 A가 C보다 3배 작다 — §3에서 설명한 "리비전마다 전체 스냅샷을 안 찍는 구조" 덕분에 나오는 정직한 이득이다.

5-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을 저장하는 방식이라고 해서 디스크 낭비가 심해지는 일은 없었다.

6. 권장 방향

  1. SQLite/MySQL을 쓴다면 "컬럼 하나에 SimpleRCS 스트림 전체를 그대로 저장"하는 가장 단순한 방식으로 시작하면 된다 — 이게 이 문서의 핵심 결론이다. §4에서는 "매번 전체 재기록이라 비효율적일 것"이라고 이론적으로 걱정했지만, §5 실측(단일 페이지 2000커밋, 다중 페이지 1000개 외삽, 저장 효율 검증까지)으로 확인한 결과 일반적인 개인 위키 규모에서는 체감되는 문제가 전혀 없었다. 오히려 "이론상 더 낫다"고 봤던 리비전별 행 정규화가 실측에서는 더 느린 경향을 보였다. 리비전별 행 정규화는 정석이라서 미리 채택할 게 아니라, 실제로 특정 페이지가 느려지는 게 확인된 뒤에만 그 페이지에 한해 고려하는 최적화다.
  2. 동시편집 충돌 감지는 반드시 애플리케이션에서 직접 구현한다. 편집 시작 버전을 저장 직전 HEAD와 비교해서 다르면 거부. 물리적 잠금과는 별개로 필요하다. 이건 저장소 선택과 무관하게 항상 해야 하는 일이고, SimpleRCS도 RCS도 이 부분을 대신해주지 않는다.
  3. 쓰기 잠금은 백엔드가 기본 제공하는 방식을 그대로 쓴다.
    • 파일: 페이지별 락파일 또는 flock()으로 "버전 확인 → 커밋" 구간을 감싼다.
    • SQLite/MySQL: 트랜잭션으로 같은 구간을 감싸면 DB가 알아서 직렬화해준다.
    • PostgreSQL Large Object를 쓸 수 있다면 별도 정규화 없이 SimpleRCS를 거의 그대로 붙일 수 있는 유일한 DB 백엔드다.
  4. 동시편집이 실제로 빈번한 서비스라면 PostgreSQL Large Object를 1순위로 검토한다. 이미 어댑터가 존재하고 추가 스키마 설계가 필요 없다. (다만 이쪽은 SQLite처럼 직접 실측하지는 않았다 — 구조적으로 §3 쓰기 방식과 맞다는 것만 확인했다.)
  5. 배포 전에는 실제로 쓸 스키마 전체(여러 테이블 포함)로 목표 규모에 가깝게 재현 측정해본다. 이 문서의 실측은 단일 테이블 기준이며, 여러 테이블을 한 DB에서 같이 운영할 때의 특성까지 보장하지는 않는다.

7. 재현 방법

스크립트는 이 위키가 아니라 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를 포함해 커밋하며 시간을 측정한다 (인메모리 타이밍이 아님).

Clone this wiki locally