메일 전송 기능을 간단하지만 간단하지 않게 구현해보자 (feat. AWS SES)

1. 들어가며

대규모 채용에 지원해보신 분이라면, 특정 사이트에 접속해 자기소개서를 작성하거나 서류를 첨부하는 등 모집 절차를 직접 경험해보신 적이 있을 것입니다.

이러한 시스템에서는 지원서 제출 시 제출 완료 메일을 받게 되고, 서류 합격 시에는 전형 안내 메일, 최종 합격 시에는 합격 메일 등 다양한 자동화된 이메일을 받게 되는데요.

사내에서 구축 중인 자체 설문조사 플랫폼에서도 지원자들에게 중요한 안내와 피드백을 전달하기 위해 이러한 메일 기능이 필요하다고 판단하여, 설문조사 결과에 따라 자동으로 메일을 발송하는 시스템을 개발하였습니다.

이 과정에서 AWS SES를 활용하여 메일 발송 기능을 구현하였고, 사용자 경험을 고려해 메일 발송 시점과 내용을 유연하게 설계할 수 있도록 고민하였습니다.

2. 어떻게 해결할 것인가?

사실 메일 기능은 활용 범위가 무궁무진하다고 생각합니다. 앞서 언급한 지원서 접수 완료 메일, 합격 메일, 코딩 테스트 등 다음 전형을 안내하는 메일뿐만 아니라, 시스템 장애 발생 시 사용자에게 안내하는 메일 등 다양한 상황에서 활용될 수 있습니다.

이처럼 메일 기능이 앞으로 어떻게 확장이 될지 예측하기 어렵기 때문에, 우선 최대한 추상화하여 설계하는 것이 중요하다고 판단하였습니다.

3. 메일 모듈 (SES 모듈)을 따로 분리하자!

SES SDK를 활용하는 메일 발송 기능을 별도의 모듈로 분리하여, 확장성과 유지보수성을 높이고 각 기능의 책임을 명확히 하고 싶었습니다.

이렇게 하면 SES 메일 관련 로직이 애플리케이션 전반에 흩어지지 않고, 필요할 때 쉽게 재사용하거나 확장할 수 있습니다.

// ses-client.provider.ts
import type { SESClient } from '@aws-sdk/client-ses';
import type { Provider } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

import { createSESClientFactory } from '../factory/create-ses-client.factory';

export const SES_CLIENT = Symbol('SES_CLIENT');

export const SESClientProvider: Provider<SESClient> = {
	provide: SES_CLIENT,
	useFactory: createSESClientFactory,
	inject: [ConfigService],
};
// creatae-ses-client.factory.ts
import { SESClient } from '@aws-sdk/client-ses';
import { getDefaultRoleAssumerWithWebIdentity } from '@aws-sdk/client-sts';
import { defaultProvider } from '@aws-sdk/credential-provider-node';
import type { ConfigService } from '@nestjs/config';

const createSESClientByConfig = (configService: ConfigService) => {
	const region = configService.getOrThrow<string>('ses.region');
	const accessKeyId = configService.getOrThrow<string>('ses.accessKeyId');
	const secretAccessKey = configService.getOrThrow<string>(
		'ses.secretAccessKey',
	);

	const sesClient = new SESClient({
		region,
		credentials: {
			accessKeyId,
			secretAccessKey,
		},
	});

	return sesClient;
};

const createSESClientByChain = async (configService: ConfigService) => {
	const region = configService.getOrThrow<string>('ses.region');

	const provider = defaultProvider({
		roleAssumerWithWebIdentity: getDefaultRoleAssumerWithWebIdentity({
			region,
		}),
	});

	const sesClient = new SESClient({
		region,
		credentials: provider,
	});

	return sesClient;
};

export const createSESClientFactory = (configService: ConfigService) => {
	const sesClient =
		process.env.NODE_ENV === 'production'
			? createSESClientByChain(configService)
			: createSESClientByConfig(configService);

	return sesClient;
};
// ses.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';

import { SESClientProvider } from './provider/ses-client.provider';
import { SESService } from './service/ses.service';

@Module({
	imports: [ConfigModule],
	providers: [SESService, SESClientProvider],
	exports: [SESService],
})
export class SESModule {}

