크롬 익스텐션 개발기 (feat. Manifest V3)

안녕하세요.
이번에 원티드 프론트엔드팀에 새로 합류하게된 임성현입니다.
제가 팀에 합류해서 처음으로 받은 미션인 크롬 익스텐션 개발 관련 경험을 공유드리고자 합니다. 정책상 자세한 서비스 로직을 공유드리지 못하는 점은 미리 양해 부탁드립니다. 🙇🏻

이번 포스팅에서 다룰 내용은 다음과 같습니다.

  • 1장. Manifest V3란?
  • 2장. Manifest V2 → V3 마이그레이션 적용 내역
  • 3장. Service Worker와 통신하기

🗣 1장. Manifest V3란?

모든 크롬 익스텐션은 manifest.json 파일이 필요합니다.
이 파일에는 package.json과 같이 익스텐션을 정의하는 정보가 포함되어 있습니다. Chrome은 2020년 11월, 향상된 기능을 제공하는 Manifest V3(통칭 MV3)를 도입했습니다.

MV3는 개인 정보 보호, 보안 및 성능 세 가지에 초점을 맞추어 개선되었는데요, Chrome 88 부터 사용할 수 있으며 Chrome Web Store는 2021년 1월부터 MV3로 작성된 익스텐션을 허가했습니다.

현재 Google은 MV2의 단계적 폐지를 계획하고 있으며 MV3는 선택이 아닌 필수사항이 될 것으로 보여집니다.

제가 이번에 MV3로 크롬 익스텐션을 개발하면서 주목한 Features는 다음과 같습니다.

⭐️ 기존 background를 대체하는 Service Worker의 등장

Service Worker는 웹 페이지가 아닌 브라우저가 백그라운드에서 실행하는 스크립트로, 웹 페이지와는 별개로 동작합니다. 따라서, DOM을 직접 제어할 수 없고 Message 기능을 이용하여 제어 대상 페이지와 통신합니다.
백그라운드 동기화, 푸시 알림 등이 가능하도록 지원해주며 오프라인에서도 서비스를 사용할 수 있도록 합니다.

기존 백그라운드와의 차이점은 Service Worker는 필요할 때만 실행된다는 것입니다. 일정 시간동안 사용이 없으면 비활성화 상태가 됩니다. 하여, 코드 작성 시 주의할 점이 있는데 사용법은 아래에서 더 자세히 설명하겠습니다.
Service Worker에 대한 자세한 소개는 아래 링크를 참고해주세요.

Service Workers: an Introduce

⭐️ Host Permission Control

특정 사이트에서만 크롬 익스텐션이 동작할 수 있도록 호스트 권한을 설정 할 수 있게 되었습니다!

⭐️ Action API consolidation

Browser Action 및 Page Action API가 단일 Action API로 통합되면서 사용법이 바뀌었습니다.

⭐️ executeScript() changes

더 이상 문자열로 작성된 코드를 실행할 수 없으며, 오직 스크립트 파일과 함수만 실행할 수 있게 되었습니다.
또한, 이 방법은 Tabs API에서 Scripting API로 마이그레이션되었습니다.

이 밖에도 Promise based API, Remotely hosted code 등이 있습니다.
자세한 변경사항은 아래 링크를 참고해주세요.

Overview of Manifest V3

🛠 2장. Manifest V2 👉 V3 마이그레이션 적용 내역

이번 미션은 기존에 MV2로 작성된 레거시 프로젝트를 되살리는 작업이었습니다. 따라서, 새로운 기능을 추가하기 이전에 살릴 수 있는 코드들은 그대로 가져가고 전체적으로 MV3를 적용하여 마이그레이션하는 작업이 우선이었습니다.

위에서 소개해드린 MV3의 Features를 바탕으로 적용한 마이그레이션 내역을 적어보겠습니다.

우선 MV3를 적용하기 위해서는 manifest.json부터 손을 봐야합니다.
기존에 작성된 manifest.json은 다음과 같습니다.

