개요
최근 Node.js 환경에서 Kafka Client의 소스코드를 분석하는 시간을 가지고있다. 여러 Kafka Client를 확인하던 중, @nestjs/microservices
라이브러리를 알게 되었는데 Kafka, RabbitMQ, Redis와 같은 여러 서비스를 손쉽게 통합할 수 있다는 것을 알게 되었다.
특히, 해당 라이브러리의 소스 코드를 확인하던 중 특이한 점을 발견했는데, 관련된 의존성을 필수적으로 모두 설치해야 하는 타 라이브러리와 달리 어플리케이션에서 필요한 의존성만 설치하면 되는 구조로 되어 있었다. Kafka를 사용해야 한다면 kafkajs
, Redis를 사용해야 한다면 ioredis
의존성이 필요한 것과 같이 말이다.
그렇다면 어떻게 원하는 의존성을 필요할 때만 추가하고 사용할 수 있는 걸까? 가장 먼저, 모듈 시스템과 Dynamic Module Import 그리고 @nestjs/microservices
의 Kafka Client의 소스 코드까지 하나씩 확인해 보도록 하자
Module System
Node.js 환경에서는 여러 Module System 환경을 가지고 있다. 예를 들어, 아무런 설정 없이 Node.js를 사용한다면 CommonJS를 사용할 것이고, 별도로 설정하거나 TypeScript를 기본으로 사용한다면 ES6를 사용할 것이다. 이번에는 각 Module System의 대표적인 CommonJS와 ES6를 간략하게 확인하고 넘어가자
CommonJS
CommonJS는 Node.js의 기본 모듈 시스템으로, require와 module.exports를 통해 모듈을 관리한다.
Node.js 초기부터 사용된 CommonJS는 동기적(synchronous)으로 모듈을 호출한다. 만약, Nest.js를 기본 설정으로 사용한다면 TypeScript의 컴파일된 파일은 CommonJS로 변환될 것이다.
// math.js
module.exports.add = (a, b) => a + b;
module.exports.subtract = (a, b) => a - b;
// app.js
console.log("Hello"); // "Hello"
const math = require('./math');
console.log(math.add(2, 3)); // 5
console.log(math.subtract(5, 2)); // 3
ESM(ECMAScript Modules)
ESM은 ECMAScript 2015(ES6) 표준에 도입된 모듈 시스템으로, import와 export를 통해 모듈을 관리한다.
ESM은 TypeScript 및 최신 JavaScript에서 사용하는 Module System이다. Nest.js를 사용해 보았다면 가장 익숙한 Module
System일 것이다.ESM은 CommonJS와 달리 모듈을 비동기(asynchronous)으로 호출하며, 런타임 이전에 모듈을 미리 로드하여 의존성을 인지할 수 있다.
// math.mjs
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
// app.mjs
import { add, subtract } from './math.mjs';
console.log(add(2, 3)); // 5
console.log(subtract(5, 2)); // 3
Dynamic Module Import
Dynamic Module Import는 런타임에 모듈을 동적으로 호출하는 방법을 말한다.
Node.js의 CommonJs와 ESM 모듈 시스템은 일반적으로 정적 모듈 호출(Static Module Import)에 최적화되어 있다. 하지만, 어플리케이션이 특정 상황에서만 모듈을 호출하거나, 초기 로드 시간을 줄이기 위해 지연 로드(Lazy Loading)가 필요한 경우, 동적 모듈 호출(Dynamic Module Import)이 필요하게 된다. 이번에는 각 모듈 시스템에서 동적으로 모듈을 호출하기 위해 어떤 방법을 사용하는지 확인해 보자.
CommonJS
CommonJS는 동기적(Synchronous)으로 동작하며, require
함수를 통해 런타임에 모듈을 호출할 수 있다. 일반적인 모듈을 호출하는 방식을 이용하더라도 동적으로 모듈을 호출할 수 있기 때문에, CommonJS 에서는 동적으로 모듈을 호출하기 수월하다.
한번, 특정 디렉터리에 있는 파일을 동적으로 로드하여, API Routing 을 구성하는 방법을 확인해 보자.
const path = require('path');
const fs = require('fs');
const routeFiles = fs.readdirSync('./routes');
routeFiles.forEach(file => {
const route = require(path.join(__dirname, 'routes', file));
app.use(route.path, route.handler);
});
CommonJS는 동적 모듈 호출이 자연스럽게 처리되므로, 별도의 타입 문제나 런타임 에러 없이 사용할 수 있다.
ESM(ECMAScript Modules)
ESM은 기본적으로 비동기적(asynchronous)으로 동작하며, import
문을 통해 정적 모듈 호출을 기본으로 지원한다. ESM 또한 CommonJS와 같이 동적 모듈 호출(Dynamic Module Import) 기능을 제공하는데, import() 함수를 통해 런타임에 모듈을 동적으로 로드할 수 있도록 지원한다.
async function loadModule(moduleName) {
try {
const module = await import(`./modules/${moduleName}.js`);
module.run();
} catch (error) {
console.error('Error loading module:', error);
}
}
loadModule('exampleModule'); // 런타임에 exampleModule.js를 로드
ESM에서는 동적 모듈 호출을 위해 import()
함수를 사용하며, Promise
를 반환하므로 await
키워드를 사용하여 비동기적으로 모듈을 호출할 수 있다. 하지만, 모듈이 존재하지 않거나, 잘못된 경로를 사용하게 된다면 런타임 과정에서 에러가 발생할 수 있는 문제점이 있어 주의해야 한다.
TypeScript
TypeScript에서는 Dynamic Import가 런타임에서 동작하지만, 컴파일 타임에 타입을 알 수 없기 때문에 다음과 같은 이슈가 발생한다.
- 타입 추론 이슈:
import()
로 호출한 모듈의 타입을 TypeScript가 추론하지 못한다. - 타입 선언 필요: Dynamic Module Import된 모듈은
any
타입으로 처리되므로, 명시적인 타입 선언이 필요하다.
위와 같이, Dynamic Module Import의 문제점은 JavaScript에서는 크게 문제가 되지 않지만, TypeScript 에서는 일부 문제점이 발생할 수 있다. 예를 들어, 파일 시스템을 사용하는 fs
모듈을 호출한다고 가정해 보자.
async function loadFsModule() {
const fs = await import('fs');
console.log(fs.readFileSync); // 타입 추론 불가, any로 처리
}
원하는 모듈을 동적으로 호출하더라도, TypeScript에서는 타입 추론이 불가능하므로, 명시적으로 타입을 선언해야 한다.
@nestjs/microservices
그렇다면, @nestjs/microservices
라이브러리는 어떤 방식으로 여러 의존성을 동적으로 호출하고 관리하고 있을까? 이번에는 @nestjs/microservices
의 Kafka Client의 소스코드를 확인하며 Dynamic Module Import에 대해 확인해 보도록 하자.
Dynamic Module Import를 위한 Package.json
Node.js에서는 의존성을 관리하기 위해서 package.json 파일에서 dependencies
, devDependencies
, peerDependencies
를 사용한다. 여기서, peerDependencies
는 일반적으로 잘 사용하진 않지만, 필요한 의존성을 명시적으로 설치하도록 유도하는 데 사용된다.
한번, @nestjs/microservices
라이브러리의 package.json을 확인한다면 아래와 같이 작성된 것을 알 수 있다.
{
"name": "@nestjs/microservices",
"version": "11.0.1",
"dependencies": {
"iterare": "1.2.1",
"tslib": "2.8.1"
},
"devDependencies": {
"@nestjs/common": "11.0.1",
"@nestjs/core": "11.0.1"
},
"peerDependencies": {
"@grpc/grpc-js": "*",
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/websockets": "^11.0.0",
"amqp-connection-manager": "*",
"amqplib": "*",
"cache-manager": "*",
"ioredis": "*",
"kafkajs": "*",
"mqtt": "*",
"nats": "*",
"reflect-metadata": "^0.1.12 || ^0.2.0",
"rxjs": "^7.1.0"
},
}
위와 같이, @nestjs/microservices
라이브러리는 큰 의존성을 가지고 있지 않다. 다만, peerDependencies
항목에는 필요한 여러 의존성의 버전이 상세하게 등록된 것을 알 수 있다. 해당 라이브러리의 package.json만 확인하더라도, 실제 구동하는데 필요한 의존성보다는 연관된 의존성이 많은 것을 알 수 있게 된다.
해당 내용을 분석하게 된다면, 해당 라이브러리는 필요할 때 원하는 의존성을 설치하도록 유도하고 있다는 것을 확인할 수 있다.
하지만, 일반적으로 TypeScript 환경에서 import
를 사용하게 되면, 해당 모듈이 필요한 시점에 미리 설치되어 있어야 한다. 그렇다면, @nestjs/microservices
라이브러리는 어떻게 필요한 의존성을 동적으로 호출하고 사용하는 것일까?
Nest.js Dynamic Module Import
nest.js는 Dynamic Module Import를 위해 공통 모듈에서 loadPackage 함수를 사용하고 있다. 해당 함수는 어떻게 Dynamic Module Import를 구현하고 있는지 확인해 보자.
import { Logger } from '../services/logger.service';
export function loadPackage(
packageName: string,
context: string,
loaderFn?: Function,
) {
try {
return loaderFn ? loaderFn() : require(packageName);
} catch (e) {
logger.error(MISSING_REQUIRED_DEPENDENCY(packageName, context));
Logger.flush();
process.exit(1);
}
}
해당 함수는 ES6 Module System을 사용하고 있으나, CommonJS의 require
함수를 사용하고 있다. 어떻게 이런 방식을 사용할 수 있을 것일까? 여기서는 TypeScript의 특성으로 인해 나타난 문제점인데, TypeScript는 특별한 설정 없이 컴파일한다면 CommonJS로 변환될 것이다. 만약, 이전에 확인한 것과 같이 import()
형식으로 Dynamic Module Import를 구성하게 되었다면, JavaScript 파일에서 지원하지 않는 오류가 발생했다는 이슈가 발생하게 될 것이다.
그렇다면, 빌드된 JavaScript 파일은 어떻게 구성되었을까?
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.loadPackage = void 0;
const logger_service_1 = require("../services/logger.service");
const MISSING_REQUIRED_DEPENDENCY = (name, reason) => `The "${name}" package is missing. Please, make sure to install this library ($ npm install ${name}) to take advantage of ${reason}.`;
const logger = new logger_service_1.Logger('PackageLoader');
function loadPackage(packageName, context, loaderFn) {
try {
return loaderFn ? loaderFn() : require(packageName);
}
catch (e) {
logger.error(MISSING_REQUIRED_DEPENDENCY(packageName, context));
logger_service_1.Logger.flush();
process.exit(1);
}
}
exports.loadPackage = loadPackage;
생각한 것과 동일하게, CommonJS Module System으로 빌드되며, require
함수를 사용하고 있는 것을 확인할 수 있다. 이렇게 구성하게 된다면 런타임 과정에서 이슈가 발생하지 않고 정상적으로 사용할 수 있게 될 것이다.
Kafka Client
Nest.js에서 Dynamic Module Import를 구성하는 것을 모두 확인해 보았다. 그렇다면, @nestjs/microservices
라이브러리에서 Kafka Client를 사용할 때, 어떻게 동적으로 의존성을 호출하고 사용하는지 확인해 보자.
// client-kafka.ts
export class ClientKafka
extends ClientProxy<never, KafkaStatus>
implements ClientKafkaProxy
{
...
constructor(protected readonly options: Required<KafkaOptions>['options']) {
super();
...
kafkaPackage = loadPackage('kafkajs', ClientKafka.name, () =>
require('kafkajs'),
);
}
public createClient<T = any>(): T {
const kafkaConfig: KafkaConfig = Object.assign(
{ logCreator: KafkaLogger.bind(null, this.logger) },
this.options.client,
{ brokers: this.brokers, clientId: this.clientId },
);
return new kafkaPackage.Kafka(kafkaConfig);
}
}
client-kafka.ts 파일에서 확인하게 된다면, ClientKafka 클래스의 생성자 부분에서 kafkajs 파일을 Dynamic Module Import 방식으로 호출하고 있는 것을 확인할 수 있다. 해당 라이브러리를 호출한 후, createClient()
함수에서 kafkajs의 Kafka 클래스를 반환하여 일반적인 kafkajs를 사용하는 것과 같이 구성하고 있다.
여기서, 하나 의문점이 드는 부분이 있는데, 분명히 Dynamic Module Import를 이용하여 모듈을 호출하게 된다면, any
타입으로 타입을 반환받는 것으로 알고 있는데, 여기서는 어떻게 명확하게 타입을 선언할 수 있는 것일까?
일반적으로 이런 상황이 발생하게 되었을 때는 1. 동일한 타입을 선언하거나, 2. 타입을 공유하는 별도의 타입 라이브러리를 구성하는 방식을 사용할텐데, @nestjs/microservices
는 어떤 방식을 사용하고 있는지 확인해 보자.
// kafka.interface.ts
/**
* Do NOT add NestJS logic to this interface. It is meant to ONLY represent the types for the kafkajs package.
*
* @see https://github.com/tulios/kafkajs/blob/master/types/index.d.ts
*/
export declare class Kafka {
constructor(config: KafkaConfig);
producer(config?: ProducerConfig): Producer;
consumer(config: ConsumerConfig): Consumer;
admin(config?: AdminConfig): Admin;
logger(): Logger;
}
최상단에 작성된 주석과 같이, kafkajs의 타입을 그대로 가져와서 사용하고 있음을 확인할 수 있다. 이런 방식은 kafkajs의 타입이 변경되는 상황이 발생하게 되었을 때, 동일한 형식으로 수정해야 하는 이슈가 발생할 수 있다. 하지만, kafkajs는 현재 2년가량 유지보수가 진행되고 있지 않고, 메인테이너 또한 새로운 담당자를 구하고 있으므로 타입 변경이 크게 발생하지 않을 것으로 보이므로 이런 방향성이 더욱 알맞는 방향일 수도 있을 것 같다.
마치며
자 이렇게 해서, @nestjs/microservices
라이브러리에서 원하는 의존성만 설치한 후, 동적으로 모듈을 호출하여 사용하는 방법을 확인해 보았다.
사실, 이번 게시글에서는 Kafka Client의 세부 구현체에 대해 분석하고, 각 라이브러리들이 어떤 방식으로 Kafka와 소통하고 있는지 작성하려 했었다. 하지만, 해당 분석을 하던 와중 @nestjs/microservices
라이브러리를 알게 되었고, 어떤 방식으로 kafkajs와 같은 여러 의존성을 어떻게 통합해서 구성하고 있는지 궁금증이 생기게 되어 글을 작성하게 되었다.
'분석과 탐구' 카테고리의 다른 글
Amazon MSK 커넥션 끊김 이슈 디버깅 (feat. Nest.js, IRSA) (0) | 2024.12.08 |
---|---|
Terraform으로 생성한 IAM Role은 비정상일까? (0) | 2024.10.13 |
글또 8기 회고와 9기의 목표 (0) | 2023.12.10 |
제로부터 시작하는 Prisma와 Nest.js (0) | 2023.07.16 |
아키텍처 패턴 그리고 헥사고날 아키텍처 (0) | 2023.07.02 |