Nodejs에서의 의존성 주입(DI)
업데이트:
기본적으로 객체지향 프로그래밍 패러다임에서는 소프트웨어가 높은 응집성(Cohesion)과 낮은 결합성(Coupling)을 가져야 한다. 이를 통해 재사용 가능하고 확장성이 높은 모듈을 만들 수 있다. 각 모듈의 결합성을 낮추기 위해서는 상위 수준 모듈이 하위 수준 모듈에 의존하지 않아야 한다.(의존성 역전 원칙). 이를 구현하기 위한 패턴이 의존성 주입 패턴이다. 의존성 역전 원칙을 잘 지킨다면 클린 아키텍쳐를 만들 수 있고 나아가서 마이크로서비스를 구축할 수 있다.
응집성 : 하나의 모듈이 단일의 역할만을 할 경우 높은 응집성을 가진다. (단일 책임 원칙)
결합성 : 모듈이 다른 모듈에 얼마나 의존하는지에 대한 척도
하드코드된 종속성
다음 코드는 하드코드된 종속성을 보인다. 각 모듈의 의존성 방향은 다음과 같다.
// db.js
const level = require('level');
const sublevel = require('level-sublevel')
module.exports = sublevel(
level('example-db', {valueEncoding: 'json'})
) // stateful instance
// authService.js
const db = require('./db') // 하드코드된 종속성
const users = db.sublevel('users')
const tokenSecret = 'SHHH!';
exports.login = (username, password, callback) => {
users.get(username, (err, user) => {
//...
});
}; // 특정 인스턴스를 export
exports.checkToken = (token, callback) => {
users.get(username, (err, user) => {
//...
});
};
// authController.js - Express Routes
const authService = require('./authService') // 하드코드된 종속성
exports.login = (req, res, next) => {
authService.login(req.body.username, req.body.password
(err, result) => {
//...
}
);
}; // 특정 인스턴스를 export
exports.checkToken = (req, res, next) => {
authService.checkToken(req.query.token,
(err, result) => {
//...
}
);
};
// app.js
const express = require('express')
const authController = require('./authController') // 하드코드된 종속성
const app = express();
app.post('/login', authController.login);
app.get('/checkToken', authController.checkToken);
app.listen(3000, () => {
console.log('server started');
})
결합성이 높아서 모듈의 재사용성, 확장성을 제한하는 방식이다. 따라서 단위 테스트 또한 어려워진다.
stateful한 instance(=export한 특정 인스턴스)에 의존하기 때문에 문제가 발생하는 것이다. 따라서 stateless한 모듈(팩토리, 생성자, 인터페이스 등)을 로드하게 되면 결합성을 낮출 수 있다.
의존성 주입 DI
모듈의 의존성을 외부 개체에 의한 입력으로 제공한다. 결합성을 감소시키고 재사용성을 높일 수 있다.
아래 코드에서는 stateful한 인스턴스를 하드코딩하는 대신에 일련의 의존성을 인수로 취하는 팩토리를 생성하는 방법으로 구현한다.
// db.js
const level = require('level');
const sublevel = require('lebel-sublevel');
module.exports = dbName => {
return sublevel(
level(dbName, {valueEncoding:'json'})
)
}
이제 특정 db에 종속되지 않은 데이터베이스 인스턴스를 생성할 수 있다.
// authService.js
module.exports = (db, tokenSecret) => {
const users = db.sublevel('users');
const authService = {};
authService.login = (username, password, callback) => {
};
authService.checkToken = (token, callback) =>{
};
return authService;
};
결합성이 높아서 모듈의 재사용성, 확장성을 제한하는 방식이다. 따라서 단위 테스트 또한 어려워진다.
stateful한 instance(=export한 싱글톤 객체)에 의존하기 때문에 문제가 발생하는 것이다. 따라서 stateless한 모듈을 로드하게 되면 결합성을 낮출 수 있다.특정 인스턴스가 아니라 단순한 팩토리를 export한다. db를 인수로 받으므로 다양한 데이터베이스 인스턴스에 연결이 가능하다.
// authController
module.exports = (authService) => {
const authController = {};
authController.login = (req, res, next) => {
};
authController.checkToken = (req, res, next) => {
};
return authController
}
// app.js
const dbFactory = require('./db');
const authServiceFactory = require('./authService')
const authControllerFactory = require9('./authController')
// 아직까지는 stateless
// 이제 종속성들을 주입해서 인스턴스화한다.
const db = dbFactory('example-db');
const authService = authServiceFactory(db, 'SHH!');
const authController = authControllerFactory(authService);
app.post('/login', authController.login);
app.get('/checkToken', authController.checkToken);
DI의 결과로 재사용성을 높이고, 결합도를 낮추게 되었다. 따라서 단위 테스트가 편리해졌다.
그러나, 종속성 그래프를 수동으로 설정해주어야 한다.
서비스 로케이터
시스템의 컴포넌트를 관리하고 모듈이 종속성을 로드해야 할 때마다 중재자 역할을 수행할 수 있도록 중앙의 레지스트리를 갖는 패턴.
- 종속성을 하드코딩한 서비스 로케이터
- injected 서비스 로케이터
- 글로벌 서비스 로케이터
가 있으나, 일반적으로는 injected 서비스 로케이터를 사용한다.
// serviceLocator.js
module.exports = () => {
const dependencies = {};
const factories = {};
const serviceLocator = {};
serviceLocator.factory = (name, factory) => {
// 컴포넌트의 이름을 해당 팩도리와 연결
factories[name] = factory;
};
serviceLocator.register = (name, instance) => {
// 컴포넌트 이름을 해당 인스턴스와 직접 연결
dependencies[name] = instance;
};
serviceLocator.get = (name) => {
if(!dependencies[name]){ // 인스턴스가 있으면 반환,
const factory = factories[name]; // 없으면 팩토리를 호출해서
dependencies[name] = factory && factory(serviceLocator); // 새 인스턴스 얻는다.
// 현재 인스턴스(serviceLocator)를 주입해서 호출한다.
if(!dependencies[name]){
throw new Error('Cannot find moudle');
}
}
return dependencies[name];
};
return serviceLocator;
}
// db.js
const level = require('level');
const sublevel = require('level-sublevel');
module.exports = (serviceLocator) => {
const dbName = serviceLocator.get('dbName');
//dbName이라는 환경 변수를 가져온다.
return sublevel(
level(dbName, {valueEncoding : 'json'})
);
}
// authService.js
//...
module.exports = (serviceLocator) => {
const db = serviceLocator.get('db');
const tokenSecret = serviceLocator.get('tokenSecret');
const users = db.sublevel('users');
const authService = {};
authService.login = (username, password, callback) => {
//...
}
authService.checkToken = (token, callback) => {
//...
}
return authService;
};
// authController.js
module.exports = (serviceLocator) => {
const authService = serviceLocator.get('authService');
const authController = {};
authController.login = (req, res, next) =>{
};
authController.checkToken = (req, res, next) =>{
};
return authController;
};
// app.js
// 서비스 로케이터 인스턴스화
const svcLoc = require('./serviceLocator')();
// 팩토리와 환경 변수 등록. 의존성은 설정되지 않았음.(Lazy)
svcLoc.register('dbName', 'example-db');
svcLoc.register('tokenSecret', 'SHH!');
svcLoc.factory('db', require('./db'));
svcLoc.factory('authService', require('authService'));
svcLoc.factory('authController', require('authController'));
// 종속성 그래프를 생성한다. Controller -> Service -> DB
const authController = svcLoc.get('authController');
app.post('/login', authController.login);
app.all('/checkToken', authController.checkToken);
장단점
장점
- 종속성 그래프를 수동으로 작성하지 않아도 된다.(DI경우 수동)
단점
- 시스템에서 서비스 로케이터를 사용할 수 있어야 하기 때문에 재사용성이 적다.
- 컴포넌트의 종속성 식별이 불분명하다.
장점은 살리고 단점은 개선한 패턴이 DI 컨테이너이다.
의존성 주입 컨테이너
의존성 주입 컨테이너란 모듈을 인스턴스화하기 전에 모듈이 필요로 하는 종속성을 식별하는 기능을 추가한 서비스 로케이터이다.
모든 모듈이 컨테이너 없이도 재사용될 수 있고, 서비스 로케이터에 의존할 필요가 없다.
// diContainer.js
// 함수 args의 이름을 추출하는 모듈
// 다른 모듈들의 코드는 DI와 같다.
const fnArgs = require('parse-fn-args');
module.exports = () => {
const dependencies = {};
const factories = {};
const diContainer = {};
diContainer.factory = (name, factory) => {
factories[name] = factory;
};
diContainer.register = (name, dep) => {
dependencies[name] = dep;
};
diContainer.get = (name) => {
if(!dependencies[name]){
const factory = factories[name];
dependencies[name] = factory && diContainer.inject(factory);
if(!dependencies[name]){
throw new Error('Cannot find Module');
}
}
return dependencies[name];
};
diContainer.inject = (factory) => {
// 입력으로 받은 팩토리에서 args 추출
const args = fnArgs(factory)
// 각 arg 이름을 해당 종속성 인스턴스에 매핑
.map(dependency => diContainer.get(dependency));
// 종속성 목록을 제공하여 팩토리를 호출
return factory.apply(null, args);
};
};
// app.js
// ...
const diContainer = require('./diContainer')();
diContainer.register('dbName', 'example-db');
diContainer.register('tokenSecret', 'SHH!');
diContainer.factory('db', require('./db'));
diContainer.factory('authService', require('authService'));
diContainer.factory('authController', require('authController'));
// 종속성 그래프를 생성한다. Controller -> Service -> DB
const authController = diContainer.get('authController');
app.post('/login', authController.login);
app.all('/checkToken', authController.checkToken);
장단점
장점
- 결합도가 낮다
- 단위 테스트가 쉽다.
- 의존성을 제외한 추가적인 서비스가 필요없다.
- DI 컨테이너 없이도 각 모듈을 재사용하기 편하다.
단점
- 의존성이 런타임에 생성된다 -> 복잡성 증가
참고
책 Node.js design pattern, Mario Casciaro
댓글남기기