티스토리 뷰

반응형

최근에 평소 쓰던 React 스택으로 크롬 확장 프로그램을 만들 일이 생겼습니다. 막상 시작하려니 "내가 쓰던 TanStack Query나 Tailwind를 그대로 써도 되나", "API는 어떻게 호출해야 하나" 같은 질문이 계속 걸렸습니다. 직접 부딪혀 보니 결론은 의외로 단순했습니다. UI 로직은 평소 React와 거의 똑같고, 다른 건 브라우저와 플랫폼 API 레이어뿐이었습니다.

이 글은 그 과정에서 헷갈렸던 지점을 정리한 것입니다. WXT를 왜 쓰는지부터 시작해서, 확장을 만들 때 실제로 막히는 부분인 API 호출 방식, 스타일 격리, 스토리지를 차례로 다룹니다. 저처럼 React는 익숙하지만 확장 개발은 처음인 분께 도움이 되길 바랍니다.

목차

WXT를 쓰는 이유

예전 방식인 바닐라 MV3는 React 개발자 입장에서 손이 많이 갔습니다. WXT는 그 불편함을 거의 다 걷어내 줍니다.

항목 기존 방식 (바닐라 MV3) WXT
빌드 webpack 직접 설정 Vite 기반, 설정 거의 0 (HMR 빠름)
manifest.json 손으로 작성·관리 wxt.config.ts에서 코드로 생성
React 연동 로더·플러그인 직접 셋업 @wxt-dev/module-react 한 줄
엔트리포인트 manifest에 일일이 경로 등록 entrypoints/ 폴더에 두면 자동 인식
브라우저 호환 Chrome/Firefox 분기 직접 browser.* 통합 API + 타겟별 빌드
content script UI DOM·스타일 격리 수동 createShadowRootUi로 한 번에
HMR/리로드 저장하면 수동 새로고침 자동 리로드

체감 차이가 큽니다. 확장 인프라를 세팅하는 데 드는 시간을 거의 0으로 만들어 주기 때문에, 결국 그냥 React 앱을 짜는 느낌으로 개발할 수 있습니다.

그대로 쓰는 것과 바꿔야 하는 것

WXT로 확장을 만들 때도 React 코드를 짜는 법은 똑같습니다. 다른 건 플랫폼 API 레이어뿐입니다.

먼저 바꿀 필요가 없는 것부터 보겠습니다. 평소 쓰던 React 도구는 그대로 가져갑니다.

  • TanStack Query, React Hook Form, Tailwind, shadcn/Radix 같은 React 라이브러리 전부
  • 컴포넌트·훅·JSX 작성 방식
  • Vite 플러그인과 HMR

반대로 확장 환경에 맞춰 바꿔야 하는 것은 다음과 같습니다.

구분 일반 React WXT 확장에서 이유
라우팅 BrowserRouter HashRouter / memory router chrome-extension:// 환경엔 history API가 안 맞음
저장소 localStorage chrome.storage (WXT storage API) 실행 영역마다 메모리가 분리됨
전역 상태 Zustand/Jotai 메모리 유지 영속이 필요하면 chrome.storage 병행 팝업을 닫으면 메모리가 날아감
네트워크 fetch 컴포넌트에서 직접 호출 경우에 따라 background 경유 content script는 CSP/CORS 영향을 받음
외부 스크립트 CDN <script>, eval 가능 전부 번들에 포함 MV3 CSP가 외부/인라인·eval을 차단
주입 UI 스타일 그냥 CSS/Tailwind createShadowRootUi로 격리 페이지 스타일과 충돌 방지
플랫폼 API window/document 직접 chrome.* API로 대체 권한·실행 영역 모델이 다름

API 호출은 어디서 부르느냐가 전부

가장 많이 헷갈리는 부분입니다. 무조건 background에서 fetch해야 하는 것이 아닙니다. UI가 어느 영역에서 실행되느냐에 따라 갈리는데, 결론부터 말하면 Shadow DOM을 써서 남의 페이지에 얹는 경우에만 background에서 fetch하면 됩니다.

먼저 확장 UI를 띄우는 방식은 크게 두 가지입니다.

