크롬 확장 공유 모듈 설계: 중복 코드 450줄 제거기

popup, options, manager, onboarding 4개 페이지에 복사된 동일한 코드를 발견했습니다. shared/ 폴더에 기능별 모듈로 추출하여 총 215줄을 줄이고, DOM 메서드로 XSS를 방지하는 아이콘 팩토리까지 구현한 과정을 공유합니다.

1. 문제 상황

증상: 사방에 복사된 동일한 코드

크롬 확장 프로젝트가 성장하면서 4개의 페이지(popup, options, manager, onboarding)가 생겼습니다. 각 페이지마다 비슷한 기능이 필요했고, 자연스럽게 복사-붙여넣기가 반복되었습니다.

extension/
  popup/popup.js       ← showToast(), hide(), show(), sendMessage()...
  options/options.js   ← showToast(), hide(), show(), sendMessage()...
  manager/manager.js   ← showToast(), hide(), show(), sendMessage()...
  onboarding/onboarding.js ← showToast(), hide(), show(), sendMessage()...

중복 코드의 실체

각 파일에서 발견된 동일한 코드들:

// popup.js, options.js, manager.js, onboarding.js 모두에 존재
function show(el) {
  if (el) el.classList.remove('hidden');
}

function hide(el) {
  if (el) el.classList.add('hidden');
}

function showToast(message, type = 'success', duration = 2500) {
  const container = document.getElementById('toastContainer');
  if (!container) return;

  const toast = document.createElement('div');
  toast.className = `toast toast-${type}`;
  // ... 20줄 더
}

async function sendMessage(payload) {
  try {
    const response = await chrome.runtime.sendMessage(payload);
    // ... 에러 처리 10줄
  } catch (e) {
    // ... 에러 처리 5줄
  }
}

문제점:

  1. 4배의 코드량: 같은 로직이 4번 작성됨
  2. 버그 수정 4배: 버그 발견 시 4곳 모두 수정 필요
  3. 불일치 위험: 한 곳만 수정하면 나머지는 구버전 유지
  4. 리뷰 부담: 같은 코드를 4번 읽어야 함

수치로 보는 문제

리팩토링 전 상태:

파일 중복 코드 (줄)
popup.js ~120줄
options.js ~120줄
manager.js ~150줄
onboarding.js ~80줄
합계 ~470줄

2. 해결 전략: shared/ 폴더 설계

핵심 아이디어

중복되는 코드를 기능별로 분류하여 shared/ 폴더에 모듈로 추출합니다.

extension/
  shared/              # ← 새로 생성
    utils.js           # DOM 유틸리티
    api.js             # 백그라운드 통신
    toast.js           # 토스트 알림
    icons.js           # SVG 아이콘 팩토리
    search.js          # 검색/필터 유틸
    theme.js           # 테마 관리
  popup/
    popup.js           # 중복 코드 삭제, shared 모듈 사용
  options/
  manager/
  onboarding/

모듈 분류 기준

카테고리 모듈 포함 함수
DOM 조작 utils.js show, hide, clear, debounce, truncateUrl
통신 api.js sendMessage, formatPinErrorMessage
UI 컴포넌트 toast.js showToast, createToastIcon
UI 컴포넌트 icons.js Icons.create, Icons.exists
데이터 처리 search.js filterItems, sortItems, highlightMatches
설정 theme.js initTheme, toggleTheme

3. 모듈별 구현

3.1 utils.js - DOM 유틸리티

가장 기본적이고 자주 사용되는 함수들:

/**
 * Shared DOM utility functions
 * Used by: popup.js, options.js, manager.js, onboarding.js
 */

/**
 * Show an element by removing 'hidden' class
 * @param {HTMLElement} el - Element to show
 */
function show(el) {
  if (el) el.classList.remove('hidden');
}

/**
 * Hide an element by adding 'hidden' class
 * @param {HTMLElement} el - Element to hide
 */
function hide(el) {
  if (el) el.classList.add('hidden');
}

/**
 * Clear all child elements from a container
 * @param {HTMLElement} el - Container element to clear
 */
