다른 앱에 ⌘V 를 보내는 Electron 네이티브 도구

왼쪽 창 안에 선 사람이 긴 막대를 뻗어 오른쪽 창 앞에 놓인 커다란 키 하나를 누른다
내 창 밖의 키는 손이 닿지 않아서 막대를 하나 만들었다

클립보드 매니저나 Raycast 같은 런처는 항목을 고르면 창을 닫고, 방금까지 쓰던 앱의 입력칸에 그 내용을 넣는다. 그런데 같은 걸 Electron 으로 만들려고 하면 첫 줄에서 막힌다. Electron 에는 다른 앱으로 키 입력을 보내는 API 가 없다. webContents.sendInputEvent 가 있지만 이 함수는 자기 창 안에서만 동작한다.

그래서 macOS 에서는 앱 안에 Swift 로 만든 작은 명령줄 실행 파일을 하나 넣는다. 앞으로 네이티브 도구라고 부를 이 파일을 Electron 메인 프로세스가 execFile 로 부르고 결과는 JSON 한 줄로 돌려받는다. ⌘V 를 실제로 누르는 쪽은 네이티브 도구이고, 누르는 방법은 CGEventPost 다. 쓰는 API 는 AppKit, ApplicationServices, CoreGraphics 쪽에 있다. 키 이벤트를 합성하고 다른 앱의 창을 만지려면 손쉬운 사용(Accessibility) 권한이 있어야 한다. 이 권한을 유지하려면 다시 빌드해도 바뀌지 않는 서명 인증서도 필요한데, ad-hoc 서명으로 두면 빌드할 때마다 권한이 풀린다. 서명 이야기는 뒤에서 다시 꺼낸다.

키를 보내는 방법은 이것으로 해결된다. 하지만 이것만으로 붙여넣기가 늘 성공하지는 않는다.

내가 만든 앱에는 ⌥V 로 여는 클립보드 기록 기능이 있다. 2026-07-16 부터 2026-07-19 까지 "붙여넣기가 되다 안 되다 함" 증상 하나를 고친 커밋이 10개를 넘었다. 원인은 한 곳에 있지 않았다. 대상 기록 순서, 최전면 대기, 키 합성 방식, 권한 요청 창에 따로따로 있었고 그중 앞의 두 곳은 키를 보내기도 전 단계였다. 대상 앱을 잘못 기록하거나 대상 앱이 최전면이 되기 전에 ⌘V 를 보내면 붙여넣기는 실패한다. 그래도 로그에는 성공이 남는다.

붙여넣기 한 번은 다음 순서로 진행된다.

  1. 런처를 띄우기 직전에 최전면 앱과 그 앱의 포커스 창 제목을 기록한다.
  2. 사용자가 항목을 고르면 Electron clipboard 로 내용을 시스템 클립보드에 쓴다.
  3. 런처를 숨긴다.
  4. 대상 앱을 활성화한다.
  5. 기록한 제목과 같은 창을 올린다.
  6. 대상 앱이 최전면이 될 때까지 기다린다.
  7. CGEventPost 로 Command 키와 V 키를 순서대로 눌렀다 뗀다.

단계 사이사이에는 기다리는 시간이 끼어 있다. 내 앱은 아래 값을 쓰고 나서 간헐적인 실패가 사라졌다. 기기가 다르면 맞는 값도 다를 수 있다.

구간값이유
대상 기록 상한300ms네이티브 도구가 늦어도 런처가 늦게 뜨지 않게 함
런처 숨긴 뒤150ms대상 앱이 키 포커스를 되찾을 시간
활성화 요청 뒤180ms창 전환 애니메이션 중에 보낸 ⌘V 가 사라지지 않게 함
최전면 확인최대 600ms, 40ms 간격활성화는 비동기라 요청 직후 최전면이 바뀌어 있지 않음
최전면 확인 뒤60ms포커스가 입력칸까지 내려갈 시간
키 이벤트 사이12ms대상 앱이 수정자 키와 V 키의 순서를 처리할 시간

창을 닫으면 포커스는 어디로 갈까

다음은 런처 창이다. 런처를 띄우는 순간 대상 앱이 비활성이 되면 나중에 대상 앱으로 되돌아가기 어려워진다.

일반 BrowserWindow 는 앱 전체가 활성화되어야 키 포커스를 받는다. 앱 전체가 활성화되면 메인 창이 같이 뜨고, 화면이 다른 Space 로 넘어가고, 대상 앱은 비활성이 된다. 런처 하나 띄웠을 뿐인데 세 가지가 한꺼번에 일어난다. 그래서 런처는 비활성 패널로 만든다. 비활성 패널은 키 포커스를 받아도 앱을 활성화하지 않으므로 대상 앱은 뒤에서 계속 활성 상태로 남는다.

