본문으로 건너뛰기

Chrome 확장 프로그램 개발 시 자주 묻는 질문

무료2016-09-24#Solution#同步注入脚本#inject script sync#chrome插件模拟请求#chrome插件拦截请求

스크립트를 동기 방식으로 삽입하는 방법, 데이터를 저장하고 가져오는 방법, 스크립트 간 통신 방법

들어가며

최근 Chrome 확장 프로그램을 하나 만들면서 많은 문제에 직면했습니다. 이제 겨우 본격적인 구현 단계에 들어섰네요.

1. 목표 및 요구사항

인터페이스를 경계로 삼아 프런트엔드 개발과 백엔드 개발을 동시에 진행할 수 있습니다. 프런트엔드 개발자에게는 두 가지 단계가 있습니다.

  1. 인터페이스가 완성되지 않은 단계: 임시로 가짜 데이터(Mock data)를 사용하여 페이지 로직을 맞춥니다.
  2. 인터페이스가 완성된 단계: 가짜 데이터를 제거하고 백엔드 서비스에서 제공하는 실제 데이터를 사용하여 연동합니다.

그렇다면 가짜 데이터가 반드시 존재할 텐데, 이를 비즈니스 코드에 포함했다가 완료 후 주석 처리할까요? 아니면 독립된 파일로 만들어 페이지에 포함했다가 나중에 script 태그를 삭제할까요? 아니면 로컬 서버를 띄워 가짜 데이터를 별도로 설정했다가 나중에 교체할까요? ... 어느 방식이든 불편합니다. 인터페이스 완성 후 기존 코드를 수정해야 하며, 수정이 발생하면 오류가 생길 가능성이 있기 때문입니다.

그렇다면 코드를 수정할 필요가 없는 방식은 없을까요?

있습니다. 가짜 데이터를 Chrome 확장 프로그램에 두면 비즈니스 코드와 완벽하게 분리할 수 있습니다.

2. 구현 방안

비즈니스 코드는 보통 DOM ready 시점에 인터페이스를 요청하고 데이터를 받아 페이지에 렌더링합니다. 물론 DOM ready 이전에 요청이 발생하는 경우도 있습니다.

따라서 모든 비즈니스 코드가 실행되기 전에 요청 가로채기 로직과 가짜 데이터를 페이지에 삽입해야 하며, 가로채기 로직은 요청에 따라 적절한 가짜 데이터를 할당해야 합니다.

최소한 Ajax 요청과 JSONP의 get 요청을 가로채야 할 것 같습니다. 가로채는 것 자체는 문제가 아니지만 필터링이 중요합니다. 데이터 요청이 아닌 것은 통과시키고, 데이터 요청만 가로채서 가짜 데이터를 반환해야 합니다. RESTful API를 고려한다면 가짜 데이터 구성이 조금 더 복잡해질 수 있습니다.

하지만 Hybrid App에는 다음과 같은 특수한 점들이 있습니다.

  • 클라이언트가 특정 기능을 노출하여 임베디드 페이지에서 사용할 수 있도록 클라이언트 인터페이스(Native Interface)를 제공합니다.
  • 데이터 요청은 클라이언트를 통해 전달되며, 보안을 위해 클라이언트에서 암호화합니다.

그렇다면 Ajax나 JSONP는 신경 쓸 필요가 없습니다. 데이터 요청은 클라이언트 인터페이스를 통해 이루어지고 데이터 역시 클라이언트가 '제공'하기 때문입니다. 우리는 클라이언트를 흉내 내기만 하면 됩니다.

요청 가로채기조차 고려할 필요 없이, 직접 클라이언트로 위장하여 데이터 요청 인터페이스를 제공하면 됩니다. (물론 다른 기능 인터페이스도 제공하여 풀 세트를 구축한다면 디버깅 시 클라이언트에 거의 의존하지 않아도 됩니다.)

P.S. Chrome 확장 프로그램은 페이지 요청을 처리하기 위해 "webRequest", "webRequestBlocking" 등의 권한을 제공합니다. 더 자세한 정보는 공식 문서를 확인하세요.

