BackEnd/Nest.js

[Nest.js] 프로바이더

Grace 2023. 5. 16. 17:34

프로바이더

컨트롤러는 요청과 응답을 가공하고 처리하는 역할을 맡습니다. 하지만 서버가 전달하는 핵심 기능은 전달받은 데이터를 어떻게 비즈니스 로직으로 해결하는가입니다. 앱이 제공하고자 하는 핵심 기능, 즉 비즈니스 로직을 수행하는 역할을 하는 것이 프로바이더입니다. 컨트롤러가 이 역할을 수행할 수도 있겠지만 소프트웨어 구조상 분리해두는 것이 단일 책임 원칙에 부합합니다.

프로바이더는 서비스, 저장소, 팩터리, 헬퍼 등 여러 가지 형태로 구현이 가능합니다.

Nest에서 제공하는 프로바이더의 핵심은 의존성을 주입할 수 있다는 점입니다. 의존성을 주입하기 위한 라이브러리가 많이 있지만 Nest가 이를 제공해주기 때문에 손쉽게 사용할 수 있습니다.

의존성 주입은 OOP에서 많이 사용하는 기법입니다. 의존성 주입을 이용하면 객체를 생성하고, 사용할 때 관심사를 분리할 수 있습니다. 이는 코드 가독성과 재사용성이 높은 SW를 만들게 도와줍니다.
// 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)
  }
}

컨트롤러는 비즈니스 로직을 직접 수행하지 않습니다. 컨트롤러에 연결된 UserService 클래스에서 수행합니다. UserserviceUserController의 생성자에서 주입받아 UserService라는 객체 멤버 변수에 할당되어 사용했습니다. 아직 데이터베이스를 연결하지 않았기 때문에 UserService 내부의 코드는 문자열을 리턴하는 임시 코드만 작성되어 있지만 UserService에 어떻게 작업을 위임하는지 보여줍니다.

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

@Injectable()
export class UsersService {
  ...
  remove(id: number) {
    return 'This action removes a #${id} user'
  }
}

@Injectable 데코레이터를 보면, UsersService 클래스에 이 데코레이터를 선언함으로써 다른 어떤 Nest 컴포넌트에서도 주입할 수 있는 프로바이더가 됩니다. 별도의 스코프를 지정해주지 않으면 일반적으로 싱글톤 인스턴스가 생성됩니다.

프로바이더 등록과 사용

프로바이더 등록

프로바이더 인스턴스 역시 모듈에서 사용할 수 있도록 등록을 해줘야 합니다. 자동 생성된 코드에서 UsersModule 모듈에 등록해둔 것을 볼 수 있습니다.

@Module({
  ...
  providers: [UsersService],
})
export class UsersModule {}

속성 기반 주입

프로바이더를 직접 주입받아 사용하지 않고 상속 관계에 있는 자식 클래스를 주입받아 사용하고 싶은 경우도 있습니다. 레거시 클래스를 확장한 새로운 클래스를 만드는 경우 새로 만든 클래스를 프로바이더로 제공하고 싶은 경우입니다. 이럴 때는 자식 클래스에서 부모 클래스가 제공하는 함수를 호출하기 위해서는 부모 클래스에서 필요한 프로바이더를 super()를 통해 전달해줘야 합니다.

//base-service.ts
// @Injectable이 선언되어 있지 않습니다. BaseService 클래스를 직접 참조하지 않기 때문입니다.
export class BaseService {
  constructor(private readonly serviceA: ServiceA) {}
  
  getHello(): string {
    return 'Hello World BASE!'
  }
  
  doSomeFuncFromA(): string {
    return this.serviceA.getHello()
  }
}

// service-A.ts
@Injectable()
export class ServiceA {
  getHello(): string {
    return 'Hello World A!'
  }
}

// service-B.ts
export class ServiceB extends BaseService {
  getHello(): string {
    return this.doSomeFuncFromA()
  }
}

만약 컨트롤러에서 ServiceB를 주입하고, getHello()를 호출한다면 이는 BaseServicedoSomeFuncFromA 함수를 호출하게 됩니다. 하지만 BaseService는 주입을 받을 수 있는 클래스로 선언되어 있지 않기 때문에 NestIoC 컨테이너는 생성자에 선언된 ServiceA를 주입하지 않습니다. 이 상태에서 컨트롤러에 서비스를 호출하는 엔드포인트를 만들고 작동을 해보면 에러가 발생합니다.

@Controller()
export class AppController {
  constructor(private readonly serviceB: ServiceB) {}
  
