Node.js 모듈 시스템: CommonJS vs ES Modules, 모듈 캐싱, 순환 참조, 모듈 해석 규칙

이 글의 핵심

require와 import가 각각 어떻게 동작하는지, 두 방식을 섞을 때 생기는 문제, 모듈 캐싱과 순환 참조 동작, Node.js가 모듈 경로를 찾는 규칙과 package.json 설정을 정리합니다.

들어가며

모듈이란?

모듈은 재사용 가능한 코드 조각을 파일로 분리한 것입니다. 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++는 CMake와 Conan·vcpkg 조합이 자주 사용됩니다. 빌드 철학 비교는 C++ 빌드 시스템 완전 비교를 참고하세요.

모듈의 장점:

  • 코드 재사용: 한 번 작성, 여러 곳에서 사용
  • 네임스페이스: 전역 스코프 오염 방지
  • 유지보수: 기능별로 파일 분리
  • 의존성 관리: 명확한 의존 관계
  • 테스트: 독립적인 단위 테스트 가능

CommonJS: require()와 module.exports

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

exports와 module.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('데이터베이스 연결');

ES Modules: import·export와 로딩 방식

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.counter = counter;          // 이 시점의 값(0)이 복사됨
exports.increment = () => ++counter;

// app.js
const math = require('./math');
console.log(math.counter);  // 0
math.increment();
console.log(math.counter);  // 0 ← 모듈 내부 counter는 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으로 해결

ESM으로 옮길 때 달라지는 규칙

CommonJS에서 ESM으로 옮길 때 가장 먼저 부딪히는 것은 문법보다 해석 규칙입니다. ESM의 import 경로는 브라우저처럼 URL로 취급되어 확장자를 자동으로 붙이지 않으므로, import { add } from './math'는 ERR_MODULE_NOT_FOUND로 실패하고 './math.js'처럼 정확한 파일명을 적어야 합니다. 디렉터리를 지정했을 때 index.js를 찾아 주는 동작도 없습니다. TypeScript로 ESM을 출력한다면 소스에서도 .ts가 아니라 컴파일 결과 기준인 .js 확장자로 import를 적는 것이 규칙이라 처음에는 어색하게 느껴집니다.

또 ESM에서는 require, module, exports, __dirname, __filename이 전부 없고, 모듈은 항상 strict mode로 실행되며, 최상위 this는 undefined입니다. 대신 최상위 await를 쓸 수 있어 설정 파일을 비동기로 읽은 뒤 export하는 코드가 간단해집니다. 다만 최상위 await가 있는 모듈은 그 await가 끝날 때까지 이 모듈을 import하는 모든 모듈의 실행을 막으므로, 느린 네트워크 요청을 최상위에서 기다리면 애플리케이션 시작 전체가 지연됩니다.

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();
});

CommonJS와 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');

사실 ESM에서는 import pkg from './commonjs-module.js'처럼 CommonJS 모듈을 바로 import할 수도 있습니다. 이때 module.exports 전체가 default export가 됩니다. import { add } from './cjs.js' 같은 named import는 Node.js가 CommonJS 소스를 정적으로 훑어 exports.add = ... 같은 패턴을 찾을 수 있을 때만 동작하고, module.exports = buildApi()처럼 실행해 봐야 알 수 있는 형태라면 does not provide an export named 'add' 에러가 납니다. 이럴 때는 default로 받은 뒤 구조 분해하면 됩니다.

반대 방향, 즉 CommonJS에서 ESM 전용 패키지를 require()하는 것은 오랫동안 ERR_REQUIRE_ESM 에러로 막혀 있었습니다. chalk 5, node-fetch 3처럼 인기 패키지가 ESM 전용으로 전환하면서 많은 프로젝트가 이 에러를 겪었고, 당시 해결책은 await import()로 바꾸거나 CommonJS를 지원하는 이전 메이저 버전에 머무는 것이었습니다. Node.js 22.12와 20.19부터는 require(esm)이 기본으로 켜져, 최상위 await가 없는 ESM이라면 require()로 동기적으로 불러올 수 있습니다. 라이브러리를 배포하는 입장이라면 여전히 구버전 Node.js 사용자를 고려해 package.json의 exports 필드에 "import"와 "require" 조건을 나눠 두는 “듀얼 패키지”를 검토할 만하지만, 같은 패키지가 두 번 로드되어 상태가 갈라지는 듀얼 패키지 위험(dual package hazard)도 함께 따져 봐야 합니다.


