Skip to content

[BE] 이메일 서비스 적용 과정

Jeongmin edited this page Dec 14, 2023 · 8 revisions

이메일 서비스 적용 계기

프로젝트 4주차를 마무리하며 대부분의 API를 완성하였다. 물론 iOS-서버 연결 과정에서 발생하는 추가 수정 / 에러 해결 작업이 남아있는 상태였으나 iOS측 요청이 들어오기 전까지 손 놓고 기다리기만 할 수는 없다고 생각했기 때문에 어떤 기능을 추가할 수 있을지 고민해보았다.

물론 초기 기획 단계에서 시간이 남으면 구현해보자고 얘기했던 기능들이 있었지만, 백엔드만 작업이 마무리가 된 상태였고 iOS측 작업이 아직 남아있었기 때문에 최대한 iOS 작업을 줄일 수 있는 추가 기능을 새로 고민해보아야 했다.

고려해보았던 추가 기능

  • 카카오 로그인 추가

    공식 문서를 읽어보니 생각보다 iOS측이 처리해야 할 요소가 많아 보여서 포기하였다.

  • 휴면계정 처리

    1년 이상 미접속한 회원들에게 휴면 처리 안내 메일을 보내고 휴면 계정의 데이터를 블라인드 처리하는 기능을 구현해보려고 했으나, 검색 도중에 법이 개정되어 더 이상 별도의 휴면회원 관리가 필요하지 않게 되었다는 사실을 알게 되었다. 구현을 해 볼수는 있겠으나, 굳이 필요한 기능이 아니라고 생각되어 포기하였다.

  • 카카오 알림톡 서비스

    우리 서비스와 카카오 알림톡을 연계하여 사용해보고 싶어 연결 방법을 찾아본 결과 사업자 등록이 필요하다는 것을 알게 되고 포기하였다.

최종적으로 추가하게 된 기능

앞선 '휴면계정 처리' 아이디어를 포기하기가 아쉬워서 고민하던 끝에, 새로운 IP 환경에서 로그인 할 경우 보안 경고 이메일을 보내는 기능을 구현해보기로 결정했다.

구현 방식 고민

채택한 기능의 핵심 포인트는 서버에서 메일을 보낼 수 있어야 한다! 였다. 그래서 어떤 방법이 있는지 찾아본 끝에 크게 두 가지 방법이 있다는 것을 알게 되었다.

nodemailer와 @nest-modules/mailer 모듈 사용

우리가 프로젝트 내내 참고했던 교재인 NestJs로 배우는 백엔드 프로그래밍에서 소개한 모듈이기도 하고, 구글 검색 & GPT 질문 결과 알게 된 방식이었다. 구현 방법을 찾아보니 큰 어려움 없이 구현 가능했지만 치명적인 문제점이 있었다.

모듈을 설치하니 아래와 같은 경고 메세지가 떴고,

Untitled

NestJs로 배우는 백엔드 프로그래밍 교재에서도 이 모듈을 실사용하는것을 추천하지 않는다는 내용을 발견하였다.

image

그래서 멘토님께 조언을 구해본 결과 외부 이메일 서비스가 존재한다는 사실을 알게되었다.

외부 이메일 서비스 사용

검색 결과 알게 된 이메일 서비스는 아래 4가지 정도였다.

  • Sendgrid
  • Mailchimp
  • NCP Outbound Maileer
  • AWS SES (Simple Email Service)

Sendgrid와 Mailchimp의 경우 한국어 자료를 찾을 수 없어서 후순위로 미뤄두었고, AWS와 NCP 사이에서 고민하다가 크레딧을 제공받는 중인 NCP에서 이메일 서비스를 이용하기로 하였다.

그런데 NCP 콘솔에서 Outbound Mailer 서비스를 신청이 안되는 것이다...! 신청한 서비스 수가 많아서 그런가 싶어 고객센터에 문의해보았다.

Untitled

아쉽게도 현재 서비스 사용이 불가능하다는 답변을 받아 AWS SES를 이용하기로 결정했다.

기능 설명

  • 사용자가 회원가입 할 때의 IP를 받아 DB에 저장해둔다.
  • 이후 회원 가입 시 접속한 IP가 아닌 환경에서 로그인 할 경우 일단 로그인 처리를 하고, 사용자에게 보안 경고 메일을 보낸다.
  • 보안 경고 메일에서 새로운 IP를 허용하거나 차단할 수 있다.
  • 새로운 IP를 허용할 경우 이후 허용된 IP에서의 로그인에 대해서는 보안 경고 메일을 보내지 않는다.
  • 새로운 IP를 차단할 경우 이후 차단된 IP에서의 refresh 요청, 로그인이 차단된다.