function clear(el) {
  while (el && el.firstChild) el.removeChild(el.firstChild);
}

/**
 * Create a debounced version of a function
 */
function createDebounce() {
  let timer = null;
  return function debounce(fn, delay) {
    return function (...args) {
      clearTimeout(timer);
      timer = setTimeout(() => fn.apply(this, args), delay);
    };
  };
}

// Create a single debounce instance for the page
const debounce = createDebounce();

/**
 * Truncate a URL for display
 * @param {string} url - URL to truncate
 * @param {number} maxLength - Maximum length (default: 40)
 */
function truncateUrl(url, maxLength = 40) {
  try {
    const u = new URL(url);
    let display = u.hostname + u.pathname;
    if (display.length > maxLength) {
      display = display.substring(0, maxLength) + '...';
    }
    return display;
  } catch {
    return url.length > maxLength ? url.substring(0, maxLength) + '...' : url;
  }
}

설계 포인트:

  • show/hidehidden CSS 클래스 토글 방식 (일관성)
  • clear는 DOM 메서드 사용 (XSS 방지)
  • debounce는 팩토리 패턴으로 페이지별 독립 인스턴스

3.2 api.js - 백그라운드 통신

/**
 * Shared API communication functions
 */

/* global chrome, showToast, i18n */

/**
 * Send a message to the background script
 * @param {Object} payload - Message payload
 * @param {Object} options - Options
 * @param {boolean} options.showError - Whether to show toast on error (default: true)
 */
async function sendMessage(payload, options = {}) {
  const { showError = true } = options;
  try {
    const response = await chrome.runtime.sendMessage(payload);
    if (response === undefined || response === null) {
      console.warn('[App] Empty response for message:', payload.type);
      return { ok: false, reason: 'empty-response' };
    }
    return response;
  } catch (e) {
    console.error('[App] Message send failed:', e);
    if (showError && typeof showToast === 'function') {
      const msg = typeof i18n !== 'undefined' && i18n.t
        ? i18n.t('toastError')
        : 'Connection error. Please try again.';
      showToast(msg, 'error');
    }
    return { ok: false, reason: 'connection-error' };
  }
}

/**
 * Format PIN error message for display
 * @param {Object} res - Response from PIN operation
 * @returns {string} Formatted error message
 */
function formatPinErrorMessage(res) {
  const useI18n = typeof i18n !== 'undefined' && i18n.t;

  if (res?.reason === 'rate-limited') {
    const seconds = Math.ceil((res.remainingMs || 30000) / 1000);
    if (useI18n) {
      return i18n.t('pinErrorRateLimited').replace('$1', seconds);
    }
    return `Too many attempts. Try again in ${seconds}s.`;
  }

  const remaining = res?.maxAttempts
    ? res.maxAttempts - (res?.attempts || 0)
    : null;

  if (remaining !== null) {
    if (useI18n) {
      return i18n.t('pinErrorAttemptsRemaining').replace('$1', remaining);
    }
    return `Wrong PIN. ${remaining} attempts remaining.`;
  }

  return useI18n ? i18n.t('pinErrorWrong') : 'Wrong PIN';
}

설계 포인트:

  • showError 옵션으로 토스트 표시 제어 (사일런트 모드 지원)
  • i18n 존재 여부 체크로 다국어 지원
  • 에러 객체를 구조화된 응답으로 반환 ({ ok, reason })

3.3 toast.js - 토스트 알림 컴포넌트

/**
 * Shared Toast notification component
 */

/**
 * Create an SVG icon element for toast
 * @param {string} type - Icon type: 'success' | 'error' | 'info'
 */
