플러그인 아키텍처 탐구: Raycast, VS Code, Figma에서 배운 것들

들어가며

DevIsland 플러그인 시스템을 설계하면서 세 가지 앱의 플러그인 구조를 집중적으로 참고했습니다. Raycast, Visual Studio Code, Figma — 각각 전혀 다른 도메인의 앱이지만, 플러그인 생태계를 안전하고 확장 가능하게 설계하는 방법에 대해 서로 다른 관점의 해답을 가지고 있습니다.


1. Raycast — “작은 명령어”의 철학

Extension 모델: Command와 Panel의 단위

Raycast의 플러그인 단위는 Extension이고, 그 안에서 실제 기능의 단위는 Command입니다. 하나의 Extension은 여러 개의 Command를 포함할 수 있고, 각 Command는 독립적인 진입점을 가집니다. 예를 들어 GitHub Extension에는 “Open Pull Request”, “Search Issues”, “Create Gist” 같은 Command들이 있고, 사용자는 Raycast 검색창에서 직접 Command 이름으로 접근합니다.

Command의 종류는 크게 세 가지입니다:

  • List: 항목 목록을 보여주는 뷰. 검색·필터·액션 조합이 가능합니다.
  • Detail: 단일 항목의 상세 정보를 마크다운으로 보여주는 뷰.
  • Form: 텍스트 필드, 드롭다운, 체크박스 등 입력 폼.

이 외에도 Menu Bar Extra Command(메뉴바 상주), No-View Command(UI 없이 즉시 실행) 같은 변형이 있습니다.

React로 선언하되 Native로 렌더링한다

Raycast Extension은 React로 UI를 작성합니다. 하지만 중요한 점은 이 React 코드가 웹 브라우저나 Electron WebView에서 실행되는 것이 아니라는 점입니다. Raycast는 React의 선언형 UI 모델만 빌려오고, 실제 렌더링은 Raycast 자신이 소유한 Native SwiftUI 컴포넌트로 처리합니다.

Extension 개발자는 @raycast/api 패키지에서 List, Detail, Form, Action 같은 컴포넌트를 import해서 JSX로 조합합니다. 그러면 Raycast는 이 컴포넌트 트리를 해석해 자신의 네이티브 UI로 그립니다. 결과적으로:

  • Extension 개발자는 CSS나 레이아웃을 직접 제어할 수 없습니다. 자유도를 의도적으로 줄인 겁니다.
  • 모든 Extension이 Raycast의 디자인 시스템을 따르므로 일관된 UX가 보장됩니다.
  • Extension 코드가 Raycast의 렌더링 파이프라인을 오염시킬 수 없습니다.
// Raycast Extension 예시 — React로 선언하지만 native로 렌더링됨
import { List, ActionPanel, Action } from "@raycast/api";

export default function Command() {
  return (
    <List>
      <List.Item
        title="Hello, Raycast"
        actions={
          <ActionPanel>
            <Action title="Do Something" onAction={() => console.log("!")} />
          </ActionPanel>
        }
      />
    </List>
  );
}

ActionPanel: 액션의 표준화

Raycast의 UI 철학에서 핵심 개념 중 하나가 ActionPanel입니다. 모든 리스트 아이템과 뷰에는 ActionPanel을 붙일 수 있고, 여기에 Action 컴포넌트를 나열합니다. 사용자는 ⌘K로 ActionPanel을 열어 가능한 액션 목록을 확인합니다.

이 구조는 Extension 개발자가 “버튼을 어디에 배치할까”를 고민하지 않게 합니다. 액션은 항상 ActionPanel 안에 있고, Raycast가 그 표현 방식을 결정합니다. Action.OpenInBrowser, Action.CopyToClipboard, Action.Push 같이 자주 쓰이는 액션은 미리 만들어진 컴포넌트로 제공됩니다.

Storage와 Preferences: 격리된 상태 관리