구현 과정

물론 최종적으로는 AWS SES를 이용해야겠다고 결심했으나 자료가 많지 않아 구현에 얼마나 시간이 소요될 지 예상하기 어려운 상황이었다. 그래서 일단은 모듈을 사용해서 구현해두고, 추후 이메일 서비스로 마이그레이션 하기로 결정했다.

모듈 이용

  • 모듈 설치 @nest-modules/mailernodemailer 기반이므로 둘 다 설치해준다.

    npm install nodemailer @nest-modules/mailer
  • app.module.ts에 Mailer Module 의존성 추가 나는 gmail을 사용하였는데, 다른 메일을 사용할 경우 host를 수정해주면 된다.

    template 부분은 메일 본문에 템플릿을 사용 할 경우에만 적어주면 된다. 나는 ejs 파일을 템플릿으로 이용하였기 때문에 추가해주었다. ejs 파일을템플릿으로 이용하기 위해서는 별도의 모듈 설치가 필요하다.

    npm install ejs @types/ejs
    MailerModule.forRootAsync({
        useFactory: () => ({
          transport: {
            host: 'smtp.gmail.com',
            port: 587,
            auth: {
              user: process.env.EMAIL_ADDRESS,
              pass: process.env.EMAIL_PASSWORD,
            },
          },
          defaults: {
            from: `"no-reply" <${process.env.EMAIL_ADDRESS}>`,
          },
          preview: true,
          template: {
            dir: __dirname + '/../views/',
            adapter: new EjsAdapter(),
            options: {
              strict: true,
            },
          },
        }),
      })
  • 의존성 설정을 하고 나면 바로 메일 전송이 가능하다.

    await this.mailerService.sendMail({
      to: user.email,
      subject: '[traveline] 새로운 환경 로그인 안내',
      template: '../views/email',
      context: {
        username: user.name,
        id: user.id,
        newIp: ipAddress,
      },
    });

    MailerService는 따로 정의할 필요 없이 코드 상단에서

    import { MailerService } from '@nestjs-modules/mailer';

    이렇게 import 해서 사용 가능하다.

이메일 서비스 이용

  • AWS SES 이용 신청

    1. 시작하기를 눌러 서비스 이용을 시작한다.

      image

    2. 인증용 이메일을 입력한다.

      메일 수신이 가능한 이메일이면 어느 이메일이든 상관 없다.

      image

    3. 내 사이트 도메인을 입력한다.

      image

    4. 시작하기

      image

    5. 이메일 인증 작업

      입력한 이메일의 수신함을 확인한다.

      image

      메일함에 도착한 링크를 클릭하여 메일주소를 인증한다.

      화면 캡처 2023-12-13 180229

      이메일 주소가 확인처리된다.

      image

    6. 전송 도메인 확인 작업

      DNS 레코드 가져오기

      image

      여기 적힌 CNAME을 네임서버에 등록한다.

      우리는 네임서버로 가비아를 이용했는데, 가비아 기준 '이름' 부분에 _domainkey 까지만 적어주면 된다. (뒤의 .traveline.store는 생략해야 한다.) '값'은 맨 마지막에 마침표를 적어서 추가해주면 된다.

      스크린샷 2023-12-13 181113

      등록하고 하루 정도 지나면 전송 도메인 확인이 완료된다.

      image

    7. 프로덕션 엑세스 요청

      이메일 전송을 위해 프로덕션 엑세스를 요청한다.

      image

      폼을 채워 제출하고 하루 정도 기다린다.

      image

      이제 이메일 전송이 가능하다. 한도 증가가 필요할 경우 추가 요청이 필요하다.

      image

  • aws-sdk 모듈 설치

    npm install aws-sdk
  • Email Module, Service 생성

    nest g mo email
    nest g s email
  • EmailService 작성

    import * as AWS from 'aws-sdk';
    import * as ejs from 'ejs';
    
    export class EmailService {
      private readonly ses: AWS.SES = new AWS.SES({
        region: process.env.AWS_REGION,
        credentials: {
          accessKeyId: process.env.AWS_ACCESS_KEY_ID,
          secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
        },
      });
    
      async sendEmail(email: string, title: string, html: string) {
        //메일 주소, 메일 제목, 렌더링 된 본문 html string을 받아온다.
        const sendEmailParams: AWS.SES.SendEmailRequest = {
          Destination: {
            CcAddresses: [], //숨은 참조 주소
            ToAddresses: [email], // 받을 사람의 이메일
          },
          Message: {
            Body: {
              Html: {
                //본문을 html로 보내고 싶었기 때문에 html로적었다. plain text로 전송하고 싶을 경우 Text:{}와 같이 적어주면 된다.
                Charset: 'UTF-8',
                Data: html,
              },
            },
            Subject: {
              Charset: 'UTF-8',
              Data: title,
            },
          },
          Source: 'no-reply@traveline.store', //발신자 이메일주소, 실존하지 않는 이메일이어도 괜찮다.
          ReplyToAddresses: [],
        };
        const result = await this.ses.sendEmail(sendEmailParams).promise();
        return result;
      }
    
      async template(ejsFileName: string, data: object) {
        //이메일 본문을 ejs -> html string 렌더링하는 함수이다.
        //템플릿을 쓰지 않을 경우 사용하지 않아도 좋다.
        //data 변수에는 ejs 렌더링에 필요한 변수들이 들어있다.
        const path = __dirname + '/../../views/' + ejsFileName; // ejs 파일 경로
    
        let html;
        await ejs.renderFile(path, data, async (err, res) => {
          if (!err) {
            html = res;
          }
        });
        return html;
      }
    }

