JavaScript 모듈화 여정: 레거시 코드를 36개 모듈로
단일 파일 JavaScript 코드를 현대적인 모듈 시스템으로 리팩토링한 실제 경험담입니다.
여러분도 이런 고민 있으신가요?
“이 파일… 도대체 몇 줄이야?”
스크롤을 내리고 또 내려도 끝이 보이지 않는 JavaScript 파일. 어디를 고쳐야 할지 찾는 데만 10분, 수정하는 데 2분. 게다가 한 부분을 고치면 엉뚱한 곳에서 버그가 터져 나옵니다.
저도 똑같은 문제를 겪었습니다.
// main.js
(function() {
// 테마 관련 코드 (100줄)
const themeToggle = ...
// 네비게이션 관련 코드 (150줄)
const navToggle = ...
// 목차 관련 코드 (200줄)
const tocGenerate = ...
// ... 1200줄 더 ...
})();
한 파일 안에 모든 기능이 뒤섞여 있었습니다.
리팩토링의 시작: 문제 정의
실제로 겪었던 고통들
- 디버깅 지옥
"이 변수가 어디서 변경되는 거지?" → Ctrl+F로 검색 → 15군데 발견 → 하나씩 확인하다 30분 소요... - 기능 추가의 공포
"새 기능 추가하려면..." → 어디에 넣어야 하지? → 기존 코드와 충돌나면 어쩌지? → 결국 맨 아래에 추가... - 팀 협업 불가능
"아, 내가 고치려던 부분을 동료도 고쳤네..." → Git Conflict 발생 → 누구 코드를 살려야 할까?
측정 가능한 지표들
리팩토링 전 상태를 객관적으로 측정했습니다.
| 지표 | Before | 문제점 |
|---|---|---|
| 파일 크기 | 2개 파일, 2070줄 | 한 파일이 너무 큼 |
| 번들 크기 | ~150KB | 초기 로딩 느림 |
| 결합도 | 높음 | 하나 고치면 다 깨짐 |
| 응집도 | 낮음 | 관련 없는 코드가 뒤섞임 |
| 테스트 | 불가능 | 독립적 테스트 안됨 |
해결 과정: 단계별 모듈화
Step 1: 기능 분석 및 그룹핑
첫 번째로 한 일은 “이 코드가 뭘 하는 거지?” 파악하기였습니다.
main.js를 읽으며 기능을 그룹으로 나눴습니다.
┌─────────────────────────────────────┐
│ main.js (1650 lines) │
├─────────────────────────────────────┤
│ │
│ ┌───────────────────────────┐ │
│ │ Core (핵심 기능) │ │
│ │ - 설정 관리 │ │
│ │ - 이벤트 통신 │ │
│ │ - 유틸리티 │ │
│ └───────────────────────────┘ │
│ │
│ ┌───────────────────────────┐ │
│ │ UI/UX (사용자 인터랙션) │ │
│ │ - 테마 토글 │ │
│ │ - 네비게이션 │ │
│ │ - 스크롤 헤더 │ │
│ └───────────────────────────┘ │
│ │
│ ┌───────────────────────────┐ │
│ │ Content (컨텐츠 처리) │ │
│ │ - 목차 생성 │ │
│ │ - 코드 블록 │ │
│ │ - 반응형 테이블 │ │
│ └───────────────────────────┘ │
│ │
└─────────────────────────────────────┘
깨달음: 사실 기능들은 이미 그룹이 있었습니다. 단지 한 파일에 뒤섞여 있었을 뿐이죠.
Step 2: 모듈 추출 전략
각 기능을 독립적인 모듈로 만들기로 결정했습니다. 하지만 어떻게?
원칙을 세웠습니다:
- 단일 책임 원칙 (SRP)
하나의 모듈 = 하나의 기능 ❌ navigation.js가 테마도 관리 ✅ navigation.js는 네비게이션만 - 의존성 최소화
❌ theme.js가 navigation.js를 직접 import ✅ event-bus를 통해 간접 통신 - 독립적 테스트 가능
각 모듈을 다른 모듈 없이도 테스트 가능
Step 3: 실제 모듈 분리 작업
예시: Theme 기능을 모듈로 분리하기
Before (main.js 안에 섞여있던 코드):
// main.js
(function() {
// ... 다른 코드 500줄 ...
// 테마 관련 (여기저기 흩어짐)
const themeToggle = document.getElementById('themeToggle');
const currentTheme = localStorage.getItem('theme') || 'light';
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
updateIcon(theme);
// 다른 부분과 결합된 로직들...
}
themeToggle.addEventListener('click', () => {
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
applyTheme(newTheme);
});
// ... 다른 코드 1000줄 ...
})();
문제점:
- 테마 관련 코드가 여기저기 흩어져 있음
- 다른 기능들과 뒤섞여 있음
- 재사용 불가능
- 테스트 불가능
After (독립된 modules/theme.js):
// modules/theme.js - 단 132줄, 명확한 책임
import { emit } from '../core/event-bus.js';
import { storage } from '../core/utils.js';
// 상태 (클로저로 보호)
let state = {
themeToggle: null,
themeIcon: null,
currentTheme: null,
initialized: false
};
// 테마 적용
const applyTheme = (theme) => {
document.documentElement.setAttribute('data-theme', theme);
state.currentTheme = theme;
storage.set('theme', theme);
emit('theme:changed', { theme }); // 다른 모듈에 알림
return theme;
};
// Public API
export const init = () => { /* ... */ };
export const toggle = () => { /* ... */ };
export const getCurrent = () => state.currentTheme;
개선 효과:
- ✅ 테마 관련 코드가 한 곳에 모임
- ✅ 명확한 Public API (init, toggle, getCurrent)
- ✅ 독립적으로 재사용 가능
- ✅ 독립적으로 테스트 가능
Step 4: 디렉토리 구조 설계
모듈이 많아지면서 구조가 필요했습니다.
assets/js/
├── core/ ← 핵심 인프라 (5개)
│ ├── config.js → 전역 설정
│ ├── event-bus.js → 모듈 간 통신
│ ├── utils.js → 공통 유틸리티
│ ├── module-loader.js → 동적 로딩
│ └── performance.js → 성능 측정
│
├── modules/ ← 기능 모듈 (20개)
│ ├── theme.js
│ ├── navigation.js
│ ├── toc.js
│ ├── code-blocks.js
│ └── ... (16개 더)
│
├── ui/ ← UI 컴포넌트 (4개)
│ ├── modal.js
│ ├── carousel.js
│ ├── animations.js
│ └── hero-canvas.js
│
├── dist/ ← 빌드 결과물
│ ├── bundle.js (41KB - 전체)
│ └── core.js (8.7KB - 필수만)
│
└── app.js ← 통합 진입점
설계 원칙:
- core/: “다른 프로젝트에서도 쓸 수 있을까?”
- modules/: “특정 기능을 담당하는가?”
- ui/: “재사용 가능한 UI 컴포넌트인가?”
핵심 패턴: Event Bus로 모듈 간 통신
가장 중요한 질문: “모듈들이 어떻게 서로 통신하지?”
처음 생각한 방법 (실패)
// ❌ 직접 import (결합도 높음)
// theme.js
import navigation from './navigation.js';
import header from './scroll-header.js';
import canvas from '../ui/hero-canvas.js';
export const toggle = () => {
// 테마 변경
const newTheme = ...;
// 다른 모듈들에게 직접 알림
navigation.updateTheme(newTheme);
header.updateTheme(newTheme);
canvas.updateTheme(newTheme);
};
문제점:
- theme.js가 다른 모듈을 알아야 함 (결합도 ↑)
- 새 모듈 추가 시 theme.js 수정 필요
- 순환 의존성 위험
해결책: Event Bus (Pub/Sub 패턴)
// event-bus.js - 중앙 메시지 허브
let eventHandlers = {};
export const on = (event, callback) => {
if (!eventHandlers[event]) {
eventHandlers[event] = [];
}
eventHandlers[event].push(callback);
// 구독 해제 함수 반환
return () => off(event, callback);
};
export const emit = (event, data) => {
if (!eventHandlers[event]) return;
eventHandlers[event].forEach(cb => cb(data));
};
사용 예시:
// theme.js - 이벤트 발행만
export const toggle = () => {
const newTheme = ...;
emit('theme:changed', { theme: newTheme }); // 알림만!
};
// navigation.js - 관심 있으면 구독
on('theme:changed', ({ theme }) => {
updateNavigationStyle(theme);
});
// hero-canvas.js - 관심 있으면 구독
on('theme:changed', ({ theme }) => {
updateCanvasColors(theme);
});
시각화:
theme.js event-bus 다른 모듈들
│ │ │
│ emit('theme:changed') │ │
├───────────────────────────>│ │
│ │ │
│ │ callback({ theme }) │
│ ├─────────────────────────>│ navigation.js
│ │ │
│ │ callback({ theme }) │
│ ├─────────────────────────>│ canvas.js
│ │ │
│ │ callback({ theme }) │
│ ├─────────────────────────>│ header.js
효과:
- ✅ theme.js는 다른 모듈을 몰라도 됨
- ✅ 새 모듈 추가 시 theme.js 수정 불필요
- ✅ 느슨한 결합 (Loose Coupling)
성능 최적화: 번들 크기 73% 감소
문제: “모듈이 많으면 더 느려지지 않나요?”
좋은 질문입니다! 저도 처음엔 그렇게 생각했습니다.
Before (단일 파일):
main.js: 150KB (모든 기능 포함)
→ 사용자가 읽기만 해도 Canvas 애니메이션 로드
→ 사용자가 검색 안 해도 Search 기능 로드
핵심 깨달음: “사용자가 실제로 필요한 기능만 로드하면 되잖아?”
해결책 1: Code Splitting (번들 분리)
Rollup으로 두 가지 번들 생성:
// rollup.config.js
export default [
{
input: 'assets/js/app.js',
output: {
file: 'assets/js/dist/bundle.js', // 41KB - 전체 기능
format: 'iife'
}
},
{
input: 'assets/js/core/index.js',
output: {
file: 'assets/js/dist/core.js', // 8.7KB - 필수만
format: 'iife'
}
}
];
사용 시나리오:
<!-- 일반 페이지: 필수 기능만 -->
<script src="/assets/js/dist/core.js"></script>
<!-- 8.7KB만 로드! -->
<!-- 아티클 페이지: 전체 기능 -->
<script src="/assets/js/dist/bundle.js"></script>
<!-- 41KB 로드 (그래도 150KB → 41KB로 73% 감소) -->
해결책 2: Lazy Loading (지연 로딩)
// app.js
export const init = () => {
// 1. 필수 기능은 즉시
theme.init();
navigation.init();
// 2. 조건부 기능은 확인 후
if (document.getElementById('heroCanvas')) {
heroCanvas.init(); // Canvas가 있을 때만!
}
// 3. 비필수 기능은 Idle 시
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
analytics.init(); // CPU 여유 있을 때
});
}
};
시각화:
페이지 로드
│
├─ [즉시] core.js (8.7KB)
│ ├─ config
│ ├─ event-bus
│ └─ utils
│
├─ [확인 후] 조건부 모듈
│ ├─ Canvas 있음? → hero-canvas.js
│ ├─ 아티클? → toc.js, code-blocks.js
│ └─ 검색창 있음? → search.js
│
└─ [Idle 시] 비필수
└─ analytics.js
해결책 3: Tree Shaking (사용 안 하는 코드 제거)
// utils.js에 20개 함수가 있지만...
export const debounce = ...
export const throttle = ...
export const isMobile = ...
// ... 17개 더
// theme.js에서 2개만 import
import { debounce, storage } from './utils.js';
// 빌드 시 18개 함수는 번들에서 제거됨!
측정 가능한 결과:
| 지표 | Before | After | 개선율 |
|---|---|---|---|
| 메인 번들 | ~150KB | 41KB | 73% ↓ |
| 코어 번들 | N/A | 8.7KB | - |
| FCP | 1.2s | 0.8s | 33% ↑ |
| TTI | 2.5s | 1.5s | 40% ↑ |
빌드 시스템: Rollup 설정
“빌드 시스템이 왜 필요한가요?”
모듈 36개를 HTML에서 하나씩 로드하면:
<!-- ❌ 이렇게 할 순 없죠 -->
<script src="/assets/js/core/config.js"></script>
<script src="/assets/js/core/event-bus.js"></script>
<script src="/assets/js/core/utils.js"></script>
<!-- ... 33개 더 ... -->
문제점:
- 36번의 HTTP 요청
- 순서 문제 (의존성)
- 브라우저 호환성
해결책: 번들러
npm install --save-dev rollup @rollup/plugin-node-resolve @rollup/plugin-terser
rollup.config.js:
import resolve from '@rollup/plugin-node-resolve';
import terser from '@rollup/plugin-terser';
const isDev = process.env.NODE_ENV !== 'production';
export default [
{
input: 'assets/js/app.js',
output: {
file: 'assets/js/dist/bundle.js',
format: 'iife',
name: 'deepApp',
sourcemap: isDev // 개발 시 디버깅 용이
},
plugins: [
resolve(),
!isDev && terser({ // 프로덕션: 압축
compress: {
drop_console: true // console.log 제거
}
})
]
}
];
package.json:
{
"type": "module",
"scripts": {
"dev": "rollup -c -w", // 개발 모드 (Watch)
"build": "NODE_ENV=production rollup -c", // 프로덕션 빌드
"start": "npm run dev & jekyll serve" // Jekyll + Watch
}
}
워크플로우:
개발 시:
npm run dev
│
├─ 파일 변경 감지 (Watch)
├─ 자동 빌드
├─ Sourcemap 생성 (디버깅 편함)
└─ 브라우저 리프레시
프로덕션:
npm run build
│
├─ 전체 번들 생성
├─ 코드 압축 (Minify)
├─ Tree Shaking
├─ console.log 제거
└─ dist/ 폴더에 최종 파일
Before/After: 무엇이 달라졌나?
코드 품질
| 측면 | Before | After | 체감 효과 |
|---|---|---|---|
| 파일 크기 | 2개, 2070줄 | 36개, 평균 80줄 | “이제 한 파일을 다 읽을 수 있어요!” |
| 버그 찾기 | 30분 | 5분 | “문제가 어디 모듈인지 바로 알아요” |
| 기능 추가 | 2시간 | 30분 | “새 모듈 만들면 끝!” |
| 테스트 | 불가능 | 가능 | “각 모듈 독립적으로 테스트” |
| 협업 | 충돌 빈번 | 충돌 거의 없음 | “각자 다른 모듈 작업” |
성능
체감 속도:
Before: "페이지가 좀 느린 것 같은데..."
After: "엄청 빨라졌어요!"
측정 결과:
- First Contentful Paint: 1.2s → 0.8s
- Time to Interactive: 2.5s → 1.5s
- Bundle Size: 150KB → 41KB (일반) / 8.7KB (핵심)
개발 경험
Before:
// "이 함수가 어디 있더라...?"
// Ctrl+F 검색 → 5분 소요
// "이거 고치면 다른 곳이 깨지지 않을까...?"
// 불안하게 수정 → 테스트 → 버그 발견 → 다시 수정
After:
// "Theme 관련이니까 modules/theme.js 보면 되겠네!"
// 바로 찾음 → 30초
// "이 모듈만 수정하면 되니까 안심!"
// 자신있게 수정 → Event Bus로 통신 → 다른 모듈은 안전
함정과 해결책
함정 1: 과도한 모듈화
실수:
components/
├── button/
│ ├── button.js
│ ├── button-primary.js
│ ├── button-secondary.js
│ ├── button-large.js
│ └── button-small.js
└── ... 너무 많음
교훈: “합칠 수 있으면 합치세요”
// button.js - 하나로 충분
export const createButton = (type, size) => { ... };
함정 2: 순환 의존성
실수:
// theme.js
import { updateNav } from './navigation.js';
// navigation.js
import { getCurrentTheme } from './theme.js';
// ❌ 순환 참조!
해결:
// Event Bus 사용
// theme.js
emit('theme:changed', { theme });
// navigation.js
on('theme:changed', ({ theme }) => { ... });
함정 3: 글로벌 상태 남용
실수:
// config.js
export let globalState = { ... }; // Mutable!
// 다른 모듈에서
globalState.foo = 'bar'; // 직접 변경 (위험!)
해결 (함수형 접근):
// config.js
let state = Object.freeze({ ... }); // Immutable
export const set = (key, value) => {
state = Object.freeze({ ...state, [key]: value });
};
체크리스트: 여러분의 프로젝트에 적용하기
🎯 Phase 1: 분석 (1일)
- 현재 코드 줄 수 측정
- 기능 목록 작성
- 기능별 그룹핑 (Core, Modules, UI)
- 의존성 관계 파악
🔨 Phase 2: 모듈 분리 (3-5일)
- Core 모듈 먼저 추출 (config, utils)
- 독립적인 기능부터 모듈화
- Event Bus 구현
- 각 모듈에 명확한 Public API
🚀 Phase 3: 빌드 시스템 (1일)
- Rollup/Webpack 설정
- 개발/프로덕션 스크립트 작성
- Tree Shaking 확인
- Bundle 크기 측정
✅ Phase 4: 검증 (1-2일)
- 모든 기능 동작 확인
- 성능 측정 (FCP, TTI)
- 브라우저 호환성 테스트
- 문서화 작성
🎓 Phase 5: 학습 (지속)
- 팀원들에게 구조 설명
- 새 모듈 추가 가이드 작성
- 코딩 컨벤션 정립
다음 단계
이 모듈화 작업은 시작일 뿐입니다. 다음으로 고려할 사항들:
1. TypeScript 도입
// 타입 안정성 확보
interface Module {
init(): void;
destroy?(): void;
}
interface Config {
DEBUG: boolean;
MOBILE_BREAKPOINT: number;
}
2. 테스트 추가
// Vitest로 각 모듈 테스트
describe('Theme Module', () => {
it('should toggle theme', () => {
theme.toggle();
expect(theme.getCurrent()).toBe('dark');
});
});
3. 모니터링
// 실제 사용자 성능 측정
import { initPerformanceMonitoring } from './core/performance.js';
initPerformanceMonitoring({
onFCP: (time) => analytics.track('FCP', time),
onTTI: (time) => analytics.track('TTI', time)
});
마무리하며
가장 큰 교훈
“큰 문제는 작은 조각으로 나누면 해결할 수 있다”
거대한 파일이 처음엔 압도적이었지만, 하나씩 기능을 분리하다 보니 36개의 작고 관리하기 쉬운 모듈이 되었습니다.
실제로 느낀 변화
- 개발 속도: 기능 추가가 2시간 → 30분
- 버그 수정: 원인 찾기 30분 → 5분
- 자신감: “이거 고치면 뭐가 깨질까…” → “이 모듈만 수정하면 돼!”
- 팀 협업: Git Conflict 빈번 → 거의 없음
여러분도 할 수 있습니다
이 가이드가 여러분의 레거시 코드 리팩토링에 도움이 되길 바랍니다.
기억하세요:
- 한 번에 하나씩
- 작게 시작하기
- 측정 가능한 목표 설정
Happy Refactoring! 🚀
작성일: 2025-10-20 프로젝트: deep JavaScript Refactoring 결과: 36 modules, 73% bundle reduction, 40% TTI improvement
댓글