Search
Duplicate
👨🏻‍💻

[Unsolved.wa 개발기 - 1] chrome extension 프로젝트 시작!

간단소개
unsolved.wa 프로젝트 개발기에 chrome extension 한스푼
팔만코딩경 컨트리뷰터
ContributorNotionAccount
주제 / 분류
Javascript
태그
Scrap
8 more properties

chrome extension을 만들자!

언제나 그렇듯 프로젝트의 시작은 가볍고.. 쉽게 끝날 것 만 같은 그런 기분..
어느날 sikang님이 채널에 올린 글을 시작으로 프로젝트에 참여하게 되었다!
자세히 보면 알겠지만 시작은 5개월 전… 졸업작품에, 코딩테스트 준비, 면접준비에 치여서 도저히 진행을 못하다가 다시 학교에 복학하면서 최근에야 다시 개발을 진행하게 되었다. (5개월이면 프로젝트하나가 거의 끝날 시간인데 프론트 파트 진행이 밀려서 팀원들에게 너무 미안한 마음이 컸다)
개발을 다시 진행하게 되어서 밀린 개발기를 동시에 작성해보려 한다. (백엔드 개발기는 sikang님의 블로그에 자세히 작성되어 있다!)

1) 원래 계획은…

처음엔 React를 활용한 web client를 개발하려고 했다. 어떻게 보면 그 당시에는 너무 당연하게 React client를 만들어야 하는거 아니야? 라고 생각했던 것 같다. 초기 기획안에서 extension 개발이 아예 없었던 것은 아니다. solved.ac api를 활용할 예정이였는데 api에 사용되는 token에 접근할 수 있는 방법으로 extension이 가장 적합했었다.
실제로 기획한 서비스 흐름은 아래와 같다. extension에서 필요한 token 정보를 chrome storage를 활용해서 React client와 통신하는 방법을 활용하려고 했다.
extension을 개발하는 것은 처음이였기 차근차근 레퍼런스를 찾아가며 시작했다. 본격적으로 시작한건 9월초였다.

2) 그런데 자체 페이지가 꼭 필요할까?

extension을 공부하다가 문뜩 이런 생각이 들었다. 서비스해야할 컨텐츠가 많지 않은데 굳이 프론트를 나눠서 개발해야 하나? 안그래도 extension 디버깅을 위해 개발자 도구를 두세개씩 띄워야 하는데 React client까지 한다면 트리플 모니터로도 부족할 것 같았다…. 그리고 이런 생각을 하게 된 가장 큰 이유는 개발 인력의 부족이였다.
프론트 팀원 중 현재 개발 작업을 진행할 수 있는 사람이 나 혼자 밖에 없었다. 그래서 바로 테스트를 진행했다. 우선 extension 을 눌렀을 때 나오는 popup(아래 이미지)은 커스텀이 가능한 것을 확인했다. 그런데 이렇게 보이는 것은 접근성이 너무 불편할 것 같았다.
extension을 활용하면 현재 chrome 탭에서 랜더링된 document에 접근할 수 있을 것 같아서 열심히 구글을 뒤졌다. 드디어 키워드를 찾았다. 만세!!
바로 테스트를 진행해 보았다. 테스트라고 해서 거창한게 아니라 document.body를 console에 출력해보았다. 그랬더니 기대한 결과가 나왔다.
그리고 전체회의 시간이 다가왔다. 팀원들과의 회의에서 React client 없이 extension만으로 개발을 진행하는 것이 어떤지 제안하였고 팀원들 모두 동의해 주었다.

3) 내가 생각한 형태를 팀원들에게 보여주기

하지만 아직 서비스 형태가 내 머리속에만 있어서 팀원들이 extension으로 개발하면 어떤 식으로 나오는지 그려지지 않을 것이라고 생각했다. 그래서 우선 빠르게 extension을 활용해서 만든 서비스가 어떤 식으로 나오는지 시각적으로 보여주려고 했다. 기능적인 것 보다 UI요소들을 먼저 개발하였다.
처음에 만든 형태는 아래와 같다. 다행이도 내가 생각한 대로 잘 개발이 되어서 팀원들에게 빠르게 보여줄 수 있었다. 아무래도 extension으로 개발을 진행했을 때 어떤 형태로 결과물이 나오는지 보여줄 수 있는 방법은 UI개발을 빠르게 진행해서 실제 랜더링된 화면을 보여주는게 최선이라고 생각했다.
아마 이 영상을 보고나서 어떤식으로 서비스가 구현될지 조금은 팀원들이 이해했을 거라고 생각한다(물론 나만의 생각이지만). 초기 모델이 나왔기 때문에 빠르게 다음 작업을 이어서 진행했다. 다음 작업까지는 꽤나 빨랐다. 애니메이션 작업에 신경을 쓰느라 기능이 많진 않지만 조금 더 명확한 형태가 나오게 되었다.