  @Get('/serviceB')
  getHelloC(): string {
    return this.serviceB.getHello()
  }
}

요청을 확인해보면 this.ServiceB 객체가 undefined라는 것을 확인할 수 있습니다. 이 문제를 해결하기 위해서는 ServiceB에서 super를 통해 ServiceA의 인스턴스를 전달해줘야 합니다.

// service-B.ts
export class ServiceB extends BaseService {
  constructor(private readonly _serviceA: ServiceA) {
    super(_serviceA)
  }
  getHello(): string {
    return this.doSomeFuncFromA()
  }
}

이렇게 매번 super로 필요한 프로바이더를 전달하는 방식은 매우 귀찮습니다. 이럴 때는 속성 기반 프로바이더를 사용할 수 있습니다.

//base-service.ts
export class BaseService {
  @Inject(ServiceA) private readonly serviceA: serviceA;
  ...
  doSomeFuncFromA(): string {
    return this.serviceA.getHello()
  }
}

BaseService 클래스의 serviceA 속성에 @Inject 데코레이터를 달아줍니다. 데코레이터의 인수는 타입(클래스 이름), 문자열, 심벌을 사용할 수 있습니다. 어떤 걸 쓸지는 프로바이더가 어떻게 정의되었는냐에 따라 달라집니다. @Injectable이 선언된 클래스는 클래스 이름 타입을 쓰면 됩니다. 문자열과 심벌은 커스텀 프로바이더일 경우 사용합니다.

상속 관계에 있지 않은 경우는 속성 기반 주입을 사용하지 말고 생성자 기반 주입(super())을 사용하는 것을 권장합니다.

유저 서비스에 회원 가입 로직

유저 서비스의 핵심 기능인 회원 가입, 이메일 인증, 로그인, 회원 정보 조회 기능을 구현해보겠습니다. 먼저 회원 가입화면을 통해 유저 정보를 입력받아 유저 생성 요청을 받습니다. 프론트엔드에서 유저 생성과 관련한 데이터를 전달해준다고 가정하고 백엔드 기능만을 구현합니다. 이 과정에서 DB에 유저 정보를 저장하고 유저에게 회원 가입 확인 이메일을 발송합니다. 이메일을 발송하는 것은 가입하고자 하는 회원의 이메일이 유효한 이메일인지 검증하는 과정입니다. 이메일 본문에는 다시 이메일 검증을 위한 요청으로의 링크가 포함되어 있습니다. 사용자가 이 링크를 누르면 이메일 승인 요청이 들어오게 되고 회원 가입 준비 단계에 있는 유저를 승인합니다.

UsersService 프로바이더

nest g s Users 명령어로 UsersService 프로바이더를 생성합니다.

AppModuleUsersService가 추가되어 있습니다.

import { Module } from '@nestjs/common';
import { UsersConroller } from './users/users.controller';
import { UserService } from './users/users.service';

@Module({
  imports: [],
  controllers: [UsersController],
  providers: [UsersService]
})
export class AppModule { }

아직 테스트를 작성하는 방법을 배우지 않았으니 .spec.ts로 끝나는 파일은 지웁니다.

회원 가입

POST /users 엔드포인트를 담당하는 컨트롤러를 수정합니다.

import { Body, Controller, Post } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UsersService } from './users.service';

@Controller('users')
export class UsersController {
  constructor(private userService: UsersService) { } // UsersService를 컨트롤러에 주입
  
  @Post()
  async createUser(@Body() dto: CreateUserDto): Promise<void> {
    const { name, eamil, password } = dto;
    await this.usersService.createUser(name, email, password) /* dto에서 얻은 정보를
                                                                UsersService에 전달 */
  }
}

내부 구현을 담당하는 UsersService를 구현하기에 앞서 이메일 검증 시 필요한 토큰 형식을 uuid로 쓸 것이기 때문에 uuid 라이브러리를 설치합니다.

$ npm i uuid
$ npm i --save-dev @types/uuid

UsersService 구현은 다음과 같습니다.

import { Injectable } from '@nestjs/common';
import * as uuid from 'uuid';

