Language Server Protocol: 에디터와 언어 서버를 잇는 표준

에디터에서 함수 이름 위에 마우스를 올리면 타입이 뜨고, F12를 누르면 정의로 이동하고, 틀린 코드에는 빨간 밑줄이 그어집니다.
너무 익숙해서 에디터가 원래 하는 일처럼 느껴지는데요.
사실 이 기능들은 에디터가 아니라 뒤에서 돌아가는 언어 서버(language server)가 만들어 주는 경우가 대부분입니다.
VS Code, Neovim, Zed, JetBrains IDE처럼 서로 전혀 다른 에디터가 같은 언어 서버를 가져다 쓸 수 있는 건 언어 서버 프로토콜(Language Server Protocol, 이하 LSP) 덕분이에요. 이번 글에서는 LSP가 어떤 문제를 풀었는지, 에디터와 언어 서버가 실제로 어떤 메시지를 주고받는지 TypeScript 언어 서버를 직접 띄워서 살펴보겠습니다. 🔍
LSP가 풀려는 문제
LSP가 나오기 전에는 에디터마다 언어 지원을 따로 만들어야 했습니다. Vim용 TypeScript 플러그인, Emacs용 TypeScript 플러그인, Sublime Text용 TypeScript 플러그인이 각자 타입 검사기를 붙이는 식이었죠. 에디터가 M개, 언어가 N개라면 M×N개의 통합 작업이 필요했고, 인기 없는 에디터나 새 언어는 늘 뒤처졌습니다.
LSP는 이 사이에 공통 규약을 하나 둡니다. 언어마다 언어 서버를 하나만 만들고, 에디터는 LSP 클라이언트만 구현하면 모든 언어 서버와 대화할 수 있어요. 통합 작업이 M×N개에서 M+N개로 줄어드는 거죠.
flowchart TB
accTitle: LSP가 에디터와 언어 서버를 잇는 구조
accDescr: VS Code, Neovim, Zed 같은 에디터는 각자 LSP 클라이언트를 하나씩 구현하고, TypeScript, Rust, Python 언어 서버는 각자 LSP 서버를 하나씩 구현한다. 둘 사이는 LSP라는 공통 규약 하나로 연결된다.
subgraph editors["에디터 (LSP 클라이언트)"]
vscode["VS Code"]
neovim["Neovim"]
zed["Zed"]
end
lsp{{"LSP"}}
subgraph servers["언어 서버"]
ts["TypeScript"]
rust["rust-analyzer"]
py["Pyright"]
end
editors <--> lsp <--> servers
LSP는 2016년 6월 27일 Microsoft, Red Hat, Codenvy가 함께 발표했습니다. VS Code를 만들면서 쓰던 언어 지원 방식을 명세로 정리해서 GitHub에 공개한 거예요. 발표 당시에는 VS Code와 Eclipse Che 정도만 지원했고, 쓸 만한 언어 서버도 몇 개 없었어요. 그 뒤 Pyright, gopls, rust-analyzer처럼 언어마다 주력 언어 서버가 자리를 잡았고, 2021년 7월 Neovim 0.5가 LSP 클라이언트를 에디터에 내장하면서 LSP는 VS Code만의 방식이 아니라 에디터 전반의 표준이 됐습니다. 2023년에는 Emacs 29가 LSP 클라이언트인 Eglot을 기본으로 넣고 JetBrains IDE도 LSP를 지원하기 시작하면서, 지금은 사실상 모든 주요 에디터가 LSP를 말합니다.
통신 방식
LSP의 메시지 형식은 JSON-RPC 2.0입니다. 에디터가 언어 서버를 하위 프로세스(subprocess)로 띄우고, 표준 입출력(stdio)으로 메시지를 주고받는 게 가장 흔한 방식이에요.
한 가지 특이한 점은 메시지마다 HTTP처럼 헤더가 붙는다는 겁니다.
Content-Length: 204\r\n
\r\n
{"jsonrpc":"2.0","id":2,"result":{"contents":{"kind":"plaintext","value":"(alias) function add(a: number, b: number): number"},"range":{"start":{"line":2,"character":22},"end":{"line":2,"character":25}}}}
Content-Length에 본문의 바이트 수를 적고, 빈 줄 다음에 JSON 본문을 붙입니다.
받는 쪽은 헤더를 읽고 정확히 그 길이만큼 잘라야 메시지 하나를 얻을 수 있어요.
JSON 안에 줄바꿈이 들어 있어도 경계가 헷갈리지 않도록 이렇게 설계된 겁니다.
비슷한 구조의 ACP(Agent Client Protocol)가 메시지를 한 줄에 하나씩 보내는 것과 비교하면 조금 더 번거롭지만 그만큼 견고하죠.
메시지는 세 종류로 나뉩니다.
우선 id가 있어서 응답을 기다리는 요청(request)이 있고, 그 요청에 돌아오는 응답(response)이 있어요.
그리고 id 없이 한 방향으로 보내고 끝나는 알림(notification)이 있습니다.
에디터가 파일을 열거나 고쳤다고 알릴 때처럼 답이 필요 없는 메시지는 알림으로 보내요.
메시지 흐름
에디터와 언어 서버의 대화는 대략 다음 순서로 흘러갑니다.
sequenceDiagram
accTitle: LSP 세션의 메시지 흐름
accDescr: 에디터가 initialize로 서로의 기능을 확인하고 initialized로 준비를 알린다. 파일을 열면 didOpen 알림을 보내고, 호버와 정의로 이동, 진단은 요청과 응답으로 처리한다. 끝낼 때는 shutdown 요청 뒤에 exit 알림을 보낸다.
participant E as 에디터
participant S as 언어 서버
E->>S: initialize
S-->>E: 지원 기능 (capabilities)
E-)S: initialized
E-)S: textDocument/didOpen
E->>S: textDocument/hover
S-->>E: 타입 정보
E->>S: textDocument/definition
S-->>E: 정의 위치
E->>S: textDocument/diagnostic
S-->>E: 오류 목록
E->>S: shutdown
E-)S: exit
가장 먼저 initialize 요청으로 서로 무엇을 할 수 있는지 확인합니다.
이 과정을 기능 협상(capability negotiation)이라고 하는데요.
에디터는 자기가 지원하는 기능을 capabilities에 담아 보내고, 언어 서버도 자기가 제공하는 기능을 응답에 담아 돌려줍니다.
TypeScript 7 언어 서버에 빈 capabilities로 요청해 보니 다음 기능을 제공한다고 답했어요.
positionEncoding, textDocumentSync, completionProvider, hoverProvider, signatureHelpProvider,
definitionProvider, typeDefinitionProvider, implementationProvider, referencesProvider,
documentHighlightProvider, documentSymbolProvider, codeActionProvider, codeLensProvider,
workspaceSymbolProvider, documentFormattingProvider, documentRangeFormattingProvider,
documentOnTypeFormattingProvider, renameProvider, foldingRangeProvider, selectionRangeProvider,
callHierarchyProvider, linkedEditingRangeProvider, semanticTokensProvider, inlayHintProvider,
diagnosticProvider, workspace, experimental, ...
자동 완성(completionProvider), 참조 찾기(referencesProvider), 이름 바꾸기(renameProvider), 인레이 힌트(inlayHintProvider)처럼 우리가 에디터에서 쓰는 기능 이름이 그대로 보이죠.
에디터는 이 목록을 보고 해당 메뉴를 켜거나 끕니다.
그다음 에디터가 파일을 열면 textDocument/didOpen 알림으로 파일 내용을 통째로 보내고, 사용자가 타이핑할 때마다 textDocument/didChange로 바뀐 부분을 알립니다.
언어 서버는 디스크의 파일이 아니라 에디터가 보내 준 내용을 기준으로 분석하기 때문에, 아직 저장하지 않은 코드에도 빨간 밑줄을 그을 수 있는 거예요.
Bun으로 LSP 클라이언트 만들어 보기
실제로 메시지를 주고받아 보겠습니다.
언어 서버로는 TypeScript 7을 씁니다.
Go로 다시 작성된 TypeScript 7은 tsc --lsp --stdio로 실행하면 컴파일러 자체가 언어 서버로 동작해요.
예전에는 typescript-language-server가 TypeScript의 tsserver를 감싸서 LSP로 바꿔 주는 어댑터 역할을 했는데, TypeScript 7에는 tsserver.js가 없어서 이 어댑터는 더 이상 붙지 않습니다.
먼저 분석할 파일 두 개를 준비합니다.
main.ts의 4행에는 일부러 타입 오류를 넣었어요.
export function add(a: number, b: number): number {
return a + b;
}
import { add } from "./math";
const total: number = add(1, 2);
const label: string = add(3, 4);
TypeScript를 설치합니다.
bun add -d typescript
그리고 에디터 역할을 할 작은 클라이언트를 만듭니다.
3행의 add 위에 커서를 올린 것처럼 호버, 정의로 이동, 진단을 차례로 요청해 볼게요.
// 1. 언어 서버를 하위 프로세스로 띄운다
const server = Bun.spawn(["bunx", "tsc", "--lsp", "--stdio"], {
stdin: "pipe",
stdout: "pipe",
stderr: "ignore",
});
let nextId = 1;
const pending = new Map<number, (result: any) => void>();
// 2. LSP 메시지는 Content-Length 헤더를 붙여 보낸다
function send(message: object) {
const body = JSON.stringify({ jsonrpc: "2.0", ...message });
server.stdin.write(
`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`,
);
}
function request(method: string, params: object): Promise<any> {
const id = nextId++;
send({ id, method, params });
return new Promise((resolve) => pending.set(id, resolve));
}
function notify(method: string, params: object) {
send({ method, params });
}
// 3. 헤더에 적힌 길이만큼 잘라서 메시지 하나로 읽는다
async function listen() {
let buffer = Buffer.alloc(0);
for await (const chunk of server.stdout) {
buffer = Buffer.concat([buffer, chunk]);
while (true) {
const headerEnd = buffer.indexOf("\r\n\r\n");
if (headerEnd < 0) break;
const length = Number(
/Content-Length: (\d+)/.exec(
buffer.subarray(0, headerEnd).toString(),
)![1],
);
if (buffer.length < headerEnd + 4 + length) break;
const message = JSON.parse(
buffer.subarray(headerEnd + 4, headerEnd + 4 + length).toString(),
);
buffer = buffer.subarray(headerEnd + 4 + length);
if (message.id !== undefined && message.method) {
// 서버가 거꾸로 보낸 요청에도 응답해야 서버가 멈추지 않는다
const result =
message.method === "workspace/configuration"
? message.params.items.map(() => null)
: null;
send({ id: message.id, result });
} else if (message.id !== undefined) {
pending.get(message.id)?.(message.result);
}
}
}
}
listen();
const root = process.cwd();
const uri = `file://${root}/main.ts`;
const { capabilities, serverInfo } = await request("initialize", {
processId: process.pid,
rootUri: `file://${root}`,
capabilities: {},
});
console.log(`연결됨: ${serverInfo.name} ${serverInfo.version}`);
console.log(
`정의로 이동: ${!!capabilities.definitionProvider}, 호버: ${!!capabilities.hoverProvider}`,
);
notify("initialized", {});
// 4. 에디터가 파일을 열었다고 알린다
notify("textDocument/didOpen", {
textDocument: {
uri,
languageId: "typescript",
version: 1,
text: await Bun.file("main.ts").text(),
},
});
// 5. 3행의 add 위에 커서를 올린 것처럼 물어본다 (0부터 센다)
const position = { line: 2, character: 22 };
const hover = await request("textDocument/hover", {
textDocument: { uri },
position,
});
console.log(`[호버] ${hover.contents.value ?? hover.contents}`);
const [definition] = await request("textDocument/definition", {
textDocument: { uri },
position,
});
console.log(
`[정의] ${definition.uri.split("/").pop()} ${definition.range.start.line + 1}행`,
);
// 6. 이 파일의 오류 목록을 달라고 요청한다
const { items } = await request("textDocument/diagnostic", {
textDocument: { uri },
});
for (const d of items) {
console.log(`[진단] ${d.range.start.line + 1}행: ${d.message}`);
}
await request("shutdown", {});
notify("exit", {});
실행하면 다음과 같이 나옵니다.
bun lsp-client.ts
연결됨: typescript-go 7.0.2
정의로 이동: true, 호버: true
[호버] (alias) function add(a: number, b: number): number
[정의] math.ts 1행
[진단] 4행: Type 'number' is not assignable to type 'string'.
[진단] 3행: 'total' is declared but its value is never read.
[진단] 4행: 'label' is declared but its value is never read.
에디터에서 마우스를 올렸을 때 뜨는 타입 정보, F12로 이동하는 위치, 빨간 밑줄로 보이는 오류가 전부 이 몇 개의 요청으로 만들어진다는 걸 확인할 수 있습니다.
VS Code든 Neovim이든 원리는 같고, 이 응답을 각자의 화면에 그려 줄 뿐이에요.
직접 만들어 보면 부딪히는 것들
처음 이 클라이언트를 만들었을 때는 initialized를 보낸 뒤로 서버가 아무 응답도 하지 않고 멈췄습니다.
원인은 메시지가 양방향이라는 데 있었어요.
initialized를 받은 TypeScript 언어 서버가 거꾸로 클라이언트에게 client/registerCapability 요청을 보내고 응답을 기다리고 있었거든요.
{
"jsonrpc": "2.0",
"id": "ts1",
"method": "client/registerCapability",
"params": {
"registrations": [
{
"id": "typescript-config-watch-id",
"method": "workspace/didChangeConfiguration",
"registerOptions": {
"section": ["js/ts", "typescript", "javascript", "editor"],
},
},
],
},
}
LSP에서는 에디터만 묻는 게 아니라 언어 서버도 에디터에게 설정값(workspace/configuration)을 묻거나, 기능 등록(client/registerCapability)을 요청합니다.
그래서 클라이언트의 listen()은 들어온 메시지에 method와 id가 둘 다 있으면 서버의 요청으로 보고 답을 보내야 해요.
서버가 보낸 id가 숫자가 아니라 "ts1" 같은 문자열이라는 점도 재밌습니다. JSON-RPC에서는 id로 문자열도 쓸 수 있거든요.
진단도 처음에는 오지 않았습니다.
LSP에는 진단을 받는 방법이 두 가지 있는데요.
언어 서버가 알아서 textDocument/publishDiagnostics 알림으로 밀어 주는 푸시(push) 방식과, 클라이언트가 textDocument/diagnostic으로 필요할 때 요청하는 풀(pull) 방식입니다.
풀 방식은 LSP 3.17에서 추가됐는데, 이번 실험처럼 클라이언트가 빈 capabilities를 보내면 TypeScript 7은 진단을 먼저 밀어 주지 않았어요.
그래서 예제에서는 6번 단계처럼 직접 요청하도록 했습니다.
마지막으로 위치를 셀 때 조심할 점이 있습니다.
position의 line과 character는 0부터 세는데, character는 기본적으로 글자 수가 아니라 UTF-16 코드 단위(code unit) 수예요.
initialize 응답에 "positionEncoding":"utf-16"이 들어 있던 게 바로 이 뜻입니다.
한글은 한 글자가 UTF-16 코드 단위 하나라 문제가 없지만, 이모지처럼 코드 단위 두 개를 차지하는 문자가 앞에 있으면 위치가 한 칸씩 밀려요.
그래서 LSP 3.17부터는 기능 협상 단계에서 UTF-8이나 UTF-32 인코딩을 고를 수 있게 됐습니다.
주요 언어 서버
언어마다 널리 쓰이는 언어 서버가 있습니다.
- TypeScript: TypeScript 7부터는
tsc --lsp --stdio, 그 전에는typescript-language-server - Rust:
rust-analyzer - Python: Pyright와 그 파생인 basedpyright, 린터와 포매터를 맡는 Ruff의
ruff server - Go:
gopls - C와 C++:
clangd
에디터마다 언어 서버를 연결하는 방법은 다릅니다. VS Code는 언어 확장 프로그램이 언어 서버를 함께 설치하고 실행해 주고, Zed는 주요 언어의 언어 서버를 알아서 내려받아요. Neovim은 LSP 클라이언트를 내장하고 있어서 설정 몇 줄이면 되고, LazyVim 같은 배포판을 쓰면 이마저도 미리 되어 있습니다. Helix는 언어별로 띄울 언어 서버를 이미 알고 있어서, 언어 서버 프로그램만 설치하면 설정 없이 바로 연결됩니다.
LSP에서 뻗어 나온 것들
LSP가 성공하자 같은 발상을 다른 영역에 적용한 프로토콜이 여럿 나왔습니다. 디버거를 에디터와 분리한 Debug Adapter Protocol(DAP)이 대표적이에요. VS Code의 중단점과 변수 보기 화면이 언어마다 같은 모양인 건 DAP 덕분입니다.
AI 코딩 도구도 이 흐름을 이어받았습니다. MCP(Model Context Protocol)는 LSP에서 영감을 받았다고 명시하고 있고, 앞에서 비교한 ACP는 에디터와 코딩 에이전트 사이에 LSP 같은 공통 규약을 두려는 시도예요. 또 클로드 코드 플러그인으로 언어 서버를 연결하면 AI 에이전트가 텍스트 검색 대신 정의로 이동이나 참조 찾기를 써서 코드를 더 정확하게 탐색합니다. 사람을 위해 만든 언어 서버를 이제 에이전트도 쓰고 있는 셈이죠.
마치며
LSP는 에디터와 언어 지원을 분리해서 M×N 문제를 M+N으로 줄인 프로토콜입니다.
메시지는 Content-Length 헤더가 붙은 JSON-RPC이고, 기능 협상으로 시작해서 파일 동기화와 요청과 응답으로 이어지며, 언어 서버도 에디터에게 요청을 보내는 양방향 구조예요.
직접 만든 클라이언트에 textDocument/completion이나 textDocument/rename 요청을 추가해 보면 에디터의 자동 완성과 이름 바꾸기가 어떻게 동작하는지 감이 올 겁니다.
더 자세한 내용은 LSP 공식 명세를 참고하세요.
This work is licensed under CC BY 4.0