function createToastIcon(type) {
  const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
  svg.setAttribute('viewBox', '0 0 24 24');
  svg.setAttribute('fill', 'none');
  svg.setAttribute('stroke', 'currentColor');
  svg.setAttribute('stroke-width', '2');
  svg.setAttribute('stroke-linecap', 'round');
  svg.setAttribute('stroke-linejoin', 'round');

  if (type === 'success') {
    // 체크마크 아이콘
    const polyline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
    polyline.setAttribute('points', '20 6 9 17 4 12');
    svg.appendChild(polyline);
  } else {
    // X 아이콘 (error, info)
    const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
    circle.setAttribute('cx', '12');
    circle.setAttribute('cy', '12');
    circle.setAttribute('r', '10');
    svg.appendChild(circle);

    const line1 = document.createElementNS('http://www.w3.org/2000/svg', 'line');
    line1.setAttribute('x1', '15');
    line1.setAttribute('y1', '9');
    line1.setAttribute('x2', '9');
    line1.setAttribute('y2', '15');
    svg.appendChild(line1);

    const line2 = document.createElementNS('http://www.w3.org/2000/svg', 'line');
    line2.setAttribute('x1', '9');
    line2.setAttribute('y1', '9');
    line2.setAttribute('x2', '15');
    line2.setAttribute('y2', '15');
    svg.appendChild(line2);
  }

  return svg;
}

/**
 * Show a toast notification
 * @param {string} message - Message to display
 * @param {string} type - Toast type: 'success' | 'error' (default: 'success')
 * @param {number} duration - Duration in ms (default: 2500)
 */
function showToast(message, type = 'success', duration = 2500) {
  const container = document.getElementById('toastContainer');
  if (!container) return;

  const toast = document.createElement('div');
  toast.className = `toast toast-${type}`;

  toast.appendChild(createToastIcon(type));

  const span = document.createElement('span');
  span.textContent = message;  // ← textContent로 XSS 방지
  toast.appendChild(span);

  container.appendChild(toast);

  // 자동 제거
  setTimeout(() => {
    toast.style.opacity = '0';
    toast.style.transform = 'translateY(10px)';
    setTimeout(() => toast.remove(), 200);
  }, duration);
}

설계 포인트:

  • DOM 메서드로 SVG 생성 (XSS 방지)
  • textContent로 메시지 삽입 (HTML 주입 방지)
  • CSS 트랜지션 활용한 부드러운 페이드아웃

3.4 icons.js - SVG 아이콘 팩토리

가장 중요한 보안 고려가 들어간 모듈:

/**
 * Shared SVG Icon Factory
 * Creates SVG icons safely using DOM methods (avoids unsafe patterns for XSS prevention)
 */

const Icons = (function () {
  'use strict';

  // Icon path definitions
  const iconDefs = {
    check: [{ type: 'polyline', points: '20 6 9 17 4 12' }],
    x: [
      { type: 'line', x1: '18', y1: '6', x2: '6', y2: '18' },
      { type: 'line', x1: '6', y1: '6', x2: '18', y2: '18' },
    ],
    trash: [
      { type: 'polyline', points: '3 6 5 6 21 6' },
      { type: 'path', d: 'M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2' },
    ],
    lock: [
      { type: 'rect', x: '3', y: '11', width: '18', height: '11', rx: '2', ry: '2' },
      { type: 'path', d: 'M7 11V7a5 5 0 0 1 10 0v4' },
    ],
    search: [
      { type: 'circle', cx: '11', cy: '11', r: '8' },
      { type: 'line', x1: '21', y1: '21', x2: '16.65', y2: '16.65' },
    ],
    // ... 더 많은 아이콘 정의
  };

  /**
   * Create an SVG element from path data
   */
  function createSvgFromData(pathData, options = {}) {
    const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
    svg.setAttribute('viewBox', '0 0 24 24');
    svg.setAttribute('fill', 'none');
    svg.setAttribute('stroke', 'currentColor');
    svg.setAttribute('stroke-width', options.strokeWidth || '2');
    svg.setAttribute('stroke-linecap', 'round');
    svg.setAttribute('stroke-linejoin', 'round');

    if (options.width) svg.setAttribute('width', options.width);
    if (options.height) svg.setAttribute('height', options.height);
    if (options.className) svg.setAttribute('class', options.className);

    pathData.forEach(d => {
      let el;
      switch (d.type) {
        case 'path':
          el = document.createElementNS('http://www.w3.org/2000/svg', 'path');
          el.setAttribute('d', d.d);
          break;
        case 'line':
          el = document.createElementNS('http://www.w3.org/2000/svg', 'line');
          el.setAttribute('x1', d.x1);
          el.setAttribute('y1', d.y1);
          el.setAttribute('x2', d.x2);
          el.setAttribute('y2', d.y2);
          break;
        case 'circle':
          el = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
          el.setAttribute('cx', d.cx);
          el.setAttribute('cy', d.cy);
          el.setAttribute('r', d.r);
          break;
        case 'polyline':
          el = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
          el.setAttribute('points', d.points);
          break;
        case 'rect':
          el = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
          el.setAttribute('x', d.x);
          el.setAttribute('y', d.y);
          el.setAttribute('width', d.width);
          el.setAttribute('height', d.height);
          if (d.rx) el.setAttribute('rx', d.rx);
          if (d.ry) el.setAttribute('ry', d.ry);
          break;
      }
      if (el) svg.appendChild(el);
    });

    return svg;
  }

  /**
   * Create an icon by name
   * @param {string} name - Icon name (e.g., 'check', 'trash', 'lock')
   * @param {Object} options - Options like width, height, className
   */
  function create(name, options = {}) {
    const pathData = iconDefs[name];
    if (!pathData) {
      console.warn(`[Icons] Unknown icon: ${name}`);
      return null;
    }
    return createSvgFromData(pathData, options);
  }

  return {
    create,
    exists: (name) => !!iconDefs[name],
    list: () => Object.keys(iconDefs),
  };
})();