Extension이 상태를 영속화할 때는 두 가지 메커니즘을 씁니다:

  • Storage: LocalStorage API를 통해 Extension 전용 key-value 저장소에 접근합니다. 다른 Extension과 공유되지 않으며, Raycast가 Extension 삭제 시 함께 지웁니다.
  • Preferences: 사용자가 Raycast 설정 UI에서 값을 입력하고, Extension은 이 값을 읽기만 합니다. Extension은 Preferences 스키마를 package.json에 선언하고, Raycast가 설정 UI를 자동으로 생성합니다.

Node.js 런타임과 샌드박스 한계

Raycast Extension은 Node.js 환경에서 실행됩니다. 이 덕분에 Extension 개발자는 npm 생태계를 그대로 활용할 수 있습니다. 하지만 이는 동시에 강한 샌드박스가 없다는 뜻이기도 합니다. Raycast는 이를 기술적 제약보다는 리뷰 프로세스와 오픈 소스 정책으로 통제합니다. 스토어에 올라오는 모든 Extension은 GitHub에 소스가 공개되어 있고 코드 리뷰를 통과해야 합니다.


2. Visual Studio Code — “구조적 안정성”의 참고서

Extension Host: 프로세스 격리의 핵심

VS Code 플러그인 아키텍처에서 가장 중요한 개념은 Extension Host입니다. Extension은 VS Code의 메인 렌더러 프로세스가 아닌 별도의 Node.js 프로세스인 Extension Host 안에서 실행됩니다. 이 설계가 가져오는 이점은 명확합니다:

  • Extension이 무한 루프나 메모리 누수를 일으켜도 VS Code UI가 멈추지 않습니다.
  • Extension이 크래시해도 Extension Host만 재시작하면 됩니다.
  • 렌더러 프로세스의 DOM에 Extension이 직접 접근할 수 없습니다.

Extension Host와 렌더러 사이의 통신은 VS Code의 내부 IPC를 통해 이루어집니다. Extension은 vscode API를 통해 VS Code의 기능을 제어하는데, 이 API 호출들은 실제로는 IPC 메시지로 Extension Host에서 렌더러로 전달됩니다.

VS Code 1.74부터는 Web Extension 개념도 도입되어, 브라우저 환경(github.dev 등)에서는 Extension이 별도 Worker 스레드에서 실행됩니다. 환경에 따라 런타임이 달라지지만 Extension Host라는 격리 레이어의 철학은 동일합니다.

Activation Events: 게으른 활성화

VS Code Extension은 VS Code가 시작될 때 즉시 로드되지 않습니다. Activation Eventspackage.json에 선언하고, 해당 이벤트가 발생할 때만 Extension이 활성화됩니다.

// package.json (VS Code Extension)
{
  "activationEvents": [
    "onLanguage:python",
    "onCommand:myExtension.helloWorld",
    "workspaceContains:**/.myconfig"
  ]
}

자주 쓰이는 Activation Events:

  • onLanguage:python: Python 파일이 열렸을 때
  • onCommand:myExtension.helloWorld: 특정 커맨드가 실행됐을 때
  • workspaceContains:**/Makefile: 워크스페이스에 특정 파일이 있을 때
  • onStartupFinished: VS Code가 완전히 시작된 뒤
  • *: 무조건 활성화 (권장하지 않음)

이 메커니즘 덕분에 수백 개의 Extension이 설치되어 있어도, 현재 작업과 관련 없는 Extension은 메모리에 올라오지 않습니다. VS Code의 시작 속도가 Extension 수에 비례해 느려지지 않는 이유입니다.

Contribution Points: 정적 선언으로 확장 지점을 등록하다

Contribution Points는 Extension이 VS Code의 어떤 부분을 확장할 수 있는지를 package.json정적으로 선언하는 방식입니다. Extension Host가 활성화되기 전에도 VS Code는 이 선언을 읽어 커맨드 팔레트에 커맨드를 등록하고, 키 바인딩을 추가하고, 언어 지원을 준비합니다.

// package.json — Contribution Points 선언 예시
{
  "contributes": {
    "commands": [
      {
        "command": "myExtension.helloWorld",
        "title": "Hello World",
        "category": "My Extension"
      }
    ],
    "menus": {
      "editor/context": [
        {
          "command": "myExtension.helloWorld",
          "when": "editorHasSelection"
        }
      ]
    },
    "configuration": {
      "title": "My Extension",
      "properties": {
        "myExtension.maxItems": {
          "type": "number",
          "default": 100,
          "description": "Maximum number of items to show"
        }
      }
    }
  }
}

