첫 확장프로그램 만들기 — 웹페이지 분석기 + 다크 모드 토글 (30분 실습)




브라우저 확장프로그램 시리즈 (4/5)
앞의 세 편에서 생태계, 가능성, 구조를 이해했어요. 이제 손으로 만들 차례예요. 처음부터 끝까지 따라 하면서 확장프로그램 하나를 완성합니다. 이 편이 끝나면 여러분은 브라우저에서 돌아가는 자기 확장프로그램을 갖게 될 거예요. 30분이면 됩니다.


들어가며 — 오늘 만들 것

오늘 만들 확장은 “웹페이지 분석기” 예요. 어떤 페이지든 열고 툴바의 확장 아이콘을 클릭하면, 팝업이 뜨면서 이런 정보를 보여줘요.

  • 페이지 제목과 URL
  • 페이지 내 이미지 개수
  • 링크 개수
  • 단어 수와 예상 읽기 시간
  • 다크 모드 토글 버튼 (누르면 현재 페이지를 어둡게 바꿈)
  • 다크 모드 상태 저장 (다른 페이지에서도 설정 유지)

이 하나의 프로젝트에 3편에서 배운 핵심 요소가 전부 들어가요.

  • manifest.json — 확장 설계도
  • Popup — 팝업 UI
  • Content Script — 페이지 분석 및 다크 모드 적용
  • Background Service Worker — 설치 시 초기화 로직
  • Storage — 다크 모드 설정 저장
  • 메시지 패싱 — Popup과 Content Script 간 통신

완성하면 툴바 아이콘을 누를 때 이미지 개수·링크 개수·단어 수·읽기 시간이 뜨는 팝업이 열리고, 버튼 하나로 페이지를 어둡게 바꿀 수 있는 확장이 됩니다.


0단계. 프로젝트 폴더 만들기

원하는 위치에 프로젝트 폴더를 하나 만들어주세요. 저는 my-analyzer로 할게요.

my-analyzer/
├── manifest.json
├── popup.html
├── popup.css
├── popup.js
├── content.js
├── background.js
└── icons/
    ├── icon16.png
    ├── icon48.png
    └── icon128.png

아이콘은 당장 없어도 돼요. 간단히 정사각형 PNG 파일 세 개를 준비해서 위 이름으로 저장하세요. 없으면 Flaticon, Icons8 같은 사이트에서 무료 아이콘을 받아도 되고, 임시로 빈 PNG를 만들어도 됩니다.

💡 아이콘 없이 시작하려면
manifest.json의 iconsaction.default_icon 부분을 일단 빼고 시작해도 동작은 해요. 나중에 추가하세요.


1단계. manifest.json — 설계도 작성

모든 건 여기서 시작해요. 다음 내용으로 manifest.json을 만드세요.

{
  "manifest_version": 3,
  "name": "웹페이지 분석기",
  "version": "1.0.0",
  "description": "현재 웹페이지를 분석하고 다크 모드를 적용합니다",

  "icons": {
    "16": "icons/icon16.png",
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  },

  "action": {
    "default_popup": "popup.html",
    "default_icon": {
      "16": "icons/icon16.png",
      "48": "icons/icon48.png"
    }
  },

  "background": {
    "service_worker": "background.js"
  },

  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content.js"],
      "run_at": "document_idle"
    }
  ],

  "permissions": ["storage", "activeTab", "scripting"]
}

각 부분 해설

  • manifest_version: 3 — 현재 표준
  • action.default_popup — 툴바 아이콘 클릭 시 뜰 HTML
  • background.service_worker — 백그라운드 스크립트 파일
  • content_scripts.matches: ["<all_urls>"] — 모든 URL에 content.js 주입
  • run_at: "document_idle" — 페이지 로딩이 안정화된 후 실행 (분석에 적합)
  • permissions:
    • storage — 설정 저장용
    • activeTab — 현재 탭 접근 (더 강한 tabs 대신 선택)
    • scripting — 스크립트 주입 API 사용

2단계. popup.html — 팝업 UI 뼈대