사용 예시:

// Before: 문자열 기반 (XSS 위험)
button.insertAdjacentHTML('beforeend', '<svg viewBox="0 0 24 24">...</svg>');

// After: Icons 팩토리 사용 (안전)
button.appendChild(Icons.create('trash', { width: '16', height: '16' }));

설계 포인트:

  • IIFE 패턴으로 네임스페이스 격리
  • 문자열 기반 HTML 삽입 완전 제거로 XSS 공격 벡터 차단
  • 데이터(iconDefs)와 로직(createSvgFromData) 분리

3.5 search.js - 검색/필터 유틸리티

/**
 * Shared Search/Filter Utilities
 */

const Search = (function () {
  'use strict';

  /**
   * Filter items by search query
   * Searches in specified fields (case-insensitive)
   */
  function filterItems(items, query, options = {}) {
    if (!query || !query.trim()) {
      return items;
    }

    const q = query.toLowerCase().trim();
    const fields = options.fields || ['title', 'url'];

    return items.filter(item => {
      return fields.some(field => {
        const value = item[field];
        return value && typeof value === 'string' && value.toLowerCase().includes(q);
      });
    });
  }

  /**
   * Sort items by field
   */
  function sortItems(items, field = 'createdAt', order = 'desc') {
    return [...items].sort((a, b) => {  // ← 원본 배열 보존
      let cmp = 0;

      if (field === 'createdAt' || field === 'date' || field === 'closedAt') {
        const aVal = a.createdAt || a.closedAt || 0;
        const bVal = b.createdAt || b.closedAt || 0;
        cmp = aVal - bVal;
      } else if (field === 'title') {
        const aVal = (a.title || '').toLowerCase();
        const bVal = (b.title || '').toLowerCase();
        cmp = aVal.localeCompare(bVal);
      } else {
        const aVal = a[field];
        const bVal = b[field];
        if (typeof aVal === 'number' && typeof bVal === 'number') {
          cmp = aVal - bVal;
        } else {
          cmp = String(aVal || '').localeCompare(String(bVal || ''));
        }
      }

      return order === 'desc' ? -cmp : cmp;
    });
  }

  /**
   * Highlight search matches in text
   * Returns HTML string - caller must sanitize before inserting
   */
  function highlightMatches(text, query) {
    if (!query || !text) {
      return text;
    }

    // 정규식 메타문자 이스케이프
    const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    const regex = new RegExp(`(${escapedQuery})`, 'gi');
    // Note: 반환된 HTML을 삽입할 때 textContent 대신 사용 시 주의
    return text.replace(regex, '<mark>$1</mark>');
  }

  /**
   * Create a search handler with debounce
   */
  function createSearchHandler(inputEl, clearBtn, onSearch, debounceMs = 150) {
    let timer = null;
    let currentQuery = '';

    function handleInput() {
      clearTimeout(timer);
      timer = setTimeout(() => {
        currentQuery = inputEl.value.trim();
        if (clearBtn) {
          clearBtn.classList.toggle('visible', currentQuery.length > 0);
        }
        onSearch(currentQuery);
      }, debounceMs);
    }

    function clear() {
      inputEl.value = '';
      currentQuery = '';
      if (clearBtn) {
        clearBtn.classList.remove('visible');
      }
      onSearch('');
    }

    if (inputEl) {
      inputEl.addEventListener('input', handleInput);
    }
    if (clearBtn) {
      clearBtn.addEventListener('click', clear);
    }

    return {
      getQuery: () => currentQuery,
      setQuery: (q) => { inputEl.value = q; currentQuery = q; },
      clear
    };
  }

  return {
    filterItems,
    sortItems,
    filterAndSort: (items, opts = {}) => {
      let result = items;
      if (opts.query) result = filterItems(result, opts.query, { fields: opts.fields });
      if (opts.sortField) result = sortItems(result, opts.sortField, opts.sortOrder || 'desc');
      return result;
    },
    highlightMatches,
    createSearchHandler,
  };
})();