4) extension 내부 구조 작업을 진행하자!

이제 ui요소는 대부분 진행이 되어서 비즈니스 로직을 이어주려고 한다. extension은 크게 3가지 모듈로 나눌 수 있는데 extension에 대한 자세한 설명은 다음 포스팅에서 제대로 다루려고 한다! 간단하게 설명하면 아래와 같다.
1.
chrome의 extension 바에서 볼 수 있는 popup script
2.
extension이 활성화 될 때 함께 활성화 되는 service worker script(background)
3.
현재 탭에 띄워진 문서에 script혹은 dom을 주입할 수 있는 content script
구조를 간단하게 그려보면 아래와 같다.
각 모듈간의 통신은 chrome.runtime.sendMessage를, 캐싱이나 저장이 필요한 자료들은 chrome.storage.local을 활용했다. 사용법에 대한 부가적인 설명은 다음 포스팅을 참고하길 바란다.

이게 왜 안돼..?

일주일동안 개발을 진행하면서 머리를 싸맸던 순간을 모아보았다. framework에 의존한 개발을 진행하다가 vanilla js로 진행하면서 내가 그동안 놓치고 지나갔던 것들이 많았다고 느꼈다. 하루에도 몇번씩 이게 왜 안돼를 외치면서 지금까지 외쳤던 것들을 정리하지 않으면 까먹을 것 같아 얼른 정리해보려고 한다!

1) chrome.runtime.sendMessage에서의 return true를 지우면 안되는 이유

return true를 지우면 안되는 이유라고 써두긴 했지만 아래 경우는 특수한 경우이다. 아래 경우를 제외하고는 return true를 지워도 문제가 발생하지 않는다.
아래 코드를 보자. 다른 블록에서는 return true가 없어도 에러가 발생하지 않았는데 가장 마지막 블록에서만 에러가 발생했다. 에러 내용은 메세지 포트가 응답을 받기 전에 닫혔다는 것이다. 아무리 코드를 살펴봐도 다른점이 없는 것 같아서 이해가 되지 않았다.
Unchecked runtime.lastError: The message port closed before a response was received.
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { if (request.message === 'A') { // do something sendResponse({ message: 'success' }); } else if (request.message === 'B') { // do something sendResponse({ message: 'success' }); } else if (request.message === 'C') { fetchBadge().then((data) => { sendResponse({ message: data }); }); return true; // ..? } });
JavaScript
도저히 코드로는 해결법이 없어서 구글에 검색해보니 나와 똑같은 고민을 하는 사람이 있었다. 이게 다 공식 문서를 제대로 읽지 않아서 발생한 이슈였다…
This function [sendResponse] becomes invalid when the event listener returns, unless you return true from the event listener to indicate you wish to send a response asynchronously (this will keep the message channel open to the other end until sendResponse is called).
결론은, sendResponse를 비동기적으로 하고싶다면, message channel이 추후에 보낼 응답을 위해 열려있을 수 있도록 true를 return 해서 event listener에게 알려줘야 한다는 내용이였다. 비동기로 응답을 처리하는게 아니라면 return true를 생략하고 바로 sendResponse함수를 호출하면 된다.

2) img src로 받은 svg이미지를 캐싱했다. 그런데 왜 사이즈 조절이 안돼..?

백준 프로필 뱃지를 서비스에서 사용하기 위해서 img src로 해당 뱃지를 그리려고 했다. 그런데 아무래도 외부 서버에 의존하다보니 서버가 갑자기 죽은경우, client에서 해당 element를 못그리는 현상이 발생했다. 그래서 미리 받아온 svg를 chrome storage에 캐싱해두고 만약의 사태를 대비해 데이터 fetching에 실패하면, 캐싱 된 svg를 가져와서 그리기로 하였다.
그런데 svg를 캐싱해서 사용하려고 하니 사이즈 조절이 안되는 것이였다. 문제는 svg를 캐싱할 때 발생했다. svg는 height, width가 정해져있어서 해당 크기를 항상 고정적으로 가지고 있었다. 상위 element의 사이즈를 줄여도 이미지가 잘리기만 할 뿐, 실제 svg 이미지의 사이즈가 조절되지는 않았다.
그런데 img src를 통해서 넣을 때에는 svg의 사이즈가 잘 조절되었기 때문에 해결 방법이 있을 것 같았다. 찾아보니 svg에는 viewBox라는 속성이 있어서 이것을 통해서 문제를 해결할 수 있었다. 역시 이것도 html 에 대한 이해 부족에서 나온…
profileView .getElementsByClassName('unwa-profile-view-container')[0] .lastElementChild.setAttribute('viewBox', '0 -30 350 170');
JavaScript
캐싱된 텍스트를 사용해도 svg 이미지가 잘리지 않고 랜더링 된 것을 볼 수 있다.

2 - 1) chrome.storage.local.get 으로 가져온 결과를 동기적으로 처리하기

