Published on

tWIL 2022.12 2주차

Authors

AWS SES

mailjet 서비스를 이용하다가 추후 캠페인 메일도 사용할 것 같아서 매우 저렴한 SES로 옮겼다. mjml 포멧으로 템플릿을 만들어 Lambda에서 렌더링 하는 방식이다. AWS Lambda, SES 를 이용한 이메일 발송처리 이 블로그를 읽은 후 AWS SQS, AWS Lambda, AWS SES로 연결되는 형태로 Terraform을 사용해 배포해 보았다. SQS Lambda 트리거 형태의 배포는 terraform-sqs-lambda-trigger-example을 참고하였다. 신규로 Lambda를 배포하지 않고 이미 배포한 Lambda 함수를 가져와서 트리거를 연결해 주는 방식이라 내가 원하던 방식이었다. Email을 보내는 인프라는 한 곳에 독립적으로 존재하면 되기 때문에 적합했다.

메일 템플릿 종류에 따라 template 이름을 정의해 준다. enum타입으로 지정하며, 함수를 사용하고자 하는 곳엔 이 타입을 알고 있어야 한다. (함수의 이벤트 타입과 쓰고자 하는 곳에서 이 타입을 동기화 할 수 없는건가... 내가 모르는 어떤 방법이 있지 않을까) API 서버는 이 타입을 그대로 가져와서 사용하도록 한다. 여기서는 resetPassword의 한 타입만을 사용했지만, 비즈니스 로직에 따라 템플릿을 만들어 사용할 수 있도록 한다. 아폴로 Rover처럼 Lambda event 타입을 지정하고 이 스키마를 사용할 수 있는 서비스가 있다면 좋겠다. (찾아보다가 없으면 만들수도...)

types.ts
export enum EmailSendEventType {
  resetPassword = "resetPassword",
}

export interface EmailSendEvent {
  mailType: EmailSendEventType;
  email: string;
  name: string;
  link: string;
  subject: string;
  message: string;
}

MJML template

이메일은 개발자들이 고통을 겪는 부분인 이메일 클라이언트들이 최신의 CSS를 지원하지 않는 문제가 있다. 심지어 다크 테마로 인해 검정색 바탕에 검정색 글씨가 나오기도 하고, align의 문제가 있어, table 태그로 디자인을 한다. 마치 드림위버로 웹디자인을 하던 시절로 돌아간 느낌. 이러한 고민을 해결해 주는 것들 중 하나가 MJML이다.

Mailjet은 mjml이라는 이메일 템플릿을 만들어 사용할 수 있도록 오픈소스로 공개했다. 사실상 소규모에서는 Mailjet 서비스를 직접 이용해도 무리가 없다. 하지만 더 저렴하고 무료 티어의 전송 가능 이메일 수가 65,000 emails/month (vs 6,000 emails/month Mailjet) 인 AWS SES를 이용하지 않을 수 없었다. 물론 Lambda에서 비용이 발생하지만 어차피 Lambda를 쓰고 있기 때문에... 마케팅 이메일의 경우 동의 여부도 우리의 DB에 담겨있기 때문에 비즈니스 로직을 거쳐 이메을 전송을 할 필요가 있고, Transaction 이메일도 우리의 비즈니르 로직을 거쳐야해서 Mailjet 서비스를 직접 이용하지 않게 되었다. 하지만 Mailjet에 가입은 해두면 템플릿을 만들기가 꽤 쉽다. 그리고 HTML과 MJML로 export도 가능하다. 아니면 Visual Studio Code에도 MJML Preview가 있으니 설치하고 템플릿 디자인을 하면 좋지만 태그 관련한 Document를 보고 약간의 학습이 필요하다.

MJML을 통해 이메일 템플릿을 만들고, ejs를 통해 필요한 값을 넣어 HTML로 렌더링 한다. 그리고 이때 HTML minimizer로 불필요한 스페이스를 없애고 작게 만든다. 이렇게 만들어진 HTML을 SES를 통해 보내면 끝.

DKIM

Mailjet 서비스의 경우 DKIM을 직접 설정해주어야 한다. SES는 알아서 우리 도메인에 DKIM txt 코드를 넣어준다. 정크 메일로 가지 않도록 보안을 강화 하여 도메인 인증을 한다.

직접 SMTP서버를 만들어 도메인 키를 넣어야 하는 경우 DKIM 키를 직접 만들어야 한다.

  • Private 키 생성