AWS SES 클라이언트를 NestJS 환경에서 모듈화하여, 환경에 따라 다양한 인증 방식(직접 키 입력/역할 체인)으로 SES 서비스에 연결합니다. 개발 환경에서는 를 바탕으로 연결, 운영 환경인 쿠버네티스 환경의 pod에서는 chain으로 연결하도록 분기처리해두었습니다.

SESModule을 통해 SES 메일 서비스와 클라이언트를 애플리케이션 전역에서 사용할 수 있도록 제공합니다.

aws-sdk-js-v3/clients/client-ses at main · aws/aws-sdk-js-v3
aws-sdk-js-v3/clients/client-ses at main · aws/aws-sdk-js-v3
Modularized AWS SDK for JavaScript. Contribute to aws/aws-sdk-js-v3 development by creating an account on GitHub.
import {
	SESClient,
	SendBulkTemplatedEmailCommand,
	SendEmailCommand,
	CreateTemplateCommand,
	UpdateTemplateCommand,
	GetTemplateCommand,
	ListTemplatesCommand,
	type BulkEmailDestination,
} from '@aws-sdk/client-ses';
import {
	BadRequestException,
	Injectable,
	InternalServerErrorException,
} from '@nestjs/common';

import { InjectSESClient } from '../decorator/inject-ses-client.decorator';

@Injectable()
export class SESService {
	constructor(@InjectSESClient() private readonly sesClient: SESClient) {}

	async sendTemplateEmail({
		templateName,
		senderEmail,
		destinationList = [],
		defaultTemplateData,
	}: {
		templateName: string;
		senderEmail: string;
		destinationList: BulkEmailDestination[];
		defaultTemplateData?: Record<string, any>;
	}) {
		if (this.countTotalDestination({ destinationList }) > 50) {
			throw new BadRequestException(
				'최대 50개의 이메일 주소만 지원합니다.',
			);
		}

		const command = new SendBulkTemplatedEmailCommand({
			Source: senderEmail,
			Template: templateName,
			Destinations: destinationList,
			DefaultTemplateData: JSON.stringify(defaultTemplateData),
		});

		try {
			await this.sesClient.send(command);
		} catch (error) {
			throw new InternalServerErrorException(
				'Bulk email 전송 과정에서 오류가 발생했습니다.',
			);
		}
	}

	async sendEmail({
		emailList,
		senderEmail,
		subject,
		body,
	}: {
		emailList: string[];
		senderEmail: string;
		subject: string;
		body: string;
	}) {
		if (emailList.length > 50) {
			throw new BadRequestException(
				'최대 50개의 이메일 주소만 지원합니다.',
			);
		}

		const command = new SendEmailCommand({
			Source: senderEmail,
			Destination: {
				ToAddresses: emailList,
			},
			Message: {
				Subject: {
					Data: subject,
					Charset: 'UTF-8',
				},
				Body: {
					Html: {
						Data: body,
						Charset: 'UTF-8',
					},
				},
			},
		});

		try {
			await this.sesClient.send(command);
		} catch (error) {
			throw new InternalServerErrorException(
				'Email 전송 과정에서 오류가 발생했습니다.',
			);
		}
	}

	...
}

SES SDK를 활용해 SES 템플릿의 CRUD 기능, 템플릿을 사용한 이메일 발송, 그리고 하드코딩된 변수로 템플릿을 적용해 메일을 보내는 메서드까지 구현해두었습니다.

각 애플리케이션과 사용자단에서 원하는 값을 자유롭게 전달할 수 있도록 매개변수만 받아서 동작하도록 설계하였습니다. 이를 통해 확장성과 유연성을 높이고, 다양한 상황에서 쉽게 활용할 수 있도록 하였습니다.

3. 이제 메일을 보내면 된다?

이제 구현한 메일 모듈을 활용해 지원자에게 이메일을 보낼 수 있습니다. 그러나 대규모 모집 환경에서는 마감 시간에 지원자가 집중적으로 몰리며, 이로 인해 서버와 메일 서버(SES)에 트래픽이 급증하는 현상이 자주 발생합니다.

