본문으로 건너뛰기
Previous
Next
Node.js 모듈 시스템 | CommonJS와 ES Modules 완벽 가이드

Node.js 모듈 시스템 | CommonJS와 ES Modules 완벽 가이드

Node.js 모듈 시스템 | CommonJS와 ES Modules 완벽 가이드

이 글의 핵심

Node.js 모듈 시스템: CommonJS와 ES Modules CommonJS 모듈·ES Modules.

들어가며

모듈이란?

모듈은 재사용 가능한 코드 조각을 파일로 분리한 것입니다. Node.js는 두 가지 모듈 시스템을 지원합니다:

  1. CommonJS: Node.js 기본 방식 (require, module.exports)
  2. ES Modules: JavaScript 표준 방식 (import, export) 한 프로젝트 안에서 도구를 주머니마다 나눠 담듯 파일을 나누면, 이름이 겹치지 않고(네임스페이스), 테스트와 교체가 쉬워집니다. require('express')처럼 이름만 말하면 node_modules에서 꺼내 오는 것은, 공용 서랍에서 표준 부품을 가져오는 것과 비슷합니다. npm·package-lock.json의존성 해석·재현 설치의 중심입니다. 같은 축에서 보면 Python pip·uv·Poetry·Go 모듈·go.sum·Rust Cargo가 있고, C++는 CMakeConan·vcpkg 조합이 자주 쓰입니다. 빌드 철학 비교는 C++ 빌드 시스템 완전 비교를 참고하세요. 모듈의 장점:
  • 코드 재사용: 한 번 작성, 여러 곳에서 사용
  • 네임스페이스: 전역 스코프 오염 방지
  • 유지보수: 기능별로 파일 분리
  • 의존성 관리: 명확한 의존 관계
  • 테스트: 독립적인 단위 테스트 가능

실전 경험에서 배운 교훈

이 기술을 실무 프로젝트에 처음 도입했을 때, 공식 문서만으로는 알 수 없었던 많은 함정들이 있었습니다. 특히 프로덕션 환경에서 발생하는 엣지 케이스들은 로컬 개발 환경에서는 재현조차 되지 않았죠.

가장 어려웠던 점은 성능 최적화였습니다. 처음엔 “동작만 하면 되겠지”라고 생각했지만, 실제 사용자 트래픽이 몰리면서 병목 지점들이 하나씩 드러났습니다. 특히 데이터베이스 쿼리 최적화, 캐싱 전략, 에러 핸들링 구조 등은 여러 번의 장애를 겪으면서 개선해 나갔습니다.

이 글에서는 그런 시행착오를 통해 얻은 실전 노하우와, “이렇게 하면 안 된다”는 교훈들을 함께 정리했습니다. 특히 트러블슈팅 섹션은 실제 장애 대응 경험을 바탕으로 작성했으니, 비슷한 문제를 마주했을 때 참고하시면 도움이 될 것입니다.

1. CommonJS 모듈

require()의 내부 동작 메커니즘

Node.js 모듈 로딩 과정:

require('./math') 실행 시 내부 동작:

1. 모듈 경로 해석 (Module Resolution):
   
   require('./math') →
   
   a. 확장자 추가 시도:
      - ./math → 실패
      - ./math.js → 성공! (파일 존재)
      - ./math.json (시도 안 함, .js 먼저 찾음)
      - ./math.node (네이티브 모듈, 마지막 시도)
   
   b. 디렉토리인 경우:
      - ./math/package.json → "main" 필드 확인
      - ./math/index.js → 기본값
   
   require('express') (패키지명) →
   
   현재 디렉토리부터 상위로 node_modules 탐색:
   /home/user/project/app.js에서 require('express'):
   
   1. /home/user/project/node_modules/express
   2. /home/user/node_modules/express
   3. /home/node_modules/express
   4. /node_modules/express
   
   찾으면 중단, 없으면 "Cannot find module 'express'" 에러

2. 캐시 확인 (Module Cache):
   
   require.cache 객체에 이미 로드된 모듈 확인:
   
   require.cache['/home/user/project/math.js'] → 있음?
   → 캐시된 module.exports 즉시 반환 (재실행 안 함)
   
   없음?
   → 3단계로 진행

3. 파일 읽기 및 래핑:
   
   const content = fs.readFileSync('/home/user/project/math.js', 'utf8');
   
   Node.js가 모듈 코드를 함수로 감쌈:
   
   (function(exports, require, module, __filename, __dirname) {
       // 여기에 math.js 코드 삽입
       function add(a, b) {
           return a + b;
       }
       module.exports = { add };
   });
   
   5개 파라미터 자동 제공:
   - exports: module.exports 참조
   - require: 다른 모듈 로드 함수
   - module: 현재 모듈 객체
   - __filename: 현재 파일 절대 경로
   - __dirname: 현재 디렉토리 절대 경로

4. 코드 실행:
   
   const module = { exports: {} };
   const exports = module.exports;
   
   wrapper(exports, require, module, __filename, __dirname);
   
   → math.js 코드 실행
   → module.exports에 함수/변수 할당

5. 캐시 저장:
   
   require.cache['/home/user/project/math.js'] = module;
   
   다음 require('./math') 호출 시 즉시 반환