@Injectable()
export class UsersService {
  async createUser(name: string, email: string, password: string) {
    /* 가입하려는 유저가 존재하는지 검사합니다. 만약 이미 존재하는 유저, 즉 가입 처리된 유저라면 
    에러를 발생시킵니다. DB를 연동한 후 구현을 해야 하므로 일단 false를 리턴하도록 합니다.*/
    await this.checkUserExists(email);

    const signupVerifyToken = uuid.v1();

    /* 유저를 데이터베이스에 저장합니다. 아직 데이터베이스를 연결하지 않았으므로, 저장했다고 가정합니다.
    이떄 토큰이 필요한데, 토큰은 유저가 회원 가입 메일을 받고 링크를 눌러 이메일 인증을 할 때 다시 받게 되는
    토큰입니다. 이 토큰으로 현재 가입하려는 회원이 본인의 이메일로 인증한 것인지 한 번 더 검증하는 장치를
    마련합니다. 토큰을 만들 때는 유효 기간을 설정하여 일정 기간 동안만 인증이 가능하도록 할 수도 있습니다.*/
    await this.saveUser(name, email, password, signupVerifyToken);
    await this.sendMemberJoinEmail(email, signupVerifyToken); // 회원 가입 인증 메일 발송
  }

  private checkUserExists(email: string) {
    /* 가입하려는 유저가 존재하는지 검사합니다. 만약 이미 존재하는 유저, 즉 가입 처리된 유저라면 
    에러를 발생시킵니다. DB를 연동한 후 구현을 해야 하므로 일단 false를 리턴하도록 합니다.*/
    return false; // TODO: DB 연동 후 구현
  }

  private saveUser(
    name: string,
    email: string,
    password: string,
    signupVerifyToken: string,
  ) {
    /* 유저를 데이터베이스에 저장합니다. 아직 데이터베이스를 연결하지 않았으므로, 저장했다고 가정합니다.
    이떄 토큰이 필요한데, 토큰은 유저가 회원 가입 메일을 받고 링크를 눌러 이메일 인증을 할 때 다시 받게 되는
    토큰입니다. 이 토큰으로 현재 가입하려는 회원이 본인의 이메일로 인증한 것인지 한 번 더 검증하는 장치를
    마련합니다. 토큰을 만들 때는 유효 기간을 설정하여 일정 기간 동안만 인증이 가능하도록 할 수도 있습니다.*/
    return; // TODO: DB 연동 후 구현
  }

  private async sendMemberJoinEmail(email: string, signupVerifyToken: string) {
    // 회원 가입 인증 메일을 발송합니다.
    await this.emailService.sendMemberJoinVerification(
      email,
      signupVerifyToken,
    );
  }
}

회원 가입 이메일 발송

이메일 서비스를 직접 만들어도 되지만 비즈니스에 더 집중하기 위해 보통 외부 이메일 서비스를 많이 사용합니다. 외부 이메일 서비스를 고를 때 고려할 점은 이메일 전송, 전송 기록 확인, 이메일 보안뿐 아니라 스팸 처리, 바운스(이메일 수신 서버로부터 이메일이 반송되는 것) 확인 기능을 매끄럽게 제공하는지 등입니다. 물론 이메일 전송 자체의 안정성이 가장 중요합니다.

무료로 이메일 전송을 해주는 nodemailer라는 라이브러리를 사용하겠습니다. 먼저 라이브러리를 설치합니다.

$ npm i nodemailer
$ npm i@types/nodemailer --save-dev

UsersService는 유저의 정보를 저장, 조회하는 역할을 위주로 합니다. Email 처리를 담당하는 EmailService 프로바이더를 새로 만들겠습니다.

$nest g s Email

email 디렉토리로 소스 코드도 분리되었습니다. 역시 .spec.ts 파일은 지워줍니다.

이제 UsersSevicesendMemberJoinEmail 메서드를 구현할 수 있습니다. 먼저 EmailServiceUsersService에서 주입받고 메일 발송 메서드를 호출하면 됩니다.

import { EmailService } from 'src/email/email.service';

export class UsersService {
  constructor(private emailService: EmailService) {}
  ...
  private async sendMemberJoinEmail(email: string, signupVerifyToken: string) {
    await this.emailService.sendMemberJoinVerification(
      email,
      signupVerifyToken,
    );
  }
}

EmailService에서 nodemailer를 이용해서 이메일을 보냅니다. 구글 계정을 이용하여 메일을 전송합니다.

 import { Injectable } from '@nestjs/common';
import * as nodemailer from 'nodemailer';
import Mail from 'nodemailer/lib/mailer';

interface EmailOptions {
  // 메일 옵션 타입입니다. 수신자, 메일 제목, html 형식의 메일 본문을 가집니다.
  to: string;
  subject: string;
  html: string;
}

@Injectable()
export class EmailService {
  private transporter: Mail;