다음으로 팝업의 구조를 만들어요. popup.html 파일을 만들고 이 내용을 넣으세요.

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <link rel="stylesheet" href="popup.css">
</head>
<body>
  <div class="container">
    <header>
      <h1>🔍 페이지 분석기</h1>
    </header>

    <section class="info">
      <div class="row">
        <span class="label">제목</span>
        <span class="value" id="pageTitle">-</span>
      </div>
      <div class="row">
        <span class="label">URL</span>
        <span class="value" id="pageUrl">-</span>
      </div>
    </section>

    <section class="stats">
      <div class="stat-card">
        <div class="stat-value" id="imgCount">-</div>
        <div class="stat-label">이미지</div>
      </div>
      <div class="stat-card">
        <div class="stat-value" id="linkCount">-</div>
        <div class="stat-label">링크</div>
      </div>
      <div class="stat-card">
        <div class="stat-value" id="wordCount">-</div>
        <div class="stat-label">단어 수</div>
      </div>
      <div class="stat-card">
        <div class="stat-value" id="readTime">-</div>
        <div class="stat-label">읽기 시간</div>
      </div>
    </section>

    <section class="actions">
      <button id="darkModeToggle" class="btn">
        🌙 다크 모드 켜기
      </button>
      <button id="refresh" class="btn btn-secondary">
        🔄 새로고침
      </button>
    </section>

    <footer>
      <span id="status">준비 완료</span>
    </footer>
  </div>

  <script src="popup.js"></script>
</body>
</html>

⚠️ 주의: <script> 태그는 반드시 파일 맨 아래에 놓으세요. 확장프로그램에서는 인라인 스크립트가 금지되어 있어요. onclick="..." 같은 속성도 쓸 수 없어요. 모든 이벤트는 JS 파일에서 addEventListener로 처리해야 합니다.


3단계. popup.css — 스타일링

팝업을 예쁘게 꾸며볼게요. popup.css 파일을 만드세요.

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  width: 320px;
  font-family: -apple-system, "Noto Sans KR", sans-serif;
  background: #f7f8fc;
  color: #1a1d2e;
}

.container {
  padding: 16px;
}

header h1 {
  font-size: 16px;
  margin-bottom: 12px;
  padding-bottom: 10px;
  border-bottom: 2px solid #e8ecf5;
}

.info {
  background: white;
  border-radius: 10px;
  padding: 12px;
  margin-bottom: 12px;
  font-size: 12px;
}

.info .row {
  display: flex;
  margin-bottom: 6px;
}

.info .row:last-child {
  margin-bottom: 0;
}

.info .label {
  min-width: 50px;
  color: #888;
  font-weight: 600;
}

.info .value {
  flex: 1;
  color: #1a1d2e;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.stats {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 8px;
  margin-bottom: 12px;
}

.stat-card {
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
  padding: 12px;
  border-radius: 10px;
  text-align: center;
}

.stat-value {
  font-size: 20px;
  font-weight: 800;
}

.stat-label {
  font-size: 11px;
  opacity: 0.9;
  margin-top: 2px;
}

.actions {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-bottom: 10px;
}

.btn {
  padding: 10px;
  border: none;
  border-radius: 8px;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
  background: #1a1d2e;
  color: white;
  transition: transform 0.1s;
}

.btn:hover {
  transform: translateY(-1px);
}

.btn.btn-secondary {
  background: white;
  color: #1a1d2e;
  border: 1px solid #d8dce8;
}

.btn.active {
  background: #f39c12;
}

footer {
  font-size: 11px;
  color: #888;
  text-align: center;
  padding-top: 8px;
  border-top: 1px dashed #d8dce8;
}

4단계. content.js — 페이지 분석기

이제 진짜 로직이에요. Content Script가 페이지를 분석하고, 팝업이 요청하면 데이터를 반환합니다. content.js를 만드세요.

// content.js
console.log('[페이지 분석기] content script 로드됨');

// 페이지 분석 함수
function analyzePage() {
  const images = document.querySelectorAll('img');
  const links = document.querySelectorAll('a[href]');

  // 본문 텍스트 추출 (스크립트, 스타일 제외)
  const bodyText = document.body.innerText || '';
  const words = bodyText.trim().split(/\s+/).filter(w => w.length > 0);
  const wordCount = words.length;

  // 분당 200단어 기준 읽기 시간 계산
  const readTime = Math.max(1, Math.round(wordCount / 200));

  return {
    title: document.title,
    url: window.location.href,
    imageCount: images.length,
    linkCount: links.length,
    wordCount,
    readTime
  };
}

