NestJS Dynamic Module을 이용하여 Naver Cloud Platform 메일 발송 📨 서비스 모듈 예제를 만들어 봅니다.
NestJS에서 Module은 애플리케이션 구조를 구성(organize)하는데 사용됩니다.
NestJS의 모듈은 기본적으로 싱글톤 패턴입니다. 같은 프로바이더 인스턴스(Service, Gateway, ...)를 많은 모듈에서 쉽게 공유되어 사용할 수 있습니다.
NestJS의 Module은 전체 Application의 모듈형 부분으로, 적합한 Providers 및 Controllers와 같은 구성요소 그룹을 정의합니다.
NestJS 모듈은 1) 정적 모듈과 2) 동적 모듈 두 가지로 분류됩니다.
정적 모듈은 NestJS가 필요한 모든 정보를 미리 호스트 및 consuming 모듈에서 선언하여 사용합니다.
동적 모듈은 모듈 등록과 Provider를 동적으로 설정이 가능한 커스텀 가능한 모듈을 쉽게 만들 수 있게 합니다.
정적 모듈 바인딩에서 불가능했던 상황들이 있습니다. 개발환경에 맞춰 서버 포트번호를 다르게 부여해야하는 등 다양한 상황에서 동적 바인딩은 말그대로 동적으로 상황에 맞게 Module을 커스텀할 수 있게 해줍니다.
우리의 예제에서는 메일 발송을 위한 모듈을 만들어 볼 것 입니다. API Key를 발급받아 환경변수에 설정하고, 이에 맞춰 Module을 동적으로 설정해봅시다.

마이페이지 - 인증키 관리 로 API 인증키를 발급합니다.
@nestjs/cli 페키지를 설치하여 프로젝트를 생성해줍니다. $ npm i -g @nestjs/cli
$ nest new mail-service-project
$ npm i
$ nest generate module mail
axios package를 설치하여 api를 요청해봅니다. $ npm i axios # 다른 http package를 써도 됩니다.
ConfigModule를 설치하고, Joi data validator를 사용합니다. (Joi는 Config와 써보고 싶었는데, 이번에 같이 사용해보겠습니다.) $ npm i --save @nestjs/config
$ npm i joi
.env 파일을 package.json 파일이 있는 디렉토리에 생성해줍니다. # .env
# NAVER CLOUD PLATFORM
ACCESS_KEY_ID=퍼블릭키
SECRET_KEY=쉿!비밀키ㅎ
SENDER_ADDRESS=1yongs_@naver.com
MAIL_API_DOMAIN=https://mail.apigw.ntruss.com/
app.module.ts에 ConfigModule을 .forRoot() static method를 통해 root로 import 해줍니다. (공식문서) // src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import * as Joi from 'joi';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: `.env`,
validationSchema: Joi.object({
ACCESS_KEY_ID: Joi.string().required(),
SECRET_KEY: Joi.string().required(),
SENDER_ADDRESS: Joi.string().required(),
MAIL_API_DOMAIN: Joi.string().required(),
}),
}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
다음으로 Dynamic Module을 만들어 봅시다.
// src/common/common.constants.ts
export const CONFIG_OPTIONS = 'CONFIG_OPTIONS';
CONFIG_OPTIONS변수는 목적(모듈 설정 옵션이라는..)을 상술하기 위한 DI 토큰으로 쓰기위해 상수로 정의하였습니다.
// src/mail/mail.interface.ts
export interface MailModuleOptions {
apiKey: string; // 네이버 클라우드 플랫폼 포털에서 발급받은 Access Key ID 값
secret: string; // Access Key ID 값 과 Secret Key 로 암호화한 서명
senderAddress: string; // 발송자 Email 주소. 임의의 도메인 주소 사용하셔도 됩니다만, 가능하면 발신자 소유의 도메인 Email 계정을 사용하실 것을 권고드립니다.
language: string; // API 응답 값의 다국어 처리를 위한 값. (입력 값 예시: ko-KR, en-US, zh-CN, 기본 값:en-US)
}
MailModuleOptions 인터페이스를 만들어 사용할 옵션의 틀을 만들어줍니다.
// src/mail/mail.module.ts
import { DynamicModule, Module } from '@nestjs/common';
import { CONFIG_OPTIONS } from 'src/common/common.constants';
import { MailModuleOptions } from './mail.interface';
@Module({})
export class MailModule {
static forRoot(options: MailModuleOptions): DynamicModule {
return {
module: MailModule,
providers: [
{
provide: CONFIG_OPTIONS,
useValue: options,
},
],
exports: [],
};
}
}
드디어 대망의 MailModule 입니다.
MailModule을 Dynamic Module로 만들어 주기 위해 DynamicModule static method 인 forRoot를 정의 해줍니다.
forRoot에 옵션을 주기위해 options를 매개변수로 주고,
DynamicModule을 return 해주는데, providers를 유심히 살펴봅시다.
옵션 값(options)을 Depedency Inject하기 위해 CONFIG_OPTIONS을 provide에 DI token 값으로 주고, 매개변수 options를 useValue에 주면 Dynamic Module를 정의할 수 있습니다.
// src/app.module.ts
//...
MailModule.forRoot({
apiKey: process.env.ACCESS_KEY_ID,
secret: process.env.SECRET_KEY,
senderAddress: process.env.SENDER_ADDRESS,
language: 'ko-KR', // 한국어
}),
//...
$ nest generate service mail # windows는 npx nest generate service mail
// src/mail/dto/send-email.dto.ts
import { Type } from 'class-transformer';
import { IsOptional, IsString, ValidateNested } from 'class-validator';
import { CommonResponseDto } from 'src/common/dto/common.dto';
export class Recipients {
@IsString()
address: string;
@IsString()
name: string;
@IsString()
type: string;
}
export class SendEmailRequestDto {
@IsString()
senderName: string;
@IsString()
title: string;
@IsString()
body: string;
@ValidateNested({ each: true })
@Type(() => Recipients)
recipients: Recipients[];
}
export class SendEmailResponseDto extends CommonResponseDto {
@IsOptional()
@IsString()
requestId?: string;
@IsOptional()
@IsString()
count?: number;
}
// src/common/dto/common.dto.ts
import { IsBoolean, IsOptional, IsString } from 'class-validator';
export class CommonResponseDto {
@IsBoolean()
status: boolean;
@IsOptional()
@IsString()
error?: string;
@IsOptional()
@IsString()
message?: string;
}
MailService
메일을 발송하는데 api url로 POST 방식으로 전송합니다. 이때 headers에는 Cloud Outbound Mailer에 기재된 내용들을 넣어주고, 필요한 Body Data를 넣어 전송합니다.
x-ncp-apigw-signature-v2는 Secret Key로 HmacSHA256 알고리즘으로 암호화한 후 Base64로 인코딩하여 담아 줍니다.
import { Inject, Injectable } from '@nestjs/common';
import axios from 'axios';
import { createHmac } from 'crypto';
import { CONFIG_OPTIONS } from 'src/common/common.constants';
import {
SendEmailRequestDto,
SendEmailResponseDto,
} from './dto/send-email.dto';
import { MailModuleOptions } from './mail.interface';
@Injectable()
export class MailService {
constructor(
@Inject(CONFIG_OPTIONS) private readonly options: MailModuleOptions,
) {}
async sendEmail(
reqData: SendEmailRequestDto,
): Promise<SendEmailResponseDto> {
const url = `/api/v1/mails`;
const method = `POST`;
try {
const { data } = await axios.post<{ requestId: string; count: number }>(
`${process.env.MAIL_API_DOMAIN}${url}`,
{
senderAddress: this.options.senderAddress,
...reqData,
},
{
headers: {
'Content-Type': 'application/json',
'x-ncp-apigw-timestamp': new Date().getTime().toString(10),
'x-ncp-iam-access-key': this.options.apiKey,
'x-ncp-apigw-signature-v2': this.makeSignature(
method,
url,
new Date().getTime().toString(),
this.options.apiKey,
this.options.secret,
),
'x-ncp-lang': this.options.language,
},
},
);
return {
...data,
status: true,
};
} catch (error) {
console.log(error);
return {
status: false,
error: error.response.data,
message: `메일 발송에 실패하였습니다.`,
};
}
}
private makeSignature(
method: string,
url: string,
timestamp: string,
accessKey: string,
secretKey: string,
): string {
const space = ' '; // 공백
const newLine = '\n'; // 줄바꿈
const hmac = createHmac('sha256', secretKey);
hmac.write(method);
hmac.write(space);
hmac.write(url);
hmac.write(newLine);
hmac.write(timestamp);
hmac.write(newLine);
hmac.write(accessKey);
hmac.end();
return Buffer.from(hmac.read()).toString('base64');
}
}
AppController
메일 발송을 위한 endpoint를 app.controller.ts에 열어줍시다.
// src/app.controller.ts
@Controller()
export class AppController {
constructor(private readonly mailService: MailService) {}
@Post('/mail')
sendToClient(
@Body() reqData: SendEmailRequestDto,
): Promise<SendEmailResponseDto> {
return this.mailService.sendEmail(reqData);
}
}
// request url
"http://localhost:3000/mail"
// post request body
{
"senderName": "황 일용",
"title": "안녕하세요 테스트 메일입니다.",
"body": "안녕하세요 Naver Cloud Platform - Cloud Outbound mailer 서비스 테스트 메일 입니다. ",
"recipients": [
{
"address": "iyhwang@hnmcorp.kr",
"name": "황일용",
"type": "R"
}
]
}
// response
{
"requestId": "20210203000054051502",
"count": 1,
"status": true
}

Dynamic Module를 적용해봄으로써 NestJS 프레임워크의 IoC, DI에 대한 이해에 도움이 많이 되었습니다. 다음번엔 Custom Provider를 리뷰를 해보도록 하겠습니다.
Custom Provider 부분에서 Factory function과 Dependency Injection에 대한 이야기를 깊이 있게 다뤄보겠습니다.
좋은글 감사합니다