6. module.exports 반환:
   
   return module.exports;

require.cache의 역할:

// 캐시 확인
console.log(require.cache);
// {
//   '/home/user/project/math.js': Module {
//     id: '/home/user/project/math.js',
//     exports: { add: [Function], subtract: [Function] },
//     loaded: true,
//     children: [],
//     paths: [...]
//   }
// }

// 캐시 삭제 (재로드하고 싶을 때)
delete require.cache[require.resolve('./math')];

// 다음 require('./math') 호출 시 재실행됨

순환 참조 시 동작:

// a.js
console.log('a.js 시작');
exports.done = false;
const b = require('./b');
console.log('b.done:', b.done);
exports.done = true;
console.log('a.js 종료');

// b.js
console.log('b.js 시작');
exports.done = false;
const a = require('./a');  // 순환!
console.log('a.done:', a.done);
exports.done = true;
console.log('b.js 종료');

// main.js
const a = require('./a');

// 출력:
// a.js 시작
// b.js 시작
// a.done: false  ← a.js가 아직 완료 안 됨!
// b.js 종료
// b.done: true
// a.js 종료

// Node.js는 순환 참조 시:
// - 캐시에 미완성 module.exports 저장
// - 재귀 방지 (무한 루프 회피)
// - 부분 완성된 객체 반환 (done=false)

기본 사용법

내보내기 (module.exports):

// math.js
function add(a, b) {
    return a + b;
}
function subtract(a, b) {
    return a - b;
}
const PI = 3.14159;
// 방법 1: 객체로 내보내기
module.exports = {
    add,
    subtract,
    PI
};

가져오기 (require):

// app.js
const math = require('./math');

// 내부 동작:
// 1. './math' 경로 해석 → /absolute/path/to/math.js
// 2. require.cache 확인 → 없음
// 3. fs.readFileSync로 파일 읽기
// 4. 함수로 래핑
// 5. 코드 실행 → module.exports 설정
// 6. 캐시 저장
// 7. module.exports 반환

console.log(math.add(10, 5));       // 15
console.log(math.subtract(10, 5));  // 5
console.log(math.PI);               // 3.14159

exports vs module.exports

exportsmodule.exports의 차이를 정확히 이해하는 것이 중요합니다:

// ✅ exports 사용 (속성 추가)
// exports는 module.exports를 가리키는 참조 변수
// 속성을 추가하는 방식으로만 사용 가능
exports.add = (a, b) => a + b;
exports.subtract = (a, b) => a - b;
// 결과: { add: [Function], subtract: [Function] }
// ✅ module.exports 사용 (전체 교체)
// module.exports는 실제로 반환되는 객체
// 완전히 새로운 객체로 교체 가능
module.exports = {
    add: (a, b) => a + b,
    subtract: (a, b) => a - b
};
// 결과: { add: [Function], subtract: [Function] }
// ❌ 잘못된 사용
exports = {
    add: (a, b) => a + b  // 작동 안 함!
};
// exports 변수에 새 객체를 할당하면
// module.exports와의 참조가 끊어짐
// require()는 module.exports를 반환하므로 이 변경사항은 무시됨

올바른 이해: Node.js가 모듈을 로드할 때 내부적으로 어떻게 동작하는지 이해하면 혼란을 피할 수 있습니다:

// Node.js 내부 동작 (개념적 설명)
function require(modulePath) {
    // 1. 빈 module 객체 생성
    const module = { exports: {} };
    
    // 2. exports는 module.exports를 참조
    // 이것이 핵심! exports는 단순히 참조 변수
    const exports = module.exports;
    
    // 3. 모듈 코드를 함수로 감싸서 실행
    (function(module, exports) {
        // 여기서 실제 모듈 코드가 실행됨
        
        exports.add = ...;        // ✅ OK - module.exports에 속성 추가
        module.exports = ...;     // ✅ OK - module.exports 자체를 교체
        exports = ...;            // ❌ 안됨 - exports 참조만 끊김, module.exports는 그대로
    })(module, exports);
    
    // 4. 최종적으로 module.exports를 반환
    // exports가 아닌 module.exports를 반환!
    return module.exports;
}

핵심 규칙:

  • exports.xxx = ... → 속성 추가 (OK)
  • module.exports = ... → 전체 교체 (OK)
  • exports = ... → 참조만 끊김 (작동 안 함)

다양한 내보내기 패턴

패턴 1: 여러 함수 내보내기

// utils.js
exports.formatDate = (date) => {
    return date.toISOString().split('T')[0];
};
exports.capitalize = (str) => {
    return str.charAt(0).toUpperCase() + str.slice(1);
};
exports.randomInt = (min, max) => {
    return Math.floor(Math.random() * (max - min + 1)) + min;
};

패턴 2: 클래스 내보내기

// user.js
// 타입 정의
class User {
    constructor(name, email) {
        this.name = name;
        this.email = email;
    }
    
    greet() {
        return `안녕하세요, ${this.name}님!`;
    }
}
module.exports = User;
// app.js
const User = require('./user');
const user = new User('홍길동', '[email protected]');
console.log(user.greet());

패턴 3: 싱글톤 패턴

// database.js
class Database {
    constructor() {
        this.connection = null;
    }
    