서버로부터 svg이미지를 가져오는데 실패했다면, 이제는 chrome storage에서 저장된 svg를 가져와야 한다. 하지만 우리가 사용할 chrome.storage.local.get함수도 비동기로 데이터를 가져오기 때문에 데이터를 return value로 보내야 하는 상황에서는 주의가 필요하다.
아래와 같이 Promise객체와 await을 활용하면 원하는 결과를 얻을 수 있다.
if (res.status >= 400) { let badgePromise = new Promise((resolve, _) => { chrome.storage.local.get('badge', (data) => { resolve(data.badge); }); }); return await badgePromise; } else { // fetch success! do something }
JavaScript

3) content-scripts에서 정적 html 파일 활용하기

content-scripts는 script를 web page의 문서에 주입하기 위한 것이다. 그러다보니 content-scripts만의 화면을 랜더링할 공간이 없다. element를 추가하고 싶다면 기존 page의 document에 주입(injection)을 하여야 한다.
처음엔 동적으로 element를 만드는 경우가 많고 element 개수가 많지 않아서 createElement() 메서드와 append() 메서드를 활용해서 구현을 하였다. 그러나 점점 extension이 복잡해지고 유지보수에 어려움을 겪어서 html파일을 분리할 필요가 있었다.
그런데 content-scripts에서는 html entry point가 없다보니 어떻게 html파일을 주입해야할지도 모르곘고, js 파일에 html파일을 import 해야하는지도 몰랐다. 여기서 내가 javascript 기본기가 많이 약하다는 것을 느꼈다….
해결책은 매우 간단했다.
1.
html파일을 만든다.
2.
fetch로 html파일을 가져온다.
3.
Response.text()를 활용해 Promise로 반환된 객체를 string으로 반환한다.
4.
element.innerHTML = htmlString 을 활용해 htmlString에 지정된 HTML을 파싱하고, 생성된 노드로 대체한다.
코드로 위 과정을 살펴보자. 우선 content-script에서 file의 url을 가져오려면 chrome.runtime.getURL() 메서드를 사용한다. 물론 manifest.json파일의 web_accessible_resources에 해당 파일이 추가되어 있어야 한다.
fetch(chrome.runtime.getURL('html이 저장된 절대경로')) .then((response) => response.text()) .then((html) => { profileView.innerHTML = html; });
JavaScript

4) insertAdjacentHTML … !!

innerHTMl을 사용해서 개발을 진행하다보니 또 문제가 발생했다. innerHTMl을 하면 기존에 있던 자손 element들을 모두 제거하고 새로운 노드로 대체해버리는 것이였다. 동적으로 생성하는 element들을 미리 html에 빈 div로 만들어 둬야하나? 아니면 자식요소를 따로따로 html파일로 분리해야하나? 엄청난 고민을 하다가… 발견했다!!
JavaScript의 세계는 넓었다,,,
insertAdjacentHTML()메서드를 활용해서 고민했던 것들을 한번에 해결할 수 있었다.
profileView .getElementsByClassName('unwa-profile-view-container')[0] .insertAdjacentHTML('beforeend', htmlString);
JavaScript

추가로

개발을 본격적으로 시작한지 4일밖에 되지 않았는데 공부할 내용만 산더미 처럼 쌓여버렸다.. 기존에는 개발을 어느정도 완료하고 해당 내용을 정리하는 방식으로 개발기를 많이 작성하였는데, 앞으로는 공부하면서 바로바로 정리하려고 한다.
목표 기간은 2주안에 테스트 배포까지 완료하는 것인데 extension 배포는 처음이다보니 시간이 어느정도 걸릴지 감이 잘 오지 않는다. 리팩토링도 해야하는데 언제하지…