Javascript/Node.js

모듈 시스템

Frankie 2021. 9. 30. 15:11

모듈이 필요한 이유

1. 코드베이스를 나누어 여러 파일로 분할해서 코드를 좀 더 구조적으로 관리. 각각의 독립적인 기능의 조각들을 개발 및 테스트하는데 도움.

2. 다른 프로젝트에 코드를 재사용 가능

3. 은닉성을 제공

4. 종속성을 관리. 필요한 일련의 종속성들을 쉽게 임포트.

 

CommonJS 내부와 모듈 패턴

 

CommonJS는 Node.js의 첫 번째 내장 모듈 시스템입니다. CommonJS 명세의 두 가지 주요 개념을 요약하면 다음과 같습니다.

- require는 로컬 파일 시스템으로부터 모듈을 임포트하게 해줍니다.

- exports와 module.exports는 특별한 변수로서 현재 모듈에서 공개될 기능들을 내보내기 위해서 사용됩니다.

 

module.exports vs exports

 

많은 사람들이 module.exports와 exports의 차이를 혼란스러워 하는데 require 함수를 통해 이 차이점을 명확하게 이해할 수 있습니다. 

변수 exports는 module.exports의 초기 값에 대한 참조일 뿐입니다. 이 값은 모듈이 로드되기 전에 만들어지는 간단한 객체 리터럴입니다.

즉, exports가 참조하는 객체에만 새로운 속성을 추가할 수 있습니다.

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

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

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

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

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

동기적인 require 함수

 

require() 함수가 동기적이고 모듈 내용을 반환하므로 콜백이 필요하지 않습니다. 그 결과 module.exports에 대한 할당도 역시 동기적이어야 합니다. 아래 예시는 올바르지 않습니다.

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

동기적 특성을 지닌 require()는 모듈을 정의할 때 동기적으로 코드를 사용하도록 제한함으로써 우리가 모듈을 정의하는 방식에 영향을 미칩니다. 이것은 Node.js의 핵심 라이브러리가 비동기 방식에 대한 대안으로 동기식 API를 제공하는 가장 중요한 이유들 중 하나입니다.

 

함수 내보내기

 

가장 일반적인 모듈 정의 패턴 중 하나가 module.exports 변수 전체를 함수로 재할당하는 것입니다.

주요 장점은 모듈에 대한 명확한 진입점을 제공하는 단일 기능을 제공하여 그것에 대한 이해와 사용을 단순화 하는 것입니다. 또한 최소한의 노출이라는 원리에 잘 맞아 떨어집니다. -> 이 방법은 서브스택 패턴으로 알려져 있습니다.

 

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

아래 코드는 export 된 함수를 네임스페이스로 사용하여 앞에 정의한 모듈의 확장을 보여줍니다.

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')

함수를 내보내는 것은 단일 기능에 중점을 두도록 하는 완벽한 방법이며, 내부 형태에 대한 가시성을 줄이면서 이외 보조적인 사항들을 export 된 함수의 속성으로 노출하여 단일 진입점을 제공합니다.

 

Node.js의 모듈성은 한 가지만 책임지는 원칙을 지킬 것을 강력히 권장합니다.

모든 모듈은 단일 기능에 대한 책임을 져야 하며, 책임은 모듈에 의해 완전히 캡슐화되어야 합니다.

 

ES 모듈(ESM)

 

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

 

Node.js에서 ESM의 사용

 

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

 

- 모듈 파일의 확장자를 .mjs로 합니다.

- 모듈과 가장 근접한 package.json의 "type" 필드에 "module"을 기재합니다.

 

exports와 imports 지정하기

 

ESM은 export 키워드를 통해 모듈의 기능을 export 하게 해줍니다.

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

 

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

export 키워드는 우리가 모듈 사용자에게 접근을 허용하는 개체 앞에 사용합니다.

 

예제를 보면

// logger.js

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

//`DEFAULT_LEVEL`로서 상수를 export
export const DEFAULT_LEVEL = 'info'

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

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

import 시에는 import 되는 개체가 현재의 스코프로 import 되기 때문에 이름이 충돌하면 코드가 동작하지 않습니다.

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')

export와 import 기본값 설정하기

 

CommonJS에서 가장 많이 사용되는 특성은 이름이 없는 하나의 개체를 module.exports에 할당하여 export 할 수 있다는 것입니다. ESM에서도 비슷한 동작을 할 수 있는데, default export 라고 불립니다.

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

