크롬 확장에서 합성 이벤트로는 붙여넣기가 안 된다 — isTrusted와 CDP 우회 실측 (2026)

·

크롬 확장으로 웹 에디터에 텍스트를 자동 입력하려고 했다. 콘텐츠 스크립트에서 KeyboardEvent를 만들어 dispatchEvent로 쏘면 될 거라고 생각했다. 이벤트는 분명히 전달됐다. 그런데 글자가 한 자도 들어가지 않았다.

key·code·keyCode·which를 다 채우고, keydownkeypressinputkeyup 순서까지 맞춰봤다. 결과는 똑같았다. 문제는 코드가 아니었다.

1. 원인 — isTrusted:false

스크립트가 만든 이벤트에는 isTrusted: false가 붙는다. 그리고 브라우저는 신뢰되지 않은 이벤트에 대해 기본 동작(default action)을 실행하지 않는다.

여기서 착각하기 쉬운 지점이 있다. 두 단계가 별개다.

단계합성 이벤트실제 사용자 입력
이벤트 전파 (리스너 호출)✅ 된다✅ 된다
브라우저 기본 동작 (글자 삽입·붙여넣기 수행)안 된다✅ 된다

그래서 addEventListener로 찍어보면 “이벤트가 잘 가고 있다”고 보인다. 리스너는 실제로 호출되니까. 하지만 그 뒤에 브라우저가 해줘야 하는 일이 일어나지 않는다.

이건 버그가 아니라 설계된 보안 경계다. 임의의 사이트나 확장이 사용자 대신 타이핑·붙여넣기·클릭을 수행할 수 있다면 그 자체가 취약점이다. 그래서 우회 수단이 제공되지 않는다 — 이벤트를 더 정교하게 만드는 방향으로는 절대 뚫리지 않는다. 2026-08-03에 이걸 실측으로 확인하고 그 방향을 버렸다.

2. 실제로 동작하는 경로 — CDP Input.*

Playwright와 Puppeteer는 어떻게 하고 있나. 답은 Chrome DevTools Protocol이다. CDP의 Input.* 명령으로 보낸 입력은 브라우저의 입력 파이프라인 자체에 주입되므로, 실제 사용자 입력과 구분되지 않는다.

확장에서는 chrome.debugger 권한으로 같은 걸 할 수 있다.

{
  "permissions": ["debugger", "clipboardWrite", "activeTab", "storage"]
}

chrome.debugger는 서비스 워커(백그라운드)에서만 쓸 수 있으므로, 구조는 이렇게 갈린다.

  • 콘텐츠 스크립트: DOM을 읽어 좌표·요소를 찾는다 (페이지 접근이 필요한 일)
  • 서비스 워커: 그 좌표를 받아 CDP로 실제 입력을 만든다 (신뢰된 입력이 필요한 일)
const CDP_VERSION = "1.3";

function send(tabId, method, params) {
  return chrome.debugger.sendCommand({ tabId }, method, params ?? {});
}

async function click(tabId, x, y) {
  const base = { x, y, button: "left", clickCount: 1 };
  await send(tabId, "Input.dispatchMouseEvent", { type: "mouseMoved", ...base, clickCount: 0 });
  await send(tabId, "Input.dispatchMouseEvent", { type: "mousePressed", ...base, buttons: 1 });
  await send(tabId, "Input.dispatchMouseEvent", { type: "mouseReleased", ...base, buttons: 0 });
}

3. 붙여넣기는 commands를 실어보내야 확실하다

⌘V를 흉내내려고 키 이벤트만 보내면, 브라우저가 그걸 편집 명령으로 해석하지 않는 경우가 있었다. 그래서 편집 명령을 명시적으로 실어보낸다.

async function paste(tabId) {
  await send(tabId, "Input.dispatchKeyEvent", {
    type: "keyDown",
    key: "v",
    code: "KeyV",
    windowsVirtualKeyCode: 86,
    nativeVirtualKeyCode: 86,
    modifiers: 4,            // Meta(⌘). Ctrl이면 2
    commands: ["paste"],     // ← 이게 핵심
  });
  await send(tabId, "Input.dispatchKeyEvent", {
    type: "keyUp", key: "v", code: "KeyV",
    windowsVirtualKeyCode: 86, nativeVirtualKeyCode: 86, modifiers: 4,
  });
}

