
글만 Git처럼 고치면 되는 줄 알았습니다
게시글 하나를 저장하듯 시작한 위키가 리비전, 권한, 검색과 복구 구조까지 필요하게 된 과정.
Sevenwiki — The Knowledge InfrastructureAugust 31, 2026
글을 Git처럼 수정할 수 있으면 되지 않을까?
Sevenwiki의 첫 문서 모델은 정말 단순했습니다.
documents
- title
- content
- updated_at 제목으로 문서를 찾고, 본문을 읽고, 편집 화면에서 고친 내용을 다시 저장하면 됐습니다. 처음에는 이것만으로도 꽤 위키처럼 보였습니다.
저도 당시에는 위키를 조금 특별한 게시글 서비스 정도로 생각했던 것 같습니다. 글을 누구나 고칠 수 있고, Git처럼 이전 내용도 볼 수 있게 만들면 되지 않을까 싶었죠.
문제는 다른 사람이 같은 문서를 만지기 시작하면서 바로 나타났습니다.
두 사람이 편집하자 조용히 글이 사라졌습니다
A와 B가 같은 리비전에서 편집을 시작했다고 해보겠습니다.
A는 첫 문단에 설명을 하나 추가해 먼저 저장했습니다. B는 그 사실을 모른 채 이전 편집 화면에서 마지막 문단의 오타만 고쳐 저장했습니다.
content를 통째로 UPDATE하면 문서는 B가 제출한 본문으로 바뀝니다. B가 보고 있던 화면에는 A의 새 문단이 없었으니, A의 수정도 함께 사라집니다.
A가 저장한 문서
└─ 새 설명 + 기존 본문
B가 제출한 문서
└─ 기존 본문 + 오타 수정
단순 UPDATE 결과
└─ A가 추가한 설명은 사라짐 SQL은 성공했고 HTTP도 200입니다. 서버 로그만 보면 아무 문제도 없는데, 사용자가 쓴 내용 하나는 유실된 셈입니다.
버전 번호를 확인해 오래된 편집을 모두 거부하면 손실은 막을 수 있습니다. 하지만 서로 다른 문단을 고친 경우에도 무조건 충돌이 나게 됩니다. 위키에서 동시 편집은 예외적인 일이 아니니, 이것도 썩 만족스럽지는 않았습니다.
이때부터 문서를 현재 본문 한 칸으로만 저장해서는 안 되겠다는 생각이 들었습니다.
이전 본문을 버리지 않기로 했습니다
V7에서는 문서의 현재 상태와 편집 이력을 나눠서 저장합니다.
document_metadata에는 현재 제목과 네임스페이스, 숨김 상태, 그리고 현재 리비전을 가리키는 ID가 들어갑니다. document_revisions에는 누가 언제 수정했는지, 편집 요약과 변경량은 얼마인지, 당시 본문이 어느 객체에 저장되어 있는지를 남깁니다.
본문은 새로 저장할 때마다 이전 값을 덮어쓰지 않습니다. 새 리비전을 만든 뒤 document_metadata.current_revision_id만 앞으로 옮깁니다.
document_metadata.current_revision_id
│
▼
revision 41 → revision 42 → revision 43
▲
current 이 구조를 잡고 나니 리비전 목록이나 diff, 되돌리기 같은 기능도 자연스럽게 만들 수 있었습니다. 사용자가 어느 리비전을 보고 편집을 시작했는지도 알 수 있으니, 동시 편집은 original, proposed, current를 비교하는 3-way merge 문제로 바뀌었습니다.
과거 기록은 나중에 붙이는 부가기능이 아니었습니다. 위키에서는 과거를 지우지 않는 것부터가 문서 모델의 시작이더군요.
저장 버튼을 눌러도 일은 끝나지 않았습니다
문서 하나를 저장할 때 바뀌는 것은 본문만이 아닙니다.
- 새 문서 리비전
- 현재 리비전을 가리키는 ID
- 편집한 사용자와 기기 정보
- 활동 로그
- 구독자에게 보낼 알림
- 편집 포인트 적립
- 검색 색인 갱신
- 링크,
include, 파일 참조 분석
처음에는 저장이 끝난 뒤 차례로 함수를 호출하면 될 것 같았습니다. 하지만 데이터베이스를 커밋한 직후 프로세스가 죽으면 검색 작업이나 알림 작업을 잃을 수 있습니다. 반대로 검색 엔진과 알림 전송까지 모두 기다리게 하면, Meilisearch가 잠깐 느려진 것만으로 문서 저장까지 실패합니다.
결국 두 종류로 나눴습니다.
문서 리비전과 현재 리비전 ID, 활동 로그처럼 반드시 함께 남아야 하는 데이터는 같은 PostgreSQL 트랜잭션에 넣습니다. 검색과 문서 분석처럼 나중에 처리해도 되는 일은 실제 작업까지 끝내지는 않되, 해야 할 작업 자체를 같은 트랜잭션에 기록합니다.
문서 저장 트랜잭션
├─ 새 리비전
├─ current_revision_id 갱신
├─ 활동 로그
├─ 알림·포인트 작업 등록
└─ 검색·문서 분석 작업 등록
커밋 이후
└─ 별도 워커가 작업 처리 저장 요청을 빠르게 끝내면서도, 커밋 직후 서버가 내려갔다고 해야 할 일을 잃지는 않는 구조입니다.
비공개 문서는 본문만 막는다고 끝이 아니더군요
권한도 비슷했습니다.
처음에는 문서 본문 API에서 ACL만 확인하면 된다고 생각하기 쉽습니다. 하지만 비공개 문서의 존재는 본문 밖에서도 얼마든지 드러날 수 있습니다.
검색 결과
최근 변경
사용자 기여 목록
역링크
알림
SSE 이벤트 스트림 본문은 403인데 검색 결과에 제목이 그대로 나오거나, 최근 변경에서 누가 어떤 비공개 문서를 편집했는지 보인다면 권한을 제대로 지킨 것이 아닙니다.
V7에서는 검색 단계와 최종 응답 단계를 나눴습니다. Meilisearch의 effective_read_level로 볼 가능성이 없는 후보를 먼저 줄이지만, 그 값을 최종 허가로 사용하지는 않습니다. 실제 응답을 만들기 직전에 현재 ACL과 숨김 상태를 다시 확인합니다.
그룹이나 계정 나이처럼 검색 색인 하나로 정확히 표현하기 어려운 조건이 있다면 후보를 넓게 잡습니다. 검색 결과를 조금 더 가져오는 편이, 실제로 볼 수 있는 문서를 처음부터 버리는 것보다 낫기 때문입니다.
기능보다 누가 데이터를 맡을지 먼저 정했습니다
프로젝트가 커지면서 Auth, Region, Media, Sitelink도 분리하게 됐습니다.
처음부터 마이크로서비스를 해보고 싶어서 나눈 것은 아닙니다. 오히려 기능을 계속 붙이다 보니 한 서비스의 장애가 왜 다른 기능까지 막아야 하는지 설명하기 어려워졌습니다.
Auth가 모든 요청마다 세션을 확인하면 Auth 장애가 모든 Region으로 번집니다. 각 Region이 파일 원본을 따로 관리하면 같은 파일의 교체 상태를 서로 다르게 기억할 수 있습니다. Sitelink를 제목으로만 연결하면 문서가 이동할 때 관계가 흔들립니다.
그래서 각 데이터에 두 가지 질문을 붙였습니다.
- 이 데이터의 최종 상태는 누가 결정하는가?
- 그 서비스가 멈췄을 때 어디까지 멈추는 것이 맞는가?
그 결과 Auth는 계정과 보안 상태를, Region은 사이트별 문서와 ACL을, Media는 파일 원본과 공개 버전을, Sitelink는 사이트 사이의 문서 관계를 맡게 됐습니다.
같은 서버에 배포할 수는 있어도 서로의 PostgreSQL이나 Redis를 직접 읽지는 않습니다. 서버 배치가 바뀌었다고 데이터의 책임까지 바뀌지 않게 하려는 선택이었습니다.
캐시와 브로커는 다시 만들 수 있어야 했습니다
Redis와 Meilisearch, JetStream을 쓰다 보면 어느 순간 그 안의 데이터도 원본처럼 느껴집니다. 하지만 장애와 복구를 생각하면 이 셋을 최종 기준으로 두기는 어려웠습니다.
검색 색인이 사라지면 Region DB에서 다시 만들 수 있어야 합니다. 실시간 알림을 놓치면 활동 로그를 다시 읽으면 됩니다. JetStream의 메시지 보존 기간을 넘겼다면 원본 서비스의 변경 내역이나 스냅샷에서 다시 따라잡을 수 있어야 합니다.
비동기 작업도 같은 기준으로 나눴습니다.
- 한 Region 안에서 시작하고 끝나는 작업은 PostgreSQL 큐에 남깁니다.
- Auth, Media, Sitelink처럼 서비스 사이를 건너가는 변경은 Transactional Outbox와 JetStream으로 전달합니다.
둘 다 정확히 한 번만 실행된다고 가정하지 않습니다. 같은 작업이 다시 오거나 늦게 끝나도 최신 상태를 덮지 않게 만들고, 전달 기록을 잃으면 원본 데이터에서 다시 시작합니다.
만들수록 위키가 다르게 보였습니다
처음에는 글을 Git처럼 수정할 수 있는 공간을 만들고 싶었습니다.
그런데 동시 편집을 다루다 보니 리비전 모델이 필요했고, 리비전을 남기다 보니 저장 트랜잭션과 후처리 작업을 나눠야 했습니다. 검색과 최근 변경을 붙이니 본문 밖의 권한까지 생각해야 했고, 서비스를 나누니 이벤트 누락과 PITR 이후의 복구까지 직접 설계해야 했습니다.
이 연재에서는 그렇게 하나씩 일이 커졌던 과정을 다뤄보려고 합니다.
완성된 구조를 정답처럼 설명하기보다는, 처음에는 무엇을 만들었고 어디에서 깨졌는지, 그래서 무엇을 버리고 다시 만들었는지, 지금 구조에도 어떤 비용이 남아 있는지를 차례로 풀어보겠습니다.