크롬 확장 공유 모듈 설계: 중복 코드 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줄
}
}
문제점:
- 4배의 코드량: 같은 로직이 4번 작성됨
- 버그 수정 4배: 버그 발견 시 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/hide는hiddenCSS 클래스 토글 방식 (일관성)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" 스크립트는 몇 가지 제약이 있습니다:
- strict mode 자동 적용 - 기존 코드와 호환성 문제
- CORS 정책 - 로컬 파일 로드 시 복잡
- 빌드 도구 필요 - 번들링 없이 바로 실행 어려움
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: 다음 조건을 만족하면 새 모듈로 추출:
- 2곳 이상에서 사용 - 재사용 가치
- 20줄 이상 - 추출할 만한 분량
- 독립적 기능 - 다른 코드와 약결합
- 테스트 가능 - 입력/출력이 명확
10. 참고 자료
- Chrome Extension Architecture
- JavaScript Module Pattern
- OWASP XSS Prevention Cheat Sheet
- MDN: createElementNS