첫째는 별도로 뜨는 UI입니다. 확장 아이콘을 누르면 나오는 작은 창인 팝업, 브라우저 오른쪽에 붙는 사이드패널, 확장 전용 탭 같은 것입니다. 말 그대로 크롬이 내 확장에게 따로 내준 별도 공간입니다. 이 화면의 주소는 chrome-extension://로 시작하고, 그 안에서는 내가 주인이라 CSP도 CORS도 내 규칙을 따릅니다. 그래서 평소 웹 개발하듯 컴포넌트에서 백엔드로 직접 fetch하면 됩니다.

둘째는 남의 페이지에 얹히는 UI입니다. 유튜브 영상 옆에 내 요약 버튼을 박거나, 지메일 위에 패널을 띄우는 것처럼 이미 떠 있는 다른 사이트 화면 안에 내 UI를 끼워 넣는 방식입니다. 이걸 content script라고 부릅니다. 이 경우 스타일 격리를 위해 Shadow DOM으로 UI를 감싸게 되는데, 그렇다고 실행되는 영역이 내 것이 되는 건 아닙니다. 코드는 여전히 그 페이지(예: youtube.com)의 영역 안에서 돌기 때문에, fetch를 그대로 날리면 그 페이지의 origin과 CSP에 막힙니다. 그래서 background를 거쳐야 합니다.

정리하면 별도로 뜨는 UI는 크롬이 내준 내 공간이라 직접 호출하면 되고, 얹히는 UI는 남의 공간이라 background를 거칩니다. 화면상 비슷해 보여도 코드가 실행되는 보안 영역이 완전히 다르기 때문입니다.

별도로 뜨는 UI (팝업/사이드패널) 얹히는 UI (content script)
어디에 떠 있나 브라우저가 내준 확장 전용 영역 그 웹페이지(예: youtube.com) 안쪽
코드의 origin chrome-extension://너의확장id https://youtube.com (페이지 origin을 빌림)
DOM 나만의 독립 DOM 남의 페이지 DOM 위에 끼어듦
CSP 적용 주체 내 manifest 그 페이지
직접 fetch 평소대로 직접 호출 background 경유 권장

집에 빗대 보면 이해가 쉽습니다. 별도로 뜨는 UI는 내 집인 독채입니다. 내 맘대로 할 수 있고 CSP와 CORS도 내 규칙입니다. 반면 얹히는 UI는 남의 집에 세든 방입니다. 인테리어인 UI는 내가 하지만, 주소와 문단속인 CSP, 우편 규칙인 origin은 모두 집주인 것입니다. 겉모습은 둘 다 내 React UI라 비슷해 보여도, 누구의 보안 영역에서 코드가 실행되느냐가 다릅니다.

별도로 뜨는 형태는 평소대로 직접 호출

팝업이나 사이드패널, 전용 페이지라면 평소 쓰던 API 방식이 그대로 다 됩니다. TanStack Query로 GET/POST/mutation을 그대로 쓰고, fetch나 axios 클라이언트, 인터셉터, 토큰 갱신도 그대로입니다. 백엔드로 직접 호출해도 문제가 없어서 background를 거칠 필요가 없습니다.

딱 하나만 챙기면 됩니다. wxt.config.tsmanifest.host_permissions에 백엔드 도메인을 추가하면 됩니다.

다음은 호출할 도메인을 manifest에 등록하는 설정입니다.

// wxt.config.ts
export default defineConfig({
  manifest: {
    host_permissions: ['https://api.myapp.com/*'],
  },
});

얹히는 형태는 background 경유

content script는 CORS와 CSP 제약, 토큰 탈취 위험이 있어서 직접 fetch하지 않습니다. 대신 background에게 대신 호출해 달라고 메시지를 보내고, 실제 fetch는 background가 합니다.

흐름을 그림으로 보면 다음과 같습니다.

content script                 background (service worker)
"API 좀 호출해줘"  ──메시지──>   실제 fetch 실행
  (직접 fetch X)               (직접 fetch O)
                  <──응답────   결과 돌려줌

다음은 background에서 메시지를 받아 실제 fetch를 수행하는 코드입니다.