electron/launcher.cjs
const win = new BrowserWindow({
  width: 750,
  height: 480,
  frame: false,
  show: false,
  skipTaskbar: true,
  type: "panel", // NSWindowStyleMaskNonactivatingPanel 대응
  webPreferences: {
    preload: path.join(__dirname, "preload.cjs"),
    paintWhenInitiallyHidden: true, // 첫 노출 때 검은 빈 창 방지
  },
});

win.setAlwaysOnTop(true, "screen-saver");
win.setVisibleOnAllWorkspaces(true, {
  visibleOnFullScreen: true,
  skipTransformProcessType: true, // 노출 순간 앱 활성화와 Space 전환 억제
});

옵션 세 개가 맡은 일은 이렇다.

옵션설명
type: "panel"macOS 에서 비활성 패널로 생성. 앱 활성화 없이 키 포커스를 받음
paintWhenInitiallyHidden숨김 상태에서도 렌더링 유지. 옵션이 없으면 런처가 처음 뜰 때 검은 창으로 보임
skipTransformProcessType모든 Space 에 띄울 때 프로세스 종류 전환을 막음. 옵션이 없으면 앱이 활성화되어 메인 창이 같이 뜸

Electron BrowserWindow 문서에서 type 의 panel 값과 paintWhenInitiallyHidden 을 찾아볼 수 있다.

런처를 숨기면 키 포커스는 대상 앱으로 돌아간다. 이때 app.hide() 까지 부르고 싶어지는데, 부르지 않는 편이 맞다. app.hide() 를 호출하면 대상 앱이 아닌 다른 앱이 최전면에 올라올 수 있다.

누구에게 돌아갈지는 언제 적어 둘까

돌아갈 곳은 런처를 띄우기 전에 적어 둬야 한다. 런처가 뜬 뒤에 최전면 앱을 조회하면 자기 앱이 결과로 나오기 때문이다. 이 순서가 네 원인 가운데 첫 번째였다.

진단 로그는 홈 폴더에 플래그 파일이 있을 때만 남기게 해 두었다. 대상 기록, 활성화, 키 합성 결과를 단계마다 한 줄씩 적는 로그였는데, 거기서 대상 앱 이름이 비어 있는 줄이 나왔다. 그때까지는 AppleScript 의 System Events 로 최전면 앱을 물었다. 이 조회는 빈 값과 함께 -1728 에러를 돌려줄 수 있고 그 에러는 런처가 screen-saver 레벨에 떠 있거나 창이 전환되는 중에 난다. 그 로그를 보고 조회를 NSWorkspace frontmostApplication 으로 바꿨다. NSWorkspace.shared.frontmostApplication 은 권한 없이 읽힌다.

앱 이름만 적어서는 부족하다. 브라우저처럼 창이 여러 개인 앱은 앱만 활성화하면 마지막으로 쓴 다른 창이 올라올 수 있어서 포커스 창의 제목도 함께 적는다.

Sources/helper/main.swift
import AppKit
import ApplicationServices

func focusedWindowTitle(pid: pid_t) -> String {
    let axApp = AXUIElementCreateApplication(pid)
    var focused: CFTypeRef?
    guard AXUIElementCopyAttributeValue(axApp, kAXFocusedWindowAttribute as CFString, &focused) == .success,
          let window = focused else { return "" }
    var title: CFTypeRef?
    guard AXUIElementCopyAttributeValue(window as! AXUIElement, kAXTitleAttribute as CFString, &title) == .success,
          let s = title as? String else { return "" }
    return s
}

func runFrontmost() {
    guard let app = NSWorkspace.shared.frontmostApplication else { fail("프런트 앱 없음") }
    emit([
        "ok": true,
        "name": app.localizedName ?? "",
        "bundle": app.bundleIdentifier ?? "",
        "pid": app.processIdentifier,
        "windowTitle": focusedWindowTitle(pid: app.processIdentifier),
    ])
}

손쉬운 사용 권한이 없으면 창 제목은 빈 문자열로 온다. 그럴 때는 앱 단위로만 돌아간다.

기록과 런처 띄우기를 동시에 시작하면 최전면 앱이 자기 앱으로 적히고 ⌘V 는 다른 앱에 붙는다. 그래서 메인 프로세스는 기록이 끝난 다음에 런처를 띄운다. 다만 네이티브 도구가 늦는다고 런처까지 늦게 뜨면 곤란하니 기록은 최대 300ms 까지만 기다린다.