여기까지 하고 끝난 줄 알았으나, 애플 로그인 시 이메일 가리기 옵션을 선택한 사용자에게는 메일이 전송되지 않는다는 사실을 알게 되었다. 가리기 처리 된 이메일로 메일을 전송하고 싶으면 애플 개발자 계정에 DKIM 서명과 SPF 인증이 완료된 도메인을 등록하여야 한다는 것을 알게 되었다. (당연한 사실이지만, 이메일 전송에 쓸 주소의 도메인에 DKIM 서명과 SPF 인증을 진행해야 한다!) SES의 전송 도메인 확인 과정에서 DKIM 서명은 완료하였고, SPF 인증만 통과하면 되었다.

애플의 공식문서에 의하면, 이메일 서비스를 이용하는 경우에는 SPF 기록이 "v=spf1 include:amazonses.com ~all"와 같으면 된다고 하였다.

image

AWS의 이 문서를 참고한 결과 AWS SES에서 MAIL FROM 설정을 통해 SPF 인증을 받을 수 있음을 알게 되었고, MAIL FROM 작업을 진행하였다.

  • MAIL FROM 설정
    1. AWS SES 콘솔 우측의 확인된 자격증명으로 이동한다.

      image

    2. 도메인을 클릭하면 인증 관련 정보가 뜬다.

      나는 이미 인증 작업을 완료하여 아래 화면과 같이 뜬다.

      image

    3. MAIL FROM 도메인을 지정해준다.

      나는 대충 mail.traveline.store로 설정했는데 사실 하위 도메인은 뭘로 하든 크게 상관 없을 것 같다.

      image

    4. 이제 네임서버에 MX, TXT 등록을 해준다.

      image

      조금 헷갈릴 수 있는데, 가비아 기준

      유형 TXT 이름 mail (mail.traveline.store 아님!) 값 feedback-smtp.ap-northeast-2.amazonses.com 우선순위 10

      유형 MX 이름 mail (mail.traveline.store 아님!) 값 "v=spf1 include:amazonses.com ~all"

      이렇게 등록하면 된다. 등록 후에 하루 정도 기다리면 성공 표시가 뜬다.

    5. 애플 개발자 계정에 도메인과 이메일을 등록한다.

      나는 no-reply@traveline.store로 메일을 전송할 예정이라 이메일은 저걸로 등록하고, 도메인은 traveline.store로 등록했다. mail.traveline.store로 도메인을 등록해야되나 싶어서 걱정했는데 테스트 하니 메일이 잘 전송되었다. 개발자 계정에 이메일과 도메인을 등록하는 과정은 내가 한 게 아니라 JK님께서 해주신 작업이라 자세히는 모른다. 공식문서이 글을 참고하여 부탁드렸다.

      혹시 SPF 등록이 제대로 되었는지 확인하고 싶다면, AWS SES를 이용해 Gmail로 메일을 보낸 뒤 이 글을 참고하여 의도대로 등록이 되었는지 확인하면 된다.

Clone this wiki locally