    connect() {
        if (!this.connection) {
            this.connection = { connected: true };
            console.log('데이터베이스 연결됨');
        }
        return this.connection;
    }
}
// 싱글톤 인스턴스 내보내기
module.exports = new Database();
// app.js
const db = require('./database');
db.connect();  // 첫 연결
// 다른 파일에서도 같은 인스턴스
const db2 = require('./database');
db2.connect();  // 이미 연결됨 (같은 인스턴스)

패턴 4: 팩토리 함수

// logger.js
// 함수 정의 및 구현
function createLogger(prefix) {
    return {
        log: (message) => {
            console.log(`[${prefix}] ${message}`);
        },
        error: (message) => {
            console.error(`[${prefix}] ERROR: ${message}`);
        }
    };
}
module.exports = createLogger;
// app.js
const createLogger = require('./logger');
const appLogger = createLogger('APP');
const dbLogger = createLogger('DB');
appLogger.log('서버 시작');
dbLogger.log('데이터베이스 연결');

2. ES Modules

ES Modules 로딩 메커니즘

import의 내부 동작 (CommonJS와 다른 점):

import { add } from './math.mjs' 실행 시:

1. 정적 분석 단계 (Parse Time):
   
   코드 실행 전에 import/export 문만 스캔:
   
   import { add } from './math.mjs'  ← 최상위에만 가능
   export function subtract() {}     ← 최상위에만 가능
   
   → 의존성 그래프 구축
   → 순환 참조 감지
   → 트리 쉐이킹 준비 (사용 안 하는 export 제거)
   
   CommonJS는 런타임에 동적 로드:
   if (condition) {
       const math = require('./math');  // ✅ 가능
   }
   
   ES Modules는 정적 (컴파일 타임):
   if (condition) {
       import { add } from './math.mjs';  // ❌ 에러!
   }
   
   동적 로드는 import() 함수 사용:
   if (condition) {
       const { add } = await import('./math.mjs');  // ✅ 가능
   }

2. 모듈 그래프 구축:
   
   app.mjs:
     import './math.mjs'
     import './utils.mjs'
   
   math.mjs:
     import './constants.mjs'
   
   utils.mjs:
     import './helpers.mjs'
   
   의존성 그래프:
   app.mjs
     ├─ math.mjs
     │   └─ constants.mjs
     └─ utils.mjs
         └─ helpers.mjs
   
   → 깊이 우선 탐색 (DFS)로 로드 순서 결정
   → constants.mjs → math.mjs → helpers.mjs → utils.mjs → app.mjs

3. 모듈 인스턴스화 (Instantiation):
   
   각 모듈의 export를 메모리에 생성:
   
   math.mjs:
     export function add() {}
     export const PI = 3.14
   
   → Module Record 생성:
     {
       exports: {
         add: [uninitialized],    ← 아직 실행 안 됨
         PI: [uninitialized]
       }
     }
   
   Live Binding (핵심 특징!):
   - export된 값은 "참조"로 연결
   - 원본 변경 시 import 측에서도 즉시 반영

4. 모듈 실행 (Evaluation):
   
   의존성 순서대로 코드 실행:
   
   constants.mjs 실행 → export 값 초기화
   math.mjs 실행 → export 값 초기화
   helpers.mjs 실행 → export 값 초기화
   utils.mjs 실행 → export 값 초기화
   app.mjs 실행
   
   각 모듈은 딱 한 번만 실행 (캐시)

5. import 해석:
   
   import { add } from './math.mjs'
   
   → add는 math.mjs의 add를 참조
   → Live Binding이므로 math.mjs에서 add 변경 시 즉시 반영

CommonJS vs ES Modules 핵심 차이:

로딩 시점:

CommonJS (런타임):
const math = require('./math');
console.log(math.add(1, 2));

→ require가 실행될 때 파일 읽기
→ 조건문 안에서 동적 로드 가능
→ 느린 시작 시간

ES Modules (파싱 타임):
import { add } from './math.mjs';
console.log(add(1, 2));

→ 코드 실행 전에 모든 import 해석
→ 정적 분석 가능 (번들러, 트리 쉐이킹)
→ 빠른 최적화

바인딩 방식:

CommonJS (값 복사):
// math.js
let counter = 0;
exports.increment = () => ++counter;
exports.getCounter = () => counter;

// app.js
const math = require('./math');
console.log(math.getCounter());  // 0
math.increment();
console.log(math.getCounter());  // 1

ES Modules (Live Binding):
// math.mjs
export let counter = 0;
export function increment() { ++counter; }

// app.mjs
import { counter, increment } from './math.mjs';
console.log(counter);  // 0
increment();
console.log(counter);  // 1 ← 자동 업데이트!

모듈 캐싱:

CommonJS:
- require.cache 객체에 저장
- 수동 삭제 가능 (재로드)
- 순환 참조 시 미완성 exports 반환

ES Modules:
- 내부 Module Map에 저장
- 캐시 삭제 불가 (안정성)
- 순환 참조 시 Live Binding으로 해결

기본 사용법

내보내기 (module.exports):