electron/launcher.cjs
async function toggleLauncher() {
  const visible = launcherWindow?.isVisible() ?? false;
  await Promise.race([
    captureFocusReturn({ launcherVisible: visible }).catch(() => {}),
    new Promise((r) => setTimeout(r, 300)),
  ]);
  showLauncher();
}

기록하는 쪽은 런처가 이미 떠 있는지부터 본다. 떠 있다면 지금 최전면은 런처일 테니 직전 대상을 그대로 둔다.

electron/clipboardHistory.cjs
async function captureFocusReturn({ launcherVisible }) {
  // 런처가 이미 떠 있으면 최전면이 런처라 직전 대상 유지
  if (launcherVisible) return;
  const f = await frontApp();
  const self = /electron|myapp/i.test(f.bundle) || /electron|myapp/i.test(f.name);
  pasteReturn = {
    bundle: self ? "" : f.bundle,
    pid: self ? 0 : f.pid,
    windowTitle: self ? "" : f.windowTitle,
    self,
    at: Date.now(),
  };
}

기록할 때 최전면 앱이 자기 앱이었다면 복귀 단계를 건너뛴다. ⌘V 는 자기 앱의 현재 입력칸에 들어간다. 앱 안의 메모 화면에서 런처를 열었을 때가 이 경우다.

다시 올렸는데 왜 아직 앞에 없을까

돌아갈 곳을 알았으니 이제 그 앱을 다시 올릴 차례다. 복귀는 프로세스 ID(pid)와 창 제목으로 한다. NSRunningApplication.activate() 로 대상 앱을 활성화하고 제목이 같은 창을 찾아 kAXRaiseAction 을 보낸다. 다른 앱 창의 속성을 읽고 동작을 실행하는 일은 AXUIElement 가 맡는다.

Sources/helper/main.swift
func runActivate(pid: pid_t, title: String) {
    guard let app = NSRunningApplication(processIdentifier: pid) else { fail("프로세스 없음") }
    let activated = app.activate()
    var raised = false
    if !title.isEmpty {
        let axApp = AXUIElementCreateApplication(pid)
        var value: CFTypeRef?
        if AXUIElementCopyAttributeValue(axApp, kAXWindowsAttribute as CFString, &value) == .success,
           let windows = value as? [AXUIElement] {
            for window in windows {
                var t: CFTypeRef?
                guard AXUIElementCopyAttributeValue(window, kAXTitleAttribute as CFString, &t) == .success,
                      let s = t as? String, s == title else { continue }
                AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, kCFBooleanTrue)
                AXUIElementPerformAction(window, kAXRaiseAction as CFString)
                raised = true
                break
            }
        }
    }
    emit(["ok": activated, "raised": raised])
}

창 제목까지 쓰게 된 건 2026-08-06 이다. 그날 브라우저 창이 여러 개일 때 다른 창에 붙는 문제를 확인했고 pid 와 창 제목으로 창을 찾아 올리는 방식으로 고쳤다. 앱 단위 AppleScript 활성화는 폴백으로만 남겼다.

창을 올린 뒤에도 확인할 것이 있다. activate() 는 활성화를 요청한 즉시 돌아온다. 활성화는 비동기라서 함수가 돌아온 순간 대상 앱이 최전면이라는 보장이 없다. 이걸 확인하지 않고 ⌘V 를 보내면 붙여넣기가 간헐적으로 실패하는데, 될 때도 있고 안 될 때도 있는 증상이 여기서 생긴다. 두 번째 원인인 최전면 대기가 여기다.

그래서 최전면 앱이 바뀔 때까지 40ms 간격으로 다시 묻고 최대 600ms 까지 기다린다. 확인이 끝나도 포커스가 입력칸까지 내려가려면 시간이 드니 60ms 를 더 쉰다.

electron/clipboardHistory.cjs
async function waitFrontmost(bundle, timeoutMs) {
  const target = bundle.toLowerCase();
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const f = await frontApp();
    if (f.bundle.toLowerCase() === target) return true;
    await sleep(40);
  }
  return false;
}

async function paste(id) {
  writeToClipboard(id);
  hideLauncher();
  await sleep(150);
  const reactivated = await reactivateReturnApp(); // 활성화 요청 뒤 180ms 대기 포함
  if (reactivated) {
    await waitFrontmost(pasteReturn.bundle, 600);
    await sleep(60);
  }
  return keystrokePaste();
}