실제로 사내에서 운영하는 부트캠프 지원 시스템에서도 마감 직전에 많은 인원이 몰리는 경험을 했는데요, 이처럼 요청이 한꺼번에 몰리면 서버에 과도한 부하가 걸릴 뿐만 아니라, 메일 발송 과정에서 발생하는 데이터베이스 조회(예: 지원자 정보, 이메일 템플릿 등)로 인해 DB에도 큰 부담이 생깁니다. 한 명의 지원자에게 이메일을 보내기 위해서는 데이터베이스에서 이름, 이메일 주소, 제출 일자 등 다양한 정보를 조회하고, 템플릿을 조합해야 하기 때문입니다.

4. 부하를 줄여보자!

부하를 효과적으로 관리하기 위해 고려한 첫 번째 방법은, 앞단 서버에 큐(Queue)를 도입하는 것입니다. 지원자가 모집을 완료하면 이메일 발송 요청이 바로 처리되는 것이 아니라 큐에 순차적으로 쌓였다가, 한번에 처리되어 메일이 발송되도록 설계하는 것인데요, 이렇게 하면 트래픽이 몰릴 때도 서버와 데이터베이스, 메일 서버에 부담을 분산시킬 수 있습니다.

faq

순서를 강제해보자.

5. 메일을 한꺼번에 (Batch) 보내자

SES SDK에는 sendMail, SendBulkTemplatedEmail 등 여러 명에게 한 번에 메일을 보낼 수 있는 기능이 있습니다.

만약 지원자가 지원을 완료할 때마다 이메일 발송 요청을 큐에 하나씩 넣어 하나씩 처리한다면, 요청 수가 많아지면서 리소스가 비효율적으로 사용될 수 있습니다.

이를 개선하기 위해, 지원자가 지원을 완료하면 바로 큐에 넣지 않고 Redis List에 이메일 정보와 메타데이터를 임시 저장한 뒤, 일정 시간(N분)마다 한 번에 여러 건의 메일 발송 요청 묶어 큐에 넣어 분산 처리하는 방식이 더 효율적입니다. 물론 메일이 실시간으로 발송되지는 않지만, 1분마다 해당 작업이 실행되도록 설정하여 사용자가 느낄 수 있는 불편함은 최소화했습니다.

이렇게 하면 요청 개수를 줄이고 SES의 배치 기능을 최대한 활용하여 서버와 데이터베이스, 메일 서버에 가는 부하를 더욱 줄일 수 있습니다.

flowchart TD A[지원자 모집 완료] --> B[이메일 정보 List에 저장] G[스케줄러] -- N분마다 --> C[큐에 이미 적재 되어있지 않은 + 이메일 정보가 있는 key들에 대해서 큐에 적재] C --> D[큐에서 하나씩 작업을 꺼내어, 해당 key에 해당하는 여러 이메일 정보들을 한 번에 가져옴] D --> E[가져온 여러 이메일 정보를 활용해, SES의 배치 발송 기능을 사용하여 한 번에 여러 명에게 이메일을 발송] E --> F[서버, DB, SES 부하 감소]

큐에서 하는 작업

...
// email.consumer.ts
interface SubmissionAnswerEmailFlushJob {
	key: string;
}

@Processor(QUEUE.SUBMISSION_ANSWER_WELCOME_EMAIL_FLUSH)
export class SubmissionAnswerWelcomeEmailFlushConsumer extends WorkerHost {
	constructor(
		@InjectRedis() private readonly redis: Redis,
		private readonly sesService: SESService,
		...
	) {
		super();
	}

	async process(job: Job<SubmissionAnswerEmailFlushJob>) {
		const { key } = job.data;

		const submissionId = this.getSubmissionIdFromKey(key);

		const emailTemplate =
			await this.submissionEmailTemplateRepoistory.findOneBySubmissionId({
				submissionId,
			});

		if (!emailTemplate) {
			throw new NotFoundException(
				'SES 이메일 템플릿을 찾을 수 없습니다.',
			);
		}

		const emailDataList = await this.redis.lrange(key, 0, -1);

		const isOverflow = emailDataList.length > 50;

		const slicedEmailDataList = isOverflow
			? emailDataList.slice(0, 50)
			: emailDataList;

		const filteredEmailDataList = Array.from(
			new Map(
				slicedEmailDataList
					.map((item) => {
						return this.extractEmailData({ emailData: item });
					})
					.map((item) => [item.email, item.unitTitle, item]),
			).values(),
		);

		const destinationList = this.createDestinationList({
			filteredEmailDataList,
			placeholder: emailTemplate.placeholder,
		});

		const sendEmailResultList = await this.sesService.sendTemplateEmail({
			templateName: emailTemplate.templateName,
			senderEmail: 'contact@email.com',
			destinationList
		});

		if (isOverflow) {
			await this.redis.ltrim(key, 50, -1);
		} else {
			await this.redis.ltrim(key, 1, 0);
		}

		return;
	}

