<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>mingh.me</title>
    <link>https://mingyeongho.tistory.com/</link>
    <description>mingyeongho.dev</description>
    <language>ko</language>
    <pubDate>Fri, 14 Aug 2026 07:52:37 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>gyeongho</managingEditor>
    <image>
      <title>mingh.me</title>
      <url>https://tistory1.daumcdn.net/tistory/8436689/attach/22272b25d5434e69aa94a51a7651ab4d</url>
      <link>https://mingyeongho.tistory.com</link>
    </image>
    <item>
      <title>SPA에 확장 프로그램 UI 추가하기</title>
      <link>https://mingyeongho.tistory.com/31</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;채팅 스포트라이트 기능을 만들기 위해 live 페이지의 채팅 영역에 스포트라이트 영역을 추가하는 작업을 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 작업을 하기 전 `document.querySelector`를 사용해 주입하고 싶은 위치를 콘솔에 찍어봤는데 분명히 존재하는 DOM이 null로 출력되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제는 치지직이 SPA로 구현되어 있어서 채팅 영역이 만들어지기 전에 Content-Scripts의 querySelector가 실행되어 발생한 문제였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Content-Scripts의 runAt 속성으로는 SPA에서 DOM이 로드되는 시점까지는 알 수 없기 때문에 채팅 영역이 존재할 때까지 기다렸다가 영역이 나타나면 기능 로직을 실행하도록 해야했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서는 &lt;b&gt;SPA로 동작하는 페이지에서 DOM의 변경을 감지하고 조작하는 방법&lt;/b&gt;에 대해 설명합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;SPA에서 Content-Scripts를 사용할 때 주의할 점&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;Content-Scripts의 실행 시점과 렌더링 시점의 불일치&lt;/b&gt;&lt;br /&gt;Content-Script는 runAt 속성에 따라 실행 시점이 정해집니다. 이 실행 시점은 SPA의 렌더링 시점과는 전혀 상관이 없습니다. 그래서 브라우저에서는 존재하는 DOM이 Content-Scripts에서 콘솔에 출력해보면 null이 나오는 이유입니다.&lt;br /&gt;&lt;br /&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;SPA 라우팅으로는 Content-Scripts가 재실행되지 않음&lt;/b&gt;&lt;br /&gt;예를 들어, chzzk.naver.com/live/*에서 동작하는 Content-Scripts가 있다고 할 때, chzzk.naver.com에서 채널 라이브를 눌러 페이지를 이동해도 Content-Scripts가 동작하지 않습니다. Content-Scripts는 페이지가 로드될 때의 URL을 기준으로 실행되는데 SPA는 URL이 바뀌어도 리로드되지 않기 때문에 Content-Scripts가 실행되지 않습니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;MutationObserver&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MutationObserver는 DOM 트리의 변경을 감지하는 웹 API로서 &lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;요소의 추가/삭제, 속성 변경, 텍스트 내용 변경 등을 옵저빙합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;기본 사용법&lt;/p&gt;
&lt;pre id=&quot;code_1783910411529&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const observer = new MutationObserver(callback);
// callback은 마이크로태스트 큐에 쌓여 현재 실행 스택이 끝난 뒤 배치 처리됩니다.

observer.observe(targetNode, {
  childList: true, // 자식 노드 추가/제거 감지
  attributes: true, // 속성 변화 감지
  subtree: tree, // 하위 트리 전체 감지
  characterData: true // 텍스트 노드 변화 감지
})&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;WXT + MutationObserver&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WXT를 사용하는 상황에서 MutationObserver를 사용해 SPA DOM을 감지하는 코드를 보겠습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1783910884324&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// chat-spotlight.content/index.ts
const waitForElement = (selector: string): Promise&amp;lt;Element&amp;gt; =&amp;gt; {
  return new Promise((resolve) =&amp;gt; {
  	const el = document.querySelector(selector);
    if (el) return resolve(el);
    
    const observer = new MutationObserver((_, observer) =&amp;gt; {
      const targetEl = document.querySelector(selector);
      if (targetEl) {
        observer.disconnect();
        resolve(targetEl)
      }
    });
    
    observer.observe(document.body, {
      childList: true,
      subtree: true,
    })
  });
};

