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는 다음 형태가 표준이다.

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

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

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

  1. prepareRename — "이 위치에서 이름을 바꿀 수 있는가?"를 묻는다. 가능하면 바꿀 범위를 돌려준다.
  2. 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(&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 서버를 띄우려면 ServerOptionscommand 형태를 쓴다.

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.telemetryLeveloff로 두면 자동으로 비활성.
  • 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.jsonactivationEvents를 최대한 좁혀라. 모든 사용자의 시작 시간을 책임진다.
  • 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인 척하는 법completionProviderinlineCompletionItemProvider의 미묘한 경계.
  • 에디터를 직접 짤 때의 LSP 클라이언트 — Helix와 Zed가 어떻게 합쳤는가.

참고 / References

현재 단락 (1/695)

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

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