NestJS Provider Scope: 의존성 주입 생명주기 완전 가이드
NestJS Provider Scope의 세 가지 유형과 각각의 특징, 활용 시나리오를 구체적인 예제와 함께 상세히 알아보자.
들어가며
NestJS를 사용할 때 Provider의 생명주기를 어떻게 관리할지 고민해본 적이 있나? 기본적으로 NestJS의 모든 Provider는 싱글톤 패턴으로 동작한다. 하지만 때로는 요청마다 새로운 인스턴스가 필요하거나, 매번 새로운 인스턴스를 생성해야 하는 경우가 있다. 이런 요구사항을 해결하기 위해 NestJS는 Provider Scope라는 개념을 제공한다.
Provider Scope는 의존성 주입 시스템에서 객체의 생명주기를 제어하는 핵심 메커니즘이다. 이 글에서는 NestJS의 세 가지 Provider Scope인 DEFAULT
, REQUEST
, TRANSIENT
의 특징과 각각의 활용 시나리오를 구체적인 예제와 함께 살펴보겠다.
Provider Scope 개념 이해
Provider Scope란 무엇인가
Provider Scope는 NestJS가 Provider 인스턴스를 언제, 어떻게 생성하고 관리할지를 결정하는 설정이다. 간단히 말해, 객체가 언제 생성되고 언제까지 살아있을지를 정의하는 것이다.
NestJS는 기본적으로 Inversion of Control (IoC) 컨테이너를 사용하여 의존성을 관리한다. 이 컨테이너가 Provider Scope 설정에 따라 인스턴스의 생명주기를 제어한다.
세 가지 Provider Scope 유형
NestJS는 다음 세 가지 Provider Scope를 제공한다:
- DEFAULT (Singleton): 애플리케이션 전체에서 단일 인스턴스를 공유
- REQUEST: HTTP 요청마다 새로운 인스턴스를 생성
- TRANSIENT: 주입될 때마다 새로운 인스턴스를 생성
각 Scope는 서로 다른 생명주기를 가지며, 메모리 사용량과 성능에 직접적인 영향을 미친다.
DEFAULT Scope (Singleton)
Singleton 패턴의 특징
DEFAULT Scope는 싱글톤 패턴을 구현한다. 애플리케이션이 시작될 때 한 번만 인스턴스가 생성되고, 애플리케이션 종료까지 동일한 인스턴스가 재사용된다.
@Injectable()
export class UserService {
private users: User[] = [];
constructor() {
console.log('UserService 인스턴스 생성됨');
}
findAll(): User[] {
return this.users;
}
create(user: User): User {
this.users.push(user);
return user;
}
}
위 코드에서 UserService
는 DEFAULT Scope를 사용한다. 애플리케이션이 시작될 때 한 번만 인스턴스가 생성되고, 모든 컨트롤러가 동일한 인스턴스를 공유한다.
DEFAULT Scope의 활용 시나리오
DEFAULT Scope는 다음과 같은 상황에서 활용된다:
1. 상태가 없는 서비스 (Stateless Services)
@Injectable()
export class CryptoService {
encrypt(data: string): string {
// 암호화 로직
return encryptedData;
}
decrypt(encryptedData: string): string {
// 복호화 로직
return decryptedData;
}
}
2. 설정 관리 서비스
@Injectable()
export class ConfigService {
private config: AppConfig;
constructor() {
this.config = this.loadConfig();
}
get<T>(key: string): T {
return this.config[key];
}
private loadConfig(): AppConfig {
// 설정 파일에서 구성 정보 로드
return configData;
}
}
3. 캐시 서비스
@Injectable()
export class CacheService {
private cache = new Map<string, any>();
get(key: string): any {
return this.cache.get(key);
}
set(key: string, value: any): void {
this.cache.set(key, value);
}
}
DEFAULT Scope의 장단점
장점:
- 메모리 효율성: 단일 인스턴스만 생성되므로 메모리 사용량이 적다
- 성능: 인스턴스 생성 비용이 한 번만 발생한다
- 상태 공유: 애플리케이션 전체에서 상태를 공유할 수 있다
단점:
- 동시성 문제: 여러 요청이 동시에 상태를 변경할 때 문제가 발생할 수 있다
- 메모리 누수: 인스턴스가 애플리케이션 종료까지 유지되므로 메모리 누수 가능성이 있다
- 테스트 어려움: 상태가 테스트 간에 공유될 수 있다
REQUEST Scope
REQUEST Scope의 동작 원리
REQUEST Scope는 HTTP 요청마다 새로운 Provider 인스턴스를 생성한다. 요청이 시작될 때 인스턴스가 생성되고, 요청이 완료되면 인스턴스가 소멸된다.
@Injectable({ scope: Scope.REQUEST })
export class RequestContextService {
private requestData: any = {};
constructor() {
console.log('RequestContextService 인스턴스 생성됨');
}
setRequestData(key: string, value: any): void {
this.requestData[key] = value;
}
getRequestData(key: string): any {
return this.requestData[key];
}
}
REQUEST Scope의 활용 시나리오
1. 요청별 컨텍스트 관리
@Injectable({ scope: Scope.REQUEST })
export class RequestContextService {
private userId: string;
private requestId: string;
setUserId(userId: string): void {
this.userId = userId;
}
getUserId(): string {
return this.userId;
}
setRequestId(requestId: string): void {
this.requestId = requestId;
}
getRequestId(): string {
return this.requestId;
}
}
2. 감사 로그 서비스
@Injectable({ scope: Scope.REQUEST })
export class AuditService {
private auditLog: AuditEntry[] = [];
logAction(action: string, details: any): void {
this.auditLog.push({
timestamp: new Date(),
action,
details
});
}
getAuditLog(): AuditEntry[] {
return this.auditLog;
}
async saveToDatabase(): Promise<void> {
// 요청 종료 시 데이터베이스에 저장
await this.auditRepository.save(this.auditLog);
}
}
3. 요청별 권한 관리
@Injectable({ scope: Scope.REQUEST })
export class AuthorizationService {
private permissions: string[] = [];
setUserPermissions(permissions: string[]): void {
this.permissions = permissions;
}
hasPermission(permission: string): boolean {
return this.permissions.includes(permission);
}
}
REQUEST Scope 사용 시 주의사항
REQUEST Scope를 사용할 때는 다음 사항들을 고려해야 한다:
1. 성능 영향
// 성능에 민감한 서비스는 REQUEST Scope 사용을 신중하게 고려
@Injectable({ scope: Scope.REQUEST })
export class HeavyComputationService {
constructor(
private readonly databaseService: DatabaseService,
private readonly cacheService: CacheService
) {
// 매 요청마다 이 초기화 작업이 실행됨
this.initializeHeavyResources();
}
}
2. 메모리 사용량
@Injectable({ scope: Scope.REQUEST })
export class RequestScopedService {
private largeDataSet: any[] = [];
constructor() {
// 매 요청마다 대용량 데이터가 로드됨
this.largeDataSet = this.loadLargeDataSet();
}
}
REQUEST Scope와 의존성 주입
REQUEST Scope Provider에 의존하는 다른 Provider들도 자동으로 REQUEST Scope가 된다:
@Injectable({ scope: Scope.REQUEST })
export class RequestScopedService {
// REQUEST Scope
}
@Injectable() // DEFAULT Scope로 선언했지만
export class DependentService {
constructor(
private readonly requestScopedService: RequestScopedService
) {
// RequestScopedService에 의존하므로 실제로는 REQUEST Scope가 됨
}
}
TRANSIENT Scope
TRANSIENT Scope의 특징
TRANSIENT Scope는 Provider가 주입될 때마다 새로운 인스턴스를 생성한다. 동일한 요청 내에서도 여러 번 주입되면 매번 새로운 인스턴스가 생성된다.
@Injectable({ scope: Scope.TRANSIENT })
export class TransientService {
private readonly instanceId: string;
constructor() {
this.instanceId = Math.random().toString(36).substr(2, 9);
console.log(`TransientService 인스턴스 생성됨: ${this.instanceId}`);
}
getInstanceId(): string {
return this.instanceId;
}
}
TRANSIENT Scope의 활용 시나리오
1. 고유한 상태를 가져야 하는 서비스
@Injectable({ scope: Scope.TRANSIENT })
export class UniqueIdGeneratorService {
private seed: number;
constructor() {
this.seed = Date.now() * Math.random();
}
generateId(): string {
return `${this.seed}_${++this.counter}`;
}
}
2. 임시 작업을 위한 서비스
@Injectable({ scope: Scope.TRANSIENT })
export class TempFileService {
private tempFilePath: string;
constructor() {
this.tempFilePath = `/tmp/${uuid()}`;
this.createTempFile();
}
private createTempFile(): void {
// 임시 파일 생성
}
write(data: string): void {
// 임시 파일에 데이터 쓰기
}
cleanup(): void {
// 임시 파일 삭제
fs.unlinkSync(this.tempFilePath);
}
}
3. 격리된 상태가 필요한 서비스
@Injectable({ scope: Scope.TRANSIENT })
export class IsolatedCalculatorService {
private state: CalculatorState = { value: 0 };
add(value: number): void {
this.state.value += value;
}
multiply(value: number): void {
this.state.value *= value;
}
getResult(): number {
return this.state.value;
}
reset(): void {
this.state = { value: 0 };
}
}
TRANSIENT Scope의 장단점
장점:
- 완전한 격리: 각 인스턴스가 고유한 상태를 가진다
- 동시성 문제 없음: 인스턴스 간 상태 공유가 없다
- 메모리 누수 방지: 사용 후 가비지 컬렉션 대상이 된다
단점:
- 높은 메모리 사용량: 매번 새로운 인스턴스를 생성한다
- 성능 오버헤드: 인스턴스 생성 비용이 반복적으로 발생한다
- 상태 공유 불가: 인스턴스 간 데이터 공유가 어렵다
실제 활용 예제와 비교
세 가지 Scope 비교 예제
다음 예제는 동일한 서비스를 서로 다른 Scope로 구현했을 때의 차이점을 보여준다:
// DEFAULT Scope
@Injectable()
export class CounterService {
private count = 0;
increment(): number {
return ++this.count;
}
getCount(): number {
return this.count;
}
}
// REQUEST Scope
@Injectable({ scope: Scope.REQUEST })
export class RequestCounterService {
private count = 0;
increment(): number {
return ++this.count;
}
getCount(): number {
return this.count;
}
}
// TRANSIENT Scope
@Injectable({ scope: Scope.TRANSIENT })
export class TransientCounterService {
private count = 0;
increment(): number {
return ++this.count;
}
getCount(): number {
return this.count;
}
}
컨트롤러에서의 동작 차이
@Controller('counter')
export class CounterController {
constructor(
private readonly counterService: CounterService,
private readonly requestCounterService: RequestCounterService,
private readonly transientCounterService: TransientCounterService
) {}
@Get('default')
getDefaultCounter(): number {
// 모든 요청에서 동일한 인스턴스 사용
return this.counterService.increment();
}
@Get('request')
getRequestCounter(): number {
// 요청마다 새로운 인스턴스 사용
return this.requestCounterService.increment();
}
@Get('transient')
getTransientCounter(): number {
// 이 메서드가 호출될 때마다 새로운 인스턴스 사용
return this.transientCounterService.increment();
}
}
성능 및 메모리 사용량 비교
@Injectable()
export class PerformanceTestService {
private readonly creationTime: number;
private readonly memoryUsage: number;
constructor() {
this.creationTime = Date.now();
this.memoryUsage = process.memoryUsage().heapUsed;
}
getMetrics(): {
creationTime: number;
memoryUsage: number;
age: number;
} {
return {
creationTime: this.creationTime,
memoryUsage: this.memoryUsage,
age: Date.now() - this.creationTime
};
}
}
Provider Scope 선택 가이드
각 Scope를 선택해야 하는 경우
DEFAULT Scope를 선택해야 하는 경우:
- 상태가 없는 순수 함수형 서비스
- 설정 관리나 유틸리티 서비스
- 전역 캐시나 공유 데이터 관리
- 성능이 중요한 서비스
REQUEST Scope를 선택해야 하는 경우:
- 사용자별 세션 데이터 관리
- 요청별 트랜잭션 관리
- 사용자 권한이나 컨텍스트 정보 관리
- 요청별 감사 로그 관리
TRANSIENT Scope를 선택해야 하는 경우:
- 임시 파일이나 리소스 관리
- 독립적인 상태가 필요한 서비스
- 병렬 처리에서 격리가 필요한 경우
- 각 사용 시점마다 초기화가 필요한 서비스
성능 고려사항
// 성능 측정을 위한 데코레이터
function measurePerformance(target: any, propertyName: string, descriptor: PropertyDescriptor) {
const method = descriptor.value;
descriptor.value = function (...args: any[]) {
const start = performance.now();
const result = method.apply(this, args);
const end = performance.now();
console.log(`${propertyName} 실행 시간: ${end - start}ms`);
return result;
};
}
@Injectable({ scope: Scope.REQUEST })
export class PerformanceAwareService {
constructor(
private readonly databaseService: DatabaseService
) {
// REQUEST Scope로 인한 초기화 오버헤드 측정
}
@measurePerformance
async processData(data: any): Promise<any> {
// 데이터 처리 로직
return processedData;
}
}
마무리
NestJS의 Provider Scope는 애플리케이션의 아키텍처와 성능에 중요한 영향을 미치는 개념이다. 각 Scope의 특징을 정확히 이해하고 적절히 활용하면 효율적이고 안정적인 애플리케이션을 구축할 수 있다.
- DEFAULT Scope는 대부분의 경우에 적합하며, 성능과 메모리 효율성을 제공한다
- REQUEST Scope는 요청별 상태 관리가 필요할 때 유용하지만, 성능 오버헤드를 고려해야 한다
- TRANSIENT Scope는 완전한 격리가 필요한 특수한 경우에만 사용해야 한다
Provider Scope를 선택할 때는 애플리케이션의 요구사항, 성능 목표, 메모리 제약 등을 종합적으로 고려해야 한다. 특히 대규모 애플리케이션에서는 Scope 선택이 전체 시스템의 성능에 큰 영향을 미칠 수 있으므로, 충분한 테스트와 모니터링을 통해 최적의 선택을 해야 한다.
참고
- NestJS Official Documentation - Injection Scopes
- NestJS Official Documentation - Providers
- Dependency Injection in NestJS
- Node.js Memory Management Best Practices