NestJS에서 Bull Queue로 대량 메일 전송 방식을 개선한 이야기

Seung Hyeon ·2025년 4월 1일
0

백엔드

목록 보기
21/21
post-thumbnail

🐞 문제 상황

초기에는 @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('메일 전송에 실패했습니다.');
  }
}

☝🏻 개선방향 1 - Queue 기반 재시도 로직을 도입

기존 방식의 핵심 문제는 실패한 메일에 대한 재시도 로직이 없고, 실패해도 그대로 끝난다는 점이었다.
따라서 아래와 같은 로직을 도입하였다.

✅ 개선 전략

  • testerList를 큐(queue)처럼 사용하여 실패자만 남기는 구조
  • Promise.all을 이용해 병렬 전송은 유지
  • 실패한 대상만 추려서 최대 10회까지 재시도
  • 최종적으로 실패한 대상은 기록을 남김
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( 
  ....

결과

  • 총 11명의 응시자에게 메일을 발송했으며, 초기에는 일부 실패한 케이스가 있었지만 재시도 로직을 통해 모두 성공적으로 전송되었다.
  • 다만, 초기 실패율이 예상보다 높았고 이로 인해 재시도 횟수를 10번, 20번 이상으로 설정해도 최종적으로 실패하는 대상이 많아질 수 있다는 우려가 있었다.
  • 그리고 재시도가 많아질수록 전체 API 응답 시간도 길어졌다.
  • 아래 그림에서 확인할 수 있듯이, 11명에게 메일을 보냈을 때 전체 응답 시간은 약 1316ms가 소요되었다.

※ 혹시 병렬로 전송하여 전송 실패한 케이스가 많이 나온건 아닐까 생각이 들어 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차에 대한 한계 최종 정리

  • 초기 실패율이 예상보다 높음
  • 재시도가 많아질수록 API 응답 시간이 너무 길어짐
  • 실패한 Job에 대한 이력 관리나 모니터링이 어려움

✌🏻 개선방향 2 - Bull Queue로 더 나은 메일 전송 시스템 만들기

NestJS BullRedis 기반의 작업 큐 라이브러리인 Bull을 NestJS에 통합해 사용할 수 있도록 도와주는 모듈로, 이메일 전송처럼 시간이 걸리거나 실패 가능성이 있는 작업을 백그라운드에서 처리할 수 있게 해준다.

1차 문제를 해결하기 위해 @nestjs/bull 을 도입한 이유

  • API는 단순히 Job만 큐에 등록하면 되기 때문에 응답 시간이 크게 단축됨
  • @bull-board/nestjs 라이브러리를 통해 모니터링 대시보드 구현 가능
  • attempts, backoff, concurrency 등 다양한 기능을 설정만으로 활용 가능 (직접 로직을 구현할 필요 없음)

Queue 시스템 구성 요소

✔️ Producer : 작업 등록자
✔️ Queue : 작업 대기열
✔️ Consumer : 작업 처리자	
  1. Producer: "새 작업이 있어요!" → Queue에 작업 등록
  2. Queue: "새 작업이 들어왔어요!" → Redis에 작업 저장
  3. Consumer: "오, 새 작업이네요!" → 즉시 작업 처리 시작 (평소에는 Queue를 항상 모니터링)

(출처: [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 {}

결과

📌 실제 응답 시간

  • 기존 수동 큐 방식: 1316ms (테스터 11명 대상)
  • nestjs Bull 적용 후: 225ms (테스트 약 500명 대상)

  • 1차 시도의 한계였던 API 응답 속도가 개선
    • API는 단순히 Job만 등록하므로 응답 속도가 빨라짐
  • 1차 시도에서 병렬 처리 시 실패가 많이 나온 문제가 2차 시도에는 발생되지 않음
  • 메일 전송 실패 시 자동 재시도 → 코드에 직접 재시도 루프 구현 필요 없음
    • attempts : 5 설정으로 최대 5번 재시도가 자동으로 이루어짐
    • concurrency : 5 설정으로 동시에 5명에게 병렬 전송
  • 실패한 Job은 Bull 내부에서 관리되므로 Bull-Board 등을 통해 모니터링이 가능해짐 (아래 이미지 참조)


마무리하며

개선을 통해 얻은 인사이트

  • 직접 재시도 큐를 구현하는 방식보다 전용 큐 시스템(Bull)을 도입하는 것이 훨씬 안정적이고 관리도 용이하다.
  • NestJS는 Bull과의 통합이 잘 되어 있어 실제 적용이 쉽다.

여전히 남은 고민
이번 개선은 약 500명의 응시자 대상까지는 무리 없이 잘 작동했지만
실제 운영 환경에서는 최대 1000명 이상이 동시에 인성검사를 받는 상황도 고려해야 한다는 요청을 받았다.

현재 구조(NestJS + Bull + MailerService)가 과연 1000명 이상을 감당할 수 있을지에 대한 걱정도 있다.
특히 MailerService 자체가 단일 서버에서 돌아가기 때문에 병목이 생길 가능성도 있을 것이다.

다음 단계
이런 상황을 대비해 자체 구현 방식 대신 외부 서비스를 사용하는 방안도 검토 중이다.
그중 하나가 바로 AWS SQS + Lambda 기반의 메일 전송 구조다.

  • SQS를 통해 안정적인 Queue 작업을 처리하고
  • Lambda로 수평 확장이 가능한 메일 전송 Worker를 구성하면
  • 수천 건의 메일도 병목 없이 빠르게 처리할 수 있을 것으로 기대된다.

👉 다음에는 AWS SQS + Lambda 기반 아키텍처를 도입하여 대량 메일 전송의 안정성과 성능을 한 단계 더 개선해보는 작업을 시도해볼 예정이다.

(참고) https://jwkdevelop.tistory.com/134

profile
안되어도 될 때까지

0개의 댓글