크롬 익스텐션을 개발하게 된 계기는 Nteracy 서비스의 핵심 기능을 유튜브에서 바로 사용할 수 있도록 하여 외부 사이트로 이동해야 하는 불편함을 없애고 사용자 경험을 개선시키기 위함이였습니다. 이를 위해 content_script를 활용했으며 개발과정에서 겪었던 경험을 공유을 공유하고자 합니다.
content_script는 크롬 익스텐션이 웹 페이지의 DOM에 접근하여 콘텐츠를 수정하거나 추가적인 기능을 제공할 수 있도록 하는 스크립트입니다. 이를 통해 특정 사이트에서만 실행되도록 설정할 수도 있으며, 페이지의 컨텍스트에서 코드가 동작하도록 만들 수 있습니다.
공식 문서에 따르면, content_script는 웹 페이지의 JavaScript 환경과 별도의 실행 환경에서 동작하지만, DOM을 직접 조작할 수 있습니다. 이를 활용하면 웹 페이지의 특정 요소를 수정하거나, 새로운 UI를 삽입하는 기능을 구현할 수 있습니다.
이러한 기능을 바탕으로, Nteracy 서비스에서는 content_script를 활용하여 유튜브의 DOM에 접근하고, 원하는 위치에 컴포넌트를 추가하는 방식으로 기능을 확장했습니다. 이를 통해 웹 페이지의 기본 구조를 유지하면서도 필요한 요소를 동적으로 삽입할 수 있었습니다.
이전 글에서는 popup을 구성하였는데 content_script도 크게 다르지 않습니다. 동일하게 manifest.json에서 content_script 속성을 정의하면 됩니다. matches는 특정 URL 패턴에서만 실행되도록 설정을 한 부분입니다. Youtube에서만 서비스를 사용하기 때문에 다음과 같이 설정을 해주었습니다.
"content_scripts": [
{
"matches": ["<https://www.youtube.com/*>"],
"js": ["content.js"]
}
],
popup과 content_script는 서로 독립적인 실행 환경에서 동작하기 때문에 동일한 스크립트 파일을 공유할 수 없습니다. 따라서 Webpack에서 entry를 각각 지정하여 개별적으로 번들링해야 합니다.
entry: {
...
content: path.resolve("./src/content/index.tsx"),
},
popup과 달리 content_script에선 index.html을 생성할 필요가 없습니다. index.html은 유튜브에서 제공되므로, HtmlWebpackPlugin과 같은 도구를 사용해 별도로 HTML 파일을 관리할 필요가 없습니다. 대신, 유튜브 페이지의 DOM에 직접 접근하여 필요한 컴포넌트를 삽입하는 방식으로 로직을 작성해야 합니다.
1. content_script는 언제 실행될까?
크롬 익스텐션을 개발하는 과정에서 content_script는 기본적으로 document_idle시점에 실행된다는 사실을 처음에는 간과했습니다. 처음에는 document_idle상태에서 스크립트가 실행되면 DOM이 모두 로드된 후이므로 “querySelector를 사용해 원하는 요소를 찾을 수 있겠지..” 라는 생각했습니다. 하지만 유튜브 페이지는 기본적인 구조가 로드된 후에도 비동기적으로 콘텐츠가 추가되는 방식이었고, 이로 인해 querySelector가 기대한 요소를 찾지 못하는 문제가 발생했습니다.
아래는 제가 처음 작성한 코드입니다. 그저 유튜브 페이지에서 특정 DOM 요소에 새로운 컴포넌트를 마운트하는 것뿐이었습니다. 그런데 문제는 querySelector()가 null을 반환하며, 내가 원하는 DOM 요소를 찾지 못했습니다.
const init = () => {
const appContainer = document.createElement("div");
appContainer.id = appId;
const element = document.querySelector(selector);
const existingAppContainer = document.getElementById(appId);
if (existingAppContainer) {
existingAppContainer.remove();
}
if (element) {
element.insertAdjacentElement("afterbegin", appContainer);
}
const root = createRoot(appContainer);
root.render(<App />);
};
2. 왜 Element를 찾을 수 없었을까?
유튜브와 같은 동적 웹사이트는 페이지가 처음 로드될 때 모든 DOM 요소가 동시에 로드되지 않습니다. 일부 요소들은 JavaScript를 통해 비동기적으로 동적으로 추가되기 때문에, document_idle 시점에서는 제가 찾으려는 요소가 아직 DOM에 로드되지 않은 경우가 많습니다. 이렇게 DOM이 동적으로 생성되는 웹사이트에서는 content_script가 실행되는 시점에서 페이지의 요소들이 준비되지 않은 상황이 발생할 수 있습니다.
3. 안정 장치를 설치
이 문제를 해결하려면 제가 찾으려는 요소가 DOM에 추가될 때까지 기다리는 방법을 사용해야 했습니다. DOM 요소가 언제 로드될지 정확한 시점을 알 수 없느니, 요소가 준비될 때까지 기다리는 안전장치를 설치해야 했습니다. 이를 위해 MutationObserver를 사용하여 DOM 변화를 감지하고, 원하는 요소가 DOM에 추가될 때까지 기다리는 방식으로 문제를 해결했습니다.
const waitForElement = (selector: string) => {
return new Promise((resolve) => {
if (document.querySelector(selector)) {
return resolve(document.querySelector(selector));
}
const observer = new MutationObserver((mutations) => {
if (document.querySelector(selector)) {
resolve(document.querySelector(selector));
observer.disconnect();
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
});
};
위의 코드에서 waitForElement 함수는 MutationObserver를 사용하여, 내가 찾고자 하는 DOM 요소가 문서에 추가되었을 때 resolve를 호출하여 프로미스를 반환합니다. 이 방법을 사용하면 동적으로 로드되는 요소들에 대한 안전장치를 마련할 수 있게 되었고, 제가 원하는 요소가 준비되었을 때 안정적으로 해당 요소에 접근할 수 있게 되었습니다.
4. init함수는 언제 실행해야 할까?
사실 간단하게 생각해보면 https://www.youtube.com/watch에 들어온 경우에 Init함수를 실행 렌더링을 진행하면 됩니다. 하지만 유저가 해당 페이지에 도달하는 방식이 다양하기 때문에 여러 가지 경우를 고려해야 했습니다.
유저가 페이지에 접근하는 방식
- 주소창을 통해 youtube/watch에 접근한 경우
- 페이지를 새로고침하는 경우
- 유튜브 내에서 다른 동영상을 클릭하여 URL이 변경되는 경우
4.1. History API
유튜브는 SPA로 동작하기 때문에 처음엔 pushState와 replaceState를 오버라이딩하여 URL 변경을 감지하는 방법을 떠올렸습니다.
하지만 유튜브의 자체적인 라우팅 소스 코드를 확인할 수 없었기 때문에, 해당 방법이 실제로 제대로 동작할지 의문이 들었습니다. 그럼에도 불구하고 직접 적용을 해봤지만.. 역시나 Init 함수가 정상적으로 실행되지 않았습니다.😅
4.2. MutationObserver
2번째 MutationObserver를 사용하여 이전 URL과 비교하여 변경이 되었다면 변경이 된 경우에만 Init함수를 실행시키는 방법이였습니다. 하지만 MutationObserver의 경우 성능 문제를 고려해야 했습니다.
let oldHref = window.location.href;
if (location.pathname === "/watch") {
init();
}
setUpDomObserver(() => {
if (location.pathname === "/watch" && oldHref !== document.location.href) {
oldHref = window.location.href;
init();
}
});
MutationObserver의 성능 문제
- 추천 영상 목록이 업데이트되면서 지속적으로 MutationObserver가 동작하게 되어 성능 부담이 발생한다.
- disconnect()를 호출하지 않으면 모든 DOM 변경을 감지하기 때문에 불필요한 연산이 많아진다.
- 하지만 disconnect()를 호출하면 이후 변경을 감지할 수 없으므로 유저가 다른 동영상을 클릭했을 때 URL 변경을 감지할 수 없게 된다.
즉, MutationObserver를 계속 유지하는 것이 성능적으로 부담이 되고, 그렇다고 끊어버리면 URL 변경을 감지할 수 없는 딜레마가 발생했습니다.
4.3. yt-navigate-finish 이벤트
문제를 해결하기 위해서 블로그를 보다가 yt-navigate-finish 이벤트 리스너가 있다는 것을 알게 되었습니다. (왜 이제야 알았을까..)
유튜브에서 자체적으로 제공하는 이벤트이기 때문에 안정적으로 동작할 수 있다고 판단했고 바로 적용해보았습니다. 하지만 유저가 다른 동영상을 클릭해서 URL이 변경이 된 경우에는 잘 동작을 했지만, 새로고침이나 주소창을 통해서 접근한 경우 가끔 이벤트 리스너가 동작하지 않았습니다.
window.addEventListener("yt-navigate-finish", () => {
if (location.pathname === "/watch") {
init();
}
});
4.4. 최종 해결 (초기 로드 및 yt-navigate-finish 이벤트)
결국, 초기 로드 시 init함수를 실행하고 유튜브 내부 페이지에서 유저가 다른 동영상을 클릭한 경우에만 yt-navigate-finish 이벤트 리스너를 사용하는 방식으로 변경을 하였습니다. isInitalize는 Flag 변수로 init함수의 중복 실행을 막기 위해 사용하였습니다.
let isInitialize = false;
// 새로고침 OR 주소창을 통한 접근한 경우
if (location.pathname === "/watch" && !isInitialize) {
isInitialize = true;
init();
}
// 유튜브 내부에서 다른 동영상 클릭 시 URL이 변경이 된 경우
window.addEventListener("yt-navigate-finish", () => {
if (location.pathname === "/watch" && !isInitialize) {
isInitialize = true;
init();
}
});
Tip🍯. DOMContentLoaded를 사용하지 않은 이유
제가 위에서 말한 부분에서 확인할 수 있듯이 크롬 익스텐션 공식문서에서는 성능 최적화를 위해 doucment_idle 시점에서 content_script를 로드하는 것을 권장하고 있습니다.
일반적인 웹 페이지에서 script태그와 DOMContentLoaded 이벤트를 적절하게 사용해서 DOM이 완전히 로드된 후 안전하게 접근할 수 있었습니다. 하지만 크롬 익스텐션의 content_script가 document_idle 시점에 실행이 되는 경우, 이미 DOM이 로드된 후에 스크립트를 실행시키기 때문에 핸들러를 설정해도 실행되지 않습니다. 문서가 정확히 언제 로드가 되었는 지 Performance를 확인해보면 더 쉽게 이해할 수 있습니다.
위의 그림에서 보면 DCL이 실행된 후에 content.js가 실행되는 것을 확인할 수 있습니다.
만약 window.load를 하게 된다면 실행은 되겠지만 페이지를 비롯한 이미지 자원 들도 모두 로드가 된 후 실행이 되기 때문에 시간이 오래걸릴 수 있기 때문에 사용을 하지 않았습니다.
5. 마치며
위의 모든 과정을 통해 content_script를 이용하여 유튜브 페이지에 Nteracy 서비스를 이용할 수 있게 되었습니다! 😃
'ETC' 카테고리의 다른 글
콘텐츠 스크립트에서 HMR 도입 (0) | 2025.02.20 |
---|---|
SPA에서 벗어나 보자 익스텐션 개발기2 (0) | 2024.03.22 |
SPA에서 벗어나 보자 익스텐션 개발기1 (2) | 2024.03.14 |
토큰 재발급 동기화 문제 (0) | 2024.03.11 |
Webpack 너무 느려.. Vite 마이그레이션 (0) | 2024.03.11 |