초기에는 @nestjs-modules/mailer
의 MailerService 로 한 번에 메일을 발송하고 있었다.
아래 코드와 같이 Promise.all로 병렬 전송을 시도한 결과 :
결국 성공과 실패가 섞인 상태로 종료되며 사용자 경험에 영향을 주는 문제가 발생했다.
/* tester별 메일 전송 작업을 비동기로 처리하는 로직 */
await Promise.all(
testerList.map(async (tester: TesterNotifyInfoDto) => {
if (!tester.testerEmail) {
throw new BadRequestException(`응시자 ${tester.testerName}님의 이메일 정보가 없어 메일 전송이 불가합니다.`);
}
const sendEmailForm: SendEmailType = {
email: tester.testerEmail,
name: tester.testerName,
subject: `[${groupInfo.companyName}] 응시 안내 메일입니다.`,
template: EmailTemplateEnum.TESTER_INFO,
text: noticeMessage,
from: EmailFromEnum.TESTER,
};
await this.sendMail(sendEmailForm, EmailFromEnum.TESTER);
}),
/* 이메일 전송 함수 */
async sendEmail(
sendEmailForm: SendEmailType,
emailFrom: EmailFromEnum,
): Promise<void> {
const mailOptions: object = await this.makeMailOptions(sendEmailForm);
try {
await this.mailerService.sendMail({ ...mailOptions, transporterName: emailFrom });
} catch (err) {
throw new BadRequestException('메일 전송에 실패했습니다.');
}
}
기존 방식의 핵심 문제는 실패한 메일에 대한 재시도 로직이 없고, 실패해도 그대로 끝난다는 점이었다.
따라서 아래와 같은 로직을 도입하였다.
✅ 개선 전략
try {
const maxTries = 10;
let queue = [...testerList];
let retryCount = 0;
while (retryCount < maxTries && queue.length > 0) {
const nextQueue = [];
await Promise.all(
queue.map(async (tester: TesterNotifyInfoDto) => {
const sendEmailForm: SendEmailType = {
email: tester.testerEmail,
name: tester.testerName,
subject: `[${groupInfo.companyName}] 응시 안내 메일입니다.`,
template: EmailTemplateEnum.TESTER_NOTICE,
text: noticeMessage,
from: EmailFromEnum.TESTER,
};
try {
await this.emailService.sendEmail(sendEmailForm, EmailFromEnum.TESTER, origin, groupInfo, tester);
} catch (err) {
nextQueue.push(tester); // 실패자만 다음 큐에 보냄
}
}),
);
queue = nextQueue; // 실행 큐를 다음 큐로 교체
retryCount++;
}
// 최종 실패자 로그
if (queue.length > 0) {
this.logger.warn(`최종 실패한 응시자:`, queue.map((t) => t.testerName));
} else {
this.logger.log('모든 메일 전송 성공');
}
} catch (err) {
throw err;
}
/* 이메일 전송 함수 (기존과 같음) */
async sendEmail(
....
결과
※ 혹시 병렬로 전송하여 전송 실패한 케이스가 많이 나온건 아닐까 생각이 들어 await Promise.all 대신 for const를 사용하여 테스트한 결과.. 11명 모두 단 한건의 실패없이 전송적으로 성공되었지만 무려 4295ms이 걸렸다... ;ㅁ;
(깔끔하게 포기)
for (const tester of queue) {
const sendEmailForm: SendEmailType = {
email: tester.testerEmail,
name: tester.testerName,
subject: `[${groupInfo.companyName}] 응시 안내 메일입니다.`,
template: EmailTemplateEnum.TESTER_NOTICE,
text: noticeMessage,
from: EmailFromEnum.TESTER,
};
✅ 개선1차에 대한 한계 최종 정리
NestJS Bull 은 Redis 기반의 작업 큐 라이브러리인 Bull을 NestJS에 통합해 사용할 수 있도록 도와주는 모듈로, 이메일 전송처럼 시간이 걸리거나 실패 가능성이 있는 작업을 백그라운드에서 처리할 수 있게 해준다.
1차 문제를 해결하기 위해 @nestjs/bull
을 도입한 이유
@bull-board/nestjs
라이브러리를 통해 모니터링 대시보드 구현 가능Queue 시스템 구성 요소
✔️ Producer : 작업 등록자
✔️ Queue : 작업 대기열
✔️ Consumer : 작업 처리자
(출처: [NestJS] Queue 완벽 가이드)
구조 프로세스 요약
사용자 요청 → 이메일 Job 생성 (Produce) → Bull 큐에 등록 → Redis에 저장 (Queue) → Processor가 처리(Consumer) → 성공/실패 처리 → 로그 기록 및 재시도
Job 생성 - Producer
@Injectable()
export class EmailProducer {
constructor(@InjectQueue('email') private readonly emailQueue: Queue) {}
async enqueueMailJob(sendEmailForm: SendEmailType, emailFrom: EmailFromEnum) {
await this.emailQueue.add(
'send-email', // job name
{
sendEmailForm, emailFrom, // job payload
},
{
attempts: 5 // 최대 재시도 횟수
backoff: 1000, // 실패 후 재시도 대기 시간 (ms)
removeOnComplete: true, // 성공 기록 표시 비활성화
removeOnFail: {
age: 1 * 60 * 60, // 실패 기록 1시간 유지 (초 단위)
},
},
);
}
}
Job 처리 - Consumer
@Processor('email')
export class EmailConsumer {
private logger = new Logger(EmailProcessor.name);
constructor(private readonly emailService: EmailService) {}
@Process({ name: 'send-email', concurrency: 5 })
async handleSendEmail(job: Job) {
const { sendEmailForm, emailFrom, origin, groupInfo, tester } = job.data;
try {
await this.emailService.sendEmail(sendEmailForm, emailFrom, origin, groupInfo, tester);
console.log(`✅ [성공] ${tester.testerName}`);
} catch (err) {
this.logger.error(`❌ [실패] ${tester.testerName} 메일 전송 실패`, err.message);
throw err;
}
}
}
Service
await Promise.all(
testerList.map(async (tester: TesterNotifyInfoDto) => {
if (!tester.testerEmail) {
throw new BadRequestException(`응시자 ${tester.testerName}님의 이메일 정보가 없어 메일 전송이 불가합니다.`);
}
const sendEmailForm: SendEmailType = {
email: tester.testerEmail,
name: tester.testerName,
subject: `[${groupInfo.companyName}] 응시 안내 메일입니다.`,
template: EmailTemplateEnum.TESTER_INFO,
text: noticeMessage,
from: EmailFromEnum.TESTER,
};
await this.emailProducer.enqueueMailJob(sendEmailForm, EmailFromEnum.TESTER, origin, groupInfo, tester);
}),
);
+ Setting
@Module({
imports: [
BullModule.registerQueue(
{
name: 'email',
limiter: {
max: 20, // 초당 최대 20개 Job 실행
duration: 1000, // 1초 기준
},
},
),
],
providers: [EmailProcessor, EmailService],
exports: [BullModule],
})
export class QueueModule {}
결과
📌 실제 응답 시간
attempts
: 5 설정으로 최대 5번 재시도가 자동으로 이루어짐concurrency
: 5 설정으로 동시에 5명에게 병렬 전송
개선을 통해 얻은 인사이트
여전히 남은 고민
이번 개선은 약 500명의 응시자 대상까지는 무리 없이 잘 작동했지만
실제 운영 환경에서는 최대 1000명 이상이 동시에 인성검사를 받는 상황도 고려해야 한다는 요청을 받았다.
현재 구조(NestJS + Bull + MailerService)가 과연 1000명 이상을 감당할 수 있을지에 대한 걱정도 있다.
특히 MailerService 자체가 단일 서버에서 돌아가기 때문에 병목이 생길 가능성도 있을 것이다.
다음 단계
이런 상황을 대비해 자체 구현 방식 대신 외부 서비스를 사용하는 방안도 검토 중이다.
그중 하나가 바로 AWS SQS + Lambda 기반의 메일 전송 구조다.
👉 다음에는 AWS SQS + Lambda 기반 아키텍처를 도입하여 대량 메일 전송의 안정성과 성능을 한 단계 더 개선해보는 작업을 시도해볼 예정이다.