·PNU-Modu

A serverless distributed edit lock built from git commits

This article is written in Korean.


PNU-Modu는 교직원이 브라우저에서 브로슈어를 고치면 그게 곧 GitHub 저장소에 커밋되고, 커밋이 CI/CD를 돌려 사이트로 배포되는 CMS다. 백엔드도 DB도 없다. 관리자 페이지(/admin)가 브라우저에서 GitHub REST API를 직접 때리는 게 전부다. 이 구조가 마음에 들었는데, 딱 하나 걸리는 게 있었다. 두 사람이 같은 문서를 동시에 열면?

먼저 연 사람이 저장하고, 나중 사람이 저장하면 앞사람 작업이 그냥 사라진다. last-write-wins. 혼자 쓸 땐 안 보이지만, 행정실에서 여러 명이 브로슈어를 나눠 맡는 순간 터질 문제였다. 그리고 이건 “나중에” 고칠 수 있는 종류가 아니었다. 협업 도구인데 협업하면 데이터가 날아간다는 건 도구가 아니다.

락을 어디에 둘 것인가

보통은 서버에 락 테이블 하나 두면 끝난다. 편집 시작하면 행 하나 넣고, 끝나면 지우고. 그런데 이 프로젝트는 서버가 없다. 서버를 두는 순간 “무설치, GitHub만으로 돌아간다”는 전제가 깨지고, 교육원 입장에서 관리할 인프라가 하나 더 생긴다. 그건 이 프로젝트의 존재 이유를 부정하는 선택이었다.

그럼 상태를 어디에 두나. 가진 건 GitHub 저장소 하나뿐이다. 그런데 생각해보면 저장소는 이미 모든 편집자가 공유하는, 원자적 쓰기가 되는 저장소다. 커밋은 순서가 있고, 누구나 같은 상태를 본다. 그러면 락도 그냥 저장소에 파일로 두면 되는 거 아닌가.

그래서 .locks/{문서ID}.lock.json이라는 파일을 커밋하는 걸 락으로 정의했다. 내용은 단순하다.

{ "docId": "01-intro", "editingBy": "wlsgur11", "startedAt": 1779..., "lastHeartbeat": 1779... }

편집을 시작하면 이 파일을 PUT으로 커밋한다. 커밋 메시지는 이렇게 남는다.

system: acquire lock for 01-intro by @wlsgur11 [skip ci]

끝내면 DELETE로 지운다. 다른 사람이 문서를 열 땐 이 파일이 있는지부터 확인한다. 있으면, 그리고 내가 아니면, 편집기를 안 열고 “누가 편집 중”이라고 알린다. 여기까지는 반나절 만에 됐다. 문제는 이다음부터였다.

편집하다 브라우저를 꺼버리면

첫 버전을 돌려보자마자 걸린 게 이거였다. 락을 잡고 편집하다가 탭을 그냥 닫으면? DELETE가 안 불린다. 락 파일은 저장소에 그대로 남는다. 그 문서는 이제 아무도 못 연다. 영원히.

beforeunload 이벤트에 release를 걸어봤다. 되긴 되는데, 믿을 수가 없다. 브라우저가 탭 닫힐 때 네트워크 요청을 끝까지 보내준다는 보장이 없다. 크래시나 강제 종료면 아예 안 불린다. 이걸 안전망으로 삼으면 안 됐다. 코드에도 그렇게 적어놨다 - beforeunload는 best-effort고, 진짜 복구는 다른 데서 해야 한다.

그래서 락에 수명을 줬다. 편집 중이면 5분마다 락 파일을 다시 커밋해서 lastHeartbeat를 갱신한다. heartbeat다.

system: heartbeat for 01-intro [skip ci]

그리고 락을 확인할 때, lastHeartbeat가 15분보다 오래됐으면 “죽은 락”으로 보고 그냥 뺏어온다. 편집자가 살아있으면 5분마다 심장이 뛰니 락은 계속 신선하다. 탭이 죽으면 심장이 멈추고, 15분 뒤 다음 사람이 자동으로 인수한다. 사람이 개입할 필요가 없다.

숫자는 5분/15분으로 잡았다. heartbeat 세 번을 놓쳐야 죽은 걸로 친다는 뜻이다. 한두 번은 네트워크가 잠깐 튈 수 있으니 그 정도 여유는 뒀다.

그래도 둘이 동시에 잡으면

heartbeat로 크래시는 복구됐는데, 더 얄궂은 경우가 남는다. 락이 죽은 걸 두 사람이 동시에 보고, 동시에 인수하려 들면? 아니면 한 명이 read-only로 보는 중에 다른 데서 편집을 시작하면?

여기서 GitHub API의 성질을 이용했다. Contents API로 파일을 쓸 때 그 파일의 현재 sha를 같이 보내면, sha가 안 맞을 때 커밋이 거절된다. optimistic concurrency다. 락을 뺏을 때 내가 본 락 파일의 sha를 실어 보내니, 그 사이 누가 먼저 뺏었으면 내 요청이 튕긴다. 락을 두 명이 동시에 소유하는 상태 자체가 안 만들어진다.

그리고 heartbeat를 그냥 심장박동으로만 쓰지 않았다. 5분마다 뛸 때, 뛰기 직전에 저장소의 최신 락을 다시 읽어서 editingBy가 아직 나인지 확인한다. 아니면 - 누가 강제로 뺏어간 거다. 그럼 heartbeat를 멈추고, 편집기를 읽기 전용으로 내리고, 사용자한테 알린다. 뺏긴 사람이 그걸 모른 채 계속 쓰다가 저장 순간 충돌 나는 걸 막는다.

