
TypeScript 공부를 시작하고 나서,
익숙했던 함수형 구조에서 클래스형 구조로 개발을 하려니 생각보다 많이 어색하다… (˚ ˃̣̣̥⌓˂̣̣̥ )
"어색하면 그냥 함수형으로 개발하면 되지 않나?" 라고 생각할 수도 있지만,
TypeScript에서만 제대로 활용할 수 있는 몇몇 라이브러리들은 클래스형 구조를 기반으로 만들어져 있기 때문에 함수형 구조만 고집하면 오히려 TypeScript의 장점을 제대로 살리지 못하는 것 같았다.
그래서 지금은 어색하더라도 클래스형 구조에 익숙해지려고 노력 중이다! ٩( ᐖ )و
오늘은 그중에서도 TypeScript + Express.js 환경에서 활용할 수 있는 의존성 주입(Dependency Injection) 라이브러리인 typedi에 대해 공부해보려고 한다!
(Nest.JS는 의존성 주입 시스템을 내장하고 있어서 typedi를 사용하지 않는다고 한다.)
엇! 그러면 typedi를 쓸 거면 그냥 Nest.JS를 사용하면 되는거 아닌가?
이거에 답변은 "NO!" 이다.
Nest.JS는 구조가 체계적이고 안정적인 반면, 프레임워크 자체가 무거운 편이다.
반대로 Express.JS는 가볍고 자유도가 높아서, 내가 원하는 아키텍처를 유연하게 구성할 수 있다.
결국 중요한 건 내가 만들려는 프로젝트의 성격이다.
ps.
쓰니가 typedi를 공부하다가 Nest.JS까지 검색 하게 되었는데..
Nest.JS는 구조가 Spring Boot랑 비슷한거 같다.
Nest.JS 잘하면 Spring Boot도 금방 배울 수 있을지도..? 🤔
Node.JS에서 의존성 주입(Dependency Injection)을 가능하게 해주는 가변고 실용적인 DI 컨테이너.
@Service() - 클래스를 DI 컨테이너에 등록@Inject() - 필드나 생성자 파라미터에 의존성 주입@InjectRepository() - TypeORM 리포지토리 주입 (typeorm-typedi-extensions 사용 시)Token - 클래스 기반 식별자 객체 ( 데코레이터 아님 )Container - 수동으로 인스턴스 등록/가져오기 위한 객체 (데코레이터 아님!!)클래스를 typedi 컨테이너에 등록하는 데코레이터
클래스 위에서 사용하고 해당 클래스를 자동으로 싱글톤 인스턴스로 관리해준다!
import { Service } from 'typedi'
@Service()
export class UserService {
getUserName() {
return 'jiseong choi';
}
}
말 그대로 주입(Inject)할 때 사용.
다른 클래스에서 정의한 Service를 사용하고 싶을때 사용한다.
사용방법은 생성자 주입 방식, 필드 주입 방식이 있다!
/* 생성자 주입 방식 */
import { Inject, Service } from 'typedi'
@Service()
export class PostService {
constructor(
@Inject(() => UserService) private userService: UserService,
) {}
getPostAuthor() {
return this.userService.getUserName;
}
}
/* 필드 주입 방식 */
import { Inject, Service } from 'typedi'
@Service()
export class PostService {
@Inject(() => UserService)
private userService!: UserService;
getPostAuthor() {
return this.userService.getUserName;
}
}
- 생성자 주입 방식
- 의존성을 생성자 파라미터를 통해 주입받는 방식.
- 클래스가 어떤 의존성을 필요로 하는지 명확하게 드러남
- 외부에서 인스턴스를 만들 때 mock 객체를 넘기기 쉬워 테스트하기에도 유리!
- 클래스가
@Service로 DI 컨테이너에 등록이 되어 있는 경우@Inject생략 가능!!
( 대신,Reflect Metadata를 사용해서 타입 정보를 읽어오는 방식이라
tsconfig에emitDecoratorMetadata가 설정이 되어있어야 한다! )- 생략이 불가능한 경우 (
@Inject필수)
@Token()으로 주입하는 경우 -> 클래스가 아니라서 자동으로 추론 ❌Container.set()으로 수동 등록한 인스턴스를 주입하는 경우
- 필드 주입 방식
- 클래스 내부 필드에 직접 데코레이터를 붙여 사용하는 방식
- 코드가 간단해 보인다는 장점이 있지만, 테스트할 때는 필드를 직접 교체해야한다!
- 테스트 코드에서는
as any를 사용해서 필드를 억지로 수정해야하는 경우도 있다.
(필드를public이나protected로 선언하면 수정을 안해도 되지만, 서비스 내부 구현을 외부에서 바꾸지 못하게 보호하는게 원칙이기 때문에 대부분private를 사용한다.)
/* 생성자 주입 방식 테스트 */
describe('PostService - 필드 주입 테스트', () => {
it('getPostAuthor()는 유저 이름을 반환해야 한다.', () => {
const mockUserService = {
getUserName: () => 'MockUser',
} as UserService;
const postService = new PostService(mockUserService);
expect(postService.getPostAuthor()).toBe('MockUser');
});
});
/* 필드 주입 방식 테스트 */
describe('PostService - 필드 주입 테스트', () => {
it('getPostAuthor()는 유저 이름을 반환해야 한다.', () => {
const postService = new PostService();
// 필드를 직접 수동으로 mock 객체로 설정
(postService as any).userService = {
getUserName: () => 'MockUser',
} as UserService;
const result = postService.getPostAuthor();
expect(result).toBe('MockUser');
});
});
🚩 이러한 이유들 때문에 테스트&유지보수성을 고려했을 때, 생성자 주입 방식이 더 적합하다!
TypeORM + typedi를 함께 사용할 때, TypeORM의 Repository를 의존성 주입(DI) 방식으로 주입해주는 테코레이터다!
즉, UserRepository 같은 TypeORM 레포지토리를 직접 new 하지 않고,
typedi가 자동으로 주입해주는 것!
- typedi 자체에서 제공하는 건 아니고,
typeorm-typedi-extensions패키지를 설치해야 사용할 수 있다.- TypeORM 이란?
- typeorm은 sequelize랑 비슷함! - RDBMS 구조에 딱 맞는 ORM
- typeorm - TypeScript에 최적화
- sequelize - JavaScript에 최적화
- 즉,
find(),findOneBy()같은 함수로 SQL 작성 없이 데이터 조회 가능!
/* data-source.ts 또는 main.ts, index.ts에서 앱 시작 전에 설정 */
import { useContainer } from 'typeorm';
import { Container } from 'typedi';
useContainer(Container); // typedi를 TypeORM에 연결
/* user.service.ts */
import { Service } from 'typedi';
import { InjectRepository } from 'typeorm-typedi-extensions';
import { Repository } from 'typeorm';
import { User } from './entities/User';
@Service()
export class UserService {
constructor(
// Service에서 직접 사용해도되지만, dao에서 사용하고 dao를 의존성 주입해도 된다!
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
findAllUsers() {
return this.userRepository.find();
}
}
typedi에서 Token은 간단히 말하자면 의존성을 구분하기 위한 고유한 식별자이다.
/* logger.interface.ts */
export interface Logger {
log(message: string): void;
}
/* logger.ts - 구현체 생성 */
export class ConsoleLogger implements {
log(message: string): void {
console.log(`[ConsoleLogger] ${message}`);
}
}
export class FileLogger implements {
log(message: string): void {
console.log(`[FileLogger] ${message} (written to file)`);
}
}
/* token.ts - 토큰 생성 */
import { Token } from 'typedi'
import type { Logger } from './logger.interface';
export const LoggerToken = new Token<Logger>();
/* main.ts 또는 app.ts - 런타임에서 분기 & 주입 */
import { Container } from 'typedi';
import { LoggerToken } from './token';
import { FileLogger, ConsoleLogger } from './logger';
// 환경에 따라 구현체 분기
if (process.env.NODE_ENV === "production") {
Container.set(LoggerToken, new FileLogger());
} else {
Container.set(LoggerToken, new ConsoleLogger());
}
// 실제 서비스 실행
const app = Container.get(UserService);
/* user.service.ts - 실제 주입받는 클래스 */
import { Inject, Service } from 'typedi';
import { Logger } from './logger.interface';
import { LoggerToken } from './token';
@Service()
export class UserService {
constructor(
@Inject(LoggerToken) private readonly logger: Logger
) {}
}
기본적으로 TypeDI는 클래스를 식별자(identifier)로 사용하여 의존성을 주입한다.
하지만 인터페이스를 식별자로 사용해 의존성 주입을 하려 한다면 문제가 발생한다.
그 이유는 TypeScript에서 인터페이스는 컴파일 타임에만 존재하고, 런타임에는 사라지기 때문이다.
반면 TypeDI의 의존성 주입은 런타임에 이루어지므로, 런타임에 존재하지 않는 인터페이스는 주입 대상이 없다는 에러를 발생시킨다.
이럴 때 “그렇다면 인터페이스를 주입하지 말고, 인터페이스를 구현한 클래스를 직접 주입하면 되지 않을까?” 라는 생각이 들 수 있다.
물론 가능하다! 하지만 문제는 환경이나 조건에 따라 사용하는 구현체가 달라질 수 있다는 점이다.
예를 들어 개발 환경에서는 ConsoleLogger, 운영 환경에서는 FileLogger를 쓰는 식이다.
이렇게 분기가 생기면, 그 분기 로직을 어디에 둘 것인가가 중요해진다.
클래스로 만들 수도 있고, 함수를 만들어서 분기할 수도 있다.
하지만 이렇게 분기하는 클래스나 함수를 만들 경우, 조건이 추가될 때마다 해당 분기 로직을 수정해야 한다.
이건 바로 OCP(개방-폐쇄 원칙) 위배다.
OCP란 기존 코드를 수정하지 않고 확장할 수 있어야 한다는 원칙이다.
즉, 분기 조건이 추가되더라도 비즈니스 로직 내부를 수정하지 않고, 외부에서 구현체를 주입받는 방식이 가장 이상적이다.
이렇게 하면 구조가 변경에 강해지고, 비즈니스 로직을 직접 수정하는 것이 아니기 때문에 실수로 인한 오류를 최소화 할 수 있다.
이런 이유로 TypeDI에서는 Token을 사용하여 인터페이스의 구현체를 명시하고,
인터페이스를 식별자로 사용하는 의존성 주입을 가능하게 만드는 것이다.
Container는 typedi에서 제공하는 중심 유틸 객체로,
직접 의존성을 주입하거나, 인스턴스를 수동으로 등록할 때 사용한다.
Container.set(...))@Inject(...), Container.get(...))new 하지 않고 컨테이너에서 주입받기 때문에 느슨한 결합이 가능따라서 Container는 앞에서 설명한 @Service(), @Inject(), @InjectRepository(), Token 등을 등록하고 유지하며, 의존성 주입 시스템이 제대로 작동하도록 만들어주는 핵심 객체이다.
TypeDI에 대해서 공부하고 정리하면서 가장 어려웠던 내용은 Token이었다.
검색을 해도 자료가 많이 없고, 공식 문서에도 설명이 너무 짧아서 어떤 상황에서 왜 사용하는지를 알기가 어려웠다.
결국 ChatGPT한테 무한 질문을 던지면서
내가 이해한 내용이 맞는지 하나하나 확인해가며 공부했다.
그 과정에서 Token을 사용하는 이유와 언제, 어떻게 사용하는지를 정확하게 알 수 있었다.
비록 시간은 오래 걸렸지만, 이해하고 나니 나름 뿌듯하다..🙃 하하하