	@OnWorkerEvent('failed')
	async onFailed(job: Job<SubmissionAnswerEmailFlushJob>, error: Error) {
		const maxAttempts = job.opts.attempts || 3;

		if (job.attemptsMade === maxAttempts) {
			const errorInstance = new Error(
				`[이메일 송신 최종 실패]\nkey: ${job.data.key}\ncause: ${error.message}`,
			);

			await this.slackService.sendErrorMessage({
				error: errorInstance,
			});
		}
	}
  1. key값을 파싱하여 id(모집 지원서 고유 id 등)와 매핑되는 SES 이메일 템플릿을 가져옵니다. (예를들어, key는 mail:flush:id와 같습니다.)
  2. key값의 value인 저장된 이메일 목록을 가져옵니다.
  3. SES의 한 번에 50명까지 메일을 보낼 수 있는 제한을 고려하여, 50개를 초과할 경우 50개만 추출합니다 (50개 chunk 단위). 그리고 중복 메일 발송을 방지하기 위해 고유한 이메일 값만 남깁니다.
  4. 이메일과 해당 사용자의 메타데이터를 매핑하여 배열을 만듭니다. 이 과정을 통해 SES 이메일 템플릿에 있는 변수사용자 정보에 맞게 치환할 수 있습니다. 예를 들어, 이메일 html 템플릿에 “name님 지원이 완료되었습니다”가 있다면 실제 이름으로 치환하여 메일을 작성합니다.
  5. sendTemplateEmail 메서드을 활용해 한 번의 요청으로 여러 명에게 메일을 보냅니다.
  6. 메일 발송이 완료되면, 요청이 50개를 초과했을 경우 key list에서 50개만 제거하고, 그렇지 않으면 list를 모두 비웁니다.
flowchart TD A[key값에서 모집 지원서 고유 id 조회] --> B[이메일 템플릿 및 이메일 목록 가져오기] B --> C[50개 초과시 50개만 추출] C --> D[중복 제거 및 고유 이메일만 남김] D --> E[이메일-유저 메타데이터 매핑 배열 생성] E --> F[템플릿 변수 치환] F --> G[여러 명에게 한 번에 이메일 발송] G --> H[50개 이상이면 key list에서 50개 제거, 아니면 전체 비우기]

6. 메일을 보내는 기능은 구현되었다. 하지만?

이렇게 설계된 메일 발송 시스템은 서버에 가는 부하를 최소화하면서 효율적으로 동작합니다.

메일 발송 과정에서는 언제든 실패가 발생할 수 있습니다. 그리고 이러한 실패를 어떻게 추적하고 관리하느냐는 생각보다 생각보다 더 중요한 문제입니다.

우선 비즈니스적 관점에서 보았을 때, 모든 메일이 반드시 성공적으로 전달되어야 합니다. 만약 메일이 제대로 전달되지 않았다면, 즉시 조치를 취해 다시 발송할 수 있어야 하며, 이를 위해서는 각 메일의 발송 상태를 정확히 파악하고 있어야 합니다. 발송 실패를 체계적으로 추적하고 관리하는 시스템은 고객 신뢰를 지키고, 중요한 비즈니스 기회를 놓치지 않기 위해 반드시 필요합니다. 예를 들어, 코딩테스트 안내장과 같이 중요한 메일이 제대로 발송되지 않으면, 사용자가 적절한 안내를 받지 못해 불이익을 겪을 수 있으며, 이는 사업적으로도 부정적인 영향을 미칠 수 있습니다.

또한 기능적인 관점에서 보았을 때, AWS SES의 경우 전체 실패율에 따라 계정이 일시적으로 잠기거나 페널티가 부과될 수 있습니다. AWS SES는 실패(바운스)율이 5% 이상이면 계정이 리뷰 대상이 되고, 10% 이상이면 계정의 메일 발송 권한이 일시적으로 정지될 수 있습니다. 실패(바운스)는 주로 이메일 주소가 유효하지 않거나 도메인이 존재하지 않는 경우 등 영구적 실패로 발생합니다. 심지어 스팸 신고율이 0.5% 이상이면 마찬가지로 페널티가 부과될 수 있습니다.

faq

공식 답변상으로 이러한 정책이 있다.

저희 서버의 문제로 인해 전사적으로 사용하는 AWS SES에 장애가 전파되는 상황을 방지해야 하므로, 실패율을 철저히 관리하고 블랙리스트도 체계적으로 관리해야 합니다. 따라서 메일 발송이 실패했을 때에 대한 이력을 추적하고 관리하고, 조치해야 합니다.

7. 실패란 무엇일까?

실패를 어떻게 처리해야할까요? 고민이 필요했습니다. 우선 실패가 무엇인지 부터 정의해보겠습니다. 메일 전달에 실패하는 경우 어떤 경우들이 있을까요?

AWS SES 이메일 발송 실패는 여러 가지 이유로 발생할 수 있습니다. 대표적인 사례와 원인은 아래와 같은데요.

Bounce(반송)

반송에는 두가지 종류가 있는데요, 하드 바운스와 소프트 바운스입니다.

