- Published on
Language Server Protocol Deep Dive — JSON-RPC, Document Sync, Completion, Semantic Tokens 완전 정복 (2025)
- Authors

- Name
- Youngju Kim
- @fjvbn20031
TL;DR
- LSP는 Microsoft가 2016년 공개한 에디터 ↔ 언어 서버 통신 프로토콜. VS Code의 설계에서 파생.
- N×M 문제 해결: 이전에는 N개 에디터 × M개 언어 = 수백 개의 플러그인이 필요했다. LSP는 모든 에디터가 같은 protocol을 쓰게 해서 N + M으로 축소.
- JSON-RPC 2.0 기반. STDIO 또는 TCP/socket 위에 request/response/notification 교환.
- Initialize 핸드셰이크: client와 server가 서로의 capabilities를 선언. 지원하는 기능만 요청.
- Document Sync:
didOpen/didChange/didSave/didClose. Full 또는 Incremental 업데이트. - 핵심 요청:
completion,hover,definition,references,rename,formatting,codeAction. - Diagnostics: 서버가
publishDiagnosticsnotification으로 push, 또는 LSP 3.17+의pullDiagnostics. - Semantic Tokens (3.16+): TextMate 정규식 문법의 대체. 서버가 정확한 심볼 분류 제공.
- 대표 서버: rust-analyzer, clangd, gopls, pyright, typescript-language-server, Sorbet, Phoenix LiveView LSP.
- 2024-2025: LSP가 AI 코딩 도구의 기반. Copilot이 LSP 위에 Copilot LSP 확장.
1. LSP 이전 — 에디터의 어둠
1.1 N×M 문제
2000년대 후반, 개발자가 새 언어를 배우려 하면:
- 에디터(예: Vim, Emacs, SublimeText, VS) 플러그인 찾기.
- 있으면 설치, 없으면 직접 작성.
- 언어 X의 IntelliSense 품질은 에디터마다 완전히 다름.
- 새 언어(Rust, Go, Kotlin)가 나오면 N개 에디터 모두에 플러그인 필요.
N개 에디터 × M개 언어 = 수백 개의 플러그인. 각 플러그인은 독립적으로 언어 분석기를 구현해야 했다. 누구도 고품질을 달성하지 못했다.
1.2 IDE vs 에디터의 격차
IDE(Eclipse, IntelliJ IDEA, Visual Studio):
- 자체 언어 분석 엔진.
- 정확한 IntelliSense, refactoring, navigation.
- 무겁고 느림.
- 한 언어에 종속.
에디터(Vim, Emacs, Sublime):
- 빠르고 가벼움.
- 하지만 IDE 같은 기능은 제한적.
- 정규식 기반 syntax highlighting.
- 문법 이해 없음.
2015년까지 "Vim과 IntelliJ 둘 다 좋지만 IntelliJ의 지능 + Vim의 속도는 불가능"이라는 통념.
1.3 Microsoft의 동기
2015년 Microsoft는 Visual Studio Code(VS Code)를 출시했다. 설계 목표:
- 빠른 크로스 플랫폼 에디터.
- IDE 수준의 언어 지원.
- 확장 가능한 플러그인 생태계.
문제: "우리도 다시 N×M 플러그인 지옥에 빠질 것인가?"
해결책: 언어 분석을 별도 프로세스로 분리하고 표준 protocol로 통신. VS Code만 쓸 게 아니라 다른 에디터들도 쓸 수 있는 개방형 표준.
1.4 LSP 탄생 (2016)
Microsoft, Red Hat, Codenvy 공동 공개. 핵심 아이디어:
[Editor] ──(LSP protocol)── [Language Server]
↓
Language-specific analysis
(parse, type check,
symbol index, ...)
에디터: dumb. "커서가 여기 있어. 여기서 뭘 할 수 있어?"라고 물음. 언어 서버: smart. 파서, 타입 체커, 심볼 테이블을 가지고 있음.
한 언어에 대한 서버 하나만 만들면 모든 에디터에서 작동. 반대로 에디터가 LSP를 지원하면 모든 언어가 자동 지원.
2. 프로토콜 기본
2.1 JSON-RPC 2.0
LSP는 JSON-RPC 2.0 위에 구축. JSON-RPC는 단순한 원격 프로시저 호출 표준.
Request (응답 필요):
{
"jsonrpc": "2.0",
"id": 1,
"method": "textDocument/hover",
"params": {
"textDocument": { "uri": "file:///workspace/main.rs" },
"position": { "line": 10, "character": 5 }
}
}
Response:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"contents": {
"kind": "markdown",
"value": "`fn main() -> ()`"
}
}
}
Notification (응답 없음):
{
"jsonrpc": "2.0",
"method": "textDocument/didChange",
"params": { ... }
}
id가 없으면 notification. 서버가 fire-and-forget으로 처리.
2.2 Content-Length 헤더
LSP는 JSON-RPC 메시지를 HTTP-like 헤더 다음에 넣는다:
Content-Length: 156\r\n
\r\n
{"jsonrpc":"2.0","id":1,"method":"textDocument/hover","params":...}
왜? TCP/STDIO는 스트림이라 메시지 경계를 알 수 없다. 헤더가 "다음 N 바이트가 이 메시지"라고 알려줌.
2.3 Transport
LSP는 transport에 무관. 가장 흔한 것:
- STDIO: 에디터가 서버를 자식 프로세스로 실행, stdin/stdout으로 통신. 가장 단순.
- Named Pipe: Windows에서 주로.
- TCP Socket: 원격 서버 또는 디버깅 목적.
VS Code는 일반적으로 STDIO.
2.4 Request/Response 매칭
클라이언트가 여러 요청을 보낼 수 있고 응답 순서는 서버가 정한다. id로 매칭:
Client: id=1 hover
Client: id=2 completion
Server: id=2 completion result (먼저 도착)
Server: id=1 hover result (나중 도착)
클라이언트는 id를 보고 원래 요청과 연결.
2.5 Cancellation
사용자가 빠르게 타이핑하면 오래된 completion 요청은 필요 없어진다. $/cancelRequest:
{
"jsonrpc": "2.0",
"method": "$/cancelRequest",
"params": { "id": 2 }
}
서버는 이 ID 요청의 처리를 중단. Optional이지만 성능에 중요.
3. 생명주기
3.1 Initialize 핸드셰이크
첫 번째 요청은 반드시 initialize:
{
"jsonrpc": "2.0",
"id": 0,
"method": "initialize",
"params": {
"processId": 12345,
"rootUri": "file:///workspace/project",
"capabilities": {
"textDocument": {
"completion": {
"completionItem": {
"snippetSupport": true,
"commitCharactersSupport": true,
"documentationFormat": ["markdown", "plaintext"]
}
},
"hover": {
"contentFormat": ["markdown", "plaintext"]
},
"synchronization": {
"dynamicRegistration": false,
"willSave": false,
"willSaveWaitUntil": false,
"didSave": true
}
},
"workspace": {
"workspaceFolders": true,
"configuration": true
}
}
}
}
Capabilities: 클라이언트가 "나는 이런 기능을 지원해"를 선언. 예: 마크다운 렌더링 지원, snippet 지원, hover 마크다운 등.
3.2 Initialize Response
서버가 자신의 capabilities 응답:
{
"jsonrpc": "2.0",
"id": 0,
"result": {
"capabilities": {
"textDocumentSync": 2,
"hoverProvider": true,
"completionProvider": {
"triggerCharacters": [".", "::"],
"resolveProvider": true
},
"definitionProvider": true,
"referencesProvider": true,
"documentSymbolProvider": true,
"workspaceSymbolProvider": true,
"codeActionProvider": true,
"renameProvider": true,
"semanticTokensProvider": {
"legend": { ... },
"full": true,
"range": true
}
},
"serverInfo": {
"name": "rust-analyzer",
"version": "0.3.1234"
}
}
}
클라이언트는 이 응답을 보고 "이 서버가 hover 지원함 → hover UI 활성화" 등을 결정.
3.3 Initialized Notification
Initialize 완료 후 클라이언트가 initialized notification:
{
"jsonrpc": "2.0",
"method": "initialized",
"params": {}
}
"이제 준비됐어, 요청 보내도 돼"라는 신호.
3.4 Shutdown
에디터 종료 시:
Client: shutdown (request)
Server: null (response)
Client: exit (notification)
Server: process exits
Graceful. shutdown에서 서버는 정리 작업 시작, 새 요청 거부. exit 후 프로세스 종료.
3.5 Dynamic Registration
일부 capability는 runtime에 추가/제거 가능:
{
"method": "client/registerCapability",
"params": {
"registrations": [{
"id": "...",
"method": "textDocument/completion",
"registerOptions": { ... }
}]
}
}
"특정 파일 타입에 대한 completion만 활성화" 같은 시나리오.
4. Document Synchronization
클라이언트와 서버가 같은 파일 내용을 공유해야 한다.
4.1 didOpen
파일을 에디터에서 열면:
{
"method": "textDocument/didOpen",
"params": {
"textDocument": {
"uri": "file:///workspace/main.rs",
"languageId": "rust",
"version": 1,
"text": "fn main() {\n println!(\"hello\");\n}\n"
}
}
}
서버가 이 파일의 전체 내용을 메모리에 로드. 파싱 시작.
4.2 didChange — Full Sync
변경 시 전체 내용 재전송:
{
"method": "textDocument/didChange",
"params": {
"textDocument": {
"uri": "file:///workspace/main.rs",
"version": 2
},
"contentChanges": [{
"text": "fn main() {\n println!(\"world\");\n}\n"
}]
}
}
단순하지만 큰 파일엔 낭비.
4.3 didChange — Incremental Sync
Range 기반 업데이트:
{
"method": "textDocument/didChange",
"params": {
"textDocument": { "uri": "...", "version": 2 },
"contentChanges": [{
"range": {
"start": { "line": 1, "character": 12 },
"end": { "line": 1, "character": 17 }
},
"text": "world"
}]
}
}
"1번 라인 12자 ~ 17자 범위를 'world'로 교체". 서버가 자체 내부 버퍼를 패치.
대부분의 에디터와 서버가 incremental 지원. 타이핑마다 KB 단위로 전송.
4.4 didSave / willSave
저장 시:
{
"method": "textDocument/didSave",
"params": {
"textDocument": { "uri": "..." },
"text": "..." // includeText 옵션에 따라
}
}
willSave는 저장 전 알림. 서버가 formatting 등을 적용할 기회.
4.5 didClose
파일 닫기:
{
"method": "textDocument/didClose",
"params": {
"textDocument": { "uri": "..." }
}
}
서버가 파일을 메모리에서 제거. 단 워크스페이스 인덱스는 디스크 기반으로 유지.
4.6 버전 추적
각 notification에 version 포함. 서버는 클라이언트 변경을 놓치면 안 됨. 응답도 특정 version 기반이므로, "version 5의 completion"이 version 6 시점에 도착해도 무시 가능.
5. 핵심 기능: Completion
가장 많이 쓰이는 LSP 기능. 타이핑 중 자동완성 제안.
5.1 Trigger
두 가지:
- 명시적: Ctrl+Space.
- Trigger character: 서버가 initialize 응답에 명시한 문자(예:
.,::).
클라이언트가 이 문자 입력 직후 자동으로 요청.
5.2 Request
{
"method": "textDocument/completion",
"params": {
"textDocument": { "uri": "file:///workspace/main.rs" },
"position": { "line": 10, "character": 14 },
"context": {
"triggerKind": 2,
"triggerCharacter": "."
}
}
}
triggerKind:
1: Invoked (명시적).2: TriggerCharacter.3: TriggerForIncompleteCompletions (이전 결과 refresh).
5.3 Response
{
"id": 5,
"result": {
"isIncomplete": false,
"items": [
{
"label": "to_string",
"kind": 2,
"detail": "fn to_string(&self) -> String",
"documentation": {
"kind": "markdown",
"value": "Converts the value to a String."
},
"insertText": "to_string()",
"insertTextFormat": 2,
"sortText": "0001",
"filterText": "to_string",
"data": { "..." }
},
...
]
}
}
필드:
label: UI에 표시되는 텍스트.kind: Method(2), Class(7), Enum(13), Variable(6) 등. 아이콘.detail: 시그니처 등 추가 정보.insertText: 실제 삽입될 텍스트.insertTextFormat: 1=PlainText, 2=Snippet (${1:param}같은 탭 stop 포함).sortText: 정렬 키 (알파벳 순과 다를 수 있음).filterText: 필터링용 (label과 다를 수 있음).
5.4 Resolve
일부 서버는 completionItem/resolve로 lazy 로드:
// 초기 item: 가벼움
{ "label": "to_string", "data": { "idx": 42 } }
// resolve 요청 후: 상세 정보 추가
{
"label": "to_string",
"documentation": "...", // 이제 채워짐
"detail": "..."
}
처음 응답을 빠르게 주고, 사용자가 특정 item에 focus할 때만 상세 로드.
5.5 isIncomplete
isIncomplete: true면 "결과가 불완전, 다음 글자마다 다시 요청해줘". 클라이언트가 triggerKind: 3으로 재요청.
반대로 false면 "이게 전부, client-side에서 필터링해". 네트워크 호출 줄임.
5.6 Snippet
Code snippet은 LSP 3.0+의 기능:
insertText: "if ${1:condition} {\n $0\n}"
${1:placeholder}: Tab stop. 사용자가 Tab으로 넘어가며 편집.
$0: 최종 커서 위치.
VS Code 같은 에디터는 이 형식을 파싱해 인터랙티브 편집 경험 제공.
6. Hover, Definition, References
6.1 Hover
{
"method": "textDocument/hover",
"params": {
"textDocument": { "uri": "..." },
"position": { "line": 10, "character": 5 }
}
}
응답:
{
"contents": {
"kind": "markdown",
"value": "```rust\nfn to_string(&self) -> String\n```\n\nConverts value to String."
},
"range": {
"start": { "line": 10, "character": 3 },
"end": { "line": 10, "character": 12 }
}
}
마크다운으로 렌더링. 코드 블록도 syntax highlight.
6.2 Go to Definition
{
"method": "textDocument/definition",
"params": {
"textDocument": { "uri": "..." },
"position": { "line": 10, "character": 5 }
}
}
응답:
{
"uri": "file:///workspace/lib.rs",
"range": {
"start": { "line": 42, "character": 8 },
"end": { "line": 42, "character": 16 }
}
}
에디터가 해당 파일을 열고 해당 위치로 점프.
여러 결과 가능 (Location[]): 예를 들어 JavaScript의 경우 declaration 여러 개.
6.3 Find All References
{
"method": "textDocument/references",
"params": {
"textDocument": { "uri": "..." },
"position": { "line": 10, "character": 5 },
"context": {
"includeDeclaration": true
}
}
}
응답: 이 심볼을 사용하는 모든 Location 배열. 프로젝트 전체 검색.
6.4 Rename
{
"method": "textDocument/rename",
"params": {
"textDocument": { "uri": "..." },
"position": { "line": 10, "character": 5 },
"newName": "new_function_name"
}
}
응답: WorkspaceEdit — 여러 파일의 여러 범위를 한 번에 편집.
{
"changes": {
"file:///workspace/main.rs": [
{ "range": {...}, "newText": "new_function_name" },
{ "range": {...}, "newText": "new_function_name" }
],
"file:///workspace/lib.rs": [
{ "range": {...}, "newText": "new_function_name" }
]
}
}
에디터가 이 edit을 원자적으로 적용.
6.5 Signature Help
함수 호출 중 파라미터 힌트:
func(|) ← 커서 여기
요청: textDocument/signatureHelp.
응답: 현재 함수의 시그니처 + 현재 파라미터 index.
6.6 Document Symbols
파일의 모든 심볼 (outline view):
[
{
"name": "MyClass",
"kind": 5, // Class
"range": { ... },
"selectionRange": { ... },
"children": [
{ "name": "field1", "kind": 8, ... },
{ "name": "method1", "kind": 6, ... }
]
}
]
트리 구조. VS Code의 outline panel이 이것을 표시.
6.7 Workspace Symbols
프로젝트 전체에서 심볼 검색:
{
"method": "workspace/symbol",
"params": { "query": "build" }
}
"build"를 포함하는 모든 심볼 반환. "Go to Symbol in Workspace" (Ctrl+T) 기능.
7. Diagnostics
컴파일 에러, 경고, 힌트 표시.
7.1 Push 방식 (기본)
서버가 파일 분석 후 자발적으로 publishDiagnostics notification:
{
"method": "textDocument/publishDiagnostics",
"params": {
"uri": "file:///workspace/main.rs",
"version": 42,
"diagnostics": [
{
"range": {
"start": { "line": 10, "character": 4 },
"end": { "line": 10, "character": 15 }
},
"severity": 1,
"code": "E0308",
"source": "rustc",
"message": "mismatched types\nexpected `String`, found `&str`",
"relatedInformation": [...]
}
]
}
}
Severity:
1: Error (빨간 밑줄).2: Warning (노란).3: Information.4: Hint.
에디터는 이 정보로 Problems panel, inline squiggly line 표시.
7.2 Pull 방식 (LSP 3.17+)
클라이언트가 필요할 때 요청:
{
"method": "textDocument/diagnostic",
"params": {
"textDocument": { "uri": "..." }
}
}
장점:
- 제어 권한: 클라이언트가 언제 받을지 결정.
- Workspace pull: 모든 파일 diagnostics를 한 번에.
하지만 push 방식이 여전히 주류.
7.3 Diagnostics Source
source: "rustc"처럼 누가 만든 경고인지 표시. 한 파일에 여러 툴의 diagnostic (예: rustc + clippy) 동시 표시 가능.
7.4 Related Information
"relatedInformation": [
{
"location": { "uri": "...", "range": {...} },
"message": "variable defined here"
}
]
"이 에러는 저기서 정의된 변수 때문" 같은 컨텍스트 링크. 에디터가 hover 시 표시.
7.5 Code Actions (Quick Fix)
Diagnostic에 연결된 수정 제안:
{
"method": "textDocument/codeAction",
"params": {
"textDocument": { "uri": "..." },
"range": { ... },
"context": {
"diagnostics": [...],
"only": ["quickfix"]
}
}
}
응답:
[
{
"title": "Add missing import",
"kind": "quickfix",
"diagnostics": [...],
"edit": {
"changes": { "file://...": [...] }
},
"isPreferred": true
}
]
에디터가 quick fix 메뉴로 표시. 사용자가 선택 → edit 적용.
Kind:
quickfix: 에러 수정.refactor: 구조 변경.refactor.extract: 함수/변수 추출.source.organizeImports: import 정리.
8. Semantic Tokens — 혁명
8.1 TextMate 문법의 한계
수십 년 동안 에디터는 TextMate grammar로 syntax highlight. 정규식 기반:
{
"match": "\\bfunction\\s+([a-zA-Z_]+)",
"captures": {
"1": { "name": "entity.name.function" }
}
}
장점: 빠름, 언어 독립. 단점: 문맥 이해 없음. 로컬 변수와 파라미터 구분 불가, 타입 정보 없음.
예: foo가 함수인지 변수인지, 사용자 정의 타입인지 구분 불가.
8.2 Semantic Tokens (LSP 3.16, 2020)
언어 서버가 정확한 토큰 정보 제공:
{
"method": "textDocument/semanticTokens/full",
"params": {
"textDocument": { "uri": "..." }
}
}
응답:
{
"data": [
0, 5, 10, 0, 0, // line 0, col 5, length 10, type 0 (function), no modifiers
1, 3, 4, 6, 1, // line +1 (relative), col 3, length 4, type 6 (variable), modifier 1 (readonly)
...
]
}
Flat array로 압축. 5개 정수가 한 토큰:
deltaLine: 이전 토큰과의 줄 차이 (상대).deltaStart: 같은 줄이면 이전 토큰과의 열 차이, 다른 줄이면 절대.length: 토큰 길이.tokenType: 타입 index (초기화 시 legend에 정의).tokenModifiers: 수식어 bitmask.
8.3 Token Types
표준:
namespace,type,class,enum,interface.struct,typeParameter,parameter.variable,property,enumMember.function,method,macro.keyword,modifier,comment,string,number.regexp,operator.
언어 서버가 임의로 확장 가능.
8.4 Modifiers
declaration,definition,readonly,static.deprecated,abstract,async.modification,documentation,defaultLibrary.
"deprecated된 함수" → 에디터가 취소선 표시. "async 함수" → 다른 색상.
8.5 Delta Updates
대형 파일에서 매 편집마다 전체 토큰을 재전송하면 낭비. LSP 3.17+는 delta:
{
"method": "textDocument/semanticTokens/full/delta",
"params": {
"textDocument": { "uri": "..." },
"previousResultId": "42"
}
}
응답: 이전 결과와의 차이만.
8.6 결과
2020년 이후 에디터들이 semantic tokens 채택. 결과:
- 로컬 변수 vs 파라미터 다른 색상.
- enum member 고유 색.
- Deprecated 취소선.
- Async 함수 강조.
VS Code, Zed, Helix 같은 최신 에디터는 semantic tokens을 적극 사용. Vim/Neovim은 점차.
9. Formatting
9.1 Document Formatting
{
"method": "textDocument/formatting",
"params": {
"textDocument": { "uri": "..." },
"options": {
"tabSize": 4,
"insertSpaces": true,
"trimTrailingWhitespace": true,
"insertFinalNewline": true
}
}
}
응답: TextEdit[] — 변경할 범위와 새 텍스트.
서버는 rustfmt, gofmt, prettier 같은 포매터와 연결.
9.2 Range Formatting
선택 영역만:
{
"method": "textDocument/rangeFormatting",
"params": { "range": {...}, "options": {...} }
}
9.3 On-Type Formatting
타이핑 중 특정 문자 입력 시 자동 포맷:
{
"method": "textDocument/onTypeFormatting",
"params": {
"ch": "}",
"options": {...}
}
}
} 입력 시 블록 들여쓰기 자동 조정 같은 용도. triggerCharacters로 서버가 지정.
10. Inlay Hints, Code Lens
10.1 Inlay Hints (3.17+)
"회색 힌트"를 인라인으로 표시:
let x = foo(); // : i32 (inlay hint)
bar(arg1: val1, arg2: val2); // parameter names as hints
요청: textDocument/inlayHint. 응답: InlayHint[].
TypeScript, Rust, C#의 에디터에서 흔함.
10.2 Code Lens
메서드 위에 "Run test | Debug" 같은 클릭 가능한 링크:
{
"range": { ... },
"command": {
"title": "Run test",
"command": "test.run",
"arguments": [...]
}
}
에디터가 이 command를 실행 요청. 서버 또는 에디터가 처리.
11. Workspace Features
11.1 Configuration
서버가 클라이언트의 설정을 조회:
{
"method": "workspace/configuration",
"params": {
"items": [
{ "section": "rust-analyzer" }
]
}
}
에디터가 settings.json의 해당 부분을 반환. 서버는 이 설정으로 동작 조정.
11.2 Workspace Folders
Multi-root workspace 지원:
{
"method": "workspace/workspaceFolders"
}
응답: WorkspaceFolder[].
하나의 서버가 여러 프로젝트 처리 가능.
11.3 File Watcher
서버가 "이 파일 변경을 알려줘"라고 요청:
{
"method": "client/registerCapability",
"params": {
"registrations": [{
"method": "workspace/didChangeWatchedFiles",
"registerOptions": {
"watchers": [
{ "globPattern": "**/*.rs" }
]
}
}]
}
}
에디터가 파일 감시. 변경 시 서버에 notification:
{
"method": "workspace/didChangeWatchedFiles",
"params": {
"changes": [
{ "uri": "...", "type": 2 } // 1=Created, 2=Changed, 3=Deleted
]
}
}
서버가 인덱스 업데이트.
11.4 Execute Command
서버가 정의한 custom command 실행:
{
"method": "workspace/executeCommand",
"params": {
"command": "rust-analyzer.reloadWorkspace",
"arguments": []
}
}
Code lens, code action 등에서 이 command를 참조. 서버가 custom 동작 수행.
12. 대표 언어 서버
12.1 rust-analyzer
Rust의 공식 LSP 구현. 초기 IntelliJ Rust plugin 팀에서 파생.
특징:
- 매우 빠름: incremental compilation, 타입 추론 lazy.
- semantic tokens 완전 지원.
- Macro expansion 지원 (Rust macro를 분석).
- Cargo와 통합.
cargo install rust-analyzer 또는 rustup component.
12.2 clangd
C/C++. LLVM 프로젝트의 일부.
특징:
- Clang 라이브러리 기반 → 정확한 파싱.
- Compile commands 필수 (
compile_commands.json). - Background indexing.
- Include insertion automatic.
큰 C++ 프로젝트에 필수. Chromium, LLVM 자체 개발에 사용.
12.3 gopls
Go의 공식.
특징:
- 매우 빠른 초기화.
- 모듈 시스템 통합.
- GOPATH, go.mod 자동.
- Refactoring 풍부.
go install golang.org/x/tools/gopls@latest.
12.4 TypeScript/JavaScript
typescript-language-server: tsc 기반.
특징:
- 엄청난 생태계 덕분에 가장 성숙.
- JSX, TSX 지원.
- Auto-import 매우 잘 됨.
- Project references, monorepo 지원.
VS Code는 자체 TypeScript 통합 사용 (LSP 기반이지만 fork).
12.5 Python
Pyright (Microsoft): 가장 빠르고 정확. 타입 체크 강. Pylsp / python-lsp-server: 오픈소스, 더 많은 플러그인. Pylance: VS Code 전용, Pyright 위에 추가 기능.
12.6 기타
- Sorbet: Ruby의 Stripe 작품.
- Elixir LS.
- Haskell Language Server (HLS).
- Metals: Scala.
- ocaml-lsp-server: OCaml.
- Deno LSP: Deno 내장 TypeScript.
거의 모든 현대 언어가 LSP 서버를 가진다.
13. LSP를 쓰는 에디터
13.1 VS Code
LSP의 원조. VS Code 자체는 electron 기반이지만 언어 분석은 별도 프로세스.
Extension API가 LSP 클라이언트를 쉽게 만들도록 설계.
13.2 Neovim
:lua vim.lsp.start(...)로 내장 LSP 클라이언트. lspconfig 플러그인이 편의 기능.
경량, 커스터마이즈 가능. 하지만 UX는 VS Code만큼 매끄럽진 않음.
13.3 Helix
Rust로 쓴 modal 에디터. 2024년 인기 상승. LSP가 내장 (플러그인 없이).
:config 없이 거의 동작. 속도와 단순성 우선.
13.4 Zed
Rust + GPU 렌더링 에디터. Atom 팀의 후속작. LSP 내장 + multi-buffer + real-time collaboration.
2023년 오픈소스화. 속도 면에서 VS Code 압도.
13.5 Emacs
eglot 또는 lsp-mode. eglot이 minimalistic, lsp-mode가 풍부.
오래된 에디터지만 LSP로 현대화.
13.6 Vim
coc.nvim 또는 nvim-lspconfig (Neovim). 전통적 Vim은 coc가 주류.
13.7 JetBrains IDEs
2023년부터 LSP 지원 시작. 하지만 자체 분석 엔진이 훨씬 발달 → LSP는 "마이너 언어" 용도.
IntelliJ IDEA, PyCharm, WebStorm 등.
13.8 Sublime Text
LSP package. 설정 약간 복잡하지만 완전 지원.
14. AI 코딩 도구의 LSP
14.1 GitHub Copilot
Copilot의 초기 버전은 에디터 고유 API를 사용. 2023년 Copilot LSP(비공개지만)를 도입해 여러 에디터에 통일된 경험.
2024년 Copilot language server를 공개. 에디터가 LSP 클라이언트로 연결.
14.2 Cursor, Continue, Supermaven
AI 에디터들. 대부분 VS Code fork 또는 LSP extension.
패턴:
- 일반 LSP (rust-analyzer 등)으로 코드 이해.
- 추가 LSP (Cursor LSP)로 AI completion.
- 둘을 결합해 컨텍스트 인식 제안.
14.3 Codeium, Tabnine
LSP 기반 completion 제공. AI completion을 LSP completion item으로 변환.
14.4 LSP의 AI 확장
2025년 논의 중:
- Streaming completions: AI가 긴 코드를 생성하는 동안 partial 전송.
- Extended context: 일반 completion보다 훨씬 긴 prompt.
- Multi-file edits: 여러 파일 동시 수정.
LSP가 확장 가능한 프로토콜이라 커스텀 $/ 메서드로 추가 기능.
15. 구현 내부 들여다보기
15.1 Main Loop
전형적 LSP 서버 구조:
while true:
message = read_message(stdin)
if message.method == "initialize":
handle_initialize(message)
elif message.method == "textDocument/didChange":
update_document(message.params)
// 백그라운드에서 diagnostics 계산
elif message.method == "textDocument/completion":
items = compute_completion(message.params)
write_response(message.id, items)
...
15.2 쓰레딩 모델
단순: single-threaded. 요청을 순차 처리.
성능: multi-threaded. Request를 워커 풀에서 처리.
문제: 상태 일관성. 파일 내용이 바뀌는 동안 분석이 돌면?
rust-analyzer의 접근:
- Main thread: 모든 요청 수신.
- 변경을 salsa 기반 increental framework에 기록.
- 쿼리는 아직 유효한 데이터를 사용.
- Stale 응답은 cancel.
15.3 Incremental Computation
분석은 비싸다. 매 키 입력마다 전체 프로젝트를 재파싱하면 불가능.
Incremental computation 프레임워크:
- Salsa (Rust).
- Reflex (OCaml).
- Bazel-like build graph.
핵심: 함수의 입력이 바뀌지 않으면 결과 재사용. 바뀐 것만 재계산.
15.4 Indexing
Workspace symbols, references를 빠르게 찾으려면 인덱스 필요.
- Symbol → Locations map.
- Location → Symbols map.
- Call graph.
초기 initialize 시 백그라운드 인덱싱 → 수 초~수 분. 그 후 incremental 업데이트.
디스크에 저장(.cache/rust-analyzer/) → 재시작 빠름.
15.5 LSP 서버 작성
간단한 서버 예 (Python):
import sys
import json
def read_message():
# Content-Length 헤더 읽기
line = sys.stdin.buffer.readline()
if not line:
return None
length = int(line.split(b": ")[1])
sys.stdin.buffer.readline() # 빈 줄
return json.loads(sys.stdin.buffer.read(length))
def write_message(msg):
payload = json.dumps(msg).encode("utf-8")
sys.stdout.buffer.write(
f"Content-Length: {len(payload)}\r\n\r\n".encode()
)
sys.stdout.buffer.write(payload)
sys.stdout.buffer.flush()
def main():
while True:
msg = read_message()
if msg is None:
break
if msg["method"] == "initialize":
write_message({
"jsonrpc": "2.0",
"id": msg["id"],
"result": {
"capabilities": {
"textDocumentSync": 1,
"hoverProvider": True
},
"serverInfo": {"name": "my-lsp", "version": "0.1"}
}
})
elif msg["method"] == "textDocument/hover":
write_message({
"jsonrpc": "2.0",
"id": msg["id"],
"result": {
"contents": {"kind": "markdown", "value": "Hello from my LSP!"}
}
})
main()
실제로는 훨씬 복잡하지만 기본 구조는 이것.
라이브러리:
pygls(Python).vscode-languageserver-node(Node.js).tower-lsp(Rust).lsp-types(Rust, 타입 정의).
16. 디버깅과 트러블슈팅
16.1 로그 확인
VS Code: Output panel → 서버 이름 선택. 클라이언트/서버 양방향 로그.
설정:
{
"rust-analyzer.trace.server": "verbose"
}
모든 LSP 메시지를 로그에 기록.
16.2 Neovim LSP 로그
:LspLog
또는:
vim.lsp.set_log_level("debug")
16.3 흔한 문제
"Completion이 느리다":
- 서버 로그 확인 — 서버가 느린가 클라이언트가 느린가.
isIncomplete: true로 반복 요청 줄이기.- 인덱싱이 아직 끝나지 않았을 수 있음.
"Diagnostics가 안 보인다":
publishDiagnosticsnotification이 오는지 로그 확인.- 에디터 UI 설정 (예: VS Code의 Problems panel 숨김).
"Go to Definition이 안 된다":
- 서버
definitionProvider: true확인. - 파일이
didOpen되었나. - 프로젝트 구성 (tsconfig.json, compile_commands.json 등) 확인.
17. 관련 프로토콜
17.1 DAP (Debug Adapter Protocol)
LSP의 디버거 버전. Microsoft가 LSP와 유사하게 설계.
[Editor] ──(DAP protocol)── [Debug Adapter] ── [Debugger (gdb, lldb, ...)]
Launch, breakpoint, step 등 표준화. VS Code는 DAP 기반 Node.js 디버거 내장.
17.2 BSP (Build Server Protocol)
빌드 시스템과의 통신. Scala 생태계가 주도. Metals, Bloop.
17.3 Copilot Chat Protocol
AI 채팅 통합 표준. 2024년 제안 단계. LSP와 결합될 가능성.
18. 학습 리소스
공식:
- https://microsoft.github.io/language-server-protocol — 공식 스펙.
- https://github.com/microsoft/language-server-protocol — GitHub.
- VS Code Extension API 문서.
구현:
rust-analyzer소스코드 (매우 교육적).gopls소스.clangd소스.pygls(Python) 예제들.
블로그:
- Aleksey Kladov의 rust-analyzer 관련 글.
- Felipe Oliveira Carvalho의 LSP 튜토리얼.
- Microsoft Language Server Protocol 블로그.
강의:
- "Building Language Servers" (Eirik Tsarpalis, NDC).
- LSP 튜토리얼 시리즈 (다양한 YouTube).
19. 요약 — 한 장 정리
┌─────────────────────────────────────────────────────┐
│ LSP Cheat Sheet │
├─────────────────────────────────────────────────────┤
│ 설계: │
│ Editor ↔ Language Server (separate process) │
│ JSON-RPC 2.0 │
│ STDIO / Socket / Pipe │
│ Content-Length header │
│ │
│ 생명주기: │
│ initialize (capabilities 협상) │
│ initialized │
│ ... requests ... │
│ shutdown → exit │
│ │
│ Document Sync: │
│ textDocument/didOpen │
│ textDocument/didChange (Full or Incremental) │
│ textDocument/didSave │
│ textDocument/didClose │
│ │
│ 핵심 요청: │
│ completion (+ resolve) │
│ hover │
│ definition / typeDefinition / implementation │
│ references │
│ rename (WorkspaceEdit) │
│ signatureHelp │
│ documentSymbol / workspaceSymbol │
│ formatting (+ range, onType) │
│ codeAction (quickfix, refactor) │
│ │
│ Diagnostics: │
│ publishDiagnostics (push, notification) │
│ diagnostic (pull, 3.17+) │
│ Severity: Error/Warning/Info/Hint │
│ │
│ Semantic Tokens (3.16+): │
│ Flat array of 5-tuples │
│ TextMate grammar 대체 │
│ Token types + modifiers │
│ Delta updates │
│ │
│ Advanced: │
│ Inlay Hints (3.17+) │
│ Code Lens │
│ Semantic Tokens delta │
│ │
│ Workspace: │
│ workspace/configuration │
│ workspace/workspaceFolders │
│ workspace/didChangeWatchedFiles │
│ workspace/executeCommand │
│ │
│ 주요 서버: │
│ rust-analyzer (Rust) │
│ clangd (C/C++) │
│ gopls (Go) │
│ Pyright (Python) │
│ typescript-language-server │
│ Sorbet (Ruby) │
│ Metals (Scala) │
│ │
│ 에디터: │
│ VS Code, Neovim, Helix, Zed, Emacs │
│ JetBrains, Sublime Text │
│ │
│ AI 통합: │
│ Copilot LSP │
│ Cursor, Continue │
│ Codeium, Tabnine │
└─────────────────────────────────────────────────────┘
20. 퀴즈
Q1. LSP가 해결한 "N×M 문제"란 무엇인가?
A. LSP 이전에는 N개 에디터 × M개 언어 = N×M개의 플러그인이 필요했다. Vim의 Rust 지원, VS Code의 Rust 지원, Emacs의 Rust 지원이 각각 독립 구현되어야 했고, 각자 파서와 타입 체커를 처음부터 만들어야 했다. 결과: 대부분 플러그인이 품질 낮음. LSP는 "한 번 표준 protocol을 정의"해서 각 언어는 한 번만 서버를 만들면 되고(rust-analyzer), 각 에디터는 한 번만 LSP 클라이언트를 구현하면 모든 언어를 지원한다. N×M 문제가 N + M으로 축소. LLVM의 3단 구조와 정확히 같은 철학 — "공통 계약"으로 조합 폭발 방지.
Q2. LSP가 JSON-RPC 위에 Content-Length 헤더를 쓰는 이유는?
A. TCP/STDIO는 스트림이지 message-oriented가 아니기 때문에 메시지 경계를 알 방법이 필요하다. Content-Length 헤더가 "다음 N 바이트가 하나의 JSON 메시지"라고 알려준다. 이 디자인은 HTTP/1.1의 Content-Length와 동일한 문제 해결. WebSocket은 자체적으로 frame 경계가 있어 필요 없지만 LSP는 단순한 pipe 위에서 작동해야 하므로 이 방식이 필수. 실제 예: Content-Length: 156\r\n\r\n{"jsonrpc":"2.0",...} — HTTP-like 헤더 + 빈 줄 + JSON body.
Q3. Capabilities 협상이 왜 중요한가?
A. 프로토콜이 계속 진화하기 때문. LSP는 3.0 → 3.16 → 3.17로 기능이 추가되는데, 모든 에디터와 서버가 같은 버전을 구현하지 못한다. Initialize 단계에서 양쪽이 "내가 지원하는 기능"을 선언하면, 서로 교집합만 사용해서 호환성 문제 없이 작동. 예: Semantic Tokens는 3.16에서 추가됐지만 구 버전 에디터는 모름. 서버가 semanticTokensProvider를 광고해도 클라이언트가 semanticTokens capability를 보내지 않았으면 서버는 이 기능을 사용 안 함. 이 우아한 버전 협상 덕분에 LSP는 깨지지 않고 진화할 수 있었다.
Q4. Incremental document sync가 Full sync보다 나은 이유는?
A. 대역폭과 파싱 비용. Full sync는 매 타이핑마다 전체 파일 내용을 전송. 1MB 파일에 한 글자 쳐도 1MB 전송 → latency + 서버가 1MB 재파싱. Incremental은 변경된 범위만 {start, end, newText}로 전송. 한 글자 변경 = 수십 바이트. 서버는 자체 버퍼를 패치하고, incremental parser(tree-sitter 같은)는 변경 범위만 재파싱. 대형 파일에서 100배 이상 효율. 서버와 클라이언트 모두 "버전 번호를 정확히 추적해야" 상태 일관성 유지. rust-analyzer, gopls 같은 고성능 서버는 이 경로에 최적화.
Q5. Semantic Tokens가 TextMate grammar를 대체한 이유는?
A. 문맥 이해 능력 차이. TextMate는 정규식 기반이라 foo가 함수인지 변수인지, 사용자 정의 타입인지 구분 불가 — "대문자로 시작하면 타입"같은 휴리스틱만. Semantic Tokens는 실제 language server의 타입 정보를 사용해서 정확한 분류 제공: 로컬 변수 vs 파라미터 vs static 필드 vs enum member 등 수십 가지. Deprecated 함수에 취소선, async 함수에 다른 색상 등 의미 있는 시각화 가능. 성능 대가: semantic tokens는 서버 호출이 필요 → 구 TextMate만큼 즉시가 아님. VS Code는 TextMate로 즉시 코어링 후 semantic tokens로 refine하는 하이브리드 접근. Zed, Helix는 semantic tokens 우선.
Q6. Diagnostics의 "push"와 "pull" 방식 차이는?
A. 책임 소재와 제어 권한. Push(전통적): 서버가 파일 분석 후 자발적으로 publishDiagnostics notification을 클라이언트에 전송. 서버가 결정 — "이제 diagnostic이 준비됐으니 알려야지". 단순하지만 클라이언트는 "언제 받을지" 제어 못 함. Pull (LSP 3.17+): 클라이언트가 textDocument/diagnostic 요청을 필요할 때 전송. 장점: (1) 클라이언트가 "이 파일 열렸을 때만 diagnostics가 필요" 같은 제어 가능, (2) Workspace-wide pull로 모든 파일 diagnostics를 한 번에 받기, (3) "부분 결과(unchanged)" 응답으로 효율. 현재는 push가 여전히 주류지만 pull이 새 표준으로 이동 중 — 특히 큰 workspace에서.
Q7. rust-analyzer가 매 키 입력마다 빠르게 응답할 수 있는 비결은?
A. Incremental computation (Salsa framework). 매 키 입력마다 프로젝트 전체를 재파싱/재분석하면 불가능. Salsa는 함수의 입력이 바뀌지 않으면 결과를 재사용하는 dependency tracking 프레임워크. 각 분석 단계(파싱, 이름 해결, 타입 추론)가 Salsa query로 표현되고, 입력 변경 시 영향받는 query만 재계산. 예: foo.rs의 한 함수 수정이 bar.rs의 타입 추론에 영향 없다면 bar의 결과는 캐시된 채 유지. 결과: 거대한 프로젝트에서도 millisecond 단위 응답. 이 패턴은 Bazel의 build graph, Nix의 pure derivation과 같은 원리. "변경된 것만 재계산"이 현대 프로그래밍 도구의 기본 기법이 됐다.
이 글이 도움이 됐다면 다음 포스트도 확인해 보세요:
- "LLVM Compiler Infrastructure Deep Dive" — LSP와 유사한 N×M 해결 철학.
- "OAuth 2.0 & OIDC Deep Dive" — 또 다른 protocol 설계 사례.
- "Rust Tokio Async Runtime" — rust-analyzer의 기반 언어.
- "Git Internals" — 개발자 도구의 기본 인프라.