이 경우 Logger라는 이름은 무시되며, export 되는 개체는 default 라는 이름 아래 등록됩니다. export 된 이름은 특별한 방법으로 다뤄집니다.

// main.js

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

default export는 이름이 없는 것으로 간주되기 때문에 이름을 명시한 ESM의 import 와는 다릅니다. import 와 동시에 우리가 지정한 이름으로 할당됩니다.

 

혼합된 export

 

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로 내보내집니다.

default export와 이름을 가진 export를 import하기 원한다면

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

 

비동기 임포트

 

import 구문은 정적이기에, 두 가지 주요 제약이 존재합니다.

- 모듈 식별자는 실행 중에 생성될 수 없습니다.

- 모듈의 import는 모든 파일의 최상위에 선언되며, 제어 구문 내에 포함될 수 없습니다.

 

CommonJS와 ESM 사이의 차이점 및 상호 이용

 

ES 모듈들은 암시적으로 strict mode에서 실행됩니다. 명시적으로 'use strict' 구문을 모든 파일의 시작에 추가할 필요가 없다는 의미와 같습니다. strict mode를 해제할 수 없지만 더 안전한 실행 모드이기 때문에 확실히 좋은 것이라고 할 수 있습니다.

 

ESM에서의 참조 유실

 

ESM에서는 ES 모듈이 strict mode에서 실행되기 때문에 require, export, module, exports, __filename 그리고 __dirname 을 포함하여 CommonJS 의 몇 가지 중요한 참조가 정의되지 않습니다. 만약 우리가 ES 모듈에서 이러한 것들을 사용한다면 ReferenceError가 발생합니다.

console.log(exports) // ReferenceError: exports is not defined
console.log(module) // ReferenceError: module is not defined
console.log(__filename) // ReferenceError: __filename is not defined
console.log(__dirname) // ReferenceError: __dirname is not defined

__filename 은 현재 모듈 파일의 절대 경로를 __dirname 은 파일이 있는 폴더의 절대 경로를 나타냅니다. 이러한 특별한 변수들은 우리가 현재 파일에 대한 상대 경로를 빌드할 때 매우 유용합니다.

 

ESM에서는 특별한 객체인 import, meta 를 사용하여 현재 파일에 대한 참조를 얻을 수 있습니다. 특히, import,meta,url 은 현재 모듈을 참조하며 file:///path/to/current_module.js 와 같은 형식입니다. 이 값은 절대 경로 형식에 대한 __filename과 __dirname를 재구성하는데 사용할 수 있습니다.

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

또한 require() 함수를 다음과 같이 재구성하는 것이 가능합니다.

import { createRequire } from 'module'
const require = createRequire(import.meta.url)

또 다른 차이점은 this 키워드의 동작입니다.

 

ES 모듈의 전역 범위에서 this 는 undefined인 반면, CommonJS에서는 this가 exports와 같은 참조를 하고 있습니다.

// this.js - ESM
console.log(this) // undefined

// this.cjs - CommonJS
console.log(this === exports) // true

상호운용

 

ESM에서 표준 import 문법을 사용하여 CommonJS 모듈을 import하는 것 또한 가능합니다. 그러나 이는 deafult exports에 한정됩니다.

import packageMain from 'commonjs-package' // 작동
import { method } from 'commonjs-package' // 에러

안타깝게도 CommonJS 모듈에서 ES 모듈을 임포트하는 것은 불가능합니다.

또한 CommonJS에서 꽤 자주 사용되는 기능인 JSON 파일을 직접적으로 가져오기는 ESM에서 불가능합니다. 따라서, 다음 import 구문은 동작하지 않습니다.

import data from './data.json'

위의 코드는 TypeError(Unknown file extension: .json)를 발생합니다.

이러한 제한을 극복하기 위해서 module.createRequire 유틸을 사용할 수 있습니다.

import { createRequire } from 'module'
const require = createRequire(import.meta.url)
const data = require('./data.json')
console.log(data)

ESM에서도 JSON 모듈을 그대로 지원하기 위한 작업은 진행 중입니다. 따라서 우리는 가까운 미래에 createRequire()에 의존할 필요가 없을 수도 있습니다.

'Javascript > Node.js' 카테고리의 다른 글

Promise.all()  (0) 2021.11.02
[mac/vscode] 프로젝트마다 node 버전 다르게 사용하는 방법  (0) 2021.10.26
promise API  (0) 2021.09.28
콜백  (0) 2021.09.27
커스텀 에러와 에러 확장  (0) 2021.09.27