// 다크 모드 적용 함수
function applyDarkMode(enabled) {
  const styleId = 'page-analyzer-dark-mode';
  const existing = document.getElementById(styleId);

  if (enabled) {
    if (existing) return;
    const style = document.createElement('style');
    style.id = styleId;
    style.textContent = `
      html {
        filter: invert(1) hue-rotate(180deg) !important;
        background: white !important;
      }
      img, video, iframe, picture, svg {
        filter: invert(1) hue-rotate(180deg) !important;
      }
    `;
    document.documentElement.appendChild(style);
  } else {
    if (existing) existing.remove();
  }
}

// 페이지 로드 시 저장된 다크 모드 설정 적용
chrome.storage.local.get('darkMode').then((result) => {
  if (result.darkMode) {
    applyDarkMode(true);
  }
});

// 팝업에서 오는 메시지 수신
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  console.log('[페이지 분석기] 메시지 수신:', message);

  if (message.action === 'analyze') {
    const data = analyzePage();
    sendResponse(data);
  }

  if (message.action === 'toggleDarkMode') {
    applyDarkMode(message.enabled);
    sendResponse({ success: true });
  }

  // 동기 응답이므로 return 값 필요 없음
});

여기서 배울 포인트

  • document.querySelectorAll로 이미지·링크 개수 세기
  • document.body.innerText로 본문 텍스트만 추출 (HTML 태그 제외)
  • CSS filter: invert() 로 초간단 다크 모드 구현
  • chrome.runtime.onMessage로 팝업 메시지 수신
  • chrome.storage.local에서 저장된 설정 읽기

5단계. popup.js — 팝업 로직

팝업이 content script에게 분석을 요청하고, 결과를 화면에 그리는 코드예요.

// popup.js

// 현재 활성 탭 가져오기
async function getCurrentTab() {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  return tab;
}

// 페이지 분석 요청
async function analyzePage() {
  const status = document.getElementById('status');
  status.textContent = '분석 중...';

  try {
    const tab = await getCurrentTab();

    // 일부 페이지(chrome://, 웹스토어)에서는 content script가 동작하지 않음
    if (!tab.url || tab.url.startsWith('chrome://') || tab.url.startsWith('chrome-extension://')) {
      status.textContent = '이 페이지에서는 분석할 수 없어요';
      return;
    }

    // content script에 분석 요청
    const response = await chrome.tabs.sendMessage(tab.id, { action: 'analyze' });

    // 결과를 UI에 반영
    document.getElementById('pageTitle').textContent = response.title || '(제목 없음)';
    document.getElementById('pageTitle').title = response.title;
    document.getElementById('pageUrl').textContent = response.url;
    document.getElementById('pageUrl').title = response.url;
    document.getElementById('imgCount').textContent = response.imageCount;
    document.getElementById('linkCount').textContent = response.linkCount;
    document.getElementById('wordCount').textContent = response.wordCount.toLocaleString();
    document.getElementById('readTime').textContent = response.readTime + '분';

    status.textContent = '분석 완료 ✓';
  } catch (error) {
    console.error(error);
    status.textContent = '분석 실패: content script 로드를 기다려주세요';
  }
}

// 다크 모드 토글
async function toggleDarkMode() {
  const tab = await getCurrentTab();
  const { darkMode = false } = await chrome.storage.local.get('darkMode');
  const newState = !darkMode;

  // 저장
  await chrome.storage.local.set({ darkMode: newState });

  // content script에 적용 요청
  try {
    await chrome.tabs.sendMessage(tab.id, {
      action: 'toggleDarkMode',
      enabled: newState
    });
  } catch (error) {
    console.error('content script 미응답:', error);
  }

  // 버튼 UI 업데이트
  updateDarkModeButton(newState);
  document.getElementById('status').textContent = newState ? '다크 모드 ON' : '다크 모드 OFF';
}

// 다크 모드 버튼 모양 업데이트
function updateDarkModeButton(enabled) {
  const btn = document.getElementById('darkModeToggle');
  btn.textContent = enabled ? '☀️ 다크 모드 끄기' : '🌙 다크 모드 켜기';
  btn.classList.toggle('active', enabled);
}