// background.ts
export default defineBackground(() => {
  browser.runtime.onMessage.addListener(async (msg) => {
    if (msg.type === 'API_CALL') {
      const res = await fetch(`https://api.myapp.com${msg.path}`, {
        method: msg.method,
        headers: { Authorization: `Bearer ${token}` }, // 토큰은 여기에만
        body: msg.body ? JSON.stringify(msg.body) : undefined,
      });
      return res.json();
    }
  });
});

content script 쪽에서는 fetch 대신 메시지만 보냅니다.

// content script
const data = await browser.runtime.sendMessage({
  type: 'API_CALL',
  method: 'GET',
  path: '/messages',
});

background가 호출을 맡는 이유는 두 가지입니다. 먼저 background는 host_permissions에 도메인을 넣으면 페이지 origin 제약 없이 호출할 수 있어 CORS와 CSP를 우회합니다. 그리고 토큰을 background에만 두면 남의 페이지 JS가 훔쳐볼 수 없어 안전합니다.

매번 sendMessage를 직접 쓰면 번거로우니, API 클라이언트 함수 안에 숨겨 두는 편이 좋습니다.

다음은 메시지 전송을 감싼 API 클라이언트입니다.

// api.ts (content script에서 import)
export const api = {
  get: (path: string) =>
    browser.runtime.sendMessage({ type: 'API_CALL', method: 'GET', path }),
  post: (path: string, body: unknown) =>
    browser.runtime.sendMessage({ type: 'API_CALL', method: 'POST', path, body }),
};

이렇게 감싸 두면 TanStack Query에서는 queryFn만 이 api로 바꾸면 됩니다.

useQuery({
  queryKey: ['messages'],
  queryFn: () => api.get('/messages'),
});

컴포넌트나 React Query 입장에서는 평소 fetch 클라이언트를 쓰는 것과 똑같이 보입니다. 안에서만 메시지로 바뀔 뿐입니다.

얹히는 UI는 Shadow DOM으로 감싸기

남의 페이지에 내 UI를 얹을 때 또 하나 꼭 챙길 것이 있습니다. 바로 스타일 충돌입니다.

그냥 페이지에 <div>를 붙이면 양쪽으로 스타일이 새어 망가집니다. 그 사이트의 CSS가 내 UI로 침범해서 버튼 스타일이나 폰트, reset.css 같은 것이 내 컴포넌트를 깨뜨립니다. 반대로 내 Tailwind 전역 스타일이 그 사이트로 침범해서 남의 페이지 레이아웃을 망가뜨리기도 합니다.

이걸 막는 것이 Shadow DOM입니다. 내 UI 주위에 보이지 않는 차단막을 쳐서, 안과 밖의 스타일이 서로 넘어오지 못하게 합니다. 그래서 그 사이트가 무엇을 하든 내 UI는 내가 만든 모습 그대로 뜹니다.

WXT에서는 createShadowRootUi가 이걸 자동으로 해 줍니다. 직접 attachShadow를 할 필요가 없습니다.

다음은 content script에서 Shadow DOM 안에 React를 마운트하는 코드입니다.

// entrypoints/example.content.ts
export default defineContentScript({
  matches: ['*://*.youtube.com/*'],
  cssInjectionMode: 'ui', // 내 CSS(Tailwind 포함)를 차단막 안에만 주입

  async main(ctx) {
    const ui = await createShadowRootUi(ctx, {
      name: 'my-widget',
      position: 'inline',
      anchor: 'body',
      onMount: (container) => {
        // 이 container가 차단막(shadow root) 안 → 여기에 React 마운트
        const root = ReactDOM.createRoot(container);
        root.render(<App />);
        return root;
      },
      onRemove: (root) => root?.unmount(),
    });

    ui.mount();
  },
});

주의할 점이 두 가지 있습니다.

첫째, cssInjectionMode: 'ui'를 꼭 켜야 합니다. 이게 내 Tailwind와 전역 CSS를 차단막 안쪽으로 넣어 줍니다. 켜지 않으면 스타일이 막 밖에 남아 UI에 적용되지 않습니다.

