javascript의 module 이해하기 (require vs import)

들어가기

이번 글은 Node.js 디자인 패턴 바이블의 2장 모듈 시스템을 공부하고 그 내용을 요약한 것입니다. 나머지 내용은 평어체로 작성하겠습니다. ^^;;

모듈의 필요성

  • 코드베이스를 나누어 여러파일로 분할 - 구조적 관리
  • 코드 재사용
  • 은닉성 - 명료한 책임을 가진 간단한 인터페이스만 노출
  • 종속성 관리 - 쉽게 불러쓸 수 있게

javascript의 두가지 모듈 시스템

  • 초기에는 모듈 시스템이 아예 없었음
  • 브라우저 애플리케이션이 복잡해지고 프레임워크(jQuery, Backbone, Angular)가 등장하면서 모듈 시스템 정의 시도 나타남 - AMD, RequireJS
"AMD"는 "Asynchronous Module Definition"의 약자로, JavaScript 모듈을 비동기적으로 로드하고 정의하는 한 방법입니다. 초기의 JavaScript 개발에서는 모든 스크립트와 라이브러리가 글로벌 스코프에 배치되었고, 이로 인해 변수명 충돌과 코드 관리의 어려움이 발생했습니다.

AMD 사양은 이러한 문제를 해결하기 위해 고안되었으며, 모듈을 정의할 때 의존성을 명시적으로 선언하고, 모든 의존성이 로드된 후에 모듈이 실행되도록 합니다. 이러한 방식은 코드를 더 깔끔하게 관리할 수 있게 해주며, 필요한 코드만을 로드할 수 있어 페이지 로드 시간을 줄이는 효과가 있습니다.

RequireJS는 AMD 사양을 구현한 JavaScript 라이브러리 중 하나로, AMD를 사용하여 모듈을 로드하고 관리할 수 있는 기능을 제공합니다. RequireJS를 사용하면 JavaScript 파일과 모듈을 비동기적으로 로드하고, 의존성 관리를 보다 쉽게 할 수 있습니다.
  • Node.js가 처음으로 만들어졌을 때 모듈 시스템으로 CommonJS를 따르는 require를 제공했다.
CommonJS는 JavaScript를 브라우저 외부의 환경에서도 사용할 수 있도록 하기 위해 만들어진 명세입니다. 이 명세는 모듈, 패키지, 시스템 등에 대한 API를 정의하여 서버사이드 JavaScript 개발을 촉진하고자 했습니다. CommonJS 명세에 따르면, 각 모듈은 자체 파일에 존재하며, require 함수를 사용하여 다른 모듈을 로드하고 exports 객체를 사용하여 공개하고자 하는 함수나 객체를 내보낼 수 있습니다.
  • 2015년에 ES6 발표 때 표준 모듈 시스템을 위한 공식적인 제안이 나왔다. 이의 실제 구현은 Node.js v13.2(2019년 말)부터 안정적으로 지원되었다. import, export를 사용한다.

모듈 시스템과 패턴

Javascript는 네임스페이스가 없다.

이로 인해 모든 스크립트는 전역 범위에서 실행된다. 만약 모듈 시스템을 아래처럼 구현하면 어떻게 될까?

exports.run = function() {
    console.log('harmful module run');
    myVariableA = 5;
};
harmfulModule.js
const fs = require('fs');

function  badRequire(filename) {
    var exports = {};
    var fileContent = fs.readFileSync(filename, 'utf-8');
    eval(fileContent);
    return exports;
}

var loadedModule = badRequire('./harmfulModule.js');
var myVariableA = 3;

loadedModule.run();
console.log(`myVariableA=${myVariableA}`); // 5로 바뀐다.
badRequire

harmfulModule의 run이 eval로 실행되었다고 하자.  

  • myVariableA는 우선 run 내에는 없다.
  • 그 바깥인 badRequire에도 없다.
  • 그 바깥인 file 수준에는 myVariableA에는 있으므로 이 값을 5로 바꾼다.

자바스크립트의 var 변수는 function scope이고, function 내에 들어있지 않으면 global scope을 가지게 되기 때문에 harmfulModule은 global variable의 값들을 변경시킬 수 있게 된다.

즉시 실행 함수 표현 (IIFE: Immediately Invoked Function Expression) 을 이용한다면?

const fs = require('fs');

function goodRequire(filename) {
    var exports = {};
    var module = { exports: exports };
    var fileContent = fs.readFileSync(filename, 'utf-8');
    var wrappedContent = `(function (exports, module) { ${fileContent} \n})(exports, module);`;
    new Function('exports', 'module', wrappedContent)(exports, module);
    return module.exports;
}

var loadedModule = goodRequire('./harmfulModule.js');
var myVariableA = 3;
loadedModule.run();
console.log(`myVariableA=${myVariableA}`);
goodRequire

여기서는 모듈의 내용을 이름이 없는 임시 함수로 감싼 다음 실행한다. 이렇게 하면 new Function으로 생기는 함수의 scope은 이 파일이 아니라 global이 되기 때문에 이 파일의 변수인 myVariable 은 보호된다. (global 환경은 여전히 더렵혀지는것은 안비밀)

