Stripe를 떠나 Rust로 초고속 지식 베이스 Outcrop을 만들며, Zanzibar식 권한, tantivy 기반 검색, ProseMirror의 Rust 포팅, Solid 통합 등 설계와 구현 과정을 공유합니다.
러스트로 정말 더 빠른 노션을 만들었습니다8개월 전, 저는 Stripe를 그만두고 지식 베이스를 만들기 시작했습니다. 그전까지는 몇 년 동안 이 충동을 꾹 눌러왔죠. 더 빠른 대안을 만들려는 시도는 많이 있었고, 그중엔 제가 만든 것도 있었습니다. 하지만 그 어떤 것도 목표에 제대로 다가가지 못했다고 생각합니다.
더 나은 지식 베이스를 만든다는 건 무엇일까요? 회사를 떠날 즈음, 저는 여러 팀을 오가며 지식 시스템을 몇 개 만들었고, 몇 개는 유지보수했고, 아주 많은 문서를 읽었습니다. 제가 다닌 회사 중 이걸 제대로 해낸 곳은 Stripe가 처음이었습니다. 자체 내부 지식 베이스를 만드는 전담 팀이 있었고, 검색이 다른 서비스와의 간극을 메워줬습니다.
결국 필요한 건 속도와 단순함입니다. 팀은 하나 이상의 스페이스를 소유했고, 어디에 무엇이 있는지 일일이 외울 필요가 없었죠. 빠른 검색으로 바로 찾을 수 있으니까요. 문서가 오래되면 팀에 작업이 자동으로 할당되었습니다.
타이밍도 더할 나위 없었습니다. Linear는 제품 관리에 대해 이미 해답을 내놨습니다. 많은 문서화 도구들이 좋은 제품 만들기를 포기하고 챗 인터페이스와의 경쟁으로 초점을 옮겼습니다. Atlassian은 Data Center 제품을 종료하고 있는데, 이는 그들의 가장 큰 고객을 단숨에 데려올 일생일대의 기회죠. 유럽에서는 데이터 레지던시 규제가 더 엄격해지고 있고, 아일랜드 회사라는 점도 도움이 됩니다.
어떻게 하면 더 단순한 제품을 만들 수 있을까요? 직관과 반대로, 훨씬 더 복잡한 것을 만드는 것부터 시작해야 합니다. 데이터베이스에 테마 하나 씌우고 끝냈다고 하기 쉬운데, 그러면 다른 도구들이 겪는 확장성 문제를 그대로 다시 겪게 됩니다.
이건 당신이 좋아하는 언어가 해결해줄 문제가 아닙니다. 저도 처음엔 일주일 만에 데이터베이스에 테마를 감싸 올렸습니다. Go를 썼고, 꽤 괜찮은 언어죠. 하지만 몇 주 지나자 애플리케이션 코드보다 코드를 생성하는 코드를 더 많이 쓰고 있었습니다. 지식 베이스에는 구성 요소가 많고, 그 모든 보일러플레이트를 다 직접 쓸 시간은 없습니다. 점진적으로 복잡해지는 시스템을 만든다면, 서비스 간 호환이 끊긴 시점을 자동으로 알려줄 수단이 있어야 합니다. 이때쯤 저는 사용자가 실시간으로 협업할 수 있어야 한다고 결정했고, 검색 지연에도 만족하지 못하고 있었으며, 누가 무엇을 할 수 있는지 확인하려고 매번 데이터베이스를 조회하는 일도 지겨워지고 있었습니다. 바로 여기서 당신이 좋아하는 언어가 등장합니다.
const (
CreateSpaceMethod = "POST"
CreateSpacePath = "/v1/spaces"
)
type CreateSpaceRequest struct {
Name string `json:"name" binding:"required"`
}
type CreateSpaceResponse = query.Space
func (r *Router) CreateSpace(c *gin.Context, req CreateSpaceRequest) (int, CreateSpaceResponse, error) {
// [...]
}
모든 것을 Rust로 다시 쓰는 데는 그리 오래 걸리지 않았습니다. 수제 코드 생성 부분을 매크로 크레이트로 교체하면서 오히려 코드 줄 수가 줄어들었습니다. 글자 수프처럼 보이던 것이 이제는 더 읽기 쉬운 utoipa 호출 한 줄로 바뀌었죠. 생태계가 가장 크지는 않지만, 삶을 훨씬 편하게 만들어주는 기특한 크레이트들이 있습니다.
#[utoipa::path(get, path = "/v1/spaces", responses((status = OK, body = Vec<model::Space>)))]
pub async fn list_spaces() -> Result<(StatusCode, Json<Vec<model::Space>>)> {
// [...]
}
Rust가 제 도구가 되고 나니, 이제는 꿈이라도 꿀 수 있고 밤새워 현실로 만들 수 있는 요소들이 아주 많아졌습니다. 한동안 Google의 Zanzibar 권한 시스템 글을 읽어왔는데, 매 호출마다 데이터베이스를 조회해서 사용자가 요청한 페이지에 실제로 접근 가능한지 확인하는 일에 질려가고 있었습니다. Google 아키텍처에서 영감을 받은 오픈 소스 구현들이 이미 있었지만, 다소 압도적이었죠. 새 Docker 컨테이너를 띄우는 건 간단하지만, 이제 문서화나 지원이 충분치 않을 수도 있는 서비스를 유지보수하고 디버깅해야 하는 부담을 떠안게 됩니다.
Zanzibar의 개념은 매력적입니다. 권한 시스템을 일반적인 데이터베이스 쿼리와 애플리케이션 코드에서 분리해 추상화하는 것이죠. 저는 그것의 더 작은 버전을 만들기로 했습니다. 분산형은 아니고, Postgres에 영속화하되 시작 시 메모리에 적재합니다. 복잡한 설정 언어도 없습니다. 이미 제 엔터티는 제가 잘 알고 있으니까요. 관계는 코드 바로 옆의 csv 파일로 정의합니다.
Scope, Role, Action, Object
space, owner|admin, read|create|update|list|delete, space
page, viewer, read|list, page|comment
권한은 상속됩니다. 특정 스페이스에 접근 권한이 있는 팀의 멤버라면 곧바로 그 스페이스에 접근할 수 있습니다. 서비스는 매크로 한 줄로 권한 시스템을 호출할 수 있습니다.
grant!(user_id, Editor, Page(page_id));
must!(user_id, Page(page_id), Update);
크게 최적화하지 않았는데도, 사용자가 리소스에 접근할 권한이 있는지 확인하는 데 나노초가 걸립니다. 사용자나 팀이 접근할 수 있는 모든 리소스를 나열하는 데는 밀리초 단위가 걸리고요.
지식 베이스의 가치는 검색 엔진의 품질만큼입니다. 저는 한동안 tantivy의 성공을 지켜봤습니다. 데모는 충분히 설득력이 있었죠. 더 쉽게 접근하는 방법도 있고, 당신이 좋아하는 도구들은 다들 비슷한 방식을 씁니다. 하지만 그러면 너무 느렸을 겁니다. 모두와 똑같은 선택을 하고 있다면, 다른 수백 개보다 당신의 방식이 정말 더 낫다고 할 수 있을까요? 제 검색 엔진은 준비됐습니다. 곧이어 whatlang을 부분적으로 사용한 언어 감지와 다국어 토크나이징을 추가했습니다.
큰 기대를 하진 않았는데, 타이핑하는 대로 체감할 지연 없이 결과가 눈앞에 나타났습니다. 권한 시스템까지 제 통제 아래 있으니 한 발 더 나아가 검색과 통합했습니다. 엔진의 코어에서는 사용자가 접근할 수 있는 리소스만 고려됩니다. 이것들은 매끈하게 동기화됩니다.
이 두 통합 시스템을 만드는 데 시간이 조금 더 걸렸을 수도 있지만, 다른 회사들의 경험담을 읽어보니 수년치 두통을 아낀 셈이라고 생각합니다.
예전에도 prosemirror로 이것저것 해봤고, 이번에도 명백한 선택지였습니다. 함께 쓸 수 있는 협업 플러그인이 많습니다. 바로 이때, 러스트라는 선택이 드디어 발목을 잡을지도 모른다는 생각을 했습니다. 프로젝트 자체에는 잘 검증된 협업 프리미티브가 있지만, 자바스크립트로 되어 있어 쓸 수 없었죠. 몇 가지 대안이 있다는 건 알았지만, 덩치가 크고 문서가 커지거나 사용자가 늘수록 느려졌습니다. 그렇게 거대한 프로젝트를 러스트로 다시 쓰는 건 무모해 보였죠.
사용자 편집은 단계(step) 목록으로 기록되어 prosemirror에 전달되고, 원본 문서와 충돌을 비교합니다. 그 후 새 버전 해시가 붙은 업데이트된 문서가 생성되어 영속화됩니다. 문제를 우회하기 위해, 클라이언트에서 스텝을 적용한 뒤 전체 문서를 스텝과 함께 서버로 보냈고, 서버는 이를 다른 연결된 사용자에게 전달했습니다. 겉보기엔 잘 동작했습니다. 모든 것이 실시간으로 일어나고 인터넷이 충분히 빠르면 아무도 눈치채지 못하죠. 하지만 이는 누구나 무엇이든 보내서 문서 전체를 바꿔치기할 수 있음을 의미했습니다. 또한 불필요한 지연을 만들고, 인터넷이 잠시 느려지면 랙이 생길 가능성도 있었습니다. 몇 글자 입력할 때마다 스텝이 생성되고 결국 새 문서가 만들어지는 특성상 이상적이지 않았습니다. 훌륭한 권한과 검색 시스템을 만들어 놓고도, 이 부분은 한 단계 퇴보한 듯했습니다.
prosemirror를 러스트로 다시 쓰는 게 무모한 일일까요? 마침 이 작업을 Go로 이미 해둔 사람이 있다는 걸 알게 됐습니다. 으. 그 주의 나머지 시간은 백엔드에서 스텝을 처리하려고 quickjs나 v8을 붙이는 데 보냈습니다. 버그가 있긴 해도 쓸 만했지만, 늘어난 복잡성이 가치 있다고 느끼지 못했습니다. 할 때가 됐습니다. 그다음 주는 prosemirror를 테스트와 수천 개의 호환 스냅샷까지 포함해 러스트로 포팅하는 데 쏟았습니다. 그때는 집을 거의 나가지도 않았던 것 같습니다.
#[test]
fn slice_can_cut_half_a_paragraph() {
let original = doc!(p!("hello world"));
let expected = doc!(p!("hello"));
let result = original.slice(0, Some(6), None).unwrap();
assert_eq!(result.content, *expected.content());
assert_eq!(result.open_start, 0);
assert_eq!(result.open_end, 1);
}
직접 해보기 전엔 얼마나 좋은지 알기 어렵습니다. 이 경우에는 문서 편집을 적용하는 데 고작 마이크로초가 걸리게 됐습니다.
그때는 크게 생각하지 않았지만, 시간이 지나고 나서야 이것이 지닌 잠재적 적용처를 깨달았습니다. 검색 엔진에 넣기 전 문서에서 텍스트 본문을 추출하기가 훨씬 쉬워졌고, 링크나 멘션을 추출하기도 쉬워졌고, 탭 자동완성도 더 간단해졌습니다. 대형 언어 모델에게 구조화된 문서의 편집을 온전히 맡길 수는 없지만, 충돌을 즉시 점검하고 해결할 수 있다면 제안의 절반이 구조를 깨도 상관없습니다. 탭 완성이나 구조화된 제안을 추가하는 것은 지금 당장 최우선 과제는 아니지만, 이제는 최소한 가능해졌습니다.
그 작은 게(crab)는 꽤 믿음직스럽습니다. 수만 줄의 코드 속에 파묻혀 일할 때 이게 가장 중요하다고 생각합니다. 몇 달 뒤 새벽 두 시에야 깨닫는 것보다, 지금 당장 뭐가 망가졌는지 알려주는 편이 낫죠. 다시 한 번 utoipa 덕분에, 실시간 메시지가 프런트엔드와 동기화되어 TypeScript까지 신뢰를 이어갈 수 있습니다.
fn create_docs() -> OpenApi {
OpenApiBuilder::new()
.info(Info::new("live", "1.0.0"))
.components(Some(
ComponentsBuilder::new()
.schema_from::<Command>()
.schema_from::<CommandReply>()
.schema_from::<MsgContent>()
.build(),
))
.build()
}
에디터는 텍스트 그 이상입니다. 그 자체로 작은 앱이죠. 이제는 잘 알려졌듯이, react는 prosemirror와 궁합이 좋지 않습니다. 억지로 우회할 수도 있지만, 이번에도 다른 길을 택할 수 있습니다. 저는 solid를 꽤 좋아합니다. 안타깝게도 생태계가 아직 UI 전체를 만들 만큼 크진 않습니다. 하지만 prosemirror와 통합해 렌더링 사이클을 맡기기엔 충분히 견고합니다. 간접층이 사라지면 만들 수 있는 것이 아주 많아집니다. 시간 말고는 저를 막는 게 없습니다. 고급 다이어그램, 플롯, 매크로, 변수, 캔버스, 그리고 어떤 에디터도 감히 꿈꾸지 않았던 모든 것들까지요. 지식 베이스는 단지 글에 그치지 않을 수 있습니다. 스레드, 몇 장의 슬라이드, 심지어 스프레드시트도요.
도구는 워크플로에 관한 것입니다. Linear는 그것을 알아냈습니다. 그리고 워크플로는 구조에 관한 것이죠. 문서는 만료되어야 합니다. 끊어진 링크와 오류를 린트해야 합니다. 지식 도구는 작업 관리 도구와 동기화되어야 합니다. 이건 쉬운 과제들입니다. 우리는 구조의 경계를 훨씬 넘어설 수 있는 시대에 살고 있습니다. 링크가 죽진 않았더라도, 이 시점엔 의미론적 관련성을 잃었을 수 있습니다. 최근 코드 변경으로 다이어그램이 최신이 아닐 수도 있습니다. 언어 모델이 당신의 글을 대신 써주진 않지만, 워크플로를 밀어줄 수는 있습니다.

해야 할 일이 아직 많습니다. 몇 가지 데모가 포함된 마케팅 페이지를 만들었으니 구경해 보세요. 대기자 명단에 합류할 수도 있습니다. 그리고 이 제품을 쓸 자신이 있다면, 좌석을 선주문해 제 작업을 후원하는 것도 고려해 주세요.
출시는 앞으로 6개월 안을 목표로 하고 있습니다. 좌석당 가격은 약 €/$10가 될 겁니다. Stripe 결제 링크를 만들어 두었고, 선주문 금액의 두 배를 크레딧으로 드립니다.
질문이나 아이디어가 있다면 outcrop.app의 imed로 연락하실 수 있습니다.
감사합니다!
Imed
2025년 10월 28일