치지직 플러그인

SPA에 확장 프로그램 UI 추가하기

gyeongho 2026. 7. 13. 14:19

채팅 스포트라이트 기능을 만들기 위해 live 페이지의 채팅 영역에 스포트라이트 영역을 추가하는 작업을 했습니다.

 

이 작업을 하기 전 `document.querySelector`를 사용해 주입하고 싶은 위치를 콘솔에 찍어봤는데 분명히 존재하는 DOM이 null로 출력되었습니다.

 

이 문제는 치지직이 SPA로 구현되어 있어서 채팅 영역이 만들어지기 전에 Content-Scripts의 querySelector가 실행되어 발생한 문제였습니다.

 

Content-Scripts의 runAt 속성으로는 SPA에서 DOM이 로드되는 시점까지는 알 수 없기 때문에 채팅 영역이 존재할 때까지 기다렸다가 영역이 나타나면 기능 로직을 실행하도록 해야했습니다.

 

이 글에서는 SPA로 동작하는 페이지에서 DOM의 변경을 감지하고 조작하는 방법에 대해 설명합니다.

 

SPA에서 Content-Scripts를 사용할 때 주의할 점

  1. Content-Scripts의 실행 시점과 렌더링 시점의 불일치
    Content-Script는 runAt 속성에 따라 실행 시점이 정해집니다. 이 실행 시점은 SPA의 렌더링 시점과는 전혀 상관이 없습니다. 그래서 브라우저에서는 존재하는 DOM이 Content-Scripts에서 콘솔에 출력해보면 null이 나오는 이유입니다.

  2. SPA 라우팅으로는 Content-Scripts가 재실행되지 않음
    예를 들어, chzzk.naver.com/live/*에서 동작하는 Content-Scripts가 있다고 할 때, chzzk.naver.com에서 채널 라이브를 눌러 페이지를 이동해도 Content-Scripts가 동작하지 않습니다. Content-Scripts는 페이지가 로드될 때의 URL을 기준으로 실행되는데 SPA는 URL이 바뀌어도 리로드되지 않기 때문에 Content-Scripts가 실행되지 않습니다.

MutationObserver

MutationObserver는 DOM 트리의 변경을 감지하는 웹 API로서 요소의 추가/삭제, 속성 변경, 텍스트 내용 변경 등을 옵저빙합니다.

 

기본 사용법

const observer = new MutationObserver(callback);
// callback은 마이크로태스트 큐에 쌓여 현재 실행 스택이 끝난 뒤 배치 처리됩니다.

observer.observe(targetNode, {
  childList: true, // 자식 노드 추가/제거 감지
  attributes: true, // 속성 변화 감지
  subtree: tree, // 하위 트리 전체 감지
  characterData: true // 텍스트 노드 변화 감지
})

 

WXT + MutationObserver

WXT를 사용하는 상황에서 MutationObserver를 사용해 SPA DOM을 감지하는 코드를 보겠습니다.

// chat-spotlight.content/index.ts
const waitForElement = (selector: string): Promise<Element> => {
  return new Promise((resolve) => {
  	const el = document.querySelector(selector);
    if (el) return resolve(el);
    
    const observer = new MutationObserver((_, observer) => {
      const targetEl = document.querySelector(selector);
      if (targetEl) {
        observer.disconnect();
        resolve(targetEl)
      }
    });
    
    observer.observe(document.body, {
      childList: true,
      subtree: true,
    })
  });
};

export default defineContentScript({
  matches: ["https://chzzk.naver.com/live/*"],
  async main(ctx) {
    const SELECTOR = "aside#aside-chatting";
    const asideEl = await waitForElement(SELECTOR);
    // DOM 조작
  }
});

위 코드로 Content-Scripts의 실행 시점과 렌더링 시점의 차이를 해결할 수 있습니다.

 

하지만 위 코드로는 SPA의 페이지 이동 시 Content-Scripts가 재실행되지 않는 문제를 해결할 수 없습니다.

 

이 문제를 해결하기 위해서는 matches의 범위를 넓히고 main 코드 안에서 MutationObserver로 DOM 변경 시 현재 페이지가 라이브 페이지 인지 확인하면 됩니다. 단, 단순히 페이지 URL에 live가 존재하느냐만 보면 A 라이브 페이지에서 B 라이브 페이지로 이동하는 경우를 놓치기 때문에 라이브 채널의 ID를 통해 라이브 채널의 진입과 이탈을 처리해야 합니다.

 

import type { ContentScriptContext } from "wxt/utils/content-script-context";

const ASIDE_SELECTOR = "aside#aside-chatting";

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

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

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

    signal?.addEventListener("abort", () => {
      observer.disconnect();
      resolve(null);
    });
  });
};

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

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

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

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

  const leave = () => {
    enterController?.abort();
    enterController = null;
    onLeave();
  };

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

    // 라이브에 있었다면 먼저 이탈 처리 (라이브 → 비라이브, 라이브 A → 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(() => {
    observer.disconnect();
    if (currentChannelId !== null) leave();
  });

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

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

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

        console.log("aside 확보:", asideSection);
        // DOM 추가 (mount)
      },
      onLeave() {
        console.log("라이브 페이지 이탈");
        // DOM 정리 (unmount)
      },
    });
  },
});

 

위의 코드처럼 작성하면 치지직에서 어느 페이지에 있던 라이브 페이지로 이동할 때 콘솔에 라이브 페이지 진입과 이탈이 출력되는 것을 확인할 수 있습니다.

WXT의 createIntegratedUi

WXT는 Content-Scripts를 통해 UI를 추가하기 위한 빌트인 유틸리티 함수를 제공합니다. https://wxt.dev/guide/essentials/content-scripts.html#ui

 

Next-gen Web Extension Framework – WXT

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.

wxt.dev

위 페이지를 통해 상황에 맞는 유틸리티 함수를 사용할 수 있습니다.

 

저는 치지직에서 사용되고 있는 채팅 DOM을 그대로 사용하고 싶어서 IntegratedUi를 사용했습니다.

 

export default defineContentScript({
  matches: ["https://chzzk.naver.com/*"],
  main(ctx) {
    const ui = createIntegratedUi(ctx, {
      position: "inline",
      anchor: "aside#aside-chatting > [role='log']",
      append: "first",
      onMount: (container) => {
        const anchor = container.parentElement;
        if (!anchor) return;
        console.log(anchor);
      },
      onRemove: (mounted) => {
      
      },
    });
    
    ui.autoMount();
  }
});

 

위의 코드는 matches 페이지에서 autoMount가 anchor가 나타날 때까지 DOM 변경을 감지한다. 는 코드입니다.

 

위 코드가 라이브 페이지에서만 동작해야 하지만 위에 적은 SPA 라우팅 문제로 인해 matches의 범위를 넓혔습니다.