VS Code 확장 만들기: Commands 등록, WebView, 언어 지원, 설정, Marketplace 배포
이 글의 핵심
yo code로 확장 프로젝트를 만들고 activate 흐름을 이해한 뒤, 명령 등록, WebView 패널, 언어 기능, 사용자 설정 추가, vsce로 패키징·배포하는 과정을 다룹니다.
이 글의 핵심
VS Code 확장을 개발하는 과정을 정리한 글입니다. Extension API, Commands, WebView, Language Server, 배포를 예제로 다룹니다.
실무에서 마주치는 문제들
코드 스니펫이 부족해요
단순한 코드 조각이라면 확장을 만들 필요 없이 .vscode/*.code-snippets 파일로 충분합니다. 확장이 필요한 것은 입력받은 이름으로 파일을 여러 개 만들거나, 현재 파일 위치를 보고 경로를 계산하는 것처럼 로직이 들어가는 자동화입니다.
커스텀 기능이 필요해요
사내 전용 설정 파일 형식의 문법 강조, 내부 API 호출 결과를 에디터 옆 패널에 보여주기, 특정 주석 패턴을 찾아 목록으로 모으기처럼 공개 확장으로는 해결되지 않는 요구가 있습니다. VS Code는 이런 기능을 확장 API로 열어 두었고, 에디터 기능의 상당 부분(Git 연동, 언어 지원 등)도 내장 확장으로 만들어져 있습니다.
팀 워크플로우가 복잡해요
“새 API를 추가할 때는 이 폴더에 이 세 파일을 만들고 라우터에 등록한다” 같은 절차를 문서로만 남기면 사람마다 결과가 달라집니다. 명령 하나로 이 절차를 실행하게 만들면 규칙이 코드로 강제됩니다.
확장을 만들기 전에 알아 둘 점은 VS Code 확장이 에디터 UI를 직접 조작할 수 없다는 것입니다. 브라우저 확장처럼 DOM에 접근하는 방식이 아니라, VS Code가 정해 둔 API(명령, 트리 뷰, 상태 표시줄, WebView 등)를 통해서만 기능을 추가할 수 있습니다. 대신 이 제약 덕분에 확장이 에디터를 느리게 하거나 업데이트 때 깨지는 일이 줄어듭니다. 확장 코드는 에디터 UI와 분리된 Extension Host 프로세스에서 실행됩니다.
VS Code Extension이란?
핵심 특징
VS Code Extension은 에디터 기능을 확장합니다. 주요 기능:
- Commands: 커스텀 명령
- Language Support: 문법 강조, 자동완성
- WebView: 커스텀 UI
- Debugger: 디버깅 지원
- Theme: 색상 테마
프로젝트 설정
설치
npm install -g yo generator-code
yo code
프로젝트 구조
my-extension/
├── src/
│ └── extension.ts
├── package.json
└── tsconfig.json
yo code는 TypeScript/JavaScript 확장, 색상 테마, 언어 지원, 스니펫 등 템플릿을 고르게 하고 기본 구조를 만들어 줍니다. 전역 설치 없이 npx --package yo --package generator-code -- yo code로 실행해도 됩니다. 생성된 프로젝트에서 F5를 누르면 Extension Development Host라는 새 VS Code 창이 열리고, 개발 중인 확장이 설치된 상태로 동작합니다. 원래 창에서 중단점을 걸어 디버깅할 수 있으며, 코드를 고친 뒤에는 새 창에서 Developer: Reload Window를 실행해야 변경이 반영됩니다. TypeScript 빌드를 감시하는 watch 작업이 함께 실행되는데, 이 작업이 실패하면 out/extension.js가 갱신되지 않아 “고쳤는데 그대로”인 상태가 되므로 터미널의 빌드 에러부터 확인합니다.
기본 Extension
extension.ts
// src/extension.ts
import * as vscode from 'vscode';
export function activate(context: vscode.ExtensionContext) {
console.log('Extension activated!');
const disposable = vscode.commands.registerCommand(
'myextension.helloWorld',
() => {
vscode.window.showInformationMessage('Hello from My Extension!');
}
);
context.subscriptions.push(disposable);
}
export function deactivate() {}
activate는 확장이 처음 필요해지는 순간 한 번 호출되고, 여기서 명령·이벤트 리스너·프로바이더를 등록합니다. 등록 함수는 모두 Disposable을 반환하는데, 이를 context.subscriptions에 넣어 두면 확장이 비활성화될 때 VS Code가 자동으로 정리합니다. 이 배열에 넣지 않은 리스너는 확장을 다시 로드해도 남아 있어, 개발 중에 같은 알림이 두세 번씩 뜨는 현상의 원인이 됩니다.
console.log는 사용자에게 보이지 않고 개발 창의 디버그 콘솔에만 출력됩니다. 배포한 확장에서 로그를 남기려면 vscode.window.createOutputChannel('My Extension')으로 출력 패널 채널을 만들어 씁니다.
package.json
package.json은 일반 npm 설정이면서 동시에 확장의 매니페스트입니다. VS Code는 확장 코드를 실행하기 전에 이 파일의 contributes만 읽어 명령 팔레트 항목, 메뉴, 설정 화면을 먼저 구성합니다. 그래서 코드에서 registerCommand로 명령을 등록해도 contributes.commands에 선언하지 않으면 명령 팔레트에 나타나지 않고, 반대로 선언만 하고 등록하지 않으면 실행할 때 command 'myextension.helloWorld' not found 에러가 납니다. 두 곳의 명령 ID는 정확히 같아야 합니다.
{
"name": "my-extension",
"displayName": "My Extension",
"description": "My awesome extension",
"version": "0.0.1",
"engines": {
"vscode": "^1.80.0"
},
"activationEvents": [],
"main": "./out/extension.js",
"contributes": {
"commands": [
{
"command": "myextension.helloWorld",
"title": "Hello World"
}
]
}
}
activationEvents가 빈 배열인데도 동작하는 이유는 VS Code 1.74부터 contributes.commands에 선언한 명령은 실행될 때 자동으로 확장을 활성화하기 때문입니다. 그보다 오래된 버전을 지원하려면 "onCommand:myextension.helloWorld"를 직접 넣어야 합니다. 특정 언어 파일을 열 때 활성화하려면 "onLanguage:markdown", 워크스페이스에 특정 파일이 있을 때는 "workspaceContains:**/package.json"처럼 지정합니다. "*"(시작 시 항상 활성화)도 가능하지만 에디터 시작을 느리게 하므로 Marketplace 검토와 사용자 모두에게 좋지 않습니다.
engines.vscode는 이 확장이 요구하는 최소 VS Code 버전입니다. @types/vscode 패키지 버전을 이보다 높게 올리면, 타입상으로는 존재하지만 사용자의 구버전 VS Code에는 없는 API를 쓰게 되어 런타임에 undefined is not a function이 납니다. 두 버전을 함께 맞춰야 하며, vsce package도 이 불일치를 검사해 경고합니다.
Commands
텍스트 변환
const disposable = vscode.commands.registerCommand(
'myextension.toUpperCase',
() => {
const editor = vscode.window.activeTextEditor;
if (editor) {
const selection = editor.selection;
const text = editor.document.getText(selection);
editor.edit((editBuilder) => {
editBuilder.replace(selection, text.toUpperCase());
});
}
}
);
editor.edit()에 전달한 콜백 안의 변경은 하나의 편집으로 묶여, 사용자가 Ctrl+Z 한 번으로 되돌릴 수 있습니다. 이 함수는 Promise를 반환하는데, 편집 도중 문서가 다른 곳에서 바뀌면 false로 끝나며 변경이 적용되지 않습니다. 결과를 확인하려면 await로 받아야 합니다. 여러 커서로 여러 곳을 선택한 경우에도 처리하려면 editor.selection 대신 editor.selections 배열을 돌면서 각각 replace하면 됩니다. 현재 코드는 첫 번째 선택만 바꿉니다.
activeTextEditor는 포커스가 에디터가 아닌 곳(터미널, 출력 패널)에 있으면 undefined입니다. 명령 팔레트에서 실행할 때는 보통 에디터가 활성 상태로 남지만, 키 바인딩이나 다른 명령에서 호출될 때는 없을 수 있으므로 예제처럼 확인하는 코드가 필요합니다. 에디터가 없을 때는 조용히 끝내기보다 showWarningMessage로 이유를 알려주는 편이 사용자에게 친절합니다.
파일 생성
vscode.commands.registerCommand('myextension.createComponent', async () => {
const name = await vscode.window.showInputBox({
prompt: 'Component name',
});
if (!name) return;
const content = `
import React from 'react';
export default function ${name}() {
return <div>${name}</div>;
}
`;
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const filePath = vscode.Uri.joinPath(
workspaceFolder!.uri,
'src',
'components',
`${name}.tsx`
);
await vscode.workspace.fs.writeFile(
filePath,
Buffer.from(content, 'utf8')
);
const document = await vscode.workspace.openTextDocument(filePath);
await vscode.window.showTextDocument(document);
});
파일을 Node.js의 fs 대신 vscode.workspace.fs로 쓰는 이유는 원격 환경 때문입니다. Remote SSH, WSL, Dev Containers, GitHub Codespaces에서는 확장이 실행되는 곳과 파일이 있는 곳이 다를 수 있는데, workspace.fs는 URI 기반이라 이 차이를 VS Code가 처리해 줍니다. 경로도 문자열 결합 대신 Uri.joinPath를 써야 Windows의 역슬래시 문제가 생기지 않습니다. writeFile은 상위 폴더가 없으면 만들어 줍니다.
이 코드를 그대로 쓰면 두 가지 문제가 생깁니다. 폴더를 열지 않은 상태에서 실행하면 workspaceFolders가 undefined라 workspaceFolder!.uri에서 Cannot read properties of undefined 에러가 납니다. 그리고 같은 이름의 파일이 이미 있으면 경고 없이 덮어씁니다. 실제로는 폴더가 없을 때 안내 메시지를 띄우고, workspace.fs.stat()으로 파일 존재 여부를 확인해 덮어쓸지 묻는 단계를 넣어야 합니다. showInputBox에 validateInput을 지정하면 컴포넌트 이름에 공백이나 소문자 시작 같은 잘못된 입력을 입력 단계에서 막을 수 있습니다.
WebView
WebView는 에디터 탭 안에 iframe으로 임의의 HTML을 띄우는 기능입니다. 차트, 미리보기, 설정 마법사처럼 기본 UI 요소로 만들기 어려운 화면에 씁니다. 다만 WebView는 무겁고 VS Code의 테마·키보드 탐색·접근성과 자동으로 어울리지 않으므로, 목록 표시라면 TreeView, 선택지라면 QuickPick처럼 네이티브 API로 해결되는지 먼저 확인하는 것이 VS Code 공식 UX 가이드의 권고입니다.
vscode.commands.registerCommand('myextension.showWebview', () => {
const panel = vscode.window.createWebviewPanel(
'myWebview',
'My Webview',
vscode.ViewColumn.One,
{
enableScripts: true,
}
);
panel.webview.html = getWebviewContent();
panel.webview.onDidReceiveMessage(
(message) => {
switch (message.command) {
case 'alert':
vscode.window.showInformationMessage(message.text);
return;
}
},
undefined,
context.subscriptions
);
});
function getWebviewContent() {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
</head>
<body>
<h1>Hello from Webview!</h1>
<button onclick="sendMessage()">Click me</button>
<script>
const vscode = acquireVsCodeApi();
function sendMessage() {
vscode.postMessage({
command: 'alert',
text: 'Hello from Webview!'
});
}
</script>
</body>
</html>
`;
}
WebView는 확장과 다른 프로세스·다른 보안 컨텍스트에서 실행되므로 vscode API에 직접 접근할 수 없고, postMessage로만 대화합니다. WebView에서 acquireVsCodeApi()로 얻은 객체의 postMessage로 보내면 확장의 onDidReceiveMessage가 받고, 반대로 확장에서 panel.webview.postMessage()로 보내면 WebView의 window.addEventListener('message', ...)가 받습니다. acquireVsCodeApi()는 한 WebView에서 한 번만 호출할 수 있어서, 두 번 부르면 An instance of the VS Code API has already been acquired 에러가 납니다.
처음 WebView를 만들 때 자주 겪는 문제는 상태가 사라지는 것입니다. 사용자가 다른 탭으로 전환하면 WebView의 내용은 파괴되고, 돌아오면 HTML을 처음부터 다시 로드합니다. 입력하던 내용과 스크롤 위치가 모두 초기화됩니다. vscode.setState()/getState()로 상태를 저장해 두고 복원하는 것이 권장 방식이고, retainContextWhenHidden: true 옵션으로 숨겨져도 유지하게 할 수 있지만 메모리를 계속 쓰므로 꼭 필요할 때만 씁니다.
보안도 챙겨야 합니다. 이 예제는 설명을 위해 Content Security Policy 없이 인라인 onclick과 스크립트를 쓰고 있지만, 실제 확장에서는 <meta http-equiv="Content-Security-Policy">로 스크립트 출처를 nonce가 붙은 것만 허용해야 합니다. WebView에 사용자 파일 내용이나 외부 데이터를 넣는다면 이 정책이 없을 때 스크립트 주입으로 이어질 수 있습니다. CSP를 적용하면 인라인 onclick은 막히므로 addEventListener로 바꿔야 합니다. 로컬 이미지나 스크립트 파일은 panel.webview.asWebviewUri()로 변환한 URI를 써야 로드되며, localResourceRoots 옵션으로 접근 가능한 폴더를 제한합니다.
Language Support
Syntax Highlighting
// package.json
{
"contributes": {
"languages": [
{
"id": "mylang",
"extensions": [".mylang"],
"aliases": ["MyLang"]
}
],
"grammars": [
{
"language": "mylang",
"scopeName": "source.mylang",
"path": "./syntaxes/mylang.tmLanguage.json"
}
]
}
}
languages는 .mylang 확장자 파일을 mylang 언어로 인식하게 하고, grammars는 그 언어의 문법 강조 규칙 파일을 연결합니다. 문법 강조는 TextMate 문법(정규식 기반 패턴 목록)으로 정의하며 확장 코드가 실행되지 않아도 적용됩니다. 정규식으로 줄 단위를 처리하는 방식이라 여러 줄에 걸친 구조나 문맥에 따라 의미가 바뀌는 토큰은 정확히 칠하기 어렵습니다. 그런 부분은 확장 코드에서 Semantic Tokens 프로바이더를 추가로 구현해 보완합니다. 문법 파일을 고친 뒤에는 Developer: Inspect Editor Tokens and Scopes 명령으로 커서 위치의 토큰에 어떤 스코프가 붙었는지 확인하면서 작업하면 편합니다.
주석 기호와 괄호 짝 맞추기는 languages 항목에 "configuration": "./language-configuration.json"을 추가해 따로 정의합니다. 이 파일이 없으면 Ctrl+/로 주석 처리가 되지 않습니다.
Code Completion
const provider = vscode.languages.registerCompletionItemProvider('javascript', {
provideCompletionItems(document, position) {
const completionItem = new vscode.CompletionItem('console.log');
completionItem.insertText = new vscode.SnippetString('console.log($1);');
completionItem.documentation = new vscode.MarkdownString('Log to console');
return [completionItem];
},
});
context.subscriptions.push(provider);
SnippetString의 $1은 삽입 후 커서가 놓일 자리입니다. provideCompletionItems는 사용자가 타이핑할 때마다 호출되므로 여기서 파일 시스템이나 네트워크를 매번 읽으면 자동완성이 눈에 띄게 늦어집니다. 필요한 데이터는 미리 캐시하고, 오래 걸리는 계산은 전달받는 CancellationToken을 확인해 사용자가 계속 타이핑하면 중단해야 합니다.
자동완성, 정의로 이동, 에러 표시 같은 언어 기능을 본격적으로 만들려면 이렇게 프로바이더를 하나씩 등록하는 대신 Language Server Protocol(LSP)을 쓰는 것이 일반적입니다. 언어 분석 로직을 별도 서버 프로세스로 분리하고, 확장은 vscode-languageclient로 서버를 띄워 표준 프로토콜로 대화합니다. 분석이 무거워도 에디터가 멈추지 않고, 같은 서버를 Neovim이나 JetBrains IDE에서도 재사용할 수 있다는 장점이 있습니다. 예제 정도의 단순한 완성 항목이라면 LSP까지는 필요 없습니다.
설정
package.json
{
"contributes": {
"configuration": {
"title": "My Extension",
"properties": {
"myextension.enable": {
"type": "boolean",
"default": true,
"description": "Enable My Extension"
},
"myextension.apiKey": {
"type": "string",
"default": "",
"description": "API Key"
}
}
}
}
}
사용
const config = vscode.workspace.getConfiguration('myextension');
const isEnabled = config.get('enable');
const apiKey = config.get('apiKey');
contributes.configuration에 선언한 설정은 VS Code 설정 화면에 자동으로 나타나고, 사용자 설정과 워크스페이스 설정(.vscode/settings.json) 중 더 구체적인 쪽이 적용됩니다. config.get('enable')의 반환 타입은 unknown에 가깝게 추론되므로 config.get<boolean>('enable', true)처럼 타입과 기본값을 함께 넘기는 편이 안전합니다. getConfiguration()은 호출 시점의 값을 읽은 스냅샷이라, 사용자가 확장 실행 중에 설정을 바꾸면 반영되지 않습니다. vscode.workspace.onDidChangeConfiguration으로 변경을 감지하고 e.affectsConfiguration('myextension.enable')일 때 다시 읽어야 합니다.
예제의 apiKey처럼 비밀 값을 일반 설정에 두면 settings.json에 평문으로 저장되고, 설정 동기화 기능으로 다른 기기에 복사되며, 워크스페이스 설정이라면 Git에 커밋될 수도 있습니다. API 키나 토큰은 context.secrets.store('apiKey', value)와 context.secrets.get('apiKey')로 SecretStorage에 저장하면 운영체제의 자격 증명 저장소(키체인 등)에 보관됩니다.
배포
VSCE
npm install -g @vscode/vsce
vsce package
vsce publish
Marketplace
- https://marketplace.visualstudio.com/manage
- Publisher 생성
- Extension 업로드
vsce package는 확장을 .vsix 파일 하나로 묶습니다. 이 파일은 Marketplace에 올리지 않고도 code --install-extension my-extension-0.0.1.vsix로 설치할 수 있어서, 사내 전용 확장은 이 방식으로 배포하는 경우가 많습니다. vsce publish로 Marketplace에 올리려면 package.json에 publisher 필드가 있어야 하고, Azure DevOps에서 발급한 Personal Access Token으로 vsce login <publisher>를 해야 합니다. 토큰 발급 시 조직 범위를 “All accessible organizations”로, 권한을 Marketplace의 Manage로 지정하지 않으면 401 또는 Failed request: Unauthorized 에러가 나는데, 처음 배포할 때 가장 많이 막히는 단계입니다.
패키징 전에 .vscodeignore로 소스 코드, 테스트, node_modules의 개발 의존성을 제외해야 .vsix 크기가 줄어듭니다. 의존성이 많은 확장이라면 esbuild나 webpack으로 하나의 파일로 번들하는 것이 공식 권장 방식이며, 번들하면 설치 크기와 활성화 시간이 모두 줄어듭니다. README.md의 이미지는 HTTPS URL이어야 하고, LICENSE 파일이 없으면 패키징 중에 경고가 뜹니다. Cursor나 VSCodium 같은 VS Code 기반 에디터 사용자에게도 배포하려면 Open VSX 레지스트리에 ovsx publish로 따로 올려야 합니다.
정리 및 체크리스트
핵심 요약
- VS Code Extension: 에디터 확장
- Commands: 커스텀 명령
- WebView: 커스텀 UI
- Language Support: 문법 강조, 자동완성
- 설정: 사용자 설정
- 배포: Marketplace
구현 체크리스트
- 프로젝트 생성
- Commands 구현
- WebView 추가
- Language Support 구현
- 설정 추가
- 테스트
- 배포
같이 보면 좋은 글
자주 묻는 질문 (FAQ)
Q. TypeScript를 배워야 하나요?
A. 필수는 아닙니다. JavaScript로도 확장을 만들 수 있고 yo code에서 JavaScript 템플릿을 고를 수 있습니다. 다만 VS Code API가 방대해 타입 정보가 있는 TypeScript 쪽이 자동완성과 오류 확인에서 훨씬 편하므로 대부분의 예제와 공식 문서가 TypeScript를 씁니다.
Q. 수익화가 가능한가요?
A. Marketplace 자체에는 유료 판매 기능이 없습니다. 유료 기능을 제공하려면 확장 안에서 자체 라이선스 키 검증이나 외부 구독 서비스와 연동하는 방식을 씁니다.
Q. 테스트는 어떻게 하나요?
A. @vscode/test-cli와 @vscode/test-electron으로 실제 VS Code 인스턴스를 띄워 통합 테스트를 실행합니다. VS Code API에 의존하지 않는 순수 로직은 따로 분리해 일반 단위 테스트 도구로 검증하면 훨씬 빠릅니다. CI(Linux)에서는 화면이 없으므로 xvfb-run으로 가상 디스플레이를 띄워야 합니다.
Q. 확장이 에디터를 느리게 만들지 않으려면?
A. 시작 시 활성화("*")를 피하고, activate에서 무거운 초기화를 하지 말고 필요한 시점에 지연 로드합니다. Developer: Show Running Extensions 명령으로 각 확장의 활성화 시간을 확인할 수 있습니다.