방안 확정 후 세부 사항을 고려해야 합니다.

  • iOS는 대개 WebViewJavascriptBridge라는 서드파티 라이브러리를 통해 native 인터페이스를 제공합니다.
  • Android는 전역 객체를 JS에 노출하는 가장 간단한 방식을 사용합니다.

WebViewJavascriptBridge의 'native ready' 알림 방식은 document에서 사용자 정의 이벤트 WebViewJavascriptBridgeReady를 트리거하는 것이며, 페이지 JS는 이 이벤트를 리스닝하여 '연결'을 완료합니다. Android는 훨씬 단순합니다. 주입된 전역 객체는 언제든 접근 가능하며 페이지 JS 실행 훨씬 이전에 이미 'native ready' 상태이므로 '연결'이 필요 없습니다.

따라서 클라이언트로 위장하는 방법은 Android의 경우 페이지의 모든 JS가 실행되기 전에 사용자 정의 전역 객체를 연결하는 것이고, iOS의 경우 언제든 수동으로 WebViewJavascriptBridgeReady를 트리거하면 됩니다. 즉, Android는 가짜 인터페이스를 동기 방식으로 삽입해야 하지만 iOS는 상관없습니다. 반면 가짜 데이터는 동기 방식으로 삽입할 필요가 없습니다. (가짜 데이터는 양이 많을 수 있으므로 비동기 삽입을 통해 페이지 속도를 높이고, 데이터가 도착하기 전까지는 요청을 큐에 쌓아두면 됩니다.)

3. 문제 및 해결 방안

1. content_scripts가 로컬 파일에 작동하지 않음

확장 프로그램 관리 페이지에서 일부 프로그램 하단에는 '파일 URL에 대한 액세스 허용'이라는 체크박스가 있습니다. 이 옵션은 기본적으로 나타나지 않으며, 설정 파일인 manifest.json에 다음과 같이 명시되어야 나타납니다.

"content_scripts": [{
  "matches": ["<all_urls>"]
}]

이를 체크해야 content_scripts가 로컬 파일을 처리할 수 있습니다.

2. 스크립트를 동기 방식으로 삽입하는 방법은?

content script는 페이지 DOM을 조작할 수 있지만 JS 실행 환경이 다르기 때문에 JS 변수를 직접 수정할 수 없습니다. 페이지 JS를 수정하려면 script 태그를 삽입해야 하며 두 단계로 나뉩니다.

  1. 페이지에 삽입할 스크립트를 가져옵니다.
  2. script 태그를 생성하여 페이지에 삽입합니다.

비동기 삽입은 매우 쉽습니다.

var loadScript = function(filename) {
    var s = document.createElement('script');
    // 확장 프로그램 설치 후 mock.js의 file:/// 경로를 가져옵니다.
    //! manifest.json에 web_accessible_resources 선언이 필요합니다.
    s.src = chrome.extension.getURL(filename);
    // head가 준비되지 않았다면 html을 가져옵니다.
    var doc = document.head || document.documentElement;
    return doc.appendChild(s);
}
// 테스트
loadScript('mock.js');

페이지에 src 속성이 file:///xxxscript 태그가 삽입되며, 스크립트 파일을 비동기로 로드한 후 실행합니다.

실제로 이 스크립트가 실행되는 시점은 DOM ready 이후이므로, 그 이전의 요청들은 가짜 클라이언트가 로딩 중인 사이에 빠져나가 버립니다. Android UA 환경에서 요청이 누락되지 않으려면 페이지의 모든 JS가 실행되기 전에 mock 로직 실행이 완료되어야 합니다.

따라서 비동기 로드 후 실행이 아닌, 삽입 즉시 실행되는 동기 삽입 방안이 필요합니다. 먼저 스크립트를 동기 방식으로 읽은 후 인라인 스크립트로 삽입하면 됩니다. 다음과 같습니다.

// 1. 스크립트 내용을 동기 방식으로 가져오기
var readFileSync = function(filename, callback) {
    // 스크립트 동기 읽기
    var xhr = new XMLHttpRequest();
    var scriptUrl = chrome.extension.getURL(filename);
    //!!! 비동기 비활성화
    xhr.open("GET", scriptUrl, false);
    xhr.onreadystatechange = function() {
        if (xhr.readyState === 4 && xhr.status === 200) {
            callback(xhr.responseText);
        }
    };
    xhr.send(null);
};
// 2. 인라인 스크립트 삽입
var writeScriptSync = function(code) {
    var s = document.createElement('script');
    s.textContent = code;
    var doc = document.head || document.documentElement;
    return doc.appendChild(s);
};
// 테스트
readFileSync('mock.js', writeScriptSync);

