브라우저에도 언어 서버를 넣어보자!
SevenMark 파서와 LSP를 서버, 포매터와 브라우저에서 따로 만들지 않고 Rust와 WebAssembly로 공유한 과정.
Sevenwiki — The Knowledge InfrastructureAugust 31, 2026
문법 구현이 세 개가 될 뻔했습니다
SevenMark 파서와 HTML 렌더러를 만들고 나니 편집 화면에도 문법을 이해하는 기능이 필요했습니다.
CodeMirror에는 문법 강조가 있어야 했고, 괄호를 잘못 닫으면 오류를 알려줘야 했습니다. 링크 대상 자동완성과 설명, 제목 접기 기능도 넣고 싶었습니다. 사용자가 입력하는 동안 바로 보이는 미리보기와 서버가 저장 후 만드는 HTML도 같은 문법을 읽어야 했습니다.
가장 빨리 만드는 방법은 각자 따로 구현하는 것이었습니다.
서버 렌더링 → Rust 파서
CodeMirror 강조 → JavaScript 정규식
포매터 → 또 다른 문자열 처리 실제로 에디터 문법 강조 정도는 JavaScript 정규식으로 금방 만들 수 있습니다.
그런데 SevenMark 문법 하나를 바꿀 때마다 문제가 생겼습니다. 서버에서는 새 옵션이 잘 렌더되는데 에디터는 오류로 표시할 수 있고, 포매터가 만든 문서를 서버 파서가 다시 읽지 못할 수도 있습니다. 미리보기에서는 정상으로 보였던 문서가 저장하고 새로고침하면 다른 모양이 되는 일도 생깁니다.
문법의 진실이 세 군데에 생기는 셈이었습니다.
이미 정규식 파서를 버린 이유가 규칙이 여러 변환 단계에 흩어졌기 때문인데, 이번에는 실행 환경별로 같은 실수를 반복하고 싶지 않았습니다.
한 번 만든 언어 도구를 어디서든 쓰고 싶었습니다
SevenMark를 독립된 Rust 워크스페이스로 분리한 가장 큰 이유입니다.
중심에는 AST와 문법 규칙, 파서가 있습니다. 그 위에 필요한 도구가 붙습니다.
sevenmark_ast + sevenmark_spec
│
sevenmark_parser
│
┌──────────┼───────────┐
HTML formatter lsp_core
renderer │
┌────────┴────────┐
native LSP WASM LSP sevenmark_lsp_core는 열린 문서와 LSP 요청을 처리하지만, 터미널의 표준 입출력이나 브라우저의 postMessage는 모릅니다.
네이티브 언어 서버는 이 공통 로직을 표준 입출력에 연결합니다. 브라우저용 sevenmark_wasm_lsp는 같은 로직을 wasm-bindgen 함수로 노출합니다.
시맨틱 토큰, 오류 진단, 자동완성, 설명, 정의로 이동과 문서 접기를 계산하는 코드는 같습니다. 달라지는 것은 메시지를 어디에서 받아 어디로 돌려주느냐뿐입니다.
처음부터 완벽하게 이 구조였던 것은 아닙니다. 브라우저에 필요한 기능을 붙일 때마다 어느 코드가 LSP의 의미이고, 어느 코드가 실행 환경에만 필요한지 다시 나눴습니다.
그래도 이 경계를 만든 뒤에는 서버와 브라우저에서 같은 버그를 두 번 고치는 일이 많이 줄었습니다.
Rust로 만들었으니 WASM으로 가져와봤습니다
SevenMark를 Rust로 만든 덕분에 브라우저에서도 같은 코드를 실행할 수 있었습니다.
그렇다고 WASM 모듈을 CodeMirror의 메인 스레드에 바로 붙이지는 않았습니다. 문서가 길어질수록 파싱과 시맨틱 토큰 계산도 무거워지고, 이 작업이 메인 스레드를 잡고 있으면 입력할 때 커서와 스크롤이 끊깁니다.
그래서 LSP는 Web Worker 안에서 실행합니다.
CodeMirror
│ didOpen / didChange / completion / hover
▼
SevenMarkLspClient
│ postMessage
▼
Web Worker
│
SevenMark WASM LSP 메인 스레드의 SevenMarkLspClient가 JSON-RPC 메시지를 만들고 Worker로 보냅니다. Worker는 WASM의 handle_lsp_message를 호출하고, 응답과 publishDiagnostics 알림을 다시 메인 스레드로 전달합니다.
브라우저에서 별도의 LSP 서버에 네트워크 요청을 보내지는 않습니다. 사용자가 키를 누를 때마다 문서 전체를 외부로 전송할 필요도 없고, 네트워크 지연이 자동완성 속도에 들어오지도 않습니다.
서버 없이 브라우저 안에 언어 서버가 돌아가는 모습을 처음 확인했을 때는 꽤 만족스러웠습니다. 정규식으로 급하게 시작했던 문법이 진짜 개발 도구처럼 느껴진 순간이기도 했습니다.
메시지를 보내는 것보다 순서를 맞추는 게 어려웠습니다
WASM이 동작한다고 바로 에디터가 안정되는 것은 아니었습니다.
사용자가 빠르게 입력하면 didChange, 자동완성, 설명 요청이 거의 동시에 들어옵니다. 문서 변경보다 자동완성 요청이 먼저 처리되면 언어 서버는 한 글자 전의 문서를 기준으로 답합니다. 결과가 조금 늦게 도착하면 이미 커서가 다른 곳으로 움직인 뒤일 수도 있습니다.
UI 클라이언트는 열린 문서마다 원문과 버전을 저장합니다.
- 문서를 처음 열 때
didOpen을 보냅니다. - 실제 내용이 달라질 때만 버전을 올려
didChange를 보냅니다. - 현재 문서에 의존하는 요청 전에는 변경 메시지가 먼저 Worker에 들어가도록 같은 큐에서 순서를 맞춥니다.
Worker 왕복을 추적하는 요청 ID와 LSP JSON-RPC의 ID도 따로 관리합니다. 둘을 같은 숫자로 대충 묶어버리면 notification과 response가 섞였을 때 어느 Promise를 끝내야 하는지 애매해집니다.
화려한 기능은 아니지만 이런 부분에서 간헐적인 버그가 많이 생깁니다. 파서는 늘 같은 답을 주는데 가끔 자동완성만 엉뚱한 곳에 뜬다면, 대개 문법보다 문서 버전과 메시지 순서를 먼저 의심해야 했습니다.
LSP 좌표를 CodeMirror 좌표로 한 번 더 바꿨습니다
앞 글에서 SevenMark AST의 UTF-8 바이트 위치를 LSP의 줄 번호와 UTF-16 위치로 바꾸는 과정을 다뤘습니다.
브라우저에서는 여기서 한 단계가 더 필요합니다. CodeMirror는 자신의 문서 오프셋으로 장식을 배치하기 때문입니다.
SevenMark AST byte span
↓
LSP line + UTF-16 character
↓
CodeMirror document offset
↓
화면의 색상과 밑줄 LSP 시맨틱 토큰은 앞 토큰과의 차이값으로 압축되어 있습니다. 클라이언트는 이를 절대 위치로 풀고, 현재 문서에서 CodeMirror 오프셋으로 변환한 뒤 장식을 만듭니다.
토큰 종류를 해석할 때도 숫자를 하드코딩하지 않았습니다. 초기화 응답의 legend를 저장해 Rust 쪽 토큰 순서가 바뀌더라도 이름을 기준으로 연결합니다.
오류 진단은 publishDiagnostics로 받고, 자동완성과 설명은 사용자가 요청한 시점에 가져옵니다. 제목과 접기 블록의 범위도 같은 문서 버전에서 계산합니다.
파서의 위치 계산이 조금만 틀리거나, LSP와 CodeMirror 변환 중 하나가 다른 버전의 원문을 보면 화면에서 바로 티가 납니다. 이 연결을 만들며 언어 도구는 파서 하나만 잘 만든다고 끝나는 일이 아니라는 걸 또 느꼈습니다.
같은 파서를 쓴다고 권한까지 브라우저에 맡기지는 않았습니다
서버와 브라우저가 공유하는 것은 SevenMark 문법의 구조와 정적인 진단입니다.
문서 ACL, include할 문서의 실제 본문, 파일의 공개 상태처럼 서버 데이터가 필요한 판단은 브라우저가 결정하지 않습니다.
브라우저 미리보기는 사용자가 입력한 원문을 빠르게 보여줄 수 있습니다. 하지만 최종 저장과 공개 렌더링은 서버의 권한 검사와 리소스 조회를 거칩니다. 생성된 HTML과 CSS도 별도의 새니타이저와 격리된 렌더러를 통과합니다.
같은 문법을 이해한다
≠
같은 데이터를 읽을 권한이 있다 WASM으로 서버 코드를 가져올 수 있다고 해서 서버의 권한까지 가져오면 안 됩니다. 문법 해석과 데이터 권한은 별도의 경계로 남겼습니다.
중복은 줄었지만 배포할 것은 늘었습니다
하나의 문법 구현을 공유하면서 얻은 이점은 분명했습니다.
새 AST 노드를 추가하면 렌더러와 포매터, LSP에서 빠진 처리가 컴파일 오류로 드러납니다. 서버와 에디터가 같은 입력을 다르게 읽는 문제도 한곳에서 고칠 수 있습니다.
대신 관리할 것도 생겼습니다.
- WASM 모듈의 초기화와 로딩 실패
- UI와 npm 패키지의 SevenMark 버전 맞추기
- Worker와 메인 스레드의 메시지 생명주기
- 열린 문서의 버전과 요청 순서
- Rust의 UTF-8 위치와 브라우저의 UTF-16 위치 변환
중복 구현의 비용을 완전히 없앤 것은 아닙니다. 하나의 언어 구현을 여러 환경에 같은 버전으로 배포하고 연결하는 비용으로 바꾼 셈입니다.
그래도 저는 이쪽이 훨씬 마음에 들었습니다. 문법을 바꿀 때 서버와 브라우저의 정규식을 따로 찾아다니는 것보다, 하나의 AST와 LSP가 어디서든 같은 의미를 내놓는 편이 SevenMark라는 언어를 계속 키우기에 훨씬 나았기 때문입니다.
이제 문법 도구는 어느 정도 자리를 잡았습니다. 다음 글부터는 Sevenwiki의 백엔드를 여러 번 갈아엎으며, 결국 Auth와 Region, Media, Sitelink로 나누게 된 이야기를 해보겠습니다.