<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>UserScripts on monkshark.dev</title><link>https://monkshark.github.io/tags/userscripts/</link><description>Recent content in UserScripts on monkshark.dev</description><generator>Hugo -- gohugo.io</generator><language>ko</language><lastBuildDate>Sun, 14 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://monkshark.github.io/tags/userscripts/index.xml" rel="self" type="application/rss+xml"/><item><title>#2 - 페이지보다 먼저, 그런데 설정을 실어서 주입하기</title><link>https://monkshark.github.io/p/masque-injection/</link><pubDate>Sun, 14 Jun 2026 00:00:00 +0000</pubDate><guid>https://monkshark.github.io/p/masque-injection/</guid><description>&lt;p&gt;위장의 첫 번째 조건은 단순하다. 가짜 값이 페이지 자신의 스크립트보다 먼저 자리잡아야 한다. 페이지가 &lt;code&gt;navigator.userAgent&lt;/code&gt;를 읽고 나서 바꿔봤자 이미 늦었다. 그런데 &amp;ldquo;먼저 박는다&amp;quot;와 &amp;ldquo;페르소나별 설정을 실어 보낸다&amp;quot;를 동시에 만족시키는 게 생각보다 까다로웠다.&lt;/p&gt;
&lt;h2 id="가짜-값은-페이지보다-먼저-자리잡아야-한다"&gt;&lt;a href="#%ea%b0%80%ec%a7%9c-%ea%b0%92%ec%9d%80-%ed%8e%98%ec%9d%b4%ec%a7%80%eb%b3%b4%eb%8b%a4-%eb%a8%bc%ec%a0%80-%ec%9e%90%eb%a6%ac%ec%9e%a1%ec%95%84%ec%95%bc-%ed%95%9c%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;가짜 값은 페이지보다 먼저 자리잡아야 한다
&lt;/h2&gt;&lt;p&gt;조건은 두 개였다. 첫째, &lt;code&gt;document_start&lt;/code&gt;에 돌아야 한다. 페이지의 첫 인라인 스크립트보다 앞서야 하니까. 둘째, 페이지와 같은 MAIN world에서 돌아야 한다. 확장의 격리된 world에서 navigator를 바꿔봐야 페이지엔 안 보인다.&lt;/p&gt;
&lt;p&gt;매니페스트에 정적 content script를 MAIN world·document_start로 박으면 타이밍은 해결된다. 그런데 여기서 막혔다. 정적 content script는 동적인 설정(어떤 페르소나를 쓸지)을 실어 보낼 수가 없고, MAIN world에선 &lt;code&gt;chrome.storage&lt;/code&gt;도 못 읽는다. 사용자가 고른 페르소나를 어떻게 그 코드 안으로 넣지?&lt;/p&gt;
&lt;h2 id="oncommitted--executescript는-너무-느렸다"&gt;&lt;a href="#oncommitted--executescript%eb%8a%94-%eb%84%88%eb%ac%b4-%eb%8a%90%eb%a0%b8%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;onCommitted + executeScript는 너무 느렸다
&lt;/h2&gt;&lt;p&gt;첫 시도는 서비스워커에서 &lt;code&gt;chrome.webNavigation.onCommitted&lt;/code&gt;를 듣고, 설정을 읽어 &lt;code&gt;chrome.scripting.executeScript&lt;/code&gt;로 주입하는 거였다. 이론상 내비게이션이 커밋되는 순간 쏘면 되니까.&lt;/p&gt;
&lt;p&gt;실제로는 처참하게 늦었다. 핸들러가 &lt;code&gt;getSettings()&lt;/code&gt;로 storage를 비동기로 읽고, executeScript 메시지가 렌더러까지 왕복하는 사이에, 로컬 페이지의 head 인라인 스크립트는 이미 다 돌아버렸다. 테스트 페이지에서 주입 표시가 &amp;ldquo;(없음)&amp;ldquo;으로 떴다. 동기 표면(navigator·screen)을 잡기엔 이 경로는 구조적으로 너무 느렸다.&lt;/p&gt;
&lt;h2 id="chromeuserscripts로-코드를-실어-보냈다"&gt;&lt;a href="#chromeuserscripts%eb%a1%9c-%ec%bd%94%eb%93%9c%eb%a5%bc-%ec%8b%a4%ec%96%b4-%eb%b3%b4%eb%83%88%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;chrome.userScripts로 코드를 실어 보냈다
&lt;/h2&gt;&lt;p&gt;답은 &lt;code&gt;chrome.userScripts&lt;/code&gt; API였다. 정확히 이 용도 — 동적 설정을 코드 문자열에 담아 MAIN world·document_start에 등록 — 를 위한 물건이다. 위장 함수를 통째로 직렬화하고, 그 뒤에 페르소나와 옵션을 JSON으로 붙여서 즉시 실행되는 한 덩어리로 만들었다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-js" data-lang="js"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userScripts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;register&lt;/span&gt;&lt;span class="p"&gt;([{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;masque&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;matches&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&amp;lt;all_urls&amp;gt;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;world&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;MAIN&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;runAt&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;document_start&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;js&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="sb"&gt;`(&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;applyInPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sb"&gt;)(&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;persona&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sb"&gt;, &lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sb"&gt;)`&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}])&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;여기서 전제는 &lt;code&gt;applyInPage&lt;/code&gt;가 완전히 self-contained여야 한다는 것이다. &lt;code&gt;.toString()&lt;/code&gt;으로 떼어내 다른 곳에서 다시 실행되니, 바깥 모듈의 어떤 심볼도 참조하면 안 된다. 그래서 헬퍼를 전부 함수 안에 인라인으로 넣고, 빌드 번들에서 외부 참조가 0인지 확인했다. 설정이 바뀌면 &lt;code&gt;update&lt;/code&gt;로 다시 등록만 하면 된다.&lt;/p&gt;
&lt;h2 id="그런데-chromeuserscripts가-undefined였다"&gt;&lt;a href="#%ea%b7%b8%eb%9f%b0%eb%8d%b0-chromeuserscripts%ea%b0%80-undefined%ec%98%80%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;그런데 chrome.userScripts가 undefined였다
&lt;/h2&gt;&lt;p&gt;빌드해서 로드했는데 여전히 &amp;ldquo;(없음)&amp;ldquo;이었다. 로그를 박아 보니 &lt;code&gt;chrome.userScripts&lt;/code&gt; 자체가 undefined였다.&lt;/p&gt;
&lt;p&gt;이게 함정이다. &lt;code&gt;userScripts&lt;/code&gt; 권한을 매니페스트에 선언해도, 사용자가 확장 세부정보에서 &amp;ldquo;사용자 스크립트 허용&amp;rdquo; 토글을 직접 켜기 전까지는 그 API 네임스페이스가 아예 노출되지 않는다. Chrome이 일부러 둔 보안 장치다. 코드로 켤 방법은 없다.&lt;/p&gt;
&lt;p&gt;그래서 감지해서 안내하는 쪽으로 갔다. &lt;code&gt;typeof chrome.userScripts&lt;/code&gt;로 켜졌는지 확인하고, 꺼져 있으면 팝업·옵션에 경고 배너와 설정 페이지 바로가기를 띄운다. 우리가 할 수 있는 건 감지·안내·딥링크까지고, 마지막 한 번은 사용자가 켜야 한다.&lt;/p&gt;
&lt;h2 id="js와-헤더가-어긋나면-안-된다"&gt;&lt;a href="#js%ec%99%80-%ed%97%a4%eb%8d%94%ea%b0%80-%ec%96%b4%ea%b8%8b%eb%82%98%eb%a9%b4-%ec%95%88-%eb%90%9c%eb%8b%a4" class="header-anchor"&gt;&lt;/a&gt;JS와 헤더가 어긋나면 안 된다
&lt;/h2&gt;&lt;p&gt;JS 표면을 다 바꿔도, HTTP 헤더가 진짜 User-Agent를 흘리면 앞서 경계한 그 모순이 그대로 생긴다. 헤더는 userScripts로 못 만지니 별도 경로가 필요했다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;chrome.declarativeNetRequest&lt;/code&gt; 동적 규칙으로 User-Agent·Accept-Language·sec-ch-ua 계열 헤더를 같은 페르소나 값으로 덮었다. 주입은 userScripts, 헤더는 DNR — 경로는 둘이지만 출처는 하나의 페르소나다. 그래서 JS에서 읽는 값과 서버가 받는 헤더가 항상 같은 사람을 가리킨다. 일관성이라는 원칙이 여기서 실제 구현으로 떨어졌다.&lt;/p&gt;
&lt;h2 id="돌아보면"&gt;&lt;a href="#%eb%8f%8c%ec%95%84%eb%b3%b4%eb%a9%b4" class="header-anchor"&gt;&lt;/a&gt;돌아보면
&lt;/h2&gt;&lt;p&gt;&amp;ldquo;페이지보다 먼저&amp;quot;와 &amp;ldquo;동적 설정을 실어서&amp;quot;는 얼핏 양립하기 어려운 요구였다. 정적 content script는 앞은 빠르지만 설정을 못 싣고, executeScript는 설정은 싣지만 너무 느렸다. 그 사이를 정확히 메우는 게 userScripts였고, 대신 사용자가 토글을 켜야 한다는 비용이 따라왔다. 결국 주입은 타이밍·동적성·탐지 가능성의 삼각형이었고, 어느 꼭짓점도 공짜가 아니었다.&lt;/p&gt;</description></item></channel></rss>