핵심xhr.open("GET", scriptUrl, false);s.textContent = code;입니다. 전자는 동기 Ajax로 파일 내용을 읽고, 후자는 코드 문자열을 script 태그에 직접 씁니다.

3. 툴바 아이콘 및 확장 프로그램 관리 페이지 아이콘

제대로 된 확장 프로그램이라면 아이콘이 있어야 합니다. manifest.json 설정 파일의 browser_action.default_iconicons 필드를 각각 설정해야 합니다. 예시는 다음과 같습니다.

// 툴바 아이콘
"browser_action": {
  "default_icon": "icon/icon.png",
},
// 확장 프로그램 관리 페이지 아이콘
"icons": {
  "128": "icon/128.png",
  "16": "icon/16.png",
  "48": "icon/48.png"
}

4. 데이터 읽기 및 쓰기

선택할 수 있는 방안은 많습니다.

  • window.localStorage: 동기 읽기/쓰기, CORS 제한 및 5MB 용량 제한 존재
  • chrome.storage.local/sync: 비동기 읽기/쓰기, 5MB 제한이 있으나 권한 선언으로 해제 가능
  • IndexedDB: 비동기 읽기/쓰기, NoSQL 데이터베이스
  • WebSQL: 비동기 읽기/쓰기, SQLite 데이터베이스, 표준 아님

표준이 아닌 WebSQL은 고려하지 않으며, window.localStorage는 큰 장점이 없습니다. chrome.storage.local/sync가 조금 더 강력하지만 읽기/쓰기 효율은 IndexedDB만 못하므로 IndexedDB 사용을 권장합니다.

또한 각각의 장점이 있습니다.

  • window.localStorage는 동기 방식이므로 동기화가 필요한 시나리오에서 유용합니다.
  • chrome.storage.local은 가장 간단한 방식으로 소량의 데이터 처리에 적합합니다.
  • chrome.storage.sync여러 기기 간 자동 동기화를 지원하여 매우 강력합니다.

실제 사용 결과 chrome.storage.sync에서 set 실패 문제(getundefined 반환)가 발견되어 임시로 상태 데이터 저장에 local을 사용 중입니다. unlimitedStorage 권한을 선언하면 5MB 제한을 없앨 수 있지만 다음과 같은 새로운 제한이 따릅니다.

참고: 이 권한은 Web SQL Database 및 애플리케이션 캐시에만 적용됩니다(이슈 58985 참조). 또한 현재 http://*.example.com과 같은 와일드카드 서브도메인에는 작동하지 않습니다.

P.S. WebSQL의 표준화 프로세스는 중단되었습니다. 모든 브라우저가 SQLite 기반으로 구현되어 SQLite의 제약을 받게 되면서 교착 상태에 빠졌기 때문입니다. 더 자세한 정보는 Web SQL Database를 확인하세요.

5. eval 권한

확장 프로그램에서는 기본적으로 외부 파일의 내용을 eval로 실행하는 것을 허용하지 않습니다. 예를 들어 프로그램 설치 시 설정 데이터를 읽어 storage에 쓸 때 eval을 사용하면 오류가 발생합니다. 꼭 사용해야 한다면 manifest.json에서 CSP(콘텐츠 보안 정책)를 수정해야 합니다.

"content_security_policy": "script-src 'unsafe-eval'; object-src 'self'"

기본값인 script-src 'self'; object-src 'self'는 안전하지 않은 eval을 허용하지 않습니다.

4. IndexedDB

네이티브 API는 사용하기 불편하고 objectStore, keyPath, transaction 등 생소한 개념들을 이해해야 합니다. 대신 서드파티 라이브러리인 Dexie.js를 DBHelper로 사용하는 것을 추천합니다.

API 설계가 매우 정교하며, CRUD(생성, 읽기, 수정, 삭제) 예시는 다음과 같습니다.

