NestJS 서비스와 의존성 주입(DI) 이해하기

서비스(Service)란?

서비스는 비즈니스 로직을 담당하는 계층입니다. @Injectable() 데코레이터로 정의되며, 컨트롤러에서 분리하여 재사용 가능하고 테스트하기 쉬운 코드를 작성할 수 있습니다.

서비스의 역할

  • 비즈니스 로직 처리
  • 데이터베이스 접근
  • 외부 API 호출
  • 데이터 변환 및 가공
  • 재사용 가능한 기능 제공

기본 서비스 생성

CLI로 서비스 생성

# 기본 서비스 생성
nest g service users

# 특정 폴더에 생성
nest g service modules/users

# 테스트 파일 없이 생성
nest g service users --no-spec

기본 서비스 구조

import { Injectable } from '@nestjs/common';

@Injectable()
export class UsersService {
private users = [];

findAll() {
return this.users;
}

findOne(id: number) {
return this.users.find(user => user.id === id);
}

create(user: any) {
this.users.push(user);
return user;
}

update(id: number, user: any) {
const index = this.users.findIndex(u => u.id === id);
if (index !== -1) {
this.users[index] = { ...this.users[index], ...user };
return this.users[index];
}
return null;
}

remove(id: number) {
const index = this.users.findIndex(u => u.id === id);
if (index !== -1) {
return this.users.splice(index, 1)[0];
}
return null;
}
}

의존성 주입(Dependency Injection)

의존성 주입은 객체가 필요로 하는 의존성을 외부에서 제공받는 디자인 패턴입니다.

생성자 기반 주입

가장 일반적인 방법입니다.

import { Controller, Get } from '@nestjs/common';
import { UsersService } from './users.service';

@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}

@Get()
findAll() {
return this.usersService.findAll();
}
}

프로퍼티 기반 주입

특수한 경우에만 사용합니다.

import { Controller, Get } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { UsersService } from './users.service';

@Controller('users')
export class UsersController {
@Inject(UsersService)
private readonly usersService: UsersService;

@Get()
findAll() {
return this.usersService.findAll();
}
}

Provider 등록

서비스를 사용하려면 모듈의 providers 배열에 등록해야 합니다.

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
})
export class UsersModule {}

Provider 등록 방식

1. 표준 방식 (권장)

@Module({
providers: [UsersService],
})

2. 클래스 Provider (상세 버전)

@Module({
providers: [
{
provide: UsersService,
useClass: UsersService,
},
],
})

3. 값 Provider

const mockUsersService = {
findAll: () => [],
};

@Module({
providers: [
{
provide: UsersService,
useValue: mockUsersService,
},
],
})

4. Factory Provider

@Module({
providers: [
{
provide: UsersService,
useFactory: (config: ConfigService) => {
return new UsersService(config.get('database'));
},
inject: [ConfigService],
},
],
})

5. 비동기 Factory Provider

@Module({
providers: [
{
provide: 'ASYNC_CONNECTION',
useFactory: async () => {
const connection = await createConnection();
return connection;
},
},
],
})

커스텀 Provider

문자열 토큰 사용

// providers
@Module({
providers: [
{
provide: 'USER_REPOSITORY',
useClass: UserRepository,
},
],
})

// 사용
export class UsersService {
constructor(
@Inject('USER_REPOSITORY')
private readonly userRepository: UserRepository,
) {}
}

인터페이스를 위한 토큰

// user.interface.ts
export interface IUserRepository {
findAll(): Promise<User[]>;
findOne(id: number): Promise<User>;
}

// user.tokens.ts
export const USER_REPOSITORY = 'USER_REPOSITORY';

// user.repository.ts
@Injectable()
export class UserRepository implements IUserRepository {
async findAll(): Promise<User[]> {
return [];
}

async findOne(id: number): Promise<User> {
return null;
}
}

// users.module.ts
@Module({
providers: [
{
provide: USER_REPOSITORY,
useClass: UserRepository,
},
],
})

// users.service.ts
export class UsersService {
constructor(
@Inject(USER_REPOSITORY)
private readonly userRepository: IUserRepository,
) {}
}

Scope (스코프)

Provider의 생명주기를 제어합니다.

