Nodejs 진영에서 DI/IoC를 사용해 서버를 구축하기 위해선 DI 프레임워크인 Nestjs를 사용하거나 Express에서 typedi를 활용해 서버를 구축해야 한다.
Nestjs에선 데코레이터(@)와 Module을 활용해 의존성들의 관계만 신경 써주면 쉽게 주입받아 관리할 수 있고, Express에서 typedi로 의존성을 관리하는 것도 비슷하다.
(@Service 데코레이터를 통해서 IoC 컨테이너에 자동으로 등록되고 런타임 때 typedi를 통해 알아서 주입받아 사용하는 형태다)
최근 들어 Nestjs에서 데코레이터를 커스텀해 사용할 일이 많아지다 보니 도대체 데코레이터의 동작원리가 어떻게 된 건지 궁금해졌다. 하지만 Nestjs에서 제공하는 커스텀 데코레이터 메서드들은 한번 더 추상화된 메서드들이다 보니 정확한 동작원리를 알기엔 어려웠다. 그래서 DI 프레임워크가 아닌 Express에서 DI/IoC 환경을 만들어봤다.
1. 개발 환경
먼저 Express + Typescript 환경에서 개발을 진행했다. 데코레이터를 사용하기 위해서 아래와 같이 기본 세팅을 진행했다.
// app.ts
import 'reflect-metadata';
import express, { Request, Response, NextFunction } from 'express';
//..... app.ts 기본 세팅들..
// tsconfig.json에 아래 두 옵션 추가
// "experimentalDecorators": true,
// "emitDecoratorMetadata": true,
app.ts 최상위에 reflect-metadata를 import 해주고, tsconfig.json에서 "experimentalDecorators": true,
"emitDecoratorMetadata": true 옵션을 활성화시켜준다.
(여기서 import한 reflect-metadata는 Reflection이라는 개념과 JS에서 제공하는 Reflect API를 활용한 방법임.)
2. Container 객체 만들기
DI를 구현할 때 직접 클래스를 인스턴스화시키고 생성자로 넣어주고 하다 보면 수많은 의존성들을 전부 관리해줘야 하는 문제가 생긴다. 이를 해결하기 위해 IoC 컨테이너라는 객체에서 모든 의존성을 한 번에 관리해줘야 한다.
먼저 Container 객체를 만들어야 한다. 개념적으로 생각해 봤을 때 의존성을 자동으로 주입한다는 건 아래와 같은 행위를 자동으로 해주는 것이다.
// 머드게임 OOP에서 의존성 관리하는 부분들
const etc = new Etc();
// 게임 시작 전
// 게임 중에 계속 바뀌는 class (상태)
const mapList = new MapList();
const jobList = new JobList();
const userData = new UserData();
// 위의 클래스들을 만들어주는 class (행동)
const item = new ItemGenerator(etc);
const mapGenerator = new MapGenerator(item, etc, mapList);
const jobGenerator = new JobGenerator(jobList);
const userGenerator = new UserGenerator(userData);
// 게임 시작 후
// 바깥쪽 (게임 세팅)
const characterCommandOutput: ICharacterCommandOutput = new CharacterCommandOutput(jobList, userData);
const characterQueryOutput: ICharacterQueryOutput = new CharacterQueryOutput(jobList, userData);
const moveOutput: IMoveOutput = new MoveOutput(userData, mapList);
const npcOutput: INpcOutput = new NpcOutput(mapList);
const enemyOutput: IEnemyOutput = new EnemyOutput(mapList);
// 핵심 로직
const charaterDomain = new CharacterDomain(characterCommandOutput, characterQueryOutput);
const moveDomain = new MoveDomain(moveOutput, characterQueryOutput);
const npcDomain = new NpcDomain(npcOutput, characterCommandOutput);
const enemyDomain = new EnemyDomain(enemyOutput, characterCommandOutput, characterQueryOutput, etc);
// 바깥쪽2 (유저 입력)
const input = new Input(charaterDomain, npcDomain, moveDomain, enemyDomain);
// 최종 인스턴스
const game = new Game(mapGenerator, jobGenerator, userGenerator, input);
해당 코드는 예전에 회사에서 했던 세미나를 위한 구현 코드 중 일부이다. <객체지향적으로 머드게임 만들기>
이런 복잡한 의존성 관리를 런타임 환경 때 자동으로 해주기 위해서 아래와 같이 Container 객체를 구현했다.
type Constructor<T> = new (...args: any[]) => T;
class Container {
private layers: Map<string, Constructor<any>> = new Map();
register<T>(key: string, type: Constructor<T>) {
this.layers.set(key, type);
}
resolve<T>(key: string): T {
const targetType = this.layers.get(key);
const dependencies = Reflect.getMetadata('design:paramtypes', targetType) || [];
const instances = dependencies.map((dependency: Constructor<any>) => this.resolve(dependency.name));
return new targetType(...instances);
}
}
const container = new Container();
코드를 설명하면 Container 클래스 내부에 layers라는 객체가 존재한다. 이 객체는 해당 객체의 key로 사용될 문자열과 의존성 주입에 사용될 클래스를 갖고 있다. register를 통해 먼저 layers에 주입할 객체들을 차곡차곡 넣어두고 resolve를 객체를 꺼내서 instances로 순서를 만드는데 여기서 this.resolve(dependency.name) 부분으로 재귀를 돌아 역으로 instances가 리턴된다. 그럼 결국 targetType = Controller부터 시작한 게 [Service { repository : Repository {} }] 의 형태로 리턴되게 된다. 이 값의 배열을 없애주고 new를 통해 인스턴스화하면 처음부터 원했던 의존성 주입이 순서대로 완료된 최종 인스턴스가 나오게 되는 것이다. (여기선 controller)
DI 데코레이터의 경우 아래와 같이 구현했다.
// IoC Container에 의존성 등록해주는 데코레이터
export function Injectable() {
return function (target: Constructor<any>) {
container.register(target.name, target);
};
}
// express route 에 api method, path를 등록해주는 데코레이터
// controller layer에서 사용됨
export function Route(method: string, path: string) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
Reflect.defineMetadata('route', { method, path }, target, propertyKey);
};
}
이제 마지막으로 의존성 주입할 클래스들에 데코레이터를 붙여주면 된다.
// controller layer
import { Injectable } from '../decorators/di.decorator';
import { Route } from '../decorators/route.decorator';
import { UserService } from './test.service';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class UserController {
constructor(private readonly userService: UserService) {}
@Route('get', '/users')
async getUsers(req: Request, res: Response, next: NextFunction) {
const result = await this.userService.getUsers();
const users = [{ id: 1, name: result }];
res.json(users);
}
@Route('get', '/user/data')
getUserData(req: Request, res: Response, next: NextFunction) {
const userData = { id: 1, name: 'kim' };
res.json(userData);
}
}
// service layer
import { Injectable } from '../decorators/di.decorator';
import { UserRepository } from './test.repository';
@Injectable()
export class UserService {
constructor(private readonly userRepository: UserRepository) {}
async getUsers() {
return await this.userRepository.getUsers();
}
// ...
}
// repository layer
import { Injectable } from '../decorators/di.decorator';
@Injectable()
export class UserRepository {
async getUsers() {
return 'aaaaaa';
}
// ...
}
우선 일차적으로 이렇게 런타임 때 di를 알아서 해주는 IoC 컨테이너 환경을 구현했다. 사실 지금까지 데코레이터는 갖다 쓸 줄만 알았지 직접 Reflect API 활용해서 구현해 본 건 처음이다. 구현하면서 느낀 건 콜백함수 콜백함수를 추상화한 느낌이었다.
앞으로 이런저런 기능들 계속 추가해 보면서 좀 더 발전시켜야겠다.
<예제 코드>
'NestJS' 카테고리의 다른 글
모듈 간의 순환참조 (1) | 2023.06.27 |
---|---|
Nest.js 라이프사이클 (0) | 2023.06.20 |
Nest.js에서 모듈간의 의존성 관리하기 (0) | 2023.04.19 |
AuthGuard 예외처리 조건 만들기 (0) | 2023.04.19 |
Nest.js 에서 역직렬화 하기 (0) | 2023.03.14 |