// math.js
function add(a, b) {
    return a + b;
}
function subtract(a, b) {
    return a - b;
}
const PI = 3.14159;
// 방법 1: 객체로 내보내기
module.exports = {
    add,
    subtract,
    PI
};

가져오기 (require):

// app.js
const math = require('./math');

// 내부 동작:
// 1. './math' 경로 해석 → /absolute/path/to/math.js
// 2. require.cache 확인 → 없음
// 3. fs.readFileSync로 파일 읽기
// 4. 함수로 래핑:
//    (function(exports, require, module, __filename, __dirname) {
//        // math.js 코드
//    })
// 5. 코드 실행 → module.exports 설정
// 6. require.cache에 저장
// 7. module.exports 반환 (값 복사)

console.log(math.add(10, 5));       // 15
console.log(math.subtract(10, 5));  // 5
console.log(math.PI);               // 3.14159

Named Export

// math.mjs
export function add(a, b) {
    return a + b;
}
export function subtract(a, b) {
    return a - b;
}
export const PI = 3.14159;
// 또는 한 번에
function multiply(a, b) {
    return a * b;
}
function divide(a, b) {
    return a / b;
}
export { multiply, divide };

가져오기:

// app.mjs
import { add, subtract, PI } from './math.mjs';
console.log(add(10, 5));  // 15
// 이름 변경
import { add as plus } from './math.mjs';
console.log(plus(10, 5));  // 15
// 모두 가져오기
import * as math from './math.mjs';
console.log(math.add(10, 5));

Default Export

// calculator.mjs
export default class Calculator {
    add(a, b) {
        return a + b;
    }
    
    subtract(a, b) {
        return a - b;
    }
}
// Named + Default
export const VERSION = '1.0.0';

가져오기:

// app.mjs
import Calculator, { VERSION } from './calculator.mjs';
const calc = new Calculator();
console.log(calc.add(10, 5));  // 15
console.log(VERSION);           // 1.0.0

동적 import

// 조건부 로딩
async function loadModule() {
    if (condition) {
        const module = await import('./heavy-module.mjs');
        module.doSomething();
    }
}
// 지연 로딩
button.addEventListener('click', async () => {
    const { processData } = await import('./data-processor.mjs');
    processData();
});

3. CommonJS vs ES Modules

비교표

특징CommonJSES Modules
문법require, module.exportsimport, export
로딩동기 (런타임)비동기 (정적 분석)
파일 확장자.js.mjs 또는 .js (with "type": "module")
기본 내보내기module.exports = ...export default ...
Named 내보내기exports.name = ...export const name = ...
동적 로딩✅ (기본)✅ (import())
트리 쉐이킹
브라우저 지원
Node.js 지원✅ (기본)✅ (v12+)

언제 무엇을 사용할까?

CommonJS 사용:

  • ✅ 기존 Node.js 프로젝트
  • ✅ npm 패키지 대부분이 CommonJS
  • ✅ 동적 로딩이 많이 필요한 경우
  • ✅ 빠른 프로토타이핑 ES Modules 사용:
  • ✅ 새 프로젝트
  • ✅ 브라우저와 코드 공유
  • ✅ 트리 쉐이킹 필요 (번들 크기 최적화)
  • ✅ 정적 분석 도구 활용

혼용 (상호 운용성)

CommonJS에서 ES Modules 사용:

// CommonJS 파일
async function loadESModule() {
    const module = await import('./es-module.mjs');
    module.default();
}

ES Modules에서 CommonJS 사용:

// ES Modules 파일
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const commonjsModule = require('./commonjs-module.js');

4. 내장 모듈

주요 내장 모듈

// 파일 시스템
const fs = require('fs');
const fsPromises = require('fs').promises;
// 경로 처리
const path = require('path');
// HTTP/HTTPS
const http = require('http');
const https = require('https');
// URL 처리
const url = require('url');
// 쿼리스트링
const querystring = require('querystring');
// 운영체제 정보
const os = require('os');
// 암호화
const crypto = require('crypto');
// 이벤트
const EventEmitter = require('events');
// 스트림
const stream = require('stream');
// 자식 프로세스
const child_process = require('child_process');

fs (파일 시스템)

const fs = require('fs').promises;
const path = require('path');
async function fileOperations() {
    try {
        // 파일 읽기
        const data = await fs.readFile('input.txt', 'utf8');
        console.log('파일 내용:', data);
        
        // 파일 쓰기
        await fs.writeFile('output.txt', 'Hello, Node.js!', 'utf8');
        
        // 파일 추가
        await fs.appendFile('output.txt', '\n추가 내용', 'utf8');
        
        // 파일 복사
        await fs.copyFile('output.txt', 'backup.txt');
        
        // 파일 이름 변경
        await fs.rename('backup.txt', 'backup-new.txt');
        
        // 파일 삭제
        await fs.unlink('backup-new.txt');
        
        // 파일 정보
        const stats = await fs.stat('output.txt');
        console.log('파일 크기:', stats.size);
        console.log('생성 시간:', stats.birthtime);
        console.log('수정 시간:', stats.mtime);
        
        // 디렉토리 생성
        await fs.mkdir('new-folder', { recursive: true });
        
        // 디렉토리 읽기
        const files = await fs.readdir('.');
        console.log('파일 목록:', files);
        
        // 디렉토리 삭제
        await fs.rmdir('new-folder');
        
    } catch (err) {
        console.error('에러:', err.message);
    }
}
fileOperations();