DEFAULT (싱글톤)

애플리케이션 전체에서 하나의 인스턴스만 생성됩니다. (기본값)

@Injectable()
export class UsersService {
// 싱글톤
}

REQUEST

각 요청마다 새로운 인스턴스를 생성합니다.

import { Injectable, Scope } from '@nestjs/common';

@Injectable({ scope: Scope.REQUEST })
export class UsersService {
constructor() {
console.log('New instance created for this request');
}
}

TRANSIENT

주입될 때마다 새로운 인스턴스를 생성합니다.

import { Injectable, Scope } from '@nestjs/common';

@Injectable({ scope: Scope.TRANSIENT })
export class LoggerService {
// 매번 새로운 인스턴스
}

실전 예제: 완전한 CRUD 서비스

dto/create-user.dto.ts

export class CreateUserDto {
name: string;
email: string;
age: number;
}

dto/update-user.dto.ts

export class UpdateUserDto {
name?: string;
email?: string;
age?: number;
}

entities/user.entity.ts

export class User {
id: number;
name: string;
email: string;
age: number;
createdAt: Date;
updatedAt: Date;
}

users.service.ts

import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { User } from './entities/user.entity';

@Injectable()
export class UsersService {
private users: User[] = [];
private idCounter = 1;

findAll(): User[] {
return this.users;
}

findOne(id: number): User {
const user = this.users.find(u => u.id === id);
if (!user) {
throw new NotFoundException(`User with ID ${id} not found`);
}
return user;
}

create(createUserDto: CreateUserDto): User {
const newUser: User = {
id: this.idCounter++,
...createUserDto,
createdAt: new Date(),
updatedAt: new Date(),
};
this.users.push(newUser);
return newUser;
}

update(id: number, updateUserDto: UpdateUserDto): User {
const user = this.findOne(id);
const updatedUser = {
...user,
...updateUserDto,
updatedAt: new Date(),
};
const index = this.users.findIndex(u => u.id === id);
this.users[index] = updatedUser;
return updatedUser;
}

remove(id: number): User {
const user = this.findOne(id);
this.users = this.users.filter(u => u.id !== id);
return user;
}

findByEmail(email: string): User | undefined {
return this.users.find(u => u.email === email);
}

search(keyword: string): User[] {
return this.users.filter(
u => u.name.includes(keyword) || u.email.includes(keyword),
);
}
}

users.controller.ts

import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
HttpCode,
HttpStatus,
} 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('search')
search(@Query('keyword') keyword: string) {
return this.usersService.search(keyword);
}

@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)
remove(@Param('id') id: string) {
return this.usersService.remove(+id);
}
}

서비스 간 의존성

서비스는 다른 서비스를 주입받을 수 있습니다.

email.service.ts

import { Injectable } from '@nestjs/common';

@Injectable()
export class EmailService {
sendWelcomeEmail(email: string, name: string) {
console.log(`Sending welcome email to ${email}`);
// 실제 이메일 전송 로직
return { sent: true };
}

sendPasswordResetEmail(email: string) {
console.log(`Sending password reset email to ${email}`);
return { sent: true };
}
}

users.service.ts (EmailService 사용)

import { Injectable } from '@nestjs/common';
import { EmailService } from '../email/email.service';
import { CreateUserDto } from './dto/create-user.dto';

@Injectable()
export class UsersService {
constructor(private readonly emailService: EmailService) {}

async create(createUserDto: CreateUserDto) {
// 사용자 생성 로직
const newUser = {
id: Date.now(),
...createUserDto,
};

// 환영 이메일 전송
await this.emailService.sendWelcomeEmail(
newUser.email,
newUser.name,
);

return newUser;
}
}

users.module.ts

import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { EmailModule } from '../email/email.module';