설계 포인트:

  • sortItems[...items]로 원본 불변성 유지
  • highlightMatches에서 정규식 메타문자 이스케이프 (특수문자 검색 지원)
  • createSearchHandler로 검색 UI 로직 캡슐화

4. HTML에서 모듈 로드

로드 순서가 중요하다

공유 모듈 간에 의존성이 있으므로 로드 순서를 지켜야 합니다:

<!-- popup.html -->
<!DOCTYPE html>
<html>
<head>
  <link rel="stylesheet" href="../shared/styles.css">
  <link rel="stylesheet" href="popup.css">
</head>
<body>
  <!-- ... HTML ... -->

  <!-- 1. 의존성 없는 모듈 먼저 -->
  <script src="../shared/utils.js"></script>
  <script src="../shared/i18n.js"></script>

  <!-- 2. utils에 의존하는 모듈 -->
  <script src="../shared/toast.js"></script>
  <script src="../shared/icons.js"></script>

  <!-- 3. toast에 의존하는 모듈 -->
  <script src="../shared/api.js"></script>

  <!-- 4. 기타 모듈 -->
  <script src="../shared/search.js"></script>
  <script src="../shared/theme.js"></script>

  <!-- 5. 페이지 스크립트 (가장 마지막) -->
  <script src="popup.js"></script>
</body>
</html>

의존성 관계

utils.js       ← 의존성 없음
i18n.js        ← 의존성 없음
toast.js       ← 의존성 없음
icons.js       ← 의존성 없음
api.js         ← toast.js, i18n.js (선택적)
search.js      ← 의존성 없음
theme.js       ← 의존성 없음

popup.js       ← 모든 shared 모듈

5. Before/After 비교

Before: popup.js (리팩토링 전)

// popup.js - 약 350줄

// ==================== 중복 유틸리티 ====================
function show(el) { if (el) el.classList.remove('hidden'); }
function hide(el) { if (el) el.classList.add('hidden'); }
function clear(el) { while (el && el.firstChild) el.removeChild(el.firstChild); }

// ==================== 중복 토스트 ====================
function createToastIcon(type) {
  const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
  // ... 30줄
}

function showToast(message, type = 'success') {
  // ... 20줄
}

// ==================== 중복 API 통신 ====================
async function sendMessage(payload) {
  try {
    const response = await chrome.runtime.sendMessage(payload);
    // ... 15줄
  } catch (e) {
    // ... 10줄
  }
}