주요 Contribution Points:

  • commands: 커맨드 팔레트에 등록할 커맨드
  • menus: 컨텍스트 메뉴, 에디터 타이틀 바, 탐색기 등에 메뉴 아이템 추가
  • keybindings: 단축키 바인딩
  • configuration: Settings UI에 설정 항목 추가
  • languages: 언어 정의 (파일 확장자, 색상 테마와의 연결)
  • viewsContainers + views: 사이드바나 패널에 커스텀 뷰 추가

Contribution Points가 강력한 이유는 정적 선언이 코드 실행보다 먼저 처리된다는 점입니다. VS Code는 Extension이 활성화되기 전에도 메뉴에 아이템을 보여주고, 사용자가 그것을 클릭하는 시점에 Extension을 활성화합니다. Extension은 이미 선언된 계약을 이행할 뿐입니다.

Extension Capabilities와 Webview 격리

VS Code는 Extension의 능력을 크게 두 가지로 분류합니다:

  • 일반 Extension: UI 스레드에 직접 접근하지 않고 Extension Host에서만 실행됩니다. 대부분의 Extension이 여기 속합니다.
  • Webview Extension: Webview를 통해 완전한 HTML/CSS/JS UI를 제공할 수 있습니다. 하지만 Webview는 iframe 안에 격리되어 VS Code의 DOM을 직접 조작하지 못합니다.

Webview API는 이 격리를 명시적으로 설계했습니다. Extension과 Webview 사이의 통신은 postMessage로만 가능하고, Webview는 자신만의 Content Security Policy를 가집니다.

Language Server Protocol: 무거운 기능의 별도 프로세스화

파싱, 타입 검사, 자동 완성처럼 무거운 언어 지원 기능은 Extension Host에서도 부담이 됩니다. VS Code는 이를 위해 Language Server Protocol (LSP)을 표준화했습니다. Language Server는 Extension Host와도 독립된 별도 프로세스로 실행되고, JSON-RPC로 통신합니다. 이 계층 분리 덕분에 TypeScript Language Server, Rust Analyzer, clangd 같은 무거운 서버가 VS Code Extension Host를 블로킹하지 않습니다. LSP는 이후 Neovim, Emacs, Sublime Text 등 다른 에디터들도 채택해 사실상 표준 프로토콜이 되었습니다.


3. Figma — “경계 계약”으로 설계하는 플러그인

Manifest: 신뢰의 첫 번째 관문

Figma 플러그인은 manifest.json에서 시작합니다. 이 파일은 플러그인이 무엇을 하고, 어떤 권한을 요구하는지를 선언합니다:

{
  "name": "My Plugin",
  "id": "1234567890",
  "api": "1.0.0",
  "main": "code.js",
  "ui": "ui.html",
  "permissions": ["currentuser"],
  "networkAccess": {
    "allowedDomains": ["https://api.example.com"],
    "reasoning": "Fetches design tokens from our design system API"
  }
}

각 필드가 하는 일:

  • api: 플러그인이 사용하는 Figma Plugin API 버전. 하위 호환성 관리에 사용됩니다.
  • main: 메인 플러그인 코드 경로. Figma의 메인 스레드에서 실행됩니다.
  • ui: 플러그인 UI HTML 경로. 별도 iframe에서 실행됩니다.
  • permissions: 요청하는 권한. currentuser는 현재 로그인한 사용자 정보 접근 권한입니다.
  • networkAccess: 네트워크 요청이 필요하면 허용할 도메인과 이유를 명시해야 합니다. 기본값은 none입니다.

networkAccess.reasoning 필드가 특히 인상적입니다. 기술적인 허용 여부를 넘어 이 권한이 필요한지를 사람이 읽을 수 있는 텍스트로 요구합니다. 이 내용은 Figma 커뮤니티에 플러그인을 제출할 때 리뷰어와 사용자에게 표시됩니다.

