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

[2장] 모듈 시스템(3)

atmosg 2022. 4. 20. 23:35

5. 모듈 정의 패턴

앞선 장에선 모듈 시스템의 기능중 종속성 로드와 관련해 살펴봤다. 이번 장에선 API를 정의하는 도구로써의 모듈 시스템을 살펴보도록 하자. API 디자인과 관련해서 고려해야할 주요 요소는 private과 public의 균형이다. 모듈의 은닉성과 확장성/코드 재사용과 직결되는 부분으로써 API 유용성에 직접적인 영향을 미치기 때문이다.

 

그럼 지금부터 본격적으로 Node.js에서 모듈 정의시 export 지정, 함수, 클래스, 인스턴스 내보내기, 몽키 패치와 같은 주요 패턴에 대해 알아보자.

 

1) exports 지정하기 (Named exports)

Node.js에서 public API를 공개하는 가장 기본적인 방법은 export에 할당하는 것이다. 이는 곧 exports에서 참조하는 객체(module.exports)의 프로퍼티에 할당한다는 의미인데, 이 객체가 이후 외부에 공개되면서 관련 기능을 담고있는 컨테이너(혹은 네임스페이스)가 된다.

// log.js
exports.info = message => console.log(`info : 니가 쓴 메시지가 ${message}느냐`);
exports.verbose = message => console.log(`verbose: 거 장황하게도 써놨네 ${message}`);
// main.js
const log = require('./log');
log.info('오빠') // info: 니가 쓴 메시지가 오빠느냐
log.verbose('나랏말싸미') // verbose: 거 장황하게도 써놨네 나랏말싸미

 

Node.js 코어 모듈 대부분이 위 패턴을 사용하는데, CommonJS의 명세에는 public 멤버를 공개할 경우 exports 변수만을

사용하도록 하고 있다.

 

2) 함수 내보내기

함수를 내보내는 가장 일반적인 패턴 중 하나는 module.exports 변수 자체를 함수로 재할당해버리는 것이다. 서브스택(substack) 패턴이라고도 하는데, 이게 무슨소리냐면 아래와 같은 방식으로 정의하는 것을 말한다.

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

// main.js
const log = require('./log');
log('이거 함수네') // info: 이거 함수네

 

이 패턴을 응용하면 export된 함수를 컨테이너 삼아서 다른 public API를 추가하며 확장이 가능해진다. 뭔가 대단할 것 같지만 그냥 아래처럼 쓸 수 있다는 소리다.

// log.js
// 이 함수가 log라는 모듈의 진입점이다
module.exports = (message) => console.log(`info: ${message}`); 

// 저 함수를 네임스페이스 삼아서
// 다른 API를 정의하고 나중에 갖다쓸 수도 있다
// 뭔가 여러개를 각각 정의해서 export하는 것 처럼 생겼지만
// 본질은 module.exports에 정의된 함수라는 객체에 딸린 API라는 것이다
module.exports.sentence = (message) => console.log(`sentence: ${message}`);
module.exports.verb = (message) => console.log(`verb: ${message}`);

// ※함수는 객체다 (뭔소린가 싶으면 검색해보자)
// main.js
const log = require('./log');
log('이게 뭐냐') // info: 이게 뭐냐
log.sentence('이게 뭐냐고 물었다') // sentence: 이게 뭐냐고 물었다
log.verb('뭐냐') // verb: 뭐냐

단순히 함수 하나를 내보내는 것 뿐이지만 '모듈에 대한 명확한 진입점 제공'이라는 단일 기능에 충실한 패턴이다. 위 경우를 예시로 들자면 log라는 모듈(곧 함수)에 진입(로드)함으로써 출력과 관련된 API를 갖다쓸 수 있게된다는 뜻이다. 그리고 이 말은 곧 한 가지만 책임진다는 단일 책임 원칙(SRP; Single Responsiblity Principle)을 잘 준수한다는 뜻이다.

 

