NestJS - Provider, Scope
이 포스트는 NestJS 의 Provider 에 대해 알아본다.
소스는 example, user-service 에 있습니다.
1. Provider: @Injectable
Provider 는 애플리케이션의 비즈니스 로직을 수행하는 역할을 한다.
(비즈니스 로직을 Controller 에서 하면 SRP 에 부합되지 않음) Provider 는 Service, Repository, Factory, Helper 등 여러 가지 형태로 구현 가능하다.
단일 책임 원칙 (SRP, Single Responsibility Principle)
NestJS 에서 제공하는 Provider 는 의존성 주입이 가능하다.
의존성 주입 (DI, Dependency Injection)
DI 를 통해 객체를 생성하고, 관심사 분리 가능, 코드 가독성과 재사용성 높아짐
users.controller.ts
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) { }
@Delete(':id')
remove(@Param('id') id: string) {
return this.usersService.remove(+id);
}
}
users.service.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class UsersService {
remove(id: number) {
return `This action removes a #${id} user`;
}
}
비즈니스 로직을 UsersController 가 수행하는 것이 아니라 UsersService 에서 수행한다.
UserService 는 UserController 의 생성자에서 주입받아 userService 객체 멤버 변수에 할당되어 사용된다.
UserService 클래스에 @Injectable
데커레이터를 선언하여 다른 컴포넌트에서 주입할 수 있는 Provider 가 된다. (즉, UserService 가 Provider) 별도의 scope 를 지정하지 않으면 Singleton Instance 가 생성된다.
1.1. Provider 등록
Controller 와 마찬가지로 Provider Instance 를 모듈에서 사용 가능하도록 users.module.ts 에 등록해준다.
app.module.ts
@Module({
controllers: [UsersController],
providers: [UsersService],
})
export class AppModule {}
1.2. Provider 사용 (속성 기반 주입): @Inject
위의 users.controller.ts 를 보면 생성자를 통해 Provider 를 주입받았다.
users.controller.ts
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) { }
@Delete(':id')
remove(@Param('id') id: string) {
return this.usersService.remove(+id);
}
}
Provider 를 직접 주입받는 것이 아니라 상속 관계에 있는 자식 클래스를 주입받아 사용하고 싶은 경우(= 레거시 클래스를 확장한 새로운 클래스를 만들고 새로 만든 클래스를 Provider 로 사용하고 싶은 경우) 에 자식 클래스에서 부모 클래스가 제공하는 함수를 호출하기 위해서 부모 클래스에서 필요한 Provider 를 super() 를 통해 전달해줘야 한다.
base-service.ts
import { ServiceA } from './service-A';
// 해당 클래스를 직접 참조하지 않으므로 @Injectable 선언하지 않음
export class BaseService {
constructor(private readonly serviceA: ServiceA) {}
getHello(): string {
return 'Hello Base.';
}
doFromA(): string {
return this.serviceA.getHello();
}
}
service-A.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class ServiceA {
getHello(): string {
return 'Hello Service A.';
}
}
service-B.ts
import { Injectable } from '@nestjs/common';
import { BaseService } from './base-service';
@Injectable()
export class ServiceB extends BaseService {
getHello(): string {
return this.doFromA();
}
}
app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { ServiceB } from './service-B';
@Controller()
export class AppController {
constructor(private readonly serviceB: ServiceB) {}
@Get('/serviceB')
getHelloB(): string {
return this.serviceB.getHello();
}
}
이제 npm run start:dev
로 서버 실행 후 엔드 포인트를 호출해보면 에러가 뜨는 것을 확인할 수 있다.
$ curl --location 'http://localhost:3000/serviceB'
{"statusCode":500,"message":"Internal server error"}%
ERROR [ExceptionsHandler] Cannot read properties of undefined (reading 'getHello')
TypeError: Cannot read properties of undefined (reading 'getHello')
at ServiceB.doFromA (/src/base-service.ts:12:26)
AppController 에서는 ServiceB 를 주입받았고, ServiceB 의 getHello() 호출 시 ServiceB 의 getHello() 는 BaseService 의 doFromA() 를 호출한다. 하지만 BaseService 는 주입가능한 클래스가 아니기 때문에(= @Injectable
데커레이터가 선언되어 있지 않음) IoC 컨테이너는 BaseService 생성자에 선언된 ServiceA 를 주입하지 않아 오류가 발생한다.
이 때는 ServiceB 에서 super 를 통해 ServiceA 의 인스턴스를 전달해주어야 한다.
service-B.ts
import { Injectable } from '@nestjs/common';
import { BaseService } from './base-service';
import { ServiceA } from './service-A';
@Injectable()
export class ServiceB extends BaseService {
// 상속 관계에서 생성자 기반 주입을 받을 때에는 하위 클래스가 super 를 통해 상위 클래스에 필요한 프로바이더를 전달해주어야 함
constructor(private readonly _serviceA: ServiceA) {
super(_serviceA);
}
getHello(): string {
return this.doFromA();
}
}
이제 오류없이 동작하는 것을 확인할 수 있다.
$ curl --location 'http://localhost:3000/serviceB'
Hello Service A.%
매번 이렇게 자식 클래스에서 super 를 통해 전달하는 대신 부모 클래스에 @Inject
데커레이터를 통해 속성 기반 프로바이더를 사용하면 된다.
service-B.ts
import { Injectable } from '@nestjs/common';
import { BaseService } from './base-service';
@Injectable()
export class ServiceB extends BaseService {
// 상속 관계에서 생성자 기반 주입을 받을 때에는 하위 클래스가 super 를 통해 상위 클래스에 필요한 프로바이더를 전달해주어야 함
// constructor(private readonly _serviceA: ServiceA) {
// super(_serviceA);
// }
getHello(): string {
return this.doFromA();
}
}
base-service.ts
// 해당 클래스를 직접 참조하지 않으므로 @Injectable 선언하지 않음
export class BaseService {
// 상속 관계에서 생성자 기반 주입을 받을 때
//constructor(private readonly serviceA: ServiceA) {}
// 상속 관계에서 속성 기반 주입을 받을 때
@Inject(ServiceA)
private readonly serviceA: ServiceA;
...
doFromA(): string {
return this.serviceA.getHello();
}
}
@Inject
데커레이터의 인수는 타입(클래스명), 문자열, 심벌이 올 수 있는데 Provider 가 어떻게 정의되느냐에 따라 다르다.
@Injectable
이 선언된 클래스는 클래스명을 사용하면 된다.
상속 관계에 있지 않은 경우는 속성 기반 주입이 아닌 생성자 기반 주입을 권장함
2. 유저 서비스 회원 가입 로직 구현
- 회원 가입
- 이메일 인증
- 로그인
- 회원 정보 조회
2.1. Provider 생성
UsersService Provider 를 생성한다.
$ nest g s Users
CREATE src/users/users.service.spec.ts (453 bytes)
CREATE src/users/users.service.ts (89 bytes)
UPDATE src/app.module.ts (302 bytes)
테스트 작성법은 아직 활용하지 않을 예정이므로 .spec.ts 는 삭제한다.
최종 디렉터리 구조는 아래와 같다.
$ pwd
/src
$ tree -L 3 -N -I "node_modules"
.
├── app.module.ts
├── email
│ └── email.service.ts
├── main.ts
└── users
├── UserInfo.ts
├── dto
│ ├── create-user.dto.ts
│ ├── user-login.dto.ts
│ └── verify-email.dto.ts
├── users.controller.ts
└── users.service.ts
2.2. 회원 가입
uuid library 설치
$ npm i uuid
$ npm i -D @types/uuid
UsersService, UsersController 의 회원 가입 로직을 수정한다.
users.service.ts
import { Injectable } from '@nestjs/common';
import * as uuid from 'uuid';
@Injectable()
export class UsersService {
// 회원 가입
async createUser(name: string, email: string, password: string) {
// 가입 유무 확인
await this.checkUserExists(email);
const signupVerifyToken = uuid.v1();
console.log('signupVerifyToken: ', signupVerifyToken);
// 유저 정보 저장
await this.saveUser(name, email, password, signupVerifyToken);
// 회원 가입 이메일 발송
await this.sendMemberJoinEmail(email, signupVerifyToken);
}
// 가입 유무 확인
private async checkUserExists(email: string) {
return false; // TODO: DB 연동 후 구현
}
// 유저 정보 저장
private saveUser(
name: string,
email: string,
password: string,
signupVerifyToken: string,
) {
return; // TODO: DB 연동 후 구현
}
// 회원 가입 이메일 발송
private async sendMemberJoinEmail(email: string, signupVerifyToken: string) {
return; // TODO: 이메일 발송 프로바이더 구현 후 적용
}
}
users.controller.ts
@Controller('users')
export class UsersController {
// UsersService 를 컨트롤러에 주입
constructor(private readonly usersService: UsersService) { }
// 회원 가입
@Post()
async createUser(@Body() dto: CreateUserDto): Promise<void> {
const {name, email, password} = dto;
console.log('createUser dto: ', dto);
await this.usersService.createUser(name, email, password);
}
}
2.3. 회원 가입 이메일 발송
이메일 전송 library 설치 (무료 서비스, 테스트용으로만 사용하고 상용 서비스에는 적용하지 말 것)
$ npm i nodemailer
$ npm i -D @types/nodemailer
이메일 처리 프로바이더를 생성 후 .spec.ts 파일은 삭제한다.
$ nest g s Email
CREATE src/email/email.service.spec.ts (453 bytes)
CREATE src/email/email.service.ts (89 bytes)
UPDATE src/app.module.ts (370 bytes)
/src/email/email.service.ts
import { Injectable } from '@nestjs/common';
import Mail from 'nodemailer/lib/mailer';
import * as nodemailer from 'nodemailer';
interface EmailOptions {
to: string;
subject: string;
html: string;
}
@Injectable()
export class EmailService {
private transporter: Mail;
constructor() {
this.transporter = nodemailer.createTransport({
service: 'Gmail',
auth: {
user: 'YOUR_EMAIL',
pass: 'YOUR_PASSWORD',
},
});
}
// 가입 인증 메일 발송
async sendMemberJoinVerification(email: string, signupVerifyToken: string) {
const baseUrl = 'http://localhost:3000';
const url = `${baseUrl}/users/email-verify?signupVerifyToken=${signupVerifyToken}`;
const mailOptions: EmailOptions = {
to: email,
subject: '가입 인증 메일',
html: `가입확인 버튼를 누르시면 가입 인증이 완료됩니다.<br />
<form action="${url}" method="POST">
<button>가입확인</button>
</form>`,
};
return await this.transporter.sendMail(mailOptions);
}
}
users.service.ts
import { EmailService } from '../email/email.service';
@Injectable()
export class UsersService {
constructor(private emailService: EmailService) { }
...
// 회원 가입 이메일 발송
private async sendMemberJoinEmail(email: string, signupVerifyToken: string) {
await this.emailService.sendMemberJoinVerification(
email,
signupVerifyToken,
);
}
}
nodemailer 는 간단한 이메일 전송 테스트만을 위한 라이브러리이므로 Gmail 에서 보안이 낮은 앱으로 판단한다. Google 앱 비밀번호로 로그인 설정 를 참고하여 앱 비밀번호 설정 후 테스트해보아야 한다.
위에서 나온 앱 비밀번호를 EmailOptions 의 pass 에 기재한다.
이제 회원 가입 요청 시 가입 인증 메일이 도착하는 것을 확인할 수 있다.
$ curl --location 'http://localhost:3000/users' \
--header 'Content-Type: application/json' \
--data-raw '{
"name": "assu",
"email": "test@test.com",
"password": "test"
}'
createUser dto: { name: 'assu', email: 'jh.lee@weversecompany.com', password: 'test' }
signupVerifyToken: b2d8f740-c163-11ed-9057-497683782c6d
2.4. 이메일 인증
메일의 가입 확인 버튼 클릭 시 진행되는 이메일 인증을 구현한다.
users.service.ts
// 이메일 인증
async verifyEmail(signupVerifyToken: string): Promise<string> {
// TODO: DB 에 signupVerifyToken 으로 회원 가입 처리중인 유저가 있는지 조회 후 없다면 에러 처리
// TODO: 바로 로그인 상태가 되도록 JWT 발급
throw new Error('아직 미구현된 로직');
}
users.controller.ts
// 이메일 인증
@Post('/email-verify')
async verifyEmail(@Query() dto: VerifyEmailDto): Promise<string> {
const { signupVerifyToken } = dto;
console.log('verifyEmail dto: ', dto);
return await this.usersService.verifyEmail(signupVerifyToken);
}
2.5. 로그인
users.service.ts
// 로그인
async login(email: string, password: string): Promise<string> {
// TODO: DB 에 email, password 가진 유저 존재 여부 조회 후 없다면 에러 처리
// TODO: JWT 발급
throw new Error('아직 미구현된 로직');
}
users.controller.ts
// 로그인
@Post('login')
async login(@Body() dto: UserLoginDto): Promise<string> {
const { email, password } = dto;
console.log('login dto: ', dto);
return await this.usersService.login(email, password);
}
2.6. 유저 정보 조회
users.service.ts
// 유저 정보 조회
async getUserInfo(userId: string): Promise<UserInfo> {
// TODO: DB 에 userId 가진 유저 존재 여부 조회 후 없다면 에러 처리
// TODO: 조회 데이터를 userInfo 타입으로 리턴
throw new Error('아직 미구현된 로직');
}
users.controller.ts
// 유저 정보 조회
@Get(':id')
async getUserInfo(@Param('id') userId: string): Promise<UserInfo> {
console.log('getUserInfo userId: ', userId);
return await this.usersService.getUserInfo(userId);
}
3. Scope
Node.js 는 멀티 스레드 상태 비저장(stateless) 모델을 따르지 않기 때문에 Singleton Instance 를 사용하는 것이 안전하다. 이것은 요청으로 들어오는 모든 정보(DB 커넥션 풀, 전역 싱글턴 서비스 등) 들을 공유할 수 있다는 것을 의미한다.
하지만 요청별 캐싱이 필요하거나, 요청을 추적하거나, 멀티테넌시참고를 지원하기 위해서는 요청 기반으로 생명 주기를 제한해야 한다.
멀티테넌시
하나의 애플리케이션 인스턴스가 여러 사용자에게 각각 다르게 동작하도록 하는 아키텍처
반대로 각 사용자마다 인스턴스가 만들어지는 것은 멀티 인스턴스 방식 ↩
Scope 종류는 아래와 같다.
- DEFAULT (권장)
- Singleton Instance 가 전체 애플리케이션에 공유됨
- Instance 수명은 애플리케이션 생명 주기와 동일
- 애플리케이션의 부트스트랩 과정이 끝나면 모든 Singleton 프로바이더의 Instance 가 생성됨
- Instance 를 캐시할 수 있고, 초기화가 애플리케이션 시작 시 한 번만 발생하므로 메모리와 동작 성능을 향상시킬 수 있기 때문에 해당 옵션을 권장함
- REQUEST
- 들어오는 요청마다 별도의 Instance 생성
- 요청이 끝나면 Instance 는 GarbageCollected 됨
- TRANSIENT
- TRANSIENT Scope 를 지정한 Instance 는 공유되지 않음
- 이 Provider 를 주입하는 컴포넌트는 새로 생성된 전용 Instance 를 주입받음
Scope 는 Controller 와 Provider 에 선언이 가능한데 만일 연관된 컴포넌트들이 서로 다른 Scope 를 가진다면 종속성을 가진 컴포넌트들의 Scope 를 따라가게 된다.
예를 들어 TestController → TestService → TestRepository 의 종속성이 있을 때 TestService 만 REQUEST Scope 이고 나머지는 모두 DEFAULT 라면 TestController 는 TestService 에 의존적이므로 Scope 가 REQUEST 로 변경된다. 하지만 TestRepository 의 경우 TestService 에 의존적이지 않으므로 그대로 DEFAULT 를 유지한다.
3.1. Provider 에 Scope 적용
@Injectable
데커레이터에 scope 속성을 준다.
import { Injectable, Scope } from '@nestjs/common';
@Injectable({ scope: Scope.REQUEST })
Custom Provider 도 마찬가지이다.
{
"provide": 'TEST',
"useClass": Test,
"scope": Scope.REQUEST
}
3.2. Controller 에 Scope 적용
@Controller
데커레이터는 ControllerOptions 를 인수로 받는데 ControllerOptions 는 ScopeOptions 를 상속받는다.
@Controller({
path: 'test',
scope: Scope.REQUEST,
})
export class UsersController { }
4. Custom Provider
이 내용은 각자 찾아보세요.
참고 사이트 & 함께 보면 좋은 사이트
본 포스트는 한용재 저자의 NestJS로 배우는 백엔드 프로그래밍을 기반으로 스터디하며 정리한 내용들입니다.
- NestJS로 배우는 백엔드 프로그래밍
- NestJS로 배우는 백엔드 프로그래밍 - Github
- NestJS 공식문서
- NestJS docs
- Nest.js Github
- NestJS 공식 예제 Starter 프로젝트 Github
- npm - nodemailer
- Google 앱 비밀번호로 로그인 설정