둘째, 드롭다운이나 모달 같은 포털 컴포넌트를 주의해야 합니다. Radix와 shadcn의 Dropdown, Dialog, Tooltip은 기본적으로 document.body에 띄워서 차단막 밖으로 빠져나가 스타일이 깨집니다. 포털의 container를 차단막 안쪽 엘리먼트로 지정해 주어야 합니다.

다음처럼 포털의 container를 shadow root 안쪽으로 지정합니다.

<DropdownMenu.Portal container={shadowContainer}>

정리하면 별도로 뜨는 UI인 팝업과 사이드패널은 Shadow DOM이 필요 없고, 남의 페이지에 얹히는 UI만 Shadow DOM으로 감싸면 됩니다. createShadowRootUicssInjectionMode: 'ui'로 거의 자동화되며, 포털 컴포넌트만 따로 챙기면 됩니다.

이 모든 게 합쳐지는 대표 사례가 플로팅 챗봇입니다. 오른쪽 아래에 동그란 버튼을 띄우고 누르면 챗봇이 펼쳐지는, 채널톡이나 인터컴 같은 형태입니다. 이 경우 createShadowRootUi로 차단막 안에 React를 마운트해서 스타일을 격리하고, CSS로 position: fixed를 줘서 floating 처리하고, API 호출은 background를 거치며 토큰은 background에만 보관하게 됩니다.

쿠키 대신 chrome.storage 쓰기

웹에서는 쿠키와 localStorage를 쓰지만, 확장에서는 보통 chrome.storage를 씁니다. 쿠키를 아예 못 쓰는 것은 아닙니다. 팝업이나 사이드패널에서는 fetch(..., { credentials: 'include' })로 쿠키 세션도 그대로 쓸 수 있고 chrome.cookies API도 있습니다. 다만 content script와 background에서는 origin과 SameSite가 꼬여서, 토큰을 직접 들고 chrome.storage에 두는 방식을 더 많이 씁니다.

보통 무엇을 저장하는지 정리하면 다음과 같습니다.

종류 예시
인증 access token, refresh token, 로그인 상태
사용자 설정 다크모드, 언어, 알림 on/off, 단축키
앱 상태/캐시 마지막 본 화면, 최근 항목, 임시 폼 데이터
온보딩 플래그 "튜토리얼 봤음" 같은 1회성 표시

chrome.storage는 영역이 나뉘어 있어서 용도에 맞게 골라 써야 합니다.

영역 특징 용도
storage.local 기기에만 저장, 용량 큼(~10MB+) 토큰, 캐시, 대부분의 데이터
storage.sync 구글 계정으로 기기 간 동기화, 용량 작음(~100KB) 사용자 설정(테마/언어)
storage.session 메모리에만 저장, 브라우저를 닫으면 삭제 민감한 임시 토큰

인증 토큰은 일반적으로 storage.local에 둡니다. 편하고 영속되기 때문입니다. 보안을 더 챙기려면 refresh token은 휘발성인 storage.session에 두고 access token은 메모리에만 두면 디스크에 남지 않습니다. 확장 storage는 그 확장 코드만 접근할 수 있어서 웹 localStorage보다 안전합니다. 다른 사이트의 JS가 읽을 수 없습니다.

마무리

WXT로 크롬 확장을 만들 때 핵심은 단순합니다. UI 로직과 React 라이브러리는 그대로 가져가고, 라우팅과 저장소, fetch 같은 플랫폼 레이어만 확장 방식으로 교체하면 됩니다. 그 교체 작업조차 WXT가 대부분 자동화해 주기 때문에, 평소 React 개발하던 흐름을 거의 그대로 유지할 수 있습니다.

특히 API 호출은 UI가 별도 영역에 뜨는지, 남의 페이지에 얹히는지만 구분하면 됩니다. 별도로 뜨면 직접 호출하고, 얹히면 background를 거치면서 Shadow DOM으로 스타일을 격리하면 됩니다. 이 한 가지 기준만 잡아 두면 나머지는 익숙한 React 개발과 다르지 않습니다.

참고 자료

반응형
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/08   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31
글 보관함