// DB 및 테이블 생성
var db = new Dexie('db');
db.version(1).stores({
    // ++는 자동 증가 필드, &는 unique 제약 조건
    tb: '++id, &key, value, desc, other'
});
// 추가
db.tb.bulkAdd([{
    key: 1,
    value: '1'
}, {
    key: 2.5,
    value: '2.5'
}, {
    key: 2,
    value: '2'
}]);
// 삭제
db.tb
    .where('key')
    .equals(2.5)
    .toArray()
    .then(function(res) {
        console.log(res);
        // 삭제
        db.tb.delete(res[0].id);
    }, console.log.bind(console));
// 수정
db.tb.put({
    key: 'getPoiInfo',
    value: 'value'
});
// 조회
db.tb
    .where('id')
    .inAnyRange([[0, 100]])
    .each(function(item) {
        console.log(item);
    });
// 조회 + 수정
db.tb
    .where('key')
    .equals(1)
    .modify({value: 'value1'});
// 사용자 정의 전체 테이블 검색
db.tb
    .filter(item => /get.*/i.test(item.key))
    .each(function(item) {
        console.log(item.value);
    });

5. 스크립트 통신 방식

확장 프로그램 개발에는 다양한 유형의 스크립트가 존재하며 이들 간의 통신이 필요합니다.

  • content script와 '확장 프로그램 전용 스크립트' 간의 통신
  • injected scriptcontent script 간의 통신
  • '확장 프로그램 전용 스크립트'들 간의 통신
  • 확장 프로그램과 외부 서버 간의 통신

우리의 관심사는 content script와 '확장 프로그램 전용 스크립트' 간의 통신입니다. content script에서 백그라운드 페이지에 가짜 데이터를 요청해야 하기 때문입니다. 이는 runtime message를 통해 구현할 수 있습니다.

// 통신 연결 수립
var port = chrome.runtime.connect({name: "app"});
// content script에서 전송
port.postMessage({
    action: 'KEYQUERY',
    key: key
});
// 백그라운드 페이지 background.js에서 수신
chrome.runtime.onConnect.addListener(function(port) {
    port.onMessage.addListener(function(msg) {
        if (port.name != "app") return;
        switch (msg.action) {
            case 'KEYQUERY':
                db.tb
                    .filter(item => item.key === msg.key)
                    .toArray()
                    .then(function(arr){
                        port.postMessage({
                            _action: msg.action,
                            data: arr
                        });
                    }, function() {
                        port.postMessage({
                            _action: msg.action,
                            data: []
                        });
                    });
        }
    }
}

위 방식은 먼저 연결을 수립한 후 메시지를 주고받는 방식이며, 연결 없이 단발성 메시지를 보낼 수도 있습니다.

// 전송
chrome.runtime.sendMessage({greeting: "hello"}, function(response) {
    console.log(response.farewell);
});
// 수신
chrome.runtime.onMessage.addListener(
    function(request, sender, sendResponse) {
        console.log(sender.tab ?
                "from a content script:" + sender.tab.url :
                "from the extension");
        if (request.greeting == "hello")
        sendResponse({farewell: "goodbye"});
    }
);

단, 주의할 점은 단발성 메시지는 안정적인 연결이 없으므로 비동기 응답이 불가능하다는 것입니다. 예를 들어:

// 수신
chrome.runtime.onMessage.addListener(
    function(request, sender, sendResponse) {
        console.log(sender.tab ?
                "from a content script:" + sender.tab.url :
                "from the extension");
        if (request.greeting == "hello")
        // 비동기 응답 시도
        setTimeout(() => {
            sendResponse({farewell: "goodbye"});
        }, 1000);
        //!!! 여기서 즉시 송신측 콜백이 트리거되어 undefined가 반환됩니다.
        // 1초 후의 sendResponse는 유실됩니다.
    }
);

이 경우 비동기 sendResponse가 유실되므로, 통신 연결을 수립하는 방식을 사용하는 것이 좋습니다.

스크립트 통신 방식에 대한 더 자세한 정보는 Chrome 확장 개발 2장 - 확장 프로그램 스크립트 실행 메커니즘 및 통신 방식을 확인하세요.

참고 자료

댓글

아직 댓글이 없습니다

댓글 작성