fs, path, os, crypto 내장 모듈

주요 내장 모듈

// 파일 시스템
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

모듈 캐싱

캐싱 동작

// 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는 같은 인스턴스 반환

단, 캐시의 키는 모듈 이름이 아니라 해석된 파일의 절대 경로라는 점이 중요합니다. 같은 라이브러리가 node_modules 안에 서로 다른 버전으로 두 번 설치되어 있거나(패키지 A와 B가 각자 다른 버전에 의존하는 경우), 심볼릭 링크로 연결된 모노레포 패키지를 서로 다른 경로로 불러오면, 두 경로가 별개의 캐시 항목이 되어 “싱글톤”이 두 개 생깁니다. instanceof 검사가 이유 없이 false가 되거나, 한쪽에서 설정한 전역 설정이 다른 쪽에 반영되지 않는 증상이 대표적입니다. npm ls <패키지명>으로 중복 설치 여부를 확인하는 것이 첫 단계입니다.

캐시 확인 및 삭제

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

순환 참조와 해결 방법

문제 상황

// a.js
const b = require('./b');
// exports에 속성을 추가하지 않고 module.exports를 새 객체로 교체
module.exports = {
    name: 'Module A',
    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.js가 a.js를 로드할 때, a.js는 아직 완전히 로드되지 않았으므로 b는 a의 처음 빈 exports 객체를 받습니다. 그 뒤 a.js가 module.exports를 새 객체로 교체하면, b가 들고 있는 것은 여전히 옛 빈 객체라서 a.name이 undefined로 남습니다. 만약 a.js가 exports.name = ...처럼 기존 객체에 속성을 추가하는 방식이었다면, b가 받은 객체와 같은 객체가 나중에 채워지므로 greet() 호출 시점에는 'Module A'가 정상적으로 보입니다. 순환 참조 버그가 “어떤 때는 되고 어떤 때는 안 되는” 이유가 이 차이이며, 모듈 최상위에서 곧바로 상대 모듈의 값을 읽는 코드(const nameOfA = a.name;)는 두 경우 모두 undefined를 얻습니다. Node.js는 이런 상황에서 Warning: Accessing non-existent property 'name' of module exports inside circular dependency 경고를 출력하므로, 이 경고가 보이면 순환 구조를 먼저 의심하면 됩니다.

해결 방법

방법 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);   // b.js에도 a.js와 같은 방식의 setA를 둔다고 가정
a.greet();  // A: Module B
b.greet();  // B: Module A

모듈 경로 해석과 node_modules 검색

모듈 경로 규칙

// 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/package.json의 "main" 필드
// 5. ./math/index.js (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' ]

설정, 로거, DB, API 클라이언트 모듈 예제

예제 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');

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 제외)
# --production은 npm 7부터 deprecated → --omit=dev 사용
npm ci --omit=dev

버전 관리 (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 실행

싱글톤, 팩토리, Private 변수 모듈 패턴

싱글톤 패턴

// 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;

Cannot find module, ESM 오류, __dirname, 순환 참조

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

ES Modules 오류

에러:

SyntaxError: Cannot use import statement outside a module

해결:

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

또는 .mjs 확장자 사용.

ES Modules에서 __dirname, __filename이 없을 때

문제:

// 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);

순환 참조로 undefined가 나올 때

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


폴더 구조, index.js, 환경별 설정, 에러 모듈

모듈 구조화

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() 사용
트리 쉐이킹불가가능
브라우저불가가능

다음 단계

추천 학습 자료

공식 문서:


자주 묻는 질문 (FAQ)

Q. Cannot use import statement outside a module 오류는 왜 생기나요?

A. Node.js는 기본적으로 .js 파일을 CommonJS로 해석하기 때문에 import 문을 만나면 이 오류를 냅니다. package.json에 "type": "module"을 추가하거나 파일 확장자를 .mjs로 바꾸면 ES Modules로 동작합니다. 전환한 뒤에는 __dirname과 __filename이 없으므로 fileURLToPath(import.meta.url)로 직접 만들어 써야 합니다.


같이 보면 좋은 글