- Published on
내 손으로 만드는 VS Code 확장과 Language Server — 2026 LSP 실전 가이드
- Authors

- Name
- Youngju Kim
- @fjvbn20031
프롤로그 — 에디터가 글을 읽게 만든 사람들
내가 처음으로 "에디터가 내 코드를 이해한다"고 느낀 건 2016년의 VS Code였다. 그 전까지 자동완성이라는 건 식별자 사전과 정규식의 그럴싸한 합작이었고, "Go to Definition"은 ctags가 지킨 마지막 보루였다. 그런데 VS Code에서 TypeScript 파일을 열면 함수 위에 타입이 떠올랐고, F12 한 번에 정의가 열렸고, 잘못된 글자에는 빨간 줄이 그어졌다.
그 뒤에는 두 가지가 같이 있었다. Extension API가 에디터와 코드를 잇는 표면이었고, Language Server Protocol(LSP) 이 그 표면 너머에서 "코드를 이해하는 일"을 따로 떼어 표준화했다.
2026년 5월 기준, LSP 사양은 3.18이고 VS Code는 stable 채널에서 매달 새 마이너 버전을 찍는다. vscode-languageserver-node 9.x가 표준 TypeScript 구현이고, Rust 쪽에는 tower-lsp가 사실상의 기본이다. 같은 LSP 서버 하나가 Neovim, Helix, Emacs, Sublime, Zed, JetBrains에서도 동작한다. 이제는 "에디터에 언어 지원을 넣는다"라는 표현 자체가 어색하다 — 우리는 "언어 서버를 짠다"고 말하고, 에디터들이 알아서 붙는다.
이 글은 그 일을 처음부터 끝까지 한 번 해보는 가이드다. 단순한 TODO 하이라이터 확장으로 시작해, 가상의 설정 언어를 위한 진짜 LSP를 짠다. 그리고 둘을 붙여서 마켓플레이스에 올리는 데까지 간다. Cursor와 Continue 같은 포크에서 같은 코드가 동작하는 이유도, Tree-sitter가 왜 LSP 옆에서 같이 산책하는지도 같이 본다.
1장 · VS Code Extension API의 지형
먼저 가장 얇은 표면, 확장 자체부터 본다. LSP는 그 위에 얹는 한 층이다.
1.1 확장이란 무엇인가
VS Code 확장은 한마디로 "에디터의 호스트 프로세스에서 실행되는 Node.js 모듈"이다. 모든 확장은 별도의 Extension Host 프로세스에서 격리되어 동작한다. 메인 UI 스레드(렌더러)와는 IPC로 떨어져 있다 — 확장이 무한 루프를 돌아도 에디터는 살아남는다.
확장이 할 수 있는 일은 대략 다음과 같다.
- 명령(Commands) 등록 — 커맨드 팔레트와 키 바인딩에 노출.
- 언어 기능 프로바이더 — Completion, Hover, Definition, References, Diagnostics 등을 직접 구현하거나 LSP에 위임.
- 데코레이션과 트리 뷰 — 편집기 본문에 색을 칠하고, 사이드바에 트리를 그린다.
- 상태바·노티피케이션·웹뷰 — 사용자 인터페이스 표면.
- 워크스페이스/파일 시스템 프로바이더 — 가상 파일 시스템, 원격 파일 시스템.
- 디버그 어댑터(DAP) — 디버거 통합.
- 태스크와 터미널 — 실행 환경.
이 글에서 우리는 위 두 개 — 명령과 언어 기능 — 를 집중해서 다룬다.
1.2 package.json 매니페스트
확장의 정체는 package.json이 전부 결정한다. 다음은 우리 TODO 하이라이터의 최소 매니페스트다.
{
"name": "todo-highlighter",
"displayName": "TODO Highlighter",
"version": "0.1.0",
"publisher": "yj",
"engines": { "vscode": "^1.95.0" },
"main": "./out/extension.js",
"activationEvents": ["onStartupFinished"],
"categories": ["Other"],
"contributes": {
"commands": [
{
"command": "todoHighlighter.scanWorkspace",
"title": "TODO: Scan Workspace"
}
],
"configuration": {
"title": "TODO Highlighter",
"properties": {
"todoHighlighter.keywords": {
"type": "array",
"default": ["TODO", "FIXME", "HACK"],
"description": "Keywords to highlight"
}
}
}
}
}
핵심 필드 세 개만 짚는다.
engines.vscode— 호환되는 최소 VS Code 버전. 너무 낮게 잡으면 새 API를 못 쓴다.main— 진입점 파일. CommonJS여야 한다(2026년 기준 ESM 진입점은 실험 단계).activationEvents— 언제 확장을 깨울지. 잘못 설정하면 사용자의 모든 VS Code 시작이 느려진다.
activationEvents는 가장 자주 실수하는 곳이다. onStartupFinished는 "에디터가 다 뜬 다음 한 번"이라 가장 무난하다. 옛날 *는 deprecated — 모든 시작에 확장을 깨우기 때문이다. 더 좋은 건 활성화 트리거를 좁히는 것이다.
"activationEvents": [
"onLanguage:tomlplus",
"onCommand:tomlplus.format",
"workspaceContains:**/.tomlplus"
]
위는 "tomlplus 언어 파일이 열렸을 때", "특정 명령이 호출됐을 때", "워크스페이스에 해당 파일이 있을 때"만 확장을 깨운다. VS Code 1.74부터 contributes.commands의 명령은 자동으로 activation 트리거가 되므로 onCommand:*는 보통 생략 가능하다.
1.3 Hello World 확장
extension.ts는 다음 형태가 표준이다.
import * as vscode from 'vscode';
export function activate(context: vscode.ExtensionContext) {
console.log('TODO Highlighter activated');
const disposable = vscode.commands.registerCommand(
'todoHighlighter.scanWorkspace',
async () => {
const files = await vscode.workspace.findFiles('**/*.{ts,js,md}', '**/node_modules/**');
vscode.window.showInformationMessage(`Scanned ${files.length} files`);
}
);
context.subscriptions.push(disposable);
}
export function deactivate() {}
context.subscriptions에 푸시한 모든 Disposable은 확장 비활성화 시 자동으로 정리된다. 메모리 누수를 막는 가장 중요한 규칙 하나다.
1.4 데코레이션으로 TODO 색칠하기
진짜 TODO 하이라이터를 만들려면 데코레이션 API를 쓴다.
const todoDecoration = vscode.window.createTextEditorDecorationType({
backgroundColor: 'rgba(255, 200, 0, 0.2)',
border: '1px dashed orange',
overviewRulerColor: 'orange',
overviewRulerLane: vscode.OverviewRulerLane.Right,
});
function updateDecorations(editor: vscode.TextEditor) {
const text = editor.document.getText();
const regex = /\b(TODO|FIXME|HACK)\b/g;
const ranges: vscode.Range[] = [];
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
const start = editor.document.positionAt(match.index);
const end = editor.document.positionAt(match.index + match[0].length);
ranges.push(new vscode.Range(start, end));
}
editor.setDecorations(todoDecoration, ranges);
}
vscode.window.onDidChangeActiveTextEditor(
(editor) => editor && updateDecorations(editor),
null,
context.subscriptions
);
vscode.workspace.onDidChangeTextDocument(
(event) => {
const editor = vscode.window.activeTextEditor;
if (editor && event.document === editor.document) updateDecorations(editor);
},
null,
context.subscriptions
);
여기까지는 LSP가 필요 없다. 정규식 한 줄과 데코레이션 API로 충분한 일은 굳이 서버를 띄울 이유가 없다. 하지만 자동완성·정의로 이동·심볼 인덱스·incremental 파싱이 필요해지면 — 그때부터는 서버다.
1.5 트리 뷰, 상태바, 웹뷰, 워크스루
확장이 다룰 수 있는 UI 표면들:
- 트리 뷰(TreeDataProvider) — 사이드바에 트리. 빌드 산출물, 테스트 목록, TODO 모음 같은 것에 어울린다.
- 상태바 아이템(StatusBarItem) — 좌/우측에 작은 텍스트. 진행 상황이나 빠른 액션.
- 웹뷰(Webview) — 임의의 HTML/JS를 띄울 수 있는 iframe과 비슷한 표면. 풀스크린 패널이 필요할 때.
- 워크스루(Walkthroughs) — 사용자에게 확장 사용법을 단계로 안내하는 온보딩 UI.
package.json에 선언만 하면 된다. - Notebook 컨트롤러 — Jupyter처럼 셀 기반 인터페이스.
- 인레이 힌트(Inlay Hints) — 타입이나 매개변수 이름을 코드 사이에 표시.
각각의 API는 같은 패턴이다 — 등록(register), 디스포저블 반환, 모델 변화 시 갱신.
2장 · 왜 LSP인가 — 한 서버, 많은 에디터
여기까지는 VS Code 안의 이야기였다. LSP는 한 단계 더 나아간다.
2.1 M×N 문제
LSP 이전, 에디터마다 같은 언어 기능을 따로 짰다. M개의 에디터가 N개의 언어를 지원하려면 M×N 개의 통합이 필요했다. Python 자동완성을 Emacs에 넣은 사람이 Vim용을 또 짜고, 그 둘이 VS Code 것과 다르게 동작한다.
Microsoft는 2016년 VS Code 출시 직전 LSP 사양을 공개했다. 아이디어는 단순하다 — 언어 기능을 서버로 분리하자. 에디터는 클라이언트가 되고, JSON-RPC로 메시지를 주고받는다. M+N 문제로 바뀐다.
2.2 사양의 위치
2026년 5월 기준:
- LSP 3.18 — 최신 안정 사양. semantic tokens v2, inlay hints, type hierarchy 등이 포함.
- DAP(Debug Adapter Protocol) — 디버거를 위한 자매 프로토콜.
- Open VSX — VSCodium·Cursor·Gitpod 등 비-MS 빌드가 쓰는 마켓플레이스 대체.
LSP는 결국 메시지 사양이다. 서버 구현은 자유롭게 고를 수 있다. Rust로는 tower-lsp(또는 더 최신 tower-lsp-server), TypeScript로는 vscode-languageserver, Go로는 go.lsp.dev/protocol, Python으로는 pygls, Haskell까지 다 있다.
2.3 누가 LSP를 쓰는가
실전에서 잘 쓰이는 서버들. 한 줄로:
- rust-analyzer — Rust 공식. tower-lsp 기반이 아니라 직접 구현.
- gopls — Go 공식. Go 팀이 직접 관리.
- typescript-language-server — Microsoft의 tsserver를 LSP로 래핑.
- pyright / pylance — Python 타입 체커 겸 LSP.
- ruff-lsp — Rust로 짠 초고속 Python 린터 + LSP.
- clangd — C/C++ 공식.
- Astro Language Server —
.astro파일 지원. - svelte-language-server, vue-language-server — 각 프레임워크.
- lsp-bridge, copilot-language-server — Copilot도 결국 LSP 메시지를 흉내낸다.
같은 서버가 Neovim에서 nvim-lspconfig, Emacs에서 lsp-mode, Helix에서 기본 내장, JetBrains에서 LSP4IJ 플러그인을 거쳐 동작한다.
3장 · LSP 프로토콜 해부
이제 메시지의 모양을 본다.
3.1 전송 — JSON-RPC over stdio (보통)
LSP의 표준 전송은 stdin/stdout에 JSON-RPC 2.0이다. HTTP가 아니라 단순 바이트 스트림. 메시지 하나는 헤더와 본문으로 나뉜다.
Content-Length: 152\r\n
\r\n
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"processId":12345,...}}
대안 전송: 소켓, IPC, WebSocket. VS Code 클라이언트는 셋 다 지원하지만 stdio가 가장 쓰기 쉽다.
3.2 라이프사이클
client ──▶ initialize ──▶ server
client ◀── initialize result ─ server
client ──▶ initialized ──▶ (notification)
... (정상 동작 구간) ...
client ──▶ shutdown ──▶ server
client ──▶ exit ──▶ server
initialize 요청에서 클라이언트는 자기가 지원하는 기능을 알리고, 서버는 자기가 지원하는 기능을 알린다. 이 합의가 ServerCapabilities다. 예: 서버가 hoverProvider: true를 안 알리면 클라이언트는 hover를 절대 요청하지 않는다.
3.3 메시지의 세 종류
- Request — id가 있고, response가 반드시 와야 한다. 예:
textDocument/hover. - Response — request에 대한 답.
id로 매칭. - Notification — id가 없고, 응답이 없다. 예:
textDocument/didChange,textDocument/publishDiagnostics.
방향도 양방향이다. 서버가 클라이언트에 window/showMessage를 보내거나, 클라이언트가 서버에 workspace/configuration을 묻는다.
3.4 텍스트 문서 동기화
서버가 파일 내용을 알아야 일을 한다. 동기화 방식은 셋 중 하나다.
- None(0) — 동기화 안 함.
- Full(1) — 변경마다 전체 내용을 보냄. 작은 파일에 단순.
- Incremental(2) — 변경된 범위만. 큰 파일에 효율적.
textDocument/didOpen, didChange, didClose, didSave가 핵심 노티피케이션이다.
3.5 핵심 기능별 메서드
서버가 구현하는 주요 요청들:
| 기능 | 요청 메서드 |
|---|---|
| 진단(빨간 줄) | 서버 → 클라 textDocument/publishDiagnostics |
| 자동완성 | textDocument/completion |
| 시그니처 도움말 | textDocument/signatureHelp |
| 호버 | textDocument/hover |
| 정의로 이동 | textDocument/definition |
| 참조 찾기 | textDocument/references |
| 심볼 검색 | textDocument/documentSymbol, workspace/symbol |
| 이름 바꾸기 | textDocument/rename, prepareRename |
| 코드 액션 | textDocument/codeAction |
| 포맷팅 | textDocument/formatting, rangeFormatting |
| 세만틱 토큰 | textDocument/semanticTokens/full |
| 인레이 힌트 | textDocument/inlayHint |
| 콜 계층 | textDocument/prepareCallHierarchy |
4장 · 미니 언어 — tomlplus를 정의한다
코드를 짜기 전에 가상의 언어를 정한다. 이름은 tomlplus. TOML과 비슷하지만 두 가지 추가가 있다.
- 참조 —
link = $other_section.key형태로 다른 섹션의 값을 가리킨다. - 타입 주석 —
port: int = 8080처럼 키 옆에 타입을 적을 수 있다.
문법은 단순하다.
[server]
host: string = "localhost"
port: int = 8080
[client]
target = $server.host
timeout: int = 30
LSP가 해야 할 일:
- 파싱하고 잘못된 곳에 진단을 띄운다.
- 키와 값에 호버로 타입을 보여준다.
$ref에서 F12로 원본 키로 점프한다.- 자동완성으로 섹션·키·
$ref후보를 제안한다. - 이름 바꾸기로 키와 모든 참조를 같이 바꾼다.
이걸 처음부터 짜본다.
5장 · TypeScript로 LSP 서버 짜기
표준 출발점은 vscode-languageserver-node다. VS Code 팀이 직접 관리하고, 클라이언트 라이브러리도 같은 모노레포에 있다.
5.1 프로젝트 구조
tomlplus-lsp/
client/
src/extension.ts # VS Code 확장 (클라이언트)
package.json
server/
src/server.ts # LSP 서버 (Node로 실행)
package.json
package.json # 워크스페이스 루트
서버는 별도의 Node 프로세스로 실행되고, 클라이언트가 그걸 자식 프로세스로 띄운다.
5.2 서버 부트스트랩
import {
createConnection,
TextDocuments,
ProposedFeatures,
InitializeParams,
TextDocumentSyncKind,
InitializeResult,
} from 'vscode-languageserver/node';
import { TextDocument } from 'vscode-languageserver-textdocument';
const connection = createConnection(ProposedFeatures.all);
const documents = new TextDocuments(TextDocument);
connection.onInitialize((params: InitializeParams): InitializeResult => {
return {
capabilities: {
textDocumentSync: TextDocumentSyncKind.Incremental,
completionProvider: { triggerCharacters: ['.', '$'] },
hoverProvider: true,
definitionProvider: true,
referencesProvider: true,
renameProvider: { prepareProvider: true },
codeActionProvider: true,
documentFormattingProvider: true,
semanticTokensProvider: {
legend: {
tokenTypes: ['keyword', 'string', 'number', 'variable', 'property'],
tokenModifiers: ['declaration', 'readonly'],
},
range: false,
full: true,
},
},
};
});
documents.listen(connection);
connection.listen();
TextDocuments 매니저가 didOpen·didChange 노티피케이션을 자동으로 처리해서 우리에게 항상 최신 텍스트를 준다. 우리는 그 위에 기능만 얹는다.
5.3 파서
진짜 LSP는 incremental 파서를 쓰지만, 시작은 단순한 라인 기반 파서로도 충분하다.
interface TomlPlusKey {
section: string;
key: string;
type: string | null;
value: string;
line: number;
range: { start: number; end: number };
}
function parse(text: string): { keys: TomlPlusKey[]; errors: ParseError[] } {
const lines = text.split('\n');
const keys: TomlPlusKey[] = [];
const errors: ParseError[] = [];
let section = '';
for (let i = 0; i < lines.length; i++) {
const line = lines[i].replace(/#.*$/, '').trim();
if (!line) continue;
const sectionMatch = /^\[([a-zA-Z_][\w]*)\]$/.exec(line);
if (sectionMatch) { section = sectionMatch[1]; continue; }
const kvMatch = /^([a-zA-Z_][\w]*)\s*(?::\s*(int|string|bool))?\s*=\s*(.+)$/.exec(line);
if (!kvMatch) {
errors.push({ line: i, message: `Invalid syntax: "${line}"` });
continue;
}
keys.push({
section,
key: kvMatch[1],
type: kvMatch[2] ?? null,
value: kvMatch[3],
line: i,
range: { start: 0, end: lines[i].length },
});
}
return { keys, errors };
}
실전 LSP에서는 Tree-sitter나 chevrotain 같은 라이브러리로 진짜 incremental 파서를 쓴다. 다음 장에서 다룬다.
5.4 진단 — publishDiagnostics
문서가 바뀔 때마다 파싱하고 잘못된 곳을 빨간 줄로 보낸다.
import { Diagnostic, DiagnosticSeverity } from 'vscode-languageserver/node';
documents.onDidChangeContent((change) => {
validateDocument(change.document);
});
function validateDocument(doc: TextDocument) {
const text = doc.getText();
const { keys, errors } = parse(text);
const diagnostics: Diagnostic[] = [];
for (const err of errors) {
diagnostics.push({
severity: DiagnosticSeverity.Error,
range: {
start: { line: err.line, character: 0 },
end: { line: err.line, character: 100 },
},
message: err.message,
source: 'tomlplus',
});
}
// 타입 미스매치 — 간단한 예
for (const k of keys) {
if (k.type === 'int' && !/^\d+$/.test(k.value.trim())) {
diagnostics.push({
severity: DiagnosticSeverity.Warning,
range: {
start: { line: k.line, character: 0 },
end: { line: k.line, character: k.range.end },
},
message: `Expected int, got "${k.value}"`,
source: 'tomlplus',
});
}
}
connection.sendDiagnostics({ uri: doc.uri, diagnostics });
}
핵심 포인트 둘:
- 항상 같은 URI의 전체 diagnostics를 보낸다. 부분 갱신 없음. 빈 배열을 보내면 "잘못된 곳 없음"이라는 뜻.
source필드는 무엇이 이 진단을 냈는지 — UI에서 "tomlplus(eslint)" 같은 라벨로 뜬다.
5.5 자동완성
import { CompletionItem, CompletionItemKind } from 'vscode-languageserver/node';
connection.onCompletion(({ textDocument, position }): CompletionItem[] => {
const doc = documents.get(textDocument.uri);
if (!doc) return [];
const text = doc.getText();
const { keys } = parse(text);
const linePrefix = doc
.getText({ start: { line: position.line, character: 0 }, end: position })
.trim();
// $로 시작 → 다른 섹션의 키 제안
if (linePrefix.endsWith('$')) {
return keys.map((k) => ({
label: `${k.section}.${k.key}`,
kind: CompletionItemKind.Variable,
detail: `${k.type ?? 'any'} = ${k.value}`,
}));
}
// 라인 시작 → 키 이름 자동완성
return [
{ label: 'host: string = ', kind: CompletionItemKind.Snippet },
{ label: 'port: int = ', kind: CompletionItemKind.Snippet },
{ label: 'enabled: bool = ', kind: CompletionItemKind.Snippet },
];
});
connection.onCompletionResolve((item) => {
// 무거운 정보를 lazy하게 채울 자리. 지금은 그대로 반환.
return item;
});
onCompletionResolve는 중요한 최적화 포인트다. 자동완성 목록에 100개 후보가 있을 때 모든 항목의 documentation을 미리 계산하면 비싸다. 대신 사용자가 한 항목을 호버했을 때만 resolve가 호출되어 그때 채워 넣는다.
5.6 호버
import { Hover } from 'vscode-languageserver/node';
connection.onHover(({ textDocument, position }): Hover | null => {
const doc = documents.get(textDocument.uri);
if (!doc) return null;
const { keys } = parse(doc.getText());
const lineText = doc.getText({
start: { line: position.line, character: 0 },
end: { line: position.line, character: 1000 },
});
const word = wordAt(lineText, position.character);
if (!word) return null;
const found = keys.find((k) => k.key === word);
if (!found) return null;
return {
contents: {
kind: 'markdown',
value: `**${found.section}.${found.key}**\n\nType: \`${found.type ?? 'any'}\`\nValue: \`${found.value}\``,
},
};
});
5.7 정의로 이동 + 참조 찾기
$server.host 같은 참조에서 F12를 누르면 원본 키로 점프해야 한다.
connection.onDefinition(({ textDocument, position }) => {
const doc = documents.get(textDocument.uri);
if (!doc) return null;
const { keys } = parse(doc.getText());
const lineText = doc.getText({
start: { line: position.line, character: 0 },
end: { line: position.line, character: 1000 },
});
const ref = parseRef(lineText, position.character); // e.g. {section:'server', key:'host'}
if (!ref) return null;
const target = keys.find((k) => k.section === ref.section && k.key === ref.key);
if (!target) return null;
return {
uri: doc.uri,
range: {
start: { line: target.line, character: 0 },
end: { line: target.line, character: target.range.end },
},
};
});
connection.onReferences(({ textDocument, position }) => {
// 모든 참조 라인을 찾아서 Location[]로 반환.
// 구현 생략 — onDefinition의 거꾸로.
return findAllReferences(textDocument.uri, position);
});
5.8 이름 바꾸기
가장 까다로운 기능 중 하나. prepareProvider: true를 켰으니 두 단계로 동작한다.
prepareRename— "이 위치에서 이름을 바꿀 수 있는가?"를 묻는다. 가능하면 바꿀 범위를 돌려준다.rename— 새 이름을 받아서WorkspaceEdit를 만든다.
import { WorkspaceEdit, TextEdit } from 'vscode-languageserver/node';
connection.onPrepareRename(({ textDocument, position }) => {
const doc = documents.get(textDocument.uri);
if (!doc) return null;
const range = findIdentifierRange(doc, position);
return range ?? null;
});
connection.onRenameRequest(({ textDocument, position, newName }): WorkspaceEdit => {
const doc = documents.get(textDocument.uri)!;
const locations = findAllOccurrences(doc, position);
return {
changes: {
[doc.uri]: locations.map((loc) => TextEdit.replace(loc.range, newName)),
},
};
});
여러 파일에 걸친 rename은 changes에 URI마다 배열을 채우면 된다. 클라이언트가 모든 편집을 한 번에 적용한다.
5.9 코드 액션
진단 옆에 떠 있는 전구 아이콘이 코드 액션이다. "이 경고를 무시", "타입을 추가", "이 키를 두 곳에서 쓰기 — 추출" 같은 것들.
import { CodeAction, CodeActionKind } from 'vscode-languageserver/node';
connection.onCodeAction((params): CodeAction[] => {
const doc = documents.get(params.textDocument.uri);
if (!doc) return [];
const actions: CodeAction[] = [];
for (const diag of params.context.diagnostics) {
if (diag.message.startsWith('Expected int')) {
actions.push({
title: 'Change type to "string"',
kind: CodeActionKind.QuickFix,
diagnostics: [diag],
edit: {
changes: {
[doc.uri]: [TextEdit.replace(diag.range, fixTypeToString(doc, diag.range))],
},
},
});
}
}
return actions;
});
5.10 세만틱 토큰
신택스 하이라이팅을 정규식 기반 TextMate 문법으로 하면 한계가 분명하다. 세만틱 토큰은 서버가 "이 단어는 변수, 저 단어는 키워드"라고 클라이언트에 직접 알려주는 메커니즘이다.
서버는 토큰을 압축 정수 배열로 보낸다 — [deltaLine, deltaStart, length, tokenType, tokenModifiers] 5개씩 묶어서. legend의 인덱스가 곧 의미다.
import { SemanticTokensBuilder } from 'vscode-languageserver/node';
connection.languages.semanticTokens.on(({ textDocument }) => {
const doc = documents.get(textDocument.uri)!;
const builder = new SemanticTokensBuilder();
const { keys } = parse(doc.getText());
for (const k of keys) {
// [line, startChar, length, tokenType, tokenModifiers]
builder.push(k.line, 0, k.key.length, /*property*/ 4, /*declaration*/ 1);
}
return builder.build();
});
세만틱 토큰은 기본 TextMate 하이라이팅 위에 덧칠해진다 — 정의되지 않은 토큰은 기본을 그대로 둔다. 이게 우아한 점이다.
5.11 포맷팅
connection.onDocumentFormatting(({ textDocument, options }) => {
const doc = documents.get(textDocument.uri)!;
const formatted = formatTomlPlus(doc.getText(), options);
return [TextEdit.replace(
{
start: { line: 0, character: 0 },
end: doc.positionAt(doc.getText().length),
},
formatted
)];
});
대형 포매터는 보통 별도의 외부 도구(black, prettier, gofmt)를 LSP 서버가 spawn해서 결과를 받는다.
6장 · 클라이언트 — 확장에서 서버 띄우기
이제 VS Code 확장 쪽이다. 서버를 자식 프로세스로 띄우고 클라이언트를 연결한다.
import * as path from 'path';
import * as vscode from 'vscode';
import {
LanguageClient,
LanguageClientOptions,
ServerOptions,
TransportKind,
} from 'vscode-languageclient/node';
let client: LanguageClient;
export function activate(context: vscode.ExtensionContext) {
const serverModule = context.asAbsolutePath(path.join('server', 'out', 'server.js'));
const serverOptions: ServerOptions = {
run: { module: serverModule, transport: TransportKind.ipc },
debug: {
module: serverModule,
transport: TransportKind.ipc,
options: { execArgv: ['--nolazy', '--inspect=6009'] },
},
};
const clientOptions: LanguageClientOptions = {
documentSelector: [{ scheme: 'file', language: 'tomlplus' }],
synchronize: {
fileEvents: vscode.workspace.createFileSystemWatcher('**/*.tomlplus'),
},
};
client = new LanguageClient('tomlplus', 'TOML+ Language Server', serverOptions, clientOptions);
client.start();
}
export function deactivate(): Thenable\<void\> | undefined {
return client?.stop();
}
TransportKind.ipc는 stdio보다 빠른 Node 전용 IPC 채널이다. stdio도 가능하지만 IPC가 표준 권장.
package.json에 언어 정의를 추가한다.
"contributes": {
"languages": [{
"id": "tomlplus",
"extensions": [".tomlplus", ".tpl"],
"configuration": "./language-configuration.json"
}],
"grammars": [{
"language": "tomlplus",
"scopeName": "source.tomlplus",
"path": "./syntaxes/tomlplus.tmLanguage.json"
}]
}
tmLanguage.json은 정규식 기반 기본 하이라이팅이고, 그 위에 LSP의 세만틱 토큰이 덧칠한다.
7장 · Rust로 LSP 짜기 — tower-lsp
같은 LSP를 Rust로도 짜본다. 왜 Rust인가:
- Tree-sitter와의 자연스러운 결합.
- Tokio 기반 비동기 처리.
- rust-analyzer가 입증한 패턴.
tower-lsp의 핵심은 trait LanguageServer다 — async trait 메서드로 LSP 요청을 받는다.
use tower_lsp::jsonrpc::Result;
use tower_lsp::lsp_types::*;
use tower_lsp::{Client, LanguageServer, LspService, Server};
#[derive(Debug)]
struct Backend {
client: Client,
}
#[tower_lsp::async_trait]
impl LanguageServer for Backend {
async fn initialize(&self, _: InitializeParams) -> Result\<InitializeResult\> {
Ok(InitializeResult {
capabilities: ServerCapabilities {
text_document_sync: Some(TextDocumentSyncCapability::Kind(
TextDocumentSyncKind::INCREMENTAL,
)),
completion_provider: Some(CompletionOptions {
trigger_characters: Some(vec![".".into(), "$".into()]),
..Default::default()
}),
hover_provider: Some(HoverProviderCapability::Simple(true)),
definition_provider: Some(OneOf::Left(true)),
..Default::default()
},
server_info: Some(ServerInfo {
name: "tomlplus-lsp".into(),
version: Some("0.1.0".into()),
}),
})
}
async fn initialized(&self, _: InitializedParams) {
self.client.log_message(MessageType::INFO, "server ready").await;
}
async fn did_change(&self, params: DidChangeTextDocumentParams) {
let uri = params.text_document.uri.clone();
let diags = validate(¶ms.content_changes);
self.client.publish_diagnostics(uri, diags, None).await;
}
async fn completion(&self, _: CompletionParams) -> Result\<Option\<CompletionResponse\>\> {
Ok(Some(CompletionResponse::Array(vec![
CompletionItem::new_simple("host".into(), "hostname".into()),
CompletionItem::new_simple("port".into(), "TCP port".into()),
])))
}
async fn shutdown(&self) -> Result\<()\> { Ok(()) }
}
#[tokio::main]
async fn main() {
let stdin = tokio::io::stdin();
let stdout = tokio::io::stdout();
let (service, socket) = LspService::new(|client| Backend { client });
Server::new(stdin, stdout, socket).serve(service).await;
}
Cargo.toml:
[dependencies]
tower-lsp = "0.20"
tokio = { version = "1", features = ["full"] }
VS Code 확장에서 Rust 서버를 띄우려면 ServerOptions의 command 형태를 쓴다.
const serverOptions: ServerOptions = {
command: context.asAbsolutePath('bin/tomlplus-lsp'),
args: [],
transport: TransportKind.stdio,
};
플랫폼별 바이너리를 함께 패키징하거나(권장), npm postinstall에서 다운로드하거나, Cargo로 사용자 머신에서 빌드하게 한다.
8장 · Tree-sitter — 파서를 직접 만들 자유
위 라인 기반 파서는 장난감이다. 진짜 LSP는 incremental 파서가 필요하다 — 사용자가 한 글자 칠 때마다 파일 전체를 다시 파싱하면 큰 파일에서 끊긴다.
Tree-sitter는 GitHub가 Atom 시절에 만든 incremental 파서 제너레이터다. 핵심 특징:
- incremental — 변경된 부분만 다시 파싱.
- 에러 복구 — 깨진 코드에서도 트리를 만든다 (LSP에 결정적).
- 다언어 — 200+ 언어 문법이 이미 존재.
- C로 짠 런타임 — TS, Rust, Python, Go에서 다 쓸 수 있다.
8.1 문법 정의
grammar.js로 문법을 쓴다.
module.exports = grammar({
name: 'tomlplus',
rules: {
source_file: ($) => repeat(choice($.section, $.assignment)),
section: ($) => seq('[', $.identifier, ']'),
assignment: ($) =>
seq($.identifier, optional($.type_annotation), '=', $.value),
type_annotation: ($) => seq(':', choice('int', 'string', 'bool')),
value: ($) => choice($.string, $.number, $.reference),
reference: ($) => seq('$', $.identifier, '.', $.identifier),
identifier: ($) => /[a-zA-Z_][\w]*/,
string: ($) => /"[^"]*"/,
number: ($) => /\d+/,
},
});
tree-sitter generate가 C 파서를 출력한다. 그걸 LSP에서 로드한다.
8.2 TypeScript에서 Tree-sitter 사용
import Parser from 'tree-sitter';
import TomlPlus from 'tree-sitter-tomlplus';
const parser = new Parser();
parser.setLanguage(TomlPlus);
function parseDoc(text: string) {
return parser.parse(text);
}
function reparse(oldTree: Parser.Tree, edit: Parser.Edit, newText: string) {
oldTree.edit(edit);
return parser.parse(newText, oldTree); // incremental
}
oldTree.edit()로 어디가 바뀌었는지 알려주면, 다음 parse는 변경된 서브트리만 다시 짓는다. 1000줄 파일에서 한 글자를 쳐도 거의 0ms.
8.3 쿼리로 노드 찾기
Tree-sitter 쿼리는 S-expression 같은 패턴 언어다.
(section (identifier) @section.name)
(assignment
(identifier) @key.name
(type_annotation (_) @key.type)?
(value) @key.value)
(reference
(identifier) @ref.section
(identifier) @ref.key)
@name 캡처가 매칭된 노드를 잡는다. LSP에서 정의·참조·심볼 추출이 다 이 쿼리로 끝난다.
8.4 Tree-sitter와 LSP의 분업
Tree-sitter는 구문을, LSP는 의미와 사용자 인터랙션을 맡는다. 보통 다음 그림이 된다.
[사용자 키입력]
↓
[VS Code 클라이언트] ── didChange ──▶ [LSP 서버]
↓
[Tree-sitter incremental parse]
↓
[심볼 인덱스 갱신]
↓
publishDiagnostics, etc.
Helix와 Zed는 Tree-sitter를 에디터 자체에 내장한다 — 클라이언트가 직접 트리를 쥐고 있고 LSP는 의미 정보만 다룬다. VS Code도 비공식 트리시터 통합이 있지만, 보통은 LSP 서버 내부에서 쓴다.
9장 · Marketplace와 OpenVSX에 배포하기
코드가 동작하면 이제 배포다.
9.1 VSIX 패키징
VS Code 확장은 .vsix라는 zip 파일이다. vsce로 만든다.
npm install -g @vscode/vsce
cd tomlplus
vsce package
# → tomlplus-0.1.0.vsix
테스트는 code --install-extension tomlplus-0.1.0.vsix로 로컬 설치.
9.2 마켓플레이스 게시
먼저 Azure DevOps에서 Publisher 계정을 만들고 Personal Access Token을 받는다.
vsce login yj
vsce publish
# 또는: vsce publish minor / patch / major
게시 정책 주의점:
LICENSE가 없으면 거부될 수 있다.README.md의 이미지는 절대 URL이어야 한다.- 24시간 안에 검수 통과 — 자동화된 정적 검사 + 보안 스캔.
extensionDependencies로 다른 확장을 끌어다 쓰면 명시.
9.3 OpenVSX — 비-MS 빌드를 위한 마켓
VSCodium, Cursor, Continue, Theia, Gitpod, code-server는 MS 마켓플레이스에 접근할 수 없거나, 라이선스상 그래선 안 된다. 그들의 대안이 Open VSX Registry(Eclipse Foundation 운영, open-vsx.org)다.
npm install -g ovsx
ovsx create-namespace yj -p <ovsx-token>
ovsx publish tomlplus-0.1.0.vsix -p <ovsx-token>
같은 vsix를 두 곳에 올리는 게 표준이다. 자동화는 GitHub Actions로:
- name: Publish to VS Marketplace
run: vsce publish -p $\{\{ secrets.VSCE_PAT \}\}
- name: Publish to OpenVSX
run: ovsx publish *.vsix -p $\{\{ secrets.OVSX_TOKEN \}\}
9.4 Cursor·Continue 호환성
Cursor는 VS Code 포크다. Open VSX를 기본 마켓플레이스로 쓰고, 일부 MS 독점 확장(C/C++, Pylance, Live Share 등)은 라이선스 때문에 직접 설치할 수 없다. 우리가 만든 일반 확장은 그대로 동작한다 — 클라이언트는 같은 VS Code API를 노출한다.
Continue, Cody 같은 AI 확장도 같은 LSP 클라이언트 코드를 쓰는 경우가 많다. 우리 서버가 표준 LSP를 잘 따르면 자연스럽게 호환된다.
9.5 텔레메트리
VS Code의 vscode/extension-telemetry 패키지로 사용 통계를 보낼 수 있다. 단:
- 사용자가
telemetry.telemetryLevel을off로 두면 자동으로 비활성. - PII(개인정보)는 절대 보내지 말 것. 익명화된 이벤트만.
- 첫 실행에서 사용자에게 알려야 한다 (마켓플레이스 정책).
import TelemetryReporter from '@vscode/extension-telemetry';
const reporter = new TelemetryReporter('<connection-string>');
context.subscriptions.push(reporter);
reporter.sendTelemetryEvent('command.scanWorkspace');
10장 · 실전 프로덕션 LSP의 패턴
마지막 장은 실제 거대 프로젝트들이 어떻게 일하는지의 스케치다.
10.1 rust-analyzer — 그래픽적 LSP 설계의 교과서
- Salsa(쿼리 시스템, demand-driven 계산)로 모든 분석을 incremental로.
- VFS 추상화로 파일 시스템과 LSP 노티피케이션을 단일 모델로 통합.
- 거대 워크스페이스(서브크레이트 100+)에서도 핫 패스가 빠르다.
10.2 gopls — Go 팀이 짠 정석
- 자체 캐시 레이어를 두고 파일 변경 시 의존 그래프만 재계산.
- "view"라는 추상으로 같은 코드의 여러 빌드 컨텍스트(GOOS/GOARCH 등)를 동시에 관리.
10.3 typescript-language-server — 어댑터 패턴
- 실제 분석은
tsserver(TypeScript 컴파일러)가 한다. - 둘 사이를 LSP↔tsserver 프로토콜로 번역하는 얇은 레이어.
- 이걸 통해 LSP를 모르는 TS 컴파일러를 모든 에디터에 노출.
10.4 ruff-lsp — Rust로 짠 초고속
- Ruff 자체는 Python 린터지만, Rust로 짜서 ESLint의 100배 빠르다.
- LSP는 그 위에 얇게 얹은 어댑터.
- pyright/pylance 같은 타입 체크와 공존 — 둘 다 활성화돼도 충돌 없음.
10.5 Astro Language Server — 다중 언어 파일
.astro파일 안에는 frontmatter(TS), 마크업(HTML), 인라인 스크립트(JS)가 섞인다.- "임베디드 언어" 패턴 — Astro LS가 영역마다 가상 문서를 만들어 TS LS, HTML LS에 위임.
- 같은 패턴이 Vue Volar, Svelte LS에서도 쓰인다.
10.6 공통 패턴 정리
- 워크스페이스 인덱스를 항상 메모리에 — 디스크 재읽기 회피.
- 분석은 demand-driven, incremental — 사용자가 hover를 한 번 누른 뒤에만 계산하고 캐시.
- 에러 복구가 사용자 경험을 좌우 — 깨진 파일에서 자동완성이 죽지 않아야 한다.
- 취소 가능성(cancellation) — 사용자가 빠르게 타이핑하면 이전 요청을 즉시 취소.
에필로그 — 두 시간으로 시작하기, 두 달로 다듬기
LSP는 처음 30분이 가장 어렵다. JSON-RPC 메시지의 방향이 헷갈리고, capabilities 협상이 막연하고, didChange 동기화 모드가 셋 다 비슷해 보인다. 그 30분만 넘으면 — 기능 하나하나는 사실 같은 패턴의 변주다. 텍스트 위치를 받고, 우리 모델에서 의미를 뽑고, 응답을 돌려준다. 그게 전부다.
시작 체크리스트
vscode-languageserver-node의 샘플 LSP(server-and-client-sample)를 먼저 돌려보고 메시지 흐름을 눈으로 본다.--inspect=6009로 서버에 디버거 붙이는 법부터 익힌다.package.json의activationEvents를 최대한 좁혀라. 모든 사용자의 시작 시간을 책임진다.TextDocumentSyncKind.Incremental기본값. Full은 작은 파일 전용.- 진단은
source필드를 채워라 — 사용자가 어디서 온 경고인지 알 수 있게. onCompletionResolve로 무거운 정보를 지연 로드.- 큰 파일에 대비해 Tree-sitter 또는 직접 짠 incremental 파서.
- 첫 배포는 OpenVSX와 마켓플레이스 둘 다.
안티 패턴
activationEvents: ["*"]— 모든 시작을 느리게 한다. 사용자 분노의 가장 큰 원인.- 매 키 입력에 풀 파싱 — 큰 파일에서 끊긴다. Tree-sitter나 debounce 필수.
- 진단을 키 입력마다 즉시 — 300ms 정도 debounce. 사용자가 글자 하나마다 빨간 줄 깜빡이는 걸 본다.
- 수동 텍스트 동기화 —
TextDocuments매니저를 안 쓰고 직접 didChange를 처리하면 incremental 동기화의 오프셋 계산을 직접 해야 한다. 거의 항상 버그가 생긴다. - 포그라운드 분석 — 무거운 작업을 동기로 하면 그동안 모든 요청이 막힌다. async 또는 워커 스레드로.
- 메모리 누수 —
context.subscriptions에 모든 디스포저블을 넣지 않으면 비활성화 후에도 핸들러가 남는다. - rename에서 prepareRename 생략 — 사용자가 잘못된 위치(주석, 문자열)에서 F2를 눌렀을 때 막아야 한다.
- MS 마켓플레이스에만 게시 — Cursor·VSCodium 사용자가 못 받는다. OpenVSX도 같이.
다음 글 예고
- incremental 컴파일러를 LSP로 노출하기 — Salsa, Adapton, demand-driven 분석.
- DAP 깊은 다이브 — LSP의 디버그 자매. 같은 JSON-RPC, 다른 메서드.
- Copilot이 LSP인 척하는 법 —
completionProvider와inlineCompletionItemProvider의 미묘한 경계. - 에디터를 직접 짤 때의 LSP 클라이언트 — Helix와 Zed가 어떻게 합쳤는가.
참고 / References
- VS Code Extension API — 공식 문서
- Language Server Protocol 3.18 사양
- vscode-languageserver-node — GitHub
- tower-lsp — GitHub
- tower-lsp-server (포크)
- pygls — Python LSP 프레임워크
- Tree-sitter 공식 문서
- Open VSX Registry
- VS Code Marketplace — 게시 가이드
- vsce — 패키징 도구
- ovsx — OpenVSX CLI
- rust-analyzer — GitHub
- gopls 문서
- typescript-language-server
- ruff-lsp
- Astro Language Server
- LSP가 어떻게 시작됐는가 — Microsoft Blog
- Salsa — incremental computation
- matklad의 LSP 디자인 노트
- @vscode/extension-telemetry