commands 배열에 실린 값은 Blink의 편집 명령으로 직접 실행된다. 이게 중요한 이유는 키 코드·모디파이어가 OS별로 어떻게 매핑되는지에 의존하지 않게 된다는 점이다. 같은 원리로 캐럿 이동도 키를 흉내내는 대신 명령으로 보낼 수 있다.

// 어떤 키를 운반체로 쓰든, 실행되는 건 commands에 실린 명령이다
async function editCommand(tabId, commands) {
  await send(tabId, "Input.dispatchKeyEvent", {
    type: "keyDown", key: "End", code: "End",
    windowsVirtualKeyCode: 35, nativeVirtualKeyCode: 35,
    commands,   // 예: ["moveToEndOfDocument"]
  });
  await send(tabId, "Input.dispatchKeyEvent", {
    type: "keyUp", key: "End", code: "End",
    windowsVirtualKeyCode: 35, nativeVirtualKeyCode: 35,
  });
}

한 가지 실측 주의점: 모든 편집 명령이 모든 에디터에서 통하는 건 아니다. 내가 다룬 에디터에서는 moveToEndOfDocument가 화면에 보이는 편집 영역이 아니라 숨겨진 입력 버퍼에만 적용되는 것으로 관측됐다. 명령을 보내놓고 “먹었겠지”라고 가정하면 안 되고, 실제 DOM으로 결과를 확인해야 한다.

4. 좌표는 최상위 프레임 기준이다

CDP 마우스 좌표는 최상위 프레임 뷰포트 기준 CSS 픽셀이다. 에디터가 iframe 안에 있으면 요소의 getBoundingClientRect()는 그 프레임 기준 값이라 그대로 쓰면 클릭이 엉뚱한 데(툴바 등) 들어간다. 조상 iframe들의 위치를 누적해서 더해야 한다.

function frameOffset(win) {
  let dx = 0, dy = 0;
  for (let i = 0; i < 6 && win && win !== window.top; i++) {
    let fe = null;
    try { fe = win.frameElement; } catch { break; }  // 교차 출처면 중단
    if (!fe) break;
    const fr = fe.getBoundingClientRect();
    dx += fr.left; dy += fr.top;
    win = fe.ownerDocument.defaultView;
  }
  return { dx, dy };
}

5. 대가 — 디버깅 배너와 attach 충돌

chrome.debugger를 붙이면 크롬이 “확장 프로그램이 이 브라우저를 디버깅하고 있습니다” 배너를 띄운다. 이건 없앨 수 없고, 사용자가 그 배너를 닫으면 detach되어 이후 CDP 명령이 전부 실패한다.

그래서 작업 전체 시간 동안 붙여두지 않고, 액션 묶음마다 붙이고 바로 뗀다.

또 하나 실측으로 걸린 것: attach 실패에 두 가지 다른 원인이 있다.

async function attach(tabId) {
  try {
    await chrome.debugger.attach({ tabId }, CDP_VERSION);
    return true;   // 우리가 붙였다 → 끝나면 우리가 뗀다
  } catch (e) {
    const m = String(e?.message ?? e);
    // 이미 붙어있는 경우 — 남의 세션을 끊으면 안 되므로 detach하지 않는다
    if (/already attached/i.test(m)) return false;
    // DevTools가 디버거를 점유한 경우 — 사용자가 조치해야 한다
    if (/devtools/i.test(m)) {
      throw new Error("개발자도구가 열려 있어서 디버거를 붙일 수 없다. DevTools를 닫고 다시 눌러라.");
    }
    throw new Error(`디버거 연결 실패: ${m}`);
  }
}

attach가 성공했는지를 우리가 직접 추적하지 않고 에러 메시지로 분기하는 게 요점이다. 다른 devtools가 이미 붙어 있을 수도 있고, 이전 실행이 비정상 종료됐을 수도 있어서 자체 상태 플래그는 금방 실제와 어긋난다. 그리고 “이미 붙어 있던” 경우에 우리가 detach하면 남의 디버깅 세션을 끊는다 — 그래서 ours 여부를 반환해 우리가 붙인 경우에만 뗀다.