openssl genrsa -des3 -out private.pem 1024
  • Public 키 생성
openssl rsa -in private.pem -out public.pem -outform PEM -pubout
  • Name server txt 레코드를 추가한다.

공개키를 한 줄로 이어서 붙여준다. 그리고 아래와 같은 키 txtservice._domainkey.{your-domain.com}으로 설정해주면 된다.

"v=DKIM1; k=rsa; p={pubout}"

AWS Lambda

Lambda 함수는 Terraform으로 배포했다. Serverless 프레임워크를 사용해도 무방하다. 이메일 서비스는 Stage의 영향을 받을 이유가 없기 때문에 1세트의 인프라만 있으면 충분하다.

TL,DR;

Transporter

메일 전송은 SES를 사용하는데 nodemailer패키지를 이용해 transporter를 만든다. 이메일 주소, 제목, 그리고 템플릿 엔진으로 만들어진 html을 전송하도록 한다.

src/libs/transporter/index.ts
import { createTransport } from "nodemailer";
import * as aws from "@aws-sdk/client-ses";

const ses = new aws.SES({ apiVersion: "2010-12-01", region: "ap-northeast-2" });

export const transporter = createTransport({
  SES: { ses, aws },
});

/**
 * It sends an email to the user with the given email, name, subject, and html
 * @param {string} email - The email address of the user
 * @param {string} name - The name of the user
 * @param {string} subject - The subject of the email
 * @param {string} html - The HTML content of the email.
 */
export const SendEmail = (
  email: string,
  name: string,
  subject: string,
  html: string,
) =>
  transporter.sendMail({
    from: `"My Service" no-reply@myservice.com`,
    to: `"${name}" ${email}`,
    subject,
    html,
  });

Templates

mjml CLI로 MJML에서 ejs로 렌더링하는 스크립트를 추가한다. 이 스크립트는 buildprebuild단계에서 실행되어야 한다.

{
  "scripts": {
    "mjml:resetPassword": "mjml -r lambda/src/libs/templates/resetPassword.mjml -o lambda/src/libs/templates/resetPassword.ejs"
  }
}

예제 리포에서는 light-server패키지를 사용하여 watch모드로 렌더링된 HTML 페이지를 직접 서브해서 Inspection이 가능하도록 셋팅하였다. light-server가 deprecated 되고, vite을 쓰라고 하지만 아직 익숙하지 않아서 light-server를 사용한다.

그리고 앞서 설명한대로 MJML => ejs로 렌더링된 상태에서 ejs 템플릿 엔진으로 필요한 값을 넣어준다.

src/libs/templates/index.ts
import ejs from "ejs";
import path from "path";
import { minify } from "html-minifier";

/**
 * It takes in a link and a message and returns a string of HTML
 * @param {string} link - The link to the reset password page.
 * @param {string} message - The message to be displayed to the user.
 * @returns A function that takes in two arguments, link and message, and returns a promise that
 * resolves to a string.
 */
export const resetPasswordHtmlRender = async (
  link: string,
  message: string,
) => {
  const response = await ejs.renderFile(
    path.resolve(__dirname, "resetPassword.ejs"),
    {
      resetLink: link,
      message,
    },
  );
  return minify(response, {
    collapseWhitespace: true,
    removeComments: true,
    minifyCSS: true,
    minifyJS: true,
    preserveLineBreaks: false,
    quoteCharacter: `'`,
    sortClassName: true,
  });
};

Lambda handler

마지막으로 핸들러를 만든다. 여기서 함수를 객체 매서드로 만들고 함수 이름을 enum타입의 키로 정의 하였기 때문에

export type EmailSend = (
  email: string,
  name: string,
  link: string,
  subject: string,
  message: string,
) => {
  [key in EmailSendEventType]: () => Promise<SentMessageInfo>;
};

함수 실행을 아래와 같이 객체로 분기시켰다.

src/index.ts
import { SendEmail } from "libs/transporter";
import { SentMessageInfo } from "nodemailer/lib/ses-transport";
import { resetPasswordHtmlRender } from "libs/templates";
import { EmailSendEvent, EmailSend } from "libs/sendEmail";

const sendEmail: EmailSend = (email, name, link, subject, message) => ({
  async resetPassword() {
    const html = await resetPasswordHtmlRender(link, message);
    return SendEmail(email, name, subject, html);
  },
});