path (경로 처리)

const path = require('path');
// 경로 결합
const filePath = path.join(__dirname, 'data', 'users.json');
console.log(filePath);
// C:\Users\JB\workspace\pkglog.com\data\users.json
// 절대 경로 생성
const absolutePath = path.resolve('data', 'users.json');
console.log(absolutePath);
// 파일 이름
console.log(path.basename('/foo/bar/file.txt'));  // file.txt
console.log(path.basename('/foo/bar/file.txt', '.txt'));  // file
// 디렉토리 이름
console.log(path.dirname('/foo/bar/file.txt'));  // /foo/bar
// 확장자
console.log(path.extname('file.txt'));  // .txt
// 경로 파싱
const parsed = path.parse('/foo/bar/file.txt');
console.log(parsed);
// {
//   root: '/',
//   dir: '/foo/bar',
//   base: 'file.txt',
//   ext: '.txt',
//   name: 'file'
// }
// 경로 정규화
console.log(path.normalize('/foo/bar/../baz'));  // /foo/baz
// 상대 경로
console.log(path.relative('/foo/bar', '/foo/baz/file.txt'));
// ../baz/file.txt

os (운영체제 정보)

const os = require('os');
// 플랫폼
console.log('플랫폼:', os.platform());  // win32, darwin, linux
// CPU 정보
console.log('CPU:', os.cpus().length, '코어');
// 메모리
console.log('총 메모리:', (os.totalmem() / 1024 / 1024 / 1024).toFixed(2), 'GB');
console.log('여유 메모리:', (os.freemem() / 1024 / 1024 / 1024).toFixed(2), 'GB');
// 홈 디렉토리
console.log('홈:', os.homedir());
// 임시 디렉토리
console.log('임시:', os.tmpdir());
// 네트워크 인터페이스
console.log('네트워크:', os.networkInterfaces());

crypto (암호화)

const crypto = require('crypto');
// 해시 생성
function hashPassword(password) {
    return crypto
        .createHash('sha256')
        .update(password)
        .digest('hex');
}
console.log(hashPassword('mypassword'));
// 89e01536ac207279409d4de1e5253e01f4a1769e696db0d6062ca9b8f56767c8
// 랜덤 문자열
const randomString = crypto.randomBytes(16).toString('hex');
console.log(randomString);
// a3f5c8b2e9d1f4a7c6b8e2d9f1a4c7b6
// UUID
const { randomUUID } = require('crypto');
console.log(randomUUID());
// 550e8400-e29b-41d4-a716-446655440000

5. 모듈 캐싱

캐싱 동작

// counter.js
let count = 0;
exports.increment = () => {
    count++;
    console.log('Count:', count);
};
exports.getCount = () => count;
// app.js
const counter1 = require('./counter');
const counter2 = require('./counter');
counter1.increment();  // Count: 1
counter2.increment();  // Count: 2
console.log(counter1.getCount());  // 2
console.log(counter2.getCount());  // 2
// counter1과 counter2는 같은 인스턴스!
console.log(counter1 === counter2);  // true

설명:

  • Node.js는 모듈을 처음 로드할 때 캐싱
  • 같은 모듈을 여러 번 require해도 한 번만 실행
  • 모든 require는 같은 인스턴스 반환

캐시 확인 및 삭제

// 캐시된 모듈 확인
console.log(require.cache);
// 캐시 삭제 (테스트 용도)
delete require.cache[require.resolve('./counter')];
// 다시 로드하면 새 인스턴스
const counter3 = require('./counter');
counter3.increment();  // Count: 1 (새로 시작)

6. 순환 참조 (Circular Dependency)

문제 상황

// a.js
const b = require('./b');
exports.name = 'Module A';
exports.greet = () => {
    console.log(`A: ${b.name}`);
};
// b.js
// 변수 선언 및 초기화
const a = require('./a');
exports.name = 'Module B';
exports.greet = () => {
    console.log(`B: ${a.name}`);
};
// app.js
// 변수 선언 및 초기화
const a = require('./a');
const b = require('./b');
a.greet();  // A: Module B
b.greet();  // B: undefined (순환 참조!)

문제: b.jsa.js를 로드할 때, a.js는 아직 완전히 로드되지 않음.

해결 방법

방법 1: 구조 재설계 (권장)

// shared.js
exports.nameA = 'Module A';
exports.nameB = 'Module B';
// a.js
const shared = require('./shared');
exports.greet = () => {
    console.log(`A: ${shared.nameB}`);
};
// b.js
// 변수 선언 및 초기화
const shared = require('./shared');
exports.greet = () => {
    console.log(`B: ${shared.nameA}`);
};

방법 2: 지연 로딩 (Lazy Loading)

// b.js
exports.name = 'Module B';
exports.greet = () => {
    // 함수 실행 시점에 로드
    const a = require('./a');
    console.log(`B: ${a.name}`);
};

방법 3: 의존성 주입