// 초기화: 팝업이 열릴 때
document.addEventListener('DOMContentLoaded', async () => {
  // 다크 모드 현재 상태 반영
  const { darkMode = false } = await chrome.storage.local.get('darkMode');
  updateDarkModeButton(darkMode);

  // 자동으로 페이지 분석
  analyzePage();

  // 이벤트 연결
  document.getElementById('darkModeToggle').addEventListener('click', toggleDarkMode);
  document.getElementById('refresh').addEventListener('click', analyzePage);
});

여기서 배울 포인트

  • chrome.tabs.query로 현재 탭 조회
  • chrome.tabs.sendMessage로 content script에 메시지 전송
  • await로 비동기 처리 깔끔하게 작성
  • chrome:// 페이지 체크 (에러 방지)
  • chrome.storage.local에 설정 저장·복원

6단계. background.js — 서비스 워커

이번 예제에서는 background가 하는 일이 많지 않아요. 설치 시 초기화 정도만 담당합니다.

// background.js
console.log('[페이지 분석기] 서비스 워커 시작');

// 확장이 처음 설치되거나 업데이트될 때
chrome.runtime.onInstalled.addListener(async (details) => {
  console.log('[페이지 분석기] onInstalled:', details.reason);

  if (details.reason === 'install') {
    // 기본 설정 저장
    await chrome.storage.local.set({
      darkMode: false,
      installedAt: Date.now()
    });
    console.log('[페이지 분석기] 설치 완료, 기본 설정 저장됨');
  }

  if (details.reason === 'update') {
    const previousVersion = details.previousVersion;
    const currentVersion = chrome.runtime.getManifest().version;
    console.log(`[페이지 분석기] ${previousVersion} → ${currentVersion} 업데이트`);
  }
});

// (선택) 스토리지 변경 감지 — 디버깅·로깅용
chrome.storage.onChanged.addListener((changes, area) => {
  console.log(`[페이지 분석기] storage.${area} 변경:`, changes);
});

여기서 배울 포인트

  • chrome.runtime.onInstalled로 설치·업데이트 이벤트 처리
  • chrome.runtime.getManifest()로 현재 버전 가져오기
  • chrome.storage.onChanged로 모든 스토리지 변화 감지

7단계. 확장프로그램 로드하기

드디어 테스트할 시간이에요!

크롬의 경우:

  1. 주소창에 chrome://extensions 입력
  2. 우측 상단 개발자 모드 토글 ON
  3. 압축해제된 확장 프로그램을 로드합니다 버튼 클릭
  4. my-analyzer 폴더 선택

엣지의 경우:

  1. 주소창에 edge://extensions 입력
  2. 좌측 개발자 모드 토글 ON
  3. 압축 해제된 확장 로드 클릭
  4. 폴더 선택

로드에 성공하면 확장 카드가 목록에 뜨고, 툴바에 아이콘이 생겨요.

💡 아이콘이 안 보이면
크롬은 기본적으로 확장 아이콘을 퍼즐 아이콘 안에 숨깁니다. 퍼즐 아이콘을 클릭하고 우리 확장 옆의 핀 아이콘을 눌러서 툴바에 고정하세요.


8단계. 테스트 — 동작 확인

아무 웹사이트나 열어보세요. 네이버, 위키피디아, 여러분의 블로그 등이요.

  1. 툴바의 확장 아이콘 클릭
  2. 팝업이 뜨면서 자동으로 페이지를 분석
  3. 이미지 수, 링크 수, 단어 수, 읽기 시간이 표시됨
  4. 🌙 다크 모드 켜기 버튼 클릭 → 페이지가 어두워짐
  5. 다른 탭을 열고 같은 사이트 접속 → 다크 모드가 자동 적용되는지 확인 (스토리지 저장이 동작하는 증거)

잘 되시나요? 축하해요! 🎉 여러분은 방금 실제로 동작하는 브라우저 확장프로그램을 만든 거예요.


9단계. 디버깅 — 안 되면 어떻게 하나

확장프로그램은 3군데에서 각각 디버깅해야 해요. 환경이 분리되어 있으니까요.

