티스토리 뷰
최근에 평소 쓰던 React 스택으로 크롬 확장 프로그램을 만들 일이 생겼습니다. 막상 시작하려니 "내가 쓰던 TanStack Query나 Tailwind를 그대로 써도 되나", "API는 어떻게 호출해야 하나" 같은 질문이 계속 걸렸습니다. 직접 부딪혀 보니 결론은 의외로 단순했습니다. UI 로직은 평소 React와 거의 똑같고, 다른 건 브라우저와 플랫폼 API 레이어뿐이었습니다.
이 글은 그 과정에서 헷갈렸던 지점을 정리한 것입니다. WXT를 왜 쓰는지부터 시작해서, 확장을 만들 때 실제로 막히는 부분인 API 호출 방식, 스타일 격리, 스토리지를 차례로 다룹니다. 저처럼 React는 익숙하지만 확장 개발은 처음인 분께 도움이 되길 바랍니다.
목차
- WXT를 쓰는 이유
- 그대로 쓰는 것과 바꿔야 하는 것
- API 호출은 어디서 부르느냐가 전부
- 얹히는 UI는 Shadow DOM으로 감싸기
- 쿠키 대신 chrome.storage 쓰기
- 마무리
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.ts의 manifest.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으로 감싸면 됩니다. createShadowRootUi와 cssInjectionMode: '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 개발과 다르지 않습니다.
참고 자료
'개발..' 카테고리의 다른 글
| Vite 8 마이그레이션으로 빌드 시간 3분의 1로 줄이기 (0) | 2026.07.11 |
|---|---|
| OpenAI API에서 Claude Agent SDK로 변경하기 (1) | 2026.06.11 |
| Query Factory 패턴을 Claude Code 스킬로 만들기 (0) | 2026.05.20 |
| Superpowers로 완성하는 AI 네이티브 엔지니어링 (0) | 2026.05.11 |
| 모노레포에서 CSS Layer 구조 설계하기 (0) | 2026.05.08 |
- Total
- Today
- Yesterday
- 티스토리챌린지
- claude
- github
- Ai
- nextjs13
- nuxt2
- Vite
- claude code
- 오블완
- seo
- 타입스크립트
- ChatGPT
- Git
- 클로드 코드
- 깃허브
- nodejs
- vue composition api
- Zustand
- React
- nextjs15
- 프론트엔드
- nextjs14
- vscode
- NUXT
- Github Actions
- 서버 to 서버
- openAI
- cors
- AWS
- NextJS
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |