본문 바로가기

Node.js/Node.js 디자인 패턴 바이블

[Node.js 디자인 패턴 바이블] CHAPTER 02 모듈 시스템

통칭되는 용어로서의 모듈은 주요 애플리케이션들을 구조화하기 위한 부품이다. 모듈은 코드베이스를 개별적으로 개발 가능하고 테스트 가능한 작은 유닛으로 나누게 해준다. 명시적으로 노출시키지 않은 모든 함수들과 변수들을 비공개로 유지하여 정보에 대한 은닉성을 강화시켜주는 주된 장치이다.

 

흥미롭게도 Node.js는 두 가지 모듈 시스템(CommonJS -CJS, ECMAScript modules - ESM 또는 ES modules)을 사용하고 있다. 이번 장에서 두 가지 형태가 왜 존재하는지 얘기해보고 각각의 장점과 단점을 알아보도록 한다.

 

2-1 모듈의 필요성

좋은 모듈 시스템은 소프트웨어 엔지니어링의 몇 가지 기본적인 필요성을 마주할 때 도움을 준다.

  • 코드베이스를 나누어 여러 파일로 분할하는 방법을 제시한다. 이것은 코드를 좀 더 구조적으로 관리할 수 있게 해주고 각각으로부터 독립적인 기능의 조각들을 개발 및 테스트하는 데에 동무을 주며 이해하기 쉽게 해준다.
  • 다른 프로젝트에서 코드를 재사용할 수 있게 해준다. 실제로 모듈은 다른 프로젝트에서도 유용하고 일반적인 특성을 구현할 수 있다. 모듈로서 기능을 구조화하는 것이 그 기능들이 필요한 다른 프로젝트로 좀 더 쉽게 이동시킬 수 있다.
  • 은닉성을 제공한다. 일반적으로 복잡한 구현을 숨긴 채 명료한 책임을 가진 간단한 인터페이스만 노출시키는 것이 좋은 방식이다. 대부분의 모듈 시스템은 함수와 클래스 또는 객체와 같이 모듈 사용자가 이용하도록 공개 인터페이스를 노출시키는 반면, 공개하지 않으려는 코드를 선택적으로 공개되지 않도록 해준다.
  • 종속성을 관리한다. 좋은 모듈 시스템은 서드파티(third-party)를 포함하여 모듈 개발자로 하여금 기존에 있는 모듈에 의존하여 쉽게 빌드할 수 있게 해준다. 또한 모듈 시스템은 모듈 사용자가 주어진 모듈의 실행에 있어 필요한(일시적 종속성들) 일련의 종속성들을 쉽게 임포트할 수 있게 해준다.

모듈과 모듈 시스템을 구별하느 것은 중요하다. 모듈 시스템이 문법이며 우리의 프로젝트 안에서 모듈을 정의하고 사용할 수 있게 해주는 도구인 반면, 모듈은 소프트웨어의  실제 유닛으로 정의할 수 있다.

 

2-2 JavaScript와 Node.js에서의 모듈 시스템

HTML <script>과 URL을 통한 리소스 접근에 의존하지 않고 오직 로컬 파일시스템의 JavaScript 파일들에만 의존하는 건데 이 모듈 시스템을 동입하기 위해 Node.js는 브라우저가 아닌 환경에서 JavaScript 모듈 시스템을 제공할 수 있도록 고안된 CommonJS(종종 CJS라 불림)의 명세를 구현하게 되었다.

 

CommonJS는 그것의 시작과 함께 Node.js에서 주된 모듈 시스템이 되었고 Browserify와 Webpack과 같은 모듈 번들러 덕분에 브라우저 환경에서도 유명세를 가지게 되었다. 이후 2015년에 ECMAScript 6(ECMAScript 2015 또는 ES2015로 불리기도 하는)의 발표와 함께 표준 모듈 시스템(ESM 또는 ECMAScript Modules)을 위한 공식적인 제안이 나오고 ESM은 거의 표준이 되어가고 있음.

 

2-3 모듈 시스템과 패턴

JavaScript의 주요 문제점 중 하나는 네임스페이스가 없다는 것이다. 모든 스크립트는 전역 범위에서 실행된다. 따라서 내부 애플리케이션 코드나 종속성 라이브러리가 그들의 기능을 노출시키는 동시에 스코프를 오염시킬 수 있다. 예를 들어 종속성 라이브러리가 전역 변수 utils를 선언했다고 생각해보면, 만약 다른 라이브러리나 애플리케이션 코드가 의도치 않게 utils를 덮어쓰거나 대체해버린다면 그것에 의존하던 코드는 예측 불가능한 상황 속에서 충돌이 일어날 것ㅇ이다. 다른 라이브러리나 애플리케이션 코드가 내부적으로 사용할 의도를 갖고 함수를 호출할 때에도 예측 불가능한 부작용이 발생할 수 있다.

 

다시 말해서, 전역 범위에 의존하는 것은 매우 위험한 작업이다. 게다가, 애플리케이션이 확장됨에 따라 더욱 개별적인 기능 구현에 의존해야 하는 상황이 벌어진다.

 

이러한 문제를 해결하기 위한 보편적인 기법을 노출식 모듈 패턴(revealing module pattern)이라고 하며, 다음과 같은 형식을 보인다.

const myModule = (() => {
	const privateFoo = () => { }
    const privateBar = []
    
    const exported = {
    	publicFoo: () => { }
        publicBar: () => { }
   	}
    
    return exported
})() // 여기서 괄호가 파싱되면, 함수는 호출됩니다.

console.log(myModule)
console.log(myModule.privateFoo, myModule.privateBar)

이 패턴은 자기 호출 함수를 사용한다. 이러한 종류의 함수를 즉시 실행 함수 표현(IIFE: Immediately Invoked Function Expression)이라고 부르며 private 범위를 만들고 공개될 부분만 내보내게 된다.

JavaScript에서는 함수 내부에 선언한 변수는 외부에서 접근할 수 없다. 함수는 선택적으로 외부 범위에 정보를 전파시키기 위해서 return 구문을 사용할 수 있다.

이 패턴은 비공개 정보의 은닉을 유지하고 공개될 API를 내보내기 위해서 이러한 특성을 핵심적으로 잘 활용하였다.

앞선 코드에서 myModule 변수는 익스포드된(exported) API만 포함하고 있으며, 모듈 내용의 나머지 부분은 사실상 외부에서 접근이 불가능하다.

로그로 출력한 내용은 다음과 같다.

위에서 보여주듯이 myModule로부터 직접 접근이 가능한 것은 익스포트된 객체뿐이라는 것을 알 수 있다.

우리가 곧 보게 되는 것은 이 패턴을 기반으로 하는 아이디어가 CommonJS 모듈 시스템에서 사용된다는 것이다.

 

2-4 CommonJS 모듈

CommonJS는 Node.js의 첫 번째 내장 모듈 시스템이다. Node.js의 CommonJS는 CommonJS 명세를 고려하여 추가적인 자체 확장 기능과 함께 구현되었다.

CommonJS 명세의 두 가지 주요 개념을 요약하면 다음과 같다.

  • require는 로컬 파일 시스템으로부터 모듈을 임포터하게 해준다.
  • exports와 module.exports는 특별한 변수로서 현재 모듈에서 공개될 기능들을 내보내기 위해서 사용한다.

2-4-1 직접 만드는 모듈 로더

Node.js에서 CommnJS가 어떻게 작동하는지 이해하기 위해서 비슷한 시스템을 만들어 보겠다. 다음의 코드는 Node.js의 require() 함수의 원래 기능 중 일부를 모방한 함수를 만든 것이다.

먼저 모듈의 내용을 로드하고 이를 private 범위로 감싸 평가하는 함수를 작성해 보겠다.

function loadModule(filename, module, require) {
	const wrappedSrc = 
    	`(function (module, exports, require) {
        	${fs.readFileSync(filename, 'utf8')}
        })(module, module.exports, require)`
    eval(wrappedSrc)
}

모듈의 소스코드는 노출식 모듈 패턴과 마찬가지로 기본적으로 함수로 감싸진다. 여기서 차이점은 일련의 변수들(module, exports 그리고 require)을 모듈에 전달한다는 것이다. 눈 여겨봐야 할 점은 래핑 함수의 exports 인자가 module.exports 의 내용으로 초기화 되었다는 것이다.

 

또 다른 주요 사항은 모듈의 내용을 읽어들이기 위해서 readFileSync를 사용했다는 것이다. 파일 시스템의 동기식 버전을 사용하는 것은 일반적으로 권장되지 않지만 여기서는 이것의 사용이 적절하다. CommonJS에서 모듈을 로드하는 것이 의도적인 동기 방식이기 때문이다. 이러한 방식에서는 여러 모듈을 임포트할 때 올바른 순서를 지키는 것이 중요하다.

 

참고. eval() 함수나 vm 모듈의 함수들은 잘못된 방식이나 자롬ㅅ된 인자를 가지고 쉽게 사용될 수 있어, 코드 인젝션 공격에 노출될 수 있다. 이러한 것들을 극도로 주의를 기울여 사용하거나 아예 사용하지 않는 것이 좋다.

 

function require(moduleName) {
	console.log(`Require invoked for moduel: ${moduleName}`)
    const id = require.resolve(moduleName)						// (1)
    if (require.cache[id]) {						// (2)
    	return require.cache[id].exports
    }
    
    // 모듈 메타데이터
    const module = {						// (3)
    	exports: {},
        id
    }
    // 캐시 업데이트
    require.cache[id] = module						// (4)
    
    // 모듈 로드
    loadModule(id, module, require)						// (5)
    
    // 익스포트되는 변수 반환
    return module.exports						// (6)
}
require.cache = {}
require.resolve = (moduleName) => {
	/* 모듈이름으로 id로 불리게 되는 모듈의 전체경로를 찾아냄(resolve) */
}

위의 함수는 Node.js에서 모듈을 로드하기 위해 사용되는 Node.js require() 함수의 동작을 모방하고 있다.

물론 이는 교육적인 목적을 위한 것이며, 실제 require() 함수의 내부 동작을 정확하고 완전하게 반영하고 있는 것은 아니다. 그러나 모듈이 어떻게 정의되고 로드되는지를 포함해서 Node.js 모듈 시스템의 내부를 이해하기에는 부족함이 없을 것이다.

 

우리가 작성한 모듈 시스템은 다음과 같이 설명된다.

1. 모듈 이름을 입력으로 받아 수행하는 첫 번쨰 일은 우리가 id라고 부르는 모듈 전체경로를 알아내는(resolve) 것이다. 이 작업은 이를 해결하기 위해 관련 알고리즘을 구현하고 있는 require.resolve()에 위임된다. (나중에 설명 예정)

2. 모듈이 이미 로드된 경우 캐시된 모듈을 사용한다. 이 경우 즉시 반환한다.

3. 모듈이 아직 로드되지 않은 경우 최초 로드를 위해서 환경 설정을 한다.

4. 최초 로드 후에 module 객체가 캐시된다.

5. 모듈 소스코드는 해당 파일에서 읽어오며, 코드 앞에서 살펴본 방식으로 평가된다. 방금 생성한 module 객체와 require() 함수의 참조를 모듈에 전달한다. 모듈은 module.exports 객체를 조작하거나 대체하여 public API를 내보낸다.

6. 마지막으로, 모듈의 public API를 나타내는 module.exports의 내용이 호출자에게 반환된다.

 

2-4-2 모듈 정의

우리가 만든 require() 함수가 어떻게 작동하는지 살펴봄으로써, 모듈을 어떻게 정의하는지 이해할 수 있게 되었다. 다음의 코드는 그 예를 보여준다.

// 또 다른 종속성 로드
const dependency = require('./anotherModule')

// private 함수
function log() {
	console.log(`Well done ${dependency.username}`)
}

// 공개적으로 사용되기 위해 익스포트되는 API
module.exports.run = () {
	log()
}

기억해야 할 기본 개념은 module.exports 변수에 할당되지 않는 이상, 모듈 안의 모든 것이 비공개라는 것이다. require()를 사용하여 모듈을 로드할 때 변수의 내용은 캐시되고 리턴된다.

 

2-4-3 module.exports 대 exports

Node.js에 익숙하지 않은 많은 개발자들이 공통적으로 혼란스러워 하는 것은 public API를 공개하기 위해 사용하는 module.exports와 exports의 차이점이다. 앞서 작성한 require함수를 통해 이 차이점을 명확하게 이해할 수 있다. 변수 exports는 module.exports의 초기 값에 대한 참조일 뿐이다. 우리는 이 값이 본질적으로 모듈이 로드되기 전에 만들어지는 간단한 객체 리터럴이라는 것을 확인했다.

 

즉, 다음 코드와 같이 exports가 참조하는 객체에만 새로운 속성(property)을 추가할 수 있다.

exports.hello = () => {
	console.log('Hello')
}

exports 변수의 재할당은 module.exports의 내용을 변경하지 않기 때문에 아무런 효과가 없다. 그것은 exports 변수 자체만을 재할당한다. 따라서 이런 코드는 잘못된 것이다.

exports = () => {
	console.log('Hello')
}

함수, 인스턴스 또는 문자열과 같은 객체 리터럴 이외의 것을 내보내려면 다음과 같이 module.exports를 다시 할당해야 한다.

module.exports = () => {
	console.log('Hello')
}

 

2-4-4 require 함수는 동기적이다.

require() 함수는 동기적이다. 그 결과 module.exports 에 대한 할당도 역시 동기적이어야 한다. 예를 들어 다음 코드는 올바르지 않다.

setTimeout(() => {
	module.exports = function () {...}
}, 100)

동기적 특성을 지닌 require()는 모듈을 정의할 때 동기적으로 코드를 사용하도록 제한함으로써 우리가 모듈을 정의하는 방식에 영향을 미친다.

 

모듈을 비동기적으로 초기화하게 된다면 미래 시점에 비동기적으로 초기화되기 때문에 미처 초기화되지 않은 모듈을 정의하고 내보낼 수 있다. 원래 Node.js는 비동기 버전의 require()를 사용했는데, 과도한 복잡성으로 인해 곧 제거되었다. 즉, 실제로 초기화 시에만 사용되는 비동기 입출력이 장점보다 더 큰 복잡성을 가져온 것이다.

 

 

2-4-5 해결(resolving) 알고리즘

`종속성 지옥(dependency hell)'이라는 용어는 프로그램의 종속성이 서로 공통된 라이브러리에 의존하지만 호환되지 않는 서로 다른 버전을 필요로 하는 상황을 나타낸다. Node.js는 로드되는 위치에 따라 다른 버전의 모듈을 로드할 수 있도록 하여 이 문제를 해결한다. 이 특성의 장점은 Node.js 패키지 매니저(npm 또는 yarn)가 애플리케이션의 종속성을 구성하는 방식과 require() 함수에서 사용하는 해결(resolving) 알고리즘에도 적용된다.

 

resolve() 함수는 모듈 이름을 입력으로 사용하여 모듈 전체의 경로를 반환한다. 이 경로는 코드를 로드하고 모듈을 고유하게 식별하는데 사용된다. 해결(resolving) 알고리즘은 크게 다음 세 가지로 나눌 수 있다.

  • 파일 모듈: moduleName이 / 로 시작하면 모듈에 대한 절대 경로라고 간주되어 그대로 반환된다. 만약 ./로 시작하면 moduleName은 상대 경로로 간주되며, 이는 요청한 모듈로부터 시작하여 계산된다.
  • 코어 모듈: moduleName이 / 또는 ./ 로 시작하지 않으면 알고리즘은 먼저 코어 Node.js 모듈 내에서 검색을 시도한다.
  • 패키지 모듈: moduleName과 일치하는 코어 모듈이 없는 경우, 요청 모듈의 경로에서 싲가하여 디렉터리 구조를 탐색하여 올라가면서 node_modules 디렉터리를 찾고 그 안에서 일치하는 모듈을 계쏙 찾는다. 알고리즘은 파일 시스템의 루트에 도달할 때까지 디렉터리 트리를 올라가면서 다음 node_modules 디렉터리를 탐색하여 계속 일치하는 모듈을 찾는다.

파일 및 패키지 모듈의 경우 개별 파일과 디렉터리가 모두 moduleName과 일치할 수 있다. 알고리즘은 다음과 일치하는지 확인한다.

  • <moduleName>.js
  • <moduleName>/index.js
  • <moduleName>/package.json의 main 속성에 지정된 디렉터리/파일

node_modules 디렉터리는 실제로 패키지 매니저가 각 패키지의 종속성을 설치하는 곳이다. 즉, 방금 설명한 알고리즘을 기반으로 각 패키지는 자체적으로 개별적인 종속성을 가질 수 있다. 예를 들면, 다음과 같은 디렉터리 구조를 생각해 볼 수 있다.

위의 예제에서 myApp, depB 그리고 depC 모두 depA에 종속성을 가지고 있다. 그러나 이들은 모두 자신의 개별적인 버전에 대한 종속성을 가지고 있다. 해석 알고리즘 규칙에 따라 require('depA')를 사용하면 모듈을 필요로 하는 모듈에 따라 다른 파일이 로드된다.

예를 들면 다음과 같다.

  • /myApp/foo.js에서 require('depA')를 호출할 경우 /myApp/node_modules/depA/index.js가 로드 된다.
  • /myApp/node_modules/depB/bar.js에서 require('depA')를 호출할 경우 /myApp/node_modules/depB/node_modules/depA/index.js가 로드된다.
  • /myApp/node_modules/depC/foobar.js에서 require('depA')를 호출할 경우 /myApp/node_modules/depC/node_modules/depA/index.js가 로드된다.

해결 알고리즘은 Node.js 종속성 관리의 견고성을 뒷받침하는 핵심적인 부분이며, 충돌 혹은 버전 호환성 문제 없이 애플리케이션에서 수백 또는 수천 개의 패키지를 가질 수 있게 한다.

 

2-4-6 모듈 캐시

require()의 후속 후출은 단순히 캐시된 버전을 반환하기 떄문에 각 모듈은 처음 로드될 때만 로드되고 평가된다. 캐싱은 성능을 위해 매우 중요하지만 다음과 같은 기능적인 영향도 있다.

  • 모듈 종속성 내에서 순환을 가질 수 있다.
  • 일정한 패키지 내에서 동일한 모듈이 필요할 떄 얼마간 동일한 인스턴스가 항상 반환된ㄷ는 것을 보장한다.

모듈 캐시는 require.cache 변수를 통해 외부에 노출되므로 필요한 경우 모듈 캐시에 직접 접근할 수도 있다.

 

2-4-7 순환 종속성

많은 사람들이 순환 종속성을 내재된 설계 문제로 생각하지만, 실제 프로젝트에서 발생할 수 있기 떄문에 최소한 CommonJS에서 어떻게 작동하는지 아는 것이 좋다. 우리가 직접 만든 require() 함수를 다시 살펴보면 이것이 어떻게 동작하는지, 무엇을 조심해야 하는지 바로 알 수 있다.

모듈은 main.js에 a.js와 b.js를 require로 불러온다. 차례대로 a.js는 b.js를 require로 불러온다. 하지만 b.js 또한 a.js에 다시 의존하고 있다. 모듈 a.js가 require로 b.js를 부르고 b.js가 require로 a.js를 부르는 것에서 우리는 명백히 순환 종속성을 가지고 있는 걸 알 수 있다.

 

  • 모듈 a.js
exports.loaded = false
const b = require('./b')
module.exports = {
	b,
    loaded: true // 이전 export문을 오버라이드
}
  • 모듈 b.js
exports.loaded = false
const a= require('./a')
module.exports = {
	a,
    loaded: true
}

이 두 모듈이 main.js에서 require 되는 것을 보자.

const a = require('./a')
const b = require('./b')
console.log('a ->', JSON.stringify(a, null, 2))
console.log('b ->', JSON.stringify(b, null, 2))

이 결과를 통해 CommonJS에서 종속성의 로드 순서에 따라서 모듈 a.js와 모듈 b.js에 의해서 익스포트된 것을 우리 애플리케이션의 각 부분이 서로 다르게 가질 수 있다는 순환 종속성의 문제를 살펴보았다. 두 모듈 각자 main.js에서 require로 불려지면 완전하게 초기화 되지만, b.js에서 a.js 모듈을 로드하면 모듈 a.js는 불완전한 상태가 된다. b.js가 require로 호출되는 순간에 다다르게 되는 것이다.

 

단계별로 서로 다른 모듈들이 어떻게 해석되고 어떻게 자신들의 로컬 범위가 변하는지 분석해보자

다음의 순서대로 진행된다.

 

1. main.js에서 처리가 시작되고 즉각적으로 require로 a.js를 불러온다.

2. 모듈 a.js는 처음으로 내보내지는 값인 loaded를 false로 설정한다.

3. 이 시점에서 모듈 a.js는 모듈 b.js를 require로 불러온다.

4. a.js에서와 같이 b.js에서 내보내지는 값인 loaded를 false로 설정한다.

5. b.js는 require로 a.js를 불러온다. (순환)

6. a.js는 이미 처리되었기 때문에 이때 내보내지는 값은 즉시 모듈 b.js의 범위로 복사된다.

7. 모듈 b.js는 마지막으로 loaded의 값을 true로 바꾼다.

8. 이제 b.js는 완전히 실행되었고 제어는 a.js로 반환한다. 현재 모듈 b.js의 상태 값을 복사하여 a.js의 범위를 갖는다.

9. 모듈 a.js의 마지막 단계는 loaded의 값을 true로 바꾸는 것이다.

10. 모듈 a.js는 현재 완전히 실행되었고 제어는 main.js로 반환된다. main.js는 현재 모듈 a.js의 상태를 복사하여 내부 범위에 갖는다.

11. main.js는 require로 b.js를 불러오고 즉각적으로 캐시된 것을 로드한다.

12. 현재 모듈 b.js의 상태가 모듈 main.js로 복사되고 우리는 모든 모듈의 마지막 상태를 그림에서 볼 수 있다.

 

앞서 언급하였듯이, 문제는 모듈 b.js가 모듈 a.js의 완전하지 않은 상태를 바라보게 되고, 이것은 b.js가 main.js에서 require될 때 전파된다는 것이다. 이러한 동작은 우리가 main.js에서 require로 불려지는 두 모듈의 순서를 바꾸어 보았을 때 반대로 b.js의 불완전한 버전을 a.js가 받게 되는 것을 직관적으로 알 수 있게 해준다.

 

이 예로 우리는 어떤 모듈이 먼저 로딩되는지를 놓치게 되면 매우 헷갈리는 문제가 될 수 있다. 프로젝트의 규모가 어느 정도 된다면 꽤 쉽게 발생할 수 있는 문제이다.

 

2-5 모듈 정의 패턴

모듈 시스템은 종속성을 로드하는 메커니즘이 되는 것 외에 API를 정의하기 위한 도구이기도 하다. API 디자인과 관련되 문제들의 경우 고려해야 할 주요 요소는 private 함수와 public 함수 간의 균형이다. 기서의 목표는 확장성과 코드 재사용 같은 소프트웨어 품질과의 균형을 유지하면서 정보 은닉 및 API 유용성을 극대화하는 것이다.

 

이 섹션에서는 Node.js에서 모듈을 정의할 때 export 지정, 함수, 클래스 그리고 인스턴스 내보내기, 몽키 패치와 같이 가장 많이 사용되는 몇 가지 패턴을 분석한다. 각각 자신만의 정보 은닉, 확장성 및 코드 재사용을 위한 적절한 방식을 가지고 있다.

 

2-5-1 exports 지정하기(Named exports)

public API를 공개하는 가장 기본적인 방법은 exports에 할당하는 것이다. 이렇게 하면 exports엣서 참조하는 객체(또는 module.exports)의 속성에 공개할 모든 값을 할당한다. 그 결과 외부에 공개된 일련의 관련 기능에 대한 컨테이너 또는 네임스페이스가 된다.

 

// logger.js 파일
exports.info = (message) => {
	console.log(`info: ${message}`)
}

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

이렇게 내보내진 함수들은 다음에 보는 바와 같이 로드된 모듈의 속성처럼 사용이 가능하다.

// file main.js
const logger = re quire('./logger')
logger.info('This is an informational message')
logger.verbose('This is a verbose message')

Node.js 코어 모듈 대부분은 이 패턴을 사용한다. CommonJS 명세에는 public 멤버들을 공개하는데 exports 변수 만을 사용하도록 하고 있다. 따라서 exports로 지정하는 것이 CommonJS의 명세와 호환되는 유일한 방식이다. module.exports는 Node.js가 제공하는 모듈 정의 패턴의 광범위한 범위를 지원하기 위한 것으로, 우리가 다음에 보게 될 것은 이것의 확장이다.

 

2-5-2 함수 내보내기

가장 일반적인 모듈 정의 패턴 중 하나가 module.exports 변수 전체를 함수로 재할당하는 것 이다. 주요 장점은 모듈에 대한 명확한 진입점을 제공하는 단일 기능을 제공하여 그것에 대한 이해와 사용을 단순화 하는 것이다. 또한 이는 최소한의 노출(small surface area)이라는 원리에 잘 맞아 떨어진다. 모듈을 정의하는 이 방법은 James Haliday(https://github.com/substack)가 많이 사용한 이후로, 커뮤니티에서 서브스택(substack) 패턴으로 알려져 있다. 다음 예제로 이 패턴을 살펴보자

 

// logger.js 파일
module.exports = (message) => {
	console.log(`info: ${message}`)
}

생각해 볼 수 있는 이 패턴의 응용은 익스포트된 함수를 다른 public API의 네임스페이스로 사용하는 것이다. 이렇게 하면, 모듈에 단일 진입점(익스포트된 함수)의 명확성을 제공하므로 매우 강력한 조합이 된다. 또한 이 접근 방ㅇ식을 응용하여 그 이상의 고급 유스케이스(usecase)를 만들 수 있는 다른 부가적인 기능들을 노출할 수 있다. 다음 코드는 익스포트된 함수를 네임스페이스로 사용하여 앞에 정의한 모듈을 어떻게 확장할 수 있는지 보여준다.

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

또 아래 코드는 방금 정의한 모듈을 사용하는 방법을 보여준다.

// main.js 파일
const logger = require('./logger')
logger('This is an informational message')
logger.verbose('This is a verbose message')

단순히 함수를 내보내는 것이 제약처럼 보일 수 있지만 실제로는 단일 기능에 중점을 두도록 하는 완벽한 방법이며, 내부 형태에 대한 가시성을 줄이면서 이외 보조적인 사항들을 익스포트된 함수의 속성으로 노출하여 단일 진입점을 제공한다. Node.js의 모듈성은 한 가지만 책임지는 원칙(SRP: Single Responsibility Principle)을 지킬 것을 강력히 권장한다. 모든 모듈은 단일 기능에 대한 책임을 져야 하며, 책임은 모듈에 의해 완전히 캡슐화되어야 한다.

 

2-5-3 클래스 내보내기

클래스를 내보내는 모듈은 함수를 내보내는 모듈이 특화된 것이다. 차이점은 이 새로운 패턴을 통해 사용자에게 생성ㅇ자를 사용하여 새 인스턴스를 만들 수 있게 하면서, 프로토타입을 확장하고 새로운 클래스를 만들 수 있는 기능을 제공할 수 있다는 것이다. 다음은 이 패턴의 예시이다.

class Logger {
	constructor(name) {
    	this.name = name
    }
    
    log(message) {
    	console.log(`[${this.name}] ${message}`)
    }
    
    info(message) {
    	this.log(`info: ${message}`)
    }
    
    verbose(message) {
    	this.log(`verbose; ${message}`)
    }
}
module.exports = Logger

그리고 다음과 같이 위 모듈을 사용할 수 있다.

// main.js 파일
const Logger = require('./logger')
const dbLogger = new Logger('DB')
dbLogger.info('This is an informational message')
const accessLogger = new Logger('ACCESS')
accessLogger.verbose('This is a verbose message')

클래스를 내보내는 것은 여전히 모듈에 대한 단일 진입점을 제공하지만 서브스택 패턴과 비교하면 훨씬 더 맣은 모듈의 내부를 노출한다. 그러나 다른 한편으로는 기능 확장에 있어 훨씬 더 강력할 수 있다.

 

2-5-4 인스턴스 내보내기

우리는 require()의 캐싱 메커니즘의 도움을 통해 생성자나 팩토리로부터 서로 다른 모듈 간에 공유할 수 있는 상태 저장 인스턴스를 쉽게 정의할 수 있다. 다음 코드는 이 패턴의 예시이다.

// logger.js 파일
class Logger {
	constructor(name) {
    	this.count = 0
        this.name =name
    }
    log(message) {
    	this.count++
        console.log('[' + this.name + ']' + message)
    }
}
module.exports = new Logger('DEFAULT')

이렇게 새로 정의된 모듈은 다음과 같이 사용할 수 있다.

// main.js
const logger = require('./logger')
logger.log('This is an informational message')

모듈이 캐시되기 때문에 logger 모듈을 필요로 하는 모든 모듈은 실제로 항상 동일한 객체의 인스턴스를 받아 상태를 공유한다. 이 패턴은 싱글톤을 만드는 것과 매우 비슷하다. 그러나 전통적인 싱글톤 패턴에서처럼 전체 애플리케이션에서 인스턴스의 고유성을 보장하지는 않는다. 해결(resolving) 알고리즘을 살펴볼 때 모듈이 애플리케이션의 종속성 트리 내에서 여러 번 설치될 수 있다는 것을 보았다. 결과적으로 동일한 논리적 모듈의 여러 인스턴스가 모두 동일한 Node.js 애플리케이션의 컨텍스트에서 실행될 수 있다.

 

이 패턴에서 한 가지 흥미로운 점은 비록 우리가 명시적으로 클래스를 내보내지는 않았지만 새로운 인스턴스를 만들지 못하게 막지 않았다는 것이다. 사실 우리는 익스포트된 인스턴스의 constructor 속성에 기반해서 같은 타입의 새로운 인스턴스를 만들 수 있다.

const customLogger = new logger.constructor('CUSTOM')
customLogger.log('This is an informational message')

위에서 보듯이 logger.constructor()를 이용하여 새로운 Logger 객체를 초기화했다. 이 기법은 신중하게 사용하거나 아예 사용하지 않는 것이 좋다. 생각해보면 모듈의 제작자가 클래스를 명시적으로 내보내지 않았다는 것은 제작자가 클래스를 private 클래스로 유지하고 싶었다는 것일 수도 있기 때문이다.

 

2-5-5 다른 모듈 또는 전역 범위(global scope) 수정

모듈이 아무것도 내보내지 않을 수도 있다. 이는 다소 부적절하게 보이지만, 우리는 모듈이 캐시에 있는 다른 모듈을 포함하여 전역 범위와 그 안에 있는 모든 개체를 수정할 수 있다는 것을 잊어서는 안된다. 이것은 일반적으로 권장되지 않지만, 이 패턴은 일부 상황(테스트를 위한 상황)에서 유용하고 안전하며, 가끔 실전에서도 사용하기 때문에 이를 이해하고 있어야 한다.

 

앞에서 모듈이 전역 범위의 다른 모듈이나 객체를 수정할 수 있다고 말했다. 이것을 몽키 패치(monkey patching)라고 한다. 일반적으로 런타임 시에 기존 객체를 수정하거나 동작을 변경하는 임시 수정 적용 관행을 그렇게 말한다.

 

다음의 예는 다른 모듈에 새로운 기능을 추가하는 방법을 보여준다.

// patcher.js 파일

// ./logger 는 다른 모듈
require('./logger').customMessage = function () {
	console.log('This is a new functionality')
}

이 새로운 patcher 모듈을 사용하는 방법은 다음 코드와 같이 간단하다.

// main.js 파일

require('./pathcer')
const logger = require('./logger')
logger.customMessage()

여기서 설명된 기술은 모두 적용하기에 위험한 기술이다. 핵심은 전역 네임스페이스나 다른 모듈을 수정하는 모듈을 갖는 데에는 부작용이 있다는 점이다. 다시 말해, 범위를 벗어난 요소의 상태에 영향을 미치므로, 특히 ㅣ여러 모듈이 동일한 속성에 대한 작업을 하는 경우에 예측할 수 없는 결과를 초래할 수 있다. 두 개의 다른 모듈이 동일한 전역 변수를 설정하려고 하거나, 동일한 모듈의 동일한 속성을 수정하려 한다고 생각해보자. 그 효과는 예측할 수 없다. 중요한 것은 전체 애플리케이션에 좋지 않은 영향을 미친다는 것이다.

다시 말하지만, 일어날 수 있는 모든 부작용을 이해하며 신중함을 갖고 이 기법을 사용하자.

 

2-6 ESM: ECMAScript 모듈

ECMAScript 모듈(ES 또는 ESM으로도 알려진)은 ECMAScript 2015 명세의 일부분으로 JavaScript에 서로 다른 실행 환경에서도 적합한 공식 모듈 시스템을 부여하기 위해 도입되었다. 문법은 매우 간단하면서 짜임새를 갖추고 있다. 순환 종속성에 대한 지원과 비동기적으로 모듈을 로드할 수 있는 방법을 제공한다.

 

ESM과 CommonJS 사이의 가장 큰 차이점은 ES 모듈은 static이라는 것이다. 즉, 임포트가 모든 모듈의 가장 상위 레벨과 제어 흐름 구문의 바깥쪽에 기술된다. 또한 임포트할 모듈 이름을 코드를 이용하여 실행 시에 동적으로 생성할 수 없으며, 상수 상수 문자열만이 허용된다.

예를 들면, 다음의 코드는 ES 모듈 사용시에 적합하지 않다.

if (condition) {
	import module1 from 'module1'
} else {
	import module2 from 'module2'
}

반면, CommonJS에서는 다음과 같이 작성하는 것이 전혀 문제되지 않습니다.

let module = null
if (condition) {
	module = require('module1')
} else {
	module = require('module2')
}

얼핏 보기에 ESM의 이러한 특성이 불필요한 제약으로 보일 수 있지만, 정적(static) 임포트를 사용하면 CommonJS의 동적인 특성으로 구현했을 때 비효율적인 여러 가지 시나리오가 가능해진다. 예를 들어, 정적 임포트는 사용하지 않는 코드 제거(tree shaking)와 같이 코드 최적화를 해줄 수 있는 종속성 트리의 정적 분석을 가능하게 해준다.

 

2-6-1 Node.js에서 ESM의 사용

Node.js는 모든 .js 파일이 CommonJS 문법을 기본으로 사용한다고 생각한다. 따라서 우리가 .js 파일에 ESM 문법을 사용한다면 인터프리터는 에러를 낼 것이다. Node.js 인터프리터가 CommonJS 모듈 대신 ES 모듈을 받아들일 수 있는 몇가지 방법이 있다.

  • 모듈 파일의 확장자를 .mjs 로 한다.
  • 모듈과 가장 근접한 package.json의 "type" 필드에 "module"을 기재한다.

 

2-6-2 exports와 imports 지정하기(named exports and imports)

ESM은 export 키워드를 통해 모듈의 기능을 익스포트하게 해준다.

참고. ESM은 CommonJS에서 여러 방법(exports와 module.exports)을 사용하는 것과는 다르게 export 한 단어만 사용한다.

 

ES 모듈에서는 기본적으로 모든 것이 private이며 export된 개체들만 다른 모듈에서 접근 가능하다.

export 키워드는 우리가 모듈 사용자에게 접근을 허용하는 개체 앞에 사용한다. 예제를 보자.

// logger.js

// `log`로서 함수를 익스포트
export function log(message) {
	console.log(message)
}

// `DEFAULT_LEVEL`로서 상수를 익스포트
export const DEFAULT_LEVEL = 'info'

// `LEVELS`로서 객체를 익스포트
expot const LEVELS = {
	error: 0,
    debug: 1,
    warn: 2,
    data: 3,
    info: 4,
    verbose: 5
}

// `Logger`로서 클래스 익스포트
export class Logger {
	constructor(name) {
    	this.name = name
    }
    
    log(message) {
    	console.og(`[${this.name}] ${message}`)
    }
}

우리가 모듈로부터 원하는 개체를 임포트하고 싶다면 import 키워드를 사용한다. 문법은 꽤나 유연하고 하나 이상의 개체를 임포트할 수 있으며 다른 이름으로도 지정할 수도 있다. 다음 예제를 보자.

 

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

이번 예제에서 모듈의 모든 멤버를 임포트하고 loggerModule 변수에 할당하기 위해서 * 문법(네임스페이스 임포트로 불림)을 사용했다. 예제의 출력은 다음과 같다.

위에서 볼 수 있듯이 우리의 모듈에서 익스포트된 모든 개체들을 loggerModule 네임스페이스로 접근할 수 있다. 예를 들어, log() 함수를 loggerModule.log 와 같이 사용할 수 있다.

참조. CommonJS와는 반대로 ESM에서는 파일의 확장자를 궃적으로 명시해야 한다는 것을 주의깊게 봐야 한다. CommonJS 에서는 ./logger 또는 ./logger.js 모두 사용 가능하며 ESM 에서는 우리가 ./logger.js를 사용하도록 하고 있다.

 

만약 우리가 규모가 큰 모듈을 사용하고자 할 때, 모듈의 모든 기능들을 원하지 않고 하나 혹은 몇 개의 개체만을 사용하고 싶을 때 다음과 같은 방법이 있다.

import { log } from './logger.js'
log('Hello World')

하나 이상의 개체를 임포트하고 싶을 때에는 다음과 같이 한다.

import { log, Logger } from './logger.js'
log('Hello World')
const logger = new Logger('DEFAULT')
logger.log('Hello world')

이와 같은 임포트 구문은 임포트되는 개체가 현재의 스코프로 임포트되기 때문에 이름이 충돌할 가능성이 있다. 다음의 코드는 동작하지 않는다.

import { log } from './logger.js'
const log = console.log

만약 위의 스니펫을 실행시킨다면 인터프리터는 다음과 같은 에러와 함께 멈춘다.

이러한 상황에서 우리는 임포트되는 개체의 이름을 as 키워드로 바꾸어주는 것으로 문제를 해결할 수 있다.

 

import { log as log2 } from './logger.js'
const log = console.log

log('message from log')
log2('message from log2')

이 방법은 서로 다른 모듈ㅇ서 같은 이름을 가진 개체의 임포트로 인해서 충돌이 발생하였을때 유용하게 사용되는데, 이것이 모듈 사용자가 모듈의 원래 이름을 바꾸는 방법을 별도로 고려하지 않아도 되게 해준다.

 

2-6-3 export와 import 기본값 설정하기(Default exports and imports)

CommonJS에서 가장 많이 사용되는 특성은 이름이 없는 하나의 개체를 module.exports에 할당하여 익스포트 할 수 있다는 것이다. 모듈 개발자에게 단일 책임 원칙을 권장하고 깔끔한 하나의 인터페이스를 노출시킨다는 것이 매우 편리하다는 사실을 확인했다. ESM에서는 비슷한 동작을 할 수 있는데, default export라고 불린다. 이를 위해서 다음의 예제와 같이 export default 키워드가 사용된다.

// logger.js
export default class Logger {
	constructor(name) {
    	this.name = name
    }
    
    log(message) {
    	console.log(`[${this.name}] ${message}`)
    }
}

이 경우에 Logger라는 이름은 무시되며, 익스포트되는 개체는 default라는 이름 아래 등록된다. 익스포트된 이름은 특별한 방법으로 다루게 된다. 다음의 예제에서처럼 임포트된다.

// main.js

import MyLogger from './logger.js'
const logger = new MyLogger('info')
logger.log('Hello World')

default export는 이름이 없는 것으로 간주되기 떄문에 이름을 명시한 ESM의 import와는 다르다. 임포트와 동시에 우리가 지정한 이름으로 할당된다. 위의 예제에서 MyLogger를 상황에 맞는 것으로 바꿀 수 있다. 이것은 CommonJS 모듈에서 우리가 했던 것과 매우 비슷하다. 주의할 점은 임포트할 모듈의 이름을 중괄호로 감싸지 않아야 하며 이름을 지정할 떄에도 마찬가지이다.

 

내부적으로 default export는 default라는 이름으로 익스포트되는 것과 동일하다. 다음의 코드 스니펫을 시행하여 해당 구문을 쉽게 확인해 볼 수 있다.

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

실행 시, 다음과 같이 출력된다.

한 가지 불가능한 것이 있다면 default 개체를 명시적으로 임포트할 수 없다. 실제로 다음의 예제는 동작하지 않는다.

import { default } from './logger.js'

SyntaxError: Unexpected reserved word error라 적힌 문법오류를 내면서 실행에 실패하는데, 그 이유는 default 라는 이름의 변수가 사용될 수 없기 때문이다. 이것은 객체의 속성으로서는 유효하기 때문에 앞선 예제에서 loggerModule.default를 사용해도 문제가 없다. 하지만 스코프 내에서 default라는 이름의 변수를 직접 사용할 수 없다.

 

2-6-4 혼합된 export(mixed exports)

ES 모듈에서는 이름이 지정된 export와 default export를 혼합하여 사용 가능하다. 예제를 살펴보자.

// logger.js
export default function log(message) {
	console.log(message)
}

export function info(message) {
	log(`info: ${message}`)
}

앞의 코드를 보면 log() 함수가 default export로서 내보내지고 info() 함수는 이름을 가진 export로 내보내진다. 내부적으로 info()가 log()를 참조할 수 있따는 것을 볼 수 있다.

log()를 default()로 호출하는 것은 문법 오류(Unexpected token default)를 내기 떄문에 불가능하다.

 

우리가 default export와 이름을 가진 export를 임포트하기 원한다면 다음과 같은 형식을 사용한다.

import mylog, { info } from './logger.js'

이 예제에서 우리는 logger.js로부터 default export를 mylog라는 이름으로, 그리고 info를 임포트한다.

 

default export와 이름을 가진 export의 차이점에 대한 몇 가지 사항을 알아보도록 하자.

  • 이름을 가진 export는 명확하다. 지정된 이름을 갖는 IDE(통합 개발 환경)로 하여금 개발자에게 자동 임포트, 자동 완성, 리팩토링 툴을 지원할 수 있게 한다. 예를 들어 우리가 writeFileSync를 타이핑한다면 에디터가 자동으로 { writeFileSync } from 'fs'를 현재 파일의 시작점에 자동으로 추가하기도 한다. 하지만 반대로 default export는 주어진 기능이 서로 다른 파일에서 서로 다른 이름을 가질 수 있기에 모든 면에서 좀 더 복잡하다. 따라서 주어진 이름에 어떠한 모듈이 적용될 것인지 추론하기 힘들어진다.
  • default export 모듈에서 가장 핵심적인 한 가지 기능과 연결하는 편리한 방법이다. 또한 사용자의 관점에서 봤을 떄떄, 바인딩을 위한 정확한 이름을 알 필요 없이 기능의 확실한 부분을 쉽게 임포트할 수 있다.
  • default export는 특정 상황에서, 사용하지 않는 코드의 제거(tree shaking) 작업을 어렵게 만든다. 예를들어, 모듈이 객체의 속성을 이용해서 모든 기능을 노출시키는 default export만 제공할 수도 있다. 우리가 이 객체를 임포트했을 때 모듈 번들러는 객체의 전체가 사용되는 것으로 간주하여 노출된 기능 중에 사용되지 않는 코드를 제거할 수 없게 된다.

이러한 이유로 명확하게 하나의 기능으로 익스포트하고 싶을 떄에는 default export를 사용하되, 이름을 사용한 export 사용에 습관을 들이는 것이 일반적으로 좋은 방법이며, 특히 하나 이상의 기능을 내보내고 싶을 때에는 더욱 그렇다.

 

이것은 엄격히 정해진 규칙이 아니며 위의 제안에 대한 주목할 만한 예외사항이 있다. 예를 들어, 모든 Node.js 코어 모듈은 default export와 named export를 동시에 갖고 있고 React(https://reactjs.org/) 역시 혼합된 방식을 사용한다.

 

우리의 모듈에 어떤 것이 최선의 방식인지, 그리고 만든 모듈로 사용자에게 주고 싶은 개발자 경험이 어떤 것인지 고려해서 사용하자.

 

2-6-5 모듈 식별자

모듈 식별자는 import 구문에서 우리가 적재하고 싶은 모듈의 경로를 명시할 때 쓰이는 값이다.

우리는 지금까지 상대 경로를 사용했다. 하지만 알아두어야 할 다양한 방법이 존재한다. 어떤 방법들이 있는지 살펴보자

  • 상대적 식별자(Relative) - ./logger.js 또는 ../logger.js와 같이 임포트하는 파일의 경로에 상대적 경로가 사용된다.
  • 절대 식별다(Absolute) - file:///opt/nodejs/config.js와 같이 직접적이고 명확하게 완전한 경로가 사용된다. 이 방법은 유일하게 ESM에 해당하며 / 또는 // 가 선행하였을 경우에는 동작하지 않는다. CommonJS와는 확연하게 다른 부분이다.
  • 노출 식별자(Bare) - fastify 또는 http와 같이 module_modules 폴더에서 사용 가능하고 패키지 매니저를 통해서 설치된 모듈 또는 Node.js 코어 모듈을 가리킨다.
  • 심층 임포트 식별자(Deep import) - fastify/lib/logger.js와 같이 node_modules에 있는 패키지의 경로를 가리킨다.

브라우저 환경에서는 https://unpkg.com/lodash와 와 같이 모듈의 URL을 명시하여 모듈을 직접 임포트할 수 있다. 이것은 Node.js에서는 지원되지 않는 특성이다.

 

2-6-6 비동기 임포트

이전 섹션에서 본 것처럼 import 구문은 정적이기에, 두 가지 제약이 존재한다.

  • 모듈 식별자는 실행 중에 생성될 수 없다.
  • 모듈의 임포트는 모든 파일의 최상위에 선언되며, 제어 구문 내에 포함될 수 없다.

이러한 제약점이 과도한 제약이 되는 경우의 몇몇 유스케이스가 존재한다. 예를 들어 우리가 현재 사용자 언어를 위한 특정 번역 모듈을 임포트해야 하거나, 사용자 운영체제에 의존하는 다양한 모듈을 임포트해야 한다고 생각해보자

 

추가적으로 우리가 상대적으로 무거운 모듈을 사용하고자 할 때, 기능의 특정 부분에만 접근하려 한다면 어떡할까?

 

ES 모듈은 이러한 제약을 극복하기 위해서 비동기 임포트(동적 임포트로도 불림)를 제공한다. 비동기 임포트는 특별한 import() 연산자를 사용하여 실행 중에 수행된다.

 

import() 연산자는 문법적으로 모듈 식별자를 인자로 취하고 모듈 객체를 프라미스로 반환하는 함수와 동일하다.

 

모듈 식별자는 이전 섹션에서 언급된 것 중에서 어떤 것이든지 사용 가능하다. 지금부터 간단한 예제와 함께 동적 임포트를 어떻게 사용하는지 살펴보자.

 

우리는 여러 나라 언어로 "Hello World"를 출력하는 커맨드라인 애플리케이션을 만들고 싶다. 추후에 더 많은 문장과 언어를 지원하고 싶을 수 있기에, 지원되는 언어에 따라 번역을 가지는 팡리을 만드는 것이 합당하다.

 

우리가 지원하고자 하는 언어르 위한 연습용 모듈을 만들어 보자.

// strings-el.js
export const HELLO = 'Γεια σου κόσμε'

// strings-en.js
export const HELLO = 'Hello World'

// strings-es.js
export const HELLO = 'Hola mundo'

// strings-it.js
export const HELLO = 'Ciao mondo'

// strings-pl.js
export const HELLO = 'Witaj świecie'

이제 커맨드라인에 사용될 언어 코드를 받고 선택된 언어의 "Hello World"를 출력하는 main 스크립트를 만들어 보자.

// main.js
const SUPPORTED_LANGUAGES = ['el', 'en', 'es', 'it', 'pl']			// (1)
const selectedLanguage = process.argv[2]			// (2)

if (!SUPPORTED_LANGUAGES.includes(selectedLanguage)) {			// (3)
	console.error("The specified language is not supported")
   	process.exit(1)
}

const translationModule = `./strings-${selectedLanguage}.js`			// (4)
import(translationModule)
	.then((strings) => {			// (5)
    	console.log(strings.HELLO)
    })

스크립트의 첫 번쨰 부분은 간단하다.

1. 지원되는 언어의 리스트를 정의한다.

2. 선택한 언어를 커맨드라인의 첫 번쨰 인자를 받는다.

3. 지원되지 않는 언어가 선택된 경우를 처리한다.

 

코드의 두 번째 부분에서 동적 임포트를 사용한다.

4. 우선 선택된 언어를 사용하여 임포트하고자 하는 모듈의 이름을 동적으로 만든다. 모듈의 이름에 상대경로를 사용하기 위해서 ./를 파일이름 앞에 붙여준다.

5. 모듈의 동적 임포틀르 하기 위해서 import() 연산자를 사용한다.

6. 동적 임포트는 비동기적으로 된다. 그러므로, 모듈이 사용될 준비가 되었을 때를 알기 위해서 .then()을 반환된 프라미스에 사용된다. 모듈이 완전히 적재되었을 때, then()으로 전달된 함수가 실행된다. 그리고 strings는 동적 임포트된 모듈의 네임스페이스가 된다. 마지막으로 strings.HELLO에 접근할 수 있으며 콘솔에 값이 출력된다.

 

다음과 같이 스크립트를 실행한다.

콘솔에 Ciao mondo가 출력되는 것을 볼 수 있다.

 

2-6-7 모듈 적재 이해하기

ESM이 어떻게 동작하고 어떻게 순환 종속성을 다루는지 이해하기 위해서는 ES 모듈을 사용할 때 JavaScript 코드가 어떻게 파싱되고 평가되는지 좀 더 알아봐야 한다.

 

이번 섹션에서는 ECMAScript 모듈이 어떻게 적재되는지 배워보고 읽기 전용 라이브 바인딩에 대한 개념을 소개한다. 마지막으로 순환 종속성 예제를 살펴보겠다.

 

2-6-7 모듈 적재 이해하기

ESM이 어떻게 동작하고 어떻게 순환 종속성을 다루는지 이해하기 위해서는 ES 모듈을 사용할 때 JavaScript 코드가 어떻게 파싱되고 평가되는지 좀 더 알아봐야 한다.

이번 섹션에서는 ECMAScript 모듈이 어떻게 적재되는지 배워보고 읽기 전용 라이브 바인딩에 대한 개념을 소개할 것이다. 마지막으로 순환 종속성 예제를 살펴보자.

 

로딩 단계

인터프리터의 목표는 필요한 모든 모듈의 그래프(종속성 그래프)를 만들어 낸다.

참조. 일반적인 용어로 종속성 그래프는 객체그룹의 종속성들을 나타내는 직접 그래프(https://en.wikipedia.org/wiki/Directed_graph)로서 정의된다. 이 섹션에서 종속성 그래프를 가리킬 때 우리는 ECMAScript 모듈들 사이의 종속성 관계를 나타낼 것이다. 앞으로 살펴보겠지만, 종속성 그래프를 사용하는 것은 주어진 프로젝트에서 적재되는 모든 모듈의 순서를 결정할 수 있게 해준다.

 

인터프리터는 모듈이 실행되어야 할 코드의 순서와 함께 모듈 간에 어떠한 종속성을 갖는지 이해하기 위해서 기본적으로 종속성 그래프르 필요로 한다. Node 인터프리터가 실행되면, 일반적으로 JavaScript 파일 형식으로 실행할 코드가 전달된다. 파일은 종속성 확인을 위한 진입점(entry point)이다. 인터프리터는 진입점에서부터 필요한 모든 코드가 탐색되고 평가될 때까지 import 구문을 재귀적인 깊이 우선 탐색으로 찾는다.

 

좀 더 구체적으로, 3단계에 걸쳐 작업이 진행된다.

  • 1단계 - 생성(또는 파싱): 모든 import구문을 찾고 재귀적으로 각 파일로부터 모든 모듈의 내용을 적재한다.
  • 2단계 - 인스턴스화: 익스포트된 모든 개체들에 대해 명명된 참조를 메모리에 유지한다. 또한 모든 import 및 export문에 대한 참조가 생성되어 이드 간의 종속성 관계(linking)을 추적한다. 이 단계에서는 어떠한 JavaScript 코드도 실행되지 않는다.
  • 3단계 - 평가: Node.js는 마지막으로 코드를 실행하여 이전에 인스턴스화된 모든 개체가 실제 값을 얻을 수 있도록 한다. 이제 모든 준비가 되었기 떄문에 진입점에서 코드를 실행할 수 있다.

쉽게 표혀하자면 1단계는 모든 점들을 찾는 것, 2단계는 각 점들을 연결하여 길을 만드는 것, 3단계는 올바른 순서로 길을 걷는 것이다.

 

이러한 접근 방법은 얼핏 보았을 때 CommonJS와 많이 달라 보이지는 않지만, 근본적인 차이가 존재한다. CommonJS는 동적 성질로 인해서 종속성 그래프가 탐색되기 전에 모든 파일들을 실행시킨다. 이전에 있던 모든 코드가 이미 실행되고도 새로운 require 구문이 매번 나타나는 것을 보았다. 이러한 이유로 if 구문이나 반복문에서도 require를 사용할 수 있고 변수에 모듈 식별자를 생성할 수 있는 것이다.

 

ESM에서는 이러한 3단계가 완전히 분리되어 있다. 종속성 그래프가 완전해지기 전까지는 어떠한 코드도 실행되지 않는다. 그러므로 모듈 임포트와 익스포트는 정적이어야 한다.

 

읽기 전용 라이브 바인딩

순환 의존성에 도움이 되는 ES 모듈의 또 다른 기본적인 특성은 임포트된 모듈이 익스포트된 값에 대해 읽기 전용 라이브 바인딩된다는 개념이다.

 

이것이 의미하는 것이 무엇인지 간단한 예제와 함께 알아보겠다.

// counter.js
export let count = 0
export function increment() {
	count++
}

이 모듈은 두 개의 값을 내보낸다. 간단한 정수 카운터인 count와 카운터를 1씩 증가시키는 increment 함수이다.

이제 이 모듈을 사용하는 코드를 작성해보자.

// main.js
import { count, increment } from './counter.js'
console.log(count) // 0을 출력
incretment()
console.log(count) // 1을 출력
count++ // TypeError: Assignment to constant variable!

코드에서 우리가 볼 수 있는 것은 우리가 count 값을 언제든지 읽을 수 있으며 increment() 함수를 이용하여 이를 변경할 수 있다는 것이다. 하지만 count 변수를 직접적으로 변경시키기려 했을때 마치 우리가 const로 바인딩 된 값을 변경하려 했을 때 발생하는 에러가 나타나게 된다.

 

이것이 입증하는 것은 스코프 내에 개체가 임포트되었을 때, 사용자 코드의 직접적인 제어 밖에 있는 바인딩 값은 그것이 원래 존재하던 모듈(live binding)에서 바뀌지 않는 한, 원래의 값에 대한 바인딩이 변경 불가(read-only binding)하다는 것이다.

 

이러한 접근 방법은 CommonJS와 근본적으로 다르다. 실제로 CommonJS에서는 모듈로부터 require가 되었을 때 exports 객체 전체가 복사(얕은 복사)된다. 이것이 의미하는 것은 숫자나 문자열과 같은 원시(primitive) 변수에 있는 값이 나중에 바뀌었을 때 이것을 제공한 모듈은 변화를 알지 못한다는 것이다.

 

순환 종속성 분석

이제 순환을 마무리하기 위해서 CommonJS 모듈 섹션에서 보았던 순환 종속성 예제를 ESM 문법을 사용하여 재구현해 보자.

먼저 모듈 a.js와 b.js 살펴보자

// a.js
import * as bModule from './b.js'
export let loaded = false
export const b = bModule
loaded = true

// b.js
import * as aModule from './a.js'
export let loaded = false
export const a = aModule
loaded = true

그리고 main.js 파일(진입점)에서 두 모듈을 어떻게 임포트했는지 보자

// main.js
import * as a from './a.js'
import * as b from './a.js'
console.log('a ->', a)
console.log('b ->', b)

 

이번 예제에서는 a.js와 b.js 사이에 실질적인 순환 참족 존재하여 JSON.stringfy를 사용하면 TypeError: Converting circular structure to JSON 에러가 나기 때문에 이것을 사용하지 않았다는 것을 눈여겨볼 필요가 있다.

 

main.js를 실행시키면 다음과 같이 출력된다.

여기서 흥미로운 점은 서로의 부분적 정보만을 가지고 있었던 CommonJS를 사용했을 떄와는 다르게 모듈 a.js와 b.js가 서로에 대한 완전한 내용을 갖는다는 점이다. 그리고 모든 loaded 값이 true로 설정된 것을 볼 수 있다. 또한 현재 스코프에서 사용 가능한 b 인스턴스가 a 내부의 실제 참조이며 b 안의 a 또한 그렇다. 이 때문에 우리가 이 모듈들을 직렬화시키기 위한 JSON.stringfy()를 사용할 수 없는 것이다. 마지막으로 모듈 a.js와 b.js의 임포트 순서를 바꿔도, 마지막 결과물을 바뀌지 않는다. CommonJS와의 동작을 비교할때 또 다른 중요한 차이점이다.

 

이 구체적인 예제를 위해 모듈 분석의 3단계(파싱, 인스턴스화, 평가)에서 무슨 일이 발생하는지 관찰하는 것에 더 많은 시간을 할애할만한 가치가 있다.

 

1 단계: 파싱

파싱 단계에서 진입점(main.js)에서부터 코드의 탐색이 시작된다. 인터프리터는 필요한 모든 모듈을 찾고 모듈 파일로부터 소스 코드를 적재하기 위해서 오직 import 구문만을 찾는다. 깊이 우선적으로 종속성 그래프가 탐색되고 모든 모듈이 한번씩만 방문된다. 그림 2.4에서 보는 바와 같이 인터프리터는 트리 구조와 같이 종속성 그래프의 외관을 만든다.

그림 2.4에 주어진 예를 가지고 파싱의 여러 단계를 살펴보겠다.

1. main.js에서 처음으로 발견된 import문이 a.js로 곧장 향하게 한다.

2. a.js에서 b.js로 향하는 import문을 발견한다.

3. b.js에서 a.js로 다시 향하는(순환) import문이 있다. 하지만 a.js는 이미 방문했기 때문에 그 경로는 다시 탐색되지 않는다.

4. 이 시점에서는 b.js가 다른 import문을 가지고 있지 않기 떄문에 탐색이 a.js로 되돌아 간다. 그리고 a.js에서 다른 import문을 가지고 있지 않기 때문에 main.js로 돌아간다. 여기서 우리는 b.js의 임포트 지점을 발견한다. 그러나 해당 모듈이 이미 탐색되었기 때문에 그 경로는 무시된다.

 

이제 순환 종속성 그래프릥 깊이 우선 방문이 끝나고 그림 2.5에서 보는 바와 같이 선형적인 모듈들의 모습을 갖게 된다. 

이 특별한 구조는 꽤 간단하다. 더욱 많이 있는 실제와 비슷한 시나리오에서는 구조가 트리 구조에 가깝게 볼 수 있다.

 

2 단계: 인스턴스화

인스턴스화 단계에서는 인터프리터가 이전 단계에서 얻어진 트리 구조를 따라 아래에서 위로 움직인다. 인터프리터는 모든 모듈에서 익스포트된 속성을 먼저 찾고 나서 메모리에 익스포트된 이름의 맵을 만든다.

그림 2.6 은 모든 모듈이 인스턴스화 되는 순서를 묘사이다.

1. 인터프리터는 b.js에서 시작하며 모듈이 loaded와 a를 익스포트하는 것을 포착한다.

2. 인터프리터는 loaded와 b를 익스포트하는 a.js로 이동한다.

3. 마지막으로 main.js로 이동하며, 더 이상의 기능에 대한 익스포트가 없다.

4. 마지막 단계에서 익스포트 맵은 익스포트된 이름의 추적만을 유지한다. 연관된 값은 현재로는 인스턴스화 되지 않은 것으로 간주한다.

 

인터프리터는 이 단계들을 거치고 나서 그림 2.7에서 보는 바와 같이 임포트를 하는 모듈에게 익스포트된 이름의 링크를 전달한다.

그림 2.7에서 본 것을 다음의 단계들을 통하여 설명할 수 있다.

1. 모듈 b.js는 aModule라는 이름으로 a.js에서의 익스포트를 연결한다.

2. 모듈 a.js는 bModule라는 이름으로 b.js에서의 익스포트를 연결한다.

3. 마지막으로 main.js는 b라는 이름으로 b.js에서의 모든 익스포트를 임포트한다. 비슷하게 a라는 이름으로 a.js에서의 모든 익스포트를 임포트한다.

4 . 다시 말하지만 모든 값이 아직 인스턴스화 되지 않았다는 것을 주목하자. 이 단계에서는 다음 단계의 마지막에 사용 가능한 값에 대한 참조만을 연결한다.

 

3 단계: 평가

마지막 단계는 평가 단계이다. 모든 파일의 모든 코드가 실행된다. 실행 순서는 원래의 종속성 그래프에서 후위 깊이 우선 탐색으로 다시 아래에서 위로 올라간다. 이러한 접근 방법으로 마지막에 실행되는 파일은 main.js이다. 이 방식이 울리가 메인 비지니스 로직을 수행하기 전에 익스포트된 모든 값이 초기화 되는 것을 보장해준다.

무슨 일이 일어나는지 그림 2.8에 있는 다이어그램을 따라가보겠다.

1. b.js부터 수행되며, 첫 번째 라인은 모듈에서 익스포트되는 loaded 값이 false로 평가된다.

2. 마찬가지로, 익스포트되는 속성 a가 평가된다. 이번에는 export 맵의 모듈 a.js를 나타내는 모듈 객체에 대한 참조로 평가된다.

3. loaded 속성의 값이 true로 바뀐다. 이 시점에서 모듈 b.js의 익스포트 상태가 완전히 평가된다.

4. 이제 a.js로 수행이 이동되거, 다시 loaded를 false로 설정하는 것으로 시작한다.

5. 이 때, export b가 익스포트 맵에서 모듈 b.js에 대한 참조로 평가된다.

6. 마지막으로 loaded 속성은 true로 바뀐다. 이제 우리는 모듈 a.js에서도 완전히 평가된 모든 익스포트를 갖게 된다.

 

모든 단계를 거치고 나서 main.js의 코드가 실행되며, 이 떄 익스포트된 모든 속성값들은 완전히 평가된 상태이다. 임포트된 모든 모듈들은 참조로 추적되고 우리는 순환 종속성이 존재하는 상황에서도 모든 모듈이 다른 모듈의 최신 상태를 갖고 있음을 확신할 수 있다.

 

2-6-8 모듈의 수정

우리는 읽기 전용 라이브 바인딩인 ES 모듈을 통해 개체들을 임포트하는 것을 보았고 그러한 이유 때문에 외부 모듈에서 그것들을 재할당하는 것이 불가능하다.

 

그러나 주의할 점이 있다. 우리가 다른 모듈에서 default export나 이름을 갖는 export의 바인딩을 바꿀 수 없는 것은 사실이지만, 이 바인딩이 객체라면 우리는 여전히 객체의 특정 속성을 재할당하여 변경하는 것이 가능하다.

 

이는 다른 모듈의 동작을 바꿀 수 있다는 점에서 주의해야 한다. 이러한 발상을 증명하기 위해서 코어 모듈 fs의 동작을 바꾸어 파일 시스템의 접근을 막고 모의 데이터를 리턴하도록 하는 모듈을 작성해보겠다. 이러한 종류의 모듈은 파일 시스템에 의존하게 되는 컴포넌트를 위한 테스트 작성에 유용할 수 있다.

// mock-read-file.js
import fs from 'fs'								// (1)

const originalReadFile = fs.readFile			// (2)
let mockedResponse = null

function mockedReadFile(path, cb) {				// (3)
	setImmediate(() => {
    	cb(null, mockedResponse)
    })
}

export function mockEnable(respondWith) {		// (4)
	mockedResponse = respondWith
    fs.readFile = mockedReadFile
}

export function mockDisable() {					// (5)
	fs.readFile = originalReadFile
}

1. 우리가 처음으로 한 것은 fs 모듈의 default export를 임포트한 것이다. 이 코드는 금방 다시 짚어볼 것이며, 지금은 fs 모듈의 default export가 파일 시스템과 상호작용하게끔 해주는 기능들의 집합을 갖고 있는 객체라는 것을 알아두자.

2. 우리는 모의 구현으로 readFile() 함수를 대체하길 원한다. 이 작업을 하기 전에 원래의 참조 값을 저장한다. 또한 나중에 사용할 mockedResponse 값을 선언한다.

3. mockedReadFile() 함수는 우리가 원래의 구현을 대체하기 위해서 사용하고자 하는 실질적인 모의 구현이다. 이 함수는 mockedResponse의 현재 값과 함께 콜백을 호출한다. 이 구현은 간략화 된 것이다. 실제 함수는 options 인자를 callback 인자 전에 받으며 여러 인코딩의 타입을 다룰 수 있다.

4. 익스포트된 mockEnable() 함수는 모의 기능을 활성화하기 위해 사용될 수 있다. 원래의 구현을 모의 구현으로 바꾼다. 모의 구현은 responseWith 인자를 통해 전달된 값과 같은 것을 리턴한다.

5. 마지막으로 익스포트된 mockDisable() 함수는 fs.readFile() 함수의 원래의 구현으로 복구시키기 위해서 사용될 수 있다.

 

이제 이 모듈을 사용하는 간단한 예제를 보자

// main.js
import fs fro 'fs'									// (1)
import { mockEnable, mockDisable } from './mock-read-file.js'

mockEnable(Buffer.from('Hello World'))				// (2)

fs.readFile('fake-path', (err, data) => {			// (3)
	if (err) {
    	console.error(err)
        process.exit(1)
    }
    console.log(data.toString()) // 'Hello World'
})

mockDisable()