sevenwiki - The Knowledge Infrastructure
Building a modern foundation for collaborative knowledge
A collaborative knowledge platform built as modern wiki infrastructure, covering backend architecture, content parsing, permission systems, search, and deployment.
소비하던 위키를 직접 만들기까지
고등학생 시절 저는 매일같이 자기 전 침대에 누워 나무위키를 읽고는 했습니다. 그날 저녁에 갑자기 떠오른 흥미로워 보이는 생물들, 이를테면 복어라든가 해파리 같은 것들에 대해 검색하고, 거기서 다시 연결된 문서들을 하나씩 따라가며 읽는 것이 꽤 큰 즐거움이었습니다.
나무위키에는 정말 많은 정보가 있었습니다. 단순히 양이 많다는 것뿐만 아니라, 그 정보들이 생각보다 읽기 쉽고, 때로는 이상할 정도로 흥미롭게 정리되어 있다는 점이 좋았습 저는 그 점이 꽤 놀랍고, 어떻게 보면 조금 낭만적이라고 느꼈습니다. 특별한 보상이 있는 것도 아니고, 누가 시킨 것도 아닌데, 사람들이 자신이 아는 것을 남기고, 고치고, 이어 붙이면서 하나의 공간을 함께 만들어 간다는 점이 인상적이었습니다. 위키는 단순히 정보를 읽는 곳이라기보다, 사람들이 지식을 쌓아 올리는 방식 그 자체처럼 보였습니다.
시간이 지나 개발을 시작한 뒤에도 그 인상은 계속 남아 있었습니다. 다만 이제는 위키를 단순한 글 모음으로만 보지는 않게 되었습니다. 수많은 사람이 동시에 문서를 만들고 수정할 수 있으려면, 그 뒤에는 생각보다 복잡한 구조가 필요했습니다. 문법을 해석하는 파서, 편집 내역을 보존하는 리비전 시스템, 문서 간 연결을 관리하는 구조, 권한과 차단, 검색과 캐시, 그리고 이 모든 것을 안정적으로 운영하기 위한 백엔드 아키텍처가 있어야 했습니다.
sevenwiki는 그때 제가 느꼈던 위키의 매력을, 이번에는 직접 시스템으로 만들어 보고 싶다는 생각에서 시작한 프로젝트입니다. 예전에는 그저 문서를 읽는 사용자였다면, 이제는 그런 지식의 공간이 실제로 어떤 구조 위에서 움직이는지 직접 설계하고 구현해 보고 싶었습니다.
작은 웹 프로젝트에서 시작한 위키
당시의 저는 웹을 깊게 다뤄 본 개발자가 아니었습니다. FastAPI로 간단한 API 서버를 만들고, Next.js로 커뮤니티 비슷한 화면을 붙여 본 정도였습니다. 회원가입, 게시글, 댓글, 간단한 권한 처리 같은 건 해봤지만, 그게 오래 유지될 수 있는 구조인지, 많은 사용자를 감당할 수 있는 아키텍처인지는 생각해본 적이 없었습니다.
그전까지의 관심사는 오히려 게임 개발 쪽에 가까웠습니다. Vulkan, OpenGL, SDL 같은 걸 기웃거렸고, 디스코드 봇을 만들며 서버나 API 개념을 조금씩 접했습니다. 웹 토이 프로젝트를 몇 번 거치면서 웹으로도 꽤 많은 걸 만들 수 있겠다는 감각이 생겼습니다.
당시에는 AI에게 코드를 물어보며 개발하기도 애매했습니다. 2022년 말의 ChatGPT는 코드베이스 전체를 같이 읽고 구조를 고민하는 도구로 쓰기에는 한계가 있었습니다. 무언가를 만들고 싶으면 결국 직접 찾아봐야 했습니다. 공식 문서를 읽고, 블로그 글을 뒤지고, 예제 코드를 따라 치고, 잘 안 되면 지우고 다시 쓰는 식이었습니다.
sevenwiki의 시작도 그 연장선에 있었습니다. “현대적인 위키 시스템을 만들겠다” 같은 목표가 있었던 건 아닙니다. 그냥 머릿속으로 상상하던 위키의 모습을 웹 위에 만들어 보고 싶었습니다.