  • Hard Bounce(하드 바운스): 영구적인 실패로, 대표적으로 존재하지 않는 이메일 주소, 잘못된 주소, 수신자 메일 서버의 영구 차단 등이 있습니다.
  • Soft Bounce(소프트 바운스): 일시적인 문제로 인한 실패입니다. 예를 들어 수신자 메일함이 가득 찼거나, 수신자 서버가 일시적으로 다운되었거나, 메시지 용량이 너무 큰 경우 등이 있습니다. 보통 여러 번 재시도 후에도 실패하면 하드 바운스로 간주합니다.

Complaint(불만 신고)

  • 수신자가 이메일을 스팸으로 신고하는 경우입니다. 이 경우 ISP(인터넷 서비스 제공자)나 메일 서비스가 해당 발신자를 스팸 발송자로 간주할 수 있습니다. 크리티컬한 실패 원인은 두 가지가 있습니다. 바운스(실패)율과 스팸 신고율입니다.

7.1 실패에 대한 처리

이러한 실패를 감지하고 효과적으로 대응하기 위해, 아래와 같은 방안들을 고민해보았습니다.

7.2 블랙리스트와 이메일 포맷 검증

  • 이메일 포맷 검증

    당연히 챙겨야할 프로세스로, 앱단에서 이메일 주소의 형식을 미리 검증하여 잘못된 입력을 막아 실패율을 낮추는 방법입니다.

    이렇게 하면 최소 유효하지 않은 이메일 주소로 인한 실패를 크게 줄일 수 있을 것입니다.

  • 블랙리스트(서프레션 리스트) 관리

    메일 발송 후 바운스(실패)나 스팸 신고가 발생한 이메일 주소는 데이터베이스에 저장하여 블랙리스트로 관리하는 것을 생각했습니다. 사실 AWS SES는 이미 서프레션 리스트 기능을 제공하고 있어, 바운스나 스팸 신고가 발생한 이메일 주소는 자동으로 이 리스트에 추가되어 이후 메일 발송이 차단됩니다.

    이 서프레션 리스트는 SES API인 ListSuppressedDestinations 통해서 확인이 가능한데요, 해당 API는 모든 값들을 페이지네이션 기반으로 모두 가져오기 때문에 특정 이메일에 대한 validation을 하려면 모든 리스트를 불러와야하는 단점이 있습니다. 이에 따라서 해당 기능은 SES가 관리하고 자동으로 필터링하게 하되, 앱단 데이터베이스에서도 SuppressedDestinations과 같은 데이터들을 동기화하고 유지하면서 블랙리스트를 걸러내는게 낫다고 판단하였습니다.

    매번 이메일을 전송할때마다 SES ListSuppressedDestinations를 페이지네이션 기반으로 모두 가져오는 것보다, 데이터베이스 쿼리 한개로 블랙리스트에 포함되어있는지 여부를 확인하는게 효율적이라고 생각하기 때문입니다. 더 나아가 데이터베이스에서 블랙리스트를 관리하게 되면 관리자가 상황에 따라 타겟을 확인하고 수동으로 블랙리스트에서 제외할 수 있는 기능을 제공할 수 있기 때문에 미래에 확장될 수 있는 기능까지 생각해보았습니다.

7.3 이메일 발송 로그 및 이력 관리