paste 함수의 순서는 앞의 대기 시간 표와 같다. 클립보드에 쓰고, 런처를 숨기고, 150ms 를 쉬고, 활성화 요청 뒤 180ms, 최전면 확인, 다시 60ms. 키는 그다음이다.

키 네 개를 어떤 순서로 누를까

이제야 키를 보낸다. ⌘V 하나에 이벤트가 네 개 든다. Command 누름, V 누름, V 뗌, Command 뗌을 각각 따로 만들고 이 순서대로 보낸다.

Sources/helper/main.swift
func runPaste() {
    guard AXIsProcessTrusted() else { fail("손쉬운 사용 권한 필요") }
    let src = CGEventSource(stateID: .combinedSessionState)
    let vKey: CGKeyCode = 0x09   // ANSI V
    let cmdKey: CGKeyCode = 0x37 // 왼쪽 Command
    guard let cmdDown = CGEvent(keyboardEventSource: src, virtualKey: cmdKey, keyDown: true),
          let vDown = CGEvent(keyboardEventSource: src, virtualKey: vKey, keyDown: true),
          let vUp = CGEvent(keyboardEventSource: src, virtualKey: vKey, keyDown: false),
          let cmdUp = CGEvent(keyboardEventSource: src, virtualKey: cmdKey, keyDown: false) else {
        fail("키 이벤트 생성 실패")
    }
    vDown.flags = .maskCommand
    vUp.flags = .maskCommand
    let gap: useconds_t = 12_000
    for event in [cmdDown, vDown, vUp, cmdUp] {
        event.post(tap: .cghidEventTap)
        usleep(gap)
    }
    emit(["ok": true])
}
부분설명
virtualKey: 0x09ANSI 배열의 V 키. 문자 대신 물리 키 코드라 한글 입력 상태에서도 ⌘V 로 인식됨
Command 키 down/upV 이벤트에 .maskCommand 만 얹으면 첫 번째는 붙고 두 번째부터는 붙지 않음. Command 키를 실제로 눌렀다 떼어 수정자 상태를 원래대로 되돌림
usleep(12_000)네 이벤트를 간격 없이 보내면 대상 앱이 ⌘V 조합으로 읽지 못할 때가 있음
.cghidEventTap하드웨어 입력과 같은 위치에 이벤트를 넣음. 현재 최전면 앱의 키 포커스로 전달됨

이벤트를 넣는 위치마다 무엇이 다른지는 CGEvent post(tap:) 문서에 정리되어 있다.

세 번째 원인인 키 합성 방식은 이 표의 두 번째 줄에 있었다. AppleScript 의 System Events 로도 keystroke "v" using command down 이라고 쓰면 같은 키 입력이 간다. 다만 AppleScript keystroke 의 결과는 자동화(Automation) 권한과 실행 맥락에 따라 달라지고 종료 코드가 0 이어도 붙여넣기는 실패할 수 있다. 그래서 이 방식을 빼고 네이티브 키 합성으로 옮겼는데, 옮긴 뒤에도 "로그는 매번 ok, 첫 번째만 붙음" 상태가 남았다.

로그가 매번 ok 였던 데는 이유가 있다. AppleScript 와 CGEventPost 는 둘 다 이벤트를 보낸 시점에 성공을 돌려줄 뿐, 대상 앱이 그 이벤트를 받았는지는 알려 주지 않는다. V 이벤트에 Command 플래그만 얹어 보낸 것이 원인이었다. 수정자 상태가 원래대로 돌아오지 않아 첫 번째만 붙었다. Command 키를 따로 눌렀다 떼게 바꾸자 두 번째부터도 붙었다.

그래서 기본 경로는 CGEventPost 로 두었다. 네이티브 도구가 없거나 CGEventPost 가 실패할 때만 AppleScript 를 폴백으로 쓴다.

권한 창은 몇 번 떠야 할까

마지막 원인은 키와도 창과도 상관없는 곳에 있었다. 권한 요청 창이다.

권한은 붙여넣기 전에 확인한다. Electron 에서는 systemPreferences.isTrustedAccessibilityClient(prompt) 를 부르고 prompt 가 true 인데 권한이 없으면 macOS 가 권한 요청 창을 띄운다. 붙여넣기마다 prompt 를 true 로 넘기면 권한이 없는 동안 그 창이 반복해서 뜬다. 그래서 요청 창은 프로세스당 한 번으로 묶고 두 번째부터는 앱이 안내 토스트를 대신 띄운다.

electron/clipboardHistory.cjs
const { systemPreferences } = require("electron");

let accessibilityPrompted = false;