// a.js
exports.name = 'Module A';
exports.setB = (b) => {
    exports.b = b;
};
exports.greet = () => {
    console.log(`A: ${exports.b.name}`);
};
// app.js
const a = require('./a');
const b = require('./b');
a.setB(b);
b.setA(a);
a.greet();  // A: Module B
b.greet();  // B: Module A

7. 모듈 해석 (Module Resolution)

모듈 경로 규칙

// 1. 상대 경로
require('./math');        // 같은 폴더
require('../utils/math'); // 상위 폴더
require('./lib/math');    // 하위 폴더
// 2. 절대 경로
require('/home/user/project/math');
// 3. 패키지 이름 (node_modules)
require('express');
require('lodash');
// 4. 내장 모듈
require('fs');
require('http');

파일 확장자 생략

// 다음 순서로 검색
require('./math');
// 1. ./math.js
// 2. ./math.json
// 3. ./math.node (네이티브 모듈)
// 4. ./math/index.js
// 5. ./math/package.json의 "main" 필드

node_modules 검색

// require('express') 실행 시 검색 순서
// 1. ./node_modules/express
// 2. ../node_modules/express
// 3. ../../node_modules/express
// ....(루트까지 계속)

확인:

console.log(require.resolve('express'));
// /home/user/project/node_modules/express/index.js
console.log(require.resolve.paths('express'));
// [ '/home/user/project/node_modules',
//   '/home/user/node_modules',
//   '/home/node_modules',
//   '/node_modules' ]

8. 실전 예제

예제 1: 설정 모듈

// config.js
require('dotenv').config();
const config = {
    server: {
        port: process.env.PORT || 3000,
        host: process.env.HOST || 'localhost'
    },
    database: {
        host: process.env.DB_HOST || 'localhost',
        port: process.env.DB_PORT || 5432,
        name: process.env.DB_NAME || 'mydb',
        user: process.env.DB_USER || 'postgres',
        password: process.env.DB_PASSWORD || '
    },
    jwt: {
        secret: process.env.JWT_SECRET || 'default-secret',
        expiresIn: '1h'
    },
    isDevelopment: process.env.NODE_ENV === 'development',
    isProduction: process.env.NODE_ENV === 'production'
};
module.exports = config;
// server.js
const config = require('./config');
console.log(`서버 포트: ${config.server.port}`);
console.log(`환경: ${config.isDevelopment ? '개발' : '운영'}`);

예제 2: 로거 모듈

// logger.js
const fs = require('fs');
const path = require('path');
class Logger {
    constructor(logFile) {
        this.logFile = logFile;
    }
    
    _write(level, message) {
        const timestamp = new Date().toISOString();
        const logMessage = `[${timestamp}] [${level}] ${message}\n`;
        
        // 콘솔 출력
        console.log(logMessage.trim());
        
        // 파일 저장
        fs.appendFileSync(this.logFile, logMessage, 'utf8');
    }
    
    info(message) {
        this._write('INFO', message);
    }
    
    error(message) {
        this._write('ERROR', message);
    }
    
    warn(message) {
        this._write('WARN', message);
    }
    
    debug(message) {
        if (process.env.NODE_ENV === 'development') {
            this._write('DEBUG', message);
        }
    }
}
// 싱글톤 인스턴스
const logger = new Logger(path.join(__dirname, 'app.log'));
module.exports = logger;
// app.js
const logger = require('./logger');
logger.info('서버 시작');
logger.error('데이터베이스 연결 실패');
logger.warn('메모리 사용량 높음');
logger.debug('디버그 정보');

예제 3: 데이터베이스 모듈

// database.js
class Database {
    constructor() {
        this.connection = null;
        this.connected = false;
    }
    
    async connect(config) {
        if (this.connected) {
            console.log('이미 연결됨');
            return this.connection;
        }
        
        try {
            // 실제로는 데이터베이스 연결 로직
            this.connection = {
                host: config.host,
                port: config.port,
                database: config.database
            };
            this.connected = true;
            
            console.log(`데이터베이스 연결 성공: ${config.host}:${config.port}`);
            return this.connection;
        } catch (err) {
            console.error('데이터베이스 연결 실패:', err.message);
            throw err;
        }
    }
    
    async query(sql, params = []) {
        if (!this.connected) {
            throw new Error('데이터베이스에 연결되지 않음');
        }
        
        console.log('쿼리 실행:', sql, params);
        // 실제 쿼리 실행 로직
        return [];
    }
    
    async close() {
        if (this.connected) {
            this.connection = null;
            this.connected = false;
            console.log('데이터베이스 연결 종료');
        }
    }
}
// 싱글톤
module.exports = new Database();
// app.js
const db = require('./database');
const config = require('./config');
async function main() {
    try {
        await db.connect(config.database);
        
        const users = await db.query('SELECT * FROM users WHERE age > ?', [18]);
        console.log('사용자:', users);
        
        await db.close();
    } catch (err) {
        console.error('에러:', err.message);
    }
}
main();

예제 4: API 클라이언트 모듈

// api-client.js
const https = require('https');
class ApiClient {
    constructor(baseUrl) {
        this.baseUrl = baseUrl;
    }
    