그래서 락이 걸린 문서를 열려고 하면 선택지를 준다. 읽기 전용으로 보기, 강제로 뺏기, 취소. 강제로 뺏으면 원래 편집자는 다음 heartbeat에서 자기가 밀려난 걸 알고 알아서 읽기 전용으로 내려간다. 넘겨받는 과정이 스스로 정리된다.

하트비트가 배포를 폭발시켰다

이쯤에서 CMS를 좀 오래 켜두고 편집을 해봤는데, GitHub Actions 탭이 이상했다. 배포가 쉴 새 없이 돌고 있었다.

당연했다. 락도, release도, heartbeat도 전부 커밋이다. 그리고 이 저장소는 main에 커밋이 꽂히면 사이트를 다시 빌드해서 배포한다. 편집자 한 명당 5분마다 heartbeat 커밋이 하나씩 꽂히니, 그때마다 Docusaurus 전체가 다시 빌드되고 GitHub Pages로 재배포됐다. 콘텐츠는 한 글자도 안 바뀌었는데.

락은 콘텐츠가 아니다. 누가 편집 중인지를 조율하는 신호일 뿐이다. 사이트 결과물과는 아무 상관이 없다. 그래서 락 관련 커밋 메시지에 전부 [skip ci]를 붙였다. 위에서 본 커밋 메시지 끝에 [skip ci]가 붙어 있는 게 그래서다. GitHub Actions는 이 태그가 붙은 커밋을 무시한다.

[skip ci]를 빠뜨린 커밋이 실수로 들어올 수도 있으니, 워크플로 쪽에도 이중으로 막아뒀다.

on:
  push:
    branches: [main]
    paths-ignore: ['.locks/**']

.locks/ 아래만 바뀐 push는 아예 배포를 안 돈다. 여기서 든 생각이, 결국 이건 두 종류의 트래픽을 한 파이프(git)에 태우면서 섞이지 않게 나누는 문제였다는 거다. 조율 신호(락)와 실제 콘텐츠. 신호가 데이터 흐름을 건드리면 안 됐다. 덤으로 배포에 concurrency: { group: pages, cancel-in-progress: true }를 걸어서, 콘텐츠를 빠르게 여러 번 저장해도 오래된 빌드는 취소되고 항상 마지막 상태만 배포되게 했다.

실제로 돌려보기

혼자서는 동시 편집을 못 만든다. 그래서 @tester라는 가짜 사용자를 하나 두고, 창을 두 개 띄워 같은 문서(99-template)를 번갈아 잡아봤다. 커밋 로그에 그날의 흔적이 그대로 남아 있다.

system: acquire lock for 99-template by @wlsgur11 [skip ci]
system: release lock for 99-template [skip ci]
system: acquire lock for 99-template by @tester [skip ci]
system: release lock for 99-template [skip ci]

한쪽이 잡으면 다른 쪽은 막히고, 놓으면 넘어가고. 강제 인수하면 원래 쪽이 읽기 전용으로 떨어지는지까지 봤다.

그리고 며칠 뒤, 시연 과정에서 나 말고 다른 운영자 두 명이 실제로 이 CMS로 콘텐츠를 편집했다. 커밋 로그에 내가 아닌 @hbcircle06, @moonbomee01-intro, 02-part-2, 04-part-4를 잡고 놓고 heartbeat를 남긴 기록이 있다. 내가 짠 락이 나 혼자 만든 시연이 아니라 다른 사람 손에서 돌아간 걸 로그가 증명한다. (브로슈어 제작 타이밍이 아직 아니라 정식 운영까지 가진 못했다. 도입 직전 단계였다.)

남는 것

git을 락 매니저로 쓴 건 영리했지만 공짜는 아니다. 락 상태를 읽으려면 API를 한 번 쳐야 하고, GitHub의 Contents API는 CDN 캐시가 껴서 방금 쓴 락이 바로 안 보일 때가 있다. 그래서 락과 목록을 읽을 땐 캐시가 덜 타는 경로(Git Trees/Blobs API)로 우회했는데, 이건 또 다른 글감이다. heartbeat 주기가 5분이라 그 안에 벌어지는 짧은 경합은 sha 체크에 기대야 하고, 편집자가 아주 많아지면 heartbeat 커밋만으로도 저장소 히스토리가 지저분해진다. 지금 규모(운영자 몇 명, 문서 수십 개)에선 문제가 안 되지만, 사용자가 수백 명이면 이 설계는 안 맞는다.

그래도 이 문제를 풀면서 얻은 게 컸다. 서버 없이도 공유 상태와 동시성 제어를 만들 수 있다는 것, 그리고 그때 필요한 건 heartbeat, TTL, optimistic concurrency, 조율 신호와 데이터의 분리 같은, 결국 분산 시스템이 다루는 것들과 같은 문제라는 것. 도구가 GitHub 하나뿐이어도 문제의 본질은 똑같았다.

다음에 같은 부류를 만나면 순서는 정해져 있다. 상태를 어디 둘지 먼저 정하고, 죽었을 때 어떻게 복구되는지(TTL)를 그다음에 정하고, 동시에 들어올 때 누가 이기는지(원자적 쓰기)를 마지막에 정한다. 이 셋을 빼먹으면 협업 도구가 아니라 협업하면 터지는 도구가 된다.