이렇게 하면 좀 더 나은 모듈 시스템을 구현할 수 있다.

또한 iief를 사용하면 모듈 제작자 입장에서도 private 변수나 함수를 노출시키지 않고 안전하게 사용할 수 있다.

const myIIFEModule = (() => {
    const privateFoo = () => {
        console.log('I am private f');
    }
    const privateBar = ['privateBar'];

    const exported = {
        publicFoo : () => {
            privateFoo();
        },
        publicBar : () => {
            return privateBar;
        },
    }

    return exported;
})();

module.exports = myIIFEModule;
privateFoo, privateBar를 숨긴 예

CommonJS 모듈

모듈 시스템 설명

require는 다음과 같은 방식으로 작동한다.

  • 모듈 이름을 통해 id를 구한다. (경로 전체가 될 수도 있음, resolve라고 하자)
  • 이미 로드된 경우 캐시된 모듈을 사용.
  • 없으면 일단 const module = { exports: {}, id: id } 인 빈 모듈을 하나 만든다. 바로 캐시 변수에 등록
  • 실제로 모듈을 불러옴
  • module.exports를 리턴함

간단히 만들어본다면,

const fs = require('fs');

function  loadModule(filename, module, require) {
    const fileContent = fs.readFileSync(filename, 'utf-8');
    const wrappedSrc = 
        `(function (module, exports, require) {
            ${fileContent}
        })(module, module.exports, require)`
    eval(wrappedSrc)
}

function myRequire(moduleName) {
    console.log(`require invoked for module: ${moduleName}`);
    const id = myRequire.resolve(moduleName);
    if (require.cache[id]) {
        return myRequire.cache[id].exports;
    }

    const module = {
        exports: {},
        id
    }

    myRequire.cache[id] = module;

    loadModule(id, module, myRequire);

    return module.exports;
}

myRequire.cache = {};
myRequire.resolve = (moduleName) => {
    return moduleName;
}

// 사용예
const loadedModule = myRequire('./mySampleModule.js');
loadedModule.run();

모듈 정의

이것과 쌍을 이루는 모듈은 다음과 같은 규칙으로 정의할 수 있다. 이 규칙은 위의 wrsppedSrc가 eval 되는 것과 module.exports가 나중에 리턴되는 것을 생각하면 유추할 수 있다.

  • module.exports가 빈 객체라고 생각하고 여기에 하나하나 채운다는 개념으로 기능을 채워간다. 함수일수도 있고 어떤 값일 수도 있다.
  • 함수, 인스턴스 또는 문자열과 같은 객체 리터럴 이외의 것을 내보내려면 다음과 같이 module.exports를 다시 할당해야 한다.
module.exports = () => {
	console.log('Hello');
}
  • require함수는 동기적이므로 module.exports 를 재할당하거나 그 안에 내용을 채우는 과정을 비동기로 구성해서는 안된다.

resolve의 3가지 형태

  • 파일 모듈 : moduleName이 / 로 시작하면 절대경로라고 가정, 그대로 반환
  • 코어 모듈 : / 또는 ./로 시작하지 않으면 먼저 코어 Node.js 모듈 내에서 검색 시도
  • 패키지 모듈 : 코어에서 못찾으면 node_modules 디렉토리 내에서 찾아봄, 없으면 계속 상위의 node_modules를 찾음

캐시

위에서 확인한대로 resolve를 통해 얻은 id가 cache 내에 이미 존재하므로 이 모듈은 싱글턴처럼 작동한다.

모듈 정의 패턴

다음과 같은 다양한 패턴으로 사용할 수 있다.

  1. exports 지정하기 (Named exports)
exports.info = (message) => {
console.log(`info: ${message}`);
}

2. 함수 내보내기

module.exports = (message) => {
console.log(`info: ${message}`);
}

함수 내보내기를 할 때 1번에서 사용한 것을 추가로 사용할 수도 있다.

module.exports.verbose = (message) => {
console.log(`verbose: ${message}`);
}

사용할 때는 다음과 같이 사용하면 된다.

const logger = require('./logger');
logger('this is an infomational message');
logger.verbose('this is a verbose message');

3. 클래스 내보내기

클래스 내보내기는 함수를 내보내는 모듈이 특화된 것이다. 이 패턴을 이용하면 사용자에게 생성자를 사용해 새 인스턴스를 만들 수 있게 해 준다.

class Logger {
	constructor(name){
    	this.count = 0;
        this.name = name;
    }
    log(message) {
    	this.count++;
        console.log(`[${this.name}] ${message}`);
    }
}
module.exports = Logger

4. 인스턴스 내보내기

만약 클래스를 정의한 다음 이를 싱글턴으로 사용하게 하려면 new 로 생성한 후 이를 exports에 어사인하면 된다. 이 인스턴스는 캐싱 되므로 여러 파일에서 부르더라도 단일 인스턴스에 접근한 것이 된다.

class Logger {
	constructor(name){
    	this.count = 0;
        this.name = name;
    }
    log(message) {
    	this.count++;
        console.log(`[${this.name}] ${message}`);
    }
}
module.exports = new Logger('DEFAULT')