  • 성공/실패 로그 관리 성공한 메일과 실패한 메일에 대한 로그를 데이터베이스에 저장하면, 관리자가 전송 이력을 쉽게 확인할 수 있고, 실패율을 실시간으로 모니터링할 수 있습니다.

8. Bounce나 Complaint 같은 정보는 이메일을 발송하는 시점에는 바로 알 수 없다?

사실 저는 처음에는 SendBulkTemplatedEmail, SendEmail 등의 SES API를 사용했을 때 사용자의 메일이 꽉찼다거나, 유효하지 않은 이메일이라던가, 스팸신고를 할 경우에는 해당 API에 대한 응답값으로 에러가 발생할 줄 알았습니다.

하지만 SES는 메일 발송 요청에 대한 성공 여부만 반환합니다. 즉, 유효하지 않은 이메일 주소(mock@notexists.com 등)로 보내더라도, 발송 요청 자체가 정상적으로 처리되었다면 성공으로 간주합니다. 실제로 해당 메일이 사용자에게 도달했는지 여부에는 관심을 두지 않습니다.

faq

SES 입장에서 보낸건 맞다.

이로 인해, SES 시스템 오류가 아닌 이상 모든 발송 요청이 대부분 성공으로 처리되기 때문에, 개발자나 운영자가 메일이 실제로 사용자에게 전달되었는지 확인할 방법이 없습니다. CS로 문의가 들어오지 않는 한, 문제를 인지하기 어렵다는 점이 문제입니다.

8.1 AWS SES의 발송 결과를 수신하는 방법

AWS SES의 발송 결과를 수신하는 방법은 여러가지가 있지만, 그중에서 SNS을 사용하는 방법을 채택하였습니다.

반송 메일 또는 수신 거부를 수신하거나 이메일이 전송되면 Amazon SNS topic에 알리도록 Amazon SES를 구성할 수 있습니다.

사내에서 이미 다양한 알림 트리거에 SNS와 Lambda를 활용하고 있기 때문에, 이번에도 동일한 방식을 적용하면 설정이 간편하고 빠르게 개발할 수 있다고 판단했습니다.

8.2 아키텍쳐

faq
  1. SES에 Configuration Set을 적용합니다.
  2. Configuration Set의 이벤트를 전달할 대상을 지정합니다(SNS, 특정 토픽 등).
  3. 이벤트가 발생하면 해당 SNS 토픽을 구독 중인 Lambda가 트리거됩니다.
  4. Lambda에서 EKS의 특정 서버 API를 호출블랙리스트에 추가하고 전송 결과 로그를 쌓습니다.
  5. Lambda에서 오류 이벤트에 대한 알림을 Slack으로 전송합니다.

8.3 lambda 함수 코드

const API_URL = process.env.API_URL;
const SLACK_WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL;

const sesEventTypeMap = {
  Send: "이메일 전송 요청 성공",
  Bounce: "이메일 반송(유효하지 않은 주소 또는 메일함 용량 초과 등)",
  Complaint: "수신자가 스팸으로 신고함",
  Open: "수신자가 이메일을 열람함",
  Click: "수신자가 이메일 내 링크를 클릭함",
  RenderingFailure: "이메일 렌더링(생성) 실패",
  Reject: "이메일 전송이 거부됨(바이러스 등)",
  Delivery: "이메일이 성공적으로 전달됨",
};

const registerBlackList = async ({ sesEventType, destination }) => {
  ...

  try {
    await fetch(API_URL, payload);
    return true;
  } catch (error) {
    console.log({ error });
  }
};

const sendEmailRecieveResultToSlack = async ({ sesEventType, destination }) => {
  const message = createEmailRecieveResultMessage({
    sesEventType,
    destination,
  });

  ...

  try {
    await fetch(SLACK_WEBHOOK_URL, payload);
    return true;
  } catch (error) {
    console.log({ error });
  }
};

const createEmailRecieveResultMessage = ({ sesEventType, destination }) => {
  // slack message 포맷에 맞게 생성
};

module.exports.handler = async (event) => {
  try {
    for (const record of event.Records) {
      const snsMessage = JSON.parse(record.Sns.Message);
      const sesEventType = snsMessage.notificationType;
      const mail = snsMessage.mail;
      const destination = mail.destination[0];

      if (
        sesEventType === "Bounce" ||
        sesEventType === "Complaint" ||
        sesEventType === "RenderingFailure" ||
        sesEventType === "Reject"
      ) {
        await sendEmailRecieveResultToSlack({
          sesEventType,
          destination,
        });
      }
    }

    return { status: "sucess", response: { result: true } };
  } catch (error) {
    return { status: "fail", response: { result: false } };
  }
};

굉장히 간단한 코드입니다. 람다는 사실 SNS이벤트를 받고 블랙리스트 및 전송 로그를 보내기 위해 API 서버를 호출하고 슬랙 알림을 보내는 전달자에 불과하거든요.

코드에서 볼수 있듯이 SNS에서 날라오는 sesEvent 타입에 따라서 분기합니다.

Bounce, Complaint일때는 슬랙 에러 메세지를 보내 개발자들이 대응하고 확인할 수 있도록 합니다.

또한 모든 상태에 대해서 API 서버에 해당 값들을 보내 발송 결과를 로깅하고, Bounce, Complaint일 경우에는 블랙리스트에 등록하도록 합니다.

9. 적용 결과

이러한 기능 덕분에 두 가지 효과를 얻을 수 있었습니다.

첫째, 지원서 제출 완료, 전형 안내, 합격 통보 등 특정 기능이나 액션에 자유롭게 메일 발송 기능을 연동할 수 있게 되었습니다. 이제 다양한 비즈니스 상황에서 필요한 시점에 손쉽게 이메일을 보낼 수 있습니다.

둘째, 이메일 발송 관리 체계를 구축함으로써 기능적인 신뢰성을 확보할 수 있었습니다. 발송 내역을 체계적으로 관리하고, 실패 상황에 신속하게 대응할 수 있어 전체 시스템의 신뢰성과 안정성을 높일 수 있었습니다. 블랙리스트에 등록된 유효하지 않은 이메일의 경우 메일을 발송하지 않고, 스팸의 경우 관리자들이 스팸 메일로 처리한 사용자들을 확인해서 스팸 메일함을 확인하거나 해제해달라고 요청하여 문제를 해결하고 있습니다. 또한 블랙리스트에 대한 리스트를 관리자가 볼 수 있으니 현황을 확인할 수 있었습니다. 발송 이력 리스트도 개발자나 관리자가 잘 전달되었는지 확인할 수 있습니다. 지원자가 1000명이면 모두에게 전달되었는가가 정말 중요한 지표인데, 이를 통해 현황을 파악하고 누락된 인원에게 재발송하는 절차를 통해 비즈니스적인 신뢰성을 확보할 수 있습니다. 또한 개발자들이 Bounce나 Complaint 상황에 대해서 바로바로 슬랙으로 파악하고 대응할 수 있어 신뢰성을 더 높일 수 있었습니다.

faq

바로 바로 피드백이 온다!

10. 마치며

프로젝트를 시작할 때, VoC에서 메일 기능이 필요하다는 요청을 받았을 때만 해도 'AWS SES를 쓰면 끝나는 간단한 일 아닌가?'라고 생각하며 일정을 매우 짧게 잡았었습니다. 하지만 실제로 개발과 설계를 진행하다 보니, Rate Limit에 걸리지 않도록 조치하고, 효율적으로 메일을 전송하며, 서버 리소스에 과부하가 걸리지 않도록 하는 등 여러 가지 관리 포인트를 고려해야 했습니다. 또한, 실패 처리와 추후 관리를 위한 체계적인 시스템 설계 역시 필수적이었습니다.

물론 더 완성도 높은 시스템을 만들기 위해 고민해야 할 부분이 더 많겠지만, 다양한 방법을 설계하고 적용해보면서 많은 성장을 경험할 수 있었습니다. 앞으로도 더 안정적인 운영을 위해 더 운영해보며 보완할 점을 지속적으로 찾아보고, 이메일이 실제로 열렸는지까지 추적·관리하는 등 기능을 확장해 더욱 효과적으로 활용할 방법들을 고민하고 적용해볼 예정입니다.