// ==================== 중복 에러 포맷팅 ====================
function formatPinErrorMessage(res) {
  // ... 25줄
}

// ==================== 실제 비즈니스 로직 ====================
// ... 나머지 200줄

After: popup.js (리팩토링 후)

// popup.js - 약 200줄

// 공유 모듈에서 가져온 함수들 사용
// show, hide, clear, debounce, truncateUrl  ← utils.js
// sendMessage, formatPinErrorMessage        ← api.js
// showToast                                 ← toast.js
// Icons.create                              ← icons.js
// Search.filterItems, Search.sortItems     ← search.js

(function() {
  'use strict';

  // ==================== DOM 요소 캐싱 ====================
  const els = {
    list: document.getElementById('list'),
    searchInput: document.getElementById('searchInput'),
    // ...
  };

  // ==================== 실제 비즈니스 로직만 ====================
  async function loadItems() {
    const res = await sendMessage({ type: 'listItems' });  // ← api.js
    if (!res.ok) {
      showToast('Failed to load', 'error');  // ← toast.js
      return;
    }
    renderItems(res.items);
  }

  function renderItems(items) {
    clear(els.list);  // ← utils.js

    items.forEach(item => {
      const li = document.createElement('li');
      li.appendChild(Icons.create('bookmark'));  // ← icons.js
      // ...
    });
  }

  // ... 나머지 비즈니스 로직
})();

코드량 변화

항목 Before After 변화
popup.js 350줄 200줄 -150줄
options.js 380줄 250줄 -130줄
manager.js 400줄 280줄 -120줄
onboarding.js 200줄 120줄 -80줄
shared/ (신규) 0줄 265줄 +265줄
총합 1330줄 1115줄 -215줄

순수 코드 감소: 215줄 (16% 감소)


6. 리팩토링 단계별 가이드

Step 1: 중복 코드 식별

# 파일 간 유사한 함수 찾기
grep -n "function show" extension/**/*.js
grep -n "function showToast" extension/**/*.js
grep -n "async function sendMessage" extension/**/*.js

Step 2: shared 폴더 생성

mkdir extension/shared

Step 3: 모듈 파일 생성 (최소 기능부터)

# 가장 단순한 것부터 시작
touch extension/shared/utils.js
touch extension/shared/toast.js
touch extension/shared/api.js

Step 4: 한 페이지씩 마이그레이션

// 1. popup.html에 스크립트 추가
// 2. popup.js에서 중복 함수 삭제
// 3. 테스트
// 4. 다음 페이지로 이동

Step 5: Git으로 히스토리 보존

# 한 번에 모든 변경 커밋하지 말고 단계별로
git add extension/shared/utils.js
git commit -m "refactor: extract DOM utilities to shared/utils.js"

git add extension/shared/api.js
git commit -m "refactor: extract API functions to shared/api.js"

# 각 페이지 마이그레이션도 개별 커밋
git add extension/popup/
git commit -m "refactor: migrate popup.js to use shared modules"

7. 핵심 설계 원칙

7.1 전역 함수 vs IIFE 모듈

// 방법 1: 전역 함수 (단순)
function show(el) { /* ... */ }
function hide(el) { /* ... */ }

// 방법 2: IIFE 모듈 (권장)
const Icons = (function() {
  'use strict';

  function create(name) { /* ... */ }
  function exists(name) { /* ... */ }

  return { create, exists };
})();

IIFE 권장 이유:

  • 내부 구현 숨김 (private 함수/변수)
  • 네임스페이스 충돌 방지
  • 명시적 API 노출

7.2 의존성 주입 vs 전역 참조

// Before: 전역 의존성
function formatPinErrorMessage(res) {
  if (i18n && i18n.t) {  // 전역 i18n 참조
    return i18n.t('error');
  }
  return 'Error';
}

// After: 존재 여부 체크 (더 안전)
function formatPinErrorMessage(res) {
  const useI18n = typeof i18n !== 'undefined' && i18n.t;  // ← 방어적 체크
  if (useI18n) {
    return i18n.t('error');
  }
  return 'Error';
}