    request(path, options = {}) {
        return new Promise((resolve, reject) => {
            const url = new URL(path, this.baseUrl);
            
            const req = https.request(url, {
                method: options.method || 'GET',
                headers: {
                    'Content-Type': 'application/json',
                    ...options.headers
                }
            }, (res) => {
                let data = ';
                
                res.on('data', (chunk) => {
                    data += chunk;
                });
                
                res.on('end', () => {
                    try {
                        const json = JSON.parse(data);
                        resolve(json);
                    } catch (err) {
                        reject(err);
                    }
                });
            });
            
            req.on('error', reject);
            
            if (options.body) {
                req.write(JSON.stringify(options.body));
            }
            
            req.end();
        });
    }
    
    get(path) {
        return this.request(path, { method: 'GET' });
    }
    
    post(path, body) {
        return this.request(path, { method: 'POST', body });
    }
}
module.exports = ApiClient;
// app.js
const ApiClient = require('./api-client');
const client = new ApiClient('https://api.github.com');
async function fetchUser(username) {
    try {
        const user = await client.get(`/users/${username}`);
        console.log('사용자:', user.name);
        console.log('저장소:', user.public_repos);
    } catch (err) {
        console.error('에러:', err.message);
    }
}
fetchUser('torvalds');

9. package.json 심화

필수 필드

{
  "name": "my-package",
  "version": "1.0.0",
  "description": "패키지 설명",
  "main": "index.js",
  "scripts": {
    "start": "node index.js",
    "test": "jest"
  },
  "keywords": ["node", "javascript"],
  "author": "Your Name <[email protected]>",
  "license": "MIT"
}

dependencies vs devDependencies

{
  "dependencies": {
    "express": "^4.18.2",
    "mongoose": "^7.0.0"
  },
  "devDependencies": {
    "nodemon": "^3.0.1",
    "jest": "^29.5.0",
    "eslint": "^8.50.0"
  }
}

차이:

  • dependencies: 프로덕션에서 필요한 패키지
  • devDependencies: 개발 중에만 필요한 패키지
# 프로덕션 설치 (devDependencies 제외)
npm install --production

버전 관리 (Semantic Versioning)

{
  "dependencies": {
    "express": "^4.18.2"
  }
}

버전 형식: MAJOR.MINOR.PATCH

  • 4.18.2: 정확히 4.18.2만
  • ^4.18.2: 4.18.2 이상, 5.0.0 미만 (MINOR, PATCH 업데이트 허용)
  • ~4.18.2: 4.18.2 이상, 4.19.0 미만 (PATCH만 업데이트 허용)
  • *: 최신 버전
  • >=4.18.2: 4.18.2 이상

scripts 활용

{
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js",
    "test": "jest",
    "test:watch": "jest --watch",
    "lint": "eslint .",
    "lint:fix": "eslint . --fix",
    "build": "webpack --mode production",
    "clean": "rm -rf dist",
    "prebuild": "npm run clean",
    "postbuild": "echo 'Build complete!'",
    "deploy": "npm run build && npm run upload"
  }
}

실행 순서:

npm run build
# 1. prebuild 실행
# 2. build 실행
# 3. postbuild 실행

10. 모듈 패턴

싱글톤 패턴

// database.js
class Database {
    constructor() {
        if (Database.instance) {
            return Database.instance;
        }
        
        this.connection = null;
        Database.instance = this;
    }
    
    connect() {
        if (!this.connection) {
            this.connection = { connected: true };
            console.log('연결됨');
        }
    }
}
module.exports = new Database();

팩토리 패턴

// user-factory.js
class User {
    constructor(name, role) {
        this.name = name;
        this.role = role;
    }
}
class Admin extends User {
    constructor(name) {
        super(name, 'admin');
        this.permissions = ['read', 'write', 'delete'];
    }
}
class Guest extends User {
    constructor(name) {
        super(name, 'guest');
        this.permissions = ['read'];
    }
}
function createUser(name, type) {
    switch (type) {
        case 'admin':
            return new Admin(name);
        case 'guest':
            return new Guest(name);
        default:
            return new User(name, 'user');
    }
}
module.exports = { createUser };

모듈 패턴 (Private 변수)

// counter.js
const counter = (() => {
    // Private 변수
    let count = 0;
    
    // Public API
    return {
        increment() {
            count++;
            return count;
        },
        decrement() {
            count--;
            return count;
        },
        getCount() {
            return count;
        },
        reset() {
            count = 0;
        }
    };
})();
module.exports = counter;

11. 자주 발생하는 문제

문제 1: Cannot find module

에러:

Error: Cannot find module './math'

원인:

  • 파일 경로 오타
  • 확장자 누락 (.mjs는 명시 필요)
  • 패키지 미설치 해결:
// ✅ 상대 경로 확인
require('./math');  // math.js가 같은 폴더에 있어야 함
// ✅ 절대 경로 사용
const path = require('path');
require(path.join(__dirname, 'math'));
// ✅ 패키지 설치
npm install express

문제 2: ES Modules 오류

에러:

SyntaxError: Cannot use import statement outside a module

해결:

// package.json
{
  "type": "module"
}

또는 .mjs 확장자 사용.

문제 3: __dirname, __filename 없음 (ES Modules)

문제:

// ES Modules에서는 __dirname, __filename 없음
console.log(__dirname);  // ReferenceError

해결:

import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
console.log(__dirname);
console.log(__filename);

