저장 버튼 하나에 몇 개의 시스템이 얽혀 있을까?
R2의 본문 객체와 PostgreSQL 리비전, 활동 로그, 알림과 후처리 작업을 하나의 문서 저장 흐름으로 만든 과정.
Sevenwiki — The Knowledge InfrastructureAugust 31, 2026
API 요청만 보면 별것 없어 보였습니다
문서 편집 API가 받는 값은 그리 많지 않습니다.
document_id
original_revision_id
content
summary
edit_summary 처음에는 새 본문을 저장하고 성공을 반환하면 될 것 같았습니다.
그런데 실제 저장 경로를 따라가면 여러 시스템이 한꺼번에 등장합니다.
- 새 본문을 넣을 R2 객체
- 새 문서 리비전
document_metadata.current_revision_id- 편집한 사용자와 기기 정보
- 활동 로그
- 구독자 알림 작업
- 포인트 적립 작업
- 링크와
include, 파일 참조 분석 - Meilisearch 색인 갱신
어느 하나만 따로 실패해도 화면마다 다른 상태를 보여줄 수 있습니다.
리비전은 생겼는데 현재 리비전 ID가 옛 값을 가리킬 수 있고, 문서는 수정됐지만 활동 로그가 없을 수도 있습니다. 데이터베이스 커밋 직후 프로세스가 죽으면 검색 작업을 등록하지 못할 수도 있습니다.
그렇다고 이 모든 일을 하나의 PostgreSQL 트랜잭션 안에서 끝낼 수도 없습니다. 본문은 R2에 있고 검색은 Meilisearch에 있으며, 알림 전송도 외부 작업입니다.
저장 기능을 만들며 가장 먼저 정해야 했던 것은 “어떻게 전부 원자적으로 만들까?”가 아니었습니다.
어디까지는 반드시 같이 성공해야 하고, 어디부터는 나중에 처리해도 될까?
이 질문이었습니다.
트랜잭션을 열기 전에 할 수 있는 일은 먼저 했습니다
service_edit_document는 바로 쓰기 트랜잭션부터 열지 않습니다.
먼저 문서 메타데이터와 현재 리비전을 읽고 편집 권한을 확인합니다. 요청 URL의 네임스페이스와 제목이 현재 문서 이름과 다르면 문서가 이동한 것으로 보고 Renamed를 반환합니다.
오래전에 열어둔 편집 화면이 이전 주소로 새 리비전을 만드는 일을 막기 위해서입니다.
현재 본문은 R2에서 읽습니다. 사용자가 편집을 시작한 original_revision_id가 현재 리비전과 다르면 원본, 제출한 본문, 현재 본문을 이용해 3-way merge를 시도합니다.
충돌이 있다면 여기서 바로 응답합니다. 아직 새 R2 객체도, 새 데이터베이스 행도 만들지 않은 상태입니다.
본문 변경량 계산도 트랜잭션 전에 끝냅니다. 긴 문서의 diff는 생각보다 비쌀 수 있고, 이를 async runtime의 작업 스레드에서 오래 실행하면 다른 요청에도 영향을 줍니다.
현재 구현은 diff 계산을 spawn_blocking으로 넘기고, 최악의 입력이 지나치게 오래 잡히지 않도록 제한 시간을 둡니다. 계산한 변경량은 리비전 기록과 포인트 계산에서 함께 사용합니다.
문서와 현재 리비전 조회
↓
1차 권한 확인
↓
R2 본문 읽기
↓
필요하면 3-way merge
↓
diff 계산
↓
새 본문 저장 준비 비싼 읽기와 계산을 끝낸 뒤에야 실제 쓰기 트랜잭션으로 들어갑니다.
트랜잭션 안에 다 넣으면 안전할 줄 알았는데요
처음에는 외부 작업도 트랜잭션 안에서 실행하면 더 안전하지 않을까 생각했습니다.
문서 행을 잠그고 R2에서 본문을 읽고, diff를 계산하고, 새 객체를 저장한 뒤 DB를 커밋하면 중간 상태를 다른 요청이 보지 못할 것 같았습니다.
하지만 R2 응답이 느려지면 그 시간 동안 문서 행의 잠금을 계속 잡게 됩니다. 한 사용자의 객체 저장 지연이 다른 편집자의 요청을 막습니다. diff 계산이 오래 걸려도 마찬가지입니다.
데이터베이스 잠금은 외부 시스템의 응답 시간을 제어하지 못합니다.
그래서 일반 경로의 R2 읽기와 쓰기, 병합과 diff는 트랜잭션 밖에서 처리합니다. 대신 실제 저장 직전에 문서와 권한이 여전히 같은지 다시 확인합니다.
트랜잭션을 길게 잡아 모든 변화를 막는 대신, 준비한 결과가 아직 유효한지를 마지막에 검증하는 쪽을 택했습니다.
R2와 PostgreSQL은 같이 롤백되지 않았습니다
리비전 본문은 R2에 저장하고, 리비전 정보와 현재 리비전 ID는 PostgreSQL에 저장합니다.
두 시스템 사이에는 하나의 분산 트랜잭션이 없습니다. 어느 쪽을 먼저 저장해도 중간 실패 가능성이 남습니다.
데이터베이스부터 커밋하고 R2 저장이 실패하면 어떻게 될까요?
PostgreSQL
current_revision_id → revision 43
R2
revision 43 본문 없음 사용자는 저장 성공 응답을 받았지만 문서를 다시 읽을 수 없습니다. 현재 리비전이 존재하지 않는 본문을 가리키는 상황입니다.
V7은 반대 순서를 사용합니다.
R2에 새 본문 객체 저장
↓
PostgreSQL 트랜잭션
↓
새 리비전을 현재 리비전으로 지정 물론 이것도 완벽하지 않습니다. R2 저장 뒤 데이터베이스 트랜잭션이 실패하면 어느 리비전에서도 참조하지 않는 객체가 남습니다.
완전한 원자성을 얻은 것이 아니라, 두 실패 중 복구하기 쉬운 쪽을 고른 것입니다.
본문이 없는 현재 리비전은 사용자 데이터가 바로 깨집니다. 참조되지 않은 R2 객체는 저장 공간을 차지하지만, 리비전 UUID로 만든 객체 키와 DB를 비교해 나중에 정리할 수 있습니다.
이 선택을 하면서 “실패가 없게 만들자”보다 “실패했을 때 어느 상태가 덜 위험하고 찾기 쉬운가”를 더 많이 생각하게 됐습니다.
저장 직전에 권한을 다시 봤습니다
요청 초기에 편집 권한을 확인했더라도 그 결과를 끝까지 믿을 수는 없습니다.
R2에서 본문을 읽고 병합과 diff를 계산하는 동안 사용자의 역할이 바뀔 수 있습니다. 문서 ACL이 수정되거나, 문서가 이동하거나, 다른 사용자가 새 리비전을 저장할 수도 있습니다.
쓰기 트랜잭션에 들어가면 document_metadata를 FOR UPDATE로 잠급니다. 그 상태에서 현재 사용자 정보와 ACL을 다시 읽고 DocumentPermission::Edit을 확인합니다.
1차 검사는 권한이 없는 요청에 비싼 R2 읽기와 diff를 쓰지 않기 위한 빠른 거절입니다. 2차 검사가 실제 저장을 허용하는 최종 검사입니다.
문서 이름과 현재 리비전도 다시 비교합니다.
준비 단계에서 본 current revision
↓
쓰기 직전 다시 읽은 current revision 둘이 같다면 그대로 진행합니다.
리비전 ID는 달라졌지만 본문과 문서 요약이 같다면 파일만 바뀐 리비전일 수 있습니다. 문서의 텍스트와 파일은 서로 다른 축으로 수정될 수 있으니 이 경우에는 텍스트 편집을 계속합니다.
실제 본문이 바뀌었다면 준비해둔 병합 결과를 그대로 저장하지 않습니다. 최신 리비전을 기준으로 다시 시도하도록 합니다.
이 드문 경로에서는 문서 잠금을 잡은 상태로 최신 R2 본문을 다시 읽어야 하므로 트랜잭션이 길어질 수 있습니다. 완전히 없애지 못한 비용이지만, 파일 변경만 있었던 편집을 불필요하게 실패시키지 않기 위해 남겼습니다.
PostgreSQL 안에서 함께 남겨야 할 것은 묶었습니다
모든 준비가 끝나고 최종 검증도 통과하면 Region이 관리하는 데이터를 한 트랜잭션에 기록합니다.
- 편집한 사용자 또는 유동 IP의 actor를 찾거나 만듭니다.
- 기기와 요청 정보를 기록합니다.
- 새 immutable 리비전을 만듭니다.
current_revision_id를 새 리비전으로 바꿉니다.- 변경량을 포함한 활동 로그를 남깁니다.
- 로그인 사용자의 포인트 적립 작업을 등록합니다.
- 구독자 알림 작업을 등록합니다.
- 메타데이터 트리거가 문서 분석과 검색 색인 작업의 세대를 올립니다.
- 모두 성공하면 커밋합니다.
실제 알림 전송과 SevenMark 분석, Meilisearch 갱신까지 요청이 기다리지는 않습니다.
하지만 나중에 해야 할 작업은 문서 변경과 같은 커밋에 남습니다. 데이터베이스를 커밋한 직후 서버가 종료돼도 워커가 처리할 작업 행이 있습니다.
반대로 트랜잭션이 롤백됐는데 알림이나 검색만 먼저 바뀌는 순서도 만들지 않습니다.
문서 저장과 후처리를 완전히 동시에 끝내지는 않지만, 후처리 작업을 잃지는 않는 구조입니다.
성공과 실패 사이에도 종류가 많았습니다
편집 API의 결과를 200과 500 두 가지로만 나누지 않았습니다.
- 본문과 문서 요약이 모두 같으면
NoChanges - 편집 중 문서가 이동했으면
Renamed - 3-way merge에서 같은 부분이 겹치면
Conflict - merge가 끝난 뒤 본문이 다시 바뀌었다면 재시도
- 새 리비전과 나중에 할 작업까지 커밋되면
Success
Conflict 응답에는 현재 리비전 ID와 현재 본문, 충돌 표시가 포함된 병합 결과를 넣습니다.
사용자가 왜 저장되지 않았는지도 모른 채 처음부터 다시 쓰게 하고 싶지 않았습니다. 어느 상태와 충돌했는지 보여주고 편집기에서 이어서 해결할 수 있게 했습니다.
성공 응답은 PostgreSQL 커밋 이후에만 보냅니다. 검색과 알림이 끝났다는 뜻은 아니지만, 최소한 그 작업들이 언젠가 실행될 수 있도록 데이터베이스에 등록됐다는 뜻입니다.
저장은 빨라졌지만 정리할 숙제도 남았습니다
R2 읽기와 쓰기, 병합과 diff를 대부분 트랜잭션 밖으로 옮기면서 문서 잠금 시간은 줄었습니다. 저장 직전에는 권한과 최신 리비전을 다시 확인해 오래된 상태로 커밋하지 않게 했습니다.
대신 다음 비용이 남습니다.
- 준비와 DB 커밋 사이에 실패하면 참조되지 않은 R2 객체가 생길 수 있습니다.
- 파일만 바뀐 리비전인지 확인하는 드문 경로에서는 잠금 안에서 R2를 다시 읽습니다.
- PostgreSQL 작업 큐가 밀리면 문서는 저장됐지만 검색과 역링크 반영이 늦을 수 있습니다.
- 실패한 후처리 작업을 운영자가 보고 다시 실행할 수 있어야 합니다.
저장 버튼 하나를 완벽한 단일 연산처럼 포장하지는 않았습니다.
대신 사용자에게 보여줄 문서 상태는 한 트랜잭션으로 확정하고, 나중에 처리할 수 있는 일은 작업으로 남기며, R2와 DB 사이에서는 복구하기 쉬운 실패를 선택했습니다.
이 흐름 안에서 동시 편집이 발견되면 3-way merge가 들어갑니다. 다음 글에서는 제가 그 기능을 위해 Rust 라이브러리까지 직접 만들게 된 과정과, 서로 다른 문단의 수정은 자동으로 합치면서 실제 충돌만 사용자에게 돌려주는 방법을 이야기해보겠습니다.