export const handler = async (
  event: EmailSendEvent,
): Promise<SentMessageInfo> => {
  const { mailType, email, name, link, subject, message } = event;
  const result = await sendEmail(email, name, link, subject, message)[
    mailType
  ]();
  return result;
};

나중에 추가될 메서드를 위해서...

Lambda function invoke

@aws-sdk/client-lambda, invoke command를 사용하였다. Lambda를 직접 호출 하는 예제는(example) 참고.

sendEmail.ts
import { LambdaClient, InvokeCommand } from "@aws-sdk/client-lambda";

// should sync with lambda email event type
export enum EmailSendEventType {
  resetPassword = "resetPassword",
}
export interface EmailSendEvent {
  mailType: EmailSendEventType;
  email: string;
  name: string;
  link: string;
  subject: string;
  message: string;
}

const client = new LambdaClient({ region: "ap-northeast-2" });

export function sendEmail(data: EmailSendEvent) {
  const command = new InvokeCommand({
    FunctionName: "mjml-email",
    Payload: Buffer.from(JSON.stringify(data)),
  });
  return client.send(command);
}

이렇게 API에서 호출할 함수는 간단해지고, 이제 email 템플릿이 바뀌었다고 API를 재배포할 필요가 없어진다. Lambda만 재배포하면 되겠다. (본격 마이크로 서비스화)

Terraform: SQS 설정

이제 원래 배포했던 인프라를 담은 리포에 SQS를 추가한다. Lambda 함수 이름을 알고 있기 때문에 그대로 매핑하고 사용하도록 한다. 예제는(terraform SQS)을 참고 했으며, Lambda trigger 테스트는 공식문서(Using Lambda with Amazon SQS)를 참고하였다.

예제를 참고하여 배포를 해보았고 성공했지만 굳이 현시점에서 SQS를 쓸 이유가 없었다. 따라서 추후 Marketing이나 캠페인이 필요한 시점에 별개의 Lambda 함수로 배포해도 무방할 듯 싶어 다시 제거하였다.

터미널 컬러라이징

콘솔에서 개발자에게 info를 제공하기 위해 echo명령을 주로 사용하는데 색상을 넣어야 할 일이 있어 ANSI escape codes를 찾아보았다. 나중을 위해 여기에 정리.

전체 이스케이핑 코드는 ANSI escape codes 참고

# Reset
Color_Off='\033[0m'       # Text Reset

# Regular Colors
Black='\033[0;30m'        # Black
Red='\033[0;31m'          # Red
Green='\033[0;32m'        # Green
Yellow='\033[0;33m'       # Yellow
Blue='\033[0;34m'         # Blue
Purple='\033[0;35m'       # Purple
Cyan='\033[0;36m'         # Cyan
White='\033[0;37m'        # White

# Bold
BBlack='\033[1;30m'       # Black
BRed='\033[1;31m'         # Red
BGreen='\033[1;32m'       # Green
BYellow='\033[1;33m'      # Yellow
BBlue='\033[1;34m'        # Blue
BPurple='\033[1;35m'      # Purple
BCyan='\033[1;36m'        # Cyan
BWhite='\033[1;37m'       # White

# Underline
UBlack='\033[4;30m'       # Black
URed='\033[4;31m'         # Red
UGreen='\033[4;32m'       # Green
UYellow='\033[4;33m'      # Yellow
UBlue='\033[4;34m'        # Blue
UPurple='\033[4;35m'      # Purple
UCyan='\033[4;36m'        # Cyan
UWhite='\033[4;37m'       # White

# Background
On_Black='\033[40m'       # Black
On_Red='\033[41m'         # Red
On_Green='\033[42m'       # Green
On_Yellow='\033[43m'      # Yellow
On_Blue='\033[44m'        # Blue
On_Purple='\033[45m'      # Purple
On_Cyan='\033[46m'        # Cyan
On_White='\033[47m'       # White

# High Intensity
IBlack='\033[0;90m'       # Black
IRed='\033[0;91m'         # Red
IGreen='\033[0;92m'       # Green
IYellow='\033[0;93m'      # Yellow
IBlue='\033[0;94m'        # Blue
IPurple='\033[0;95m'      # Purple
ICyan='\033[0;96m'        # Cyan
IWhite='\033[0;97m'       # White