6. 클립보드는 포커스를 요구한다

HTML 서식을 유지하며 붙여넣으려면 클립보드에 text/htmltext/plain을 같이 올린다.

new ClipboardItem({
  "text/html": new Blob([html], { type: "text/html" }),
  "text/plain": new Blob([text], { type: "text/plain" }),
});

navigator.clipboard.write문서에 포커스가 있어야 동작한다. 자동화 중에는 포커스가 쉽게 빠지므로 실패를 전제로 재시도 구조를 넣는 편이 안전했다.

for (let attempt = 1; attempt <= 3; attempt++) {
  await ensureFocus();
  try { await navigator.clipboard.write(makeItems()); return; }
  catch (e) { if (attempt === 3) throw e; await sleep(500); }
}

7. 그리고 붙여넣은 뒤를 반드시 확인해라

이게 가장 값비싸게 배운 부분이다. CDP로 붙여넣기가 성공해도, 에디터가 그 HTML을 자기 방식으로 재해석한다. 내가 다룬 에디터에서는 인라인 style="text-align:left"가 조용히 버려졌고, 문단마다 자체 클래스가 강제로 배정됐다. 명령을 보냈다는 사실이 결과를 보장하지 않는다.

그래서 붙여넣은 직후 실제 DOM을 덤프해서 확인하는 절차를 넣었다. 추측으로 두 번 헛수고한 뒤에야 이렇게 했다.

정리

  • 합성 이벤트로는 입력·붙여넣기가 안 된다. isTrusted:false는 우회할 수 없는 보안 경계다. 그 방향의 코드 개선은 전부 헛수고다.
  • 실제 경로는 chrome.debugger + CDP Input.*. Playwright가 쓰는 방식이고 실제 입력과 구분되지 않는다.
  • 붙여넣기·캐럿 이동은 키 흉내보다 commands 배열로 편집 명령을 실어보내는 게 확실하다.
  • 좌표는 최상위 프레임 기준이므로 iframe 오프셋을 누적해야 한다.
  • 디버깅 배너와 attach 충돌은 없앨 수 없다 — 짧게 붙이고, 에러 메시지로 분기하고, 남의 세션은 끊지 않는다.
  • 명령이 성공한 것과 결과가 의도대로인 것은 다르다. DOM으로 확인해라.

자주 묻는 질문

왜 합성 이벤트로는 안 되나요? 코드를 잘못 쓴 건가요?

코드 문제가 아닙니다. 브라우저는 스크립트가 만든 이벤트(isTrusted:false)에 대해 기본 동작을 실행하지 않도록 설계돼 있습니다. 악성 사이트가 사용자 대신 입력·붙여넣기·클릭을 수행하는 것을 막기 위한 보안 경계이고, 우회 수단이 제공되지 않습니다. 이벤트 속성을 채워넣거나 이벤트 순서를 흉내내도 결과는 같습니다.

리스너는 호출되는데 왜 아무 일도 안 일어나나요?

이벤트 전파(리스너 호출)와 브라우저 기본 동작(글자 삽입, 붙여넣기 수행)은 별개 단계입니다. 합성 이벤트는 전파는 되지만 기본 동작 단계에서 걸러집니다. 그래서 addEventListener로 확인하면 '이벤트가 잘 가고 있다'고 착각하기 쉽습니다.

chrome.debugger를 쓰면 배너가 계속 뜨나요?

attach된 동안 표시됩니다. 그래서 작업 전체 시간 동안 붙여두지 않고, 필요한 액션 묶음마다 attach → 실행 → detach 하는 방식으로 노출을 줄이는 게 좋습니다. 사용자가 그 배너를 닫으면 detach되어 이후 명령이 실패하므로, 그 실패를 정상 경로로 처리해둬야 합니다.

개발자도구를 열어둔 상태에서도 되나요?

안 됩니다. DevTools가 이미 디버거를 점유하고 있으면 attach가 실패합니다. 실측에서 이 경우 에러 메시지에 devtools가 포함되어 왔기 때문에, 그 문자열로 분기해서 '개발자도구를 닫아라'는 안내를 띄우도록 처리했습니다.

#크롬 확장#Chrome Extension#CDP#자동화#Playwright