export default defineContentScript({
  matches: [&quot;https://chzzk.naver.com/live/*&quot;],
  async main(ctx) {
    const SELECTOR = &quot;aside#aside-chatting&quot;;
    const asideEl = await waitForElement(SELECTOR);
    // DOM 조작
  }
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 코드로 &lt;b&gt;Content-Scripts의 실행 시점과 렌더링 시점의 차이&lt;/b&gt;를 해결할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 위 코드로는 SPA의 페이지 이동 시 Content-Scripts가 재실행되지 않는 문제를 해결할 수 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해서는 matches의 범위를 넓히고 main 코드 안에서 MutationObserver로 DOM 변경 시 현재 페이지가 라이브 페이지 인지 확인하면 됩니다. 단, 단순히 페이지 URL에 live가 존재하느냐만 보면 A 라이브 페이지에서 B 라이브 페이지로 이동하는 경우를 놓치기 때문에 &lt;b&gt;라이브 채널의 ID&lt;/b&gt;를 통해 라이브 채널의 진입과 이탈을 처리해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1783912620628&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import type { ContentScriptContext } from &quot;wxt/utils/content-script-context&quot;;

const ASIDE_SELECTOR = &quot;aside#aside-chatting&quot;;

/**
 * 선택자에 해당하는 요소가 DOM에 나타날 때까지 기다린다.
 * - 이미 존재하면 즉시 resolve
 * - 없으면 MutationObserver로 추가를 감시하다가 발견 시 resolve
 * - signal이 abort되면 감시를 멈추고 null로 resolve (라이브 이탈 등으로 취소)
 */
const waitForElement = (
  selector: string,
  signal?: AbortSignal,
): Promise&amp;lt;Element | null&amp;gt; =&amp;gt; {
  return new Promise((resolve) =&amp;gt; {
    const el = document.querySelector(selector);
    if (el) return resolve(el);

    const observer = new MutationObserver(() =&amp;gt; {
      const targetEl = document.querySelector(selector);
      if (targetEl) {
        observer.disconnect();
        resolve(targetEl);
      }
    });

    observer.observe(document.body, {
      childList: true,
      subtree: true,
    });

    signal?.addEventListener(&quot;abort&quot;, () =&amp;gt; {
      observer.disconnect();
      resolve(null);
    });
  });
};

/** 현재 URL에서 라이브 채널 ID를 추출한다. 라이브 페이지가 아니면 null */
const getLiveChannelId = () =&amp;gt;
  location.pathname.match(/^\/live\/([^/]+)/)?.[1] ?? null;

interface LivePageCallbacks {
  /** 라이브 페이지 진입 시 호출. signal은 이탈 시 abort된다. */
  onEnter: (channelId: string, signal: AbortSignal) =&amp;gt; void;
  /** 라이브 페이지 이탈 시 호출. */
  onLeave: () =&amp;gt; void;
}

/**
 * 라이브 페이지 진입/이탈을 감지하는 상태 기계.
 * - SPA 이동은 반드시 DOM 변경을 동반하므로 MutationObserver에 얹혀 URL을 검사한다
 * - 상태(현재 채널 ID)가 바뀐 순간에만 콜백을 호출한다
 *   &amp;rarr; 라이브 A &amp;rarr; 라이브 B 이동도 채널 ID가 다르므로 이탈 + 진입으로 감지된다
 * - 진입마다 AbortController를 만들어 onEnter에 signal을 넘기고, 이탈 시 abort한다
 *   &amp;rarr; onEnter 안에서 진행 중이던 대기 작업(waitForElement 등)이 함께 취소된다
 * - ctx 무효화(확장 리로드) 시 감시를 중단하고 이탈 처리한다
 */
const watchLivePage = (
  ctx: ContentScriptContext,
  { onEnter, onLeave }: LivePageCallbacks,
) =&amp;gt; {
  let currentChannelId: string | null = null;
  let enterController: AbortController | null = null;

  const enter = (channelId: string) =&amp;gt; {
    enterController = new AbortController();
    onEnter(channelId, enterController.signal);
  };

  const leave = () =&amp;gt; {
    enterController?.abort();
    enterController = null;
    onLeave();
  };

  const sync = () =&amp;gt; {
    const next = getLiveChannelId();
    if (next === currentChannelId) return; // 변화 없으면 무시

    // 라이브에 있었다면 먼저 이탈 처리 (라이브 &amp;rarr; 비라이브, 라이브 A &amp;rarr; B 공통)
    if (currentChannelId !== null) leave();

    currentChannelId = next;
    if (next !== null) enter(next);
  };

  const observer = new MutationObserver(sync);
  observer.observe(document.body, { childList: true, subtree: true });

  ctx.onInvalidated(() =&amp;gt; {
    observer.disconnect();
    if (currentChannelId !== null) leave();
  });

  sync(); // 새로고침으로 /live/*에 바로 진입한 경우 즉시 반영
};

export default defineContentScript({
  matches: [&quot;https://chzzk.naver.com/*&quot;],
  main(ctx) {
    watchLivePage(ctx, {
      async onEnter(channelId, signal) {
        console.log(`라이브 페이지 진입: ${channelId}`);

        const asideSection = await waitForElement(ASIDE_SELECTOR, signal);
        if (!asideSection) return; // 기다리는 중에 이탈해서 취소됨

        console.log(&quot;aside 확보:&quot;, asideSection);
        // DOM 추가 (mount)
      },
      onLeave() {
        console.log(&quot;라이브 페이지 이탈&quot;);
        // DOM 정리 (unmount)
      },
    });
  },
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 코드처럼 작성하면 치지직에서 어느 페이지에 있던 라이브 페이지로 이동할 때 콘솔에 라이브 페이지 진입과 이탈이 출력되는 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;WXT의 createIntegratedUi&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WXT는 Content-Scripts를 통해 UI를 추가하기 위한 빌트인 유틸리티 함수를 제공합니다. &lt;a href=&quot;https://wxt.dev/guide/essentials/content-scripts.html#ui&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://wxt.dev/guide/essentials/content-scripts.html#ui&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1783913493822&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Next-gen Web Extension Framework &amp;ndash; WXT&quot; data-og-description=&quot;WXT provides the best developer experience, making it quick, easy, and fun to develop web extensions. With built-in utilities for building, zipping, and publishing your extension, it's easy to get started.&quot; data-og-host=&quot;wxt.dev&quot; data-og-source-url=&quot;https://wxt.dev/guide/essentials/content-scripts.html#ui&quot; data-og-url=&quot;https://wxt.dev&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bsNC7P/dJMb9kmoAef/R3VmI8M9o1sMD3JNo3Jm3k/img.png?width=1280&amp;amp;height=640&amp;amp;face=0_0_1280_640&quot;&gt;&lt;a href=&quot;https://wxt.dev/guide/essentials/content-scripts.html#ui&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://wxt.dev/guide/essentials/content-scripts.html#ui&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bsNC7P/dJMb9kmoAef/R3VmI8M9o1sMD3JNo3Jm3k/img.png?width=1280&amp;amp;height=640&amp;amp;face=0_0_1280_640');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Next-gen Web Extension Framework &amp;ndash; WXT&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;WXT provides the best developer experience, making it quick, easy, and fun to develop web extensions. With built-in utilities for building, zipping, and publishing your extension, it's easy to get started.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;wxt.dev&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 페이지를 통해 상황에 맞는 유틸리티 함수를 사용할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 치지직에서 사용되고 있는 채팅 DOM을 그대로 사용하고 싶어서 IntegratedUi를 사용했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1783917396485&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default defineContentScript({
  matches: [&quot;https://chzzk.naver.com/*&quot;],
  main(ctx) {
    const ui = createIntegratedUi(ctx, {
      position: &quot;inline&quot;,
      anchor: &quot;aside#aside-chatting &amp;gt; [role='log']&quot;,
      append: &quot;first&quot;,
      onMount: (container) =&amp;gt; {
        const anchor = container.parentElement;
        if (!anchor) return;
        console.log(anchor);
      },
      onRemove: (mounted) =&amp;gt; {
      
      },
    });
    
    ui.autoMount();
  }
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 코드는 &lt;b&gt;matches 페이지에서 autoMount가 anchor가 나타날 때까지 DOM 변경을 감지한다.&lt;/b&gt; 는 코드입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 코드가 라이브 페이지에서만 동작해야 하지만 위에 적은 SPA 라우팅 문제로 인해 matches의 범위를 넓혔습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>치지직 플러그인</category>
      <category>IntegratedUi</category>
      <category>MutationObserver</category>
      <author>gyeongho</author>
      <guid isPermaLink="true">https://mingyeongho.tistory.com/31</guid>
      <comments>https://mingyeongho.tistory.com/31#entry31comment</comments>
      <pubDate>Mon, 13 Jul 2026 14:19:16 +0900</pubDate>
    </item>
  </channel>
</rss>