문제 4: 순환 참조

증상: 모듈이 undefined 또는 일부만 로드됨 해결: 위 “순환 참조” 섹션 참조

12. 실전 팁

모듈 구조화

src/
├── config/
│   ├── database.js
│   ├── server.js
│   └── index.js
├── models/
│   ├── user.js
│   └── post.js
├── controllers/
│   ├── userController.js
│   └── postController.js
├── routes/
│   ├── userRoutes.js
│   └── postRoutes.js
├── middlewares/
│   ├── auth.js
│   └── errorHandler.js
├── utils/
│   ├── logger.js
│   └── validator.js
└── index.js

index.js 패턴

// models/index.js
const User = require('./user');
const Post = require('./post');
const Comment = require('./comment');
module.exports = {
    User,
    Post,
    Comment
};
// app.js
const { User, Post } = require('./models');
const user = new User('홍길동');
const post = new Post('제목');

환경별 설정

// config/index.js
const development = require('./development');
const production = require('./production');
const test = require('./test');
const configs = {
    development,
    production,
    test
};
const env = process.env.NODE_ENV || 'development';
module.exports = configs[env];

에러 처리

// utils/errors.js
// 타입 정의
class AppError extends Error {
    constructor(message, statusCode) {
        super(message);
        this.statusCode = statusCode;
        this.isOperational = true;
        
        Error.captureStackTrace(this, this.constructor);
    }
}
class NotFoundError extends AppError {
    constructor(message = '리소스를 찾을 수 없습니다') {
        super(message, 404);
    }
}
class ValidationError extends AppError {
    constructor(message = '유효하지 않은 입력입니다') {
        super(message, 400);
    }
}
module.exports = {
    AppError,
    NotFoundError,
    ValidationError
};

정리

핵심 요약

  1. CommonJS: require, module.exports (Node.js 기본)
  2. ES Modules: import, export (표준, 최신)
  3. 내장 모듈: fs, path, http, os, crypto
  4. 모듈 캐싱: 한 번 로드하면 캐시됨
  5. 순환 참조: 구조 재설계 또는 지연 로딩으로 해결
  6. package.json: 프로젝트 메타데이터, 의존성 관리

비교: CommonJS vs ES Modules

특징CommonJSES Modules
문법require/module.exportsimport/export
로딩동기, 런타임비동기, 정적
동적 로딩기본 지원import() 사용
트리 쉐이킹불가가능
브라우저불가가능

다음 단계

추천 학습 자료

공식 문서:


관련 글

심화 부록: 구현·운영 관점

이 부록은 앞선 본문에서 다룬 주제(「Node.js 모듈 시스템 | CommonJS와 ES Modules 완벽 가이드」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.

내부 동작과 핵심 메커니즘

flowchart TD
  A[입력·요청·이벤트] --> B[파싱·검증·디코딩]
  B --> C[핵심 연산·상태 전이]
  C --> D[부작용: I/O·네트워크·동시성]
  D --> E[결과·관측·저장]
sequenceDiagram
  participant C as 클라이언트/호출자
  participant B as 경계(런타임·게이트웨이·프로세스)
  participant D as 의존성(API·DB·큐·파일)
  C->>B: 요청/이벤트
  B->>D: 조회·쓰기·RPC
  D-->>B: 지연·부분 실패·재시도 가능
  B-->>C: 응답 또는 오류(코드·상관 ID)
  • 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
  • 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
  • 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
  • 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.

프로덕션 운영 패턴

영역운영 관점 질문
관측성요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가
안전성입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가
신뢰성재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가
성능캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가
배포롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가
용량피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가

스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.

확장 예시: 엔드투엔드 미니 시나리오

앞선 본문 주제(「Node.js 모듈 시스템 | CommonJS와 ES Modules 완벽 가이드」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.

  1. 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
  2. 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
  3. 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
  4. 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
  5. 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
  ctx = newCorrelationId()
  validated = validateSchema(request)
  authorize(validated, ctx)
  result = domainCore(validated)
  persistOrEmit(result, idempotentKey)
  recordMetrics(ctx, latency, outcome)
  return result

문제 해결(Troubleshooting)

증상가능 원인조치
간헐적 실패레이스, 타임아웃, 외부 의존성, DNS최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검
성능 저하N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거
메모리 증가캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납상한·TTL·힙/FD 스냅샷 비교
빌드·배포만 실패환경 변수, 권한, 플랫폼 차이, lockfileCI 로그와 로컬 diff, 런타임·이미지 버전 핀
설정 불일치프로필·시크릿·기본값, 리전스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화
데이터 불일치비멱등 재시도, 부분 쓰기, 캐시 무효화 누락멱등 키·아웃박스·트랜잭션 경계 재검토

권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.

배포 전에는 git addgit commitgit pushnpm run deploy 순서를 권장합니다.


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. Node.js 모듈 시스템: CommonJS와 ES Modules 완벽 가이드. CommonJS 모듈·ES Modules로 흐름을 잡고 원리·코드·실무 적용을 한글로 정리합니다. Node.js·JavaScript·모… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

Node.js, JavaScript, 모듈, CommonJS, ES Modules, require, import 등으로 검색하시면 이 글이 도움이 됩니다.