DB 없이 GitHub 리포를 저장소로 — 성립 조건은 「1인」 이 아니라 「append-only 이벤트 로그」
마이그레이션 0개인 북마크 앱(app421)이 여러 기기에서 굴러가는 이유
Rails 앱에서 DB 대신 GitHub 리포를 저장소로 써도 되는지 판단하는 글이다. 다 읽으면 어떤 데이터면 되고 어떤 데이터면 안 되는지, 그리고 여러 기기에서 동시에 써도 안 깨지게 하는 최소한의 장치를 알 수 있다.
이 앱에는 마이그레이션이 없다
app421(YT Mark)은 유튜브 북마크 앱이다. 담고, 분류하고, 상황별로 꺼내서 재생목록으로 던진다. 누가 봐도 CRUD 앱인데 테이블이 하나도 없다.
데이터는 사용자 본인의 GitHub 리포에 JSONL(한 줄에 JSON 하나) 파일 하나로 쌓인다. 북마크를 담으면 {"op":"add",...} 한 줄, 지우면 {"op":"del",...} 한 줄이 붙는다. 쓰기는 크롬 확장이 하고 읽기는 Rails 가 한다. 서버는 상태를 하나도 안 가진다.
「git 을 DB 로 쓴다」 는 시도는 보통 망한다. 그런데 이건 굴러간다. 왜인지가 이 글의 내용이다.
쓰기: 통째로 PUT, 충돌하면 다시
GitHub Contents API 에는 부분 수정이 없다. 파일을 통째로 덮어쓴다. 대신 PUT 할 때 지금 알고 있는 sha 를 같이 보내면 낙관적 잠금(남이 먼저 바꿨으면 거절)이 걸린다. 다른 기기가 먼저 썼다면 409 가 돌아온다.
확장은 세 겹으로 처리한다. 같은 브라우저 안에서는 Promise 체인으로 쓰기를 줄 세운다. 매번 원격 파일을 읽어 내 이벤트를 뒤에 붙인 다음 통째로 PUT 한다. 409 가 나면 다시 읽고 다시 붙인다. 오프라인이면 pending 배열에 쌓였다가 나중에 밀린다.
버전 컬럼과 트랜잭션으로 직접 만들었으면 꽤 일이었을 낙관적 잠금이, API 스펙에 이미 들어 있었다.
읽기: Rails 는 접기만 한다
Rails 는 파일을 받아서(60초 캐시) 이벤트를 현재 상태로 접는다. 핵심은 이 한 줄이다. 이벤트를 at 시각순으로 정렬하고 나서 접는다. 그래서 여러 기기에서 밀어 넣은 줄이 파일 안에서 뒤섞여 있어도 결과가 같은 곳에 모인다.
삭제도 줄을 지우지 않고 del 이벤트를 하나 더 쓴다. 쓰기 주체를 확장 하나로 두고 Rails 는 읽기 전용으로 묶어 둔 덕에, 경합 처리 코드가 한 곳에만 있다.
성립 조건은 「1인」 이 아니다
흔히 「개인용이니까 git 으로 해도 된다」 고 정리한다. 그렇게 외우면 다음 앱에서 깨진다. 실제 조건은 셋이다.
append-only 이벤트 로그일 것. 409 가 나도 「원격 줄 + 내 줄」 로 다시 합치면 끝이다. 양쪽 다 살아남는다. git 저장소가 DB 노릇을 못 하는 지점이 merge 인데, 이벤트 로그는 그 문제를 애초에 안 만든다. 반대로 혼자 써도 한 줄을 그 자리에서 고치는 구조라면 git 으로 가는 순간 괴로워진다.
줄 단위 텍스트일 것. JSONL 이라 추가는 한 줄이고 충돌도 줄 단위로 난다. 사람이 리포에서 열어 볼 수도 있다. SQLite 파일을 커밋하는 변형은 안 된다. 바이너리라 diff 가 의미 없고 리포만 부푼다.
통째로 읽어도 되는 크기일 것. 쿼리가 없다. 필터와 집계를 전부 메모리에서 한다. 수천 건까지는 이쪽이 오히려 편하다. 인덱스 고민도 N+1 도 없다.
실제로 밟은 것: 0 과 「없음」
링크를 우클릭해서 저장하면 oEmbed(URL 하나로 제목 · 썸네일 같은 정보를 받는 방식)로 정보를 받는데, 여기엔 영상 길이가 없다. 그래서 d: 0 인 add 이벤트가 들어온다. 처음엔 이걸 그대로 받아서, 이미 알던 길이를 0 으로 덮어썼다. 그 영상은 「10분 이하」 필터에서 조용히 사라졌다.
지금은 0 을 「못 얻었다」 로 보고 이전 값을 살린다. 이벤트를 합치는 구조라면 「없음」 과 「0」 을 구분하지 않으면 데이터가 소리 없이 망가진다.
어디서 깨지나
지금 문제는 아니고 「커지면」 의 이야기다. 쓸 때마다 파일 전체를 PUT 하니까 만 건을 넘기면 북마크 하나에 몇 MB 를 올리게 된다. 그때는 월별 파일로 나누면 구조를 안 바꾸고 넘어간다. 같은 fold 규칙이 Ruby 와 JS 두 군데에 있어서 규칙을 바꾸면 두 곳을 고쳐야 한다. 삭제가 tombstone(지웠다는 표시)으로 영원히 남으니 민감한 데이터에는 쓰면 안 된다. 나머지 한계와 일반 DB 와의 비교는 아래 상세에 표로 뒀다.
DB 를 둘지 말지, 데이터를 어디에 둘지 같은 설계 단계에서 막혀 있다면 negabaro 에 물어봐도 된다. 이런 작은 구조를 직접 굴려 본 경험으로 같이 본다.
어디에 둘까
| 저장소 | 맞는 데이터 | 동시 쓰기 | 운영 부담 |
|---|---|---|---|
| GitHub 리포 + JSONL 이벤트 | 추가 위주, 수천 건, 사용자가 데이터를 갖고 싶어 함 | sha 낙관적 잠금 + 재시도 | 마이그레이션 · 백업 없음 |
| GitHub 리포 + SQLite 파일 | 맞는 경우가 거의 없다 | 파일 단위 충돌, 합칠 수 없음 | 리포가 부푼다 |
| 일반 DB (SQLite · PostgreSQL) | 그 자리 수정, 검색, 여러 사람 | 트랜잭션 | 마이그레이션 · 백업 · 서버 |
그 자리에서 고치는 데이터가 많거나 검색이 필요하면 고민 없이 DB 다.
커지면 깨지는 지점
| 지점 | 지금 | 넘어가면 |
|---|---|---|
| 파일 크기 | 1MB 를 넘으면 Contents API 가 content 를 비워 보내서 download_url 로 받는다 | 쓸 때마다 전체 PUT. 월별 파일로 나눈다 |
| 재시도 | 최대 2번 | 세 번째 충돌이면 pending 에 남아 다음 차례를 기다린다. 잃지는 않는다 |
| rate limit | 인증 요청 시간당 5,000, 60초 캐시로 막는다 | 기기가 많고 새로고침이 잦으면 느껴진다 |
| fold 규칙 | Ruby 와 JS 에 같은 규칙 | 바꾸면 두 곳을 고친다 |
fold — 시각순으로 접는다
def self.fold(events)
events.sort_by { |e| e["at"].to_s }.each_with_object({}) do |e, map|
next_state = apply(map[e["id"]], e)
map[e["id"]] = next_state if next_state
end
end
# 길이 0 은 「못 얻었다」. 이미 알던 값을 지우지 않는다
d: e["d"].to_i.positive? ? e["d"].to_i : prev&.dig(:d).to_i
핵심 포인트
데이터를 상태가 아니라 이벤트(add · del · played)로 한 줄씩 쌓는다
쓰기 주체는 하나(확장)로 두고, GET → 줄 붙이기 → sha 와 함께 통째 PUT
409 가 오면 다시 읽고 다시 붙인다. 오프라인이면 pending 에 쌓는다
Rails 는 읽기 전용. 시각순 정렬 후 fold 해서 여러 기기의 로그를 한 상태로 모은다
병합할 때 「없음」 과 「0」 을 구분한다. 커지면 월별 파일로 나눈다