Popup 디버깅

팝업을 띄운 상태에서 팝업 안에서 우클릭 → “검사”. DevTools가 뜨면 팝업 전용 콘솔이에요.

Content Script 디버깅

웹페이지에서 F12 → DevTools의 일반 콘솔 에 content script 로그가 찍혀요. console.log('[페이지 분석기]') 같은 식으로 접두어를 붙이면 필터링하기 편해요.

Background Service Worker 디버깅

chrome://extensions → 우리 확장 카드 → 서비스 워커 링크 클릭. 별도 DevTools가 뜨면서 background 콘솔을 볼 수 있어요.

흔한 오류들

“Could not establish connection. Receiving end does not exist.”
→ Content script가 아직 로드 안 된 페이지에서 메시지를 보낸 경우. chrome:// 페이지나 방금 로드한 페이지에서 자주 발생. 페이지 새로고침 후 다시 시도.

팝업에서 alert()이 뜨지 않음
→ 팝업에서 alert는 되는데, Background Service Worker에서는 안 됨. chrome.notifications 사용.

코드 수정했는데 반영이 안 됨
chrome://extensions에서 새로고침(↻) 버튼 클릭 필요. Content script는 페이지도 새로고침해야 해요.


10단계. 다음으로 — 확장해볼 아이디어

완성된 확장을 시작점으로 해서 이런 기능들을 추가해볼 수 있어요.

난이도: 쉬움

  • 분석 항목 추가 (헤딩 개수, 외부 링크 수, 이미지 총 용량 등)
  • 다크 모드 강도 조절 슬라이더
  • 분석 결과 복사 버튼

난이도: 중간

  • 사이트별 다크 모드 화이트리스트/블랙리스트
  • 페이지 내 모든 링크를 새 탭으로 여는 버튼
  • 특정 키워드 하이라이트 기능

난이도: 도전

  • 옵션 페이지 추가 (다크 모드 색상 커스터마이징)
  • 단축키 등록 (Ctrl+Shift+D로 다크 모드 토글)
  • 페이지 접속 통계 기록 (storage에 방문 이력 쌓기)

다음 편에서 이 확장을 실제 스토어에 올리는 법을 알려드릴 테니, 지금 만든 상태에서 기능을 좀 더 다듬어보세요. 스토어에 뭘 올릴지는 여러분의 선택이에요.


전체 파일 구조 정리

최종적으로 폴더는 이런 모습일 거예요.

my-analyzer/
├── manifest.json       ← 설계도 (6KB)
├── popup.html          ← 팝업 UI (2KB)
├── popup.css           ← 팝업 스타일 (2KB)
├── popup.js            ← 팝업 로직 (3KB)
├── content.js          ← 페이지 분석기 (2KB)
├── background.js       ← 서비스 워커 (1KB)
└── icons/
    ├── icon16.png
    ├── icon48.png
    └── icon128.png

깃허브에 올려두면 포트폴리오로도 활용할 수 있어요.


마무리하며

오늘 이런 걸 했어요.

  1. ✅ manifest.json으로 확장 설계
  2. ✅ popup.html/css/js로 사용자 UI 구축
  3. ✅ content.js로 페이지 분석 및 다크 모드 구현
  4. ✅ background.js로 설치 초기화
  5. ✅ 메시지 패싱으로 popup과 content 통신
  6. ✅ chrome.storage로 설정 영구 저장
  7. ✅ 실제 브라우저에 로드해서 동작 확인

3편에서 배운 추상적인 개념들이 실제 코드에서 어떻게 쓰이는지 다 경험하셨을 거예요. “아, 메시지 패싱이 이렇게 하는 거구나”, “storage가 환경 간 공유 창구구나” 하고요.

이 확장은 지금 당신의 컴퓨터에서만 돌아가고 있어요. 다음 편에서는 이걸 크롬 웹 스토어와 엣지 애드온 스토어에 올려서 전 세계 사람들이 쓸 수 있게 만들어볼 거예요. 심사 과정에서 자주 거절되는 이유, 스크린샷·설명문 작성 팁, 업데이트 전략까지 전부 다룹니다.

시리즈의 마지막 편에서 만나요. 👋





댓글 남기기