Skip to content

필사 모드: 내 손으로 만드는 VS Code 확장과 Language Server — 2026 LSP 실전 가이드

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

프롤로그 — 에디터가 글을 읽게 만든 사람들

내가 처음으로 "에디터가 내 코드를 이해한다"고 느낀 건 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`는 다음 형태가 표준이다.

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가 해야 할 일:

1. 파싱하고 잘못된 곳에 진단을 띄운다.

2. 키와 값에 호버로 타입을 보여준다.

3. `$ref`에서 F12로 원본 키로 점프한다.

4. 자동완성으로 섹션·키·`$ref` 후보를 제안한다.

5. 이름 바꾸기로 키와 모든 참조를 같이 바꾼다.

이걸 처음부터 짜본다.

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 서버 부트스트랩

createConnection,

TextDocuments,

ProposedFeatures,

InitializeParams,

TextDocumentSyncKind,

InitializeResult,

} from 'vscode-languageserver/node';

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

문서가 바뀔 때마다 파싱하고 잘못된 곳을 빨간 줄로 보낸다.

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 자동완성

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 호버

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`를 켰으니 두 단계로 동작한다.

1. `prepareRename` — "이 위치에서 이름을 바꿀 수 있는가?"를 묻는다. 가능하면 바꿀 범위를 돌려준다.

2. `rename` — 새 이름을 받아서 `WorkspaceEdit`를 만든다.

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 코드 액션

진단 옆에 떠 있는 전구 아이콘이 코드 액션이다. "이 경고를 무시", "타입을 추가", "이 키를 두 곳에서 쓰기 — 추출" 같은 것들.

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의 인덱스가 곧 의미다.

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 확장 쪽이다. 서버를 자식 프로세스로 띄우고 클라이언트를 연결한다.

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(&params.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 사용

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(개인정보)는 절대 보내지 말 것. 익명화된 이벤트만.

- 첫 실행에서 사용자에게 알려야 한다 (마켓플레이스 정책).

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 — 공식 문서](https://code.visualstudio.com/api)

- [Language Server Protocol 3.18 사양](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/)

- [vscode-languageserver-node — GitHub](https://github.com/microsoft/vscode-languageserver-node)

- [tower-lsp — GitHub](https://github.com/ebkalderon/tower-lsp)

- [tower-lsp-server (포크)](https://github.com/tower-lsp-community/tower-lsp-server)

- [pygls — Python LSP 프레임워크](https://github.com/openlawlibrary/pygls)

- [Tree-sitter 공식 문서](https://tree-sitter.github.io/tree-sitter/)

- [Open VSX Registry](https://open-vsx.org/)

- [VS Code Marketplace — 게시 가이드](https://code.visualstudio.com/api/working-with-extensions/publishing-extension)

- [vsce — 패키징 도구](https://github.com/microsoft/vscode-vsce)

- [ovsx — OpenVSX CLI](https://github.com/eclipse/openvsx/tree/master/cli)

- [rust-analyzer — GitHub](https://github.com/rust-lang/rust-analyzer)

- [gopls 문서](https://github.com/golang/tools/tree/master/gopls)

- [typescript-language-server](https://github.com/typescript-language-server/typescript-language-server)

- [ruff-lsp](https://github.com/astral-sh/ruff-lsp)

- [Astro Language Server](https://github.com/withastro/language-tools)

- [LSP가 어떻게 시작됐는가 — Microsoft Blog](https://devblogs.microsoft.com/visualstudio/language-server-protocol/)

- [Salsa — incremental computation](https://github.com/salsa-rs/salsa)

- [matklad의 LSP 디자인 노트](https://matklad.github.io/)

- [@vscode/extension-telemetry](https://github.com/microsoft/vscode-extension-telemetry)

현재 단락 (1/695)

내가 처음으로 "에디터가 내 코드를 이해한다"고 느낀 건 2016년의 VS Code였다. 그 전까지 자동완성이라는 건 식별자 사전과 정규식의 그럴싸한 합작이었고, "Go to De...

작성 글자: 0원문 글자: 24,997작성 단락: 0/695