JavaScript 모듈화 여정: 레거시 코드를 36개 모듈로

단일 파일 JavaScript 코드를 현대적인 모듈 시스템으로 리팩토링한 실제 경험담입니다.


여러분도 이런 고민 있으신가요?

“이 파일… 도대체 몇 줄이야?”

스크롤을 내리고 또 내려도 끝이 보이지 않는 JavaScript 파일. 어디를 고쳐야 할지 찾는 데만 10분, 수정하는 데 2분. 게다가 한 부분을 고치면 엉뚱한 곳에서 버그가 터져 나옵니다.

저도 똑같은 문제를 겪었습니다.

// main.js
(function() {
  // 테마 관련 코드 (100줄)
  const themeToggle = ...

  // 네비게이션 관련 코드 (150줄)
  const navToggle = ...

  // 목차 관련 코드 (200줄)
  const tocGenerate = ...

  // ... 1200줄 더 ...
})();

한 파일 안에 모든 기능이 뒤섞여 있었습니다.


리팩토링의 시작: 문제 정의

실제로 겪었던 고통들

  1. 디버깅 지옥
    "이 변수가 어디서 변경되는 거지?"
    → Ctrl+F로 검색 → 15군데 발견
    → 하나씩 확인하다 30분 소요...
    
  2. 기능 추가의 공포
    "새 기능 추가하려면..."
    → 어디에 넣어야 하지?
    → 기존 코드와 충돌나면 어쩌지?
    → 결국 맨 아래에 추가...
    
  3. 팀 협업 불가능
    "아, 내가 고치려던 부분을 동료도 고쳤네..."
    → Git Conflict 발생
    → 누구 코드를 살려야 할까?
    

측정 가능한 지표들

리팩토링 전 상태를 객관적으로 측정했습니다.

지표 Before 문제점
파일 크기 2개 파일, 2070줄 한 파일이 너무 큼
번들 크기 ~150KB 초기 로딩 느림
결합도 높음 하나 고치면 다 깨짐
응집도 낮음 관련 없는 코드가 뒤섞임
테스트 불가능 독립적 테스트 안됨

해결 과정: 단계별 모듈화

Step 1: 기능 분석 및 그룹핑

첫 번째로 한 일은 “이 코드가 뭘 하는 거지?” 파악하기였습니다.

main.js를 읽으며 기능을 그룹으로 나눴습니다.

┌─────────────────────────────────────┐
│      main.js (1650 lines)          │
├─────────────────────────────────────┤
│                                     │
│  ┌───────────────────────────┐    │
│  │  Core (핵심 기능)          │    │
│  │  - 설정 관리              │    │
│  │  - 이벤트 통신            │    │
│  │  - 유틸리티               │    │
│  └───────────────────────────┘    │
│                                     │
│  ┌───────────────────────────┐    │
│  │  UI/UX (사용자 인터랙션)  │    │
│  │  - 테마 토글              │    │
│  │  - 네비게이션             │    │
│  │  - 스크롤 헤더            │    │
│  └───────────────────────────┘    │
│                                     │
│  ┌───────────────────────────┐    │
│  │  Content (컨텐츠 처리)    │    │
│  │  - 목차 생성              │    │
│  │  - 코드 블록              │    │
│  │  - 반응형 테이블          │    │
│  └───────────────────────────┘    │
│                                     │
└─────────────────────────────────────┘

깨달음: 사실 기능들은 이미 그룹이 있었습니다. 단지 한 파일에 뒤섞여 있었을 뿐이죠.

Step 2: 모듈 추출 전략

각 기능을 독립적인 모듈로 만들기로 결정했습니다. 하지만 어떻게?

원칙을 세웠습니다:

  1. 단일 책임 원칙 (SRP)
    하나의 모듈 = 하나의 기능
    
    ❌ navigation.js가 테마도 관리
    ✅ navigation.js는 네비게이션만
    
  2. 의존성 최소화
    ❌ theme.js가 navigation.js를 직접 import
    ✅ event-bus를 통해 간접 통신
    
  3. 독립적 테스트 가능
    각 모듈을 다른 모듈 없이도 테스트 가능
    

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             ← 통합 진입점

설계 원칙:

  1. core/: “다른 프로젝트에서도 쓸 수 있을까?”
  2. modules/: “특정 기능을 담당하는가?”
  3. 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

댓글