두 실행 컨텍스트: 메인 스레드와 UI iframe

Figma 플러그인의 핵심 구조적 특징은 코드가 두 개의 완전히 다른 컨텍스트에서 실행된다는 점입니다:

  • 메인 스레드 (code.js): Figma의 실제 캔버스에 접근할 수 있는 코드가 여기서 실행됩니다. figma.currentPage, figma.createRectangle() 같은 Figma Plugin API를 직접 호출합니다. 그러나 DOM이 없습니다 — UI를 직접 그릴 수 없습니다.
  • UI iframe (ui.html): HTML/CSS/JS로 구성된 플러그인 UI가 샌드박스 iframe 안에서 실행됩니다. 사용자와 상호작용하는 폼, 버튼 등이 여기 있습니다. 하지만 Figma Plugin API에 직접 접근할 수 없습니다.

두 컨텍스트가 통신하는 방법은 오직 postMessage뿐입니다:

// code.js (메인 스레드) — Figma API 접근 가능, DOM 없음
figma.showUI(__html__);

figma.ui.onmessage = (msg) => {
  if (msg.type === 'create-rect') {
    const rect = figma.createRectangle();
    rect.x = msg.x;
    rect.y = msg.y;
    figma.closePlugin();
  }
};

// ui.html (iframe) — DOM 있음, Figma API 직접 접근 불가
document.getElementById('btn').onclick = () => {
  parent.postMessage({
    pluginMessage: { type: 'create-rect', x: 100, y: 100 }
  }, '*');
};

이 구조가 가진 의미:

  • UI 코드(iframe)가 악성이더라도 Figma 캔버스에 직접 접근하지 못합니다. 반드시 메인 스레드를 거쳐야 하고, 메인 스레드가 무엇을 허용할지 결정합니다.
  • 메인 스레드 코드는 DOM API가 없으므로 XSS 공격 벡터가 없습니다.
  • 두 컨텍스트 사이의 메시지는 직렬화 가능한 값만 전달할 수 있어 참조 공유가 불가능합니다.

네트워크 격리: 기본이 차단, 예외가 허용

Figma 플러그인의 네트워크 접근 정책은 allowlist 기반입니다. manifest에 명시하지 않은 도메인으로의 fetch 요청은 차단됩니다. 이 정책은 플러그인이 사용자의 Figma 디자인 데이터를 외부로 무단 전송하는 것을 막기 위한 설계입니다.

networkAccess.allowedDomains에 선언된 도메인만 허용되며, https://만 지원합니다(HTTP 불가). Figma 커뮤니티에 플러그인을 제출할 때 이 도메인들과 reasoning이 리뷰됩니다.

플러그인 데이터와 공유 데이터

Figma는 플러그인 데이터 저장을 여러 레벨로 제공합니다:

  • setPluginData / getPluginData: 특정 노드(레이어)에 플러그인 전용 메타데이터를 저장합니다. 해당 플러그인만 읽고 쓸 수 있습니다.
  • setSharedPluginData / getSharedPluginData: 네임스페이스를 지정하면 다른 플러그인도 읽을 수 있는 데이터를 노드에 저장합니다. 플러그인 간 데이터 공유가 필요한 경우 사용합니다.
  • clientStorage: 노드와 무관하게 클라이언트에 저장하는 키-값 저장소. 사용자 설정 같은 로컬 상태에 씁니다.

setSharedPluginData의 존재가 흥미롭습니다. 완전한 격리보다는 제어된 공유를 허용하는 선택입니다. 네임스페이스를 통해 의도적인 공유만 가능하게 하고, 실수로 다른 플러그인 데이터를 덮어쓰는 일을 방지합니다.

How Plugins Run: 실행 모델의 현실

Figma 플러그인의 메인 스레드 코드는 실제로 Figma의 메인 JavaScript 환경 내부에서 실행됩니다. VS Code의 Extension Host처럼 별도 프로세스가 아닙니다. 이는 플러그인이 메인 스레드를 블로킹할 수 있다는 의미이기도 합니다.