3) 클래스 내보내기

함수를 내보내는 모듈과 비슷하며, 모듈에 대한 단일 진입점을 제공한다. 차이점은 클래스를 통해 새 인스턴스를 만들 수 있는 대신 더 많은 모듈의 내부를 노출한다는 것이다. 

// Logger.js
class Logger {
    constructor(name) {
        this.name = name;
    }

    log(message) {
        console.log(`[${this.name}] ${message}`);
    }

    info(message) {
        console.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('DB info 메시지')
// info: DB info 메시지

const accessLogger = new Logger('ACCESS');
accessLogger.verbose('verbose메시지')
// [ACCESS] verbose: verbose메시지

accessLogger.makeMsg = function(message) {
    console.log(`내가 만든 메서드 ${message}`)
}

accessLogger.makeMsg('이건 static일까?')
// 내가 만든 메서드 이건 static일까?

4) 인스턴스 내보내기

말그대로 생성자나 팩토리로부터 만들어진 인스턴스를 내보내는걸 말한다. require( )의 캐싱 메커니즘 덕분에 서로 다른 모듈에서 공유할 수 있는 인스턴스를 쉽게 만들어낼 수 있다. 이 패턴은 싱글톤을 만드는 방식과 유사하나 (원하는 바는 아니지만) 새로운 인스턴스를 만들어낼 수도 있다.

// Logger.js
class Logger {
    constructor(name) {
        this.name = name;
        this.count = 0;
    }

    log(message) {
        this.count++
        console.log(`[${this.name}] ${message}`);
    }
}

module.exports = new Logger('DEFAULT')
// Main.js
const logger = require('./logger');

console.log(logger)
// Logger { name: 'DEFAULT', count: 0 }

console.log(Object.getOwnPropertyNames(logger.__proto__))
// [ 'constructor', 'log' ]

logger.log('ㅎㅇ')
// [DEFAULT] ㅎㅇ

// 좋은 예시는 아니지만 
// 프로토타입의 constructor 메서드에 접근해서
// 새로운 인스턴스를 만들어낼 수 있다
// 근데 진짜 나쁜 예시로 안쓰는게 좋다
const customLogger = new logger.constructor('CUSTOM');
customLogger.log('커스텀했다')
// [CUSTOM] 커스텀했다

5) 타 모듈 / 전역 범위(global scope) 수정하기

먼저 말해두겠지만, 이 기법은 부작용이 매우 큰 기법이며, 실제 사용을 위한다기보단 '아 이런 잘못된 방식도 있구나'하는 이해를 쌓기위한 방법임을 알린다.

 

지금까진 모듈이 함수든 클래스든 뭔가를 exports했지만, 아무것도 내보내지 않을 수도 있다. 대신 캐싱돼있는 모듈을 포함해 전역에 존재하는 변수, 개체들을 수정할 수 있다. 이를 몽키 패치(monkey patching)라고 하는데, 이게 무슨 쓸모가 있겠냐 생각할 수 있겠지만 실제로 일부 상황(주로 테스트를 위한)에서 사용된다.

// logger.js
class Logger {
    constructor(name) {
        this.name = name;
    }

    log(message) {
        console.log(`[${this.name}] ${message}`);
    }
}

module.exports = new Logger('DEFAULT')
// patcher.js
require('./logger').customMsg = function() {
    console.log('patcher 모듈에서 임의로 추가한 메서드')
}
// main.js
require('./pacther');

const logger = require('./logger'); 

logger.customMsg() 
// patcher 모듈에서 임의로 추가한 메서드
// 분명 logger 모듈엔 customMsg란 메서드따윈 없다
// 그러나 patcher 모듈에서 임의로 logger 메서드를 바꿔버린 것이다

이러한 방식은 전체 애플리케이션에 악영향을 미치므로 부작용을 반드시 이해하고 있길 바란다.