처음 선택한 건 Next.js였습니다. 당시 React 기반 웹 서비스를 만들 때 가장 많이 쓰이던 선택지였고, 이미 토이 프로젝트에서 써본 경험이 있었습니다.
처음 일주일은 거의 UI만 만들었습니다. 문서 페이지 레이아웃, 사이드바 위치, 제목과 본문 간격, 최근 변경 목록 같은 것들을 하나씩 잡았습니다. 지금 보면 초보적인 화면이었지만, 입력한 글이 위키 문서처럼 보인다는 것만으로 충분했습니다.
처음에는 화면을 만드는 게 위키를 만드는 일의 대부분이라고 생각했습니다. 문서를 저장하고, URL로 접근하고, 수정할 수 있으면 위키처럼 보일 거라고 생각했습니다.
나무위키 문서를 직접 뜯어보면서 그 생각이 틀렸다는 걸 알았습니다. 화면보다 까다로운 건 본문 데이터 자체였습니다. 텍스트 안에 제목, 링크, 목록, 접기, 틀, 각주 같은 문법들이 복잡하게 얽혀 있었습니다. 나무위키의 글은 단순한 문자열이 아니라, ‘나무마크’라는 문법 체계로 파싱해야 하는 대상이었습니다.
나무마크는 구현하는 입장에서 불편한 부분이 많았습니다. 어떤 문법은 너무 특수했고, 어떤 문법은 예외가 많았으며, 단순해 보여도 실제로 파싱하려 하면 구조가 쉽게 꼬였습니다. 그래서 나무마크를 그대로 따라가기보다는, 더 일관된 방식의 위키 문법을 직접 만들어보기로 했습니다.
사용자가 작성한 텍스트를 그대로 화면에 보여주는 것만으로는 부족했습니다. [[문서]] 같은 링크는 실제 문서로 연결되어야 했고, 제목 문법은 목차와 연결되어야 했으며, 인용문이나 목록, 코드 블록 같은 요소들도 각각의 의미에 맞게 렌더링되어야 했습니다. 결국 위키를 만들려면 문서를 단순한 텍스트가 아니라, 하나의 문법으로 해석해야 했습니다.
처음에는 정규식을 써서 문자열을 일괄 변경하는 정도로 끝낼 수 있을 줄 알았습니다. 링크 문법은 <a> 태그로, 제목은 <h2>로 바꾸는 식이면 충분해 보였습니다.
하지만 실제로 구현을 시작하자 금방 한계에 부딪혔습니다. 위키 문법은 단순한 문자열 치환으로 다룰 수 있는 구조가 아니었습니다.
가장 큰 문제는 문법의 중첩이었습니다. 굵은 글씨 안에 링크가 들어가고, 그 링크 안에 다시 다른 서식이 섞이거나, 접기 상자 블록 안에 목록과 인용문이 수십 줄씩 들어가는 구조는 단순한 정규식으로 처리할 수 없었습니다. 앞뒤 맥락을 고려하지 않고 무작정 태그를 바꾸다 보면 HTML 구조가 깨지거나 태그가 닫히지 않는 오류가 계속 발생했습니다.
결국 단순한 텍스트 변환이 아니라, 전체 문자열을 토큰 단위로 쪼개고 이를 트리 구조로 파싱하는 접근이 필요하다는 걸 알게 되었습니다. 웹 화면을 만드는 UI 구현으로 시작했던 작업이, 텍스트를 해석하는 파서(Parser) 개발이라는 전혀 다른 레이어의 문제로 바뀌는 시점이었습니다.
{{{ }}} 블록에서 시작한 sevenmark 문법
나무마크를 살펴보면서 가장 먼저 눈에 들어온 것은 {{{ }}} 형태의 블록 문법이었습니다. 나무마크에서는 리터럴 블록이나 위키 스타일처럼, 일반 텍스트와 구분되는 특수한 영역을 표현할 때 이런 블록 문법이 자주 사용됩니다. 문서 안에서 “여기부터는 일반적인 문장이 아니라 별도의 의미를 가진 영역이다”라는 점이 분명하게 드러난다는 점에서, 저는 이 방식이 꽤 마음에 들었습니다.
하지만 동시에 마음에 들지 않는 부분도 분명했습니다. 특히 테이블 문법은 처음 봤을 때부터 꽤 지옥처럼 느껴졌습니다.
나무마크에서 표를 작성하면 대략 이런 형태가 됩니다.
||<-2><tablealign=right><tablewidth=400><tablebgcolor=white,white><tablecolor=black,black> '''{{{+1 제목}}}[br]Subtitle''' ||
||<-2>{{{#!wiki style="margin: -5px -10px"
[[파일:example.png|width=100%]]}}} ||
||<width=25%> '''항목''' || 내용 ||
|| '''링크''' || [[문서명|표시될 텍스트]] || 우선 사용자 입장에서 ||를 구분자로 쓰는 것부터 마음에 들지 않았습니다. 마크다운을 쓸 때도 늘 느끼던 불편함이었지만, 하나의 행 안에서 || 기호만으로 모든 셀을 구분하면 텍스트가 길어질수록 가독성이 최악으로 떨어집니다. 행과 열의 정렬이 한눈에 들어오지 않으니, 내가 지금 채우고 있는 칸이 정확히 어떤 칸인지, 어떤 셀과 병합되는지 파악하기가 어려워집니다.
게다가 이 문법은 셀 병합(<-2>), 전체 정렬(<tablealign=right>), 너비 속성, 배경색과 글자색 옵션에 더해 내부 스타일 블록({{{#!wiki style="..."}}})과 이미지, 링크 문법까지 한 줄 안에 전부 구겨 넣는 방식이었습니다.
작성할 때 여러 문법을 동시에 기억해야 하는 것도 부담이었지만, 진짜 문제는 파서를 구현할 때 터졌습니다. 한 줄 안에서 테이블 속성, CSS 스타일, 인라인 문법이 마구 얽혀 나타나다 보니, 파서 입장에서는 “지금 읽어 들인 이 기호가 테이블 구조의 일부인지, 스타일 블록의 경계인지, 아니면 단순 텍스트 콘텐츠인지”를 매 순간 모호함 없이 판별해 내야 했습니다.
저는 이 부분을 조금 더 일관된 방식으로 바꿔 보고 싶었습니다.
나무마크를 그대로 따라가기보다는, {{{ }}} 블록이라는 구조는 유지하되 그 안의 문법을 더 명확하게 정리하고 싶었습니다. 예를 들어 표라면 {{{#table ... }}}, 목록이라면 {{{#list ... }}}, 이미지라면 {{{#image ... }}}처럼 블록의 시작 부분에서 이 문법이 무엇을 의미하는지 바로 드러나도록 하는 방식입니다.
그리고 세부 옵션은 모두 #key="value" 형태의 인수로 통일하고 싶었습니다. #size="3", #color="red", #style="...", #width="300" 같은 인수들을 블록 앞부분에 선언하고, 그 뒤에 실제 내용을 작성하는 구조입니다. HTML 태그의 속성처럼, 문법의 종류와 옵션이 한눈에 보이도록 만들고 싶었습니다.
예를 들어 스타일이 적용된 안내 블록은 이런 식으로 작성할 수 있기를 바랐습니다.
{{{
#type="notice"
#size="+1"
#color="#2f6fed"
#align="center"
#style="margin: -5px -10px; padding: 8px 12px; background: linear-gradient(to right, #f8fbff, #e8f0ff); border-radius: 8px;"
sevenmark 문법 테스트용 블록입니다.
}}} 표 문법도 같은 방향으로 정리할 수 있을 것이라고 생각했습니다.
{{{
#table="true"
#style="border-collapse: collapse; width: 100%;"
#caption="sevenmark table example"
[[제목]][[설명]][[상태]]
[[#x="2" 병합된 셀]][[완료]]
[[#align="right" 합계]][[3개 항목]][[정상]]
}}} 이 방식이 완벽한 답은 아닙니다. 하지만 블록은 {{{ }}}로 감싸고, 블록의 종류는 이름으로 구분하고, 세부 설정은 #key="value" 형태로 표현하는 방향성은 분명했습니다. 문서를 쓰는 사람에게는 규칙이 예측 가능하고, 구현하는 입장에서는 문법마다 완전히 다른 파싱 규칙을 두지 않아도 됐습니다.
처음에는 이 인수 부분을 정규식으로 잡아내려고 했습니다.
처음 시도한 정규식은 대략 이런 형태였습니다.
((?:#w+="w+"s*)+)s*(.*) 처음에는 이 정도로도 충분해 보였습니다. #size="+1"이나 #align="center" 같은 단순한 인수들은 그럭저럭 잡혔습니다.
CSS가 들어가기 시작하면 한계가 드러났습니다.
((?:#(?:.*?)="(?:.*?)"s*)+)s*(.*) 이렇게 바꾸면 더 많은 경우를 잡을 수는 있었습니다. 실제로 한동안은 정규식 테스트 사이트를 켜 놓고, 인수들이 어디까지 잡히는지, 본문은 어디서부터 분리되는지 계속 확인했습니다. Group 1에는 인수 목록이, Group 2에는 본문이 들어가도록 온갖 패턴을 실험했습니다.
{{{
#type="notice"
#size="+1"
#color="#2f6fed"
#align="center"
#style="margin: -5px -10px; padding: 8px 12px; background: linear-gradient(to right, #f8fbff, #e8f0ff); border-radius: 8px;"
sevenmark 문법 테스트용 블록입니다.
}}} #color="#2f6fed"에는 색상 코드의 #가 들어가고, linear-gradient(to right, ...)에는 괄호와 쉼표가 들어가며, margin: -5px -10px;에는 공백과 콜론, 세미콜론이 섞입니다. 정규식이 감당할 수 있는 입력이 아니었습니다.
더 큰 문제는 중첩이었습니다.
[[이것은 링크|{{{#color="red" 붉은 글씨로 표시됩니다.}}}]]
이런 중첩을 처리하려면 파서가 스택을 활용해서든 어떤 방식으로든 현재 파싱 위치의 context를 유지해야 합니다. 지금 보고 있는 {{{가 어느 깊이에서 열린 블록인지, 그 안에서 다른 문법이 시작됐는지, 닫는 기호가 나왔을 때 어떤 문맥을 닫아야 하는지를 상태로 기억해야 합니다.
정규식은 상태를 가질 수 없습니다. 그 구조를 추적하는 건 원천적으로 불가능했습니다.
결국 필요한 건 몇 개의 정규식으로 HTML을 흉내 내는 스크립트가 아니라, 중첩 구조와 문맥을 해석할 수 있는 독립적인 마크업 언어 파서였습니다.
마크업 언어를 만든다는 것
위키 문법을 처리하는 일은 점차 프로그래밍 언어를 만드는 과정과 닮아갔습니다. 텍스트를 읽어 의미 있는 단위로 쪼개고 구조를 해석한 뒤, 이를 다시 렌더링 가능한 형태로 변환하는 파서가 필요했기 때문입니다.
오히려 어떤 면에서는 마크업 언어가 일반적인 프로그래밍 언어보다 까다로웠습니다. 프로그래밍 언어는 엄격한 규칙에 맞춰 코드를 작성하게 강제하지만, 위키 문법은 사람이 편하게 글을 쓰는 도구여야 합니다. 문법이 너무 엄격하면 작성하기 불편하고, 너무 느슨하면 예외 처리가 비대해집니다. 결국 “사람의 자연스러운 작성 방식”과 “기계의 안정적인 해석” 사이에서 끊임없이 균형을 잡아야 했습니다.
이 문제를 체계적으로 해결하기 위해 한동안 기존 파서 도구들을 검토했습니다. ANTLR4, lark, lalrpop, pest.rs 같은 파서 생성기(Parser Generator)를 활용해 문법 파일(Grammar)을 정의하면, 정규식보다 훨씬 안정적으로 구조를 잡을 수 있을 것이라 기대했습니다.
하지만 막상 프로젝트에 적용하려고 하니 또 다른 한계에 부딪혔습니다.
sevenmark가 다루려는 문법은 일반적인 프로그래밍 언어처럼 토큰과 구문이 깔끔하게 나뉘는 형태가 아니었습니다. 마크다운 계열 문법은 문맥이 매우 중요했습니다. 같은 기호라도 어디에 등장하느냐에 따라 의미가 달라졌습니다. 줄의 시작에 있으면 제목이나 목록이 될 수 있지만, 문장 중간에 있으면 그냥 텍스트일 수 있었습니다. 코드 블록 안에서는 [[문서]]처럼 보이는 문자열도 링크가 아니라 그대로 출력되어야 했고, 스타일 블록 안에서는 다시 인라인 문법을 해석해야 했습니다. 테이블 안에서는 [와 ]가 셀을 나누는 기호가 될 수 있지만, 다른 곳에서는 전혀 다른 의미를 가질 수 있었습니다.
결국 파서는 단순히 “이 문자가 나오면 이 문법이다”라고 판단할 수 없었습니다. 지금 파서가 어떤 블록 안에 있는지, 현재 줄이 문서의 어느 위치인지, 이 영역이 리터럴인지 렌더링 대상인지, 내부 문법을 다시 해석해야 하는지 같은 상태를 계속 들고 있어야 했습니다. 앞에서 어떤 블록에 들어갔는지에 따라 뒤에 등장하는 기호의 의미가 바뀌었고, 어떤 문법은 닫히기 전까지 내부 해석 방식을 완전히 바꾸어야 했습니다.
이런 특성 때문에 파서 생성기를 사용해도 문법이 깔끔해지지 않았습니다. 오히려 문법 파일 안에 예외가 계속 늘어나거나, 결국 중요한 처리를 별도의 코드로 빼야 했습니다. 겉으로는 파서 생성기를 쓰고 있지만, 실제 복잡도는 여전히 제가 직접 들고 가는 상태가 되어 버렸습니다. 문법을 선언적으로 정의하려고 할수록, 마크업 특유의 느슨함과 문맥 의존성이 계속 발목을 잡았습니다.
그때부터 방향을 바꾸기 시작했습니다.
sevenmark는 파서 생성기로 한 번에 깔끔하게 처리할 수 있는 언어라기보다는, 직접 상태를 관리하면서 문서를 읽어야 하는 쪽에 가까웠습니다. 블록을 만나면 현재 상태를 바꾸고, 닫는 기호가 나오면 다시 이전 상태로 돌아가고, 내부에 들어온 텍스트는 그 문맥에 맞는 방식으로 다시 해석해야 했습니다. 이런 구조에서는 수동 파서나 파서 조합기 방식이 더 자연스럽다고 느꼈습니다.
수많은 시행착오 끝에 제가 얻은 결론은 단순했습니다. sevenmark의 파서는 문법 규칙을 한 번에 선언해 끝낼 수 있는 형태가 아니었습니다. 문서를 위에서 아래로 읽으면서, 현재 위치와 문맥, 중첩 상태를 계속 추적해야 했습니다. 정규식만으로도 부족했고, 일반적인 파서 생성기만으로도 충분히 깔끔하지 않았습니다.
결국 필요한 것은 sevenmark의 문법 구조에 맞춘 직접적인 파서였습니다. 문서 전체를 블록 단위로 나누고, 각 블록 안에서 다시 인라인 문법을 해석하며, 필요한 경우 재귀적으로 내부 내용을 파싱하는 구조가 필요했습니다. 이때부터 sevenmark는 단순한 HTML 변환기가 아니라, 명확한 파싱 단계와 렌더링 단계를 가진 하나의 작은 언어 처리 시스템으로 바뀌기 시작했습니다.
Rust와 파서 조합기
이때쯤 저는 주로 사용하던 언어를 Python과 TypeScript에서 점점 Rust로 옮겨 가고 있었습니다.
처음에는 단순히 성능 때문만은 아니었습니다. Rust는 타입으로 상태를 명확하게 표현하기 좋았고, 문자열을 다루는 과정에서도 어떤 값을 복사하고, 어떤 값을 참조로 유지할지 계속 의식하게 만들어 주었습니다. 파서처럼 입력 문자열을 계속 읽고, 나누고, 구조화된 결과로 바꾸는 코드에서는 이런 특성이 꽤 매력적으로 느껴졌습니다.
그래서 Rust 생태계에서 파서 관련 crate들을 찾아보기 시작했습니다. 그때 가장 먼저 눈에 들어온 것이 nom이었습니다.
nom은 파서 조합기 방식으로 파서를 작성할 수 있게 해 주는 라이브러리였습니다. 처음에는 “파서 조합기”라는 말 자체가 조금 낯설었습니다. 하지만 개념을 이해하고 나니, 제가 sevenmark에서 필요하다고 느끼던 구조와 꽤 잘 맞아 보였습니다.

#key="value" 형태의 인수 하나를 읽는 파서가 있고, 그런 인수들이 여러 개 이어진 인수 목록을 읽는 파서가 있고, 다시 그 인수 목록과 본문을 합쳐 하나의 {{{ }}} 블록을 읽는 파서가 있는 식입니다.
즉, 문법을 작은 조각으로 나눕니다.
parse_argument
-> #key="value" 하나를 읽는 파서
parse_argument_list
-> 여러 개의 argument를 읽는 파서
parse_block
-> {{{ ... }}} 형태의 블록을 읽는 파서
parse_inline
-> 링크, 강조, 취소선 같은 인라인 문법을 읽는 파서 이 방식은 정규식으로 모든 것을 한 번에 처리하려던 방식과는 완전히 달랐습니다. 정규식에서는 패턴 하나가 점점 커지고 복잡해졌지만, 파서 조합기에서는 각 문법을 작은 함수 단위로 나눌 수 있었습니다. 문제가 생기면 전체 정규식을 다시 해석하는 대신, 어떤 작은 파서가 잘못 동작하는지 확인하면 되었습니다.
예를 들어 이런 sevenmark 문법이 있다고 하면,
{{{
#type="notice"
#color="#2f6fed"
#style="padding: 8px 12px; border-radius: 8px;"
sevenmark 문법 테스트용 블록입니다.
}}} 이것을 하나의 정규식으로 처리하려고 하면 점점 복잡해집니다. 어디까지가 인수인지, 어디부터가 본문인지, 값 안에 들어간 CSS의 #, :, ;, (, ) 같은 문자를 어떻게 다룰지 계속 고민해야 합니다.
하지만 파서 조합기 방식으로 보면 단계가 조금 더 명확해집니다.
먼저 {{{를 읽습니다. 그다음 앞부분에서 #type="notice", #color="#2f6fed", #style="..." 같은 인수들을 하나씩 읽습니다. 인수 목록이 끝나면 본문을 읽고, 마지막으로 닫는 }}}를 확인합니다. 각 단계는 별도의 파서가 되고, 전체 블록 파서는 그 파서들을 순서대로 조합한 결과가 됩니다.
이 구조가 좋았던 이유는 sevenmark의 문법이 계속 바뀌고 있었기 때문입니다.
처음부터 완성된 문법을 가지고 있었던 것이 아니었습니다. #table을 어떻게 표현할지, 스타일 인수는 어디까지 허용할지, 링크 안에 스타일 블록을 넣을 수 있게 할지, 표 안의 셀 병합은 어떤 방식으로 표현할지 계속 바뀌고 있었습니다. 이런 상황에서 거대한 정규식이나 고정된 문법 파일에 모든 것을 밀어 넣으면, 문법을 수정할 때마다 전체 구조가 같이 흔들렸습니다.
반면 작은 파서들을 조합하는 방식은 변경에 조금 더 유연했습니다. 인수 문법을 바꾸고 싶으면 parse_argument를 고치면 되었고, 블록의 종류를 추가하고 싶으면 새로운 블록 파서를 만들면 되었습니다. 공통적으로 쓰이는 인수 파서나 인라인 파서는 여러 문법에서 재사용할 수 있었습니다.
물론 nom을 사용한다고 해서 모든 문제가 바로 해결된 것은 아니었습니다.
오히려 처음에는 다른 종류의 어려움이 생겼습니다. Rust의 타입, lifetime, 에러 타입, 입력 스트림을 다루는 방식은 처음 접하는 입장에서는 꽤 낯설었습니다. 작은 예제에서는 잘 동작하던 코드도 실제 sevenmark 문법에 적용하면 금방 복잡해졌습니다.
특히 어려웠던 것은 “실패”를 다루는 방식이었습니다.
파서에서 실패는 항상 오류가 아닙니다. 예를 들어 현재 위치에서 링크 문법을 읽으려고 했는데 [[가 없다면, 그것은 문서가 잘못된 것이 아니라 그냥 링크가 아닌 일반 텍스트일 수 있습니다. 이런 경우에는 다른 파서를 시도하거나, 해당 부분을 텍스트로 처리하면 됩니다.
반대로 {{{로 블록을 열어 놓고 끝까지 닫는 }}}가 나오지 않는다면, 그것은 실제 문법 오류에 가깝습니다. 이 경우에는 조용히 일반 텍스트로 넘기기보다는, 파서가 명확히 오류를 반환해야 합니다.
즉, 파서는 단순히 성공과 실패만을 구분하는 코드가 아니었습니다. 어떤 실패는 정상적인 분기이고, 어떤 실패는 복구 가능한 오류이며, 어떤 실패는 문서 전체를 잘못된 상태로 만드는 오류였습니다. 이 차이를 코드로 표현하는 일은 생각보다 중요했습니다.
그럼에도 nom을 사용하면서 파서를 바라보는 방식은 훨씬 정리되었습니다.
sevenmark 문법은 더 이상 “문자열에서 특정 패턴을 찾아 HTML로 바꾸는 규칙들”이 아니었습니다. 입력을 읽고, 작은 문법 단위로 나누고, 그 결과를 조합해 더 큰 구조를 만드는 방식으로 생각하게 되었습니다. 이 관점은 이후 AST를 설계하고 렌더링 파이프라인을 나누는 데에도 큰 영향을 주었습니다.
그러다 시간이 지나면서 winnow를 사용하게 되었습니다.
winnow는 nom에서 갈라져 나온 파서 조합기 라이브러리였고, 제가 느끼기에는 sevenmark 같은 프로젝트에 더 잘 맞았습니다. 기본적인 접근은 비슷했습니다. 작은 파서를 만들고, 그 파서들을 조합해서 큰 문법을 읽습니다. 하지만 API가 더 정돈되어 있다고 느꼈고, 에러 처리나 파서 조합 방식도 제가 작성하던 코드와 더 잘 맞았습니다.
특히 sevenmark처럼 문법이 계속 변하고, 파서 내부에서 문맥을 자주 확인해야 하는 프로젝트에서는 코드의 가독성이 중요했습니다. 파서 코드는 한 번 작성하고 끝나는 코드가 아니었습니다. 새로운 문법을 추가할 때마다 다시 열어 보고, 기존 문법과 충돌하지 않는지 확인하고, 특정 예외를 어디서 처리할지 계속 고민해야 했습니다.
그래서 단순히 “동작하는 파서”를 만드는 것만으로는 부족했습니다. 나중에 다시 읽을 수 있고, 문법이 늘어나도 구조가 무너지지 않으며, 각 파서가 어떤 역할을 하는지 분명하게 드러나는 코드가 필요했습니다. 그런 점에서 winnow는 점점 더 자연스러운 선택이 되었습니다.
결국 sevenmark의 파서는 winnow 위에 자리를 잡기 시작했습니다.
처음에는 정규식 몇 개로 문법을 처리하려고 했습니다. 그다음에는 파서 생성기로 문법을 선언해 보려고 했고, 이후에는 nom을 통해 파서 조합기 방식으로 접근하기 시작했습니다. 그리고 여러 시행착오를 거치며, 최종적으로는 winnow를 중심으로 작은 파서들을 조합하는 구조에 정착하게 되었습니다.
이 변화는 단순히 사용하는 라이브러리가 바뀐 것이 아니었습니다.
처음의 질문은 “이 문자열을 어떻게 HTML로 바꿀까?”에 가까웠습니다. 하지만 nom과 winnow를 거치면서 질문은 점점 달라졌습니다.
“이 문서는 어떤 구조를 가지고 있는가?” “지금 이 문법은 어떤 문맥에서 해석되어야 하는가?” “파싱 결과를 어떤 노드로 표현해야 렌더링과 검증을 분리할 수 있는가?”
이 질문들이 sevenmark 파서의 방향을 결정했습니다. 그리고 그 다음 단계는 자연스럽게 AST였습니다. 문법을 작은 파서들로 읽어낼 수 있게 되었다면, 이제 그 결과를 단순한 HTML 문자열이 아니라 문서의 구조를 담은 중간 표현으로 남겨야 했습니다.
AST — 문서의 구조를 담는 중간 표현
파서가 어느 정도 자리를 잡기 시작했을 때, 처음에는 파싱 도중에 바로 HTML 문자열을 만들어 나가는 방식을 생각해 보기도 했습니다. 파서가 블록을 열면 <div>를 출력하고, 닫으면 </div>를 출력하는 식이었습니다. 실제로 이 방식을 sevenmark-lab이라는 이름의 실험 프로젝트에서 잠깐 시도했습니다. Visitor 패턴으로 각 AST 노드 타입마다 HTML 변환 메서드를 정의하는 구조였고, 구현 자체는 어렵지 않았습니다.
하지만 이 방식은 곧 한계에 부딪혔습니다.
목차는 문서 전체를 한 번 훑어서 헤더 구조를 파악한 뒤에야 만들 수 있었습니다. 각주도 마찬가지였습니다. 각주가 문서 어디에 정의되어 있든, 최종적으로는 번호가 매겨진 순서대로 페이지 하단에 모여야 했는데, 파싱과 동시에 HTML을 생성하면 각주 번호는 문서 전체를 다 보기 전까지는 결정할 수 없었습니다. include 문법은 더 복잡했습니다. 다른 문서의 내용을 현재 문서 안에 삽입하는 기능인데, 삽입되는 문서도 파싱되고 렌더링되어야 했습니다. 결국 파싱과 렌더링을 한 번의 패스로 처리하는 것은 불가능했습니다.
그래서 파서의 출력을 HTML이 아닌, 문서의 구조 그 자체를 담은 중간 표현으로 남기기로 했습니다.
sevenmark의 AST는 Element라는 열거형으로 표현했습니다. Rust의 열거형은 각 variant가 서로 다른 구조의 데이터를 담을 수 있는 tagged union에 가깝기 때문에, 여러 종류의 문법 요소를 하나의 타입으로 표현하는 데 자연스럽게 맞아 들어갔습니다.
pub enum Element {
Text(TextElement),
Bold(TextStyleElement),
Italic(TextStyleElement),
Header(HeaderElement),
Table(TableElement),
List(ListElement),
Include(IncludeElement),
Footnote(FootnoteElement),
// ... 40개 이상의 variant들
} 트레이트 객체(dyn Trait) 없이 완전히 평탄한 열거형 구조였습니다. 동적 디스패치 없이 컴파일 타임에 모든 variant를 알 수 있기 때문에, 패턴 매칭이 완전했고 match 처리 누락을 컴파일러가 잡아줬습니다.
모든 element는 최소한 Span { start: usize, end: usize }를 가집니다. 원본 문서에서 해당 element가 차지하는 byte offset 범위입니다. 이 정보는 단순히 텍스트 범위를 표시하는 것 이상으로 쓰였습니다. 나중에 LSP(Language Server Protocol)를 구현할 때, “커서가 지금 어떤 element 안에 있는가”를 판단하는 핵심 정보가 되었습니다. 또한 구조적인 element들은 open_span과 close_span을 추가로 가져서, {{{나 }}}처럼 구분자의 위치를 element의 내용과 별도로 추적했습니다. LSP에서 {{{와 내용 부분에 서로 다른 색상의 semantic highlight를 적용하기 위해서였습니다.
테이블 구조는 특히 설계하기 까다로웠습니다. sevenmark의 테이블은 바깥 블록, 행, 셀의 3단계 중첩이었고, 그 안에서 다시 {{{#if}}} 조건 블록이 행 수준이나 셀 수준에 들어올 수 있었습니다.
TableElement {
children: Vec<TableRowItem>
}
enum TableRowItem {
Row(TableRowElement),
Conditional(ConditionalTableRows), // if block at row level
}
TableRowElement {
children: Vec<TableCellItem>
}
enum TableCellItem {
Cell(TableCellElement { x, y, parameters, children: Vec<Element> }),
Conditional(ConditionalTableCells), // if block at cell level
} x, y 좌표는 셀 병합을 표현합니다. #x="2"이면 오른쪽으로 두 칸을 병합하는 식입니다.
파라미터 타입도 시간이 지나면서 바뀌었습니다. 처음에는 BTreeMap<String, Parameter>를 사용했습니다. 알파벳순 정렬이 자연스럽게 보장되었기 때문입니다. 그런데 나중에 포매터(formatter)를 만들 때 문제가 생겼습니다. 사용자가 {{{#style="..." #color="red" content}}} 라고 썼을 때, 포매터가 다시 출력하면 BTreeMap의 정렬 때문에 {{{#color="red" #style="..." content}}} 로 순서가 바뀌어 버렸습니다. 의미론적으로는 동일하지만 사용자가 원래 작성한 순서가 유지되지 않았습니다. 결국 IndexMap으로 교체해서 삽입 순서를 보존하도록 했습니다.
파라미터의 값 자체도 Vec<Element>였습니다. #style="**굵게**" 처럼 파라미터 값 안에 다시 sevenmark 인라인 문법이 들어올 수 있도록 설계했기 때문입니다. 파라미터 파싱도 재귀적이었습니다.
문법이 쌓여가다
nom 기반으로 파서를 만들기 시작한 것은 2024년 11월이었습니다. 처음에는 정말 기본적인 것들만 있었습니다. {{{ }}}로 블록을 감싸고 #key="value" 형태의 파라미터를 붙이는 기본 구조, 그리고 **bold**, *italic*, ~~취소선~~ 같은 마크다운 스타일의 인라인 텍스트 스타일이 전부였습니다. \x 형태의 이스케이프와 블록 주석도 초기에 추가됐습니다.
파라미터 시스템이 자리를 잡으면서, #key="value"뿐만 아니라 #key 단독으로 불리언 플래그처럼 쓰는 것, 그리고 || 구분자로 파라미터와 본문을 명시적으로 분리하는 것도 지원하게 됐습니다.
그다음 큰 과제는 테이블이었습니다.
{{{#table #style="border-collapse: collapse;"
[[
[[표 제목 1]]
[[표 제목 2]]
]]
[[
[[#x="2" 병합된 셀]]
]]
}}} 바깥 {{{ }}}가 테이블 전체를 감싸고, 그 안의 [[ ]]가 행을, 다시 그 안의 [[ ]]가 셀을 나타내는 3단계 중첩 구조였습니다. 같은 [[ ]] 기호가 문맥에 따라 링크 문법이 되기도 하고 테이블의 행/셀 구분자가 되기도 했습니다. 파서는 지금 어떤 블록 안에 있는지에 따라 같은 기호를 다르게 해석해야 했습니다.
그 이후로 include 문법이 추가됐습니다.
{{{#include #var1="값" 포함할문서제목}}} 다른 문서의 내용을 현재 문서 안에 삽입하는 기능입니다. [var(key)] 매크로로 포함되는 문서 안에서 변수를 참조할 수 있어서, 나무위키의 틀(template) 기능과 비슷하게 동작했습니다. 이 기능은 파서가 단독으로 처리할 수 없고 데이터베이스에서 해당 문서를 조회해야 했기 때문에, 파서 서버가 백엔드 DB와 연결되어야 했습니다.
그 뒤로 각주({{{#fn}}}), 카테고리, 리다이렉트 문법이 순서대로 자리를 잡았습니다. 문법 하나를 추가하면 파서와 렌더러 양쪽을 함께 손봐야 했고, 기존 문법과 충돌하지 않는지 확인하는 과정이 매번 반복됐습니다.
이 시기 ParseContext는 재귀 상태를 함수 인자로 전달하는 방식이었습니다. element_parser(input, recursion_depth, from_footnote)처럼 각 파서 함수가 현재 재귀 깊이와 각주 안에 있는지 여부를 인자로 받았습니다. from_footnote는 각주 안에서 또 다른 각주 파서를 비활성화하기 위한 불리언이었습니다. 나중에 winnow로 마이그레이션하면서 이 상태를 파서 입력 스트림 자체에 내장하는 방식으로 개선됩니다.
처음 올린 서버는 Python이었다
파서를 만드는 동안에도 백엔드는 함께 자라고 있었습니다.
처음 서버는 Python이었습니다. FastAPI로 라우트를 만들고, MongoDB를 붙이고, 계정과 문서 API를 하나씩 만들었습니다. 지금 보면 투박한 부분이 많지만, 당시에는 가장 빨리 “위키처럼 동작하는 것”을 만들 수 있는 선택이었습니다. Python은 손이 빨랐고, FastAPI는 제가 생각한 엔드포인트를 거의 그대로 코드로 옮길 수 있게 해주었습니다.
파서는 처음부터 별도 서버로 두었습니다. 파서는 Rust였고, 백엔드는 Python이었기 때문입니다. Python에서 Rust 코드를 직접 호출하려면 바인딩을 만들어야 했지만, HTTP로 호출하면 훨씬 단순했습니다. 백엔드는 원문을 파서 서버에 보내고, 파서 서버는 HTML과 링크 목록, include 대상 문서를 돌려줬습니다. 이 구조는 꽤 오래 살아남았습니다. 언어와 구현은 여러 번 바뀌었지만, “문법 해석은 별도 서비스가 맡는다”는 경계는 지금까지 이어졌습니다.
이 시기 데이터 모델은 MongoDB답게 한 문서 안에 많은 것을 품고 있었습니다. 문서 제목, 삭제 여부, 태그, ACL, 토론, 링크 목록, include 목록, 렌더링된 HTML 캐시, 그리고 모든 리비전이 하나의 document 안에 들어갔습니다. versions 배열은 그 문서의 전체 편집 이력이었습니다. 최신 리비전은 latest라는 인덱스로 가리켰습니다.
처음에는 이 방식이 자연스러웠습니다. 위키 문서 하나를 가져오면 그 안에 필요한 것이 거의 다 들어 있으니까요. 문서의 현재 내용도 있고, 이전 버전도 있고, 이미 렌더링해 둔 HTML도 있습니다. 하지만 시간이 지나자 이 편리함은 조금씩 불편함으로 바뀌었습니다.
리비전은 계속 늘어납니다. include 관계가 있는 문서의 캐시를 언제 무효화할지도 생각해야 합니다. 문서 하나에 너무 많은 책임이 들어가면, 작은 수정 하나에도 “이 필드가 누구와 연결되어 있지?”를 계속 떠올려야 했습니다. ACL도 마찬가지였습니다. 처음 ACL은 True 또는 그룹 목록이 들어 있는 배열로 표현됐습니다. 빠르게 만들기에는 괜찮았지만, 정책이 조금만 복잡해져도 무엇이 허용이고 무엇이 거부인지 읽기 어려워졌습니다.
그때는 아직 몰랐지만, 이 MongoDB 모델은 sevenwiki가 나중에 왜 관계형 데이터베이스로 옮겨가야 했는지 미리 보여주고 있었습니다. 문서와 리비전, 사용자와 권한, 링크와 include 관계는 서로 느슨한 JSON 덩어리라기보다 분명한 관계를 가진 데이터였습니다.
모든 걸 Rust 하나로
Python 백엔드와 Rust 파서를 함께 운영하는 구조는 한동안 잘 버텼습니다. 하지만 기능이 늘어날수록 마음 한쪽이 계속 불편했습니다. 타입 힌트는 있었지만, 제가 원하는 만큼의 안전망은 아니었습니다. 문서 리비전, 권한, 검색, 인증 같은 것들이 얽히기 시작하자, 잘못된 타입이나 빠진 필드를 런타임이 되어서야 발견하는 일이 점점 부담스러워졌습니다.
이미 파서는 Rust로 쓰고 있었습니다. 그렇다면 백엔드도 Rust로 옮기면 어떨까 하는 생각이 들었습니다. 그렇게 sevenwiki-api-legacy-1이 만들어졌습니다.
이때부터 데이터 모델도 크게 바뀌었습니다. 문서 하나 안에 배열로 들어 있던 리비전들이 별도 테이블로 분리됐습니다. 문서의 고정된 정보는 document_metadata에, 편집 이력은 document_revisions에 들어갔습니다. 일반 문서, 사용자 문서, 파일, 태그 같은 구분도 namespace라는 개념으로 들어왔습니다. 같은 namespace 안에서는 같은 이름의 문서가 두 개 생길 수 없게 DB 제약도 걸었습니다.
이 변화는 단순히 MongoDB에서 PostgreSQL로 바꿨다는 이야기가 아니었습니다. “문서”라는 하나의 덩어리를, 여러 관계로 나누어 보기 시작했다는 뜻이었습니다. 문서의 이름과 최신 리비전은 다르고, 리비전의 내용과 작성자는 다르고, 파일 문서와 일반 문서는 다릅니다. 이런 차이를 데이터베이스가 직접 알게 만들었습니다.
동시 편집 문제도 이때 제대로 눈에 들어왔습니다. 여러 사람이 같은 문서를 열고 편집하면, 나중에 저장한 사람이 먼저 저장한 사람의 내용을 덮어쓸 수 있습니다. 그래서 레거시 Rust 백엔드 안에 직접 3-way merge를 구현했습니다. 아직 지금의 threeway_merge 라이브러리처럼 Git의 xdiff를 감싸는 방식은 아니었지만, “문서를 저장한다”는 일이 단순히 INSERT 하나로 끝나지 않는다는 사실을 이때 배웠습니다.
검색도 커졌습니다. 한국어 검색을 잘하고 싶어서 OpenSearch에 Nori 분석기를 붙이고, 커스텀 Docker 이미지와 3노드 클러스터까지 구성했습니다. 기능만 보면 강력했습니다. 하지만 로컬에서 개발할 때도 검색 엔진 세 노드를 띄워야 한다는 사실은 금방 무겁게 느껴졌습니다. 개발 환경을 켜는 일 자체가 부담스러워지면, 좋은 도구도 프로젝트의 속도를 늦춥니다.
이 Rust 레거시 백엔드는 많은 것을 가르쳐주었습니다. 관계형 모델이 왜 필요한지, 검색 인프라는 어디까지 감당할 수 있는지, 동시 편집은 왜 조심해야 하는지. 동시에 한 가지 한계도 분명히 보여주었습니다. 언어를 Rust로 바꾸는 것만으로 구조가 자동으로 좋아지지는 않습니다. 경계를 어디에 둘지, 어떤 책임을 어떤 모듈이 가져야 할지 다시 설계하지 않으면, Rust 코드도 충분히 복잡해질 수 있었습니다.
V7 — 다시 짓는다는 것
여러 번 만들고 지우다 보니, 어느 순간부터는 기능을 더 붙이는 것보다 다시 짓는 편이 낫겠다는 생각이 들었습니다. 이전 백엔드는 분명 돌아가고 있었지만, 코드를 열 때마다 어디서부터 고쳐야 할지 잠깐 멈춰야 했습니다. HTTP 핸들러와 비즈니스 로직, 데이터베이스 쿼리와 에러 처리, 마이그레이션이 한데 섞여 있었습니다. 처음에는 빠르게 만들 수 있어서 좋았지만, 시간이 지나자 그 빠름이 그대로 빚처럼 돌아왔습니다.
그래서 V7을 시작했습니다.
재미있는 점은 V7도 지금의 모습으로 바로 태어나지는 않았다는 것입니다. 처음 흔적은 오히려 Python에 가까웠습니다. FastAPI로 설정과 세션, PostgreSQL DSN, Redis 같은 것들을 잡아보던 흔적이 먼저 있었고, 곧바로 Rust 서버가 옆에 생겼습니다. 그 Rust 코드도 처음에는 아직 마음이 완전히 정리되지 않은 상태였습니다. 의존성에는 MongoDB와 SeaORM이 같이 있었고, 초기 사용자 모델에는 MongoDB의 ObjectId가 들어 있었습니다.
하지만 오래 가지 않았습니다. 하루 남짓한 시간 안에 MongoDB는 빠지고, PostgreSQL과 SeaORM 마이그레이션이 들어왔습니다. 문서와 사용자, 리비전처럼 관계가 분명한 데이터를 다루는 위키에서 NoSQL의 자유로움은 그다지 큰 장점이 아니었습니다. 오히려 제가 원한 것은 자유보다 제약이었습니다. 같은 namespace 안에서 같은 문서명이 두 번 들어가지 않게 하는 제약, 리비전이 반드시 문서에 속하게 하는 제약, 스키마가 언제 어떻게 바뀌었는지 남기는 마이그레이션. 그런 것들이 필요했습니다.
이전에는 이런 제약을 코드가 기억해야 했습니다. V7에서는 데이터베이스와 타입 시스템이 같이 기억하게 만들고 싶었습니다.
그 마음이 가장 먼저 드러난 곳이 Cargo workspace였습니다. 처음부터 코드를 여러 crate로 나누었습니다. 서버 설정은 config, 요청과 응답 타입은 dto, DB 테이블은 entity, 마이그레이션은 migration, R2 같은 저장소 클라이언트는 storage, 실제 HTTP 서버는 server, 백그라운드 작업은 worker가 맡았습니다.
중요한 것은 폴더를 예쁘게 나누는 것이 아니었습니다. 경계가 컴파일러에게 보인다는 점이 중요했습니다. server는 dto와 entity를 알 수 있지만, entity는 서버의 사정을 몰라야 했습니다. DTO는 데이터베이스 테이블을 그대로 노출하지 않아야 했고, 워커와 서버가 주고받는 작업 페이로드는 같은 타입으로 공유되어야 했습니다. 사람이 조심해서 지키는 규칙은 언젠가 무너집니다. Rust의 crate 경계는 적어도 그 규칙을 실수로 넘을 때 빌드 실패라는 방식으로 알려줍니다.
이때부터 V7은 점점 “큰 서버 하나”라기보다, 각자 자기 일을 하는 작은 경계들의 묶음에 가까워졌습니다.
PostgreSQL은 문서와 리비전, 사용자와 권한처럼 오래 보존되어야 하는 사실을 맡았습니다. Redis도 하나로 쓰지 않고 둘로 나눴습니다. 세션은 실수로 지워지면 안 되기 때문에 noeviction 정책을 쓰는 세션 전용 Redis에 두고, 파싱 결과나 OAuth 임시 상태처럼 사라져도 되는 값들은 캐시 전용 Redis에 두었습니다. 처음에는 조금 유난스러워 보일 수도 있지만, 세션과 캐시는 실패했을 때의 의미가 다릅니다. 캐시가 날아가면 느려지고, 세션이 날아가면 사용자가 로그아웃됩니다.
검색은 OpenSearch에서 MeiliSearch로 옮겨갔습니다. OpenSearch에 Nori 분석기를 붙이고 3노드 클러스터를 띄우던 시절도 있었지만, 로컬 개발 환경에서부터 너무 무거웠습니다. 검색 엔진을 운영하는 일이 위키를 만드는 일보다 커지면 곤란했습니다. MeiliSearch는 덜 거창했고, 그 점이 오히려 좋았습니다.
sevenmark 파서는 여전히 별도 HTTP 서비스로 남았습니다. 처음 Python 백엔드가 Rust 파서 서버를 호출하던 구조가, V7에서도 모양만 바뀌어 이어졌습니다. 서버는 문서를 파서에게 보내고, 파서는 HTML과 링크, include 관계를 돌려줍니다. 파싱 결과는 Redis에 zstd로 압축해서 저장했고, 리비전 ID를 캐시 키에 넣었습니다. 같은 문서는 같은 결과를 다시 쓰고, 내용이 바뀌면 자연스럽게 다른 캐시가 됩니다.
이미지 업로드도 같은 방식으로 분리했습니다. 사용자가 올린 이미지를 메인 API 서버 안에서 직접 디코딩하고 WebP로 바꾸는 것은 마음에 들지 않았습니다. 이미지 처리는 CPU와 메모리를 많이 쓸 수 있고, animated GIF나 WebP처럼 여러 프레임을 가진 파일은 더 조심해야 합니다. 그래서 smol-image-processor라는 작은 서비스를 따로 만들었습니다.
그 서비스는 거창한 일을 하지 않습니다. JPEG, PNG, GIF, WebP를 받아서 EXIF orientation을 적용하고, 메타데이터를 지우고, WebP로 다시 내보냅니다. animated 이미지는 frame 수를 제한하고, 너무 큰 입력이나 너무 큰 출력은 거부합니다. V7은 그 결과 bytes와 헤더에 담긴 width, height, size, animated 여부만 받아서 R2에 저장합니다. 이렇게 하니 V7은 이미지의 세부 사정을 몰라도 됐습니다. 위키 API는 위키의 규칙을 다루고, 이미지 서비스는 이미지의 위험한 부분을 먼저 씻어냈습니다.
R2도 두 갈래로 나누었습니다. 사용자 업로드 파일과 프로필 이미지는 asset bucket으로 갔고, 문서 리비전 본문은 revision bucket으로 갔습니다. 문서 본문은 DB에 그대로 넣지 않고 zstd로 압축해서 R2에 저장했습니다. DB에는 리비전의 메타데이터와 R2 object key만 남깁니다. 처음에는 조금 돌아가는 길처럼 보였지만, 리비전 본문은 시간이 지날수록 계속 쌓입니다. 데이터베이스가 모든 것을 등에 업고 가게 만들고 싶지는 않았습니다.
로그인 버튼 뒤의 일
위키를 만들다 보면 문서와 파서가 먼저 눈에 들어오지만, 실제 서비스에서 사용자가 가장 먼저 만나는 것은 로그인 화면입니다. 처음에는 이메일과 비밀번호를 확인하고 세션 하나를 만들면 충분할 것 같았습니다. 하지만 계정이라는 것은 생각보다 작은 기능이 아니었습니다.
세션부터 그랬습니다. V7은 세션을 Redis에 저장했습니다. 브라우저에는 세션 ID만 쿠키로 내려가고, 서버는 Redis에서 세션 정보를 꺼내 사용자를 확인합니다. 여기까지는 흔한 구조입니다. 그런데 시간이 지나자 세션 하나만 저장해서는 부족했습니다. 사용자는 여러 기기에서 로그인할 수 있고, 설정 화면에서 어떤 세션이 열려 있는지 보고 끊을 수 있어야 합니다. 그래서 세션 데이터와 별도로 관리용 ID가 생기고, 사용자별 활성 세션 인덱스가 Redis sorted set으로 들어갔습니다. 세션은 sliding TTL로 연장되지만, 최대 수명은 넘지 못하게 했습니다. “로그인 상태를 유지한다”는 말 뒤에도 작은 규칙들이 필요했습니다.
OAuth도 단순히 “구글로 로그인” 버튼 하나가 아니었습니다. GitHub나 Google에서 이메일은 받아올 수 있지만, sevenwiki 안에서 사용할 handle은 따로 정해야 했습니다. 그래서 OAuth 로그인은 곧바로 계정을 만드는 대신 pending signup 상태를 거칠 수 있게 했습니다. Redis에는 잠깐 유효한 pending token이 들어가고, 사용자는 이어지는 화면에서 handle과 표시 이름을 고릅니다. 이때 같은 token으로 두 번 가입이 처리되지 않도록 lock도 걸었습니다. 버튼은 가볍지만, 그 뒤에서는 “이 사람이 같은 브라우저에서 이어서 가입을 완료하고 있는가”를 계속 확인해야 했습니다.
TOTP 2FA도 비슷했습니다. 비밀번호가 맞았다고 바로 세션을 만들지 않고, 먼저 임시 토큰을 발급합니다. 사용자가 인증 앱의 6자리 코드를 입력하면 그때 세션이 만들어집니다. 백업 코드는 해시해서 저장하고, 한 번 쓰면 사라집니다. 나중에는 백업 코드가 동시에 두 번 사용되는 상황까지 막기 위해 사용자 행을 잠그고 검증하도록 바뀌었습니다. 대부분의 사용자는 이 과정을 “코드 입력” 정도로만 보겠지만, 서버 입장에서는 한 번만 쓸 수 있는 문을 정말 한 번만 열리게 만드는 일이었습니다.
프론트엔드에서도 이 흐름은 계속 커졌습니다. 처음에는 로그인과 회원가입 페이지가 있고, 사용자 상태를 클라이언트 store에 담아두는 정도였습니다. 하지만 곧 SSR load에서 쿠키를 읽고 사용자 정보를 가져오는 방식으로 옮겨갔습니다. 화면이 처음 렌더링될 때부터 사용자가 누구인지 알고 있어야 hydration이 덜 흔들렸고, 클라이언트가 스스로 “나는 로그인했다”고 주장하는 모양도 줄일 수 있었습니다.
설정 화면은 이 변화가 모여 있는 곳이었습니다. 처음에는 테마나 알림 같은 가벼운 설정을 담는 다이얼로그였지만, 나중에는 프로필 이미지와 배너 업로드, 비밀번호 변경, 이메일 변경, 2FA 설정, 연결된 OAuth 계정, 활성 세션 관리까지 들어왔습니다. 데스크톱에서는 사이드바가 있는 다이얼로그로, 모바일에서는 바텀시트처럼 열리게 만들었습니다. 계정 설정은 단순한 폼 모음이 아니라, 사용자가 자기 계정의 상태를 이해하고 직접 통제할 수 있는 작은 관리 도구가 되었습니다.
저장 버튼 뒤에 있는 일들
문서 편집은 화면에서는 버튼 하나로 끝납니다. 사용자는 내용을 고치고 저장을 누릅니다. 하지만 서버 입장에서는 그 버튼 하나 뒤에 꽤 긴 확인 과정이 숨어 있습니다.
먼저 세션을 확인합니다. 로그인한 사용자라면 세션 Redis에서 사용자 정보를 가져오고, 익명 사용자라면 IP 기반 컨텍스트를 만듭니다. 그다음 이 사용자가 이 문서를 편집할 수 있는지 권한을 확인합니다. 편집이나 회원가입처럼 남용될 수 있는 요청에는 Turnstile 검증도 붙습니다. 여기까지 통과해야 비로소 “문서를 저장해도 되는가”라는 질문으로 넘어갑니다.
여기서 가장 조심해야 하는 것은 동시 편집입니다.
사용자가 편집 화면을 열었을 때의 리비전과, 저장 버튼을 누르는 순간 서버의 최신 리비전이 같다면 쉽습니다. 그냥 새 리비전을 만들면 됩니다. 하지만 그 사이에 누군가 먼저 저장했다면 이야기가 달라집니다. 사용자의 편집을 그대로 덮어쓰면 앞사람의 기여가 사라지고, 무조건 실패시키면 작은 비충돌 수정까지 매번 사용자가 다시 해야 합니다.
그래서 필요한 것이 3-way merge였습니다. V7은 사용자가 편집을 시작한 리비전을 base, 사용자가 제출한 내용을 ours, 서버의 최신 내용을 theirs로 놓고 병합을 시도합니다. 서로 다른 문단을 고쳤다면 자동으로 합쳐서 저장하고, 같은 위치를 다르게 고쳤다면 conflict marker가 들어간 텍스트를 에디터로 돌려보냅니다. 사용자는 그때 직접 어느 쪽을 남길지 결정합니다.
처음에는 이 merge도 직접 구현했습니다. 하지만 텍스트 병합은 생각보다 깊은 문제였습니다. 줄이 반복되고, 비슷한 문맥이 여러 곳에 있고, 어느 정도를 같은 변경으로 볼지 애매한 순간들이 계속 생겼습니다. 결국 Git이 오랫동안 써 온 xdiff를 Rust에서 직접 감싸는 작은 라이브러리를 만들었습니다. 그것이 threeway_merge입니다.
처음 threeway_merge는 정말 작은 Rust crate에서 시작했습니다. 기본 템플릿에 가까운 코드가 있었고, 곧바로 src/xdiff 아래에 C 파일들이 들어왔습니다. build.rs는 그 C 파일들을 빌드 시점에 정적 라이브러리로 컴파일했습니다. 런타임에 git merge-file을 실행하는 대신, Git 계열의 검증된 병합 엔진을 Rust 함수처럼 호출할 수 있게 만든 것입니다.
이 과정에서 작은 설계 변화들도 있었습니다. 초기에는 결과 문자열 안의 <<<<<<<, =======, >>>>>>> 같은 marker를 세어서 충돌 개수를 추정했습니다. 나중에는 xdiff가 반환하는 값을 그대로 사용하도록 바뀌었습니다. 병합 엔진이 이미 충돌 개수를 알고 있는데, 굳이 문자열을 다시 훑어 추측할 이유가 없었습니다. 테스트도 점점 강해졌습니다. 처음에는 Git과 “대체로 비슷한지” 보던 테스트가, 나중에는 실제 git merge-file의 출력과 내용, 충돌 개수까지 맞는지 비교하는 식으로 바뀌었습니다.
그런데 V7에는 이 라이브러리를 쓰면서도 한동안 알아차리지 못한 버그가 있었습니다.
문제는 MergeFavor::Union이었습니다. 이 옵션은 충돌이 나면 사용자에게 보여주는 대신 양쪽 내용을 자동으로 합쳐버립니다. 라이브러리 입장에서는 정상적인 기능입니다. 하지만 위키 편집기 입장에서는 위험했습니다. 실제로는 같은 문단을 두 사람이 다르게 고쳤는데, 서버는 “둘 다 넣으면 되겠네” 하고 저장해버릴 수 있었습니다. 그리고 충돌 수는 0이 됩니다.
4개월 동안 아무도 눈치채지 못했습니다. 대부분의 경우 자동으로 합쳐진 결과가 그럴듯했기 때문입니다. 버그가 가장 무서운 순간은 에러가 터질 때가 아니라, 아무 일도 없었다는 얼굴을 하고 지나갈 때였습니다.
수정은 허무할 정도로 작았습니다.
favor: None 하지만 이 한 줄은 꽤 많은 것을 바꿨습니다. 이제 충돌은 숨겨지지 않고 에디터 위로 올라옵니다. 사용자가 직접 보고 고칠 수 있습니다. 위키에서 자동 병합은 편의를 위한 기능이지만, 충돌을 숨기는 자동 병합은 편의가 아니라 데이터 손상에 더 가깝다는 것을 배웠습니다.
저장 자체가 끝나도 서버의 일은 아직 남아 있습니다. 새 리비전이 생기면 역링크를 갱신해야 하고, MeiliSearch 색인을 업데이트해야 하고, 문서 구독자에게 알림을 보내야 하며, 기여 포인트도 기록해야 합니다. 이 모든 일을 저장 요청 안에서 끝낼 필요는 없습니다. 사용자는 문서가 저장됐는지만 빨리 알면 됩니다. 나머지는 뒤에서 처리하면 됩니다.
문서 옆에 생긴 대화들
위키를 처음 만들 때는 문서가 가장 중요했습니다. 제목이 있고, 본문이 있고, 누군가 그것을 고치면 새 리비전이 생기는 구조만 있으면 위키처럼 보일 것 같았습니다. 하지만 실제 위키에서 문서는 혼자 존재하지 않습니다. 누군가는 바로 고칠 수 있지만, 누군가는 수정안을 올리고 검토를 기다려야 합니다. 어떤 수정은 문서 안에서 해결되지 않고 토론으로 넘어갑니다. 누군가는 문서를 지켜보다가 새 변경을 알고 싶어 하고, 누군가는 특정 사용자에게 답을 요청하고 싶어 합니다.
그래서 문서 곁에 작은 도구들이 하나씩 붙기 시작했습니다.
편집 요청은 그중 가장 위키다운 기능이었습니다. 누구나 문서를 고칠 수 있게 열어두고 싶지만, 모든 문서가 항상 무방비로 열려 있을 수는 없습니다. 보호된 문서나 신뢰가 필요한 상황에서는 사용자가 수정안을 제출하고, 모더레이터가 그것을 승인하거나 거절합니다. 여기서도 3-way merge가 다시 등장했습니다. 사용자가 수정안을 올린 뒤 문서가 다시 바뀌었다면, 승인자는 그 수정안을 현재 문서에 어떻게 합칠지 결정해야 했습니다. 충돌이 없으면 자동으로 합치고, 충돌이 있으면 그대로 보여줍니다. 저장 버튼 뒤에 숨어 있던 병합 문제가, 이번에는 검토 버튼 뒤로 옮겨온 셈이었습니다.
토론도 단순한 댓글 목록으로 끝나지 않았습니다. 토론은 문서에 붙어 있고, 메시지에는 순서가 있으며, 닫힌 토론에는 더 이상 메시지를 달 수 없어야 했습니다. 메시지는 접거나 다시 펼칠 수 있고, 중요한 메시지는 고정할 수 있으며, 특정 사용자를 담당자로 지정할 수도 있었습니다. 숨김 문서에 붙은 토론은 숨김 문서의 권한을 따라야 했고, 토론 메시지 안의 멘션은 파서를 거쳐 알림으로 이어졌습니다.
이때 알림은 작은 기능처럼 보이지만, 실제로는 서로 멀리 떨어진 기능들을 다시 이어 주는 장치였습니다. 편집 요청이 승인되거나 거절되면 요청자에게 알려야 하고, 누군가 토론에 새 메시지를 쓰면 그 토론을 지켜보는 사람에게 알려야 합니다. 문서가 바뀌면 문서 구독자에게 알려야 하고, 게시판 글이나 토론에서 누군가를 멘션하면 그 사람에게 알려야 합니다. 알림은 단순한 종 아이콘이 아니라, 위키 안에서 일어난 사건을 사용자에게 다시 돌려보내는 통로였습니다.
게시판도 뒤늦게 들어왔습니다. 위키 문서는 지식을 정리하는 곳이지만, 모든 대화가 문서의 토론란에 붙어야 하는 것은 아닙니다. 공지, 질문, 일반적인 커뮤니티 대화처럼 문서와 1:1로 묶이지 않는 흐름도 필요했습니다. 그래서 게시판과 게시글이 생겼고, 게시글 본문도 sevenmark로 렌더링했습니다. 결국 sevenmark는 위키 문서의 문법을 넘어, sevenwiki 안에서 사용자가 쓰는 글 전반을 표현하는 언어가 되었습니다.
이 변화들은 문서 모델 밖으로 sevenwiki가 넓어지는 과정이었습니다. 처음에는 문서 하나를 잘 저장하는 것이 목표였지만, 시간이 지나자 그 문서를 둘러싼 검토, 대화, 알림, 커뮤니티 흐름까지 함께 설계해야 했습니다. 위키는 문서의 집합이지만, 동시에 그 문서를 둘러싸고 사람들이 움직이는 방식의 집합이기도 했습니다.
작업 큐를 세 번 갈아치우다
백그라운드 작업도 한 번에 정답을 찾지는 못했습니다.
처음에는 Python worker가 있었습니다. Celery와 Redis로 이메일 발송, 검색 색인, 나중에는 BGE-M3 임베딩 같은 작업까지 처리했습니다. V7로 넘어오면서 백엔드가 Rust가 되었고, 워커도 Rust로 옮기고 싶었습니다. 그래서 한동안은 apalis와 Redis를 사용했습니다. Python worker가 하던 일을 조금씩 Rust worker로 옮겨오는 과정이었습니다.
문제는 작업이 생각보다 소중하다는 데 있었습니다. 이메일 하나쯤은 다시 보내면 된다고 생각할 수도 있지만, 문서 색인이나 알림, R2 파일 삭제처럼 뒤에서 반드시 처리되어야 하는 일도 많았습니다. Redis 큐를 단순하게 쓰면 서버 재시작이나 장애 상황에서 작업이 사라질 수 있었습니다. 작업이 사라진다는 것은, 사용자는 저장했다고 믿는데 검색에는 반영되지 않거나, 삭제해야 할 파일이 그대로 남거나, 누군가 받아야 할 알림이 조용히 사라진다는 뜻이었습니다.
그래서 RabbitMQ로 옮겼습니다. 메시지를 더 단단하게 보관할 수 있고, 큐라는 개념도 명확했습니다. 하지만 RabbitMQ로 간 뒤에도 마음이 완전히 편하지는 않았습니다. 설정과 운영이 제가 원하던 것보다 무거웠고, V7의 작업 모델에 비해 장비가 조금 큰 느낌이었습니다.
마지막으로 정착한 것은 NATS JetStream이었습니다. NATS는 가볍고, JetStream은 메시지를 스트림에 영속적으로 저장할 수 있습니다. 워커는 pull consumer로 자기 속도에 맞춰 작업을 가져갑니다. 이메일, 색인, 알림, 역링크 갱신, 스토리지 정리 같은 작업은 각각 별도 subject와 stream으로 나뉘었습니다. 실패하면 지수 백오프로 다시 시도하고, 여러 워커가 동시에 같은 작업에 달려들지 않도록 동시 처리 수도 제한했습니다.
결국 작업 큐를 세 번 갈아치운 셈입니다. 처음에는 “HTTP 응답을 느리게 만들지 않기 위해” 큐가 필요했고, 나중에는 “완료되어야 하는 일을 잃어버리지 않기 위해” 큐가 필요하다는 것을 알게 됐습니다. 비슷해 보이지만, 둘은 꽤 다른 요구사항이었습니다.
권한을 세 번 설계하다
V7에서 가장 많이 헤맨 부분을 하나만 고르라면, 저는 권한 시스템을 고를 것 같습니다.
처음에는 권한을 아주 정교하게 만들고 싶었습니다. 위키에는 문서가 있고, namespace가 있고, 사용자 역할이 있고, 익명 사용자도 있습니다. 어떤 문서는 모두가 읽을 수 있지만 아무나 편집하면 안 되고, 어떤 문서는 관리자만 수정해야 하며, 어떤 사용자는 토론만 관리할 수 있어야 합니다. 이런 조건들을 떠올리다 보면 자연스럽게 “유연한 ACL 시스템”을 만들고 싶어집니다.
그래서 첫 번째 권한 시스템은 규칙을 행으로 저장하는 방식이었습니다. “이 scope에서, 이 subject에게, 이 action을 허용하거나 거부한다”는 규칙을 데이터베이스에 하나씩 넣었습니다. 전역 규칙, namespace 규칙, 문서별 규칙이 있고, 우선순위도 있었습니다. 특정 IP만 막거나, 특정 역할에게만 편집을 허용하거나, document:* 같은 와일드카드도 표현할 수 있었습니다.
표현력은 좋았습니다. 문제는 표현력이 좋은 시스템은 대개 읽는 사람에게도 많은 것을 요구한다는 점입니다. 권한 하나를 확인하려면 어떤 scope의 어떤 규칙이 먼저 적용되는지, deny와 allow가 충돌하면 무엇이 이기는지, priority가 어떻게 계산되는지 계속 따라가야 했습니다. 만들 때는 멋있었지만, 운영하는 입장에서는 매번 작은 판결문을 읽는 기분이었습니다.
그래서 두 번째로는 Discord 스타일의 비트필드 권한으로 옮겨갔습니다. 각 역할은 여러 권한 비트를 가지고 있고, 사용자가 가진 역할들의 비트를 합쳐 최종 권한을 계산하는 방식입니다. ADMINISTRATOR 비트가 있으면 모든 권한을 갖고, 문서·토론·리비전·시스템 권한을 각각 다른 비트 묶음으로 나누었습니다. 문서별 override도 allow와 deny 비트로 계산했습니다.
이 방식은 훨씬 빨랐고, 캐싱하기도 좋았습니다. 기본 권한을 Redis에 넣어두면 매번 DB를 볼 필요도 줄었습니다. 하지만 시간이 지나자 또 다른 문제가 보였습니다. 권한이 비트가 되는 순간, 사람은 그 의미를 숫자 뒤에서 다시 찾아야 합니다. 코드 안에서는 효율적이지만, 서비스 정책을 생각할 때는 “이 사용자가 무엇을 할 수 있는가”가 한눈에 들어오지 않았습니다.
그 사이에 SpiceDB도 잠깐 시도했습니다. Google Zanzibar 논문을 기반으로 한 관계 기반 권한 시스템입니다. “A는 B의 멤버이고, B는 C의 editor이므로, A는 C를 편집할 수 있다” 같은 관계를 선언적으로 표현할 수 있습니다. 멋진 도구였습니다. 문서만 읽어도 마음이 흔들릴 정도로 잘 만든 시스템이었습니다.
하지만 sevenwiki에 정말 그 정도가 필요했는지는 다른 문제였습니다. 외부 권한 서버를 하나 더 운영해야 하고, 로컬 개발 환경도 복잡해지고, 장애 지점도 늘어납니다. 관계 기반 권한은 강력했지만, 제가 실제로 풀고 있던 문제는 그만큼 복잡하지 않았습니다.
결국 세 번째 권한 시스템은 오히려 단순해졌습니다. 관리자, 문서 모더레이터, 토론 모더레이터, 신고 모더레이터, 커뮤니티 매니저, 신뢰 사용자 같은 역할을 두고, 이것들을 Everyone, User, Trusted, Mod, Admin 같은 접근 수준으로 접었습니다.
조금 허무할 정도로 단순했습니다. 그런데 그 단순함이 맞았습니다.
실제로 필요한 질문은 대개 이 정도였습니다. 이 문서는 모두가 읽을 수 있는가. 로그인한 사용자만 편집할 수 있는가. 모더레이터만 처리해야 하는 요청인가. 관리자 전용 기능인가. 이 질문을 대답하기 위해 분산 권한 서버나 30개가 넘는 비트가 항상 필요한 것은 아니었습니다. 정교함은 그 자체로 가치가 아니라, 실제로 필요한 복잡도를 감당할 때만 가치가 있었습니다.
권한 시스템을 세 번 갈아엎고 나서야, 저는 조금 늦게 배웠습니다. 시스템은 똑똑할수록 좋은 것이 아니라, 서비스를 운영하는 사람이 끝까지 이해할 수 있을 만큼만 똑똑해야 했습니다.
추가했다가 지운 것들
이 과정에서 만든 것들이 모두 남아 있지는 않습니다. 오히려 꽤 많은 기능은 들어왔다가 사라졌습니다.
한동안은 BGE-M3 임베딩 모델로 문서를 벡터화해서 시맨틱 검색과 RAG를 붙여보려고 했습니다. 키워드가 정확히 일치하지 않아도 의미로 문서를 찾고, 관련 문서를 바탕으로 답변까지 만들 수 있으면 멋질 것 같았습니다. 실제로 워커에 임베딩 파이프라인을 붙이고, 문서 색인과 함께 벡터도 다루어 보았습니다.
하지만 곧 지웠습니다. 멋진 것과 필요한 것은 달랐습니다. 모델을 운영하고, 임베딩을 만들고, 색인과 동기화하고, 검색 품질을 검증하는 일은 작지 않았습니다. 그런데 sevenwiki가 그 복잡도를 감당할 만큼 그 기능을 필요로 하고 있는지는 확실하지 않았습니다. 불확실한 기능이 시스템의 무게를 늘리고 있다면, 지우는 편이 맞았습니다.
문서 조회수도 들어왔다가 사라졌고, 리비전 평점도 들어왔다가 포인트 시스템으로 흡수됐습니다. 반달 리비전을 따로 표시하는 기능도 만들었다가 제거했습니다. 그때마다 조금 아깝기는 했습니다. 코드를 썼고, 화면을 만들었고, 데이터 모델도 고민했으니까요. 하지만 위키를 만드는 일은 기능을 많이 붙이는 일이 아니라, 오래 남길 기능을 고르는 일이기도 했습니다.
반대로 남은 기능들도 있습니다. TOTP 2FA는 유지됐습니다. 비밀번호를 맞힌 뒤에도 바로 세션을 만들지 않고, 임시 토큰을 거쳐 OTP 코드를 확인한 뒤에야 로그인되도록 했습니다. 백업 코드도 해시해서 저장했습니다. 이런 기능은 화려하지 않지만, 계정 보안이라는 아주 분명한 문제를 해결합니다.
포인트 시스템도 남았습니다. 문서 생성, 편집, 토론 작성 같은 기여 행위에 포인트를 주는 구조입니다. 처음에는 리더보드를 Redis sorted set으로 만들었지만, 이후에는 PostgreSQL 쿼리로 옮겼습니다. 포인트는 단순한 숫자처럼 보이지만, 중복 지급을 막아야 하고, 일일 획득 한도도 있어야 하며, 같은 이벤트가 재시도되어도 두 번 적립되면 안 됩니다. 결국 idempotency_key를 두고 PostgreSQL 트랜잭션 안에서 원자적으로 처리하는 방식으로 정리됐습니다.
기능을 추가하는 것보다 어려운 일은, 그 기능을 계속 데리고 갈지 판단하는 일이었습니다. V7에는 그 흔적이 많이 남아 있습니다. 어떤 기능은 남았고, 어떤 기능은 사라졌습니다. 사라진 기능들도 완전히 헛된 것은 아니었습니다. 적어도 “이건 지금의 sevenwiki가 감당할 필요가 없다”는 판단을 남겼으니까요.
운영을 앞두고 보이기 시작한 것들
기능을 만들 때는 화면에 보이는 것이 먼저 눈에 들어옵니다. 문서를 편집할 수 있는가, 검색이 되는가, 알림이 오는가, 신고를 넣을 수 있는가. 하지만 서비스가 실제로 누군가의 계정을 받고, 문서를 오래 보관하고, 관리자가 처리해야 할 사건을 남기기 시작하면 다른 질문들이 더 크게 보입니다.
예를 들어 계정 탈퇴가 그랬습니다. 처음에는 사용자를 지우면 된다고 생각하기 쉽습니다. 하지만 위키에서 사용자는 단순한 계정 하나가 아닙니다. 그 사람이 쓴 문서와 토론, 편집 요청과 신고, 포인트 기록과 중재 로그가 이미 여러 곳에 남아 있습니다. 계정을 완전히 지워버리면 개인 정보는 사라질지 몰라도, 문서의 기여 이력도 함께 무너집니다. 반대로 아무것도 지우지 않으면 탈퇴라는 말이 무색해집니다.
그래서 V7의 탈퇴는 삭제라기보다 비활성화에 가까워졌습니다. 사용자 행에는 deleted_at이 남고, 이메일과 비밀번호, OAuth 연결, TOTP 정보, 프로필 이미지와 알림 설정처럼 사적인 데이터는 지웁니다. 대신 handle과 표시 이름은 남겨 기여의 출처를 보존하고, 다시 같은 handle을 누군가 가져가지 못하게 막습니다. 화면에서는 그런 사용자를 흐리게 보여주거나 (탈퇴)라고 표시합니다. 리더보드, 최근 변경, 문서 역사, 검색 드롭다운, 사용자 프로필이 모두 같은 규칙을 따라야 했습니다. 백엔드의 작은 필드 하나는 결국 화면 곳곳의 문장과 색까지 바꾸었습니다.
신고와 숨김 문서도 비슷했습니다. 신고는 투명해야 하지만, 아무에게나 보이면 안 됩니다. 숨김 문서는 관리자에게는 보여야 하지만, 검색 결과나 최근 변경, 실시간 이벤트 스트림을 통해 제목과 활동이 새어나가면 숨김의 의미가 없습니다. 문서 diff도 마찬가지였습니다. 서로 다른 문서의 리비전을 잘못 비교할 수 있으면, 공개 문서를 보는 권한만으로 숨겨진 문서의 본문이 따라 나올 수 있었습니다.
이런 버그들은 대개 화려하지 않습니다. 버튼이 깨지거나 화면이 하얗게 되는 종류의 문제가 아니라, 원래 보이면 안 되는 것이 아주 조용히 보이는 문제입니다. 그래서 더 위험했습니다. V7에는 이 시점부터 보안 회귀 테스트가 늘어났습니다. 숨김 문서가 검색에 나오지 않는지, 최근 변경 목록과 SSE에 새지 않는지, diff는 같은 문서의 리비전끼리만 허용하는지, TOTP 백업 코드가 동시에 두 번 쓰이지 않는지 같은 것들을 실제 API 흐름으로 눌러보기 시작했습니다.
오래 붙잡고 있던 기능을 지운 일도 있었습니다. 문서의 각 줄을 누가 썼는지 보여주는 blame API는 흥미로운 기능이었지만, 숨김 리비전과 권한 정책까지 함께 생각하면 유지해야 할 표면이 너무 넓었습니다. 결국 백엔드에서 API와 타입, 캐시 키를 걷어내고, 프론트엔드에서도 호출 지점을 없앴습니다. 대신 신고 큐는 mod:report 권한이 있는 사람에게만 보이도록 좁혔고, 이미 처리된 신고를 다시 처리하려는 경우도 화면에서 조용히 막았습니다.
이 무렵의 작업은 새 기능을 붙이는 일이라기보다, 서비스가 스스로의 경계를 이해하도록 만드는 일이었습니다. 어떤 정보는 오래 남겨야 하고, 어떤 정보는 탈퇴와 함께 사라져야 합니다. 어떤 기록은 모두에게 열려 있어야 하고, 어떤 기록은 관리자만 볼 수 있어야 합니다. 위키는 많은 사람이 함께 쓰는 공간이지만, 모두에게 모든 것을 보여주는 공간은 아니었습니다.
화면을 만드는 것도 결국 코드였다
백엔드가 점점 단단해지는 동안, 화면도 계속 흔들렸습니다. 처음에는 Next.js로 위키 화면을 만들었습니다. 문서 제목이 있고, 본문이 있고, 오른쪽에는 최근 변경 같은 것이 보이는, 제가 생각하던 “위키 같은 화면”을 빠르게 그리기에는 충분했습니다.
하지만 어느 정도 만들고 나니, 화면도 단순히 HTML을 예쁘게 배치하는 일이 아니라는 것을 알게 되었습니다. 위키의 화면은 계속 상태를 품고 있습니다. 로그인한 사용자와 익명 사용자, 편집 가능한 문서와 보호된 문서, 미리보기와 원문, 충돌 해결 화면과 리비전 비교 화면, 검색 결과와 알림. 페이지 하나하나가 단순한 문서가 아니라 작은 도구에 가까웠습니다.
결국 프론트엔드는 SvelteKit으로 옮겨갔습니다. 지금의 sevenwiki-ui-ko입니다. Cloudflare Workers에 SSR로 올리기 편했고, Svelte의 반응성 모델도 제게는 더 잘 맞았습니다. 값이 바뀌면 그 값에 기대고 있던 UI가 자연스럽게 따라 움직이는 느낌이 좋았습니다. React에서 익숙했던 많은 습관을 다시 생각해야 했지만, 위키처럼 상태가 많은 화면을 만들기에는 그 단순함이 꽤 편했습니다.
UI도 여러 번 옷을 갈아입었습니다. 처음에는 거의 Tailwind 클래스만으로 화면을 만들었습니다. 그다음에는 shadcn-svelte와 OKLCH 색상 토큰을 들여와서 버튼, 입력창, 카드 같은 기본 부품을 정리했습니다. 한때는 Catalyst UI Kit 같은 구성으로 navbar와 sidebar를 만들었고, 잠깐 osu! web의 내비게이션을 참고해보기도 했습니다. 그 시도는 오래 남지 않았지만, 남긴 흔적은 분명했습니다. 저는 위키가 너무 딱딱한 관리 도구처럼 보이길 원하지 않았고, 그렇다고 마케팅 사이트처럼 과장되게 보이길 원하지도 않았습니다.
결국 지금의 방향은 조금 조용한 쪽에 가깝습니다. 문서를 읽는 공간은 문서가 주인공이어야 하고, 편집 도구는 오래 써도 피곤하지 않아야 했습니다. 브랜드 색상 #7777ff는 링크와 강조색으로 남겼지만, 화면 전체를 그 색으로 덮지는 않았습니다. 위키는 매일 들어와서 읽고 고치는 곳이지, 첫 방문에서 한 번 감탄하고 떠나는 랜딩 페이지가 아니기 때문입니다.
에디터도 같은 이유로 바뀌었습니다.
처음에는 Monaco Editor를 썼습니다. VS Code에서 쓰는 에디터라 기능은 강력했고, TextMate grammar를 붙이면 sevenmark 문법도 색칠할 수 있었습니다. 처음 에디터 화면이 Monaco 위에서 돌아갔을 때는 꽤 뿌듯했습니다. 제가 만든 문법이 마치 진짜 언어처럼 색이 입혀지는 느낌이었습니다.
하지만 곧 무게가 느껴졌습니다. 번들은 컸고, 모바일에서는 다루기 불편했습니다. 위키 편집기는 강력해야 하지만, 문서 하나 고치자고 거대한 IDE를 브라우저에 올리는 느낌은 조금 과했습니다.
그래서 CodeMirror 6으로 옮겼습니다. 처음에는 낯설었습니다. 상태는 immutable하게 관리되고, 모든 변화는 transaction으로 흘렀습니다. 하지만 익숙해지고 나니 오히려 sevenmark 같은 문법에 더 잘 맞았습니다. syntax highlighting, diff editor, conflict resolver 같은 것들을 필요한 만큼 얹을 수 있었습니다.
마지막으로 에디터에 LSP를 연결했습니다. sevenmark LSP 서버를 WASM으로 빌드해서 Web Worker 안에서 돌리고, 에디터는 JSON-RPC 메시지를 주고받습니다. 이제 브라우저 안에서도 자동완성, semantic token highlighting, diagnostic, folding range가 동작합니다. {{{# 뒤에 커서를 두면 가능한 블록이 뜨고, 테이블 셀 안에서는 그 문맥에 맞는 파라미터가 제안됩니다. 문법을 만들고, 그 문법을 파싱하고, 다시 그 문법을 쓰는 사람에게 에디터 도움말로 돌려주는 순환이 생긴 것입니다.
렌더링 영역도 한 번 크게 바뀌었습니다. sevenmark로 렌더링한 HTML은 일반 앱 UI와 다릅니다. 위키 문서 안의 <table>은 앱의 테이블 컴포넌트가 아니고, 문서 안의 <h2>는 페이지 레이아웃의 제목이 아닙니다. 그런데 처음에는 같은 CSS 세계에 같이 놓여 있었습니다. Tailwind의 base 스타일과 앱의 전역 스타일이 위키 본문에 스며들었습니다.
그래서 sevenmark 본문을 Shadow DOM 안에 넣었습니다. 서버에서는 선언적 Shadow DOM으로 HTML을 내려주고, 클라이언트에서는 필요한 인터랙션만 붙였습니다. 목차, 각주 hover 카드, 코드 하이라이팅, TeX 렌더링, 섹션 접기 같은 것들은 문서 영역 안에서 자기 세계를 갖게 되었습니다. Shadow DOM은 단순한 기술 선택이라기보다, “문서와 앱은 서로 다른 표면이다”라는 판단에 가까웠습니다.
sevenmark, 독립하다
2026년 2월쯤, sevenmark는 다시 한 번 경계를 넘었습니다. 더 이상 V7 안에 딸린 파서가 아니라, 독립된 프로젝트가 되었습니다.
처음에는 문법을 파싱해서 HTML로 바꾸는 라이브러리 정도로 생각했지만, 시간이 지나자 필요한 것이 계속 늘어났습니다. AST 타입이 필요했고, HTML 렌더러가 필요했고, 포매터가 필요했고, 브라우저에서 돌릴 WASM 빌드가 필요했고, 에디터와 연결할 LSP가 필요했습니다. VS Code와 JetBrains 플러그인까지 생각하기 시작하자, 이것은 더 이상 “파서 파일 몇 개”가 아니었습니다. 하나의 작은 언어 생태계였습니다.
그래서 sevenmark_ast, sevenmark_parser, sevenmark_html, sevenmark_formatter, sevenmark_lsp_core, sevenmark_wasm 같은 crate들이 나뉘었습니다. AST는 문서 구조를 담고, parser는 텍스트를 AST로 바꾸고, renderer는 AST를 HTML로 바꾸고, formatter는 AST를 다시 sevenmark 소스로 돌려놓습니다. LSP는 이 모든 것을 에디터가 이해할 수 있는 형태로 연결합니다.
파서 내부에서도 변화가 많았습니다. 오래 미뤄두었던 것 중 하나가 리스트와 블록 인용이었습니다. -나 > 같은 기호는 단순해 보이지만, 파서에게는 꽤 성가신 존재였습니다. 줄의 시작에 있으면 목록이나 인용이지만, 문장 중간에 있으면 그냥 문자입니다. 같은 문자가 위치에 따라 완전히 다른 의미를 갖는 것입니다.
그래서 BlockMode가 생겼습니다. 지금 파서가 전체 문서를 읽고 있는지, 테이블 셀 같은 중첩된 문맥을 읽고 있는지, 아니면 완전히 인라인 내용만 읽고 있는지 상태로 들고 있게 했습니다. 줄 시작 문법은 전체 문서를 읽을 때만 켜고, 테이블 셀 안에서는 껐습니다. 문법 하나를 추가한 것처럼 보이지만, 실제로는 파서가 “내가 지금 어디에 있는지”를 더 분명히 알게 된 변화였습니다.
블록 인용은 한 단계 더 까다로웠습니다. > 접두사를 떼어낸 뒤 안쪽 내용을 다시 파싱해야 하는데, 그러면 파서가 보는 문자열의 위치와 원본 문서의 위치가 달라집니다. 에디터에서 diagnostic이나 semantic token을 표시하려면 AST의 span은 항상 원본 문서 기준이어야 합니다. 그래서 InputSource::Segmented라는 소스 맵이 들어갔습니다. 파서가 보는 논리적 위치와 원본 문서 위치를 매핑해 두고, LSP가 위치를 물어볼 때 다시 원래 자리로 돌려놓는 방식입니다.
winnow 1.0으로 올릴 때도 파서의 모양이 드러났습니다. 이전에는 alt() 하나에 많은 분기를 넣을 수 있었는데, 새 버전에서는 제한이 줄었습니다. sevenmark의 {로 시작하는 문법이 너무 많아서 그대로는 컴파일되지 않았습니다. 결국 분기를 여러 그룹으로 나누어 다시 묶었습니다. 겉으로는 라이브러리 업그레이드였지만, 실제로는 sevenmark 문법이 얼마나 많이 자랐는지 보여주는 순간이었습니다.
상태 관리도 바뀌었습니다. 처음에는 재귀 깊이나 각주 내부 여부 같은 값을 함수 인자로 계속 넘겼습니다. from_footnote 같은 불리언이 하나씩 늘어날수록 함수 시그니처가 길어졌고, 파서가 어디에 있는지 함수 호출을 따라가며 추적해야 했습니다. 나중에는 ParseContext를 입력 스트림 안에 넣고, 재귀 깊이와 BlockMode, guard stack을 함께 관리했습니다. 볼드 안에서 다시 볼드를 파싱하지 않도록 막는 것, 각주 안에서 또 각주가 열리지 않게 하는 것, 너무 깊게 중첩된 문서가 WASM에서 panic하지 않게 Element::Error로 남기는 것들이 이 상태 안으로 들어갔습니다.
코드 블록도 몇 번 고쳤습니다. 처음에는 닫는 }}}를 만날 때까지 읽으면 된다고 생각했습니다. 하지만 코드 블록 안에 다시 {{{가 등장하면 금방 틀어졌습니다. 줄 단위로 닫는 방식도 시도했지만 경계 케이스가 남았습니다. 결국 memchr2를 이용해 여는 기호와 닫는 기호를 빠르게 찾고, 중첩 깊이를 세는 balanced scanner로 바뀌었습니다. 작은 최적화처럼 보이지만, 파서가 임의의 문서 앞에서 쉽게 흔들리지 않게 만드는 중요한 변화였습니다.
렌더러는 maud를 사용했습니다. 문자열을 이어 붙여 HTML을 만드는 대신, Rust의 match와 반복문을 그대로 쓰면서 타입이 있는 HTML을 만들 수 있었습니다. 이 렌더러는 서버에서도 쓰이고, WASM 빌드를 통해 브라우저 미리보기에서도 쓰입니다. 서버에서 렌더링한 문서와 브라우저에서 미리보기로 보는 문서가 같은 코드를 지나간다는 점은 꽤 중요했습니다. 같은 문법이 두 장소에서 다르게 보이는 순간, 사용자는 에디터를 믿기 어려워지기 때문입니다.
포매터도 생겼습니다. AST를 다시 sevenmark 소스로 돌려놓는 일입니다. 단순히 예쁘게 줄을 맞추는 기능처럼 보이지만, 사실은 파서가 문서를 얼마나 잘 이해하고 있는지 확인하는 반대 방향의 길이기도 합니다. 파싱한 구조를 다시 안정적인 원문 형태로 만들 수 있어야, LSP의 formatting 기능도 자연스럽게 붙일 수 있습니다.
LSP는 처음부터 입출력 방식에 묶이지 않게 만들었습니다. core는 JSON-RPC 문자열을 받고, 상태를 갱신하고, 응답과 notification을 돌려주는 순수한 로직에 가깝습니다. 네이티브 환경에서는 stdio transport가 그것을 감싸고, 브라우저에서는 Web Worker 안의 WASM이 같은 core를 사용합니다. 덕분에 VS Code, JetBrains, 웹 에디터가 같은 언어 이해 기능을 공유할 수 있었습니다.
기능도 점점 촘촘해졌습니다. 처음에는 문법 색칠 정도였지만, 나중에는 자동완성, hover, definition, semantic token, folding range, document symbol까지 들어갔습니다. semantic token 종류도 점점 늘었습니다. {{{, }}}, #key, 문자열 값, 테이블 셀, 조건식의 연산자까지 서로 다른 의미를 갖게 되었습니다. 사람이 보기에는 그냥 문서 문법이지만, 에디터는 그 안에서 작은 언어를 보고 있었습니다.
한편으로는 Namumark 호환 레이어도 들어갔습니다. sevenmark는 처음에 나무마크의 불편함에서 출발했지만, 현실적으로 한국어 위키 문서 생태계에서 나무마크를 완전히 무시하기는 어려웠습니다. 그래서 파이프 테이블, [[파일:...]], [[분류:...]], [include(...)], [목차], 나무마크식 각주와 스타일 문법들을 sevenmark AST로 변환하는 계층을 추가했습니다. 렌더러는 그 문서가 sevenmark로 쓰였는지 Namumark로 쓰였는지 몰라도 됩니다. 결국 같은 AST로 들어오면 같은 방식으로 렌더링됩니다.
문법 자체도 계속 바뀌었습니다. 처음에는 거의 모든 것을 {{{ }}} 블록으로 표현하려 했습니다. 코드 블록도 {{{#code ... }}}, TeX도 {{{#tex ... }}} 같은 식이었습니다. 그런데 시간이 지날수록 이 방식이 너무 욕심이라는 생각이 들었습니다. 코드 블록은 이미 백틱 3개가 널리 쓰이고, 수식은 $...$, $$...$$가 훨씬 자연스럽습니다. 그래서 일부 문법은 과감히 마크다운에 가까운 형태로 바꿨습니다. 일관성만 붙잡고 있기보다, 이미 사람들이 익숙한 표면을 받아들이는 편이 나았습니다.
이렇게 sevenmark는 처음의 작은 파서에서 점점 멀어졌습니다. 이제는 문법을 읽고, 렌더링하고, 포매팅하고, 에디터를 도와주고, 다른 위키 문법까지 받아들이는 도구가 되었습니다. 처음 정규식 테스트 사이트에서 Group 1, Group 2가 잘 잡히는지 보던 때를 생각하면 꽤 먼 곳까지 온 셈입니다.
지금까지 온 자리
처음에는 위키를 단순히 화면으로 보여주는 서비스를 만들려 했습니다. 정규식으로 마크업을 HTML로 변환하면 충분할 것이라 생각했습니다.
하지만 그 생각은 빠르게 바뀌었습니다. 위키 문법을 제대로 만들려면 파서가 필요했고, 파서를 제대로 만들려면 AST가 필요했고, AST를 렌더링하려면 렌더러가 필요했고, 렌더러를 에디터와 연결하려면 LSP가 필요했습니다. 백엔드를 만들다 보니 3-way merge가 필요했고, 검색이 필요했고, 권한 시스템이 필요했고, 비동기 작업 큐가 필요했습니다.
하나를 만들면 다음이 보이고, 다음을 만들면 또 그 다음이 보였습니다. 워커 큐를 세 번 교체하고, 권한 시스템을 세 번 재작성하고, SpiceDB도 시도해보고, BGE-M3 RAG도 붙였다가 떼어내고, Monaco를 CodeMirror로 바꾸고, nom을 winnow로 바꿨습니다.
그 과정에서 만든 것의 상당 부분은 결국 지워졌습니다. 하지만 그 과정에서 배운 것은 지워지지 않았습니다. 4개월 동안 아무도 몰랐던 3-way merge 버그, 행 기반 ACL이 왜 결국 과설계인지, 비트필드 권한이 언제 적합하고 언제 과설계인지, Shadow DOM이 왜 필요했는지, memchr로 balanced scanner를 만드는 방법 같은 것들입니다.
sevenwiki는 아직 완성된 서비스가 아닙니다. 하지만 그 과정 하나하나가 제가 위키를 만들어가는 방식이었고, 그 자체로 충분히 많은 것을 가르쳐주었습니다.