7.3 XSS 방지 원칙

// 하지 말 것: 문자열 기반 HTML 삽입
// element.insertAdjacentHTML('beforeend', `<svg>${data}</svg>`);

// 안전한 방법: DOM API 사용
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
const text = document.createElement('span');
text.textContent = userInput;  // 자동 이스케이프

8. 베스트 프랙티스

DO (해야 할 것)

  • [x] 기능별로 모듈 분리 (utils, api, ui 등)
  • [x] IIFE 패턴으로 네임스페이스 격리
  • [x] DOM 메서드 사용 (문자열 기반 HTML 삽입 지양)
  • [x] 의존성 명시적 체크 (typeof x !== 'undefined')
  • [x] 단계별 커밋으로 히스토리 보존
  • [x] JSDoc 주석으로 API 문서화

DON'T (하지 말아야 할 것)

  • [ ] 모든 것을 한 파일에 (shared/everything.js)
  • [ ] 문자열 기반으로 SVG 생성
  • [ ] 순환 의존성 (A → B → A)
  • [ ] 한 번에 모든 파일 리팩토링 (점진적으로!)
  • [ ] 테스트 없이 배포

모듈 분리 체크리스트

질문 Yes이면
2개 이상의 파일에서 사용? 공유 모듈 후보
단독으로 테스트 가능? 분리 가능
다른 기능과 강하게 결합? 분리 보류
변경 빈도가 높은가? 별도 모듈 권장

9. FAQ

Q: ES Modules (import/export)를 안 쓰는 이유는?

A: 크롬 확장의 팝업/옵션 페이지는 일반적으로 ES Modules를 지원하지만, type="module" 스크립트는 몇 가지 제약이 있습니다:

  1. strict mode 자동 적용 - 기존 코드와 호환성 문제
  2. CORS 정책 - 로컬 파일 로드 시 복잡
  3. 빌드 도구 필요 - 번들링 없이 바로 실행 어려움

Vanilla JS 프로젝트에서는 전역 함수나 IIFE가 더 간단합니다.

Q: 모듈 간 의존성은 어떻게 관리하나요?

A: HTML에서 로드 순서로 관리합니다. 의존성 없는 모듈을 먼저 로드하고, 의존성 있는 모듈을 나중에 로드합니다. 복잡해지면 RequireJS나 번들러 도입을 고려하세요.

Q: TypeScript로 전환해야 하나요?

A: 프로젝트 규모가 커지면 권장합니다. 하지만 작은 크롬 확장이라면 JSDoc 타입 힌트만으로도 충분합니다:

/**
 * @param {string} name - Icon name
 * @param {{width?: string, height?: string}} options
 * @returns {SVGElement|null}
 */
function create(name, options = {}) { /* ... */ }

Q: 테스트는 어떻게 하나요?

A: 공유 모듈은 순수 JavaScript이므로 Jest로 단위 테스트 가능합니다:

// search.test.js
const { filterItems } = require('./search.js');

test('filterItems returns matching items', () => {
  const items = [
    { title: 'Hello World', url: 'https://example.com' },
    { title: 'Goodbye', url: 'https://test.com' },
  ];

  const result = filterItems(items, 'hello');
  expect(result).toHaveLength(1);
  expect(result[0].title).toBe('Hello World');
});

Q: 새 모듈 추가 기준은?

A: 다음 조건을 만족하면 새 모듈로 추출:

  1. 2곳 이상에서 사용 - 재사용 가치
  2. 20줄 이상 - 추출할 만한 분량
  3. 독립적 기능 - 다른 코드와 약결합
  4. 테스트 가능 - 입력/출력이 명확

10. 참고 자료


시리즈 목차

  1. JavaScript URL 비교와 정규화
  2. Web Crypto API로 안전한 해싱 구현하기
  3. CSS 변수와 다크 모드 구현하기
  4. 크롬 확장 프로젝트 구조 정리하기
  5. 크롬 확장 다국어(i18n) 구현하기
  6. 크롬 확장 공유 모듈 설계 ← 현재 글