  constructor() {
    this.transporter = nodemailer.createTransport({
      // nodemailer에서 제공하는 Transporter 객체를 생성합니다.
      service: 'Gmail',
      auth: {
        user: 'GMAIL',
        pass: 'PASSWORD',
      },
    });
  }

  async sendMemberJoinVerification(
    emailAddress: string,
    signupVerifyToken: string,
  ) {
    const baseUrl = 'http://localhost:3000';

    // 유저가 누를 버튼이 가질 링크를 구성합니다. 이 링크를 통해 다시 우리 서비스로 이메일 인증 요청이 들어옵니다.
    const url = `${baseUrl}/users/email-verify?signupVerifyToken=${signupVerifyToken}`;

    const mailOptions: EmailOptions = {
      to: emailAddress,
      subject: '가입 인증 메일',
      // 메일 본문을 구성합니다. form 태그를 이용하여 POST 요청을 합니다.
      html: `가입확인 버튼을 누르시면 가입 인증이 완료됩니다.<br />
              <form action="${url}" method="POST" >
                <button>가입확인 </button>
              </form>`,
    };

    return await this.transporter.sendMail(mailOptions); // transporter 객체를 이용하여 메일을 전송합니다.
  }
}

여기서 주의해야 할 점은 전송 서버로 이용할 이메일 서비스의 계정과 비밀번호, 그리고 유저가 인증할 때 URL을 구성하는 도메인 주소가 하드코딩되어 있다는 것입니다. 도메인 주소는 로컬에서 서버를 띄워서 구현하고 있기 때문에 localhost:3000으로 했습니다. 'GMAIL', 'PASSWORD' 부분은 사용하는 Gmail 계정을 입력하면 됩니다.

nodemailer는 간단한 이메일 전송 테스트만을 위해 작성된 라이브러리이기 때문에 Gmail에서 보안이 낮은 앱으로 판단합니다. 따라서 구글 계정 설정에서 2단계 인증을 활성화하여 앱 비밀번호를 생성해야 합니다.

이메일 인증

이메일 인증 로직 역시 UsersService에 처리 로직을 위임합니다.

  @Post('/email-verify')
  async verifyEmail(@Query() dto: VerifyEmailDto): Promise<void> {
    const { signupVerifyToken } = dto;

    return await this.userService.verifyEmail(signupVerifyToken);
  }

UserService에는 이메일 인증 로직을 구현해야 합니다. 역시 데이터베이스가 필요하기 떄문에 나중에 구현할 사항을 주석으로 적어두고, 일단은 에러를 일으키도록 해두겠습니다.

async verifyEmail(signupVerifyToken: string): Promise<string> {
  // TODO
  // 1. DB에서 signupVerifyToken으로 회원 가입 처리중인 유저가 있는지 조회하고 없다면 에러 처리
  // 2. 바로 로그인 상태가 되도록 JWT 발급
  
  throw new Error('Method not implemented')
}

로그인

컨트롤러에는 요청, 응답 처리만 하고 UsersService로 위임합니다.

@Post('/login')
async login(@Body() dto: UserLoginDto): Promise<string> {
  const { email, password } = dto;
  
  return await this.usersService.login(email, password)
}

로그인 로직은 전달받은 이메일 주소와 패스워드로 가입처리가 완료된 유저가 존재하는지 검사하고, 만약 존재한다면 JWT 토큰을 응답으로 돌려주는 것입니다. 역시 데이터베이스가 구현되어 있지 않기 때문에 예외를 던지도록 구현합니다.

async login(email: string, password: string): Promise<string> {
  // TODO
  // 1. email, password를 가진 유저가 존재하는지 DB에서 확인하고 없다면 에러 처리
  // 2. JWT 발급
  
  throw new Error('Method not implemented')
}

유저 정보 조회

@Post('/:id')
async getUserInfo(@Param('id') userId: string): Promise<UserInfo> {
  return await this.usersService.getUserInfo(userId)
}
async getUserInfo(userId: string): Promise<UserInfo> {
  // TODO
  // 1. userId를 가진 유저가 존재하는지 DB에서 확인하고 없다면 에러 처리
  // 2. 조회된 데이터를 UserInfo 타입으로 응답
  
  throw new Error('Method not implemented')
}

'BackEnd > Nest.js' 카테고리의 다른 글

[Nest.js] 파이프와 유효성 검사  (0) 2023.05.16
[Nest.js] Config 패키지  (0) 2023.05.16
[Nest.js] 인터페이스  (0) 2023.05.08
[Nest.js] 백엔드 개발 기본 지식  (0) 2023.04.27
[Nest.js] Get Started  (0) 2023.04.27