회사 업무 도우미 크롬 확장 프로그램 만든 후기
📝 계기
내 회사에선 업무 기록을 위해 이슈를 레드마인에서 관리하고 있다. 하지만 매번 이슈가 들어올 때마다 레드마인에 등록하는 것은 여간 귀찮은 일이 아닐 수 없다.
그래서 레드마인 등록을 포함해서, 우리 회사에 만연하고 있는 쓸모없는 반복 작업을 대신해줄 수 있는 크롬 확장 프로그램을 만들어보고자 하는 생각을 했다.
🔍 조사
크롬 확장 프로그램은 웹 개발과 거의 다름이 없는 듯하다. 심지어는 리액트로 개발도 충분히 가능하다고 한다. 하지만 난 리액트는 할 줄 모르기 때문에 바닐라 HTML/JS를 이용해서 개발해보려고 한다.
크롬 확장 프로그램 공식 페이지에는 다양한 기능이 소개되어 있다. 하지만.. 영어다.
나 같은 영알못들은 알아보기가 쉽지 않다. 또, 아무리 쳐다봐도 각 함수들에 들어갈 매개변수들을 알아보기가 쉽지 않았다 ㅠㅠ
크롬 확장 프로그램엔 크게 버전이 현재 3개까지 있다. 이 버전은 현재 Manifest V3이 최신인 상태고, 기왕 개발해볼 것이라면 최신 버전으로 해보는 것이 옳지 않겠냐는 마음으로 자료 조사를 시작했다.
우아한 형제들의 멋진 개발자 분들이 블로그에 글을 많이 적어주시긴 하셨다만, 아쉽게도 Manifest V2 글들이 많았다. 헤매기도 많이 헤맸다 ㅠㅠ
🧮 설계
일단 프로토타입으로는 3가지 기능을 구현하기로 결정했다.
레드마인 이슈번호로 이슈 빠르게 열기, 이메일 내용으로 레드마인 빠르게 작성 기능은 확장 프로그램 아이콘을 클릭해서 뜨는 팝업 창에 버튼을 두기로 마음 먹었고, 레드마인 자동 로그인 기능은 레드마인의 로그인 페이지로 리다이렉트 될 때마다 실행되도록 해야겠다고 설계했다.
레드마인 자동 로그인 기능을 위해선, 옵션 창에서 미리 아이디와 비밀번호를 받아놓아야 하기도 하므로 옵션 창도 구현해야 한다.
🔑 구현
manifest.json 구현
일단 크롬 확장프로그램을 구현하려면, manifest의 작성은 필수다.
{
"manifest_version": 3,
"name": "Helper Chrome Extension",
"version": "1.0",
"description": "크롬 확장 프로그램",
"permissions": [
"scripting",
"storage",
"webNavigation",
"tabs",
"activeTab"
],
"host_permissions": [
"<http://redmine.com/*>"
],
"background": {
"service_worker": "background.js",
"type": "module"
},
"content_scripts": [
{
"matches": ["<http://redmine.com/login?*>"],
"js": ["redmineAutoLogin.js"]
}
],
"action": {
"default_popup": "popup.html"
},
"options_page": "options.html",
"icons": {
"132": "./img/icon132.png"
}
}
크롬 확장 프로그램의 핵심인 manifest.json 파일이다.
key 설명
manifest_version | 개발하는 크롬 확장 프로그램의 manifest 버전 |
name | 확장 프로그램 이름 |
version | 확장 프로그램 버전 |
description | 확장 프로그램 설명 |
permissions | 이 확장 프로그램에서 사용하려는 크롬 API들의 권한들을 모두 명시 |
host_permissions | permission에 있는 권한들을 각각 어느 사이트에서 사용할 것인지 명시 |
background | service_worker에 지정한 스크립트가 항상 background에 상주한다 |
content_scripts | matches에 해당하는 주소에 접근할 때 js 파일이 실행된다 |
action | 확장 프로그램 메뉴에서 앱 선택 시 default_popup에 지정해준 html 파일이 뜬다 |
options_page | 확장 프로그램 옵션에 들어갈 시 여기에 지정해준 html 파일이 뜬다 |
icons | 확장 프로그램의 아이콘. key값엔 이미지 파일의 크기가 들어가는 듯. |
크롬 확장에서 DOM을 직접 조작하기 위해 scripting, 레드마인 계정 정보를 저장하기 위해 storage, 현재 탭 정보를 얻어오기 위한 activeTab, 탭을 실행하기 위한 tabs, 맨 처음에 옵션 창을 띄우기 위한 webNavigation 권한을 얻어왔다.
그리고 앱 첫 설치 시 옵션 창을 띄우기 위한 background.js의 설정, 레드마인 로그인 페이지 접속 시 실행될 redmineAutoLogin.js의 설정, 팝업과 옵션 페이지의 설정까지 마무리!
background.js 구현
// background.js
chrome.runtime.onInstalled.addListener((reason) => {
chrome.tabs.create({
url: 'options.html'
});
chrome.storage.local.set({redmineUrl: "<http://redmine.com>"});
});
background.js는 다음과 같이 간단히 구현했다. 그냥 이 앱이 딱 깔렸을 때 바로 option.html을 실행하도록 했다.
옵션 창 구현
<!DOCTYPE html>
<html lang="kr">
<style>
.title {
padding: 30px;
}
.setting {
display: flex;
justify-content: center;
}
table {
border-collapse: collapse;
margin: 10px;
}
tr, td {
padding-left: 10px;
padding-right: 10px;
border: 1px solid black;
}
</style>
<head>
<title>크롬 익스텐션 설정 페이지</title>
<meta charset="UTF-8">
</head>
<body>
<div style="text-align: center;">
<h1 class="title">헬퍼 설정 페이지</h1>
</div>
<div class="setting">
<table>
<tr>
<th>레드마인 URL</th>
<td><input id="redmineUrl"></td>
</tr>
<tr>
<th>레드마인 자동 로그인</th>
<td><input type="checkbox" id="redmineAutoLogin"></td>
</tr>
<tr>
<th>레드마인 ID</th>
<td><input id="redmineId"></td>
</tr>
<tr>
<th>레드마인 PW</th>
<td><input id="redminePw" type="password"></td>
</tr>
</table>
<button id="saveBtn">저장</button>
</div>
</body>
<script src="options.js"></script>
</html>
옵션 창이라고 해서 별 거 없다. 그냥 단순히 테이블과 버튼을 만들었다.
window.onload = function() {
displayOptionData();
}
const displayOptionData = () => {
document.querySelectorAll("input").forEach(inputs => {
const id = inputs.id;
chrome.storage.local.get(id, data => {
if(data[id] === undefined) return;
switch(inputs.type) {
case "text":
case "password":
inputs.value = data[id];
break;
case "checkbox":
inputs.checked = data[id];
break;
default:
alert("error on displayOptionData");
break;
}
});
});
};
document.getElementById("saveBtn").addEventListener("click", (e) => {
const optionData = {};
document.querySelectorAll("input").forEach(inputs => {
const id = inputs.id;
let value;
switch(inputs.type) {
case "text":
case "password":
value = inputs.value;
break;
case "checkbox":
value = inputs.checked;
break;
default:
alert("error on saveBtnOnClick");
break;
}
optionData[id] = value;
});
chrome.storage.local.set(optionData, () => alert('저장 완료'));
});
옵션 창이 처음 켜졌을 때, 이미 저장된 데이터가 있다면 크롬 storage에서 불러오고, 저장 버튼을 누르면 입력한 데이터를 크롬 storage에 넣는 로직뿐이다.
chrome.storage는 local과 sync가 있다. sync는 인터넷에 저장하는 거라 같은 계정끼리 동기화가 되지만 하루 횟수 제한이 있다고 한다. 그래서 그냥 local로 저장했다. 별로 동기화까지 되어야 할 의미는 없으니까.
팝업 창 구현
<!-- popup.html -->
<!DOCTYPE html>
<html lang="kr">
<head>
<meta charset="UTF-8">
</head>
<body>
<table style="width: 120px">
<tr><td><button id="openOption">옵션</button></td></tr>
<tr><td><button id="openRedmineIssue">레드마인 이슈 이동</button></td></tr>
<tr><td><button id="writeRedmineIssueByEmail">이메일로 레드마인 빠르게 쓰기</button></td></tr>
</table>
<script src="popup.js"></script>
</body>
<style>
button {
width: 100%;
}
</style>
</html>
여러 기능이 들어갈 popup.html을 살펴보자. 생긴건 투박하지만 나름 알차다.
document.getElementById("openOption").addEventListener("click", () => {
chrome.tabs.create({
url: 'options.html'
});
});
옵션 창을 열어주는 로직. chrome.tabs API를 이용했다. 단순해!
document.getElementById("openRedmineIssue").addEventListener("click", async () => {
const redmineIssueIdx = parseInt(prompt("레드마인 번호를 입력해주세요"));
if(redmineIssueIdx <= 0 || Number.isNaN(redmineIssueIdx)) {
alert("레드마인 번호를 제대로 입력해주세요");
return;
}
chrome.tabs.create({
url: await getRedmineIssueUrl(redmineIssueIdx)
});
});
/**
* 옵션에서 입력한 레드마인 URL 얻어오기
* @returns {string}
*/
const getRedmineUrl = async () => {
const redmineUrlObj = await chrome.storage.local.get('redmineUrl');
return redmineUrlObj.redmineUrl;
}
/**
* 특정 레드마인 이슈의 URL 반환
* @param {number} issueIdx 레드마인 이슈 번호
* @returns {string} 레드마인 이슈 URL
*/
const getRedmineIssueUrl = async (issueIdx) => {
const redmineUrl = await getRedmineUrl();
return `${redmineUrl}/issues/${issueIdx}`;
};
레드마인 번호를 입력 받아서 켜주는 로직을 살펴보자.
chrome storage에서 값을 얻어오는 로직과 현재 탭의 정보를 얻어오는 로직은 비동기로 이루어진다. async / await을 알맞게 활용해주자.
자동 로그인 구현
앞에서 언급했듯이, 레드마인 로그인 페이지에 접속 시 redmineAutoLogin.js 가 실행된다.
/**
* 레드마인 로그인
* @param {string} id 레드마인 아이디
* @param {string} password 레드마인 비밀번호
*/
const loginRedmine = (id, password) => {
try {
document.getElementById("username").value = id;
document.getElementById("password").value = password;
document.getElementById("login-submit").click();
}
catch (e) {
alert("로그인 실패: " + e);
console.error(e);
}
}
/**
* chrome.storage.local에서 데이터 가져오기
*/
const getOptionData = async () => {
return await chrome.storage.local.get();
};
const login = async () => {
const optionData = await getOptionData();
const {redmineId, redminePw, redmineAutoLogin} = optionData;
if(redmineAutoLogin) {
loginRedmine(redmineId, redminePw);
}
}
login();
원래 크롬 확장 프로그램에서 직접 DOM에 접근하는 것은 금지지만, 요 content_scripts로는 가능한 듯 싶다.
레드마인 로그인 페이지에 접속하면 login 함수가 실행된다. 여기서 옵션 창에 적은 정보를 가져오고, 그걸로 DOM에 접근하여 로그인 정보를 입력하고 로그인 버튼을 눌러버린다!
이메일 내용으로 레드마인 빠르게 작성 구현
이번 확장 프로그램 구현의 하이라이트.
이메일을 열고, popup.html에서 레드마인 빠르게 작성 버튼을 누르면, 이메일의 제목과 내용으로 레드마인을 자동으로 작성해준다.
그런데 구현이 조금 재밌었다. 이메일 창의 주소에 mailSN이라는 것이 있는데, 이메일 창의 제목과 본문의 html id 값이 각각 TRANSLATE_SUBJECT_TARGET_${mailSN} TRANSLATE_BODY_TARGET_${mailSN} 와 같았다.
/**
* 현재 탭의 정보 구하는 함수
* @returns {string}
*/
const getTabData = async () => {
const tabs = await chrome.tabs.query({active: true, currentWindow: true});
const tabData = tabs[0];
return tabData;
};
/**
* 네이버 웍스 이메일 상세 URL에서 mailSN 구하기
* @param {string} url
* @returns {number|null} mailSN 반환. URL이 올바르지 않을 경우 null 반환
*/
const parseMailSNFromURL = (url) => {
const mailSNRegexParsedResult = url.match(new RegExp(/mailSN=\\d+/)); // expected output: 'mailSN=1234'
if(mailSNRegexParsedResult !== null) {
const mailSN = parseInt(mailSNRegexParsedResult[0].split('=')[1]);
return mailSN;
}
return null;
};
const tabData = await getTabData();
const url = tabData.url;
const mailSN = parseMailSNFromURL(url);
현재 탭의 주소를 얻어와서 mailSN을 구하는 함수를 구현했다. 이제 제목과 본문 내용을 파싱하면 된다.
하지만 content_scripts와 달리, popup 창이나 option 창, background에서는 그냥은 DOM에 접근할 수 없다. 해킹의 요소가 너무 위험하기 때문.
따라서 이를 위해 scripting이라는 권한이 필요하다.
/**
* 활성화 된 이메일 창에서 제목과 내용 얻기
* @param {number} mailSN
* @returns {object} 이메일 제목과 내용
*/
const getDetailFromEmail = async (mailSN) => {
const tabData = await getTabData();
const mailData = await chrome.scripting.executeScript({
target: {tabId: tabData.id},
func: (mailSN) => {
const mailTitle = document.getElementById(`TRANSLATE_SUBJECT_TARGET_${mailSN}`).innerText;
const mailBody = document.getElementById(`TRANSLATE_BODY_TARGET_${mailSN}`).innerText;
return {mailTitle, mailBody};
},
args: [mailSN]
});
return mailData[0].result;
};
chrome.scripting.executeScript를 사용하면, 원하는 탭에서 func에 넣어준 함수를 실행할 수 있다. (실행시킬 탭의 id를 target에 넣어줘야 함) 여기서 func에 들어갈 매개변수는 args에 배열 형태로 하나씩 넣어준다.
그러면 해당 func의 결과가 반환된다. 이렇게 이메일의 제목과 본문을 얻어오는 데에 성공했다.
/**
* 레드마인에 일감 창 열고 내용 등록하기
* @param {string} title 레드마인 일감 제목
* @param {string} content 레드마인 일감 내용
*/
const writeRedmineIssue = async (title, content) => {
const redmineUrl = await getRedmineUrl();
chrome.tabs.create({
url: redmineUrl + "/issues/new"
})
.then((tab) => {
chrome.scripting.executeScript({
target: {tabId: tab.id},
func: (title, content) => {
const redmineTitle = document.getElementById("issue_subject");
const redmineContent = document.getElementById("issue_description");
redmineTitle.value = title;
redmineContent.value = content;
},
args: [title, content]
});
})
.catch((e) => {
alert("레드마인 내용 등록 중 에러 발생: " + e);
console.error(e);
});
};
이제 레드마인에 접속해서 새 이슈 작성 창을 열고, 열린 창에다가 우리가 얻어온 제목과 본문을 삽입해주면 된다.
삽입하는 방식은 이메일에서 제목과 본문을 얻어온 방식과 같다. chrome.scripting.executeScript를 통해 해당 페이지에 삽입할 함수를 입력해주자.
그러면 이제 정리하면 이메일로 레드마인 빠르게 작성 버튼을 눌렀을 때 작동될 함수는 이렇다.
document.getElementById("writeRedmineIssueByEmail").addEventListener("click", async () => {
const tabData = await getTabData();
const url = tabData.url;
const mailSN = parseMailSNFromURL(url);
if(mailSN === null) {
alert("이메일 주소가 올바르지 않습니다");
console.error(url);
return;
}
const {mailTitle, mailBody} = await getDetailFromEmail(mailSN);
writeRedmineIssue(mailTitle, mailBody);
});
음 예뻐.
결과
뭐 굳이 사진을 달을 필요도 없다. 잘 작동한다.
내가 쓸라고 만들었고, 만족도도 아주 높다. 앞으로 회사에서 반복적으로 발생하는 노가다 적인 일들은 이렇게 코드로 해결해보는 것도 좋은 방식이라 생각한다.
여러분도 한번씩 만들어보기 바란다.