function ensureAccessibility() {
  if (systemPreferences.isTrustedAccessibilityClient(false)) return true;
  if (!accessibilityPrompted) {
    accessibilityPrompted = true;
    return systemPreferences.isTrustedAccessibilityClient(true);
  }
  return false;
}

권한을 허용했는데도 다시 빌드할 때마다 풀린다면 서명을 봐야 한다. macOS 는 개인정보 보호 권한을 TCC 데이터베이스에 저장하고 TCC 는 앱을 코드 서명으로 구분한다. ad-hoc 서명은 빌드할 때마다 코드 해시가 바뀐다. 그래서 TCC 는 다시 빌드한 앱을 매번 다른 앱으로 판단한다.

고정 인증서로 옮기려면 이렇게 한다.

  1. 로그인 키체인에 코드 서명용 self-signed 인증서를 한 번 만든다.
  2. 로컬 배포 스크립트의 서명 인증서를 1단계 인증서로 지정한다.
  3. 아래 명령으로 이전 서명의 권한 항목을 지운다.
  4. 앱을 실행한다.
  5. 손쉬운 사용 권한을 다시 허용한다.
터미널
tccutil reset Accessibility com.example.myapp

인증서가 같으면 앱의 지정 요구 사항(designated requirement)이 바뀌지 않는다. 지정 요구 사항이 같으니 다시 빌드해도 권한이 유지된다.

증상별 원인과 해결

붙여넣기가 실패할 때 먼저 눈에 들어오는 건 증상이다. 증상으로 원인을 찾을 수 있게 지금까지 나온 내용을 표로 모았다.

증상원인해결 방법
런처가 뜨면서 메인 창이 같이 나오고 다른 Space 로 전환됨일반 창이라 키 포커스를 받을 때 앱 전체가 활성화됨type: "panel" 과 skipTransformProcessType: true
런처가 처음 뜰 때 검은 창숨김 패널에 GPU backing 이 없음paintWhenInitiallyHidden: true
런처는 떴는데 Enter 와 화살표가 동작하지 않음다른 앱이 활성인 상태라 key window 확보가 런처 띄우기와 겹침런처가 뜬 뒤 40ms, 120ms 에 win.focus() 와 webContents.focus() 를 한 번 더 호출
다른 앱에 붙음대상 기록이 런처 띄우기와 동시에 실행되어 최전면 앱이 자기 앱으로 기록됨기록을 마친 뒤 런처 띄우기. 런처가 이미 떠 있으면 기록하지 않음
대상 앱 이름이 빈 값AppleScript System Events 조회가 -1728 에러로 실패NSWorkspace.shared.frontmostApplication
같은 앱의 다른 창에 붙음앱 단위 활성화는 마지막으로 쓴 창을 올림pid 로 활성화한 뒤 창 제목이 같은 창에 kAXRaiseAction
될 때도 있고 안 될 때도 있음활성화 요청 직후 대상이 아직 최전면이 아님최전면 확인 폴링 뒤 60ms 대기
로그는 성공인데 붙지 않음AppleScript keystroke 가 자동화 권한과 맥락에 영향을 받음CGEventPost 우선, AppleScript 는 폴백
첫 붙여넣기만 되고 두 번째부터 안 됨V 이벤트에 Command 플래그만 얹어 수정자 상태가 남음Command 키 down/up 을 따로 보냄
두 번째 이후 간헐적으로 안 됨네 이벤트를 간격 없이 보냄이벤트 사이 12ms
붙여넣을 때마다 권한 요청 창이 뜸매번 prompt: true 로 권한 확인권한 요청 창은 프로세스당 한 번
다시 빌드할 때마다 권한이 풀림ad-hoc 서명으로 코드 해시가 바뀜고정 인증서로 서명한 뒤 tccutil reset 으로 이전 항목 정리

왜 못 붙여 넣을까

Electron 앱이 다른 앱에 붙여 넣지 못하는 이유는 Electron 이 보내는 키 입력이 자기 창 밖으로 나가지 않기 때문이다. 이 부분은 Swift 로 만든 네이티브 도구와 CGEventPost 로 해결했다.

커밋 10개 넘게 고친 것은 나머지 네 가지였다. 돌아갈 앱을 런처보다 먼저 기록하기, 그 앱이 최전면이 될 때까지 기다리기, Command 키를 따로 눌렀다 떼기, 권한 창을 한 번만 띄우기다. 앞의 셋은 틀려도 로그에 성공이 남는다.

표에 적은 ms 값은 내 기기에서 맞았던 값이다. 다른 Mac 에서 같은 값이 맞는지는 아직 확인하지 않았다.