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

들어가며

플러그인 시스템을 설계할 때 가장 어려운 질문은 “어디까지 열어줄 것인가”입니다. 너무 열면 안정성이 무너지고, 너무 닫으면 확장성이 없습니다. 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 경유 가능

세 가지를 참고해서 설계한 구조

세 앱을 분석하면서 실제로 플러그인 시스템을 만들 때 각각에서 무엇을 가져올 수 있는지 정리해봤습니다.

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

플러그인이 뷰를 직접 주입하는 대신 선언형 데이터 모델을 반환하고, 호스트 앱이 이를 자신의 네이티브 컴포넌트로 렌더링하는 구조는 UI 안정성과 일관성을 동시에 잡는 방법입니다. 플러그인의 UI 자유도는 줄어들지만, 호스트 앱의 디자인 시스템을 깨지 않고 확장할 수 있습니다. 특히 플러그인이 렌더링 경로에 개입하지 못하도록 데이터와 렌더링을 분리하는 것이 핵심입니다.

VS Code에서: manifest 기반 lazy activation + 정적 contribution

플러그인이 관심 있는 이벤트(activationEvents)와 기여할 확장 지점(contribution points)을 manifest에 미리 선언하면, 호스트는 플러그인 코드를 실행하기 전에도 UI를 구성할 수 있습니다. 또한 이 선언을 기반으로 필요할 때만 플러그인을 활성화하고, 관련 없는 이벤트는 dispatch하지 않아 성능 예산을 통제할 수 있습니다. 플러그인 런타임을 별도 프로세스나 worker로 분리하는 방향은 초기에 오버헤드가 있더라도 앱 핵심 경로를 보호하는 데 가장 확실한 수단입니다.

Figma에서: permission 선언 + sanitized DTO 경계

플러그인이 접근할 수 있는 데이터와 기능을 manifest의 permissions로 미리 선언하게 하고, 호스트는 내부 모델을 그대로 노출하지 않고 sanitized DTO만 플러그인에 전달합니다. Figma의 메인 스레드 ↔ iframe 메시지 경계처럼, 두 컨텍스트 사이를 직렬화 가능한 값만 오가게 하면 플러그인이 호스트의 내부 상태를 참조·변형하는 경로를 원천 차단할 수 있습니다. 네트워크 접근은 기본 차단, 허용 도메인과 이유를 명시하는 방식은 사용자 신뢰를 얻는 데도 효과적입니다.


마치며

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

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

플러그인 시스템을 직접 만들어보려 한다면, 세 앱이 각자의 방식으로 먼저 걸어간 길이 좋은 지도가 되어줄 겁니다.


🤖 이 글은 Claude Code가 작성했습니다.

답글 남기기

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