NestJS에서 프로바이더(Provider)는 애플리케이션의 핵심 개념 중 하나로, 의존성 주입(Dependency Injection)을 통해 다양한 기능을 제공합니다. 서비스(Service)는 가장 일반적인 프로바이더의 형태로, 비즈니스 로직을 컨트롤러에서 분리하여 관리할 수 있게 해줍니다. 이 글에서는 NestJS의 프로바이더와 서비스를 이해하고 활용하는 방법을 알아보겠습니다.
목차
- 프로바이더란 무엇인가?
- 서비스 클래스 만들기
- 의존성 주입 이해하기
- 스코프와 라이프사이클
- 커스텀 프로바이더
- 프로바이더 등록 방법
- 실전 예제: 사용자 관리 서비스 구현
- 정리
프로바이더란 무엇인가?
프로바이더는 NestJS 애플리케이션에서 대부분의 기능을 담당하는 클래스들입니다. 다음과 같은 것들이 프로바이더가 될 수 있습니다:
- 서비스 (Services)
- 리포지토리 (Repositories)
- 팩토리 (Factories)
- 헬퍼 (Helpers) 등
프로바이더의 주요 목적은 의존성 주입을 통해 코드를 모듈화하고, 재사용 가능하게 만드는 것입니다. NestJS는 의존성 주입을 위해 강력한 IoC(Inversion of Control) 컨테이너를 제공합니다.
프로바이더는 @Injectable() 데코레이터로 표시되며, 모듈의 providers 배열에 등록됩니다.
서비스 클래스 만들기
서비스는 가장 흔한 프로바이더 유형으로, 비즈니스 로직을 담당합니다. NestJS CLI를 사용하여 서비스를 생성할 수 있습니다:
nest generate service users
# 또는 짧게
nest g s users
이 명령은 다음 파일을 생성합니다:
- src/users/users.service.ts: 서비스 클래스
- src/users/users.service.spec.ts: 테스트 파일
생성된 기본 서비스는 다음과 같습니다:
// src/users/users.service.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class UsersService {}
@Injectable() 데코레이터는 이 클래스가 NestJS의 IoC 컨테이너에 의해 관리되는 프로바이더임을 나타냅니다.
이제 서비스에 비즈니스 로직을 추가해 보겠습니다:
// src/users/users.service.ts
import { Injectable } from '@nestjs/common';
import { User } from './interfaces/user.interface';
@Injectable()
export class UsersService {
private readonly users: User[] = [];
findAll(): User[] {
return this.users;
}
findOne(id: number): User {
return this.users.find(user => user.id === id);
}
create(user: User): User {
this.users.push(user);
return user;
}
update(id: number, updatedUser: Partial<User>): User {
const user = this.findOne(id);
if (user) {
Object.assign(user, updatedUser);
}
return user;
}
delete(id: number): boolean {
const index = this.users.findIndex(user => user.id === id);
if (index !== -1) {
this.users.splice(index, 1);
return true;
}
return false;
}
}
이 서비스에서는 사용자 데이터에 대한 CRUD(Create, Read, Update, Delete) 작업을 처리하는 메서드를 정의했습니다. 실제 애플리케이션에서는 데이터베이스와 상호 작용하는 코드가 여기에 포함될 것입니다.
의존성 주입 이해하기
서비스를 만들었으니 이제 컨트롤러에서 사용해 보겠습니다. NestJS의 의존성 주입 시스템을 통해 컨트롤러에 서비스를 주입할 수 있습니다:
// src/users/users.controller.ts
import { Controller, Get, Post, Body, Param, Put, Delete } from '@nestjs/common';
import { UsersService } from './users.service';
import { User } from './interfaces/user.interface';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get()
findAll(): User[] {
return this.usersService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string): User {
return this.usersService.findOne(+id);
}
@Post()
create(@Body() createUserDto: CreateUserDto): User {
return this.usersService.create(createUserDto as User);
}
@Put(':id')
update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto): User {
return this.usersService.update(+id, updateUserDto);
}
@Delete(':id')
delete(@Param('id') id: string): boolean {
return this.usersService.delete(+id);
}
}
여기서 중요한 부분은 컨트롤러의 constructor입니다:
constructor(private readonly usersService: UsersService) {}
이 코드는 NestJS에게 UsersService 인스턴스를 컨트롤러에 주입하도록 지시합니다. 컨트롤러는 서비스 메서드를 호출하여 비즈니스 로직을 실행합니다.
이렇게 의존성 주입을 사용하면 다음과 같은 이점이 있습니다:
- 관심사 분리: 컨트롤러는 요청 처리에만 집중하고, 서비스는 비즈니스 로직에만 집중합니다.
- 테스트 용이성: 의존성을 쉽게 모킹(mocking)할 수 있어 단위 테스트가 용이해집니다.
- 코드 재사용: 동일한 서비스를 여러 컨트롤러에서 사용할 수 있습니다.
- 유지보수성: 코드 변경 시 영향 범위가 최소화됩니다.
서비스를 사용하기 위해서는 모듈에 등록해야 합니다:
// src/users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService], // 다른 모듈에서 이 서비스를 사용할 수 있게 함
})
export class UsersModule {}
스코프와 라이프사이클
기본적으로 NestJS의 프로바이더는 싱글톤(Singleton)으로 생성됩니다. 즉, 같은 프로바이더 클래스의 인스턴스는 애플리케이션 전체에서 하나만 존재합니다.
그러나 프로바이더의 스코프를 변경할 수도 있습니다:
import { Injectable, Scope } from '@nestjs/common';
@Injectable({ scope: Scope.REQUEST })
export class RequestScopedService {}
NestJS는 다음과 같은 스코프를 제공합니다:
- DEFAULT: 싱글톤 인스턴스가 전체 애플리케이션에서 공유됩니다(기본값).
- REQUEST: 각 HTTP 요청마다 새로운 인스턴스가 생성됩니다.
- TRANSIENT: 프로바이더를 주입받는 각 컨슈머마다 새로운 인스턴스가 생성됩니다.
REQUEST 또는 TRANSIENT 스코프를 사용할 때는 성능 영향을 고려해야 합니다. 매번 새 인스턴스를 생성하는 것은 메모리 사용량과 처리 시간을 증가시킬 수 있습니다.
커스텀 프로바이더
NestJS는 다양한 형태의 커스텀 프로바이더를 지원합니다:
1. 값 프로바이더 (Value Providers)
값 프로바이더는 상수값을 프로바이더로 사용할 수 있게 합니다:
const configProvider = {
provide: 'CONFIG',
useValue: {
apiUrl: 'https://api.example.com',
timeout: 3000,
},
};
@Module({
providers: [configProvider],
})
export class AppModule {}
컨트롤러나 다른 프로바이더에서 이 값을 주입받을 수 있습니다:
@Injectable()
export class AppService {
constructor(@Inject('CONFIG') private readonly config) {}
getApiUrl(): string {
return this.config.apiUrl;
}
}
2. 클래스 프로바이더 (Class Providers)
클래스 프로바이더는 특정 토큰에 대해 사용할 클래스를 지정합니다:
const userServiceProvider = {
provide: UsersService,
useClass: UsersServiceImplementation,
};
@Module({
providers: [userServiceProvider],
})
export class AppModule {}
3. 팩토리 프로바이더 (Factory Providers)
팩토리 프로바이더는 프로바이더 인스턴스를 동적으로 생성합니다:
const databaseProvider = {
provide: 'DATABASE',
useFactory: async (configService: ConfigService) => {
const dbHost = configService.get('DB_HOST');
const dbPort = configService.get('DB_PORT');
// 데이터베이스 연결 생성
return new DatabaseConnection(dbHost, dbPort);
},
inject: [ConfigService], // 팩토리 함수에 주입할 의존성
};
@Module({
providers: [databaseProvider, ConfigService],
})
export class AppModule {}
4. 별칭 프로바이더 (Alias Providers)
별칭 프로바이더는 기존 프로바이더의 별칭을 만듭니다:
const loggerAliasProvider = {
provide: 'LOGGER',
useExisting: LoggerService,
};
@Module({
providers: [LoggerService, loggerAliasProvider],
})
export class AppModule {}
프로바이더 등록 방법
프로바이더는 모듈의 providers 배열에 등록합니다:
@Module({
providers: [
UsersService,
// 또는 더 자세한 형태로:
{
provide: UsersService,
useClass: UsersService,
},
// 커스텀 프로바이더도 함께 등록할 수 있습니다:
configProvider,
databaseProvider,
],
})
export class UsersModule {}
다른 모듈에서 프로바이더를 사용하려면, 해당 프로바이더를 내보내야(export) 합니다:
@Module({
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
그리고 이 모듈을 사용할 모듈에서 임포트합니다:
@Module({
imports: [UsersModule],
controllers: [AppController],
})
export class AppModule {}
실전 예제: 사용자 관리 서비스 구현
지금까지 배운 내용을 활용하여 보다 현실적인 사용자 관리 서비스를 구현해 보겠습니다. 이 예제에서는 사용자 데이터를 메모리에 저장하지만, 실제 애플리케이션에서는 데이터베이스를 사용할 것입니다.
먼저 필요한 인터페이스와 DTO를 정의합니다:
// src/users/interfaces/user.interface.ts
export interface User {
id: number;
username: string;
email: string;
password: string; // 실제로는 암호화된 비밀번호
createdAt: Date;
updatedAt: Date;
}
// src/users/dto/create-user.dto.ts
export class CreateUserDto {
readonly username: string;
readonly email: string;
readonly password: string;
}
// src/users/dto/update-user.dto.ts
export class UpdateUserDto {
readonly username?: string;
readonly email?: string;
readonly password?: string;
}
다음으로, 사용자 서비스를 구현합니다:
// src/users/users.service.ts
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { User } from './interfaces/user.interface';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
@Injectable()
export class UsersService {
// 메모리에 사용자 데이터 저장 (실제로는 데이터베이스 사용)
private users: User[] = [];
private nextId = 1;
findAll(): User[] {
return this.users.map(user => this.sanitizeUser(user));
}
findOne(id: number): User {
const user = this.users.find(user => user.id === id);
if (!user) {
throw new NotFoundException(`User with ID ${id} not found`);
}
return this.sanitizeUser(user);
}
findByUsername(username: string): User | undefined {
return this.users.find(user => user.username === username);
}
findByEmail(email: string): User | undefined {
return this.users.find(user => user.email === email);
}
create(createUserDto: CreateUserDto): User {
// 중복 사용자 이름, 이메일 확인
const existingUserName = this.findByUsername(createUserDto.username);
if (existingUserName) {
throw new ConflictException(`Username ${createUserDto.username} already exists`);
}
const existingEmail = this.findByEmail(createUserDto.email);
if (existingEmail) {
throw new ConflictException(`Email ${createUserDto.email} already exists`);
}
// 새 사용자 생성
const newUser: User = {
id: this.nextId++,
...createUserDto,
createdAt: new Date(),
updatedAt: new Date(),
};
this.users.push(newUser);
return this.sanitizeUser(newUser);
}
update(id: number, updateUserDto: UpdateUserDto): User {
const userIndex = this.users.findIndex(user => user.id === id);
if (userIndex === -1) {
throw new NotFoundException(`User with ID ${id} not found`);
}
// 이메일 중복 확인 (이메일 변경 시)
if (updateUserDto.email && updateUserDto.email !== this.users[userIndex].email) {
const existingEmail = this.findByEmail(updateUserDto.email);
if (existingEmail) {
throw new ConflictException(`Email ${updateUserDto.email} already exists`);
}
}
// 사용자 이름 중복 확인 (사용자 이름 변경 시)
if (updateUserDto.username && updateUserDto.username !== this.users[userIndex].username) {
const existingUserName = this.findByUsername(updateUserDto.username);
if (existingUserName) {
throw new ConflictException(`Username ${updateUserDto.username} already exists`);
}
}
// 사용자 정보 업데이트
this.users[userIndex] = {
...this.users[userIndex],
...updateUserDto,
updatedAt: new Date(),
};
return this.sanitizeUser(this.users[userIndex]);
}
delete(id: number): void {
const userIndex = this.users.findIndex(user => user.id === id);
if (userIndex === -1) {
throw new NotFoundException(`User with ID ${id} not found`);
}
this.users.splice(userIndex, 1);
}
// 보안을 위해 비밀번호 제거
private sanitizeUser(user: User): Omit<User, 'password'> {
const { password, ...result } = user;
return result;
}
}
이제 사용자 컨트롤러를 구현합니다:
// src/users/users.controller.ts
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
HttpStatus,
HttpCode
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get()
findAll() {
return this.usersService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.usersService.findOne(+id);
}
@Post()
@HttpCode(HttpStatus.CREATED)
create(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
}
@Put(':id')
update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
return this.usersService.update(+id, updateUserDto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
delete(@Param('id') id: string) {
this.usersService.delete(+id);
}
}
마지막으로, 이들을 모듈에 등록합니다:
// src/users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
이 예제는 다음과 같은 특징을 갖습니다:
- 관심사 분리: 컨트롤러는 요청 처리만 담당하고, 서비스는 비즈니스 로직을 담당합니다.
- 예외 처리: NotFoundException, ConflictException 등의 예외를 사용하여 오류 상황을 명확히 표현합니다.
- 데이터 검증: 중복 사용자 이름, 이메일 등을 확인합니다.
- 보안 고려: 응답에서 비밀번호를 제거하는 sanitizeUser 메서드를 사용합니다.
실제 애플리케이션에서는 다음과 같은 추가 작업이 필요할 것입니다:
- 데이터베이스 연결 (TypeORM, Mongoose 등 사용)
- 비밀번호 해싱 (bcrypt 등 사용)
- 인증 및 권한 부여 시스템 구현
- 유효성 검증 (class-validator 등 사용)
정리
이번 글에서는 NestJS의 프로바이더와 서비스에 대해 알아보았습니다. 프로바이더는 의존성 주입 시스템을 통해 애플리케이션의 모듈화와 유지보수성을 향상시키는 핵심 개념입니다. 서비스는 비즈니스 로직을 컨트롤러에서 분리하여 단일 책임 원칙(SRP)을 준수하게 해줍니다.
NestJS의 프로바이더 시스템은 다음과 같은 장점을 제공합니다:
- 코드 모듈화: 관심사를 분리하여 코드를 더 쉽게 관리할 수 있습니다.
- 재사용성: 동일한 서비스를 여러 컨트롤러나 다른 서비스에서 사용할 수 있습니다.
- 테스트 용이성: 의존성을 쉽게 모킹하여 단위 테스트를 작성할 수 있습니다.
- 확장성: 다양한 유형의 프로바이더를 통해 복잡한 애플리케이션 구조를 구현할 수 있습니다.
다음 글에서는 NestJS의 모듈(Modules)에 대해 알아보고, 애플리케이션 구조화와 모듈 간 의존성 관리 방법을 살펴보겠습니다.
'NestJS' 카테고리의 다른 글
6. NestJS 미들웨어: 요청 처리 파이프라인 이해하기 (0) | 2025.03.30 |
---|---|
5. NestJS 모듈: 애플리케이션 구조화와 모듈간 의존성 관리 (0) | 2025.03.30 |
3. NestJS 컨트롤러: RESTful API 엔드포인트 구축하기 (0) | 2025.03.30 |
2. NestJS 설치 및 첫 번째 프로젝트 구성하기 (0) | 2025.03.30 |
1. NestJS 소개: 철학, 아키텍처, Express와의 차이점 (0) | 2025.03.30 |