Figma는 이 제약을 인정하면서, 무거운 연산이 필요한 경우 figma.ui를 통해 iframe Worker로 작업을 넘기는 패턴을 권장합니다. 메인 스레드는 가볍게 유지하고, 실제 연산은 UI iframe의 Web Worker에서 수행하는 방식입니다.


세 아키텍처의 비교

항목 Raycast VS Code Figma
런타임 격리 Node.js 프로세스 (단일) Extension Host 프로세스 (별도) 메인 스레드 내 + iframe
UI 모델 React 선언 → Native 렌더링 Contribution Points + Webview HTML/CSS/JS iframe
UI 자유도 낮음 (컴포넌트만 선택) 중간 (Webview는 자유롭지만 격리) 높음 (완전한 HTML)
네트워크 제한 없음 (리뷰로 통제) 제한 없음 manifest allowlist 필수
저장소 LocalStorage (플러그인별 격리) ExtensionContext.globalState clientStorage + node metadata
활성화 사용자가 커맨드 실행 시 activationEvents 선언 figma.showUI() 호출 시
플러그인 간 통신 불가 불가 (command API 경유만) sharedPluginData 경유 가능

DevIsland 플러그인 설계에 적용한 것들

Raycast에서: 선언형 UI + host 렌더링

플러그인이 SwiftUI 뷰를 직접 주입하지 않고 PluginUIComponentDTO 같은 선언형 데이터 모델을 반환하면, DevIsland가 이를 native 컴포넌트로 렌더링합니다. Raycast의 “React로 선언, native로 렌더링” 패턴과 동일한 철학입니다. 플러그인의 UI 자유도를 줄이는 대신, 앱 전체의 디자인 일관성과 렌더링 안정성을 확보합니다.

VS Code에서: activationEvents + contribution points

PluginManifestactivationEventssurfaces 필드는 VS Code의 Activation Events와 Contribution Points에서 직접 영감을 받았습니다. 플러그인은 관심 있는 이벤트와 기여할 슬롯을 미리 선언하고, PluginHost는 이 선언을 기반으로 불필요한 dispatch를 줄입니다. 또한 v2 external runtime 설계에서는 Extension Host처럼 별도 worker process에서 플러그인을 실행하는 방향을 우선 검토합니다.

Figma에서: manifest permission + DTO boundary

PluginPermission enum과 manifest의 permissions 선언은 Figma의 권한 모델을 참고했습니다. 특히 네트워크 접근을 기본 none으로 두고 허용 도메인과 이유를 요구하는 방식은, DevIsland의 v2 외부 플러그인에서 동일하게 적용할 계획입니다. Figma의 메인 스레드 ↔ iframe 메시지 경계는 DevIsland의 PluginEventFactory가 raw 내부 모델 대신 sanitized DTO만 플러그인에 전달하는 설계로 이어졌습니다.


마치며

세 앱은 서로 다른 문제를 풀고 있지만, 좋은 플러그인 아키텍처에 공통된 패턴이 있다는 것을 보여줍니다: 선언이 코드보다 먼저 처리되고, 격리 경계에서 직렬화 가능한 메시지만 오가며, 저장소는 플러그인별로 격리된다.

세 앱 모두 완벽한 격리를 달성하지는 못합니다. Raycast는 Node.js의 자유로움을 리뷰 프로세스로 보완하고, Figma는 메인 스레드 공유를 인정하면서 iframe 경계로 UI를 격리하며, VS Code는 Extension Host 프로세스 분리라는 강력한 수단을 씁니다. 어떤 격리 모델을 택하느냐는 결국 앱의 보안 요구사항, 성능 예산, 개발자 경험 사이의 트레이드오프 결정입니다.

DevIsland는 v1에서 built-in plugin으로 시작해 이 트레이드오프를 직접 경험한 뒤, v2 외부 runtime 설계로 나아가려 합니다. 세 앱이 각자의 방식으로 먼저 걸어간 길이 그 과정에 좋은 지도가 되리라 생각합니다.

이 글은 DevIsland의 플러그인 아키텍처 설계 문서를 작성하는 과정에서 정리한 내용입니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 항목은 *(으)로 표시합니다