Rust로 장난감 LSP 서버를 직접 만들어 보며 기본 구조부터 실제 에디터 연결, 자동완성·문서 수정·간단한 챗봇까지 확장하는 과정을 살펴본다.
A hands-on guide to building toy LSP servers in Rust
지난 몇 주 동안 저는 여러 에디터와 플랫폼 사이에서 코드를 공유할 수 있는 해법을 찾고 있었습니다. 저는 CodeOwners platform을 만들고 있고, 제공 기능의 일부는 개발자들이 사용하는 각자의 에디터(Visual Studio Code, neovim, Zed 등)와의 다양한 통합, 그리고 잠재적으로는 LLM 에이전트와의 통합입니다. 시작부터 각 에디터마다 별도의 통합이 필요하다는 점은 알고 있었지만, CODEOWNERS 규칙을 위한 패턴 매칭 로직은 모두에서 동일합니다. 그리고 이 코드가 Lua에서 실행되든 Rust에서 실행되든 일관된 결과를 내는 것이 중요했습니다.
그래서 과제는 두 가지였습니다. 어떻게 하면 이 로직을 플랫폼과 언어 전반에 걸쳐 일관되게 유지할 수 있을지, 그리고 업데이트를 할 때 어떻게 동기화를 유지할지였습니다. 한 가지 아이디어는 WebAssembly로 로직을 캡슐화해 어디서든 같은 코드가 패턴 매칭을 담당하게 하는 것이었습니다. 하지만 또 다른 난관이 있었습니다. 바로 속도입니다. CODEOWNERS CLI는 소유권 데이터를 찾기 위해 모든 파일을 읽기 때문에, 이 부분은 WASM으로 할 수 없고 각 에디터의 확장(해당 에디터가 지원하는 언어로 작성) 안에 있어야 합니다. Rust라면 빠르게 만들 수 있지만, 다른 언어에서는 항상 가능한 선택지가 아닙니다. (이론적으로는 C 바인딩을 쓸 수도 있지만, 제가 원했던 것보다 훨씬 빨리 복잡해지고 있었습니다.)
Helix처럼 플러그인 시스템이 없는 에디터의 확장 지원을 살펴보던 중, 저는 완전히 다른 접근을 발견했습니다. 바로 LSP입니다. CODEOWNERS 규칙을 위한 LSP 서버를 만들면 어떨까? 처음엔 말도 안 된다고 생각했습니다. 제 직감으로는 LSP 서버를 만드는 건 엄청나게 어렵다고 느꼈는데, 그 인상은 제 Neovim 설정을 위해 LSP 서버를 세팅할 때 겪었던 고통스럽고 버그투성이인 경험에서 왔습니다. 하나를 _설치_하는 것도 그렇게 어려웠다면, 만드는 건 얼마나 더 어렵겠는가?
결론은, 생각보다 훨씬 어렵지 않았습니다.
LSP는 프로토콜입니다. 에디터가 통신하는 서버를 정의합니다. LSP 서버를 이해하기 위한 가장 단순한 멘탈 모델은 다음과 같습니다. JSON 객체를 받고 JSON 객체로 응답하는 TCP 서버입니다.
명세(spec)는 그 JSON 객체들이 어떻게 생겼는지, 메서드 이름이 무엇을 의미하는지(textDocument/completion, textDocument/hover 등), 어떤 필드를 기대해야 하는지, 무엇을 돌려줘야 하는지를 표준화합니다. 에디터는 프로토콜을 말하고, 서버는 듣고 응답합니다. 그리고 프로토콜이 어디서나 같기 때문에, LSP를 구현한 어떤 에디터든 LSP를 구현한 어떤 서버와도 대화할 수 있습니다.
Rust로 LSP를 구현하는 선택지는 몇 가지가 있습니다. 가장 유명한 것은 tower-lsp이지만, 아쉽게도 그 프로젝트는 약 3년 동안 업데이트가 없었습니다. 대안들도 활동이 많지는 않습니다. LSP는 다소 니치한 영역이니까요. 이 글에서는 tower-lsp의 커뮤니티 포크이면서 활발히 유지보수되는 tower-lsp-server를 사용하겠습니다.
먼저 의존성을 추가합니다:
[package]
name = "lsp-fun"
version = "0.1.0"
edition = "2024"
[dependencies]
tower-lsp-server = "0.23.0"
다음으로 LanguageServer 트레이트를 구현하는 구조체를 정의합니다. 이 트레이트가 필수로 요구하는 메서드는 initialize와 shutdown 두 개뿐입니다. 나머지는 전부 선택 사항이며, 기본적으로 아무 것도 하지 않는(no-op) 구현이 제공됩니다. 가장 미니멀한 형태에서는 서버가 정말 아무 것도 하지 않습니다:
use tower_lsp_server::{
LanguageServer, LspService,
ls_types::{InitializeParams, InitializeResult},
};
#[derive(Debug)]
struct Backend {}
impl LanguageServer for Backend {
아직 Tokio나 다른 비동기 런타임에 특화된 것에 대한 의존성은 전혀 없습니다. 이 코드는 WASM으로도 컴파일될 수 있어서, 브라우저에서 실행하는 것도 가능합니다.
cargo run을 실행하면 서비스가 라우팅할 수 있는 메서드의 전체 목록을 볼 수 있습니다:
[src/main.rs:24:5] lsp_service = (
LspService {
inner: Router {
server: Backend,
methods: [
"textDocument/foldingRange",
"textDocument/references",
"workspace/symbol",
"textDocument/prepareTypeHierarchy",
LspService::new는 튜플을 반환합니다. 서비스 자체와 ClientSocket인데, 이는 본질적으로 tx/rx 채널로서 나중에 서버에서 에디터로(그리고 그 반대로) 메시지를 푸시하는 데 사용합니다.
서비스와 통신하기 위해 클라이언트가 필요할까요? 아니요. 저수준을 이해하기 위해 실행 중인 서버조차 필요 없습니다. 아래는 initialize 함수를 서비스로 보내고 수동으로 언랩(unwrapping)하는 예시입니다. tower-service, serde_json, futures 크레이트를 추가해야 합니다.
서비스를 테스트하는 데 에디터(또는 서버)조차 필요 없습니다. JSON-RPC initialize 요청을 수동으로 실행하고 응답을 확인할 수 있습니다. 의존성에 tower-service, serde_json, futures를 추가한 뒤 아래를 시도해 보세요:
use tower_lsp_server::{
LanguageServer, LspService,
ls_types::{InitializeParams, InitializeResult},
};
use tower_service::Service;
#[derive(Debug)]
struct Backend {}
Output:
Server response: Ok(
Some(
Response {
jsonrpc: Version,
result: Object {
"capabilities": Object {},
},
id: Number(
1,
서버는 빈 capabilities 객체로 응답했습니다. 우리가 아무 기능도 선언하지 않았으니 당연하죠. 프로토콜과 서버의 이런 분리는 정말 유용하고, Rust와 그 생태계에서 제가 가장 좋아하는 점 중 하나입니다. 서버나 에디터를 에뮬레이션하지 않고도 단위 테스트를 어떻게 작성할 수 있을지 이미 감이 오실 겁니다.
이제 실제 TCP 서버를 붙이고 Neovim에 연결해 봅시다. Tokio를 추가합니다:
tokio = { version = "1.50.0", features = [
"macros",
"rt-multi-thread",
"io-std",
"io-util",
"net",
] }
use tower_lsp_server::{
Client, LanguageServer, LspService, Server,
ls_types::{InitializeParams, InitializeResult, InitializedParams, MessageType},
};
#[derive(Debug)]
struct Backend {
client: Client,
}
Client 구조체(클로저 안에서 프레임워크가 주입해 줌)는 에디터와 상호작용하는 방법입니다. 여기서는 초기화 직후 로그 메시지를 보내는 데 사용합니다. 서버는 이 핸들을 통해 언제든 에디터로 정보를 푸시할 수 있습니다.
서버를 실행한 뒤 Neovim에서 한 줄 명령으로 연결합니다:
:lua vim.lsp.start({ name = 'custom_tcp', cmd = vim.lsp.rpc.connect('127.0.0.1', 9292), root_dir = vim.fn.getcwd() })
:LspInfo를 실행하면 살아있는 것을 확인할 수 있습니다:
vim.lsp: Active Clients ~
custom_tcp (id: 4)
Version: ? (no serverInfo.version response)
Root directory: ~/Documents/lsp-trials
Command: <function @/usr/share/nvim/runtime/lua/vim/lsp/rpc.lua:626>
Settings: {}
Attached buffers: 24
이렇게 해서 실제로 동작하는 LSP 서버가 완성되었습니다.
그렇다면 이걸로 실제로 무엇을 할 수 있을까요? 감을 잡기 위해 먼저 조금 유치한 것부터 시작해 보고, 그다음 더 말도 안 되는 쪽으로 확장해 봅시다.
%에서 트리거되고 항목 하나를 제안하는 completion 핸들러를 추가해 봅시다. initialize에서 capability를 알리고, completion 메서드를 구현합니다:
use tower_lsp_server::{
Client, LanguageServer, LspService, Server,
ls_types::{
CompletionItem, CompletionItemKind, CompletionOptions, CompletionParams,
CompletionResponse, InitializeParams, InitializeResult, InitializedParams, MessageType,
ServerCapabilities,
},
};
에디터에서 %를 입력하면 자동완성 팝업이 나타납니다. label은 목록에 표시되는 텍스트이고, detail은 옆에 보이는 고스트 텍스트이며, insert_text는 제안을 수락했을 때 파일에 실제로 삽입되는 내용입니다.

서버는 변경에 반응해 문서를 _수정_할 수도 있습니다. 어떤 _사람들_은 좋아할 만한 예시로, 특정 문구를 감시하다가 apply_edit로 즉시 치환해 버리는 서버를 소개합니다:
use std::collections::HashMap;
use tower_lsp_server::{
Client, LanguageServer, LspService, Server,
ls_types::{
DidChangeTextDocumentParams, InitializeParams, InitializeResult, InitializedParams,
MessageType, Position, Range, ServerCapabilities, TextDocumentSyncCapability,
TextDocumentSyncKind, TextEdit, WorkspaceEdit,
},
이제 EU Commission sucks를 입력할 때마다 텍스트가 사라집니다. 더 나아가 관련 당사자에게 경고하도록 API 요청을 보내는 것도 가능하겠죠. 끝없는 가능성을 상상해 보세요!

아예 대놓고 멍청한(혹은 아닐지도 모르는) 건 어떨까요? ##로 시작하고 개행으로 끝나는 줄을 트리거로 해서 OpenAI 호환 엔드포인트로 API 요청을 보내고, 그 응답을 다음 줄에 반환하는 겁니다!

[dependencies]
tokio = { version = "1.50.0", features = [
"macros",
"rt-multi-thread",
"io-std",
"io-util",
"net",
] }
tower-lsp-server = "0.23.0"
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use tower_lsp_server::{
Client, LanguageServer, LspService, Server,
ls_types::{
이전 코드는 일부가 LLM으로 생성된 것이어서, 특히 LLM 엔드포인트로 보내는 요청은 실제 돈이 들기 때문에, 추천해서 쓸 만한 것이라기보다는 장난감 프로그램에 가깝습니다.
그렇다면 왜 LSP 서버는 프로그래밍 언어 영역을 넘어 더 널리 쓰이지 않을까요? 솔직히 잘 모르겠습니다. 저는 이 분야에 아직 새로워서, LSP가 MCP의 대안으로서 의미가 있을지 완전히 평가할 수는 없습니다. LSP는 정해진 메서드 집합을 중심으로 설계되어서 특정 한계가 있다고 주장할 수도 있고, 에디터를 위해 특별히 설계된 것이라고 말할 수도 있습니다. 하지만 한편으로는, 요즘 AI를 둘러싼 상황을 보면 그게 오히려 최선이었을지도 모르겠습니다.
여기까지 읽으셨다면, 앞으로의 글을 이메일로 받아보기 위해 뉴스레터 구독도 꼭 해 주세요!