@Module({
imports: [EmailModule], // EmailService를 사용하기 위해
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

Optional Dependencies

선택적 의존성을 처리합니다.

import { Injectable, Optional, Inject } from '@nestjs/common';

@Injectable()
export class UsersService {
constructor(
@Optional()
@Inject('LOGGER')
private readonly logger?: LoggerService,
) {}

findAll() {
this.logger?.log('Finding all users');
return [];
}
}

순환 의존성 해결

forwardRef() 사용

// users.service.ts
import { Injectable, Inject, forwardRef } from '@nestjs/common';
import { PostsService } from '../posts/posts.service';

@Injectable()
export class UsersService {
constructor(
@Inject(forwardRef(() => PostsService))
private readonly postsService: PostsService,
) {}
}

// posts.service.ts
import { Injectable, Inject, forwardRef } from '@nestjs/common';
import { UsersService } from '../users/users.service';

@Injectable()
export class PostsService {
constructor(
@Inject(forwardRef(() => UsersService))
private readonly usersService: UsersService,
) {}
}

테스트를 위한 Mock Provider

users.service.spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from './users.service';
import { EmailService } from '../email/email.service';

describe('UsersService', () => {
let service: UsersService;
let emailService: EmailService;

const mockEmailService = {
sendWelcomeEmail: jest.fn(),
};

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
{
provide: EmailService,
useValue: mockEmailService,
},
],
}).compile();

service = module.get<UsersService>(UsersService);
emailService = module.get<EmailService>(EmailService);
});

it('should be defined', () => {
expect(service).toBeDefined();
});

it('should send welcome email on user creation', async () => {
const createUserDto = {
name: 'John',
email: 'john@example.com',
age: 25,
};

await service.create(createUserDto);

expect(mockEmailService.sendWelcomeEmail).toHaveBeenCalledWith(
'john@example.com',
'John',
);
});
});

Repository 패턴

데이터 액세스 로직을 분리합니다.

user.repository.ts

import { Injectable } from '@nestjs/common';
import { User } from './entities/user.entity';

@Injectable()
export class UserRepository {
private users: User[] = [];

async findAll(): Promise<User[]> {
return this.users;
}

async findById(id: number): Promise<User | undefined> {
return this.users.find(u => u.id === id);
}

async create(user: Partial<User>): Promise<User> {
const newUser = {
id: this.users.length + 1,
...user,
} as User;
this.users.push(newUser);
return newUser;
}

async update(id: number, user: Partial<User>): Promise<User | null> {
const index = this.users.findIndex(u => u.id === id);
if (index === -1) return null;
this.users[index] = { ...this.users[index], ...user };
return this.users[index];
}

async delete(id: number): Promise<boolean> {
const index = this.users.findIndex(u => u.id === id);
if (index === -1) return false;
this.users.splice(index, 1);
return true;
}
}

users.service.ts (Repository 사용)

import { Injectable, NotFoundException } from '@nestjs/common';
import { UserRepository } from './user.repository';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';

@Injectable()
export class UsersService {
constructor(private readonly userRepository: UserRepository) {}

async findAll() {
return this.userRepository.findAll();
}

async findOne(id: number) {
const user = await this.userRepository.findById(id);
if (!user) {
throw new NotFoundException(`User with ID ${id} not found`);
}
return user;
}

async create(createUserDto: CreateUserDto) {
return this.userRepository.create(createUserDto);
}

async update(id: number, updateUserDto: UpdateUserDto) {
const user = await this.userRepository.update(id, updateUserDto);
if (!user) {
throw new NotFoundException(`User with ID ${id} not found`);
}
return user;
}

async remove(id: number) {
const deleted = await this.userRepository.delete(id);
if (!deleted) {
throw new NotFoundException(`User with ID ${id} not found`);
}
return { deleted: true };
}
}

정리

  • 서비스는 @Injectable() 데코레이터로 정의
  • 비즈니스 로직을 컨트롤러에서 분리
  • 의존성 주입으로 느슨한 결합 유지
  • Provider 등록 방식: 표준, 클래스, 값, Factory
  • Scope로 생명주기 제어 (DEFAULT, REQUEST, TRANSIENT)
  • Repository 패턴으로 데이터 액세스 로직 분리
  • Mock Provider로 테스트 용이성 확보

다음 단계

서비스와 의존성 주입을 이해했다면:

  • 데이터베이스 연결 및 TypeORM 사용하기
  • 파이프로 데이터 검증하기
  • 미들웨어, 가드, 인터셉터 활용하기
  • 예외 필터로 에러 처리하기
Share