익스텐션을 개발하면서 새롭게 빌드하고 다시 로드하는 작업을 반복적으로 하다보니 HMR 도입이 필요하다는 생각을 하게 되었습니다. 하지만 콘텐츠 스크립트에서 HMR을 적용하는 과정에서 많은 문제들이 있었고 그 경험들을 공유하려고 합니다.
우선 익스텐션에서 HMR의 과정을 알아보기 전에 HMR의 등장 배경과 일반적인 HMR이 어떻게 동작을 하는 지 에 대해서 알아보려고 합니다.
1. HMR 등장 배경
HMR은 맨 처음부터 있던 기술은 아니였습니다. HMR이전에 Live Reload라는 초기 기술이 있었습니다. 발전 과정을 보면서 간단하게 이전 개발자들이 어떤 불편함이 있었고 어떻게 해결을 했는 지 알아보도록 하죠!
1.1. Live Reload란?
Live Reload는 개발 중 코드 변경 사항을 빠르게 반영하기 위한 초기 기술로 “코드 변경 감지 → 브라우저에게 변경된 사실을 알림 → 브라우저를 새로고침” 으로 과정이 이뤄집니다. 이와 같은 방식을 통해 개발 생산성을 향상시켰지만, 전체 페이지가 새로고침이 되기 때문에 상태가 초기화 되거나 깜빡 거리는 문제가 발생했습니다. 이런 한계를 극복하기 위해 HMR이라는 개념이 등장하게 되었습니다.
2. HMR 동작 원리
HMR(Hot Module Replacement)의 동작 원리를 이해하기 위해, 먼저 watch와 webpack-dev-server의 개념을 간단히 살펴보겠습니다.
2.1. watch
webpack은 기본적으로 빌드를 실행하면 번들링을 수행하고 종료됩니다. 하지만 코드 변경이 있을 때 마다 수동으로 빌드를 재실행하는 건 너무 비효율적입니다. 이와 같은 문제를 해결하기 위해서 webpack에서 watch 기능을 제공합니다. watch는 특정 파일이 변경될 경우 이를 감지하고 자동으로 다시 번들링을 수행합니다. 다음과 같은 기능을 통해 우리는 계속 최신의 번들 상태를 유지할 수 있게 됩니다.
module.exports = {
watch: true,
...
}
2.2. webpack-dev-server
webpack의 watch 기능만으로는 브라우저가 변경 사항을 자동으로 반영하지 못합니다. 이를 해결하기 위해 webpack-dev-server는 WebSocket을 활용하여 브라우저와 통신하면서, 파일 변경 사항을 감지하고 자동으로 반영하게 합니다. 기본적으로 webpack-dev-server는 watch의 기능을 포함하고 있기 때문에 따로 설정을 해주지 않아도 됩니다.
module.exports = {
devServer : {
hot: true, // HMR 활성화
...
}
}
2.3. 그래서 정확한 과정은 어떻게 될까?
webpack-dev-server의 hot을 설정하게 되면 HMR를 사용할 수 있는 환경이 만들어지고 다음과 같은 단계를 통해서 실시간으로 업데이트를 할 수 있습니다.
- WS을 통해 브라우저(HMR 클라이언트)와 webpack-dev-server가 연결
- 브라우저와 webpack-dev-server 간 WS을 통해 브라우저에게 코드 변경을 알림
- Wepback 런타임이 브라우저에서 서버로 .hot-update.json(어떤 모듈이 업데이트되었는 지에 관한 매니페스트 파일)을 요청
- Webpack 런타임이 .hot-update.json을 바탕으로 .hot-update.js(변경된 모듈에 대한 청크파일)를 요청
- Wepback 런타임이 변경된 청크를 통해 웹 페이지를 동적으로 반영하여 업데이트를 적용
실제로 CRA를 통해 생성한 프로젝트의 HMR 과정을 보면 ws이 연결되고 코드가 수정된 경우 다음과 같이 네트워크 요청하는 것을 확인할 수 있습니다.
3. 콘텐츠 스크립트에서 HMR 적용하기
이제 드디어 본론에 대해서 애기할 시간입니다. 크롬 익스텐션을 가장 불편했던 점은 코드 변경 후 확인 과정이였습니다. chrome://extensions에 들어가 확장 프로그램을 새로고침하고 페이지도 새로고침을 해야만 변경 사항을 확인할 수 있었습니다. 하지만 너무 비효율적이였습니다.. 이미 HMR을 사용해보니깐 더 필요성을 크게 느꼈던 거 같습니다. HMR을 적용하는 과정에서 어떤 문제점이 있었고 어떻게 해결했는 지 자세하게 설명해보도록 하겠습니다. 일반적인 웹 개발 환경과 어떤점이 다른지에 대해서 비교하면서 어떻게 적용할 수 있었는 지 알아보도록 하죠!
3.1. 메모리가 아닌 디스크 기반의 빌드가 필요
웹 개발 환경에선 메모리 방식을 사용해서 서버에서 필요한 파일들을 빠르게 보내는 주는 구조로 되어 있습니다. 그래서 실제로 빌드를 하지 않는 이상 dist파일이 생기지 않도록 되어 있습니다. 하지만 크롬 익스텐션의 경우 chrome://extensions/에 빌드된 파일을 업로드해야지만 테스트를 할 수 있는 환경이 만들어지기 때문에 writeToDisk를 true로 설정해줘야 됩니다.
module.exports = {
...
devServer : {
hot: true,
devMiddleware: { writeToDisk: true }
}
}
3.2. WebSocket 재설정
우리가 만약 처음 웹 개발을 한다면 localhost에서 개발을 진행합니다. 하지만 크롬 익스텐션의 콘텐츠 스크립트의 경우 대부분 미리 배포된 페이지(https://www.youtube.com)에서 개발을 해야 됩니다. 바로 이 차이점에 때문에 다음과 같은 문제가 발생하게 됩니다.
webpack-dev-server는 현재 페이지의 window.location.origin을 기준으로 웹 소켓 연결을 설정합니다. webpack-dev-server가 https://www.youtube.com/ws로 잘못된 연결을 시도합니다. 반면, 실제 dev-server는 localhost:8080에서 실행 중이기 때문에 두 사이에 연결이 이루어지지 않아 웹 소켓 오류가 발생합니다. 그래서 명시적으로 localhost:8080으로 웹 소켓 연결을 하라고 작성해줘야 됩니다.
명시적으로 작성해서 제대로 연결이 가능하도록 했지만, youtube 페이지에서 localhost:8080으로 웹 소켓을 연결하려고 하면 아래와 같은 에러가 발생하게 됩니다.
이 문제를 해결하기 위해선 allowHost를 통해서 youtube연결이 가능하도록 설정을 해줘야 됩니다. 다음과 같이 설정을 하면 코드가 변경이 되면 알림을 보낼 수 있게 됩니다.
allowedHosts: [".youtube.com"],
client: {
webSocketURL: "ws://localhost:8080/ws",
}
...
3.3. hot-update.json CORS 에러
youtube.com에서 localhost로 hot-update.json을 요청하는 과정에서 서로 다른 출처 간의 연결 문제로 CORS 에러가 발생했습니다. 해당 에러를 해결하기 위해서 localhost로부터 hot-update.json을 chrome://extension_id로 부터 리소스를 가져오는 방식으로 CORS에러를 해결했습니다. publicPath를 chrome.runtime.getUrl을 통해서 동적으로 변경해서 localhost이 아닌 chrome-extension://<extension_id>로 부터 가져올 수 있게 설정을 해줬습니다.
content.js
__webpack_public_path__ = chrome.runtime.getURL("");
웹 페이지에서 크롬 익스텐션의 리소스에 대해서 접근할 수 있게 만들기 위해서 다음과 같은 설정을 해줘야 됩니다.
manifest.json
"web_accessible_resources": [
{
"resources": ["*.hot-update.json"],
"matches": ["<https://www.youtube.com/*>"]
}
],
4. 콘텐츠 스크립트는 격리된 세계에서 작업
콘텐츠 스크립트는 웹 페이지에서 실행되어 DOM에 접근할 수 있습니다. 하지만 구글은 Chrome 확장 프로그램의 보안성을 강화하기 위해 콘텐츠 스크립트와 웹 페이지의 스크립트는 서로 영향을 줄 수 없도록 만들었습니다. 실제로 DevTools의 source 탭에서도 콘텐츠 스크립트가 별도의 영역으로 표시된 것을 확인할 수 있습니다.
이 격리된 특성 때문에, 유튜브 페이지에서 hot-update.json을 가져온 후 필요한 청크 파일을 로드하려고 하면 문제가 발생합니다. 해당 작업이 웹 페이지의 스크립트 영역에서 실행되기 때문입니다. 웹 페이지의 스크립트는 콘텐츠 스크립트와 별개의 환경에 존재하므로, 콘텐츠 스크립트에서 이 업데이트된 모듈을 사용할 수 없습니다. 아래의 그림을 보면 더 쉽게 이해할 수 있습니다.
이를 해결하려면 Background를 통해서 필요한 모듈을 동적으로 콘텐츠 스크립트에서 실행하도록 전달해야 합니다. 이 과정에서 Wepback의 HMR을 커스터마이징하는 작업이 필요합니다. 이를 구현하기 위해 @cooby/crx-load-script-webpack-plugin 라이브러리를 사용했습니다. 해당 라이브러리를 통해서 아래의 그림처럼 동작하게 만들어서 콘텐츠 스크립트에서도 HMR을 적용할 수 있었습니다.
5. 새로고침을 하면 HMR 동기화 문제와 해결 방법
5.1. HMR 동기화 문제
일반적인 웹 개발에서는 브라우저가 새로고침 시 메모리 상에 있는 최신의 번들을 요청하기 때문에 HMR이 원할하게 동작합니다. 하지만 콘텐츠 스크립트의 경우 한번 로드 되면 새로고침해도 이전의 번들 파일(content.js)이 재실행됩니다. 이로 인해 Webpack의 런타임이 요청하는 hot-update.json과 실제 dev-server가 제공하는 hot-update.json이 일치하지 않아 오류가 발생합니다. 다시 HMR을 적용하기 위해선 확장 프로그램을 새로고침할 수 밖에 없었습니다.
공식 문서에서도 콘텐츠 스크립트가 변경되면 수동으로 새로고침을 해야 된다고 명시되어 있습니다.
5.2. 동적으로 콘텐츠 스크립트를 로드
HMR 동기화 문제를 해결하기 위해 콘텐츠 스크립트를 동적으로 로드하는 방식을 도입했습니다. 기존에 manifest.json에 정적으로 선언된 경우에는 브라우저가 확장 프로그램을 다시 로드하지 않는 이상 변경된 파일이 적용되지 않았습니다. 이를 해결하기 위해 chrome.scripting.executeScript를 사용하여 페이지에 접근할 때마다 최신 content.js를 동적으로 로드하도록 변경하였습니다.
background.js
if (process.env.NODE_ENV === "development") {
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (tab.url?.startsWith("chrome://")) return undefined;
if (
changeInfo.status === "complete" &&
tab.url?.startsWith("<https://www.youtube.com>")
) {
chrome.scripting.executeScript({
target: { tabId: tabId },
files: ["content.js"],
});
}
});
}
이 방식을 적용한 결과, webpack-dev-server와 content.js에서 실행되는 webpack 런타임이 동기화되었고, 페이지를 새로고침해도 HMR이 정상적으로 동작하는 것을 확인할 수 있었습니다.
다만, 이 방법은 개발 환경에서 HMR을 원활히 사용하기에 적합했지만, 배포 환경에서는 번들 파일이 변경될 일이 없기 때문에 정적 로드 방식이 더 효율적입니다. 이를 위해 환경에 따라 manifest.json을 동적으로 생성하는 방식으로 개선했습니다.
6. 환경에 따른 manifest.json 분리
개발 환경에서는 콘텐츠 스크립트를 제거한 manifest.json을 사용하고, 배포 환경에서는 콘텐츠 스크립트를 추가하는 방식으로 manifest.json 파일을 동적으로 생성하도록 설정하여 각각 환경에서 동작할 수 있도록 하였습니다.
manifest.json
{
- "content_scripts": [
- { "matches": ["<https://www.youtube.com/*>"], "js": ["content.js"] }
- ],
}
webpack.prod.js
const updateManifest = () => {
...
};
module.exports = merge(common, {
mode: "production",
plugins: [
{
apply: (compiler) => {
compiler.hooks.afterEmit.tap("UpdateManifestPlugin", updateManifest);
},
},
],
});
7. 마치며
위의 모든 과정을 거치면서 다음과 같이 콘텐츠 스크립트에서도 HMR이 동작할 수 있도록 하였습니다. 덕분에 이전과는 비교도 되지 않을 만큼 편하게 개발을 할 수 있었습니다. 궁금한 부분이나 잘못된 부분이 있다면 공유해주시면 감사하겠습니다!
'ETC' 카테고리의 다른 글
SPA에서 벗어나 보자 익스텐션 개발기3 (0) | 2025.02.08 |
---|---|
SPA에서 벗어나 보자 익스텐션 개발기2 (0) | 2024.03.22 |
SPA에서 벗어나 보자 익스텐션 개발기1 (2) | 2024.03.14 |
토큰 재발급 동기화 문제 (0) | 2024.03.11 |
Webpack 너무 느려.. Vite 마이그레이션 (0) | 2024.03.11 |