// 기존 MV2 manifest.json{
// ...
"manifest_version": 2,
"browser_action": {
"default_title": "...",
"default_popup": "popup.html",
"default_icon": "favicon.png"
},
"background": {
"scripts": ["background.js"],
"persistent": false
},
"permissions": [
"tabs",
"\u003Call_urls\u003E",
"cookies",
"contextMenus",
"unlimitedStorage",
"notifications",
"storage",
"clipboardWrite"
],
// ...
}
  • 가장 먼저, manifest_version3으로 올려줍니다.
  • browser_actionpage_action이 존재한다면 action으로 통합합니다.
  • background의 사용은 service_worker로 대체하고 persistent 속성은 제거합니다.
  • <all_urls> 등의 호스트 권한 설정은 host_permissions로 옮겨줍니다.
  • 꼭 필요한 권한이 아니라면 빼는 것이 좋습니다. 이는 익스텐션 심사에 크게 영향을 미칩니다.

이를 토대로 새롭게 작성한 manifest.json은 다음과 같습니다.

// 새로 작성한 MV3 manifest.json{
// ...
"manifest_version": 3,
"action": {
"default_title": "...",
"default_popup": "popup.html",
"default_icon": "favicon.png"
},
"background": {
"service_worker": "background.js"
},
"permissions": [
"tabs",
"activeTab",
"scripting",
"cookies",
"declarativeContent",
"storage"
],
"host_permissions": [
// ...
],
// ...
}

manifest.json 수정이 완료되었습니다.
새롭게 추가된 권한 중 scriptingexecuteScript()를 실행하기 위해 필수적으로 넣어야하는 권한입니다. 기존 executeScript()의 사용법은 다음과 같이 변경되었습니다.
예제는 익스텐션을 실행한 탭을 새로고침하는 방법에 대해 소개합니다.

// MV2
chrome.tabs.getSelected(null, function(tab) {
const code = 'window.location.reload();';
chrome.tabs.executeScript(tab.id, {code: code});
});
// MV3
function refresh() {
window.location.reload();
};
chrome.tabs.query({ active: true, currentWindow: true }, function(tabs) {
chrome.scripting.executeScript({
target: {tabId: tabs[0].id},
function: refresh,
});
});

declarativeContent API를 사용하면 특정 도메인에서만 익스텐션이 동작하도록 설정하는 것이 가능합니다. 다음과 같이요.

// background.jschrome.runtime.onInstalled.addListener(function() {
chrome.action.disable();
chrome.declarativeContent.onPageChanged.removeRules(undefined, function() {
chrome.declarativeContent.onPageChanged.addRules([{
conditions: [
new chrome.declarativeContent.PageStateMatcher({
pageUrl: {hostSuffix: 'yourDomain.com'},
})
],
actions: [new chrome.declarativeContent.ShowPageAction()]
}]);
});
});

런타임 시 onInstalled 이벤트가 발생했을 때 특정 conditions를 충족하면 actions가 발동하도록 하는 예제입니다. 코드를 실행해보면 hostSuffix의 조건을 충족하지 못한 도메인에서는 팝업창이 노출되지 않습니다.

browser_actionpage_actionaction으로 통합되어 위 manifest.json에서도 이에 맞게 적용했습니다. 사용하실 때에도 마찬가지입니다.

// MV2
chrome.browserAction.onClicked.addListener((tab) => { ... });
chrome.pageAction.onClicked.addListener((tab) => { ... });
// MV3
chrome.action.onClicked.addListener((tab) => { ... });

💫 3장. Service Worker와 통신하기

이번 장에서는 Service Worker와 익스텐션이 Message를 통해 통신하는 방법에 대해 소개합니다.

Service Worker는 익스텐션이 보낸 메시지를 수신하여 서비스 로직을 수행한 뒤, Response를 되돌려주는 역할을 할 수 있습니다. 어떻게보면 하나의 작은 서버 역할을 한다고 생각할 수 있습니다.

다음 예제에서는 이미 구축된 노드 서버가 있다고 가정하여 익스텐션으로 로그인처리를 하는 방법에 대해 소개합니다.

work-flow를 간단히 요약하면 다음과 같습니다.

  1. 익스텐션을 실행하면 popup.js가 실행되어 userStatus를 묻는 메시지를 Service Worker에 전송합니다.
  2. Service Worker는 userStatus 메시지를 수신하여 로그인 확인을 한 뒤, popup.js에 Response를 전달합니다.
  3. popup.js는 Response에 담긴 각 메시지에 맞는 작업을 수행합니다.