ESM: ECMAScript 모듈

가장 큰 차이점은 es모듈이 static이라는 것이다.  import 구문은 소스 최상단에 기술하게 된다.

몇몇 문법을 짚어보자.

// logger.js

// variable
export const DEFAULT_LEVEL = 'info'

// function
export function log(message) {
	console.log(message);
}

// object
export const LEVELS = {
	error:0,
    debug:1,
}

// class
export class Logger {
	constructor(name){
    	this.count = 0;
        this.name = name;
    }
    log(message) {
    	this.count++;
        console.log(`[${this.name}] ${message}`);
    }
}

// instance
export const loggerInstance = new Logger('DEFAULT');

// default export
export default Logger;

default 로 export 되는 것은 마치 CommonJS 모듈 시스템에서 module.exports에 바로 어사인하는 것과 비슷한데, 불러쓸 때는 이렇게 쓰면 된다.

import Logger, { loggerInstance, log, LEVELS, DEFAULT_LEVEL } from './logger.js'

여기서 default 로 export 된 것은 이름을 마음대로 바꿔서 부를 수 있고, named export된 것은 만약 이름이 충돌한다면 as blabla 로 바꿔서 쓸 수 있다.

export가 어떤 형태로 되었는지는 import * as 로 확인해 볼 수 있다.

import * as loggerModule from './logger.js'
console.log(loggerModule);

// 결과
[Module: null prototype] {
  DEFAULT_LEVEL: 'info',
  LEVELS: { error: 0, debug: 1 },
  Logger: [class Logger],
  default: [class Logger],
  log: [Function: log],
  loggerInstance: Logger { count: 0, name: 'DEFAULT' }
}

주목할 점은 default로 export된 클래스는 default라는 key로 모듈 내에 들어있는데, 그렇다고 해서 import { default } from './logger.js' 로 사용할 수는 없다. (오류남)

비동기로 import 사용하기

import는 반드시 동기적으로 사용해야 한다. 만약 비동기로 어떤 모듈을 부르고 싶다면 import() 함수를 이용해야 한다. 이는 promise를 반환하므로 then이나 await를 사용하면 된다.

다음은 언어 설정에 따른 문자열을 로드하는 예이다.

const language = process.argv[2]; // Get the language argument from command line

async function loadModuleAndGreet(lang) {
  let module;
  if (lang === 'kr') {
    module = await import('./kr.mjs');
  } else if (lang === 'en') {
    module = await import('./en.mjs');
  } else {
    console.log('Language not supported');
    return;
  }
  console.log(module.greeting);
}

loadModuleAndGreet(language);

ESM vs CommonJS

CommonJS의 require 문과 ES6의 import 문을  비교해보았다. 이 표는 두 모듈 시스템 간의 차이점을 개괄적으로 보여준다.

기준 CommonJS의 require ES6의 import
JSON JSON 파일을 직접 require 할 수 있음. JSON 파일을 직접 import 할 수 없음. fetch 또는 다른 방법을 사용해야 함.
비동기성 기본적으로 동기적. 모듈이 순서대로 로드되고 실행됨. 비동기적. 정적 import는 모듈이 평가되기 전에 해결되고 가져옴. 동적 import (import())를 사용하여 모듈을 비동기적으로 로드할 수 있음.
역사 Node.js의 모듈 시스템으로 도입되어 널리 사용됨. ECMAScript 6 (또는 ES2015로 알려짐) 사양의 일부. 브라우저와 서버 호환을 위해 설계됨.
확장자 일반적으로 .js이지만 구성에 따라 .json이나 다른 것들도 가능. 일반적으로 모듈을 위해 .js 또는 .mjs. .json 파일을 직접 import 할 수 없음.
사용 문법 const module = require('module-name'); import module from 'module-name'; 또는 import { specificFunction } from 'module-name';
내보내기
문법
module.exports = { function1, function2 } 또는 exports.function1 = function1; export default function1; 또는 export { function1, function2 };
범위 required 모듈 안의 변수는 명시적으로 export하지 않는 이상 해당 모듈 내에 머물러 있음. 변수와 함수는 기본적으로 모듈에 범위가 지정되어 있으며, 명시적으로 export하지 않는 이상 외부에서 접근할 수 없음.
라이브
바인딩
라이브 바인딩이 없음. require 시점의 값이 반환됨. 라이브 바인딩을 지원함. 모듈에서 export된 값이 변경되면 import에서도 그 변경을 반영함.
브라우저 브라우저에서 기본적으로 지원되지 않음. Webpack이나 Browserify와 같은 번들링 도구가 필요함. 현대 브라우저에서 지원됨. 오래된 브라우저는 Babel과 같은 트랜스파일러와 번들러가 필요함.

참고로 라이브 바인딩 관련해서는 예제로 넣어두었다.

결론

이 글에서는 javascript의 두가지 모듈 시스템에 대해 다루어보았다. 모듈 시스템의 역사와 문법, 사용예, 차이점도 알아보았다. 순환참조나, 모듈 커스터마이즈는 일부 생략했다.

예제 파일