# Bold High Intensity
BIBlack='\033[1;90m'      # Black
BIRed='\033[1;91m'        # Red
BIGreen='\033[1;92m'      # Green
BIYellow='\033[1;93m'     # Yellow
BIBlue='\033[1;94m'       # Blue
BIPurple='\033[1;95m'     # Purple
BICyan='\033[1;96m'       # Cyan
BIWhite='\033[1;97m'      # White

# High Intensity backgrounds
On_IBlack='\033[0;100m'   # Black
On_IRed='\033[0;101m'     # Red
On_IGreen='\033[0;102m'   # Green
On_IYellow='\033[0;103m'  # Yellow
On_IBlue='\033[0;104m'    # Blue
On_IPurple='\033[0;105m'  # Purple
On_ICyan='\033[0;106m'    # Cyan
On_IWhite='\033[0;107m'   # White

스크립트에서

#    .---------- constant part!
#    vvvv vvvv-- the code from above
RED='\033[0;31m'
NC='\033[0m' # No Color
printf "I ${RED}love${NC} Stack Overflow\n"

echo 커멘드로는

#    .---------- constant part!
#    vvvv vvvv-- the code from above
RED='\033[0;31m'
NC='\033[0m' # No Color
echo -e "I ${RED}love${NC} Stack Overflow"

그리고 NodeJS 환경에서 process.stdout.write을 쓸 경우나 console.log의 경우

Reset = "\x1b[0m"
Bright = "\x1b[1m"
Dim = "\x1b[2m"
Underscore = "\x1b[4m"
Blink = "\x1b[5m"
Reverse = "\x1b[7m"
Hidden = "\x1b[8m"

FgBlack = "\x1b[30m"
FgRed = "\x1b[31m"
FgGreen = "\x1b[32m"
FgYellow = "\x1b[33m"
FgBlue = "\x1b[34m"
FgMagenta = "\x1b[35m"
FgCyan = "\x1b[36m"
FgWhite = "\x1b[37m"

BgBlack = "\x1b[40m"
BgRed = "\x1b[41m"
BgGreen = "\x1b[42m"
BgYellow = "\x1b[43m"
BgBlue = "\x1b[44m"
BgMagenta = "\x1b[45m"
BgCyan = "\x1b[46m"
BgWhite = "\x1b[47m"

3D WebGL Viewer

이제 프로젝트는 뷰어의 고도화 작업에 임박해있다. 이번주는 3D 세상을 위해 glTF 모델을 연구하는 시간이 많았다. 관련된 내용은 사실 Three.js를 사용하면서 테스트가 되었지만, glTF의 스키마를 정확히 알아본적이 없었다. 추후 glTF 모델은 DB화가 이루어져야 했기에 스키마를 탐색하였다.

glTF 2.0

glTF 2.0 Schema를 면밀히 보면서 주력으로 사용할 Autodesk Forge 뷰어에서 Node와 Hierarchy를 테스트 해보았다.

생각보다 단순했다. 솔루션에서 개발한 제품에서 glTF로 모델을 뽑아낼 때 nodes의 Property를 children까시 설정해서 뽑아주면 되었다.

{
  "nodes": [
    {
      "name": "Car",
      "children": [1, 2, 3, 4]
    },
    {
      "name": "wheel_1"
    },
    {
      "name": "wheel_2"
    },
    {
      "name": "wheel_3"
    },
    {
      "name": "wheel_4"
    }
  ]
}

그리고 glTF는 스키마를 가지고 있기 때문에 Validator를 붙여서 확인할 수 있는데 스키마가 Open API Spec이어서 만들어 볼까 했지만 이미 누군가가 만들어놔서 감사하다.

gltf-validator

Forge Viewer

이제 Forege viewer에서 속성과 특성 데이터를 추출해줄 수 있어야 하는데 Autodesk.Viewing.UI.PropertyPanel 여기를 보고 아무리 glTF파일에 meshnodeextras에 값을 넣어도 안되었는데 알고보니 glTF 2.0 Loader 문서에 나와있었다.

The glTF 2.0 Loader does not support these features: _ Property database. _ Measurement tool.

서포트 하지 않는다.

Wraping up

다음 주는 Viewer를 조금 더 고도화하며, WebGL에서 Point Cloud 데이터와 모델의 연동, 그리고 Three.js를 진행 하는 프로젝트의 뷰어로 활용가능한지 적합성 테스트를 계속 진행할 예정이다.