// popup.jschrome.runtime.sendMessage({
message: 'userStatus'
}, (response) => {
if (response.message === 'success') {
console.log('로그인 성공');
} else if (response.message === 'login'){
console.log('로그인하세요');
window.location.href = './login.html';
}
});

popup.js가 실행되면 userStatus 메시지를 전송하고 콜백 함수를 실행합니다. 성공과 실패 여부에 따라 익스텐션의 다음 동작을 작성할 수 있습니다.

// background.jsfunction isLogedIn(sendResponse) {
chrome.storage.local.get(['userStatus'], (response) => {
const error = chrome.runtime.lastError;
if (error) console.log(error);

if (!response.userStatus) {
sendResponse({ message: 'login' });
} else {
sendResponse({ message: 'success' });
}
});
};
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.message === 'userStatus') {
isLogedIn(sendResponse);
return true;
}
});

Service Worker는 userStatus를 메시지를 수신하여 로그인을 확인하는isLogedIn함수를 실행합니다. 여기에 sendResponse를 매개변수로 전달하였는데요, 이는 popup.js에 응답 메시지를 전달하는데 사용됩니다.

return true;를 간과하지 마세요. 이것을 명시적으로 적어주어야 response를 받아야하는 곳에서 비동기로 콜백함수를 호출할 수 있게 됩니다.

isLogedIn 함수는 익스텐션의 storage에 저장된 값을 꺼내어 로그인 상태를 확인합니다. 이 storage는 browser의 local storage, session storage와 별개로 익스텐션만이 가지고 있는 저장소입니다. 개발자도구를 이용해 저장소를 직접 살펴보는것은 불가능합니다. 그렇다해도 유저 기밀정보를 이 곳에 저장하지 않는것이 좋습니다.

로그인이 필요한 경우에 Service Worker는 login 메시지를 응답값으로 보내어 login.html로 redirect합니다.

// login.jsdocument.querySelector('form').addEventListener('submit', (e) => {
e.preventDefault();

const email = document.querySelector('#email').value;
const pwd = document.querySelector('#password').value;
chrome.runtime.sendMessage({
message: 'login',
payload: { email, pwd }
}, (response) => {
if (response === 'success') {
window.location.href = './popup.html';
} else {
alert('로그인 실패');
}
});
});

마크업은 생략하겠습니다. 아이디, 비밀번호 입력 후 submit을 했다고 가정해보죠. 아이디, 비밀번호를 payload에 담아 login 메시지를 Service Worker에 전달합니다. Response의 성공, 실패 여부에 따라 login.js의 다음 동작을 작성할 수 있습니다.

// background.js// ...
async function getAuth(userInfo, sendResponse) {
const params = {
email: userInfo.email,
password: userInfo.pwd
};
const response = await fetch('yourUrl', {
method: 'POST',
body: JSON.stringify(params),
headers: { 'Content-Type': 'application/json' }
});
if (response.status !== 200) {
sendResponse('fail');
} else {
const result = await response.json();
chrome.storage.local.set({
userStatus: result.userStatus
}, (res) => {
if (chrome.runtime.lastError) sendResponse('fail');
sendResponse('success');
});
}
};
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.message === 'userStatus') {
isLogedIn(sendResponse);
return true;
} else if (request.message === 'login') {
getAuth(request.payload, sendResponse);
return true;
}
});

login 메시지를 수신한 Service Worker는 서버에 로그인 요청을 하는 getAuth 함수를 실행합니다. 로그인에 성공하게 되면 익스텐션의 storage에 로그인 상태를 저장하고 success 메시지를 응답으로 보냅니다.

이 순환구조를 도식화해보면 다음과 같습니다.

<Work-flow 도식화>

🙇🏻 끝으로

주니어 개발자로서 첫 미션, 첫 블로그 포스팅… 모든게 처음이고 아직 배움과 경험이 필요한 걸음마 단계를 지나고 있습니다. 원티드에서의 소프트 랜딩을 도와주신 팀원 분들, 원티드 동료분들께 늘 감사함을 느끼고 있습니다.
부족한 글이지만 끝까지 읽어주셔서 감사합니다. 더욱 정진하여 양질의 포스팅을 할 수 있도록 부단히 노력하겠습니다.

References

Welcome to Manifest V3 — Chrome Developers

현재 원티드에서 현재 다양한 포지션을 채용하고 있습니다.

원티드 채용공고 확인하기

--

--